Samonaprawiający się Kubernetes w praktyce: trzy awarie, sygnały od SigNoz oraz zatwierdzone rozwiązania
Samonaprawiający się Kubernetes z OpenTelemetry i SigNoz: pętla awarii, zatrzymanie z powodu braku pamięci oraz gwałtowny wzrost liczby tokenów na Kind – co pokazuje każdy sygnał oraz zatwierdzone rozwiązania oparte na AI.
Źródła: Self-Healing Kubernetes: Wiring OpenTelemetry, SigNoz, and a Groq-Powered Remediation Agent (PART 7)
Demo składa się z kilku elementów w ruchu. Lokalny klastr Kind hostuje SigNoz do przetwarzania śladów i logów, a także aplikację w Pythonie, której trzy punkty końcowe są celowo uszkodzone. Agent naprawczy sprawdza obecność anomalii i prosi Groq o diagnozę, natomiast panel w React umożliwia operatorowi zatwierdzenie lub odrzucenie proponowanej naprawy, zanim cokolwiek zostanie uruchomione. Gdy to wszystko zostanie zbudowane, najciekawszą częścią jest obserwowanie jego zachowania. Ten test symuluje każdą awarię przy pełnym działaniu całego stacku, sprawdza, co SigNoz rejestruje w przypadku każdego incydentu, oraz wymienia elementy, które muszą ulec zmianie, zanim ktokolwiek będzie mógł polegać na tym rozwiązaniu poza laptopem.
Potwierdzenie działania stacku
Zanim zostanie wywołana jakakolwiek awaria, powinno działać pięć komponentów. Pierwszą sprawdzeniem jest to, czy każdy pod jest sprawny w obu przestrzeniach nazw.
# All pods should show Running
kubectl get pods -n observability
kubectl get pods -n apps
Porty przekierowujące dla SigNoz, usługi agregującej dane oraz panelu sterowania wymagają osobnych kart terminala, a ostatni polecenie w tym bloku dodaje logi agenta na końcu.
# Port-forwards (run each in its own tab if not already open)
# Tab A: kubectl port-forward -n observability svc/signoz 8080:8080
# Tab B: kubectl port-forward -n apps svc/summarizer 8000:80
# Tab C: kubectl port-forward -n apps svc/approval-dashboard 9000:9000
# Tab D: kubectl port-forward -n apps svc/approval-frontend-svc 3000:3000# Confirm the agent is polling
kubectl logs -n apps deployment/remediation-agent --tail=5
Ostatnie komunikaty o braku problemów w tym wyjściu oznaczają, że agent jest sprawny. Pod, który nie jest w stanie „Running”, powinien zostać najpierw zbadany za pomocą kubectl describe i naprawiony. Zachowaj otwarte dwie karty przeglądarki: SigNoz na porcie 8080 oraz panel sterowania na porcie 3000.
Szczegóły pierwszego scenariusza: CrashLoopBackOff
Ustawienie zmiennej środowiskowej KILL_ME w konfiguracji summarizer deployment, a następnie wywołanie jego punktu końcowego powodującego awarię sprawia, że proces kończy pracę w trakcie żądania.
kubectl set env deployment/summarizer -n apps KILL_ME=true
curl -s http://localhost:8000/crash
Wywołanie curl może nie zwrócić żadnych danych lub utknąć, ponieważ proces kończy swoją pracę w trakcie działania. Monitorowanie kontenerów pokazuje, że licznik restartów rośnie, a status zmienia się pomiędzy Running a CrashLoopBackOff, przy czym dwóch lub trzech takich cykli wystarcza.
kubectl get pods -n apps -w
W SigNoz. Przegląd usługi sumaryzującej pokazuje, że wskaźnik błędów skacze do 100% w momencie, gdy pod zaczyna się zawieszać, natomiast liczba żądań spada do zera, gdy pod jest wyłączony pomiędzy restartami. Ruch, potem nic, a następnie ponowny ruch to wizualny znak pętli awarii. Proces wdrażania wygląda inaczej – z czystym przejściem i brakiem nagłego wzrostu błędów – podobnie jak podział sieci, przy którym wskaźnik błędów rośnie, podczas gdy pod pozostaje w stanie „Running”.
Karta „Logs”, przefiltrowana na sumaryzator, zawiera kluczową linię, którą aplikacja wygenerowała tuż przed zatrzymaniem się.
CRITICAL summarizer — KILL_ME is true, exiting process
Ten log zawiera identyfikator śledzenia żądania, które je wywołało, dzięki czemu jeden kliknięcie prowadzi od wiersza z logiem do śledzenia oraz do dokładnego żądania HTTP, które zakończyło proces.
Agent reaguje. W ciągu 30 sekund od pierwszego awarii uruchamia się cykl sprawdzania. Widzi wskaźnik błędów na poziomie 1,0 przy progu 0,10, pakuje sygnał i wywołuje Groq. Logi agenta pokazują tę sekwencję działań.
kubectl logs -n apps deployment/remediation-agent --tail=20 -f
WARNING agent — crash loop signal: error_rate=1.00
INFO agent — anomaly detected: crash_loop — calling Groq for diagnosis
INFO agent — diagnosis: type=crash_loop severity=critical confidence=0.91
fix=kubectl set env deployment/summarizer -n apps KILL_ME-
INFO incident_poster — incident posted: id=<uuid> type=crash_loop
Zatwierdzenie. Panel sterowania pokazuje kartę w ciągu około dziesięciu sekund, a proponowane rozwiązanie jest właściwe, więc operator kliknie „Zatwierdź”. Serwer sprawdza, czy polecenie zaczyna się od „kubectl set env” i nie zawiera żadnych zablokowanych terminów, a następnie je wykonywa. Karta aktualizuje się, potwierdzając zatwierdzenie oraz zmianę środowiska.
✓ Approved by operator
deployment.apps/summarizer env updated
W monitoringu kapsuły kolejna próba uruchomienia się udaje, pętla awarii się kończy, a wskaźnik błędów SigNoz wraca do zera. Przed rozpoczęciem następnego scenariusza szybka weryfikacja potwierdza, że zmienna zniknęła.
# The approve action already removed KILL_ME, but confirm:
kubectl get deployment summarizer -n apps -o jsonpath='{.spec.template.spec.containers[0].env}' | jq
KILL_ME nie powinno pojawiać się w wyniku.
Szczegóły drugiego scenariusza: OOMKill
Pętla żądań POST do punktu końcowego powodującego wyciek stopniowo powiększa bufor w pamięci.
for i in {1..30}; do
curl -s -X POST http://localhost:8000/leak | jq -c '{leaked_mb,chunks}'
sleep 0.5
done
Pamięć w kontenerze rośnie podczas monitorowania, a mniej więcej przy 25. próbie kontener jest zamykany w trakcie cyklu, po czym narzędzie curl zaczyna zwracać błędy połączenia.
W SigNoz. Tym razem wskaźnik błędów nie rośnie stopniowo. Zamykanie kontenera z powodu braku pamięci następuje nagle, a aplikacja nigdy nie zwraca odpowiedzi o błędzie. Zamiast tego szybko spada liczba żądań: ruch, nagłe zatrzymanie, przerwa, a potem ponownie ruch po ponownym uruchomieniu kontenera.
W dziennikach znajduje się prawdziwa historia. Po przefiltrowaniu danych przez narzędzie podsumowujące, przerwie towarzyszą ostrzeżenia dotyczące rosnącej wielkości bufora.
WARNING summarizer — leak endpoint hit, bucket now holds 10 MB
WARNING summarizer — leak endpoint hit, bucket now holds 20 MB
...
WARNING summarizer — leak endpoint hit, bucket now holds 240 MB
Po ostatnim ostrzeżeniu zapada cisza, bez żadnych wyjątków, bez śladu wywołania błędu i bez komunikatu o uporządkowanym zamykaniu, ponieważ mechanizm OOM w jądrze nie daje procesowi szansy na zapisanie informacji. Seria ostrzeżeń o problemach, po której następuje całkowita cisza, to wzorzec, który warto rozpoznać.
Klastor potwierdza przyczynę z własnej strony.
kubectl describe pod -n apps -l app=summarizer | grep -A 5 "Last State"
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Kod wyjścia 137 to specyficzny kod jądra, wynikający z SIGKILL (sygnał 9) plus 128. Odróżnia on zabicie procesu z powodu braku pamięci od awarii aplikacji, która kończy się kodem 1, oraz od niepowodzenia sondy sprawdzającej aktywność procesu, która również daje kod 137, ale z innym ciągiem znaków w wyniku opisowym.
Agent reaguje. Jego funkcja sprawdzania braku pamięci przeszukuje indeks logów SigNoz w poszukiwaniu ciągu znaków „OOMKilled” z ostatnich dziesięciu minut. W przypadku znalezienia takiego zapisu łączy próbki logów oraz zdarzenie ponownego uruchomienia kontenera w jedną wiadomość, a następnie przekazuje wynik diagnozy.
type=oom_kill severity=critical confidence=0.88
fix=kubectl set resources deployment/summarizer -n apps \
--containers=summarizer --limits=memory=512Mi
Zaproponowane rozwiązanie podwaja limit pamięci. Jest to rozsądna środek krótkoterminowy, ponieważ daje czas na wdrożenie poprawki w aplikacji. Po zatwierdzeniu backend stosuje limit 512Mi, Kubernetes przeprowadza restart stopniowy, a pod wraca z dostępną ilością pamięci.
Sama wyciek pamięci pozostaje w kodzie. Zmiana jedynie podnosi ten limit, a prawdziwy incydent zakończyłby się poprawką kodu, która regularnie usuwa nadmiar pamięci. Zadaniem agenta jest zatrzymanie wycieku, natomiast ustalenie przyczyny podstawowej nadal wymaga interwencji człowieka.
Scenariusz trzeci: opóźnienia i gwałtowny wzrost liczby tokenów
Krótki cykl wywołań do drogiego endpointu powoduje trzecią awarię.
for i in {1..5}; do
curl -s -X POST http://localhost:8000/expensive \
| jq '{output_tokens, length_chars}'
sleep 2
done
Nic się nie zawala i nic nie zostaje zakończone, a kontener pozostaje w stanie działania. Jednak każda odpowiedź przychodzi w czasie od 8 do 15 sekund, a liczba tokenów zbliża się do maksymalnej wartości 2 048.
W SigNoz. To właśnie w takich cichych awariach stacku do obserwacji odgrywa kluczową rolę. Linia opóźnienia p95, która przy normalnym ruchu jest bliska 1,5 sekundy, gwałtownie rośnie, gdy wykonują się kosztowne wywołania; zmiana ta jest nagła, a nie stopniowa, ponieważ każde wywołanie osiąga maksymalną wartość. Sortowanie śladów według czasu trwania umieszcza te wywołania na szczycie listy – każde z nich trwa od 8 do 15 sekund.
Otwarcie powolnego śladu w grafie płomienia pokazuje niemal cały czas spędzony w obrębie pojedynczego podelementu, czyli wyjściowy żądanie HTTP do api.groq.com. Element odpowiadający samemu obsługiwaczowi końcówki jest krótki, ponieważ obsługa FastAPI praktycznie nic nie robi, a dominuje czas potrzebny na przesyłanie i odbieranie danych od modelu LLM. Długość treści odpowiedzi tego elementu jest znacznie większa niż w przypadku zwykłego wywołania funkcji summarize – jest to liczba tokenów wyjściowych wyrażona w bajtach. Równa linia, wzrost, ponownie równa linia po zakończeniu pętli: to właśnie profil źle skonfigurowanej końcówki, a nie awarii infrastruktury.
Agent reaguje. Sprawdzenie opóźnienia porównuje wartość p95 dla poszczególnych tras. Droga o najwyższym czasie działania osiąga około 11 000 ms, podczas gdy progiem jest 5 000 ms, w związku z czym agent grupuje te dane razem.
{
"check": "latency_spike",
"spikes": { "/expensive": 11240.0 },
"p95_ms_by_route": {
"/summarize": 1820.0,
"/expensive": 11240.0,
"/healthz": 4.0
}
}
Groq odczytuje to i stawia dokładną diagnozę.
type=token_spike severity=high confidence=0.85
root_cause: The /expensive endpoint has no reasonable max_tokens budget
and a prompt that invites long outputs, causing p95 latency
to spike to 11s versus 1.8s on the normal /summarize path.
fix=kubectl set env deployment/summarizer -n apps MAX_TOKENS_EXPENSIVE=256
Zaproponowane rozwiązanie ustawia zmienną środowiskową, którą aplikacja może odczytać w celu ograniczenia budżetu tokenów na danym punkcie końcowym. Po zatwierdzeniu zmiana ta zostaje wprowadzona. W rzeczywistym wdrożeniu aplikacja miałaby również odczytywać wartość MAX_TOKENS_EXPENSIVE z środowiska. Agent proponuje zmianę konfiguracji, a nie modyfikację kodu, co jest odpowiednim zakresem dla rozwiązania na poziomie kubectl.
Trzy awarie w jednym oknie czasowym
Gdy wszystkie trzy scenariusze są wykonywane kolejno, przegląd sumaryczny w oknie czasowym trwającym dwie godziny pokazuje trzy odrębne wzorce.
- Pętla awarii objawia się falami w stopie błędów – dochodzi do 100%, następnie spadku do zera i ponownego wzrostu, przy odpowiadających temu przerwach w przepustowości.
- Awaria spowodowana brakiem pamięci objawia się nagłym spadkiem liczby żądań bez wzrostu liczby błędów, ponieważ proces nie ma możliwości zwrócenia błędu.
Na jednym panelu kontrolnym widnieją trzy tryby awarii o charakterystycznych kształtach, bez konieczności stosowania specjalnych reguł alarmowych ani ręcznego analizowania logów. To właśnie korzyść płynąca z powiązania śladów, metryk i logów.
Zmiany potrzebne do użycia w produkcji
Demo działa na laptopie i pomija elementy, które są istotne w innych środowiskach.
Należy zastąpić lokalną pamięć do przechowywania informacji o incydentach. Lista incydentów znajduje się jako słownik w języku Python wewnątrz poda backendowego, więc po ponownym uruchomieniu wszystkie dane są tracione. Użycie SQLite poprzez SQLAlchemy to zmiana wymagająca zaledwie jednej linijki kodu i nie wymagająca dodatkowych usług, natomiast Redis nadaje się wtedy, gdy panel kontrolny musi być skalowalny na kilka replik backendowych. Struktura API pozostaje niezmieniona – zmienia się jedynie sposób przechowywania danych.
Dodaj powiadomienia w Slacku. Obecnie ktoś musi obserwować panel sterowania, aby dowiedzieć się o pojawieniu się karty. Wywołanie webhooka w Slacku w odbiorcy incydentów w backendzie rozwiązuje ten problem.
import httpx
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")def notify_slack(card: dict):
if not SLACK_WEBHOOK:
return
d = card["diagnosis"]
httpx.post(SLACK_WEBHOOK, json={
"text": (
f"*{d['severity'].upper()} — {d['incident_type']}* on `{card['service']}`\n"
f"Root cause: {d['root_cause']}\n"
f"Proposed fix: `{d['proposed_fix']}`\n"
f"Confidence: {int(d['confidence']*100)}%\n"
f"<http://your-dashboard-url|Review on dashboard>"
)
})
Wyzwalanie powiadamiacza na końcu procesu odbierania oznacza, że każdy nowy incydent wysyła sygnał do kanału dyżurnego, a osoba upoważniona nie musi już obserwować karty przeglądarki.
Należy prawidłowo zarządzać uprawnieniami klastra. Serwer backendowy korzysta z kubectl, co jest w porządku, gdy środowisko węzła je konfiguruje. W rzeczywistym klastrze pod backendowy wymaga ServiceAccount z odpowiednimi uprawnieniami RBAC oraz klienta Pythona Kubernetes przygotowanego do obsługi uprawnień wewnątrz klastra.
from kubernetes import client, config
config.load_incluster_config()
apps_v1 = client.AppsV1Api()
core_v1 = client.CoreV1Api()
Każdy zatwierdzony typ polecenia staje się wtedy bezpośrednim wywołaniem SDK, a nie łańcuchem w shellu. Jest to bardziej rozbudowane rozwiązanie, ale zapewnia ustrukturyzowane radzenie sobie z błędami, rejestrację działań w serwerze API oraz brak ryzyka iniekcji shellu.
Zaostrz listę dozwolonych polecenia. Dema akceptuje każde polecenie kubectl set env. W środowisku produkcyjnym należy to ograniczyć do określonych deploymentów i namespace’ów.
ALLOWED_TARGETS = {
"deployment/summarizer -n apps",
"deployment/worker -n apps",
}
def validate_command(cmd: str) -> bool:
if not any(cmd.startswith(p) for p in ALLOWED_PREFIXES):
return False
if any(term in cmd for term in BLOCKED_TERMS):
return False
if not any(target in cmd for target in ALLOWED_TARGETS):
return False
return True
Model nie powinien być w stanie zastosować naprawy w żadnym przestrzeni nazw ani środowisku implementacji poza listą, która mu została podana.
Projekty wartych dalszego zbadania
Demo zostało stworzone od zera, aby pokazać, jak łączą się poszczególne elementy, natomiast użycie w produkcji opierałoby się na istniejących projektach.
- KubeStellar pomaga, gdy obciążenia pracowe obejmują kilka klastrów. Przejmuje on zadania z płaszczyzny sterowania, które utrudniają naprawę problemów między klastrami – na przykład identyfikuje, w którym klastrze wystąpił incydent, zanim zostanie zaproponowana jakakolwiek naprawa.
- OpenTelemetry oferuje znacznie większe możliwości niż wymagało to demo. Collector posiada procesory i eksporterów, które mogą automatycznie dodawać metadane Kubernetes do obiektów typu spans, dzięki czemu atrybuty zasobów nie muszą być ręcznie dodawane do manifestów aplikacji.
Co zawiera cała implementacja
Zakończony system to klastr Kind składający się z trzech węzłów, posiadający realistyczną topologię planowania wielu węzłów, oraz SigNoz zainstalowany za pomocą Helm, który przetwarza ślady, metryki i logi za pośrednictwem OpenTelemetry Collector. Usługa Python FastAPI jest automatycznie wyposażana w narzędzia pomiarowe przy uruchamianiu, dzięki czemu każda prośba HTTP i każde wezwanie LLM staje się śladem, a każdy wiersz logu jest powiązany ze sobą bez konieczności dodawania kodu pomiarowego w aplikacji.
Trzy uszkodzone punkty końcowe powodują trzy najczęstsze awarie w Kubernetes: awarię procesu, wyczerpanie pamięci oraz pogorszenie kosztów i opóźnień. Agent naprawczy sprawdza SigNoz co 30 sekund, stosuje kontrolę progów, pakuje sygnały w ustrukturyzowany zapytanie, prosi model językowy o diagnozę i publikuje kartę incydentu. Backend FastAPI otrzymuje te karty, weryfikuje proponowane naprawy na podstawie listy dozwolonych rozwiązań i uruchamia zatwierdzone polecenia za pomocą kubectl. Dashboard React umożliwia kontrolę zatwierdzeń i odrzuceń, pokazuje odznakę wskazującą na pewność modelu, baner dla niepewnych diagnoz oraz rejestr każdej podjętej akcji.
Wszystko działa lokalnie przy użyciu usług z darmowego poziomu API i nie generuje żadnych kosztów. Kod jest na tyle mały – około 600 linii w Pythonie wraz z jedną stroną HTML i kilkoma plikami YAML – że każda jego część może być zrozumiana od początku do końca.
Literatura pokrewna
- Zarządzający-sobą zespół wielu agentów w LangGraph: architekt, tester, debugger — Stwórz maszynę stanów LangGraph w Pythonie, w której architekt, programista, środowisko testowe pytest, debugger i recenzent współpracują i powtarzają proces, aż testy się udają.
- Budowanie agenta ReAct w LangChain: wykonawcy, notatniki i ich ograniczenia — Stwórz agenta ReAct wykorzystującego narzędzia, zobacz, jak agent i AgentExecutor dzielą pracę, i dowiedz się, dlaczego LangGraph lepiej radzi sobie z przepływem sterowania, pętlami i stanem.