CalicoとCiliumの通信経路を実測する:Service・VXLAN・Policy・既存TCP
やったこと
代表的なCNIであるCalicoとCiliumを取り上げ、kube-proxyありのiptables構成と、kube-proxyなしのeBPF構成の通信経路を比較した。
同じPod・Service・NetworkPolicyの構成で動かしたところ、HTTP接続やDNS、readinessによる接続先の切り替え、通信の許可・拒否といった基本動作に差は見られなかった。
一方、Pod内やNode上でパケットを採取して確認すると、Serviceの宛先がbackend宛に変換される場所や、接続状態を保持する仕組みに明確な違いがあった。
CNIとは
CNI(Container Network Interface)は、KubernetesがPodのネットワークをセットアップする際に呼び出す共通インターフェース規格である。CNIプラグインを通じてネットワークインターフェースやIPアドレスを設定し、Podをネットワークにつなぐ。CalicoやCiliumはCNIに対応したネットワークソフトウェアで、Pod間通信やNetworkPolicyによるアクセス制御も担う。
kube-proxyの役割
PodにIPアドレスが付いて通信できるようになっても、ServiceのClusterIPをどのbackend Podへ振り分けるかという処理が必要になる。このServiceの転送ルールを各Nodeに設定するのがkube-proxyである。ServiceとEndpointSliceの変化を監視し、接続先の追加・削除などをルールに反映する。
今回のiptablesモードでは、kube-proxyがカーネルにルールを設定し、実際のbackend選択と宛先変換はLinuxカーネルが行う。通信のたびにkube-proxyプロセスがパケットを受け取り、中継する構成ではない。Pod同士をつなぐネットワークと、その上でServiceの宛先を選ぶ処理を分けると、Calicoとkube-proxyが担う役割も見えやすくなる。
eBPFでServiceを処理する構成
eBPFはLinuxカーネルのパケット処理やソケット操作の途中でプログラムを動かせる仕組みである。CNIのような接続規格やkube-proxyのようなコンポーネントを指す名前ではなく、ネットワーク機能の実装にも使われる技術である。
CiliumはeBPFを使って、Pod間通信やNetworkPolicyに加え、Serviceの振り分けも実装する。このService機能でkube-proxyを置き換えるのがkube-proxy replacementである。今回有効にしたsocket-LBでは、アプリのconnect()を処理する段階でbackendを選び、ソケットの宛先を書き換える。アプリは同じClusterIPへ接続していても、宛先が変わる場所はiptables構成と異なる。
CalicoとCiliumの特徴と今回の検証構成
CalicoはBGPによるルーティングや柔軟なNetworkPolicy、CiliumはeBPFによる高度な通信制御やHubbleによる可視化が主な特徴として挙げられる。Calicoにもkube-proxyを置き換えるeBPFモードがあるが、本記事ではCalicoのiptables構成と、Ciliumのsocket-LBを有効にしたeBPF構成を比較した。以降のCalicoおよびCiliumの記述は、この検証構成を指す。
役割
今回のCalico構成
今回のCilium構成
PodのIP・ネットワーク接続
Calico
Cilium
NetworkPolicyの適用
Calicoが設定したiptablesルール
CiliumのeBPF
Serviceのbackend選択と宛先変換
kube-proxyが設定したiptablesルール
CiliumのeBPF(socket-LB)
検証結果
アプリから見た基本動作は同じだったが、ServiceからPodへの宛先変換は異なる場所で処理されていた。 主な共通点と違いは次の4点である。
Serviceの宛先変換:CalicoはPodを出た後にNode上で変換する。一方、Ciliumはsocket接続時(connectシステムコール時)に変換するため、Podのeth0には最初からbackend宛のSYNパケットが現れる。
別Nodeへの転送:どちらもVXLANによるカプセル化を使用していた。ただし、外側UDPのポート番号とVXLANのVNIの扱いに違いがあった。
NetworkPolicy:どちらもパケットが宛先Podへ届く前に破棄された。CalicoではiptablesのDROPカウンタ、Ciliumではendpointの拒否イベントとして観測できた。
readinessによる切り替え:NotReadyになったPodは新規接続の振り分け先から除外されたが、すでに確立していた既存TCP接続は切断されず、同じPodと通信を維持できた。
最も大きな違いは黄色いひし形で示した宛先変換の位置にある。図は別Nodeのbackendに接続する場合を描いている。
※図の凡例:青はPod・アプリ、黄色いひし形は変換やPolicyの判定処理、灰色は転送経路、赤はパケットの拒否を表す。
Container内のアプリプロセスはuser spaceで動く。Pod内のContainerは同じnetwork namespaceを使い、IPアドレスとポート空間を共有する。アプリのsocketやPodのeth0はこのnamespaceに属する。socket-LBのcgroup hookもiptablesもカーネル内の処理である。図ではkindのContainerとして動く論理Nodeと、その中の処理の区分を示した。これらのNodeは同じLinuxカーネル上で動く。
比較した構成
検証環境はcontrol-plane 1台、worker 2台のクラスタ構成とした。clientと同じworkerノードにbackend Podを1台、別workerノードにもう1台配置している。Serviceへの新規接続とPod IPへの直接接続を比較し、readiness probeの状態変化やNetworkPolicyの適用前後における挙動を観測した。
項目
Calico
Cilium
バージョン
v3.33.0
v1.20.2
Serviceの実装
kube-proxyのiptablesモード
kube-proxy replacement、Full socket-LB
Node間の転送
VXLAN、BGP無効
VXLAN
共通条件
Kubernetes v1.35.0、IPv4、Pod MTU 1400
同左
検証詳細
Calicoの場合
ServiceのClusterIPへ接続すると、client Podのeth0だけでなく、Node側のvethインターフェース上でもClusterIP宛のSYNパケットが観測された。パケットの宛先がbackendのPod IPへ変換されるのは、Nodeに入った後である。
別Node上で動作するbackend Podが選択された通信経路を追跡した結果は、以下のとおりである(IPアドレスは役割名に置き換えている)。
client Pod eth0 client:33706 → Service:8080 SYN seq=433715408
Nodeのhost veth client:33706 → Service:8080 SYN seq=433715408
vxlan.calico client:33706 → backend:8080 SYN seq=433715408
backend Pod eth0 client:33706 → backend:8080 SYN seq=433715408
送信元ポートとSYNシーケンス番号は一貫しており、宛先IPアドレスのみが途中で書き換わっていることがわかる。これは、Node上でkube-proxyが生成したiptablesチェーン(KUBE-SERVICES → KUBE-SVC-* → KUBE-SEP-* → DNAT)のルールによって処理されているためである。実際に新規接続を8回試行したところ、該当Serviceのエントリポイントにあるカウンタも正確に8パケット分増加していた。
なお、ここでkube-proxyが担っている役割は、カーネルにiptablesルールをプロビジョニングすることである。通信が発生するたびにパケットがkube-proxyプロセスへ渡されるわけではなく、Linuxカーネル自体がiptablesルールに従ってbackendを選択し、DNAT(宛先NAT)を実行する。[1]
また、この宛先変換のマッピング情報はconntrack(接続追跡テーブル)に保持される。今回の検証でも、original方向にclient → Service、reply方向にbackend → clientという対応関係が記録されていた。この接続状態が管理されているおかげで、Service宛てに接続したクライアントと、実際に応答を返すbackend Podとの間で正常にセッションが維持される。
Ciliumの場合
一方、CiliumではクライアントアプリがClusterIP宛てに接続を要求していても、client Podのeth0でキャプチャされたSYNパケットは最初からbackendのPod IP宛てになっていた。新規に試行した8回の接続すべてにおいて、この挙動が確認できた。
これは、Ciliumのsocket-LB機能が、アプリケーションがconnect()システムコールを発行した時点でServiceのbackendを選択し、ソケットの宛先アドレス自体を書き換えるためである。TCPパケットが生成されるよりも前の段階で宛先が書き換わるため、パケットがPodを出た後にNode上でServiceのDNATを行う必要がなくなる。[2]
今回の通常設定ではソケットトレースログが間引かれるため、一時的にレートリミットを解除して別の8接続を採取した。各ソケット(socket cookie)において、以下のように変換前後の状態を確認できた。
接続先の変換 Service:8080 → backend:8080
アプリに見せる宛先 backend:8080 → Service:8080
2行目は、アプリがgetpeername()で接続相手の情報を参照した際に返される逆変換(reverse translation)の処理である。ネットワーク上ではbackend Podと直接通信しつつも、アプリケーション側にはあくまで「Serviceに接続している」と見せかける仕組みになっている。実際のパケット送受信はbackend宛てのBPF conntrackテーブルで追跡され、Serviceとの対応関係はBPFのsocket reverse NATテーブルで管理されていた。
なお、socketトレースとpcap(パケットキャプチャ)の突き合わせは、clientのcgroup、逐次実行した各接続のタイムスタンプ、および応答したbackend Podの組み合わせによって確認した。socket cookieはパケットヘッダには含まれず、トレース側には送信元ポートが出力されないため、これらを直接の結合キーにすることはできない点に注意が必要である。
このアーキテクチャの違いは障害調査やトラブルシューティング時のパケット解析(tcpdump)にも大きく影響する。Cilium環境のPod内でパケットをキャプチャしてClusterIP宛ての通信が見当たらなくても、Serviceが使われていないわけではない。socket-LBが有効な構成では、Pod内であっても追うべき宛先はbackendのIPアドレスになる。
別Nodeへの転送は、両方ともVXLANを採用
Serviceの宛先変換が行われる場所は異なっていたものの、今回の検証構成におけるNode間のパケット転送は、CalicoとCiliumのどちらもVXLANによるオーバーレイネットワークを用いていた。同一Node内のPod同士の直接通信ではVXLANヘッダは付与されないが、別Node上のPod宛て通信では、内側のPod間TCPパケットが外側のNode間UDPパケットでカプセル化される。
内側のTCPパケットにおける送信元・宛先IP、ポート番号、SYNシーケンス番号を追跡したところ、送信元NodeのNIC、トンネルデバイス、宛先NodeのNIC、そして受信側Podのeth0まで、一連の通信を正確に追うことができた。トンネルデバイス名としては、Calicoがvxlan.calico、Ciliumがcilium_vxlanを使用していた。
特に興味深いのはVXLANヘッダに含まれるVNI(VXLAN Network Identifier)の使われ方である。Calicoでは固定値の4096が設定されていたのに対し、Ciliumではclientからbackendへの往路が8639、復路が40262となっており、それぞれ送信元Podに割り当てられたSecurity Identityの値と一致していた。Ciliumはトンネルでパケットをカプセル化して転送する際、VNIフィールドを利用して送信元Podのセキュリティ属性(どのNetworkPolicyグループに属するか)を宛先Nodeへ伝達している。[3]
NetworkPolicyの動作:どちらも宛先Podの手前で拒否
別Node上で動作するbackend Podに対してIngressの隔離NetworkPolicyを適用し、client PodからPod IPへ直接接続を試行した。その結果、どちらの構成でもSYNパケットは宛先Nodeのeth0までは到達したものの、backend Podのeth0には届かずに破棄された。
今回の検証では1接続の最初のSYNパケットと再送パケットを含めて計3パケットを観測した。Calicoでは対象Pod向けのiptablesチェーンにおいてDROPカウンタが3パケット(180バイト)増加し、Ciliumでは対象endpointのイベントログに3件のPolicy deniedが記録された。その後、送信元Podを許可するルールを追加すると、どちらの構成でもパケットがbackend Podへ到達し、HTTP 200の正常応答が得られるようになった。
「通信が拒否された」という最終的な結果は同じであっても、トラブルシューティング時に確認すべき調査ポイントは異なる。CalicoであればiptablesチェーンのDROPカウンタを確認し、CiliumであればeBPFエンドポイントやPolicyのイベントログをパケットトレースと突き合わせることで、パケットが宛先Nodeに到達した後に破棄された要因を正確に特定できる。
readiness probeによる制御:新規接続の振り分け先変更と既存TCPの継続
通信確立中のbackend PodをNotReady状態に遷移させ、選択状態の収束を確認してから新規TCP接続を試すと、もう一方のReadyなPodへ切り替わっていた。しかしその間も、すでに確立していた既存のTCP接続は切断されることなく、元のPodから継続してHTTP 200応答を受け取り続けた。
readiness probeの失敗は、Pod上のアプリケーションのTCPソケットを強制クローズする処理ではない。今回の検証用backend Podは、NotReady状態であってもリクエストを受け取れば正常に応答できる状態を保っていた。つまり、Serviceで「新規接続の振り分け対象から外れること」と、「既存の接続済みソケットで通信を維持できること」は完全に両立する。
観測した状態
Calico
Cilium
NotReady後の新規8接続
すべて残ったReadyなPodへ
すべて残ったReadyなPodへ
既存TCP
同じ接続で52応答
同じ接続で55応答
保持していた接続状態
Serviceとbackendを結ぶconntrack
backend宛のBPF conntrackとsocket reverse NAT
既存のTCP接続についてはPodを削除せずにclientと同一Node上のbackendとの間で約1分間にわたり観測を継続した(クライアント側での自動再接続は行わずに1本のTCPセッションを維持)。その後、readiness状態を回復させると、両構成ともに新規接続の振り分け先として2つのbackend Podが再び選択されるようになった。なお、通常のClusterIP Serviceに対するDNS名前解決の結果は、この一連の動作中も一貫して同一のClusterIPアドレスを返し続けていた。
まとめ
CalicoとCiliumのどちらを採用した場合でも、アプリケーションから見たKubernetesの基本動作(Service連携やアクセス制御)は共通していた。しかし、パケットレベルの挙動に着目すると、Podから送出されるパケットの宛先アドレスには決定的な違いが存在していた。Calico(iptables構成)ではパケットがPodを出た後にNodeのカーネル内でServiceからbackendへの変換が行われるのに対し、Cilium(socket-LB構成)ではソケットのconnect()処理時にあらかじめ変換が完了している。
このアーキテクチャの違いを理解しておくことは、パケットキャプチャ(tcpdump)を用いた障害解析やネットワーク設計において極めて重要である。Calicoでは「どの観測点の間でClusterIPからPod IPへ宛先が書き換わるか」を追う必要がある一方、Ciliumでは最初からbackend宛てのパケットとsocket-LBの状態を突き合わせて確認しなければならない。一見同じように機能するKubernetesクラスタであっても、基盤となるCNIの実装特性に応じた適切な観測ポイントを見極めることが肝要である。
余談: なぜkube-proxyを利用しない構成が広がっているのか
kube-proxyを用いないいわゆる「kube-proxy-less」な構成は、主要クラウド各社のマネージドKubernetesサービスでも標準採用が進んでいる。2026年10月時点の各社公式ドキュメントに基づくと、現状は以下のように整理できる。
クラウドの構成
ネットワークの実装
kube-proxy
GKE Autopilotの新規クラスタ
Ciliumを使うDataplane V2が既定
使用しない
AKS Automatic
Azure CNI Overlay powered by Ciliumが既定
使用しない
EKSのEC2ノード構成(Auto Modeを除く)
Amazon VPC CNIとkube-proxy add-on
使用する
Google CloudのGKE Dataplane V2では、Serviceのパケット転送およびNetworkPolicyの評価をeBPFで処理し、高度なポリシーログ出力も提供している。Microsoft AzureのAKSでも、Azure CNIとCiliumのデータプレーンを統合することで、Service転送やセキュリティポリシーの適用、さらに拡張アドオンを通じた詳細な可視化を実現している。[4, 5]
一方、AWSのEKSにおける標準的なEC2ノード構成では、Amazon VPC CNIがPodネットワークのプロビジョニングを行い、Serviceのロードバランシングはkube-proxyアドオンが担当する。NetworkPolicyの適用エンジンと、Serviceの負荷分散にkube-proxyを用いるかどうかは独立した選択肢となっている。[6, 7]
なお、Calicoにもkube-proxyを置き換えてeBPFでServiceを処理するモードが存在し、kube-proxy自体にも従来のiptablesに代わるnftablesモードなどのパフォーマンス改善が進められている。そのため、単に「CalicoかCiliumか」という製品の比較にとどまらず、「どのデータプレーン層でServiceルーティングを実装するか」という設計視点が重要となる。[8, 9]
Ciliumをフル活用する構成の大きな利点は、Serviceのbackend検索やNetworkPolicyの評価、通信の可観測性をすべて同一のeBPFデータプレーンで一元処理できる点にある。特にsocket-LBを適用した場合、ソケット接続の開始時点でbackendが確定するため、後段のネットワークスタックにおけるService DNAT処理を丸ごと省略できる。今回の検証においてPodのeth0で観測された宛先の違いは、まさにこのアーキテクチャ設計の違いがパケットレベルに現れた好例といえる。[10]
AIの利用について
本記事の作成にはAIを利用している。
参考文献
kubernetes/pkg/proxy/iptables/proxier.go at v1.35.0 · kubernetes/kubernetes · GitHub
cilium/Documentation/network/kubernetes/kubeproxy-free.rst at v1.20.2 · cilium/cilium · GitHub
cilium/Documentation/network/concepts/routing.rst at v1.20.2 · cilium/cilium · GitHub
GKE Dataplane V2 | GKE networking | Google Cloud Documentation
Configure Azure CNI Powered by Cilium in Azure Kubernetes Service (AKS) - Azure Kubernetes Service | Microsoft Learn
Assign IPs to Pods with the Amazon VPC CNI - Amazon EKS
Manage kube-proxy in Amazon EKS clusters - Amazon EKS
About Calico eBPF | Calico Documentation
Virtual IPs and Service Proxies | Kubernetes
Kubernetes Without kube-proxy — Cilium 1.20.4 documentation
ingress-nginx 退役之後:在臨時叢集實測 Traefik 的 ingress-nginx 相容模式與 Gateway API
> 一句話結論:用 ingress-nginx 的小叢集,不必急著重寫所有 Ingress。實測下來,Traefik 的 ingress-nginx 相容模式可以原封不動吃下常見的 annotation;想一步到位改 Gateway API 也行,但轉址狀態碼、IP 白名單這類細節要自己補。這篇是評估,不是遷移紀錄。
遇到的問題
單機叢集常見的配置是:k3s 的內建 Traefik 關掉,自己用 Helm 裝 ingress-nginx,所有服務掛 ingressClassName: nginx,再用幾個 nginx.ingress.kubernetes.io/... annotation 處理 HTTPS 轉址、逾時、IP 白名單。
這個專案已經退役了。官方 repo 的說明寫得很直接:
- 2026 年 3 月之後,不再有新版本、bug 修正,也不再修補新發現的安全漏洞。
- 已經部署的不會壞,Helm chart 和映像也還在。
- 沒有安裝過的人,建議直接找一個 Gateway API 的實作。
也就是說現有的叢集不會馬上出事,但每一天都在累積「沒人修」的風險。接下來有兩種做法:
1. 換 controller,Ingress 不動。 讓別的 controller 認得 nginx 的 annotation。
2. 換 API。 改寫成 Gateway API 的 Gateway 與 HTTPRoute。
這篇不下結論要選哪個,而是在隔離的臨時叢集把兩條路都實際跑一遍,記下哪些順利、哪些要注意。
測試環境
整個實驗用一個臨時的 k3d 單節點叢集,不佔主機的埠,測完就刪。
| 項目 | 版本 |
|---|---|
| k3s(k3d 預設) | v1.31.5 |
| Traefik(Helm chart 42.2.0) | v3.7.16 |
| Gateway API CRD(standard) | v1.7.0 |
| cert-manager | v1.21.3 |
| ingress2gateway | v1.2.0 |
測試用的後端是 traefik/whoami,它會回傳請求的標頭,還有 ?wait=25s 這種可以讓後端故意慢回應的參數,以及 /echo 的 WebSocket 端點,很適合拿來測逾時。
要先講清楚的限制:
- 叢集裡用自簽憑證,沒有真的向 Let's Encrypt 申請。
- 從叢集內的 Pod 發請求,沒有經過 NodePort 或 LoadBalancer,所以「來源 IP 會不會被改掉」沒有驗證。
- 單節點,沒做效能比較。
手把手步驟
前置:k3s 內建的 Traefik 不能直接用
先查了 k3s 自帶的 Traefik。k3d 預設的 k3s v1.31.5,內建的是 Traefik 2.11.18。
Traefik 的 ingress-nginx 相容 provider,官方文件寫明需要 v3.6.2 以上。所以 v1.31.5 附的版本沒有這個功能(用 traefik --help 也確實只看到 kubernetesgateway,而且還需要 --experimental.kubernetesgateway)。
比較新的 k3s 會附 Traefik 3.7.x:查 k3s v1.37.1 的內建 manifest,chart 是 41.4.0,對應 Traefik v3.7.12。但舊版 k3s 的叢集,得自己用 Helm 裝新版 Traefik。
下面的實驗都是這樣做:建叢集時停用內建 Traefik,再用 Helm 裝。
k3d cluster create demo --servers 1 --agents 0 --no-lb \
--k3s-arg "--disable=traefik@server:0"
路線一:讓 Traefik 直接吃 nginx 的 Ingress
Helm values 只要打開 provider:
providers:
kubernetesIngressNGINX:
enabled: true # 預設 ingressClass 是 nginx
原本的 Ingress 完全不用改。測試用的是三種最常見的寫法,原封不動貼進去:
# 1. HTTPS 轉址
metadata:
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# 2. 長時間逾時
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
# 3. IP 白名單
nginx.ingress.kubernetes.io/whitelist-source-range: "192.0.2.0/24"
實測結果:
| 項目 | 結果 |
|---|---|
| HTTP → HTTPS 轉址 | 308 Permanent Redirect,Location 正確帶回原本的路徑,和 ingress-nginx 的預設一致 |
| whitelist-source-range | 名單外的來源收到 403 Forbidden |
| proxy-read-timeout | 有生效。為了方便驗證,改成 5 秒、後端 25 秒才回應,結果在 5.0 秒收到 504;改成 60 秒就正常通過 |
| backend-protocol: "HTTPS" | 後端用自簽憑證的 HTTPS,收到 200;拿掉 annotation 就變成 400 |
| WebSocket | 透過這個 Ingress 連線、閒置 30 秒後仍可收發 |
這個 provider 可以和 Gateway API 的 provider 同時開著。Traefik 官方文件沒有說明兩者能不能並存,所以實測了:同一個 Traefik 裡,Ingress 和 HTTPRoute 都正常回應。
有一個小地方:Ingress 的 ADDRESS 欄位是空的。這個 provider 預設沒有回填 status.loadBalancer,chart 有 publishService 這個選項,我沒有測它有沒有用。依賴 Ingress status 的工具(例如自動建立 DNS 記錄的)要留意。
另外,如果打算讓 ingress-nginx 和 Traefik 並行一段時間再切換,官方文件提醒兩個 controller 會互相覆寫 Ingress 的 status,路由不受影響,但監看 status 的工具會被干擾。這一點我沒有實測。
路線二:改寫成 Gateway API
先裝 Gateway API 的標準 CRD,再裝 Traefik 並打開 Gateway provider:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.7.0/standard-install.yaml
providers:
kubernetesGateway:
enabled: true
gateway:
enabled: false # 自己建 Gateway
Traefik 的 chart 輸出訊息建議安裝 v1.5.1,我裝的是 v1.7.0,GatewayClass 仍然正常被接受。
Gateway 的 listener 埠必須對應 Traefik 的 entrypoint(預設是 HTTP 8000、HTTPS 8443),不是對外的 80 和 443:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: main, namespace: traefik}
spec:
gatewayClassName: traefik
listeners:
- name: web
port: 8000
protocol: HTTP
allowedRoutes: {namespaces: {from: All}}
- name: websecure
port: 8443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs: [{name: demo-tls}]
allowedRoutes: {namespaces: {from: All}}
HTTP 轉 HTTPS
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: app-redirect, namespace: demo}
spec:
parentRefs: [{name: main, namespace: traefik, sectionName: web}]
hostnames: [app.example.test]
rules:
- filters:
- type: RequestRedirect
requestRedirect: {scheme: https, statusCode: 308}
這一段有三個發現:
- 不要寫 port。 一開始寫了 port: 443,Location 變成 https://app.example.test:443/x。拿掉之後才是乾淨的 https://app.example.test/x。
- statusCode 預設是 302。 沒寫的話,原本 ingress-nginx 的 308 會變成臨時轉址。
- 308 可以用,但屬於 Extended。 CRD 的說明是「301 與 302 是 Core,其餘是 Extended」。實測 Traefik 3.7.16 接受並回傳 308 Permanent Redirect。換別的 Gateway 實作就不一定支援,要看該實作的文件。
IP 白名單
Gateway API 標準沒有 IP 白名單的欄位。Traefik 的做法是建一個 Middleware,再用 ExtensionRef 掛到 HTTPRoute:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: {name: only-office, namespace: demo}
spec:
ipAllowList:
sourceRange: ["192.0.2.0/24"]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: internal, namespace: demo}
spec:
parentRefs: [{name: main, namespace: traefik, sectionName: websecure}]
hostnames: [internal.example.test]
rules:
- filters:
- type: ExtensionRef
extensionRef: {group: traefik.io, kind: Middleware, name: only-office}
backendRefs: [{name: whoami, port: 80}]
名單外的來源收到 403 Forbidden,把 sourceRange 換成 Pod 網段就通過。這是 Traefik 專屬的寫法,換 Gateway 實作就得重寫。
cert-manager 整合
憑證的位置從 Ingress 的 annotation,搬到 Gateway 的 listener。cert-manager 要打開 Gateway 支援:
config:
apiVersion: controller.config.cert-manager.io/v1alpha1
kind: ControllerConfiguration
enableGatewayAPI: true
Gateway 加上 issuer 的 annotation,listener 指向一個「還不存在」的 Secret:
metadata:
annotations:
cert-manager.io/cluster-issuer: selfsigned
spec:
listeners:
- name: auto
hostname: auto.example.test
port: 8443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs: [{name: auto-tls}]
大約 12 秒後,cert-manager 自動建出 Certificate(READY=True)和 auto-tls 這個 Secret,Traefik 開始用它提供 HTTPS,請求回 200。
這裡用的是自簽的 issuer。真正用 Let's Encrypt 時,DNS-01 或 HTTP-01 的流程這次沒有驗證。
長連線與逾時
原本 annotation 裡的 proxy-read-timeout: 3600,主要是為了 WebSocket 和長時間回應。到了 Traefik,要分三種情況看。
預設值。 不調任何逾時:
- WebSocket 連上之後閒置 400 秒,再送一次訊息,仍然收到回應。
- 後端 100 秒才回應的 HTTP 請求,等到 100.1 秒成功收到 200。
3600 秒本身沒有實際等待測試,只能說預設值下,閒置幾百秒不會被切斷。
調得很短的 entrypoint 逾時。 為了確認哪些設定會影響,把 HTTPS entrypoint 的 readTimeout、writeTimeout、idleTimeout 都設成 10 秒:
ports:
websecure:
transport:
respondingTimeouts:
readTimeout: 10s
writeTimeout: 10s
idleTimeout: 10s
- WebSocket 閒置 30 秒,仍然正常。升級成 WebSocket 之後,連線不受這幾個逾時影響。
- 一般 HTTP 請求,後端 25 秒才回應,在第 10 秒被切斷(客戶端看到連線被中斷,沒有收到 HTTP 狀態)。
也就是說,要讓「長時間才回應」的請求(例如串流輸出)不被切掉,要看的是 entrypoint 的 writeTimeout。預設值下 100 秒的回應沒有被切斷(前面實測過),所以一般不用動,但一旦被調過就會是問題。
nginx annotation 不能蓋過 entrypoint。 在 entrypoint 設了 10 秒的情況下,即使 Ingress 寫了 proxy-read-timeout: "3600",後端 25 秒的請求還是在 10 秒被切斷。annotation 控制的是後端那一段的逾時,不會改寫 entrypoint 的設定。
ingress2gateway 轉出來的東西
官方的轉換工具 ingress2gateway 可以把現有的 Ingress 轉成 Gateway API。拿上面的三個 Ingress 餵給 v1.2.0:
ingress2gateway print --input-file ing.yaml --providers ingress-nginx
轉換器會印出警告,值得逐條看:
- force-ssl-redirect 被標為 Unsupported annotation。不過因為 Ingress 有 TLS,工具仍然自動產生了 308 的 HTTP → HTTPS 轉址路由,結果是對的,警告看起來只是沒有逐個 annotation 處理。
- whitelist-source-range 轉換失敗:IP-based authorization is not supported。白名單會直接掉,轉完的路由是沒有限制的。
- 逾時 annotation 會做「best-effort」轉成 timeouts.request,但數字不對。
最後一點值得單獨講。同樣的 annotation,轉出來的結果:
| proxy-read-timeout(秒) | 轉出的 timeouts.request |
|---|---|
| 60 | 10m0s(應該是 1 分鐘) |
| 100 | 16m40s(應該是 1 分 40 秒) |
| 3600 | 10h0m0s(應該是 1 小時) |
每一筆都是預期的 10 倍。從原始碼看,解析 annotation 的那一步是正確的(秒數轉成 Duration),所以問題出在後面哪一步,這裡沒有追到,也沒找到對應的 issue。這是 v1.2.0 的觀察,之後版本可能已經修掉,重點是轉換結果一定要人工逐項核對,不要直接 apply。
踩到的坑
整理一下實測中遇到的:
1. k3s 內建的 Traefik 可能太舊。 v1.31.5 附的是 2.11.18,沒有 ingress-nginx 相容 provider,要自己用 Helm 裝 3.6.2 以上。
2. Gateway 的 listener 埠是 entrypoint 的埠。 寫成 80、443 會對不上。
3. RequestRedirect 不要寫 port,也別忘了 statusCode。 前者會讓網址多出 :443,後者預設是 302,不是 ingress-nginx 的 308。
4. IP 白名單在 Gateway API 沒有標準寫法。 得靠實作專屬的 Middleware,轉換工具也不會幫你處理。
5. 轉換工具的輸出要核對。 至少逾時數字要自己重算。
為什麼這樣評估
如果現有叢集的 Ingress 很多,而且多半只用到轉址、逾時、白名單這幾個 annotation,路線一的成本最低:換掉 controller 就好,YAML 一行不改,風險也最小。缺點是 annotation 的相容只涵蓋「一部分」。Traefik 官方文件也寫了某些 annotation 的行為有差異,所以還是要逐項驗證。
路線二是長期比較乾淨的方向,因為 Kubernetes 官方文件說 Ingress API 已經凍結,並建議改用 Gateway。但每個服務都得重寫,白名單、逾時這類能力要換成實作專屬的寫法,等於把鎖定換了一個地方。
所以比較務實的順序是:先用路線一把 controller 換掉,擺脫退役的專案;再依需求,一個服務一個服務搬到 Gateway API。這篇只是把可行性驗過一遍,哪一天真的動手,仍然要在自己的叢集重新驗證,尤其是真實憑證、來源 IP 與多節點的情境。
延伸閱讀
- Ingress NGINX 退役說明(官方 repo)
- Kubernetes:Ingress(含 API 已凍結的說明)
- Traefik:從 ingress-nginx 遷移
- Traefik:Kubernetes Ingress NGINX provider
- ingress2gateway
- cert-manager:Gateway API 支援
Stop Writing Bash Scripts for Everything! Build Your Own DevOps CLI Tool With Go
Imagine having one command that checks your servers, monitors Docker containers, tests API endpoints, and reports infrastructure health.
Instead of maintaining 20 different scripts, you build one powerful CLI tool.
Sounds cool?
That's exactly where Go becomes interesting for DevOps engineers.
Bash is amazing for automation. Python is excellent for scripting. But when you want to build portable, compiled command-line applications that are easy to distribute, Go deserves your attention.
Think about tools like:
- Docker
- kubectl
- Terraform
- Helm
- Hugo
Go is used to build all of these.
So why not build your own?
Let's create a practical DevOps CLI tool from scratch. 🚀
---
🤔 1. Why Should DevOps Engineers Learn Go?
Imagine you're managing multiple servers.
You regularly need to:
- Check whether servers are reachable.
- Monitor API availability.
- Inspect Docker containers.
- Check application health.
- Automate repetitive operations.
- Generate reports.
You could write separate Bash or Python scripts for every task.
But as your automation grows, maintaining those scripts can become difficult.
Go offers some useful advantages:
| Feature | Why it matters |
|---|---|
| Compiled binaries | Easy application distribution |
| Static typing | Catch many errors during development |
| Goroutines | Run concurrent tasks |
| Standard library | Build HTTP clients and servers |
| Cross-compilation | Build executables for different platforms |
| Fast startup | Useful for command-line applications |
The idea isn't to replace Bash or Python.
It's to choose the right tool for the job.
⚡ 2. What Exactly Is a CLI Tool?
CLI stands for Command-Line Interface.
You've probably used these commands:
docker ps
kubectl get pods
terraform plan
git status
These are examples of command-line interfaces.
A CLI tool allows users to interact with software through commands and arguments.
Imagine building your own tool called devopsctl.
Its commands could look like this:
devopsctl server check
devopsctl docker status
devopsctl api health
devopsctl system info
Instead of manually remembering multiple scripts, you have one organized command-line application.
Our goal is to build a small version of this idea.
---
🛠️ 3. Set Up Your Go Environment
First, install Go from:
go.dev
Verify the installation:
go version
Create your project:
mkdir devopsctl
cd devopsctl
Initialize a Go module:
go mod init devopsctl
Create a file:
touch main.go
Your project structure:
devopsctl/
│
├── main.go
├── go.mod
└── README.md
Now let's start building.
🌐 4. Build Your First Server Health Checker
One common DevOps task is checking whether a service is reachable.
Let's build a command that checks multiple URLs concurrently.
Create main.go:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func checkServer(url string, wg *sync.WaitGroup) {
defer wg.Done()
client := http.Client{
Timeout: 5 * time.Second,
}
start := time.Now()
response, err := client.Get(url)
if err != nil {
fmt.Printf("[DOWN] %s: %v\n", url, err)
return
}
defer response.Body.Close()
latency := time.Since(start)
fmt.Printf(
"[UP] %s | Status: %s | Latency: %v\n",
url,
response.Status,
latency,
)
}
func main() {
servers := []string{
"https://google.com",
"https://github.com",
"https://go.dev",
}
var wg sync.WaitGroup
for _, server := range servers {
wg.Add(1)
go checkServer(server, &wg)
}
wg.Wait()
fmt.Println("Health checks completed!")
}
Run it:
go run main.go
Example output:
[UP] https://google.com | Status: 200 OK | Latency: 120ms
[UP] https://github.com | Status: 200 OK | Latency: 180ms
[UP] https://go.dev | Status: 200 OK | Latency: 95ms
Health checks completed!
Your first Go-based DevOps utility is ready.
What did we learn?
- net/http handles HTTP requests.
- sync.WaitGroup coordinates concurrent tasks.
- go starts a goroutine.
- time measures request duration.
- Error handling helps identify unavailable services.
One important detail: this checks whether an HTTP request succeeds at the transport level. A service returning HTTP 500 is still reachable, but its application response indicates an error. A production health checker should evaluate HTTP status codes and application-specific health endpoints.
---
🐳 5. Add Docker Container Monitoring
Now let's make the project more interesting.
What if your tool could inspect Docker containers?
Docker exposes a command-line interface that you can call from Go.
For a simple learning example:
package main
import (
"fmt"
"os/exec"
)
func checkDocker() {
cmd := exec.Command("docker", "ps")
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println("Docker command failed:", err)
return
}
fmt.Println(string(output))
}
func main() {
fmt.Println("Checking Docker containers...")
checkDocker()
}
Run:
go run main.go
This executes:
docker ps
and displays the result.
For a more advanced production tool, consider using the Docker Engine API or official Go SDK rather than parsing command output.
Also, external command execution should use fixed executable paths or carefully validated arguments. Never pass untrusted user input directly into a shell command.
---
📊 6. Turn It Into a Real CLI Application
So far, we've written individual functions.
But a professional CLI should support commands and arguments.
For example:
devopsctl health
devopsctl docker
devopsctl version
Go provides the flag package in its standard library.
Here's a simple example:
package main
import (
"flag"
"fmt"
)
func main() {
health := flag.Bool(
"health",
false,
"Check application health",
)
version := flag.Bool(
"version",
false,
"Show application version",
)
flag.Parse()
if *health {
fmt.Println("Application is healthy!")
return
}
if *version {
fmt.Println("devopsctl version 1.0.0")
return
}
flag.Usage()
}
Run:
go run main.go --health
Output:
Application is healthy!
Check the version:
go run main.go --version
For larger applications, explore Cobra, a popular Go library for building command-line applications.
spf13/cobra
---
🚀 7. Compile Your CLI Into a Single Executable
Here's one of the most satisfying parts of Go.
Build your application:
go build -o devopsctl
Run it:
./devopsctl --health
You can also cross-compile for different operating systems and architectures.
For example, build a Linux AMD64 executable from another supported development environment:
GOOS=linux GOARCH=amd64 go build -o devopsctl-linux
Build for Windows:
GOOS=windows GOARCH=amd64 go build -o devopsctl.exe
Build for macOS on Apple Silicon:
GOOS=darwin GOARCH=arm64 go build -o devopsctl-macos
This makes it convenient to distribute your CLI without requiring users to install a separate Go runtime.
Remember that cross-compilation can depend on native dependencies and build configuration. The examples above are simplest for pure-Go projects.
---
☸️ 8. Take It Further: Build a Kubernetes CLI
Now imagine extending devopsctl with Kubernetes functionality.
You could support commands such as:
devopsctl k8s pods
devopsctl k8s deployments
devopsctl k8s nodes
Go has a powerful Kubernetes ecosystem.
Explore:
- client-go — Kubernetes API client library.
- controller-runtime — Libraries for controllers and operators.
- Kubebuilder — Framework for building Kubernetes APIs and controllers.
Official resources:
kubernetes.io
kubernetes/client-go
With these tools, you can build applications that interact with Kubernetes clusters programmatically.
For example, a Kubernetes monitoring CLI could:
1. Connect to a cluster using kubeconfig.
2. List Pods across namespaces.
3. Identify Pods that are not Ready.
4. Display restart counts.
5. Retrieve selected Pod logs.
6. Generate a troubleshooting report.
That is a much more advanced project than simply running kubectl get pods.
---
🧪 9. Build This Portfolio Project: DevOpsCTL
Here's the project I'd recommend building and publishing on GitHub.
<text>Project: DevOpsCTL — Your Personal DevOps Command-Line Assistant</text>
Core features
| Module | Functionality |
|---|---|
| Server | Check endpoint availability |
| Docker | List and inspect containers |
| Kubernetes | List Pods and Deployments |
| System | Display basic system information |
| API | Check HTTP status and latency |
| Reports | Export health-check results as JSON |
Suggested architecture
DEVOPSCTL
|
+----------+----------+
| | |
v v v
Server Docker Kubernetes
Module Module Module
| | |
v v v
HTTP Docker K8s API
Client SDK Client
| | |
+----------+----------+
|
v
CLI Output
|
v
JSON Reports
Suggested repository structure
devopsctl/
│
├── cmd/
│ └── root.go
│
├── internal/
│ ├── server/
│ ├── docker/
│ ├── kubernetes/
│ └── system/
│
├── main.go
├── go.mod
├── Dockerfile
├── README.md
└── LICENSE
Advanced features to add later
- Prometheus metrics integration.
- YAML configuration support.
- Concurrent health checks with configurable limits.
- Colorful terminal output.
- Unit and integration tests.
- GitHub Actions CI pipeline.
- Release binaries for Linux, macOS, and Windows.
Don't build everything at once. Start with the health checker, then add one module at a time.
---
🗓️ 10. Your 7-Day Go Developer Challenge
Want to actually learn Go? Follow this challenge.
| Day | Task |
|---|---|
| 1 | Learn Go syntax, variables, and functions |
| 2 | Learn structs, slices, maps, and interfaces |
| 3 | Practice goroutines and channels |
| 4 | Build a REST API |
| 5 | Build a server health checker |
| 6 | Integrate Docker or Kubernetes |
| 7 | Publish your first Go CLI project on GitHub |
At the end of seven days, you won't be a Go expert.
But you'll have something much more valuable: a working project and practical experience.
---
🎯 Final Thoughts
Go is not just another programming language to add to your resume.
It's a way to move from writing small automation scripts toward building real infrastructure tools.
You can use it to create:
- DevOps automation utilities.
- Cloud-native applications.
- REST APIs.
- Kubernetes operators.
- Monitoring systems.
- Distributed services.
- Command-line applications.
And the best part?
You don't need to master the entire language before building something useful.
Start small. Build something. Break it. Debug it. Improve it.
Don't just learn tools built by engineers. Start building tools that other engineers can use.
👇 Let's discuss!
If you could build your own DevOps CLI tool, which feature would you implement first?
1. Docker monitoring
2. Kubernetes troubleshooting
3. AWS resource management
4. Server health monitoring
5. CI/CD automation
Share your ideas in the comments!
---
📚 Continue Your Go Learning Journey
Want a structured resource to learn Go?
🐹 Mastering Go — Complete Guide
You can also explore:
- 🐳 Docker Mastery
- ☸️ CKA Complete Study Guide
- 🏗️ Terraform Associate (003) Exam Crash Course
- 🚀 DevOps Complete Pack
Learn Go. Automate intelligently. Build the tools of tomorrow.
5 reactions
LWKD: Week Ending October 25, 2026
Last Week in Kubernetes Development
Week Ending October 25, 2026
Developer News
SIG ContribEx has updated the repo creation guidelines and the WG governance document to clarify how Working Groups relate to code ownership and repository creation, giving future WG proposals a clearer path from charter to code.
SIG Architecture has added notes on future conformance in the developer guide, outlining how upcoming conformance work (including the newly formed WG Workload Conformance) will relate to existing conformance definitions.
SIG ContribEx is coordinating KubeCon EU 2027 Project Pavilion and PR participation for SIGs, WGs, Committees, and Subprojects; interested groups should submit the registration form by November 25.
The Maintainer Summit NA 2026 unconference is accepting topic ideas; sessions are open community-led discussions and need only a topic, a note-taker, and a facilitator attending in Salt Lake City.
The SIG Scheduling leadership change proposed last week has taken effect after lazy consensus closed on October 26: Heba Elayoty is now a SIG Chair, Matt Matejczyk is a SIG Tech Lead, and Kensei Nakada has stepped down from Chair while remaining a Tech Lead.
Release Schedule
Next Deadline: Code Freeze, November 16th (AoE)
November patch releases are targeted for November 10, with cherry-pick PRs due November 6.
Featured PRs
143463: KEP-5710: Log error when PostFilter plugin does not implement PodGroupPostFilterPlugin
jdzikowski added a scheduler initialization check for plugins that implement PostFilter but not PodGroupPostFilterPlugin, as part of KEP-5710. With the GenericWorkload feature gate enabled, the scheduler now logs an error when an enabled plugin will be skipped during scheduling for a PodGroup or CompositePodGroup. Previously, this behavior could go unnoticed, potentially leaving workload-aware scheduling integrations without the expected post-filtering behavior. The check also fulfills one of the KEP's graduation criteria for General Availability.
143387: KEP-5491: Adds integration and e2e test for List Type Attributes
everpeace added integration and end-to-end tests for list-typed matchAttribute and distinctAttribute constraints in Dynamic Resource Allocation (DRA), as specified by KEP-5491. The tests exercise allocation against a real API server and scheduler, covering intersecting and disjoint attribute lists, scalar-to-list matching, and distinctness constraints. These cases help verify that device allocation behaves correctly across different attribute combinations and provide coverage required by the KEP's test plan for beta promotion in Kubernetes v1.38.
KEP of the Week
KEP-6268: Stall and Resume Slow Watch Streams
A slow watch client can fill its kube-apiserver buffer, delaying other watchers before the server force-closes its stream. KEP-6268 instead marks that watcher unsynced, catches it up from shared watch-cache history, and resumes it on the same connection. If the needed events have aged out, the server sends 410 Gone. Clients and the API need no changes.
@nami-ant and @rpb-ant authored the KEP for SIG API Machinery. They revised the design in September to share catch-up work across watchers, following etcd's model. The KEP merged October 1, while the kube-apiserver implementation remains under review behind the off-by-default WatchCacheStallResume gate. KEP-6268 is implementable and targets Alpha in v1.38; Beta and Stable milestones are TBD.
Other Merges
- Fix for a kubelet bug that set pod sandbox CPU and memory limits to the partial sum of container limits when only some containers declared them
- kube-scheduler's preemption disruption metric gains a victim label; PreemptionVictim gains a Type() method and PreemptionCandidate.NumPodGroupDisruptions() is replaced by NumDisruptionsByVictimType()
- MutatingAdmissionWebhook config gains a staticOrder field to control manifest vs. API-registered webhook ordering; requires the ManifestBasedAdmissionControlConfig gate and must be removed before downgrading to v1.37 or earlier
- A new evictionrequest-controller creates Evictions from EvictionRequests and coordinates external responders
- Add cgroup_mount_mode to the CRI API to support writable cgroups
- kubectl selector commands like create pdb --selector now accept !=
- client-go adds DropManagedFields to omit managedFields from API responses, behind the new alpha ManagedFieldsOptOutClient gate
- kubelet adds the alpha kubelet_device_plugin_devices metric, breaking device counts down by health state
- Watch cache keeps the last snapshot behind the compaction window, avoiding etcd fallback for Exact LIST
- Workload-aware scheduling: Pod scheduling status now names its parent PodGroup and PlacementFeasible counts are clearer, stricter CompositePodGroup validation, recursive status propagation, nodes with empty topology label values skipped as placement domains, multiple placement plugins intersected, and TopologyAwareWorkloadScheduling promoted to Beta (still disabled by default)
Subprojects and Dependency Updates
- etcd has released v3.7.3, v3.6.16, and v3.5.35, including multiple security patches, as well as fixing a few v3.7 regressions.
- vertical-pod-autoscaler v1.8.1 and vertical-pod-autoscaler v1.7.3 were both released. They are built with Go 1.26.10, blocking the compromised eMudhra intermediate CAs.
- etcd v3.7.3: New patch release, see CHANGELOG; also v3.6.16, v3.5.35
- coredns v1.15.0: Fixes CVE-2026-56865, CVE-2026-56864, CVE-2026-33818, CVE-2026-97032, CVE-2026-78659, CVE-2026-97031, and CVE-2026-111943; adds DNS-over-QUIC forwarding support, opt-in JSON query logging, and authenticated RFC 2136 dynamic DNS updates
- prometheus v3.15.1: Builds with Go 1.27.3 and bumps golang.org/x/net to v0.60.0 to fix HTTP/2 vulnerabilities; also v3.13.5
- vsphere-csi-driver v3.8.0: Adds support for Kubernetes 1.36, upgrades csi-proxy to v2.0.0-alpha.2, and bumps Go dependencies to address CVEs
Shoutouts
- No shoutouts this week. Want to thank someone for special efforts to improve Kubernetes? Tag them in the #shoutouts channel.
LWKD is a publication of Kubernetes Project Contributor Communications. Read it on the web, and contribute or send corrections to the GitHub repository.
Powered by Buttondown, the easiest way to start and grow your newsletter.
updated the repo creation guidelines: community#9232
WG governance document: community#9228
added notes on future conformance: community#9227
November 16th (AoE): github.com/kubernetes/sig-release/tree/master/rel…
143463: KEP-5710: Log error when PostFilter plugin does not implement PodGroupPostFilterPlugin: kubernetes#143463
jdzikowski: github.com/jdzikowski
KEP-5710: enhancements#5710
143387: KEP-5491: Adds integration and e2e test for List Type Attributes: kubernetes#143387
everpeace: github.com/everpeace
KEP-5491: enhancements#5491
KEP-6268: Stall and Resume Slow Watch Streams: github.com/kubernetes/enhancements/blob/master/ke…
@nami-ant: github.com/nami-ant
@rpb-ant: github.com/rpb-ant
KEP merged October 1: enhancements#6269
kube-apiserver implementation: kubernetes#141450
Beta and Stable milestones are TBD: enhancements#6268
kubelet bug: kubernetes#141014
gains a victim label: kubernetes#142955
staticOrder field: kubernetes#143160
evictionrequest-controller: kubernetes#137133
cgroup_mount_mode to the CRI API: kubernetes#139824
create pdb --selector: kubernetes#142216
DropManagedFields: kubernetes#142695
kubelet_device_plugin_devices: kubernetes#143223
keeps the last snapshot behind the compaction window: kubernetes#143325
now names its parent PodGroup: kubernetes#143184
PlacementFeasible counts: kubernetes#143183
CompositePodGroup validation: kubernetes#142830
recursive status propagation: kubernetes#141860
empty topology label values skipped: kubernetes#140308
multiple placement plugins intersected: kubernetes#139565
promoted to Beta: kubernetes#140295
vertical-pod-autoscaler v1.8.1: github.com/kubernetes/autoscaler/releases/tag/ver…
vertical-pod-autoscaler v1.7.3: github.com/kubernetes/autoscaler/releases/tag/ver…
etcd v3.7.3: github.com/etcd-io/etcd/releases/tag/v3.7.3
v3.6.16: github.com/etcd-io/etcd/releases/tag/v3.6.16
v3.5.35: github.com/etcd-io/etcd/releases/tag/v3.5.35
coredns v1.15.0: github.com/coredns/coredns/releases/tag/v1.15.0
prometheus v3.15.1: github.com/prometheus/prometheus/releases/tag/v3.…
v3.13.5: github.com/prometheus/prometheus/releases/tag/v3.…
vsphere-csi-driver v3.8.0: github.com/kubernetes-sigs/vsphere-csi-driver/rel…
to the GitHub repository: kubernetes-sigs/lwkd
kubecost 3.x を GKE に入れてみた — 入れる前に知っておきたかった 9 つのこと
はじめに
Kubernetes のクラスタは、誰がいくら使っているのかが見えにくいです。
クラウドの請求書には「ノード 3 台分」としか書かれておらず、その上で動くアプリごとの内訳は出てきません。
それを見えるようにするツールが kubecost です。検証用の GKE クラスタに入れてみました。
入れること自体は Helm で 1 コマンドですが、既定のまま入れると困る点がいくつもありました。
この記事は、実際に入れて確かめた「入れる前に知っておきたかったこと」のまとめです。
対象は、Kubernetes を触り始めて「requests って何となく書いている」くらいの方です。requestsは後述で説明します。
確かめた環境
項目
内容
クラスタ
GKE(検証用)。手元の確認には minikube も使用
kubecost
Helm chart kubecost/kubecost 3.2.4、無償版(Foundations)
見る方法
まずは kubectl port-forward。その後 Kong 越しの公開を準備
まず、何が見えるのか
namespace ごとのコスト
Monitor/Allocatiomsにnamespace(クラスタの中の区画)ごとに、CPU・メモリ・ディスク・ロードバランサーのコストが並びます。
注目してほしいのは idle の行です。これは「ノードとして借りているのに、どの Pod にも割り当てられていない分」です。
このクラスタでは合計 $16.83 のうち $8.71 が idle でした。半分以上が、誰も使っていない分の料金です。
何にお金がかかっているか
Monitor/Assetsでクラウドの資源の内訳を見ることができます。ほとんどがノード代だと分かります。
どこを削れるか
Savingsでは削減の候補が金額つきで並びます。一番大きいのは「Right-size container requests」、つまりコンテナの requests の見直しでした。
!
requests とは
requests は「このコンテナはこれだけ CPU とメモリを使う予定です」という申告です。
Kubernetes はこの申告をもとに Pod をノードに詰めます。申告が実際より大きいと、使っていない分までノードを押さえてしまい、その分の料金を払うことになります。
コンテナごとに、今の申告(current)と、実際の使用量から計算した推奨値(rec)が出ます。
この画面は記事の最後でもう一度使います。
!
なお、入れてまだ 2 日目のスクリーンショットなので、数字は粗いです。
入れる前に知っておきたかった 9 つのこと
1. 2.x と 3.x は別物。検索で出てくる情報の多くは 2.x
最初につまずいたのがここです。
2.x
3.x
Helm chart の名前
kubecost/cost-analyzer
kubecost/kubecost
Prometheus
必須
不要
データの置き場
Prometheus
ClickHouse(同梱)
2.x の公式ドキュメントには、Prometheus は省略できず、無効にするとコストが 0 になる、と書かれています。
検証用クラスタにはPrometheusサーバがない(外部送信のみ)ため、同時構築が必要な2.xではなく3.xを選びました。
!
chart の名前もリポジトリの URL も違うので、2.x の記事の手順をそのまま 3.x に使うことはできません。
また、2.x の最新である 2.9 は 3.x への移行準備用の版で、新規に利用する場合は適していません(helm template がエラーで止まります)。
2. 既定のまま入れると、強い権限を持つ部品が一緒に入る
chart を既定値で展開すると 8 つの部品が出てきます。そのうち 2 つは、入れる前に権限を確認した方がよいものでした。
部品
持っている権限
何のための部品か
networkCosts
ノードのネットワークを直接使う特権コンテナ。全 namespace の Secret を読める
通信量ごとのコスト
clusterController
クラスタの資源を作成・変更・削除できる
推奨値を自動で適用する
Secret には、データベースのパスワードなどが入っています。コストを見るツールに、そこまでの権限を渡す必要はありませんでした。
この 2 つを無効にすると、残る権限は読み取りだけになります。
networkCosts:
enabled: false
clusterController:
enabled: false
!
「無効にすると requests の推奨値が出なくなるのでは」と最初は思いましたが、出ました。
clusterController は推奨値を適用するための部品で、表示するだけなら要りません。
3. ディスクを既定で 161Gi 要求する。消しても残る
デフォルトのままだと、永続ディスクを合計 161Gi を必要とします。検証用には大きすぎるので、8Gi まで削りました。
!
もう 1 つ、helm uninstall してもディスク(PVC)が消えません。消さずに残す印が chart 側で付いているためです。
試しに入れて消す、を繰り返すと、使われないディスクが課金されたまま残ります。消すときは PVC も手で消します。
kubectl get pvc -n kubecost
4. 既定では、利用状況を外部に送信する
デフォルトでは、利用状況の送信とログの収集が有効です。社内のクラスタに入れるなら、先に決めておく項目です。
telemetry:
enabled: false
reporting:
logCollection: false
無効にした状態でブラウザの通信を見たところ、画面からの通信はすべて kubecost 自身に向いていました。
5. Spot を使っているなら、ラベルを教えないと高く見積もられる
Spot(クラウドの余った計算資源を安く借りる仕組み。GKE では Spot VM)のノードは、通常より大幅に安いです。
kubecost は「このノードは Spot か」をノードのラベルで判断します。どのラベルを見るかを教えないと、通常価格で計算され、コストがずっと高く出ます。
GKE の場合はこうです。
finopsagent:
agent:
kubecost:
spotLabel: cloud.google.com/gke-spot
spotLabelValue: "true"
このラベルはクラウドによって違います。同じクラウドでも、ノードの増やし方(Karpenter を使うかどうか)で変わります。
6. 手元のクラスタで数字が出ても、正しいとは限らない
minikube(手元の PC で動かす Kubernetes)でも、コストの数字は出ます。
ただし、これは実際の料金ではなく、kubecost が持っている仮の単価で計算したものです。
!
「手元で数字が出た」は「動いた」の確認にはなりますが、「金額が合っている」かの確認にはなりません。
金額の確認は、実際のクラウドの上で、請求と見比べて行います。
7. 無償版でできないことは、API が教えてくれる
無償版(Foundations)には制限があります。どこまで使えるかは、実際に叩くのが確実でした。
やったこと
結果
30 日ぶんのデータを要求
拒否。上限は 15 日(360 時間)
ResourceQuota の推奨値
拒否(有償版が必要)
コンテナの requests の推奨値
使える
kubecost 自身のログイン機能(OIDC)
使えない(有償版が必要)
最後の行が次の項目につながります。
8. アラートは「壊れたときだけ」届く
kubecost 自身の状態を見張って Slack に知らせる機能(health アラート)があります。
設定に「1 時間ごと」とあったので、1 時間ごとに通知が来ると思っていましたが、来ませんでした。
1時間ごとに毎回通知が来るわけではなく、通知は『異常があった時だけ』です。
通知の経路が生きているかは、Alerts 画面の「Test Alert」ボタンで確かめます。
!
「通知が来ない」を「壊れている」と読んでも、「正常」と読んでも間違えます。確かめる手段を別に持っておくのが大事でした。
9. 画面にログイン機能が無い。公開するなら手前で守る
!
無償版の kubecost には、ユーザーもロールもありません。URL に届いた人は全員、すべての画面と API を使えます。
port-forward で自分だけが見るなら問題ありませんが、チームで見るために公開するなら、手前に門番が要ります。
私たちは API ゲートウェイの Kong を手前に置き、4 種類の守りを重ねる設計にしました。(これから実装していく予定です!)
守り
見るもの
IP 制限
どこから来たか
ログイン(Keycloak)
誰か
ロールの検査
管理者か
特定パスの拒否
入れた人にも見せないもの
公開前に確かめたところ、穴が 2 つ見つかりました。1 つ目(IP 制限が 3 回に 1 回外れる)は前の記事に書きました。ここでは 2 つ目を書きます。
拒否したはずのパスが %2F ですり抜けた
kubecost の API の中に、Slack の webhook の URL を返すものがあります(/model/v3/alerts 以下)。
webhook の URL は、知っていれば誰でもそのチャンネルに投稿できる合鍵です。管理者にも見せる必要はないので、このパスだけ Kong で拒否しました。
rules:
- matches:
- path: {type: PathPrefix, value: /model/v3/alerts}
叩くと 403 が返りました。ここで終わりにするところでしたが、形を変えて試しました。
リクエストのパス
結果
/model/v3/alerts/global/slack
403
//model//v3/alerts/global/slack
403
/model/x/../v3/alerts/global/slack
403
/model%2Fv3/alerts/global/slack
200。webhook が返る
/model/x%2F..%2Fv3/alerts/global/slack
200。webhook が返る
%2F は / を別の書き方にしたもの(URL エンコード)です。
原因は、同じ文字列を 2 者が別の読み方をしていたことでした。
Kong は、照合の前にパスを整えます(// をまとめる、../ を畳む)。ただし %2F は / に戻しません。URL の決まりでは、エンコードされた / は「区切りではない、ただの文字」を意味するからで、Kong の動きは仕様どおりです
kubecost の画面側の nginx は、%2F を / に戻してから中継先を決めます
どちらも単体では間違っていません。検査する者と使う者で読み方がずれていたことが穴でした。
直し方は、%2F を含むパスをまとめて拒否する条件を足すことでした。
rules:
- matches:
- path: {type: PathPrefix, value: /model/v3/alerts}
- path: {type: RegularExpression, value: "/.%2[Ff]."}
これで全部 403 になり、通常の画面は壊れませんでした。ここまでは手元の minikube(Kong 3.9)で確かめた内容です。
!
正常な形で 403 が出るのは「設定が書けた」の確認です。「塞いだ」の確認は、形を変えた入力でやります。
「ほかに漏れる API は無い」の確かめ方
webhook を返す API がほかに無いかも確かめました。kubecost のバイナリからパスらしい文字列を 32,329 本取り出し、全部叩きました。
このとき、本物の webhook は使いません。検査のログに本物の秘密が残るからです。
かといって何も設定せずに叩くと、0 件でも「漏れない」のか「返すものが無いだけ」なのか区別できません。
そこで、一目で偽物と分かる文字列を webhook として設定し、先に「漏れると分かっているパス」で検出できることを確かめてから総当たりしました。
CANARY=CANARYWEBHOOK0001
先に確認: 漏れると分かっているパスで見つからなければ、検査の方が壊れている
curl -s "${BASE}/v3/alerts/global/slack" | grep -q "${CANARY}" \
|| { echo "検査が機能していない" >&2; exit 1; }
while read -r p; do
curl -s "${BASE}${p}" | grep -q "${CANARY}" && echo "LEAK ${p}"
done < candidates.txt
返したのは 2 本だけで、どちらも拒否済みのパスの下でした。
!
副産物として、Helm の設定値を丸ごと返す API も見つかりました。
設定値に秘密を直接書いていたら、そこから読めていたことになります。秘密は設定値に書かず Secret で渡す、という決まりの理由が 1 つ増えました。
見えた。では、どう直すか
最後に、requests の推奨値の画面に戻ります。
このクラスタでは、申告が多すぎるものと、足りないものの両方がありました。
コンテナ
メモリの申告(current)
推奨値(rec)
状態
データベース層のアプリ
4Gi
568Mi
多すぎる
kubecost の収集役
512Mi
244Mi
多すぎる
Kong
256Mi
1310Mi
足りない
Keycloak
512Mi
819Mi
足りない
多すぎる申告はお金の無駄になり、足りない申告は、ノードが混んだときに Pod が追い出される原因になります。
コストを見るつもりで入れたツールが、安定性の問題も見つけてくれました。
ただし、kubecost が出すのは推奨値の表示までです。
そして、この数字を人が読んで設定ファイルを書き換える運用は、アプリが増えると続きません。使用量は日々変わるからです。
そこで次に試したいのが、Kubernetes の 2 つの自動調整の仕組みです。
仕組み
何を変えるか
向いている場面
VPA(Vertical Pod Autoscaler)
1 つの Pod の requests を、実際の使用量に合わせて上げ下げする
申告が実態とずれているとき
HPA(Horizontal Pod Autoscaler)
Pod の数を、負荷に合わせて増減する
時間帯で負荷が変わるとき
VPA を入れれば、多すぎる申告が自動で下がり、その分ノードを減らせるかもしれません。足りない申告も自動で上がります。
HPA を入れれば、負荷の低い時間帯は Pod を減らしておけます。
どちらも、kubecost が見せてくれた「ズレ」を、人の手を借りずに直し続けてくれる仕組みです。
!
本当にコストが下がるのかは、まだ試していません。
次は VPA と HPA を入れて、kubecost の数字が入れる前と後でどう変わるかを見てみるつもりです。
まとめ
kubecost は 2.x と 3.x で構成が違う。Prometheus が無いクラスタなら 3.x
既定のまま入れない。権限の強い部品、大きなディスク、外部への送信を先に確認する
Spot のラベルを教えないと、コストが高く出る
手元で数字が出ても、金額が正しい証拠にはならない
無償版にはログイン機能が無い。公開するなら手前で守り、形を変えた入力で確かめる
推奨値は表示まで。VPA と HPA を入れればコストも下げられるかもしれないので、次に試す
参考文献
Kubecost Core Architecture Overview(3.x) — 3.x の部品の構成
Kubecost Core Architecture Overview(2.x) — 2.x は Prometheus が必須であること
kubecost/kubecost(Helm chart) — 3.x の chart と既定値
OpenCost — kubecost のコスト計算の元になっているオープンソース
Resource Management for Pods and Containers(Kubernetes) — requests と limits の意味
HTTPRoute(Gateway API) — パスの照合方法(PathPrefix / RegularExpression)
Route(Kong Gateway) — Kong のルートの照合とパスの扱い
Request Termination プラグイン(Kong) — その場で決まった応答を返すプラグイン
ngx_http_core_module の location(nginx) — 照合は %XX をデコードした後のパスで行われること
RFC 3986 Uniform Resource Identifier — 予約文字と、エンコードされた予約文字の扱い(2.2 節、6.2.2 節)
Autoscaling Workloads(Kubernetes) — VPA と HPA の全体像
Horizontal Pod Autoscaling(Kubernetes)
Vertical Pod Autoscaler(kubernetes/autoscaler)
Vertical Pod Autoscaling(GKE)
執筆者紹介 / 会社情報
執筆者
Higu
BREXA SOLVIA SES
アサイン先 株式会社Scalar
最近は Terraform・AWS・GCP・Kubernetes を勉強しています。わからないことだらけですが少しずつ整理しながら書いています!
会社情報
Scalar, Inc は、「データマネジメントの未来を創る」を掲げ、データベースミドルウェアの研究開発・販売を行う日本発のグローバルスタートアップ企業です!複数の異なるデータベース(RDB、NoSQL など)を仮想的に統合し、トランザクション処理や分析クエリを実現するデータベースミドルウェアである ScalarDB、分散台帳技術(DLT)を用いたソフトウェアで、データの改ざん検知を行う ScalarDL を主な製品としています。是非下記の弊社サイトもチェック📝をお願いします!
🔧 Product
🧑💻 開発者ブログ
🏢 コーポレートサイト
@trq212 so two things:
1) @bgrant0607, one of the OG kubernetes architects, wrote about drift/forking in K8s, and while code is different i think some concepts that went into the design of the kustomize CLI are at play here - github.com/kubernetes/design-proposals-archive/bl…
2) i think @0xblacklight 's work on a "design-iterated-loop" skill and his talk at AIE captures maybe the shape of "how can we drive agents to keep our fork up to date w/ upstream while preserving our changes" youtube.com (also a very k8s/control-loop-pilled approach)
11 likes · 995 views
klog, zap, logrus などで stderr に吐かれるログのレベルを Datadog で良い感じにする
こんにちは、@key60228 です。
新しく立ち上げた GKE Cluster に Datadog Agent を入れてログを有効化してみたところ、とんでもない数のエラーログが吐かれていました。
まだ何も特別なことをしていないのに…???と不思議に思いログの詳細を見てみたところ、特に問題なさそうなログがほとんどでした。
結論としては、各コンポーネントが吐くログが Datadog が解釈できる形式で構造化されておらず、エラーレベルとして判定されてしまっていることが原因でした。
というわけでこの記事では各コンポーネント、ロガーがどんな構造で出力していて、どうすれば正しいログレベルで表示できるのかについてまとめていければと思います。
ソリューションを求めている方へ
Datadog のログパイプラインで加工してあげてください。
Terraform
pipelines.tf
resource "datadog_logs_custom_pipeline" "container_log_level" {
name = "container-log-level"
description = "stderr 由来で error になったコンテナログの status を、本文の klog / zap / logfmt のレベルから付け直す。"
is_enabled = true
filter {
query = "kube_cluster_name:*"
}
processor {
grok_parser {
name = "klog / zap / logfmt からレベルを取り出す"
is_enabled = true
source = "message"
samples = [
"I0911 07:22:25.408586 1 cni_writer.go:930] Successfully synced \"gke-xxxxx-xxxxx-nod-xxxxxxxx-xxxx\"", # klog (GKE cni-writer)
"E0911 07:10:07.225293 1 base_controller.go:161] \"controller failed to sync\" err=\"...\" key=\"image-sync\"", # klog (OCM work-agent)
"2026-09-11T07:19:45Z\tINFO\tcontrollers.Spoke\trequeueing\t{\"request\": {\"name\":\"xxx\",\"namespace\":\"fleetconfig-system\"}, \"after\": \"1m0s\"}", # zap console、タブ区切り (fleetconfig-controller)
"2026-09-11T04:39:22.159Z error logging/logging.go:16 NodeCollector: Failed to export metrics {\"kind\": \"receiver\"}", # zap console、スペース区切り (gke-metrics-agent)
"time=2026-09-11T06:13:28Z level=info msg=Reconciling subsys=sfc reasons=[...]", # logfmt / logrus (cilium)
]
grok {
support_rules = ""
match_rules = file("${path.module}/grok/container_log_level.grok")
}
}
}
processor {
lookup_processor {
name = "klog の severity 1 文字をレベル語に直す"
is_enabled = true
source = "klog.severity"
target = "level"
lookup_table = [
"I,info",
"W,warning",
"E,error",
"F,critical",
]
}
}
processor {
lookup_processor {
name = "zap のレベルを status remapper が解釈できる語に直す"
is_enabled = true
source = "zap.level"
target = "level"
lookup_table = [
"DEBUG,debug",
"INFO,info",
"WARN,warning",
"ERROR,error",
"DPANIC,critical",
"PANIC,critical",
"FATAL,critical",
]
}
}
processor {
lookup_processor {
name = "logrus のレベルを status remapper が解釈できる語に直す"
is_enabled = true
source = "logfmt.level"
target = "level"
lookup_table = [
"trace,debug",
"debug,debug",
"info,info",
"warning,warning",
"error,error",
"fatal,critical",
"panic,critical",
]
}
}
processor {
status_remapper {
name = "level を status にする"
is_enabled = true
sources = ["level"]
}
}
}
grok/container_log_level.grok
klog %{regex("[IWEF]"):klog.severity}%{regex("\d{4} \d{2}:\d{2}:\d{2}\.\d+")}\s+%{integer:klog.thread_id}\s+%{regex("[^\]]+"):klog.location}]\s?%{data:klog.message}
zap %{regex("\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})"):zap.timestamp}[\t ]+%{regex("(DEBUG|INFO|WARN|ERROR|DPANIC|PANIC|FATAL|debug|info|warn|error|dpanic|panic|fatal)"):zap.level:uppercase}[\t ]+%{data:zap.message}
logfmt_with_time time=%{notSpace:logfmt.time}\s+level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message}
logfmt level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message}
Datadog Log Management のログレベルの仕組み
Datadog のログでは status という予約済み属性が Log Explorer で表示されるログレベルに対応しています。1
Datadog Agent を使っている場合、この status は次の3つのタイミングで決まります。
Datadog Agent がログを送る時
Datadog が JSON ログをパースする時
ログパイプラインの Status Remapper を通る時
① Datadog Agent がログを送る時
Kubernetes の Datadog Agent (各ノードの DaemonSet) では stdout に出たものを info、stderr に出たものを error と判定して Datadog に送っているようです。
github.com/DataDog/datadog-agent/blob/f85c4980d2b…
② Datadog が JSON ログをパースする時
次に、ログが JSON 形式であれば、Datadog は取り込み時の前処理で status、severity、level などの属性を探し、見つかった値で status を上書きします。2
{"level":"info","msg":"..."} のようなログは、stderr に書かれていても info として扱われます。
③ ログパイプラインの Status Remapper を通る時
最後に、ログパイプライン に Status Remapper を置くと、任意の属性の値で status を上書きできます。
Status Remapper は指定された属性の値を先頭の文字列で以下の通り判定してくれます。3
値
status
0 から 7 の整数
Syslog の規格に従う
emerg または f で始まる文字列 (大文字小文字を区別しない)
emerg (0)
a で始まる文字列 (大文字小文字を区別しない)
alert (1)
c で始まる文字列 (大文字小文字を区別しない)
critical (2)
err で始まる文字列 (大文字小文字を区別しない)
error (3)
w で始まる文字列 (大文字小文字を区別しない)
warning (4)
n で始まる文字列 (大文字小文字を区別しない)
notice (5)
i で始まる文字列 (大文字小文字を区別しない)
info (6)
d、t、v、trace、verbose で始まる文字列 (大文字小文字を区別しない)
debug (7)
o または s で始まる文字列、または OK か Success に一致する文字列 (大文字小文字を区別しない)
OK
上記以外
info (6)
つまり、プレーンテキストの本文にレベルを埋め込んでいるログは、②の前処理では拾われず、③のパイプラインを用意しない限り①のストリームだけで status が決まります。
今回の自分の環境のエラーログは、まさにこのパターンでした。
対策と各コンポーネント/ロガーの仕組み
根本的には各コンポーネントが JSON でログを吐くか、レベルに応じて stdout/stderr を使い分けていればこういった問題は起きません。
しかし GKE が管理するシステムコンポーネントは出力形式をこちら側では変えられず、OSS のコンポーネントも全てが出力形式を選べるわけではありません。
また、Datadog Agent でストリームと status の mapping を変更する方法も現時点ではなさそうです。
となるとメッセージ本文からレベルを取り出し、Status Remapper で status 属性を更新するログパイプラインを用意するのが良さそうです。
今回の自分の環境で問題になったログは、出力元のロガーで分けると klog、zap、logrus の3種類でした。
これらのロガーで、どのようにレベルを抽出すれば良いか見ていきます。
klog
klog は glog の fork で、Kubernetes 本体も使用しているロガーです。
kubernetes/klog
今回の環境では、GKE のシステムコンポーネント (cni-writer、gke-metadata-server、proxy-agent etc.) や cert-manager などが klog かつ stderr でログを吐いていました。
klog の構造は以下のようになっており、先頭の L が severity を指しています。
Lmmdd hh:mm:ss.uuuuuu threadid file:line] msg
github.com/kubernetes/klog/blob/ae3961e316a310e71…
severity は I が INFO、W が WARNING、E が ERROR、F が FATAL を指します。
github.com/kubernetes/klog/blob/ae3961e316a310e71…
例えば proxy-agent の次のログは INFO ですが、stderr に書かれているため Datadog 上では ERROR になっていました。
そこで、まずは Grok Parser で属性をパースしてあげます。
klog %{regex("[IWEF]"):klog.severity}%{regex("\d{4} \d{2}:\d{2}:\d{2}\.\d+")}\s+%{integer:klog.thread_id}\s+%{regex("[^\]]+"):klog.location}]\s?%{data:klog.message}
上記ルールで IWEF の値を klog.severity に書き出しましたが、klog の場合はこの severity をそのまま Status Remapper に渡すことはできません。
I と W は期待通り info と warning になり、F は emergency になりますが、E は何にも当てはまらず、info になってしまいます。
そこで、Lookup Processor で klog.severity を調整してから level 属性に入れます。 (ついでに F も critical に調整しています)
I,info
W,warning
E,error
F,critical
その後 Status Remapper を挟んであげることで、proxy-agent の INFO ログは info として表示されるようになりました。
zap
zap は Uber が開発しているロガーで、Kubernetes 周りでは controller-runtime を使ったコントローラーの多くが zap を使っています。
uber-go/zap
今回の環境では、gke-metrics-agent や 別記事 で導入していた Open Cluster Management の fleetconfig-controller が zap でログを吐いていました。
zap には JSON で出力する JSON encoder と、人が読みやすいテキストで出力する console encoder があります。
github.com/uber-go/zap/blob/4892335e05f14bce8a98a…
console encoder はデフォルトでは、タイムスタンプ、レベル、ロガー名、呼び出し元、メッセージ、追加フィールド (JSON) を区切り文字でつないで 1 行にします。
TIMESTAMP LEVEL LOGGER CALLER MESSAGE {FIELDS}
zap の console encoder の出力は、次のルールで分解します。
zap %{regex("\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})"):zap.timestamp}[\t ]+%{regex("(DEBUG|INFO|WARN|ERROR|DPANIC|PANIC|FATAL|debug|info|warn|error|dpanic|panic|fatal)"):zap.level:uppercase}[\t ]+%{data:zap.message}
同じ console encoder でも、出力の細かいところはコンポーネントによって異なっているため、区切り文字はタブと半角スペース、レベルは大文字と小文字の両方に対応しています。
zap のレベルは DEBUG、INFO、WARN、ERROR、DPANIC、PANIC、FATAL とあり、今のままだと DPANIC が debug に、PANIC が info になってしまいます。
ついでに FATAL は klog に合わせて critical として扱うよう、Lookup Processor で調整してあげます。
github.com/uber-go/zap/blob/4892335e05f14bce8a98a…
"DEBUG,debug",
"INFO,info",
"WARN,warning",
"ERROR,error",
"DPANIC,critical",
"PANIC,critical",
"FATAL,critical",
なお、controller-runtime の zap のオプションをコマンドライン引数に結び付けているコントローラーであれば、--zap-encoder=json を渡して JSON encoder に切り替えられます。
JSON encoder の出力には level 属性が含まれるので、前処理で status に反映され、パイプラインは要りません。
logrus
logrus もメジャーな構造化ロガーで、今回の環境では Zitadel が logrus でログを出していました。
sirupsen/logrus
出力形式は TextFormatter と JSONFormatter から選べ、TextFormatter は端末以外に出力するとき key=value を並べた logfmt 風の形式になります。
time="2026-10-13T05:58:18Z" level=INFO msg="structured logger configured" caller="..." error="..."
レベルは level= の後ろに info、warning、error のような語で書かれます。
スクリーンショットのログは level=INFO ですが、Datadog 上では ERROR になっていました。
logrus の TextFormatter の出力は、次の 2 つのルールで分解します。
logfmt_with_time time=%{notSpace:logfmt.time}\s+level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message}
logfmt level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message}
TextFormatter は設定によってタイムスタンプを出さないことがあるため、time= で始まる行と level= で始まる行の両方に対応しています。
今回のログのように時刻を time="..." と引用符で囲む場合も、引用符ごと notSpace で logfmt.time に取り込まれるので問題ありません。
logrus のレベルは以下の通りなので、zap 同様に Lookup Processor で調整してあげましょう。
github.com/sirupsen/logrus/blob/6ef0748d1177dadae…
"trace,debug",
"debug,debug",
"info,info",
"warning,warning",
"error,error",
"fatal,critical",
"panic,critical",
これでログレベルが正しく表現されるようになりました。
!
この記事の本筋とズレるので本文では言及しませんでしたが、ZITADEL はオプションでログフォーマットを JSON 化できます。4
まとめ
パイプラインを適用したあとは、status:error のログがほとんどなくなりました。
これで万事解決…とは思いつつ、klog、zap、logrus とは異なる出力形式を持つロガーを採用しているコンポーネントが出てきたらその都度対応しないといけない…? 🤔
GKE とか各 KaaS のシステムコンポーネント以外は大概ログの format などをこっちで指定できるから特に問題にならないのかな〜〜とは思いつつ、こんな複雑なことをしないといけないのはちょっと不思議な気がするな〜〜 5
もっと良い方法あるよって方、もし居たら教えてください〜〜
最後に
AI Shiftではエンジニアの採用に力を入れています!少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
hrmos.co
脚注
属性とエイリアス設定 - Datadog Docs ↩︎
Pipelines (Preprocessing) - Datadog Docs ↩︎
Log Status Remapper - Datadog Docs ↩︎
ZITADEL Production Setup - ZITADEL Docs ↩︎
「こんな複雑なことをしないといけないのはおかしい」というソフトウェアエンジニアの勘 - valid,invalid ↩︎
HPA は kubectl scale の台数を守らない
CPU 使用率は低いのに、Pod の台数を増やしたいときがある。1 台あたりのワーカーが下流の応答待ちで埋まっているときや、イベントの前に台数を確保しておくときだ。そういうとき、HPA(HorizontalPodAutoscaler)が付いた Deployment でも、kubectl scale で replicas を増やすことがある。HPA は、CPU 使用率などのメトリクスから Pod の数を決めて、Deployment の replicas を書き換えるコントローラーだ。
増やした直後は、kubectl get deploy の READY が増やした数でそろう。HPA もすぐには戻さないので、そのまま保たれるように見える。
数分後に見ると、元の数に戻っている。エラーも警告も出ていない。HPA が戻したのなら、なぜ戻すまでに数分かかるのかが分からない。
この状態を再現し、戻したのが本当に HPA なのか、なぜ数分後に戻るのかを確かめた。
再現
冒頭の状態を、kind で立てた Kubernetes v1.37.0 のクラスタで再現した1。
CPU をほとんど使わない Pod を 2 台で動かす Deployment に、HPA を付けた。HPA は、CPU 使用率 50% を目標に、minReplicas 2、maxReplicas 5 で設定した。HPA は 15 秒ごとに数を決め直す。この 1 回の処理を、以下では同期と呼ぶ。
$ kubectl get hpa c1-1
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
c1-1 Deployment/c1-1 cpu: 0%/50% 2 5 2 81s
この Deployment を、kubectl scale で 2 台から 4 台に増やした。
$ kubectl scale deploy/c1-1 --replicas=4
deployment.apps/c1-1 scaled
増やした直後は、READY が 4/4 にそろった。
$ kubectl get deploy c1-1
NAME READY UP-TO-DATE AVAILABLE AGE
c1-1 4/4 4 4 107s
約 5 分後に、2 台に戻った。
$ kubectl get deploy c1-1
NAME READY UP-TO-DATE AVAILABLE AGE
c1-1 2/2 2 2 7m17s
切り分け
Deployment の replicas を書き戻せるのは、ほかの人や CI が実行する kubectl apply、GitOps のツール、HPA などのコントローラーだ。
Kubernetes のオブジェクトには managedFields が付いている。オブジェクトのフィールドごとに、どのマネージャー(書き手)が書いたかの記録だ2。2 台に戻ったあとの Deployment を kubectl get deploy c1-1 -o yaml --show-managed-fields で見ると、spec.replicas のマネージャーが kube-controller-manager だった。
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:spec:
f:replicas: {}
manager: kube-controller-manager
operation: Update
subresource: scale
kube-controller-manager は、HPA などのコントローラーをまとめて動かす、クラスタ内のプロセスだ。kubectl apply や GitOps のツールが書いたなら、マネージャーには kube-controller-manager 以外の名前が入る。
どのコントローラーが書いたかは、Event で分かる。Event は、Kubernetes がオブジェクトごとに残す出来事の記録だ。kubectl events --for hpa/c1-1 で見ると、戻ったときに SuccessfulRescale が出ていた3。New size: 2 は、HPA が replicas を 2 に書き換えたことを示す。戻したのは HPA だ。
$ kubectl events --for hpa/c1-1
LAST SEEN TYPE REASON OBJECT MESSAGE
16s Normal SuccessfulRescale HorizontalPodAutoscaler/c1-1 New size: 2; reason: All metrics below target
残るのは、冒頭の問いのうち、HPA が戻すまでになぜ約 5 分かかるのかだ。
何が起きているか
同期の処理
HPA は同期のたびに、次の処理を行う。
Pod の CPU 使用率を読む
使用率の平均を目標で割って、比を出す。比から Pod の台数を計算する。以下では、この数を計算値と呼ぶ。比が 0.9〜1.1 なら今の数(Deployment の spec.replicas)が、範囲の外なら今の Pod の数に比を掛けた数(小数は切り上げ)が計算値になる4
減らすときは、過去 5 分の計算値の最大より下にしない。そのうえで、minReplicas から maxReplicas の範囲に収める5
3 で決まった数がクラスタ上の Deployment の spec.replicas と違えば、spec.replicas をその数に書き換える
3 の「5 分」は、HPA に behavior を書かないときの値で、kube-controller-manager の --horizontal-pod-autoscaler-downscale-stabilization の既定値から来る6。HPA ごとに、behavior.scaleDown.stabilizationWindowSeconds で変えられる。
増やしてから戻るまで
増やしてから戻るまでの同期を、次の表に示す。秒は kubectl scale を実行してからの経過時間だ。計算値は、再現環境で kube-controller-manager のログを --v=4(ログを詳しく出す設定)にして取った。
操作から
新しい 2 台のメトリクス
計算値
replicas
8 秒
なし
4 台
4 台
23 秒
なし
4 台
4 台
38 秒
あり(0%)
0 台
4 台
(15 秒ごとに同じ)
323 秒
あり(0%)
0 台
2 台
8 秒と 23 秒の同期では、新しい 2 台は動いているが、メトリクスがまだない。metrics-server は、各ノードから Pod の CPU とメモリの使用量を集めて、HPA に渡すコンポーネントだ。起動したばかりの Pod の値は、すぐには出ない。
HPA は、メトリクスのある Pod だけで出した比が 1 を下回るときは、メトリクスのない Pod を使用率 100% とみなして計算し直す7。ここでは、既存の 2 台が 0%、新しい 2 台が 100% で、平均は 50% になる。目標の 50% との比は 1.0 なので、処理の 2 で計算値は今の数の 4 になる。
38 秒の同期からは新しい 2 台のメトリクスが出て、計算値は 0 になる。ただし処理の 3 で、過去 5 分の計算値の最大の 4 より下にしないので、replicas は 4 のまま保たれる。
323 秒の同期で、23 秒の計算値 4 が 5 分より前になり、過去 5 分の計算値の最大は 0 になる。HPA はこれを minReplicas の 2 に収めて、spec.replicas を 2 に書き換える。
HPA にとってはいつもの縮小なので、Event は Normal の SuccessfulRescale だけで、エラーも警告も出ない。
kubectl scale の台数が残る期間
HPA は、人が kubectl scale で書いた replicas を、今の数として計算に入れる。減らすときに HPA が守るのは、過去 5 分の計算値だけだ。
人が書いた数が残るのは、計算値が最後にその数になってから 5 分のあいだだけだ。再現で 4 台が約 5 分残ったのは、新しい 2 台のメトリクスが出るまでの同期で計算値が 4 になり、その 4 が過去 5 分の計算値の最大として残ったからだ。
計算値が一度もその数にならなければ、最初の同期で戻る。同じ構成で 2 台から 3 台に増やすと、約 10 秒後の同期で 2 台に戻った。メトリクスのない 1 台を使用率 100% とみなしても、3 台の平均は約 33% で、目標の 50% との比は 0.9 を下回り、計算値は 2 になる。
数分後に戻るのも、すぐ戻るのも、戻しているのは HPA だ。
対策
手を入れる先は、HPA の下限(minReplicas)と、減らすときの規則(behavior.scaleDown)だ。
minReplicas を上げる
HPA の minReplicas を、増やしたい台数まで上げる。その場で replicas が上がる。上がるのは下限だけなので、負荷が増えれば HPA はさらに増やせる。
$ kubectl patch hpa <hpa> --type=merge -p '{"spec":{"minReplicas":4}}'
horizontalpodautoscaler.autoscaling/<hpa> patched
maxReplicas を超える数にするなら、同じ patch で maxReplicas も上げる。
減らす向きを止めてから増やす
HPA の behavior.scaleDown.selectPolicy を Disabled にしてから、kubectl scale で増やす。HPA は replicas を減らさなくなる8。
$ kubectl patch hpa <hpa> --type=merge -p '{"spec":{"behavior":{"scaleDown":{"selectPolicy":"Disabled"}}}}'
horizontalpodautoscaler.autoscaling/<hpa> patched
$ kubectl scale deploy/<deployment> --replicas=4
deployment.apps/<deployment> scaled
どれを選ぶか
私は minReplicas を上げる。止まるのは下限より下への縮小だけで、HPA は負荷に応じて増やすことも、下限までの縮小も続ける。減らす向きを止める案は、HPA が負荷に応じて増やした分の縮小まで止めてしまう。
増やした台数が要らなくなったら、minReplicas を元の値に戻す。
脚注
検証結果はこちら、コードは show-your-work にある。 ↩︎
Kubernetes のドキュメントの Field management。 ↩︎
Event は既定で 1 時間で消える。kube-apiserver の --event-ttl の既定が 1h0m0s だからだ。 ↩︎
Kubernetes のドキュメントの Algorithm details。 ↩︎
kube-controller-manager の normalizeDesiredReplicas。 ↩︎
kube-controller-manager の既定値の設定。 ↩︎
kube-controller-manager の replica_calculator.go。 ↩︎
Kubernetes のドキュメントの Example: disable scale down。 ↩︎
Four Kubernetes node bugs I found by reading CI failures to the end
---
title: "Four Kubernetes node bugs I found by reading CI failures to the end"
published: true
description: "A flake nobody had root-caused, a test that hung nodes for hours, a 3.9 GB image that never finished pulling, and a kubelet memory regression. How each one went from a red CI run to a patch."
tags: kubernetes, go, sre, opensource
---
Kubernetes' node tests fail a lot. Most of the time someone re-runs them, they go green, and everyone moves on.
Over the past week I picked four failures in SIG Node territory and refused to move on — I kept reading the logs until the timeline explained the failure. Three were CI flakes. The fourth was a second, separate bug hiding inside a reported kubelet 1.37 memory regression. In every case, the bug wasn't where the error message pointed.
Status, to be upfront: as of October 10, 2026, all four of my fixes are open pull requests, triaged or awaiting review, not merged. This post is about the investigations — which is the part I'd want to read anyway.
How I read a failing CI run
Every Kubernetes CI run on Prow keeps its artifacts: the build log, the JUnit results, the kubelet and containerd logs from the node, and for node tests, the VM's serial console. TestGrid shows the history, so you can tell a one-off from a pattern.
My loop for each flake:
1. Find the pattern in TestGrid. Which job, how often, and — most useful — which similar jobs don't fail.
2. Build a timeline across logs. Test log, kubelet log and serial console on one clock, to the second.
3. Explain the difference. If only one job fails, something about that job's environment is the clue.
4. Reproduce before fixing. Either in a unit test that fails on master, or on a machine set up like the CI node.
Bug 1: the image record that kept disappearing
PR: kubernetes/kubernetes#142801 · fixes #140942
The symptom
Tests for KubeletEnsureSecretPulledImages — the feature that makes the kubelet re-check credentials before reusing a private image — kept failing in one job, ci-kubernetes-node-swap-ubuntu-serial: 3 of the last 10 runs, 6 of 20 over a longer window. A test helper waits up to five seconds for the kubelet to write an ImagePulledRecord for an image, and the record never appeared.
This had already been through two issues. One was closed by a PR that only added debug logging. The working theory in the open issue was that the kubelet doesn't write a fresh record when an image is already cached from an earlier test.
The timeline
From the failing run (prow job 2080994746846679040):
!Timeline of the failing run: at 14:49:41 the test pod pulls the pause image and the record is written; at 14:49:42 the test restarts the kubelet and image GC runs at 85% disk usage, exactly the high threshold, and the post-GC hook deletes the record; at 14:49:47 the test times out waiting for a record that no longer exists
The record was written. Then it was deleted one second later. The cached-image theory was wrong.
Why only this job
That job's image filesystem sat at about 85% for the whole run — exactly the kubelet's default high threshold for image garbage collection. Every kubelet restart in that job triggered a GC pass. Other jobs never got there.
The root cause
After a GC pass, the kubelet calls post-GC hooks with the list of images that "remain". The ImagePullManager's hook, PruneUnknownRecords, deletes every pulled-record for an image that isn't in that list.
The list was built inside freeSpace(), from the images it evaluated. But images in use by a running container, and pinned images, are filtered out before freeSpace() ever sees them. And freeSpace() stops early once it has freed enough bytes, so images after that point were never added either:
// imagesInEvictionOrder, simplified
for image, record := range im.imageRecords {
if isImageUsed(image, imagesInUse) {
continue // in-use images never reach freeSpace()...
}
if record.pinned {
continue // ...and neither do pinned ones
}
images = append(images, evictionInfo{id: image, imageRecord: *record})
}
So the list of "remaining" images was really "the eviction candidates GC looked at and kept". The image the test pod was running wasn't on it, and its record got pruned.
It fails safe — a later pod just has to re-verify credentials — but it's wrong, and it's invisible: the deletions weren't logged.
The fix
Pass the hooks every image the GC manager still tracks, not just the candidates it evaluated:
// remainingImages returns the IDs of all images still known after a GC pass.
// This must include in-use and pinned images, not just the eviction candidates
// that survived: post-GC hooks treat anything missing from the list as gone.
func (im *realImageGCManager) remainingImages() []string {
im.imageRecordsLock.Lock()
defer im.imageRecordsLock.Unlock()
// The same image can be tracked once per runtime handler.
imageIDs := sets.New[string]()
for imageKey := range im.imageRecords {
imageIDs.Insert(getImageIDFromTuple(imageKey))
}
return sets.List(imageIDs)
}
Both GarbageCollect and DeleteUnusedImages now call im.runPostGCHooks(ctx, im.remainingImages(), freeTime), and pruned records are logged at V(4). The new TestPostGCHooksReceiveAllRemainingImages fails on master — the hook receives an empty list — and passes with the change.
Status (Oct 10, 2026): open, awaiting triage and review from SIG Node.
Bug 2: the swap test that hung nodes for hours
PR: kubernetes/kubernetes#142850 · related to #138226
The symptom
A swap e2e test, "should be able to use more than the node memory capacity", sometimes passed, sometimes failed, and sometimes produced a run that ended in exit status 255 with no JUnit results at all — the node had stopped responding.
The numbers didn't add up
The test allocates memory equal to the node's full capacity — 8 GiB on the CI node, which also has 1 GiB of swap. The system needs some of that RAM too, so part of the pod's allocation can't fit in memory and has to go to swap. In my reproduction below, when the pod was allowed to finish, about 761 MiB of it ended up in swap.
Under LimitedSwap, a container's swap limit is proportional to its memory request:
> swap limit = (memory request ÷ node memory) × node swap
| Request | Swap limit | Against ~761 MiB of overflow |
| --- | ---: | :--- |
| 30% (before) | ~307 MiB | too small |
| 90% (after) | ~921 MiB | fits |
So in every run, the pod hit its swap limit with memory still left to place. What happened next varied — which is why the test flaked instead of failing every time:
- The OOM killer stepped in quickly. The run recovered, and sometimes the test passed.
- It didn't. The kernel tried to reclaim memory elsewhere. On this image system.slice and user.slice can't swap, so it thrashed the page cache instead — and the node hung for hours. Those are the exit status 255 runs with no JUnit results.
The serial console of one failed run showed exactly this: the pod pinned at its swap limit, with roughly 700 MB of swap still free on the node.
Reproducing it
On a VM set up like the CI node (8 GiB RAM, 1 GiB swap, Ubuntu 24.04, the same slice swap settings): at 30% the pod hit its swap limit and was OOM-killed. At 90% it finished allocating with about 761 MiB in swap, with no OOM and no stalls.
The fix
Three changes, all test-only:
1. Request 90% of memory, so the pod's swap limit can hold the overflow.
2. Lower the memory.available hard-eviction threshold to 100 Mi for these tests. Under node-level pressure, swap only starts a few seconds before the default 250 Mi threshold would evict the pod — before the test can see any swap usage.
3. Delete the pod with DeleteSync, so it's gone before the kubelet restarts in AfterEach. Nothing kills the pod while the kubelet is down.
Status (Oct 10, 2026): open, triaged as important-longterm, with the swap tests' original author asked to review.
Bug 3: the 3.9 GB image that never finished pulling
PR: kubernetes/kubernetes#142802 · related to #141200
The symptom
The GPU sanity test (nvidia-smi plus a CUDA demo) timed out after 30 minutes with the pod stuck in ErrImagePull. Even passing runs spent up to 24 minutes pulling the image.
The containerd log
cancel pulling image docker.io/nvidia/cuda:12.5.0-devel-ubuntu22.04 because of no progress in 5m0s
stop pulling image ...: active requests=1, bytes read=3918316892
failed to extract layer (... sha256:496b16dd...) to overlayfs as "extract-...": context canceled
The image is about 3.9 GB, and its largest layer is about 2.5 GB. The log shows 3.9 GB had been read before the pull was cancelled during layer extraction. So the expensive part wasn't the network — it was unpacking the big layer, which reported no "progress" to containerd's five-minute watchdog. The cancelled pull didn't reuse what it had already fetched, so each kubelet retry started the download again. That's containerd/containerd#13909, with a proposed fix upstream.
The fix
Use a smaller image. On amd64, the test only needs nvidia-smi (which comes from the driver) and the cuda-demo-suite-12-5 apt package, whose binaries link the CUDA runtime statically. The ~90 MB nvidia/cuda:12.5.0-base-ubuntu22.04 image has the same CUDA apt repository, keyring and environment. Other architectures build samples with nvcc, so they keep the devel image, and the pod is pinned to the GPU node's architecture so the image always matches the script's uname -m branch.
I left arm64 alone because I couldn't test it on arm64 GPU nodes. It can still hit the containerd behavior.
Status (Oct 10, 2026): open, triaged as important-longterm, awaiting /ok-to-test and review.
Bug 4: a second bug inside the 1.37 kubelet memory regression
PR: google/cadvisor#3946 · issue kubernetes/kubernetes#142874
This one isn't a flake. A SIG Node contributor reported 20–30% higher kubelet memory in Kubernetes 1.37, traced to cAdvisor — the library the kubelet uses to collect container stats — and wrote the main fix. My part is a separate, smaller bug I found while reproducing it. To keep the credit straight:
| | What it fixes | Author |
| --- | --- | --- |
| google/cadvisor#3944 | The reported cause: changing counters that stop housekeeping from ever backing off | the reporter |
| google/cadvisor#3946 | A back-off reset bug that makes the interval oscillate even when stats are unchanged | me |
The reported cause
cAdvisor polls each container's stats on a "housekeeping" interval. When stats haven't changed, it backs off — doubling the interval up to a maximum. When they change, it drops back to the baseline.
MemoryQoS, default-on in 1.37, sets memory.high on containers. The kernel then reclaims memory continuously, which keeps advancing counters in memory.stat. cAdvisor compared whole stats structs, so they never looked unchanged, and housekeeping never backed off. The reporter's fix, google/cadvisor#3944, excludes those counters from the comparison.
The second bug underneath
I reproduced the regression and confirmed #3944 fixes the counters. But while simulating it, I found the back-off had a bug of its own:
if stats[0].StatsEq(stats[1]) && (cd.housekeepingInterval < cd.maxHousekeepingInterval) {
cd.housekeepingInterval *= 2
// ... cap at max ...
} else if cd.housekeepingInterval != *HousekeepingInterval {
// Lower interval back to the baseline.
cd.housekeepingInterval = *HousekeepingInterval
}
Once the interval reaches the maximum, interval < max is false — so even with unchanged stats, execution falls into the else branch and resets to the baseline. Instead of staying backed off, the interval cycles: with cAdvisor's defaults, 1s → 2s → 4s → … → 1m → 1s; with the kubelet's settings, 10s → 15s → 10s → 15s.
!Chart of the housekeeping interval over successive ticks with unchanged stats: the original code climbs to the maximum and then drops back to the baseline in a repeating sawtooth, while the fixed code climbs to the maximum and stays there
The fix separates the two conditions, so only changed stats reset the interval:
if stats[0].StatsEq(stats[1]) {
if cd.housekeepingInterval < cd.maxHousekeepingInterval {
cd.housekeepingInterval *= 2
// ... cap at max ...
}
} else if cd.housekeepingInterval != *HousekeepingInterval {
cd.housekeepingInterval = *HousekeepingInterval
}
The new TestNextHousekeepingIntervalDynamic steps through unchanged and changed samples. Run against the original code, it fails exactly where the sawtooth starts:
!Terminal recording: the new test fails on the original cAdvisor code, expecting 4s but getting 1s at step 3 and 2s at step 4, then passes with the fix
In my simulation of a 10-minute run with the kubelet's settings and MemoryQoS-style advancing counters, a container got 60 housekeeping ticks on the current code, 48 with #3944, and 41 with both fixes. Most of the improvement comes from #3944; mine removes the oscillation that remains. Neither patch alone gets the kubelet to back off as intended, and the simulation measures housekeeping ticks, not the production memory figure in the report.
Status (Oct 10, 2026): #3946 is open; the issue is assigned to me, and the reporter called the patch a good one.
What the four had in common
Each bug lived at an edge that normal runs never reach:
- A list that silently meant "some of everything". "Remaining images" excluded in-use and pinned images, and nothing downstream knew.
- A number that couldn't work. A swap limit smaller than the overflow it had to hold.
- A timeout that measured the wrong thing. A "no progress" watchdog applied to an unpack that reports no progress — and the fetched data not reused.
- An else branch that fired at the boundary. Correct below the maximum, wrong exactly at it.
And each one was explained by a difference: the one job at 85% disk, the one test whose request didn't match its allocation, the one image that was 40 times bigger than it needed to be, the one comparison that never settled.
Flakes aren't noise. They're bugs that only show up under one environment's conditions. The job that fails is telling you which conditions.
What the evidence does and doesn't establish
| Evidence | Establishes | Does not establish |
| --- | --- | --- |
| Prow timeline for bug 1 | The record was written, then pruned by GC one second later, in that run | That every historical failure of these tests had this cause |
| TestPostGCHooksReceiveAllRemainingImages | The hook now receives in-use and pinned images | That the fix has been reviewed or accepted |
| VM reproduction for bug 2 | 30% fails and 90% holds on a CI-like machine | Behavior on every node image the swap tests run on |
| containerd log for bug 3 | 3.9 GB had been read before the pull was cancelled during layer extraction | That every layer had fully downloaded, or that the smaller image never hits the same timeout |
| cAdvisor test, failing on original and passing with the fix | The back-off reset bug exists and the change removes it | The production memory savings |
| 60 / 48 / 41 tick simulation | The relative effect of #3944 and #3946 in my model | The 20–30% memory figure, which comes from the reporter's environment |
Check each one yourself
Bug 1 — kubelet image GC. Check out the PR branch and run the new unit test. Without the fix, the hook receives an empty list:
go test ./pkg/kubelet/images/ -run TestPostGCHooksReceiveAllRemainingImages -v
Bug 2 — swap test. This is a node e2e test, so it needs a Linux node with swap enabled. The PR describes the VM I used (8 GiB RAM, 1 GiB swap, Ubuntu 24.04). From a Kubernetes checkout:
make test-e2e-node FOCUS="should be able to use more than the node memory capacity"
Bug 3 — GPU image. The full test needs a GPU node; in CI it's the pull-kubernetes-e2e-gce-device-plugin-gpu job. The size difference behind the fix is easy to see anywhere crictl runs:
crictl pull nvidia/cuda:12.5.0-devel-ubuntu22.04 # ~3.9 GB, ~2.5 GB largest layer
crictl pull nvidia/cuda:12.5.0-base-ubuntu22.04 # ~90 MB
The cancellation itself is containerd's image_pull_progress_timeout (five minutes by default) firing during extraction; containerd/containerd#13909 has the details.
Bug 4 — cAdvisor back-off.
git clone https://github.com/google/cadvisor && cd cadvisor/lib
# add TestNextHousekeepingIntervalDynamic from the PR, then:
go test ./manager/ -run TestNextHousekeepingIntervalDynamic -v
On the original code it fails at step 3; with the fix applied, it passes.
---
I'm Sachin, an SRE / platform engineer working on Kubernetes, observability and distributed systems — currently open to new roles. LinkedIn · GitHub
1 reaction
@danluu Not to toot my own horn, but I also discovered a bug in a real project while porting it to another language: Kubernetes! I ported about 100,000 lines of it up TypeScript and found a real bug: kubernetes#141155.
I talk about it a little in ngrok.com. t.co
4 likes · 230 views
Mentions
CalicoとCiliumの通信経路を実測する:Service・VXLAN・Policy・既存TCP やったこと 代表的なCNIであるCalicoとCiliumを取り上げ、kube-proxyありのiptables構成と、kube-proxyなしのeBPF構成の通信経路を比較した。 同じPod・Service・NetworkPolicyの構成で動かしたところ、HTTP接続やDNS、readinessによる接続先の切り替え、通信の許可・拒否といった基本動作に差は見られなかった。 一方、Pod内やNode上でパケットを採取して確認すると、Serviceの宛先がbackend宛に変換される場所や、接続状態を保持する仕組みに明確な違いがあった。 CNIとは CNI(Container Network Interface)は、KubernetesがPodのネットワークをセットアップする際に呼び出す共通インターフェース規格である。CNIプラグインを通じてネットワークインターフェースやIPアドレスを設定し、Podをネットワークにつなぐ。CalicoやCiliumはCNIに対応したネットワークソフトウェアで、Pod間通信やNetworkPolicyによるアクセス制御も担う。 kube-proxyの役割 PodにIPアドレスが付いて通信できるようになっても、ServiceのClusterIPをどのbackend Podへ振り分けるかという処理が必要になる。このServiceの転送ルールを各Nodeに設定するのがkube-proxyである。ServiceとEndpointSliceの変化を監視し、接続先の追加・削除などをルールに反映する。 今回のiptablesモードでは、kube-proxyがカーネルにルールを設定し、実際のbackend選択と宛先変換はLinuxカーネルが行う。通信のたびにkube-proxyプロセスがパケットを受け取り、中継する構成ではない。Pod同士をつなぐネットワークと、その上でServiceの宛先を選ぶ処理を分けると、Calicoとkube-proxyが担う役割も見えやすくなる。 eBPFでServiceを処理する構成 eBPFはLinuxカーネルのパケット処理やソケット操作の途中でプログラムを動かせる仕組みである。CNIのような接続規格やkube-proxyのようなコンポーネントを指す名前ではなく、ネットワーク機能の実装にも使われる技術である。 CiliumはeBPFを使って、Pod間通信やNetworkPolicyに加え、Serviceの振り分けも実装する。このService機能でkube-proxyを置き換えるのがkube-proxy replacementである。今回有効にしたsocket-LBでは、アプリのconnect()を処理する段階でbackendを選び、ソケットの宛先を書き換える。アプリは同じClusterIPへ接続していても、宛先が変わる場所はiptables構成と異なる。 CalicoとCiliumの特徴と今回の検証構成 CalicoはBGPによるルーティングや柔軟なNetworkPolicy、CiliumはeBPFによる高度な通信制御やHubbleによる可視化が主な特徴として挙げられる。Calicoにもkube-proxyを置き換えるeBPFモードがあるが、本記事ではCalicoのiptables構成と、Ciliumのsocket-LBを有効にしたeBPF構成を比較した。以降のCalicoおよびCiliumの記述は、この検証構成を指す。 役割 今回のCalico構成 今回のCilium構成 PodのIP・ネットワーク接続 Calico Cilium NetworkPolicyの適用 Calicoが設定したiptablesルール CiliumのeBPF Serviceのbackend選択と宛先変換 kube-proxyが設定したiptablesルール CiliumのeBPF(socket-LB) 検証結果 アプリから見た基本動作は同じだったが、ServiceからPodへの宛先変換は異なる場所で処理されていた。 主な共通点と違いは次の4点である。 Serviceの宛先変換:CalicoはPodを出た後にNode上で変換する。一方、Ciliumはsocket接続時(connectシステムコール時)に変換するため、Podのeth0には最初からbackend宛のSYNパケットが現れる。 別Nodeへの転送:どちらもVXLANによるカプセル化を使用していた。ただし、外側UDPのポート番号とVXLANのVNIの扱いに違いがあった。 NetworkPolicy:どちらもパケットが宛先Podへ届く前に破棄された。CalicoではiptablesのDROPカウンタ、Ciliumではendpointの拒否イベントとして観測できた。 readinessによる切り替え:NotReadyになったPodは新規接続の振り分け先から除外されたが、すでに確立していた既存TCP接続は切断されず、同じPodと通信を維持できた。 最も大きな違いは黄色いひし形で示した宛先変換の位置にある。図は別Nodeのbackendに接続する場合を描いている。 ※図の凡例:青はPod・アプリ、黄色いひし形は変換やPolicyの判定処理、灰色は転送経路、赤はパケットの拒否を表す。 Container内のアプリプロセスはuser spaceで動く。Pod内のContainerは同じnetwork namespaceを使い、IPアドレスとポート空間を共有する。アプリのsocketやPodのeth0はこのnamespaceに属する。socket-LBのcgroup hookもiptablesもカーネル内の処理である。図ではkindのContainerとして動く論理Nodeと、その中の処理の区分を示した。これらのNodeは同じLinuxカーネル上で動く。 比較した構成 検証環境はcontrol-plane 1台、worker 2台のクラスタ構成とした。clientと同じworkerノードにbackend Podを1台、別workerノードにもう1台配置している。Serviceへの新規接続とPod IPへの直接接続を比較し、readiness probeの状態変化やNetworkPolicyの適用前後における挙動を観測した。 項目 Calico Cilium バージョン v3.33.0 v1.20.2 Serviceの実装 kube-proxyのiptablesモード kube-proxy replacement、Full socket-LB Node間の転送 VXLAN、BGP無効 VXLAN 共通条件 Kubernetes v1.35.0、IPv4、Pod MTU 1400 同左 検証詳細 Calicoの場合 ServiceのClusterIPへ接続すると、client Podのeth0だけでなく、Node側のvethインターフェース上でもClusterIP宛のSYNパケットが観測された。パケットの宛先がbackendのPod IPへ変換されるのは、Nodeに入った後である。 別Node上で動作するbackend Podが選択された通信経路を追跡した結果は、以下のとおりである(IPアドレスは役割名に置き換えている)。 client Pod eth0 client:33706 → Service:8080 SYN seq=433715408 Nodeのhost veth client:33706 → Service:8080 SYN seq=433715408 vxlan.calico client:33706 → backend:8080 SYN seq=433715408 backend Pod eth0 client:33706 → backend:8080 SYN seq=433715408 送信元ポートとSYNシーケンス番号は一貫しており、宛先IPアドレスのみが途中で書き換わっていることがわかる。これは、Node上でkube-proxyが生成したiptablesチェーン(KUBE-SERVICES → KUBE-SVC-* → KUBE-SEP-* → DNAT)のルールによって処理されているためである。実際に新規接続を8回試行したところ、該当Serviceのエントリポイントにあるカウンタも正確に8パケット分増加していた。 なお、ここでkube-proxyが担っている役割は、カーネルにiptablesルールをプロビジョニングすることである。通信が発生するたびにパケットがkube-proxyプロセスへ渡されるわけではなく、Linuxカーネル自体がiptablesルールに従ってbackendを選択し、DNAT(宛先NAT)を実行する。[1] また、この宛先変換のマッピング情報はconntrack(接続追跡テーブル)に保持される。今回の検証でも、original方向にclient → Service、reply方向にbackend → clientという対応関係が記録されていた。この接続状態が管理されているおかげで、Service宛てに接続したクライアントと、実際に応答を返すbackend Podとの間で正常にセッションが維持される。 Ciliumの場合 一方、CiliumではクライアントアプリがClusterIP宛てに接続を要求していても、client Podのeth0でキャプチャされたSYNパケットは最初からbackendのPod IP宛てになっていた。新規に試行した8回の接続すべてにおいて、この挙動が確認できた。 これは、Ciliumのsocket-LB機能が、アプリケーションがconnect()システムコールを発行した時点でServiceのbackendを選択し、ソケットの宛先アドレス自体を書き換えるためである。TCPパケットが生成されるよりも前の段階で宛先が書き換わるため、パケットがPodを出た後にNode上でServiceのDNATを行う必要がなくなる。[2] 今回の通常設定ではソケットトレースログが間引かれるため、一時的にレートリミットを解除して別の8接続を採取した。各ソケット(socket cookie)において、以下のように変換前後の状態を確認できた。 接続先の変換 Service:8080 → backend:8080 アプリに見せる宛先 backend:8080 → Service:8080 2行目は、アプリがgetpeername()で接続相手の情報を参照した際に返される逆変換(reverse translation)の処理である。ネットワーク上ではbackend Podと直接通信しつつも、アプリケーション側にはあくまで「Serviceに接続している」と見せかける仕組みになっている。実際のパケット送受信はbackend宛てのBPF conntrackテーブルで追跡され、Serviceとの対応関係はBPFのsocket reverse NATテーブルで管理されていた。 なお、socketトレースとpcap(パケットキャプチャ)の突き合わせは、clientのcgroup、逐次実行した各接続のタイムスタンプ、および応答したbackend Podの組み合わせによって確認した。socket cookieはパケットヘッダには含まれず、トレース側には送信元ポートが出力されないため、これらを直接の結合キーにすることはできない点に注意が必要である。 このアーキテクチャの違いは障害調査やトラブルシューティング時のパケット解析(tcpdump)にも大きく影響する。Cilium環境のPod内でパケットをキャプチャしてClusterIP宛ての通信が見当たらなくても、Serviceが使われていないわけではない。socket-LBが有効な構成では、Pod内であっても追うべき宛先はbackendのIPアドレスになる。 別Nodeへの転送は、両方ともVXLANを採用 Serviceの宛先変換が行われる場所は異なっていたものの、今回の検証構成におけるNode間のパケット転送は、CalicoとCiliumのどちらもVXLANによるオーバーレイネットワークを用いていた。同一Node内のPod同士の直接通信ではVXLANヘッダは付与されないが、別Node上のPod宛て通信では、内側のPod間TCPパケットが外側のNode間UDPパケットでカプセル化される。 内側のTCPパケットにおける送信元・宛先IP、ポート番号、SYNシーケンス番号を追跡したところ、送信元NodeのNIC、トンネルデバイス、宛先NodeのNIC、そして受信側Podのeth0まで、一連の通信を正確に追うことができた。トンネルデバイス名としては、Calicoがvxlan.calico、Ciliumがcilium_vxlanを使用していた。 特に興味深いのはVXLANヘッダに含まれるVNI(VXLAN Network Identifier)の使われ方である。Calicoでは固定値の4096が設定されていたのに対し、Ciliumではclientからbackendへの往路が8639、復路が40262となっており、それぞれ送信元Podに割り当てられたSecurity Identityの値と一致していた。Ciliumはトンネルでパケットをカプセル化して転送する際、VNIフィールドを利用して送信元Podのセキュリティ属性(どのNetworkPolicyグループに属するか)を宛先Nodeへ伝達している。[3] NetworkPolicyの動作:どちらも宛先Podの手前で拒否 別Node上で動作するbackend Podに対してIngressの隔離NetworkPolicyを適用し、client PodからPod IPへ直接接続を試行した。その結果、どちらの構成でもSYNパケットは宛先Nodeのeth0までは到達したものの、backend Podのeth0には届かずに破棄された。 今回の検証では1接続の最初のSYNパケットと再送パケットを含めて計3パケットを観測した。Calicoでは対象Pod向けのiptablesチェーンにおいてDROPカウンタが3パケット(180バイト)増加し、Ciliumでは対象endpointのイベントログに3件のPolicy deniedが記録された。その後、送信元Podを許可するルールを追加すると、どちらの構成でもパケットがbackend Podへ到達し、HTTP 200の正常応答が得られるようになった。 「通信が拒否された」という最終的な結果は同じであっても、トラブルシューティング時に確認すべき調査ポイントは異なる。CalicoであればiptablesチェーンのDROPカウンタを確認し、CiliumであればeBPFエンドポイントやPolicyのイベントログをパケットトレースと突き合わせることで、パケットが宛先Nodeに到達した後に破棄された要因を正確に特定できる。 readiness probeによる制御:新規接続の振り分け先変更と既存TCPの継続 通信確立中のbackend PodをNotReady状態に遷移させ、選択状態の収束を確認してから新規TCP接続を試すと、もう一方のReadyなPodへ切り替わっていた。しかしその間も、すでに確立していた既存のTCP接続は切断されることなく、元のPodから継続してHTTP 200応答を受け取り続けた。 readiness probeの失敗は、Pod上のアプリケーションのTCPソケットを強制クローズする処理ではない。今回の検証用backend Podは、NotReady状態であってもリクエストを受け取れば正常に応答できる状態を保っていた。つまり、Serviceで「新規接続の振り分け対象から外れること」と、「既存の接続済みソケットで通信を維持できること」は完全に両立する。 観測した状態 Calico Cilium NotReady後の新規8接続 すべて残ったReadyなPodへ すべて残ったReadyなPodへ 既存TCP 同じ接続で52応答 同じ接続で55応答 保持していた接続状態 Serviceとbackendを結ぶconntrack backend宛のBPF conntrackとsocket reverse NAT 既存のTCP接続についてはPodを削除せずにclientと同一Node上のbackendとの間で約1分間にわたり観測を継続した(クライアント側での自動再接続は行わずに1本のTCPセッションを維持)。その後、readiness状態を回復させると、両構成ともに新規接続の振り分け先として2つのbackend Podが再び選択されるようになった。なお、通常のClusterIP Serviceに対するDNS名前解決の結果は、この一連の動作中も一貫して同一のClusterIPアドレスを返し続けていた。 まとめ CalicoとCiliumのどちらを採用した場合でも、アプリケーションから見たKubernetesの基本動作(Service連携やアクセス制御)は共通していた。しかし、パケットレベルの挙動に着目すると、Podから送出されるパケットの宛先アドレスには決定的な違いが存在していた。Calico(iptables構成)ではパケットがPodを出た後にNodeのカーネル内でServiceからbackendへの変換が行われるのに対し、Cilium(socket-LB構成)ではソケットのconnect()処理時にあらかじめ変換が完了している。 このアーキテクチャの違いを理解しておくことは、パケットキャプチャ(tcpdump)を用いた障害解析やネットワーク設計において極めて重要である。Calicoでは「どの観測点の間でClusterIPからPod IPへ宛先が書き換わるか」を追う必要がある一方、Ciliumでは最初からbackend宛てのパケットとsocket-LBの状態を突き合わせて確認しなければならない。一見同じように機能するKubernetesクラスタであっても、基盤となるCNIの実装特性に応じた適切な観測ポイントを見極めることが肝要である。 余談: なぜkube-proxyを利用しない構成が広がっているのか kube-proxyを用いないいわゆる「kube-proxy-less」な構成は、主要クラウド各社のマネージドKubernetesサービスでも標準採用が進んでいる。2026年10月時点の各社公式ドキュメントに基づくと、現状は以下のように整理できる。 クラウドの構成 ネットワークの実装 kube-proxy GKE Autopilotの新規クラスタ Ciliumを使うDataplane V2が既定 使用しない AKS Automatic Azure CNI Overlay powered by Ciliumが既定 使用しない EKSのEC2ノード構成(Auto Modeを除く) Amazon VPC CNIとkube-proxy add-on 使用する Google CloudのGKE Dataplane V2では、Serviceのパケット転送およびNetworkPolicyの評価をeBPFで処理し、高度なポリシーログ出力も提供している。Microsoft AzureのAKSでも、Azure CNIとCiliumのデータプレーンを統合することで、Service転送やセキュリティポリシーの適用、さらに拡張アドオンを通じた詳細な可視化を実現している。[4, 5] 一方、AWSのEKSにおける標準的なEC2ノード構成では、Amazon VPC CNIがPodネットワークのプロビジョニングを行い、Serviceのロードバランシングはkube-proxyアドオンが担当する。NetworkPolicyの適用エンジンと、Serviceの負荷分散にkube-proxyを用いるかどうかは独立した選択肢となっている。[6, 7] なお、Calicoにもkube-proxyを置き換えてeBPFでServiceを処理するモードが存在し、kube-proxy自体にも従来のiptablesに代わるnftablesモードなどのパフォーマンス改善が進められている。そのため、単に「CalicoかCiliumか」という製品の比較にとどまらず、「どのデータプレーン層でServiceルーティングを実装するか」という設計視点が重要となる。[8, 9] Ciliumをフル活用する構成の大きな利点は、Serviceのbackend検索やNetworkPolicyの評価、通信の可観測性をすべて同一のeBPFデータプレーンで一元処理できる点にある。特にsocket-LBを適用した場合、ソケット接続の開始時点でbackendが確定するため、後段のネットワークスタックにおけるService DNAT処理を丸ごと省略できる。今回の検証においてPodのeth0で観測された宛先の違いは、まさにこのアーキテクチャ設計の違いがパケットレベルに現れた好例といえる。[10] AIの利用について 本記事の作成にはAIを利用している。 参考文献 kubernetes/pkg/proxy/iptables/proxier.go at v1.35.0 · kubernetes/kubernetes · GitHub cilium/Documentation/network/kubernetes/kubeproxy-free.rst at v1.20.2 · cilium/cilium · GitHub cilium/Documentation/network/concepts/routing.rst at v1.20.2 · cilium/cilium · GitHub GKE Dataplane V2 | GKE networking | Google Cloud Documentation Configure Azure CNI Powered by Cilium in Azure Kubernetes Service (AKS) - Azure Kubernetes Service | Microsoft Learn Assign IPs to Pods with the Amazon VPC CNI - Amazon EKS Manage kube-proxy in Amazon EKS clusters - Amazon EKS About Calico eBPF | Calico Documentation Virtual IPs and Service Proxies | Kubernetes Kubernetes Without kube-proxy — Cilium 1.20.4 documentation
ingress-nginx 退役之後:在臨時叢集實測 Traefik 的 ingress-nginx 相容模式與 Gateway API > 一句話結論:用 ingress-nginx 的小叢集,不必急著重寫所有 Ingress。實測下來,Traefik 的 ingress-nginx 相容模式可以原封不動吃下常見的 annotation;想一步到位改 Gateway API 也行,但轉址狀態碼、IP 白名單這類細節要自己補。這篇是評估,不是遷移紀錄。 遇到的問題 單機叢集常見的配置是:k3s 的內建 Traefik 關掉,自己用 Helm 裝 ingress-nginx,所有服務掛 ingressClassName: nginx,再用幾個 nginx.ingress.kubernetes.io/... annotation 處理 HTTPS 轉址、逾時、IP 白名單。 這個專案已經退役了。官方 repo 的說明寫得很直接: - 2026 年 3 月之後,不再有新版本、bug 修正,也不再修補新發現的安全漏洞。 - 已經部署的不會壞,Helm chart 和映像也還在。 - 沒有安裝過的人,建議直接找一個 Gateway API 的實作。 也就是說現有的叢集不會馬上出事,但每一天都在累積「沒人修」的風險。接下來有兩種做法: 1. 換 controller,Ingress 不動。 讓別的 controller 認得 nginx 的 annotation。 2. 換 API。 改寫成 Gateway API 的 Gateway 與 HTTPRoute。 這篇不下結論要選哪個,而是在隔離的臨時叢集把兩條路都實際跑一遍,記下哪些順利、哪些要注意。 測試環境 整個實驗用一個臨時的 k3d 單節點叢集,不佔主機的埠,測完就刪。 | 項目 | 版本 | |---|---| | k3s(k3d 預設) | v1.31.5 | | Traefik(Helm chart 42.2.0) | v3.7.16 | | Gateway API CRD(standard) | v1.7.0 | | cert-manager | v1.21.3 | | ingress2gateway | v1.2.0 | 測試用的後端是 traefik/whoami,它會回傳請求的標頭,還有 ?wait=25s 這種可以讓後端故意慢回應的參數,以及 /echo 的 WebSocket 端點,很適合拿來測逾時。 要先講清楚的限制: - 叢集裡用自簽憑證,沒有真的向 Let's Encrypt 申請。 - 從叢集內的 Pod 發請求,沒有經過 NodePort 或 LoadBalancer,所以「來源 IP 會不會被改掉」沒有驗證。 - 單節點,沒做效能比較。 手把手步驟 前置:k3s 內建的 Traefik 不能直接用 先查了 k3s 自帶的 Traefik。k3d 預設的 k3s v1.31.5,內建的是 Traefik 2.11.18。 Traefik 的 ingress-nginx 相容 provider,官方文件寫明需要 v3.6.2 以上。所以 v1.31.5 附的版本沒有這個功能(用 traefik --help 也確實只看到 kubernetesgateway,而且還需要 --experimental.kubernetesgateway)。 比較新的 k3s 會附 Traefik 3.7.x:查 k3s v1.37.1 的內建 manifest,chart 是 41.4.0,對應 Traefik v3.7.12。但舊版 k3s 的叢集,得自己用 Helm 裝新版 Traefik。 下面的實驗都是這樣做:建叢集時停用內建 Traefik,再用 Helm 裝。 k3d cluster create demo --servers 1 --agents 0 --no-lb \ --k3s-arg "--disable=traefik@server:0" 路線一:讓 Traefik 直接吃 nginx 的 Ingress Helm values 只要打開 provider: providers: kubernetesIngressNGINX: enabled: true # 預設 ingressClass 是 nginx 原本的 Ingress 完全不用改。測試用的是三種最常見的寫法,原封不動貼進去: # 1. HTTPS 轉址 metadata: annotations: nginx.ingress.kubernetes.io/force-ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true" # 2. 長時間逾時 nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" # 3. IP 白名單 nginx.ingress.kubernetes.io/whitelist-source-range: "192.0.2.0/24" 實測結果: | 項目 | 結果 | |---|---| | HTTP → HTTPS 轉址 | 308 Permanent Redirect,Location 正確帶回原本的路徑,和 ingress-nginx 的預設一致 | | whitelist-source-range | 名單外的來源收到 403 Forbidden | | proxy-read-timeout | 有生效。為了方便驗證,改成 5 秒、後端 25 秒才回應,結果在 5.0 秒收到 504;改成 60 秒就正常通過 | | backend-protocol: "HTTPS" | 後端用自簽憑證的 HTTPS,收到 200;拿掉 annotation 就變成 400 | | WebSocket | 透過這個 Ingress 連線、閒置 30 秒後仍可收發 | 這個 provider 可以和 Gateway API 的 provider 同時開著。Traefik 官方文件沒有說明兩者能不能並存,所以實測了:同一個 Traefik 裡,Ingress 和 HTTPRoute 都正常回應。 有一個小地方:Ingress 的 ADDRESS 欄位是空的。這個 provider 預設沒有回填 status.loadBalancer,chart 有 publishService 這個選項,我沒有測它有沒有用。依賴 Ingress status 的工具(例如自動建立 DNS 記錄的)要留意。 另外,如果打算讓 ingress-nginx 和 Traefik 並行一段時間再切換,官方文件提醒兩個 controller 會互相覆寫 Ingress 的 status,路由不受影響,但監看 status 的工具會被干擾。這一點我沒有實測。 路線二:改寫成 Gateway API 先裝 Gateway API 的標準 CRD,再裝 Traefik 並打開 Gateway provider: kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.7.0/standard-install.yaml providers: kubernetesGateway: enabled: true gateway: enabled: false # 自己建 Gateway Traefik 的 chart 輸出訊息建議安裝 v1.5.1,我裝的是 v1.7.0,GatewayClass 仍然正常被接受。 Gateway 的 listener 埠必須對應 Traefik 的 entrypoint(預設是 HTTP 8000、HTTPS 8443),不是對外的 80 和 443: apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: {name: main, namespace: traefik} spec: gatewayClassName: traefik listeners: - name: web port: 8000 protocol: HTTP allowedRoutes: {namespaces: {from: All}} - name: websecure port: 8443 protocol: HTTPS tls: mode: Terminate certificateRefs: [{name: demo-tls}] allowedRoutes: {namespaces: {from: All}} HTTP 轉 HTTPS apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: {name: app-redirect, namespace: demo} spec: parentRefs: [{name: main, namespace: traefik, sectionName: web}] hostnames: [app.example.test] rules: - filters: - type: RequestRedirect requestRedirect: {scheme: https, statusCode: 308} 這一段有三個發現: - 不要寫 port。 一開始寫了 port: 443,Location 變成 https://app.example.test:443/x。拿掉之後才是乾淨的 https://app.example.test/x。 - statusCode 預設是 302。 沒寫的話,原本 ingress-nginx 的 308 會變成臨時轉址。 - 308 可以用,但屬於 Extended。 CRD 的說明是「301 與 302 是 Core,其餘是 Extended」。實測 Traefik 3.7.16 接受並回傳 308 Permanent Redirect。換別的 Gateway 實作就不一定支援,要看該實作的文件。 IP 白名單 Gateway API 標準沒有 IP 白名單的欄位。Traefik 的做法是建一個 Middleware,再用 ExtensionRef 掛到 HTTPRoute: apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: {name: only-office, namespace: demo} spec: ipAllowList: sourceRange: ["192.0.2.0/24"] --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: {name: internal, namespace: demo} spec: parentRefs: [{name: main, namespace: traefik, sectionName: websecure}] hostnames: [internal.example.test] rules: - filters: - type: ExtensionRef extensionRef: {group: traefik.io, kind: Middleware, name: only-office} backendRefs: [{name: whoami, port: 80}] 名單外的來源收到 403 Forbidden,把 sourceRange 換成 Pod 網段就通過。這是 Traefik 專屬的寫法,換 Gateway 實作就得重寫。 cert-manager 整合 憑證的位置從 Ingress 的 annotation,搬到 Gateway 的 listener。cert-manager 要打開 Gateway 支援: config: apiVersion: controller.config.cert-manager.io/v1alpha1 kind: ControllerConfiguration enableGatewayAPI: true Gateway 加上 issuer 的 annotation,listener 指向一個「還不存在」的 Secret: metadata: annotations: cert-manager.io/cluster-issuer: selfsigned spec: listeners: - name: auto hostname: auto.example.test port: 8443 protocol: HTTPS tls: mode: Terminate certificateRefs: [{name: auto-tls}] 大約 12 秒後,cert-manager 自動建出 Certificate(READY=True)和 auto-tls 這個 Secret,Traefik 開始用它提供 HTTPS,請求回 200。 這裡用的是自簽的 issuer。真正用 Let's Encrypt 時,DNS-01 或 HTTP-01 的流程這次沒有驗證。 長連線與逾時 原本 annotation 裡的 proxy-read-timeout: 3600,主要是為了 WebSocket 和長時間回應。到了 Traefik,要分三種情況看。 預設值。 不調任何逾時: - WebSocket 連上之後閒置 400 秒,再送一次訊息,仍然收到回應。 - 後端 100 秒才回應的 HTTP 請求,等到 100.1 秒成功收到 200。 3600 秒本身沒有實際等待測試,只能說預設值下,閒置幾百秒不會被切斷。 調得很短的 entrypoint 逾時。 為了確認哪些設定會影響,把 HTTPS entrypoint 的 readTimeout、writeTimeout、idleTimeout 都設成 10 秒: ports: websecure: transport: respondingTimeouts: readTimeout: 10s writeTimeout: 10s idleTimeout: 10s - WebSocket 閒置 30 秒,仍然正常。升級成 WebSocket 之後,連線不受這幾個逾時影響。 - 一般 HTTP 請求,後端 25 秒才回應,在第 10 秒被切斷(客戶端看到連線被中斷,沒有收到 HTTP 狀態)。 也就是說,要讓「長時間才回應」的請求(例如串流輸出)不被切掉,要看的是 entrypoint 的 writeTimeout。預設值下 100 秒的回應沒有被切斷(前面實測過),所以一般不用動,但一旦被調過就會是問題。 nginx annotation 不能蓋過 entrypoint。 在 entrypoint 設了 10 秒的情況下,即使 Ingress 寫了 proxy-read-timeout: "3600",後端 25 秒的請求還是在 10 秒被切斷。annotation 控制的是後端那一段的逾時,不會改寫 entrypoint 的設定。 ingress2gateway 轉出來的東西 官方的轉換工具 ingress2gateway 可以把現有的 Ingress 轉成 Gateway API。拿上面的三個 Ingress 餵給 v1.2.0: ingress2gateway print --input-file ing.yaml --providers ingress-nginx 轉換器會印出警告,值得逐條看: - force-ssl-redirect 被標為 Unsupported annotation。不過因為 Ingress 有 TLS,工具仍然自動產生了 308 的 HTTP → HTTPS 轉址路由,結果是對的,警告看起來只是沒有逐個 annotation 處理。 - whitelist-source-range 轉換失敗:IP-based authorization is not supported。白名單會直接掉,轉完的路由是沒有限制的。 - 逾時 annotation 會做「best-effort」轉成 timeouts.request,但數字不對。 最後一點值得單獨講。同樣的 annotation,轉出來的結果: | proxy-read-timeout(秒) | 轉出的 timeouts.request | |---|---| | 60 | 10m0s(應該是 1 分鐘) | | 100 | 16m40s(應該是 1 分 40 秒) | | 3600 | 10h0m0s(應該是 1 小時) | 每一筆都是預期的 10 倍。從原始碼看,解析 annotation 的那一步是正確的(秒數轉成 Duration),所以問題出在後面哪一步,這裡沒有追到,也沒找到對應的 issue。這是 v1.2.0 的觀察,之後版本可能已經修掉,重點是轉換結果一定要人工逐項核對,不要直接 apply。 踩到的坑 整理一下實測中遇到的: 1. k3s 內建的 Traefik 可能太舊。 v1.31.5 附的是 2.11.18,沒有 ingress-nginx 相容 provider,要自己用 Helm 裝 3.6.2 以上。 2. Gateway 的 listener 埠是 entrypoint 的埠。 寫成 80、443 會對不上。 3. RequestRedirect 不要寫 port,也別忘了 statusCode。 前者會讓網址多出 :443,後者預設是 302,不是 ingress-nginx 的 308。 4. IP 白名單在 Gateway API 沒有標準寫法。 得靠實作專屬的 Middleware,轉換工具也不會幫你處理。 5. 轉換工具的輸出要核對。 至少逾時數字要自己重算。 為什麼這樣評估 如果現有叢集的 Ingress 很多,而且多半只用到轉址、逾時、白名單這幾個 annotation,路線一的成本最低:換掉 controller 就好,YAML 一行不改,風險也最小。缺點是 annotation 的相容只涵蓋「一部分」。Traefik 官方文件也寫了某些 annotation 的行為有差異,所以還是要逐項驗證。 路線二是長期比較乾淨的方向,因為 Kubernetes 官方文件說 Ingress API 已經凍結,並建議改用 Gateway。但每個服務都得重寫,白名單、逾時這類能力要換成實作專屬的寫法,等於把鎖定換了一個地方。 所以比較務實的順序是:先用路線一把 controller 換掉,擺脫退役的專案;再依需求,一個服務一個服務搬到 Gateway API。這篇只是把可行性驗過一遍,哪一天真的動手,仍然要在自己的叢集重新驗證,尤其是真實憑證、來源 IP 與多節點的情境。 延伸閱讀 - Ingress NGINX 退役說明(官方 repo) - Kubernetes:Ingress(含 API 已凍結的說明) - Traefik:從 ingress-nginx 遷移 - Traefik:Kubernetes Ingress NGINX provider - ingress2gateway - cert-manager:Gateway API 支援
Stop Writing Bash Scripts for Everything! Build Your Own DevOps CLI Tool With Go Imagine having one command that checks your servers, monitors Docker containers, tests API endpoints, and reports infrastructure health. Instead of maintaining 20 different scripts, you build one powerful CLI tool. Sounds cool? That's exactly where Go becomes interesting for DevOps engineers. Bash is amazing for automation. Python is excellent for scripting. But when you want to build portable, compiled command-line applications that are easy to distribute, Go deserves your attention. Think about tools like: - Docker - kubectl - Terraform - Helm - Hugo Go is used to build all of these. So why not build your own? Let's create a practical DevOps CLI tool from scratch. 🚀 --- 🤔 1. Why Should DevOps Engineers Learn Go? Imagine you're managing multiple servers. You regularly need to: - Check whether servers are reachable. - Monitor API availability. - Inspect Docker containers. - Check application health. - Automate repetitive operations. - Generate reports. You could write separate Bash or Python scripts for every task. But as your automation grows, maintaining those scripts can become difficult. Go offers some useful advantages: | Feature | Why it matters | |---|---| | Compiled binaries | Easy application distribution | | Static typing | Catch many errors during development | | Goroutines | Run concurrent tasks | | Standard library | Build HTTP clients and servers | | Cross-compilation | Build executables for different platforms | | Fast startup | Useful for command-line applications | The idea isn't to replace Bash or Python. It's to choose the right tool for the job. ⚡ 2. What Exactly Is a CLI Tool? CLI stands for Command-Line Interface. You've probably used these commands: docker ps kubectl get pods terraform plan git status These are examples of command-line interfaces. A CLI tool allows users to interact with software through commands and arguments. Imagine building your own tool called devopsctl. Its commands could look like this: devopsctl server check devopsctl docker status devopsctl api health devopsctl system info Instead of manually remembering multiple scripts, you have one organized command-line application. Our goal is to build a small version of this idea. --- 🛠️ 3. Set Up Your Go Environment First, install Go from: go.dev Verify the installation: go version Create your project: mkdir devopsctl cd devopsctl Initialize a Go module: go mod init devopsctl Create a file: touch main.go Your project structure: devopsctl/ │ ├── main.go ├── go.mod └── README.md Now let's start building. 🌐 4. Build Your First Server Health Checker One common DevOps task is checking whether a service is reachable. Let's build a command that checks multiple URLs concurrently. Create main.go: package main import ( "fmt" "net/http" "sync" "time" ) func checkServer(url string, wg *sync.WaitGroup) { defer wg.Done() client := http.Client{ Timeout: 5 * time.Second, } start := time.Now() response, err := client.Get(url) if err != nil { fmt.Printf("[DOWN] %s: %v\n", url, err) return } defer response.Body.Close() latency := time.Since(start) fmt.Printf( "[UP] %s | Status: %s | Latency: %v\n", url, response.Status, latency, ) } func main() { servers := []string{ "https://google.com", "https://github.com", "https://go.dev", } var wg sync.WaitGroup for _, server := range servers { wg.Add(1) go checkServer(server, &wg) } wg.Wait() fmt.Println("Health checks completed!") } Run it: go run main.go Example output: [UP] https://google.com | Status: 200 OK | Latency: 120ms [UP] https://github.com | Status: 200 OK | Latency: 180ms [UP] https://go.dev | Status: 200 OK | Latency: 95ms Health checks completed! Your first Go-based DevOps utility is ready. What did we learn? - net/http handles HTTP requests. - sync.WaitGroup coordinates concurrent tasks. - go starts a goroutine. - time measures request duration. - Error handling helps identify unavailable services. One important detail: this checks whether an HTTP request succeeds at the transport level. A service returning HTTP 500 is still reachable, but its application response indicates an error. A production health checker should evaluate HTTP status codes and application-specific health endpoints. --- 🐳 5. Add Docker Container Monitoring Now let's make the project more interesting. What if your tool could inspect Docker containers? Docker exposes a command-line interface that you can call from Go. For a simple learning example: package main import ( "fmt" "os/exec" ) func checkDocker() { cmd := exec.Command("docker", "ps") output, err := cmd.CombinedOutput() if err != nil { fmt.Println("Docker command failed:", err) return } fmt.Println(string(output)) } func main() { fmt.Println("Checking Docker containers...") checkDocker() } Run: go run main.go This executes: docker ps and displays the result. For a more advanced production tool, consider using the Docker Engine API or official Go SDK rather than parsing command output. Also, external command execution should use fixed executable paths or carefully validated arguments. Never pass untrusted user input directly into a shell command. --- 📊 6. Turn It Into a Real CLI Application So far, we've written individual functions. But a professional CLI should support commands and arguments. For example: devopsctl health devopsctl docker devopsctl version Go provides the flag package in its standard library. Here's a simple example: package main import ( "flag" "fmt" ) func main() { health := flag.Bool( "health", false, "Check application health", ) version := flag.Bool( "version", false, "Show application version", ) flag.Parse() if *health { fmt.Println("Application is healthy!") return } if *version { fmt.Println("devopsctl version 1.0.0") return } flag.Usage() } Run: go run main.go --health Output: Application is healthy! Check the version: go run main.go --version For larger applications, explore Cobra, a popular Go library for building command-line applications. spf13/cobra --- 🚀 7. Compile Your CLI Into a Single Executable Here's one of the most satisfying parts of Go. Build your application: go build -o devopsctl Run it: ./devopsctl --health You can also cross-compile for different operating systems and architectures. For example, build a Linux AMD64 executable from another supported development environment: GOOS=linux GOARCH=amd64 go build -o devopsctl-linux Build for Windows: GOOS=windows GOARCH=amd64 go build -o devopsctl.exe Build for macOS on Apple Silicon: GOOS=darwin GOARCH=arm64 go build -o devopsctl-macos This makes it convenient to distribute your CLI without requiring users to install a separate Go runtime. Remember that cross-compilation can depend on native dependencies and build configuration. The examples above are simplest for pure-Go projects. --- ☸️ 8. Take It Further: Build a Kubernetes CLI Now imagine extending devopsctl with Kubernetes functionality. You could support commands such as: devopsctl k8s pods devopsctl k8s deployments devopsctl k8s nodes Go has a powerful Kubernetes ecosystem. Explore: - client-go — Kubernetes API client library. - controller-runtime — Libraries for controllers and operators. - Kubebuilder — Framework for building Kubernetes APIs and controllers. Official resources: kubernetes.io kubernetes/client-go With these tools, you can build applications that interact with Kubernetes clusters programmatically. For example, a Kubernetes monitoring CLI could: 1. Connect to a cluster using kubeconfig. 2. List Pods across namespaces. 3. Identify Pods that are not Ready. 4. Display restart counts. 5. Retrieve selected Pod logs. 6. Generate a troubleshooting report. That is a much more advanced project than simply running kubectl get pods. --- 🧪 9. Build This Portfolio Project: DevOpsCTL Here's the project I'd recommend building and publishing on GitHub. <text>Project: DevOpsCTL — Your Personal DevOps Command-Line Assistant</text> Core features | Module | Functionality | |---|---| | Server | Check endpoint availability | | Docker | List and inspect containers | | Kubernetes | List Pods and Deployments | | System | Display basic system information | | API | Check HTTP status and latency | | Reports | Export health-check results as JSON | Suggested architecture DEVOPSCTL | +----------+----------+ | | | v v v Server Docker Kubernetes Module Module Module | | | v v v HTTP Docker K8s API Client SDK Client | | | +----------+----------+ | v CLI Output | v JSON Reports Suggested repository structure devopsctl/ │ ├── cmd/ │ └── root.go │ ├── internal/ │ ├── server/ │ ├── docker/ │ ├── kubernetes/ │ └── system/ │ ├── main.go ├── go.mod ├── Dockerfile ├── README.md └── LICENSE Advanced features to add later - Prometheus metrics integration. - YAML configuration support. - Concurrent health checks with configurable limits. - Colorful terminal output. - Unit and integration tests. - GitHub Actions CI pipeline. - Release binaries for Linux, macOS, and Windows. Don't build everything at once. Start with the health checker, then add one module at a time. --- 🗓️ 10. Your 7-Day Go Developer Challenge Want to actually learn Go? Follow this challenge. | Day | Task | |---|---| | 1 | Learn Go syntax, variables, and functions | | 2 | Learn structs, slices, maps, and interfaces | | 3 | Practice goroutines and channels | | 4 | Build a REST API | | 5 | Build a server health checker | | 6 | Integrate Docker or Kubernetes | | 7 | Publish your first Go CLI project on GitHub | At the end of seven days, you won't be a Go expert. But you'll have something much more valuable: a working project and practical experience. --- 🎯 Final Thoughts Go is not just another programming language to add to your resume. It's a way to move from writing small automation scripts toward building real infrastructure tools. You can use it to create: - DevOps automation utilities. - Cloud-native applications. - REST APIs. - Kubernetes operators. - Monitoring systems. - Distributed services. - Command-line applications. And the best part? You don't need to master the entire language before building something useful. Start small. Build something. Break it. Debug it. Improve it. Don't just learn tools built by engineers. Start building tools that other engineers can use. 👇 Let's discuss! If you could build your own DevOps CLI tool, which feature would you implement first? 1. Docker monitoring 2. Kubernetes troubleshooting 3. AWS resource management 4. Server health monitoring 5. CI/CD automation Share your ideas in the comments! --- 📚 Continue Your Go Learning Journey Want a structured resource to learn Go? 🐹 Mastering Go — Complete Guide You can also explore: - 🐳 Docker Mastery - ☸️ CKA Complete Study Guide - 🏗️ Terraform Associate (003) Exam Crash Course - 🚀 DevOps Complete Pack Learn Go. Automate intelligently. Build the tools of tomorrow.
LWKD: Week Ending October 25, 2026 Last Week in Kubernetes Development Week Ending October 25, 2026 Developer News SIG ContribEx has updated the repo creation guidelines and the WG governance document to clarify how Working Groups relate to code ownership and repository creation, giving future WG proposals a clearer path from charter to code. SIG Architecture has added notes on future conformance in the developer guide, outlining how upcoming conformance work (including the newly formed WG Workload Conformance) will relate to existing conformance definitions. SIG ContribEx is coordinating KubeCon EU 2027 Project Pavilion and PR participation for SIGs, WGs, Committees, and Subprojects; interested groups should submit the registration form by November 25. The Maintainer Summit NA 2026 unconference is accepting topic ideas; sessions are open community-led discussions and need only a topic, a note-taker, and a facilitator attending in Salt Lake City. The SIG Scheduling leadership change proposed last week has taken effect after lazy consensus closed on October 26: Heba Elayoty is now a SIG Chair, Matt Matejczyk is a SIG Tech Lead, and Kensei Nakada has stepped down from Chair while remaining a Tech Lead. Release Schedule Next Deadline: Code Freeze, November 16th (AoE) November patch releases are targeted for November 10, with cherry-pick PRs due November 6. Featured PRs 143463: KEP-5710: Log error when PostFilter plugin does not implement PodGroupPostFilterPlugin jdzikowski added a scheduler initialization check for plugins that implement PostFilter but not PodGroupPostFilterPlugin, as part of KEP-5710. With the GenericWorkload feature gate enabled, the scheduler now logs an error when an enabled plugin will be skipped during scheduling for a PodGroup or CompositePodGroup. Previously, this behavior could go unnoticed, potentially leaving workload-aware scheduling integrations without the expected post-filtering behavior. The check also fulfills one of the KEP's graduation criteria for General Availability. 143387: KEP-5491: Adds integration and e2e test for List Type Attributes everpeace added integration and end-to-end tests for list-typed matchAttribute and distinctAttribute constraints in Dynamic Resource Allocation (DRA), as specified by KEP-5491. The tests exercise allocation against a real API server and scheduler, covering intersecting and disjoint attribute lists, scalar-to-list matching, and distinctness constraints. These cases help verify that device allocation behaves correctly across different attribute combinations and provide coverage required by the KEP's test plan for beta promotion in Kubernetes v1.38. KEP of the Week KEP-6268: Stall and Resume Slow Watch Streams A slow watch client can fill its kube-apiserver buffer, delaying other watchers before the server force-closes its stream. KEP-6268 instead marks that watcher unsynced, catches it up from shared watch-cache history, and resumes it on the same connection. If the needed events have aged out, the server sends 410 Gone. Clients and the API need no changes. @nami-ant and @rpb-ant authored the KEP for SIG API Machinery. They revised the design in September to share catch-up work across watchers, following etcd's model. The KEP merged October 1, while the kube-apiserver implementation remains under review behind the off-by-default WatchCacheStallResume gate. KEP-6268 is implementable and targets Alpha in v1.38; Beta and Stable milestones are TBD. Other Merges - Fix for a kubelet bug that set pod sandbox CPU and memory limits to the partial sum of container limits when only some containers declared them - kube-scheduler's preemption disruption metric gains a victim label; PreemptionVictim gains a Type() method and PreemptionCandidate.NumPodGroupDisruptions() is replaced by NumDisruptionsByVictimType() - MutatingAdmissionWebhook config gains a staticOrder field to control manifest vs. API-registered webhook ordering; requires the ManifestBasedAdmissionControlConfig gate and must be removed before downgrading to v1.37 or earlier - A new evictionrequest-controller creates Evictions from EvictionRequests and coordinates external responders - Add cgroup_mount_mode to the CRI API to support writable cgroups - kubectl selector commands like create pdb --selector now accept != - client-go adds DropManagedFields to omit managedFields from API responses, behind the new alpha ManagedFieldsOptOutClient gate - kubelet adds the alpha kubelet_device_plugin_devices metric, breaking device counts down by health state - Watch cache keeps the last snapshot behind the compaction window, avoiding etcd fallback for Exact LIST - Workload-aware scheduling: Pod scheduling status now names its parent PodGroup and PlacementFeasible counts are clearer, stricter CompositePodGroup validation, recursive status propagation, nodes with empty topology label values skipped as placement domains, multiple placement plugins intersected, and TopologyAwareWorkloadScheduling promoted to Beta (still disabled by default) Subprojects and Dependency Updates - etcd has released v3.7.3, v3.6.16, and v3.5.35, including multiple security patches, as well as fixing a few v3.7 regressions. - vertical-pod-autoscaler v1.8.1 and vertical-pod-autoscaler v1.7.3 were both released. They are built with Go 1.26.10, blocking the compromised eMudhra intermediate CAs. - etcd v3.7.3: New patch release, see CHANGELOG; also v3.6.16, v3.5.35 - coredns v1.15.0: Fixes CVE-2026-56865, CVE-2026-56864, CVE-2026-33818, CVE-2026-97032, CVE-2026-78659, CVE-2026-97031, and CVE-2026-111943; adds DNS-over-QUIC forwarding support, opt-in JSON query logging, and authenticated RFC 2136 dynamic DNS updates - prometheus v3.15.1: Builds with Go 1.27.3 and bumps golang.org/x/net to v0.60.0 to fix HTTP/2 vulnerabilities; also v3.13.5 - vsphere-csi-driver v3.8.0: Adds support for Kubernetes 1.36, upgrades csi-proxy to v2.0.0-alpha.2, and bumps Go dependencies to address CVEs Shoutouts - No shoutouts this week. Want to thank someone for special efforts to improve Kubernetes? Tag them in the #shoutouts channel. LWKD is a publication of Kubernetes Project Contributor Communications. Read it on the web, and contribute or send corrections to the GitHub repository. Powered by Buttondown, the easiest way to start and grow your newsletter. updated the repo creation guidelines: community#9232 WG governance document: community#9228 added notes on future conformance: community#9227 November 16th (AoE): github.com/kubernetes/sig-release/tree/master/rel… 143463: KEP-5710: Log error when PostFilter plugin does not implement PodGroupPostFilterPlugin: kubernetes#143463 jdzikowski: github.com/jdzikowski KEP-5710: enhancements#5710 143387: KEP-5491: Adds integration and e2e test for List Type Attributes: kubernetes#143387 everpeace: github.com/everpeace KEP-5491: enhancements#5491 KEP-6268: Stall and Resume Slow Watch Streams: github.com/kubernetes/enhancements/blob/master/ke… @nami-ant: github.com/nami-ant @rpb-ant: github.com/rpb-ant KEP merged October 1: enhancements#6269 kube-apiserver implementation: kubernetes#141450 Beta and Stable milestones are TBD: enhancements#6268 kubelet bug: kubernetes#141014 gains a victim label: kubernetes#142955 staticOrder field: kubernetes#143160 evictionrequest-controller: kubernetes#137133 cgroup_mount_mode to the CRI API: kubernetes#139824 create pdb --selector: kubernetes#142216 DropManagedFields: kubernetes#142695 kubelet_device_plugin_devices: kubernetes#143223 keeps the last snapshot behind the compaction window: kubernetes#143325 now names its parent PodGroup: kubernetes#143184 PlacementFeasible counts: kubernetes#143183 CompositePodGroup validation: kubernetes#142830 recursive status propagation: kubernetes#141860 empty topology label values skipped: kubernetes#140308 multiple placement plugins intersected: kubernetes#139565 promoted to Beta: kubernetes#140295 vertical-pod-autoscaler v1.8.1: github.com/kubernetes/autoscaler/releases/tag/ver… vertical-pod-autoscaler v1.7.3: github.com/kubernetes/autoscaler/releases/tag/ver… etcd v3.7.3: github.com/etcd-io/etcd/releases/tag/v3.7.3 v3.6.16: github.com/etcd-io/etcd/releases/tag/v3.6.16 v3.5.35: github.com/etcd-io/etcd/releases/tag/v3.5.35 coredns v1.15.0: github.com/coredns/coredns/releases/tag/v1.15.0 prometheus v3.15.1: github.com/prometheus/prometheus/releases/tag/v3.… v3.13.5: github.com/prometheus/prometheus/releases/tag/v3.… vsphere-csi-driver v3.8.0: github.com/kubernetes-sigs/vsphere-csi-driver/rel… to the GitHub repository: kubernetes-sigs/lwkd
kubecost 3.x を GKE に入れてみた — 入れる前に知っておきたかった 9 つのこと はじめに Kubernetes のクラスタは、誰がいくら使っているのかが見えにくいです。 クラウドの請求書には「ノード 3 台分」としか書かれておらず、その上で動くアプリごとの内訳は出てきません。 それを見えるようにするツールが kubecost です。検証用の GKE クラスタに入れてみました。 入れること自体は Helm で 1 コマンドですが、既定のまま入れると困る点がいくつもありました。 この記事は、実際に入れて確かめた「入れる前に知っておきたかったこと」のまとめです。 対象は、Kubernetes を触り始めて「requests って何となく書いている」くらいの方です。requestsは後述で説明します。 確かめた環境 項目 内容 クラスタ GKE(検証用)。手元の確認には minikube も使用 kubecost Helm chart kubecost/kubecost 3.2.4、無償版(Foundations) 見る方法 まずは kubectl port-forward。その後 Kong 越しの公開を準備 まず、何が見えるのか namespace ごとのコスト Monitor/Allocatiomsにnamespace(クラスタの中の区画)ごとに、CPU・メモリ・ディスク・ロードバランサーのコストが並びます。 注目してほしいのは idle の行です。これは「ノードとして借りているのに、どの Pod にも割り当てられていない分」です。 このクラスタでは合計 $16.83 のうち $8.71 が idle でした。半分以上が、誰も使っていない分の料金です。 何にお金がかかっているか Monitor/Assetsでクラウドの資源の内訳を見ることができます。ほとんどがノード代だと分かります。 どこを削れるか Savingsでは削減の候補が金額つきで並びます。一番大きいのは「Right-size container requests」、つまりコンテナの requests の見直しでした。 ! requests とは requests は「このコンテナはこれだけ CPU とメモリを使う予定です」という申告です。 Kubernetes はこの申告をもとに Pod をノードに詰めます。申告が実際より大きいと、使っていない分までノードを押さえてしまい、その分の料金を払うことになります。 コンテナごとに、今の申告(current)と、実際の使用量から計算した推奨値(rec)が出ます。 この画面は記事の最後でもう一度使います。 ! なお、入れてまだ 2 日目のスクリーンショットなので、数字は粗いです。 入れる前に知っておきたかった 9 つのこと 1. 2.x と 3.x は別物。検索で出てくる情報の多くは 2.x 最初につまずいたのがここです。 2.x 3.x Helm chart の名前 kubecost/cost-analyzer kubecost/kubecost Prometheus 必須 不要 データの置き場 Prometheus ClickHouse(同梱) 2.x の公式ドキュメントには、Prometheus は省略できず、無効にするとコストが 0 になる、と書かれています。 検証用クラスタにはPrometheusサーバがない(外部送信のみ)ため、同時構築が必要な2.xではなく3.xを選びました。 ! chart の名前もリポジトリの URL も違うので、2.x の記事の手順をそのまま 3.x に使うことはできません。 また、2.x の最新である 2.9 は 3.x への移行準備用の版で、新規に利用する場合は適していません(helm template がエラーで止まります)。 2. 既定のまま入れると、強い権限を持つ部品が一緒に入る chart を既定値で展開すると 8 つの部品が出てきます。そのうち 2 つは、入れる前に権限を確認した方がよいものでした。 部品 持っている権限 何のための部品か networkCosts ノードのネットワークを直接使う特権コンテナ。全 namespace の Secret を読める 通信量ごとのコスト clusterController クラスタの資源を作成・変更・削除できる 推奨値を自動で適用する Secret には、データベースのパスワードなどが入っています。コストを見るツールに、そこまでの権限を渡す必要はありませんでした。 この 2 つを無効にすると、残る権限は読み取りだけになります。 networkCosts: enabled: false clusterController: enabled: false ! 「無効にすると requests の推奨値が出なくなるのでは」と最初は思いましたが、出ました。 clusterController は推奨値を適用するための部品で、表示するだけなら要りません。 3. ディスクを既定で 161Gi 要求する。消しても残る デフォルトのままだと、永続ディスクを合計 161Gi を必要とします。検証用には大きすぎるので、8Gi まで削りました。 ! もう 1 つ、helm uninstall してもディスク(PVC)が消えません。消さずに残す印が chart 側で付いているためです。 試しに入れて消す、を繰り返すと、使われないディスクが課金されたまま残ります。消すときは PVC も手で消します。 kubectl get pvc -n kubecost 4. 既定では、利用状況を外部に送信する デフォルトでは、利用状況の送信とログの収集が有効です。社内のクラスタに入れるなら、先に決めておく項目です。 telemetry: enabled: false reporting: logCollection: false 無効にした状態でブラウザの通信を見たところ、画面からの通信はすべて kubecost 自身に向いていました。 5. Spot を使っているなら、ラベルを教えないと高く見積もられる Spot(クラウドの余った計算資源を安く借りる仕組み。GKE では Spot VM)のノードは、通常より大幅に安いです。 kubecost は「このノードは Spot か」をノードのラベルで判断します。どのラベルを見るかを教えないと、通常価格で計算され、コストがずっと高く出ます。 GKE の場合はこうです。 finopsagent: agent: kubecost: spotLabel: cloud.google.com/gke-spot spotLabelValue: "true" このラベルはクラウドによって違います。同じクラウドでも、ノードの増やし方(Karpenter を使うかどうか)で変わります。 6. 手元のクラスタで数字が出ても、正しいとは限らない minikube(手元の PC で動かす Kubernetes)でも、コストの数字は出ます。 ただし、これは実際の料金ではなく、kubecost が持っている仮の単価で計算したものです。 ! 「手元で数字が出た」は「動いた」の確認にはなりますが、「金額が合っている」かの確認にはなりません。 金額の確認は、実際のクラウドの上で、請求と見比べて行います。 7. 無償版でできないことは、API が教えてくれる 無償版(Foundations)には制限があります。どこまで使えるかは、実際に叩くのが確実でした。 やったこと 結果 30 日ぶんのデータを要求 拒否。上限は 15 日(360 時間) ResourceQuota の推奨値 拒否(有償版が必要) コンテナの requests の推奨値 使える kubecost 自身のログイン機能(OIDC) 使えない(有償版が必要) 最後の行が次の項目につながります。 8. アラートは「壊れたときだけ」届く kubecost 自身の状態を見張って Slack に知らせる機能(health アラート)があります。 設定に「1 時間ごと」とあったので、1 時間ごとに通知が来ると思っていましたが、来ませんでした。 1時間ごとに毎回通知が来るわけではなく、通知は『異常があった時だけ』です。 通知の経路が生きているかは、Alerts 画面の「Test Alert」ボタンで確かめます。 ! 「通知が来ない」を「壊れている」と読んでも、「正常」と読んでも間違えます。確かめる手段を別に持っておくのが大事でした。 9. 画面にログイン機能が無い。公開するなら手前で守る ! 無償版の kubecost には、ユーザーもロールもありません。URL に届いた人は全員、すべての画面と API を使えます。 port-forward で自分だけが見るなら問題ありませんが、チームで見るために公開するなら、手前に門番が要ります。 私たちは API ゲートウェイの Kong を手前に置き、4 種類の守りを重ねる設計にしました。(これから実装していく予定です!) 守り 見るもの IP 制限 どこから来たか ログイン(Keycloak) 誰か ロールの検査 管理者か 特定パスの拒否 入れた人にも見せないもの 公開前に確かめたところ、穴が 2 つ見つかりました。1 つ目(IP 制限が 3 回に 1 回外れる)は前の記事に書きました。ここでは 2 つ目を書きます。 拒否したはずのパスが %2F ですり抜けた kubecost の API の中に、Slack の webhook の URL を返すものがあります(/model/v3/alerts 以下)。 webhook の URL は、知っていれば誰でもそのチャンネルに投稿できる合鍵です。管理者にも見せる必要はないので、このパスだけ Kong で拒否しました。 rules: - matches: - path: {type: PathPrefix, value: /model/v3/alerts} 叩くと 403 が返りました。ここで終わりにするところでしたが、形を変えて試しました。 リクエストのパス 結果 /model/v3/alerts/global/slack 403 //model//v3/alerts/global/slack 403 /model/x/../v3/alerts/global/slack 403 /model%2Fv3/alerts/global/slack 200。webhook が返る /model/x%2F..%2Fv3/alerts/global/slack 200。webhook が返る %2F は / を別の書き方にしたもの(URL エンコード)です。 原因は、同じ文字列を 2 者が別の読み方をしていたことでした。 Kong は、照合の前にパスを整えます(// をまとめる、../ を畳む)。ただし %2F は / に戻しません。URL の決まりでは、エンコードされた / は「区切りではない、ただの文字」を意味するからで、Kong の動きは仕様どおりです kubecost の画面側の nginx は、%2F を / に戻してから中継先を決めます どちらも単体では間違っていません。検査する者と使う者で読み方がずれていたことが穴でした。 直し方は、%2F を含むパスをまとめて拒否する条件を足すことでした。 rules: - matches: - path: {type: PathPrefix, value: /model/v3/alerts} - path: {type: RegularExpression, value: "/.%2[Ff]."} これで全部 403 になり、通常の画面は壊れませんでした。ここまでは手元の minikube(Kong 3.9)で確かめた内容です。 ! 正常な形で 403 が出るのは「設定が書けた」の確認です。「塞いだ」の確認は、形を変えた入力でやります。 「ほかに漏れる API は無い」の確かめ方 webhook を返す API がほかに無いかも確かめました。kubecost のバイナリからパスらしい文字列を 32,329 本取り出し、全部叩きました。 このとき、本物の webhook は使いません。検査のログに本物の秘密が残るからです。 かといって何も設定せずに叩くと、0 件でも「漏れない」のか「返すものが無いだけ」なのか区別できません。 そこで、一目で偽物と分かる文字列を webhook として設定し、先に「漏れると分かっているパス」で検出できることを確かめてから総当たりしました。 CANARY=CANARYWEBHOOK0001 先に確認: 漏れると分かっているパスで見つからなければ、検査の方が壊れている curl -s "${BASE}/v3/alerts/global/slack" | grep -q "${CANARY}" \ || { echo "検査が機能していない" >&2; exit 1; } while read -r p; do curl -s "${BASE}${p}" | grep -q "${CANARY}" && echo "LEAK ${p}" done < candidates.txt 返したのは 2 本だけで、どちらも拒否済みのパスの下でした。 ! 副産物として、Helm の設定値を丸ごと返す API も見つかりました。 設定値に秘密を直接書いていたら、そこから読めていたことになります。秘密は設定値に書かず Secret で渡す、という決まりの理由が 1 つ増えました。 見えた。では、どう直すか 最後に、requests の推奨値の画面に戻ります。 このクラスタでは、申告が多すぎるものと、足りないものの両方がありました。 コンテナ メモリの申告(current) 推奨値(rec) 状態 データベース層のアプリ 4Gi 568Mi 多すぎる kubecost の収集役 512Mi 244Mi 多すぎる Kong 256Mi 1310Mi 足りない Keycloak 512Mi 819Mi 足りない 多すぎる申告はお金の無駄になり、足りない申告は、ノードが混んだときに Pod が追い出される原因になります。 コストを見るつもりで入れたツールが、安定性の問題も見つけてくれました。 ただし、kubecost が出すのは推奨値の表示までです。 そして、この数字を人が読んで設定ファイルを書き換える運用は、アプリが増えると続きません。使用量は日々変わるからです。 そこで次に試したいのが、Kubernetes の 2 つの自動調整の仕組みです。 仕組み 何を変えるか 向いている場面 VPA(Vertical Pod Autoscaler) 1 つの Pod の requests を、実際の使用量に合わせて上げ下げする 申告が実態とずれているとき HPA(Horizontal Pod Autoscaler) Pod の数を、負荷に合わせて増減する 時間帯で負荷が変わるとき VPA を入れれば、多すぎる申告が自動で下がり、その分ノードを減らせるかもしれません。足りない申告も自動で上がります。 HPA を入れれば、負荷の低い時間帯は Pod を減らしておけます。 どちらも、kubecost が見せてくれた「ズレ」を、人の手を借りずに直し続けてくれる仕組みです。 ! 本当にコストが下がるのかは、まだ試していません。 次は VPA と HPA を入れて、kubecost の数字が入れる前と後でどう変わるかを見てみるつもりです。 まとめ kubecost は 2.x と 3.x で構成が違う。Prometheus が無いクラスタなら 3.x 既定のまま入れない。権限の強い部品、大きなディスク、外部への送信を先に確認する Spot のラベルを教えないと、コストが高く出る 手元で数字が出ても、金額が正しい証拠にはならない 無償版にはログイン機能が無い。公開するなら手前で守り、形を変えた入力で確かめる 推奨値は表示まで。VPA と HPA を入れればコストも下げられるかもしれないので、次に試す 参考文献 Kubecost Core Architecture Overview(3.x) — 3.x の部品の構成 Kubecost Core Architecture Overview(2.x) — 2.x は Prometheus が必須であること kubecost/kubecost(Helm chart) — 3.x の chart と既定値 OpenCost — kubecost のコスト計算の元になっているオープンソース Resource Management for Pods and Containers(Kubernetes) — requests と limits の意味 HTTPRoute(Gateway API) — パスの照合方法(PathPrefix / RegularExpression) Route(Kong Gateway) — Kong のルートの照合とパスの扱い Request Termination プラグイン(Kong) — その場で決まった応答を返すプラグイン ngx_http_core_module の location(nginx) — 照合は %XX をデコードした後のパスで行われること RFC 3986 Uniform Resource Identifier — 予約文字と、エンコードされた予約文字の扱い(2.2 節、6.2.2 節) Autoscaling Workloads(Kubernetes) — VPA と HPA の全体像 Horizontal Pod Autoscaling(Kubernetes) Vertical Pod Autoscaler(kubernetes/autoscaler) Vertical Pod Autoscaling(GKE) 執筆者紹介 / 会社情報 執筆者 Higu BREXA SOLVIA SES アサイン先 株式会社Scalar 最近は Terraform・AWS・GCP・Kubernetes を勉強しています。わからないことだらけですが少しずつ整理しながら書いています! 会社情報 Scalar, Inc は、「データマネジメントの未来を創る」を掲げ、データベースミドルウェアの研究開発・販売を行う日本発のグローバルスタートアップ企業です!複数の異なるデータベース(RDB、NoSQL など)を仮想的に統合し、トランザクション処理や分析クエリを実現するデータベースミドルウェアである ScalarDB、分散台帳技術(DLT)を用いたソフトウェアで、データの改ざん検知を行う ScalarDL を主な製品としています。是非下記の弊社サイトもチェック📝をお願いします! 🔧 Product 🧑💻 開発者ブログ 🏢 コーポレートサイト
@trq212 so two things: 1) @bgrant0607, one of the OG kubernetes architects, wrote about drift/forking in K8s, and while code is different i think some concepts that went into the design of the kustomize CLI are at play here - github.com/kubernetes/design-proposals-archive/bl… 2) i think @0xblacklight 's work on a "design-iterated-loop" skill and his talk at AIE captures maybe the shape of "how can we drive agents to keep our fork up to date w/ upstream while preserving our changes" youtube.com (also a very k8s/control-loop-pilled approach)
klog, zap, logrus などで stderr に吐かれるログのレベルを Datadog で良い感じにする こんにちは、@key60228 です。 新しく立ち上げた GKE Cluster に Datadog Agent を入れてログを有効化してみたところ、とんでもない数のエラーログが吐かれていました。 まだ何も特別なことをしていないのに…???と不思議に思いログの詳細を見てみたところ、特に問題なさそうなログがほとんどでした。 結論としては、各コンポーネントが吐くログが Datadog が解釈できる形式で構造化されておらず、エラーレベルとして判定されてしまっていることが原因でした。 というわけでこの記事では各コンポーネント、ロガーがどんな構造で出力していて、どうすれば正しいログレベルで表示できるのかについてまとめていければと思います。 ソリューションを求めている方へ Datadog のログパイプラインで加工してあげてください。 Terraform pipelines.tf resource "datadog_logs_custom_pipeline" "container_log_level" { name = "container-log-level" description = "stderr 由来で error になったコンテナログの status を、本文の klog / zap / logfmt のレベルから付け直す。" is_enabled = true filter { query = "kube_cluster_name:*" } processor { grok_parser { name = "klog / zap / logfmt からレベルを取り出す" is_enabled = true source = "message" samples = [ "I0911 07:22:25.408586 1 cni_writer.go:930] Successfully synced \"gke-xxxxx-xxxxx-nod-xxxxxxxx-xxxx\"", # klog (GKE cni-writer) "E0911 07:10:07.225293 1 base_controller.go:161] \"controller failed to sync\" err=\"...\" key=\"image-sync\"", # klog (OCM work-agent) "2026-09-11T07:19:45Z\tINFO\tcontrollers.Spoke\trequeueing\t{\"request\": {\"name\":\"xxx\",\"namespace\":\"fleetconfig-system\"}, \"after\": \"1m0s\"}", # zap console、タブ区切り (fleetconfig-controller) "2026-09-11T04:39:22.159Z error logging/logging.go:16 NodeCollector: Failed to export metrics {\"kind\": \"receiver\"}", # zap console、スペース区切り (gke-metrics-agent) "time=2026-09-11T06:13:28Z level=info msg=Reconciling subsys=sfc reasons=[...]", # logfmt / logrus (cilium) ] grok { support_rules = "" match_rules = file("${path.module}/grok/container_log_level.grok") } } } processor { lookup_processor { name = "klog の severity 1 文字をレベル語に直す" is_enabled = true source = "klog.severity" target = "level" lookup_table = [ "I,info", "W,warning", "E,error", "F,critical", ] } } processor { lookup_processor { name = "zap のレベルを status remapper が解釈できる語に直す" is_enabled = true source = "zap.level" target = "level" lookup_table = [ "DEBUG,debug", "INFO,info", "WARN,warning", "ERROR,error", "DPANIC,critical", "PANIC,critical", "FATAL,critical", ] } } processor { lookup_processor { name = "logrus のレベルを status remapper が解釈できる語に直す" is_enabled = true source = "logfmt.level" target = "level" lookup_table = [ "trace,debug", "debug,debug", "info,info", "warning,warning", "error,error", "fatal,critical", "panic,critical", ] } } processor { status_remapper { name = "level を status にする" is_enabled = true sources = ["level"] } } } grok/container_log_level.grok klog %{regex("[IWEF]"):klog.severity}%{regex("\d{4} \d{2}:\d{2}:\d{2}\.\d+")}\s+%{integer:klog.thread_id}\s+%{regex("[^\]]+"):klog.location}]\s?%{data:klog.message} zap %{regex("\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})"):zap.timestamp}[\t ]+%{regex("(DEBUG|INFO|WARN|ERROR|DPANIC|PANIC|FATAL|debug|info|warn|error|dpanic|panic|fatal)"):zap.level:uppercase}[\t ]+%{data:zap.message} logfmt_with_time time=%{notSpace:logfmt.time}\s+level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message} logfmt level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message} Datadog Log Management のログレベルの仕組み Datadog のログでは status という予約済み属性が Log Explorer で表示されるログレベルに対応しています。1 Datadog Agent を使っている場合、この status は次の3つのタイミングで決まります。 Datadog Agent がログを送る時 Datadog が JSON ログをパースする時 ログパイプラインの Status Remapper を通る時 ① Datadog Agent がログを送る時 Kubernetes の Datadog Agent (各ノードの DaemonSet) では stdout に出たものを info、stderr に出たものを error と判定して Datadog に送っているようです。 github.com/DataDog/datadog-agent/blob/f85c4980d2b… ② Datadog が JSON ログをパースする時 次に、ログが JSON 形式であれば、Datadog は取り込み時の前処理で status、severity、level などの属性を探し、見つかった値で status を上書きします。2 {"level":"info","msg":"..."} のようなログは、stderr に書かれていても info として扱われます。 ③ ログパイプラインの Status Remapper を通る時 最後に、ログパイプライン に Status Remapper を置くと、任意の属性の値で status を上書きできます。 Status Remapper は指定された属性の値を先頭の文字列で以下の通り判定してくれます。3 値 status 0 から 7 の整数 Syslog の規格に従う emerg または f で始まる文字列 (大文字小文字を区別しない) emerg (0) a で始まる文字列 (大文字小文字を区別しない) alert (1) c で始まる文字列 (大文字小文字を区別しない) critical (2) err で始まる文字列 (大文字小文字を区別しない) error (3) w で始まる文字列 (大文字小文字を区別しない) warning (4) n で始まる文字列 (大文字小文字を区別しない) notice (5) i で始まる文字列 (大文字小文字を区別しない) info (6) d、t、v、trace、verbose で始まる文字列 (大文字小文字を区別しない) debug (7) o または s で始まる文字列、または OK か Success に一致する文字列 (大文字小文字を区別しない) OK 上記以外 info (6) つまり、プレーンテキストの本文にレベルを埋め込んでいるログは、②の前処理では拾われず、③のパイプラインを用意しない限り①のストリームだけで status が決まります。 今回の自分の環境のエラーログは、まさにこのパターンでした。 対策と各コンポーネント/ロガーの仕組み 根本的には各コンポーネントが JSON でログを吐くか、レベルに応じて stdout/stderr を使い分けていればこういった問題は起きません。 しかし GKE が管理するシステムコンポーネントは出力形式をこちら側では変えられず、OSS のコンポーネントも全てが出力形式を選べるわけではありません。 また、Datadog Agent でストリームと status の mapping を変更する方法も現時点ではなさそうです。 となるとメッセージ本文からレベルを取り出し、Status Remapper で status 属性を更新するログパイプラインを用意するのが良さそうです。 今回の自分の環境で問題になったログは、出力元のロガーで分けると klog、zap、logrus の3種類でした。 これらのロガーで、どのようにレベルを抽出すれば良いか見ていきます。 klog klog は glog の fork で、Kubernetes 本体も使用しているロガーです。 kubernetes/klog 今回の環境では、GKE のシステムコンポーネント (cni-writer、gke-metadata-server、proxy-agent etc.) や cert-manager などが klog かつ stderr でログを吐いていました。 klog の構造は以下のようになっており、先頭の L が severity を指しています。 Lmmdd hh:mm:ss.uuuuuu threadid file:line] msg github.com/kubernetes/klog/blob/ae3961e316a310e71… severity は I が INFO、W が WARNING、E が ERROR、F が FATAL を指します。 github.com/kubernetes/klog/blob/ae3961e316a310e71… 例えば proxy-agent の次のログは INFO ですが、stderr に書かれているため Datadog 上では ERROR になっていました。 そこで、まずは Grok Parser で属性をパースしてあげます。 klog %{regex("[IWEF]"):klog.severity}%{regex("\d{4} \d{2}:\d{2}:\d{2}\.\d+")}\s+%{integer:klog.thread_id}\s+%{regex("[^\]]+"):klog.location}]\s?%{data:klog.message} 上記ルールで IWEF の値を klog.severity に書き出しましたが、klog の場合はこの severity をそのまま Status Remapper に渡すことはできません。 I と W は期待通り info と warning になり、F は emergency になりますが、E は何にも当てはまらず、info になってしまいます。 そこで、Lookup Processor で klog.severity を調整してから level 属性に入れます。 (ついでに F も critical に調整しています) I,info W,warning E,error F,critical その後 Status Remapper を挟んであげることで、proxy-agent の INFO ログは info として表示されるようになりました。 zap zap は Uber が開発しているロガーで、Kubernetes 周りでは controller-runtime を使ったコントローラーの多くが zap を使っています。 uber-go/zap 今回の環境では、gke-metrics-agent や 別記事 で導入していた Open Cluster Management の fleetconfig-controller が zap でログを吐いていました。 zap には JSON で出力する JSON encoder と、人が読みやすいテキストで出力する console encoder があります。 github.com/uber-go/zap/blob/4892335e05f14bce8a98a… console encoder はデフォルトでは、タイムスタンプ、レベル、ロガー名、呼び出し元、メッセージ、追加フィールド (JSON) を区切り文字でつないで 1 行にします。 TIMESTAMP LEVEL LOGGER CALLER MESSAGE {FIELDS} zap の console encoder の出力は、次のルールで分解します。 zap %{regex("\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})"):zap.timestamp}[\t ]+%{regex("(DEBUG|INFO|WARN|ERROR|DPANIC|PANIC|FATAL|debug|info|warn|error|dpanic|panic|fatal)"):zap.level:uppercase}[\t ]+%{data:zap.message} 同じ console encoder でも、出力の細かいところはコンポーネントによって異なっているため、区切り文字はタブと半角スペース、レベルは大文字と小文字の両方に対応しています。 zap のレベルは DEBUG、INFO、WARN、ERROR、DPANIC、PANIC、FATAL とあり、今のままだと DPANIC が debug に、PANIC が info になってしまいます。 ついでに FATAL は klog に合わせて critical として扱うよう、Lookup Processor で調整してあげます。 github.com/uber-go/zap/blob/4892335e05f14bce8a98a… "DEBUG,debug", "INFO,info", "WARN,warning", "ERROR,error", "DPANIC,critical", "PANIC,critical", "FATAL,critical", なお、controller-runtime の zap のオプションをコマンドライン引数に結び付けているコントローラーであれば、--zap-encoder=json を渡して JSON encoder に切り替えられます。 JSON encoder の出力には level 属性が含まれるので、前処理で status に反映され、パイプラインは要りません。 logrus logrus もメジャーな構造化ロガーで、今回の環境では Zitadel が logrus でログを出していました。 sirupsen/logrus 出力形式は TextFormatter と JSONFormatter から選べ、TextFormatter は端末以外に出力するとき key=value を並べた logfmt 風の形式になります。 time="2026-10-13T05:58:18Z" level=INFO msg="structured logger configured" caller="..." error="..." レベルは level= の後ろに info、warning、error のような語で書かれます。 スクリーンショットのログは level=INFO ですが、Datadog 上では ERROR になっていました。 logrus の TextFormatter の出力は、次の 2 つのルールで分解します。 logfmt_with_time time=%{notSpace:logfmt.time}\s+level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message} logfmt level=%{word:logfmt.level:lowercase}\s+%{data:logfmt.message} TextFormatter は設定によってタイムスタンプを出さないことがあるため、time= で始まる行と level= で始まる行の両方に対応しています。 今回のログのように時刻を time="..." と引用符で囲む場合も、引用符ごと notSpace で logfmt.time に取り込まれるので問題ありません。 logrus のレベルは以下の通りなので、zap 同様に Lookup Processor で調整してあげましょう。 github.com/sirupsen/logrus/blob/6ef0748d1177dadae… "trace,debug", "debug,debug", "info,info", "warning,warning", "error,error", "fatal,critical", "panic,critical", これでログレベルが正しく表現されるようになりました。 ! この記事の本筋とズレるので本文では言及しませんでしたが、ZITADEL はオプションでログフォーマットを JSON 化できます。4 まとめ パイプラインを適用したあとは、status:error のログがほとんどなくなりました。 これで万事解決…とは思いつつ、klog、zap、logrus とは異なる出力形式を持つロガーを採用しているコンポーネントが出てきたらその都度対応しないといけない…? 🤔 GKE とか各 KaaS のシステムコンポーネント以外は大概ログの format などをこっちで指定できるから特に問題にならないのかな〜〜とは思いつつ、こんな複雑なことをしないといけないのはちょっと不思議な気がするな〜〜 5 もっと良い方法あるよって方、もし居たら教えてください〜〜 最後に AI Shiftではエンジニアの採用に力を入れています!少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?(オンライン・19時以降の面談も可能です!) 【面談フォームはこちら】 hrmos.co 脚注 属性とエイリアス設定 - Datadog Docs ↩︎ Pipelines (Preprocessing) - Datadog Docs ↩︎ Log Status Remapper - Datadog Docs ↩︎ ZITADEL Production Setup - ZITADEL Docs ↩︎ 「こんな複雑なことをしないといけないのはおかしい」というソフトウェアエンジニアの勘 - valid,invalid ↩︎
HPA は kubectl scale の台数を守らない CPU 使用率は低いのに、Pod の台数を増やしたいときがある。1 台あたりのワーカーが下流の応答待ちで埋まっているときや、イベントの前に台数を確保しておくときだ。そういうとき、HPA(HorizontalPodAutoscaler)が付いた Deployment でも、kubectl scale で replicas を増やすことがある。HPA は、CPU 使用率などのメトリクスから Pod の数を決めて、Deployment の replicas を書き換えるコントローラーだ。 増やした直後は、kubectl get deploy の READY が増やした数でそろう。HPA もすぐには戻さないので、そのまま保たれるように見える。 数分後に見ると、元の数に戻っている。エラーも警告も出ていない。HPA が戻したのなら、なぜ戻すまでに数分かかるのかが分からない。 この状態を再現し、戻したのが本当に HPA なのか、なぜ数分後に戻るのかを確かめた。 再現 冒頭の状態を、kind で立てた Kubernetes v1.37.0 のクラスタで再現した1。 CPU をほとんど使わない Pod を 2 台で動かす Deployment に、HPA を付けた。HPA は、CPU 使用率 50% を目標に、minReplicas 2、maxReplicas 5 で設定した。HPA は 15 秒ごとに数を決め直す。この 1 回の処理を、以下では同期と呼ぶ。 $ kubectl get hpa c1-1 NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE c1-1 Deployment/c1-1 cpu: 0%/50% 2 5 2 81s この Deployment を、kubectl scale で 2 台から 4 台に増やした。 $ kubectl scale deploy/c1-1 --replicas=4 deployment.apps/c1-1 scaled 増やした直後は、READY が 4/4 にそろった。 $ kubectl get deploy c1-1 NAME READY UP-TO-DATE AVAILABLE AGE c1-1 4/4 4 4 107s 約 5 分後に、2 台に戻った。 $ kubectl get deploy c1-1 NAME READY UP-TO-DATE AVAILABLE AGE c1-1 2/2 2 2 7m17s 切り分け Deployment の replicas を書き戻せるのは、ほかの人や CI が実行する kubectl apply、GitOps のツール、HPA などのコントローラーだ。 Kubernetes のオブジェクトには managedFields が付いている。オブジェクトのフィールドごとに、どのマネージャー(書き手)が書いたかの記録だ2。2 台に戻ったあとの Deployment を kubectl get deploy c1-1 -o yaml --show-managed-fields で見ると、spec.replicas のマネージャーが kube-controller-manager だった。 - apiVersion: apps/v1 fieldsType: FieldsV1 fieldsV1: f:spec: f:replicas: {} manager: kube-controller-manager operation: Update subresource: scale kube-controller-manager は、HPA などのコントローラーをまとめて動かす、クラスタ内のプロセスだ。kubectl apply や GitOps のツールが書いたなら、マネージャーには kube-controller-manager 以外の名前が入る。 どのコントローラーが書いたかは、Event で分かる。Event は、Kubernetes がオブジェクトごとに残す出来事の記録だ。kubectl events --for hpa/c1-1 で見ると、戻ったときに SuccessfulRescale が出ていた3。New size: 2 は、HPA が replicas を 2 に書き換えたことを示す。戻したのは HPA だ。 $ kubectl events --for hpa/c1-1 LAST SEEN TYPE REASON OBJECT MESSAGE 16s Normal SuccessfulRescale HorizontalPodAutoscaler/c1-1 New size: 2; reason: All metrics below target 残るのは、冒頭の問いのうち、HPA が戻すまでになぜ約 5 分かかるのかだ。 何が起きているか 同期の処理 HPA は同期のたびに、次の処理を行う。 Pod の CPU 使用率を読む 使用率の平均を目標で割って、比を出す。比から Pod の台数を計算する。以下では、この数を計算値と呼ぶ。比が 0.9〜1.1 なら今の数(Deployment の spec.replicas)が、範囲の外なら今の Pod の数に比を掛けた数(小数は切り上げ)が計算値になる4 減らすときは、過去 5 分の計算値の最大より下にしない。そのうえで、minReplicas から maxReplicas の範囲に収める5 3 で決まった数がクラスタ上の Deployment の spec.replicas と違えば、spec.replicas をその数に書き換える 3 の「5 分」は、HPA に behavior を書かないときの値で、kube-controller-manager の --horizontal-pod-autoscaler-downscale-stabilization の既定値から来る6。HPA ごとに、behavior.scaleDown.stabilizationWindowSeconds で変えられる。 増やしてから戻るまで 増やしてから戻るまでの同期を、次の表に示す。秒は kubectl scale を実行してからの経過時間だ。計算値は、再現環境で kube-controller-manager のログを --v=4(ログを詳しく出す設定)にして取った。 操作から 新しい 2 台のメトリクス 計算値 replicas 8 秒 なし 4 台 4 台 23 秒 なし 4 台 4 台 38 秒 あり(0%) 0 台 4 台 (15 秒ごとに同じ) 323 秒 あり(0%) 0 台 2 台 8 秒と 23 秒の同期では、新しい 2 台は動いているが、メトリクスがまだない。metrics-server は、各ノードから Pod の CPU とメモリの使用量を集めて、HPA に渡すコンポーネントだ。起動したばかりの Pod の値は、すぐには出ない。 HPA は、メトリクスのある Pod だけで出した比が 1 を下回るときは、メトリクスのない Pod を使用率 100% とみなして計算し直す7。ここでは、既存の 2 台が 0%、新しい 2 台が 100% で、平均は 50% になる。目標の 50% との比は 1.0 なので、処理の 2 で計算値は今の数の 4 になる。 38 秒の同期からは新しい 2 台のメトリクスが出て、計算値は 0 になる。ただし処理の 3 で、過去 5 分の計算値の最大の 4 より下にしないので、replicas は 4 のまま保たれる。 323 秒の同期で、23 秒の計算値 4 が 5 分より前になり、過去 5 分の計算値の最大は 0 になる。HPA はこれを minReplicas の 2 に収めて、spec.replicas を 2 に書き換える。 HPA にとってはいつもの縮小なので、Event は Normal の SuccessfulRescale だけで、エラーも警告も出ない。 kubectl scale の台数が残る期間 HPA は、人が kubectl scale で書いた replicas を、今の数として計算に入れる。減らすときに HPA が守るのは、過去 5 分の計算値だけだ。 人が書いた数が残るのは、計算値が最後にその数になってから 5 分のあいだだけだ。再現で 4 台が約 5 分残ったのは、新しい 2 台のメトリクスが出るまでの同期で計算値が 4 になり、その 4 が過去 5 分の計算値の最大として残ったからだ。 計算値が一度もその数にならなければ、最初の同期で戻る。同じ構成で 2 台から 3 台に増やすと、約 10 秒後の同期で 2 台に戻った。メトリクスのない 1 台を使用率 100% とみなしても、3 台の平均は約 33% で、目標の 50% との比は 0.9 を下回り、計算値は 2 になる。 数分後に戻るのも、すぐ戻るのも、戻しているのは HPA だ。 対策 手を入れる先は、HPA の下限(minReplicas)と、減らすときの規則(behavior.scaleDown)だ。 minReplicas を上げる HPA の minReplicas を、増やしたい台数まで上げる。その場で replicas が上がる。上がるのは下限だけなので、負荷が増えれば HPA はさらに増やせる。 $ kubectl patch hpa <hpa> --type=merge -p '{"spec":{"minReplicas":4}}' horizontalpodautoscaler.autoscaling/<hpa> patched maxReplicas を超える数にするなら、同じ patch で maxReplicas も上げる。 減らす向きを止めてから増やす HPA の behavior.scaleDown.selectPolicy を Disabled にしてから、kubectl scale で増やす。HPA は replicas を減らさなくなる8。 $ kubectl patch hpa <hpa> --type=merge -p '{"spec":{"behavior":{"scaleDown":{"selectPolicy":"Disabled"}}}}' horizontalpodautoscaler.autoscaling/<hpa> patched $ kubectl scale deploy/<deployment> --replicas=4 deployment.apps/<deployment> scaled どれを選ぶか 私は minReplicas を上げる。止まるのは下限より下への縮小だけで、HPA は負荷に応じて増やすことも、下限までの縮小も続ける。減らす向きを止める案は、HPA が負荷に応じて増やした分の縮小まで止めてしまう。 増やした台数が要らなくなったら、minReplicas を元の値に戻す。 脚注 検証結果はこちら、コードは show-your-work にある。 ↩︎ Kubernetes のドキュメントの Field management。 ↩︎ Event は既定で 1 時間で消える。kube-apiserver の --event-ttl の既定が 1h0m0s だからだ。 ↩︎ Kubernetes のドキュメントの Algorithm details。 ↩︎ kube-controller-manager の normalizeDesiredReplicas。 ↩︎ kube-controller-manager の既定値の設定。 ↩︎ kube-controller-manager の replica_calculator.go。 ↩︎ Kubernetes のドキュメントの Example: disable scale down。 ↩︎
Four Kubernetes node bugs I found by reading CI failures to the end --- title: "Four Kubernetes node bugs I found by reading CI failures to the end" published: true description: "A flake nobody had root-caused, a test that hung nodes for hours, a 3.9 GB image that never finished pulling, and a kubelet memory regression. How each one went from a red CI run to a patch." tags: kubernetes, go, sre, opensource --- Kubernetes' node tests fail a lot. Most of the time someone re-runs them, they go green, and everyone moves on. Over the past week I picked four failures in SIG Node territory and refused to move on — I kept reading the logs until the timeline explained the failure. Three were CI flakes. The fourth was a second, separate bug hiding inside a reported kubelet 1.37 memory regression. In every case, the bug wasn't where the error message pointed. Status, to be upfront: as of October 10, 2026, all four of my fixes are open pull requests, triaged or awaiting review, not merged. This post is about the investigations — which is the part I'd want to read anyway. How I read a failing CI run Every Kubernetes CI run on Prow keeps its artifacts: the build log, the JUnit results, the kubelet and containerd logs from the node, and for node tests, the VM's serial console. TestGrid shows the history, so you can tell a one-off from a pattern. My loop for each flake: 1. Find the pattern in TestGrid. Which job, how often, and — most useful — which similar jobs don't fail. 2. Build a timeline across logs. Test log, kubelet log and serial console on one clock, to the second. 3. Explain the difference. If only one job fails, something about that job's environment is the clue. 4. Reproduce before fixing. Either in a unit test that fails on master, or on a machine set up like the CI node. Bug 1: the image record that kept disappearing PR: kubernetes/kubernetes#142801 · fixes #140942 The symptom Tests for KubeletEnsureSecretPulledImages — the feature that makes the kubelet re-check credentials before reusing a private image — kept failing in one job, ci-kubernetes-node-swap-ubuntu-serial: 3 of the last 10 runs, 6 of 20 over a longer window. A test helper waits up to five seconds for the kubelet to write an ImagePulledRecord for an image, and the record never appeared. This had already been through two issues. One was closed by a PR that only added debug logging. The working theory in the open issue was that the kubelet doesn't write a fresh record when an image is already cached from an earlier test. The timeline From the failing run (prow job 2080994746846679040): !Timeline of the failing run: at 14:49:41 the test pod pulls the pause image and the record is written; at 14:49:42 the test restarts the kubelet and image GC runs at 85% disk usage, exactly the high threshold, and the post-GC hook deletes the record; at 14:49:47 the test times out waiting for a record that no longer exists The record was written. Then it was deleted one second later. The cached-image theory was wrong. Why only this job That job's image filesystem sat at about 85% for the whole run — exactly the kubelet's default high threshold for image garbage collection. Every kubelet restart in that job triggered a GC pass. Other jobs never got there. The root cause After a GC pass, the kubelet calls post-GC hooks with the list of images that "remain". The ImagePullManager's hook, PruneUnknownRecords, deletes every pulled-record for an image that isn't in that list. The list was built inside freeSpace(), from the images it evaluated. But images in use by a running container, and pinned images, are filtered out before freeSpace() ever sees them. And freeSpace() stops early once it has freed enough bytes, so images after that point were never added either: // imagesInEvictionOrder, simplified for image, record := range im.imageRecords { if isImageUsed(image, imagesInUse) { continue // in-use images never reach freeSpace()... } if record.pinned { continue // ...and neither do pinned ones } images = append(images, evictionInfo{id: image, imageRecord: *record}) } So the list of "remaining" images was really "the eviction candidates GC looked at and kept". The image the test pod was running wasn't on it, and its record got pruned. It fails safe — a later pod just has to re-verify credentials — but it's wrong, and it's invisible: the deletions weren't logged. The fix Pass the hooks every image the GC manager still tracks, not just the candidates it evaluated: // remainingImages returns the IDs of all images still known after a GC pass. // This must include in-use and pinned images, not just the eviction candidates // that survived: post-GC hooks treat anything missing from the list as gone. func (im *realImageGCManager) remainingImages() []string { im.imageRecordsLock.Lock() defer im.imageRecordsLock.Unlock() // The same image can be tracked once per runtime handler. imageIDs := sets.New[string]() for imageKey := range im.imageRecords { imageIDs.Insert(getImageIDFromTuple(imageKey)) } return sets.List(imageIDs) } Both GarbageCollect and DeleteUnusedImages now call im.runPostGCHooks(ctx, im.remainingImages(), freeTime), and pruned records are logged at V(4). The new TestPostGCHooksReceiveAllRemainingImages fails on master — the hook receives an empty list — and passes with the change. Status (Oct 10, 2026): open, awaiting triage and review from SIG Node. Bug 2: the swap test that hung nodes for hours PR: kubernetes/kubernetes#142850 · related to #138226 The symptom A swap e2e test, "should be able to use more than the node memory capacity", sometimes passed, sometimes failed, and sometimes produced a run that ended in exit status 255 with no JUnit results at all — the node had stopped responding. The numbers didn't add up The test allocates memory equal to the node's full capacity — 8 GiB on the CI node, which also has 1 GiB of swap. The system needs some of that RAM too, so part of the pod's allocation can't fit in memory and has to go to swap. In my reproduction below, when the pod was allowed to finish, about 761 MiB of it ended up in swap. Under LimitedSwap, a container's swap limit is proportional to its memory request: > swap limit = (memory request ÷ node memory) × node swap | Request | Swap limit | Against ~761 MiB of overflow | | --- | ---: | :--- | | 30% (before) | ~307 MiB | too small | | 90% (after) | ~921 MiB | fits | So in every run, the pod hit its swap limit with memory still left to place. What happened next varied — which is why the test flaked instead of failing every time: - The OOM killer stepped in quickly. The run recovered, and sometimes the test passed. - It didn't. The kernel tried to reclaim memory elsewhere. On this image system.slice and user.slice can't swap, so it thrashed the page cache instead — and the node hung for hours. Those are the exit status 255 runs with no JUnit results. The serial console of one failed run showed exactly this: the pod pinned at its swap limit, with roughly 700 MB of swap still free on the node. Reproducing it On a VM set up like the CI node (8 GiB RAM, 1 GiB swap, Ubuntu 24.04, the same slice swap settings): at 30% the pod hit its swap limit and was OOM-killed. At 90% it finished allocating with about 761 MiB in swap, with no OOM and no stalls. The fix Three changes, all test-only: 1. Request 90% of memory, so the pod's swap limit can hold the overflow. 2. Lower the memory.available hard-eviction threshold to 100 Mi for these tests. Under node-level pressure, swap only starts a few seconds before the default 250 Mi threshold would evict the pod — before the test can see any swap usage. 3. Delete the pod with DeleteSync, so it's gone before the kubelet restarts in AfterEach. Nothing kills the pod while the kubelet is down. Status (Oct 10, 2026): open, triaged as important-longterm, with the swap tests' original author asked to review. Bug 3: the 3.9 GB image that never finished pulling PR: kubernetes/kubernetes#142802 · related to #141200 The symptom The GPU sanity test (nvidia-smi plus a CUDA demo) timed out after 30 minutes with the pod stuck in ErrImagePull. Even passing runs spent up to 24 minutes pulling the image. The containerd log cancel pulling image docker.io/nvidia/cuda:12.5.0-devel-ubuntu22.04 because of no progress in 5m0s stop pulling image ...: active requests=1, bytes read=3918316892 failed to extract layer (... sha256:496b16dd...) to overlayfs as "extract-...": context canceled The image is about 3.9 GB, and its largest layer is about 2.5 GB. The log shows 3.9 GB had been read before the pull was cancelled during layer extraction. So the expensive part wasn't the network — it was unpacking the big layer, which reported no "progress" to containerd's five-minute watchdog. The cancelled pull didn't reuse what it had already fetched, so each kubelet retry started the download again. That's containerd/containerd#13909, with a proposed fix upstream. The fix Use a smaller image. On amd64, the test only needs nvidia-smi (which comes from the driver) and the cuda-demo-suite-12-5 apt package, whose binaries link the CUDA runtime statically. The ~90 MB nvidia/cuda:12.5.0-base-ubuntu22.04 image has the same CUDA apt repository, keyring and environment. Other architectures build samples with nvcc, so they keep the devel image, and the pod is pinned to the GPU node's architecture so the image always matches the script's uname -m branch. I left arm64 alone because I couldn't test it on arm64 GPU nodes. It can still hit the containerd behavior. Status (Oct 10, 2026): open, triaged as important-longterm, awaiting /ok-to-test and review. Bug 4: a second bug inside the 1.37 kubelet memory regression PR: google/cadvisor#3946 · issue kubernetes/kubernetes#142874 This one isn't a flake. A SIG Node contributor reported 20–30% higher kubelet memory in Kubernetes 1.37, traced to cAdvisor — the library the kubelet uses to collect container stats — and wrote the main fix. My part is a separate, smaller bug I found while reproducing it. To keep the credit straight: | | What it fixes | Author | | --- | --- | --- | | google/cadvisor#3944 | The reported cause: changing counters that stop housekeeping from ever backing off | the reporter | | google/cadvisor#3946 | A back-off reset bug that makes the interval oscillate even when stats are unchanged | me | The reported cause cAdvisor polls each container's stats on a "housekeeping" interval. When stats haven't changed, it backs off — doubling the interval up to a maximum. When they change, it drops back to the baseline. MemoryQoS, default-on in 1.37, sets memory.high on containers. The kernel then reclaims memory continuously, which keeps advancing counters in memory.stat. cAdvisor compared whole stats structs, so they never looked unchanged, and housekeeping never backed off. The reporter's fix, google/cadvisor#3944, excludes those counters from the comparison. The second bug underneath I reproduced the regression and confirmed #3944 fixes the counters. But while simulating it, I found the back-off had a bug of its own: if stats[0].StatsEq(stats[1]) && (cd.housekeepingInterval < cd.maxHousekeepingInterval) { cd.housekeepingInterval *= 2 // ... cap at max ... } else if cd.housekeepingInterval != *HousekeepingInterval { // Lower interval back to the baseline. cd.housekeepingInterval = *HousekeepingInterval } Once the interval reaches the maximum, interval < max is false — so even with unchanged stats, execution falls into the else branch and resets to the baseline. Instead of staying backed off, the interval cycles: with cAdvisor's defaults, 1s → 2s → 4s → … → 1m → 1s; with the kubelet's settings, 10s → 15s → 10s → 15s. !Chart of the housekeeping interval over successive ticks with unchanged stats: the original code climbs to the maximum and then drops back to the baseline in a repeating sawtooth, while the fixed code climbs to the maximum and stays there The fix separates the two conditions, so only changed stats reset the interval: if stats[0].StatsEq(stats[1]) { if cd.housekeepingInterval < cd.maxHousekeepingInterval { cd.housekeepingInterval *= 2 // ... cap at max ... } } else if cd.housekeepingInterval != *HousekeepingInterval { cd.housekeepingInterval = *HousekeepingInterval } The new TestNextHousekeepingIntervalDynamic steps through unchanged and changed samples. Run against the original code, it fails exactly where the sawtooth starts: !Terminal recording: the new test fails on the original cAdvisor code, expecting 4s but getting 1s at step 3 and 2s at step 4, then passes with the fix In my simulation of a 10-minute run with the kubelet's settings and MemoryQoS-style advancing counters, a container got 60 housekeeping ticks on the current code, 48 with #3944, and 41 with both fixes. Most of the improvement comes from #3944; mine removes the oscillation that remains. Neither patch alone gets the kubelet to back off as intended, and the simulation measures housekeeping ticks, not the production memory figure in the report. Status (Oct 10, 2026): #3946 is open; the issue is assigned to me, and the reporter called the patch a good one. What the four had in common Each bug lived at an edge that normal runs never reach: - A list that silently meant "some of everything". "Remaining images" excluded in-use and pinned images, and nothing downstream knew. - A number that couldn't work. A swap limit smaller than the overflow it had to hold. - A timeout that measured the wrong thing. A "no progress" watchdog applied to an unpack that reports no progress — and the fetched data not reused. - An else branch that fired at the boundary. Correct below the maximum, wrong exactly at it. And each one was explained by a difference: the one job at 85% disk, the one test whose request didn't match its allocation, the one image that was 40 times bigger than it needed to be, the one comparison that never settled. Flakes aren't noise. They're bugs that only show up under one environment's conditions. The job that fails is telling you which conditions. What the evidence does and doesn't establish | Evidence | Establishes | Does not establish | | --- | --- | --- | | Prow timeline for bug 1 | The record was written, then pruned by GC one second later, in that run | That every historical failure of these tests had this cause | | TestPostGCHooksReceiveAllRemainingImages | The hook now receives in-use and pinned images | That the fix has been reviewed or accepted | | VM reproduction for bug 2 | 30% fails and 90% holds on a CI-like machine | Behavior on every node image the swap tests run on | | containerd log for bug 3 | 3.9 GB had been read before the pull was cancelled during layer extraction | That every layer had fully downloaded, or that the smaller image never hits the same timeout | | cAdvisor test, failing on original and passing with the fix | The back-off reset bug exists and the change removes it | The production memory savings | | 60 / 48 / 41 tick simulation | The relative effect of #3944 and #3946 in my model | The 20–30% memory figure, which comes from the reporter's environment | Check each one yourself Bug 1 — kubelet image GC. Check out the PR branch and run the new unit test. Without the fix, the hook receives an empty list: go test ./pkg/kubelet/images/ -run TestPostGCHooksReceiveAllRemainingImages -v Bug 2 — swap test. This is a node e2e test, so it needs a Linux node with swap enabled. The PR describes the VM I used (8 GiB RAM, 1 GiB swap, Ubuntu 24.04). From a Kubernetes checkout: make test-e2e-node FOCUS="should be able to use more than the node memory capacity" Bug 3 — GPU image. The full test needs a GPU node; in CI it's the pull-kubernetes-e2e-gce-device-plugin-gpu job. The size difference behind the fix is easy to see anywhere crictl runs: crictl pull nvidia/cuda:12.5.0-devel-ubuntu22.04 # ~3.9 GB, ~2.5 GB largest layer crictl pull nvidia/cuda:12.5.0-base-ubuntu22.04 # ~90 MB The cancellation itself is containerd's image_pull_progress_timeout (five minutes by default) firing during extraction; containerd/containerd#13909 has the details. Bug 4 — cAdvisor back-off. git clone https://github.com/google/cadvisor && cd cadvisor/lib # add TestNextHousekeepingIntervalDynamic from the PR, then: go test ./manager/ -run TestNextHousekeepingIntervalDynamic -v On the original code it fails at step 3; with the fix applied, it passes. --- I'm Sachin, an SRE / platform engineer working on Kubernetes, observability and distributed systems — currently open to new roles. LinkedIn · GitHub
@danluu Not to toot my own horn, but I also discovered a bug in a real project while porting it to another language: Kubernetes! I ported about 100,000 lines of it up TypeScript and found a real bug: kubernetes#141155. I talk about it a little in ngrok.com. t.co