kubernetes-sigs

Stars and mentions

Last 30 days, Oct 4 – Nov 2

GitHub stars388
mentions42
09182736Oct 4Oct 18Nov 2
UTC days · repositories kubernetes-sigs owns16 repositories

Package downloads

Cumulative recorded downloads · since 2025-10-21

npm—
PyPI4.4M
01.1M2.2M3.3M4.4MOct 4Oct 18Nov 2
Oct 24npm: 0/1 packages observed · PyPI: 1/1 packages observed

Across 2 known packages in repositories kubernetes-sigs owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete. PyPI excludes known mirrors.

Top repositories

kubernetes-sigs’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside kubernetes-sigs’s account where kubernetes-sigs opened or reviewed pull requests in the past 365 days.

Who noticed kubernetes-sigs

Everyone who starred or forked kubernetes-sigs’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

42posts
Nov 30 posts
  1. How many of the agent developers are using this for their agents? kubernetes-sigs/agent-sandbox

    2 likes · 174 views
  2. 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 支援

  3. 單機 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

  4. 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
  5. 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 ↩︎

  6. 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 を実際に使う章を追加しています

  7. 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
  8. CoreDNS の Kubernetes プラグインで、Service のドメイン名がクラスタ内で名前解決できるまで はじめに 前回の記事 Deployment を apply してから Pod が Ready になるまで では、Deployment を kubectl apply してから、Pod が Ready になり、Service 経由でトラフィックを受けられる状態になるまでの流れを整理しました。 この記事はその続編として、Service が Ready な Pod を Endpoint として持つようになった後、クラスタ内の Pod からその Service のドメイン名 (<service>.<namespace>.svc.cluster.local のような名前) で名前解決できるようになるまでの流れを整理します。 1. kubelet が Pod の DNS 設定 (/etc/resolv.conf 相当) をどう組み立てるか 2. CoreDNS がプラグインチェーンとしてどう構成され、どの順序で実行されるか 3. CoreDNS の kubernetes プラグインが Service の情報からどう A レコードを組み立てるか 4. クラスタ外のドメインへのクエリが、どのようにして上流 DNS へ転送されるか 5. 実際の運用クラスタでよく使われる Corefile の設定例と、それぞれの設定値が何のためにあるか 6. Pod から接続先を指定する際に、名前解決のクエリ数を抑えるための Tips 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |:---|:---|:---|:---| | Kubernetes | v1.37.1 | kubernetes/kubernetes | kubelet による Pod への DNS 設定注入、kubeadm による kube-dns Service・CoreDNS Deployment の生成 | | CoreDNS | v1.14.6 | coredns/coredns | プラグインチェーンの構築、kubernetes プラグインによる名前解決、forward/cache 等の周辺プラグイン | | kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージは kindest/node:v1.37.0 | kind は各ノードコンテナ内で実際に kubeadm init を実行してクラスタを構築します。 // pkg/cluster/internal/create/actions/kubeadminit/init.go args := []string{ // init because this is the control plane node "init", // specify our generated config file "--config=/kind/kubeadm.conf", "--skip-token-print", // increase verbosity for debugging "--v=6", } (参考: init.go#L72-L78) そのため、1.2節で説明する kubeadm の DNS アドオン生成ロジックは、kind クラスタでの検証結果とそのまま対応します。 TL;DR - kubelet は、Pod ごとに /etc/resolv.conf を組み立てて注入します。 nameserver はクラスタ DNS の ClusterIP に固定され、ndots:5 がデフォルトで設定されるため、ドット数の少ない短い名前のクエリは search ドメインを総当たりします - CoreDNS は Caddy をベースにしたプラグインチェーン型の DNS サーバーです。 実行順序は Corefile に書いた記述順ではなく、CoreDNS 本体のビルド時に固定された順序で決まります - kubernetes プラグインは Service・EndpointSlice を informer で watch し、A レコードを組み立てます。 通常の Service は ClusterIP を1件返し、Headless Service は EndpointSlice の各アドレスを列挙します - クラスタ内のゾーンにマッチしないクエリは forward プラグインで上流 DNS へ転送されます。 CoreDNS 自身も dnsPolicy: Default で動くため、loop プラグインが自己転送ループを検知する安全装置になっています - 接続先の指定方法によって、クラスタ内の名前解決にかかるクエリ数は大きく変わります。 別 Namespace の Service へは完全な名前より <service>.<namespace> の方がクエリ数を抑えられ、外部ドメインしか解決しない Pod では dnsPolicy: Default にして CoreDNS を経由しない選択肢もあります CoreDNS の概要 Kubernetes が定めているのは、Service/Pod のドメイン名をどう解決すべきかという DNS ベースのサービスディスカバリの仕様であり、それを満たす実装自体は特定の1つに限定していません。 > This document is a specification for DNS-based Kubernetes service discovery. While service discovery in Kubernetes may be provided via other protocols and mechanisms, DNS is very commonly used and is a highly recommended add-on. The actual DNS service itself need not be provided by the default Kube-DNS implementation. (参考: kubernetes/dns: DNS-Based Service Discovery) この仕様を満たす実装として、かつては kube-dns が使われていましたが、Kubernetes 1.11 以降は CoreDNS が広く使われるようになりました。CoreDNS は Kubernetes プロジェクトが管理する実装ではなく、Kubernetes とは別に CNCF がホストする独立したプロジェクト (graduated project) です。 > CoreDNS is sister CNCF project and is the successor to SkyDNS, on which kube-dns is based... As an independent project, it is more actively developed than kube-dns... (参考: KEP-427: CoreDNS) CoreDNS がデフォルトになったのも Kubernetes プロジェクト全体の決定ではなく、kubeadm チームが 1.11 から選んだ結果です。 > In fact, the kubeadm team chose to make it the default option starting with Kubernetes 1.11. (参考: CoreDNS GA for Kubernetes Cluster DNS) この記事の対象である Kubernetes v1.37 のリリースでは、kube-dns サブプロジェクト自体の retire (引退) が正式にアナウンスされています。 > CoreDNS has been the default cluster DNS add-on since Kubernetes v1.13, and kube-dns has not kept pace since then; features like EndpointSlices and dual-stack Services aren't available in it. Kubernetes has already retired the kube-dns subproject... It is expected that no new packages will be built for kube-dns after v1.40. (参考: Kubernetes v1.37: Garhwal) CoreDNS 自体は、Go で書かれたプラグインチェーン方式の DNS サーバーです。README では次のように説明されています。 > CoreDNS is a DNS server/forwarder, written in Go, that chains plugins. Each plugin performs a (DNS) function. (参考: coredns/coredns README) ファイルベースのゾーン配信・キャッシュ・ロードバランシング・DNSSEC・etcd や Kubernetes をバックエンドにした動的なレコード生成など、多数の機能をプラグインとして提供しています。クラスタ内の名前解決に使われるのは、このうちの kubernetes プラグインです。 設定ファイルは Corefile と呼ばれ、どのプラグインをどのゾーンに適用するかを記述します。この Corefile のパースとプラグインチェーンの構築には、Web サーバー Caddy からフォークされた github.com/coredns/caddy が使われており (go.mod#L19)、1.3節で見る「プラグインの実行順序が固定である」という仕組みもここに由来します。この記事では、kubeadm が CoreDNS をデプロイする場合の構成 (Corefile を含む ConfigMap と Deployment を kube-system Namespace に coredns という名前で生成する構成) を前提に説明します。 公式ドキュメントでも、kubelet が Service 名で名前解決できるように Pod の DNS を設定し、CoreDNS がその情報を元に応答することが説明されています。 > Kubernetes publishes information about Pods and Services which is used to program DNS. kubelet configures Pods' DNS so that running containers can look up Services by name rather than IP. (参考: DNS for Services and Pods) 以降の章では、この「kubelet が Pod の DNS を設定する」部分と、「CoreDNS がその情報を元に応答する」部分を、それぞれソースコードから確認していきます。 第1章: kubernetes プラグインが Service のドメイン名を解決できるようになるまで この章では、kubelet が Pod に DNS 設定を注入してから、CoreDNS の kubernetes プラグインが Service のドメイン名を A レコードとして返すまでの流れを説明します。 sequenceDiagram participant KUBELET as kubelet participant POD as Pod (アプリケーションコンテナ) participant CD as CoreDNS (kubernetes プラグイン) participant API as kube-apiserver KUBELET->>POD: /etc/resolv.conf を生成 (nameserver=ClusterIP, search, ndots:5) API-->>CD: Service/EndpointSlice/Namespace の ADD/UPDATE イベント (informer) CD->>CD: informer のローカルキャッシュを更新 POD->>CD: nginx (ndots:5 により search ドメインが展開される) CD->>POD: nginx.default.svc.cluster.local の A レコードを応答 1.1 kubelet が Pod に DNS 設定を注入する kubelet は Pod の dnsPolicy (デフォルトは ClusterFirst) を見て、コンテナに渡す DNS 設定の種別を決めます。dnsPolicy には次の4種類があります。 | dnsPolicy | 概要 | |:---|:---| | ClusterFirst (デフォルト) | hostNetwork でない Pod はクラスタ DNS を優先して使う。hostNetwork の Pod では Default にフォールバックする | | ClusterFirstWithHostNet | hostNetwork の Pod でもクラスタ DNS を優先して使う | | Default | kubelet が稼働するノードの /etc/resolv.conf をそのまま使う | | None | DNS 設定を空にする。nameserver・search 等は Pod の dnsConfig フィールドで明示的に指定する | // staging/src/k8s.io/api/core/v1/types.go const ( // DNSClusterFirstWithHostNet indicates that the pod should use cluster DNS // first, if it is available, then fall back on the default // (as determined by kubelet) DNS settings. DNSClusterFirstWithHostNet DNSPolicy = "ClusterFirstWithHostNet" // DNSClusterFirst indicates that the pod should use cluster DNS // first unless hostNetwork is true, if it is available, then // fall back on the default (as determined by kubelet) DNS settings. DNSClusterFirst DNSPolicy = "ClusterFirst" // DNSDefault indicates that the pod should use the default (as // determined by kubelet) DNS settings. DNSDefault DNSPolicy = "Default" // DNSNone indicates that the pod should use empty DNS settings. DNS // parameters such as nameservers and search paths should be defined via // DNSConfig. DNSNone DNSPolicy = "None" ) (参考: types.go#L3963-L3982) この記事では、通常の Pod (hostNetwork でない) がデフォルトの ClusterFirst で動作する場合を扱います。dnsPolicy からどの DNS 設定を使うかを判定する実装は次の通りです。 // pkg/kubelet/network/dns/dns.go func (c *Configurer) getPodDNSType(pod *v1.Pod) (podDNSType, error) { dnsPolicy := pod.Spec.DNSPolicy switch dnsPolicy { case v1.DNSNone: return podDNSNone, nil case v1.DNSClusterFirstWithHostNet: return podDNSCluster, nil case v1.DNSClusterFirst: if !kubecontainer.IsHostNetworkPod(pod) { return podDNSCluster, nil } // Fallback to DNSDefault for pod with hostNetwork and DNSClusterFirst fallthrough case v1.DNSDefault: return podDNSHost, nil } ... } (参考: dns.go#L304-L323) DNSClusterFirst (hostNetwork でない通常の Pod) は podDNSCluster に分類され、GetPodDNS の中でクラスタ DNS 向けの設定が組み立てられます。 // pkg/kubelet/network/dns/dns.go case podDNSCluster: if len(c.clusterDNS) != 0 { // For a pod with DNSClusterFirst policy, the cluster DNS server is // the only nameserver configured for the pod. The cluster DNS server // itself will forward queries to other nameservers that is configured // to use, in case the cluster DNS server cannot resolve the DNS query // itself. dnsConfig.Servers = []string{} for _, ip := range c.clusterDNS { dnsConfig.Servers = append(dnsConfig.Servers, ip.String()) } dnsConfig.Searches = c.generateSearchesForDNSClusterFirst(dnsConfig.Searches, pod) dnsConfig.Options = defaultDNSOptions break } // clusterDNS is not known. Pod with ClusterDNSFirst Policy cannot be created. ... // Fallback to DNSDefault. fallthrough case podDNSHost: (参考: dns.go#L402-L422) ポイントは、nameserver をノード本来の /etc/resolv.conf に書かれたものに追加するのではなく、c.clusterDNS (クラスタ DNS の ClusterIP) だけで 置き換えている ことです。コメントにある通り、クラスタ DNS 自身が外部ドメインへのクエリを上流に転送する前提になっています。 search ドメインは generateSearchesForDNSClusterFirst で組み立てます。 // pkg/kubelet/network/dns/dns.go func (c *Configurer) generateSearchesForDNSClusterFirst(hostSearch []string, pod *v1.Pod) []string { if c.ClusterDomain == "" { return hostSearch } nsSvcDomain := fmt.Sprintf("%s.svc.%s", pod.Namespace, c.ClusterDomain) svcDomain := fmt.Sprintf("svc.%s", c.ClusterDomain) clusterSearch := []string{nsSvcDomain, svcDomain, c.ClusterDomain} return omitDuplicates(append(clusterSearch, hostSearch...)) } (参考: dns.go#L165-L175) <namespace>.svc.<cluster-domain> → svc.<cluster-domain> → <cluster-domain> の順で search リストを作り、ノード本来の search ドメイン (hostSearch) をその後ろに付け足しています。ndots:5 はデフォルト値として別途定義されており、これがそのまま dnsConfig.Options に代入されます。 // pkg/kubelet/network/dns/dns.go var ( // The default dns opt strings. defaultDNSOptions = []string{"ndots:5"} ) (参考: dns.go#L42-L45) なぜ5なのかについては、Kubernetes の共同創業者である Tim Hockin 氏が、このデフォルト値の由来を議論した issue で段階的に説明しています。 > 3) Services are the vast majority of lookups so class "svc" must be in the search path and ndots must be >= 1 (e.g. name must resolve) > ... > 6) Because of (4), we don't really want apps to hardcode the FQDN (bad for portability), so svc.$zone must be in the search path and ndots must be >= 2 (e.g. kubernetes.default must resolve) > 7) Because of (2) and (4), same-namespace and cross-namespace lookups of non-service names should be easy. Therefore $zone must be in the search path and ndots must be >= 3 (e.g. name.namespace.svc must resolve). > 8) Because of (1) and (4), and the fact that petsets have per-endpoint names, local and cross-namespace petnames should be easy. Given the previous search paths and ndots >= 4, we can ensure that petname.service.namespace.svc resolves. > 9) We also support SRV records of the form _$port._$proto.$service.$namespace.svc.$zone. Given (6) and (2), we must enable _$port._$proto.$service.$namespace.svc to resolve. That requires ndots = 5. > > This explains how we got to ndots = 5. (参考: kubernetes/kubernetes#33554) 同じ namespace の Service 名のみでの解決 (ndots >= 1) から始まり、svc.$zone の省略 (ndots >= 2)、$zone の省略 (ndots >= 3)、StatefulSet (コメント当時の名称は petset) の Pod 単位の名前解決 (ndots >= 4)、最後に SRV レコードの解決 (ndots = 5) まで、サポートしたい省略形を1つずつ積み上げた結果として5に行き着いたという経緯です。同じコメントでは、この先の _$port._$proto.$petname.$service.$namespace.svc.$zone という最悪ケースまで含めると本来は ndots = 6 が必要になる点や、それでも6まで上げなかった理由にも触れられています。 クラスタ DNS の IP アドレスとクラスタドメインは、kubelet 起動時のフラグ (--cluster-dns、--cluster-domain) から渡されます。 // cmd/kubelet/app/options/options.go fs.StringVar(&c.ClusterDomain, "cluster-domain", c.ClusterDomain, "Domain for this cluster. If set, kubelet will configure all containers to search this domain in addition to the host's search domains") ... fs.StringSliceVar(&c.ClusterDNS, "cluster-dns", c.ClusterDNS, "Comma-separated list of DNS server IP address. This value is used for containers DNS server in case of Pods with \"dnsPolicy=ClusterFirst\". Note: all DNS servers appearing in the list MUST serve the same set of records otherwise name resolution within the cluster may not work correctly. There is no guarantee as to which DNS server may be contacted for name resolution.") (参考: options.go#L434、options.go#L437) これらは KubeletConfiguration のフィールドとしてバインドされ、 // pkg/kubelet/apis/config/types.go // clusterDomain is the DNS domain for this cluster. If set, kubelet will // configure all containers to search this domain in addition to the // host's search domains. ClusterDomain string // clusterDNS is a list of IP addresses for a cluster DNS server. If set, // kubelet will configure all containers to use this for DNS resolution // instead of the host's DNS servers. ClusterDNS []string (参考: types.go#L203-L211) kubelet 起動時に IP としてパースされたうえで dns.Configurer に渡されます。 // pkg/kubelet/kubelet.go clusterDNS := make([]net.IP, 0, len(kubeCfg.ClusterDNS)) for _, ipEntry := range kubeCfg.ClusterDNS { ip := netutils.ParseIPSloppy(ipEntry) if ip == nil { logger.Info("Invalid clusterDNS IP", "IP", ipEntry) } else { clusterDNS = append(clusterDNS, ip) } } (参考: kubelet.go#L606-L613、dns.NewConfigurer の呼び出しは kubelet.go#L642) 公式ドキュメントも同様の仕組みを説明しており、ここでの /etc/resolv.conf の例は実装とそのまま対応します。 > DNS queries may be expanded using the Pod's /etc/resolv.conf. kubelet configures this file for each Pod. For example, a query for just data may be expanded to data.test.svc.cluster.local. The values of the search option are used to expand queries. nameserver 10.32.0.10 search <namespace>.svc.cluster.local svc.cluster.local cluster.local options ndots:5 (参考: DNS for Services and Pods#Namespaces of Services) 1.2 kube-dns Service が CoreDNS の前段に立つ kubelet に渡す --cluster-dns の値は、CoreDNS の前段に立つ kube-dns という名前の Service の ClusterIP です。この Service や CoreDNS の Deployment/ConfigMap 自体は Kubernetes 本体 (kube-controller-manager など) が管理するものではなく、kubeadm のようなクラスタ構築ツールが生成します。kind も内部で kubeadm init を使うため (対象コンポーネントとバージョンの節を参照)、ここで見るロジックは kind クラスタにもそのまま適用されます。 // cmd/kubeadm/app/constants/constants.go // GetDNSIP returns a dnsIP, which is 10th IP in svcSubnet CIDR range func GetDNSIP(svcSubnetList string) (net.IP, error) { svcSubnetCIDR, err := GetKubernetesServiceCIDR(svcSubnetList) ... dnsIP, err := netutils.GetIndexedIP(svcSubnetCIDR, 10) ... return dnsIP, nil } (参考: constants.go#L676-L690) kubeadm は ClusterIP を Service CIDR の固定10番目のアドレスとして機械的に計算します。デフォルトの --service-cluster-ip-range=10.96.0.0/12 であれば 10.96.0.10 になります。Service 名が coredns ではなく kube-dns で固定されているのは、CoreDNS の概要で触れた通り kubeadm が Kubernetes 1.11 から kube-dns アドオンに代えて CoreDNS をデフォルトにした経緯による互換名で、--cluster-dns に渡す Service 名・ClusterIP を変えずに移行できるようにするためのものです。 kubeadm が生成する Corefile のゾーンには、kubelet 側の --cluster-domain と一致するクラスタドメイン (デフォルト cluster.local) が指定されています。 kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure fallthrough in-addr.arpa ip6.arpa } (参考: manifests.go#L163-L192) 1.3 CoreDNS のプラグインチェーンが構築される仕組み CoreDNS のプラグインは、共通の Handler インターフェースを実装します。 // plugin/plugin.go Handler interface { ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) Name() string } (参考: plugin.go#L51-L54) 各プラグインの setup.go は、Corefile のパース時に AddPlugin で自分自身をチェーンに登録します。kubernetes プラグインの例です。 // plugin/kubernetes/setup.go dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { k.Next = next return k }) (参考: setup.go#L53-L56) ポイントは、ここで登録される順序が Corefile に記述した順序ではない ということです。実行順序は CoreDNS のビルド時に固定される Directives というスライスで決まっており、ソースコードのコメントにもその意図が明記されています。 // core/dnsserver/zdirectives.go // Directives are registered in the order they should be // executed. // // Ordering is VERY important. Every plugin will // feel the effects of all other plugin below // (after) them during a request, but they must not // care what plugin above them are doing. var Directives = []string{ (参考: zdirectives.go#L5-L12、全体は zdirectives.go#L1-L72) このファイルはリポジトリ直下の plugin.cfg から自動生成されるもので、真の実行順序の定義元は plugin.cfg の行順です。つまり、Corefile 内でユーザーがどの順番にプラグインを並べて書いても、CoreDNS 内部での実行順序は変わりません。 登録されたプラグインは、NewServer の中で 末尾から 折り畳まれてチェーンになります。 // core/dnsserver/server.go // compile custom plugin for everything var stack plugin.Handler for i := len(site.Plugin) - 1; i >= 0; i-- { stack = site.Plugin[i](stack) // register the *handler* also site.registerHandler(stack) ... } site.pluginChain = stack (参考: server.go#L110-L136) site.Plugin は Directives の順序どおりに並んでいるため、末尾から畳み込むことで「最初に実行されるべきプラグイン (errors など) が一番外側のハンドラになり、残りのチェーンを Next として保持する」という入れ子構造が作られます。実際にクエリを受けたときは、このチェーンの先頭を1回呼ぶだけです。 // core/dnsserver/server.go if r.Question[0].Qtype != dns.TypeDS { rcode, _ := h.pluginChain.ServeDNS(ctx, w, r) if !plugin.ClientWrite(rcode) { errorFunc(s.Addr, w, r, rcode) } return } (参考: server.go#L335-L341) あとは各プラグインが、自分の処理が終わったタイミングで Next.ServeDNS (共通ヘルパーの plugin.NextOrFailure) を呼び出し、次のプラグインに処理を委譲していく、という仕組みです。 // plugin/plugin.go func NextOrFailure(name string, next Handler, ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { if next != nil { ... pw := &pluginWriter{ResponseWriter: w, plugin: next.Name()} return next.ServeDNS(ctx, pw, r) } return dns.RcodeServerFailure, Error(name, errors.New("no next plugin found")) } (参考: plugin.go#L76-L89) 1.4 Corefile 内の主なプラグイン (ディレクティブ) の一覧 実際の運用クラスタでよく使われる Corefile には、だいたい次のようなプラグインが並びます。これは一般的に Corefile へ書くときの並び順 (3章で見る設定例もこの順) であり、前節で見た通り、CoreDNS 内部での実際の実行順序とは別物です。詳しい設定値の解説は3章で扱うので、ここでは一覧として概要だけ押さえます。 | プラグイン (ディレクティブ) | 概要 | |:---|:---| | errors | 処理中に発生したエラーを標準出力にログとして出力する | | health | /health エンドポイントで liveness probe に応答する | | ready | /ready エンドポイントで readiness probe に応答する (他プラグインの準備状況を集約する) | | kubernetes | Kubernetes の Service・EndpointSlice・Namespace の情報から DNS レコードを動的に生成する | | prometheus | :9153 で Prometheus 形式のメトリクスを公開する | | forward | 自身のゾーンにマッチしないクエリを上流の DNS サーバーへ転送する | | cache | 応答をメモリ上にキャッシュする | | loop | 自分自身への転送ループを検知してプロセスを終了させる | | reload | Corefile の変更を検知し、無停止で設定を再読込する | | loadbalance | 複数のレコードがある場合に応答の順序をランダム化する | | autopath | search ドメインの展開を CoreDNS 側で肩代わりする (3.11節で解説するオプション機能) | | log | クエリのログを標準出力へ出力する (3.12節で解説するオプション機能) | 実際の実行順序 (plugin.cfg の行順) は、この書き方の並びとは一致しません。 reload:reload ready:ready health:health prometheus:metrics errors:errors log:log loadbalance:loadbalance cache:cache autopath:autopath kubernetes:kubernetes loop:loop forward:forward (参考: plugin.cfg) 特に cache と autopath が kubernetes より先に実行される点は、それぞれ3.7節の disable denial/disable success と3.11節の autopath の動作に直結するので覚えておいてください。 1.5 kubernetes プラグインが Service のドメイン名を A レコードに変換する kubernetes プラグインの ServeDNS は、まずクエリされた名前が自分の担当ゾーン (Corefile で指定した cluster.local など) にマッチするかを確認します。 // plugin/kubernetes/handler.go func (k Kubernetes) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { state := request.Request{W: w, Req: r} qname := state.QName() zone := plugin.Zones(k.Zones).Matches(qname) if zone == "" { return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r) } ... (参考: handler.go#L13-L91、ゾーンマッチ判定は L17-L20) マッチした場合、クエリ名は parseRequest で <service>.<namespace>.svc.<zone> のようなパターンに分解されます。コメントに書かれている通り、パターンは4種類あります。 // plugin/kubernetes/parse.go // 4 Possible cases: // 1. _port._protocol.service.namespace.pod|svc.zone // 2. (endpoint): endpoint.service.namespace.pod|svc.zone // 3. (service): service.namespace.pod|svc.zone // 4. (endpoint multicluster): endpoint.cluster.service.namespace.pod|svc.zone (参考: parse.go#L31-L89、4パターンの説明は L32-L36) ゾーンに近い側のラベルから pod/svc → namespace → service の順に剥がしていき、残りがあれば port/protocol (SRV クエリ) または endpoint として解釈します。パースした結果から実データを引くのが Records で、Pod 向けなら findPods、Service 向けなら findServices が呼ばれます。 // plugin/kubernetes/kubernetes.go func (k *Kubernetes) Records(ctx context.Context, state request.Request, exact bool) ([]msg.Service, error) { ... if isPod { ... return k.findPods(r, zone) } ... return k.findServices(r, zone) } (参考: kubernetes.go#L332-L363) Service の情報は、kubernetes プラグインが自前で持つ informer から取得します。Service・Pod・EndpointSlice・Namespace のそれぞれに対して、CoreDNS 起動時に informer が登録されます。 // plugin/kubernetes/controller.go dns.svcLister, dns.svcController = object.NewIndexerInformer( cache.ToListWatcherWithWatchListSemantics( &cache.ListWatch{ ListFunc: serviceListFunc(ctx, dns.client, api.NamespaceAll, dns.selector), WatchFunc: serviceWatchFunc(ctx, dns.client, api.NamespaceAll, dns.selector), }, kubeClient, ), &api.Service{}, cache.ResourceEventHandlerFuncs{AddFunc: dns.Add, UpdateFunc: dns.Update, DeleteFunc: dns.Delete}, cache.Indexers{svcNameNamespaceIndex: svcNameNamespaceIndexFunc, svcIPIndex: svcIPIndexFunc, svcExtIPIndex: svcExtIPIndexFunc}, object.DefaultProcessor(object.ToService, nil), ) (参考: controller.go#L142-L154、EndpointSlice の watch は controller.go#L174-L190) EndpointSlice を直接 watch しているため、古い Endpoints API を経由せず、Service の変更はほぼリアルタイムで CoreDNS 側のローカルキャッシュに反映されます。 レコードの組み立ては findServices の中で、ClusterIP 型と Headless 型で明確に分岐しています。 // plugin/kubernetes/kubernetes.go // Endpoint query or headless service if svc.Headless() || r.endpoint != "" { if endpointsList == nil { endpointsList = endpointsListFunc() } for _, ep := range endpointsList { if object.EndpointsKey(svc.Name, svc.Namespace) != ep.Index { continue } for _, eps := range ep.Subsets { for _, addr := range eps.Addresses { ... s := msg.Service{Host: addr.IP, Port: int(p.Port), TTL: k.ttl} ... services = append(services, s) } } } continue } // ClusterIP service for _, p := range svc.Ports { ... for _, ip := range svc.ClusterIPs { s := msg.Service{Host: ip, Port: int(p.Port), TTL: k.ttl} ... services = append(services, s) } } (参考: kubernetes.go#L509-L560) 通常の ClusterIP 型 Service は、svc.ClusterIPs (固定の仮想 IP、通常1件) をそのまま A レコードとして返すだけです。一方 Headless Service では EndpointSlice の各アドレスを1レコードずつ列挙するため、Pod の数だけ A レコードが返ります。公式ドキュメントでも同様の違いが説明されています。 > [Headless Services](/docs/concepts/services-networking/service/#headless-services) (without a cluster IP) are also assigned DNS A and/or AAAA records, with a name of the form my-svc.my-namespace.svc.cluster-domain.example. Unlike normal Services, this resolves to the set of IPs of all of the Pods selected by the Service. (参考: DNS for Services and Pods#A/AAAA records) Pod 向けクエリ (findPods) では、Corefile の pods オプションで挙動が変わります。デフォルトの insecure モードは、Pod の実在を一切確認せず、クエリ名からハイフン区切りの IP アドレスを機械的に組み立てて返すだけです。 // plugin/kubernetes/kubernetes.go const ( // podModeDisabled is the default value where pod requests are ignored podModeDisabled = "disabled" // podModeVerified is where Pod requests are answered only if they exist podModeVerified = "verified" // podModeInsecure is where pod requests are answered without verifying they exist podModeInsecure = "insecure" ... (参考: kubernetes.go#L76-L82) insecure と verified で決定的に違うのは、API への Pod watch の有無です。Pod informer (podController) は verified モードのときだけ起動されます。 // plugin/kubernetes/kubernetes.go k.opts.initPodCache = k.podMode == podModeVerified (参考: kubernetes.go#L290、Pod informer の起動は controller.go#L443-L451 で initPodCache が真の場合のみ実行されます) つまり insecure (もしくは disabled) モードでは、クラスタ全体の Pod への API watch 自体が張られません。これが insecure モードが verified モードよりコストの低い実装である理由です。 1.6 実地検証: kind クラスタで Service のドメイン名を解決する 実際に kind でクラスタを作り、Deployment と Service を apply して、Pod からの名前解決を確認します。 kind create cluster --name coredns-demo --image kindest/node:v1.37.0 前回記事と同じ内容の Deployment と、そのための Service を apply します。 apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment namespace: default spec: replicas: 2 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.27 ports: - containerPort: 80 readinessProbe: httpGet: path: / port: 80 --- apiVersion: v1 kind: Service metadata: name: nginx namespace: default spec: selector: app: nginx ports: - port: 80 targetPort: 80 Service が Ready になると、EndpointSlice には Ready な Pod の IP が2件登録されます。 apiVersion: discovery.k8s.io/v1 kind: EndpointSlice metadata: name: nginx-8dcnm namespace: default labels: kubernetes.io/service-name: nginx addressType: IPv4 ports: - name: "" port: 80 protocol: TCP endpoints: - addresses: ["10.244.0.5"] conditions: {ready: true, serving: true, terminating: false} targetRef: {kind: Pod, name: nginx-deployment-56b9f4956-4vj5r, namespace: default} - addresses: ["10.244.0.2"] conditions: {ready: true, serving: true, terminating: false} targetRef: {kind: Pod, name: nginx-deployment-56b9f4956-ll45d, namespace: default} 別の Pod を立てて、Service の ClusterIP と /etc/resolv.conf を確認します。dig コマンドを使うため、nicolaka/netshoot イメージを使います。 $ kubectl run dnsutils --image=nicolaka/netshoot --restart=Never -- sleep 3600 $ kubectl get svc nginx -o jsonpath='{.spec.clusterIP}' 10.96.251.146 $ kubectl exec dnsutils -- cat /etc/resolv.conf search default.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5 1.1節で確認した generateSearchesForDNSClusterFirst / defaultDNSOptions の実装どおり、search には3つのクラスタ用ドメインが並び、nameserver は kube-dns の ClusterIP だけに固定されています。 ここで、短い Service 名 nginx の A レコードを dig で問い合わせてみます。dig は OS の resolver ライブラリを経由せず、/etc/resolv.conf の search/ndots を自前で解釈する実装を持っており、+search オプションで有効にできます。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx ;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 ;; ANSWER SECTION: nginx.default.svc.cluster.local. 30 IN A 10.96.251.146 Corefile に一時的に log プラグインを追加してクエリログを確認すると、CoreDNS が受けたクエリは1回だけでした。 $ kubectl -n kube-system logs coredns-55c7f56bc5-rw2kv [INFO] 10.244.0.9:45819 - 52547 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000791553s nginx (ドット数0) は ndots:5 未満なので、search リストの先頭 (default.svc.cluster.local) を付加した名前を最初に試し、そこで一致したため1回で完結しています。 search リストのどのエントリにも一致しない名前では挙動が変わります。存在しない名前 doesnotexist を問い合わせてみます。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A doesnotexist ;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 ;; ->>HEADER<<- ... status: NXDOMAIN [INFO] 10.244.0.9:51748 - 43574 "A IN doesnotexist.default.svc.cluster.local. udp 79 false 1232" NXDOMAIN qr,aa,rd 149 0.000185618s [INFO] 10.244.0.9:35606 - 63632 "A IN doesnotexist.svc.cluster.local. udp 71 false 1232" NXDOMAIN qr,aa,rd 141 0.000223409s [INFO] 10.244.0.9:54743 - 6397 "A IN doesnotexist.cluster.local. udp 67 false 1232" NXDOMAIN qr,aa,rd 137 0.000049374s [INFO] 10.244.0.9:41978 - 30757 "A IN doesnotexist. udp 53 false 1232" NXDOMAIN qr,rd,ra 30 0.004721411s 今度は4回のクエリが発生しています。search リストの3つのエントリ (default.svc.cluster.local、svc.cluster.local、cluster.local) を順に試し、いずれも kubernetes プラグインが権威を持って (aa フラグ付き) NXDOMAIN を返した後、最後に絶対名としてそのまま試し、forward プラグイン経由で上流に転送されています (aa なし、ra フラグ付き)。これが ndots:5 による無駄なクエリの実態です。 最初から Service の完全な名前 (nginx.default.svc.cluster.local) を指定すれば、この総当たりを避けられるか確認します。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx.default.svc.cluster.local ;; ANSWER SECTION: nginx.default.svc.cluster.local. 30 IN A 10.96.251.146 [INFO] 10.244.0.9:53934 - 25649 "A IN nginx.default.svc.cluster.local.default.svc.cluster.local. udp 98 false 1232" NXDOMAIN qr,aa,rd 168 0.000245658s [INFO] 10.244.0.9:59436 - 62868 "A IN nginx.default.svc.cluster.local.svc.cluster.local. udp 90 false 1232" NXDOMAIN qr,aa,rd 160 0.00007454s [INFO] 10.244.0.9:38202 - 29221 "A IN nginx.default.svc.cluster.local.cluster.local. udp 86 false 1232" NXDOMAIN qr,aa,rd 156 0.000101372s [INFO] 10.244.0.9:41656 - 7194 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000086122s 意外にも、こちらも4回のクエリになりました。nginx.default.svc.cluster.local はドット数が4つで、やはり ndots:5 (デフォルト5) 未満です。そのため search リストの各エントリを末尾に付加した名前がまず試され (いずれも NXDOMAIN)、最後に指定した名前そのもの (絶対名としての nginx.default.svc.cluster.local) を試してようやく成功しています。Service の完全な名前のつもりで指定しても、末尾にドットを付けない限り ndots:5 の対象から外れるわけではありません。次の節で、この問題を避ける方法を見ていきます。 1.7 ndots:5 によるクエリ増幅を減らす ndots オプションの仕様上、クエリ名に含まれるドットの数が ndots 以上であれば、resolver は search リストを展開せず、クエリ名をそのまま (絶対名として) 問い合わせます。 > Resolver queries having fewer than ndots dots (default is 1) in them will be attempted using each component of the search path in turn until a match is found. (参考: resolv.conf(5)) 1.6節で見た通り、ドット数だけでは ndots:5 の対象から外れません。nginx.default.svc.cluster.local のドット数は4つで、デフォルトの ndots:5 を下回るため、末尾にドットを付けない限り search 展開の対象になります。確実に展開を避けるには、末尾にドットを付けた絶対名で問い合わせます。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A nginx.default.svc.cluster.local. ;; ANSWER SECTION: nginx.default.svc.cluster.local. 30 IN A 10.96.251.146 [INFO] 10.244.0.9:44887 - 63099 "A IN nginx.default.svc.cluster.local. udp 72 false 1232" NOERROR qr,aa,rd 96 0.000108205s 今度は1回のクエリだけで完結しています。末尾のドットで絶対名・相対名を区別するという表記自体は、DNS のマスターファイル形式を定めた RFC 1035 で定義されています。 > Domain names that end in a dot are called absolute, and are taken as complete. Domain names which do not end in a dot are called relative; the actual domain name is the concatenation of the relative part with an origin specified in a $ORIGIN, $INCLUDE, or as an argument to the master file loading routine. (参考: RFC 1035 Section 5.1) これを踏まえると、次のような問い合わせ方でクエリ数を減らせます。 - 外部ドメインは、末尾にドットを付けた絶対名 (example.com. のような表記) で問い合わせる。ドット数に関わらず search リストの展開を避けられます - クラスタドメイン内で、かつ同じ Namespace の Service であれば、Service 名のみで問い合わせる。1.6節で確認した通り、search リストの先頭が <namespace>.svc.cluster.local なので、最初の展開でヒットします 第2章: クラスタ外ドメインへのクエリが上流 DNS へ転送されるまで この章では、cluster.local にマッチしないクエリが、どのようにして上流の DNS サーバーへ転送されるかを説明します。 sequenceDiagram participant POD as Pod participant CD as CoreDNS participant KUBE as kubernetes プラグイン participant FWD as forward プラグイン participant UP as 上流 DNS サーバー POD->>CD: kubernetes.io の A レコードを問い合わせ CD->>KUBE: プラグインチェーンの先頭から順に処理 KUBE->>KUBE: ゾーンマッチ判定 (cluster.local/in-addr.arpa/ip6.arpa のいずれにも一致しない) KUBE->>FWD: Next.ServeDNS で次のプラグインに委譲 FWD->>FWD: ノードの /etc/resolv.conf から上流サーバーを決定 FWD->>UP: クエリをそのまま転送 UP-->>FWD: 応答 FWD-->>POD: 非権威応答 (ra フラグ) として返す 2.1 ゾーンにマッチしないクエリは forward プラグインへ回る 1.5節で見た通り、kubernetes プラグインの ServeDNS は冒頭でゾーンマッチを判定します。 // plugin/kubernetes/handler.go zone := plugin.Zones(k.Zones).Matches(qname) if zone == "" { return plugin.NextOrFailure(k.Name(), k.Next, ctx, w, r) } (参考: handler.go#L17-L20) kubernetes.io のような外部ドメインは、Corefile で指定した cluster.local、in-addr.arpa、ip6.arpa のいずれにもマッチしません。そのため、kubernetes プラグインは最初から処理をせず、チェーン内の次のプラグインに委譲します。この仕組みは、1.5節で触れた fallthrough (ゾーンにはマッチしたが名前が見つからなかった場合に次へ委譲する) とは別のものです。ゾーンマッチの判定自体で素通りするのが外部ドメイン、ゾーンにはマッチしたうえで NXDOMAIN になったクエリを次へ渡すかどうかを制御するのが fallthrough、という違いになります。 kubernetes プラグインより後に Corefile で登録されているプラグインのうち、実際にクエリに応答するのは forward です。 2.2 forward プラグインがノードの resolv.conf を読んで上流に転送する forward . /etc/resolv.conf という設定は、TO 引数にファイルパスを渡した場合の構文です。CoreDNS はこのファイルを resolv.conf 形式としてパースし、nameserver 行から上流サーバーの一覧を得ます。 // plugin/pkg/parse/host.go func tryFile(s string) ([]string, error) { c, err := dns.ClientConfigFromFile(s) ... for _, s := range c.Servers { servers = append(servers, net.JoinHostPort(stripZone(s), c.Port)) } return servers, nil } (参考: host.go#L87-L99、呼び出し元は resolve.go#L33-L63) max_concurrent オプションを指定すると、同時並行で扱うアップストリームへのクエリ数に上限を設けられます。超過した場合はキューイングせず即座に REFUSED を返します。 // plugin/forward/forward.go if f.maxConcurrent > 0 { count := atomic.AddInt64(&(f.concurrent), 1) defer atomic.AddInt64(&(f.concurrent), -1) if count > f.maxConcurrent { maxConcurrentRejectCount.Add(1) return dns.RcodeRefused, f.ErrLimitExceeded } (参考: forward.go#L119-L124) 2.3 CoreDNS 自身も dnsPolicy: Default で動いている CoreDNS の Pod 自体は、他の一般的な Pod と違って dnsPolicy: Default で動作します。これはノード本来の /etc/resolv.conf をそのまま使うという設定です。CoreDNS 自身がクラスタ DNS であるため、ClusterFirst で自分自身を nameserver に指定するわけにはいきません。 この構成には副作用があります。ノードの /etc/resolv.conf に、ループバックアドレス (systemd-resolved が使う 127.0.0.53 など) が nameserver として書かれていると、forward . /etc/resolv.conf が実質的に CoreDNS 自身へ転送するループになってしまいます。これを防ぐのが loop プラグインです。起動時に自分自身宛てのランダムなプローブクエリを送り、一定回数以上観測されたら転送ループと判断してプロセスを終了させます。 // plugin/loop/loop.go if state.Name() == l.qname { l.inc() } if l.seen() > 2 { log.Fatalf(`Loop (%s -> %s) detected for zone %q, ...`, ...) } (参考: loop.go#L33-L57) プロセスが終了すると Kubernetes が CrashLoopBackOff として検知し、Pod を再起動します。無限ループによる CPU・メモリの枯渇を未然に防ぐための安全装置です。README の「Troubleshooting Loops In Kubernetes Clusters」でも、この構成上の注意点が説明されています。 (参考: CoreDNS loop plugin#Troubleshooting Loops In Kubernetes Clusters) 2.4 実地検証: 外部ドメインへのフォワード kind クラスタのノード (コンテナ) 自体の /etc/resolv.conf を見ると、CoreDNS の Pod が実際に使っている上流サーバーを確認できます。 $ docker exec coredns-demo-control-plane cat /etc/resolv.conf nameserver 192.168.5.2 options ndots:0 この状態で kubernetes.io の A レコードを問い合わせると、1.6節で見た doesnotexist と同じく search 展開が働きます。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A kubernetes.io ;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1 ;; ANSWER SECTION: kubernetes.io. 5 IN A 3.33.186.135 kubernetes.io. 5 IN A 15.197.167.90 [INFO] 10.244.0.9:42449 - 56539 "A IN kubernetes.io.default.svc.cluster.local. udp 80 false 1232" NXDOMAIN qr,aa,rd 150 0.000148246s [INFO] 10.244.0.9:50373 - 65120 "A IN kubernetes.io.svc.cluster.local. udp 72 false 1232" NXDOMAIN qr,aa,rd 142 0.000084414s [INFO] 10.244.0.9:34883 - 29654 "A IN kubernetes.io.cluster.local. udp 68 false 1232" NXDOMAIN qr,aa,rd 138 0.000044665s [INFO] 10.244.0.9:46833 - 20811 "A IN kubernetes.io. udp 54 false 1232" NOERROR qr,rd,ra 89 0.009308609s search リストの3つのエントリは、いずれも kubernetes プラグインが権威を持って (aa フラグ付き) NXDOMAIN を返しています。最後に絶対名として試されたクエリだけが NOERROR になっており、フラグも qr,aa,rd ではなく qr,rd,ra (aa なし、ra あり) に変わっています。これは、kubernetes プラグインのゾーンにマッチしなかったため forward プラグインへ処理が渡り、実際にネットワーク越しに上流サーバーへ問い合わせて非権威応答を返しているためです。応答時間も 0.0093s と、kubernetes プラグインが権威応答を返していた 0.00005〜0.0003s 台より一桁遅くなっています。 1.7節で見た通り、末尾にドットを付けた絶対名で問い合わせれば、この search 展開を避けられます。 $ kubectl exec dnsutils -- dig +search +noall +answer +comments A kubernetes.io. ;; ANSWER SECTION: kubernetes.io. 5 IN A 3.33.186.135 kubernetes.io. 5 IN A 15.197.167.90 [INFO] 10.244.0.9:57891 - 60795 "A IN kubernetes.io. udp 54 false 1232" NOERROR qr,rd,ra 89 0.003405158s 1回のクエリで完結しています。 第3章: 主なプラグインの一覧と参考の設定 この章で紹介するプラグインの一覧は次の通りです。各プラグインの概要・設定値・採用理由は、3.1節以降でそれぞれ解説します。 | サブ章 | プラグイン | 概要 | |:---|:---|:---| | 3.1 | errors | エラーを標準出力へログ出力する | | 3.2 | health | liveness probe (/health) に応答する | | 3.3 | ready | readiness probe (/ready) に応答する | | 3.4 | kubernetes | Service/EndpointSlice から DNS レコードを生成する | | 3.5 | prometheus | Prometheus 形式のメトリクスを公開する | | 3.6 | forward | ゾーンにマッチしないクエリを上流 DNS サーバーへ転送する | | 3.7 | cache | 応答をキャッシュする (serve_stale/prefetch による上流障害対策を含む) | | 3.8 | loop | 自己転送ループを検知してプロセスを終了する | | 3.9 | reload | Corefile の変更を無停止で再読込する | | 3.10 | loadbalance | 複数レコードの応答順をランダム化する | | 3.11 | autopath | search ドメインの展開を CoreDNS 側で肩代わりする | | 3.12 | log | クエリのログを標準出力へ出力する | 実際の運用クラスタでは、公式の coredns.yaml.base をベースにしつつ、いくつかの設定をカスタマイズした Corefile がよく使われます。次の例は、3.7節で解説する serve_stale/prefetch も含めて、ここまでに登場したプラグインを組み合わせた設定です。 .:53 { errors health { lameduck 10s } ready kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure fallthrough in-addr.arpa ip6.arpa } prometheus :9153 forward . /etc/resolv.conf cache 30 { disable denial cluster.local disable success cluster.local prefetch 5 60s 10% serve_stale 1h verify } loop reload loadbalance } 3.11節で解説する autopath を使うには kubernetes プラグインを pods verified にする必要があり、3.4節で説明したデフォルトの pods insecure とは両立しません。そのため autopath まで含める場合は、cluster.local 向けのブロックを . (それ以外の全ゾーン) のブロックと分ける必要があります。 cluster.local in-addr.arpa ip6.arpa { errors health { lameduck 10s } ready autopath @kubernetes kubernetes cluster.local in-addr.arpa ip6.arpa { pods verified fallthrough in-addr.arpa ip6.arpa } prometheus :9153 cache 30 { disable denial cluster.local disable success cluster.local prefetch 5 60s 10% serve_stale 1h verify } loop reload loadbalance } .:53 { errors forward . /etc/resolv.conf cache 30 loop reload loadbalance } 3.1 errors 処理中に発生したエラーを標準出力へログとして出力するだけのプラグインです。設定可能なオプションはありません。 errors // plugin/errors/errors.go func (h errorHandler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { rcode, err := plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) if err != nil { ... } return rcode, err } (参考: errors.go#L94-L122) > Any errors encountered during the query processing will be printed to standard output. (参考: CoreDNS errors plugin) 3.2 health /health (デフォルトポート 8080) で liveness probe に応答するプラグインです。ハンドラの実装は常に 200 を返すだけです (参考: CoreDNS health plugin)。この例での設定は次の通りです。 | 設定 | 値 | 概要 | |:---|:---|:---| | lameduck | 10s | SIGTERM を受けてから実際にプロセスを終了するまでの猶予時間 | health { lameduck 10s } // plugin/health/health.go h.mux.HandleFunc(h.healthURI.Path, func(w http.ResponseWriter, _r *http.Request) { // We're always healthy. w.WriteHeader(http.StatusOK) io.WriteString(w, http.StatusText(http.StatusOK)) }) (参考: health.go#L60-L64) // plugin/health/health.go (OnFinalShutdown) time.Sleep(h.lameduck) (参考: health.go#L82-L101) この猶予の間も /health は 200 を返し続けます。kube-apiserver 公式の coredns.yaml.base ではデフォルト値が 5s ですが、この設定例では 10s に延ばしています。Pod が Terminating になってから、その変更が EndpointSlice に反映され、kube-proxy のルールが更新されるまでには前回記事で見た通り非同期の遅延があります。lameduck を長めに取ることで、その間も liveness probe を失敗させずに Pod を生かしたまま、既存のコネクションを処理し切る時間を確保しています。 3.3 ready /ready (デフォルトポート 8181) で readiness probe に応答するプラグインです。health との違いは、各プラグインが実装する Readiness インターフェースを集約して判定する点です。設定可能なオプションはありません。 ready // plugin/ready/readiness.go type Readiness interface { Ready() bool } (参考: readiness.go#L4-L7) 登録された各プラグインの Ready() を呼び、1つでも false であれば /ready は 503 を返します。 > will return 200 OK, when all plugins that are able to signal readiness have done so (参考: CoreDNS ready plugin) health が無条件に 200 を返すのに対し、ready は kubernetes プラグインの informer の初回同期が終わっているかなど、実際に名前解決ができる状態かどうかを反映します。 3.4 kubernetes 1.5節・1.6節で見た通り、Service/EndpointSlice/Namespace の情報から DNS レコードを組み立てるプラグインです (参考: CoreDNS kubernetes plugin)。この例での設定は次の通りです。 | 設定 | 値 | 概要 | |:---|:---|:---| | pods | insecure | Pod への API watch を行わず、クエリ名から IP を機械的に組み立てて応答する軽量なモード | | fallthrough | in-addr.arpa ip6.arpa | PTR レコード (逆引き) を解決できない場合に、次のプラグインへ処理を委ねる | kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure fallthrough in-addr.arpa ip6.arpa } pods insecure は、Pod の実在を一切確認せず、クエリ名からハイフン区切りの IP アドレスを機械的に組み立てて返すだけのモードです。クラスタ全体の Pod を verified モードで watch すると、Pod 数や churn に比例してメモリ・CPU のコストが増えるため、通常のクラスタ内通信で Pod 単位の名前解決を積極的に使わないのであれば、insecure (または disabled) で十分です。 fallthrough in-addr.arpa ip6.arpa は、PTR レコードのクエリについて、kubernetes プラグインが持つ情報で解決できなかった場合に、エラーを確定させず次のプラグイン (forward) に処理を委ねる設定です。 // plugin/pkg/fall/fall.go func (f F) Through(qname string) bool { return plugin.Zones(f.Zones).Matches(qname) != "" } (参考: fall.go#L27-L31) in-addr.arpa/ip6.arpa はクラスタ全体の逆引きゾーンであり、kubernetes プラグインが把握しているのは自身が管理する Service/Pod の IP だけです。それ以外の IP (ノード自体や外部サービスなど) への逆引きクエリも飛んでくるため、fallthrough を指定しないと、kubernetes プラグインが分からない IP への逆引きがすべて NXDOMAIN で打ち切られてしまいます。 3.5 prometheus :9153 で Prometheus 形式のメトリクスを公開するだけのプラグインです。Corefile 上のディレクティブ名は prometheus ですが、実装は plugin/metrics というディレクトリにあり、coredns.io 上のページ名も metrics です (参考: CoreDNS metrics plugin)。 | 設定 | 値 | 概要 | |:---|:---|:---| | ADDR | :9153 | メトリクスのバインドアドレス。ポートのみ指定すると全インターフェースにバインドする | prometheus :9153 // plugin/metrics/setup.go func init() { plugin.Register("prometheus", setup) } ... const defaultAddr = "localhost:9153" (参考: setup.go#L29、setup.go#L142) デフォルトのバインドアドレスは localhost:9153 ですが、Corefile で :9153 とポートだけを指定すると、全インターフェースにバインドされます。これは Pod 外 (Prometheus など) からスクレイプできるようにするためです。 3.6 forward 2.2節で見た通り、ノードの /etc/resolv.conf をパースして得た上流サーバーへそのままクエリを転送するプラグインです (参考: CoreDNS forward plugin)。 | 設定 | 値 | 概要 | |:---|:---|:---| | FROM | . | 全ゾーンを対象にする | | TO | /etc/resolv.conf | ノードの resolv.conf を読み込み、そこに書かれた上流サーバーへ転送する | | max_concurrent | (未設定) | 同時並行クエリ数の上限を設けない | forward . /etc/resolv.conf 公式の coredns.yaml.base では max_concurrent 1000 が付与されていますが、この設定例ではあえて外しています。max_concurrent を設定すると、上限を超えた同時並行クエリは待たされずに即座に REFUSED として打ち切られます。一部のアプリケーションの Pod が単位時間あたり非常に多くの DNS クエリを発行する環境では、この上限によって REFUSED が多発するよりも、conntrack テーブルを逼迫させてでも上流への転送を試みさせたい、という判断から上限を外している例があります。これは良し悪しというより、どちらのリスクを許容するかというトレードオフです。 3.7 cache 応答をキャッシュするプラグインです (参考: CoreDNS cache plugin)。cache 30 の 30 は TTL の上限 (キャップ) であり、レコード本来の TTL をこの値より延長するものではありません。この例での設定は次の通りです。 | 設定 | 値 | 概要 | |:---|:---|:---| | TTL 上限 | 30 | 成功応答・否定応答ともにキャップする上限秒数 | | disable denial | cluster.local | cluster.local 向けの否定応答のキャッシュを無効化する | | disable success | cluster.local | cluster.local 向けの肯定応答のキャッシュを無効化する | | prefetch | 5 60s 10% | アクセス頻度の高いエントリを TTL 失効前にバックグラウンドで先読み更新する | | serve_stale | 1h verify | 上流が応答不能なとき、TTL 切れの古いエントリを最大1時間まで返し続ける | cache 30 { disable denial cluster.local disable success cluster.local prefetch 5 60s 10% serve_stale 1h verify } // plugin/cache/cache.go func computeTTL(msgTTL, minTTL, maxTTL time.Duration) time.Duration { ttl := min(max(msgTTL, minTTL), maxTTL) ... } (参考: cache.go#L135-L136、デフォルトの上限は成功応答が3600秒・否定応答が1800秒で、cache.go#L358-L363 で定義) disable denial <zone>/disable success <zone> は、指定したゾーンについてキャッシュそのものを無効化するオプションです。 // plugin/cache/cache.go case response.NoError, response.Delegation: if plugin.Zones(w.pexcept).Matches(m.Question[0].Name) != "" { // zone is in exception list, do not cache return } ... case response.NameError, response.NoData, response.ServerError: if plugin.Zones(w.nexcept).Matches(m.Question[0].Name) != "" { return } (参考: cache.go#L279-L317) cluster.local に disable denial/disable success を設定している理由は、1.3節・1.4節で見た CoreDNS のプラグイン実行順序に由来します。cache プラグインは kubernetes プラグインより先に実行されるため、本来 kubernetes プラグインが権威を持つはずの cluster.local の応答まで cache プラグインが先にキャッシュしてしまいます。これは、権威を持つゾーンのデータはキャッシュされたデータより常に優先されるべきという RFC 1035 の原則に反する構成です。 > authoritative data in zones will always "hide", and hence take precedence over, cached data. (参考: RFC 1035 Section 6.1.2) kubeadm がこの設定を追加した際の議論でも、Service の追加・削除や EndpointSlice の更新 (Pod の再作成・再スケジュールなど) の反映が、cache のキャッシュによって本来のタイミングより最大で TTL 分遅れて伝わってしまう点が、実務上の懸念として挙げられています。TTL を 0 にする対処法もありますが、TTL 0 の応答を正しく扱えないクライアントが存在するため、disable denial/disable success によるキャッシュの無効化のほうが望ましいとされています。 (参考: kubernetes/kubernetes#128359: disable caching for authoritative zone to comply with rfc-1035 section 6.1.2) forward . /etc/resolv.conf は、上流 DNS が一時的に応答不能になると、そのままクラスタ内の名前解決にも影響します。これを緩和するのが prefetch と serve_stale です。 serve_stale [DURATION] [REFRESH_MODE] は、上流が応答不能なときに TTL 切れの古いエントリを返して名前解決を継続させるオプションです。REFRESH_MODE は immediate (デフォルト。先に stale なエントリを返しつつ裏側で更新を試みる) と verify (上流から取得できないことを確認してから stale を返す) の2種類があります。 // plugin/cache/setup.go case "serve_stale": ... ca.staleUpTo = 1 * time.Hour // デフォルトDURATION ... ca.verifyStale = false // デフォルトはimmediate ... ca.verifyStale = mode == "verify" (参考: setup.go#L173-L210) prefetch AMOUNT [[DURATION] [PERCENTAGE%]] は、アクセス頻度の高いエントリを TTL 失効前にバックグラウンドで先読み更新するオプションです。デフォルトは無効で、AMOUNT に先読み対象とみなす最低アクセス回数を指定すると有効になります。 // plugin/cache/cache.go prefetch: 0, // 無効 (AMOUNT 指定時のみ有効) duration: 1 * time.Minute,

  9. Agent Executorの概要 はじめに Googleは2026年5月、Agent Substrateと同じ日に、エージェントの実行・再開・分散デプロイのためのオープンソースのランタイム標準としてAgent Executorを発表しました。以前、Agent Substrateの概要で整理したAgent Substrateは、エージェントを少数のworkerへ多重化する実行基盤でした。Agent Executorは、その上でエージェントの実行を管理する側にあたります。 リポジトリ名とCLI名はaxで、READMEの見出しもAXです。ブログではAgent Executorと呼ばれており、ブログからのリンク先はこのリポジトリ(github.com/google/ax)です。 この記事では、Agent Executor v0.3.1 が提供する機能と、アーキテクチャ、Task・Workspace・Modelという3つのリソース、TaskがAgent SubstrateのActorになるまでの流れを整理します。あわせて、kindクラスタで実際に動かして確認した結果も載せています。 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |---|---|---|---| | Agent Executor(AX) | v0.3.1 | google/ax | 本記事全体のソースコード調査対象 | | Agent Substrate | 672533541dbf | agent-substrate/substrate | AX v0.3.1のgo.modが固定しているコミット。kindでの検証にもこのコミットを使用 | | kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージはkindest/node:v1.37.0 | :::note alert Agent Executorはプレビューで、READMEは破壊的変更が入る見込みであることを明記しています。 > We are still actively refining our core concepts, protocols, and specifications. We will likely to introduce major breaking changes prior to a stable release. (参考: README.md#L6-L9) 本記事の実装説明は AX v0.3.1 に固定します。Agent Substrate は、このタグの依存先コミットを確認対象にします。別記事で扱った Substrate v0.5.0 とは API が異なるため、設定例をそのまま混ぜないようにします。リポジトリ名を省略したソースコードの引用は、すべて google/ax の v0.3.1 です。 ::: TL;DR - AX は Task を管理し、実行を Substrate の Actor に任せます。Task・Workspace・Model は独自の gRPC API で扱い、Kubernetes の CRD にはしません。 - 状態は Redis、作業キューは Redis Streams に置きます。イベント単位の分配は、Task ごとの直列化や処理成功の保証とは異なります。 - AX の Suspend は /workspace のデータを保存します。中断時のプロセスメモリやルートファイルシステムの変更は保存せず、再開には golden snapshot と durable data を使います。 - ブログの機能説明と v0.3.1 の実装は分けて読みます。応答の再送や Task の分岐など、コードで確認できない機能もあります。 - 検証には WorkerPool と保存先の設定が必要です。arm64 では runner イメージを用意し、Task の準備完了とコマンドの成否を分けて確認しました。 第1章: Agent Executorとは この章では、ブログが説明しているAgent Executorの機能を確認し、リポジトリのv0.3.1が提供している内容と照らし合わせます。 1.1 ブログの説明 Google Cloud のブログを参考に、機能と構成を整理します。以下の公式図はブログの説明する全体像で、v0.3.1 の実装状況は1.3節で確認します。 ブログは、長時間動くエージェントのワークフローが障害に弱く、本番環境で信頼性と効率を保って管理することが難しい、という課題から説明を始めています。 > Today, we're introducing Agent Executor, Google's open-source runtime standard for agent execution, resumption, and distributed deployment. (参考: Agent Executor, Google's distributed Agent Runtime | Google Cloud Blog) Agent Executorが備える機能として、ブログは次の5つを挙げています。 | 機能 | ブログの説明の要約 | |---|---| | Durable execution | イベントログとスナップショットにより、中断や障害から実行を再開する | | Secure isolation | sandbox により、コードの副作用や悪意ある処理の影響を隔離する | | Session consistency | single-writer により、共有セッション状態への更新を調整する | | Connection recovery | クライアントの再接続時に、最後に受信したシーケンス以降の応答を補完する | | Trajectory branching | checkpoint から実行を分岐し、複数の処理を試す | (参考: Agent Executor, Google's distributed Agent Runtime | Google Cloud Blog) 最初の公式図は、実行を調整する Controller、履歴を扱う Event Log、実行対象の Actors という3つの要素を示しています。Actors には、エージェントだけでなくハーネス、スキル、ツール、sandbox も含まれます。 !Agent Executorの構成図。ControllerとEvent Logが双方向に接続され、Controllerから、Harnesses/Agents/Skills/Tools/Sandboxesを含むActorsへ矢印が伸びている。 (参考: Agent Executor, Google's distributed Agent Runtime | Google Cloud Blog, 2026-05-20) 図中の Event Log と、後述する Redis Streams の作業キューは、同じ機能として扱えません。Streams があることだけで、応答の再送や実行履歴の復元まで実装されているとは判断できません。 2つ目の公式図は、異なるエージェントや実行環境を組み合わせる構想です。Antigravity、自社で運用するエージェント、Google が管理するエージェントなどを、Agent Executor の周囲に配置しています。個々の連携が v0.3.1 に実装済みであることを示す図ではありません。 !Agent Executorを中央に置き、Antigravity(またはcustom harness)、Custom agents managed by Google、Frontier agents from Google、Self-managed agentsと双方向の矢印で接続している図。 (参考: Agent Executor, Google's distributed Agent Runtime | Google Cloud Blog, 2026-05-20) Kubernetes上でのスケールについては、同日に発表されたAgent Substrateと組み合わせると説明されています。 > Agent Substrate introduces a new level of abstraction for Kubernetes that moves agents onto and off of ready compute capacity in real-time, resulting in lower latency with higher scale and efficiency. (参考: Agent Executor, Google's distributed Agent Runtime | Google Cloud Blog) Agent Substrateのリポジトリ側でも、Agent Executorはエコシステムの1つとして紹介されています。 > Agent Executor: A distributed agent runtime that demonstrates building a secure, hyper-scalable agent harness on Agent Substrate (参考: agent-substrate/substrate README.md#L46) 1.2 リポジトリが提供するもの google/axのREADMEは、AXを次のように説明しています。 > AX is a high-throughput, declarative orchestrator to run billions of autonomous agent workloads in a cluster. It runs on top of Agent Substrate for sandboxed execution and is built to run billions of tasks per cluster. If you have used Kubernetes, ax will feel similar. (参考: README.md#L13) README の「billions」はプロジェクトが掲げる規模の目標で、本記事で検証した性能値ではありません。エージェントを「ステートレスなマイクロサービスでも、完了まで走るバッチジョブでもない新しい種類のワークロード」と位置づけ、3つのリソースで宣言的に扱う、という設計です。 > Agents are a new kind of workload. They are neither stateless microservices nor run-to-completion batch jobs. They accumulate state, need strict isolation, call out to model APIs and tool servers, and can burn money in a loop if nobody is watching. (参考: README.md#L47) | やりたいこと | AXが提供するもの | |---|---| | 信頼できないエージェントのコードを、CPU/メモリ制限付きの隔離されたsandboxで実行する | Task | | Gitリポジトリ、MCPサーバー、skillパッケージをあらかじめ用意して、エージェントがすぐ動ける状態で起動する | Workspace | | プラットフォーム自身が使うLLMを、Kubernetesのsecretの認証情報とともに設定する | Model | | エージェントを一時停止し、保存したデータを使って再開する | ax suspend / ax resume | | 実行中のエージェントにシェルで入って中の様子を見る | ax ssh | (参考: README.md#L49-L55の機能紹介を要約。リソース制限の実装上の注意点は6.2節) 1.3 ブログの機能とv0.3.1のコードの対応 ブログの5つの機能と、v0.3.1のソースコード・同梱ドキュメントを突き合わせると、次のようになります。「見つからなかった」と書いている箇所は、リポジトリ内をevent logやsingle-writerなどで検索した範囲での結果です。 | ブログの機能 | v0.3.1で確認できたこと | |---|---| | Durable execution | スナップショットによるSuspend/Resumeは、Agent SubstrateのActorTemplateのSnapshotsConfigを通じて使っている(4.4節)。イベントログに相当する実装は見つからなかった | | Secure isolation | ActorTemplateのSandboxConfigでgVisorを指定しており、隔離はAgent Substrateのsandboxに任せている(4.4節) | | Session consistency | シングルライターに相当する実装は見つからなかった | | Connection recovery | 最後に受信したシーケンスから応答を補完する実装は見つからなかった。WatchTaskはTaskのステータス遷移を流すRPCで、エージェントの応答を再送するものではない(2.4節) | | Trajectory branching | ロードマップの項目(「Stateful Task Branching」)で、実装はまだない(第7章) | | 他のエージェントとの組み合わせ | Antigravityは、Workspaceのgoalを実行する環境準備のために使われている(5.2節)。LangChain/LangGraph、ADK、A2Aに関する記述や実装は見つからなかった | ブログはプロジェクトの機能を説明していますが、本記事が動作を確認する対象は v0.3.1 です。表の「見つからなかった」は、このタグの公開コードで対応する実装を確認できなかったという意味です。 第2章: 全体アーキテクチャ この章では、AXのコンポーネントと、Taskがax applyされてから実行されるまでのデータの流れを整理します。 2.1 コンポーネント AXは4つのバイナリで構成されています。 | バイナリ | 役割 | |---|---| | ax | 開発者向けのCLI。マニフェストの適用、リソースの参照・watch、クラスタへのトンネルを担う | | ax-server | ステートレスな gRPC API(ポート8080)。CLI が YAML から変換した型付きリクエストを受け、検証・保存とイベント発行を行う | | ax-controller | 再調整を行うworker。Redisのストリームを読み、Agent Substrate上にatespaceとactorを用意して、Taskを望ましい状態に近づける。レプリカを増やしてスケールする | | ax-task-runner | 各Taskのコンテナの中で動くエントリポイント。ワークスペースを準備し、メタデータを提供し、エージェントのコマンドを実行する | (参考: DESIGN.md#L37-L44) v0.3.1 の構成では、AX が Task の管理を、Agent Substrate が Actor の実行を担当します。DESIGN.md を基に、役割の境界が分かるように整理した図です。 flowchart TD CLI["ax CLI<br/>Task / Workspace / Model を適用"] subgraph AX["AX の制御層"] Server["ax-server<br/>gRPC API"] Redis[("Redis<br/>リソースの状態<br/>作業キュー / 状態変更通知")] Controller["ax-controller<br/>Task を再調整"] Server -->|保存・イベント発行| Redis Redis -->|Redis Streams| Controller end subgraph Substrate["Agent Substrate の実行基盤"] API["Control API<br/>Actor の配置・停止・再開"] Actor["Worker 上の Actor<br/>ax-task-runner + エージェント"] API -->|Worker へ割り当て| Actor end CLI -->|gRPC| Server Controller -->|ActorTemplate / Actor の作成など| API (参考: DESIGN.md#L7-L35) 2.2 etcdの外にRedisを置く理由 AXがCRDではなくRedisを使う理由は、DESIGN.mdの冒頭に書かれています。 > Storing millions of short-lived tasks as Kubernetes CRDs pushes etcd past its comfort zone (single-digit GB storage limits, write-rate bottlenecks, control plane degradation). AX keeps its state in Redis and uses Redis Streams as the work queue between the API server and a horizontally scaled pool of controllers. (参考: DESIGN.md#L5) これは、AX が想定する規模に対するプロジェクトの設計判断です。etcd や CRD の一律の上限を示すものではありません。数百万の短命な Task による更新負荷を避けるため、状態をRedisに置き、Redis Streamsをapiサーバーとcontrollerの間の作業キューにする、という設計です。Kubernetes の管理対象から高頻度に変わる状態を分ける点は、Agent Substrate と共通しています。AX は Task を Redis に、依存先の Substrate は Actor の状態を PostgreSQL に保存し、それぞれ異なるリソースを管理します。 RedisのキーはTask・Workspace・Modelごとに作られます。kindで実際にax applyした直後にredis-cli --scanで確認すると、次のキーがありました。 $ redis-cli --scan | sort ax:stream:tasks ax:task:default:simple-task ax:tasks:atespace:default ax:tasks:index ax:task:<atespace>:<name>がTask本体(protojsonでシリアライズした文字列)、ax:tasks:indexとax:tasks:atespace:<atespace>が一覧用のソート済みセットで、ax:stream:tasksがイベントのStreamです。SaveTaskは、これらのキーの更新とStreamへのイベント追加を1つのトランザクションにまとめています。 pipe := s.client.TxPipeline() pipe.Set(ctx, s.taskKey(atespace, name), data, s.opts.TTL) pipe.ZAdd(ctx, s.taskIndexKey(), redis.Z{Score: score, Member: member}) pipe.ZAdd(ctx, s.taskAtespaceIndexKey(atespace), redis.Z{Score: score, Member: name}) pipe.XAdd(ctx, &redis.XAddArgs{ Stream: s.opts.StreamName, Values: map[string]interface{}{ "action": "reconcile", "atespace": atespace, "name": name, }, }) pipe.Publish(ctx, s.taskPubSubChannel(atespace, name), data) (参考: internal/store/redis/store.go#L123-L176) 最後のPublishは、ax watchのためのPub/Subです。 2.3 Redis Streamsのコンシューマーグループ ax-controllerは、Redis Streamsのコンシューマーグループ(デフォルトはax-controllers)に参加して、イベントを受け取ります。 streams, err := sub.store.client.XReadGroup(ctx, &redis.XReadGroupArgs{ Group: sub.group, Consumer: sub.consumer, Streams: []string{sub.store.opts.StreamName, ">"}, Count: sub.store.opts.ReadBatchSize, Block: sub.store.opts.ReadBlock, }).Result() (参考: internal/store/redis/store.go#L631-L637) 通常の新規配信では、同じグループ内の1つのコンシューマーにイベントが割り当てられます。ただし、同じ Task に対する別々のイベントは、異なるコンシューマーで並行して処理される可能性があります。Task ごとのロックや single-writer を、この仕組みだけで保証するわけではありません。既存の検証では ax-controller を2レプリカにし、同じ Task に対して8回 ax apply を実行しました。以下の出力では2つのコンシューマーが登録され、どちらも pending が0であることを確認できます。これは均等配分や exactly-once 処理を示すものではありません。 $ kubectl -n ax-system scale deploy/ax-controller --replicas=2 $ redis-cli XINFO CONSUMERS ax:stream:tasks ax-controllers name ax-controller-79c5679f9f-jj2sg-7357 pending 0 idle 279 name ax-controller-79c5679f9f-r74l6-2741 pending 0 idle 1195 (出力はinactiveを省略しています) worker.goのRunは、processEventが失敗した場合もログに出力したうえで、必ずイベントをACKします。失敗したイベントがキューに残り続けて、後続のイベントの処理を止めることを避けるためです。 if err := w.processEvent(ctx, ev); err != nil { slog.Error("error processing task event", "id", ev.ID, "atespace", ev.Atespace, "name", ev.Name, "action", ev.Action, "error", err, ) } if err := sub.Ack(ctx, ev); err != nil { slog.Warn("failed to acknowledge task event", "id", ev.ID, "error", err) } (参考: internal/controller/worker.go#L92-L103) 失敗したイベントを自動で再投入する処理はありません。すでに別のイベントがあれば処理されますが、それもなければ新しいイベントの発行が必要です。kindでも、失敗したTaskのイベントがACKされた状態(pending 0)のままFailedで止まり、ax resumeやax applyを実行して新しいイベントが発行されるまで、再調整は動きませんでした(8.6節)。 2.4 削除と状態変更の通知 ax deleteは、Taskのレコードをすぐには消しません。MarkTaskDeletingでフェーズをTerminatingにして、削除イベントを発行します。 pipe := s.client.TxPipeline() pipe.Set(ctx, s.taskKey(atespace, name), data, s.opts.TTL) pipe.Publish(ctx, s.taskPubSubChannel(atespace, name), data) pipe.XAdd(ctx, &redis.XAddArgs{ Stream: s.opts.StreamName, Values: map[string]interface{}{ "action": "delete", "atespace": atespace, "name": name, }, }) (参考: internal/store/redis/store.go#L284-L317) ax-controller は、削除イベントを受け取ると Actor を削除し、Task 用 ActorTemplate の削除を試みてから、Redis のレコードを消します。 if err := w.reconciler.ReconcileDelete(ctx, ev.Atespace, ev.Name); err != nil { // Leave the record in Terminating so the failure is visible; re-running // `ax delete` republishes the event and retries the cleanup. return fmt.Errorf("cleaning up task %s/%s: %w", ev.Atespace, ev.Name, err) } if err := w.store.DeleteTask(ctx, ev.Atespace, ev.Name); err != nil { (参考: internal/controller/worker.go#L107-L119) Actor の削除が失敗すると Terminating のまま残り、ax delete を再実行すると再試行できます。一方、ActorTemplate の削除エラーは警告ログに記録するだけで、Task の削除は続行します。Task が一覧から消えたことだけでは、関連テンプレートがすべて削除されたとは限りません。 (参考: reconciler.go#L390-L405) ax watchが使うWatchTaskも、この仕組みの上にあります。RedisのPub/Subを購読して、最初に現在のTaskをINITIALとして送り、以降は変更をMODIFIEDとして流します。更新通知で Task のフェーズが Running・Failed・Completed になるとストリームを閉じます。初回の INITIAL 送信では同じ終了判定を行わないため、すでに Running の Task を watch した場合は次の通知を待つことがあります(参考: internal/server/server.go#L201-L238)。ブログの「Connection recovery」のように、最後に受信した位置から応答を補完する仕組みではありません。 第3章: Task・Workspace・Model この章では、AXが扱う3種類のリソースを、docs/concepts.mdとmanifests.mdをもとに整理します。いずれのリソースも、ax.io/v1alpha1のマニフェストとしてax applyで適用し、Atespace(Agent Substrate 上の論理的なグループと名前の識別範囲)ごとに管理されます。Kubernetes namespace や、認可・隔離の保証とは区別します。 3.1 Task Taskは、隔離された実行の最小単位です。 > The smallest unit of isolated execution. A Task declares the container image and command, compute requests and limits, environment variables, and references to one or more Workspaces under spec.workspaces. Each workspace is mounted at its own path, and the first serves as the command's working directory. (参考: docs/concepts.md#L7) 最小の Task は、イメージと環境変数だけで書けます。以下は同梱例で、8章では検証環境に合わせた runner イメージへ差し替えます。command がない場合は、runner がメタデータを提供しながら待機し、LLM を自動で呼び出すわけではありません。 apiVersion: ax.io/v1alpha1 kind: Task metadata: name: simple-task atespace: default spec: image: "gcr.io/ax-substrate/ate-images/ax-task-runner@sha256:3a0dea6ad8b55278685db58aca6e37dc4ba04056831d45bef3aaeafdca43cac6" env: - name: ENVIRONMENT value: "test" debug: true # serve guest services so `ax ssh` works; off by default (参考: examples/simple.yaml) status.phaseは、Pending・Running・Suspended・Failed・Terminatingなどの1語で、詳細はconditionsに入ります。ax watchで遷移を見られます。 stateDiagram-v2 [*] --> Pending : ax apply Pending --> Running : Resumeに成功 Pending --> Failed : 再調整に失敗 Running --> Suspended : ax suspend Suspended --> Running : ax resume Running --> Failed : 再調整に失敗 Suspended --> Failed : 再調整に失敗 Failed --> Running : ax resumeまたはax apply Pending --> Terminating : ax delete Running --> Terminating : ax delete Suspended --> Terminating : ax delete Failed --> Terminating : ax delete Terminating --> [*] : ActorとActorTemplateを削除後 (参考: 初期値のPendingはstore.go#L142-L144、Failed・Suspended・Runningはreconciler.go#L193-L218、Terminatingはstore.go#L284-L317。FailedからRunningへの遷移は、kindでの確認結果をもとにした図示です) conditionsは2種類です。 | Condition | Trueになる条件 | |---|---| | WorkspaceReady | すべてのworkspaceのセットアップが終わった。以降はTrueのまま | | Ready | Actor が実行中で、controller が WorkspaceReady を True と認識している | (参考: docs/concepts.md#L15-L18) Running や Ready=True は、タスクのコマンドが成功したことや、Git の取得が成功したことを保証しません。5.1節・5.2節で実際の判定内容を説明します。 3.2 Workspace Workspaceは、エージェントが作業を始められる状態にするための準備(Gitリポジトリのclone、MCPサーバー、skill)を、宣言的に1回だけ書いておくためのリソースです。 > A Workspace populates the filesystem and tool landscape. Declare it once, bind it from as many tasks as you like, and the runner materializes it inside each sandbox before the command starts: > > - Git repositories cloned into subdirectories of the workspace path. > - MCP servers and registries (Model Context Protocol) that the agent can call. > - Skill registries and the path where skills are materialized. (参考: docs/concepts.md#L26-L30) Taskとの紐づけにはgoalも指定できます。goalは、環境準備を自然言語で書いたもので、最初の起動時にエージェントへ渡され、ツールチェインや依存関係のインストールなどを任されます(5.2節)。 spec: workspaces: - name: my-service # mounted at /workspace/my-service, the command's working directory goal: "Install dependencies and run the test suite" - name: team-tools path: "/workspace/tools" # explicit mount path (参考: docs/manifests.md#L40-L47) ただし、v0.3.1のrunnerのコードで実際に処理しているのは、Gitのclone、skillsのディレクトリの作成、goalのAntigravityへの引き渡しです(参考: internal/workspace/setup.go#L83-L124)。mcpやskills.registriesの中身は、ax describe workspaceの表示と、Taskの中の/metadata/v1alpha1/ax/workspacesが返すYAMLには含まれますが、runnerがそれをファイルに書き出す処理は見つかりませんでした。 3.3 Model Modelは、LLMそのものではなく、名前を付けたモデルの設定です。 > A Model is not a model. It is a named model configuration: which provider to call, which model identifier to use, provider-specific generation parameters such as temperature, and a reference to the Kubernetes secret holding the API key. (参考: docs/concepts.md#L36) apiVersion: ax.io/v1alpha1 kind: Model metadata: name: default-model atespace: default spec: provider: google model: gemini-3.8-flash secretKey: name: gemini-api-secret key: GEMINI_API_KEY (参考: docs/manifests.md#L89-L101) 同梱ドキュメントは provider に google と anthropic を挙げています(参考: docs/manifests.md#L81-L123)。concepts.mdは、この設定を使う場面として「goalからworkspaceを計画するとき」を挙げていますが、v0.3.1では、Modelを読み込むPlanner(internal/workspace/planner.go)は、定義されているだけで、controllerやrunnerからは呼ばれていません。goalの環境準備に実際に使われるAPIキーは、Model リソースではなく、Atespace と同名の Kubernetes namespace にある gemini-api-secret の GEMINI_API_KEY です。取得できない場合は controller 自身の GEMINI_API_KEY 環境変数へフォールバックします。取得した値は Task のコンテナの環境変数へ渡されます(参考: reconciler.go#L352-L368)。 第4章: TaskがActorになるまで この章では、ax-controllerが1つのTaskを再調整する流れを、reconciler.goの実装に沿って整理します。 4.1 再調整の流れ ax-controllerのReconcileは、Taskを受け取って、Agent SubstrateのControl APIを次の順に呼びます。 sequenceDiagram participant W as ax-controller participant S as Agent Substrate<br/>Control API participant A as Actor<br/>(ax-task-runner) W->>S: EnsureAtespace W->>S: ActorTemplateを作成(Task単位) W->>S: EnsureActor(名前はTaskと同じ) alt spec.suspend が true W->>S: SuspendActor Note over W: phase = Suspended else W->>S: ResumeActor S-->>W: workerのIP loop WorkspaceReadyになるまで(最大15秒) W->>A: GET /readyz?check=workspace end Note over W: phase = Running end (参考: internal/controller/reconciler.go#L95-L302) 1. atespaceの作成: EnsureAtespaceで、Taskが属するatespaceをSubstrate側に用意します(参考: reconciler.go#L119-L124)。 2. Actorの名前: Actorの名前は、常にTaskの名前と同じです。atenet-routerのate-target-actorヘッダーにも、そのまま使えます。 // 2. The actor is always named after the task, so the two can be used // interchangeably (for example in the router's ate-target-actor header). actorName := task.Metadata.Name task.Status.Actor = actorName (参考: reconciler.go#L126-L130) 3. ActorTemplate: Taskごとに専用のActorTemplateを作ります(4.2節)。 4. Actor: EnsureActorでActorを作ります。すでに存在し、状態がCRASHEDなら削除して作り直します(参考: internal/substrate/client.go#L306-L366)。 5. Suspend/Resume: spec.suspendがtrueならSuspendActor、そうでなければResumeActorを呼びます。ResumeActorのレスポンスに含まれるworkerのIPが、ax get tasksのWORKER-IP列になります。 6. readyzの確認: Resumeの後、Actorの中のrunnerの/readyz?check=workspaceを500msごとに確認します。ワークスペースのセットアップは1回しか行われないため、WorkspaceReadyが一度Trueになった後は、このポーリングは行いません(参考: reconciler.go#L220-L291)。 Substrateの認証には、ax-controllerのPodに投影されたServiceAccountのトークン(audienceはapi.ate-system.svc)を使い、SubstrateのサーバーのTLS証明書は、servicedns.podcert.ate.dev/identityというsignerのClusterTrustBundleで検証します(参考: deploy/ax-controller.yaml#L69-L119)。このsignerは、Agent Substrateの概要で整理したpodcertcontrollerが提供しているものです。 4.2 Taskごとに作られるActorTemplate ActorTemplateは、Taskごとに作られます。名前は、Taskの名前と、イメージ・環境変数のダイジェストから決まります。 // taskTemplateName derives the per-task ActorTemplate name from the task name and a // digest of the image and container environment, so a spec change yields a new template. func taskTemplateName(taskName, image string, env map[string]string) string { (参考: reconciler.go#L371-L385) 環境変数には、ユーザーがspec.envに書いたものに加えて、Taskの起動設定(AX_TASK_YAML)とバインドしたWorkspaceのYAML(AX_WORKSPACES_YAML)も含まれます。ただし、statusとsuspendのフラグは除かれています。suspendを切り替えるたびに新しいゴールデンスナップショットが作られることを避けるためです。 // Only launch configuration belongs in the template; status and suspend // changes must not create new golden snapshots. launchTask := proto.Clone(task).(*v1alpha1.Task) launchTask.Status = nil launchTask.Spec.Suspend = false if taskYAML, err := yaml.Marshal(launchTask); err == nil { extraEnv["AX_TASK_YAML"] = string(taskYAML) } (参考: reconciler.go#L157-L169) 起動設定を変更すると、新しい名前の ActorTemplate が作られます。ただし、EnsureActor は既存 Actor が正常ならその Actor を返し、参照するテンプレートを更新しません。新しい ActorTemplate の作成と、既存 Actor への設定反映は別です。ax apply を Deployment のローリングアップデートと同じ動作として扱うことはできません。 (参考: client.go#L306-L365) kindで2つのTaskを作ったときは、simple-task-tmpl-5200c4f4のような名前のテンプレートが、Taskごとに1つずつ作られました。削除時は、名前のパターンに一致するテンプレートをすべて消します(参考: reconciler.go#L409-L445)。 4.3 ActorTemplateの中身 ActorTemplateは、substrate.BuildActorTemplateが組み立てます。 Containers: []*ateapipb.Container{{ Name: "guest", Image: image, Command: command, Env: envList, Readyz: &ateapipb.ContainerReadyz{ HttpGet: &ateapipb.HTTPGetAction{ Path: "/readyz", Port: 80, }, }, VolumeMounts: []*ateapipb.VolumeMount{{ Name: "workspace", MountPath: "/workspace", }}, }}, Volumes: []*ateapipb.Volume{{ Name: "workspace", DurableDir: &ateapipb.DurableDirVolumeSource{}, }}, (参考: internal/substrate/client.go#L212-L262) コンテナのコマンドは常に/usr/local/bin/ax-task-runnerで、/workspaceはdurableDirのボリュームとしてマウントされます。ユーザーがspec.commandに書いたコマンドは、コンテナのエントリポイントにはならず、AX_TASK_YAML経由でrunnerが受け取って実行します(5.1節)。 4.4 スナップショットとsandboxの設定 スナップショットとsandboxの設定は、次のようにコードに固定されています。 SnapshotsConfig: &ateapipb.SnapshotsConfig{ StorageLocation: snapshotsBucket, OnPause: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA, OnCommit: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA, OnResume: &ateapipb.OnResumeConfig{ FromData: ateapipb.ResumeSource_RESUME_SOURCE_GOLDEN, }, }, SandboxConfig: &ateapipb.SandboxConfig{ SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default", }, (参考: internal/substrate/client.go#L263-L274) Agent Substrateのprotoのコメントには、SNAPSHOT_CONTENT_SCOPE_DATAとRESUME_SOURCE_GOLDENが、次のように説明されています。 > Captures durable data without process memory or root filesystem changes. (参考: agent-substrate/substrate ateapi.proto#L194-L195) > Restores with the version's golden snapshot and the actor's own durable data. (参考: agent-substrate/substrate ateapi.proto#L889-L890) つまり、Suspend時に保存されるのは、プロセスのメモリやルートファイルシステムの変更を除いた、durableなデータ(/workspace)だけです。Resume時は、そのバージョン(ActorTemplate)のゴールデンスナップショットと、Actor自身のdurableなデータから復元されます。 保存するデータと、復元の土台を分けると次のようになります。 flowchart LR subgraph Before["Suspend 前の Actor"] Data["/workspace<br/>作業ファイル"] Volatile["実行中のメモリ<br/>/tmp などの変更"] end Saved[("Actor 自身の<br/>durable data")] Golden[("ActorTemplate の<br/>golden snapshot")] NotSaved["中断時の状態として<br/>保存しない"] Restored["Resume 後の Actor<br/>golden snapshot を土台に<br/>/workspace のデータを復元"] Data -->|Suspend: DATA| Saved Volatile -.-> NotSaved Saved --> Restored Golden -->|Resume: GOLDEN| Restored AX v0.3.1 の固定設定を簡略化した図。中断直前のプロセスメモリを保存する方式とは異なります。 (参考: client.go#L249-L274、ateapi.proto#L889-L890) Agent Substrateの概要で動かしたCounterデモは、SNAPSHOT_FIDELITY_MEMORYでプロセスのメモリごと保存していました。AXは、メモリではなく/workspaceのデータだけを保存する設定を選んでいます。これはdocs/runner.mdにも書かれています。 > The /workspace volume is what survives suspend and resume. Agent Substrate snapshots it when a task is suspended and restores it into a fresh container when the task is resumed, so the runner will see the same files but a new process tree. (参考: docs/runner.md#L26) スナップショットの保存先は、AX_SNAPSHOTS_BUCKET環境変数で指定します。未指定のときのデフォルト値は、コード内に固定されたバケットのURIです(参考: internal/substrate/client.go#L205-L228)。deploy/ax-controller.yamlも、同じバケットをAX_SNAPSHOTS_BUCKETに設定しているため(参考: deploy/ax-controller.yaml#L82-L83)、自分の環境にデプロイするときは、この値を差し替える必要があります(8.1節)。 第5章: Taskのコンテナの中 この章では、ActorとしてSubstrate上で動くコンテナの中で、ax-task-runnerが何をしているかを整理します。 5.1 runnerの契約 ax-task-runnerは、各Taskのコンテナで、PID 1として動くプログラムです。コントローラーは、spec.commandをコンテナのエントリポイントとして実行せず、常にrunnerを起動し、設定を環境変数で渡します。 | コントローラーが設定するもの | 値 | |---|---| | コンテナのコマンド | 常に/usr/local/bin/ax-task-runner | | AX_TASK_YAML | Taskの起動設定(statusとsuspendのフラグを除く)のYAML | | AX_WORKSPACES_YAML | バインドしたWorkspaceの、複数ドキュメントのYAML | | /workspace | durableなディレクトリとしてマウント | | readiness probe | ポート80のGET /readyz | (参考: docs/runner.md#L13-L22) runnerには、次の動作が求められます。 - ポート80でHTTPを提供し、/healthzと/readyz(ワークスペースの準備ができるまで503)、/metadata/v1alpha1/ax/taskと/metadata/v1alpha1/ax/workspacesを返す - 各workspaceを1回だけ準備する(再起動のたびに再準備しない) - spec.commandを、最初のworkspaceを作業ディレクトリにして子プロセスとして実行し、監視する - コマンドが終了しても動き続ける(PID 1が終了するとメタデータサーバーも止まり、ax sshが使えなくなるため) - SIGTERMを受けたらコマンドのプロセスグループに転送し、猶予期間の後にSIGKILLする - spec.debugがtrueのときだけ、ゲストサービスを提供する (参考: docs/runner.md#L28-L47) コマンドの終了後もrunnerが動き続ける点は、コードにも現れています。 select { case err := <-exited: reportExit(cfg, cmd, err) // Keep the sandbox up and inspectable until told to stop. <-ctx.Done() (参考: runner/runner.go#L190-L196) docs/runner.mdには、「コマンドの終了ステータスをコントロールプレーンは現状読み取らない」とも書かれています(参考: docs/runner.md#L43)。Taskのフェーズは、コマンドが成功したかどうかとは別に決まります。 runnerは、同梱のデフォルト実装を使わずに置き換えることもできます。イメージを継承する、runnerパッケージを自分のバイナリに埋め込む、まったく別の言語で書く、の3段階があり、いずれも/usr/local/bin/ax-task-runnerに実行ファイルを置く契約を満たす必要があります(参考: docs/runner.md#L58-L134)。 5.2 ワークスペースの準備 ワークスペースの準備は、各workspaceのパスごとに、最初の1回だけ実行されます。Gitリポジトリのclone(失敗時は5回までリトライ)、skillsのディレクトリの作成、goalが指定されていればAntigravityへの引き渡しの順です。完了すると、/axの下にマーカーファイルを書き、以降の起動ではスキップします。 markerPath := filepath.Join(AXDir, MarkerName(targetPath)) if _, err := os.Stat(markerPath); err == nil { slog.Info("workspace already initialized; skipping maiden run setup", "path", targetPath) return res, nil } (参考: internal/workspace/setup.go#L83-L124) goalの引き渡しは、antigravity_bootstrap.pyというスクリプトに対して、GEMINI_API_KEYが環境変数にある場合に、10分(AX_BOOTSTRAP_TIMEOUTで変更可)の制限時間で実行します。スクリプトかGEMINI_API_KEYがなければ、ログを出してこのステップをスキップします。 cmd := exec.CommandContext(ctx, "python3", bootstrapScriptPath, "--goal", goal, "--workspace", targetPath, "--data-dir", dataDir, ) (参考: internal/workspace/setup.go#L277-L315) Gitのcloneに失敗しても、セットアップ全体は失敗扱いになりません。失敗はログとして/ax/git-error.logに残り、マーカーファイルだけが書かれません。コードのコメントは、これによって次の起動で再試行されることを意図しています。ただし、kindで試した限りでは、Resumeしてもcloneは再試行されませんでした(8.5節)。runner に伝わるセットアップエラーは、作業ディレクトリの作成失敗などです。この結果、Git の取得失敗が WorkspaceReady に反映されない場合があります。8.5節で、kindでこの動作を確認しています。 5.3 ax sshの仕組み ax sshは、SSHではなく、Agent Substrateのゲストサービス(agent-substrate/env)のgRPCを使います。spec.debug: trueのときだけ、runnerが同じポート80で、HTTPとh2cを多重化してこのサービスを提供します。 > - Process service: start, inspect, stream output from, and kill processes. This is what powers ax ssh. > - File system service: streaming file reads and writes inside the workspace. (参考: docs/sandbox.md#L32-L33) CLIは、kubectl port-forwardでatenet-routerへ接続し、ate-target-actorヘッダーにTaskの名前を入れて、プロセスの開始と出力のストリームを呼び出します(参考: cmd/ax/main.go#L1084-L1094、internal/guest/client.go#L45-L73)。spec.debugがfalseのTaskには、CLIの側で接続を拒否します(参考: cmd/ax/main.go#L1045-L1051)。 ゲストサービスは、任意のプロセス実行とファイルアクセスを許すため、デフォルトでは無効です。 5.4 ネットワーク Taskには、KubernetesのServiceやIngressはありません。通常のクライアントから Task へ接続する方法として、Agent Substrate の atenet-router(ate-system名前空間のatenet-routerサービス)を使い、ate-target-actor: <atespace>/<task> ヘッダーで宛先を指定します。controller の準備完了チェックには worker IP への直接接続もあります。宛先のActorが停止中なら、先に Resume してからリクエストを転送します。これは AX の spec.suspend や Task status を更新する操作ではないため、Actor と Task の表示が一致するとは限りません(参考: docs/networking.md#L3-L5)。 第6章: 構築にあたって この章では、AXをデプロイするために必要なものと、v0.3.1の時点で注意しておきたい点を整理します。 6.1 前提となるもの READMEは、AXを動かす前提として、Agent Substrateがクラスタで動いていることを挙げています。 > AX schedules every task as a sandboxed actor on Agent Substrate, so Substrate must be running in your cluster before you deploy AX. (参考: README.md#L63) 実際にkindで動かしたところ、次のものを別途用意する必要がありました。 | 追加で用意するもの | 役割 | 備考 | |---|---|---| | Agent Substrate | Actorの実行基盤 | AX v0.3.1のgo.modが固定するコミットを使った(Agent Substrate v0.5.0との組み合わせは確認していない) | | WorkerPool | Actorを載せるworkerの集まり | AXのリポジトリにはWorkerPoolの記述がなく、デプロイ手順にも含まれない。無いとno free workers availableでResumeに失敗する | | スナップショットの保存先 | Actorのスナップショットの保存先 | AX_SNAPSHOTS_BUCKETで差し替える。デフォルトはコード内に固定された値 | | Taskのruntimeイメージ | /usr/local/bin/ax-task-runnerを含むイメージ | デフォルトのイメージはgcr.io/ax-substrate/...から取得される。make build-task-runnerはlinux/amd64向けにビルドするため(参考: Makefile#L46-L51)、arm64の環境では自分でビルドする必要があった | | Redis | Task・Workspace・Modelの状態とイベント | deploy/redis.yamlで用意できる。レプリカ1、永続ボリュームなし | (参考: Redisはdeploy/redis.yaml。WorkerPoolが記述に無いことは、リポジトリ内をWorkerPoolで検索した結果) 6.2 v0.3.1で注意しておきたい点 - Task のリソース設定と、実際の制限を分けて確認します。spec.resources は API に定義されていますが、このタグの BuildActorTemplate はその値を ActorTemplate のリソース設定へ渡していません。WorkerPool の Pod 制限が、そのまま Task ごとの指定値になるわけではありません。(参考: ax.proto#L71-L111、client.go#L212-L275) - Streams の未 ACK イベントを回収する処理は確認できませんでした。新しいコンシューマーグループは $ から作られ、読み取りは > の新規イベントだけを対象にします。初回起動前のイベントや、コンシューマー停止で pending に残ったイベントを自動復旧できるとは限りません。(参考: store.go#L585-L659) - WorkspaceReady の待機には上限があります。ポーリングが時間切れになっても phase は Running のまま、Ready は False になります。後から runner の準備が終わっても、そのことだけで再調整を起こす処理はありません。(参考: reconciler.go#L220-L302) - ax-server に認証処理がありません。grpc.NewServer()にオプションを渡しておらず、平文のHTTP/2(h2c)でポート8080を待ち受けます(参考: internal/server/server.go#L38-L46、cmd/ax-server/main.go#L66-L74)。ax CLIはデフォルトでkubectl port-forwardを使ってax-system名前空間のax-serverサービスへ接続するため(参考: internal/tunnel/tunnel.go#L181-L200)、CLI経由のアクセスは、そのport-forwardを許可するKubernetesのRBACに依存します。クラスタ内の他のPodからax-serverへ直接到達できるかどうかは、NetworkPolicyなどクラスタ側の設定次第です。認証・ガバナンスは、ロードマップ(第7章)の項目に入っています。 - Redisは単一レプリカで、永続ボリュームを持ちません。 deploy/redis.yamlはレプリカ1のDeploymentで、ボリュームがありません。確認したRedisの設定はappendonly no、save 3600 1 300 100 60 10000(デフォルトのRDBスナップショットのみ)でした。Redis Podが再作成されると、Task・Workspace・Modelの状態が失われる可能性があります。Redisをどう構成するかは、利用者が決める前提のようです。 - 失敗したイベントの自動再投入はありません。2.3節で見た通り、失敗時も ACK を試みます。別のイベントがキューにない場合は、ax resume や ax apply などで新しいイベントを発行する必要があります。 - controller は1つのイベントを同期的に処理します。Worker.Runのループは、イベントを1つ取り出し、processEventが終わってから次を読みます。ResumeActorとWorkspaceReadyのポーリング(最大15秒)がこのループの中で行われるため、スケールはレプリカを増やすことで行います。 - Task の status.pendingApproval と status.usage は、このタグで利用する処理を確認できませんでした。 spec.policies(予算・承認の設定)は、過去のバージョンから削除済み(reserved)です(参考: pkg/apis/v1alpha1/ax.proto#L85-L87)。 第7章: ロードマップ この章では、docs/roadmap.mdに書かれている今後の予定のうち、ブログの機能に関係するものを整理します。 ロードマップは5つの項目に分かれています(参考: docs/roadmap.md)。 | 項目 | 内容 | |---|---| | Stabilize Core Specs | Task・Workspace・Model・Sandbox/SandboxConfigのスキーマとライフサイクルの安定化。Taskのトークン/タイムアウトの予算や承認ポリシーも含む | | Actor Architecture | 新しいAgent SubstrateのActor APIへの移行、ワークスペースのセットアップを別Actorに分離、最小権限のポリシー、アイドル検出と自動Suspend、Taskの分岐 | | Agentic Environment | goalによるワークスペースの動的な環境構築、組み込みのブートストラップとエージェントハーネスのカスタマイズ | | Networking, Identity, Governance & Observability | TaskへのSPIFFE ID、MCPとskill registryのガバナンス、OpenTelemetryによるテレメトリと軌跡の収集 | | Documentation | Substrateとの役割分担、カスタムイメージ、カスタムrunnerのドキュメント | ブログが挙げる機能との関係では、次の項目が対応します。 > - Idleness Detection and Automatic Suspension for Density: Continuously monitor actor activity (process execution, I/O, network traffic, and active gRPC/SSH sessions) to detect idle tasks and automatically trigger SuspendActor checkpointing, reclaiming worker CPU and memory to maximize cluster density. > - Stateful Task Branching: Enable branching (forking) a running or suspended Task—including its checkpointed memory and workspace filesystem state—into multiple parallel tasks to explore speculative execution paths concurrently. (参考: docs/roadmap.md#L22-L23) ブログの「Trajectory branching」は、ロードマップの「Stateful Task Branching」にあたります。v0.3.1 はアイドルを自動検出して Suspend する実装ではありません。CLI や上位システムからの明示的な指示が必要です。「Migration to the New Actor」は、AXがAgent Substrateの新しいActor APIへ移行する予定を示しています。今回の検証では、go.modが固定するコミットのAgent Substrateを使いました。 第8章: kindクラスタで実際に動かす この章では、既存の kind 検証記録に、再現に必要な準備と待機手順を補って整理します。arm64 環境で Task の起動・停止、保存されるデータ、失敗時の状態を確認したものです。LLM を使う goal の実行や大規模な負荷試験は対象外です。 8.1 構築 AX v0.3.1 の go.mod は Go 1.27.1 を指定しています。Docker・kubectl・ko と、この Go バージョンを使える環境を用意します。以下は arm64 用の手順です。 Agent Substrate は依存先コミットで構築します。既存の checkout を変更せずに済むよう専用ディレクトリを作ります。クラスタ作成スクリプトは同名の既存クラスタを削除するため、ax-demo が既存環境と重複しないことを確認します。 mkdir ax-kind-review cd ax-kind-review git clone https://github.com/agent-substrate/substrate.git substrate-pinned cd substrate-pinned git checkout --detach 672533541dbf export KIND_CLUSTER_NAME=ax-demo hack/create-kind-cluster.sh hack/install-ate-kind.sh --deploy-ate-system 続いて、AXのRedis、ax-controller、ax-serverをkoでビルドしてデプロイします。deploy/ax-controller.yamlが設定しているAX_SNAPSHOTS_BUCKETは、kindのRustFSのバケット(ate-snapshots)へ差し替えます。 cd .. git clone --branch v0.3.1 --depth 1 https://github.com/google/ax.git ax-v0.3.1 cd ax-v0.3.1 go install ./cmd/ax # Go のインストール先 bin ディレクトリを PATH に追加しておく kubectl apply -f deploy/redis.yaml export KO_DOCKER_REPO=localhost:5001 KO_DEFAULTPLATFORMS=linux/arm64 ko resolve -f deploy/ax-controller.yaml \ | sed 's|gs://dberkov-gke-dev3/ate-env/|gs://ate-snapshots/ax/|' \ | kubectl apply -f - ko resolve -f deploy/ax-server.yaml | kubectl apply -f - kubectl -n ax-system rollout status deploy/ax-redis --timeout=5m kubectl -n ax-system rollout status deploy/ax-controller --timeout=5m kubectl -n ax-system rollout status deploy/ax-server --timeout=5m kubectl -n ax-system get pods 以下は既存の実行結果の抜粋です。Pod 名は実行ごとに変わります。 NAME READY STATUS RESTARTS AGE ax-controller-79c5679f9f-jj2sg 1/1 Running 0 6s ax-redis-86df786c87-sj8z2 1/1 Running 0 30s ax-server-7f8fc9b657-6rdnr 1/1 Running 0 6s Taskのruntimeイメージは、デフォルトのイメージ(gcr.io/ax-substrate/...)を使わず、追加する AX の実行ファイルを /usr/local/bin/ax-task-runner だけにしたイメージを自分でビルドしました。ax-task-runnerをlinux/arm64向けにビルドし、gitを含むベースイメージにコピーして、kindのローカルレジストリにpushします。 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ax-task-runner ./cmd/ax-task-runner cat > Dockerfile.kind <<'EOF' FROM alpine/git:latest COPY ax-task-runner /usr/local/bin/ax-task-runner ENTRYPOINT ["/usr/local/bin/ax-task-runner"] EOF docker build --platform linux/arm64 -f Dockerfile.kind -t localhost:5001/ax-task-runner:kind . docker push localhost:5001/ax-task-runner:kind RUNNER_IMAGE=$(docker image inspect localhost:5001/ax-task-runner:kind --format '{{index .RepoDigests 0}}') RUNNER_IMAGE には完全な digest 付きの参照が入ります。続けて、検証用の simple.yaml を作ります。 cat > simple.yaml <<EOF apiVersion: ax.io/v1alpha1 kind: Task metadata: name: simple-task atespace: default spec: image: "${RUNNER_IMAGE}" env: - name: ENVIRONMENT value: test debug: true EOF :::note info このイメージには、デフォルトのイメージに入っているPythonとAntigravity(google-antigravity)が含まれていません。そのため、goalを指定したワークスペースのブートストラップ(5.2節)は、今回の検証の対象外です。 ::: 最初の Task を適用する前に、次の出力に ax-controllers グループがあることを確認します。グループは controller が起動時に作るため、未作成なら controller のログを確認して待ちます。 kubectl -n ax-system exec deploy/ax-redis -- redis-cli XINFO GROUPS ax:stream:tasks AX の CLI は、通常は現在の kubeconfig context から ax-server へのトンネルを自動作成します。以降は kind-ax-demo を対象とし、別の接続先を指定する AX_SERVER や --server を使わない前提です。 8.2 WorkerPoolが無いとResumeに失敗する デプロイ直後に、READMEのとおりax applyでTaskを適用すると、TaskはFailedになりました。 $ ax apply -f simple.yaml # image は上でpushした digest 指定のイメージ task.ax.io/simple-task created $ ax describe task simple-task Conditions: TYPE STATUS REASON MESSAGE Ready False ActorResumeFailed resuming actor default/simple-task: rpc error: code = ResourceExhausted desc = no free workers available ActorTemplateとActorの作成には成功し、ResumeActorの段階で、Actorを載せるworkerがない、というエラーになっています。Agent Substrateでは、ActorはWorkerPoolのworkerに載せられます。kind向けのインストーラが用意するのはシステムコンポーネントまでで、WorkerPoolは含まれていません。AXのリポジトリにも、WorkerPoolを作る手順はありません。以下を ax-workers.yaml として AX 側のディレクトリに保存します。nodeSelector のバージョン値は当時の記録です。再現時は kubectl get nodes -L ate.dev/substrate-version で、インストーラが付けた値と一致するか確認します。 apiVersion: v1 kind: Namespace metadata: name: ax-workers --- apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: name: ax-workers namespace: ax-workers spec: replicas: 2 workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor template: nodeSelector: ate.dev/substrate-version: "67253354" resources: limits: cpu: "1" memory: 1Gi requests: cpu: 250m memory: 1Gi (参考: demos/counter/counter.yaml.tmplをもとに、名前とreplicasを変えたもの。workerImageのko://は、ko resolveでイメージに置き換えて適用する) ko:// の参照は、その Go パッケージを含む Substrate 側の checkout で解決します。 cd ../substrate-pinned ko resolve -f ../ax-v0.3.1/ax-workers.yaml | kubectl apply -f - kubectl -n ax-workers get pods -w 2つの worker Pod が Ready になったら Ctrl-C で監視を終了し、AX のディレクトリへ戻ります。 cd ../ax-v0.3.1 ax apply -f simple.yaml ax get tasks ax describe task simple-task WorkerPool を作った後、ax apply をもう一度実行した既存の記録では、TaskがRunningになり、ReadyがTrueになりました。 $ ax get tasks NAME ATESPACE PHASE ACTOR WORKER-IP AGE simple-task default Running simple-task 10.244.0.24 2m $ ax describe task simple-task Conditions: TYPE STATUS REASON MESSAGE WorkspaceReady True SetupComplete Workspace setup completed at 10.244.0.24 Ready True TaskRunning Task is running and its workspace is ready ax sshで中に入ると、PID 1がax-task-runnerで、spec.envの値とAX_METADATA_URLが設定されていることを確認できました。 $ ax ssh simple-task -- sh -c 'echo $ENVIRONMENT; echo $AX_METADATA_URL; ps -o pid,args | head' test http://127.0.0.1:80 PID COMMAND 1 /usr/local/bin/ax-task-runner 11 sh -c echo $ENVIRONMENT; echo $AX_METADATA_URL; ps -o pid,args | head 12 ps -o pid,args 13 head 8.3 Redisの状態 Redis の内容は、たとえば kubectl -n ax-system exec deploy/ax-redis -- redis-cli XLEN ax:stream:tasks で参照できます。以下の redis-cli コマンドは、その Redis コンテナ内での実行を省略して表記しています。 ax apply を3回実行した時点の既存の記録では、次の状態でした。追加で適用すると件数は変わります。ax applyのたびに、内容が変わらなくても、Streamにイベントが1件追加されます。コンシューマーグループのpendingは0です。 $ redis-cli XLEN ax:stream:tasks 3 $ redis-cli XINFO GROUPS ax:stream:tasks name ax-controllers consumers 1 pending 0 last-delivered-id 1792367750651-0 entries-read 3 lag 0 $ redis-cli TTL ax:task:default:simple-task -1 最初の2回はFailedで終わっていますが、pending 0なので、失敗したイベントもACKされています(2.3節)。TTL は設定されておらず(-1)、期限による自動削除はされません。ただし、Redis 自体の再作成によるデータ消失とは別です。 8.4 Suspend/Resumeで残るもの ax suspend / ax resume の返却は API の受付を示します。実際の状態は ax get tasks と ax describe task で確認し、Suspended への遷移後に Resume、Ready=True の確認後に接続します。ax watch は Ready=False の Running 通知でも終了する場合があるため、終了だけを準備完了と判断しません。 Suspend/Resumeで何が残るかを確認します。/workspace と /tmp にファイルを書き、バックグラウンドで sleep を起動してから、ax suspend と ax resume を実行した記録です。このときはバックグラウンドプロセスが標準出力を保持したため、ax ssh の終了時に stream terminated by RST_STREAM が出ました。再現時はプロセスの標準入出力をリダイレクトすると、出力ストリームの保持を避けられます。 $ ax ssh simple-task -- sh -c 'echo "before suspend" > /workspace/note.txt; echo "volatile" > /tmp/volatile.txt; (sleep 3000 &)' $ ax suspend task simple-task task.ax.io/simple-task suspended $ ax get tasks NAME ATESPACE PHASE ACTOR WORKER-IP AGE simple-task default Suspended simple-task <none> 4m $ ax resume task simple-task task.ax.io/simple-task resumed $ ax ssh simple-task -- sh -c 'ls /workspace; cat /workspace/note.txt; ls /tmp; ps -o pid,args' note.txt before suspend PID COMMAND 1 /usr/local/bin/ax-task-runner 9 ps -o pid,args /workspace/note.txtだけが残り、/tmp/volatile.txtとsleepプロセスは消えました。SuspendでWORKER-IPが<none>になり、Resumeではworkerが変わることもあります(この例では10.244.0.24から10.244.0.23へ変わりました)。これは、/workspaceのデータだけを保存して復元する設定(4.4節)の動作どおりです。Agent SubstrateのCounterデモのように、プロセスのメモリが引き継がれる動作ではありません。 8.5 Git の取得失敗と準備完了判定 Workspace で Git リポジトリを指定した Task も試しました。以下の image は、8.1節で取得した完全な参照へ置き換えます。 apiVersion: ax.io/v1alpha1 kind: Workspace metadata: name: demo-ws atespace: default spec: git: - name: origin repo: "https://github.com/chalk/chalk.git" branch: "main" depth: 1 --- apiVersion: ax.io/v1alpha1 kind: Task metadata: name: ws-task atespace: default spec: image: "<8.1節のRUNNER_IMAGEの値に置き換える>" workspaces: - name: demo-ws debug: true Taskは数秒でRunningになり、ReadyもTrueになりましたが、中を見るとcloneは失敗していました。 $ ax ssh ws-task -- sh -c 'ls /workspace/demo-ws; ls /ax; cat /ax/git-error.log' chalk git-error.log error: git fetch: exit status 128 output: Reinitialized existing Git repository in /workspace/demo-ws/chalk/.git/ error: remote origin already exists. fatal: unable to access 'https://github.com/chalk/chalk.git/': Send failure: Broken pipe 5.2節で見た通り、Gitのエラーはセットアップ全体の失敗として扱われないため、WorkspaceReady: True / SetupCompleteと表示されながら、リポジトリは空のままでした。ログの Broken pipe だけでは、失敗原因を確定できません。egress 設定やプロキシ、接続先などの切り分けは未実施です。AXのリポジトリには、egressに関する記述がロードマップの1行(「setup egress」)しかなく(参考: docs/roadmap.md#L21)、egressポリシーを設定する手順はありません。外部のGitリポジトリをcloneするには、Agent SubstrateのEgressPolicyを別途設定する必要があると思います(今回は検証していません)。 cloneが失敗したあとにResumeしても再試行されるかを、Actorの中に作るローカルのGitリポジトリで確かめました。最初は存在しないパス(/workspace/seed.git)をWorkspaceのリポジトリに指定してTaskを起動し(cloneは失敗します)、起動後にActorの中でそのパスにbareリポジトリを作ってコミットを1つ入れてから、ax suspendとax resumeを実行しました。 $ ax ssh ws2 -- sh -c 'git init -q --bare -b main /workspace/seed.git; ...' # コミットv1を入れる $ ax suspend task ws2 # ax get tasks で Suspended を確認してから再開する $ ax resume task ws2 $ ax ssh ws2 -- sh -c 'ls -a /workspace/demo2/src; git -C /workspace/demo2/src log --oneline' . .. .git fatal: your current branch 'master' does not have any commits yet リポジトリが存在する状態でResumeしても、/workspace/demo2/srcは空のままで、/ax/git-error.logのタイムスタンプも最初の起動のときから変わっていませんでした。手動でgit fetch origin mainを実行すると取得できたため、リポジトリ自体は有効です。この観測では、Resume による Git 取得の再試行は確認できませんでした。Agent Substrateのprotoのコメントによると、RESUME_SOURCE_GOLDENの復元は、ActorTemplateのゴールデンスナップショットとActor自身のdurableなデータから行われます(4.4節)。runnerのプロセスは、ゴールデンスナップショットから復元されている可能性があります(今回は、それ以上の確認はしていません)。 8.6 失敗とリトライ kindでは、次の2つの失敗を確認しました。いずれも自動では再試行されず、ax resumeなどで新しいイベントを発行するまでFailedのままでした。 - workerの不足: workerが2つで、2つのTaskが実行中のときに3つ目のTaskを作ると、no free workers availableでFailedになりました。WorkerPoolのreplicasを4に増やしてからax resumeすると、Runningになりました。 - ゴールデンスナップショットの未完成: Task を作ってまもなく Suspend / Resume を実行すると、FailedPrecondition desc = a Golden data resume requires the ActorTemplate golden snapshot, which is not availableでFailedになりました。しばらく待ってからax resumeをやり直すと、Runningになりました。ActorTemplateのゴールデンスナップショットは、Taskの作成後に非同期で作られるため、その完成前にResumeすると失敗する、と考えられます(4.4節のprotoのコメントに合わせた解釈で、作成のタイミングそのものは確認していません)。 $ ax describe task ws-task Conditions: TYPE STATUS REASON MESSAGE WorkspaceReady True SetupComplete Workspace setup completed at 10.244.0.24 Ready False ActorResumeFailed resuming actor default/ws-task: rpc error: code = FailedPrecondition desc = a

    1 like
  10. Autonomous agent loops with Gemini 4 Argon and GKE Agent Sandbox: Running untrusted code safely at scale --- title: "Autonomous agent loops with Gemini 4 Argon and GKE Agent Sandbox: Running untrusted code safely at scale" published: true description: "Run untrusted LLM-generated Python and Bash code safely at scale using Gemini 4 Argon and GKE Agent Sandbox with gVisor isolation." tags: kubernetes, ai, gke, googlecloud cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com --- Autonomous AI systems are only as useful as the actions they can verify. With the release of Gemini 4 Argon, multi-step reasoning and native tool calling allow agents to write data transformation scripts, generate infrastructure migrations, and debug failing tests across long execution horizons. However, a fundamental architectural bottleneck emerges the moment the model outputs a block of Python or Bash: something has to execute that code and return the output so the model can iterate. Running LLM-generated code inside the same Kubernetes Pod as your orchestrator application is an immediate security liability. A hallucinated command, a prompt-injection payload, or a dependency conflict can corrupt the container filesystem, exfiltrate mounted service account credentials, or starve adjacent workloads of memory. Conversely, spinning up a fresh Kubernetes Job or virtual machine for every turn of a multi-step reasoning loop introduces 5 to 30 seconds of cold-start latency per iteration, stalling the agent workflow. By pairing a Gemini 4 Argon reasoning loop with GKE Agent Sandbox (Generally Available on GKE 1.32 and later across both GKE Autopilot and GKE Standard), platform teams can isolate every code execution step inside a kernel-hardened gVisor runtime with sub-second allocation from pre-warmed Pod pools. In this article, I will walk through the architecture of a self-correcting Gemini 4 Argon code execution loop on Google Kubernetes Engine (GKE), show how to configure warm sandbox pools across GKE Autopilot and Standard, and implement a Python orchestrator that claims, executes, and recycles isolated sandboxes. Architectural tension in multi-turn code execution When a Gemini 4 Argon agent solves an analytical or engineering task, it rarely stops at a single code snippet. Instead, it operates in an iterative loop: 1. Task submission: The client sends an analytical query or code generation request to the GKE orchestrator Pod. 2. Model reasoning and tool dispatch: The orchestrator invokes Gemini 4 Argon with an execute_python tool declaration. The model emits a tool call containing the proposed script. 3. Warm pool allocation: The orchestrator requests a pre-warmed sandbox from the GKE Sandbox Router, which allocates an initialized gVisor Pod in under 200ms. 4. Isolated execution: The untrusted script runs inside the sandbox, capturing stdout, stderr, and exit codes without host kernel access. 5. Recycling and feedback: The used sandbox is returned to the router for teardown, and execution outputs are fed back to Gemini 4 Argon for iterative refinement until the final answer is ready. Evaluating where to execute that generated code requires balancing isolation boundaries against allocation latency: | Execution pattern | Isolation boundary | Allocation latency | Operational trade-off | | :--- | :--- | :--- | :--- | | In-process subprocess in orchestrator Pod | Shared container kernel and filesystem | < 10ms | Exposes Workload Identity tokens, application memory, and internal cluster networking to arbitrary code | | Per-invocation Kubernetes Job | Standard container (runc) or RuntimeClass | 3s to 20s | Scheduling, image pulling, and container initialization add seconds of delay to every turn of the agent loop | | GKE Agent Sandbox with SandboxWarmPool | User-space kernel via gVisor + default-deny network | < 200ms (p90) | Requires maintaining a small footprint of pre-warmed idle Pods in exchange for sub-second isolation | GKE Agent Sandbox resolves this tension by combining four open-source custom resources from the kubernetes-sigs/agent-sandbox project with GKE kernel isolation: - Kernel-level isolation via Sandbox and gVisor: Each Sandbox (agents.x-k8s.io/v1alpha1) manages an isolated Pod running inside GKE Sandbox, intercepting application system calls in user space so untrusted code never interacts directly with the host Linux kernel. - Reusable blueprints via SandboxTemplate: A SandboxTemplate (extensions.agents.x-k8s.io/v1alpha1) defines the Pod specification, resource boundaries, and security context once for a class of agent tools. - Pre-warmed capacity via SandboxWarmPool: Rather than waiting for the Kubernetes scheduler and container runtime on each tool call, a SandboxWarmPool (extensions.agents.x-k8s.io/v1alpha1) maintains a standing buffer of initialized Sandbox Pods ready to be claimed in under 200 milliseconds. - Declarative allocation via SandboxClaim and zero-trust posture: When an agent needs to run code, the Sandbox Router creates a SandboxClaim (extensions.agents.x-k8s.io/v1alpha1) to bind a warm Sandbox Pod and deletes the claim on release. Sandbox Pods disable Kubernetes service account token automounting (automountServiceAccountToken: false) and enforce strict network egress policies, keeping your orchestrator's Workload Identity Federation for GKE credentials unreachable from the code execution environment. Configuring the cluster and sandbox node pools Before deploying the sandbox templates and the Gemini orchestrator, you need a GKE cluster with gVisor (GKE Sandbox) support and the Agent Sandbox controller installed. Enabling Agent Sandbox on GKE Autopilot On GKE Autopilot, GKE Sandbox is supported out of the box and automatically provisions gVisor-enabled compute capacity whenever Pods request runtimeClassName: gvisor. If you are using the managed GKE Agent Sandbox addon preview, pass --enable-agent-sandbox via gcloud beta; otherwise, create a standard Autopilot cluster and install the upstream controller manifests in the next step: export PROJECT_ID="production-ai-platform" export LOCATION="us-central1" export CLUSTER_NAME="agent-execution-cluster" gcloud beta container clusters create-auto "${CLUSTER_NAME}" \ --project="${PROJECT_ID}" \ --location="${LOCATION}" \ --enable-agent-sandbox Enabling Agent Sandbox on GKE Standard On GKE Standard, your cluster must keep at least one regular node pool for system Pods while isolating untrusted execution onto a dedicated node pool configured with --image-type=cos_containerd and --sandbox=type=gvisor. Separating the untrusted execution node pool from the node pool hosting your orchestrator Pods also prevents noisy-neighbor resource contention: # Enable the Agent Sandbox addon on an existing Standard cluster (beta) gcloud beta container clusters update "${CLUSTER_NAME}" \ --project="${PROJECT_ID}" \ --location="${LOCATION}" \ --enable-agent-sandbox # Create a dedicated gVisor node pool for untrusted code execution gcloud container node-pools create sandbox-execution-pool \ --cluster="${CLUSTER_NAME}" \ --project="${PROJECT_ID}" \ --location="${LOCATION}" \ --machine-type=e2-standard-8 \ --image-type=cos_containerd \ --sandbox=type=gvisor \ --num-nodes=3 \ --enable-autoscaling \ --min-nodes=2 \ --max-nodes=10 If you are installing the open-source kubernetes-sigs/agent-sandbox controller and extension CRDs (Sandbox, SandboxTemplate, SandboxClaim, and SandboxWarmPool) directly onto any GKE Autopilot or Standard cluster, apply the release manifests: export AGENT_SANDBOX_VERSION="v0.1.0" kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/manifest.yaml" kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/extensions.yaml" Defining the SandboxTemplate, SandboxWarmPool, and SandboxClaim Once the cluster and CRDs are ready, you define the execution environment using SandboxTemplate and SandboxWarmPool. When you specify runtimeClassName: gvisor on GKE, the GKE admission controller automatically injects the required sandbox.gke.io/runtime: gvisor node selector and taint tolerations on both Autopilot and Standard clusters. Create a manifest named analytics-sandbox-pool.yaml in a dedicated agent-sandboxes namespace. This configuration enforces non-root execution, drops all Linux capabilities, disables service account token mounting, and keeps 5 pre-warmed Python runtime Pods ready for tool calls: apiVersion: v1 kind: Namespace metadata: name: agent-sandboxes --- apiVersion: extensions.agents.x-k8s.io/v1alpha1 kind: SandboxTemplate metadata: name: python-analytics-template namespace: agent-sandboxes spec: podTemplate: metadata: labels: app.kubernetes.io/component: execution-sandbox sandbox-tier: untrusted-python spec: # GKE automatically adds the gVisor nodeSelector and toleration for this RuntimeClass runtimeClassName: gvisor automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: type: RuntimeDefault containers: - name: python-runtime image: registry.k8s.io/agent-sandbox/python-runtime-sandbox:v0.1.0 ports: - containerPort: 8888 securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL resources: requests: cpu: "500m" memory: "1Gi" ephemeral-storage: "2Gi" limits: cpu: "1000m" memory: "1Gi" ephemeral-storage: "2Gi" --- apiVersion: extensions.agents.x-k8s.io/v1alpha1 kind: SandboxWarmPool metadata: name: python-analytics-warmpool namespace: agent-sandboxes spec: replicas: 5 sandboxTemplateRef: name: python-analytics-template Under the hood, whenever the Sandbox Router (or a Kubernetes client) allocates a sandbox from this warm pool, it creates a SandboxClaim resource targeting python-analytics-template: apiVersion: extensions.agents.x-k8s.io/v1alpha1 kind: SandboxClaim metadata: name: analytics-task-claim-example namespace: agent-sandboxes spec: sandboxTemplateRef: name: python-analytics-template To prevent untrusted code from scanning internal cluster services or calling the GKE metadata server, apply a default-deny NetworkPolicy (isolate-untrusted-sandboxes.yaml) in the agent-sandboxes namespace that blocks all egress and only permits ingress on port 8888 from the Sandbox Router: apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: isolate-untrusted-sandboxes namespace: agent-sandboxes spec: podSelector: matchLabels: app.kubernetes.io/component: execution-sandbox policyTypes: - Ingress - Egress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: agent-sandbox-system - podSelector: matchLabels: app.kubernetes.io/component: sandbox-router ports: - protocol: TCP port: 8888 egress: [] Apply the manifests to your cluster: kubectl apply -f analytics-sandbox-pool.yaml kubectl apply -f isolate-untrusted-sandboxes.yaml Building the Gemini orchestrator loop With the sandbox pool running in agent-sandboxes, you can deploy the orchestrator service in a separate agent-orchestrator namespace. The orchestrator uses Workload Identity Federation for GKE to authenticate with the Gemini platform and calls the Sandbox Router whenever Gemini invokes the code execution tool. Here is the core implementation (orchestrator.py) using the google-genai SDK and the agentic_sandbox client. In the snippet below, GEMINI_MODEL defaults to gemini-4-argon: import os from google import genai from google.genai import types from agentic_sandbox import SandboxClient # Defaults to gemini-4-argon on the Gemini platform: MODEL_ID = os.environ.get("GEMINI_MODEL", "gemini-4-argon") # Initialize the Gemini platform client using Workload Identity credentials genai_client = genai.Client( vertexai=True, project=os.environ["GOOGLE_CLOUD_PROJECT"], location=os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1"), ) # Initialize the GKE Agent Sandbox client (communicates via the Sandbox Router) sandbox_client = SandboxClient(namespace="agent-sandboxes") def execute_python_in_sandbox(code: str) -> dict[str, str | int]: """Executes Python code inside an isolated GKE gVisor sandbox and returns stdout, stderr, and exit code.""" sandbox = sandbox_client.claim_sandbox( template_name="python-analytics-template", timeout_seconds=5, ) try: result = sandbox.execute(code, timeout_seconds=30) return { "stdout": result.output or "", "stderr": result.error or "", "exit_code": result.exit_code, } finally: # Always release the sandbox so the SandboxClaim is deleted and replaced sandbox.release() def run_autonomous_task(user_prompt: str) -> str: """Runs a multi-turn Gemini 4 Argon reasoning loop backed by GKE Agent Sandbox.""" config = types.GenerateContentConfig( system_instruction=( "You are a quantitative engineering agent. When solving data or systems problems, " "write Python scripts and run them using execute_python_in_sandbox. " "If execution returns a non-zero exit_code or stderr traceback, inspect the error, " "adjust your code, and re-run until the output is verified." ), tools=[execute_python_in_sandbox], automatic_function_calling=types.AutomaticFunctionCallingConfig( maximum_remote_calls=10, ), temperature=0.1, ) chat = genai_client.chats.create( model=MODEL_ID, config=config, ) response = chat.send_message(user_prompt) return response.text or "" When genai_client.chats.create receives a tool declaration with a typed Python function, the google-genai SDK automatically manages the multi-turn tool-calling loop (bounded by maximum_remote_calls=10), invoking execute_python_in_sandbox each time the model emits a function call and feeding the structured stdout, stderr, and exit_code dictionary back into the conversation context. Because sandbox.release() runs inside a finally block, every execution turn gets a clean, ephemeral container filesystem, preventing state pollution across unrelated tasks. Verifying sandbox allocation and observability After deploying the warm pool and orchestrator, verify that the pre-warmed Pods are scheduled under the gVisor runtime and that the warm pool controller is maintaining target capacity: # Inspect the warm pool status, active sandboxes, and claims kubectl get sandboxwarmpool,sandbox,sandboxclaim -n agent-sandboxes # Confirm that sandbox Pods are running with RuntimeClass gvisor kubectl get pods -n agent-sandboxes \ -l app.kubernetes.io/component=execution-sandbox \ -o custom-columns="NAME:.metadata.name,STATUS:.status.phase,RUNTIME:.spec.runtimeClassName,NODE:.spec.nodeName" Expected output: NAME STATUS RUNTIME NODE python-analytics-warmpool-6d8f9b7c4-2x9kl Running gvisor gke-agent-execution-sandbox-pool-8a12f-9b2c python-analytics-warmpool-6d8f9b7c4-7m4pq Running gvisor gke-agent-execution-sandbox-pool-8a12f-9b2c python-analytics-warmpool-6d8f9b7c4-9v1zr Running gvisor gke-agent-execution-sandbox-pool-8a12f-c41d To inspect sandbox claim latency and catch container OOM kills triggered by runaway agent scripts in Cloud Logging, run the following query: resource.type="k8s_pod" resource.labels.namespace_name="agent-sandboxes" (reason="OOMKilling" OR reason="SandboxClaimed" OR reason="SandboxReleased") In Cloud Monitoring, track kubernetes.io/pod/ephemeral_storage/used_bytes and kubernetes.io/container/memory/used_bytes filtered by namespace_name="agent-sandboxes" to rightsize your SandboxTemplate resource limits based on the actual memory footprint of the scripts Gemini 4 Argon generates. Operational trade-offs and sharp edges When running GKE Agent Sandbox with Gemini 4 Argon in production, keep these operational boundaries in mind: - Warm pool sizing vs. burst concurrency: If 20 concurrent Gemini 4 Argon requests trigger tool execution simultaneously and your SandboxWarmPool only holds replicas: 5, the remaining 15 requests will wait for cold Pod creation. Size replicas to cover your p95 burst concurrency during peak traffic hours. - Stateful multi-step sessions: In the example above, releasing the sandbox inside execute_python_in_sandbox destroys the Pod after every turn. If your Gemini 4 Argon agent writes files to disk in turn 1 and analyzes them in turn 2, bind the sandbox instance to the chat session lifecycle and invoke sandbox.release() only after the entire conversation completes. - gVisor syscall overhead for heavy I/O: Because gVisor intercepts system calls in user space, compute-bound Python scripts (such as NumPy or Pandas array operations) run near native speed, whereas workloads performing thousands of small file opens or network socket creations incur additional CPU overhead. - Hard execution timeouts: LLM-generated code can inadvertently introduce infinite while loops. Always enforce a strict timeout_seconds parameter on sandbox.execute() so the orchestrator returns a timeout error to Gemini 4 Argon rather than blocking the request worker indefinitely. Next steps Pairing Gemini 4 Argon with GKE Agent Sandbox gives your agents the ability to test and self-correct their own code without exposing your cluster or credentials to untrusted execution. By isolating every tool invocation inside a pre-warmed gVisor Pod with zero network egress, you get both kernel-level safety and sub-second iteration latency. To build this pattern in your own environment, explore the GKE Agent Sandbox conceptual guide, follow the GKE Agent Sandbox installation instructions, and check out the upstream kubernetes-sigs/agent-sandbox repository.

    5 reactions
Trending awards, every completed day and week
Gold
01st place finishes
Silver
12nd place finishes
Bronze
23rd place finishes
Awarded repositories
1Owned by kubernetes-sigs now

Repositories

Ordered by gold, then silver, then bronze.

  1. contributor-playgroundBest: 2nd by PR authors, 34 PR authors012

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.