@lautidev_ Hola, encantado de charlar mas sobre esto, te paso un par de cosas que estoy usando y me rinden
para algo bien facil de usar y local miren herdr.dev en una maquina, tailscale y poco mas
para remote infra mas seria, kubernetes-sigs/agent-sandbox es facil de configurar
1 like · 26 views
ロードバランサーなしKubernetesでHTTPS公開!NGINX Gateway Fabricとcert-managerでLet's Encryptを自動化
はじめに
こんにちは!医療ITエンジニアの三浦です。
今回は、Ubuntu 26.04 LTS上に構築したシングルノードKubernetes環境で、 NGINX Gateway Fabric(NGF) と cert-manager を使用して、Let's EncryptのSSL/TLS証明書を自動取得・更新する環境を構築します。
AWSやAzureなどのクラウド環境では、ロードバランサーがHTTP/HTTPS通信を受け付ける構成が一般的です。
一方、オンプレミスやVPSなど、ロードバランサーを用意しない環境では、KubernetesのNodePort 80/443で外部通信を受け付けることができます。
今回は以下の構成を目指します。
- Gateway APIでHTTP/HTTPSのルーティングを管理
- NGINX Gateway FabricでTLS終端
- cert-managerでLet's Encrypt証明書を取得・自動更新
- NodePortで外部通信を受け付ける
- HTTPSでNginxのWebページを公開
今回はNodePortを80/443に直接割り当て、ホストの80/443ポートで通信を受け付けます。
環境
今回使用する環境は以下のとおりです。
| 項目 | 環境 |
|---|---|
| OS | Ubuntu 26.04 LTS |
| コンテナランタイム | containerd |
| Kubernetes | v1.37系 |
| CNI | Calico |
| Gateway API | v1.7.0 |
| Gateway Controller | NGINX Gateway Fabric |
| パッケージ管理 | Helm 3 |
| 証明書管理 | cert-manager |
| 証明書発行元 | Let's Encrypt |
前回の記事「Ubuntu 26.04 LTSにContainerd、Kubernetes 1.37、Calico 3.33をインストール」で、containerd・Kubernetes・Calicoの構築とNginxのNodePort公開まで確認しました。
本記事はその続編です。Gateway API、Helm、NGINX Gateway Fabric、cert-managerは今回新たに導入します。 前回のNginx検証用DeploymentとServiceは削除済みであることを前提とします。
1. 今回の構成
今回の構成は以下のようになります。
Internet
|
| HTTPS :443 / HTTP :80
|
[Public IP]
|
| NodePort :80 / :443
|
Ubuntu 26.04
|
Kubernetes (Single Node)
|
+-- NGINX Gateway Fabric
| |
| +-- NodePort 80 (HTTP)
| +-- NodePort 443 (HTTPS)
| |
| +-- Gateway
| |
| +-- HTTP Listener :80
| +-- HTTPS Listener :443
| |
| +-- TLS Termination
| |
| +-- HTTPRoute
| |
| +-- Nginx Service
| |
| +-- Nginx Pod
|
+-- cert-manager
|
+-- Let's Encrypt
|
+-- Certificate
|
+-- TLS Secret
NGFがHTTPS通信のTLS終端を担当し、復号後のHTTPリクエストをバックエンドのNginxに転送します。
cert-managerはLet's Encryptと通信して証明書を取得し、KubernetesのSecretとして保存します。
NGFはそのSecretを参照してTLS終端を行います。
NodePortとGateway Listener
今回はNodePortとGateway Listenerの両方をHTTP 80、HTTPS 443に設定します。NodePortはKubernetesのServiceを介して通信を転送する仕組みであり、NGFのPodがホストのポートを直接bindするわけではありません。
Let's EncryptのHTTP-01認証では、インターネットから対象ドメインのTCP 80番に到達できる必要があります。DNS、ファイアウォール、外部ネットワークの設定も確認してください。
2. NodePortの範囲を拡張
KubernetesのNodePortは通常 30000-32767 の範囲で割り当てられます。今回は80/443を直接指定するため、kube-apiserverの許可範囲を拡張します。以下はkubeadmで構築したクラスタの例です。
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml
spec.containers[].command の既存の引数を残し、以下を追加します。
- --service-node-port-range=80-32767
保存後、kubeletがStatic Podの変更を検知し、kube-apiserverを再作成します。
kubectl get pods -n kube-system
kubectl get --raw='/readyz'; echo
注意: この変更はクラスタ全体のNodePort割当範囲を広げます。80~29999のポートも割り当て可能になるため、他のServiceやホスト上のサービスとの競合、ファイアウォール設定に注意してください。設定前にマニフェストをバックアップし、APIサーバーの一時停止を許容できるタイミングで作業します。
3. Gateway API・Helm・NGINX Gateway Fabricのインストール
前回の記事で構築したKubernetes環境に、今回新たにGateway API、Helm、NGINX Gateway Fabric(NGF)をインストールします。Gateway APIはv1.7.0、NGFのNamespaceは nginx-gateway、Helmリリース名は my-ngf とします。
Gateway APIのインストール
Gateway APIのCRDをインストールします。
kubectl apply --server-side \
-f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.7.0/standard-install.yaml
インストールを確認します。
kubectl get crd | grep gateway.networking.k8s.io
Helmのインストール
今回使用するHelm 3をインストールします。
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
NGINX Gateway Fabricのインストール
NGFのNamespaceを作成します。
kubectl create namespace nginx-gateway
NodePort 80/443を指定してNGFをインストールします。この操作より前に、第2章のNodePort範囲拡張を完了させてください。
helm install my-ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
--namespace nginx-gateway \
-f - <<'EOF'
nginx:
service:
type: NodePort
nodePorts:
- port: 80
listenerPort: 80
- port: 443
listenerPort: 443
EOF
※ これは今回新規に設定するNGFのHelm Valuesです。NGFのChartバージョンによってValuesのスキーマが異なる可能性があるため、実際に使用するChartの helm show values oci://ghcr.io/nginx/charts/nginx-gateway-fabric で nodePorts の形式を確認してください。
NGFの起動を待機します。
kubectl wait --timeout=5m -n nginx-gateway \
deployment/my-ngf-nginx-gateway-fabric \
--for=condition=Available
導入状態と設定を確認します。
helm list -n nginx-gateway
helm get values my-ngf -n nginx-gateway
kubectl get pods -n nginx-gateway
kubectl get gatewayclass
このコマンドで新規インストールしたNGFの状態を確認します。既に別途NGFを導入している環境では、重複インストールを避けてください。
Gatewayリソースは第5章で作成します。NGFのデータプレーンServiceはGatewayの作成に応じて生成されるため、NodePortの割り当てはGateway作成後に確認します。
4. cert-managerのインストール
次に、TLS証明書の取得・更新を管理するcert-managerをインストールします。
今回はGateway APIを使用するため、Gateway API連携を有効化します。
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true \
--set config.gatewayAPI.enabled=true \
--wait
インストール後、Podの状態を確認します。
kubectl get pods -n cert-manager
以下のコンポーネントが稼働していることを確認します。
- cert-manager
- cert-manager-cainjector
- cert-manager-webhook
Gateway APIのCRDはcert-managerの起動前にインストールしておく必要があります。
後からGateway APIをインストールした場合は、cert-managerを再起動します。
kubectl rollout restart deployment/cert-manager \
-n cert-manager
5. Gatewayリソースの作成
続いて、NGFでHTTP/HTTPS通信を受け付けるGatewayを作成します。
今回新たに、default NamespaceにGatewayを作成します。
証明書を保存するSecretも同じNamespaceに配置します。
kubectl apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: prod-gateway
namespace: default
spec:
gatewayClassName: nginx
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: my-app-tls
allowedRoutes:
namespaces:
from: All
EOF
この設定では、HTTPとHTTPSの2つのListenerを作成します。
HTTPSのListenerでは、my-app-tls というSecretをTLS証明書として参照します。
現時点ではSecretが存在しないため、HTTPS Listenerが正常な状態にならない場合があります。
後ほどcert-managerで証明書を発行すると、Secretが自動生成されます。
Gatewayの状態を確認します。
kubectl get gateway -n default
kubectl describe gateway prod-gateway -n default
NGFのServiceも確認します。
kubectl get svc prod-gateway-nginx -n default
Gateway用のデータプレーンServiceで、以下のNodePortが割り当てられていることを確認します。
80:80/TCP
443:443/TCP
6. Let's EncryptのClusterIssuerを作成
cert-managerでLet's Encryptの証明書を取得するには、IssuerまたはClusterIssuerを作成します。
今回は複数のNamespaceから共通利用できる ClusterIssuer を使用します。
Let's Encryptには、本番環境とステージング環境があります。
本番環境には、認証失敗の回数や同一証明書の発行回数にレート制限があります。設定ミスのまま試行を繰り返すと、しばらく証明書を発行できなくなることがあります。
そのため、最初はステージング環境で証明書発行をテストし、問題がないことを確認してから本番環境に切り替えます。
この章ではステージング用と本番用の2つのClusterIssuerを作成しておき、実際の発行テストと切り替えは第7章で行います。
ステージング環境用のClusterIssuer
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: your-email@example.com
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
gatewayHTTPRoute:
serviceType: ClusterIP
parentRefs:
- name: prod-gateway
namespace: default
kind: Gateway
sectionName: http
EOF
your-email@example.com は自分のメールアドレスに変更してください。
gatewayHTTPRoute を指定することで、cert-managerはGateway APIのHTTPRouteを利用してHTTP-01認証を実行します。
認証時には一時的なHTTPRoute、Service、Podが作成されます。
これらを経由してLet's Encryptからの認証リクエストに応答します。
serviceType: ClusterIP は認証用Serviceをクラスタ内部に限定する設定です。外部公開はNGFが担当します。
本番環境用のClusterIssuer
続けて、本番用のClusterIssuerも作成しておきます。
ステージング用との違いは server と privateKeySecretRef のみです。
実際に本番証明書を発行するのは、第7章でステージング証明書の発行を確認してからです。
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: your-email@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
gatewayHTTPRoute:
serviceType: ClusterIP
parentRefs:
- name: prod-gateway
namespace: default
kind: Gateway
sectionName: http
EOF
ClusterIssuerの確認
作成したClusterIssuerを確認します。
kubectl get clusterissuer
NAME READY AGE
letsencrypt-prod True 10s
letsencrypt-staging True 30s
両方の READY が True になっていることを確認します。
詳細も確認しておきます。
kubectl describe clusterissuer letsencrypt-staging
Statusに The ACME account was registered with the ACME server と表示されていれば、Let's EncryptへのACMEアカウント登録は成功しています。
なお、ここで確認できるのは「Let's Encryptと通信でき、アカウントを登録できた」ことまでです。
ClusterIssuerがReadyでも、ドメイン認証が成功したわけではありません。
実際のHTTP-01認証は、次章のCertificate作成時に行われます。
7. Certificateリソースの作成
続いて、取得する証明書を定義します。
今回は example.com の証明書を取得する例です。
実際に所有しているドメインに変更してください。
事前にDNSのAレコードを設定し、対象ドメインがサーバーのグローバルIPアドレスを参照するようにしておきます。
また、TCP 80番ポートからNGFのHTTP Listenerにアクセスできる必要があります。
ステージング証明書の取得
まずはステージング用のClusterIssuerを指定して、Certificateを作成します。
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-app-tls
namespace: default
spec:
secretName: my-app-tls
dnsNames:
- example.com
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
EOF
証明書の発行処理中は、以下のリソースで進行状況を確認できます。
kubectl get certificaterequest -n default
kubectl get order -n default
kubectl get challenge -n default
kubectl get httproute -n default
認証中は cm-acme-http-solver-xxxxx という一時的なHTTPRouteが作成されます。
認証が完了すると、ChallengeやこのHTTPRouteは自動的に削除されます。
ステージング証明書の確認
本番証明書に切り替える前に、以下の3点を確認します。
1. Certificateが発行済みであること
kubectl get certificate my-app-tls -n default
NAME READY SECRET AGE
my-app-tls True my-app-tls 1m
READY が True になっていることを確認します。
あわせて、Challengeが残っていないことも確認します。
kubectl get challenge -n default
No resources found と表示されれば、認証が完了して一時リソースが片付いた状態です。
READY が True にならない場合は、詳細を確認します。
kubectl describe certificate my-app-tls -n default
kubectl describe challenge -n default
2. Secretにステージング証明書が保存されていること
kubectl get secret my-app-tls -n default \
-o jsonpath='{.data.tls\.crt}' | base64 -d \
| openssl x509 -noout -issuer -subject -dates
issuerに (STAGING) が含まれていれば、ステージング環境から発行された証明書です。
3. NGFがその証明書でTLS終端していること
外部のPCから、443番ポートで提供されている証明書を確認します。
openssl s_client \
-connect example.com:443 \
-servername example.com \
</dev/null 2>/dev/null \
| openssl x509 -noout -issuer
ここでもissuerに (STAGING) が含まれていれば、外部から443番ポートに到達でき、GatewayのHTTPS ListenerがSecretを読み込めていることまで確認できます。
HTTPRouteは第9章で作成するため、この時点でHTTPSにアクセスするとHTTPのレスポンスは404になりますが、TLSの確認には影響しません。
この3点を確認できれば、DNS、TCP 80番ポートへの到達、HTTP-01認証、Secretの生成、Gatewayからの参照まで一通り動作しています。
なお、ステージング環境の証明書はブラウザから信頼されません。
確認ができたら、本番用ClusterIssuerに切り替えます。
本番証明書への切り替え
CertificateのissuerRefを本番用ClusterIssuerに変更します。
kubectl patch certificate my-app-tls \
-n default \
--type=merge \
-p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}'
cert-managerがIssuerの変更を検知し、本番用の証明書を発行します。
kubectl get certificate my-app-tls -n default -w
READY が一度 False になり、再び True になれば発行完了です(Ctrl+C で監視を終了します)。
本番証明書の確認
Secretの証明書が本番環境のものに置き換わったことを確認します。
kubectl get secret my-app-tls -n default \
-o jsonpath='{.data.tls\.crt}' | base64 -d \
| openssl x509 -noout -issuer -subject -dates
issuerから (STAGING) がなくなり、O = Let's Encrypt になっていれば本番証明書です。
外部からも確認します。
openssl s_client \
-connect example.com:443 \
-servername example.com \
</dev/null 2>/dev/null \
| openssl x509 -noout -issuer
NGFはSecretの変更を検知して設定を更新するため、NGFの再起動は不要です。
Secretには証明書と秘密鍵が保存されており、NGFはGatewayで指定したこのSecretを参照してTLS終端を行います。
8. NginxのWebサーバーをデプロイ
証明書の準備ができたら、バックエンドとなるNginxをデプロイします。
今回は動作確認用のシンプルな構成とします。
kubectl create deployment nginx-demo \
--image=nginx:stable \
-n default
Serviceを作成します。
kubectl expose deployment nginx-demo \
--port=80 \
--target-port=80 \
--type=ClusterIP \
-n default
状態を確認します。
kubectl get pods -n default
kubectl get svc nginx-demo -n default
この段階では、Nginxはクラスタ内部からのみアクセスできます。
外部からアクセスするには、Gateway APIのHTTPRouteを作成します。
9. HTTPRouteの作成
HTTPSのリクエストをNginxに転送するHTTPRouteを作成します。
example.com の部分は実際に所有しているドメインに変更してください。
kubectl apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: nginx-route
namespace: default
spec:
parentRefs:
- name: prod-gateway
sectionName: https
hostnames:
- example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: nginx-demo
port: 80
EOF
この設定では、以下の通信経路になります。
HTTPS Request
|
v
NGINX Gateway Fabric
|
| TLS Termination
|
v
HTTPRoute
|
v
nginx-demo Service
|
v
Nginx Pod :80
HTTPRouteの状態を確認します。
kubectl get httproute -n default
kubectl describe httproute nginx-route -n default
Accepted=True と ResolvedRefs=True になっていることを確認します。
HTTPからHTTPSへのリダイレクト
通常のHTTPアクセスをHTTPSへリダイレクトするHTTPRouteも作成します。
kubectl apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: https-redirect
namespace: default
spec:
parentRefs:
- name: prod-gateway
sectionName: http
hostnames:
- example.com
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
EOF
これにより、HTTPでアクセスした場合はHTTPSにリダイレクトされます。
ただし、Let's EncryptのHTTP-01認証でもHTTPを使用します。
cert-managerが作成する認証用HTTPRouteとリダイレクト用HTTPRouteが競合しないことを確認してください。
HTTP-01認証の一時的なRouteは、特定の認証パスに対して作成されるため、通常のPathPrefixルートより優先される構成です。
10. 動作確認
ブラウザで以下のURLにアクセスします。
https://example.com
Nginxの初期ページが表示されれば成功です。
また、curlでも確認できます。
curl -I https://example.com
HTTPからHTTPSへのリダイレクトも確認します。
curl -I http://example.com
301リダイレクトが返されることを確認します。
証明書の内容はOpenSSLでも確認できます。
openssl s_client \
-connect example.com:443 \
-servername example.com \
</dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
Issuer、Subject、有効期限などを確認できます。
11. 証明書の自動更新
cert-managerを使用するメリットの1つが、TLS証明書の自動更新です。
証明書の有効期限を監視し、更新が必要になると自動的に新しい証明書の発行処理を実行します。
更新後の証明書は、同じKubernetes Secretに保存されます。
NGFはSecretの変更を検知し、新しい証明書を使用するように設定を更新します。
通常、証明書更新のたびに手動でNGFを再起動する必要はありません。
証明書の状態は以下のコマンドで確認できます。
kubectl get certificate -n default
詳細を確認します。
kubectl describe certificate my-app-tls -n default
更新予定時刻や発行状態を確認できます。
また、cert-managerのログも確認できます。
kubectl logs -n cert-manager \
deployment/cert-manager
証明書更新時にもHTTP-01認証が必要になるため、TCP 80番ポートは継続して外部からアクセスできる状態にしておきます。
12. トラブルシューティング
証明書が発行されない
まず、Certificateの状態を確認します。
kubectl describe certificate my-app-tls -n default
続いて、Challengeを確認します。
kubectl get challenge -n default
kubectl describe challenge -n default
主な原因として、以下が考えられます。
- DNSが正しいグローバルIPを参照していない
- TCP 80番ポートが外部に公開されていない
- GatewayのHTTP Listenerが存在しない
- HTTP-01認証用HTTPRouteがGatewayに接続できない
- ファイアウォールで通信が遮断されている
- cert-managerから認証用Serviceへ通信できない
GatewayがReadyにならない
kubectl describe gateway prod-gateway -n default
GatewayのConditionsを確認します。
特に、TLS Secretの存在、GatewayClass、Listenerの設定を確認してください。
HTTPSで502エラーになる
kubectl get pods -n default
kubectl get svc -n default
kubectl describe httproute nginx-route -n default
NginxのPodが正常に稼働しているか、Serviceのポート番号が正しいかを確認します。
まとめ
今回は、シングルノードKubernetes環境でNGINX Gateway Fabricとcert-managerを使用し、Let's Encryptの証明書を自動取得・更新する環境を構築しました。
今回のポイントは以下のとおりです。
- Gateway APIでHTTP/HTTPSのルーティングを管理できる
- NGFでTLS終端を実現できる
- cert-managerで証明書の取得・更新を自動化できる
- NodePort 80/443を使用してロードバランサーなしでWebサービスを公開できる
- NodePort範囲を拡張して80/443を直接割り当てられる
クラウドのマネージドロードバランサーを使用しなくても、Kubernetesの標準的なリソースを組み合わせることでHTTPS環境を構築できます。
ただし、シングルノード構成はホスト障害時にサービス全体が停止するため、高可用性が必要な本番システムでは冗長化も検討する必要があります。
今後は、NGFを利用したmTLS(クライアント証明書認証)や、Blue/Greenデプロイメントにも挑戦していきたいと思います。
参考リンク
- 前回の記事:Ubuntu 26.04 LTSにContainerd、Kubernetes 1.37、Calico 3.33をインストール
- Kubernetes Gateway API
- NGINX Gateway Fabric公式ドキュメント
- NGINX Gateway Fabric Helmインストール
- cert-manager公式ドキュメント
- cert-manager HTTP-01
- Let's Encrypt
How many of the agent developers are using this for their agents?
kubernetes-sigs/agent-sandbox
2 likes · 174 views
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 支援
單機 Kubernetes 的管理介面連換兩次:Rancher Portainer Headlamp,以及 sessionTTL 與 token 的坑
> 一句話結論:單機或小叢集只想要一個「看得到所有 workload」的介面,Headlamp 夠用而且很輕。Rancher 和 Portainer 都不是不好,是對這個場景太重,或剛好踩到記憶體的坑。
當初怎麼做
單機的 Kubernetes 叢集,總是想要一個網頁介面:不用每次都敲 kubectl get pods -A,看 log、看資源用量也比較直覺。
第一個選的是 Rancher。理由很單純:名氣大、功能完整,感覺「正式的叢集就該有」。
哪裡不行
Rancher:用不到的功能太多,壞了又很難修
Rancher 預設會帶一整組週邊元件:多叢集管理、Fleet、Turtles、內嵌的 Cluster API controller。
某一天 Rancher 就卡在 CrashLoopBackOff,連續好幾天。原因是兩套內嵌的 CAPI controller 的 CRD,storedVersions 版本衝突。官方文件有記載這類問題,正式的解法是跑它自己的清理工具,而那是破壞性的。
回頭看,這個判斷就很明顯:一台機器、一個叢集,從頭到尾沒用過下游叢集、Fleet、Turtles。為了一個「看叢集」的需求,裝了一整個多叢集管理平台,出問題時要追的東西比要看的東西還多。
所以沒有去追這個衝突,直接整個換掉。
Portainer:輕量,但看 Helm 頁面會吃光記憶體
換成 Portainer CE,用 Helm 安裝,單機叢集夠用。
直到點進 Kubernetes 的 Helm 頁面。這個頁面會讀 Helm repository 的 index,記憶體瞬間飆高。
原本 limit 是 512Mi,pod 連續被 OOMKilled 三次,前端出現「Unable to retrieve helm repositories」和一堆連線被重置的錯誤。
調到 1Gi 之後還是撐不住,反覆 OOM。與其繼續加記憶體,不如承認它的行為不適合這台機器。
換成什麼
換成 Headlamp。它是 Kubernetes SIG 的專案,只做 Kubernetes,介面簡單,資源限制設 300m CPU、256Mi 記憶體就夠。
用 Helm 安裝,重點設定是 ingress 和一個 clusterRoleBinding 綁 cluster-admin,這樣可視範圍就是整個叢集。
遷移怎麼進行
遷移本身很單純,因為這類管理介面幾乎沒有自己的狀態要搬:
1. 新增 Headlamp 的部署設定,移除 Portainer 的。
2. 確認舊的 namespace 和 PVC 真的被清掉,沒清就手動刪。
3. 把文件、儀表板裡寫死的舊 namespace 名稱全部換掉。
4. 產生登入 token。
接下來才是真正花時間的部分,登入。
踩到的坑
Headlamp 的登入頁需要貼上一個 ServiceAccount 的 bearer token。
坑一:token 的有效期限
最先想到的是用 kubectl create token 產生 token。預設的有效期限只有 1 小時,所以文件上寫的是加 --duration=8760h(一年)。
後來發現隔天還是要重新登入,就修改文件,說「API Server 通常會限制手動產生 token 的存活時間,--duration 無法突破上限」,改用一個永久的 Secret:
apiVersion: v1
kind: Secret
metadata:
name: headlamp-permanent-token
namespace: portal
annotations:
kubernetes.io/service-account.name: headlamp
type: kubernetes.io/service-account-token
建立之後,Kubernetes 的 controller 會自動把 token 填進這個 Secret,而且不會過期。取出來用:
kubectl -n portal get secret headlamp-permanent-token -o jsonpath='{.data.token}' | base64 -d
Secret 本身不含明文憑證,可以放進 Git。
這個做法是對的,但當時的診斷是錯的。為了驗證,這次在一個全新的 k3s(v1.31.5)叢集重做一遍:
| 方式 | token 的有效期限 |
|---|---|
| kubectl create token(預設) | 1 小時 |
| kubectl create token --duration=8760h | 8760 小時(一年),沒被截短 |
| kubernetes.io/service-account-token 的 Secret | 沒有 exp,不會過期 |
也就是說,在預設的 k3s 上,--duration=8760h 其實會生效。隔天又被要求重新登入,真正的原因是下一個坑。當時那個叢集有沒有額外的 token 期限上限,已經無法確認,所以不能說前面的說法一定是錯的,但至少「--duration 無法突破上限」不是普遍成立的結論。
永久 Secret 仍然比較省事:不用記得重新產生,也能放進 Git 管理。要記得這個 token 綁的是 cluster-admin,等於整個叢集的鑰匙,不要貼在聊天或截圖裡。
坑二:token 沒過期,卻還是要重新登入
Headlamp 前端有自己的 session TTL,預設只有 24 小時,跟 token 有效期限無關。到期之後被踢回登入頁,要重貼同一組還有效的 token。預設值 24 小時是照上游文件與 相關 issue(新增 session TTL 設定)的說法,這次沒有另外量測。
chart 有 config.sessionTTL 可以調,文件說上限是一年,於是設成 31536000 秒。
結果 Headlamp 直接 CrashLoopBackOff:
invalid value "3.1536e+07" for flag -session-ttl: parse error
原因出在 chart 的 template:它把 sessionTTL 直接插進 args,沒有轉成整數。Helm 讀 values 檔裡的數字時是 float64,Go 預設的浮點數輸出格式,數值一到 7 位數就改用科學記號。
不用部署,本機 helm template 就能重現。測 chart 0.43.0,傳進去的值和渲染結果如下:
| config.sessionTTL | 渲染出的參數 |
|---|---|
| 999999 | -session-ttl=999999 |
| 1000000 | -session-ttl=1e+06 |
| 31536000 | -session-ttl=3.1536e+07 |
分界就在 7 位數。再用寫這篇時最新的 0.46.0 測一次,31536000 仍然渲染成 3.1536e+07,問題還在。
所以只能取 6 位數以內的值。設成 999999,約 11.5 天,是不踩到科學記號的最長值。
這類「數字在 YAML 裡是整數,進了 template 變成浮點數」的坑,不只在 Headlamp,任何 chart 把數字裸插進 command line 都可能遇到。覺得數字很大的時候,先 helm template 看一眼渲染結果再部署。
坑三:Application 永遠 OutOfSync
如果用 GitOps 工具管理,Headlamp 部署後可能一直顯示 OutOfSync,雖然服務完全正常。
chart 固定在 Deployment 的 pod spec 帶入 hostUsers: true。這個欄位依賴 user namespace 的 feature gate,k3s v1.31.5 預設沒開,API server 收到會直接丟掉這個欄位,結果 live 的物件永遠沒有它,對不上期望的內容。
在乾淨的 k3s v1.31.5 上實測:chart 渲染出 hostUsers: true,套用之後讀回 Deployment,這個欄位是空的。
解法是在 Application 加 ignoreDifferences 忽略這個欄位。
換了之後的代價
Portainer 還能順便管主機上的 Docker,Headlamp 只管 Kubernetes。這個功能隨著替換一起捨棄,需要看 Docker 時回到命令列。
另外,Headlamp 比較陽春。沒有 Rancher 的多叢集、也沒有 Portainer 那種一站式的應用範本。對單機叢集來說,這些剛好都用不到。
如果日後真的要管多個叢集,或需要比較完整的權限管理,Rancher 這類工具還是會回來,只是這一次不會為了一個「看 pod」的需求先裝它。
延伸閱讀
- Headlamp 專案
- Kubernetes 文件:ServiceAccount 的長期 API token
Gateway API in Production: Replacing Ingress NGINX Without Breaking Your TLS Certificates
Ingress NGINX is in maintenance mode, and the Kubernetes project has been nudging everyone toward Gateway API for a while now. The networking spec graduated to GA (v1) for GatewayClass, Gateway and HTTPRoute, the ecosystem has converged, and your next cluster upgrade is a good excuse to stop postponing the migration.
The problem is that "replace your ingress controller" is a sentence that hides a lot of risk: a single typo in a TLS listener and every browser in production gets a certificate error. This article walks through a migration strategy that never requires a big-bang cutover: run Gateway API next to your existing Ingress NGINX, move one hostname at a time, shift traffic by weight, and validate the TLS chain at every step.
We'll use Envoy Gateway as the controller and cert-manager for certificates, but the pattern applies to any conformant implementation (Istio, Cilium, Traefik, NGINX Gateway Fabric).
Context: why this migration is different
Swapping an ingress controller is not like swapping a Deployment image. Three things make it risky:
1. TLS termination moves. Your certificates live in Secrets referenced by Ingress objects. The new data plane has to reference the same Secrets (or new ones), possibly from a different namespace, under a stricter cross-namespace model.
2. The public IP changes. A new controller means a new LoadBalancer Service, a new external IP, and a DNS change. DNS changes are not atomic — you cannot roll them back in 30 seconds.
3. Annotations don't translate. Rewrites, CORS, rate limits, auth, timeouts — everything encoded in nginx.ingress.kubernetes.io/* annotations becomes either native Gateway API fields, filters, or implementation-specific policies.
The good news: Gateway API was explicitly designed for coexistence. An Ingress and a Gateway can serve the same hostname from the same cluster, and nothing in the spec forbids two controllers from watching different resources. That's the lever we're going to pull.
Target architecture
The model splits responsibilities across three object types, which maps nicely onto real org boundaries:
- GatewayClass — cluster-scoped, owned by the platform team. Points at a controller.
- Gateway — the listener (ports, protocols, TLS certs, allowed routes). Lives in an infrastructure namespace.
- HTTPRoute — owned by application teams, in application namespaces, attached to a Gateway via parentRefs.
- ReferenceGrant — the explicit opt-in that allows a cross-namespace reference (Gateway → Secret, HTTPRoute → Service).
Concretely: one shared Gateway in gateway-infra, TLS Secrets managed by cert-manager, and each team shipping its own HTTPRoute in its own namespace.
Step 1 — Install the CRDs and the controller
On a vanilla Kubernetes cluster, install the standard channel CRDs first:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
kubectl get crd | grep gateway.networking.k8s.io
> OpenShift note: since 4.19, the Ingress Operator can manage the Gateway API CRDs itself. Check for ownership before applying anything, otherwise the operator will fight you:
> ```bash
> oc get crd gateways.gateway.networking.k8s.io \
> -o jsonpath='{.metadata.labels}{"\n"}{.metadata.annotations}{"\n"}'
> ```
> If the CRDs are operator-managed, skip the kubectl apply and only install your controller. Envoy Gateway's data plane runs unprivileged on ports 10080/10443, so the restricted-v2 SCC is sufficient — no SCC patching required.
Install Envoy Gateway:
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.2.6 \
-n envoy-gateway-system --create-namespace \
--set config.envoyGateway.provider.kubernetes.rateLimitDeployment.container.resources.requests.cpu=100m
kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway
Then declare the GatewayClass:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg-external
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
kubectl get gatewayclass eg-external \
-o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}'
# expected: True
Step 2 — Wire cert-manager to Gateway API
cert-manager ships a "gateway shim" that watches Gateway objects for the cert-manager.io/cluster-issuer annotation and creates Certificate resources from the listeners. It's off by default:
helm upgrade --install cert-manager jetstack/cert-manager \
-n cert-manager --create-namespace \
--version v1.16.2 \
--set crds.enabled=true \
--set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
--set config.kind="ControllerConfiguration" \
--set config.enableGatewayAPI=true
For HTTP-01 solving, cert-manager can create a temporary HTTPRoute instead of an Ingress:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: apps-gw
namespace: gateway-infra
kind: Gateway
Critical detail during coexistence: if your existing Ingress NGINX still serves the hostname and holds the DNS record, HTTP-01 challenges will land on NGINX, not on the Gateway. Two safe options: keep issuing certificates through the existing Ingress solver until DNS is switched, or — far better — use DNS-01. DNS-01 is independent of which data plane owns the IP, which removes an entire class of migration failures.
Step 3 — The Gateway, reusing the existing certificate
Here's the trick that avoids re-issuing certs on day one: point the new Gateway at the same Secret your Ingress already uses. No new ACME order, no rate-limit risk, identical chain.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: apps-gw
namespace: gateway-infra
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: eg-external
listeners:
- name: http
protocol: HTTP
port: 80
hostname: "*.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
- name: https-shop
protocol: HTTPS
port: 443
hostname: shop.example.com
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: shop-example-com-tls
namespace: shop
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
Two things to notice. First, allowedRoutes uses a label selector, not All — namespaces must explicitly opt in. Second, the certificate Secret lives in the shop namespace while the Gateway lives in gateway-infra. That cross-namespace reference is denied by default. You need a ReferenceGrant in the namespace that owns the Secret:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gw-to-read-shop-tls
namespace: shop
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: gateway-infra
to:
- group: ""
kind: Secret
name: shop-example-com-tls
Note the name field in to: — grant access to that one Secret, not to every Secret in the namespace.
Verify the listener is actually programmed. This is the single most useful command of the whole migration:
kubectl -n gateway-infra get gateway apps-gw \
-o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{range .conditions[*]}{.type}={.status}{" "}{end}{"\n"}{end}'
A ResolvedRefs=False with reason RefNotPermitted means your ReferenceGrant is missing or mismatched.
Step 4 — The HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: shop
spec:
parentRefs:
- name: apps-gw
namespace: gateway-infra
sectionName: https-shop
hostnames:
- shop.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: X-Forwarded-Proto
value: https
backendRefs:
- name: shop-api
port: 8080
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: shop-frontend
port: 8080
Don't forget the namespace label required by allowedRoutes:
kubectl label namespace shop gateway-access=true
If a rule needs to target a Service in another namespace (say payments), add a ReferenceGrant in payments with from.kind: HTTPRoute. Same mechanism, same least-privilege rule: name the target Service explicitly.
Step 5 — Validate TLS before touching DNS
At this point both data planes serve shop.example.com, but only NGINX has the DNS record. Test the Gateway directly by resolving the hostname yourself:
GW_IP=$(kubectl -n envoy-gateway-system get svc \
-l gateway.envoyproxy.io/owning-gateway-name=apps-gw \
-o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}')
curl -sSv --resolve shop.example.com:443:$GW_IP https://shop.example.com/healthz
Then compare the certificate served by each data plane. Identical fingerprints mean zero client-visible change:
fp() { openssl s_client -connect "$1:443" -servername shop.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256; }
NGINX_IP=$(dig +short shop.example.com | head -1)
echo "nginx: $(fp $NGINX_IP)"
echo "gateway: $(fp $GW_IP)"
Also check the full chain and SAN list — a frequent failure mode is a Gateway serving only the leaf certificate because the Secret was built by hand:
openssl s_client -connect $GW_IP:443 -servername shop.example.com -showcerts </dev/null 2>/dev/null \
| grep -E 's:|i:'
openssl s_client -connect $GW_IP:443 -servername shop.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates -ext subjectAltName
And make sure an unknown SNI doesn't silently fall through to the wrong certificate:
openssl s_client -connect $GW_IP:443 -servername notmydomain.example.com </dev/null 2>&1 | head -20
Step 6 — Shift traffic by weight, without DNS
DNS-based cutovers are coarse: TTLs lie, resolvers cache, and rollback takes minutes. A much tighter loop is to make Ingress NGINX itself forward a percentage of traffic to the Gateway, using its canary annotations plus an ExternalName Service.
apiVersion: v1
kind: Service
metadata:
name: gw-bridge
namespace: shop
spec:
type: ExternalName
externalName: envoy-gateway-infra-apps-gw-7f9c2d11.envoy-gateway-system.svc.cluster.local
ports:
- name: https
port: 443
Get the real Service name first:
kubectl -n envoy-gateway-system get svc \
-l gateway.envoyproxy.io/owning-gateway-name=apps-gw -o name
Then a canary Ingress on the same hostname:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-canary-to-gateway
namespace: shop
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "5"
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/upstream-vhost: "shop.example.com"
nginx.ingress.kubernetes.io/proxy-ssl-server-name: "on"
nginx.ingress.kubernetes.io/proxy-ssl-name: "shop.example.com"
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-example-com-tls
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gw-bridge
port:
number: 443
Now you have a real dial. Ramp it with a single patch, watch your SLOs, and roll back in seconds:
for w in 5 25 50 100; do
kubectl -n shop annotate ingress shop-canary-to-gateway \
nginx.ingress.kubernetes.io/canary-weight="$w" --overwrite
sleep 600
done
# instant rollback
kubectl -n shop annotate ingress shop-canary-to-gateway \
nginx.ingress.kubernetes.io/canary-weight="0" --overwrite
You can also pin the canary to a header (nginx.ingress.kubernetes.io/canary-by-header: x-use-gateway) so QA can target the new path deterministically before any percentage of real users is affected.
Once 100% has been stable for a few days, switch DNS to the Gateway's IP, wait out the TTL, then delete the canary Ingress and the legacy Ingress. The same weighting primitive exists natively inside Gateway API for backend-level canaries:
backendRefs:
- name: shop-frontend
port: 8080
weight: 90
- name: shop-frontend-next
port: 8080
weight: 10
Security best practices
Never use allowedRoutes.namespaces.from: All on an internet-facing Gateway. Any namespace in the cluster could then attach a route to your public listener and claim a hostname. Use Same or a label Selector, and treat the label as a privileged operation controlled by the platform team.
Scope every ReferenceGrant. Always set to[].name. A grant without a name allows every Secret (or every Service) of that kind in the namespace — including, potentially, the Secret holding a wildcard certificate.
Enforce TLS floor and cipher policy. Gateway API leaves this to implementation-specific policies. With Envoy Gateway:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
name: tls-hardening
namespace: gateway-infra
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: apps-gw
tls:
minVersion: "1.2"
maxVersion: "1.3"
ciphers:
- ECDHE-ECDSA-AES256-GCM-SHA384
- ECDHE-RSA-AES256-GCM-SHA384
- ECDHE-ECDSA-CHACHA20-POLY1305
headers:
xForwardedClientCert:
mode: Sanitize
Redirect HTTP to HTTPS at the route level, so a misconfigured app can never be served in clear text:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: https-redirect
namespace: shop
spec:
parentRefs:
- name: apps-gw
namespace: gateway-infra
sectionName: http
hostnames: ["shop.example.com"]
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
Encrypt the last hop too. If your backend speaks TLS, use BackendTLSPolicy with explicit CA validation and hostname verification rather than disabling verification:
apiVersion: gateway.networking.k8s.io/v1alpha3
kind: BackendTLSPolicy
metadata:
name: shop-api-upstream-tls
namespace: shop
spec:
targetRefs:
- group: ""
kind: Service
name: shop-api
sectionName: "8443"
validation:
caCertificateRefs:
- group: ""
kind: ConfigMap
name: internal-ca
hostname: shop-api.shop.svc.cluster.local
Lock down the data plane with NetworkPolicy. The Envoy pods should accept traffic from the LoadBalancer and reach only the namespaces they route to. On OpenShift, confirm the proxy runs under restricted-v2 and not a relaxed SCC:
oc -n envoy-gateway-system get pod -l app.kubernetes.io/name=envoy \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.openshift\.io/scc}{"\n"}{end}'
Audit certificate ownership after the switch. When you finally delete the legacy Ingress, make sure cert-manager still owns the Certificate object — deleting an Ingress with the cert-manager.io/cluster-issuer annotation can garbage-collect the Certificate and, depending on your setup, the Secret with it. Create a standalone Certificate resource explicitly rather than relying on annotation-driven shims for anything production-critical:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: shop-example-com
namespace: shop
spec:
secretName: shop-example-com-tls
duration: 2160h
renewBefore: 720h
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
dnsNames:
- shop.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
Alert on expiry independently. Scrape certmanager_certificate_expiration_timestamp_seconds and alert at 14 days, plus a black-box probe that checks the certificate actually served on port 443. A valid Secret that the Gateway failed to load is a silent outage waiting for renewal day.
Conclusion
Migrating off Ingress NGINX doesn't have to be a maintenance-window event. The key takeaways:
- Coexist first. Gateway API and Ingress can serve the same hostname simultaneously. Build the Gateway, validate it out-of-band with curl --resolve, and only then send it traffic.
- Reuse the existing TLS Secret for the first cutover. Comparing SHA-256 fingerprints between the old and new data plane is a 10-second check that eliminates the scariest failure mode.
- ReferenceGrant is a security boundary, not a formality. Scope it to a named Secret or Service, and keep allowedRoutes on a label selector.
- Shift traffic with weights, not DNS. The NGINX canary + ExternalName bridge gives you a percentage dial with sub-second rollback. Move DNS only once 100% has been stable for days.
- Prefer DNS-01 during the transition, so certificate issuance never depends on which data plane currently owns the public IP.
- Harden explicitly. TLS floor, HTTP→HTTPS redirect, upstream TLS validation and NetworkPolicy are all policy objects you have to write — nothing is secure by default just because it's new.
Do one low-traffic hostname end to end first. The second one takes an afternoon, and by the fifth you'll be deleting Ingress objects without thinking about it.
1 reaction
Want to practise Kubernetes without paying for a cloud cluster?
kubernetes-sigs/kind (15.5k ★) runs a local cluster inside Docker.
go install sigs.k8s.io
kind create cluster
kubernetes-sigs/kind
What's the first app you'd deploy on it? t.co
5 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 ↩︎
Kubernetes の分散ジョブスケジューラ比較
はじめに
この記事は、Slurm と Kubernetes のどちらかが優れていると主張するためのものではありません。両者の違いをできるだけ公平に並べたうえで、Kubernetes の上でジョブのスケジューリングがどう組み立てられているのかを整理することが目的です。
!
筆者は Slurm も Kubernetes もどっちも好きだにゃーん。(=´ω`;=)
Kubernetes に、4 つの Pod からなる分散学習ジョブを投げたとします。2 つは動いたのに、残り 2 つはノードに空きが無く、いつまでも Pending のまま、ということが起こり得ます。クラスタが壊れているわけではありません。
kube-scheduler は Pod を 1 つずつ正しく置くが、4 つ全部が置けるかは誰も確かめない
図のとおり、どの部品も自分の仕事を正しくこなしています。ジョブを扱うコントローラは 4 つの Pod を作り、kube-scheduler は GPU が空いているノードに Pod を 1 つずつ置きます。置けない Pod は Pending のまま待ちます。置かれた 2 つの worker は、そこで止まります。分散学習では、all-reduce のような集合通信を始める前に、全 worker が互いの居場所を確かめ合う rendezvous という集合の段階があります。4 つの GPU で学習するなら、4 つの worker が全員そろうまで、先に起動した worker はこの段階で待ち続けます。残り 2 つは Pending のままなので、全員はいつまでもそろいません。その間、置かれた 2 つは GPU を確保したまま 1 ステップも学習を進められず、設定した待ち時間を過ぎるとタイムアウトのエラーで終了します。再試行の設定によって Pod が作り直されても、空きが増えていなければ同じことを繰り返します。
問題は、4 つがそろうかをジョブ全体として判断する部品が、既定の設定の Kubernetes では動いていないことです。
HPC で広く使われる Slurm では、この状態はふつう起こりません。Slurm にはできて、追加の仕組みを入れていない素の Kubernetes にはできないことを、まず 5 つ並べ、D1 から D5 の ID を付けます。後の節では、各仕組みがどの ID を埋めるのかを、この ID で示します。
D1 から D5 は、どれもジョブ全体か利用者のアカウントを単位にした判断
D1 は、複数ノードのジョブを 1 つの単位として始めることです。Slurm は、要求したノードと GPU がすべてそろってから、それらを 1 つの割り当てとして確保してジョブを始めます。この割り当てを allocation と呼びます。素の Kubernetes は Pod を 1 つずつノードに置くので、一部だけ動いて残りが待つ状態が起こります。
D2 は、上限を超えたジョブを待ち行列で待たせることです。Slurm では、アカウントごとに GPU 数などの合計に上限を設けられます。この上限の 1 つが GrpTRES です。上限を超えるジョブも受け付けたうえで、PENDING のまま待たせます。Kubernetes の ResourceQuota は、上限を超える Pod の作成を断るだけで、順番を持った待ち行列にはなりません。
D3 は、大きなジョブを遅らせずに小さなジョブを先に流す backfill です。Slurm は、各ジョブに指定された実行時間の上限をもとに大きなジョブの開始予定時刻を計算し、それを動かさない範囲でだけ小さなジョブを先に始めます。素の Kubernetes には、ジョブ全体の開始予定時刻という考え方がありません。
D4 は、動いているジョブを止めて、作り直さずに再開することです。Slurm でプリエンプションの方式に suspend を選ぶと、ジョブのプロセスは一時停止され、resume するとそのまま続きから動きます。Kubernetes の Job を suspend すると、動いている Pod は削除されます。
D5 は、過去の使用量から優先度を決めることです。Slurm では、設定すれば fair-share を優先度の計算に入れられます。利用実績の記録をもとに、使いすぎた利用者の優先度を下げる仕組みです。Kubernetes の PriorityClass は、Pod ごとに決まった値を与えるだけです。
Slurm 側の D1 から D5 には、設定で有効にする機能も含まれます。5 つに共通するのは、判断の単位が Pod ではなく、ジョブ全体か利用者のアカウントだという点です。このうち D3 の backfill と D5 の fair-share は製品ごとの差が大きいため、この記事では D1、D2、D4 を中心に比べます。この記事では、Kubernetes の上でこの「ジョブ単位の判断」を後から足す Kueue・Volcano・KAI-Scheduler が、それぞれどこを受け持ち、内部がどうなっているのかを整理します。主張は公式ドキュメントで確認したものに限り、その場で出典を示します。Amazon EKS Distributed AI ワークショップの本に、Kueue を実際に使う章を追加しました。併せて読むと、手を動かして確かめられます。
全体像
Kueue は 2 番目の段を受け持つ。Volcano と KAI は 4 番目の段に入り、それぞれ待ち行列も持つ
名前
受け持ち
置き場所
kube-scheduler
Pod を 1 つずつノードに置く
Kubernetes の素の機能
Kueue
ジョブ単位で、資源の枠に収まるかを決める
kube-scheduler の手前
Volcano
対象に指定した Pod の配置を、kube-scheduler の代わりに担当する
kube-scheduler と並ぶ選択肢
KAI-Scheduler
上と同じ位置で、GPU の分け合いや階層キューなど GPU クラスタ向けの機能が多い
kube-scheduler と並ぶ選択肢
Slurm
ノードの確保そのものがジョブの単位になっている
Kubernetes とは別の世界
この図は、段ごとにどの製品が受け持つかを示したもので、全部を同時に入れる構成を示したものではありません。Kueue と Volcano・KAI-Scheduler は待ち行列の段で受け持ちが重なるので、この記事では基本的にどちらか一方を選ぶ構成として比べます。併用の余地も含めた関係は、各製品を見たあとの「Kueue と Volcano・KAI の関係」で整理します。
2 つの前提を確認したあと、この表の順に、仕組みと内部の構成を 1 つずつ見ていきます。
そもそも何を決める層なのか
分散ジョブを動かす仕組みは、2 つの違う問いに答えています。1 つは「このジョブを今動かしてよいか」、もう 1 つは「どの Pod をどのノードに置くか」です。この 2 つは別の問いで、同じ機構が両方を受け持つとは限りません。この区別を知らないと、Kueue と Volcano を同じ種類の仕組みだと誤解してしまいます。前者の「動かしてよいか」の判断を、Kueue では admission と呼びます。後に出てくる Volcano の admission webhook は、Kubernetes が API へのリクエストを検証・書き換えする仕組みのことで、Kueue の admission とは別物です。
gang scheduling とは
同期型の分散学習では、決まった数の worker がそろわないと学習が進みません。必要な数に足りないまま一部だけ動かしても、残りを待つ間、その分の資源が占有されるだけです。必要な数のタスクがそろうときだけ始め、そろわなければ始めないという考え方を、gang scheduling と呼びます。
素の Kubernetes の挙動
標準の kube-scheduler は、Pod を 1 つずつ見て置き場所を決めるだけです。ジョブという単位そのものを知らないので、4 つのうち 2 つだけ動いて残り 2 つを待つ状態が、何の異常もなく起こります。これが D1 の欠けている状態です。なお、kube-scheduler 自身にも PodGroup をまとめて置く gang scheduling が入りつつあります。v1.37 で Beta になりましたが既定では無効で、GenericWorkload の feature gate を有効にする必要があります。この記事では、既定の設定の kube-scheduler を前提にします。
どの部品も Pod を単位に動き、ジョブ全体をまとめて置く部品は無い
kube-controller-manager の Job コントローラが Pod を作り、kube-scheduler が未配置の Pod を 1 つずつ選んでノードを決め、その結果を API server に書き戻します。kubelet は自分のノードに割り当てられた Pod を実行します。次に見る Kueue・Volcano・KAI-Scheduler は、kube-scheduler の手前に置くか、kube-scheduler の代わりに使うかして、ジョブ単位の扱いを持ち込む仕組みです。
4 つの部品の役割
部品
動く場所
何をするか
この記事との関係
API server
コントロールプレーン
クラスタの状態を読み書きする唯一の窓口です。Job や Pod の定義を受け取って保存し、他の部品はすべてここを通して状態を見たり書いたりします。
Kueue の webhook も、ここに届いた作成のリクエストに割り込んで suspend: true を書き加えます。
kube-controller-manager
コントロールプレーン
「あるべき状態」と「いまの状態」の差を埋める controller をまとめて動かすプロセスです。Job controller は、Job に書かれた数だけ Pod を作ります。
Pod を作る数は知っていますが、その Pod が全部置けるかは確かめません。
kube-scheduler
コントロールプレーン
まだノードが決まっていない Pod を 1 つずつ取り出し、空きと条件に合うノードを選んで、結果を API server に書き戻します。
判断の単位が Pod 1 つなので、D1 の状態が起こります。Volcano・KAI-Scheduler は、この部品の代わりに置き先を決めます。
kubelet
各ノード
自分のノードに割り当てられた Pod を API server から受け取り、コンテナを起動して動かし続けます。
置かれた worker が rendezvous で待ち続けるのは、kubelet が起動したコンテナの中です。
Kueue とは
!
ここから先は、ノードを自動で足し引きする Karpenter を使う構成を前提にします。Karpenter は OSS ですが、AWS が開発を始めたもので、この記事では実質的に Amazon EKS を前提にした部品として扱います。そのため以降は、Amazon EKS Distributed AI ワークショップの本で今回実装した EKS 上の構成を前提に解説します。
Kueue が決めるのは、ジョブが Pod を作ってよい時期
Kueue は、ジョブが要求する GPU などの資源量を、quota と呼ぶあらかじめ決めた枠と比べ、枠に収まるジョブだけを通します。収まらないジョブは、Job や TrainJob のように suspend できるものなら、Pod を作らせずに待たせます。通した後にどのノードへ置くかは kube-scheduler が決めますが、Kueue は資源の種類と配置条件を表す ResourceFlavor で、候補のノードを絞れます。
ID で言うと、Kueue が埋めるのは D2 です。上限を超えたジョブを、Pod を作らせないまま待ち行列で待たせます。D1 については、ジョブ全体の資源量を枠と照合するところまでで、各 Pod をどこに置けるかは kube-scheduler が Pod ごとに判断します。D5 は、Admission Fair Sharing を設定すれば、同じ ClusterQueue を使う LocalQueue の間で過去の使用量を数え、通す順番に入れられます。D4 は埋めません。
Topology Aware Scheduling という機能を使うと、設定したラックやノードなどのトポロジーに沿って、Kueue が置き先の範囲を絞れます。waitForPodsReady は既定では無効です。有効にすると、通したジョブの全 Pod が決めた時間内に Ready にならないとき、Kueue はそのジョブを取り下げて待ち行列に戻し、戻すたびに次に通すまでの間隔を延ばします。上限回数を設定すれば、そこで打ち切ります。さらに blockAdmission を有効にすると、先に通したジョブの全 Pod が Ready になるまで、後続のジョブを通さなくなります。ただし、どちらも先のジョブの全 Pod を同時に置けることを保証する機能ではありません。
Kueue はジョブを止め、通すときに nodeSelector などを足すだけで、Trainer・kube-scheduler・Karpenter は変わらない
図は、ワークショップの本で実際に動かしている EKS 上の構成です。先に、図に出てくる Kueue の 3 つのオブジェクトを説明します。
オブジェクト
置き場所
役割
LocalQueue
ジョブと同じ namespace
利用者がジョブを投入する窓口です。ジョブは kueue.x-k8s.io/queue-name ラベルで、投入先の LocalQueue の名前を指定します。LocalQueue 自身は枠を持たず、どの ClusterQueue に流すかだけを決めています。
ClusterQueue
namespace に属さない、クラスタ全体のオブジェクト
GPU などの枠を持ち、通してよいかを判断します。複数の namespace の LocalQueue が、同じ ClusterQueue を共有できます。
ResourceFlavor
namespace に属さない、クラスタ全体のオブジェクト
枠の中身がどのノードの資源かを表します。この構成では、Karpenter の NodePool の名前をノードのラベルとして持たせています。
Kueue の実体は kueue-system namespace の kueue-controller-manager という 1 つの Deployment で、webhook とコントローラはこのプロセスの中にあります。CRD と webhook の設定はクラスタ全体に登録されますが、本で使っている Terraform の設定ファイル kueue.tf では、webhook が働く namespace を managed-by-kueue=true のラベルを付けたものだけに絞っています。TrainJob を 1 つ投入すると、図の番号の順に次のことが起きます。主体は動く部品、相手はその部品が手を入れる対象です。
番号
主体
相手
すること
その後の状態
1
利用者
EKS の API server
kueue.x-k8s.io/queue-name: gpu-ddp のラベルを付けた Kubeflow Trainer の TrainJob を kubectl apply で送る
作成のリクエストが API server に届く
2
Kueue の webhook
API server が保存しようとしている TrainJob
作成を止めずに suspend: true を書き加える
TrainJob は止まった状態で保存される。Trainer が作る JobSet も止まった状態のままで、Pod は作られない
3
kueue-controller-manager のジョブ用コントローラ
保存された TrainJob
必要な GPU や CPU の量をまとめた Workload を 1 つ作る
Kueue が待ち行列で扱う単位ができる
4
kueue-controller-manager のスケジューラ
LocalQueue を経て ClusterQueue に並んだ Workload
いま使われている量に要求量を足し、ClusterQueue の ResourceFlavor ごとの枠に収まるかを判断する
収まらなければ TrainJob は止まったままで、枠が空くのを待つ
5
kueue-controller-manager
枠に収まった Workload の TrainJob
Workload を admitted にし、ResourceFlavor の nodeLabels を nodeSelector として、tolerations があればそれも付けて、suspend を外す
Workload は admitted、TrainJob は suspend: false になる
6
Trainer と JobSet・Job のコントローラ
suspend が外れた TrainJob
Trainer が、suspend の解除と 5 で付いた nodeSelector・tolerations を JobSet に伝え、JobSet と Job のコントローラが Pod を作る
Pod ができ、置き先が未定のまま並ぶ
7
kube-scheduler
置き先が未定の Pod
nodeSelector に合い、空きのあるノードに bind する。置けない Pod には Unschedulable の印を付ける
置けた Pod は動き出し、残りは Pending のまま
8
Karpenter
Unschedulable の印が付いた Pod
条件に合う EC2 ノードを起動する
ノードが Ready になると 7 に戻り、kube-scheduler が残りを bind する
Kueue が手を入れるのは 2 から 5 までで、6 から 8 の Trainer・kube-scheduler・Karpenter の動き方は変わりません。止まっている TrainJob には Pod が無いので、Karpenter はそのためのノードを起動しません。この構成は、本の Kueue の章でも解説しています。
Volcano とは
Volcano は、Kueue と違って配置そのものを担当します。schedulerName で Volcano を指定した Pod について、kube-scheduler の代わりに置き先を決めます。Volcano は、ジョブのタスクを 1 つずつ仮に割り当て、最低人数に届けばまとめて確定します。最低人数は、Volcano の Job である vcjob では minAvailable、Pod のまとまりを表す PodGroup では minMember という名前です。複数チームに資源を割り振る Queue というオブジェクトも持ちます。ID で言うと、Volcano は自分が配置する Pod について D1 と D2 を埋め、D4 は埋めません。D5 は、v1.16.0 で入った fairshare プラグインを設定すれば、同じ Queue を使う namespace の間で過去の使用量を数え、待っているジョブの順番に入れられます。
Volcano はキュー、gang、配置を自分の Pod について一通り持つ
Volcano のサーバ側は、主に admission webhook・controller-manager・scheduler の 3 つです。admission webhook がジョブなどのリソースを検証・補完します。vcjob を使う場合は、controller-manager が最低人数を指定した PodGroup と、ジョブの Pod を作ります。scheduler は一定周期でセッションを開き、設定ファイルに書いた順に action を実行します。enqueue は Queue の空きを見て PodGroup をスケジューリング対象に進めてよいかを決め、allocate はタスクを 1 つずつ仮に割り当てます。preempt は優先度で場所を空け、reclaim は Queue の取り分を他の Queue から取り戻します。backfill は資源量を指定しない Pod を隙間に入れる機能で、Slurm の backfill とは別物です。Queue はプロセスではなく、テナントごとの重みと上限を書いたオブジェクトです。gang のプラグインは、最低人数を満たせば確定し、満たさなければ取り消すか、資源が空くのを待つ pipelined という中間状態に置きます。確定した割り当ては、Volcano の scheduler 自身が API server に反映します。
KAI-Scheduler とは
KAI-Scheduler も、Volcano と同じく、kube-scheduler の代わりに配置を担当するスケジューラです。特徴は、1 枚の GPU を複数のジョブで分け合う機能を持つことです。チームごとの枠を何段にも入れ子にできる階層キューや、動いているジョブも含めて置き直し、空きをまとまった形にする consolidation も持っています。ID で言うと、D1 と D2 を埋め、D4 は埋めない点は Volcano と同じです。D5 は、Time Based Fairshare を有効にすれば、キューごとの過去の使用量を Prometheus に貯め、各キューの保証量を超えて使う分の取り分に反映できます。
配置を決める Deployment と、それを反映する Deployment が分かれている
KAI-Scheduler では、pod-grouper という部品が PyTorchJob や JobSet などのワークロードから KAI-Scheduler 独自の PodGroup を作り、API server に書き込みます。これが gang の単位になります。scheduler は、階層キューの取り分と、Pod をなるべく少ないノードに詰める bin-packing を考えて置き先を決めます。そのうえで書くのは BindRequest というオブジェクトだけで、実際の bind はそれを読んだ別デプロイの binder が行います。この点は、スケジューラのプロセス自身が bind まで行う Volcano とは異なります。GPU を分け合うときは、既定のモードでは共有する GPU を専用の reservation Pod が押さえ、各 Pod が annotation で要求した割合はスケジューラが勘定します。このモードでは GPU メモリは分離されず、HAMi-core を使うモードを選ぶとメモリの上限がかかります。
Kueue と Volcano・KAI の関係
Kueue と Volcano・KAI-Scheduler は、受け持つ段の一部が重なる技術です。重なるのは待ち行列と枠、つまり D2 で、Volcano と KAI-Scheduler も自分の Queue を持ちます。重ならないのは配置で、Kueue は配置をしません。Kueue の公式ドキュメントは、Pod をノードに置くのは kube-scheduler の責任だとし、成熟した部品の機能を作り直さないことを設計の原則に挙げています。
そのため、この記事では代表的な構成として次の 2 通りを比べます。1 つは Kueue と kube-scheduler の組み合わせで、待ち行列を Kueue が、配置を kube-scheduler が受け持ちます。もう 1 つは Volcano か KAI-Scheduler を単体で使う方法で、待ち行列と配置の両方を 1 つの製品が受け持ちます。この 2 通りは同じ問題への別の答えなので、その意味では競合します。後の表で Kueue と Volcano・KAI を横に並べているのは、この 2 通りを比べているためです。
併用できないわけではありません。Kueue はジョブの開始を suspend などで止め、置き先の条件を足しますが、Pod をどのスケジューラが置くかは Pod の schedulerName で決まります。そのため、Kueue で通したジョブの配置を KAI-Scheduler に任せる構成は、仕組みの上では組めそうに見えます。ただし、待ち行列と枠を 2 か所で管理することになり、両者の枠をそろえる必要があります。2026 年 10 月 23 日の時点で、Kueue・Volcano・KAI-Scheduler のどのリポジトリのドキュメントにも、この組み合わせ方の手順は見つかりませんでした。
それでも EKS では、併用を考える理由が 1 つあります。KAI-Scheduler の設計提案によると、KAI-Scheduler は枠を超えて待たせているジョブでも Pod の作成を止めないので、Pod は作られ、Unschedulable の印が付きます。Karpenter はこの印を需要とみなすので、動かないジョブのために GPU ノードが立つことがあると、この文書は指摘しています。同じ文書は、Kueue と Volcano はこの Pod を Karpenter から隠すとも書いています。これを直す提案は、まだ Proposed の段階です。Karpenter と組むなら、ジョブを Pod の手前で止める Kueue を前に置く意味があるかもしれません。ただし Kueue が通したあとに KAI-Scheduler の枠で止まれば同じことが起きるので、両者の枠をそろえられるかが鍵になります。この組み合わせは、ワークショップの本の Advanced として今後確かめます。
Slurm とは
Slurm の仕組みは「AWS ParallelCluster ワークショップ」でくわしく解説しているので、ぜひ参照してください。
Slurm は資源をジョブ単位で渡し、Kubernetes は Pod のまとまりを表すオブジェクトを足してそこへ近づける
資源の割り当てが先に 1 回で決まり、タスクはその中で始まる
Slurm と Kubernetes 系の違い
表に入る前に 1 つ注意があります。Slurm が「gang scheduling」と呼ぶ機能は、ここまで使ってきた意味と強調点が違います。Slurm の gang scheduling は、同じノードを複数のジョブで時間を分けて使う機能で、1 つのジョブに属するタスクはまとめて一時停止・再開されます。この記事がここまで「全部そろうまで始めない」という起動の話をしてきたのに対し、Slurm のこの機能は「始めたあとの時間の分け方」の話で、D4 の suspend を使って成り立っています。
ID
できること
Slurm
Kueue と kube-scheduler
Volcano / KAI の単体
D1
最低人数がそろう場合だけ配置を確定する
◯ ジョブの単位そのもの
△ ジョブ全体の資源量を quota と照合するが、各 Pod を置けるかは kube-scheduler が個別に判断する
◯ Volcano の minAvailable、KAI の batch-min-member
D2
上限を超えたジョブを待ち行列で待たせる
◯ アカウントごとの上限と QOS
◯ ClusterQueue の quota
◯ Volcano の Queue、KAI の Queue
D4
動いているジョブのプロセスを一時停止し、同じ状態で後から再開する
◯ scontrol suspend と resume
✕ Kueue が使う Job の suspend は動いている Pod を削除する
✕ Volcano の vcctl job suspend は Pod を削除し、再開時に Pod を作り直す。KAI は、対応する機能を公式ドキュメントで確認できず
D4 の ✕ は、Kubernetes 側が劣っているとだけ読むと誤解します。Slurm の suspend はプロセスに停止のシグナルを送るだけで、止まったジョブはノードのメモリに残ったままだと、slurm.conf の PreemptMode の説明に書かれています。プロセスが生きている以上、GPU 上に確保したメモリも残ると考えられるので、止めても後から来たジョブがその GPU のメモリを使えるとは限りません。また Kubernetes では、spot の中断や Karpenter の縮小でノードそのものが消えるので、その場で止めた状態もノードと一緒に消えます。そのため Kubernetes では、学習コードが途中経過をチェックポイントとして共有のストレージに保存し、Pod を消されたら別のノードで続きから再開する方式が基本になります。Kueue が通したジョブを取り下げると Pod は消えるので、続きから再開できるかは、学習コードが途中経過を保存しているかで決まります。プロセスごと保存する仕組みとしては、kubelet の checkpoint API と、GPU の状態を保存する NVIDIA の cuda-checkpoint があります。ただし cuda-checkpoint は UVM メモリなど一部のメモリに対応していないため、この記事では扱いません。
この表の内容は、各製品のドキュメントを確認した時点のものです。バージョンが進むと変わる可能性があるため、最終判断は参考に挙げた一次資料の最新版で確認してください。
Kueue の最近のアップデート
Kueue は、機能を足すマイナー版がおよそ 2 か月ごと、修正のパッチ版がおよそ毎週出ていて、2026 年 10 月 23 日の時点の最新は v0.20.3 です。ここでは、この記事の D1 の弱点に関わる、v0.20.0 の 2 つの機能を挙げます。どちらも alpha で既定は無効なので、使うには feature gate を有効にする必要があります。
機能
何に使えるか
KueueDRADeviceFeasibility
通す前に、ノードごとに DRA のデバイスの空きを確かめ、置けないジョブのために quota を確保しないようにする。対象は DRA で GPU を要求するジョブで、device plugin の nvidia.com/gpu で要求するジョブや、CPU・メモリ・taint が原因で置けない場合は対象外
DynamicQuotaOrchestration
見つかった容量を、配下の Cohort と ClusterQueue に nominalQuota の比で配り、status.effectiveQuotas に書く。ノード数が変わるクラスタで、ClusterQueue の枠を実際の容量に追従させる
どちらも、「Kueue が通しても、置けるとは限らない」という D1 の弱点に手を入れる変更です。何が困るのかを具体的に書くと、次の 2 つです。
1 つ目は、デバイスが足りないのに通してしまうことです。Topology Aware Scheduling を使わない構成では、Kueue は設定した枠からいま使われている量を引いた残りの数だけを見て、その GPU がどのノードにあるかは見ません。たとえば枠が 8 枚分残っていて、実際の空きも 8 枚あっても、それが 8 台のノードに 1 枚ずつ散らばっていれば、1 台に 8 枚を要求するジョブはどのノードにも置けません。それでも数の上では枠に収まるので、Kueue はジョブを通し、その 8 枚分の quota を確保します。Pod は Pending のまま動かず、確保された quota のせいで、本当は置ける別のジョブが待たされます。KueueDRADeviceFeasibility は、通す前にノードごとの空きを確かめることで、この取り違えを減らします。
2 つ目は、quota の数と実際の容量がずれることです。quota は人が書いた固定の数なので、spot の中断や Karpenter の縮小でノードが減っても、そのまま残ります。実際より大きな枠のまま通したジョブは、置き場所が無く Pending になります。逆にノードを増やしても枠を書き換えなければ、空いた GPU を使えません。DynamicQuotaOrchestration は、見つかった容量に合わせて枠を配り直すので、この書き換えを手でしなくて済むようになります。nominalQuota は、配分の比として引き続き人が決めます。
どちらもまだ alpha なので、本番で頼る前に、自分の Kueue の版と feature gate の状態を確かめてください。
なお、EKS で KueueDRADeviceFeasibility を使い、ノードの追加を Karpenter に任せるなら、Karpenter も DRA の要求を理解する必要があります。Karpenter は既定では DRA の要求を無視します。NVIDIA GPU を DRA で扱う DRANVIDIAGPU の feature gate は、v1.15 で alpha として入ったばかりで既定は無効です。有効にするには --ignore-dra-requests=false の設定も要ります。alpha で既定は無効なので、この記事では本番向けの構成には含めません。DRA を使う構成は、今後ワークショップの本で検証します。
まとめ
Kueue は通してよいかだけを決めて D2 を埋め、配置は kube-scheduler に任せます。Volcano と KAI-Scheduler は配置そのものを担当し、D1 と D2 を自分の Pod について埋めます。Kueue と kube-scheduler の組み合わせと、Volcano か KAI-Scheduler の単体は、待ち行列の段で重なる別の選択肢です。ただし Karpenter と組む EKS では、Kueue を前に置いて KAI-Scheduler に配置を任せる併用も検討の余地があり、これは今後確かめます。D5 は、3 つとも設定すれば過去の使用量を優先度に入れられますが、数える単位と効く範囲は製品ごとに違います。今回確認した範囲では、どれも D4 のように止めたプロセスをそのまま再開することはできません。
Ray がどこに位置するのか気になった人もいるかもしれません。Ray は、ここまでの仕組みで資源が確保されてジョブが動き出したあと、その一段上の層で、確保済みの GPU にタスクを割り振る役目を担います。Ray の autoscaler を使う構成では、足りない分の worker Pod を Ray の側から要求することもあります。
参考
Kueue の概要
Kueue の Topology Aware Scheduling
waitForPodsReady の設定
Volcano のコア概念
Volcano のアーキテクチャ
KAI-Scheduler の README
Slurm のクイックスタート
Slurm のアーキテクチャ概要
Slurm の Gang Scheduling
Slurm のリソース上限
Slurm のスケジューリング設定 (backfill)
Slurm の preemption
Slurm の Multifactor Priority
Kubernetes の ResourceQuota
Kubernetes の Job の suspend
Kubernetes の Pod の優先度
kube-scheduler の仕組み
Amazon EKS Distributed AI ワークショップ - Kueue を実際に使う章を追加しています
llm-d vs Plain vLLM: A Practical Kubernetes Inference Quickstart
A single model server can look healthy while users wait longer with every burst of traffic. Add replicas behind a generic load balancer and a subtle problem appears: requests that could reuse a model's cached prompt may land on a different replica, which must process that context again. Background agent loops can also compete with interactive requests for the same serving capacity.
That is the problem llm-d 1.0 is designed to address. Released on October 20, 2026, llm-d adds a Kubernetes-native coordination layer around inference servers such as vLLM. Its focus is not replacing vLLM. It is coordinating a fleet of model servers with cache-aware routing, traffic control, and production operations.
This guide explains when plain vLLM is enough, what llm-d adds, and how to follow the project's quickstart to deploy a small test configuration. The commands and configuration below are based on the llm-d project's published guide; no independent deployment or benchmark is reported here.
First, separate the model server from the fleet manager
vLLM serves model requests. It provides an inference engine and an API server for supported models. If one instance serves your workload and the surrounding platform already handles availability and scaling, adding a separate orchestration layer may not help.
llm-d coordinates model servers across Kubernetes. The project's vLLM integration guide describes it as a framework that coordinates a fleet of vLLM instances. Its features include prefix-aware routing, distributed KV-cache management, prefill/decode disaggregation, and wide expert parallelism. The llm-d project is built around the Kubernetes Gateway API Inference Extension and operates at the cluster-serving layer.
The short comparison:
- Plain vLLM: a simpler choice for one server, a small deployment, or a workload where generic routing is sufficient.
- vLLM behind llm-d: worth evaluating when several replicas share traffic, prompts repeat, tenants need different service priorities, or Kubernetes is already the operational home for model serving.
- Neither choice guarantees lower cost or latency. Model, hardware, concurrency, prompt reuse, and configuration determine the result. Benchmark with representative requests.
What changed in llm-d 1.0
The release announcement presents 1.0 as an operational milestone, with three capabilities especially relevant to platform teams.
Cache-aware routing. Instead of treating every replica as interchangeable, llm-d can route a request toward a replica that has a reusable prefix in its KV cache. That matters when requests share long system prompts, document context, or agent history. Reuse is an opportunity, not a promise: the benefit depends on cache retention, request similarity, and whether the selected replica has capacity.
Multi-tenant flow control. llm-d 1.0 adds priority- and fairness-aware flow control. The project describes separate queues by flow and priority, fair dispatch among tenants at a priority level, and load shedding when demand exceeds available capacity. The operational goal is to prevent a noisy background workload from overwhelming latency-sensitive traffic. Operators still need to define sensible priorities, quotas, and service objectives.
Safer operations. The maintainers describe router high availability and rolling updates, including coordinated management for disaggregated prefill and decode deployments. This matters because a serving system is not production-ready just because it returns tokens once. Upgrades, restarts, and partial failures are part of normal operation.
These capabilities add moving parts. A feature-rich router is not automatically better than a direct vLLM service. Use it when the traffic pattern or operational requirement justifies the layer.
A practical first deployment
The official quickstart is an end-to-end test of an optimized baseline in standalone mode. It uses Helm for the llm-d router and Kubernetes manifests for the model server. Its current default deploys eight replicas of Qwen/Qwen3-32B on NVIDIA GPUs, so this is not a laptop experiment: plan for substantial compatible GPU-backed Kubernetes capacity and the associated cost. The project also lists alternative configurations for other hardware, with their own model and capacity requirements.
The quickstart also downloads model weights, so startup can take several minutes. Check the current project guide before running commands because chart versions, model defaults, and hardware-specific examples can change.
1. Check prerequisites and choose a test cluster
You need a Kubernetes cluster, kubectl, Helm, and enough compatible accelerator capacity for the selected model server. The guide includes other model-server configurations for AMD, Intel, TPU, and CPU, but some alternatives use a different model. Confirm the exact model and hardware pairing in the repository before applying a configuration.
Clone the repository and load its environment definitions:
export branch="main"
git clone https://github.com/llm-d/llm-d.git
cd llm-d
git checkout "$branch"
export REPO_ROOT="$(realpath "$(git rev-parse --show-toplevel)")"
source "$REPO_ROOT/guides/env.sh"
export GUIDE_NAME="quickstart"
export NAMESPACE="llm-d-quickstart"
For a repeatable evaluation, record the commit you checked out, model ID, accelerator type, replica count, and relevant chart versions. A tutorial's main branch can change after publication, so pin and record the exact revision for a controlled test.
2. Install the inference API resources and create a namespace
The quickstart installs the Gateway API Inference Extension custom resource definitions. It derives the manifest path from the repository's environment script:
kubectl apply -f \
"https://github.com/kubernetes-sigs/gateway-api-inference-extension/${GAIE_URL}/v1-manifests.yaml"
kubectl create namespace "$NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -
This is a cluster-level change for the API resources and a namespace-level change for the test deployment. Review the manifests and your cluster's change controls before applying them to a shared or production cluster.
3. Configure access to model weights
The quickstart asks for a Hugging Face token in a Kubernetes secret named llm-d-hf-token, with the key HF_TOKEN. Keep the token out of shell history and logs where possible. Use your organization's secret-management practice for anything beyond a temporary test.
kubectl create secret generic llm-d-hf-token \
--from-literal="HF_TOKEN=${HF_TOKEN}" \
--namespace "$NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -
Set HF_TOKEN in your environment through an approved secret-injection method first. Do not paste a real token into a shared terminal transcript or commit it to a values file.
4. Deploy the router and model servers
The standalone router chart includes its proxy. The guide supplies its base and optimized-baseline values files, then applies the GPU vLLM model-server configuration:
helm install "$GUIDE_NAME" \
"$ROUTER_STANDALONE_CHART" \
-f guides/recipes/router/base.values.yaml \
-f guides/optimized-baseline/router/optimized-baseline.values.yaml \
-n "$NAMESPACE" \
--version "$ROUTER_CHART_VERSION"
kubectl apply -n "$NAMESPACE" \
-k guides/optimized-baseline/modelserver/gpu/vllm/base/
Wait for the model-server pods to become ready before sending a request:
bash "$REPO_ROOT/guides/scripts/wait-for-pods-ready.sh" \
-l llm-d.ai/guide=optimized-baseline \
-n "$NAMESPACE"
A pod being scheduled is not the same as a model being ready. Check pod events and logs if the wait takes longer than expected. Weight downloads, accelerator allocation, image pulls, and model initialization can all affect startup time.
5. Send a request through the router
The quickstart retrieves the router service's cluster IP and sends a completion request from a temporary pod inside the cluster:
export IP="$(kubectl get service "${GUIDE_NAME}-epp" \
-n "$NAMESPACE" \
-o jsonpath='{.spec.clusterIP}')"
kubectl run curl-debug --rm -it \
--image=cfmanteiga/alpine-bash-curl-jq \
--namespace="$NAMESPACE" \
--env="IP=$IP" \
-- /bin/bash
Inside that temporary shell:
curl -X POST "http://${IP}/v1/completions" \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen3-32B",
"prompt": "Explain what a Kubernetes readiness probe checks."
}'
This confirms the request path works. It does not establish that llm-d is faster or cheaper than direct vLLM. The quickstart is a deployment and verification path, not a benchmark result.
Make the comparison fair
A useful evaluation has two configurations: your current baseline and the candidate deployment. Keep the model, accelerator type, precision, prompt set, output length, concurrency, and warm-up policy consistent. Measure both cold-start and steady-state behavior separately.
Track at least:
- Time to first token and end-to-end latency, including high percentiles
- Input and output tokens per second under realistic concurrency
- GPU or accelerator utilization and memory use
- Cache hit behavior for repeated prefixes, if that is the feature being tested
- Error rate, queue depth, and behavior during traffic bursts
- Infrastructure cost for the full serving path, including router and supporting services
- Upgrade and recovery behavior, not just a successful steady-state run
Use a mix of repeated-context interactive requests and unrelated requests. If every test prompt is identical, the cache may look unusually favorable. If no prompts share context, a prefix-cache optimization has little opportunity to help. Include both patterns if both occur in production.
The llm-d project points to Prism and its benchmark recipes for examining published configurations and comparing runs. Treat vendor or project benchmarks as a starting point. Reproduce the configuration and traffic pattern that resemble your own service before making a deployment decision.
When should you stay with plain vLLM?
Stay with a simpler deployment if one replica meets the service objective, traffic is predictable, prompts rarely share context, and your existing load balancer and autoscaler handle the service adequately. A separate coordination layer increases the number of components to configure, observe, secure, and upgrade.
Evaluate llm-d if you operate a fleet and can identify a measurable limitation: cache locality is poor, interactive traffic is hurt by batch or agent bursts, model pools need priority control, or you need coordinated distributed inference capabilities. Start with the optimized baseline and add one feature at a time. That helps answer whether a specific capability improved your service rather than attributing every change to the platform as a whole.
A short decision checklist
Before a pilot, answer these questions:
- Traffic: Do requests repeat long prefixes, arrive in bursts, or mix interactive and background work?
- Scale: Do you need multiple model-server replicas or pools?
- Platform fit: Is Kubernetes already where your team runs and operates inference?
- Capacity: Is the required accelerator capacity available for a representative test?
- Measurement: Can you compare latency, throughput, cache behavior, and full infrastructure cost fairly?
- Operations: Who will own router health, model-server upgrades, alerting, and incident response?
- Rollback: Can you switch traffic back to the existing vLLM path if the pilot misses its objectives?
The key is to define a pass/fail criterion before deployment. For example, require a target improvement in p95 time-to-first-token under a real request mix, without exceeding a defined cost or error-rate budget. Pick thresholds that match your service; there is no universal number that makes llm-d worthwhile.
The takeaway
llm-d 1.0 makes the most sense as a fleet coordination layer for teams serving open models on Kubernetes. It adds routing, cache, flow-control, and operational capabilities around inference engines such as vLLM. It is not a drop-in speed switch, and the project's reported performance results are tied to particular hardware and workloads.
For a single model server, plain vLLM may remain the better engineering choice. For a multi-replica fleet with shared context, mixed priorities, and real operational requirements, llm-d is now a concrete stack to evaluate. Start with the official quickstart, measure your own workload, and only keep the extra layer if the results justify its complexity.
I write about practical AI infrastructure and developer tools. Subscribe for future implementation guides and evidence-based comparisons.
Have you operated a multi-replica vLLM deployment? What would make you add a routing layer: cache reuse, latency under bursts, or simpler operations?
Sources
- llm-d v1.0 release notes
- llm-d official quickstart
- vLLM documentation: llm-d integration
- CNCF project page for llm-d
- Google Cloud: llm-d 1.0 overview
1 reaction
Mentions
@lautidev_ Hola, encantado de charlar mas sobre esto, te paso un par de cosas que estoy usando y me rinden para algo bien facil de usar y local miren herdr.dev en una maquina, tailscale y poco mas para remote infra mas seria, kubernetes-sigs/agent-sandbox es facil de configurar
ロードバランサーなしKubernetesでHTTPS公開!NGINX Gateway Fabricとcert-managerでLet's Encryptを自動化 はじめに こんにちは!医療ITエンジニアの三浦です。 今回は、Ubuntu 26.04 LTS上に構築したシングルノードKubernetes環境で、 NGINX Gateway Fabric(NGF) と cert-manager を使用して、Let's EncryptのSSL/TLS証明書を自動取得・更新する環境を構築します。 AWSやAzureなどのクラウド環境では、ロードバランサーがHTTP/HTTPS通信を受け付ける構成が一般的です。 一方、オンプレミスやVPSなど、ロードバランサーを用意しない環境では、KubernetesのNodePort 80/443で外部通信を受け付けることができます。 今回は以下の構成を目指します。 - Gateway APIでHTTP/HTTPSのルーティングを管理 - NGINX Gateway FabricでTLS終端 - cert-managerでLet's Encrypt証明書を取得・自動更新 - NodePortで外部通信を受け付ける - HTTPSでNginxのWebページを公開 今回はNodePortを80/443に直接割り当て、ホストの80/443ポートで通信を受け付けます。 環境 今回使用する環境は以下のとおりです。 | 項目 | 環境 | |---|---| | OS | Ubuntu 26.04 LTS | | コンテナランタイム | containerd | | Kubernetes | v1.37系 | | CNI | Calico | | Gateway API | v1.7.0 | | Gateway Controller | NGINX Gateway Fabric | | パッケージ管理 | Helm 3 | | 証明書管理 | cert-manager | | 証明書発行元 | Let's Encrypt | 前回の記事「Ubuntu 26.04 LTSにContainerd、Kubernetes 1.37、Calico 3.33をインストール」で、containerd・Kubernetes・Calicoの構築とNginxのNodePort公開まで確認しました。 本記事はその続編です。Gateway API、Helm、NGINX Gateway Fabric、cert-managerは今回新たに導入します。 前回のNginx検証用DeploymentとServiceは削除済みであることを前提とします。 1. 今回の構成 今回の構成は以下のようになります。 Internet | | HTTPS :443 / HTTP :80 | [Public IP] | | NodePort :80 / :443 | Ubuntu 26.04 | Kubernetes (Single Node) | +-- NGINX Gateway Fabric | | | +-- NodePort 80 (HTTP) | +-- NodePort 443 (HTTPS) | | | +-- Gateway | | | +-- HTTP Listener :80 | +-- HTTPS Listener :443 | | | +-- TLS Termination | | | +-- HTTPRoute | | | +-- Nginx Service | | | +-- Nginx Pod | +-- cert-manager | +-- Let's Encrypt | +-- Certificate | +-- TLS Secret NGFがHTTPS通信のTLS終端を担当し、復号後のHTTPリクエストをバックエンドのNginxに転送します。 cert-managerはLet's Encryptと通信して証明書を取得し、KubernetesのSecretとして保存します。 NGFはそのSecretを参照してTLS終端を行います。 NodePortとGateway Listener 今回はNodePortとGateway Listenerの両方をHTTP 80、HTTPS 443に設定します。NodePortはKubernetesのServiceを介して通信を転送する仕組みであり、NGFのPodがホストのポートを直接bindするわけではありません。 Let's EncryptのHTTP-01認証では、インターネットから対象ドメインのTCP 80番に到達できる必要があります。DNS、ファイアウォール、外部ネットワークの設定も確認してください。 2. NodePortの範囲を拡張 KubernetesのNodePortは通常 30000-32767 の範囲で割り当てられます。今回は80/443を直接指定するため、kube-apiserverの許可範囲を拡張します。以下はkubeadmで構築したクラスタの例です。 sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml spec.containers[].command の既存の引数を残し、以下を追加します。 - --service-node-port-range=80-32767 保存後、kubeletがStatic Podの変更を検知し、kube-apiserverを再作成します。 kubectl get pods -n kube-system kubectl get --raw='/readyz'; echo 注意: この変更はクラスタ全体のNodePort割当範囲を広げます。80~29999のポートも割り当て可能になるため、他のServiceやホスト上のサービスとの競合、ファイアウォール設定に注意してください。設定前にマニフェストをバックアップし、APIサーバーの一時停止を許容できるタイミングで作業します。 3. Gateway API・Helm・NGINX Gateway Fabricのインストール 前回の記事で構築したKubernetes環境に、今回新たにGateway API、Helm、NGINX Gateway Fabric(NGF)をインストールします。Gateway APIはv1.7.0、NGFのNamespaceは nginx-gateway、Helmリリース名は my-ngf とします。 Gateway APIのインストール Gateway APIのCRDをインストールします。 kubectl apply --server-side \ -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.7.0/standard-install.yaml インストールを確認します。 kubectl get crd | grep gateway.networking.k8s.io Helmのインストール 今回使用するHelm 3をインストールします。 curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash helm version NGINX Gateway Fabricのインストール NGFのNamespaceを作成します。 kubectl create namespace nginx-gateway NodePort 80/443を指定してNGFをインストールします。この操作より前に、第2章のNodePort範囲拡張を完了させてください。 helm install my-ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \ --namespace nginx-gateway \ -f - <<'EOF' nginx: service: type: NodePort nodePorts: - port: 80 listenerPort: 80 - port: 443 listenerPort: 443 EOF ※ これは今回新規に設定するNGFのHelm Valuesです。NGFのChartバージョンによってValuesのスキーマが異なる可能性があるため、実際に使用するChartの helm show values oci://ghcr.io/nginx/charts/nginx-gateway-fabric で nodePorts の形式を確認してください。 NGFの起動を待機します。 kubectl wait --timeout=5m -n nginx-gateway \ deployment/my-ngf-nginx-gateway-fabric \ --for=condition=Available 導入状態と設定を確認します。 helm list -n nginx-gateway helm get values my-ngf -n nginx-gateway kubectl get pods -n nginx-gateway kubectl get gatewayclass このコマンドで新規インストールしたNGFの状態を確認します。既に別途NGFを導入している環境では、重複インストールを避けてください。 Gatewayリソースは第5章で作成します。NGFのデータプレーンServiceはGatewayの作成に応じて生成されるため、NodePortの割り当てはGateway作成後に確認します。 4. cert-managerのインストール 次に、TLS証明書の取得・更新を管理するcert-managerをインストールします。 今回はGateway APIを使用するため、Gateway API連携を有効化します。 helm repo add jetstack https://charts.jetstack.io helm repo update helm install cert-manager jetstack/cert-manager \ --namespace cert-manager \ --create-namespace \ --set crds.enabled=true \ --set config.gatewayAPI.enabled=true \ --wait インストール後、Podの状態を確認します。 kubectl get pods -n cert-manager 以下のコンポーネントが稼働していることを確認します。 - cert-manager - cert-manager-cainjector - cert-manager-webhook Gateway APIのCRDはcert-managerの起動前にインストールしておく必要があります。 後からGateway APIをインストールした場合は、cert-managerを再起動します。 kubectl rollout restart deployment/cert-manager \ -n cert-manager 5. Gatewayリソースの作成 続いて、NGFでHTTP/HTTPS通信を受け付けるGatewayを作成します。 今回新たに、default NamespaceにGatewayを作成します。 証明書を保存するSecretも同じNamespaceに配置します。 kubectl apply -f - <<'EOF' apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: prod-gateway namespace: default spec: gatewayClassName: nginx listeners: - name: http protocol: HTTP port: 80 allowedRoutes: namespaces: from: All - name: https protocol: HTTPS port: 443 tls: mode: Terminate certificateRefs: - kind: Secret name: my-app-tls allowedRoutes: namespaces: from: All EOF この設定では、HTTPとHTTPSの2つのListenerを作成します。 HTTPSのListenerでは、my-app-tls というSecretをTLS証明書として参照します。 現時点ではSecretが存在しないため、HTTPS Listenerが正常な状態にならない場合があります。 後ほどcert-managerで証明書を発行すると、Secretが自動生成されます。 Gatewayの状態を確認します。 kubectl get gateway -n default kubectl describe gateway prod-gateway -n default NGFのServiceも確認します。 kubectl get svc prod-gateway-nginx -n default Gateway用のデータプレーンServiceで、以下のNodePortが割り当てられていることを確認します。 80:80/TCP 443:443/TCP 6. Let's EncryptのClusterIssuerを作成 cert-managerでLet's Encryptの証明書を取得するには、IssuerまたはClusterIssuerを作成します。 今回は複数のNamespaceから共通利用できる ClusterIssuer を使用します。 Let's Encryptには、本番環境とステージング環境があります。 本番環境には、認証失敗の回数や同一証明書の発行回数にレート制限があります。設定ミスのまま試行を繰り返すと、しばらく証明書を発行できなくなることがあります。 そのため、最初はステージング環境で証明書発行をテストし、問題がないことを確認してから本番環境に切り替えます。 この章ではステージング用と本番用の2つのClusterIssuerを作成しておき、実際の発行テストと切り替えは第7章で行います。 ステージング環境用のClusterIssuer kubectl apply -f - <<'EOF' apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-staging spec: acme: email: your-email@example.com server: https://acme-staging-v02.api.letsencrypt.org/directory privateKeySecretRef: name: letsencrypt-staging-account-key solvers: - http01: gatewayHTTPRoute: serviceType: ClusterIP parentRefs: - name: prod-gateway namespace: default kind: Gateway sectionName: http EOF your-email@example.com は自分のメールアドレスに変更してください。 gatewayHTTPRoute を指定することで、cert-managerはGateway APIのHTTPRouteを利用してHTTP-01認証を実行します。 認証時には一時的なHTTPRoute、Service、Podが作成されます。 これらを経由してLet's Encryptからの認証リクエストに応答します。 serviceType: ClusterIP は認証用Serviceをクラスタ内部に限定する設定です。外部公開はNGFが担当します。 本番環境用のClusterIssuer 続けて、本番用のClusterIssuerも作成しておきます。 ステージング用との違いは server と privateKeySecretRef のみです。 実際に本番証明書を発行するのは、第7章でステージング証明書の発行を確認してからです。 kubectl apply -f - <<'EOF' apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: email: your-email@example.com server: https://acme-v02.api.letsencrypt.org/directory privateKeySecretRef: name: letsencrypt-prod-account-key solvers: - http01: gatewayHTTPRoute: serviceType: ClusterIP parentRefs: - name: prod-gateway namespace: default kind: Gateway sectionName: http EOF ClusterIssuerの確認 作成したClusterIssuerを確認します。 kubectl get clusterissuer NAME READY AGE letsencrypt-prod True 10s letsencrypt-staging True 30s 両方の READY が True になっていることを確認します。 詳細も確認しておきます。 kubectl describe clusterissuer letsencrypt-staging Statusに The ACME account was registered with the ACME server と表示されていれば、Let's EncryptへのACMEアカウント登録は成功しています。 なお、ここで確認できるのは「Let's Encryptと通信でき、アカウントを登録できた」ことまでです。 ClusterIssuerがReadyでも、ドメイン認証が成功したわけではありません。 実際のHTTP-01認証は、次章のCertificate作成時に行われます。 7. Certificateリソースの作成 続いて、取得する証明書を定義します。 今回は example.com の証明書を取得する例です。 実際に所有しているドメインに変更してください。 事前にDNSのAレコードを設定し、対象ドメインがサーバーのグローバルIPアドレスを参照するようにしておきます。 また、TCP 80番ポートからNGFのHTTP Listenerにアクセスできる必要があります。 ステージング証明書の取得 まずはステージング用のClusterIssuerを指定して、Certificateを作成します。 kubectl apply -f - <<'EOF' apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: my-app-tls namespace: default spec: secretName: my-app-tls dnsNames: - example.com issuerRef: name: letsencrypt-staging kind: ClusterIssuer EOF 証明書の発行処理中は、以下のリソースで進行状況を確認できます。 kubectl get certificaterequest -n default kubectl get order -n default kubectl get challenge -n default kubectl get httproute -n default 認証中は cm-acme-http-solver-xxxxx という一時的なHTTPRouteが作成されます。 認証が完了すると、ChallengeやこのHTTPRouteは自動的に削除されます。 ステージング証明書の確認 本番証明書に切り替える前に、以下の3点を確認します。 1. Certificateが発行済みであること kubectl get certificate my-app-tls -n default NAME READY SECRET AGE my-app-tls True my-app-tls 1m READY が True になっていることを確認します。 あわせて、Challengeが残っていないことも確認します。 kubectl get challenge -n default No resources found と表示されれば、認証が完了して一時リソースが片付いた状態です。 READY が True にならない場合は、詳細を確認します。 kubectl describe certificate my-app-tls -n default kubectl describe challenge -n default 2. Secretにステージング証明書が保存されていること kubectl get secret my-app-tls -n default \ -o jsonpath='{.data.tls\.crt}' | base64 -d \ | openssl x509 -noout -issuer -subject -dates issuerに (STAGING) が含まれていれば、ステージング環境から発行された証明書です。 3. NGFがその証明書でTLS終端していること 外部のPCから、443番ポートで提供されている証明書を確認します。 openssl s_client \ -connect example.com:443 \ -servername example.com \ </dev/null 2>/dev/null \ | openssl x509 -noout -issuer ここでもissuerに (STAGING) が含まれていれば、外部から443番ポートに到達でき、GatewayのHTTPS ListenerがSecretを読み込めていることまで確認できます。 HTTPRouteは第9章で作成するため、この時点でHTTPSにアクセスするとHTTPのレスポンスは404になりますが、TLSの確認には影響しません。 この3点を確認できれば、DNS、TCP 80番ポートへの到達、HTTP-01認証、Secretの生成、Gatewayからの参照まで一通り動作しています。 なお、ステージング環境の証明書はブラウザから信頼されません。 確認ができたら、本番用ClusterIssuerに切り替えます。 本番証明書への切り替え CertificateのissuerRefを本番用ClusterIssuerに変更します。 kubectl patch certificate my-app-tls \ -n default \ --type=merge \ -p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}' cert-managerがIssuerの変更を検知し、本番用の証明書を発行します。 kubectl get certificate my-app-tls -n default -w READY が一度 False になり、再び True になれば発行完了です(Ctrl+C で監視を終了します)。 本番証明書の確認 Secretの証明書が本番環境のものに置き換わったことを確認します。 kubectl get secret my-app-tls -n default \ -o jsonpath='{.data.tls\.crt}' | base64 -d \ | openssl x509 -noout -issuer -subject -dates issuerから (STAGING) がなくなり、O = Let's Encrypt になっていれば本番証明書です。 外部からも確認します。 openssl s_client \ -connect example.com:443 \ -servername example.com \ </dev/null 2>/dev/null \ | openssl x509 -noout -issuer NGFはSecretの変更を検知して設定を更新するため、NGFの再起動は不要です。 Secretには証明書と秘密鍵が保存されており、NGFはGatewayで指定したこのSecretを参照してTLS終端を行います。 8. NginxのWebサーバーをデプロイ 証明書の準備ができたら、バックエンドとなるNginxをデプロイします。 今回は動作確認用のシンプルな構成とします。 kubectl create deployment nginx-demo \ --image=nginx:stable \ -n default Serviceを作成します。 kubectl expose deployment nginx-demo \ --port=80 \ --target-port=80 \ --type=ClusterIP \ -n default 状態を確認します。 kubectl get pods -n default kubectl get svc nginx-demo -n default この段階では、Nginxはクラスタ内部からのみアクセスできます。 外部からアクセスするには、Gateway APIのHTTPRouteを作成します。 9. HTTPRouteの作成 HTTPSのリクエストをNginxに転送するHTTPRouteを作成します。 example.com の部分は実際に所有しているドメインに変更してください。 kubectl apply -f - <<'EOF' apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: nginx-route namespace: default spec: parentRefs: - name: prod-gateway sectionName: https hostnames: - example.com rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: nginx-demo port: 80 EOF この設定では、以下の通信経路になります。 HTTPS Request | v NGINX Gateway Fabric | | TLS Termination | v HTTPRoute | v nginx-demo Service | v Nginx Pod :80 HTTPRouteの状態を確認します。 kubectl get httproute -n default kubectl describe httproute nginx-route -n default Accepted=True と ResolvedRefs=True になっていることを確認します。 HTTPからHTTPSへのリダイレクト 通常のHTTPアクセスをHTTPSへリダイレクトするHTTPRouteも作成します。 kubectl apply -f - <<'EOF' apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: https-redirect namespace: default spec: parentRefs: - name: prod-gateway sectionName: http hostnames: - example.com rules: - filters: - type: RequestRedirect requestRedirect: scheme: https statusCode: 301 EOF これにより、HTTPでアクセスした場合はHTTPSにリダイレクトされます。 ただし、Let's EncryptのHTTP-01認証でもHTTPを使用します。 cert-managerが作成する認証用HTTPRouteとリダイレクト用HTTPRouteが競合しないことを確認してください。 HTTP-01認証の一時的なRouteは、特定の認証パスに対して作成されるため、通常のPathPrefixルートより優先される構成です。 10. 動作確認 ブラウザで以下のURLにアクセスします。 https://example.com Nginxの初期ページが表示されれば成功です。 また、curlでも確認できます。 curl -I https://example.com HTTPからHTTPSへのリダイレクトも確認します。 curl -I http://example.com 301リダイレクトが返されることを確認します。 証明書の内容はOpenSSLでも確認できます。 openssl s_client \ -connect example.com:443 \ -servername example.com \ </dev/null 2>/dev/null \ | openssl x509 -noout -issuer -subject -dates Issuer、Subject、有効期限などを確認できます。 11. 証明書の自動更新 cert-managerを使用するメリットの1つが、TLS証明書の自動更新です。 証明書の有効期限を監視し、更新が必要になると自動的に新しい証明書の発行処理を実行します。 更新後の証明書は、同じKubernetes Secretに保存されます。 NGFはSecretの変更を検知し、新しい証明書を使用するように設定を更新します。 通常、証明書更新のたびに手動でNGFを再起動する必要はありません。 証明書の状態は以下のコマンドで確認できます。 kubectl get certificate -n default 詳細を確認します。 kubectl describe certificate my-app-tls -n default 更新予定時刻や発行状態を確認できます。 また、cert-managerのログも確認できます。 kubectl logs -n cert-manager \ deployment/cert-manager 証明書更新時にもHTTP-01認証が必要になるため、TCP 80番ポートは継続して外部からアクセスできる状態にしておきます。 12. トラブルシューティング 証明書が発行されない まず、Certificateの状態を確認します。 kubectl describe certificate my-app-tls -n default 続いて、Challengeを確認します。 kubectl get challenge -n default kubectl describe challenge -n default 主な原因として、以下が考えられます。 - DNSが正しいグローバルIPを参照していない - TCP 80番ポートが外部に公開されていない - GatewayのHTTP Listenerが存在しない - HTTP-01認証用HTTPRouteがGatewayに接続できない - ファイアウォールで通信が遮断されている - cert-managerから認証用Serviceへ通信できない GatewayがReadyにならない kubectl describe gateway prod-gateway -n default GatewayのConditionsを確認します。 特に、TLS Secretの存在、GatewayClass、Listenerの設定を確認してください。 HTTPSで502エラーになる kubectl get pods -n default kubectl get svc -n default kubectl describe httproute nginx-route -n default NginxのPodが正常に稼働しているか、Serviceのポート番号が正しいかを確認します。 まとめ 今回は、シングルノードKubernetes環境でNGINX Gateway Fabricとcert-managerを使用し、Let's Encryptの証明書を自動取得・更新する環境を構築しました。 今回のポイントは以下のとおりです。 - Gateway APIでHTTP/HTTPSのルーティングを管理できる - NGFでTLS終端を実現できる - cert-managerで証明書の取得・更新を自動化できる - NodePort 80/443を使用してロードバランサーなしでWebサービスを公開できる - NodePort範囲を拡張して80/443を直接割り当てられる クラウドのマネージドロードバランサーを使用しなくても、Kubernetesの標準的なリソースを組み合わせることでHTTPS環境を構築できます。 ただし、シングルノード構成はホスト障害時にサービス全体が停止するため、高可用性が必要な本番システムでは冗長化も検討する必要があります。 今後は、NGFを利用したmTLS(クライアント証明書認証)や、Blue/Greenデプロイメントにも挑戦していきたいと思います。 参考リンク - 前回の記事:Ubuntu 26.04 LTSにContainerd、Kubernetes 1.37、Calico 3.33をインストール - Kubernetes Gateway API - NGINX Gateway Fabric公式ドキュメント - NGINX Gateway Fabric Helmインストール - cert-manager公式ドキュメント - cert-manager HTTP-01 - Let's Encrypt
How many of the agent developers are using this for their agents? kubernetes-sigs/agent-sandbox
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 支援
單機 Kubernetes 的管理介面連換兩次:Rancher Portainer Headlamp,以及 sessionTTL 與 token 的坑 > 一句話結論:單機或小叢集只想要一個「看得到所有 workload」的介面,Headlamp 夠用而且很輕。Rancher 和 Portainer 都不是不好,是對這個場景太重,或剛好踩到記憶體的坑。 當初怎麼做 單機的 Kubernetes 叢集,總是想要一個網頁介面:不用每次都敲 kubectl get pods -A,看 log、看資源用量也比較直覺。 第一個選的是 Rancher。理由很單純:名氣大、功能完整,感覺「正式的叢集就該有」。 哪裡不行 Rancher:用不到的功能太多,壞了又很難修 Rancher 預設會帶一整組週邊元件:多叢集管理、Fleet、Turtles、內嵌的 Cluster API controller。 某一天 Rancher 就卡在 CrashLoopBackOff,連續好幾天。原因是兩套內嵌的 CAPI controller 的 CRD,storedVersions 版本衝突。官方文件有記載這類問題,正式的解法是跑它自己的清理工具,而那是破壞性的。 回頭看,這個判斷就很明顯:一台機器、一個叢集,從頭到尾沒用過下游叢集、Fleet、Turtles。為了一個「看叢集」的需求,裝了一整個多叢集管理平台,出問題時要追的東西比要看的東西還多。 所以沒有去追這個衝突,直接整個換掉。 Portainer:輕量,但看 Helm 頁面會吃光記憶體 換成 Portainer CE,用 Helm 安裝,單機叢集夠用。 直到點進 Kubernetes 的 Helm 頁面。這個頁面會讀 Helm repository 的 index,記憶體瞬間飆高。 原本 limit 是 512Mi,pod 連續被 OOMKilled 三次,前端出現「Unable to retrieve helm repositories」和一堆連線被重置的錯誤。 調到 1Gi 之後還是撐不住,反覆 OOM。與其繼續加記憶體,不如承認它的行為不適合這台機器。 換成什麼 換成 Headlamp。它是 Kubernetes SIG 的專案,只做 Kubernetes,介面簡單,資源限制設 300m CPU、256Mi 記憶體就夠。 用 Helm 安裝,重點設定是 ingress 和一個 clusterRoleBinding 綁 cluster-admin,這樣可視範圍就是整個叢集。 遷移怎麼進行 遷移本身很單純,因為這類管理介面幾乎沒有自己的狀態要搬: 1. 新增 Headlamp 的部署設定,移除 Portainer 的。 2. 確認舊的 namespace 和 PVC 真的被清掉,沒清就手動刪。 3. 把文件、儀表板裡寫死的舊 namespace 名稱全部換掉。 4. 產生登入 token。 接下來才是真正花時間的部分,登入。 踩到的坑 Headlamp 的登入頁需要貼上一個 ServiceAccount 的 bearer token。 坑一:token 的有效期限 最先想到的是用 kubectl create token 產生 token。預設的有效期限只有 1 小時,所以文件上寫的是加 --duration=8760h(一年)。 後來發現隔天還是要重新登入,就修改文件,說「API Server 通常會限制手動產生 token 的存活時間,--duration 無法突破上限」,改用一個永久的 Secret: apiVersion: v1 kind: Secret metadata: name: headlamp-permanent-token namespace: portal annotations: kubernetes.io/service-account.name: headlamp type: kubernetes.io/service-account-token 建立之後,Kubernetes 的 controller 會自動把 token 填進這個 Secret,而且不會過期。取出來用: kubectl -n portal get secret headlamp-permanent-token -o jsonpath='{.data.token}' | base64 -d Secret 本身不含明文憑證,可以放進 Git。 這個做法是對的,但當時的診斷是錯的。為了驗證,這次在一個全新的 k3s(v1.31.5)叢集重做一遍: | 方式 | token 的有效期限 | |---|---| | kubectl create token(預設) | 1 小時 | | kubectl create token --duration=8760h | 8760 小時(一年),沒被截短 | | kubernetes.io/service-account-token 的 Secret | 沒有 exp,不會過期 | 也就是說,在預設的 k3s 上,--duration=8760h 其實會生效。隔天又被要求重新登入,真正的原因是下一個坑。當時那個叢集有沒有額外的 token 期限上限,已經無法確認,所以不能說前面的說法一定是錯的,但至少「--duration 無法突破上限」不是普遍成立的結論。 永久 Secret 仍然比較省事:不用記得重新產生,也能放進 Git 管理。要記得這個 token 綁的是 cluster-admin,等於整個叢集的鑰匙,不要貼在聊天或截圖裡。 坑二:token 沒過期,卻還是要重新登入 Headlamp 前端有自己的 session TTL,預設只有 24 小時,跟 token 有效期限無關。到期之後被踢回登入頁,要重貼同一組還有效的 token。預設值 24 小時是照上游文件與 相關 issue(新增 session TTL 設定)的說法,這次沒有另外量測。 chart 有 config.sessionTTL 可以調,文件說上限是一年,於是設成 31536000 秒。 結果 Headlamp 直接 CrashLoopBackOff: invalid value "3.1536e+07" for flag -session-ttl: parse error 原因出在 chart 的 template:它把 sessionTTL 直接插進 args,沒有轉成整數。Helm 讀 values 檔裡的數字時是 float64,Go 預設的浮點數輸出格式,數值一到 7 位數就改用科學記號。 不用部署,本機 helm template 就能重現。測 chart 0.43.0,傳進去的值和渲染結果如下: | config.sessionTTL | 渲染出的參數 | |---|---| | 999999 | -session-ttl=999999 | | 1000000 | -session-ttl=1e+06 | | 31536000 | -session-ttl=3.1536e+07 | 分界就在 7 位數。再用寫這篇時最新的 0.46.0 測一次,31536000 仍然渲染成 3.1536e+07,問題還在。 所以只能取 6 位數以內的值。設成 999999,約 11.5 天,是不踩到科學記號的最長值。 這類「數字在 YAML 裡是整數,進了 template 變成浮點數」的坑,不只在 Headlamp,任何 chart 把數字裸插進 command line 都可能遇到。覺得數字很大的時候,先 helm template 看一眼渲染結果再部署。 坑三:Application 永遠 OutOfSync 如果用 GitOps 工具管理,Headlamp 部署後可能一直顯示 OutOfSync,雖然服務完全正常。 chart 固定在 Deployment 的 pod spec 帶入 hostUsers: true。這個欄位依賴 user namespace 的 feature gate,k3s v1.31.5 預設沒開,API server 收到會直接丟掉這個欄位,結果 live 的物件永遠沒有它,對不上期望的內容。 在乾淨的 k3s v1.31.5 上實測:chart 渲染出 hostUsers: true,套用之後讀回 Deployment,這個欄位是空的。 解法是在 Application 加 ignoreDifferences 忽略這個欄位。 換了之後的代價 Portainer 還能順便管主機上的 Docker,Headlamp 只管 Kubernetes。這個功能隨著替換一起捨棄,需要看 Docker 時回到命令列。 另外,Headlamp 比較陽春。沒有 Rancher 的多叢集、也沒有 Portainer 那種一站式的應用範本。對單機叢集來說,這些剛好都用不到。 如果日後真的要管多個叢集,或需要比較完整的權限管理,Rancher 這類工具還是會回來,只是這一次不會為了一個「看 pod」的需求先裝它。 延伸閱讀 - Headlamp 專案 - Kubernetes 文件:ServiceAccount 的長期 API token
Gateway API in Production: Replacing Ingress NGINX Without Breaking Your TLS Certificates Ingress NGINX is in maintenance mode, and the Kubernetes project has been nudging everyone toward Gateway API for a while now. The networking spec graduated to GA (v1) for GatewayClass, Gateway and HTTPRoute, the ecosystem has converged, and your next cluster upgrade is a good excuse to stop postponing the migration. The problem is that "replace your ingress controller" is a sentence that hides a lot of risk: a single typo in a TLS listener and every browser in production gets a certificate error. This article walks through a migration strategy that never requires a big-bang cutover: run Gateway API next to your existing Ingress NGINX, move one hostname at a time, shift traffic by weight, and validate the TLS chain at every step. We'll use Envoy Gateway as the controller and cert-manager for certificates, but the pattern applies to any conformant implementation (Istio, Cilium, Traefik, NGINX Gateway Fabric). Context: why this migration is different Swapping an ingress controller is not like swapping a Deployment image. Three things make it risky: 1. TLS termination moves. Your certificates live in Secrets referenced by Ingress objects. The new data plane has to reference the same Secrets (or new ones), possibly from a different namespace, under a stricter cross-namespace model. 2. The public IP changes. A new controller means a new LoadBalancer Service, a new external IP, and a DNS change. DNS changes are not atomic — you cannot roll them back in 30 seconds. 3. Annotations don't translate. Rewrites, CORS, rate limits, auth, timeouts — everything encoded in nginx.ingress.kubernetes.io/* annotations becomes either native Gateway API fields, filters, or implementation-specific policies. The good news: Gateway API was explicitly designed for coexistence. An Ingress and a Gateway can serve the same hostname from the same cluster, and nothing in the spec forbids two controllers from watching different resources. That's the lever we're going to pull. Target architecture The model splits responsibilities across three object types, which maps nicely onto real org boundaries: - GatewayClass — cluster-scoped, owned by the platform team. Points at a controller. - Gateway — the listener (ports, protocols, TLS certs, allowed routes). Lives in an infrastructure namespace. - HTTPRoute — owned by application teams, in application namespaces, attached to a Gateway via parentRefs. - ReferenceGrant — the explicit opt-in that allows a cross-namespace reference (Gateway → Secret, HTTPRoute → Service). Concretely: one shared Gateway in gateway-infra, TLS Secrets managed by cert-manager, and each team shipping its own HTTPRoute in its own namespace. Step 1 — Install the CRDs and the controller On a vanilla Kubernetes cluster, install the standard channel CRDs first: kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml kubectl get crd | grep gateway.networking.k8s.io > OpenShift note: since 4.19, the Ingress Operator can manage the Gateway API CRDs itself. Check for ownership before applying anything, otherwise the operator will fight you: > ```bash > oc get crd gateways.gateway.networking.k8s.io \ > -o jsonpath='{.metadata.labels}{"\n"}{.metadata.annotations}{"\n"}' > ``` > If the CRDs are operator-managed, skip the kubectl apply and only install your controller. Envoy Gateway's data plane runs unprivileged on ports 10080/10443, so the restricted-v2 SCC is sufficient — no SCC patching required. Install Envoy Gateway: helm install eg oci://docker.io/envoyproxy/gateway-helm \ --version v1.2.6 \ -n envoy-gateway-system --create-namespace \ --set config.envoyGateway.provider.kubernetes.rateLimitDeployment.container.resources.requests.cpu=100m kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway Then declare the GatewayClass: apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: eg-external spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller kubectl get gatewayclass eg-external \ -o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}' # expected: True Step 2 — Wire cert-manager to Gateway API cert-manager ships a "gateway shim" that watches Gateway objects for the cert-manager.io/cluster-issuer annotation and creates Certificate resources from the listeners. It's off by default: helm upgrade --install cert-manager jetstack/cert-manager \ -n cert-manager --create-namespace \ --version v1.16.2 \ --set crds.enabled=true \ --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \ --set config.kind="ControllerConfiguration" \ --set config.enableGatewayAPI=true For HTTP-01 solving, cert-manager can create a temporary HTTPRoute instead of an Ingress: apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: platform@example.com privateKeySecretRef: name: letsencrypt-prod-account-key solvers: - http01: gatewayHTTPRoute: parentRefs: - name: apps-gw namespace: gateway-infra kind: Gateway Critical detail during coexistence: if your existing Ingress NGINX still serves the hostname and holds the DNS record, HTTP-01 challenges will land on NGINX, not on the Gateway. Two safe options: keep issuing certificates through the existing Ingress solver until DNS is switched, or — far better — use DNS-01. DNS-01 is independent of which data plane owns the IP, which removes an entire class of migration failures. Step 3 — The Gateway, reusing the existing certificate Here's the trick that avoids re-issuing certs on day one: point the new Gateway at the same Secret your Ingress already uses. No new ACME order, no rate-limit risk, identical chain. apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: apps-gw namespace: gateway-infra annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: gatewayClassName: eg-external listeners: - name: http protocol: HTTP port: 80 hostname: "*.example.com" allowedRoutes: namespaces: from: Selector selector: matchLabels: gateway-access: "true" - name: https-shop protocol: HTTPS port: 443 hostname: shop.example.com tls: mode: Terminate certificateRefs: - kind: Secret name: shop-example-com-tls namespace: shop allowedRoutes: namespaces: from: Selector selector: matchLabels: gateway-access: "true" Two things to notice. First, allowedRoutes uses a label selector, not All — namespaces must explicitly opt in. Second, the certificate Secret lives in the shop namespace while the Gateway lives in gateway-infra. That cross-namespace reference is denied by default. You need a ReferenceGrant in the namespace that owns the Secret: apiVersion: gateway.networking.k8s.io/v1beta1 kind: ReferenceGrant metadata: name: allow-gw-to-read-shop-tls namespace: shop spec: from: - group: gateway.networking.k8s.io kind: Gateway namespace: gateway-infra to: - group: "" kind: Secret name: shop-example-com-tls Note the name field in to: — grant access to that one Secret, not to every Secret in the namespace. Verify the listener is actually programmed. This is the single most useful command of the whole migration: kubectl -n gateway-infra get gateway apps-gw \ -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{range .conditions[*]}{.type}={.status}{" "}{end}{"\n"}{end}' A ResolvedRefs=False with reason RefNotPermitted means your ReferenceGrant is missing or mismatched. Step 4 — The HTTPRoute apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: shop namespace: shop spec: parentRefs: - name: apps-gw namespace: gateway-infra sectionName: https-shop hostnames: - shop.example.com rules: - matches: - path: type: PathPrefix value: /api filters: - type: RequestHeaderModifier requestHeaderModifier: set: - name: X-Forwarded-Proto value: https backendRefs: - name: shop-api port: 8080 - matches: - path: type: PathPrefix value: / backendRefs: - name: shop-frontend port: 8080 Don't forget the namespace label required by allowedRoutes: kubectl label namespace shop gateway-access=true If a rule needs to target a Service in another namespace (say payments), add a ReferenceGrant in payments with from.kind: HTTPRoute. Same mechanism, same least-privilege rule: name the target Service explicitly. Step 5 — Validate TLS before touching DNS At this point both data planes serve shop.example.com, but only NGINX has the DNS record. Test the Gateway directly by resolving the hostname yourself: GW_IP=$(kubectl -n envoy-gateway-system get svc \ -l gateway.envoyproxy.io/owning-gateway-name=apps-gw \ -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}') curl -sSv --resolve shop.example.com:443:$GW_IP https://shop.example.com/healthz Then compare the certificate served by each data plane. Identical fingerprints mean zero client-visible change: fp() { openssl s_client -connect "$1:443" -servername shop.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -fingerprint -sha256; } NGINX_IP=$(dig +short shop.example.com | head -1) echo "nginx: $(fp $NGINX_IP)" echo "gateway: $(fp $GW_IP)" Also check the full chain and SAN list — a frequent failure mode is a Gateway serving only the leaf certificate because the Secret was built by hand: openssl s_client -connect $GW_IP:443 -servername shop.example.com -showcerts </dev/null 2>/dev/null \ | grep -E 's:|i:' openssl s_client -connect $GW_IP:443 -servername shop.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -dates -ext subjectAltName And make sure an unknown SNI doesn't silently fall through to the wrong certificate: openssl s_client -connect $GW_IP:443 -servername notmydomain.example.com </dev/null 2>&1 | head -20 Step 6 — Shift traffic by weight, without DNS DNS-based cutovers are coarse: TTLs lie, resolvers cache, and rollback takes minutes. A much tighter loop is to make Ingress NGINX itself forward a percentage of traffic to the Gateway, using its canary annotations plus an ExternalName Service. apiVersion: v1 kind: Service metadata: name: gw-bridge namespace: shop spec: type: ExternalName externalName: envoy-gateway-infra-apps-gw-7f9c2d11.envoy-gateway-system.svc.cluster.local ports: - name: https port: 443 Get the real Service name first: kubectl -n envoy-gateway-system get svc \ -l gateway.envoyproxy.io/owning-gateway-name=apps-gw -o name Then a canary Ingress on the same hostname: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: shop-canary-to-gateway namespace: shop annotations: nginx.ingress.kubernetes.io/canary: "true" nginx.ingress.kubernetes.io/canary-weight: "5" nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" nginx.ingress.kubernetes.io/upstream-vhost: "shop.example.com" nginx.ingress.kubernetes.io/proxy-ssl-server-name: "on" nginx.ingress.kubernetes.io/proxy-ssl-name: "shop.example.com" spec: ingressClassName: nginx tls: - hosts: [shop.example.com] secretName: shop-example-com-tls rules: - host: shop.example.com http: paths: - path: / pathType: Prefix backend: service: name: gw-bridge port: number: 443 Now you have a real dial. Ramp it with a single patch, watch your SLOs, and roll back in seconds: for w in 5 25 50 100; do kubectl -n shop annotate ingress shop-canary-to-gateway \ nginx.ingress.kubernetes.io/canary-weight="$w" --overwrite sleep 600 done # instant rollback kubectl -n shop annotate ingress shop-canary-to-gateway \ nginx.ingress.kubernetes.io/canary-weight="0" --overwrite You can also pin the canary to a header (nginx.ingress.kubernetes.io/canary-by-header: x-use-gateway) so QA can target the new path deterministically before any percentage of real users is affected. Once 100% has been stable for a few days, switch DNS to the Gateway's IP, wait out the TTL, then delete the canary Ingress and the legacy Ingress. The same weighting primitive exists natively inside Gateway API for backend-level canaries: backendRefs: - name: shop-frontend port: 8080 weight: 90 - name: shop-frontend-next port: 8080 weight: 10 Security best practices Never use allowedRoutes.namespaces.from: All on an internet-facing Gateway. Any namespace in the cluster could then attach a route to your public listener and claim a hostname. Use Same or a label Selector, and treat the label as a privileged operation controlled by the platform team. Scope every ReferenceGrant. Always set to[].name. A grant without a name allows every Secret (or every Service) of that kind in the namespace — including, potentially, the Secret holding a wildcard certificate. Enforce TLS floor and cipher policy. Gateway API leaves this to implementation-specific policies. With Envoy Gateway: apiVersion: gateway.envoyproxy.io/v1alpha1 kind: ClientTrafficPolicy metadata: name: tls-hardening namespace: gateway-infra spec: targetRefs: - group: gateway.networking.k8s.io kind: Gateway name: apps-gw tls: minVersion: "1.2" maxVersion: "1.3" ciphers: - ECDHE-ECDSA-AES256-GCM-SHA384 - ECDHE-RSA-AES256-GCM-SHA384 - ECDHE-ECDSA-CHACHA20-POLY1305 headers: xForwardedClientCert: mode: Sanitize Redirect HTTP to HTTPS at the route level, so a misconfigured app can never be served in clear text: apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: https-redirect namespace: shop spec: parentRefs: - name: apps-gw namespace: gateway-infra sectionName: http hostnames: ["shop.example.com"] rules: - filters: - type: RequestRedirect requestRedirect: scheme: https statusCode: 301 Encrypt the last hop too. If your backend speaks TLS, use BackendTLSPolicy with explicit CA validation and hostname verification rather than disabling verification: apiVersion: gateway.networking.k8s.io/v1alpha3 kind: BackendTLSPolicy metadata: name: shop-api-upstream-tls namespace: shop spec: targetRefs: - group: "" kind: Service name: shop-api sectionName: "8443" validation: caCertificateRefs: - group: "" kind: ConfigMap name: internal-ca hostname: shop-api.shop.svc.cluster.local Lock down the data plane with NetworkPolicy. The Envoy pods should accept traffic from the LoadBalancer and reach only the namespaces they route to. On OpenShift, confirm the proxy runs under restricted-v2 and not a relaxed SCC: oc -n envoy-gateway-system get pod -l app.kubernetes.io/name=envoy \ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.openshift\.io/scc}{"\n"}{end}' Audit certificate ownership after the switch. When you finally delete the legacy Ingress, make sure cert-manager still owns the Certificate object — deleting an Ingress with the cert-manager.io/cluster-issuer annotation can garbage-collect the Certificate and, depending on your setup, the Secret with it. Create a standalone Certificate resource explicitly rather than relying on annotation-driven shims for anything production-critical: apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: shop-example-com namespace: shop spec: secretName: shop-example-com-tls duration: 2160h renewBefore: 720h privateKey: algorithm: ECDSA size: 256 rotationPolicy: Always dnsNames: - shop.example.com issuerRef: name: letsencrypt-prod kind: ClusterIssuer Alert on expiry independently. Scrape certmanager_certificate_expiration_timestamp_seconds and alert at 14 days, plus a black-box probe that checks the certificate actually served on port 443. A valid Secret that the Gateway failed to load is a silent outage waiting for renewal day. Conclusion Migrating off Ingress NGINX doesn't have to be a maintenance-window event. The key takeaways: - Coexist first. Gateway API and Ingress can serve the same hostname simultaneously. Build the Gateway, validate it out-of-band with curl --resolve, and only then send it traffic. - Reuse the existing TLS Secret for the first cutover. Comparing SHA-256 fingerprints between the old and new data plane is a 10-second check that eliminates the scariest failure mode. - ReferenceGrant is a security boundary, not a formality. Scope it to a named Secret or Service, and keep allowedRoutes on a label selector. - Shift traffic with weights, not DNS. The NGINX canary + ExternalName bridge gives you a percentage dial with sub-second rollback. Move DNS only once 100% has been stable for days. - Prefer DNS-01 during the transition, so certificate issuance never depends on which data plane currently owns the public IP. - Harden explicitly. TLS floor, HTTP→HTTPS redirect, upstream TLS validation and NetworkPolicy are all policy objects you have to write — nothing is secure by default just because it's new. Do one low-traffic hostname end to end first. The second one takes an afternoon, and by the fifth you'll be deleting Ingress objects without thinking about it.
Want to practise Kubernetes without paying for a cloud cluster? kubernetes-sigs/kind (15.5k ★) runs a local cluster inside Docker. go install sigs.k8s.io kind create cluster kubernetes-sigs/kind What's the first app you'd deploy on it? t.co
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 ↩︎
Kubernetes の分散ジョブスケジューラ比較 はじめに この記事は、Slurm と Kubernetes のどちらかが優れていると主張するためのものではありません。両者の違いをできるだけ公平に並べたうえで、Kubernetes の上でジョブのスケジューリングがどう組み立てられているのかを整理することが目的です。 ! 筆者は Slurm も Kubernetes もどっちも好きだにゃーん。(=´ω`;=) Kubernetes に、4 つの Pod からなる分散学習ジョブを投げたとします。2 つは動いたのに、残り 2 つはノードに空きが無く、いつまでも Pending のまま、ということが起こり得ます。クラスタが壊れているわけではありません。 kube-scheduler は Pod を 1 つずつ正しく置くが、4 つ全部が置けるかは誰も確かめない 図のとおり、どの部品も自分の仕事を正しくこなしています。ジョブを扱うコントローラは 4 つの Pod を作り、kube-scheduler は GPU が空いているノードに Pod を 1 つずつ置きます。置けない Pod は Pending のまま待ちます。置かれた 2 つの worker は、そこで止まります。分散学習では、all-reduce のような集合通信を始める前に、全 worker が互いの居場所を確かめ合う rendezvous という集合の段階があります。4 つの GPU で学習するなら、4 つの worker が全員そろうまで、先に起動した worker はこの段階で待ち続けます。残り 2 つは Pending のままなので、全員はいつまでもそろいません。その間、置かれた 2 つは GPU を確保したまま 1 ステップも学習を進められず、設定した待ち時間を過ぎるとタイムアウトのエラーで終了します。再試行の設定によって Pod が作り直されても、空きが増えていなければ同じことを繰り返します。 問題は、4 つがそろうかをジョブ全体として判断する部品が、既定の設定の Kubernetes では動いていないことです。 HPC で広く使われる Slurm では、この状態はふつう起こりません。Slurm にはできて、追加の仕組みを入れていない素の Kubernetes にはできないことを、まず 5 つ並べ、D1 から D5 の ID を付けます。後の節では、各仕組みがどの ID を埋めるのかを、この ID で示します。 D1 から D5 は、どれもジョブ全体か利用者のアカウントを単位にした判断 D1 は、複数ノードのジョブを 1 つの単位として始めることです。Slurm は、要求したノードと GPU がすべてそろってから、それらを 1 つの割り当てとして確保してジョブを始めます。この割り当てを allocation と呼びます。素の Kubernetes は Pod を 1 つずつノードに置くので、一部だけ動いて残りが待つ状態が起こります。 D2 は、上限を超えたジョブを待ち行列で待たせることです。Slurm では、アカウントごとに GPU 数などの合計に上限を設けられます。この上限の 1 つが GrpTRES です。上限を超えるジョブも受け付けたうえで、PENDING のまま待たせます。Kubernetes の ResourceQuota は、上限を超える Pod の作成を断るだけで、順番を持った待ち行列にはなりません。 D3 は、大きなジョブを遅らせずに小さなジョブを先に流す backfill です。Slurm は、各ジョブに指定された実行時間の上限をもとに大きなジョブの開始予定時刻を計算し、それを動かさない範囲でだけ小さなジョブを先に始めます。素の Kubernetes には、ジョブ全体の開始予定時刻という考え方がありません。 D4 は、動いているジョブを止めて、作り直さずに再開することです。Slurm でプリエンプションの方式に suspend を選ぶと、ジョブのプロセスは一時停止され、resume するとそのまま続きから動きます。Kubernetes の Job を suspend すると、動いている Pod は削除されます。 D5 は、過去の使用量から優先度を決めることです。Slurm では、設定すれば fair-share を優先度の計算に入れられます。利用実績の記録をもとに、使いすぎた利用者の優先度を下げる仕組みです。Kubernetes の PriorityClass は、Pod ごとに決まった値を与えるだけです。 Slurm 側の D1 から D5 には、設定で有効にする機能も含まれます。5 つに共通するのは、判断の単位が Pod ではなく、ジョブ全体か利用者のアカウントだという点です。このうち D3 の backfill と D5 の fair-share は製品ごとの差が大きいため、この記事では D1、D2、D4 を中心に比べます。この記事では、Kubernetes の上でこの「ジョブ単位の判断」を後から足す Kueue・Volcano・KAI-Scheduler が、それぞれどこを受け持ち、内部がどうなっているのかを整理します。主張は公式ドキュメントで確認したものに限り、その場で出典を示します。Amazon EKS Distributed AI ワークショップの本に、Kueue を実際に使う章を追加しました。併せて読むと、手を動かして確かめられます。 全体像 Kueue は 2 番目の段を受け持つ。Volcano と KAI は 4 番目の段に入り、それぞれ待ち行列も持つ 名前 受け持ち 置き場所 kube-scheduler Pod を 1 つずつノードに置く Kubernetes の素の機能 Kueue ジョブ単位で、資源の枠に収まるかを決める kube-scheduler の手前 Volcano 対象に指定した Pod の配置を、kube-scheduler の代わりに担当する kube-scheduler と並ぶ選択肢 KAI-Scheduler 上と同じ位置で、GPU の分け合いや階層キューなど GPU クラスタ向けの機能が多い kube-scheduler と並ぶ選択肢 Slurm ノードの確保そのものがジョブの単位になっている Kubernetes とは別の世界 この図は、段ごとにどの製品が受け持つかを示したもので、全部を同時に入れる構成を示したものではありません。Kueue と Volcano・KAI-Scheduler は待ち行列の段で受け持ちが重なるので、この記事では基本的にどちらか一方を選ぶ構成として比べます。併用の余地も含めた関係は、各製品を見たあとの「Kueue と Volcano・KAI の関係」で整理します。 2 つの前提を確認したあと、この表の順に、仕組みと内部の構成を 1 つずつ見ていきます。 そもそも何を決める層なのか 分散ジョブを動かす仕組みは、2 つの違う問いに答えています。1 つは「このジョブを今動かしてよいか」、もう 1 つは「どの Pod をどのノードに置くか」です。この 2 つは別の問いで、同じ機構が両方を受け持つとは限りません。この区別を知らないと、Kueue と Volcano を同じ種類の仕組みだと誤解してしまいます。前者の「動かしてよいか」の判断を、Kueue では admission と呼びます。後に出てくる Volcano の admission webhook は、Kubernetes が API へのリクエストを検証・書き換えする仕組みのことで、Kueue の admission とは別物です。 gang scheduling とは 同期型の分散学習では、決まった数の worker がそろわないと学習が進みません。必要な数に足りないまま一部だけ動かしても、残りを待つ間、その分の資源が占有されるだけです。必要な数のタスクがそろうときだけ始め、そろわなければ始めないという考え方を、gang scheduling と呼びます。 素の Kubernetes の挙動 標準の kube-scheduler は、Pod を 1 つずつ見て置き場所を決めるだけです。ジョブという単位そのものを知らないので、4 つのうち 2 つだけ動いて残り 2 つを待つ状態が、何の異常もなく起こります。これが D1 の欠けている状態です。なお、kube-scheduler 自身にも PodGroup をまとめて置く gang scheduling が入りつつあります。v1.37 で Beta になりましたが既定では無効で、GenericWorkload の feature gate を有効にする必要があります。この記事では、既定の設定の kube-scheduler を前提にします。 どの部品も Pod を単位に動き、ジョブ全体をまとめて置く部品は無い kube-controller-manager の Job コントローラが Pod を作り、kube-scheduler が未配置の Pod を 1 つずつ選んでノードを決め、その結果を API server に書き戻します。kubelet は自分のノードに割り当てられた Pod を実行します。次に見る Kueue・Volcano・KAI-Scheduler は、kube-scheduler の手前に置くか、kube-scheduler の代わりに使うかして、ジョブ単位の扱いを持ち込む仕組みです。 4 つの部品の役割 部品 動く場所 何をするか この記事との関係 API server コントロールプレーン クラスタの状態を読み書きする唯一の窓口です。Job や Pod の定義を受け取って保存し、他の部品はすべてここを通して状態を見たり書いたりします。 Kueue の webhook も、ここに届いた作成のリクエストに割り込んで suspend: true を書き加えます。 kube-controller-manager コントロールプレーン 「あるべき状態」と「いまの状態」の差を埋める controller をまとめて動かすプロセスです。Job controller は、Job に書かれた数だけ Pod を作ります。 Pod を作る数は知っていますが、その Pod が全部置けるかは確かめません。 kube-scheduler コントロールプレーン まだノードが決まっていない Pod を 1 つずつ取り出し、空きと条件に合うノードを選んで、結果を API server に書き戻します。 判断の単位が Pod 1 つなので、D1 の状態が起こります。Volcano・KAI-Scheduler は、この部品の代わりに置き先を決めます。 kubelet 各ノード 自分のノードに割り当てられた Pod を API server から受け取り、コンテナを起動して動かし続けます。 置かれた worker が rendezvous で待ち続けるのは、kubelet が起動したコンテナの中です。 Kueue とは ! ここから先は、ノードを自動で足し引きする Karpenter を使う構成を前提にします。Karpenter は OSS ですが、AWS が開発を始めたもので、この記事では実質的に Amazon EKS を前提にした部品として扱います。そのため以降は、Amazon EKS Distributed AI ワークショップの本で今回実装した EKS 上の構成を前提に解説します。 Kueue が決めるのは、ジョブが Pod を作ってよい時期 Kueue は、ジョブが要求する GPU などの資源量を、quota と呼ぶあらかじめ決めた枠と比べ、枠に収まるジョブだけを通します。収まらないジョブは、Job や TrainJob のように suspend できるものなら、Pod を作らせずに待たせます。通した後にどのノードへ置くかは kube-scheduler が決めますが、Kueue は資源の種類と配置条件を表す ResourceFlavor で、候補のノードを絞れます。 ID で言うと、Kueue が埋めるのは D2 です。上限を超えたジョブを、Pod を作らせないまま待ち行列で待たせます。D1 については、ジョブ全体の資源量を枠と照合するところまでで、各 Pod をどこに置けるかは kube-scheduler が Pod ごとに判断します。D5 は、Admission Fair Sharing を設定すれば、同じ ClusterQueue を使う LocalQueue の間で過去の使用量を数え、通す順番に入れられます。D4 は埋めません。 Topology Aware Scheduling という機能を使うと、設定したラックやノードなどのトポロジーに沿って、Kueue が置き先の範囲を絞れます。waitForPodsReady は既定では無効です。有効にすると、通したジョブの全 Pod が決めた時間内に Ready にならないとき、Kueue はそのジョブを取り下げて待ち行列に戻し、戻すたびに次に通すまでの間隔を延ばします。上限回数を設定すれば、そこで打ち切ります。さらに blockAdmission を有効にすると、先に通したジョブの全 Pod が Ready になるまで、後続のジョブを通さなくなります。ただし、どちらも先のジョブの全 Pod を同時に置けることを保証する機能ではありません。 Kueue はジョブを止め、通すときに nodeSelector などを足すだけで、Trainer・kube-scheduler・Karpenter は変わらない 図は、ワークショップの本で実際に動かしている EKS 上の構成です。先に、図に出てくる Kueue の 3 つのオブジェクトを説明します。 オブジェクト 置き場所 役割 LocalQueue ジョブと同じ namespace 利用者がジョブを投入する窓口です。ジョブは kueue.x-k8s.io/queue-name ラベルで、投入先の LocalQueue の名前を指定します。LocalQueue 自身は枠を持たず、どの ClusterQueue に流すかだけを決めています。 ClusterQueue namespace に属さない、クラスタ全体のオブジェクト GPU などの枠を持ち、通してよいかを判断します。複数の namespace の LocalQueue が、同じ ClusterQueue を共有できます。 ResourceFlavor namespace に属さない、クラスタ全体のオブジェクト 枠の中身がどのノードの資源かを表します。この構成では、Karpenter の NodePool の名前をノードのラベルとして持たせています。 Kueue の実体は kueue-system namespace の kueue-controller-manager という 1 つの Deployment で、webhook とコントローラはこのプロセスの中にあります。CRD と webhook の設定はクラスタ全体に登録されますが、本で使っている Terraform の設定ファイル kueue.tf では、webhook が働く namespace を managed-by-kueue=true のラベルを付けたものだけに絞っています。TrainJob を 1 つ投入すると、図の番号の順に次のことが起きます。主体は動く部品、相手はその部品が手を入れる対象です。 番号 主体 相手 すること その後の状態 1 利用者 EKS の API server kueue.x-k8s.io/queue-name: gpu-ddp のラベルを付けた Kubeflow Trainer の TrainJob を kubectl apply で送る 作成のリクエストが API server に届く 2 Kueue の webhook API server が保存しようとしている TrainJob 作成を止めずに suspend: true を書き加える TrainJob は止まった状態で保存される。Trainer が作る JobSet も止まった状態のままで、Pod は作られない 3 kueue-controller-manager のジョブ用コントローラ 保存された TrainJob 必要な GPU や CPU の量をまとめた Workload を 1 つ作る Kueue が待ち行列で扱う単位ができる 4 kueue-controller-manager のスケジューラ LocalQueue を経て ClusterQueue に並んだ Workload いま使われている量に要求量を足し、ClusterQueue の ResourceFlavor ごとの枠に収まるかを判断する 収まらなければ TrainJob は止まったままで、枠が空くのを待つ 5 kueue-controller-manager 枠に収まった Workload の TrainJob Workload を admitted にし、ResourceFlavor の nodeLabels を nodeSelector として、tolerations があればそれも付けて、suspend を外す Workload は admitted、TrainJob は suspend: false になる 6 Trainer と JobSet・Job のコントローラ suspend が外れた TrainJob Trainer が、suspend の解除と 5 で付いた nodeSelector・tolerations を JobSet に伝え、JobSet と Job のコントローラが Pod を作る Pod ができ、置き先が未定のまま並ぶ 7 kube-scheduler 置き先が未定の Pod nodeSelector に合い、空きのあるノードに bind する。置けない Pod には Unschedulable の印を付ける 置けた Pod は動き出し、残りは Pending のまま 8 Karpenter Unschedulable の印が付いた Pod 条件に合う EC2 ノードを起動する ノードが Ready になると 7 に戻り、kube-scheduler が残りを bind する Kueue が手を入れるのは 2 から 5 までで、6 から 8 の Trainer・kube-scheduler・Karpenter の動き方は変わりません。止まっている TrainJob には Pod が無いので、Karpenter はそのためのノードを起動しません。この構成は、本の Kueue の章でも解説しています。 Volcano とは Volcano は、Kueue と違って配置そのものを担当します。schedulerName で Volcano を指定した Pod について、kube-scheduler の代わりに置き先を決めます。Volcano は、ジョブのタスクを 1 つずつ仮に割り当て、最低人数に届けばまとめて確定します。最低人数は、Volcano の Job である vcjob では minAvailable、Pod のまとまりを表す PodGroup では minMember という名前です。複数チームに資源を割り振る Queue というオブジェクトも持ちます。ID で言うと、Volcano は自分が配置する Pod について D1 と D2 を埋め、D4 は埋めません。D5 は、v1.16.0 で入った fairshare プラグインを設定すれば、同じ Queue を使う namespace の間で過去の使用量を数え、待っているジョブの順番に入れられます。 Volcano はキュー、gang、配置を自分の Pod について一通り持つ Volcano のサーバ側は、主に admission webhook・controller-manager・scheduler の 3 つです。admission webhook がジョブなどのリソースを検証・補完します。vcjob を使う場合は、controller-manager が最低人数を指定した PodGroup と、ジョブの Pod を作ります。scheduler は一定周期でセッションを開き、設定ファイルに書いた順に action を実行します。enqueue は Queue の空きを見て PodGroup をスケジューリング対象に進めてよいかを決め、allocate はタスクを 1 つずつ仮に割り当てます。preempt は優先度で場所を空け、reclaim は Queue の取り分を他の Queue から取り戻します。backfill は資源量を指定しない Pod を隙間に入れる機能で、Slurm の backfill とは別物です。Queue はプロセスではなく、テナントごとの重みと上限を書いたオブジェクトです。gang のプラグインは、最低人数を満たせば確定し、満たさなければ取り消すか、資源が空くのを待つ pipelined という中間状態に置きます。確定した割り当ては、Volcano の scheduler 自身が API server に反映します。 KAI-Scheduler とは KAI-Scheduler も、Volcano と同じく、kube-scheduler の代わりに配置を担当するスケジューラです。特徴は、1 枚の GPU を複数のジョブで分け合う機能を持つことです。チームごとの枠を何段にも入れ子にできる階層キューや、動いているジョブも含めて置き直し、空きをまとまった形にする consolidation も持っています。ID で言うと、D1 と D2 を埋め、D4 は埋めない点は Volcano と同じです。D5 は、Time Based Fairshare を有効にすれば、キューごとの過去の使用量を Prometheus に貯め、各キューの保証量を超えて使う分の取り分に反映できます。 配置を決める Deployment と、それを反映する Deployment が分かれている KAI-Scheduler では、pod-grouper という部品が PyTorchJob や JobSet などのワークロードから KAI-Scheduler 独自の PodGroup を作り、API server に書き込みます。これが gang の単位になります。scheduler は、階層キューの取り分と、Pod をなるべく少ないノードに詰める bin-packing を考えて置き先を決めます。そのうえで書くのは BindRequest というオブジェクトだけで、実際の bind はそれを読んだ別デプロイの binder が行います。この点は、スケジューラのプロセス自身が bind まで行う Volcano とは異なります。GPU を分け合うときは、既定のモードでは共有する GPU を専用の reservation Pod が押さえ、各 Pod が annotation で要求した割合はスケジューラが勘定します。このモードでは GPU メモリは分離されず、HAMi-core を使うモードを選ぶとメモリの上限がかかります。 Kueue と Volcano・KAI の関係 Kueue と Volcano・KAI-Scheduler は、受け持つ段の一部が重なる技術です。重なるのは待ち行列と枠、つまり D2 で、Volcano と KAI-Scheduler も自分の Queue を持ちます。重ならないのは配置で、Kueue は配置をしません。Kueue の公式ドキュメントは、Pod をノードに置くのは kube-scheduler の責任だとし、成熟した部品の機能を作り直さないことを設計の原則に挙げています。 そのため、この記事では代表的な構成として次の 2 通りを比べます。1 つは Kueue と kube-scheduler の組み合わせで、待ち行列を Kueue が、配置を kube-scheduler が受け持ちます。もう 1 つは Volcano か KAI-Scheduler を単体で使う方法で、待ち行列と配置の両方を 1 つの製品が受け持ちます。この 2 通りは同じ問題への別の答えなので、その意味では競合します。後の表で Kueue と Volcano・KAI を横に並べているのは、この 2 通りを比べているためです。 併用できないわけではありません。Kueue はジョブの開始を suspend などで止め、置き先の条件を足しますが、Pod をどのスケジューラが置くかは Pod の schedulerName で決まります。そのため、Kueue で通したジョブの配置を KAI-Scheduler に任せる構成は、仕組みの上では組めそうに見えます。ただし、待ち行列と枠を 2 か所で管理することになり、両者の枠をそろえる必要があります。2026 年 10 月 23 日の時点で、Kueue・Volcano・KAI-Scheduler のどのリポジトリのドキュメントにも、この組み合わせ方の手順は見つかりませんでした。 それでも EKS では、併用を考える理由が 1 つあります。KAI-Scheduler の設計提案によると、KAI-Scheduler は枠を超えて待たせているジョブでも Pod の作成を止めないので、Pod は作られ、Unschedulable の印が付きます。Karpenter はこの印を需要とみなすので、動かないジョブのために GPU ノードが立つことがあると、この文書は指摘しています。同じ文書は、Kueue と Volcano はこの Pod を Karpenter から隠すとも書いています。これを直す提案は、まだ Proposed の段階です。Karpenter と組むなら、ジョブを Pod の手前で止める Kueue を前に置く意味があるかもしれません。ただし Kueue が通したあとに KAI-Scheduler の枠で止まれば同じことが起きるので、両者の枠をそろえられるかが鍵になります。この組み合わせは、ワークショップの本の Advanced として今後確かめます。 Slurm とは Slurm の仕組みは「AWS ParallelCluster ワークショップ」でくわしく解説しているので、ぜひ参照してください。 Slurm は資源をジョブ単位で渡し、Kubernetes は Pod のまとまりを表すオブジェクトを足してそこへ近づける 資源の割り当てが先に 1 回で決まり、タスクはその中で始まる Slurm と Kubernetes 系の違い 表に入る前に 1 つ注意があります。Slurm が「gang scheduling」と呼ぶ機能は、ここまで使ってきた意味と強調点が違います。Slurm の gang scheduling は、同じノードを複数のジョブで時間を分けて使う機能で、1 つのジョブに属するタスクはまとめて一時停止・再開されます。この記事がここまで「全部そろうまで始めない」という起動の話をしてきたのに対し、Slurm のこの機能は「始めたあとの時間の分け方」の話で、D4 の suspend を使って成り立っています。 ID できること Slurm Kueue と kube-scheduler Volcano / KAI の単体 D1 最低人数がそろう場合だけ配置を確定する ◯ ジョブの単位そのもの △ ジョブ全体の資源量を quota と照合するが、各 Pod を置けるかは kube-scheduler が個別に判断する ◯ Volcano の minAvailable、KAI の batch-min-member D2 上限を超えたジョブを待ち行列で待たせる ◯ アカウントごとの上限と QOS ◯ ClusterQueue の quota ◯ Volcano の Queue、KAI の Queue D4 動いているジョブのプロセスを一時停止し、同じ状態で後から再開する ◯ scontrol suspend と resume ✕ Kueue が使う Job の suspend は動いている Pod を削除する ✕ Volcano の vcctl job suspend は Pod を削除し、再開時に Pod を作り直す。KAI は、対応する機能を公式ドキュメントで確認できず D4 の ✕ は、Kubernetes 側が劣っているとだけ読むと誤解します。Slurm の suspend はプロセスに停止のシグナルを送るだけで、止まったジョブはノードのメモリに残ったままだと、slurm.conf の PreemptMode の説明に書かれています。プロセスが生きている以上、GPU 上に確保したメモリも残ると考えられるので、止めても後から来たジョブがその GPU のメモリを使えるとは限りません。また Kubernetes では、spot の中断や Karpenter の縮小でノードそのものが消えるので、その場で止めた状態もノードと一緒に消えます。そのため Kubernetes では、学習コードが途中経過をチェックポイントとして共有のストレージに保存し、Pod を消されたら別のノードで続きから再開する方式が基本になります。Kueue が通したジョブを取り下げると Pod は消えるので、続きから再開できるかは、学習コードが途中経過を保存しているかで決まります。プロセスごと保存する仕組みとしては、kubelet の checkpoint API と、GPU の状態を保存する NVIDIA の cuda-checkpoint があります。ただし cuda-checkpoint は UVM メモリなど一部のメモリに対応していないため、この記事では扱いません。 この表の内容は、各製品のドキュメントを確認した時点のものです。バージョンが進むと変わる可能性があるため、最終判断は参考に挙げた一次資料の最新版で確認してください。 Kueue の最近のアップデート Kueue は、機能を足すマイナー版がおよそ 2 か月ごと、修正のパッチ版がおよそ毎週出ていて、2026 年 10 月 23 日の時点の最新は v0.20.3 です。ここでは、この記事の D1 の弱点に関わる、v0.20.0 の 2 つの機能を挙げます。どちらも alpha で既定は無効なので、使うには feature gate を有効にする必要があります。 機能 何に使えるか KueueDRADeviceFeasibility 通す前に、ノードごとに DRA のデバイスの空きを確かめ、置けないジョブのために quota を確保しないようにする。対象は DRA で GPU を要求するジョブで、device plugin の nvidia.com/gpu で要求するジョブや、CPU・メモリ・taint が原因で置けない場合は対象外 DynamicQuotaOrchestration 見つかった容量を、配下の Cohort と ClusterQueue に nominalQuota の比で配り、status.effectiveQuotas に書く。ノード数が変わるクラスタで、ClusterQueue の枠を実際の容量に追従させる どちらも、「Kueue が通しても、置けるとは限らない」という D1 の弱点に手を入れる変更です。何が困るのかを具体的に書くと、次の 2 つです。 1 つ目は、デバイスが足りないのに通してしまうことです。Topology Aware Scheduling を使わない構成では、Kueue は設定した枠からいま使われている量を引いた残りの数だけを見て、その GPU がどのノードにあるかは見ません。たとえば枠が 8 枚分残っていて、実際の空きも 8 枚あっても、それが 8 台のノードに 1 枚ずつ散らばっていれば、1 台に 8 枚を要求するジョブはどのノードにも置けません。それでも数の上では枠に収まるので、Kueue はジョブを通し、その 8 枚分の quota を確保します。Pod は Pending のまま動かず、確保された quota のせいで、本当は置ける別のジョブが待たされます。KueueDRADeviceFeasibility は、通す前にノードごとの空きを確かめることで、この取り違えを減らします。 2 つ目は、quota の数と実際の容量がずれることです。quota は人が書いた固定の数なので、spot の中断や Karpenter の縮小でノードが減っても、そのまま残ります。実際より大きな枠のまま通したジョブは、置き場所が無く Pending になります。逆にノードを増やしても枠を書き換えなければ、空いた GPU を使えません。DynamicQuotaOrchestration は、見つかった容量に合わせて枠を配り直すので、この書き換えを手でしなくて済むようになります。nominalQuota は、配分の比として引き続き人が決めます。 どちらもまだ alpha なので、本番で頼る前に、自分の Kueue の版と feature gate の状態を確かめてください。 なお、EKS で KueueDRADeviceFeasibility を使い、ノードの追加を Karpenter に任せるなら、Karpenter も DRA の要求を理解する必要があります。Karpenter は既定では DRA の要求を無視します。NVIDIA GPU を DRA で扱う DRANVIDIAGPU の feature gate は、v1.15 で alpha として入ったばかりで既定は無効です。有効にするには --ignore-dra-requests=false の設定も要ります。alpha で既定は無効なので、この記事では本番向けの構成には含めません。DRA を使う構成は、今後ワークショップの本で検証します。 まとめ Kueue は通してよいかだけを決めて D2 を埋め、配置は kube-scheduler に任せます。Volcano と KAI-Scheduler は配置そのものを担当し、D1 と D2 を自分の Pod について埋めます。Kueue と kube-scheduler の組み合わせと、Volcano か KAI-Scheduler の単体は、待ち行列の段で重なる別の選択肢です。ただし Karpenter と組む EKS では、Kueue を前に置いて KAI-Scheduler に配置を任せる併用も検討の余地があり、これは今後確かめます。D5 は、3 つとも設定すれば過去の使用量を優先度に入れられますが、数える単位と効く範囲は製品ごとに違います。今回確認した範囲では、どれも D4 のように止めたプロセスをそのまま再開することはできません。 Ray がどこに位置するのか気になった人もいるかもしれません。Ray は、ここまでの仕組みで資源が確保されてジョブが動き出したあと、その一段上の層で、確保済みの GPU にタスクを割り振る役目を担います。Ray の autoscaler を使う構成では、足りない分の worker Pod を Ray の側から要求することもあります。 参考 Kueue の概要 Kueue の Topology Aware Scheduling waitForPodsReady の設定 Volcano のコア概念 Volcano のアーキテクチャ KAI-Scheduler の README Slurm のクイックスタート Slurm のアーキテクチャ概要 Slurm の Gang Scheduling Slurm のリソース上限 Slurm のスケジューリング設定 (backfill) Slurm の preemption Slurm の Multifactor Priority Kubernetes の ResourceQuota Kubernetes の Job の suspend Kubernetes の Pod の優先度 kube-scheduler の仕組み Amazon EKS Distributed AI ワークショップ - Kueue を実際に使う章を追加しています
llm-d vs Plain vLLM: A Practical Kubernetes Inference Quickstart A single model server can look healthy while users wait longer with every burst of traffic. Add replicas behind a generic load balancer and a subtle problem appears: requests that could reuse a model's cached prompt may land on a different replica, which must process that context again. Background agent loops can also compete with interactive requests for the same serving capacity. That is the problem llm-d 1.0 is designed to address. Released on October 20, 2026, llm-d adds a Kubernetes-native coordination layer around inference servers such as vLLM. Its focus is not replacing vLLM. It is coordinating a fleet of model servers with cache-aware routing, traffic control, and production operations. This guide explains when plain vLLM is enough, what llm-d adds, and how to follow the project's quickstart to deploy a small test configuration. The commands and configuration below are based on the llm-d project's published guide; no independent deployment or benchmark is reported here. First, separate the model server from the fleet manager vLLM serves model requests. It provides an inference engine and an API server for supported models. If one instance serves your workload and the surrounding platform already handles availability and scaling, adding a separate orchestration layer may not help. llm-d coordinates model servers across Kubernetes. The project's vLLM integration guide describes it as a framework that coordinates a fleet of vLLM instances. Its features include prefix-aware routing, distributed KV-cache management, prefill/decode disaggregation, and wide expert parallelism. The llm-d project is built around the Kubernetes Gateway API Inference Extension and operates at the cluster-serving layer. The short comparison: - Plain vLLM: a simpler choice for one server, a small deployment, or a workload where generic routing is sufficient. - vLLM behind llm-d: worth evaluating when several replicas share traffic, prompts repeat, tenants need different service priorities, or Kubernetes is already the operational home for model serving. - Neither choice guarantees lower cost or latency. Model, hardware, concurrency, prompt reuse, and configuration determine the result. Benchmark with representative requests. What changed in llm-d 1.0 The release announcement presents 1.0 as an operational milestone, with three capabilities especially relevant to platform teams. Cache-aware routing. Instead of treating every replica as interchangeable, llm-d can route a request toward a replica that has a reusable prefix in its KV cache. That matters when requests share long system prompts, document context, or agent history. Reuse is an opportunity, not a promise: the benefit depends on cache retention, request similarity, and whether the selected replica has capacity. Multi-tenant flow control. llm-d 1.0 adds priority- and fairness-aware flow control. The project describes separate queues by flow and priority, fair dispatch among tenants at a priority level, and load shedding when demand exceeds available capacity. The operational goal is to prevent a noisy background workload from overwhelming latency-sensitive traffic. Operators still need to define sensible priorities, quotas, and service objectives. Safer operations. The maintainers describe router high availability and rolling updates, including coordinated management for disaggregated prefill and decode deployments. This matters because a serving system is not production-ready just because it returns tokens once. Upgrades, restarts, and partial failures are part of normal operation. These capabilities add moving parts. A feature-rich router is not automatically better than a direct vLLM service. Use it when the traffic pattern or operational requirement justifies the layer. A practical first deployment The official quickstart is an end-to-end test of an optimized baseline in standalone mode. It uses Helm for the llm-d router and Kubernetes manifests for the model server. Its current default deploys eight replicas of Qwen/Qwen3-32B on NVIDIA GPUs, so this is not a laptop experiment: plan for substantial compatible GPU-backed Kubernetes capacity and the associated cost. The project also lists alternative configurations for other hardware, with their own model and capacity requirements. The quickstart also downloads model weights, so startup can take several minutes. Check the current project guide before running commands because chart versions, model defaults, and hardware-specific examples can change. 1. Check prerequisites and choose a test cluster You need a Kubernetes cluster, kubectl, Helm, and enough compatible accelerator capacity for the selected model server. The guide includes other model-server configurations for AMD, Intel, TPU, and CPU, but some alternatives use a different model. Confirm the exact model and hardware pairing in the repository before applying a configuration. Clone the repository and load its environment definitions: export branch="main" git clone https://github.com/llm-d/llm-d.git cd llm-d git checkout "$branch" export REPO_ROOT="$(realpath "$(git rev-parse --show-toplevel)")" source "$REPO_ROOT/guides/env.sh" export GUIDE_NAME="quickstart" export NAMESPACE="llm-d-quickstart" For a repeatable evaluation, record the commit you checked out, model ID, accelerator type, replica count, and relevant chart versions. A tutorial's main branch can change after publication, so pin and record the exact revision for a controlled test. 2. Install the inference API resources and create a namespace The quickstart installs the Gateway API Inference Extension custom resource definitions. It derives the manifest path from the repository's environment script: kubectl apply -f \ "https://github.com/kubernetes-sigs/gateway-api-inference-extension/${GAIE_URL}/v1-manifests.yaml" kubectl create namespace "$NAMESPACE" \ --dry-run=client -o yaml | kubectl apply -f - This is a cluster-level change for the API resources and a namespace-level change for the test deployment. Review the manifests and your cluster's change controls before applying them to a shared or production cluster. 3. Configure access to model weights The quickstart asks for a Hugging Face token in a Kubernetes secret named llm-d-hf-token, with the key HF_TOKEN. Keep the token out of shell history and logs where possible. Use your organization's secret-management practice for anything beyond a temporary test. kubectl create secret generic llm-d-hf-token \ --from-literal="HF_TOKEN=${HF_TOKEN}" \ --namespace "$NAMESPACE" \ --dry-run=client -o yaml | kubectl apply -f - Set HF_TOKEN in your environment through an approved secret-injection method first. Do not paste a real token into a shared terminal transcript or commit it to a values file. 4. Deploy the router and model servers The standalone router chart includes its proxy. The guide supplies its base and optimized-baseline values files, then applies the GPU vLLM model-server configuration: helm install "$GUIDE_NAME" \ "$ROUTER_STANDALONE_CHART" \ -f guides/recipes/router/base.values.yaml \ -f guides/optimized-baseline/router/optimized-baseline.values.yaml \ -n "$NAMESPACE" \ --version "$ROUTER_CHART_VERSION" kubectl apply -n "$NAMESPACE" \ -k guides/optimized-baseline/modelserver/gpu/vllm/base/ Wait for the model-server pods to become ready before sending a request: bash "$REPO_ROOT/guides/scripts/wait-for-pods-ready.sh" \ -l llm-d.ai/guide=optimized-baseline \ -n "$NAMESPACE" A pod being scheduled is not the same as a model being ready. Check pod events and logs if the wait takes longer than expected. Weight downloads, accelerator allocation, image pulls, and model initialization can all affect startup time. 5. Send a request through the router The quickstart retrieves the router service's cluster IP and sends a completion request from a temporary pod inside the cluster: export IP="$(kubectl get service "${GUIDE_NAME}-epp" \ -n "$NAMESPACE" \ -o jsonpath='{.spec.clusterIP}')" kubectl run curl-debug --rm -it \ --image=cfmanteiga/alpine-bash-curl-jq \ --namespace="$NAMESPACE" \ --env="IP=$IP" \ -- /bin/bash Inside that temporary shell: curl -X POST "http://${IP}/v1/completions" \ -H 'Content-Type: application/json' \ -d '{ "model": "Qwen/Qwen3-32B", "prompt": "Explain what a Kubernetes readiness probe checks." }' This confirms the request path works. It does not establish that llm-d is faster or cheaper than direct vLLM. The quickstart is a deployment and verification path, not a benchmark result. Make the comparison fair A useful evaluation has two configurations: your current baseline and the candidate deployment. Keep the model, accelerator type, precision, prompt set, output length, concurrency, and warm-up policy consistent. Measure both cold-start and steady-state behavior separately. Track at least: - Time to first token and end-to-end latency, including high percentiles - Input and output tokens per second under realistic concurrency - GPU or accelerator utilization and memory use - Cache hit behavior for repeated prefixes, if that is the feature being tested - Error rate, queue depth, and behavior during traffic bursts - Infrastructure cost for the full serving path, including router and supporting services - Upgrade and recovery behavior, not just a successful steady-state run Use a mix of repeated-context interactive requests and unrelated requests. If every test prompt is identical, the cache may look unusually favorable. If no prompts share context, a prefix-cache optimization has little opportunity to help. Include both patterns if both occur in production. The llm-d project points to Prism and its benchmark recipes for examining published configurations and comparing runs. Treat vendor or project benchmarks as a starting point. Reproduce the configuration and traffic pattern that resemble your own service before making a deployment decision. When should you stay with plain vLLM? Stay with a simpler deployment if one replica meets the service objective, traffic is predictable, prompts rarely share context, and your existing load balancer and autoscaler handle the service adequately. A separate coordination layer increases the number of components to configure, observe, secure, and upgrade. Evaluate llm-d if you operate a fleet and can identify a measurable limitation: cache locality is poor, interactive traffic is hurt by batch or agent bursts, model pools need priority control, or you need coordinated distributed inference capabilities. Start with the optimized baseline and add one feature at a time. That helps answer whether a specific capability improved your service rather than attributing every change to the platform as a whole. A short decision checklist Before a pilot, answer these questions: - Traffic: Do requests repeat long prefixes, arrive in bursts, or mix interactive and background work? - Scale: Do you need multiple model-server replicas or pools? - Platform fit: Is Kubernetes already where your team runs and operates inference? - Capacity: Is the required accelerator capacity available for a representative test? - Measurement: Can you compare latency, throughput, cache behavior, and full infrastructure cost fairly? - Operations: Who will own router health, model-server upgrades, alerting, and incident response? - Rollback: Can you switch traffic back to the existing vLLM path if the pilot misses its objectives? The key is to define a pass/fail criterion before deployment. For example, require a target improvement in p95 time-to-first-token under a real request mix, without exceeding a defined cost or error-rate budget. Pick thresholds that match your service; there is no universal number that makes llm-d worthwhile. The takeaway llm-d 1.0 makes the most sense as a fleet coordination layer for teams serving open models on Kubernetes. It adds routing, cache, flow-control, and operational capabilities around inference engines such as vLLM. It is not a drop-in speed switch, and the project's reported performance results are tied to particular hardware and workloads. For a single model server, plain vLLM may remain the better engineering choice. For a multi-replica fleet with shared context, mixed priorities, and real operational requirements, llm-d is now a concrete stack to evaluate. Start with the official quickstart, measure your own workload, and only keep the extra layer if the results justify its complexity. I write about practical AI infrastructure and developer tools. Subscribe for future implementation guides and evidence-based comparisons. Have you operated a multi-replica vLLM deployment? What would make you add a routing layer: cache reuse, latency under bursts, or simpler operations? Sources - llm-d v1.0 release notes - llm-d official quickstart - vLLM documentation: llm-d integration - CNCF project page for llm-d - Google Cloud: llm-d 1.0 overview