Przejdź do treści
Automatyzacja i DevOps · ~54 min czytania

Kubernetes od podstaw: control plane, workery, Flux, Helm, Longhorn i sekrety

Jak elementy typowego stacku Kubernetes zależą od siebie, sprawdzone na działającym klastrze z trzema węzłami control plane: testy awarii, GitOps, storage, sekrety, aktualizacja i ściągawka poleceń.

Autor: Zespół LinuxLab

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.

ElementDo czego służyOd czego zależyCo zależy od niego
TerraformTworzy i zmienia maszyny wirtualne (VM) węzłów: control plane i workeryAPI platformy, tu libvirt na hoście KVM (ang. Kernel-based Virtual Machine)Wszystko, co działa w klastrze
etcdBaza klucz-wartość ze stanem klastraDyski control plane, kworum członkówkube-apiserver
kube-apiserverAPI Kubernetes: odczyt i zapis obiektówetcd, certyfikatykubectl, scheduler, kontrolery, kubelet, Flux, operatory
kube-scheduler, kube-controller-managerPrzydział podów do węzłów, pętle kontrolerówkube-apiserverKażdy nowy lub poprawiany pod
kubelet + containerdUruchamianie kontenerów na węźle, sondykube-apiserver, system węzłaPody
Wtyczka sieciowa CNI (ang. Container Network Interface, tu flannel), kube-proxySieć podów i adresy Servicekubelet, kube-apiserverKomunikacja podów, Service, Ingress
MetalLBAdres IP dla Service typu LoadBalancerAPI, sieć warstwy 2 (L2)Traefik (wejście HTTP z zewnątrz)
TraefikKontroler Ingress: reguły HTTP do ServiceMetalLB, Service, obiekty IngressRuch użytkowników do aplikacji
LonghornWolumeny (PersistentVolume) replikowane między workeramiiscsid, 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 klastremOsobny serwerKopie Longhorna, logi w Loki
FluxUzgadnia klaster z repozytorium GitAPI, Git, repozytoria HelmCała infrastruktura i aplikacje z Git
Sealed Secrets, External SecretsSekrety bez jawnego tekstu w GitKlucz kontrolera albo sejf (OpenBao, Secret Manager)Secret używany przez pody
Alloy, LokiZbieranie i przechowywanie logów podówAPI (lista podów), S3, PVCDiagnostyka 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.

MaszynaRolaAdresvCPU / RAMDyski
k8s-adminstacja administratora, Git, S3 (Garage), OpenBao, Docker dla kind192.168.124.52 / 2,5 GiB30 GiB
k8s-cp-1, k8s-cp-2, k8s-cp-3control plane (stacked etcd).11, .12, .132 / 2 GiB20 GiB
k8s-worker-1, k8s-worker-2workery, Longhorn.21, .222 / 3,5 GiB30 GiB + 20 GiB na Longhorn
k8s-worker-3trzeci worker, dodany i usunięty Terraformem.232 / 3,5 GiB30 GiB + 20 GiB
VIP (ang. virtual IP) APIadres wirtualny kube-apiserver utrzymywany przez kube-vip192.168.124.10--
pula MetalLBadresy 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ładnikWersja 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, pause3.7.0-0, v1.14.6, 3.10.2
containerd, runc2.2.1-0ubuntu1~24.04.3, 1.3.4-0ubuntu1~24.04.1
kube-vip, flannelv1.2.4, v0.28.10
Flux2.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, kind4.3.0, 0.51.0, 0.11.0, 5.24.4, 0.40.0, 0.33.0
Charty HelmMetalLB 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-adminGarage 2.4.1, OpenBao 2.7.1, Docker 29.1.3, git 2.43.0
Aplikacje testowepodinfo 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.

KomponentRolaCo widać w labie
kube-apiserverSerwer HTTP API Kubernetes. Jedyny komponent, który czyta i zapisuje etcd.Trzy instancje, każda na swoim węźle, port 6443
etcdSpójna baza klucz-wartość na wszystkie dane API.Trzech członków, wersja 3.7.0, jeden lider
kube-schedulerWybiera węzeł dla podów, które jeszcze go nie mają.Trzy instancje, aktywna jedna (lease kube-scheduler)
kube-controller-managerUruchamia kontrolery, np. ten, który z Deployment robi ReplicaSet i pody.Trzy instancje, aktywna jedna (lease kube-controller-manager)
kube-vipUtrzymuje 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 etcdWymagana większośćIle awarii przetrwa
110
220
321
431
532

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), gniazdo unix:///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 ma 10.244.4.0/24), a ruch między węzłami idzie tunelem VXLAN (ang. Virtual Extensible LAN) przez interfejs flannel.1 i 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
ObiektKiedy go używaćPrzykład w labie
DeploymentAplikacje bezstanowe. Aktualizacja tworzy nowy ReplicaSet i stopniowo wymienia pody (rolling update).podinfo (2 repliki)
ReplicaSetTworzony przez Deployment, ręcznie prawie nigdy. Stare ReplicaSety z 0 replik zostają jako historia do wycofania zmiany.podinfo-64c9544c9d, podinfo-5598684fff...
StatefulSetAplikacje ze stanem: stała nazwa poda (-0, -1), własny wolumen dla każdej repliki, uruchamianie po kolei.valkey z wolumenem data-valkey-0
DaemonSetPo jednym podzie na każdym (pasującym) węźle: agenci sieci, storage, logów.kube-proxy, longhorn-manager, alloy
Job / CronJobZadania, 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 ServiceCo dajeW labie
ClusterIPAdres 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
NodePortPort 30000-32767 otwarty na każdym węźle.Traefik dostał porty 30927 i 31447
LoadBalancerZewnę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 GitSealed SecretsExternal Secrets
Co jest w Gitjawna wartość (base64)szyfrogramtylko wskazanie klucza w sejfie
Gdzie jest źródło prawdyGitGitsejf (OpenBao, Secret Manager)
Rotacjacommitnowy kubeseal i commitzmiana w sejfie, ESO kopiuje sam
Co trzeba chronićcałe repozytoriumklucz prywatny kontroleradostęp klastra do sejfu
W labienie używamypodinfo-tokenpodinfo-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:

ChartWartość domyślnaSkutek w labieZmiana
Loki 7.3.0chunksCache.enabled: true, allocatedMemory: 8192Wyrenderowany 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ęźlechunksCache.enabled: false, tryb SingleBinary
MetalLB 0.16.1frrk8s.enabled: truePod FRR-K8s (demon routingu BGP) na każdym z 5 węzłów, 150-190 MiB RAM na węzeł, zbędny w trybie L2frrk8s.enabled: false
Longhorn 1.13.0po 3 repliki sidecarów CSIZmniejszyliś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.1deployment.replicas: 1, bez PDBkubectl drain węzła z Traefikiem przerwał ruch HTTP na ok. 50 s2 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.

KontrolerWersja w labieZa co odpowiadaObiekty (CRD)
source-controllerv1.9.6Pobiera źródła: repozytoria Git, repozytoria i charty Helm, artefakty OCIGitRepository, HelmRepository, HelmChart
kustomize-controllerv1.9.6Buduje katalog przez Kustomize i stosuje wynik (server-side apply), usuwa obiekty usunięte z GitKustomization
helm-controllerv1.6.5Instaluje i aktualizuje release'y HelmHelmRelease
notification-controllerv1.9.4Powiadomienia (Slack, Teams...) i webhooki z Git zamiast odpytywaniaAlert, 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)KtoCo się stało
09:22:36.739administratorgit push commitu 885043f
09:23:27source-controllerNewArtifact: pobrał commit (repozytorium odpytuje co interval: 1m0s)
09:23:27kustomize-controllerinfra-configs: DependencyNotReady, ponowienie za 30 s, bo infra-controllers nie uzgodnił jeszcze nowej rewizji
09:23:28kustomize-controllerinfra-controllers uzgodniony (1,3 s)
09:23:58kustomize-controllerinfra-configs uzgodniony; apps i monitoring czekały 30 s na ten etap
09:24:28kustomize-controllerapps: utworzył ConfigMap podinfo-config-ckfbfdtt2g, usunął starą podinfo-config-gm4fcbk4cg
09:24:28-09:24:31kube-controller-managerDeployment podinfo: nowy ReplicaSet podinfo-5598684fff skalowany 0 -> 1 -> 2, stary podinfo-64c9544c9d 2 -> 1 -> 0
09:24:28-09:24:30kube-scheduler, kubeletNowe pody przydzielone do k8s-worker-1 i k8s-worker-2, obraz już był na węzłach
09:24:33kustomize-controllerHealth check passed in 5.160763671s
09:24:33.736Traefik, pod podinfoPierwsza 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
StatusCo znaczyGdzie szukać przyczyny
ErrImagePull, ImagePullBackOffkubelet 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
CrashLoopBackOffKontener startuje i kończy się błędem; kubelet restartuje go z rosnącą przerwą.logs -p (poprzednie uruchomienie), kod wyjścia w describe
OOMKilledProces przekroczył limit pamięci kontenera i jądro go zabiło (kod wyjścia 137).describe pod: Last State, Reason: OOMKilled
PendingScheduler nie znalazł węzła albo czeka na wolumen.describe pod: zdarzenie FailedScheduling
Running, ale 0/1 gotowychKontener 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

PolecenieCo robi
kubectl config get-contextsLista kontekstów z kubeconfig; gwiazdka oznacza bieżący, ostatnia kolumna domyślny namespace.
kubectl config viewkubeconfig 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 -cZmiana nazwy kontekstu, nazwa bieżącego kontekstu.
kubens, kubens NAMESPACE, kubens -cLista namespace'ów, ustawienie domyślnego w bieżącym kontekście, bieżący namespace.
kubectl auth whoamiUżytkownik i grupy, jakie API widzi dla bieżących poświadczeń.
kubectl auth can-i '*' '*' --all-namespacesCzy bieżący użytkownik może wszystko (yes/no); zamiast gwiazdek konkretny czasownik i zasób.
kubectl cluster-info, kubectl versionAdres API i CoreDNS; wersje klienta, wbudowanego Kustomize i serwera.

Przegląd klastra i zasobów

PolecenieCo robi
kubectl get nodes -o wideWęzły z rolą, wersją kubeleta, adresem, systemem, jądrem i containerd.
kubectl get pods -A -o wideWszystkie pody z adresem IP i węzłem.
kubectl get pods -n NS --show-labels, kubectl get pods -l KLUCZ=WARTOSCEtykiety podów; wybór podów po etykiecie (tak jak Service).
kubectl get events -n NS --sort-by=.lastTimestampZdarzenia namespace w kolejności czasu: planowanie, pobieranie obrazów, sondy, wolumeny.
kubectl get svc,ingress -n NS, kubectl get endpointslices -n NSUsługi i reguły Ingress; adresy podów gotowych do przyjmowania ruchu.
kubectl get pv, kubectl get pvc -A, kubectl get storageclassWolumeny, żądania wolumenów i klasy storage.
kubectl get ds -ADaemonSety z liczbą węzłów, na których mają działać (DESIRED).
kubectl top nodes, kubectl top pods -A --sort-by=memoryBieżące zużycie CPU i pamięci (wymaga metrics-server).
kubectl describe node NAZWATaint, warunki węzła i sekcja Allocated resources (suma requests i limits).
kubectl api-resources --api-group=longhorn.ioTypy obiektów z danej grupy API, także dodane przez CRD.
kubectl explain pod.spec.topologySpreadConstraints.nodeTaintsPolicyOpis pola wprost z API klastra, w wersji klastra.

Diagnostyka podów

PolecenieCo robi
kubectl describe pod POD -n NSEtykiety, właściciel, obraz i jego skrót, sondy, requests/limits, QoS, zdarzenia.
kubectl logs POD -n NS, kubectl logs deploy/NAZWA -n NSLogi kontenera; przy Deployment kubectl wybiera jeden z podów.
kubectl logs POD -pLogi poprzedniego uruchomienia kontenera, po restarcie (CrashLoopBackOff, OOMKilled).
kubectl logs POD -c KONTENER -f --since=15m --tail=50Wybrany kontener, śledzenie na bieżąco, ograniczenie czasu i liczby wierszy.
kubectl logs -l KLUCZ=WARTOSC --prefixLogi wielu podów naraz z nazwą poda na początku wiersza.
kubectl get pod POD -o yamlPeł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 -wObserwacja zmian statusu na bieżąco.
kubectl exec deploy/NAZWA -n NS -- POLECENIEPolecenie w kontenerze (z -it i sh: powłoka interaktywna).
kubectl port-forward svc/NAZWA 13100:3100 -n NSLokalny port przekierowany do Service, poda (pod/) albo Deploymentu (deploy/).
kubectl run test --rm -i --restart=Never --image=busybox:1.38.0 -- nslookup USLUGAJednorazowy pod do testów sieci i DNS, usuwany po zakończeniu.

Zmiany, wdrożenia i węzły

PolecenieCo robi
kubectl apply -f PLIK.yaml, kubectl apply -k KATALOGZastosowanie manifestu albo katalogu Kustomize (w klastrze z Flux zmiany robi się w Git).
kubectl kustomize KATALOGZbudowanie manifestów bez wysyłania do klastra; sprawdzenie przed commitem.
kubectl rollout status deploy/NAZWACzekanie na zakończenie wdrożenia.
kubectl rollout history deploy/NAZWA, kubectl rollout undo deploy/NAZWAHistoria rewizji (domyślnie 10 starych ReplicaSetów) i powrót do poprzedniej; przy Flux wycofanie robi się git revert.
kubectl rollout restart deploy/NAZWAWymiana wszystkich podów, np. po zmianie Secretu czytanego ze zmiennych środowiskowych.
kubectl scale deploy NAZWA --replicas=N, kubectl set image deploy/NAZWA KONTENER=OBRAZ:TAGRęczna zmiana liczby replik i obrazu (Flux ją cofnie, jeśli zarządza obiektem).
kubectl label deploy NAZWA KLUCZ=WARTOSC, kubectl delete pod PODDodanie etykiety; usunięcie poda (kontroler utworzy nowy).
kubectl cordon NODE, kubectl uncordon NODEWyłączenie i włączenie planowania nowych podów na węźle.
kubectl drain NODE --ignore-daemonsets --delete-emptydir-dataPrzeniesienie podów z węzła przez Eviction API, z poszanowaniem PDB.
kubectl get csr, kubectl certificate approve CSRLista i zatwierdzenie żądań certyfikatów (np. certyfikat serwera kubeleta).
kubectl create secret generic NAZWA --from-literal=KLUCZ=WARTOSC --dry-run=client -o yamlWygenerowanie manifestu Secret bez tworzenia go w klastrze (wejście dla kubeseal).

Flux

PolecenieCo robi
flux check --pre, flux versionSprawdzenie wymagań przed instalacją; wersje CLI i kontrolerów.
flux bootstrap git --url=ssh://... --branch=main --path=clusters/NAZWA --private-key-file=KLUCZInstalacja Flux i podłączenie klastra do katalogu w repozytorium Git.
flux get all -AStan wszystkich obiektów Flux: źródła, charty, release'y, Kustomizacje.
flux get kustomizations, flux get helmreleases -A, flux get sources helm -AStan wybranego rodzaju obiektów.
flux reconcile source git flux-systemNatychmiastowe pobranie repozytorium zamiast czekania na interval.
flux reconcile kustomization NAZWA --with-sourcePobranie źródła i natychmiastowe uzgodnienie Kustomizacji.
flux reconcile helmrelease NAZWA -n NS --with-sourceTo samo dla HelmRelease; --force wymusza instalację lub upgrade, --reset zeruje licznik nieudanych prób.
flux suspend kustomization NAZWA, flux resume kustomization NAZWAWstrzymanie i wznowienie uzgadniania (ręczne zmiany zostają do czasu resume).
flux logs --kind=HelmRelease --name=NAZWA --namespace=NS --tail=20Logi kontrolerów dla jednego obiektu; --level=error, --since=1h.
flux events --for Kustomization/NAZWAZdarzenia jednego obiektu Flux.
flux tree kustomization NAZWAObiekty utworzone przez Kustomizację (inwentarz).
flux trace NAZWA --kind=Deployment --api-version=apps/v1 -n NSZ której Kustomizacji, ścieżki, repozytorium i rewizji pochodzi obiekt.
flux diff kustomization NAZWA --path ./KATALOGRóżnica między lokalnym katalogiem a klastrem; kod wyjścia 0 to brak zmian, 1 to zmiany.

Helm

PolecenieCo robi
helm repo add NAZWA URL, helm repo updateDodanie repozytorium chartów i pobranie aktualnego indeksu.
helm show values REPO/CHART --version XDomyślne values.yaml chartu w danej wersji: punkt wyjścia do własnych wartości.
helm template NAZWA REPO/CHART --version X --set KLUCZ=WARTOSCWyrenderowanie manifestów bez instalacji, np. żeby sprawdzić requests podów.
helm list -AWszystkie release'y, także zainstalowane przez Flux.
helm get values RELEASE -n NS, helm get metadata RELEASE -n NSWartości podane przy instalacji; metadane release'u.
helm history RELEASE -n NSRewizje release'u ze statusem.

k9s, Pluto, kind, kubeseal i External Secrets

PolecenieCo robi
k9s -n NS, k9s --readonlyPanel 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.0Przestarzał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.0To samo dla release'ów Helm i obiektów w klastrze.
pluto list-versionsZnane Pluto wersje API z datami wycofania i usunięcia.
kind create cluster --name NAZWA, kind get clusters, kind delete cluster --name NAZWAKlaster w kontenerach Dockera: utworzenie, lista, usunięcie.
kubeseal --fetch-cert > cert.pemPobranie certyfikatu publicznego kontrolera Sealed Secrets.
... --dry-run=client -o yaml | kubeseal --cert cert.pem --format yaml > sealed.yamlZaszyfrowanie Secretu do SealedSecret, który można zapisać w Git.
kubectl annotate externalsecret NAZWA force-sync=$(date +%s) --overwriteNatychmiastowa synchronizacja ExternalSecret zamiast czekania na refreshInterval.
longhornctl check preflightKontrola 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.ioWolumeny, repliki, węzły i kopie Longhorna.

Węzeł, etcd i kubeadm

PolecenieCo robi
kubeadm config images pull --config PLIKPobranie obrazów control plane przed kubeadm init.
kubeadm init --config PLIK --upload-certsPierwszy control plane; --upload-certs udostępnia certyfikaty kolejnym węzłom control plane na 2 godziny.
kubeadm token create --print-join-command --ttl 1hNowe 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 nodePlan i aktualizacja pierwszego control plane; aktualizacja pozostałych węzłów.
kubeadm certs check-expirationTerminy ważności certyfikatów i CA.
kubeadm reset -fUsunięcie konfiguracji Kubernetes z węzła przed jego wycofaniem.
kubectl -n kube-system exec etcd-NODE -- etcdctl --endpoints=... --cacert=... --cert=... --key=... member listCzłonkowie etcd; endpoint status pokazuje lidera i rozmiar bazy, endpoint health czas zapisu.
iscsiadm -m session -P 1, lsblk -o NAME,SIZE,TYPE,TRANSesje iSCSI wolumenów Longhorna i ich urządzenia blokowe na węźle.
multipath -t, multipath -llBieżą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łów nfs i dm_crypt oraz 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, a values.yaml trzeba 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ń.