Stan na 12 października 2026 r.: Kubernetes 1.37.1, Flux 2.9.6, Helm 4.3.0, Longhorn 1.13.0, węzły na Ubuntu 24.04.5 LTS (ang. Long Term Support). Wszystkie wyniki pochodzą z klastra labowego; adresy, nazwy hostów i tokeny są labowe albo zastąpione znacznikami.
W tym artykule pokazujemy, z czego składa się klaster Kubernetes i jak jego elementy zależą od siebie: od maszyn tworzonych przez Terraform, przez control plane z etcd i workery z kubeletem, po Flux, który z repozytorium Git wdraża MetalLB, Traefik, Longhorn, Sealed Secrets, External Secrets, Loki i Alloy. Każde pojęcie wyjaśniamy na działającym klastrze, na prawdziwych wynikach poleceń, łącznie z wyłączeniem dwóch z trzech węzłów control plane, błędnymi commitami i aktualizacją Kubernetes do nowszej wersji. Na końcu jest ściągawka poleceń.
Jak elementy klastra Kubernetes zależą od siebie: mapa całego stacku
Kubernetes nie jest jednym programem, tylko zestawem usług, które rozmawiają ze sobą przez jedno API. Administrator nie uruchamia kontenerów ręcznie: zapisuje w API oczekiwany stan (na przykład "dwie repliki aplikacji podinfo w wersji 6.15.0"), a kolejne komponenty klastra porównują go ze stanem faktycznym i wykonują brakujące kroki. Ta pętla, zwana uzgadnianiem (ang. reconciliation), powtarza się w każdym elemencie opisanym niżej: w kontrolerach Kubernetes, w kubelecie na węźle, we Flux i w operatorach takich jak Longhorn.
Poniższy rysunek pokazuje klaster testowy, na którym sprawdziliśmy każde polecenie z tego artykułu. Strzałki oznaczają, kto z kim się łączy i w którą stronę płyną zmiany.
STACJA ADMINISTRATORA k8s-admin 192.168.124.5
kubectl, k9s, helm, flux, kubeseal, pluto, kind ~/.kube/config
usługi labu: Git (fleet-infra.git), S3 (Garage), sejf OpenBao
| |
| HTTPS :6443 (API) | git push
v v
VIP 192.168.124.10 (kube-vip) repozytorium Git fleet-infra
| ^
v | Flux co 1 min pobiera zmiany
+------------------- CONTROL PLANE: 3 węzły -------------------+
| k8s-cp-1 .11 k8s-cp-2 .12 k8s-cp-3 .13 |
| kube-apiserver kube-apiserver kube-apiserver |
| etcd <------- Raft: zapis wymaga 2 z 3 głosów ------> etcd |
| kube-scheduler i kube-controller-manager (aktywny 1 lider) |
+------------------------------+-------------------------------+
| kubelet pyta API o zadania
+-----------------+-----------------+
v v
+--- k8s-worker-1 .21 -----+ +--- k8s-worker-2 .22 -----+
| kubelet, containerd | | kubelet, containerd |
| kube-proxy, flannel | | kube-proxy, flannel |
| pody: podinfo, Traefik, | | pody: podinfo, Traefik, |
| Flux, Loki, Alloy ... | | Valkey, Alloy ... |
| Longhorn + iscsid |<------>| Longhorn + iscsid |
| /var/lib/longhorn 20 GiB | repliki| /var/lib/longhorn 20 GiB |
+------------+-------------+ +-------------+------------+
| kopie wolumenów, chunki logów |
+-----------------+------------------+
v
S3 na k8s-admin (bucket longhorn-backups, loki)
Zależności najłatwiej czytać od dołu. Bez maszyn (tu tworzy je Terraform) nie ma węzłów. Bez containerd kubelet nie uruchomi kontenera. Bez etcd kube-apiserver nie ma gdzie zapisać stanu, a bez kube-apiservera nie działa nic, co ten stan zmienia: kubectl, scheduler, Flux ani operatory. Aplikacja, która już działa na workerze, przeżyje jednak niedostępność API po utracie kworum etcd: kontenery pilnuje dalej kubelet, a ruch kierują reguły kube-proxy i sieć podów. Pokazujemy to niżej pomiarem.
| Element | Do czego służy | Od czego zależy | Co zależy od niego |
|---|---|---|---|
| Terraform | Tworzy i zmienia maszyny wirtualne (VM) węzłów: control plane i workery | API platformy, tu libvirt na hoście KVM (ang. Kernel-based Virtual Machine) | Wszystko, co działa w klastrze |
| etcd | Baza klucz-wartość ze stanem klastra | Dyski control plane, kworum członków | kube-apiserver |
kube-apiserver | API Kubernetes: odczyt i zapis obiektów | etcd, certyfikaty | kubectl, scheduler, kontrolery, kubelet, Flux, operatory |
kube-scheduler, kube-controller-manager | Przydział podów do węzłów, pętle kontrolerów | kube-apiserver | Każdy nowy lub poprawiany pod |
| kubelet + containerd | Uruchamianie kontenerów na węźle, sondy | kube-apiserver, system węzła | Pody |
| Wtyczka sieciowa CNI (ang. Container Network Interface, tu flannel), kube-proxy | Sieć podów i adresy Service | kubelet, kube-apiserver | Komunikacja podów, Service, Ingress |
| MetalLB | Adres IP dla Service typu LoadBalancer | API, sieć warstwy 2 (L2) | Traefik (wejście HTTP z zewnątrz) |
| Traefik | Kontroler Ingress: reguły HTTP do Service | MetalLB, Service, obiekty Ingress | Ruch użytkowników do aplikacji |
| Longhorn | Wolumeny (PersistentVolume) replikowane między workerami | iscsid, dyski workerów, sterownik CSI (ang. Container Storage Interface) | StatefulSet, żądania wolumenów aplikacji (PVC, ang. PersistentVolumeClaim), kopie w S3 (ang. Simple Storage Service) |
| S3 (Garage) | Magazyn obiektów poza klastrem | Osobny serwer | Kopie Longhorna, logi w Loki |
| Flux | Uzgadnia klaster z repozytorium Git | API, Git, repozytoria Helm | Cała infrastruktura i aplikacje z Git |
| Sealed Secrets, External Secrets | Sekrety bez jawnego tekstu w Git | Klucz kontrolera albo sejf (OpenBao, Secret Manager) | Secret używany przez pody |
| Alloy, Loki | Zbieranie i przechowywanie logów podów | API (lista podów), S3, PVC | Diagnostyka po fakcie |
Klaster testowy: 3 control plane, 2 workery i stacja administratora
Wszystkie polecenia i wyniki pochodzą z klastra zbudowanego 12 października 2026 r. na hoście labowym bm-lab-01 (Debian 13.7, jądro 6.12.111+deb13-amd64, libvirt 11.3.0-3+deb13u3 i QEMU 10.0.13+ds-0+deb13u1, czyli emulator, który z KVM uruchamia maszyny wirtualne). Maszyny utworzył Terraform 1.16.5 z providerem dmacvicar/libvirt 0.8.3. Jak działa sam lab KVM, opisują artykuły Lab na osobnym hoście: od czystego Debiana 13 do jedenastu maszyn z Terraform oraz Terraform od podstaw.
| Maszyna | Rola | Adres | vCPU / RAM | Dyski |
|---|---|---|---|---|
k8s-admin | stacja administratora, Git, S3 (Garage), OpenBao, Docker dla kind | 192.168.124.5 | 2 / 2,5 GiB | 30 GiB |
k8s-cp-1, k8s-cp-2, k8s-cp-3 | control plane (stacked etcd) | .11, .12, .13 | 2 / 2 GiB | 20 GiB |
k8s-worker-1, k8s-worker-2 | workery, Longhorn | .21, .22 | 2 / 3,5 GiB | 30 GiB + 20 GiB na Longhorn |
k8s-worker-3 | trzeci worker, dodany i usunięty Terraformem | .23 | 2 / 3,5 GiB | 30 GiB + 20 GiB |
| VIP (ang. virtual IP) API | adres wirtualny kube-apiserver utrzymywany przez kube-vip | 192.168.124.10 | - | - |
| pula MetalLB | adresy dla Service typu LoadBalancer | .200-.220 | - | - |
Węzły działają na Ubuntu 24.04.5 LTS z jądrem 6.8.0-146-generic. Wybraliśmy Ubuntu, a nie Debiana 13, z powodu środowiska uruchomieniowego kontenerów: Debian 13.7 ma pakiet containerd 1.7.24~ds1-6+deb13u1, a Ubuntu 24.04 ma containerd 2.2.1-0ubuntu1~24.04.3. Dokumentacja Kubernetes 1.37 zapowiada, że od wersji 1.38 kubelet przestanie obsługiwać containerd 1.y.
| Składnik | Wersja w labie |
|---|---|
| Kubernetes (kubeadm, kubelet, kubectl) | 1.37.0-1.1, na końcu podniesiony do 1.37.1-1.1 z pkgs.k8s.io |
| etcd, CoreDNS, pause | 3.7.0-0, v1.14.6, 3.10.2 |
| containerd, runc | 2.2.1-0ubuntu1~24.04.3, 1.3.4-0ubuntu1~24.04.1 |
| kube-vip, flannel | v1.2.4, v0.28.10 |
| Flux | 2.9.6 (source-controller v1.9.6, kustomize-controller v1.9.6, helm-controller v1.6.5, notification-controller v1.9.4) |
| Helm, k9s, kubectx/kubens, Pluto, kubeseal, kind | 4.3.0, 0.51.0, 0.11.0, 5.24.4, 0.40.0, 0.33.0 |
| Charty Helm | MetalLB 0.16.1, Traefik 41.7.1 (Traefik 3.7.14), Longhorn 1.13.0, Sealed Secrets 2.20.0, External Secrets 2.12.0, metrics-server 3.14.0, Loki 7.3.0, Alloy 1.13.1 (Alloy 1.20.1) |
Usługi na k8s-admin | Garage 2.4.1, OpenBao 2.7.1, Docker 29.1.3, git 2.43.0 |
| Aplikacje testowe | podinfo 6.15.0, Valkey 9.1.2-alpine, busybox 1.38.0 |
Cały kod labu, czyli Terraform z szablonami cloud-init, konfigurację kubeadm, skrypty stacji administratora, repozytorium Flux i przykładowe manifesty, opublikowaliśmy w katalogu kubernetes-lab/ repozytorium sysgroup/linuxlab (stan z 12 października 2026, commit 79d4723). Jego README podaje kolejność budowy klastra, od terraform apply po sprawdzenie aplikacji przez Ingress.
Konwencja w blokach z wynikami: $ oznacza powłokę zwykłego użytkownika na k8s-admin, z której uruchamiamy kubectl, flux, helm i pozostałe narzędzia klienckie. # oznacza powłokę root na wskazanym węźle (kubeadm, systemctl, apt-get). Bloki bez znaku zachęty zawierają same polecenia do skopiowania. Adresy, nazwy hostów i tokeny są labowe; tokeny i hasła w wynikach zastąpiliśmy znacznikami.
Dlaczego dwie wersje Kubernetes w wynikach. Klaster zbudowaliśmy na wersji 1.37.0, a w sekcji o aktualizacji podnieśliśmy go do 1.37.1. Wyniki zebrane przed aktualizacją pokazują v1.37.0.
Control plane: kube-apiserver, etcd, scheduler i controller-manager
Control plane to część klastra, która przechowuje stan i podejmuje decyzje. Nie uruchamia aplikacji użytkownika: w naszym klastrze węzły control plane mają taint node-role.kubernetes.io/control-plane:NoSchedule, więc scheduler nie kieruje na nie zwykłych podów. Definicje komponentów pochodzą z opisu komponentów Kubernetes 1.37.
| Komponent | Rola | Co widać w labie |
|---|---|---|
kube-apiserver | Serwer HTTP API Kubernetes. Jedyny komponent, który czyta i zapisuje etcd. | Trzy instancje, każda na swoim węźle, port 6443 |
| etcd | Spójna baza klucz-wartość na wszystkie dane API. | Trzech członków, wersja 3.7.0, jeden lider |
kube-scheduler | Wybiera węzeł dla podów, które jeszcze go nie mają. | Trzy instancje, aktywna jedna (lease kube-scheduler) |
kube-controller-manager | Uruchamia kontrolery, np. ten, który z Deployment robi ReplicaSet i pody. | Trzy instancje, aktywna jedna (lease kube-controller-manager) |
| kube-vip | Utrzymuje adres wirtualny 192.168.124.10 przed trzema apiserverami. | Statyczny pod na każdym control plane, lider w lease plndr-cp-lock |
W klastrze zbudowanym przez kubeadm komponenty control plane są statycznymi podami: kubelet uruchamia je bezpośrednio z plików w /etc/kubernetes/manifests, bez udziału schedulera. Dlatego widać je w kube-system z nazwą węzła w nazwie poda (CoreDNS i kube-proxy to zwykłe pody: Deployment i DaemonSet):
$ kubectl get pods -n kube-system -o wide --sort-by=.spec.nodeName | grep k8s-cp-1
kube-controller-manager-k8s-cp-1 1/1 Running 0 4m38s 192.168.124.11 k8s-cp-1 <none> <none>
coredns-559f6c778d-nv8n9 1/1 Running 0 4m29s 10.244.0.2 k8s-cp-1 <none> <none>
etcd-k8s-cp-1 1/1 Running 0 4m38s 192.168.124.11 k8s-cp-1 <none> <none>
kube-vip-k8s-cp-1 1/1 Running 0 4m6s 192.168.124.11 k8s-cp-1 <none> <none>
kube-scheduler-k8s-cp-1 1/1 Running 0 4m38s 192.168.124.11 k8s-cp-1 <none> <none>
kube-apiserver-k8s-cp-1 1/1 Running 0 4m38s 192.168.124.11 k8s-cp-1 <none> <none>
coredns-559f6c778d-9bq97 1/1 Running 0 4m29s 10.244.0.3 k8s-cp-1 <none> <none>
kube-proxy-lpffl 1/1 Running 0 4m29s 192.168.124.11 k8s-cp-1 <none> <none>
Scheduler i controller-manager działają na każdym węźle control plane, ale pracuje tylko lider. Liderów wyłaniają obiekty Lease w API (leasy apiserver-* to znaczniki obecności trzech apiserverów, nie wybór lidera):
$ kubectl get lease -n kube-system
NAME HOLDER AGE
apiserver-kf5z2wq736jr26vpg3bcqyqzxa apiserver-kf5z2wq736jr26vpg3bcqyqzxa_9850a779-cbd7-44b9-8b4a-eebfc28f02c5 34m
apiserver-rce7fv3cai5h65valxjvanxtkm apiserver-rce7fv3cai5h65valxjvanxtkm_cbe8470e-74f4-43e2-a800-6c9e38e602a3 42m
apiserver-u2xvoocyb5looqdejbvcjsqq2u apiserver-u2xvoocyb5looqdejbvcjsqq2u_6956b96a-8e2e-4fcc-bab5-5b108afb691c 38m
kube-controller-manager k8s-cp-2_25ab1ec4-0563-44b2-a984-740d6bad82f9 128m
kube-scheduler k8s-cp-2_330f4c14-b82d-4a3a-94de-112494ba8ed0 128m
plndr-cp-lock k8s-cp-2 128m
Dlaczego trzy węzły control plane: kworum etcd
etcd zapisuje zmianę dopiero wtedy, gdy potwierdzi ją większość członków (algorytm Raft). Tabela z odpowiedzi na częste pytania w dokumentacji etcd 3.6 pokazuje, ile awarii wytrzymuje klaster danej wielkości:
| Liczba członków etcd | Wymagana większość | Ile awarii przetrwa |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
Dwa węzły nie dają żadnej odporności, bo awaria jednego odbiera większość. Cztery węzły tolerują tyle samo awarii co trzy, a zwiększają wymaganą większość. Dlatego dokumentacja kubeadm o topologiach klastra zaleca co najmniej trzy węzły control plane dla wysokiej dostępności (HA, ang. High Availability). W topologii stacked etcd (domyślnej w kubeadm) każdy węzeł control plane ma swojego członka etcd, a lokalny apiserver rozmawia tylko z nim:
# grep etcd-servers /etc/kubernetes/manifests/kube-apiserver.yaml
- --etcd-servers=https://127.0.0.1:2379
Klienci spoza podów, czyli kubectl na stacji administratora i kubelety na węzłach, łączą się z adresem wirtualnym (VIP) 192.168.124.10. Pody w klastrze, na przykład kontrolery Flux, używają Service kubernetes (10.96.0.1), za którym EndpointSlice wskazuje wszystkie trzy apiservery (192.168.124.11, .12 i .13). Utrzymuje go kube-vip: jego lider odpowiada na zapytania ARP (ang. Address Resolution Protocol) o ten adres. kube-vip sprawdza swoje przywództwo przez lokalny apiserver (wpis kubernetes w hostAliases wskazuje 127.0.0.1), więc gdy apiserver albo etcd na węźle lidera przestaje działać, lease wygasa i adres przejmuje inny węzeł.
$ kubectl -n kube-system exec etcd-k8s-cp-1 -- etcdctl --endpoints=https://192.168.124.11:2379,https://192.168.124.12:2379,https://192.168.124.13:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key member list
2fe616b682cf047f, started, k8s-cp-2, https://192.168.124.12:2380, https://192.168.124.12:2379, false
96340e7d03c72af2, started, k8s-cp-1, https://192.168.124.11:2380, https://192.168.124.11:2379, false
db28d533c5ad613f, started, k8s-cp-3, https://192.168.124.13:2380, https://192.168.124.13:2379, false
Test awarii: wyłączenie jednego, a potem dwóch węzłów control plane
Na k8s-admin uruchomiliśmy pętlę, która co sekundę pytała API o /readyz (z limitem 2 s) i pobierała stronę podinfo przez Ingress. Pętla obcinała komunikat błędu do 60 znaków. O 09:04:23 na hoście KVM twardo wyłączyliśmy (virsh destroy) węzeł k8s-cp-1, który był wtedy liderem etcd i trzymał VIP:
09:04:20 api=ok app=200
09:04:21 api=ok app=200
09:04:22 api=ok app=200
09:04:26 api=Unable to connect to the server: net/http: request canceled app=200
09:04:29 api=Unable to connect to the server: net/http: request canceled app=200
09:04:32 api=Unable to connect to the server: net/http: request canceled app=200
09:04:35 api=Unable to connect to the server: context deadline exceeded ( app=200
09:04:39 api=Unable to connect to the server: net/http: request canceled app=200
09:04:40 api=ok app=200
$ kubectl -n kube-system logs kube-vip-k8s-cp-3 | tail -3
I1012 09:04:39.468046 1 leaderelection.go:272] "Successfully acquired lease" lock="kube-system/plndr-cp-lock"
2026/10/12 09:04:39 INFO New leader leader=k8s-cp-3
2026/10/12 09:04:39 INFO layer 2 broadcaster starting IP=192.168.124.10 device=enp1s0
API pod adresem VIP było niedostępne przez ok. 17 sekund, od wyłączenia o 09:04:23 do pierwszej poprawnej odpowiedzi o 09:04:40. Ten czas odpowiada życiu lease kube-vip (vip_leaseduration: 15 w wygenerowanym manifeście): nowy lider może przejąć adres dopiero, gdy lease starego wygaśnie. etcd działał dalej na dwóch członkach z trzech, a aplikacja odpowiadała kodem 200 przez cały czas. Następnie wyłączyliśmy drugi węzeł, k8s-cp-2. Został jeden członek etcd z trzech, czyli brak większości:
$ kubectl get nodes --request-timeout=5s
Unable to connect to the server: dial tcp 192.168.124.10:6443: connect: no route to host
$ kubectl --server https://192.168.124.13:6443 get nodes --request-timeout=15s
Error from server: etcdserver: request timed out
Apiserver na k8s-cp-3 działał, ale nie mógł niczego zapisać ani odczytać, bo etcd bez większości w kółko rozpoczynał wybory (became pre-candidate at term 3 w logu etcd). kube-vip stracił lease i zwolnił VIP. Mimo to podinfo nadal odpowiadało przez Traefik: kubelet, kube-proxy i flannel na workerach działają dalej bez control plane, tylko nie przyjmują zmian. Po włączeniu k8s-cp-2 o 09:07:51 API wróciło o 09:08:36, czyli po 45 s liczonych razem z rozruchem VM. Wynik całego testu: 230 pomiarów, 51 bez odpowiedzi API i 0 bez odpowiedzi aplikacji.
Wniosek dla produkcji. Trzy węzły control plane chronią przed awarią jednego z nich, nie dwóch. Utrata kworum nie zatrzymuje działających podów, ale blokuje wszystkie zmiany: wdrożenia, skalowanie, zastępowanie padniętych podów i pracę Flux. Kopię etcd (etcdctl snapshot save, opis w dokumentacji o obsłudze etcd) trzeba robić niezależnie od liczby węzłów.
Worker node: kubelet, containerd, kube-proxy i sieć podów
Worker node to maszyna, na której działają pody aplikacji. Na każdym workerze pracują trzy usługi Kubernetes i jedna wtyczka sieciowa:
- kubelet - agent węzła działający jako usługa systemd. Pobiera z API listę podów przydzielonych do węzła, zleca ich uruchomienie, wykonuje sondy i raportuje stan.
- containerd - środowisko uruchomieniowe kontenerów. Pobiera obrazy i uruchamia kontenery przez
runc. kubelet rozmawia z nim przez CRI (ang. Container Runtime Interface), gniazdounix:///run/containerd/containerd.sock. - kube-proxy - na podstawie obiektów Service i EndpointSlice ustawia na węźle reguły przekierowania ruchu z adresu Service do podów.
- CNI - wtyczka sieciowa. W labie to flannel: każdy węzeł dostaje podsieć /24 z puli
10.244.0.0/16(worker-1 ma10.244.4.0/24), a ruch między węzłami idzie tunelem VXLAN (ang. Virtual Extensible LAN) przez interfejsflannel.1i port UDP 8472.
kube-proxy i flannel działają jako DaemonSet, czyli obiekt, który uruchamia po jednym podzie na każdym pasującym węźle. Gdy Terraform dodał trzeciego workera, DaemonSety same uruchomiły na nim swoje pody. Kolumna DESIRED pokazuje, które działają na wszystkich sześciu węzłach, a które tylko na workerach (Longhorn i Alloy nie tolerują taintu control plane):
$ kubectl get ds -A
NAMESPACE NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE
kube-flannel kube-flannel-ds 6 6 6 6 6
kube-system kube-proxy 6 6 6 6 6
longhorn-system longhorn-manager 3 3 3 3 3
metallb-system metallb-speaker 6 6 6 6 6
monitoring alloy 3 3 3 3 3
kubelet to nie kubeconfig: agent węzła ma własną tożsamość
Nazwy brzmią podobnie, ale to dwie różne rzeczy. kubelet jest programem na węźle. kubeconfig jest plikiem z danymi dostępu do API. kubelet też ma swój kubeconfig, /etc/kubernetes/kubelet.conf, ale loguje się w nim jako węzeł, nie jako administrator:
# cat /etc/kubernetes/kubelet.conf
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: DATA+OMITTED
server: https://192.168.124.10:6443
name: default-cluster
contexts:
- context:
cluster: default-cluster
namespace: default
user: default-auth
name: default-context
current-context: default-context
kind: Config
users:
- name: default-auth
user:
client-certificate: /var/lib/kubelet/pki/kubelet-client-current.pem
client-key: /var/lib/kubelet/pki/kubelet-client-current.pem
# openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -subject -enddate
subject=O = system:nodes, CN = system:node:k8s-worker-1
notAfter=Oct 12 08:32:00 2027 GMT
Użytkownik system:node:k8s-worker-1 w grupie system:nodes może zmieniać tylko obiekty swojego węzła. Certyfikat kubeleta jest rotowany automatycznie (rotateCertificates: true w /var/lib/kubelet/config.yaml). W naszym klastrze kubelet dostaje też certyfikat serwera podpisany przez urząd certyfikacji klastra (CA, ang. Certificate Authority; opcja serverTLSBootstrap: true). Każdy taki certyfikat trzeba raz zatwierdzić (kubectl certificate approve), za to metrics-server łączy się z kubeletami bez wyłączania weryfikacji TLS (ang. Transport Layer Security).
Plik ~/.kube/config: serwer, użytkownik, kontekst i namespace
kubectl szuka danych dostępu w pliku $HOME/.kube/config albo w plikach wskazanych zmienną KUBECONFIG. Według dokumentacji kubeconfig plik ma trzy listy: clusters (adres serwera API i certyfikat CA), users (poświadczenia) oraz contexts (para klaster plus użytkownik, opcjonalnie z domyślnym namespace). current-context wskazuje kontekst używany domyślnie.
$ kubectl config view
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: DATA+OMITTED
server: https://192.168.124.10:6443
name: lab
contexts:
- context:
cluster: lab
user: kubernetes-admin
name: kubernetes-admin@lab
current-context: kubernetes-admin@lab
kind: Config
users:
- name: kubernetes-admin
user:
client-certificate-data: DATA+OMITTED
client-key-data: DATA+OMITTED
Plik skopiowaliśmy z /etc/kubernetes/admin.conf na węźle k8s-cp-1. Użytkownikiem jest certyfikat klienta, więc kto ma ten plik, ma pełne uprawnienia do klastra. Jego tożsamość i termin ważności sprawdza się tak:
$ kubectl config view --raw -o jsonpath="{.users[0].user.client-certificate-data}" | base64 -d | openssl x509 -noout -subject -enddate
subject=O = kubeadm:cluster-admins, CN = kubernetes-admin
notAfter=Oct 12 08:32:58 2027 GMT
$ kubectl auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
kubeadm wystawia certyfikaty na 1 rok, a urzędy certyfikacji na 10 lat; kubeadm certs check-expiration na k8s-cp-1 pokazał 364d dla admin.conf i 9y dla ca. Odnawia je kubeadm certs renew (opis zarządzania certyfikatami kubeadm) oraz aktualizacja przez kubeadm upgrade: w labie po aktualizacji do 1.37.1 admin.conf na k8s-cp-1 był ważny do 12 października 2027 09:57 UTC zamiast 08:32. Kopia pliku na stacji administratora ma własny certyfikat i własny termin, więc po odnowieniu trzeba ją skopiować ponownie.
kubectx i kubens: przełączanie klastrów i namespace'ów
kubectx przełącza kontekst w kubeconfig, a kubens zmienia domyślny namespace zapisany w bieżącym kontekście. Oba robią to samo co kubectl config use-context i kubectl config set-context --current --namespace=..., tylko krócej. Na k8s-admin utworzyliśmy dodatkowo klaster kind (opisany niżej), więc w kubeconfig są dwa konteksty:
$ kubectx
kind-nauka
kubernetes-admin@lab
$ kubectx lab=kubernetes-admin@lab # zmiana nazwy kontekstu
✔ Context kubernetes-admin@lab renamed to lab.
$ kubectx lab # przełączenie
✔ Switched to context "lab".
$ kubectx - # powrót do poprzedniego
✔ Switched to context "kind-nauka".
$ kubectx lab
✔ Switched to context "lab".
$ kubens podinfo # domyślny namespace w kontekście "lab"
✔ Active namespace is "podinfo"
$ kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
kind-nauka kind-nauka kind-nauka
* lab lab kubernetes-admin podinfo
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
podinfo-5c7cc598d9-92bzv 1/1 Running 0 15m
podinfo-5c7cc598d9-k8sp9 1/1 Running 0 15m
valkey-0 1/1 Running 0 12m
Ustawiony przez kubens namespace działa także w innych terminalach, bo jest zapisany w pliku kubeconfig. Przy pracy z kilkoma klastrami (na przykład produkcja i test) bezpieczniej sprawdzać kontekst przed każdym poleceniem zmieniającym stan: kubectx -c wypisuje bieżący kontekst, a kubens -c bieżący namespace.
Namespace: podział klastra na przestrzenie nazw
Namespace to przestrzeń nazw dla obiektów. Dwa Deploymenty mogą mieć tę samą nazwę w różnych namespace'ach, a uprawnienia RBAC (ang. Role-Based Access Control), limity zasobów i polityki sieciowe zwykle przypisuje się właśnie do namespace. Część obiektów nie należy do żadnego namespace: węzły, PersistentVolume, StorageClass, ClusterRole. W naszym klastrze każdy komponent ma swój namespace:
$ kubectl get ns
NAME STATUS AGE
default Active 30m
external-secrets Active 14m
flux-system Active 20m
kube-flannel Active 29m
kube-node-lease Active 30m
kube-public Active 30m
kube-system Active 30m
longhorn-system Active 22m
metallb-system Active 14m
monitoring Active 5m31s
podinfo Active 70s
traefik Active 14m
Namespace nie izoluje sieci ani węzłów. Bez obiektów NetworkPolicy pod z podinfo łączy się z usługą w monitoring: kubectl -n podinfo exec deploy/podinfo -- wget -qO- http://loki.monitoring.svc:3100/ready zwrócił w labie ready. Pody z różnych namespace'ów działają też na tych samych workerach.
Pod, Deployment, ReplicaSet, StatefulSet i DaemonSet: kto tworzy pody
Pod to najmniejsza jednostka, którą Kubernetes uruchamia: jeden lub kilka kontenerów z wspólnym adresem IP i wspólnymi wolumenami. Pody rzadko tworzy się ręcznie. Zwykle tworzy je kontroler na podstawie obiektu wyższego poziomu, a każdy pod zapisuje swojego właściciela w metadata.ownerReferences:
Deployment podinfo "chcę 2 repliki z tym szablonem"
| tworzy i skaluje
v
ReplicaSet podinfo-64c9544c9d "dokładnie 2 pody z szablonem o skrócie 64c9544c9d"
| tworzy
v
Pod podinfo-64c9544c9d-rv4q9, Pod podinfo-64c9544c9d-v4h7m
| Obiekt | Kiedy go używać | Przykład w labie |
|---|---|---|
| Deployment | Aplikacje bezstanowe. Aktualizacja tworzy nowy ReplicaSet i stopniowo wymienia pody (rolling update). | podinfo (2 repliki) |
| ReplicaSet | Tworzony przez Deployment, ręcznie prawie nigdy. Stare ReplicaSety z 0 replik zostają jako historia do wycofania zmiany. | podinfo-64c9544c9d, podinfo-5598684fff... |
| StatefulSet | Aplikacje ze stanem: stała nazwa poda (-0, -1), własny wolumen dla każdej repliki, uruchamianie po kolei. | valkey z wolumenem data-valkey-0 |
| DaemonSet | Po jednym podzie na każdym (pasującym) węźle: agenci sieci, storage, logów. | kube-proxy, longhorn-manager, alloy |
| Job / CronJob | Zadania, które mają się zakończyć, jednorazowo albo według harmonogramu. | nie używaliśmy |
StatefulSet bywa nazywany "rodzajem poda", ale to kontroler, tak jak Deployment. Różnica leży w tym, jak traktuje pody. Po usunięciu poda valkey-0 StatefulSet utworzył nowy pod o tej samej nazwie, z nowym adresem IP, podłączony do tego samego PVC (ang. PersistentVolumeClaim), więc dane w Valkey przetrwały:
$ kubectl -n podinfo get pod valkey-0 -o wide --no-headers
valkey-0 1/1 Running 0 27m 10.244.4.20 k8s-worker-1 <none> <none>
$ kubectl -n podinfo delete pod valkey-0
pod "valkey-0" deleted from podinfo namespace
$ kubectl -n podinfo wait --for=condition=Ready pod/valkey-0 --timeout=180s
pod/valkey-0 condition met
$ kubectl -n podinfo get pod valkey-0 -o wide --no-headers
valkey-0 1/1 Running 0 3s 10.244.4.28 k8s-worker-1 <none> <none>
$ kubectl -n podinfo get pvc data-valkey-0 --no-headers
data-valkey-0 Bound pvc-c4017280-dd59-4c60-813c-90c53899f9ee 1Gi RWO longhorn <unset> 27m
$ kubectl -n podinfo exec valkey-0 -- valkey-cli get notatka
zapisane przez podinfo w Valkey
Service i Ingress: droga żądania HTTP od klienta do poda
Adres IP poda zmienia się przy każdym restarcie, więc aplikacje nie łączą się z podami bezpośrednio. Service daje stały adres i nazwę DNS dla grupy podów wybranej etykietami (selector). Listę aktualnych adresów podów trzyma obiekt EndpointSlice; trafiają do niej tylko pody gotowe (sonda readiness), co pokazujemy w sekcji o sondach.
| Typ Service | Co daje | W labie |
|---|---|---|
ClusterIP | Adres wirtualny dostępny tylko wewnątrz klastra. | podinfo 10.96.17.26:80 |
ClusterIP: None (headless) | Bez adresu wirtualnego; DNS zwraca adresy podów, a StatefulSet dostaje nazwy valkey-0.valkey. | valkey |
NodePort | Port 30000-32767 otwarty na każdym węźle. | Traefik dostał porty 30927 i 31447 |
LoadBalancer | Zewnętrzny adres IP od implementacji load balancera (w chmurze od dostawcy, u nas od MetalLB). | traefik 192.168.124.200 |
$ kubectl get services -A -o wide | grep -E "^NAMESPACE|^podinfo|^traefik|kube-dns"
NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
kube-system kube-dns ClusterIP 10.96.0.10 <none> 53/UDP,53/TCP,9153/TCP 46m k8s-app=kube-dns
podinfo podinfo ClusterIP 10.96.17.26 <none> 80/TCP 16m app.kubernetes.io/name=podinfo
podinfo valkey ClusterIP None <none> 6379/TCP 16m app.kubernetes.io/name=valkey
traefik traefik LoadBalancer 10.103.107.91 192.168.124.200 80:30927/TCP,443:31447/TCP 26m app.kubernetes.io/instance=traefik-traefik,app.kubernetes.io/name=traefik
Nazwy Service rozwiązuje CoreDNS pod adresem 10.96.0.10. Pełna nazwa ma postać usługa.namespace.svc.cluster.local, a dzięki liście search w /etc/resolv.conf poda wystarczy sama nazwa usługi w tym samym namespace:
$ kubectl run dns-test -n podinfo --rm -i --restart=Never --image=busybox:1.38.0 -- sh -c \
"nslookup podinfo.podinfo.svc.cluster.local; nslookup valkey-0.valkey.podinfo.svc.cluster.local; cat /etc/resolv.conf"
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: podinfo.podinfo.svc.cluster.local
Address: 10.96.17.26
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: valkey-0.valkey.podinfo.svc.cluster.local
Address: 10.244.4.106
search podinfo.svc.cluster.local svc.cluster.local cluster.local k8s.lab
nameserver 10.96.0.10
options ndots:5
...
Service typu LoadBalancer bez chmury: adres z MetalLB
W chmurze adres dla Service typu LoadBalancer przydziela dostawca. W klastrze na własnych VM nie ma kto tego zrobić i Service zostaje w stanie <pending>. Tak wyglądał Traefik, zanim Flux utworzył pulę adresów MetalLB:
$ kubectl get svc -n traefik
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
traefik LoadBalancer 10.103.107.91 <pending> 80:30927/TCP,443:31447/TCP 2m40s
Po utworzeniu IPAddressPool (192.168.124.200-220) i L2Advertisement ten sam Service miał już adres 192.168.124.200 w chwili, gdy Flux zgłosił gotowość etapu z pulą. W trybie L2 jeden węzeł odpowiada na zapytania ARP o adres usługi, więc ruch trafia do niego, a kube-proxy przekazuje go do poda Traefika. Konkretny adres zarezerwowaliśmy adnotacją metallb.io/loadBalancerIPs: 192.168.124.200 w wartościach chartu Traefika.
Ingress, IngressClass i kontroler Ingress (Traefik)
Ingress to reguła HTTP: "żądania do hosta podinfo.k8s.lab kieruj do Service podinfo na porcie http". Sam obiekt niczego nie robi. Według dokumentacji Ingress potrzebny jest kontroler Ingress, czyli program, który czyta reguły i obsługuje ruch. W labie robi to Traefik 3.7.14; chart utworzył IngressClass o nazwie traefik i ustawił ją jako domyślną.
$ kubectl get ingressclass
NAME CONTROLLER PARAMETERS AGE
traefik (default) traefik.io/ingress-controller <none> 2m40s
$ kubectl get ingress -n podinfo
NAME CLASS HOSTS ADDRESS PORTS AGE
podinfo traefik podinfo.k8s.lab 192.168.124.200 80 27s
$ curl -s --resolve podinfo.k8s.lab:80:192.168.124.200 http://podinfo.k8s.lab/ | jq .
{
"hostname": "podinfo-64c9544c9d-rv4q9",
"version": "6.15.0",
"revision": "dd507173b7b75b2312a36cabe0de5f09c1ce69c8",
"color": "#34577c",
"logo": "https://raw.githubusercontent.com/stefanprodan/podinfo/gh-pages/cuddle_clap.gif",
"message": "Klaster lab na bm-lab-01",
"goos": "linux",
"goarch": "amd64",
"runtime": "go1.27.0",
"num_goroutine": "7",
"num_cpu": "2"
}
Opcja --resolve zastępuje rekord DNS: w produkcji nazwa podinfo.k8s.lab miałaby rekord A wskazujący adres load balancera. Cała droga żądania wygląda tak:
curl http://podinfo.k8s.lab/
-> 192.168.124.200:80 adres MetalLB, ARP odbiera jeden z workerów
-> Service traefik kube-proxy kieruje do poda Traefika
-> Traefik reguła Ingress: host podinfo.k8s.lab -> Service podinfo:http
-> Service podinfo EndpointSlice: tylko pody z readiness = true
-> pod podinfo :9898 kontener odpowiada JSON-em
Ingress NGINX i Gateway API. Popularny kontroler ingress-nginx został wycofany: projekt Kubernetes zapowiedział koniec utrzymania w marcu 2026 r., a ostatnie wydanie (controller-v1.15.1) ma datę 19 marca 2026 r. Samo API Ingress jest zamrożone, ale nie zostanie usunięte; dla nowych wdrożeń dokumentacja Kubernetes 1.37 zaleca Gateway API. Traefik obsługuje oba API (provider Kubernetes Gateway jest w chartcie 41.7.1 domyślnie wyłączony).
ConfigMap i Secret: konfiguracja aplikacji i dane poufne
ConfigMap przechowuje konfigurację w postaci par klucz-wartość albo całych plików. Pod może dostać ją jako zmienne środowiskowe albo jako pliki w wolumenie. podinfo czyta zmienne z prefiksem PODINFO_, więc ConfigMap wygląda tak:
$ kubectl -n podinfo get configmap
NAME DATA AGE
kube-root-ca.crt 1 19m
podinfo-config-gm4fcbk4cg 3 19m
$ kubectl -n podinfo describe pod podinfo-64c9544c9d-rv4q9 | grep -A1 "Environment Variables from"
Environment Variables from:
podinfo-config-gm4fcbk4cg ConfigMap Optional: false
Sufiks -gm4fcbk4cg dodał Kustomize (opisany niżej): to skrót treści. Zmiana treści daje nową nazwę ConfigMap, a ponieważ Deployment odwołuje się do nowej nazwy, zmienia się jego szablon i Kubernetes wymienia pody. Bez sufiksu zmiana ConfigMap nie restartuje podów: zmienne środowiskowe są czytane tylko przy starcie kontenera.
Secret wygląda podobnie, ale jest przeznaczony na dane poufne. Wartości w polu data są zakodowane w base64, czyli dają się odczytać bez żadnego klucza:
$ kubectl -n podinfo create secret generic przyklad --from-literal=haslo=Lab-Haslo-2026 --dry-run=client -o yaml
apiVersion: v1
data:
haslo: TGFiLUhhc2xvLTIwMjY=
kind: Secret
metadata:
name: przyklad
namespace: podinfo
$ echo TGFiLUhhc2xvLTIwMjY= | base64 -d
Lab-Haslo-2026
Według dokumentacji Secrets obiekty Secret są domyślnie zapisywane w etcd bez szyfrowania, a każdy, kto może utworzyć pod w namespace, może odczytać każdy Secret z tego namespace. Ochronę daje szyfrowanie w spoczynku (EncryptionConfiguration w kube-apiserver) i ograniczenie uprawnień RBAC. Z tego samego powodu jawnego Secretu nie wolno zapisać w repozytorium Git, z którego Flux buduje klaster. Rozwiązują to dwa niezależne podejścia: Sealed Secrets i External Secrets.
Sealed Secrets: zaszyfrowany sekret w repozytorium Git
Sealed Secrets składa się z kontrolera w klastrze i programu kubeseal. Kontroler przy starcie generuje parę kluczy. kubeseal szyfruje Secret kluczem publicznym i tworzy obiekt SealedSecret, który bezpiecznie trzymać w Git. Odszyfrować go może tylko kontroler w tym klastrze, kluczem prywatnym. Kontroler, po odszyfrowaniu, tworzy zwykły Secret.
$ kubeseal --fetch-cert > ~/sealed-secrets-cert.pem
$ kubectl -n podinfo create secret generic podinfo-token --from-literal=token=lab-token-0001 --dry-run=client -o yaml \
| kubeseal --cert ~/sealed-secrets-cert.pem --format yaml > apps/lab/podinfo-token-sealed.yaml
$ cat apps/lab/podinfo-token-sealed.yaml
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: podinfo-token
namespace: podinfo
spec:
encryptedData:
token: AgAFb9hQnqZyIvh7k7fnkNqzpq8SM9EzQJL5B0ZPJcPbP6IEex2IkfUZ1C0NKEjSf3SyWzkC2le...
template:
metadata:
name: podinfo-token
namespace: podinfo
Po commicie i uzgodnieniu przez Flux kontroler utworzył Secret, a Deployment podinfo, który odwołuje się do niego przez secretKeyRef, dostał zmienną API_TOKEN:
$ kubectl -n podinfo get sealedsecret,secret
NAME STATUS SYNCED AGE
sealedsecret.bitnami.com/podinfo-token True 5s
NAME TYPE DATA AGE
secret/podinfo-token Opaque 1 5s
$ kubectl -n podinfo exec deploy/podinfo -- printenv API_TOKEN
lab-token-0001
Domyślny zakres szyfrowania to strict: nazwa i namespace są częścią szyfrogramu. Ten sam SealedSecret wgrany do namespace debug nie dał się odszyfrować:
$ kubectl -n debug get sealedsecret podinfo-token -o jsonpath='{.status.conditions[0].type}={.status.conditions[0].status}: {.status.conditions[0].message}'
Synced=False: no key could decrypt secret (token)
Klucze kontrolera trzeba archiwizować (kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key). Po utracie klastra bez tej kopii żaden SealedSecret z repozytorium nie da się odszyfrować. Kontroler dodaje nowy klucz co 30 dni i zachowuje stare, więc starsze SealedSecrety nadal działają.
External Secrets i Secret Manager: sekret pobierany z zewnętrznego sejfu
External Secrets Operator (ESO) działa odwrotnie: wartość sekretu w ogóle nie trafia do Git, nawet zaszyfrowana. W Git są tylko dwa opisy: ClusterSecretStore (gdzie jest sejf i jak się do niego logować) oraz ExternalSecret (który klucz z sejfu skopiować do którego Secretu). Kontroler ESO cyklicznie, co refreshInterval, pobiera wartość i aktualizuje Secret.
W firmach sejfem bywa Google Secret Manager w GCP (ang. Google Cloud Platform), AWS Secrets Manager (AWS, ang. Amazon Web Services) albo HashiCorp Vault. W labie rolę sejfu pełni OpenBao 2.7.1 w trybie dev na k8s-admin (dane tylko w pamięci, niedopuszczalne w produkcji). ESO łączy się z nim przez provider vault, bo OpenBao zachowuje API Vaulta. Dla Google Secret Manager zmienia się tylko blok provider (provider gcpsm); dokumentacja ESO opisuje uwierzytelnianie przez Workload Identity w GKE (ang. Google Kubernetes Engine), bez długoterminowego klucza w klastrze.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: openbao
spec:
provider:
vault:
server: http://192.168.124.5:8200
path: secret
version: v2
auth:
tokenSecretRef:
name: openbao-token
namespace: external-secrets
key: token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: podinfo-db
namespace: podinfo
spec:
refreshInterval: 1m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: podinfo-db
data:
- secretKey: password
remoteRef:
key: podinfo/db
property: password
Token openbao-token jest jedynym sekretem, który założyliśmy ręcznie, poza Git (problem "sekretu zero": coś musi uwierzytelnić klaster do sejfu). Rotację sprawdziliśmy zmianą hasła w OpenBao:
$ bao kv put -mount=secret podinfo/db password=Haslo-v2 > /dev/null
$ kubectl -n podinfo get secret podinfo-db -o jsonpath='{.data.password}' | base64 -d
Haslo-v2
$ kubectl -n podinfo describe externalsecret podinfo-db | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Created 62s external-secrets secret created
Normal Updated 2s external-secrets secret updated
Hasło zmieniliśmy w OpenBao o 09:27:53, a Secret podinfo-db miał nową wartość o 09:28:47, czyli po 54 s, w granicach refreshInterval: 1m. Pod, który czyta hasło ze zmiennej środowiskowej, zobaczy je dopiero po restarcie: dokumentacja Kubernetes mówi wprost, że zmienne z Secretu nie są aktualizowane, a Secret zamontowany jako wolumen jest.
| Secret w Git | Sealed Secrets | External Secrets | |
|---|---|---|---|
| Co jest w Git | jawna wartość (base64) | szyfrogram | tylko wskazanie klucza w sejfie |
| Gdzie jest źródło prawdy | Git | Git | sejf (OpenBao, Secret Manager) |
| Rotacja | commit | nowy kubeseal i commit | zmiana w sejfie, ESO kopiuje sam |
| Co trzeba chronić | całe repozytorium | klucz prywatny kontrolera | dostęp klastra do sejfu |
| W labie | nie używamy | podinfo-token | podinfo-db, klucze S3 dla Longhorna i Loki |
PersistentVolume, PersistentVolumeClaim i StorageClass: dane, które przeżyją pod
System plików kontenera znika razem z podem. Dane, które mają przetrwać restart, trafiają na wolumen opisany trzema obiektami:
- PersistentVolumeClaim (PVC) - żądanie aplikacji: "potrzebuję 1 GiB z dostępem
ReadWriteOnce(RWO, zapis z jednego węzła naraz)". Należy do namespace aplikacji. - StorageClass - przepis na tworzenie wolumenów: który sterownik CSI i z jakimi parametrami.
- PersistentVolume (PV) - konkretny wolumen. Przy dynamicznym przydziale tworzy go sterownik CSI w odpowiedzi na PVC i wiąże z nim (status
Bound).
$ kubectl get pvc -A
NAMESPACE NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
monitoring storage-loki-0 Bound pvc-e6429f61-8f71-4ec4-b801-4fe859489de7 2Gi RWO longhorn <unset> 132m
podinfo data-valkey-0 Bound pvc-c4017280-dd59-4c60-813c-90c53899f9ee 1Gi RWO longhorn <unset> 128m
$ kubectl get storageclass longhorn -o yaml | grep -A12 "^parameters"
parameters:
backupTargetName: default
dataEngine: v1
dataLocality: disabled
disableRevisionCounter: "true"
fromBackup: ""
fsType: ext4
numberOfReplicas: "2"
staleReplicaTimeout: "30"
unmapMarkSnapChainRemoved: ignored
provisioner: driver.longhorn.io
reclaimPolicy: Delete
volumeBindingMode: Immediate
Zdarzenia z pierwszego startu valkey-0 pokazują cały łańcuch zależności: StatefulSet tworzy PVC, Longhorn (przez CSI) tworzy wolumen, scheduler czeka 3 sekundy, aż PVC zostanie związane, a potem wolumen jest podłączany do wybranego węzła. Od utworzenia PVC do startu kontenera minęły 23 sekundy:
$ kubectl -n podinfo get events --sort-by=.lastTimestamp | grep -E "valkey|data-valkey"
8m40s Normal SuccessfulCreate statefulset/valkey Create Claim data-valkey-0 Pod valkey-0 in StatefulSet valkey success
8m40s Normal SuccessfulCreate statefulset/valkey Create Pod valkey-0 in StatefulSet valkey successful
8m40s Normal ExternalProvisioning persistentvolumeclaim/data-valkey-0 Waiting for a volume to be created either by the external provisioner 'driver.longhorn.io' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
8m40s Normal Provisioning persistentvolumeclaim/data-valkey-0 External provisioner is provisioning volume for claim "podinfo/data-valkey-0"
8m37s Warning FailedScheduling pod/valkey-0 0/5 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found
8m37s Normal Scheduled pod/valkey-0 Successfully assigned podinfo/valkey-0 to k8s-worker-1
8m37s Normal ProvisioningSucceeded persistentvolumeclaim/data-valkey-0 Successfully provisioned volume pvc-c4017280-dd59-4c60-813c-90c53899f9ee
8m27s Normal SuccessfulAttachVolume pod/valkey-0 AttachVolume.Attach succeeded for volume "pvc-c4017280-dd59-4c60-813c-90c53899f9ee"
8m25s Normal Pulling pod/valkey-0 Pulling image "docker.io/valkey/valkey:9.1.2-alpine"
8m17s Normal Pulled pod/valkey-0 Successfully pulled image "docker.io/valkey/valkey:9.1.2-alpine" in 7.346s (7.346s including waiting). Image size: 18809321 bytes.
8m17s Normal Created pod/valkey-0 Container created
8m17s Normal Started pod/valkey-0 Container started
reclaimPolicy: Delete oznacza, że usunięcie PVC usuwa także wolumen z danymi. Dla danych, które mają przetrwać pomyłkowe usunięcie aplikacji, stosuje się StorageClass z Retain albo kopie zapasowe opisane niżej.
Longhorn na workerach: repliki i demon iscsid
Longhorn 1.13.0 to rozproszony storage blokowy działający w samym klastrze. Dla każdego wolumenu uruchamia osobny silnik (engine) na węźle, na którym działa pod, a dane zapisuje synchronicznie do replik na różnych węzłach. W labie każdy wolumen ma dwie repliki, po jednej na workerze, w katalogu /var/lib/longhorn na osobnym dysku 20 GiB:
$ kubectl -n longhorn-system get volumes.longhorn.io -o wide
NAME DATA ENGINE STATE ROBUSTNESS SCHEDULED SIZE NODE SWITCHOVER AGE
pvc-c4017280-dd59-4c60-813c-90c53899f9ee v1 attached healthy 1073741824 k8s-worker-1 26m
pvc-e6429f61-8f71-4ec4-b801-4fe859489de7 v1 attached healthy 2147483648 k8s-worker-2 30m
$ kubectl -n longhorn-system get replicas.longhorn.io -o custom-columns=WOLUMEN:.spec.volumeName,WEZEL:.spec.nodeID,STAN:.status.currentState
WOLUMEN WEZEL STAN
pvc-c4017280-dd59-4c60-813c-90c53899f9ee k8s-worker-2 running
pvc-c4017280-dd59-4c60-813c-90c53899f9ee k8s-worker-1 running
pvc-e6429f61-8f71-4ec4-b801-4fe859489de7 k8s-worker-1 running
pvc-e6429f61-8f71-4ec4-b801-4fe859489de7 k8s-worker-2 running
Silnik wystawia wolumen węzłowi jako dysk iSCSI (ang. Internet Small Computer Systems Interface). Dlatego na każdym workerze musi działać inicjator iSCSI z pakietu open-iscsi, czyli demon iscsid: to on loguje się do celu iSCSI wystawionego przez pod instance-manager i tworzy urządzenie blokowe, które kubelet montuje w podzie. Na k8s-worker-1, do którego po aktualizacji węzłów (opisanej dalej) były podłączone oba wolumeny, Loki i Valkey, widać dwie sesje iSCSI i dwa dyski sdX, każdy zamontowany w katalogu swojego poda:
# iscsiadm -m session -P 1 | grep -E "Target:|Current Portal|Initiatorname|Session State"
Target: iqn.2019-10.io.longhorn:pvc-e6429f61-8f71-4ec4-b801-4fe859489de7 (non-flash)
Current Portal: 10.244.4.79:3260,1
Iface Initiatorname: iqn.2004-10.com.ubuntu:01:104aca7a71ad
iSCSI Session State: LOGGED_IN
Internal iscsid Session State: NO CHANGE
Target: iqn.2019-10.io.longhorn:pvc-c4017280-dd59-4c60-813c-90c53899f9ee (non-flash)
Current Portal: 10.244.4.79:3260,1
Iface Initiatorname: iqn.2004-10.com.ubuntu:01:104aca7a71ad
iSCSI Session State: LOGGED_IN
Internal iscsid Session State: NO CHANGE
# lsblk -o NAME,SIZE,TYPE,TRAN,VENDOR,MOUNTPOINT | grep -E "NAME|sd|vdc"
NAME SIZE TYPE TRAN VENDOR MOUNTPOINT
sda 2G disk iscsi IET /var/lib/kubelet/pods/9410aac0-0300-48fc-81f4-f622f91f9f6f/volumes/kubernetes.io~csi/pvc-e6429f61-8f71-4ec4-b801-4fe859489de7/mount
sdb 1G disk iscsi IET /var/lib/kubelet/pods/8b036196-e148-48b5-a7ca-c516ade56d05/volumes/kubernetes.io~csi/pvc-c4017280-dd59-4c60-813c-90c53899f9ee/mount
vdc 20G disk virtio 0x1af4 /var/lib/longhorn
# ls -l /dev/longhorn/
total 0
brw-rw---- 1 root disk 8, 16 Oct 12 10:33 pvc-c4017280-dd59-4c60-813c-90c53899f9ee
brw-rw---- 1 root disk 8, 0 Oct 12 10:33 pvc-e6429f61-8f71-4ec4-b801-4fe859489de7
Przed instalacją wymagania węzłów sprawdza longhornctl check preflight (narzędzie wiersza poleceń Longhorn CLI 1.13.0; CLI, ang. Command Line Interface). Wynik trafia do pola msg w logu narzędzia, dlatego poniżej zamieniamy w nim \n na nowe wiersze. Na świeżych workerach z Ubuntu 24.04 narzędzie zgłosiło dwa błędy i jedno ostrzeżenie (polecenie wymaga zmiennej KUBECONFIG i istniejącego namespace longhorn-system):
$ export KUBECONFIG=$HOME/.kube/config
$ longhornctl check preflight 2>&1 | grep Retrieved | sed 's/\\n/\n/g'
time="2026-10-12T08:41:43Z" level=info msg="Retrieved preflight checker result:
k8s-worker-1:
error:
- '[KernelModules] nfs is not loaded. (exit code: 1)'
- '[KernelModules] dm_crypt is not loaded. (exit code: 1)'
info:
- '[KubeDNS] Kube DNS "coredns" is set with 2 replicas and 2 ready replicas'
- '[IscsidService] Service iscsid is running'
- '[NFSv4] NFS4 is supported'
- '[Packages] nfs-common is installed'
- '[Packages] open-iscsi is installed'
- '[Packages] cryptsetup is installed'
- '[Packages] dmsetup is installed'
warn:
- '[MultipathService] multipathd.service is running. Please refer to https://longhorn.io/kb/troubleshooting-volume-with-multipath/ for more information.'
k8s-worker-2:
...
Moduł nfs jest potrzebny do wolumenów RWX (ang. ReadWriteMany, zapis z wielu węzłów), a dm_crypt do wolumenów szyfrowanych. Ładujemy je i zapisujemy w /etc/modules-load.d/longhorn.conf. multipathd jest w obrazie Ubuntu domyślnie włączony i potrafi przejąć urządzenia /dev/sdX tworzone przez Longhorna. Baza wiedzy Longhorna zaleca wykluczenie ich w /etc/multipath.conf:
# printf "nfs\ndm_crypt\n" > /etc/modules-load.d/longhorn.conf
# modprobe nfs; modprobe dm_crypt
# cat >> /etc/multipath.conf <<'EOF'
blacklist {
devnode "^sd[a-z0-9]+"
}
EOF
# systemctl restart multipathd
# multipath -t | grep -A1 "^blacklist {"
blacklist {
devnode "^sd[a-z0-9]+"
Po poprawkach błędy zniknęły, a ostrzeżenie o multipathd zostało, bo longhornctl sprawdza tylko, czy usługa działa, a nie jej konfigurację. To, że blacklista działa, potwierdza pusty wynik multipath -ll przy podłączonym wolumenie. Więcej o samym multipath piszemy w artykule Multipath w Linuksie: jeden dysk iSCSI przez dwie ścieżki, awaria i powrót ścieżki.
Co się dzieje, gdy pod z wolumenem przechodzi na inny węzeł
Wolumen RWO może być podłączony tylko do jednego węzła naraz. Wyłączyliśmy planowanie na k8s-worker-1 (kubectl cordon) i usunęliśmy valkey-0. Nowy pod trafił na k8s-worker-2, ale musiał poczekać, aż Longhorn odłączy wolumen od starego węzła:
$ kubectl -n podinfo get events --field-selector involvedObject.name=valkey-0 --sort-by=.lastTimestamp
34s Normal Killing pod/valkey-0 Stopping container valkey
32s Warning FailedAttachVolume pod/valkey-0 Waiting for detach for volume "pvc-c4017280-dd59-4c60-813c-90c53899f9ee" Volume is already exclusively attached to one node, waiting on detach before it can be attached to another node
11s Normal SuccessfulAttachVolume pod/valkey-0 AttachVolume.Attach succeeded for volume "pvc-c4017280-dd59-4c60-813c-90c53899f9ee"
9s Normal Pulling pod/valkey-0 Pulling image "docker.io/valkey/valkey:9.1.2-alpine"
1s Normal Started pod/valkey-0 Container started
Całość trwała 31,5 s i obejmowała pobranie obrazu Valkey na nowym węźle. Replika na k8s-worker-2 miała aktualne dane, więc valkey-cli get notatka zwróciło zapisaną wcześniej wartość. Przy jednej replice i awarii węzła z tą repliką wolumen byłby niedostępny do czasu powrotu węzła.
Kopie zapasowe wolumenów w S3 (Simple Storage Service) poza klastrem
Repliki chronią przed awarią węzła, ale nie przed usunięciem PVC, błędem aplikacji czy utratą całego klastra. Do tego służy backup target: magazyn poza klastrem, do którego Longhorn wysyła kopie wolumenów. Według dokumentacji Longhorna może to być NFS (ang. Network File System) albo magazyn zgodny z S3, czyli z API przechowywania obiektów zapoczątkowanym przez Amazon. W labie S3 udostępnia Garage 2.4.1 na k8s-admin, a cel kopii ustawiają wartości chartu Longhorna:
defaultBackupStore:
backupTarget: s3://longhorn-backups@garage/
backupTargetCredentialSecret: longhorn-s3
Secret longhorn-s3 z kluczami S3 tworzy External Secrets z OpenBao, więc i tu w Git nie ma kluczy. Snapshot Longhorna to lokalny punkt w czasie, przechowywany obok replik. Backup to spłaszczona kopia snapshotu wysłana do S3. Oba utworzyliśmy obiektami w API:
$ kubectl apply -f - <<'EOF'
apiVersion: longhorn.io/v1beta2
kind: Snapshot
metadata:
name: valkey-2026-10-12
namespace: longhorn-system
spec:
volume: pvc-c4017280-dd59-4c60-813c-90c53899f9ee
createSnapshot: true
---
apiVersion: longhorn.io/v1beta2
kind: Backup
metadata:
name: valkey-2026-10-12
namespace: longhorn-system
labels:
backup-volume: pvc-c4017280-dd59-4c60-813c-90c53899f9ee
spec:
snapshotName: valkey-2026-10-12
backupMode: full
EOF
$ kubectl -n longhorn-system get backups.longhorn.io
NAME SNAPSHOTNAME SNAPSHOTSIZE SNAPSHOTCREATEDAT BACKUPTARGET STATE LASTSYNCEDAT
valkey-2026-10-12 valkey-2026-10-12 71303168 2026-10-12T09:30:46Z default Completed 2026-10-12T09:30:50Z
$ s3cmd ls -r s3://longhorn-backups/ | awk '{print $3, $4}'
4052 s3://longhorn-backups/backupstore/volumes/45/12/pvc-c4017280-dd59-4c60-813c-90c53899f9ee/backups/backup_valkey-2026-10-12.cfg
9462 s3://longhorn-backups/backupstore/volumes/45/12/pvc-c4017280-dd59-4c60-813c-90c53899f9ee/blocks/1a/6b/1a6b7c9ee5c72d3887a920159b85ad89b2392945cc25bbdf0179659bb07dbfd1.blk
...
491 s3://longhorn-backups/backupstore/volumes/45/12/pvc-c4017280-dd59-4c60-813c-90c53899f9ee/volume.cfg
$ s3cmd du s3://longhorn-backups/
133737 14 objects s3://longhorn-backups/
W produkcji kopie tworzy obiekt RecurringJob według harmonogramu, a o tym, czy kopia nadaje się do odtworzenia, przesądza dopiero próba odtworzenia wolumenu. Ten sam Garage przechowuje też logi z Loki (bucket loki), co opisujemy w sekcji o Alloy.
Helm: chart, values.yaml i release
Helm to menedżer pakietów dla Kubernetes. Słownik Helm 4.3.0 definiuje cztery pojęcia potrzebne w praktyce:
- chart - pakiet z szablonami manifestów i domyślnymi wartościami (
values.yaml), - repozytorium - serwer HTTP albo rejestr OCI (ang. Open Container Initiative), z którego pobiera się charty,
- values - wartości, które nadpisują domyślne ustawienia szablonów,
- release - zainstalowana kopia chartu; każda zmiana zwiększa numer rewizji.
W labie żadnego chartu nie instalowaliśmy poleceniem helm install. Robi to helm-controller Flux na podstawie obiektów HelmRelease, ale wynik to zwykłe release'y Helm, widoczne dla helm list:
$ helm list -A
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
alloy monitoring 1 2026-10-12 08:59:40.069467009 +0000 UTC deployed alloy-1.13.1 v1.20.1
external-secrets external-secrets 1 2026-10-12 08:48:56.075874439 +0000 UTC deployed external-secrets-2.12.0 v2.12.0
loki monitoring 1 2026-10-12 08:58:04.675333317 +0000 UTC deployed loki-7.3.0 3.6.12
longhorn longhorn-system 2 2026-10-12 10:20:21.292485549 +0000 UTC deployed longhorn-1.13.0 v1.13.0
metallb metallb-system 2 2026-10-12 08:53:50.172398849 +0000 UTC deployed metallb-0.16.1 v0.16.1
metrics-server kube-system 1 2026-10-12 08:48:57.665690314 +0000 UTC deployed metrics-server-3.14.0 0.9.0
sealed-secrets kube-system 1 2026-10-12 08:49:37.555996179 +0000 UTC deployed sealed-secrets-2.20.0 0.40.0
traefik traefik 4 2026-10-12 10:35:12.961285297 +0000 UTC deployed traefik-41.7.1 v3.7.14
$ helm get values longhorn -n longhorn-system | head -6
USER-SUPPLIED VALUES:
csi:
attacherReplicaCount: 2
provisionerReplicaCount: 2
resizerReplicaCount: 1
snapshotterReplicaCount: 1
$ helm history metallb -n metallb-system
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Mon Oct 12 08:48:57 2026 superseded metallb-0.16.1 v0.16.1 Install complete
2 Mon Oct 12 08:53:50 2026 deployed metallb-0.16.1 v0.16.1 Upgrade complete
Każda rewizja powyżej 1 to skutek commitu w Git, który zmienił values: MetalLB (wyłączenie FRR-K8s, czyli demona routingu BGP, ang. Border Gateway Protocol), Longhorn (repliki CSI) i Traefik (repliki, PodDisruptionBudget, rozkład na węzły). Stan release'u Helm trzyma w Secretach typu helm.sh/release.v1 (na przykład sh.helm.release.v1.longhorn.v1) w namespace release'u. Release zainstalowany przez helm-controller Flux ma w metadanych APPLY_METHOD: server-side apply (helm get metadata longhorn -n longhorn-system). Uwaga na kolumnę APP VERSION: chart Loki 7.3.0 deklaruje 3.6.12, a w podzie działał obraz grafana/loki:3.6.11; o tym, co faktycznie działa, rozstrzyga kubectl get pod -o jsonpath='{..image}'.
values.yaml: domyślne wartości chartu trzeba dopasować do klastra
Domyślne values chartów są pisane pod typowy, większy klaster. W labie cztery z nich trzeba było zmienić, a trzy problemy wyszły dopiero przy pomiarze albo przy pracy z węzłami:
| Chart | Wartość domyślna | Skutek w labie | Zmiana |
|---|---|---|---|
| Loki 7.3.0 | chunksCache.enabled: true, allocatedMemory: 8192 | Wyrenderowany StatefulSet chunks-cache żąda 9830Mi RAM; worker ma 3,2 GiB do przydziału, więc pod nie zmieściłby się na żadnym węźle | chunksCache.enabled: false, tryb SingleBinary |
| MetalLB 0.16.1 | frrk8s.enabled: true | Pod FRR-K8s (demon routingu BGP) na każdym z 5 węzłów, 150-190 MiB RAM na węzeł, zbędny w trybie L2 | frrk8s.enabled: false |
| Longhorn 1.13.0 | po 3 repliki sidecarów CSI | Zmniejszyliśmy do 1; Longhorn ma PodDisruptionBudget (PDB) minAvailable: 1 dla csi-attacher i csi-provisioner, więc kubectl drain workera z takim podem czekał 10 minut i się nie udał | attacherReplicaCount: 2, provisionerReplicaCount: 2 |
| Traefik 41.7.1 | deployment.replicas: 1, bez PDB | kubectl drain węzła z Traefikiem przerwał ruch HTTP na ok. 50 s | 2 repliki, PDB minAvailable: 1, rozkład na węzły |
Gdzie trzymać values? Krótkie wartości wpisujemy wprost w spec.values HelmRelease. Dłuższe trzymamy w pliku values.yaml obok HelmRelease i zamieniamy go w ConfigMap generatorem Kustomize; HelmRelease czyta je przez valuesFrom. Tak zrobiliśmy dla Loki i Alloy:
infrastructure/monitoring/loki/
|-- externalsecret.yaml # klucze S3 z OpenBao
|-- helmrelease.yaml # chart loki 7.3.0, valuesFrom: ConfigMap loki-values
|-- kustomization.yaml # configMapGenerator: loki-values z pliku values.yaml
|-- kustomizeconfig.yaml # nameReference: sufiks nazwy ConfigMap trafia też do HelmRelease
`-- values.yaml # właściwa konfiguracja chartu
Kustomize: konfiguracja bazowa (base) i nakładka (overlay)
Kustomize składa manifesty z kilku katalogów bez szablonów. Plik kustomization.yaml wymienia zasoby (resources), generatory (configMapGenerator, secretGenerator) i zmiany (patches, images, namespace). Kustomize jest wbudowany w kubectl (kubectl kustomize katalog, kubectl apply -k katalog); kubectl 1.37.0 zgłasza Kustomize Version: v5.8.1.
Base to konfiguracja wspólna dla wszystkich środowisk, a overlay to katalog, który bierze base i zmienia tylko to, co w danym środowisku jest inne. Nakładka apps/lab w naszym repozytorium:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../base/podinfo
- ../base/valkey
- podinfo-token-sealed.yaml # SealedSecret
- podinfo-db-externalsecret.yaml # ExternalSecret
configMapGenerator:
- name: podinfo-config
namespace: podinfo
behavior: merge
literals:
- "PODINFO_UI_MESSAGE=Zmiana z Git: wersja 2"
patches:
- target:
kind: Deployment
name: podinfo
patch: |-
- op: replace
path: /spec/replicas
value: 2
Różnicę między bazą a nakładką najprościej zobaczyć, budując obie wersje:
$ diff -u <(kubectl kustomize apps/base/podinfo) <(kubectl kustomize apps/lab) | grep -E "^[-+] .*(PODINFO_UI_MESSAGE|podinfo-config-|replicas:|API_TOKEN)"
- PODINFO_UI_MESSAGE: Konfiguracja bazowa
+ PODINFO_UI_MESSAGE: 'Zmiana z Git: wersja 2'
- name: podinfo-config-457hdcb89d
+ name: podinfo-config-ckfbfdtt2g
- replicas: 1
+ replicas: 2
+ - name: API_TOKEN
- name: podinfo-config-457hdcb89d
+ name: podinfo-config-ckfbfdtt2g
+ replicas: 1
Nakładka zmieniła komunikat, a przez to nazwę ConfigMap i odwołanie w Deployment, podniosła liczbę replik podinfo z 1 do 2 i dodała zmienną API_TOKEN. Ostatnia linia + replicas: 1 należy do StatefulSetu Valkey, którego baza podinfo w ogóle nie zawiera.
Dwie różne rzeczy o nazwie Kustomization. Plik kustomization.yaml (apiVersion: kustomize.config.k8s.io/v1beta1) opisuje, jak zbudować manifesty, i nigdy nie trafia do klastra. Obiekt Kustomization Flux (apiVersion: kustomize.toolkit.fluxcd.io/v1) żyje w klastrze i mówi, który katalog z repozytorium zbudować, jak często i w jakiej kolejności. Kustomization Flux wskazuje katalog, w którym leży kustomization.yaml.
Flux: klaster uzgadniany z repozytorium Git (GitOps)
GitOps to model pracy, w którym repozytorium Git jest źródłem prawdy o stanie klastra. Administrator nie wykonuje kubectl apply na produkcji, tylko robi commit, a program w klastrze sam pobiera zmiany i je stosuje. Robi to Flux 2.9.6, zestaw czterech kontrolerów w namespace flux-system. Każdy z nich obsługuje własne typy obiektów, dodane do API przez CRD (ang. Custom Resource Definition), czyli definicje nowych rodzajów zasobów.
| Kontroler | Wersja w labie | Za co odpowiada | Obiekty (CRD) |
|---|---|---|---|
| source-controller | v1.9.6 | Pobiera źródła: repozytoria Git, repozytoria i charty Helm, artefakty OCI | GitRepository, HelmRepository, HelmChart |
| kustomize-controller | v1.9.6 | Buduje katalog przez Kustomize i stosuje wynik (server-side apply), usuwa obiekty usunięte z Git | Kustomization |
| helm-controller | v1.6.5 | Instaluje i aktualizuje release'y Helm | HelmRelease |
| notification-controller | v1.9.4 | Powiadomienia (Slack, Teams...) i webhooki z Git zamiast odpytywania | Alert, Provider, Receiver |
Z CRD korzystają też Longhorn, MetalLB czy External Secrets: dodają własne typy obiektów, a ich kontrolery uzgadniają je dokładnie tak, jak kube-controller-manager uzgadnia Deploymenty.
flux bootstrap: Flux instaluje sam siebie z repozytorium Git
Repozytorium w labie to zwykłe repozytorium bare na k8s-admin, dostępne przez SSH (konto git z powłoką git-shell). flux bootstrap git zapisuje w nim manifesty Flux, instaluje kontrolery i konfiguruje je tak, by od tej chwili Flux zarządzał także sobą:
$ flux check --pre
► checking prerequisites
✔ Kubernetes 1.37.0 >=1.33.0-0
✔ prerequisites checks passed
$ flux bootstrap git --url=ssh://git@192.168.124.5/srv/git/fleet-infra.git --branch=main \
--path=clusters/lab --private-key-file=$HOME/.ssh/flux-fleet-infra --silent
► cloning branch "main" from Git repository "ssh://git@192.168.124.5/srv/git/fleet-infra.git"
✔ cloned repository
► generating component manifests
✔ generated component manifests
✔ committed component manifests to "main" ("0c87658e68c6bf752a8d2133317c275bb409885c")
► pushing component manifests to "ssh://git@192.168.124.5/srv/git/fleet-infra.git"
► installing components in "flux-system" namespace
✔ installed components
✔ reconciled components
► determining if source secret "flux-system/flux-system" exists
► generating source secret
✔ public key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
► applying source secret "flux-system/flux-system"
✔ reconciled source secret
► generating sync manifests
✔ generated sync manifests
✔ committed sync manifests to "main" ("dbeacf1e2783412f5a687d874c68256bc985eba7")
► pushing sync manifests to "ssh://git@192.168.124.5/srv/git/fleet-infra.git"
► applying sync manifests
✔ reconciled sync configuration
◎ waiting for GitRepository "flux-system/flux-system" to be reconciled
✔ GitRepository reconciled successfully
◎ waiting for Kustomization "flux-system/flux-system" to be reconciled
✔ Kustomization reconciled successfully
► confirming components are healthy
✔ helm-controller: deployment ready
✔ kustomize-controller: deployment ready
✔ notification-controller: deployment ready
✔ source-controller: deployment ready
✔ all components are healthy
Klucz prywatny SSH trafił do Secretu flux-system (pola identity, identity.pub, known_hosts). Plik gotk-sync.yaml w repozytorium łączy klaster z katalogiem clusters/lab:
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: flux-system
namespace: flux-system
spec:
interval: 1m0s
ref:
branch: main
secretRef:
name: flux-system
url: ssh://git@192.168.124.5/srv/git/fleet-infra.git
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: flux-system
namespace: flux-system
spec:
interval: 10m0s
path: ./clusters/lab
prune: true
sourceRef:
kind: GitRepository
name: flux-system
Repozytorium konfiguracji klastra: układ katalogów
Układ katalogów odpowiada zaleceniom Flux dla monorepo: clusters/ opisuje konkretne klastry, infrastructure/ dodatki klastra, apps/ aplikacje. Drugi klaster (na przykład produkcyjny) dostałby katalog clusters/prod z innymi nakładkami tych samych baz.
fleet-infra/
|-- clusters/lab/
| |-- flux-system/ gotk-components.yaml, gotk-sync.yaml (wygenerował bootstrap)
| |-- infrastructure.yaml Kustomization Flux: infra-controllers, infra-configs, monitoring
| `-- apps.yaml Kustomization Flux: apps
|-- infrastructure/
| |-- controllers/ HelmRepository + HelmRelease: MetalLB, Traefik, Longhorn,
| | Sealed Secrets, External Secrets, metrics-server
| |-- configs/ IPAddressPool, ClusterSecretStore, ExternalSecret (klucze S3)
| `-- monitoring/ loki/ i alloy/: HelmRelease + values.yaml
`-- apps/
|-- base/podinfo/ Deployment, Service, Ingress, PDB, ConfigMap (generator)
|-- base/valkey/ StatefulSet, Service headless
`-- lab/ nakładka: komunikat, 2 repliki, SealedSecret, ExternalSecret
Kolejność wdrażania: dependsOn między etapami
Nie wszystko można zastosować naraz. IPAddressPool MetalLB albo ClusterSecretStore to typy, które istnieją dopiero po instalacji chartów z ich CRD. Loki potrzebuje sekretu S3 i StorageClass Longhorna. Dlatego repozytorium dzieli się na etapy połączone polem dependsOn; Flux stosuje etap dopiero, gdy poprzedni jest gotowy (wait: true sprawdza stan wszystkich jego obiektów):
Kustomization flux-system (clusters/lab)
`-> infra-controllers HelmRelease: MetalLB, Traefik, Longhorn, Sealed Secrets, ESO, metrics-server
`-> infra-configs IPAddressPool, L2Advertisement, ClusterSecretStore, ExternalSecret S3
|-> monitoring Loki (PVC Longhorn, S3), Alloy
`-> apps podinfo, Valkey, SealedSecret, ExternalSecret
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: infra-configs
namespace: flux-system
spec:
dependsOn:
- name: infra-controllers
interval: 1h
retryInterval: 1m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./infrastructure/configs
prune: true
Tuż po pierwszym pushu infrastruktury widać, jak etapy czekają na siebie:
$ flux get kustomizations
NAME REVISION SUSPENDED READY MESSAGE
flux-system main@sha1:8f53869a False True Applied revision: main@sha1:8f53869a
infra-configs False False dependency 'flux-system/infra-controllers' is not ready
infra-controllers False Unknown Reconciliation in progress
monitoring False False dependency 'flux-system/infra-configs' is not ready
Znaczenie pól według dokumentacji API Kustomization: interval - jak często Flux buduje i stosuje katalog, przy okazji cofając ręczne zmiany (drift); retryInterval - jak często ponawia nieudaną próbę; prune: true - usuwa z klastra obiekty, które zniknęły z Git; wait: true - czeka na gotowość wszystkich zastosowanych obiektów; timeout - limit czasu na budowanie, stosowanie i sprawdzanie stanu.
Jedna zmiana od git push do nowego poda: oś czasu
Zmieniliśmy w nakładce apps/lab komunikat podinfo i wypchnęliśmy commit, bez ręcznego wywoływania Flux. Czasy pochodzą ze zdarzeń w flux-system i podinfo oraz z pętli curl, która czekała na nowy komunikat:
| Czas (UTC) | Kto | Co się stało |
|---|---|---|
| 09:22:36.739 | administrator | git push commitu 885043f |
| 09:23:27 | source-controller | NewArtifact: pobrał commit (repozytorium odpytuje co interval: 1m0s) |
| 09:23:27 | kustomize-controller | infra-configs: DependencyNotReady, ponowienie za 30 s, bo infra-controllers nie uzgodnił jeszcze nowej rewizji |
| 09:23:28 | kustomize-controller | infra-controllers uzgodniony (1,3 s) |
| 09:23:58 | kustomize-controller | infra-configs uzgodniony; apps i monitoring czekały 30 s na ten etap |
| 09:24:28 | kustomize-controller | apps: utworzył ConfigMap podinfo-config-ckfbfdtt2g, usunął starą podinfo-config-gm4fcbk4cg |
| 09:24:28-09:24:31 | kube-controller-manager | Deployment podinfo: nowy ReplicaSet podinfo-5598684fff skalowany 0 -> 1 -> 2, stary podinfo-64c9544c9d 2 -> 1 -> 0 |
| 09:24:28-09:24:30 | kube-scheduler, kubelet | Nowe pody przydzielone do k8s-worker-1 i k8s-worker-2, obraz już był na węzłach |
| 09:24:33 | kustomize-controller | Health check passed in 5.160763671s |
| 09:24:33.736 | Traefik, pod podinfo | Pierwsza odpowiedź HTTP z nowym komunikatem |
Od pushu do zmiany na produkcji minęło 1 min 57 s, z czego ok. 50 s to odpytywanie repozytorium, a ok. 60 s to oczekiwanie etapów na siebie. Samo wdrożenie podów zajęło 5 s. Opóźnienie odpytywania usuwa webhook z serwera Git do obiektu Receiver notification-controllera albo ręczne flux reconcile source git flux-system. Ten sam commit uzgadniają wszystkie etapy po kolei, bo każdy z nich czyta to samo repozytorium.
Polecenia flux: get all, logs, reconcile, suspend i resume
flux get all -A wypisuje wszystkie obiekty Flux ze stanem. Kolejność bloków odpowiada łańcuchowi zależności: źródło Git, repozytoria Helm, pobrane charty, release'y, Kustomizacje (fragment):
$ flux get all -A
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
flux-system gitrepository/flux-system main@sha1:99e0e4c9 False True stored artifact for revision 'main@sha1:99e0e4c9'
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
flux-system helmrepository/longhorn sha256:1cd78a23 False True stored artifact: revision 'sha256:1cd78a23'
...
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
flux-system helmchart/longhorn-system-longhorn 1.13.0 False True pulled 'longhorn' chart with version '1.13.0'
...
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
longhorn-system helmrelease/longhorn 1.13.0 False True Helm install succeeded for release longhorn-system/longhorn.v1 with chart longhorn@1.13.0
metallb-system helmrelease/metallb 0.16.1 False True Helm upgrade succeeded for release metallb-system/metallb.v2 with chart metallb@0.16.1
...
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
flux-system kustomization/apps main@sha1:99e0e4c9 False True Applied revision: main@sha1:99e0e4c9
Skąd się wziął obiekt i co obejmuje dana Kustomizacja, pokazują flux trace i flux tree. To odpowiednik inwentarza: Flux zapisuje listę obiektów, które utworzył, i na jej podstawie usuwa te, które zniknęły z Git (prune):
$ flux tree kustomization apps
Kustomization/flux-system/apps
├── Namespace/podinfo
├── ConfigMap/podinfo/podinfo-config-ckfbfdtt2g
├── Service/podinfo/podinfo
├── Service/podinfo/valkey
├── Deployment/podinfo/podinfo
├── StatefulSet/podinfo/valkey
├── SealedSecret/podinfo/podinfo-token
├── ExternalSecret/podinfo/podinfo-db
└── Ingress/podinfo/podinfo
$ flux trace podinfo --kind=Deployment --api-version=apps/v1 -n podinfo
Object: Deployment/podinfo
Namespace: podinfo
Status: Managed by Flux
---
Kustomization: apps
Namespace: flux-system
Path: ./apps/lab
Revision: main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6
Status: Last reconciled at 2026-10-12 09:27:47 +0000 UTC
Message: Applied revision: main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6
---
GitRepository: flux-system
Namespace: flux-system
URL: ssh://git@192.168.124.5/srv/git/fleet-infra.git
Branch: main
Revision: main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6
Status: Last reconciled at 2026-10-12 09:27:15 +0000 UTC
Message: stored artifact for revision 'main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6'
Flux cofa ręczne zmiany w obiektach, którymi zarządza. Gdy trzeba je chwilowo zachować (na przykład w trakcie awarii), Kustomizację wstrzymuje się poleceniem flux suspend. Test na liczbie replik podinfo: ręczne skalowanie do 4 replik Flux cofnął do 2 przy najbliższym uzgodnieniu; po flux suspend ręczna zmiana na 3 repliki została, a flux reconcile odmówił pracy; flux resume przywrócił stan z Git.
$ kubectl -n podinfo scale deploy podinfo --replicas=4
deployment.apps/podinfo scaled
$ kubectl -n podinfo get deploy podinfo --no-headers
podinfo 2/4 2 2 29m
$ flux reconcile kustomization apps
...
✔ applied revision main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6
$ kubectl -n podinfo get deploy podinfo --no-headers
podinfo 2/2 2 2 29m
$ flux suspend kustomization apps
► suspending kustomization apps in flux-system namespace
✔ kustomization suspended
$ kubectl -n podinfo scale deploy podinfo --replicas=3
deployment.apps/podinfo scaled
$ flux reconcile kustomization apps
✗ resource is suspended
$ kubectl -n podinfo get deploy podinfo --no-headers
podinfo 2/3 3 2 30m
$ flux resume kustomization apps
...
✔ Kustomization apps reconciliation completed
✔ applied revision main@sha1:99e0e4c9bca955a9e2ab8c2e9b746db66f7b4bd6
$ kubectl -n podinfo get deploy podinfo --no-headers
podinfo 2/2 2 2 30m
flux logs pokazuje logi kontrolerów Flux z filtrem na rodzaj i nazwę obiektu, na przykład flux logs --kind=HelmRelease --name=traefik --namespace=traefik --tail=3. Czyta logi bieżących podów kontrolerów, więc gdy pod został przeniesiony na inny węzeł, starsze wpisy znikają z jego wyniku. W labie właśnie tak zniknęły błędy opisane niżej; zachował je Loki, o czym w następnej sekcji.
$ flux logs --kind=HelmRelease --name=traefik --namespace=traefik --tail=3
2026-10-12T10:35:11.448Z info HelmRelease/traefik.traefik - release out-of-sync with desired state: release config values changed
2026-10-12T10:35:11.523Z info HelmRelease/traefik.traefik - running 'upgrade' action with timeout of 5m0s
2026-10-12T10:35:40.929Z info HelmRelease/traefik.traefik - release in-sync with desired state
Błędny commit: niepoprawny YAML i nieistniejący obraz
Pierwsza wersja zmiany komunikatu miała w pliku kustomization.yaml wiersz - PODINFO_UI_MESSAGE=Zmiana z Git: wersja 2. Dwukropek ze spacją sprawił, że YAML odczytał go jako mapę, a nie tekst, i Kustomize odrzucił cały katalog. Flux niczego nie zastosował, a aplikacja dalej działała na poprzedniej rewizji (REVISION przy apps została na ac41bea2):
$ flux get kustomizations
NAME REVISION SUSPENDED READY MESSAGE
apps main@sha1:ac41bea2 False False error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into Go struct field ConfigMapArgs.configMapGenerator.GeneratorArgs.KvPairSources.literals of type string
flux-system main@sha1:dd6c4387 False True Applied revision: main@sha1:dd6c4387
...
$ kubectl kustomize apps/lab
error: invalid Kustomization: json: cannot unmarshal object into Go struct field ConfigMapArgs.configMapGenerator.GeneratorArgs.KvPairSources.literals of type string
Ten sam błąd zwraca kubectl kustomize apps/lab na stacji administratora, więc wystarczy uruchomić go przed pushem. Poprawka to cudzysłów wokół całej wartości: - "PODINFO_UI_MESSAGE=Zmiana z Git: wersja 2".
Drugi test: commit z tagiem obrazu, którego nie ma w rejestrze (images: newTag: 6.99.0 w nakładce). Deployment utworzył nowy ReplicaSet, którego pod utknął w ImagePullBackOff. Strategia rolling update nie usunęła starych podów, bo nowy nigdy nie był gotowy, więc ruch dalej obsługiwała wersja 6.15.0 (4 z 4 odpowiedzi). Flux zgłosił błąd po upływie timeout: 5m:
$ kubectl -n podinfo get rs,pods -o wide
NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
replicaset.apps/podinfo-5598684fff 0 0 0 10m podinfo ghcr.io/stefanprodan/podinfo:6.15.0 app.kubernetes.io/name=podinfo,pod-template-hash=5598684fff
replicaset.apps/podinfo-5c7cc598d9 2 2 2 8m11s podinfo ghcr.io/stefanprodan/podinfo:6.15.0 app.kubernetes.io/name=podinfo,pod-template-hash=5c7cc598d9
replicaset.apps/podinfo-64c9544c9d 0 0 0 32m podinfo ghcr.io/stefanprodan/podinfo:6.15.0 app.kubernetes.io/name=podinfo,pod-template-hash=64c9544c9d
replicaset.apps/podinfo-76bbf468fc 1 1 0 53s podinfo ghcr.io/stefanprodan/podinfo:6.99.0 app.kubernetes.io/name=podinfo,pod-template-hash=76bbf468fc
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
pod/podinfo-5c7cc598d9-92bzv 1/1 Running 0 8m11s 10.244.4.27 k8s-worker-1 <none> <none>
pod/podinfo-5c7cc598d9-k8sp9 1/1 Running 0 8m9s 10.244.5.23 k8s-worker-2 <none> <none>
pod/podinfo-76bbf468fc-7mrhd 0/1 ImagePullBackOff 0 53s 10.244.4.31 k8s-worker-1 <none> <none>
pod/valkey-0 1/1 Running 0 4m56s 10.244.5.24 k8s-worker-2 <none> <none>
$ flux get kustomizations apps
NAME REVISION SUSPENDED READY MESSAGE
apps main@sha1:99e0e4c9 False False health check failed after 5m0.09651218s: timeout waiting for: [Deployment/podinfo/podinfo status: 'InProgress']
$ git revert --no-edit HEAD
...
1 file changed, 3 deletions(-)
$ git push -q origin main
$ git log --oneline -3
bbec582 Revert "podinfo: aktualizacja do 6.99.0"
d37c48b podinfo: aktualizacja do 6.99.0
99e0e4c podinfo: haslo bazy z OpenBao przez External Secrets
$ flux reconcile kustomization apps --with-source
...
✔ applied revision main@sha1:bbec582f279cb87bdc17e8fa86aa6a808b02e293
W GitOps wycofanie zmiany to kolejny commit (git revert), a nie kubectl rollout undo: ręczne wycofanie w klastrze Flux przy następnym uzgodnieniu nadpisałby znowu błędną wersją z Git.
Grafana Alloy i Loki: logi wszystkich podów w jednym miejscu
Grafana Alloy to kolektor danych telemetrycznych: metryk, logów, śladów i profili. W labie działa jako DaemonSet na workerach i zbiera logi podów ze swojego węzła, a wysyła je do Loki, bazy logów od Grafana Labs. Loki 3.6.11 w trybie SingleBinary zapisuje indeks (pliki TSDB, ang. time series database, w index/) i fragmenty logów, czyli chunki (w fake/, bo przy auth_enabled: false tenant nazywa się fake), w buckecie S3 loki, a dane robocze trzyma na wolumenie Longhorna zamontowanym w /var/loki. Zależności wyglądają tak:
pod podinfo --stdout--> containerd --> /var/log/pods/...
^ kubelet udostępnia logi przez API
Alloy (DaemonSet) --discovery.kubernetes: lista podów z tego węzła (API)
--loki.source.kubernetes: czyta logi podów przez API
--loki.write--> Service loki:3100 --> Loki (PVC Longhorn)
--> S3 bucket "loki" (Garage)
Konfigurację Alloy przekazuje chart z pliku values.yaml (pole alloy.configMap.content). Wzorowaliśmy ją na przykładzie z dokumentacji Alloy; poniżej pełna treść użyta w labie:
discovery.kubernetes "pod" {
role = "pod"
selectors {
role = "pod"
field = "spec.nodeName=" + coalesce(sys.env("HOSTNAME"), constants.hostname)
}
}
discovery.relabel "pod_logs" {
targets = discovery.kubernetes.pod.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "app"
}
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
target_label = "node"
}
}
loki.source.kubernetes "pod_logs" {
targets = discovery.relabel.pod_logs.output
forward_to = [loki.process.pod_logs.receiver]
}
loki.process "pod_logs" {
stage.static_labels {
values = {
cluster = "lab",
}
}
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki.monitoring.svc:3100/loki/api/v1/push"
}
}
Zmienną HOSTNAME chart Alloy 1.13.1 ustawia na nazwę węzła (fieldRef: spec.nodeName), więc każdy pod DaemonSetu obsługuje tylko pody ze swojego workera. Po kilku minutach Loki miał logi z dziesięciu namespace'ów. Zapytanie przez API Loki (port-forward na svc/loki) zwróciło logi startu podinfo z obu workerów:
$ kubectl -n monitoring port-forward svc/loki 13100:3100 &
$ curl -s http://127.0.0.1:13100/loki/api/v1/label/namespace/values | jq -c .data
["debug","external-secrets","flux-system","kube-flannel","kube-system","longhorn-system","metallb-system","monitoring","podinfo","traefik"]
$ curl -s -G http://127.0.0.1:13100/loki/api/v1/query_range \
--data-urlencode 'query={namespace="podinfo", app="podinfo"} |= "Starting"' \
--data-urlencode since=1h --data-urlencode limit=4 \
| jq -r '.data.result[] | .stream as $s | .values[] | "\($s.node) \($s.pod) \(.[1])"' | cut -c1-200
k8s-worker-2 podinfo-5c7cc598d9-k8sp9 {"level":"info","ts":"2026-10-12T09:26:32.614Z","caller":"http/server.go:273","msg":"Starting HTTP Server.","addr":":9898"}
k8s-worker-2 podinfo-5c7cc598d9-k8sp9 {"level":"info","ts":"2026-10-12T09:26:32.611Z","caller":"podinfo/main.go:170","msg":"Starting podinfo","version":"6.15.0","revision":"dd507173b7b75b2312a36cabe0d
k8s-worker-1 podinfo-5c7cc598d9-92bzv {"level":"info","ts":"2026-10-12T09:26:30.459Z","caller":"http/server.go:273","msg":"Starting HTTP Server.","addr":":9898"}
k8s-worker-1 podinfo-5c7cc598d9-92bzv {"level":"info","ts":"2026-10-12T09:26:30.455Z","caller":"podinfo/main.go:170","msg":"Starting podinfo","version":"6.15.0","revision":"dd507173b7b75b2312a36cabe0d
Największa wartość zbierania logów wychodzi po fakcie. Błędy Flux z poprzedniej sekcji zniknęły z flux logs, bo pod kustomize-controller-59469d77f9-jfw8s przestał istnieć przy opróżnianiu węzła. Loki zachował oba:
$ curl -s -G http://127.0.0.1:13100/loki/api/v1/query_range \
--data-urlencode 'query={namespace="flux-system"} |= "health check failed"' \
--data-urlencode start=2026-10-12T09:00:00Z --data-urlencode end=2026-10-12T10:50:00Z \
--data-urlencode limit=1 --data-urlencode direction=forward \
| jq -r '.data.result[0].values[0][1]' | jq -r '[.ts, .level, .msg, .error] | @tsv'
2026-10-12T09:38:47.905Z error Reconciliation failed after 5m0.964320221s, next try in 1m0s health check failed after 5m0.09651218s: timeout waiting for: [Deployment/podinfo/podinfo status: 'InProgress']
Dwa ograniczenia tej konfiguracji: DaemonSet Alloy nie toleruje taintu control plane, więc logi kube-apiserver i etcd zostają poza Loki, a etykieta app jest pusta dla podów Flux, bo te używają etykiety app, a nie app.kubernetes.io/name. Obie rzeczy zmienia się w values.yaml (tolerancje, dodatkowe reguły rule). Szerzej o centralnym zbieraniu logów z serwerów piszemy w artykule Centralne logowanie zdarzeń Linux: Ansible, RELP i OpenSearch.
Diagnostyka poda: describe, events, labels, logs i get -o yaml
Przy problemie z podem kolejność jest zwykle ta sama: kubectl get pods (stan), kubectl describe pod (zdarzenia i powód), kubectl logs (co mówi aplikacja), a na końcu pełny obiekt przez -o yaml albo konkretne pole przez -o jsonpath.
kubectl describe pod: etykiety, właściciel, sondy i zdarzenia
$ kubectl -n podinfo describe pod podinfo-64c9544c9d-rv4q9
Name: podinfo-64c9544c9d-rv4q9
Namespace: podinfo
...
Node: k8s-worker-1/192.168.124.21
...
Labels: app.kubernetes.io/name=podinfo
pod-template-hash=64c9544c9d
...
Status: Running
IP: 10.244.4.19
...
Controlled By: ReplicaSet/podinfo-64c9544c9d
Containers:
podinfo:
...
Image: ghcr.io/stefanprodan/podinfo:6.15.0
Image ID: ghcr.io/stefanprodan/podinfo@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
...
State: Running
...
Ready: True
Restart Count: 0
Limits:
memory: 64Mi
Requests:
cpu: 10m
memory: 32Mi
Liveness: http-get http://:http/healthz delay=0s timeout=1s period=10s successThreshold=1 failureThreshold=3
Readiness: http-get http://:http/readyz delay=0s timeout=1s period=5s successThreshold=1 failureThreshold=2
Environment Variables from:
podinfo-config-gm4fcbk4cg ConfigMap Optional: false
...
QoS Class: Burstable
...
Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 8m20s default-scheduler Successfully assigned podinfo/podinfo-64c9544c9d-rv4q9 to k8s-worker-1
Normal Pulling 8m19s kubelet spec.containers{podinfo}: Pulling image "ghcr.io/stefanprodan/podinfo:6.15.0"
Normal Pulled 8m5s kubelet spec.containers{podinfo}: Successfully pulled image "ghcr.io/stefanprodan/podinfo:6.15.0" in 13.346s (13.347s including waiting). Image size: 34763248 bytes.
Normal Created 8m5s kubelet spec.containers{podinfo}: Container created
Normal Started 8m5s kubelet spec.containers{podinfo}: Container started
Warning Unhealthy 8m5s kubelet spec.containers{podinfo}: Readiness probe failed: Get "http://10.244.4.19:9898/readyz": dial tcp 10.244.4.19:9898: connect: connection refused
Co tu czytać: Labels to etykiety, po których pod wybiera Service (app.kubernetes.io/name=podinfo) i ReplicaSet (pod-template-hash). Controlled By wskazuje właściciela. Image ID to skrót obrazu, który faktycznie działa, niezależnie od tagu. Events to historia z ostatniej godziny: przydział do węzła, pobranie obrazu (13,3 s, 34 763 248 bajtów), start i jedna nieudana sonda readiness tuż po starcie, zanim aplikacja otworzyła port. QoS Class (klasa QoS, ang. Quality of Service) i Tolerations omawiamy niżej. Zdarzenia całego namespace, posortowane w czasie, daje kubectl get events -n podinfo --sort-by=.lastTimestamp.
kubectl logs i kubectl get -o yaml: co mówi aplikacja i jak wygląda pełny obiekt
$ kubectl -n podinfo logs deploy/podinfo --tail=4
Found 2 pods, using pod/podinfo-64c9544c9d-v4h7m
{"level":"info","ts":"2026-10-12T09:02:35.360Z","caller":"podinfo/main.go:170","msg":"Starting podinfo","version":"6.15.0","revision":"dd507173b7b75b2312a36cabe0de5f09c1ce69c8","port":"9898"}
{"level":"info","ts":"2026-10-12T09:02:35.366Z","caller":"http/server.go:273","msg":"Starting HTTP Server.","addr":":9898"}
{"level":"warn","ts":"2026-10-12T09:02:35.404Z","caller":"http/cache.go:161","msg":"cache server is offline","error":"dial tcp: lookup valkey on 10.96.0.10:53: no such host","server":"tcp://valkey:6379"}
$ kubectl -n podinfo logs -l app.kubernetes.io/name=podinfo --prefix --tail=2 --since=30m
[pod/podinfo-64c9544c9d-rv4q9/podinfo] {"level":"info","ts":"2026-10-12T09:02:36.269Z","caller":"http/server.go:273","msg":"Starting HTTP Server.","addr":":9898"}
[pod/podinfo-64c9544c9d-rv4q9/podinfo] {"level":"warn","ts":"2026-10-12T09:02:36.303Z","caller":"http/cache.go:161","msg":"cache server is offline","error":"dial tcp: lookup valkey on 10.96.0.10:53: no such host","server":"tcp://valkey:6379"}
[pod/podinfo-64c9544c9d-v4h7m/podinfo] {"level":"info","ts":"2026-10-12T09:02:35.366Z","caller":"http/server.go:273","msg":"Starting HTTP Server.","addr":":9898"}
[pod/podinfo-64c9544c9d-v4h7m/podinfo] {"level":"warn","ts":"2026-10-12T09:02:35.404Z","caller":"http/cache.go:161","msg":"cache server is offline","error":"dial tcp: lookup valkey on 10.96.0.10:53: no such host","server":"tcp://valkey:6379"}
[pod/podinfo-64c9544c9d-v4h7m/podinfo] {"level":"warn",...,"msg":"cache server is offline",...}
Ostrzeżenie cache server is offline to prawdziwy przykład zależności czasowej: Flux zastosował podinfo i Valkey jednocześnie, podinfo wystartował pierwszy, a Service headless valkey nie miał jeszcze rekordu DNS, bo pod valkey-0 nie był gotowy. podinfo ponawia połączenie co jakiś czas, więc pamięć podręczna zaczęła działać bez restartu. Kubernetes nie ustala kolejności startu między Deploymentem a StatefulSetem; aplikacja musi sama znieść chwilowy brak zależności. Przydatne opcje kubectl logs: -c (kontener w podzie wielokontenerowym), -p (poprzednie uruchomienie kontenera, po restarcie), -f (śledzenie na bieżąco), --since=15m, --tail=N, -l etykieta --prefix (wiele podów naraz).
$ kubectl -n podinfo get pod podinfo-64c9544c9d-rv4q9 -o yaml | grep -nE "^(apiVersion|kind|metadata|spec|status):|ownerReferences|nodeName|podIP:|phase|qosClass"
1:apiVersion: v1
2:kind: Pod
3:metadata:
12: ownerReferences:
21:spec:
65: nodeName: k8s-worker-1
102:status:
168: phase: Running
169: podIP: 10.244.4.19
172: qosClass: Burstable
$ kubectl -n podinfo get pod podinfo-64c9544c9d-rv4q9 -o jsonpath='{.status.containerStatuses[0].imageID}{"\n"}'
ghcr.io/stefanprodan/podinfo@sha256:ec73780a8425f59ea49f5bc8cdff0d598805a224fbaa1f86c67a244f250fa9da
-o yaml pokazuje obiekt tak, jak przechowuje go API: spec (to, czego chcemy) i status (to, co zgłasza klaster). -o jsonpath wyciąga jedno pole i nadaje się do skryptów. Etykiety widać w kubectl get pods --show-labels, a filtruje się po nich opcją -l.
ImagePullBackOff, CrashLoopBackOff, OOMKilled i Pending: co znaczą
W przestrzeni debug uruchomiliśmy pięć podów, z których każdy celowo zawodzi w inny sposób. Status z kubectl get pods i pierwsze polecenie diagnostyczne:
$ kubectl -n debug get pods
NAME READY STATUS RESTARTS AGE
liveness-exec 1/1 Running 2 (39s ago) 3m14s
za-duze-requests 0/1 Pending 0 75s
za-maly-limit 0/1 OOMKilled 0 3m14s
zly-config 0/1 Error 3 (60s ago) 75s
zly-tag 0/1 ImagePullBackOff 0 3m14s
| Status | Co znaczy | Gdzie szukać przyczyny |
|---|---|---|
ErrImagePull, ImagePullBackOff | kubelet nie pobrał obrazu (zły tag, brak dostępu do rejestru, limit pobrań). Ponawia z rosnącą przerwą, maksymalnie co 5 minut. | describe pod, zdarzenia Failed |
CrashLoopBackOff | Kontener startuje i kończy się błędem; kubelet restartuje go z rosnącą przerwą. | logs -p (poprzednie uruchomienie), kod wyjścia w describe |
OOMKilled | Proces przekroczył limit pamięci kontenera i jądro go zabiło (kod wyjścia 137). | describe pod: Last State, Reason: OOMKilled |
Pending | Scheduler nie znalazł węzła albo czeka na wolumen. | describe pod: zdarzenie FailedScheduling |
Running, ale 0/1 gotowych | Kontener działa, ale nie przechodzi sondy readiness; pod nie dostaje ruchu. | describe pod: Readiness probe failed |
$ kubectl -n debug describe pod zly-tag | sed -n '/^Events:/,$p'
Normal Pulling 51s (x3 over 100s) kubelet spec.containers{app}: Pulling image "ghcr.io/stefanprodan/podinfo:6.99.0"
Warning Failed 50s (x3 over 94s) kubelet spec.containers{app}: Failed to pull image "ghcr.io/stefanprodan/podinfo:6.99.0": rpc error: code = NotFound desc = failed to pull and unpack image "ghcr.io/stefanprodan/podinfo:6.99.0": failed to resolve image: ghcr.io/stefanprodan/podinfo:6.99.0: not found
Warning Failed 50s (x3 over 94s) kubelet spec.containers{app}: Error: ErrImagePull
Normal BackOff 13s (x5 over 93s) kubelet spec.containers{app}: Back-off pulling image "ghcr.io/stefanprodan/podinfo:6.99.0"
Warning Failed 13s (x5 over 93s) kubelet spec.containers{app}: Error: ImagePullBackOff
$ kubectl -n debug logs zly-config -p
BLAD: brak zmiennej DATABASE_URL
$ kubectl -n debug get pod zly-config -o jsonpath='{.status.containerStatuses[0].state}'
{"waiting":{"message":"back-off 2m40s restarting failed container=app pod=zly-config_debug(f56f0286-ec5d-4313-a1e9-f98ce8140d79)","reason":"CrashLoopBackOff"}}
$ kubectl -n debug describe pod za-maly-limit | grep -E -A4 "^\s+State:"
State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Mon, 12 Oct 2026 09:11:22 +0000
Finished: Mon, 12 Oct 2026 09:11:23 +0000
$ kubectl -n debug describe pod za-duze-requests | sed -n '/^Events:/,$p'
Warning FailedScheduling 76s default-scheduler 0/5 nodes are available: 2 Insufficient memory, 3 node(s) had untolerated taint(s). preemption: 0/5 nodes are available: 5 Preemption is not helpful for scheduling.
Ostatni komunikat dobrze pokazuje, jak scheduler myśli: pod żąda 8 GiB pamięci (requests.memory: 8Gi), dwa workery mają za mało miejsca, a trzy węzły control plane odpadają przez taint. Wywłaszczenie innych podów nic by nie dało. Status CrashLoopBackOff przeplata się z Error i Running: kubectl get pods pokazuje stan z chwili wywołania, a obserwację przebiegu daje kubectl get pod zly-config -w. kubectl logs pokazuje bieżące uruchomienie kontenera, a kubectl logs -p poprzednie, zakończone błędem; w labie zaraz po restarcie -p zwracał jeszcze unable to retrieve container logs, a po kilkunastu sekundach właściwy komunikat.
Requests, limits i sondy: konfiguracja dopasowana do realiów
requests to ilość CPU i pamięci, którą scheduler rezerwuje dla kontenera przy wyborze węzła. limits to górna granica: przekroczenie limitu CPU kończy się dławieniem (throttling), a przekroczenie limitu pamięci zabiciem procesu przez jądro, jak w przykładzie OOMKilled wyżej. Tak opisuje to dokumentacja zarządzania zasobami. Klasa QoS wynika z tych ustawień (opis klas QoS): Guaranteed, gdy każdy kontener ma requests równe limits dla CPU i pamięci; Burstable, gdy ma jakiekolwiek requests albo limits, ale nie spełnia warunków Guaranteed (tak jest w podinfo); BestEffort, gdy nie ma żadnych. Przy braku zasobów na węźle Kubernetes usuwa najpierw pody BestEffort, potem Burstable, a na końcu Guaranteed.
Wartości dobiera się z pomiaru, nie z przykładów z internetu. metrics-server dostarcza bieżące zużycie dla kubectl top, a k9s pokazuje je od razu jako procent requests i limits:
$ kubectl top pods -n podinfo
NAME CPU(cores) MEMORY(bytes)
podinfo-849cfc656f-smx74 1m 13Mi
podinfo-849cfc656f-xtfr5 2m 13Mi
valkey-0 14m 4Mi
$ kubectl get pods -n podinfo -o custom-columns=POD:.metadata.name,REQ_CPU:.spec.containers[0].resources.requests.cpu,REQ_MEM:.spec.containers[0].resources.requests.memory,LIM_MEM:.spec.containers[0].resources.limits.memory,QOS:.status.qosClass
POD REQ_CPU REQ_MEM LIM_MEM QOS
podinfo-849cfc656f-smx74 10m 32Mi 64Mi Burstable
podinfo-849cfc656f-xtfr5 10m 32Mi 64Mi Burstable
valkey-0 10m 32Mi 128Mi Burstable
podinfo zużywa 13Mi z zarezerwowanych 32Mi, więc ma zapas. Valkey zużywał 11-14m CPU przy requests 10m, czyli więcej, niż zarezerwował; k9s pokazał to w kolumnie %CPU/R jako 110%. Przy obciążonym węźle taki kontener może dostać mniej CPU, niż potrzebuje: według dokumentacji requests CPU działa jak waga, więc przy rywalizacji o procesor kontenery z większym requests dostają więcej czasu. Drugi wniosek widać na poziomie węzła:
$ kubectl describe node k8s-worker-1 | sed -n '/Allocated resources/,/Events/p'
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 990m (49%) 4 (200%)
memory 1036Mi (31%) 5248Mi (158%)
Suma limitów pamięci (5248Mi) przekracza pamięć workera (3,2 GiB do przydziału), a suma requests to 31%. Każdy kontener z osobna mieści się w limicie, ale gdy kilka naraz zużyje pamięć blisko swoich limitów, zabraknie jej na węźle: kubelet zacznie usuwać pody (eviction przy presji zasobów), a przy gwałtownym skoku procesy zabije jądro (OOM killer, ang. out-of-memory killer). Realne limity to takie, których suma ma pokrycie w pamięci węzła, a nie tylko w ustawieniach poszczególnych kontenerów. Na węzłach control plane z 2 GiB RAM największym konsumentem jest sam kube-apiserver (547-783Mi zmierzone kubectl top pods -n kube-system), co warto uwzględnić przy doborze wielkości maszyn.
Sondy readiness, liveness i startup
Według dokumentacji sond Kubernetes 1.37: readiness decyduje, czy pod ma dostawać ruch (porażka usuwa jego adres z EndpointSlice, bez restartu); liveness decyduje, czy kontener trzeba zrestartować (na przykład przy zakleszczeniu); startup wstrzymuje obie pozostałe sondy, dopóki aplikacja nie skończy startu. podinfo ma endpoint /readyz/disable, który przełącza gotowość. Wyłączyliśmy ją w jednym podzie:
$ kubectl -n podinfo port-forward pod/podinfo-64c9544c9d-rv4q9 19898:9898 &
$ curl -s -X POST http://127.0.0.1:19898/readyz/disable -o /dev/null -w "%{http_code}\n"
202
$ kubectl -n podinfo get pods -l app.kubernetes.io/name=podinfo -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
podinfo-64c9544c9d-rv4q9 0/1 Running 0 16m 10.244.4.19 k8s-worker-1 <none> <none>
podinfo-64c9544c9d-v4h7m 1/1 Running 0 16m 10.244.5.21 k8s-worker-2 <none> <none>
$ kubectl -n podinfo get endpointslices -l kubernetes.io/service-name=podinfo \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} ready={.conditions.ready} serving={.conditions.serving} {.targetRef.name}{"\n"}{end}'
10.244.5.21 ready=true serving=true podinfo-64c9544c9d-v4h7m
10.244.4.19 ready=false serving=false podinfo-64c9544c9d-rv4q9
$ for i in 1 2 3 4 5 6; do curl -s --resolve podinfo.k8s.lab:80:192.168.124.200 http://podinfo.k8s.lab/ | jq -r .hostname; done | sort | uniq -c
6 podinfo-64c9544c9d-v4h7m
Pod nadal działał (Running, 0 restartów), ale wszystkie 6 żądań trafiło do drugiej repliki. Sondę liveness sprawdziliśmy na przykładzie z dokumentacji: kontener usuwa plik /tmp/healthy po 30 sekundach, a sonda cat /tmp/healthy zaczyna zawodzić:
$ kubectl -n debug describe pod liveness-exec | sed -n '/^Events:/,$p'
...
Warning Unhealthy 51s (x3 over 61s) kubelet spec.containers{app}: Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory
Normal Killing 51s kubelet spec.containers{app}: Container app failed liveness probe, will be restarted
Normal Created 21s (x2 over 96s) kubelet spec.containers{app}: Container created
Normal Started 21s (x2 over 95s) kubelet spec.containers{app}: Container started
Liveness ma sprawdzać to, czemu restart naprawdę pomoże. Strona o konfiguracji sond ostrzega, że źle zaprojektowana sonda liveness prowadzi do kaskadowych awarii. Przykład: sonda liveness zależna od bazy danych przy awarii bazy zrestartuje naraz wszystkie repliki aplikacji, choć restart niczego nie naprawi. Dostępność zależności lepiej sprawdzać w readiness.
Dlaczego nie używać tagu latest
Dokumentacja obrazów Kubernetes 1.37 zaleca unikanie :latest w produkcji, bo trudniej ustalić, która wersja działa, i trudniej poprawnie wycofać zmianę. Dla tagu latest (albo bez tagu) domyślne imagePullPolicy to Always, a dla konkretnego tagu IfNotPresent. W GitOps dochodzi trzeci powód: Git przestaje opisywać stan klastra. Commit z image: app:latest nie zmienia się, gdy w rejestrze pojawi się nowy obraz, więc Flux nie ma czego wdrożyć, a dwa pody tej samej aplikacji mogą działać na różnych obrazach, zależnie od tego, kiedy węzeł pobrał tag. W labie każda wersja ma konkretny tag w Git (podinfo:6.15.0, valkey:9.1.2-alpine), a to, co faktycznie działa, potwierdza skrót Image ID z describe. Jeszcze ściślejsze jest wskazanie obrazu skrótem: image: ghcr.io/stefanprodan/podinfo@sha256:ec73780a....
PodDisruptionBudget i rozkład replik: opróżnianie węzłów bez przerwy w działaniu
Dwie repliki nie wystarczą, jeśli stoją na jednym węźle. Przy aktualizacji workerów kubectl drain k8s-worker-1 usunął naraz oba pody podinfo i jedyny pod Traefika, bo wszystkie były na tym węźle i żaden nie miał PodDisruptionBudget (PDB). Pętla curl zanotowała przerwy w dostępności aplikacji:
przerwa aplikacji: 10:21:33 - 10:22:22 drain k8s-worker-1 (ok. 50 s)
przerwa aplikacji: 10:24:39 - 10:25:02 drain k8s-worker-2 (ok. 23 s)
PDB ogranicza liczbę podów, które można jednocześnie usunąć dobrowolnie, czyli przez kubectl drain, aktualizację węzła czy autoskalowanie (opis zakłóceń i PDB). Drain używa Eviction API i czeka, aż PDB pozwoli usunąć kolejny pod. Dodaliśmy więc PDB z minAvailable: 1 dla podinfo i Traefika oraz reguły rozkładu replik na węzły:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: podinfo
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: podinfo
---
# fragment Deployment podinfo (spec.template.spec)
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
nodeTaintsPolicy: Honor
labelSelector:
matchLabels:
app.kubernetes.io/name: podinfo
matchLabelKeys:
- pod-template-hash
Każde z trzech pól ostatniego bloku wynikało z pomiaru, nie z założeń. whenUnsatisfiable: ScheduleAnyway (preferencja) nie wystarczył: scheduler i tak postawił obie repliki na jednym workerze. nodeTaintsPolicy: Honor sprawia, że węzeł po kubectl cordon nie liczy się do rozkładu; według kubectl explain domyślne zachowanie to Ignore, przy którym zablokowana replika nie mogłaby przejść na drugi węzeł. matchLabelKeys: [pod-template-hash] liczy rozkład tylko wśród podów tej samej wersji: bez tego przy rolling update stare pody przechylały rachunek i obie nowe repliki lądowały na jednym węźle. Pole jest w Kubernetes 1.37 w wersji beta, włączone domyślnie (MatchLabelKeysInPodTopologySpread). Po tych zmianach powtórzyliśmy opróżnienie obu workerów przy działającej pętli pomiarowej; jej podsumowanie:
pomiarów: 120, od 10:31:04 do 10:33:34, app!=200: 0, api!=ok: 0
Opróżnienie obu workerów po kolei (45 s i 40 s) przeszło bez ani jednej nieudanej odpowiedzi aplikacji i API. Kubernetes nie przenosi jednak podów z powrotem po kubectl uncordon: po drugim drainie wszystkie repliki stały na jednym workerze, aż do kubectl rollout restart. StatefulSet Valkey z jedną repliką i wolumenem RWO zawsze ma przerwę przy przenosinach (w labie ok. 30 s); dostępność bez przerw wymaga replikacji po stronie samej aplikacji.
Narzędzia administratora: k9s, Pluto i kind
k9s: terminalowy panel do pracy z klastrem
k9s (wersja 0.51.0) to interfejs tekstowy w terminalu, który na bieżąco obserwuje klaster i pozwala przechodzić między zasobami jak w menedżerze plików. Skróty widać w nagłówku: d opis, l logi, p logi poprzedniego uruchomienia, y YAML, shift-f przekierowanie portu. Zrzut z labu w trakcie wdrożenia błędnego tagu (ramki okna zastąpiliśmy znakami ASCII, kolory pominęliśmy):
Context: kubernetes-admin@lab [R] <0> all <d> Describe <o> Show Node ____ __ ________
Cluster: lab <1> podinfo <?> Help <f> Show PortForward | |/ / __ \______
User: kubernetes-admin <2> default <shift-j> Jump Owner <w> Warp To Namespace | /\____ / ___/
K9s Rev: v0.51.0 <l> Logs <y> YAML | \ \ / /\___ \
K8s Rev: v1.37.0 <p> Logs Previous |____|\__ \/____//____ /
CPU: 25% <shift-f> Port-Forward \/ \/
MEM: 62%
+------------------------------------------------------------ pods(podinfo)[4] ------------------------------------------------------------+
| NAME↑ PF READY STATUS RESTARTS CPU %CPU/R %CPU/L MEM %MEM/R %MEM/L IP NODE AGE |
| podinfo-5c7cc598d9-92bzv ● 1/1 Running 0 1 10 n/a 15 48 24 10.244.4.27 k8s-worker-1 8m35s |
| podinfo-5c7cc598d9-k8sp9 ● 1/1 Running 0 1 10 n/a 14 44 22 10.244.5.23 k8s-worker-2 8m33s |
| podinfo-76bbf468fc-7mrhd ● 0/1 ImagePullBackOff 0 0 0 n/a 0 0 0 10.244.4.31 k8s-worker-1 77s |
| valkey-0 ● 1/1 Running 0 11 110 n/a 8 25 6 10.244.5.24 k8s-worker-2 5m20s |
...
Kolumny %CPU/R, %MEM/R i %MEM/L to zużycie względem requests i limitów, przydatne przy doborze wartości z poprzedniej sekcji. Rodzaj zasobu wybiera się dwukropkiem i nazwą, jak w vim; działa to także dla zasobów z CRD, na przykład :kustomizations i :helmreleases Flux (zrzut niżej). Klawisz 0 pokazuje wszystkie namespace'y. Opcja --readonly włącza tryb tylko do odczytu (w nagłówku pojawia się [R]), co przydaje się przy oglądaniu produkcji.
+--------------------------------------------------------- kustomizations(all)[5] ---------------------------------------------------------+
| NAMESPACE NAME↑ READY STATUS AGE |
| flux-system apps Unknown Reconciliation in progress 33m |
| flux-system flux-system True Applied revision: main@sha1:d37c48b1d5dd3dc008ca2cc40b78159d3f67dabf 52m |
| flux-system infra-configs True Applied revision: main@sha1:d37c48b1d5dd3dc008ca2cc40b78159d3f67dabf 46m |
| flux-system infra-controllers True Applied revision: main@sha1:d37c48b1d5dd3dc008ca2cc40b78159d3f67dabf 46m |
| flux-system monitoring True Applied revision: main@sha1:d37c48b1d5dd3dc008ca2cc40b78159d3f67dabf 46m |
...
Pluto: wykrywanie przestarzałych i usuniętych wersji API przed aktualizacją
Kubernetes co kilka wersji usuwa stare wersje API (na przykład extensions/v1beta1 dla Ingress zniknęło w 1.22). Manifest z usuniętym API po aktualizacji klastra przestaje się wdrażać. Pluto 5.24.4 szuka takich wersji w plikach, w release'ach Helm i w obiektach klastra. Sprawdziliśmy go na czterech starych manifestach:
$ pluto detect-files -d examples/old-manifests -o wide -t k8s=v1.37.0
NAME NAMESPACE KIND VERSION REPLACEMENT DEPRECATED DEPRECATED IN REMOVED REMOVED IN REPL AVAIL REPL AVAIL IN
raport-nocny <UNKNOWN> CronJob batch/v1beta1 batch/v1 true v1.21.0 true v1.25.0 true v1.21.0
sklep <UNKNOWN> HorizontalPodAutoscaler autoscaling/v2beta2 autoscaling/v2 true v1.23.0 true v1.26.0 false
stary-sklep <UNKNOWN> Ingress extensions/v1beta1 networking.k8s.io/v1 true v1.14.0 true v1.22.0 true v1.19.0
sklep <UNKNOWN> PodDisruptionBudget policy/v1beta1 policy/v1 true v1.21.0 true v1.25.0 true v1.21.0
$ echo $?
4
Opcję -t k8s=v1.37.0 trzeba podawać zawsze. Bez niej Pluto 5.24.4 porównuje z domyślną wersją docelową v1.25.0 z pliku versions.yaml projektu, więc HorizontalPodAutoscaler (HPA) autoscaling/v2beta2, usunięty w 1.26, był oznaczony jako REMOVED false. Kod wyjścia mówi, co znaleziono: według pluto --help 2 oznacza wersje przestarzałe, 3 usunięte, a 4 przestarzałe bez dostępnego zamiennika; 0 oznacza brak znalezisk. Na repozytorium fleet-infra, na release'ach Helm i na obiektach w klastrze wynik był czysty:
$ pluto detect-files -d ~/fleet-infra -o wide
There were no resources found with known deprecated apiVersions.
$ pluto detect-helm -o wide -t k8s=v1.37.0
There were no resources found with known deprecated apiVersions.
$ pluto detect-api-resources -o wide -t k8s=v1.37.0
There were no resources found with known deprecated apiVersions.
detect-helm czyta manifesty zapisane w release'ach Helm, a detect-api-resources sprawdza obiekty klastra po wersji API, którą je utworzono; API serwera samo tej informacji nie pokazuje, bo zwraca obiekt w wersji, o którą się pyta. Pluto uruchamia się przed każdą aktualizacją wersji minor, najlepiej także w CI (ang. continuous integration) dla repozytorium Flux.
kind: klaster Kubernetes w kontenerach Dockera do nauki
kind (ang. Kubernetes in Docker) uruchamia węzły Kubernetes jako kontenery Dockera. Taki klaster powstaje w minutę i znika jednym poleceniem, więc nadaje się do nauki, testów manifestów i CI. Na k8s-admin (Docker 29.1.3):
$ kind create cluster --name nauka
Creating cluster "nauka" ...
• Ensuring node image (kindest/node:v1.37.0) 🖼️ ...
✓ Ensuring node image (kindest/node:v1.37.0) 🖼️
• Preparing nodes 📦 ...
✓ Preparing nodes 📦
• Writing configuration 📜 ...
✓ Writing configuration 📜
• Starting control-plane 🕹️ ...
✓ Starting control-plane 🕹️
• Installing CNI 🔌 ...
✓ Installing CNI 🔌
• Installing StorageClass 💾 ...
✓ Installing StorageClass 💾
Set kubectl context to "kind-nauka"
You can now use your cluster with:
kubectl cluster-info --context kind-nauka
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
nauka-control-plane NotReady control-plane 18s v1.37.0 172.18.0.2 <none> Debian GNU/Linux 13 (trixie) 6.8.0-146-generic (amd64) containerd://2.3.4
$ docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
NAMES IMAGE STATUS
nauka-control-plane kindest/node:v1.37.0 Up 23 seconds
$ kind delete cluster --name nauka
Deleting cluster "nauka" ...
Deleted nodes: ["nauka-control-plane"]
Tworzenie zajęło 1 min 25 s, w tym pobranie obrazu węzła. kind od razu przełączył bieżący kontekst kubectl na kind-nauka, co dobrze pokazuje, czemu przed poleceniem zmieniającym stan sprawdza się kontekst. Węzeł kind jest kontenerem z obrazem Debian 13 i własnym containerd 2.3.4, działa na jądrze hosta (6.8.0-146-generic), ma własny CNI (kindnet) i provisioner local-path dla PVC. Według dokumentacji konfiguracji kind w pliku konfiguracyjnym można zadeklarować kilka węzłów, także kilka węzłów control-plane, żeby przetestować wysoką dostępność. Ograniczenia: węzły dzielą jądro i zasoby jednego hosta, więc testów awarii sprzętu, Longhorna z iSCSI czy wydajności kind nie zastąpi; do tego służy lab na VM.
Terraform: dodawanie, usuwanie i aktualizacja węzłów klastra
Terraform opisuje maszyny, na których działa Kubernetes. W labie jedna mapa w variables.tf (albo terraform.tfvars) definiuje wszystkie węzły, a rola węzła wybiera szablon cloud-init: dla workerów doinstalowuje open-iscsi i nfs-common, ładuje moduły dla Longhorna, wyklucza dyski Longhorna z multipathd i montuje dysk danych. Pakiety Kubernetes są przypięte do wersji klastra i wstrzymane (apt-mark hold). Podstawy narzędzia opisuje artykuł Terraform od podstaw.
nodes = {
"k8s-admin" = { role = "admin", ip = "192.168.124.5", memory_mib = 2560, disk_gib = 30 }
"k8s-cp-1" = { role = "control-plane", ip = "192.168.124.11" }
"k8s-cp-2" = { role = "control-plane", ip = "192.168.124.12" }
"k8s-cp-3" = { role = "control-plane", ip = "192.168.124.13" }
"k8s-worker-1" = { role = "worker", ip = "192.168.124.21", memory_mib = 3584, disk_gib = 30, data_disk_gib = 20 }
"k8s-worker-2" = { role = "worker", ip = "192.168.124.22", memory_mib = 3584, disk_gib = 30, data_disk_gib = 20 }
"k8s-worker-3" = { role = "worker", ip = "192.168.124.23", memory_mib = 3584, disk_gib = 30, data_disk_gib = 20 }
}
Dodanie workera: terraform plan, apply i kubeadm join
$ terraform plan -out=tfplan-worker3
# libvirt_cloudinit_disk.seed["k8s-worker-3"] will be created
# libvirt_domain.node["k8s-worker-3"] will be created
# libvirt_network.k8s will be updated in-place
# libvirt_volume.data["k8s-worker-3"] will be created
# libvirt_volume.os["k8s-worker-3"] will be created
# terraform_data.os_resize["k8s-worker-3"] will be created
Plan: 5 to add, 1 to change, 0 to destroy.
$ terraform show -no-color tfplan-worker3
...
~ resource "libvirt_network" "k8s" {
~ dns {
+ hosts {
+ hostname = "k8s-worker-3"
+ ip = "192.168.124.23"
...
$ terraform apply tfplan-worker3
Plan dodaje tylko zasoby nowego węzła (dysk systemowy, jego powiększenie, dysk Longhorna, dysk cloud-init, VM) i dopisuje rekord DNS w sieci libvirt. apply trwał 40 s, a cloud-init kilka minut, bo aktualizuje system i restartuje maszynę. Do klastra węzeł dołącza się świeżym tokenem; token z kubeadm init jest ważny 24 godziny:
# kubeadm token create --print-join-command --ttl 1h # na k8s-cp-1
kubeadm join 192.168.124.10:6443 --token <TOKEN> --discovery-token-ca-cert-hash sha256:<HASH>
# kubeadm join 192.168.124.10:6443 --token <TOKEN> --discovery-token-ca-cert-hash sha256:<HASH> # na k8s-worker-3
This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.
$ kubectl get nodes -o wide | grep -E "^NAME|k8s-worker-3"
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
k8s-worker-3 Ready worker 53s v1.37.0 192.168.124.23 <none> Ubuntu 24.04.5 LTS 6.8.0-146-generic (amd64) containerd://2.2.1
Po 53 s węzeł był gotowy, DaemonSety uruchomiły na nim swoje pody, a Longhorn zarejestrował dysk 20 GiB (kubectl -n longhorn-system get nodes.longhorn.io). Przypięta wersja pakietów miała tu znaczenie: repozytorium pkgs.k8s.io miało już 1.37.1, a polityka różnic wersji zabrania, by kubelet był nowszy niż kube-apiserver (może być starszy o najwyżej trzy wersje minor).
Usunięcie workera bez utraty danych
$ kubectl drain k8s-worker-3 --ignore-daemonsets --delete-emptydir-data
node/k8s-worker-3 cordoned
Warning: ignoring DaemonSet-managed Pods: kube-flannel/kube-flannel-ds-hf7hj, kube-system/kube-proxy-x4dcs, ...
evicting pod longhorn-system/instance-manager-c8b97df1b69798610fa8b805a97aa6f4
pod/instance-manager-c8b97df1b69798610fa8b805a97aa6f4 evicted
node/k8s-worker-3 drained
$ kubectl -n longhorn-system get replicas.longhorn.io -o custom-columns=WEZEL:.spec.nodeID --no-headers | sort | uniq -c
2 k8s-worker-1
2 k8s-worker-2
$ kubectl delete node k8s-worker-3
node "k8s-worker-3" deleted
# kubeadm reset -f # na k8s-worker-3
$ kubectl -n longhorn-system delete nodes.longhorn.io k8s-worker-3
The request is invalid: : could not delete node k8s-worker-3 with node ready condition is False, reason is KubernetesNodeGone, node schedulable true, and 0 replica, 0 engine running on it
$ kubectl -n longhorn-system patch nodes.longhorn.io k8s-worker-3 --type merge -p '{"spec":{"allowScheduling":false}}'
node.longhorn.io/k8s-worker-3 patched
$ terraform apply # mapa bez k8s-worker-3
Plan: 0 to add, 1 to change, 5 to destroy.
...
Apply complete! Resources: 0 added, 1 changed, 5 destroyed.
Kolejność ma znaczenie: najpierw opróżnienie węzła i sprawdzenie, że Longhorn nie trzyma na nim żadnej repliki (tu: 0, wszystkie na worker-1 i worker-2), potem usunięcie z Kubernetes i dopiero na końcu usunięcie VM. Longhorn trzyma własny obiekt węzła; jego webhook odrzucił usunięcie, dopóki węzeł miał włączone planowanie replik, a po ustawieniu allowScheduling: false Longhorn sam usunął obiekt.
Dwie pułapki Terraform w tym labie. Provider dmacvicar/libvirt 0.8.3 zapisuje w stanie disk.volume_id zamiast disk.file, więc każdy plan pokazywał zmianę wszystkich sześciu VM; pomogło lifecycle { ignore_changes = [disk] }. Druga pułapka: poprawka w szablonie cloud-init zmienia dysk cloud-init, a to wymusiłoby odtworzenie wszystkich węzłów, czyli także członków etcd. cloud-init działa tylko przy pierwszym starcie, więc dysk ma ignore_changes = [user_data, network_config]: nowa wersja szablonu trafia tylko do nowych węzłów.
Aktualizacja Kubernetes 1.37.0 do 1.37.1 przez kubeadm
Klaster na VM aktualizuje się w miejscu, według procedury kubeadm dla 1.37: najpierw pierwszy control plane (kubeadm upgrade apply), potem kolejne (kubeadm upgrade node), na końcu workery. Na każdym węźle po kolei: drain, aktualizacja pakietów kubelet i kubectl, restart kubeleta, uncordon. Wersji minor nie wolno przeskakiwać (1.36 do 1.38), a przed aktualizacją wersji minor sprawdza się manifesty Pluto.
# apt-mark unhold kubeadm && apt-get install -y kubeadm=1.37.1-1.1 && apt-mark hold kubeadm # na k8s-cp-1
# kubeadm upgrade plan
COMPONENT NODE CURRENT TARGET
kube-apiserver k8s-cp-1 v1.37.0 v1.37.1
kube-apiserver k8s-cp-2 v1.37.0 v1.37.1
...
kube-proxy 1.37.0 v1.37.1
CoreDNS v1.14.6 v1.14.6
etcd k8s-cp-1 3.7.0-0 3.7.0-0
...
You can now apply the upgrade by executing the following command:
kubeadm upgrade apply v1.37.1
# kubeadm upgrade apply v1.37.1 --yes
...
[upgrade/addon] Skipping upgrade of addons because control plane instances [k8s-cp-2 k8s-cp-3] have not been upgraded
[upgrade] SUCCESS! A control plane node of your cluster was upgraded to "v1.37.1".
upgrade apply trwał 2 min 3 s. kube-proxy i CoreDNS kubeadm zaktualizował dopiero przy ostatnim control plane ([addons] Applied essential addon: kube-proxy w wyniku kubeadm upgrade node na k8s-cp-3). Workery mogą chwilowo zostać na starszej wersji, bo polityka różnic wersji na to pozwala. Pętla pomiarowa pokazała jedną przerwę API: 10:05:31-10:05:50, w trakcie kubeadm upgrade node na k8s-cp-3, który trzymał wtedy VIP. kubeadm podmienił tam manifest kube-apiservera, lokalny apiserver przestał odpowiadać, a kube-vip, który odnawia lease przez lokalny apiserver, stracił przywództwo i zrestartował się (nowy start w logu o 10:05:51). Adres przejął k8s-cp-2 o 10:05:46. Przed aktualizacją control plane warto sprawdzić, kto trzyma VIP (kubectl get lease -n kube-system plndr-cp-lock), i liczyć się z kilkunastoma sekundami przerwy API przy tym węźle.
Przy workerach drain dwa razy odmówił pracy i oba komunikaty warto znać:
error: unable to drain node "k8s-worker-1" due to error: cannot delete Pods that declare no controller (use --force to override): debug/liveness-exec, debug/zly-config, debug/zly-tag, continuing command...
error when evicting pods/"csi-provisioner-768bdcfcbd-7kbwf" -n "longhorn-system" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
...
error: unable to drain node "k8s-worker-2" due to error: error when evicting pods/"csi-provisioner-768bdcfcbd-7kbwf" -n "longhorn-system": global timeout reached: 10m0s, continuing command...
Pierwszy: pody utworzone bez kontrolera (nasze testowe w debug) po usunięciu nie wrócą, więc drain wymaga ich ręcznego usunięcia albo --force. Drugi to opisany wyżej skutek zmniejszenia replik CSI Longhorna do 1 przy PDB minAvailable: 1. Nasz skrypt aktualizacji nie sprawdzał kodu wyjścia kubectl drain i po nieudanym drainie zaktualizował kubelet na nieopróżnionym węźle; skrypt powinien w takiej sytuacji przerwać pracę. Po poprawkach drain workerów trwał 2 min 42 s i 1 min 36 s. Najdłużej czekał na pod instance-manager Longhorna: według opisu konserwacji węzłów w dokumentacji Longhorna ustawienie Node Drain Policy (domyślnie "Block If Contains Last Replica") wstrzymuje jego usunięcie, dopóki wolumeny nie zostaną odłączone, a na innych węzłach nie będzie zdrowych replik. Te poprawne draine i tak przerwały działanie aplikacji; przyczynę i rozwiązanie opisuje sekcja o PodDisruptionBudget.
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-cp-1 Ready control-plane 121m v1.37.1
k8s-cp-2 Ready control-plane 119m v1.37.1
k8s-cp-3 Ready control-plane 117m v1.37.1
k8s-worker-1 Ready worker 117m v1.37.1
k8s-worker-2 Ready worker 117m v1.37.1
Kiedy Terraform aktualizuje control plane i workery sam
Aktualizacja w miejscu przez kubeadm to jeden model. W drugim Terraform wymienia maszyny: tworzy nowy węzeł z nowszym obrazem i wersją, dołącza go do klastra, opróżnia stary i usuwa go z mapy. To ta sama sekwencja co dodanie i usunięcie workera wyżej, powtórzona dla każdego węzła; dla control plane trzeba pilnować, by etcd w każdej chwili miał większość (najpierw dodać nowego członka, potem usunąć starego). W klastrach zarządzanych, na przykład GKE, Terraform opisuje wersję wprost w zasobach providera google (wersja 8.6.0 z 6 października 2026 r.): min_master_version w google_container_cluster i version w google_container_node_pool, a tempo wymiany węzłów ustala blok upgrade_settings (max_surge, max_unavailable). Opróżnianie węzłów wykonuje wtedy dostawca, ale PDB i rozkład replik działają tak samo jak w labie, więc to od nich zależy, czy aktualizacja przejdzie bez przerwy w działaniu aplikacji.
Ściągawka poleceń: kubectl, kubectx, flux, helm, k9s, pluto, kind i narzędzia węzła
Każde polecenie z tabel uruchomiliśmy w labie opisanym wyżej. Wielkimi literami zaznaczono wartości do podmiany. Polecenia kubectl, flux, helm, kubeseal, pluto, kind i k9s uruchamia się na stacji administratora; kubeadm, iscsiadm i multipath na wskazanym węźle jako root.
Kontekst, namespace i uprawnienia
| Polecenie | Co robi |
|---|---|
kubectl config get-contexts | Lista kontekstów z kubeconfig; gwiazdka oznacza bieżący, ostatnia kolumna domyślny namespace. |
kubectl config view | kubeconfig bez danych binarnych (DATA+OMITTED); --raw pokazuje wszystko, łącznie z kluczem. |
kubectx, kubectx NAZWA, kubectx - | Lista kontekstów, przełączenie, powrót do poprzedniego. |
kubectx NOWA=STARA, kubectx -c | Zmiana nazwy kontekstu, nazwa bieżącego kontekstu. |
kubens, kubens NAMESPACE, kubens -c | Lista namespace'ów, ustawienie domyślnego w bieżącym kontekście, bieżący namespace. |
kubectl auth whoami | Użytkownik i grupy, jakie API widzi dla bieżących poświadczeń. |
kubectl auth can-i '*' '*' --all-namespaces | Czy bieżący użytkownik może wszystko (yes/no); zamiast gwiazdek konkretny czasownik i zasób. |
kubectl cluster-info, kubectl version | Adres API i CoreDNS; wersje klienta, wbudowanego Kustomize i serwera. |
Przegląd klastra i zasobów
| Polecenie | Co robi |
|---|---|
kubectl get nodes -o wide | Węzły z rolą, wersją kubeleta, adresem, systemem, jądrem i containerd. |
kubectl get pods -A -o wide | Wszystkie pody z adresem IP i węzłem. |
kubectl get pods -n NS --show-labels, kubectl get pods -l KLUCZ=WARTOSC | Etykiety podów; wybór podów po etykiecie (tak jak Service). |
kubectl get events -n NS --sort-by=.lastTimestamp | Zdarzenia namespace w kolejności czasu: planowanie, pobieranie obrazów, sondy, wolumeny. |
kubectl get svc,ingress -n NS, kubectl get endpointslices -n NS | Usługi i reguły Ingress; adresy podów gotowych do przyjmowania ruchu. |
kubectl get pv, kubectl get pvc -A, kubectl get storageclass | Wolumeny, żądania wolumenów i klasy storage. |
kubectl get ds -A | DaemonSety z liczbą węzłów, na których mają działać (DESIRED). |
kubectl top nodes, kubectl top pods -A --sort-by=memory | Bieżące zużycie CPU i pamięci (wymaga metrics-server). |
kubectl describe node NAZWA | Taint, warunki węzła i sekcja Allocated resources (suma requests i limits). |
kubectl api-resources --api-group=longhorn.io | Typy obiektów z danej grupy API, także dodane przez CRD. |
kubectl explain pod.spec.topologySpreadConstraints.nodeTaintsPolicy | Opis pola wprost z API klastra, w wersji klastra. |
Diagnostyka podów
| Polecenie | Co robi |
|---|---|
kubectl describe pod POD -n NS | Etykiety, właściciel, obraz i jego skrót, sondy, requests/limits, QoS, zdarzenia. |
kubectl logs POD -n NS, kubectl logs deploy/NAZWA -n NS | Logi kontenera; przy Deployment kubectl wybiera jeden z podów. |
kubectl logs POD -p | Logi poprzedniego uruchomienia kontenera, po restarcie (CrashLoopBackOff, OOMKilled). |
kubectl logs POD -c KONTENER -f --since=15m --tail=50 | Wybrany kontener, śledzenie na bieżąco, ograniczenie czasu i liczby wierszy. |
kubectl logs -l KLUCZ=WARTOSC --prefix | Logi wielu podów naraz z nazwą poda na początku wiersza. |
kubectl get pod POD -o yaml | Pełny obiekt: spec i status. |
kubectl get pod POD -o jsonpath='{.status.containerStatuses[0].imageID}' | Jedno pole obiektu, tu skrót obrazu, który faktycznie działa. |
kubectl get pod POD -w | Obserwacja zmian statusu na bieżąco. |
kubectl exec deploy/NAZWA -n NS -- POLECENIE | Polecenie w kontenerze (z -it i sh: powłoka interaktywna). |
kubectl port-forward svc/NAZWA 13100:3100 -n NS | Lokalny port przekierowany do Service, poda (pod/) albo Deploymentu (deploy/). |
kubectl run test --rm -i --restart=Never --image=busybox:1.38.0 -- nslookup USLUGA | Jednorazowy pod do testów sieci i DNS, usuwany po zakończeniu. |
Zmiany, wdrożenia i węzły
| Polecenie | Co robi |
|---|---|
kubectl apply -f PLIK.yaml, kubectl apply -k KATALOG | Zastosowanie manifestu albo katalogu Kustomize (w klastrze z Flux zmiany robi się w Git). |
kubectl kustomize KATALOG | Zbudowanie manifestów bez wysyłania do klastra; sprawdzenie przed commitem. |
kubectl rollout status deploy/NAZWA | Czekanie na zakończenie wdrożenia. |
kubectl rollout history deploy/NAZWA, kubectl rollout undo deploy/NAZWA | Historia rewizji (domyślnie 10 starych ReplicaSetów) i powrót do poprzedniej; przy Flux wycofanie robi się git revert. |
kubectl rollout restart deploy/NAZWA | Wymiana wszystkich podów, np. po zmianie Secretu czytanego ze zmiennych środowiskowych. |
kubectl scale deploy NAZWA --replicas=N, kubectl set image deploy/NAZWA KONTENER=OBRAZ:TAG | Ręczna zmiana liczby replik i obrazu (Flux ją cofnie, jeśli zarządza obiektem). |
kubectl label deploy NAZWA KLUCZ=WARTOSC, kubectl delete pod POD | Dodanie etykiety; usunięcie poda (kontroler utworzy nowy). |
kubectl cordon NODE, kubectl uncordon NODE | Wyłączenie i włączenie planowania nowych podów na węźle. |
kubectl drain NODE --ignore-daemonsets --delete-emptydir-data | Przeniesienie podów z węzła przez Eviction API, z poszanowaniem PDB. |
kubectl get csr, kubectl certificate approve CSR | Lista i zatwierdzenie żądań certyfikatów (np. certyfikat serwera kubeleta). |
kubectl create secret generic NAZWA --from-literal=KLUCZ=WARTOSC --dry-run=client -o yaml | Wygenerowanie manifestu Secret bez tworzenia go w klastrze (wejście dla kubeseal). |
Flux
| Polecenie | Co robi |
|---|---|
flux check --pre, flux version | Sprawdzenie wymagań przed instalacją; wersje CLI i kontrolerów. |
flux bootstrap git --url=ssh://... --branch=main --path=clusters/NAZWA --private-key-file=KLUCZ | Instalacja Flux i podłączenie klastra do katalogu w repozytorium Git. |
flux get all -A | Stan wszystkich obiektów Flux: źródła, charty, release'y, Kustomizacje. |
flux get kustomizations, flux get helmreleases -A, flux get sources helm -A | Stan wybranego rodzaju obiektów. |
flux reconcile source git flux-system | Natychmiastowe pobranie repozytorium zamiast czekania na interval. |
flux reconcile kustomization NAZWA --with-source | Pobranie źródła i natychmiastowe uzgodnienie Kustomizacji. |
flux reconcile helmrelease NAZWA -n NS --with-source | To samo dla HelmRelease; --force wymusza instalację lub upgrade, --reset zeruje licznik nieudanych prób. |
flux suspend kustomization NAZWA, flux resume kustomization NAZWA | Wstrzymanie i wznowienie uzgadniania (ręczne zmiany zostają do czasu resume). |
flux logs --kind=HelmRelease --name=NAZWA --namespace=NS --tail=20 | Logi kontrolerów dla jednego obiektu; --level=error, --since=1h. |
flux events --for Kustomization/NAZWA | Zdarzenia jednego obiektu Flux. |
flux tree kustomization NAZWA | Obiekty utworzone przez Kustomizację (inwentarz). |
flux trace NAZWA --kind=Deployment --api-version=apps/v1 -n NS | Z której Kustomizacji, ścieżki, repozytorium i rewizji pochodzi obiekt. |
flux diff kustomization NAZWA --path ./KATALOG | Różnica między lokalnym katalogiem a klastrem; kod wyjścia 0 to brak zmian, 1 to zmiany. |
Helm
| Polecenie | Co robi |
|---|---|
helm repo add NAZWA URL, helm repo update | Dodanie repozytorium chartów i pobranie aktualnego indeksu. |
helm show values REPO/CHART --version X | Domyślne values.yaml chartu w danej wersji: punkt wyjścia do własnych wartości. |
helm template NAZWA REPO/CHART --version X --set KLUCZ=WARTOSC | Wyrenderowanie manifestów bez instalacji, np. żeby sprawdzić requests podów. |
helm list -A | Wszystkie release'y, także zainstalowane przez Flux. |
helm get values RELEASE -n NS, helm get metadata RELEASE -n NS | Wartości podane przy instalacji; metadane release'u. |
helm history RELEASE -n NS | Rewizje release'u ze statusem. |
k9s, Pluto, kind, kubeseal i External Secrets
| Polecenie | Co robi |
|---|---|
k9s -n NS, k9s --readonly | Panel tekstowy dla namespace; tryb tylko do odczytu. |
w k9s: :kustomizations, 0, d, l, p, y, shift-f, ? | Widok zasobu, wszystkie namespace'y, opis, logi, logi poprzednie, YAML, przekierowanie portu, pomoc. |
pluto detect-files -d KATALOG -o wide -t k8s=v1.37.0 | Przestarzałe i usunięte API w plikach; zawsze z wersją docelową. |
pluto detect-helm -o wide -t k8s=v1.37.0, pluto detect-api-resources -o wide -t k8s=v1.37.0 | To samo dla release'ów Helm i obiektów w klastrze. |
pluto list-versions | Znane Pluto wersje API z datami wycofania i usunięcia. |
kind create cluster --name NAZWA, kind get clusters, kind delete cluster --name NAZWA | Klaster w kontenerach Dockera: utworzenie, lista, usunięcie. |
kubeseal --fetch-cert > cert.pem | Pobranie certyfikatu publicznego kontrolera Sealed Secrets. |
... --dry-run=client -o yaml | kubeseal --cert cert.pem --format yaml > sealed.yaml | Zaszyfrowanie Secretu do SealedSecret, który można zapisać w Git. |
kubectl annotate externalsecret NAZWA force-sync=$(date +%s) --overwrite | Natychmiastowa synchronizacja ExternalSecret zamiast czekania na refreshInterval. |
longhornctl check preflight | Kontrola wymagań Longhorna na węzłach (potrzebuje KUBECONFIG i namespace longhorn-system). |
kubectl -n longhorn-system get volumes.longhorn.io,replicas.longhorn.io,nodes.longhorn.io,backups.longhorn.io | Wolumeny, repliki, węzły i kopie Longhorna. |
Węzeł, etcd i kubeadm
| Polecenie | Co robi |
|---|---|
kubeadm config images pull --config PLIK | Pobranie obrazów control plane przed kubeadm init. |
kubeadm init --config PLIK --upload-certs | Pierwszy control plane; --upload-certs udostępnia certyfikaty kolejnym węzłom control plane na 2 godziny. |
kubeadm token create --print-join-command --ttl 1h | Nowe polecenie kubeadm join dla workera. |
kubeadm join VIP:6443 --token ... --discovery-token-ca-cert-hash ... --control-plane --certificate-key ... | Dołączenie kolejnego control plane. |
kubeadm upgrade plan, kubeadm upgrade apply vX.Y.Z, kubeadm upgrade node | Plan i aktualizacja pierwszego control plane; aktualizacja pozostałych węzłów. |
kubeadm certs check-expiration | Terminy ważności certyfikatów i CA. |
kubeadm reset -f | Usunięcie konfiguracji Kubernetes z węzła przed jego wycofaniem. |
kubectl -n kube-system exec etcd-NODE -- etcdctl --endpoints=... --cacert=... --cert=... --key=... member list | Członkowie etcd; endpoint status pokazuje lidera i rozmiar bazy, endpoint health czas zapisu. |
iscsiadm -m session -P 1, lsblk -o NAME,SIZE,TYPE,TRAN | Sesje iSCSI wolumenów Longhorna i ich urządzenia blokowe na węźle. |
multipath -t, multipath -ll | Bieżąca konfiguracja multipathd i urządzenia, które przejął (dla Longhorna lista ma być pusta). |
Najważniejsze zależności w jednym miejscu
- Trzy węzły control plane dają etcd z kworum 2 z 3; utrata dwóch blokuje wszystkie zmiany, ale nie zatrzymuje działających podów.
- kubelet jest agentem węzła z własną tożsamością
system:node:...; kubeconfig to plik z adresem API, poświadczeniami i kontekstami. kubectx i kubens tylko wygodnie go edytują. - Service daje stały adres grupie podów wybranych etykietami, Ingress kieruje do niej ruch HTTP przez kontroler (Traefik), a Service typu LoadBalancer bez chmury potrzebuje własnej implementacji load balancera (w labie MetalLB).
- Secret to base64, nie szyfrowanie. Do Git trafia SealedSecret (szyfrogram) albo ExternalSecret (wskazanie klucza w sejfie, np. Google Secret Manager).
- Longhorn potrzebuje na workerach
iscsid, modułównfsidm_cryptoraz wyłączenia multipathd dla swoich dysków; repliki chronią przed awarią węzła, kopie w S3 przed utratą danych i klastra. - Flux stosuje repozytorium Git etapami (
dependsOn); Helm i Kustomize budują manifesty, avalues.yamltrzeba dopasować do realnych zasobów klastra. - PodDisruptionBudget, rozkład replik i liczba replik decydują, czy opróżnianie węzłów przejdzie bez przerwy (w labie dopiero ich dodanie dało 0 nieudanych odpowiedzi); requests, limits i sondy decydują, gdzie pod trafi, kiedy zostanie zabity i kiedy dostaje ruch.
- Terraform tworzy i usuwa węzły, ale dołączenie do klastra, opróżnienie węzła i aktualizację wersji wykonuje się poleceniami Kubernetes w określonej kolejności.
Potrzebujesz pomocy z klastrem Kubernetes?
Pomagamy budować i utrzymywać klastry Kubernetes, wdrożenia GitOps z Flux oraz automatyzację infrastruktury. Porozmawiajmy o Twoim środowisku - bezpłatnie i bez zobowiązań.
Powiązany artykuł
Terraform od podstaw: bezpieczny lab, plan, state i backend
Narzędzie, którym w tym artykule tworzymy i usuwamy węzły klastra.
Zobacz też