Want to practise Kubernetes without paying for a cloud cluster?
kubernetes-sigs/kind (15.5k ★) runs a local cluster inside Docker.
go install sigs.k8s.io
kind create cluster
kubernetes-sigs/kind
What's the first app you'd deploy on it? t.co
5 views
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,
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
An Overview of Agent Substrate
A Kubernetes Pod is a useful unit of deployment. An agent session has a different lifetime: it may run briefly, wait for minutes, then need its process and filesystem state back for the next turn. Keeping a dedicated environment resident through every wait can consume capacity that other sessions could use.
Agent Substrate separates those lifetimes. Kubernetes runs a pool of worker Pods; Substrate places sandboxed agent environments onto them, persists their state, and restores them when work arrives. This article follows one request through that system, then examines the components and a Counter demo you can try locally.
TL;DR
- Kubernetes manages Workers; Substrate manages Actors. Actor state and assignments live behind a separate API backed by PostgreSQL.
- Workers can serve many Actors over time. Capacity-based concurrency is combined with suspend/resume to reuse compute.
- atelet and ateom save and restore sandbox state. They use gVisor or microVMs; the snapshot fidelity determines what is preserved.
- Credentials can stay outside the Actor. An egress gateway and credential provider can inject API credentials into outgoing requests.
- Check the implemented behavior, not just the design. Authorization enforcement is experimental and disabled by default for ordinary Control RPCs. Idle detection and performance claims also require context.
Implementation details target Agent Substrate v0.5.0. See [Components and versions](#components-and-versions) for the full scope.
This walkthrough assumes familiarity with Pods, Deployments and basic Kubernetes networking. Start with the [request lifecycle](#follow-a-request-into-a-suspended-actor), jump to the [kind demo](#try-the-counter-demo-with-kind), or use the [evaluation questions](#what-to-evaluate-in-your-own-workload) to assess the fit for your platform.
The design decision: keep the workers, move the sessions
An agent may execute code briefly, wait for inference or an external tool, then wait indefinitely for another user turn. Running untrusted code also creates a need for isolated environments, often with one tenant per sandbox.
The combination matters: a large fleet can contain many more waiting environments than actively executing ones. Reserving CPU and memory for every waiting agent wastes capacity. Snapshot-based multiplexing is intended to let those environments retain state without remaining assigned to running compute.
Source: docs/architecture.md#L17-L23.
What Kubernetes still does—and what Substrate moves elsewhere
The architecture document identifies three pressures behind its design:
| Pressure | Why it matters for agents |
|:---|:---|
| API resource count and write traffic | Millions of frequently changing agent records are a different workload from ordinary Kubernetes reconciliation |
| Activation latency | Pod scheduling, reconciliation, network hops and image preparation add overhead to very short executions |
| State movement | Rapidly saving and restoring many environments is a different problem from attaching conventional persistent volumes |
These are the project's design judgments for its intended scale, rather than universal Kubernetes limits or a claim that every Pod takes seconds to start.
Kubernetes still handles infrastructure provisioning and worker Pod lifecycles. Substrate introduces a separate control plane for Actor state, assignment and resumption, so an activation does not need to wait for a fresh Pod lifecycle.
Sources: docs/architecture.md#L153, docs/architecture.md#L160-L172.
The important boundary is the activation path: a new turn can restore an Actor onto an existing Worker without provisioning another Pod. Database write throughput, reconciliation and environment preparation all contribute to that choice.
Follow a request into a suspended Actor
The execution layer combines routing, state persistence and sandbox isolation:
!Agent Substrate adds a gateway, state persistence and kernel isolation above Kubernetes, compute and storage.
Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026).
Two lifecycles, two APIs
| Resource | Meaning | Managed through |
|:---|:---|:---|
| ActorTemplate | Immutable workload definition, including images and snapshot settings | Substrate API and database |
| WorkerPool | Defines a pool that atecontroller reconciles into a Deployment | Kubernetes CRD |
| Atespace | Logical grouping and identity scope for Actors; not a Kubernetes namespace | Substrate API and database |
| Actor | An instance of an ActorTemplate and the unit of suspend/resume | Substrate API and database |
| Worker | A record corresponding to a worker Pod; can host multiple Actors concurrently | Substrate API and database |
Sources: docs/glossary.md#L10-L23, docs/glossary.md#L38-L52.
An Actor is the stateful environment that belongs to a session. A Worker is the reusable execution capacity. Suspending an Actor releases its assignment; it does not require destroying and recreating the worker Pod. Worker capacity can also permit several Actors to run concurrently.
Restore first, forward second
For an HTTP request targeting a suspended Actor, the activation path has four steps:
1. The router identifies the Actor from ate-target-actor.
2. The control plane assigns a Worker and coordinates restoration of the Actor's snapshot.
3. Once the assignment is ready, the router forwards the request through the Worker's tunnel to the Actor.
4. The Actor handles the request and returns its response.
For an Actor that is already running, ResumeActor returns the existing assignment. The call is idempotent.
Source: workflow_resume.go.
Returning a response does not automatically suspend the Actor. The application or higher-level system must choose a safe suspension boundary, taking account of outstanding work and network calls.
Suspend and resume
!Active agent environments on Workers exchange state with idle environments persisted in storage through suspend/resume.
Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026).
Here, “idle” means state has been persisted and the environment removed from running compute, rather than a process simply sleeping. The illustration is conceptual: it does not imply a fixed one-to-one relationship between Workers and Actors.
When a request arrives, atenet-router asks the control plane to resume the target Actor and resolve its Worker assignment. A suspended Actor is restored from its latest snapshot before traffic is forwarded. It may resume on a different Worker.
The router itself does not decide whether the Actor is running; that decision belongs to the control plane. Suspending the Actor saves state and releases its assignment for reuse.
Source: docs/architecture.md#L193-L204.
What runs where
| Component | Responsibility | Relationship to Kubernetes |
|:---|:---|:---|
| ate-api-server (ateapi) | Actor lifecycle, scheduling and snapshot coordination | Separate API, scheduler and workflow layer backed by PostgreSQL |
| atecontroller | Reconciles WorkerPool and related CRDs | Standard controller-runtime controller |
| atelet / ateom | Coordinates and executes sandbox checkpoint/restore | Additional execution layer inside worker Pod boundaries |
| atenet router and egress | Resumption-aware ingress and controlled outbound traffic | Routing plus Actor lifecycle coordination |
| podcertcontroller | Issues short-lived certificates for component mTLS | Signer controller for PodCertificateRequest |
The threat-model diagram shows how these components connect:
!Substrate architecture showing ate-api-server, atelet, ateom, atenet-router, object storage and PostgreSQL.
Source: docs/threat-model.md#L30-L40.
{% details Repository map: controllers, sidecars and tools %}
The repository also includes supporting programs:
| Directory | Role |
|:---|:---|
| cmd/ateapi | gRPC control plane |
| cmd/atecontroller | Kubernetes controllers |
| cmd/atelet | Node supervisor deployed as a DaemonSet |
| cmd/ateom-gvisor | Drives gVisor inside worker Pods |
| cmd/ateom-microvm | Runs Actors with Cloud Hypervisor microVMs |
| cmd/objectstore-plugin | Snapshot transfer sidecar for node and control-plane operations |
| cmd/atenet | Networking daemon and proxy integration |
| cmd/dataplane/envoy | Envoy image with Rust Dynamic Modules for egress enforcement |
| cmd/credential-provider/kubernetes-secrets | Reference credential provider backed by Kubernetes Secrets |
| cmd/podcertcontroller | Pod certificate signers |
| cmd/kubectl-ate | Actor and other Substrate resource operations |
| cmd/ate-setup | Installation and removal |
| cmd/benchmarking | Synthetic resource-consuming test workloads |
| tools/setup-gcp | GKE, storage and IAM provisioning |
The README's tour is not an exhaustive inventory; the sidecar, provider, dataplane and setup entries are supplemented from their own source and documentation.
Sources: README.md#L260-L272, cmd/objectstore-plugin/main.go#L15-L19, cmd/ate-setup/README.md#L3, cmd/dataplane/envoy/README.md.
{% enddetails %}
Resource ownership and assignment
!Agent Substrate between the agent application layer and Kubernetes, with sandbox isolation and storage beneath it.
Source: Bringing you Agent Sandbox on GKE and Agent Substrate, Google Cloud Blog (May 20, 2026).
The green sandbox-isolation block is the foundation for the checkpoint and restore operations. Agent Substrate incorporates Agent Sandbox's secure runtime and snapshot capabilities into a separate execution layer.
classDiagram
namespace kube-apiserver {
class WorkerPool {
<<CRD>>
}
class Deployment
class WorkerPod {
ateom
runsc
}
}
namespace ate-api-server {
class ActorTemplate {
<<record>>
}
class Actor {
<<record>>
status
snapshotRefs
}
class Worker {
<<record>>
actorAssignments
podIP
}
}
ActorTemplate ..> WorkerPool : worker_selector (labels)
WorkerPool ..> Deployment : reconciled by atecontroller
Deployment "1" *-- "*" WorkerPod : manages
Actor "*" --> "1" ActorTemplate : actor_template
Actor "0..*" --> "0..1" Worker : runs on
Worker "1" --> "1" WorkerPod : maps to
Adapted from docs/architecture.md#L268-L303; the multiplicity reflects the implementation's support for multiple Actors per Worker.
The split between API servers is central. Infrastructure resources stay in Kubernetes, while frequent Actor/Worker assignment changes go through ate-api-server and its state store.
Two resource layers
The architecture gives three reasons for this split:
- Keep high-frequency Actor updates away from the Kubernetes control plane.
- Use low-latency lookups and atomic assignments for resumption. The document's 100ms figure is a design target, not a result measured here.
- Retain Kubernetes RBAC, auditing and policies for infrastructure resources such as WorkerPools and SandboxConfigs.
One detail in that document is stale: it says authorization has not been implemented. The same v0.5.0 tag contains OpenFGA-based authorization. --experimental-enable-authz defaults to false and controls enforcement for ordinary Control RPCs. AccessPolicy RPCs are checked regardless of that flag.
Sources: docs/architecture.md#L247-L261, cmd/ateapi/main.go#L84-L88, cmd/ateapi/internal/authz/interceptor.go#L28-L60.
ate-api-server: the Actor control plane
ateapi owns Actor lifecycles, schedules them onto Workers and coordinates snapshots. Its gRPC server connects to PostgreSQL, creates Kubernetes clients and configures its mTLS credentials.
Sources: docs/glossary.md#L56-L58, cmd/ateapi/main.go#L509-L521, cmd/ateapi/main.go#L560-L586.
templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv, *templateResyncInterval)
templateReconciler.Start(shutdownCtx)
workerAssignmentReconciler := controlapi.NewWorkerAssignmentReconciler(persistence, workerCache)
workerAssignmentReconciler.Start(shutdownCtx)
Source: cmd/ateapi/main.go#L326-L330.
Despite their names, these reconcilers are internal Substrate loops rather than controller-runtime reconcilers watching custom resources. ActorTemplateReconciler prepares golden snapshots; WorkerAssignmentReconciler handles Actors lost when ateom restarts.
This component does not map one-to-one to kube-apiserver: it combines Actor-specific API handling, scheduling and workflows.
atecontroller: conventional Kubernetes reconciliation
atecontroller uses ctrl.NewManager and registers four reconcilers, including WorkerPoolReconciler. Its core structure is familiar:
func (r *WorkerPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
wp := &atev1alpha1.WorkerPool{}
if err := r.Get(ctx, req.NamespacedName, wp); err != nil {
if k8errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("failed to get worker pool %q: %w", req.NamespacedName, err)
}
if !wp.GetDeletionTimestamp().IsZero() {
log.Info("WorkerPool is being deleted")
return ctrl.Result{}, nil
}
if err := r.reconcileWorkerPool(ctx, wp); err != nil {
log.Error(err, "Failed to reconcile worker pool")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
Sources: cmd/atecontroller/main.go#L233-L276, cmd/atecontroller/internal/controllers/workerpool_controller.go#L105-L129.
Reconciliation does more than produce a Deployment. The WorkerPool reconciler protects referenced SandboxConfigs with finalizers; a separate SandboxConfigProtectionReconciler releases protection. Updating the recorded SandboxConfig uses two status writes so that status never references a configuration whose protection has already been released.
Owns(&Deployment{}) and Watches(&SandboxConfig{}, ...) connect these resources through normal controller-runtime mechanisms.
Sources: cmd/atecontroller/internal/controllers/workerpool_controller.go#L134-L136, cmd/atecontroller/internal/controllers/workerpool_controller.go#L416-L425.
atelet and ateom: checkpoint and restore
atelet runs on each node as a DaemonSet. ateom runs inside worker Pods and exposes operations such as RunWorkload, CheckpointWorkload and RestoreWorkload.
atelet coordinates worker supervision, image caching and CSI volumes. It also reuses kubelet's image credential-provider mechanism through --image-credential-provider-config.
Sources: docs/architecture.md#L324-L326, cmd/atelet/main.go#L103-L104.
Snapshot upload and download are delegated to objectstore-plugin sidecars over a Unix-socket gRPC API:
| Plugin role | Operations |
|:---|:---|
| NodeProvider, alongside atelet | Upload and fetch snapshot files |
| ControlProvider, alongside ate-api-server | Copy and delete whole snapshots |
The main processes do not directly transfer snapshots to object storage. This does not mean they hold no credentials at all: mTLS and database authentication remain separate concerns.
Source: docs/architecture.md#L330.
For gVisor, cmd/ateom-gvisor/runsc.go invokes runsc checkpoint and runsc restore. The microVM implementation uses Kata integration and Cloud Hypervisor, with userfaultfd demand paging when restoring memory.
Sources: docs/architecture.md#L328-L338.
Kubelet and the container runtime still run the worker Pod itself. Substrate manages sandboxed Actors inside it. Kubernetes already has a Kubelet Checkpoint API; Substrate's distinction is coordinating persistence, assignment, restore and reconnection as an Actor lifecycle.
atenet: routing that can resume its destination
Ingress requests identify an Actor with the ate-target-actor header, formatted as <atespace>/<actor>. In the Envoy configuration described here, an ext_proc processor calls the control plane's ResumeActor RPC before resolving the destination.
Once the assignment is available, the router opens an mTLS CONNECT tunnel to the Worker's atunnel listener on port 8443. atunnel, hosted by ateom, forwards traffic over the Actor's private veth interface. Worker Pod port 80 is not a direct Actor ingress endpoint.
Sources: docs/architecture.md#L344-L354.
The Kubernetes analogy is Service/Ingress/Gateway routing with an extra orchestration step: restore the destination if necessary before forwarding to it.
podcertcontroller: Pod certificate signers
podcertcontroller provides three signers:
| Signer | Purpose |
|:---|:---|
| servicedns.podcert.ate.dev/identity | Service DNS certificates backed by a local CA |
| podidentity.podcert.ate.dev/identity | Pod identity certificates equivalent to Kubernetes service account identity |
| postgres.podcert.ate.dev/identity | PostgreSQL login certificates for ate-api-server |
Source: cmd/podcertcontroller/main.go#L15-L22.
It watches certificates.k8s.io/v1 PodCertificateRequest objects. The API became GA in Kubernetes v1.37:
PodCertificateRequest: {
{Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.35"), Default: false, PreRelease: featuregate.Beta},
{Version: version.MustParse("1.37"), Default: true, PreRelease: featuregate.GA}, // LockToDefault in 1.38
},
Source: kube_features.go.
The API lets kubelet request certificates and project them into Pods. A signer must still process those requests. The well-known kubernetes.io/kube-apiserver-client-pod signer described in the API comments is unimplemented in this version.
Substrate fills its own needs with the three signers above. Its README calls the controller a “polyfill” for the missing signers.
Sources: certificate API types, README.md#L268-L269.
Keep session state and API credentials separate
Isolation, identity and credentials are separate controls
!The agent environment combines identity-based ingress and egress, secrets management, kernel isolation and rapid suspend/resume.
Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026).
Kernel isolation does not by itself protect external API credentials. That also requires the gateway and credential-provider configuration described below.
The security document distinguishes Met, In Progress, Future and Out of Scope. Selected principles in v0.5.0 are:
| Principle | Meaning | Documented status |
|:---|:---|:---|
| Always Sandboxed | Run Actors in gVisor or Cloud Hypervisor microVMs rather than relying on ordinary Linux containers alone | Met |
| Zero Ambient Credentials | Do not implicitly give Actors infrastructure permissions or external API keys | Met; distinct from demos that explicitly pass keys in environment variables |
| No Access by Default | Restrict Actor access to the control plane, separate from required infrastructure traffic | Loopback/private netns: Met; DNS filtering: In Progress |
| Fail-Closed Policy-Enforced Egress Gateway | Control outbound traffic through an egress proxy | Gateway: Met; fail-closed behavior: In Progress, with UDP passthrough noted |
Sources: docs/security.md#L30-L33, docs/security.md#L41, docs/security.md#L59, docs/security.md#L75-L77.
Warning: The same document lists Atespace authorization as In Progress, and node blast-radius containment and storage encryption as Future. The security architecture is not evidence that every control is fully implemented. Ordinary Control RPC authorization still requires the experimental flag.
Inject credentials outside the Actor
With egress credential injection, an Actor sends an HTTPS request containing a placeholder header. The gateway obtains the real credential and substitutes it outside the sandbox:
1. The Actor sends the request with a placeholder value.
2. The egress gateway terminates TLS and matches an EgressPolicy header-replacement rule.
3. The credential provider authorizes access and reads the secret.
4. The gateway replaces the header and makes a separate TLS connection to the destination.
The Actor must trust the gateway's CA. If the request does not contain the header to replace, credential lookup and replacement do not occur.
This keeps the API key outside Actor memory and snapshots. It does not prevent misuse of an allowed API; destination restrictions and application authorization still matter.
Sources: docs/egress-credential-injection.md#L1-L15, docs/egress-credential-injection.md#L41-L54.
The reference kubernetes-secrets provider starts with an empty deny-all policy. Readable namespaces must be explicitly allowed for each Atespace. Actors do not read Kubernetes Secrets themselves.
Source: README.md#L132-L136.
EgressPolicy defines destinations and header replacement
An EgressPolicy specifies allowed traffic and header replacement rules. The credential provider separately decides whether the Actor can use the referenced secret. The API defines the policy as an unordered set of allows: traffic matching no rule is denied.
Source: pkg/proto/ateapipb/ateapi.proto#L609-L610.
For example, allow HTTPS to api.example.com and replace its Authorization header:
rules:
- https:
hostnames: ["api.example.com"]
effects:
replaceHeaders:
- header: Authorization
prefix: "Bearer "
credentialUri: ate-secret://k8s.io/default/ns1/example-api/token
Source: docs/egress-credential-injection.md#L69-L76.
The policy is associated with an Actor rather than embedded in its ActorTemplate:
# Create an egress policy.
kubectl ate create egress-policy <actor-name> -a <atespace> -f policy.yaml
Source: cmd/kubectl-ate/README.md#L247-L253.
Note: The Claude Code demo discussed later directly places ANTHROPIC_API_KEY in the ActorTemplate environment. That differs from gateway injection. In either case, the Actor needs an egress policy permitting its HTTPS destination, such as api.anthropic.com.
The complete request path
With routing and credential injection in place, an HTTP-triggered agent can resume and call an external LLM as follows. This combines the documented flows; Counter itself does not call an LLM.
sequenceDiagram
actor User as User / higher-level system
participant Router as atenet-router
participant API as ate-api-server
participant Atelet as atelet
participant Tunnel as ateom / atunnel
participant Agent as Actor
participant Egress as atenet-egress
participant Cred as credential provider
participant LLM as External LLM API
User->>Router: HTTP request (ate-target-actor)
Router->>API: ResumeActor
alt Actor is suspended
API->>Atelet: Restore request
Atelet->>Tunnel: RestoreWorkload
Tunnel-->>Atelet: Restore complete
Atelet-->>API: Complete
else Actor is running
Note over API: Return existing assignment
end
API-->>Router: Worker assignment
Router->>Tunnel: Forward through mTLS CONNECT
Tunnel->>Agent: Forward request
Agent->>Egress: HTTPS (placeholder header)
Egress->>Cred: Request credential
Cred-->>Egress: Return API key after authorization
Egress->>LLM: Replace header and send over a separate TLS connection
LLM-->>Egress: Response
Egress-->>Agent: Response
Agent-->>Tunnel: Result
Tunnel-->>Router: Response
Router-->>User: Response
Note over User,API: A higher-level system can request SuspendActor to save state and release the assignment
Sources: Resume sequence, credential injection. Cold boot without a snapshot and other branches are omitted.
The Actor-to-gateway and gateway-to-provider connections use separate TLS sessions. The gateway must terminate the first to replace headers in the second.
Try the Counter demo with kind
The Counter workload makes state preservation visible: each request increments counters in process memory, a file and the root filesystem. The example below uses gVisor and includes recorded output from a local kind run.
Set up the cluster and demo
Building v0.5.0 requires Go 1.27.0 and a working Docker-based development environment. The microVM option additionally requires /dev/kvm and suitable virtualization support.
Sources: go.mod#L1-L5, hack/create-kind-cluster.sh#L74-L87.
Use a dedicated checkout and cluster name. The creation script deletes and recreates an existing cluster with the same name, so choose one that does not overlap with an environment you want to keep.
git clone --branch v0.5.0 --depth 1 \
https://github.com/agent-substrate/substrate.git substrate-v0.5.0
cd substrate-v0.5.0
export KIND_CLUSTER_NAME=ate-overview
# create cluster and local registry (IPv4; IP_FAMILY=dual|ipv6 overrides)
hack/create-kind-cluster.sh
# install ate, PostgreSQL, rustfs
hack/install-ate-kind.sh --deploy-ate-system \
--credential-provider='{"name":"k8s.io"}'
# install counter demo
hack/install-ate-kind.sh --deploy-demo-counter
# install kubectl-ate
go install ./cmd/kubectl-ate
# create a counter actor in the demo's atespace (--template names the
# actor template, resolved in the actor's atespace)
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
# port-forward the network router to bind to local port `8000`
kubectl port-forward -n ate-system svc/atenet-router 8000:80
Source: README.md#L111-L129.
The creation script sets up a local registry and a kind cluster with kindest/node:v1.37.0. It explicitly enables certificate-related feature gates, including ClusterTrustBundleProjection; some comments still describe v1.36 requirements.
Source: hack/create-kind-cluster.sh#L104-L113.
The system installation uses ko to build and push its Go components and deploys PostgreSQL, RustFS, CRDs and SandboxConfig resources. The Counter installation adds its WorkerPool and ActorTemplate and waits for a golden snapshot.
WorkerPool and ActorTemplate
The demo's WorkerPool includes the following settings. The installer resolves template placeholders and ko:// references before applying the configuration.
apiVersion: ate.dev/v1alpha1
kind: WorkerPool
metadata:
name: counter
namespace: ate-demo-counter
labels:
workload: counter
spec:
replicas: 3
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor
sandboxClasses:
- name: gvisor
template:
resources:
limits:
cpu: "1"
memory: 1Gi
requests:
cpu: 250m
memory: 1Gi
Source: demos/counter/counter.yaml.tmpl#L26-L63.
An ActorTemplate is not a Kubernetes object. This YAML maps to Substrate's protobuf fields and is passed through kubectl ate create actor-template:
metadata:
atespace: ate-demo-counter
name: counter
workerSelector:
matchLabels:
workload: counter
containers:
- name: counter
image: ko://github.com/agent-substrate/substrate/demos/counter
wakeupProbe:
httpGet:
path: /readyz
port: 80
volumeMounts:
- name: data
mountPath: /home/counter
resources:
limits:
- name: cpu
quantity: "1"
- name: memory
quantity: 512Mi
snapshotConfig:
preferredFidelity: SNAPSHOT_FIDELITY_MEMORY
storageLocation: gs://${BUCKET_NAME}/ate-demo-counter/
sandboxConfig:
sandboxClass: gvisor
configName: gvisor-default
volumes:
- name: data
durableDir: {}
Source: demos/counter/counter-template.yaml.tmpl#L22-L69.
SNAPSHOT_FIDELITY_MEMORY requests preservation of process memory. gvisor-default names a cluster-scoped SandboxConfig whose assets specify where to download the runtime binaries:
apiVersion: ate.dev/v1alpha1
kind: SandboxConfig
metadata:
name: gvisor-default
annotations:
sandboxconfig.ate.dev/is-class-default: "true"
spec:
sandboxClass: gvisor
defaultVersion: v3
versions:
- name: v3
state: Enabled
pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
assets:
arm64:
gvisor:
url: "gs://gvisor/releases/nightly/2026-10-10/aarch64/gvisor.tar.zstd"
sha256: "aaf414f06eea7960238e43165c691f0a36cefc0593fb141e011e96211c7b6f57"
Source: manifests/ate-install/sandboxconfig-gvisor.yaml#L22-L48. Only the arm64 assets are shown; amd64 assets are omitted.
Observe Actor state and preserved counters
A newly created Counter Actor starts suspended: its snapshot exists, but it has no Worker assignment yet.
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
Recorded output:
ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE
ate-demo-counter my-counter-1 ate-demo-counter/counter ACTOR_STATE_SUSPENDED <none> 1 0s
Send a request through the router. It resumes the Actor before forwarding, and the recorded response is HTTP 200:
curl -X POST \
-H "ate-target-actor: ate-demo-counter/my-counter-1" \
-i http://localhost:8000/
Recorded output:
HTTP/1.1 200 OK
Content-Length: 111
Content-Type: text/plain; charset=utf-8
hello from: 169.254.17.2 | preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1
A second POST increments the counter to 2. The Actor is now running and assigned to a worker Pod:
curl -X POST \
-H "ate-target-actor: ate-demo-counter/my-counter-1" \
http://localhost:8000/
kubectl ate get actor my-counter-1 -a ate-demo-counter
Recorded output:
ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE
ate-demo-counter my-counter-1 ate-demo-counter/counter ACTOR_STATE_RUNNING ate-demo-counter/counter-796d677995-v55tv 10.244.0.23 3 20s
Explicitly suspend it, then send another request:
kubectl ate suspend actor my-counter-1 -a ate-demo-counter
curl -X POST \
-H "ate-target-actor: ate-demo-counter/my-counter-1" \
http://localhost:8000/
Recorded output:
hello from: 169.254.17.2 | preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3
The memory, file and root-filesystem counters continue at 3. This illustrates the documented snapshot-and-resume lifecycle, including preserved process memory.
Stop the port-forward when finished, then remove the dedicated cluster. The kind-registry container is separate and remains after cluster deletion.
hack/kind.sh delete cluster --name ate-overview
Plan the deployment around more than Pods
Worker Pods and CRDs are only part of the deployment. The following table distinguishes infrastructure that must be supplied from components the installer can deploy:
| Dependency | Purpose | Installation considerations |
|:---|:---|:---|
| PostgreSQL | Actor/Worker state store | The kind setup includes a development database; production operators supply database identities, schema and connection settings |
| GCS or S3-compatible object storage | Durable snapshot storage | Configure storage.bucketName / BUCKET_NAME; kind uses ate-snapshots and an in-cluster RustFS service |
| Egress credential provider | Resolve credentials outside Actors | Provider selection is required, including explicit {"enabled":false} to disable injection; {"name":"k8s.io"} deploys the reference provider |
| Sandbox runtime binaries | Run Actors with gVisor or microVMs | Selected through a cluster-scoped SandboxConfig; default gVisor assets come from a public GCS bucket |
| Proxy dataplane image | Router and egress proxies | --atenet-dataplane selects Envoy, requiring its custom build, or agentgateway, using a published image |
| Object-storage permissions | Let plugin sidecars transfer snapshots | For GCS, configure the required Workload Identity/IAM bindings; the kind substitute uses local credentials |
| CSI driver, when needed | Attach CSI-backed persistent storage to Actors | Not needed for the demo's durableDir; Substrate imposes additional controller/node deployment requirements |
External databases are not provisioned by ateapi or ate-setup. Snapshot-bucket selection and credential-provider selection also need explicit configuration, even when other defaults are available.
Sources: docs/postgres.md#L8, docs/operator-install.md#L314, docs/egress-credential-injection.md#L128-L131.
Missing GCS IAM bindings can produce a 403 on the first suspend/resume, and also prevent control-plane snapshot operations and image pulls.
Source: tools/setup-gcp/README.md#L154-L159.
Note: Substrate directly orchestrates CSI operations from ateapi and atelet. A CSI controller must be reachable as a cluster-network gRPC service, unlike the usual controller-side Unix socket arrangement. See docs/csi-deployment.md#L5.
For the Kubernetes v1.37 setup covered here, Pod certificate and ClusterTrustBundle features are GA and enabled by default. Substrate also includes its certificate controller. Provider-specific installation requirements should still be checked separately.
Source: ClusterTrustBundle feature definition.
GKE availability
Google announced Agent Substrate availability on GKE on September 16, 2026.
The GKE offering installs open-source Substrate onto GKE Standard; Autopilot is not supported. Evaluation and non-production use are available, while production support is allowlisted under a private GA program. Substrate has no additional product charge, but the GKE and other resources created still incur their normal charges.
Source: Install Agent Substrate on GKE.
The announcement also describes ComputeClasses and optional Filestore agent volumes. Shared workspace storage serves a different purpose from snapshots of execution state.
Google reports sub-500ms resumption, more than 500 suspend/resume activations per second and 10x density. These are Google's published figures, separate from upstream design targets and the Counter example.
Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026).
What to evaluate in your own workload
Substrate is worth examining when a fleet has many isolated, stateful environments but only a fraction need to execute at once. The useful question is how much resident capacity suspend/resume can release, and whether restoration meets the application's latency needs.
Start with three checks:
- Suspension boundaries: identify when an environment can safely stop, including outstanding network calls and application work.
- State requirements: decide what must survive, then select snapshot fidelity and any persistent workspace storage accordingly.
- Operational ownership: account for the database, snapshot backend, identity and egress services as well as the Workers.
The Counter demo exposes the core mechanism. An agent platform must then connect it to real session events and measure its own activation latency and capacity use.
Further implementation details
{% details Claude Code demo: workload execution versus UI state %}
What the Claude Code demo actually implements
claude-code-multiplex defines three Claude Code Actors—luna, mars and orion—with a two-Pod WorkerPool. It uses the control API rather than sending workload HTTP through atenet-router, so it does not use the routing header.
Source: demos/claude-code-multiplex/README.md#L14-L21.
An ActorTemplate supplies the task and interval as environment variables:
env:
- name: ACTOR_NAME
value: "luna"
- name: TASK
value: "Tell me one short, surprising fact about the Moon. One sentence."
- name: INTERVAL_SECONDS
value: "45"
- name: ANTHROPIC_API_KEY
value: "${ANTHROPIC_API_KEY}"
Source: demos/claude-code-multiplex/agent-luna-template.yaml.tmpl#L34-L42.
The workload repeatedly runs claude --print and then sleeps:
while true; do
...
claude --print "${TASK}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] claude exited non-zero"
...
sleep "${INTERVAL_SECONDS}"
done
Source: demos/claude-code-multiplex/workload/run.sh#L17-L49.
That idle interval is an opportunity for multiplexing. However, the v0.5.0 UI does not implement everything its README's task-flow description suggests:
| UI behavior | Implementation |
|:---|:---|
| “Give a task” | Records the selected Actor and task in UI-server assignment state |
| queued / running / completed | Derived from timestamps and randomized waiting/execution durations |
| Actor state and logs | Retrieved from the Substrate and Kubernetes APIs |
giveTask does not send a task to the Actor or call ResumeActor / SuspendActor. A comment in computeState identifies the task state as a time-based UI display rather than Substrate state.
Source: demos/claude-code-multiplex/ui/server.go#L252-L315.
A changing UI status therefore does not establish that a real LLM task completed or automatic multiplexing occurred. The useful pieces are the ActorTemplates, the periodic Claude Code workload and the state-viewing UI. Verify suspend/resume through real API operations and Actor state, as in the [Counter demo](#try-the-counter-demo-with-kind).
{% enddetails %}
{% details RustFS: the local snapshot backend %}
RustFS
RustFS is a separate, Rust-based S3-compatible object store. The kind installation uses it as its local snapshot backend. The v0.5.0 manifest selects rustfs/rustfs:1.0.0-beta.3.
This development setup does not replace production storage availability, persistence or operational design. objectstore-plugin uses RustFS through its S3 backend; GCS uses the same plugin interface with a different backend.
Sources: manifests/ate-install/kind/rustfs.yaml#L55-L75, rustfs/rustfs.
{% enddetails %}
{% details ReBAC: deriving permissions from relationships %}
ReBAC and Kubernetes RBAC
Substrate's API authorization uses OpenFGA's relationship-based access control model. In this version, ordinary Control RPC enforcement remains experimental and disabled by default.
Source: docs/security.md#L46.
Kubernetes RBAC checks applicable RoleBindings and ClusterRoleBindings against Role/ClusterRole rules: API groups, resources, verbs, namespaces and sometimes resource names. It does not automatically derive access from ownership or arbitrary parent-child relationships.
Source: Using RBAC Authorization.
ReBAC derives access from relations between subjects and objects, and potentially relations between objects. Inheritance is explicit in the authorization model, rather than automatic for every parent-child association.
Source: OpenFGA: Modeling—Getting Started.
Substrate's .fga model makes a Worker's read permission depend on its parent global object's viewer or controller relation:
<!-- OpenFGA DSL; Python fencing provides partial syntax highlighting only. -->
type worker
relations
define parent_global: [global]
define host_node: [node]
define can_get: viewer from parent_global or controller from parent_global
Source: cmd/ateapi/internal/authz/model.fga#L29-L34.
An Atespace can inherit its owner from its parent global object:
type atespace
relations
define parent_global: [global]
define owner: [user] or owner from parent_global
An ActorTemplate can derive read permission from the viewer relation on its parent Atespace:
type actor_template
relations
define parent_atespace: [atespace]
define can_get: viewer from parent_atespace
Sources: cmd/ateapi/internal/authz/model.fga#L71-L77, cmd/ateapi/internal/authz/model.fga#L101-L106.
The relation data connects global → atespace → actor_template and global → worker. Permission checks follow those modelled relations. The practical difference from ordinary Kubernetes RoleBindings is the ability to derive child access from parent relations according to an explicit model.
{% enddetails %}
Components and versions
| Component | Version | Repository | Scope |
|:---|:---|:---|:---|
| Agent Substrate | v0.5.0 | agent-substrate/substrate | Implementation, configuration and source examples |
| Kubernetes | v1.37.1 | kubernetes/kubernetes | Pod certificate APIs and feature gates |
| kind | v0.33.0 | kubernetes-sigs/kind | Local example using kindest/node:v1.37.0 |
GKE availability information reflects the official documentation checked on October 20, 2026. The GKE installer does not necessarily distribute the same version as the upstream tag used here.
Note: Agent Substrate is pre-1.0 and does not guarantee backward compatibility. Unless another repository is named, source links in this article refer to agent-substrate/substrate v0.5.0.
References
- Kubernetes Podcast #272: Agent Substrate
- Agent Substrate repository
- Bringing you Agent Sandbox on GKE and Agent Substrate
- Agent Substrate on GKE announcement
- Install Agent Substrate on GKE
- Architecture
- Threat model
- Glossary
- Security guide
-
LeaderWorkerSetとDisaggregatedSetの概要
はじめに
以前、Agent Substrate の概要を執筆した流れで、もう1つ気になっていた LeaderWorkerSet (LWS) を確認してみました。LWSはSIG Appsが管理するKubernetesの公式サブプロジェクト(kubernetes-sigs)で、vLLM/SGLangのような分散LLM推論を「leader 1台 + worker N台」のPodグループ単位でデプロイ・スケール・ローリングアップデートするためのAPIです。
関連する仕組みについては、以下の記事も執筆しています。エージェントの Task を管理する Agent Executor、その実行基盤となる Agent Substrate、単一 Pod のサンドボックスを管理する Agent Sandbox について整理しています。
- Agent Executorの概要
- Agent Substrate の概要
- Agent Sandboxの概要
Google Cloudは、Agent Substrateをエージェント実行基盤のスタックの中でどう位置づけているかを、次の図で示しています。
!Agent Substrateを中心とした、エージェント基盤のレイヤー構成図。最上位にAgent Runtime/Agent Harnesses/Custom Agents&Toolsからなる Agent Application Layer、その下にSandbox Isolation(gVisor/MicroVM)を含むAgent Substrateと、Deployments/StatefulSets/LWSなどのOther k8s workloads、最下層にKubernetesとIaaS/Bare metal、Blob storageが並ぶ。
(出典: Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog, 2026-05-20)
LWSは、この図の右側のOther k8s workloads (Deployments, StatefulSets, LWS)に入っています。緑色のAgent Substrate(エージェントの実行基盤)の内側ではなく、DeploymentやStatefulSetと並ぶ「その他のKubernetesワークロード」の1つという位置づけです。
この記事では、LeaderWorkerSetがどういう課題を解決するためのAPIなのか、内部でどう実装されているか、そしてその上に作られた新しいAPIであるDisaggregatedSetが何を追加しているのかを整理します。
対象コンポーネントとバージョン
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| LeaderWorkerSet (LWS) | v1.0.0 | kubernetes-sigs/lws | 本記事全体のソースコード調査対象(LeaderWorkerSet/DisaggregatedSet両APIを含む) |
| kubernetes/kubernetes | v1.37.1 | kubernetes/kubernetes | LWSのgo.modが参照するk8s.io/api v0.37.1に対応するリリース |
| kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージはkindest/node:v1.37.0 |
本記事の実装説明は LWS v1.0.0 に固定します。LeaderWorkerSet と DisaggregatedSet は、このプロジェクトが CRD とコントローラーで提供する API です。Kubernetes 本体に組み込まれた API とは区別します。
(参考: Distributed Inference APIs | LWS Overview)
TL;DR
- LWS は Pod グループをレプリケーションの単位にします。leader と worker をまとめてスケール・更新します。
- デフォルトの Ordinal モードは2階層の StatefulSet で構成されます。Hash モードでは leader の管理に Deployment を使います。
- デフォルトでは、Pod の削除やコンテナの再起動を検知するとグループを再作成します。他のグループは再作成の対象になりません。
- 並列作成と gang scheduling は別の機能です。全 Pod をまとめてスケジューリングするには、追加の設定と対応するスケジューラーが必要です。
- DisaggregatedSet は複数 role の LWS をまとめて管理します。role 間の更新進捗を調整しますが、更新中の稼働数の比率が常に一定になるわけではありません。
第1章: LeaderWorkerSetが解決する課題
この章では、分散LLM推論のようなワークロードが、なぜDeploymentやStatefulSetだけでは扱いにくいのかを整理します。
1.1 Podグループを1つの単位として扱う必要性
vLLM や SGLang のようなフレームワークで、tensor 並列・pipeline 並列を複数ホストにまたがって構成する場合、1つの推論インスタンスを複数の Pod に分けて動かすことがあります。各プロセスは、フレームワークに応じた rank や並列数の設定で協調動作します。READMEはLWSの目的を次のように説明しています。
> LeaderWorkerSet (LWS): An API for deploying a group of pods as a unit of replication. It aims to address common deployment patterns of AI/ML inference workloads, especially multi-host inference workloads where the LLM will be sharded and run across multiple devices on multiple nodes.
(参考: README.md#L9)
StatefulSetは個々のPodに安定した名前とストレージを与えますが、「leaderとworkerで別々のPodテンプレートを使い、それらをまとめて1つのレプリカとしてスケール・更新する」という発想を持っていません。LWSはこのギャップを埋めるためのAPIです。
<p align="center">
<img src="raw.githubusercontent.com" width="550" alt="LWS Concept">
</p>
LeaderWorkerSetの概念図。複数のreplica(leader + workerの組)が1つのLeaderWorkerSetにまとまる。
(参考: README.md#L31-L33)
1.2 なぜStatefulSetを土台に選んだか
デフォルトの Ordinal モードでは、LWS は StatefulSet を組み合わせて Pod を管理します。公式ドキュメントは、この選択の理由を5点挙げています。
> 1. Deterministic Ordinal Identity and Predictable Networking ... 2. Native Dynamic Storage (volumeClaimTemplates) ... 3. Parallel Pod Management ... 4. Partitioned Rolling Updates ... 5. Kubernetes API Composition Best Practices
(参考: Architecture and Relationship with StatefulSet | LWS Concepts)
特に3番目の「並列 Pod 管理」は、1つの Pod が Ready になるまで次の Pod の作成を待つと、NCCL/MPI などの通信相手がそろわず起動が進まないケースに対応します。StatefulSet の Parallel は、前の Pod の Ready を待たずに Pod を作成できる設定です。全 Pod の同時起動や、一括でのリソース確保を保証するものではありません。
また、startupPolicy のデフォルトは LeaderCreated で、leader Pod の作成後に worker 用 StatefulSet を作ります。LeaderReady を選ぶと、leader が Ready になるまで worker の作成を待ちます。
(参考: Startup Policy | LWS Concepts、leaderworkerset_types.go#L650-L656)
1.3 vLLM の並列実行と LWS のグループ管理
LWS が Pod をまとめ、vLLM がモデルを分割して実行します。Pod の数を指定するだけで、モデルの分割方法まで決まるわけではありません。
Google Cloud の2024年11月8日の記事では、Llama 3.1 405B FP16 を、各8基の H100 を持つ2台の A3 ノードで動かしています。ノード内は tensor parallelism (TP) で分割し、ノード間は層を分担する pipeline parallelism (PP) を使う、TP=8・PP=2 の例です。
(参考: How to deploy and serve multi-host gen AI large open models over GKE | Google Cloud Blog)
このように1 Pod が1ノードの8 GPU を使う構成を LWS で表すと、設定の対応は次のようになります。
| 設定 | 値 | 意味 |
|---|---:|---|
| LWS spec.replicas | 1 | 推論インスタンスを1組動かす |
| LWS leaderWorkerTemplate.size | 2 | leader を含む2 Pod で1組を構成する |
| 各 Pod の GPU 要求 | 8 | 1組あたり合計16 GPU を使う |
| vLLM --tensor-parallel-size | 8 | tensor を8分割して実行する |
| vLLM --pipeline-parallel-size | 2 | モデルの層を2段に分けて実行する |
ここで replicas を2にすると、同じ構成の推論インスタンスが2組、定常状態で4 Pod・32 GPU になります。1つのモデルの分割数を増やす操作とは異なります。また、「TP はノード内、PP はノード間」はこの例の構成です。GPU 間の接続やモデルに応じて別の分割も選べます。
(参考: Parallelism and Scaling | vLLM)
Google の記事は Ray を使っています。Ray は、Python の処理を複数の CPU・GPU やノードに分散して実行するためのオープンソースのフレームワークです。この構成では、vLLM の分散実行を担います。
2026年10月21日に確認した vLLM の LWS 手順には multiprocessing と Ray の両方の例があります。Ray は LWS 自体の必須要件ではありません。multiprocessing の例では、LWS が設定する環境変数を vLLM の起動引数に渡します。
| LWS の環境変数 | vLLM の引数 | 渡す情報 |
|---|---|---|
| LWS_GROUP_SIZE | --nnodes | グループ内の Pod 数。この例ではノード数に対応 |
| LWS_WORKER_INDEX | --node-rank | グループ内のインデックス。leader は0 |
| LWS_LEADER_ADDRESS | --master-addr | 分散実行の接続先となる leader のアドレス |
同じ手順では、HTTP Service が leader のラベルだけを選択し、worker は分散実行に参加します。外部リクエストの受付先と、計算に参加する Pod を分けている点も、2種類のテンプレートを使う理由です。
(参考: LWS | vLLM、leaderworkerset_types.go#L70-L80)
:::note info
vLLM の stable ドキュメントは更新されます。ここでは設定の対応を説明しており、この GPU 構成で推論を実行したわけではありません。実際に試す際は、vLLM のイメージと対応する手順を固定し、GPU メモリ、モデルの配布方法、ノード間通信も確認してください。手順にある「2ノード・各8 GPU」はそのデプロイ例の条件で、LWS API 全体の最低要件ではありません。
:::
第2章: Ordinal モードの2階層 StatefulSet 構成
この章では、LeaderWorkerSetが実際にどのKubernetesリソースを作ってグループを実現しているかを、ソースコードから確認します。
2.1 LeaderWorkerSetSpecの主要フィールド
LeaderWorkerSetSpecは、グループ数(replicas)とグループの中身(leaderWorkerTemplate)を分けて持っています。
type LeaderWorkerSetSpec struct {
// replicas is the number of leader-workers groups. ...
// On scale down, the leader pod as well as the workers statefulset will be deleted.
// Default to 1.
Replicas *int32 `json:"replicas,omitempty"`
// leaderWorkerTemplate defines the template for leader/worker pods
LeaderWorkerTemplate LeaderWorkerTemplate `json:"leaderWorkerTemplate"`
...
}
(参考: leaderworkerset_types.go#L175-L191)
LeaderWorkerTemplateの方は、leader用とworker用で別々のPodTemplateを持てるようになっています。
type LeaderWorkerTemplate struct {
// leaderTemplate defines the pod template for leader pods.
LeaderTemplate *corev1.PodTemplateSpec `json:"leaderTemplate,omitempty"`
// workerTemplate defines the pod template for worker pods.
WorkerTemplate corev1.PodTemplateSpec `json:"workerTemplate"`
// size is the number of pods to create. It is the total number of pods in each group.
// The minimum is 1 which represent the leader. ...
Size *int32 `json:"size,omitempty"`
...
}
(参考: leaderworkerset_types.go#L383-L403)
sizeが1グループあたりのPod総数(leader含む)で、replicasがグループの数です。更新や再作成が完了した定常状態では、Pod の総数は replicas * size になります。更新・終了処理の途中では、この数と一致しないことがあります。leaderTemplate を省略すると、leader にも workerTemplate が使われます。
2.2 Leader用StatefulSetとWorker用StatefulSetの構築
ここでは、デフォルトの groupIdentity: Ordinal を説明します。LWS は、LeaderWorkerSet そのものを管理するLeaderWorkerSetReconcilerと、個々のleader Podを監視するPodReconcilerという2つのコントローラーで構成されています。Leader用のStatefulSetはLeaderWorkerSetReconciler側、Worker用のStatefulSetはPodReconciler側で構築されます。
Leader用StatefulSetの構築では、replicas個のleader PodをParallelなPod管理方式で作ります。
statefulSetConfig := appsapplyv1.StatefulSet(lws.Name, lws.Namespace).
WithSpec(appsapplyv1.StatefulSetSpec().
WithServiceName(lws.Name).
WithReplicas(replicas).
WithPodManagementPolicy(appsv1.ParallelPodManagement).
WithTemplate(podTemplateApplyConfiguration).
...
(参考: leaderworkerset_controller.go#L1189-L1226)
デフォルトの startupPolicy: LeaderCreated では、leader Pod が1つ作られるたびに、PodReconcilerがそのleaderに対応するWorker用StatefulSetを<lws名>-<replicaのインデックス>という名前で作ります。Pod数はsize - 1(leaderを除いた数)で、オーディナルは0ではなく1から始まります。
statefulSetConfig := appsapplyv1.StatefulSet(workerStatefulSetName(&leaderPod), leaderPod.Namespace).
WithSpec(appsapplyv1.StatefulSetSpec().
WithServiceName(serviceName).
WithReplicas(*currentLws.Spec.LeaderWorkerTemplate.Size - 1).
WithPodManagementPolicy(appsv1.ParallelPodManagement).
WithTemplate(&podTemplateApplyConfiguration).
WithOrdinals(appsapplyv1.StatefulSetOrdinals().WithStart(1)).
...
(参考: pod_controller.go#L1586-L1649)
つまり、replicas=2, size=3のLeaderWorkerSetを1つ作ると、実際には次のKubernetesリソースが生成されます。
LeaderWorkerSet (replicas=2, size=3)
│
└── Leader StatefulSet "<lws名>" (replicas=2)
│
├── Leader Pod "<lws名>-0" + Worker StatefulSet "<lws名>-0" (replicas=2, startOrdinal=1)
│ → Worker Pod "<lws名>-0-1", "<lws名>-0-2"
│
└── Leader Pod "<lws名>-1" + Worker StatefulSet "<lws名>-1" (replicas=2, startOrdinal=1)
→ Worker Pod "<lws名>-1-1", "<lws名>-1-2"
(参考: Architecture and Relationship with StatefulSet | LWS Concepts)
5.2節で、この構造を実際にkindクラスタ上で確認します。
第3章: グループ単位の振る舞いを支える主な仕組み
この章では、LeaderWorkerSetが「グループを1つの単位として扱う」ために追加している、StatefulSet単体には無い仕組みを見ていきます。
3.1 グループ単位の再作成: RestartPolicy
分散推論では、1つのworker Podが落ちて集団通信が崩れても、そのPodだけを再起動すれば済むとは限りません。LWSはデフォルトで、グループ内のどれか1つのPodが失敗・再起動した場合、グループ全体(leader + 全worker)を作り直すRecreateGroupOnPodRestartというポリシーを使います。
実装では、PodReconciler が Pod の削除やコンテナの再起動を検知し、グループの leader Pod を削除することで再作成を進めます。
// the leader pod will be deleted if the worker pod is deleted or any container was restarted
if !isRecreatePending && !podutils.ContainerRestarted(pod) && !podutils.PodDeleted(pod) {
return false, ctrl.Result{}, nil
}
(参考: pod_controller.go#L562-L567)
worker 用 StatefulSet の owner reference は leader Pod を指しています。leader の削除に伴って、ガベージコレクションが worker 用 StatefulSet とその Pod を削除します。その後、leader 用 StatefulSet と PodReconciler が新しい leader と worker 群を作ります。削除・終了・再作成は非同期に進むため、全 Pod が同時に停止・起動するという意味ではありません。
(参考: pod_controller.go#L384-L393、pod_controller.go#L684-L699)
v1.0.0 では、再作成回数の上限を設ける maxGroupRestarts と、再作成の間隔を延ばす restartBackoff も設定できます。どちらもデフォルトでは未設定で、回数制限やバックオフは有効になりません。5.3節では、これらを設定せずに worker Pod を1つ削除した場合を確認します。
(参考: leaderworkerset_types.go#L413-L439)
3.2 グループ識別方式: OrdinalとHash
LeaderWorkerSetSpecのgroupIdentityフィールドは、グループの名前付けとスケールダウン方式を切り替えます。
> groupIdentity determines how group identities are assigned.
> Ordinal (default) manages leaders through a StatefulSet: groups are named <lws>-0..<lws>-N-1 and scale down always removes the highest ordinal.
> Hash manages leaders through a Deployment: group names are hash-suffixed, scale down prefers unscheduled and not-ready groups over healthy ones, and rollouts are paced by a group readiness gate on the leader pods.
(参考: leaderworkerset_types.go#L215-L225)
Hashモードの実装コメントは、その仕組みをさらに具体的に説明しています。
> reconcileHash reconciles a LeaderWorkerSet with GroupIdentity=Hash. Leaders are managed through a Deployment instead of a StatefulSet: the ReplicaSet picks scale-down victims (unscheduled and not-ready groups before healthy ones) and the Deployment paces rollouts, throttled by the group-ready readiness gate that the pod controller maintains on leader pods.
(参考: leaderworkerset_hash.go#L45-L49)
| 項目 | Ordinal(デフォルト) | Hash |
|---|---|---|
| leaderの実体 | StatefulSet | Deployment |
| グループ名 | <lws>-0, <lws>-1, ... の連番 | Deployment が生成するハッシュ・接尾辞を含む名前 |
| スケールダウン対象 | 常に最大オーディナルのグループ | 未スケジュール・未Readyなグループを優先 |
| ロールアウトの調整方法 | StatefulSetのpartition | leader Podへのreadiness gate |
Ordinal は安定したグループ名が必要な構成に適しています。Hash は、連番に依存せず、未スケジュール・未 Ready のグループを優先して減らしたい場合の選択肢です。ただし、アプリケーションの処理内容を見て、任意のグループを確実に残す機能ではありません。groupIdentity は作成後に変更できません。
(参考: Group Identity | LWS Concepts)
3.3 トポロジー配置とgang scheduling
分散推論はノード間の通信帯域がボトルネックになりやすいため、LWS には1つのグループを同じトポロジードメイン(ラック、ブロックなど)に配置するための設定があります。利用する場合は、次のアノテーションにノードのトポロジーラベルのキーを指定します。デフォルトでこの配置制約が付くわけではありません。
> The annotation leaderworkerset.sigs.k8s.io/exclusive-topology defines a 1:1 mapping between an LWS replica and a topology domain.
(参考: Exclusive Topology Placement | LWS Concepts)
これとは別に、Alpha機能として「グループ内の全Podをall-or-nothingでスケジューリングする」gang schedulingも用意されています。
> Native gang scheduling uses the Kubernetes Workload-Aware Scheduling APIs to admit related pods as a unit. LWS owns the Workload and PodGroup objects and sets each managed pod's spec.schedulingGroup; users express the intent only on the LeaderWorkerSet.
(参考: Gang Scheduling | LWS Concepts)
v1.0.0 の Kubernetes ネイティブな gang scheduling には、次の設定が必要です。5章の通常のインストールでは有効にしていません。
| 対象 | 必要な設定 |
|---|---|
| Kubernetes | v1.37 の Workload / PodGroup API。kube-apiserver・kube-controller-manager・kube-scheduler で GenericWorkload=true を有効化し、kube-apiserver の --runtime-config に scheduling.k8s.io/v1beta1=true を指定する |
| LWS コントローラー | featureGates.WorkloadAwareScheduling: true と gangSchedulingManagement.schedulerProvider: kubernetes |
| LeaderWorkerSet | spec.scheduling を設定する。scheduling: {} ではグループごとの gang scheduling を使う |
(参考: Native Gang Scheduling | LWS Installation、Gang Scheduling | LWS Concepts)
:::note warn
gang scheduling は Alpha 機能です。Kubernetes ネイティブの API を使う構成のほか、Volcano 向けの実装もあります。ネイティブ構成のグループ単位の gang scheduling は startupPolicy: LeaderReady と併用できず、exclusive-topology アノテーションとの併用にも制約があります。並列作成だけを設定した通常の LWS は、Pod ごとにスケジューリングされます。
:::
3.4 HPA が増減するのもグループ数
LWS は HPA 向けの scale サブリソースを持ち、spec.replicas を通じてグループ数を増減できます。HPA が変更するのはグループ数で、size や vLLM の TP・PP 数ではありません。1.3節の構成なら、1レプリカ追加するたびに2 Pod・16 GPU 分の配置先が必要です。
v1.0.0 の status.hpaPodSelector は leader Pod だけを選択します。Pod の CPU 使用率を使う HPA でも、worker を含むグループ全体の GPU 負荷を自動的に評価するわけではありません。推論負荷に合わせるには、受付待ちリクエスト数など、利用するサービング基盤で取得できる指標とスケール方針を検討します。
(参考: leaderworkerset_types.go#L607-L611、scale サブリソースの定義、Autoscaling | LWS Examples)
第4章: DisaggregatedSet — 複数のLWSをrole単位でまとめる
この章では、LWSの上に作られた新しいAPIであるDisaggregatedSetが何を追加しているかを整理します。
4.1 Disaggregated推論という課題
LLM推論は、入力プロンプトからKVキャッシュを作るprefillフェーズと、トークンを1つずつ生成するdecodeフェーズに分けられます。一般に prefill は計算量、decode はメモリ帯域の影響を受けやすいため、それぞれ別のリソース量・別のスケール方針を持つPod群として分離(disaggregate)したい、という要求があります。
> DisaggregatedSet (DS): An API to support advanced multi-node inference. LWS forms the core API for multi-node while DisaggregatedSet builds on it to add advanced disaggregated workload deployment with support for autoscaling, rollouts and failure handling.
(参考: README.md#L11)
<p align="center">
<img src="raw.githubusercontent.com" width="700" alt="DisaggregatedSet Concept">
</p>
DisaggregatedSetの概念図。複数のroleがそれぞれ独立したLeaderWorkerSetとして管理される。
(参考: README.md#L42-L44)
4.2 DisaggregatedSetは子LeaderWorkerSetを作るAPI
DisaggregatedSetはLWSを置き換えるAPIではなく、LWSの上に乗るAPIです。
> DisaggregatedSet does not replace LeaderWorkerSet — it orchestrates multiple LeaderWorkerSets.
>
> Each role defined in a DisaggregatedSet spec maps to an independent LeaderWorkerSet, deployed in the same namespace.
(参考: DisaggregatedSet | LWS Concepts)
DisaggregatedSetSpec は、role(prefill/decode 等)のリストと、スライス数(同じ role トポロジーをまるごと複製する単位)を持ちます。slices のデフォルトは1です。以下では subrole を使わない基本構成を説明します。
type DisaggregatedSetSpec struct {
// Roles defines the list of roles (at least 1 required).
Roles []DisaggregatedRoleSpec `json:"roles"`
// Slices is the number of independent copies of the whole role topology.
Slices *int32 `json:"slices,omitempty"`
...
}
(参考: disaggregatedset_types.go#L217-L247)
DisaggregatedRoleSpecはsigs.k8s.io/lwsのLeaderWorkerSetTemplateSpecをそのまま埋め込んでおり、各roleの実体は中身がLWSのSpecそのものです。
type DisaggregatedRoleSpec struct {
// Name is the unique identifier for this role.
Name string `json:"name"`
...
// LeaderWorkerSetTemplateSpec defines the LWS template for this role.
// Note: Spec.RolloutStrategy.Type must be RollingUpdate (or empty) and
// Spec.RolloutStrategy.RollingUpdateConfiguration.Partition must not be set.
// DisaggregatedSet handles rollouts across roles.
leaderworkerset.LeaderWorkerSetTemplateSpec `json:",inline"`
}
(参考: disaggregatedset_types.go#L184-L216)
定常状態では、各スライスの role ごとに子 LWS が作られます。更新中は旧・新リビジョンの子 LWS が共存するため、常に「role 数と同じ数の LWS」になるわけではありません。子 LWS の名前は、どのDisaggregatedSetのどのスライス・どのリビジョン・どのroleに属するかが分かるように、次の規則で生成されます。
func GenerateName(baseName string, slice int, revision, role string) string {
return fmt.Sprintf("%s-%d-%s-%s", baseName, slice, revision, role)
}
(参考: utils.go#L96-L98)
生成された子LWSには、disaggregatedset.x-k8s.io/name・disaggregatedset.x-k8s.io/role・disaggregatedset.x-k8s.io/sliceといったラベルが付き、ハードコードした名前ではなくこれらのラベルで子リソースを特定することが推奨されています(参考: DisaggregatedSet | LWS Concepts)。5.4節で、実際にDisaggregatedSetを作って子LWSの名前・ラベルを確認します。
4.3 role間で協調するロールアウト
DisaggregatedSet は、prefill と decode のような複数 role の更新進捗を調整します。各 role が独立に更新を完了するのではなく、role ごとのレプリカ数に対する更新割合を考慮して、旧・新リビジョンへの移行を進めます。
各 role の LWS Spec は RollingUpdate を使い、partition の値は DisaggregatedSet が管理します。一方、追加を許すグループ数の maxSurge と、利用できなくなることを許すグループ数の maxUnavailable は、role ごとに設定できます。
v1.0.0 のデフォルトは maxSurge: 0・maxUnavailable: 1 です。1グループしかない role は、更新中に全グループが利用できなくなることがあります。たとえば prefill が1、decode が2の構成でも、更新途中の稼働数が常に 1:2 に保たれる保証はありません。停止を許さない role では maxUnavailable: 0 と正の maxSurge、追加分を動かせるリソースを用意する必要があります。
(参考: DisaggregatedSet | LWS Concepts、disaggregatedset_types.go#L184-L216)
DisaggregatedSet が管理するのは role の配置・スケール・更新です。prefill から decode への KV キャッシュ転送や、推論リクエストの振り分けには、利用する推論フレームワーク側の設定も必要です。
第5章: kindクラスタで実際に動かす
この章では、2026年10月21日に kind v0.33.0・Kubernetes v1.37.0・LWS v1.0.0 で実行した手順と結果を整理します。nginx で API とリソース構成を確認するもので、LLM 推論の性能や GPU 通信、gang scheduling は検証していません。
5.1 インストール
Docker・kind・kubectl を用意し、検証専用のクラスタを作ります。lws-demo は既存環境と重複しない名前にしてください。通常の LWS / DisaggregatedSet を使うには、公式のリリースマニフェストを適用します。
kind create cluster --name lws-demo --image kindest/node:v1.37.0
VERSION=v1.0.0
kubectl apply --server-side -f https://github.com/kubernetes-sigs/lws/releases/download/$VERSION/manifests.yaml
kubectl wait deploy/lws-controller-manager -n lws-system --for=condition=available --timeout=5m
(参考: Install a released version | LWS Installation)
このマニフェストにはLeaderWorkerSetとDisaggregatedSet両方のCRDが含まれており、DisaggregatedSetを使うための追加のインストール手順は必要ありません。
> Starting from v0.9.0, DisaggregatedSet is bundled with the LWS controller manager.
>
> For kubectl and Kustomize installs, the standard v0.9.0+ manifests include the DisaggregatedSet CRD, controller permissions, and validating webhook. No separate DisaggregatedSet installation step is required.
(参考: DisaggregatedSet | LWS Installation)
5.2 LeaderWorkerSetを作って2階層のStatefulSetを確認する
GPU を使わずに確認できるよう、公式の nginx 例から LWS 部分を使いました。以下を lws-sample.yaml として保存します。HPA は使わないため、この確認に Metrics Server は不要です。
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
name: leaderworkerset-sample
spec:
replicas: 2
leaderWorkerTemplate:
size: 3
leaderTemplate:
spec:
containers:
- name: leader
image: nginx:1.27
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}
workerTemplate:
spec:
containers:
- name: worker
image: nginx:1.27
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}
(参考: nginx.yaml#L5-L38、HPA部分は本記事では使用していません)
kubectl apply -f lws-sample.yaml
kubectl wait pod -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample --for=condition=Ready --timeout=5m
replicas=2, size=3 で作成し、全6 Pod が Ready になるまで確認すると、2.2節で説明した通りのStatefulSet構造がそのまま現れました。
以下は今回の実行結果です。
$ kubectl get leaderworkerset,statefulset,pods
NAME READY DESIRED UP-TO-DATE AGE
leaderworkerset.leaderworkerset.x-k8s.io/leaderworkerset-sample 2 2 2 36s
NAME READY AGE
statefulset.apps/leaderworkerset-sample 2/2 35s
statefulset.apps/leaderworkerset-sample-0 2/2 35s
statefulset.apps/leaderworkerset-sample-1 2/2 35s
NAME READY STATUS RESTARTS AGE
pod/leaderworkerset-sample-0 1/1 Running 0 35s
pod/leaderworkerset-sample-0-1 1/1 Running 0 35s
pod/leaderworkerset-sample-0-2 1/1 Running 0 35s
pod/leaderworkerset-sample-1 1/1 Running 0 35s
pod/leaderworkerset-sample-1-1 1/1 Running 0 35s
pod/leaderworkerset-sample-1-2 1/1 Running 0 35s
leader用StatefulSetが1つ(leaderworkerset-sample)、replicaごとのworker用StatefulSetが2つ(leaderworkerset-sample-0、leaderworkerset-sample-1)作られ、worker Podのオーディナルが0ではなく1から始まっていることも確認できました。あわせて、グループ内のPod間でDNS解決するためのheadless Serviceも作られています。
$ kubectl get svc leaderworkerset-sample -o wide
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
leaderworkerset-sample ClusterIP None <none> <none> 35s leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample
5.3 Pod を1つ削除してグループの再作成を確認する
3.1節で見たRecreateGroupOnPodRestart(デフォルト)の挙動を、replica 0のworker Pod 1つを明示的に削除して確認しました。
削除前後で、次のコマンドから Pod の UID と作成時刻を取得して比較できます。同じ名前の Pod でも、UID が変われば別の Pod です。
kubectl get pods -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample \
-o custom-columns=NAME:.metadata.name,UID:.metadata.uid,CREATED:.metadata.creationTimestamp
kubectl delete pod leaderworkerset-sample-0-1
# 再作成は非同期に進むため、グループ0の置き換えと全6 Pod の Ready を確認する
kubectl get pods -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample -w
一覧の監視は Ctrl-C で終了し、最初の UID・作成時刻の取得コマンドを再実行します。
削除前後でPodのUIDと作成時刻を比較すると、削除したPodだけでなく、同じreplica(グループ0)のleaderと残りのworkerもすべて新しいUID・新しい作成時刻を持つPodに置き換わっていました。
UID は先頭8文字を抜粋しています。
| Pod | 削除前の UID | 削除後の UID | 結果 |
|---|---|---|---|
| leaderworkerset-sample-0 | 6196dd0c | f8e535fc | 作り直された |
| leaderworkerset-sample-0-1 | e41eae20 | 82eebca7 | 作り直された |
| leaderworkerset-sample-0-2 | 6985a793 | 539304af | 作り直された |
| leaderworkerset-sample-1 | 561f7a2d | 561f7a2d | 変化なし |
| leaderworkerset-sample-1-1 | 4353aebd | 4353aebd | 変化なし |
| leaderworkerset-sample-1-2 | 464c526f | 464c526f | 変化なし |
グループ1(無関係なreplica)のPodは一切再作成されず、UIDも作成時刻も変化しませんでした。worker Podを1つ削除しただけで、そのPodが属するグループ全体(leader含む)だけが作り直され、他のグループには影響しないことを確認できました。
5.4 DisaggregatedSetを作って子LWSの生成を確認する
4.2節で見た子 LWS の生成を、1スライス・prefill/decode の2 role で確認しました。実際の推論処理はせず、nginx で API の挙動だけを見ています。以下を disaggregated-sample.yaml として保存します。
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata:
name: nginx-disagg
spec:
roles:
- name: prefill
spec:
replicas: 1
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- {name: nginx, image: nginx:1.27, resources: {requests: {cpu: 50m, memory: 64Mi}}}
- name: decode
spec:
replicas: 2
leaderWorkerTemplate:
size: 1
workerTemplate:
spec:
containers:
- {name: nginx, image: nginx:1.27, resources: {requests: {cpu: 50m, memory: 64Mi}}}
(参考: disaggregatedset_types.go#L184-L247で確認したスキーマに沿って作成)
kubectl apply -f disaggregated-sample.yaml
kubectl get leaderworkerset -l disaggregatedset.x-k8s.io/name=nginx-disagg -w
各 role が必要数の Ready グループを持つことを確認し、Ctrl-C で監視を終了します。以下は今回の実行結果で、リビジョンハッシュは設定内容によって変わります。
適用すると、4.2節で説明した命名規則の通り、<ds名>-<スライス番号>-<リビジョンハッシュ>-<role名>という名前の子LWSが2つ作られました。
$ kubectl get disaggregatedset,leaderworkerset
NAME AGE
disaggregatedset.disaggregatedset.x-k8s.io/nginx-disagg 3s
NAME READY DESIRED UP-TO-DATE AGE
leaderworkerset.leaderworkerset.x-k8s.io/leaderworkerset-sample 2 2 2 43s
leaderworkerset.leaderworkerset.x-k8s.io/nginx-disagg-0-69150551-decode 2 2 2 3s
leaderworkerset.leaderworkerset.x-k8s.io/nginx-disagg-0-69150551-prefill 1 1 1 3s
nginx-disagg-0-69150551-prefillのラベルとowner referenceを見ると、4.2節で引用したdisaggregatedset.x-k8s.io/*ラベルと、親のDisaggregatedSetへのowner referenceが実際に付与されていました。
$ kubectl get leaderworkerset nginx-disagg-0-69150551-prefill --show-labels
LABELS: disaggregatedset.x-k8s.io/name=nginx-disagg,disaggregatedset.x-k8s.io/revision=69150551,disaggregatedset.x-k8s.io/role=prefill,disaggregatedset.x-k8s.io/slice=0,...
実際に確認する際は、ハッシュを含む名前を固定せず、ラベルから子 LWS を取得できます。owner reference は次のコマンドで確認できます。
kubectl get leaderworkerset \
-l disaggregatedset.x-k8s.io/name=nginx-disagg,disaggregatedset.x-k8s.io/role=prefill \
-o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{.metadata.ownerReferences}{"\n"}{end}'
この例は size: 1 で、各グループには leader だけが存在します。leaderTemplate を省略しているため、leader は workerTemplate の nginx で動きます。今回の size: 1 の構成では worker 用 StatefulSet は作られず、各 role の leader 用 StatefulSet だけが作られました。
また、DisaggregatedSetの状態(status.roleStatuses)には各roleのreplicas/readyReplicas/updatedReplicasがroleごとに記録されていました。
status:
roleStatuses:
- {name: prefill, desiredReplicas: 1, replicas: 1, readyReplicas: 1, updatedReplicas: 1}
- {name: decode, desiredReplicas: 2, replicas: 2, readyReplicas: 2, updatedReplicas: 2}
DisaggregatedSetという1つのリソースを操作するだけで、prefillとdecodeという性質の異なる2つのPod群が、それぞれ独立したLWSとして一貫した命名・ラベルの下に管理されることを確認できました。
検証が終わったら、作成したクラスタを削除します。
kind delete cluster --name lws-demo
おわりに
Agent Substrate が Actor 用の control plane を Kubernetes の外に持つ一方で、LWS は StatefulSet や Deployment を組み合わせて Pod グループを管理します。同じ AI 関連の基盤でも、扱う単位と Kubernetes に任せる範囲が違う点が面白いと感じました。
今回は nginx でリソース構成とグループの再作成を確認しました。実際の分散推論では、GPU の確保や通信、role ごとの可用性も含めた検証が必要で、専用の検証環境も欲しくなります。
参考
参考リンク
本文で登場する順に並べています。
- Agent Executorの概要
- Agent Substrate の概要
- Agent Sandboxの概要
- Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog
- kubernetes-sigs/lws
- Distributed Inference APIs | LWS Overview
- Architecture and Relationship with StatefulSet | LWS Concepts
- Startup Policy | LWS Concepts
- How to deploy and serve multi-host gen AI large open models over GKE | Google Cloud Blog
- Parallelism and Scaling | vLLM
- LWS | vLLM
- Failure Handling and Restart Policies | LWS Concepts
- Group Identity | LWS Concepts
- Exclusive Topology Placement | LWS Concepts
- Gang Scheduling | LWS Concepts
- Autoscaling | LWS Examples
- DisaggregatedSet | LWS Concepts
- Installation | LWS Docs
1 like
Agent Substrateの概要
はじめに
Kubernetes Podcast の #272 Agent Substrate で、大量のAIエージェントをKubernetes上で動かすための新しいランタイムとして紹介されているのを聞いて気になったので、確認してまとめてみました。
Agent Substrate(リポジトリ名は substrate)は、Google が公開している OSS で、多数のエージェントを Kubernetes 上の sandbox で効率よく実行することを目指すシステムです。「数百万」という規模や低レイテンシは、プロジェクトが掲げる設計目標として扱います。Kubernetes自体を置き換えるものではなく、Pod・オートスケーリング・NetworkPolicyといった既存の仕組みの上に、エージェント特有のスケジューリング・スナップショット・ID管理・トラフィックのルーティングを追加するレイヤーとして設計されています。
Googleは2026年5月、Agent SandboxのGKEでのGA発表と合わせて、Agent Substrateをエージェント実行基盤のスタックの中でどう位置づけているかを次の図で示しています。
!Agent Substrateを中心としたエージェント基盤のレイヤー構成図。最上位にAgent Runtime/Agent Harnesses/Custom Agents&Toolsからなる Agent Application Layer、その下にSandbox Isolation(gVisor/MicroVM)を含むAgent Substrateと、Deployments/StatefulSets/LWSなどのOther k8s workloads、最下層にKubernetesとIaaS/Bare metal、Blob storageが並ぶ。
(参考: Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog, 2026-05-20)
> Agent Substrate introduces a new level of abstraction, moving and placing agents in and out of running compute in real time. It incorporates Agent Sandbox's secure runtime and snapshotting capabilities, combined with a minimal control plane that works around Kubernetes's constraints. Where standard Kubernetes is optimized for thousands of long-running services, Agent Substrate is designed for millions of sub-second tool calls.
(参考: Bringing you Agent Sandbox on GKE and Agent Substrate)
この図の緑色のブロックに書かれているSandbox Isolation (gVisor / MicroVM)が、本記事の2.3節・3.3節で見るActorのcheckpoint/restoreの基盤であり、引用した文の通りAgent SubstrateはAgent Sandboxのセキュアなランタイムとスナップショット機能を取り込む位置づけです。
その後、2026年9月16日に GKE 向けの提供が発表されました。本記事では、この発表で示された構成も取り上げ、OSS v0.5.0 の実装と GKE 向けの提供条件を分けて整理します。
(参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16)
この記事では、Agent Substrateが解決しようとしている課題、全体のアーキテクチャ、そして各コンポーネントがKubernetesの既存の仕組みのどれに相当する/何を補完しているのかを整理します。
関連する仕組みも別記事にまとめています。
- Agent Executorの概要:Task を管理し、実行を Substrate の Actor に任せる仕組み
- LeaderWorkerSet と DisaggregatedSet の概要:分散推論向けに Pod グループを管理する仕組み
- Agent Sandboxの概要:単一 Pod のサンドボックス環境を管理する API
対象コンポーネントとバージョン
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| Agent Substrate | v0.5.0 | agent-substrate/substrate | 本記事全体のソースコード調査対象 |
| Kubernetes | v1.37.1 | kubernetes/kubernetes | PodCertificateRequest API の定義・feature gate の状態を確認 |
| kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージは kindest/node:v1.37.0(Agent Substrateの hack/tools/kind/go.mod が固定するバージョン) |
GKE 向けの提供条件は公式ドキュメントの確認時点(2026年10月20日)の情報です。以下で扱う OSS のタグと、GKE 向けインストーラの配布バージョンが同一であるとは限りません。
:::note info
Agent Substrate は v1.0 未満で、後方互換性を保証していません。本記事の実装・設定・引用は v0.5.0 に固定します。本文でリポジトリ名を省略したソースコードの引用は、すべて agent-substrate/substrate のものです。
:::
TL;DR
- Kubernetes 上に Actor 専用の control plane を追加します。Worker Pod の配置・起動は Kubernetes に任せ、Actor の状態管理と Worker への割り当ては独自 API と PostgreSQL で扱います。
- 少数の Worker で多数の Actor を扱います。Worker の容量に応じた同時実行と、Suspend/Resume による時間的な多重化を組み合わせます。
- atelet と ateom が sandbox の状態を保存・復元します。gVisor または micro-VM を利用し、Actor の状態を保ちながら Worker を使い回します。保存範囲は snapshot fidelity によって変わります。
- 外部 API の認証情報を Actor の外で注入できます。egress gateway と credential provider を使う構成では、Actor に実際の API キーを渡さずに呼び出せます。
- 設計目標と実装済みの機能は区別が必要です。認可は実験的な機能として存在しますが、通常の Control RPC への強制はデフォルトで無効です。自動アイドル判定や性能値も、個別に確認する必要があります。
第1章: Agent Substrateが解決しようとしている課題
この章では、Agent Substrateがなぜ「Kubernetesに乗せるだけ」ではなくcontrol planeごと新しく作る必要があったのか、その問題意識を整理します。
1.1 エージェントワークロードの特性
Agent Substrateのアーキテクチャドキュメントは、対象ワークロードの特性を次のように説明しています。
> Agents and "agent-like" workloads are generally very bursty, spending most of their time waiting for input or events, then handling those events, then going back to waiting. The time they spend actually doing work is often very short, and the time they spend waiting can be unbounded. Because they often run untrusted logic, they are often run in sandboxes, which means they are usually single-tenant instances, and there are a great many of them.
(参考: architecture.md#L17-L23)
ポイントは3つです。
- 実際に処理している時間が短く、待ち時間(LLMやツール呼び出しの応答待ち)が大半を占める
- 信頼できないコードを実行するためsandbox実行が前提になり、1インスタンス=1テナントになりがち
- そのインスタンスの数が非常に多い
この「アイドル時間が長い・数が多い・sandboxが要る」という3条件が、後述するスナップショットベースの多重化モデルの動機になっています。
1.2 Kubernetesの標準機能で足りない理由
READMEとarchitecture.mdは、Kubernetesをそのまま使わない理由を具体的に挙げています。
> * The Kubernetes API server is not designed to handle millions of resources. It excels at reconciling resources asynchronously across many controllers, but it is not so good at storing very large numbers of discrete resources, or for handling a huge volume of write traffic.
> * Scheduling a workload on Kubernetes requires several asynchronous processes to converge, plus several network hops, plus image pulling and other steps, which can add up. Getting a Pod running in a couple of seconds is great when that Pod will run for hours or days, but for a workload that will run for milliseconds to low-digit seconds, that latency is unacceptable.
> * State management is difficult. Kubernetes provides an API for managing state (PersistentVolumes), but it is not designed for millions of volumes, with widely varying amounts of data, being attached and detached at high Speed.
(参考: architecture.md#L160-L172)
これは、Agent Substrate が想定する規模と応答時間に対する、プロジェクト側の設計判断です。Kubernetes の一律の上限や、すべての環境で Pod 起動に数秒かかるという測定結果ではありません。一方で、ワーカーPodそのものの起動・ワークロード分離・インフラのプロビジョニングはKubernetesに任せたままにしています(「Agent Substrate leverages Kubernetes for infrastructure provisioning and worker lifecycle management (Kubernetes Pods)」、参考: architecture.md#L153)。Agent Substrateが新しく作っているのは、あくまで「Actorの状態管理とスケジューリングだけに特化した、もう1段速いcontrol plane」です。
なぜetcdへの大量の書き込みが台数を増やしても速くならないのかというメカニズム(Raftはリーダー1台が書き込みを確定させる仕組みであること)は、以前別記事Kubernetes の Control Plane と etcd、何台にすべきかで整理しました。ただし、Agent Substrate の設計理由を Raft だけで説明することはできません。非同期な reconciliation や Pod の準備時間も含め、Actor の再開要求で Kubernetes の更新完了を待たない構成にしている点が重要です。
第2章: 全体アーキテクチャ
この章では、Actor のリソースモデルと、起動要求を受けてから応答するまでの流れを整理します。まず、GKE 向け発表の図で、Kubernetes の上に追加される機能を確認します。
!Kubernetes・Compute・Storage の上に、Gateway、Suspend/Resume、gVisor / MicroVM による分離を置く Agent Substrate の全体像。
(参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16)
この図を実装上のコンポーネントへ対応づけたものが、次の脅威モデルの構成図です。
<p align="center">
<img src="raw.githubusercontent.com" width="700" alt="Substrate Architecture Diagram">
</p>
Agent Substrateのアーキテクチャ図。ate-api-server・atelet・ateom・atenet-router・Object Storage・PostgreSQL(Substrate Database)が、Kubernetes上でどう配置されるかを示している。
(参考: threat-model.md#L30-L32)
この図に登場するコンポーネントは、threat-model.mdの「Key Components」節で次のように説明されています。
> * ate-api-server: Actor creation and scheduling and credential issuance.
> * atelet: Per-node daemon, performs snapshot/resume.
> * ateom: Per-worker Pod sidecar, running inside the worker Pod. ...
> * Worker: Preprovisioned Pods that actors get scheduled to.
> * Actor: The core compute primitive, gets scheduled to/from worker via Run for cold start and Resume for snapshot resume.
(参考: threat-model.md#L36-L40)
以降の節では、この図の個々のコンポーネントがどう実装されているかを、ソースコードから具体的に見ていきます。
2.1 ActorとWorkerの多重化モデル
Agent Substrateの用語は次のように定義されています。
- ActorTemplate: actorの「クラス」の定義(コンテナイメージやスナップショット設定)。immutableで、Kubernetesオブジェクトではなく、ate APIのgRPC経由で作成され、control plane側のデータベースに保存される(参考: glossary.md#L10-L19)
- WorkerPool: Kubernetesの CRD。atecontrollerによって Deployment へreconcileされる(参考: glossary.md#L21-L23)
- Atespace: Actor が属する論理的なグループと識別範囲。Kubernetes namespaceではなくglobal-scopedなcontrol plane DB上のレコード(参考: glossary.md#L38-L43)
- Actor: ActorTemplateから作られる1インスタンス。suspend/resumeの単位で、resumeのたびに別のworkerへ移ることがある(参考: glossary.md#L45-L48)
- Worker: WorkerPool内の1つのworker Podを表すレコード。1つのWorkerは複数のActorを同時にホストする(参考: glossary.md#L50-L52)
これらの関係は、architecture.mdのクラス図に整理されています。
classDiagram
namespace kube-apiserver {
class WorkerPool {
<<CRD>>
}
class Deployment
class WorkerPod {
ateom
runsc
}
}
namespace ate-api-server {
class ActorTemplate {
<<record>>
}
class Actor {
<<record>>
status
snapshotRefs
}
class Worker {
<<record>>
actorAssignments
podIP
}
}
ActorTemplate ..> WorkerPool : worker_selector (labels)
WorkerPool ..> Deployment : reconciled by atecontroller
Deployment "1" *-- "*" WorkerPod : manages
Actor "*" --> "1" ActorTemplate : actor_template
Actor "0..*" --> "0..1" Worker : runs on
Worker "1" --> "1" WorkerPod : maps to
API リソースの関係。元の図を基に、Worker が複数 Actor をホストできる実装に合わせて多重度を補正したもの。
(参考: architecture.md#L268-L303)
WorkerPoolはkube-apiserver、ActorTemplate/Actor/Workerはate-api-serverと、管理するAPIサーバーが分けて設計されている点が、このアーキテクチャの核心です。WorkerPoolはKubernetesのDeploymentとして実体化されますが、「どのActorがどのWorkerで動いているか」という高頻度に変化する状態は、Kubernetesリソースではなくate-api-server側のレコードとして管理されます。
2.2 2層のリソースモデル: 宣言的なCRDと、頻繁に更新される状態の専用ストア
architecture.md は、この使い分けの理由を次の 3 点にまとめています。引用中の 100ms は設計上の目標であり、本記事で測定した性能値ではありません。
> 1. Scalability: Offloading the high-frequency, high-scale management of millions of actors to a specialized state store prevents overwhelming the primary cluster control plane with thousands of updates per second.
> 2. Latency: Achieving 100ms resumption requires low-latency state lookups and atomic worker assignments that bypass the eventual consistency and variable latency of standard Kubernetes API servers.
> 3. Governance: Keeping infrastructure resources (WorkerPools, SandboxConfigs) as Kubernetes objects allows platform teams to apply familiar RBAC, auditing, and policy enforcement to the underlying infrastructure, while workload definitions (ActorTemplates) are managed through the substrate API instead, which authenticates callers itself but does not yet implement authorization
(参考: architecture.md#L247-L261)
「インフラ寄りのリソース(WorkerPool・SandboxConfig)はKubernetesに残してRBACや監査を使い回し、ワークロード定義(ActorTemplate)やその実行状態(Actor/Worker)はetcdの外に出して専用ストアに置く」という切り分けです。ただし、このドキュメントの「認可はまだ実装されていない」という説明は、同じタグの実装と一致していません。v0.5.0 には OpenFGA を使う認可機能があり、--experimental-enable-authz (デフォルト false) で通常の Control RPC にも認可を強制できます。AccessPolicy RPC には、このフラグによらずチェックが行われます。
(参考: main.go#L84-L88、interceptor.go#L28-L60)
2.3 Actorのライフサイクル(Resume/Suspend)
!Worker 上の Active な Agent Environment と、Storage に状態を保存した Idle な環境を Suspend/Resume で切り替える図。
(参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16)
図の Active は実行中、Idle は状態を保存して計算資源から外した環境を表しています。単にプロセスを待機させる構成とは異なり、再開時には保存した状態を Worker へ復元します。図は概念図であり、Worker と Actor が常に1対1という意味ではありません。
リクエストを受けてからActorが応答するまでの流れは、architecture.mdのHigh-Level Designに説明されています。
> When a request comes in for that actor instance, the Agent Substrate's proxy intercepts the request. It looks in the control-plane to determine if that actor is currently running or not. If not, it assigns the actor to a worker, which involves telling a node-level component (the "atelet") to restore the actor's most-recent snapshot into the worker on that node. The request is then forwarded to the actor.
>
> Eventually, the user (or the higher-level system) is done with the actor, or it has been idle for a while. It can request the Agent Substrate to suspend the actor, taking a snapshot and freeing up the worker. The next time a request comes in for that actor, it will be resumed again, possibly on a different worker.
(参考: architecture.md#L193-L204)
つまり、Envoyベースのルーター(atenet-router)がリクエストごとにcontrol planeへ再開(Resume)を依頼し、Actorが止まっていればcontrol planeがスナップショットから復元してから、リクエストが転送される、という流れです。5.1節で見る通り、atenet-router自身はActorが動いているかを判断していません。Actorは特定のworkerに固定されず、suspend/resumeのたびに別のworkerへ移動する可能性があります。
第3章: 主要コンポーネントの役割とKubernetesとの対応関係
ここからは、各コンポーネントの実装コードを見ながら、それぞれがKubernetesの既存コンセプトのどれに対応する/何を代替・補完しているのかを整理します。先に全体像として、主要コンポーネントと対応関係の一覧を示します。
| コンポーネント | 役割 | Kubernetesとの対応関係 |
|---|---|---|
| ate-api-server(ateapi) | Actorのライフサイクル管理・スケジューリング・スナップショット調整を行う独自control plane | Kubernetes とは別の Actor 用 API・scheduler・workflow をまとめた制御層。状態は PostgreSQL に保存 |
| atecontroller | WorkerPool/SandboxConfigなどのCRDをDeployment等へreconcileする | sigs.k8s.io/controller-runtime を使った標準的な Kubernetes Controller |
| atelet / ateom | ノードごとのDaemonSet(atelet)とworker Pod内のsandbox駆動プロセス(ateom)で、sandboxのcheckpoint/restoreを行う | Worker Pod の内側で Actor の sandbox を管理する追加の実行層 |
| atenet(router / egress) | Actorの起動要求(ingress)とegressトラフィックのルーティング・制御を行う | Service/Ingress/Gatewayに相当するが、宛先Actorの起動待ち合わせを行う追加のオーケストレーション層を持つ |
| podcertcontroller | コンポーネント間mTLS用の短命証明書を発行する | PodCertificateRequest APIに対する、Kubernetes本体にまだ存在しないsignerの実装 |
上の表は役割ごとに整理したもので、agent-substrate/substrateリポジトリには、これ以外にもsidecarやインストーラ、CLIなどが同梱されています。v0.5.0のcmd/配下とtools/配下を、実体ごとに並べると次の通りです。
| ディレクトリ | 種別 | 役割 | 本記事の節 |
|---|---|---|---|
| cmd/ateapi | control plane(gRPC API server) | Actor/Workerのライフサイクル管理とスケジューリング | 3.1節 |
| cmd/atecontroller | Kubernetes Controller | WorkerPoolなどのCRDをDeploymentなどへreconcileする | 3.2節 |
| cmd/atelet | DaemonSet | ノード上のworker Podを監督し、スナップショットと状態転送を調整する | 3.3節 |
| cmd/ateom-gvisor | worker Pod内のプロセス | runscのcheckpoint/restoreを実行する | 3.3節 |
| cmd/ateom-microvm | worker Pod内のプロセス | ateom-gvisorのmicro-VM版。Cloud Hypervisorの仮想マシンとしてActorを動かす | 3.3節 |
| cmd/objectstore-plugin | sidecar | GCS/S3へのスナップショット転送を担う。nodeモードはatelet、controlモードはate-api-serverのsidecarとして動く | 3.3節 |
| cmd/atenet | Deployment(router)など | Envoyのルーティングとproxy sidecarを提供するネットワーキングのデーモン | 3.4節 |
| cmd/dataplane/envoy | コンテナイメージ | atenet-egressが使うEnvoyのイメージ。egress policyを強制するRust製のEnvoy Dynamic Moduleを含む | 3.4節, 4.2節 |
| cmd/credential-provider/kubernetes-secrets | リファレンス実装 | Kubernetes Secretを解決するegress credential provider | 4.2節 |
| cmd/podcertcontroller | Deployment | PodCertificateRequestのsigner(Kubernetes本体にまだ無いものを補う「polyfill」) | 3.5節 |
| cmd/kubectl-ate | CLI | Agent Substrateのリソース(Actorなど)を操作する | 7.3節 |
| cmd/ate-setup | CLI(インストーラ) | クラスタへのAgent Substrateのインストールと削除 | 6章 |
| cmd/benchmarking | 負荷試験用ワークロード | RAM・ディスク・ファイルディスクリプタを任意に消費するgluttonなど | 本記事では扱わない |
| tools/setup-gcp | CLI(プロビジョニング) | GKE・GCS・IAMなどGCP側のリソースを用意する | 6章 |
(参考: README.md#L260-L272、cmd/objectstore-plugin/main.go#L15-L19、cmd/ate-setup/README.md#L3、cmd/dataplane/envoy/README.md)
READMEの「Tour」節の一覧には、objectstore-plugin・credential-provider・dataplane・ate-setupは載っていません。この4つは、cmd/配下のディレクトリと各ディレクトリのREADMEやソースコード冒頭のコメントから補っています。
3.1 ate-api-server: 独自control plane
ate-api-server(バイナリ名 ateapi)は、Actorのライフサイクル全体を司るcontrol planeです。
> ate-api-server (binary ateapi): the control plane. It owns the Actor lifecycle, schedules Actors onto Workers, and coordinates their snapshots, all backed by the state store. The kubectl-ate CLI talks to it.
(参考: glossary.md#L56-L58)
実装(cmd/ateapi/main.go)を見ると、PostgreSQLへの接続(connectStore)、Kubernetesクライアントの生成(newKubeClients、WorkerPool/SandboxConfig/CSIDriverConfigのinformerはこのクライアントから取得する)、mTLS用のサーバー証明書構築(buildServerCreds)などを単一のgRPCサーバープロセスが担っています(参考: main.go#L509-L521、main.go#L560-L576、main.go#L578-L586)。
templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv, *templateResyncInterval)
templateReconciler.Start(shutdownCtx)
workerAssignmentReconciler := controlapi.NewWorkerAssignmentReconciler(persistence, workerCache)
workerAssignmentReconciler.Start(shutdownCtx)
(参考: main.go#L326-L330)
ここで出てくるActorTemplateReconcilerやWorkerAssignmentReconcilerは、名前に"Reconciler"と付いていますが、controller-runtimeのReconcilerとは別物です。Kubernetesのカスタムリソースを監視するものではなく、ate-api-serverプロセス内部で動く独自のループで、前者はActorTemplateから「golden snapshot」を作るフローを、後者はateomの再起動で失われたActorをクラッシュ扱いにする処理を担っています。
Kubernetes との対応関係: ate-api-server は Actor/Worker 専用の gRPC API server で、スケジューリングとライフサイクル調整も担当します。状態の保存先は PostgreSQL です。kube-apiserver と一対一に対応するものではなく、Kubernetes とは別に Actor の制御を引き受ける位置づけです。
3.2 atecontroller: WorkerPool CRDのController
atecontrollerは、ここまでのコンポーネントとは対照的に、Kubernetesの標準的なControllerパターンをそのまま踏襲しています。sigs.k8s.io/controller-runtime の ctrl.NewManager を使い、WorkerPoolReconciler など4つのReconcilerを登録しています(参考: main.go#L233-L276)。
WorkerPoolReconciler.Reconcile は、典型的な「Get → DeletionTimestampチェック → reconcile処理」の構造です。
func (r *WorkerPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
wp := &atev1alpha1.WorkerPool{}
if err := r.Get(ctx, req.NamespacedName, wp); err != nil {
if k8errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("failed to get worker pool %q: %w", req.NamespacedName, err)
}
if !wp.GetDeletionTimestamp().IsZero() {
log.Info("WorkerPool is being deleted")
return ctrl.Result{}, nil
}
if err := r.reconcileWorkerPool(ctx, wp); err != nil {
log.Error(err, "Failed to reconcile worker pool")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
(参考: workerpool_controller.go#L105-L129)
実際の処理(reconcileWorkerPool)は、WorkerPoolをDeploymentへ変換するだけでなく、WorkerPoolが参照するSandboxConfig(クラスタスコープのCRDで、gVisorやmicro-VMのランタイムバイナリを保持する)に保護用のfinalizerを付与する処理も行っています。コメントにはその設計意図が明記されています。
> A class that switches to a new SandboxConfig does so in two status writes. This makes sure status never names a config after its protection finalizer was released.
(参考: workerpool_controller.go#L134-L136)
SandboxConfigへのfinalizer付与をWorkerPool側のReconcilerが行い、解放は別のSandboxConfigProtectionReconcilerが行うという2段階の設計で、Owns(&Deployment{}) / Watches(&SandboxConfig{}, ...) という標準的なOwns/Watchesパターンで結び付けられています(参考: workerpool_controller.go#L416-L425)。
Kubernetes との対応関係: atecontrollerは「CRD→下位リソースへの変換」というDeployment Controllerと同じパターンを踏襲した、controller-runtime を使った標準的な Kubernetes Controller です。Agent Substrateの他のコンポーネントが独自のcontrol planeを持つのに対し、WorkerPoolとSandboxConfigに関してはKubernetesネイティブな仕組みをそのまま使っています。
3.3 atelet / ateom: ノードエージェントとsandboxのcheckpoint/restore
ノード側の実行基盤は、DaemonSetのateletとworker Pod内で動くateomの2層構造です。
> atelet: A lightweight supervisor running on each node as a DaemonSet. It acts as the "Herder," managing a pool of physical pods and communicating with the Control Plane.
>
> ateom: A specialized sandbox-herder container image — one per sandbox class (ateom-gvisor, ateom-microvm) — that runs inside the physical worker pods. It provides a gRPC interface for atelet to trigger RunWorkload, CheckpointWorkload, and RestoreWorkload operations. This separation ensures that the physical pod lifecycle remains decoupled from the sandboxed agent process.
(参考: architecture.md#L324-L326)
atelet はノードごとの DaemonSet として動き、イメージキャッシュやCSIボリュームの管理、kubeletのcredential-provider機構を再利用したイメージPull認証を実装しています(--image-credential-provider-config、参考: main.go#L103-L104)。一方でスナップショット本体のアップロード/ダウンロードはatelet本体ではなく、サイドカーの「object-store plugin」が担います。
> External snapshots move through an object-store plugin, a sidecar that serves the objectstore.v1 gRPC API over a Unix socket in its pod. atelet's sidecar (NodeProvider) uploads and fetches a snapshot's files; ate-api-server's sidecar (ControlProvider) copies and deletes whole snapshots. Neither main process talks to object storage for snapshots.
(参考: architecture.md#L330)
スナップショット転送は object-store plugin に分離され、atelet・ate-api-server 本体はその gRPC API を呼びます。オブジェクトストレージへのアクセス設定はプラグイン側で扱います。これは本体プロセスが mTLS や DB 接続用の認証情報も持たない、という意味ではありません。
sandboxのcheckpoint/restoreは、ateomがgVisorのrunscまたはCloud Hypervisor(micro-VM)を直接操作して実装しています。
> Lifecycle Management: The ateom process invokes the sandbox runtime to checkpoint or restore processes within the physical pod boundaries — runsc for gVisor, or the Kata + Cloud Hypervisor stack for micro-VMs.
(参考: architecture.md#L328)
gVisor版はcmd/ateom-gvisor/runsc.goのcmdCheckpoint/cmdRestoreがrunsc checkpoint/runsc restoreコマンドを直接起動しています。micro-VM版はcmd/ateom-microvm/checkpoint.goのCheckpointWorkloadとKata Containers連携パッケージ(internal/kata)を使い、Cloud HypervisorのVM状態をスナップショットし、復元時はuserfaultfdによるメモリのデマンドページングで読み戻しています(参考: architecture.md#L338)。
Kubernetes との対応関係: atelet/ateom は、Worker Pod の内側で動く Actor の sandbox を管理します。Worker Pod 自体の実行は引き続き kubelet とコンテナランタイムが担当します。
Kubernetes にも Kubelet Checkpoint API と CRI の checkpoint 操作はあります。Agent Substrate の特徴は checkpoint の存在自体ではなく、保存先・Worker の割り当て・restore・ネットワークの再接続までを Actor のライフサイクルとして調整することです。
(参考: Kubelet Checkpoint API)
3.4 atenet: ルーティングとegress
atenetは、ActorのResume要求とトラフィックのルーティングを兼ねるネットワーキングスタックです。
> Ingress Routing: atenet-router runs Envoy with an ext_proc external processor. A higher-order system connects to the router and supplies the Actor target in ate-target-actor as <atespace>/<actor>. The ext_proc calls the Control Plane to resume the Actor and resolve its current worker assignment.
(参考: architecture.md#L344-L349)
実装では、cmd/atenet/internal/router/ingress/ingress.goがActorResumer.ResumeActorを呼び出しており、ここでate-api-serverへResumeActor RPCを投げています。つまりatenet-routerは、Envoyのext_proc拡張機構を使って「ルーティング先が止まっていれば先にResumeしてから転送する」という、通常のKubernetes Service/Ingressにはないオーケストレーションを行っています。
workerへの転送は、mTLSのCONNECTトンネル経由です。
> After resolving the assignment, atenet-router opens an mTLS CONNECT tunnel to the worker's atunnel listener on port 8443. atunnel, hosted by ateom, forwards the request to the active Actor over its private veth interface. Worker pod port 80 is not a direct Actor ingress path.
(参考: architecture.md#L351-L354)
Kubernetes との対応関係: KubernetesのService/Ingress/Gatewayに相当する「エンドポイントへのルーティング」をEnvoyで行う点は同じですが、通常のServiceが単純な負荷分散であるのに対し、atenet-routerは「宛先のActorが止まっていれば先に起動する」という追加のオーケストレーション層を持っています。
3.5 podcertcontroller: PodCertificateRequestのsigner
podcertcontrollerは、コンポーネント間の相互TLS認証に使う短命な証明書を発行するコントローラーです。
> Command podcertcontroller is a pod certificate controller that implements three signers.
>
> - servicedns.podcert.ate.dev/identity: Issues certificate for Kubernetes service DNS names, backed by a local CA.
> - podidentity.podcert.ate.dev/identity: Issues certificates equivalent to KSA tokens, backed by a local CA.
> - postgres.podcert.ate.dev/identity: Issues PostgreSQL login certificates to ate-api-server.
(参考: main.go#L15-L22)
このコントローラーは、certificates.k8s.io/v1 の PodCertificateRequest を監視し、証明書の発行・拒否を判断する signer です。API 自体は Kubernetes v1.37 で GA になりました。
PodCertificateRequest: {
{Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.35"), Default: false, PreRelease: featuregate.Beta},
{Version: version.MustParse("1.37"), Default: true, PreRelease: featuregate.GA}, // LockToDefault in 1.38
},
(参考: kube_features.go#L1945-L1949)
PodCertificateRequestのAPI定義には、signer名についてこう書かれています。
> signerName indicates the requested signer.
>
> All signer names beginning with kubernetes.io are reserved for use by the Kubernetes project. There is currently one well-known signer documented by the Kubernetes project, kubernetes.io/kube-apiserver-client-pod, which will issue client certificates understood by kube-apiserver. It is currently unimplemented.
(参考: types.go#L437-L444)
つまり、PodCertificateRequestというAPI自体(kubeletがリクエストを作り、証明書をPodへprojected volumeとしてマウントする仕組み)はKubernetes本体がGA機能として持っていますが、API 定義で挙げられている組み込み signer kubernetes.io/kube-apiserver-client-pod は未実装です。PodCertificateRequest を処理する signer は、利用者や外部コンポーネントが用意します。podcertcontrollerは、このsigner不在の部分を、Substrate独自の3種類のID(Service DNS、Pod Identity、PostgreSQLログイン証明書)向けに自前で実装したものです。READMEがこれを次のように説明しているのは、この実装状況と整合しています。
> cmd/podcertcontroller: A "polyfill" that provides Pod Certificate signers that will eventually ship in upstream Kubernetes (with different names).
(参考: README.md#L268-L269)
Kubernetes との対応関係: podcertcontrollerは、KubernetesのCertificateSigningRequest(CSR)機構と同じ発想の、PodCertificateRequestに対するsigner controllerです。kube-controller-managerが提供する組み込みsignerがまだ存在しない領域を、Substrate固有の3種類のID向けに自前実装で埋めています。
第4章: セキュリティモデル
この章では、sandbox の分離と、外部 API の認証情報を Actor に渡さずに使う仕組みを整理します。設計原則、実装状況、デモの設定は分けて説明します。
4.1 常時sandbox実行とゼロクレデンシャル
!Agent Environment の周囲に Identity-based Ingress/Egress、Secrets Management、Kernel Isolation、Rapid Suspend/Resume を配置した図。
(参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16)
カーネルの分離と通信制御、認証情報の管理は別々の役割です。sandbox を選ぶだけで外部 API の資格情報まで保護されるわけではなく、4.2節の gateway と credential provider の構成も必要になります。
security.mdのStatusテーブルには、各セキュリティ原則の実装状況が「Met」「In Progress」「Future」「Out of Scope」の4段階で整理されています。それぞれの定義は次の通りです。
- Met: Fully implemented in the current codebase.
- In Progress: Partially implemented or under active development.
- Future: Planned architecture and roadmap capabilities not yet implemented.
- Out of Scope: Not substrate's job, handled by a layer outside substrate.
(参考: security.md#L30-L33)
以下は、そのうちゼロトラストの中核にあたる4つの原則です。
| 原則 | 内容 | 状態 |
|---|---|---|
| Always Sandboxed | すべてのActorは強力なコンピュートsandbox内で実行される。Linuxコンテナ単体では不十分 | Met (gVisor, CHV micro-VM) |
| Zero Ambient Credentials | Actor に基盤の権限や外部 API キーを暗黙に引き継がせない。明示的に環境変数へキーを渡すデモとは区別する | Met (no credentials inside sandbox) |
| No Access by Default | Actor から control plane へのアクセスを制限する。worker ノード上の atelet など、基盤コンポーネントに必要な通信とは区別する | Met (Loopback, private netns)<br>In-Progress (DNS filtering) |
| Fail-Closed Policy-Enforced Egress Gateway | outbound トラフィックを共有 egress proxy で制御する。fail-closed は設計原則であり、UDP passthrough など未完了の項目がある | Met (atenet-egress)<br>In-Progress (fail closed, UDP currently passthrough) |
(参考: security.md#L41、security.md#L59、security.md#L75、security.md#L77)
:::note warn
同じテーブルでは、Atespace 単位の API 認可 (ReBAC) は「In Progress」、Node Blast Radius Containment やストレージの暗号化は「Future」です。認可のコードは存在するものの、通常の Control RPC への強制は実験的なフラグで有効化する段階です (2.2 節)。zero-trustという設計思想自体は固まっていますが、個々の統制がすべて実装済みというわけではありません。
:::
4.2 egress gatewayによる認証情報注入
Actorが外部APIを呼び出す際の認証情報は、Actor自身ではなくegress gatewayが注入します。
> Egress credential injection puts a secret into an actor's outbound HTTPS requests without the actor ever holding it. ... The actor never holds the secret. It sends the header with a placeholder value, which the gateway discards, so it cannot read, snapshot, or exfiltrate the secret, nor choose the value that leaves the cluster.
(参考: egress-credential-injection.md#L1-L15)
具体的な流れは次の4ステップです(docs/egress-credential-injection.mdのmermaid図および本文を要約)。
1. Actorがプレースホルダー値を入れたヘッダー付きでHTTPSリクエストを送る
2. egress gatewayがActorのTLSを終端し、EgressPolicyのreplace_headersルールに一致すればcredential providerへ問い合わせる
3. credential providerがそのActorにその認証情報を渡してよいか判断し、シークレットストアから読み出す(シークレットストアへの直接アクセス権を持つのはprovider だけ)
4. egress gatewayがプレースホルダーを実際の認証情報に置き換え、新しいTLSコネクションで送信する
HTTPS を終端するため、Actor 側が egress gateway の CA を信頼する設定も必要です。また、注入対象のヘッダーがリクエストに含まれなければ、認証情報の取得・置き換えは行われません。
この仕組みは API キーそのものを Actor から隔離しますが、許可された API を使った不適切な操作まで自動的に防ぐわけではありません。宛先や操作の権限、アプリケーション側の認可も必要です。
(参考: egress-credential-injection.md#L41-L54)
リファレンス実装であるcmd/credential-provider/kubernetes-secretsはKubernetesのSecretを解決するcredential providerで、デフォルトでは空のdeny-allポリシーから始まり、atespaceごとに読み取り可能なnamespaceを明示的に許可する必要があります(参考: README.md#L132-L136)。Actorが直接Secretを読みに行くことはなく、egress gatewayを経由した時だけ、しかも許可された宛先向けのリクエストに限ってクレデンシャルが挿入される設計です。
第5章: AIエージェントがタスクを実行するまでの流れ
ここまでの章は、Actorという「箱」がどう起動・停止するか(2.3節)、egressがどう認証情報を守るか(4.2節)を別々に説明してきました。この章では、実際にAIエージェントとして動くActorが、利用者からのリクエストを受けてから外部LLM APIを呼び出し、応答を返すまでの一連の流れを1つの図にまとめます。
5.1 HTTP リクエストを起点に Actor を Resume する場合
Counter デモのように atenet-router 経由でリクエストを受ける構成では、Actor の起動・復元を待ってから通信を転送します。以下は、その流れに外部 LLM API の呼び出しを組み合わせた例です。Counter デモ自体が LLM を呼ぶわけではありません。
sequenceDiagram
actor User as 利用者 / 上位システム
participant Router as atenet-router
participant API as ate-api-server
participant Atelet as atelet
participant Tunnel as ateom / atunnel
participant Agent as Actor
participant Egress as atenet-egress
participant Cred as credential provider
participant LLM as 外部 LLM API
User->>Router: HTTP リクエスト (ate-target-actor)
Router->>API: ResumeActor
alt Actor が停止中
API->>Atelet: 復元要求
Atelet->>Tunnel: RestoreWorkload
Tunnel-->>Atelet: 復元完了
Atelet-->>API: 完了
else Actor が実行中
Note over API: 既存の割り当てを返す
end
API-->>Router: Worker の割り当て
Router->>Tunnel: mTLS CONNECT トンネルで転送
Tunnel->>Agent: リクエストを転送
Agent->>Egress: HTTPS (プレースホルダーのヘッダー)
Egress->>Cred: 認証情報を要求
Cred-->>Egress: 認可後、実際の API キーを返す
Egress->>LLM: ヘッダーを差し替え、別の TLS 接続で送信
LLM-->>Egress: 応答
Egress-->>Agent: 応答
Agent-->>Tunnel: 処理結果
Tunnel-->>Router: 応答
Router-->>User: 応答
Note over User,API: 上位システムなどが SuspendActor を要求すると保存・解放する
前半は architecture.md の Resume シーケンス、後半は egress credential injection を基に簡略化しています。Actor がスナップショットを使わず cold boot する場合などは省略しています。
atenet-router は転送前に ResumeActor を呼び、実行中なら既存の割り当てを使います。停止中なら、control plane が復元を調整してから割り当てを返します。ResumeActor は実行中の Actor に対して冪等です。
(参考: workflow_resume.go#L67-L113)
外部 LLM API との通信では、Actor 側と egress gateway の間、gateway と LLM API の間に別々の TLS 接続があります。gateway が HTTPS を終端してヘッダーを置き換えるため、エンドツーエンドの 1 本の TLS 接続ではありません。
応答を返しただけでは、Actor が自動的に Suspend されるとは限りません。いつ処理が終わったか、どの待機区間で Suspend できるかは、上位システムやワークロードとの連携も含めて設計する必要があります。LLM の応答待ち中であることと、安全に Suspend できることも同じではありません。
5.2 Claude Code デモで実装されていること
すべてのエージェントが外部からのHTTPリクエストを起点に動くわけではありません。READMEが挙げているデモの1つclaude-code-multiplexは、3体のClaude Codeエージェント(luna/mars/orion)を2 Podのworker poolで共有する構成です。
> Three Claude Code agents (luna, mars, orion) registered as Substrate actors.
> A WorkerPool of two pods.
>
> This demo uses the ateapi control plane to manage Actors and does not send workload HTTP traffic through atenet-router, so it does not use the ate-target-actor routing header.
(参考: demos/claude-code-multiplex/README.md#L14-L21)
このデモのActorTemplateは、タスクの内容(TASK)と実行間隔(INTERVAL_SECONDS)を環境変数としてあらかじめ焼き込んでおくだけのシンプルな作りです。
env:
- name: ACTOR_NAME
value: "luna"
- name: TASK
value: "Tell me one short, surprising fact about the Moon. One sentence."
- name: INTERVAL_SECONDS
value: "45"
- name: ANTHROPIC_API_KEY
value: "${ANTHROPIC_API_KEY}"
(参考: agent-luna-template.yaml.tmpl#L34-L42)
コンテナのエントリポイントは、claude --printでタスクを1回実行してはINTERVAL_SECONDS秒だけアイドルになる、というループを回し続けるだけです。
while true; do
...
claude --print "${TASK}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] claude exited non-zero"
...
sleep "${INTERVAL_SECONDS}"
done
(参考: run.sh#L38-L49)
READMEは、このアイドル区間こそが多重化の余地になると説明しています。
> The idle window is what substrate uses to suspend this actor and multiplex its worker onto another actor.
(参考: run.sh#L17-L19)
ただし、このデモの README の説明と UI の実装は区別が必要です。README はタスクの投入・完了と自動 Suspend を説明していますが、v0.5.0 の UI は次の動作になっています。
| 処理 | UI の実装 |
|---|---|
| 「Give a task」 | Actor とタスクを選び、UI サーバー内の assignment に記録する |
| queued / running / completed の表示 | 作成時刻とランダムに決めた待ち時間・実行時間から計算する |
| Actor の実行状態・ログの表示 | ate API と Kubernetes API から取得する |
giveTask はタスクを Actor に送信したり、ResumeActor / SuspendActor を呼び出したりしません。computeState にも、タスク状態が時間経過による表示であり、Substrate 側の状態ではないとコメントされています。
(参考: server.go#L252-L315)
したがって、デモ画面のタスク状態表示 (queued / running / completed) が切り替わっても、LLM タスクの実行完了や自動多重化が行われたことを示すわけではありません。このデモから読み取れるのは、Claude Code を起動する ActorTemplate と、周期的に claude --print を実行するワークロード、状態を表示する UI の構成です。Suspend/Resume の動作確認は、7.3 節のように実際の API 操作と Actor の状態で確かめます。
5.3 エージェントが外部LLM APIを呼び出す部分の実体: EgressPolicy
5.1 節の通信では、EgressPolicy が到達先とヘッダー置き換えのルールを定めます。参照された認証情報をその Actor に使わせてよいかは、credential provider 側でも判断します。ate APIのプロトコル定義では、ルールに一致しない通信はすべて拒否されると明記されています。
> Rules are an unordered set of allows. Traffic is denied when no rule matches.
(参考: ateapi.proto#L609-L610)
したがって、Actorが外部のLLM API(例: api.anthropic.com)へHTTPSで到達するには、そのホスト名を許可するEgressPolicyを別途作成しておく必要があります。ドキュメントが挙げている例は、api.example.comへのHTTPSを許可しつつAuthorizationヘッダーを実際の認証情報に差し替えるものです。
rules:
- https:
hostnames: ["api.example.com"]
effects:
replaceHeaders:
- header: Authorization
prefix: "Bearer "
credentialUri: ate-secret://k8s.io/default/ns1/example-api/token
(参考: egress-credential-injection.md#L69-L76)
EgressPolicy は ActorTemplate の設定ではなく、Actor に対応づけて作成するリソースで、kubectl ateから個別に作成・更新します。
# Create an egress policy.
kubectl ate create egress-policy <actor-name> -a <atespace> -f policy.yaml
(参考: cmd/kubectl-ate/README.md#L247-L253)
:::note info
5.2節のclaude-code-multiplexデモは、ANTHROPIC_API_KEYをActorTemplateの環境変数にそのまま埋め込む方式を取っており、4.2節で説明したegress credential injection(Actorはプレースホルダーしか持たず、egress gatewayが認証情報を差し替える方式)とは異なります。どちらの方式でも、Actorがapi.anthropic.comのようなホストにHTTPSで到達するには、該当ホストを許可するEgressPolicyがそのActorに設定されている必要がある点は共通です。
:::
第6章: 構築にあたって追加で用意するもの
ここまでの章で、Agent SubstrateはKubernetesのPod・CRD・オートスケーリングの上に成り立っていると説明してきましたが、実際に構築するには、Kubernetes クラスタに加えていくつかの周辺コンポーネント・前提条件を別途用意する必要があります。この章では、7章のkindでの検証を通じて実際に目にしたものも含めて、その一覧を整理します。
| 追加で用意するもの | 役割 | Agent Substrate側の扱い |
|---|---|---|
| PostgreSQL | Actor/Workerの頻繁に更新される状態を保持する専用state store(2.2節) | 開発/kind向けにはhack/install-ate-kind.shがPostgreSQLをPod内に同梱してデプロイする。本番では外部DBの接続文字列(ATE_API_POSTGRES_OWNER_CONNECTION_STRING等)を運用者が用意する必要がある |
| オブジェクトストレージ(GCS/S3) | Actorのスナップショット(checkpoint)を永続化する保存先 | 外部スナップショットの保存先が必要。通常のインストールでは storage.bucketName (BUCKET_NAME) を指定し、kind 環境ではスクリプトが ate-snapshots を設定する。kind 環境ではS3互換のrustfsをクラスタ内に建てて代用する |
| egress credential provider | Actorへの認証情報注入(4.2節)の実体となるSecret解決サービス | --credential-providerの指定自体が必須(注入を使わない場合も{"enabled":false}の明示が必要)。参照実装はKubernetes Secretを解決するkubernetes-secretsで、{"name":"k8s.io"}を指定するとインストーラが同梱のものをデプロイする |
| gVisor / micro-VMのsandboxバイナリ | Actorを実際に動かすsandboxランタイム本体 | クラスタスコープのSandboxConfigリソースで指定する(7.2節で例示)。デフォルト設定はgs://gvisor/...という公開GCSバケットから取得する |
| Envoy または agentgatewayのデータプレーンイメージ | atenet-router/atenet-egressが使うプロキシ本体 | --atenet-dataplaneでenvoy(カスタムビルドが必要)かagentgateway(公開イメージをpull)かを選ぶ |
| オブジェクトストレージへのアクセス権限 | atelet・ate-api-serverのsidecar(objectstore-plugin)がスナップショットを読み書きするための認証 | プラグインはPodが持つ認証情報(ambient credentials)でGCSまたはS3にアクセスするため、利用するストレージに合わせてその権限をプラグインが使えるように設定する必要がある。GKE(GCS)の場合は、Workload Identity連携のIAMバインディングが前提で、無いと最初のsuspend/resumeでGCSから403が返る。kindではrustfs+ローカル認証情報で代用され、この手当ては不要 |
| ストレージ用のCSIドライバ(条件付き) | CSI経由のVolume(永続ストレージ)をActorにアタッチする場合にのみ必要 | 今回使ったdurableDirのようなデフォルトのVolumeタイプには不要。使う場合は、通常のKubernetes CSIと異なりControllerをネットワーク越しのgRPCとして公開する必要があるなど、独自の追加要件がある |
PostgreSQLが必須である点は、ドキュメントにも明記されています。
> External databases are never provisioned by ateapi or ate-setup. Their operators must create the database identities and schema described below before deploying Substrate.
(参考: postgres.md#L8)
オブジェクトストレージのバケット名(storage.bucketName/BUCKET_NAME)も、設定一覧の表で他の多くの設定のようなデフォルト値(—)を持たない項目として記載されています(参考: operator-install.md#L314)。
なお、3.5節のpodcertcontrollerが使うPodCertificateRequest・ClusterTrustBundleは、v1.37でGAになりデフォルトで有効です(PodCertificateRequestは3.5節、ClusterTrustBundleはkube_features.go#L1417-L1421を参照)。本記事はKubernetes v1.37以降を対象としているため、この2つのAPIを有効化するための追加作業はなく、表にも含めていません。podcertcontroller自体も、Agent Substrateがマニフェスト(manifests/ate-install/pod-certificate-controller.yaml)で同梱しており、別途用意する必要はありません。
オブジェクトストレージへのアクセス権限が無いとどうなるかは、GKE(GCS)で構築する場合について、セットアップツールのドキュメントに明記されています。
> On a fresh GCP project these bindings do not exist, and nothing else creates them — without them atelet cannot read or write external snapshots (403 from GCS on the first suspend/resume), ate-api cannot copy or collect them, and nodes cannot pull images.
(参考: tools/setup-gcp/README.md#L154-L159)
egress credential providerについても、「指定しない」という選択肢自体が無いことが明記されています。
> The provider is selected when the egress gateway is deployed, with --deploy-ate-system or --deploy-atenet, for either the Envoy or agentgateway dataplane. The selection is required: an install that wants no injection says so with {"enabled":false}.
(参考: egress-credential-injection.md#L128-L131)
:::note info
Actorに永続ストレージをアタッチするためのストレージ用CSIドライバ(CreateVolumeなどを持つもの)については、Agent Substrateが通常のKubernetesとは異なる要件を課す点が特徴的です。CSI Controllerを「Podの中のUnixソケット」ではなく「クラスタネットワーク越しのgRPCサービス」として公開する必要があり、ドキュメントはこれを通常のKubernetesのCSI運用との違いとして明示しています。
> Substrate directly orchestrates storage operations from its control plane (ateapi) and node daemon (atelet), introducing specific deployment and networking requirements for both the CSI Controller and the CSI Node DaemonSet.
(参考: csi-deployment.md#L5)
:::
7章で実際にkindクラスタ上に構築した際も、ここに挙げたPostgreSQL・オブジェクトストレージ(rustfs)・SandboxConfig・credential providerはすべて何らかの形で登場しています。kind向けのインストーラはこれらの多くを、開発用に同梱された代替物で自動的に用意してくれますが、本番環境では、外部 DB・ストレージ・IAM など利用する構成に応じた準備が必要です。provider や proxy など、インストーラがデプロイするものもあるため、すべてを別途手動で導入するという意味ではありません。
6.1 GKE で利用する場合の提供範囲
GKE 版は GKE Standard に OSS をインストールする構成です。Autopilot は対象外で、評価・非本番用途は利用できますが、本番サポートは allowlist による private GA です。Agent Substrate 自体の追加料金はなくても、作成する GKE などのリソースには料金がかかります。
(参考: Install Agent Substrate on GKE)
発表では、基盤側の ComputeClass と、任意の Filestore agent volumes 連携も紹介されています。後者は共有ファイルシステム向けで、sandbox の実行状態を保存するスナップショットとは役割が異なります。
また、再開500ms未満・毎秒500超の Suspend/Resume・従来比10倍の密度という性能値は Google の公表値です。OSS の設計目標や、この原稿の kind での確認結果とは分けて読みます。
(参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16)
第7章: kindクラスタで実際に動かす
この章では、公開記事に掲載していた kind での確認手順と実行結果を整理します。出力は既存の検証記録です。
7.1 手順
ビルドには、対象タグの go.mod が指定する Go 1.27.0 と、Docker などの開発環境が必要です。micro-VM を試す場合は /dev/kvm と仮想化の要件もあります。以下は gVisor を使う Counter デモの手順です。
(参考: go.mod#L1-L5、create-kind-cluster.sh#L74-L87)
v0.5.0 を使うため、専用ディレクトリと検証用クラスタ名を指定します。クラスタ作成スクリプトは同名の既存クラスタを削除して作り直すため、既存環境と重複しない名前を使います。以降は、そのディレクトリで実行する手順です。
git clone --branch v0.5.0 --depth 1 https://github.com/agent-substrate/substrate.git substrate-v0.5.0
cd substrate-v0.5.0
export KIND_CLUSTER_NAME=ate-overview
# create cluster and local registry (IPv4; IP_FAMILY=dual|ipv6 overrides)
hack/create-kind-cluster.sh
# install ate, PostgreSQL, rustfs
hack/install-ate-kind.sh --deploy-ate-system --credential-provider='{"name":"k8s.io"}'
# install counter demo
hack/install-ate-kind.sh --deploy-demo-counter
# install kubectl-ate
go install ./cmd/kubectl-ate
# create a counter actor in the demo's atespace (--template names the
# actor template, resolved in the actor's atespace)
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
# port-forward the network router to bind to local port `8000`
kubectl port-forward -n ate-system svc/atenet-router 8000:80
(参考: README.md#L111-L129)
hack/create-kind-cluster.shは、ローカルのDockerレジストリを建てたうえでkindest/node:v1.37.0のkindクラスタを作成します。cmd/podcertcontrollerが依存するClusterTrustBundle・PodCertificateRequestのfeature gateも、このスクリプトがkindのクラスタ設定で明示的に有効化しています。
スクリプトのコメントには Kubernetes v1.36 時点の説明が残っていますが、本記事の v1.37 ではこれらの機能は GA・デフォルト有効です。スクリプトは ClusterTrustBundleProjection なども明示的に設定しています。
(参考: create-kind-cluster.sh#L104-L113)
hack/install-ate-kind.sh --deploy-ate-systemは、koでateapi・atecontroller・atelet・podcertcontrollerなどのGoバイナリをビルドしてローカルレジストリにpushし、PostgreSQL・rustfs(オブジェクトストレージ)・CRD・SandboxConfigを含めて一括でデプロイします。--deploy-demo-counterはさらにCounterデモ用のWorkerPoolとActorTemplateをデプロイし、golden snapshotの作成を待ちます。
7.2 デモのWorkerPoolとActorTemplate
Counter デモが使う WorkerPool の抜粋です。テンプレート内のプレースホルダーや ko:// の参照は、インストーラが解決してから適用します。
apiVersion: ate.dev/v1alpha1
kind: WorkerPool
metadata:
name: counter
namespace: ate-demo-counter
labels:
workload: counter
spec:
replicas: 3
workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor
sandboxClasses:
- name: gvisor
template:
resources:
limits:
cpu: "1"
memory: 1Gi
requests:
cpu: 250m
memory: 1Gi
(参考: counter.yaml.tmpl#L26-L63)
ActorTemplateは前述の通りKubernetesオブジェクトではなく、ate API の protobuf フィールドに対応した YAML として kubectl ate create actor-template に渡します。CLI が読み込んで API に送信します。
metadata:
atespace: ate-demo-counter
name: counter
workerSelector:
matchLabels:
workload: counter
containers:
- name: counter
image: ko://github.com/agent-substrate/substrate/demos/counter
wakeupProbe:
httpGet:
path: /readyz
port: 80
volumeMounts:
- name: data
mountPath: /home/counter
resources:
limits:
- name: cpu
quantity: "1"
- name: memory
quantity: 512Mi
snapshotConfig:
preferredFidelity: SNAPSHOT_FIDELITY_MEMORY
storageLocation: gs://${BUCKET_NAME}/ate-demo-counter/
sandboxConfig:
sandboxClass: gvisor
configName: gvisor-default
volumes:
- name: data
durableDir: {}
(参考: counter-template.yaml.tmpl#L22-L69)
snapshotConfig.preferredFidelity: SNAPSHOT_FIDELITY_MEMORYが、Suspend時にプロセスメモリごとスナップショットを取る設定です。sandboxConfigが名指ししているgvisor-defaultは、インストーラが標準で用意する SandboxConfig (クラスタスコープの CRD) で、gVisorのバイナリをGCSの公開バケットから取得する設定を持っています。
apiVersion: ate.dev/v1alpha1
kind: SandboxConfig
metadata:
name: gvisor-default
annotations:
sandboxconfig.ate.dev/is-class-default: "true"
spec:
sandboxClass: gvisor
defaultVersion: v3
versions:
- name: v3
state: Enabled
pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
assets:
arm64:
gvisor:
url: "gs://gvisor/releases/nightly/2026-10-10/aarch64/gvisor.tar.zstd"
sha256: "aaf414f06eea7960238e43165c691f0a36c
1 like
Kubernetes Memory on cgroup v2: Debugging OOM Kills and Evictions
A container is OOMKilled, but kubectl top never showed it reaching its limit. Or a Pod is evicted while your node dashboard still shows free memory. In both cases, the first question is which memory value the decision actually used.
This guide separates cgroup-limit OOM, node-side OOM and kubelet eviction, then shows how to inspect the evidence and review memory requests and limits. It assumes familiarity with Pod resources and basic PromQL.
TL;DR
- The working set is a heuristic, not a measurement of unreclaimable memory. It subtracts inactive_file from usage but still includes reclaimable active page cache and slab.
- Distinguish memcg OOM from global OOM using kernel logs. A container can be below its own limit when an ancestor cgroup reaches its limit or a node-side allocation fails. OOMKilled and SystemOOM alone cannot establish the cause.
- Eviction uses the kubelet's memory.available, not Linux MemAvailable. Check the kubelet's statistics and configured thresholds.
- Review requests and limits separately. Requests influence placement and victim selection. To address a cgroup limit, reduce usage or adjust the limit after checking node capacity.
- Check Memory QoS configuration as well as its feature gate. In v1.37, the gate defaults to enabled, but request-based protection and memory.high remain disabled by default.
This article targets cgroup v2, Kubernetes v1.37.1 and containerd. See [Components and versions](#components-and-versions) for exact versions. The kind examples use a v1.37.0 node image.
Use the [investigation workflow](#investigation-workflow) for an incident, the [metric map](#quick-reference-contradictions-explained-and-a-metric-map) to understand a dashboard, or [requests and limits](#what-to-do-about-it) for prevention. The [kind examples](#hands-on-verification-with-kind) explore reclaim and Memory QoS in more detail.
Identify the failure before choosing a metric
| Observation | Mechanism | Evidence to check |
|:---|:---|:---|
| Container termination reason is OOMKilled | The kernel killed a process; this alone does not distinguish the OOM source | Kernel logs, container and ancestor cgroup limits, node allocation constraints |
| Pod is Evicted with The node was low on resource: memory. | The kubelet responded to node memory pressure | Kubelet memory.available, configured thresholds and eviction message |
| Exit code is 137 without an OOM reason | The process received SIGKILL; the exit code alone does not establish OOM | Termination details, events and kernel logs |
memcg OOM means an allocation cannot fit within a cgroup limit after reclaim. Global OOM means a node-side allocation cannot be satisfied after reclaim, potentially within NUMA or cpuset constraints. A container below its own limit can be affected by either an ancestor limit or a node-side failure.
Investigating OOM and eviction
Investigation workflow
Start with the observed termination or eviction, then compare metrics from the same time. Scrapes can miss the peak; kernel logs are stronger evidence of the OOM source than a sampled usage graph.
1. When a Pod was OOMKilled
- Check whether container_memory_usage_bytes is close to the limit. memcg OOM is evaluated against this value
- Compare with container_memory_rss to tell whether anonymous pages are the main cause. An OOM can occur even when RSS is low
- If the gap between the working set or usage and RSS is large, page cache, shmem or kernel memory may be pushing usage up
- If OOMKilled occurs despite headroom under the limit, suspect global OOM. Correlate SystemOOM with kernel logs and inspect ancestor limits. A missing event does not establish memcg OOM
- If you see no spike in the metrics, suspect a surge shorter than the scrape interval. See [My working set is below the limit, but the container was OOMKilled](#my-working-set-is-below-the-limit-but-the-container-was-oomkilled) for what to check
2. When a Pod was evicted
- If the message is The node was low on resource: memory., node-wide memory pressure is the cause
- Use the [node-utilization query](#why-node-exporter-and-kubelet-usage-disagree) to check the kubelet-view node memory utilization. The node-exporter-based utilization alone is not enough to judge
- Check whether a Pod with a small (or unset) memory request relative to actual usage is on the same node. Compare each Pod's container_memory_working_set_bytes (cAdvisor) against kube_pod_resource_request{resource="memory"} (kube-scheduler). Note that Pods with large requests make the scheduler keep placement density low, so they are unlikely to be the cause of pressure
3. To prevent recurrence
- Set a memory request informed by workload history to avoid excessive placement density. PromQL for checking is collected in [Align memory request with actual usage](#align-memory-request-with-actual-usage)
- For containers where memcg OOM occurs, also review the limit
- Control placement with Pod anti-affinity or topology spread constraints so that Pods with large memory usage do not gather on the same node
Telling which path occurred
Using Kubernetes information
Start by narrowing down with what you can check using kubectl.
| Where to look | What it tells you |
|:---|:---|
| Last State in kubectl describe pod, or lastState.terminated in kubectl get pod -o yaml | That an OOM kill happened (reason: OOMKilled). It cannot distinguish the path |
| Events in kubectl describe node <node name> | Use SystemOOM as a clue and verify the source in kernel logs. If there is no record, that does not make it a memcg OOM; it just means you cannot tell |
There is no OOM-specific event on the Pod side. You only see things like BackOff accompanying the restart.
Check it as follows. SystemOOM is an event attached to the Node, and since Node is not a namespaced object, the event itself is recorded in the default namespace.
# 1. Find which node the OOMKilled Pod is on
kubectl get pod -n <namespace> <Pod name> -o wide
Output:
NAME READY STATUS RESTARTS AGE IP NODE
myapp-... 1/1 Running 5 (2m ago) 1h 10.0.0.1 node-1
# 2. Check whether that node's events contain SystemOOM
kubectl describe node node-1
Output:
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning SystemOOM 3m kubelet System OOM encountered, victim process: java, pid: 12345
# To list only events
kubectl get events -n default \
--field-selector reason=SystemOOM,involvedObject.name=node-1
If SystemOOM exists, inspect kernel messages from the same time: constraint, oom_memcg, task_memcg and the killed PID. A parsing failure can also produce this event. Match the event and kernel log to the container's termination time (lastState.terminated.finishedAt).
Not finding SystemOOM is not evidence of memcg OOM. The same state results from event TTL expiry, a kubelet restart, or a missed event.
The event retention period is set by kube-apiserver's --event-ttl, and the default is 1 hour (options.go). So an old SystemOOM may no longer be available through kubectl. If you forward events to an external logging platform, check there.
On the node, inspect the kernel log:
sudo journalctl -k -b -o short-iso \
| rg 'oom-kill:|Out of memory:|Memory cgroup out of memory:|Killed process'
CONSTRAINT_MEMCG and oom_memcg identify the limiting cgroup; task_memcg identifies the victim's cgroup. If these differ, inspect the Pod and ancestor limits. CONSTRAINT_CPUSET and CONSTRAINT_MEMORY_POLICY call for checking NUMA and cpuset restrictions. Align timestamps with the container's finishedAt.
If logs are unavailable, use the following as supporting evidence rather than proof:
- Whether container_memory_usage_bytes was close to the limit at that time (if so, memcg OOM is likely)
- Whether [the kubelet-view node memory utilization](#why-node-exporter-and-kubelet-usage-disagree) was high at that time (if so, global OOM is likely)
Why OOMKilled cannot distinguish the paths
When a task exits with code 137, the container runtime (containerd) checks oom_kill in the cgroup's memory.events, and if it is 1 or more, it updates the termination reason to OOMKilled (events.go). The check is oomMetricsEventOccurred() in the same file.
switch v := taskMetricsAny.(type) {
case *cg1.Metrics:
return v.GetMemoryOomControl().GetOomKill() > 0, nil
case *cg2.Metrics:
return v.GetMemoryEvents().GetOomKill() > 0, nil
The definition of oom_kill in memory.events is "The number of processes belonging to this cgroup killed by any kind of OOM killer". So a process chosen and killed by the kernel in a global OOM is counted too, and both paths produce OOMKilled.
{% details How the kubelet parser produces SystemOOM %}
When SystemOOM is recorded
SystemOOM is an event the kubelet records on the Node object. The kubelet parses OOM messages in the kernel log and records it only when the cgroup where the OOM happened is judged to be the root (/) (oom_watcher_linux.go).
for event := range outStream {
// Count every OOM kill per container to back the
// container_oom_events_total metric.
recordOOMKill(event.ContainerName)
if event.VictimContainerName == recordEventContainerName {
...
ow.recorder.WithLogger(logger).Eventf(ref, v1.EventTypeWarning, systemOOMEvent, "%s", eventMsg)
}
}
The usual association with global OOM comes from the combination of the kernel log format and the parser. For an OOM caused by a memcg limit, the kernel prints oom_memcg=<cgroup path>, whereas for a global OOM it prints ,global_oom instead (mem_cgroup_print_oom_context() in mm/memcontrol.c).
if (memcg) {
pr_cont(",oom_memcg=");
pr_cont_cgroup_path(memcg->css.cgroup);
} else
pr_cont(",global_oom");
The modern-format regular expression in the kubelet's forked parser matches the format containing oom_memcg= (oomparser_linux.go).
containerRegexp = regexp.MustCompile(`oom-kill:constraint=(.*),nodemask=(.*),cpuset=(.*),mems_allowed=(.*),oom_memcg=(.*),task_memcg=(.*),task=(.*),pid=(.*),uid=(.*)`)
So no cgroup can be extracted from a global OOM message, and the initial value / of OomInstance is left as is (StreamOoms in oomparser_linux.go).
oom := &OomInstance{
ContainerName: "/",
VictimContainerName: "/",
TimeOfDeath: msg.Timestamp,
}
In other words, the presence of SystemOOM is not "the result of directly determining the path" but "the result of whether the kernel log could be parsed". Still, in practice it is enough to understand that a recorded event is a reason to inspect node-side OOM, while neither presence nor absence establishes the source.
{% enddetails %}
How it appears in metrics
Metrics do not distinguish the path with a label. The kernel's __oom_kill_process() records the same event regardless of the path.
| Metric | Emitted by | memcg OOM | global OOM |
|:---|:---|:---|:---|
| node_vmstat_oom_kill | node-exporter (oom_kill in /proc/vmstat) | Increases | Increases |
| container_oom_events_total | kubelet (result of kernel log parsing) | The series of the container the killed process belonged to increases | The id="/" series increases, not a container series |
| oom_kill in cgroup v2 memory.events | (a cgroup file, not a metric; containerd reads it) | Increases | Increases |
Only container_oom_events_total behaves differently because, as described earlier, the parser cannot extract a cgroup in a global OOM and treats it as the root (/). This is only a result of whether the log could be parsed, so do not use it to determine the path; confirm the source from constraint and oom_memcg in the kernel log.
> Note: Although container_oom_events_total has container_ in its name, in v1.37 it is counted by the kubelet, not cAdvisor (recordOOMKill() in oom_counter.go). The meaning of the value does not change, but you have to look elsewhere when reading the code.
What to look at after narrowing down
- If you judge it to be a global OOM, inspect node-side memory pressure and any NUMA or cpuset constraints. Compare the memory requests and actual usage of the Pods placed on the same node, and check whether any Pod has too small a request
- If you judge it to be a memcg OOM, review that container's memory limit and its actual usage. If the container seems to have headroom under its limit, it may have reached the limit of the Pod as a whole or of /kubepods.slice
- If you cannot decide, keep both possibilities open and check both the container's limit and usage and the node's memory utilization
{% details Kernel OOM behavior and victim selection %}
The two OOM kill paths
There are two paths by which a container's process gets OOM killed. Both are SIGKILLs from the kernel's OOM killer, but the conditions under which they occur and the scope they affect differ. As [defined in the terminology notes](#how-this-article-names-the-two-oom-kill-paths), memcg OOM enforces a configured limit and global OOM protects the node, so their purposes differ at the root.
| Path | Condition | What gets killed | Node event |
|:---|:---|:---|:---|
| memcg OOM | memory.max exceeded for a container, Pod or /kubepods.slice | Processes inside the cgroup that hit its limit | Not recorded |
| global OOM | A node-side allocation cannot be satisfied after reclaim, possibly within NUMA or cpuset constraints | Chosen from eligible processes under the allocation constraints | SystemOOM is recorded when the kubelet parses the log and treats the triggering cgroup as / |
Even if your container is under its limit, it can be OOM killed because of another Pod on the same node. That is global OOM.
SystemOOM is a kubelet-generated clue to investigate, not a definitive classification of the OOM source. Events expire according to the API server's --event-ttl (one hour by default), so a missing event does not rule out global OOM.
OOM kill from exceeding a cgroup limit (memcg OOM)
resources.limits.memory is set as the container cgroup's memory.max. When a cgroup's memory.current reaches memory.max, this is what happens. As described above, limits are set not only on containers but also on Pods and /kubepods.slice, and the same flow applies whichever level reaches its limit.
1. The kernel tries to reclaim memory inside that cgroup. Page cache is basically reclaimed here.
2. If usage still does not fit under memory.max after reclaim, the cgroup's OOM killer SIGKILLs processes in that cgroup.
3. With cgroup v2, the kubelet defaults to singleProcessOOMKill: false and sets memory.oom.group=1 for the container. A memcg OOM therefore normally kills the container's processes together. With singleProcessOOMKill: true, a child process can be killed while the container keeps running. Container restarts follow restartPolicy. See the implementation.
The implementation is try_charge_memcg() in mm/memcontrol.c. It is a goto loop returning to the retry: label, and proceeds in this order.
| Step | Location | What happens |
|:---|:---|:---|
| 1. Try to charge | L2177-L2178 | If page_counter_try_charge() succeeds, the allocation completes and it returns |
| 2. Reclaim | L2211-L2216 | On failure it reclaims with try_to_free_mem_cgroup_pages(), and if mem_cgroup_margin() satisfies the request, goes back to 1 |
| 3. Count the retry | L2244-L2245 | If reclaim is not enough, it decrements nr_retries and goes back to 1. The initial value is MAX_RECLAIM_RETRIES (= 16; mm/internal.h) |
| 4. Invoke the OOM killer | L2259-L2264 | Only after the retries are used up does it call mem_cgroup_oom() |
MAX_RECLAIM_RETRIES is a retry budget, not a guarantee of exactly 16 reclaim attempts. Allocation flags and reclaim progress change the control flow. The check in step 2 looks like this in code.
nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages,
gfp_mask, reclaim_options, NULL);
psi_memstall_leave(&pflags);
if (mem_cgroup_margin(mem_over_limit) >= nr_pages)
goto retry;
The kernel documentation for memory.max says the same.
> Memory usage hard limit. ... If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup.
> Warning: memory.current reaching memory.max does not by itself cause an OOM. A cgroup sitting at its limit with page cache is a normal state. For the same reason, the working set reaching memory.max is not an OOM condition either. Only inactive_file is subtracted from the working set, and reclaimable memory remains in the rest:
>
> - active_file: demoted to inactive under pressure and then reclaimed
> - slab_reclaimable (dentry / inode caches): in memcg reclaim too, shrink_slab() is called after shrink_lruvec() (shrink_node_memcgs() in mm/vmscan.c)
>
> So there is no threshold of "OOM once either value reaches the limit". The actual condition is when the charge fails and reclaim cannot make room. What cannot be reclaimed is mainly anonymous pages (without swap) and shmem, so that is where to look when narrowing down.
That said, what the kernel compares against memory.max in a memcg OOM is the whole of memory.current, and it is not decided by any single metric. memory.current includes, besides anonymous pages, kernel memory, shmem (tmpfs and shared memory), and page cache that cannot be reclaimed, or cannot be reclaimed right away.
container_memory_rss is an indicator for determining whether anonymous pages are the main cause. If cgroup RSS is close to the limit, anonymous pages are a likely major contributor, but RSS being below the limit is no evidence that an OOM will not occur. When the gap between RSS and the working set is large, check container_memory_usage_bytes (the value of memory.current) together with the breakdown in memory.stat on the node: anon, file, shmem, slab and so on.
OOM kill from node memory exhaustion (global OOM)
When a node-side allocation cannot be satisfied after reclaim, the kernel evaluates eligible processes using oom_badness(). cpusets, NUMA policies and oom_score_adj=-1000 can restrict the candidates. A container need not exceed its own limit to be selected.
There is no guaranteed ordering between kubelet eviction and the kernel's OOM killer. The kubelet evicts Pods to protect the node, and its selection puts "Pods whose usage exceeds their memory request" first (rankMemoryPressure() in helpers.go).
// rankMemoryPressure orders the input pods for eviction in response to memory pressure.
// It ranks by whether or not the pod's usage exceeds its requests, then by priority, and
// finally by memory usage above requests.
func rankMemoryPressure(pods []*v1.Pod, stats statsFunc) {
orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods)
}
A Pod with a zero memory request and positive usage belongs to the "exceeds request" group. Within that group, priority and then usage above request determine the order, so it is not necessarily evicted first. If eviction acts in time, the result is Evicted, not OOMKilled.
Global OOM happens first when eviction cannot reclaim memory as fast as memory grows. There are several reasons it cannot keep up.
| Factor | Value | Effect |
|:---|:---|:---|
| Evaluation interval | 10 seconds (evictionMonitoringPeriod) | Detection can be up to 10 seconds late |
| Pods evicted per evaluation | Only one ("we kill at most a single pod during each eviction interval" in eviction_manager.go) | At most one Pod is evicted per synchronization; notifications and cleanup waits mean this is not a fixed rate |
| Waiting for cleanup | Up to 30 seconds (podCleanupTimeout) | The next evaluation may be delayed further |
| Default threshold | memory.available<100Mi | Extremely thin on large nodes |
| How memory.available is computed | From the root cgroup's anon + file | Contains no kernel memory, so it [overestimates free memory](#values-the-kubelet-uses-for-eviction-decisions) |
The official Kubernetes documentation also says (Node out of memory behavior):
> If the node experiences an out of memory (OOM) event prior to the kubelet being able to reclaim memory, the node depends on the oom_killer to respond.
It is more likely when applications that allocate a lot of memory right at startup, or in response to a sudden surge in requests, share a node.
> Warning: Eviction protects the node; it does not protect individual Pods from OOM. Containers without limits have no memory.max backstop at all, so eviction is merely the last line of defense.
>
> Moreover, rankMemoryPressure() looks at Pod priority before usage size. If the offending Pod has high priority, low-priority Pods with small usage get evicted first, and global OOM can occur without the pressure being resolved. The proper countermeasure is to set limits and requests rather than relying on eviction.
Which process gets killed
The kernel evaluates eligible processes using resident pages, swap, page tables and oom_score_adj; the adjustment alone does not establish a fixed kill order. The kubelet sets oom_score_adj (a bonus added to oom_score) according to the container's QoS class, so the QoS class and the memory request setting directly become how likely a container is to be killed. You can check the QoS class with kubectl get pod <Pod name> -o jsonpath='{.status.qosClass}', and how it is determined is described in Pod Quality of Service Classes.
| Condition | oom_score_adj | Likelihood of being killed |
|:---|:---|:---|
| system-node-critical (evaluated before the QoS class) | -997 | Least likely |
| Guaranteed | -997 | Least likely |
| Burstable | 1000 - 1000 × memory request / node memory capacity (clamped to 3–999) | The smaller the request, the more likely |
| BestEffort | 1000 | Most likely |
The implementation is GetContainerOOMScoreAdjust() in Kubernetes' policy.go. system-node-critical is evaluated before the QoS class decision.
if types.IsNodeCriticalPod(pod) {
// Only node critical pod should be the last to get killed.
return guaranteedOOMScoreAdj
}
The formula above is the container-level baseline. Pod-level requests and restartable init containers can introduce adjustments. An omitted memory request can also default from the limit, so inspect the applied Pod spec.
The Burstable clamp is implemented with a lower bound of 1000 + guaranteedOOMScoreAdj (= 3) and an upper bound of besteffortOOMScoreAdj - 1 (= 999). A Pod whose request is set smaller than its actual usage is more likely to be targeted in both eviction and global OOM.
> Warning: PriorityClass works differently for eviction and for global OOM. It is a misreading to think that raising priority protects you from both.
>
> | PriorityClass | Eviction | global OOM (oom_score_adj) |
> |:---|:---|:---|
> | User defined (up to 1000000000) | The higher the value, the later it is evicted | Determined by QoS class and request |
> | system-cluster-critical | Exempt from node-pressure eviction | Determined by QoS class and request (not protected) |
> | system-node-critical | Exempt from node-pressure eviction | Fixed at -997 |
> | static / mirror Pod | Exempt from node-pressure eviction | Determined by QoS class and request |
>
> The three exempt rows identify critical Pods for which IsCriticalPod() is true; the kubelet checks static and mirror Pods as well as system-critical priority classes.
>
> What oom_score_adj protects is only system-node-critical, for which IsNodeCriticalPod() is true. If a system-cluster-critical Pod is BestEffort, its oom_score_adj is 1000, and it is more likely to be selected in a global OOM. "I raised the priority but it was still OOMKilled" comes from this path.
> Warning: Neither an OOM kill nor node-pressure eviction directly deletes the Pod API object. What happens next depends on restartPolicy.
>
> - Always or OnFailure: the container is recreated on the same node. The Pod stays on the same node, so if the cause is not resolved, OOM kills will happen again
> - Never: the container is not recreated and the Pod remains Failed. If a controller such as a Job creates a successor Pod, it may be placed on a different node
>
> If OOMKilled repeats even though there is headroom under the limit, suspect this path.
> Note: Because the OOM kill frees memory, the pressure may already be gone when the kubelet next evaluates free memory. In that case the node may get no MemoryPressure condition and no node.kubernetes.io/memory-pressure:NoSchedule taint, and scheduling of new Pods does not stop. Conversely, if the pressure continues, a later evaluation sets the condition and taint, and eviction also occurs. Note that it is decided by memory.available at evaluation time, not by whether an OOM kill happened.
{% enddetails %}
Checking with PSI (Pressure Stall Information)
This is an auxiliary indicator for the case where things are slow although usage has not reached the limit. It shows how long processes were actually stalled waiting for memory reclaim.
{% details How to read PSI and PromQL %}
You can obtain the time a container was stalled waiting for memory allocation as PSI. Even when usage has not reached the limit, you can detect being made to wait by memory reclaim.
| Metric | Emitted by | Description |
|:---|:---|:---|
| container_pressure_memory_waiting_seconds_total | cAdvisor (embedded in kubelet) | Time some processes were waiting for memory (total of some in memory.pressure) |
| container_pressure_memory_stalled_seconds_total | cAdvisor (embedded in kubelet) | Time all processes were stalled waiting for memory (total of full in memory.pressure) |
Note: These metrics are controlled by the KubeletPSI feature gate, which went GA in v1.36 and is enabled by default (it cannot be disabled). The kernel must support PSI, though, and the kubelet decides this by whether cpu.pressure exists at the cgroup root (cadvisor_linux.go). If the metrics do not appear, check whether the kernel was built with CONFIG_PSI=y and whether psi=0 is set as a kernel parameter.
memory.pressure also has avg10 / avg60 / avg300, "the average over the last N seconds (%)", but only the cumulative value (total) is exposed as a Prometheus metric. So use rate() to see a ratio.
The unit is seconds, so the result of rate() is "how many seconds of waiting per second", a ratio from 0 to 1. Multiply by 100 to get a percentage directly.
<!-- PromQL uses Python fencing for syntax highlighting only. -->
# Per Pod, the share of time (%) all processes were stalled waiting for memory
100 * max by (namespace, pod) (
rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m])
)
# Per Pod, the share of time (%) some processes were waiting for memory
100 * max by (namespace, pod) (
rate(container_pressure_memory_waiting_seconds_total{container="", image="", pod!=""}[5m])
)
When there is plenty of memory, neither increases, so rate() stays at 0. Under pressure, waiting (some) rises first, and if it gets worse stalled (full) follows. waiting counts even when only some processes waited, so to judge severity, use stalled. It is the time all processes in the container were stopped, so if it shows a value, throughput is directly affected.
To see memory pressure on the whole node, use the root cgroup series.
# Share of time (%) the whole node was completely stalled waiting for memory
100 * max by (node) (
rate(container_pressure_memory_stalled_seconds_total{id="/"}[5m])
* on(instance) group_left(node) kubelet_node_name
)
Containers that are struggling although they are below their limit can be found by combining PSI with utilization. container_spec_memory_limit_bytes is the container limit exposed by cAdvisor.
# Containers whose usage is under 80 % of the limit but that are stalled waiting for memory
(
100 * rate(container_pressure_memory_stalled_seconds_total{container!="", image!=""}[5m]) > 1
)
and
(
container_memory_usage_bytes{container!="", image!=""}
/ container_spec_memory_limit_bytes{container!="", image!=""} < 0.8
)
and keeps only series whose label sets match exactly on both sides. A container with no limit has container_spec_memory_limit_bytes of 0, so the division gives +Inf, which does not satisfy < 0.8, and it is excluded automatically.
Here is an example alert. It catches stalled that keeps occurring.
- alert: ContainerMemoryStalled
# Fires when the 5-minute average of fully stalled time is 5 % or more for 10 minutes
expr: |
100 * max by (namespace, pod) (
rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m])
) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "A Pod is stalled waiting for memory allocation (add the namespace and pod labels with your own template)"
description: "It may be made to wait by reclaim even though it has not reached its memory limit. Check the gap between usage and RSS and the amount of page cache."
The right threshold of 5 % depends on the environment. First look at the distribution of values without an alert to understand the normal level, then decide.
For details on PSI see Understand Pressure Stall Information (PSI) Metrics.
{% enddetails %}
Quick reference: contradictions explained and a metric map
The working set, cgroup usage, process RSS and node availability answer different questions. Use this map to identify the value behind the dashboard before comparing it with a limit or threshold. For a non-root cgroup, working set is max(memory.current - inactive_file, 0). It still includes some reclaimable memory.
| Scope | Metric | Emitted by | Backing value in cgroup v2 / /proc | Page cache handling | Main use |
|:---|:---|:---|:---|:---|:---|
| Container | container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | max(memory.current − inactive_file, 0) | Active pages count as in use | kubectl top pod, usage visualization |
| Container | container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Not included (anonymous pages only) | Understanding what the app allocated |
| Container | container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Everything included | What memcg OOM is evaluated against |
| Node | container_memory_working_set_bytes{id="/"} | cAdvisor (embedded in kubelet) | anon + file − inactive_file in the root cgroup's memory.stat (see below) | Active pages count as in use | Eviction decisions, kubectl top node |
| Node | node_memory_MemAvailable_bytes | node-exporter | MemAvailable in /proc/meminfo | Reclaimable pages count as free | Visualizing node memory utilization |
| Node | kube_node_status_capacity{resource="memory"} | kube-state-metrics | (status.capacity of the Node object) | (capacity, not usage) | Denominator when converting to utilization |
Looking at the same node, utilization computed from node_memory_MemAvailable_bytes does not match the utilization the kubelet uses for eviction decisions. With workloads that do a lot of file I/O or logging, the difference can reach several GiB.
Different emitters also mean different labels on the metrics. cAdvisor metrics carry no label identifying the node, so when you compute per-node utilization you need to align labels with kubelet_node_name, as shown later.
Kubernetes memory usage and cgroup v2
Why the numbers differ
A process RSS measurement includes its resident mappings, including shared and file-backed pages. Cgroup accounting charges memory to a group, while working set subtracts one category of file cache from usage. An application's heap metric covers a different subset again.
Reclaimability is a separate question: clean file cache can often be reclaimed, dirty pages may need writeback, and anonymous memory normally needs swap or explicit release by the process. Calling free does not necessarily return allocator-held memory to the OS.
These differences explain why heap size, process RSS, cgroup RSS and working set need not match. The following sections connect those values to cgroup v2 accounting.
Memory breakdown in cgroup v2
Container memory usage is managed in a per-container cgroup. In cgroup v2, memory.current is the current usage and memory.stat is its breakdown.
# Example values in a container's cgroup (checked on the node)
cat /sys/fs/cgroup/<container cgroup>/memory.current
Output:
5368709120
cat /sys/fs/cgroup/<container cgroup>/memory.stat
Output:
anon 2147483648 # anonymous pages (heap, stack: memory not backed by a file)
file 3087007744 # page cache (including tmpfs and shared memory)
shmem 268435456 # the part of file that is tmpfs or shared memory
...
inactive_anon 134217728 # anon LRU: not accessed for a while
active_anon 2013265920 # anon LRU: recently accessed
inactive_file 1073741824 # file LRU: not accessed for a while
active_file 1744830464 # file LRU: recently accessed
unevictable 268435456 # pages that cannot be reclaimed (here, the tmpfs part)
...
The following relationships hold in this example. Note that inactive_file + active_file does not equal file; it equals file - shmem. The next section explains why.
anon + file + kernel memory = 5368709120 (memory.current)
inactive_file + active_file = 2818572288 = file - shmem
inactive_anon + active_anon = 2147483648 = anon (shmem is not in here)
unevictable = 268435456 = shmem (tmpfs without swap)
The main fields of memory.stat are:
anon (anonymous pages)
- Memory not backed by a file. Regions the application allocated with malloc and similar belong here.
- Unlike page cache, the kernel cannot reclaim it independently of the process. With swap it can write pages out and free up space, but on nodes that do not use swap it is memory the kernel cannot reclaim under pressure. When swap is unavailable, the kernel sets scan_balance = SCAN_FILE and excludes the anon LRU from scanning (get_scan_count() in mm/vmscan.c).
- On the other hand, this value drops if the process itself returns memory to the OS with munmap or madvise(MADV_DONTNEED). Note that calling free does not reduce it unless the allocator or language runtime returns the memory to the OS.
file (page cache)
- Contents of files held in memory by the kernel during reads and writes. The application did not allocate it explicitly.
- When memory runs short the kernel discards (reclaims) it automatically, so it is basically reclaimable memory.
- Cache of files on disk is split into active_file (recently accessed) and inactive_file (not accessed for a while). Reclaim takes inactive_file first.
- active_file + inactive_file does not equal file. These are totals of the LRU lists, and as described below shmem is on the anon LRU rather than the file LRU. Besides shmem, pages that fall off the LRU, such as mlocked pages, also show up as a difference.
- It includes not only the cache of files on disk but also shmem (tmpfs and shared memory), because Linux treats these as page cache too. shmem is written out to swap rather than to a file, so on nodes without swap it cannot be reclaimed. The breakdown is visible as shmem in memory.stat.
> Warning: shmem is counted in file, but it is not on the file LRU. The kernel classifies swap-backed pages as anon, and tmpfs pages are among them. folio_is_file_lru() in mm_inline.h is defined as "0 if @folio is a normal anonymous folio, a tmpfs folio or otherwise ram or swap backed folio".
>
> The counters they are charged to are different, though. A tmpfs page is added to both NR_FILE_PAGES (= file) and NR_SHMEM (= shmem) (mm/shmem.c):
>
> ```c
> __lruvec_stat_mod_folio(folio, NR_FILE_PAGES, nr);
> __lruvec_stat_mod_folio(folio, NR_SHMEM, nr);
> ```
>
> Furthermore, for a Kubernetes emptyDir with medium: Memory, the kubelet mounts tmpfs with the noswap option when the node supports it (generateTmpfsMountOptions() in empty_dir.go). The kernel makes the inode of a noswap tmpfs unevictable (shmem_get_inode() in mm/shmem.c), so these pages sit on the unevictable LRU, not the anon LRU.
>
> ```c
> if (sbinfo->noswap)
> mapping_set_unevictable(inode->i_mapping);
> ```
>
> So shmem is accounted as follows:
>
> | mount | LRU | Where it shows up in memory.stat |
> |:---|:---|:---|
> | emptyDir with medium: Memory (noswap) | unevictable | unevictable |
> | Any other tmpfs / shared memory | anon LRU | inactive_anon / active_anon |
>
> In neither case does it enter inactive_file / active_file. That matters in two ways later:
>
> - inactive_file + active_file generally corresponds to file - shmem; unevictable pages and asynchronous statistics can cause differences
> - Only inactive_file is subtracted from the working set, so shmem stays in the working set in full
With workloads that write a lot of logs or read and write large files, the page cache can grow to several GiB even though the application did not intend that.
> Note: memory.current includes memory the kernel itself uses (slab, socket buffers and so on) in addition to anon and file. So adding anon and file alone does not give memory.current.
How cgroup v2 memory controls map to Pod settings
cgroup v2 memory control interface
cgroup v2 offers four levels of control over memory usage. memory.min and memory.low are protections ("do not reclaim up to here"), and memory.high and memory.max are limits ("do not allow use beyond here").
| File | Type | Behavior |
|:---|:---|:---|
| memory.min | Protection (hard) | Protects memory within the effective boundary determined by ancestors and usage. It does not preallocate physical memory |
| memory.low | Protection (soft) | Best-effort avoidance of reclaim up to this value. It is reclaimed if nothing else can be |
| memory.high | Limit (soft) | Exceeding it applies reclaim pressure and throttling. Crossing memory.high does not itself invoke the OOM killer; memory.max and global OOM still apply |
| memory.max | Limit (hard) | Exceeding it triggers reclaim, and if usage still does not fit, the cgroup's OOM killer runs |
Other files that report state:
- memory.current: current usage (what container_memory_usage_bytes is backed by)
- memory.stat: usage breakdown
- memory.events: counts of low, high, max, oom, oom_kill and oom_group_kill
- memory.swap.max: swap limit
- memory.pressure: PSI for waiting on memory allocation
For exact definitions of each file see the kernel's Memory Interface Files, and for how Kubernetes treats cgroup v2 see About cgroup v2.
How Pod settings are reflected
Pod settings are reflected into cgroup v2 as follows.
| Pod setting | Target in cgroup v2 |
|:---|:---|
| resources.limits.memory | memory.max |
| resources.requests.memory | Not reflected (with the default kubelet configuration in v1.37; you can reflect it by changing settings → [KEP-2570](#kep-2570-support-memory-qos-with-cgroups-v2)) |
| resources.limits.cpu | cpu.max |
| resources.requests.cpu | cpu.weight |
cgroup v2 is a directory tree. Its top is the root cgroup (/sys/fs/cgroup), where any process that is in no child cgroup belongs, so it contains not only Pods but every process on the node, such as the kubelet and containerd. Kubernetes creates the hierarchy below it: "all Pods on the node → Pod → container".
# With the systemd cgroup driver (the kubeadm default; names differ with the cgroupfs driver, see below)
/sys/fs/cgroup ← root cgroup (id="/")
│ contains every process on the node
│ the value used for eviction decisions
│
├── system.slice/ kubelet, containerd, sshd, etc.
│ ├── kubelet.service (processes that are not Pods)
│ └── containerd.service
│
└── kubepods.slice/ ← all Pods on the node
│ (id="/kubepods.slice")
├── kubepods-pod<UID>.slice/ ← Pod (Guaranteed)
│ ├── cri-containerd-<ID>.scope ← container
│ └── cri-containerd-<pause ID>.scope ← pause container
│
├── kubepods-burstable.slice/ (intermediate slice per QoS class)
│ └── kubepods-burstable-pod<UID>.slice/ ← Pod (Burstable)
│ └── cri-containerd-<ID>.scope ← container
│
└── kubepods-besteffort.slice/
└── kubepods-besteffort-pod<UID>.slice/ ← Pod (BestEffort)
└── cri-containerd-<ID>.scope ← container
When filtering metrics, the root cgroup is id="/", all Pods on the node are id="/kubepods.slice", a Pod is container="", image="", pod!="", and a container is container!="", image!="". What the kubelet uses for eviction decisions is the working set of the topmost root cgroup ([see below](#values-the-kubelet-uses-for-eviction-decisions)). That is a different level from memcg OOM caused by a container or Pod exceeding its limit.
Memory limits are set not only on containers but also on higher levels.
| Level | cgroup | Memory limit |
|:---|:---|:---|
| Whole node (root cgroup) | / | Not set (the root has no memory.max → [see below](#values-the-kubelet-uses-for-eviction-decisions)) |
| All Pods on the node | /kubepods.slice | Capacity - kube-reserved - system-reserved when Pod allocatable enforcement is enabled |
| Pod | Differs by QoS class (see below) | The Pod's effective limit, including Pod-level spec.resources.limits.memory when configured |
| Container | cri-containerd-<ID>.scope under the Pod's cgroup | That container's limits.memory |
The examples here use container-level resources and omit Pod-level resources. Without a Pod-level limit, the required container limits must be present for a finite Pod limit to be set. A Pod's effective limit is not simply the sum of its regular containers' limits.memory. If there are init containers, their maximum is also considered, and spec.overhead is added if set. For the calculation see Resource sharing within containers and Pod Overhead.
So even if a container has not reached its own limit, an OOM kill can occur when the Pod as a whole or /kubepods.slice hits its limit.
> Note: The paths above assume the kubelet uses the systemd cgroup driver. With the cgroupfs driver, paths look like /kubepods/burstable/pod<UID>/<container ID>, with no .slice or .scope and no cri-containerd- prefix on the container.
>
> The kubelet's own default is cgroupfs, but kubeadm sets systemd when the value is empty (defaults.go, kubelet.go). In v1.37, a supported runtime's CRI RuntimeConfig response takes precedence through driver auto-detection. Check the effective driver with:
>
> ```bash
> $ kubectl get --raw "/api/v1/nodes/<node name>/proxy/configz" \
| jq '.kubeletconfig.cgroupDriver'
> "systemd"
> ```
>
> The id label of cAdvisor metrics also changes with the cgroup driver, so when filtering in PromQL, check the value in your own cluster with this query:
>
> ```bash
> group by (id) (container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"})
> ```
>
> A Pod's cgroup path depends on its QoS class. As the tree above shows, Burstable and BestEffort go through an intermediate slice per QoS class.
>
> In the <UID> part, systemd escaping replaces the hyphens of the Pod UID with underscores (example: 6983351e-4ede-... → pod6983351e_4ede_...). The UID of a static Pod (mirror Pod) is a hash with no hyphens, so it looks like pod1ff70b81885169da4e9e32c9530b77c9.slice.
>
> The QoS class and UID of a Pod can be read from the Pod resource, and once you have those two you can assemble the cgroup path.
>
> ```bash
> $ kubectl get pod -n <namespace> <Pod name> -o jsonpath='{.status.qosClass}{"\n"}{.metadata.uid}{"\n"}'
> Burstable
> 6983351e-4ede-4611-8bab-b986614af18d
>
> # To list them
> $ kubectl get pods -n <namespace> -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass'
> ```
Container metrics
The cAdvisor built into the kubelet exposes the cgroup values above as Prometheus metrics. They are served from the kubelet's /metrics/cadvisor endpoint, and you do not need to run a separate cAdvisor Pod or DaemonSet.
| Metric | Emitted by | Backing value in cgroup v2 | Description |
|:---|:---|:---|:---|
| container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Usage including anonymous pages, page cache and kernel memory. The value memcg OOM is evaluated against |
| container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | memory.current - inactive_file | Usage minus inactive_file, clamped to zero. A heuristic that still includes some reclaimable memory |
| container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Amount of anonymous pages. Unlike true RSS, it does not include mmaped file pages (see below) |
| container_memory_cache | cAdvisor (embedded in kubelet) | file in memory.stat | Page cache size. Includes shmem (tmpfs and shared memory) |
| container_memory_total_active_file_bytes | cAdvisor (embedded in kubelet) | active_file in memory.stat | Active page cache |
| container_memory_total_inactive_file_bytes | cAdvisor (embedded in kubelet) | inactive_file in memory.stat | Inactive page cache |
| container_memory_mapped_file | cAdvisor (embedded in kubelet) | file_mapped in memory.stat | Amount of mmaped files |
| `cont
2 comments
Kubernetesのログ入門
はじめに
Kubernetesを普段使っていても、kubectl logsでPodのログが見られることは分かるものの、実際にどんな種類のログがあるのか、どういう形で管理されているのか、保存期間はどれくらいなのか、長期間保存するにはどうすればいいのか、といったところまではなかなか分からないと思います。
そこでこの記事では、Podのコンテナログとkubelet・コンテナランタイムなどのシステムコンポーネントログについて、ディレクトリ構成・確認方法・ローテーションの仕組みを中心に整理しました。EventとAudit Logについては、別の記事で扱っているのでそちらを参照してください。
対象コンポーネントとバージョン
| コンポーネント | バージョン | リポジトリ | 本記事での役割 |
|---|---|---|---|
| Kubernetes | v1.37.1 | kubernetes/kubernetes | kubeletのコンテナログ管理・ローテーション・klog連携の実装をソースコードで確認 |
| kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージはkindest/node:v1.37.0 |
| containerd | v2.3.4 | containerd/containerd | 検証環境のCRIランタイム(kindest/node:v1.37.0に同梱。ノード上でcontainerd --versionを実行して確認) |
| eventrouter | commit 0d28dde(2026-09-16) | openshift/eventrouter | Eventを長期保存する実装例として、Event APIの監視方法と転送方式をソースコードで確認 |
| Fluent Bit | v5.1.2 | fluent/fluent-bit | ノードレベルロギングエージェントの実装例として、in_tail・filter_kubernetes・out_lokiの実装をソースコードで確認 |
| OpenTelemetry Collector Contrib | v0.162.0 | open-telemetry/opentelemetry-collector-contrib | Fluent Bitと並ぶノードレベルロギングエージェントの実装例として、filelog receiver・container operator・k8sattributesprocessorの実装をソースコードで確認 |
| OpenTelemetry Collector | v0.162.0 | open-telemetry/opentelemetry-collector | otlphttpexporterの実装をソースコードで確認 |
| Vector | v0.58.0 | vectordotdev/vector | Fluent Bit・OpenTelemetry Collectorと並ぶノードレベルロギングエージェントの実装例として、kubernetes_logs source・loki sinkの実装をソースコードで確認 |
| Grafana Loki | v3.7.8 | grafana/loki | Fluent Bit・OpenTelemetry Collector・Vectorからのログ転送先として、実際にクラスタ外で動かして検証 |
| stern | v1.34.0 | stern/stern | 複数Pod横断でのログ確認ツールの実装例として、Podの追跡方式をソースコードで確認 |
TL;DR
- コンテナログ・システムコンポーネントログ・Event・監査ログという4分類でログの全体像を整理できます。
- コンテナログは/var/log/pods/<namespace>_<name>_<uid>/<container>/<restartCount>.logに書き込まれ、/var/log/containers/に互換用のシンボリックリンクが張られます。
- コンテナログのローテーションは、kubelet自身がファイルをtruncateするのではなく、CRIランタイムのReopenContainerLogを呼んで新しいファイルへの書き込みを再開させる方式です。
- stdout/stderrを分離して取得できるPodLogsQuerySplitStreamsというFeature Gateもあります。
- kubeletやコンテナランタイムなど「コンテナの外で動くコンポーネント」自身のログには、Kubernetes側のローテーション機構がありません。
- kubectl get --rawでノードのkubeletログやその他のシステムサービスのログを直接取得できるLog Query機能がv1.36で安定版になりました。
- DaemonSetで動くログ収集エージェントは、kubeletのローテーション方式(ファイルのrename)に対応するため、パスを監視しつつファイルの実体を見分ける必要があり、Fluent Bitはinode比較、OpenTelemetry CollectorとVectorはファイル内容のチェックサムと、実装方式が異なります。
- Event(デフォルト1時間で消える)を長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを立てます。
第1章: Kubernetesで扱うログの全体像
まず、この記事とEvent・監査ログの記事で扱う範囲を整理します。コンテナログ・システムコンポーネントログ・Event・監査ログという4種類のログについて、それぞれの位置づけを確認します。
1.1 4種類のログの位置づけ
公式ドキュメントのLogging Architectureは、ノード上のログをどう扱い・集約するかを説明するページで、Kubernetesのログ種別を網羅的に分類するものではありません。見出しも次の3つで、対象はノード上のログに限定されています。
- Pod and container logs
- System component logs
- Cluster-level logging architectures
EventとAudit Logがここに登場しないのは、ノード上のファイルではなくAPIオブジェクトとして記録される別の仕組みだからです。
この記事では、ノード上のログ(コンテナログ+システムコンポーネントログ)とAPIオブジェクトのログ(Event、Audit Log)という区別で、それぞれ次の章で扱います。
| 系統 | 種類 | 記録される場所 | この記事での扱い |
|---|---|---|---|
| ノード上のログ | コンテナログ | ノードのファイルシステム(/var/log/pods等) | 第2章で詳説 |
| ノード上のログ | システムコンポーネントログ(kubelet・コンテナランタイム・static Pod) | journald、/var/log配下のファイル | 第3章で詳説 |
| APIオブジェクトのログ | Event | kube-apiserver(デフォルト--event-ttlで1時間保持) | 第4章では概要のみ。詳細はKubernetesのeventログの一覧に記載 |
| APIオブジェクトのログ | Audit Log | Policyで指定したファイル/webhookバックエンド | 第4章では概要のみ。詳細はKubernetesの監査ログ(Audit Log)入門に記載 |
第2章: Podとコンテナのログ
Podのコンテナがstdout/stderrに書いたログを、kubeletがどのようにノード上のファイルへ落とし込み、どうローテーションしているかを確認します。
2.1 ディレクトリ構成とファイルパスの決定
コンテナログの絶対パスは、Pod単位のディレクトリとコンテナ単位のサブディレクトリの組み合わせで決まります。
// BuildPodLogsDirectory builds absolute log directory path for a pod sandbox.
func BuildPodLogsDirectory(podLogsDir, podNamespace, podName string, podUID types.UID) string {
return filepath.Join(podLogsDir, strings.Join([]string{podNamespace, podName,
string(podUID)}, logPathDelimiter))
}
(参考: pkg/kubelet/kuberuntime/helpers.go#L209-L226)
podLogsDirのデフォルト値は/var/log/podsで、KubeletConfigurationのpodLogsDirフィールド(デフォルトは空文字列扱いで、起動時にこのデフォルトが補完されます)で変更できます。
DefaultPodLogsDir = "/var/log/pods"
(参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L42、フィールド定義はstaging/src/k8s.io/kubelet/config/v1beta1/types.go#L132-L137)
変更する場合は、KubeletConfiguration(--configで指定するファイル、kindクラスタでは/var/lib/kubelet/config.yaml)に次のように指定します。
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
podLogsDir: /mnt/log/pods
コンテナ単位のファイル名は<コンテナ名>/<再起動回数>.logで、このパスがCRIのContainerConfig.LogPathとしてランタイムに渡され、Pod Sandbox単位で設定されるLogDirectoryと結合されて最終的な絶対パスになります。
containerLogsPath := buildContainerLogsPath(container.Name, restartCount)
// ...
config := &runtimeapi.ContainerConfig{
// ...
LogPath: containerLogsPath,
// ...
}
(参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L360-L382、Sandbox側のLogDirectory設定はpkg/kubelet/kuberuntime/kuberuntime_sandbox.go#L117-L118)
実際にkindクラスタ上でPodを起動して確認すると、/var/log以下は次のような階層になっていました。
$ tree /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4
/var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4
`-- app
|-- 0.log
|-- 0.log.20261007-065856.gz
|-- 0.log.20261007-065907.gz
|-- 0.log.20261007-065917.gz
`-- 0.log.20261007-065927
2 directories, 5 files
pods/の下が<namespace>_<name>_<uid>、その下が<コンテナ名>というディレクトリになっており、ソースコードの実装どおりの階層が再現されています。
2.2 /var/log/containers/へのシンボリックリンク
/var/log/containers/配下にも、コンテナログのシンボリックリンクが作られます。これはCRI導入前、kubeletがdockerdを直接操作していた頃に使われていたコンテナログの配置をそのまま残したものです。関数名もlegacyLogSymlinkと、ソースコード上はっきり「legacy」と位置づけられています。
// legacyLogSymlink composes the legacy container log path. It is only used for legacy cluster
// logging support.
func legacyLogSymlink(containerID string, containerName, podName, podNamespace string) string {
return logSymlink(legacyContainerLogsDir, kubecontainer.BuildPodFullName(podName, podNamespace),
containerName, containerID)
}
(参考: pkg/kubelet/kuberuntime/legacy.go#L31-L75)
シンボリックリンクの作成自体は、コンテナ起動が成功した直後、実際のログファイルが存在することを確認してから行われます。
// Symlink container logs to the legacy container log location for cluster logging
// support.
// TODO(random-liu): Remove this after cluster logging supports CRI container log path.
(参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L300-L317)
:::note warn
上記のTODOコメントにあるとおり、このシンボリックリンクはいずれ削除される可能性があります。
:::
実際の検証環境では、次のようなシンボリックリンクが張られていました。
$ ls -l /var/log/containers/ | grep demo-logtest
lrwxrwxrwx 1 root root 81 Oct 7 06:58 demo-logtest_default_app-a33e8752ef7403a4970f2531374c62e9a657e5b945b3df05f1a8604da9f7d222.log -> /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4/app/0.log
2.3 CRIログフォーマットとkubectl logs
コンテナランタイムがファイルに書き込むログの各行は、CRIで定められた形式に従います。この形式はkubelet側のk8s.io/cri-clientモジュールでパースされており、ソースコード中のコメントに例が示されています。
// parseCRILog parses logs in CRI log format. CRI Log format example:
//
// 2016-10-06T00:17:09.669794202Z stdout P log content 1
// 2016-10-06T00:17:09.669794203Z stderr F log content 2
(参考: staging/src/k8s.io/cri-client/pkg/logs/logs.go#L123-L168)
タイムスタンプ・ストリーム種別(stdout/stderr)に続く1文字は、そのログ行が1行の出力として完結しているか(F = Full)、まだ続きがあるか(P = Partial)を示すタグです。
LogTagPartial LogTag = "P"
LogTagFull LogTag = "F"
(参考: staging/src/k8s.io/cri-api/pkg/apis/runtime/v1/constants.go#L49-L52)
:::note info
このタグは、アプリケーションが改行区切りで書いた複数行のログ(スタックトレース等)を結合するためのものではありません。CRIランタイム側の1行あたりの読み取りバッファ上限(containerdではMaxContainerLogLineSize、デフォルト16KB)を、アプリケーションが1回の書き込みで超えた場合に、その1行を複数の物理行に分割して書き込むための仕組みです。分割された行のうち、最後の断片だけがFになります。
if maxLen > 0 && length > maxLen {
// ...
writeLineBuffer(partial, buf)
// ...
}
(参考: internal/cri/io/logger.go#L114-L195、バッファ上限の設定はinternal/cri/config/config_unix.go#L102)
:::
検証環境のkube-apiserverのログを見ると、実際にこの形式で書き込まれていることが確認できます。
$ docker exec k8s-logging-demo-control-plane tail -c 300 /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/0.log
2026-09-30T06:07:44.320806626Z stderr F I0930 06:07:44.320423 1 controller.go:667] quota admission added evaluator for: controllerrevisions.apps
2026-09-30T06:07:44.563889999Z stderr F I0930 06:07:44.563773 1 controller.go:667] quota admission added evaluator for: replicasets.apps
kubectl logsはこのCRI形式のファイルを直接読み、タグとタイムスタンプを除いた本文だけをクライアントに返します。
CRIログ形式がstdout/stderrをストリーム種別として行ごとに記録していることを利用して、Pod APIからstdout・stderrのどちらか一方だけを取得できる機能もあります。PodLogsQuerySplitStreamsというFeature Gateで、v1.32でアルファとして導入され、v1.37時点でもデフォルトで無効です(参考: pkg/features/kube_features.go#L899-L902、デフォルト状態は同ファイルL1978-L1980)。
kubelet側の実装では、PodLogOptionsのstreamフィールドの値に応じて、CRIログのstdout行・stderr行のどちらを応答に書き込むかを切り替えているだけです。
switch *wantedStream {
case v1.LogStreamStdout:
stdout, stderr = fw, nil
case v1.LogStreamStderr:
stdout, stderr = nil, fw
case v1.LogStreamAll:
stdout, stderr = fw, fw
(参考: pkg/kubelet/server/server.go#L888-L901)
Feature Gateを有効にして実際に試すと、streamクエリパラメータでstdout/stderrを分離して取得できました。
$ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stdout"
stdout-line-1
stdout-line-2
done
$ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stderr"
stderr-line-1
stderr-line-2
普段のkubectl logsはstreamを指定しない(All)ため、これまでどおり両方のストリームがまとめて返ってきます。
2.4 ログローテーション
コンテナログのローテーションはpkg/kubelet/logs/container_log_manager.goが担当します。デフォルト値は次のとおりです。
| 設定項目 | デフォルト値 | 役割 |
|---|---|---|
| containerLogMaxSize | 10Mi | 1ファイルあたりの上限サイズ |
| containerLogMaxFiles | 5 | コンテナあたりの最大ファイル数(現行+ローテーション済み合計) |
| containerLogMaxWorkers | 1 | ローテーションを並行実行するワーカー数 |
| containerLogMonitorInterval | 10秒 | ログサイズを監視する間隔 |
(参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L256-L266)
これらもKubeletConfigurationで変更できます。たとえばログの量が多いワークロードが多いノードで、1ファイルあたりの上限を大きくしつつ保持ファイル数を減らす場合は次のようになります。
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
containerLogMaxSize: 50Mi
containerLogMaxFiles: 3
containerLogMaxWorkers: 2
containerLogMonitorInterval: 10s
ローテーションの実体は、containerLogMonitorIntervalごとに実行中の全コンテナを列挙し、ログファイルのサイズがcontainerLogMaxSize以上ならローテーション処理をキューに積む、というシンプルなポーリングです。
func (c *containerLogManager) processContainer(ctx context.Context, worker int) (ok bool) {
// ...
if info.Size() < c.policy.MaxSize {
// rotate不要
return
}
if err := c.rotateLog(ctx, id, path); err != nil {
// ...
}
return
}
(参考: pkg/kubelet/logs/container_log_manager.go#L218-L270)
ここで重要なのは、kubeletがファイルをtruncateして使い回すのではなく、次の手順を踏む点です。
1. 現在のログファイルをタイムスタンプ付きの名前にrenameする(この時点ではまだ無圧縮)。これはkubelet自身が行う処理で、ファイルの中身ごと退避させるだけです。
2. CRIランタイムのReopenContainerLogを呼ぶ。ここから先はkubeletではなくランタイム側の処理です。
1. リネームによって空になった元のパスに、新しいログファイルを作成する。
2. コンテナのstdout/stderrから受け取ったバイトの書き込み先を、今作った新しいファイルに差し替える。
3. それまで書き込み先として使っていた古いファイルハンドル(リネームされた方のファイルを握っていたハンドル)を閉じる。
3. ReopenContainerLogが失敗したらリネームを元に戻し、次のローテーション周期(デフォルト10秒)で再試行する(ログを失わないための保険)。
コンテナプロセス自身は、自分のstdoutの書き込み先ファイルが新しいファイルに切り替わったことを一切意識しません。ファイルの実体を直接触っているのはコンテナプロセスではなくランタイム側であり、コンテナはあくまでランタイムが用意したパイプに書き込み続けているだけだからです。
(参考: containerd側のReopenContainerLog実装はinternal/cri/server/container_log_reopen.go#L29-L51、新しいログファイルをO_CREATEで作成している箇所はinternal/cri/server/helpers_linux.go#L58-L62)
func (c *containerLogManager) rotateLatestLog(ctx context.Context, id, log string) error {
// ...
timestamp := c.clock.Now().Format(timestampFormat)
rotated := fmt.Sprintf("%s.%s", log, timestamp)
if err := c.osInterface.Rename(log, rotated); err != nil {
return fmt.Errorf("failed to rotate log %q to %q: %w", log, rotated, err)
}
if err := c.runtimeService.ReopenContainerLog(ctx, id); err != nil {
// rename back...
}
return nil
}
(参考: pkg/kubelet/logs/container_log_manager.go#L414-L436)
リネーム後、まだ圧縮されていないローテーション済みファイルはgzipで非同期に圧縮され、圧縮後は元ファイルが削除されます。ファイル数がcontainerLogMaxFilesを超える場合は、古い順に削除されます(「現在のファイル」と「ローテーション中の1ファイル」を除いたMaxFiles - 2個までが、圧縮済みログとして保持される計算です)。
// removeExcessLogs removes old logs to make sure there are only at most MaxFiles log files.
func (c *containerLogManager) removeExcessLogs(logs []string) ([]string, error) {
sort.Strings(logs)
maxRotatedFiles := c.policy.MaxFiles - 2
// ...
}
(参考: pkg/kubelet/logs/container_log_manager.go#L272-L371、gzip圧縮の実装は同ファイルL373-L411)
実際にローテーションを起こしてみる
kindクラスタ上でstdoutに大量の行を書き続けるだけのPodを動かし、ローテーションの様子を観察しました。開始直後は、ローテーション済みファイルがまだ圧縮されていない状態が見えます。
$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [ 17M] 0.log
`-- [ 11M] 0.log.20260930-062154
1 directory, 2 files
しばらく待つと、ローテーション済みファイルが.gzに置き換わり、さらに新しいローテーションも発生しています。
$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [ 11M] 0.log
|-- [1005K] 0.log.20260930-062154.gz
`-- [ 17M] 0.log.20260930-062214
1 directory, 3 files
さらにローテーションが繰り返されると、圧縮済み3ファイル+ローテーション中の1ファイル+現在のファイルの合計5ファイルで安定し、6ファイル目が作られるタイミングで最も古いファイルが削除されました。
$ tree -h /var/log/pods/default_demo-logtest_.../app
/var/log/pods/default_demo-logtest_.../app
|-- [2.9M] 0.log
|-- [1005K] 0.log.20260930-062154.gz
|-- [1.5M] 0.log.20260930-062214.gz
|-- [1.4M] 0.log.20260930-062234.gz
`-- [ 17M] 0.log.20260930-062254
1 directory, 5 files
containerLogMaxFiles: 5が「現在のファイルを含めて合計5ファイル」を意味することが、実際の挙動としても確認できました。
:::note warn
kubectl logsで読めるのは現在(最新)のログファイルだけです。ローテーション済みの.log.<timestamp>や.gzファイルの内容はkubectl logsでは取得できません。ノード上のファイルを直接見るか、ログ収集エージェント側でローテーション済みファイルまで拾う設計にしておく必要があります(参考: Logging Architecture#Log rotation)。
:::
第3章: システムコンポーネントのログ
Podのコンテナとは別に、kubeletやコンテナランタイム自身、そして制御コンポーネントにもログがあります。kubeletとコンテナランタイムはPodを管理する側なので、常にノードのOS上のプロセスとして動きます。一方、kube-apiserverなどの制御コンポーネントをsystemdのunitとして動かすかstatic Podとして動かすかは、クラスタの構築方法次第です。動かし方によってログの出力先・ローテーション方式が次のように変わります。
| パターン | 実体 | ログの出力先 | ローテーション | 確認コマンド |
|---|---|---|---|---|
| systemdのunit | ノードOS上で直接動くプロセス | journald | Kubernetes側の機構はなし。journald/OS側に依存(3.4節) | journalctl -u <unit名> |
| static Pod | kubeletが管理する「ただのコンテナ」 | 通常のPodと同じ/var/log/pods(stdout) | 通常のコンテナログと同じcontainerLogMaxSize/MaxFiles(第2章2.4) | kubectl logs |
この章では、それぞれどこに・どんな形式で出力されるかを以下で詳しく確認します。この記事の検証環境(kind)では、次のように動いています。
- systemd
- kubelet
- コンテナランタイム(containerd)
- static Pod
- kube-apiserver
- kube-controller-manager
- kube-scheduler
- etcd
3.1 systemdで管理されるコンポーネント
kubeletとコンテナランタイムは、通常コンテナの中では動かず、ノードのOS上で直接動くプロセスです。ノードの初期化・サービス管理の仕組みはsystemdであることが多いため、この記事もsystemdを前提に話を進めます。systemd環境では、デフォルトでjournaldにログを書き込みます。
今回の検証で用いたkindでも、kubeletはsystemdのunitとして動いています。
$ docker exec k8s-logging-demo-control-plane systemctl status kubelet --no-pager | head -8
● kubelet.service - kubelet: The Kubernetes Node Agent
Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-09-30 06:07:36 UTC; 14min ago
Main PID: 766 (kubelet)
$ docker exec k8s-logging-demo-control-plane journalctl -u kubelet --no-pager | tail -3
Sep 30 06:21:41 k8s-logging-demo-control-plane kubelet[766]: I0930 06:21:41.349103 766 pod_startup_latency_tracker.go:144] "Observed pod startup duration" pod="default/demo-logtest" ...
journalctl -u kubeletでkubelet自身のログが直接読めます。
3.2 static Podとして動くコンポーネント
static Podの元になるマニフェストの置き場所は、kubeletのKubeletConfigurationではstaticPodPathというフィールドで指定します。
// staticPodPath is the path to the directory containing local (static) pods to
// run, or the path to a single static pod file.
// Default: ""
StaticPodPath string `json:"staticPodPath,omitempty"`
(参考: staging/src/k8s.io/kubelet/config/v1beta1/types.go#L127-L131)
デフォルト値は空文字列で、kubelet自体は/etc/kubernetes/manifestsをハードコードしていません。この慣習的なパスはkubeadmなど、kubeletを実際にセットアップするツール側の取り決めで、実際にkubeadmが生成するKubeletConfigurationには次のように明示的に書き込まれています。
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
staticPodPath: /etc/kubernetes/manifests
監視の実装はpkg/kubelet/config/file.goのNewSourceFileで、指定したディレクトリをファイルシステム通知で監視し、追加・削除されたマニフェストに応じてPodを起動・停止します。static Podは通常のAPIオブジェクトとしては作成されないため、kubeletはpkg/kubelet/pod/mirror_client.go経由で対応する「mirror Pod」をAPIサーバー上に作り、kubectl get pod等から見えるようにしています。
重要なのは、これらのコンポーネントがただのコンテナとして動いているという点です。static Podとして動くkube-apiserver等がstdoutに書いたログは、通常のPodのコンテナと全く同じ扱い(第2章のCRIログ管理・/var/log/pods・containerLogMaxSize/MaxFilesによるローテーション)を受けます。実際、検証環境でも次のとおり通常のPodと同じディレクトリ構成でした。
$ tree --matchdirs -P '*kube-apiserver*' --prune /var/log/pods
/var/log/pods
`-- kube-system_kube-apiserver-k8s-logging-demo-control-plane_aec712fda8c04555eaada4085f170287
`-- kube-apiserver
3 directories, 0 files
3.3 ログのフォーマットとklog
システムコンポーネントのログは、同じ内容でも次の2つのフォーマットのどちらかで出力されます。
| 値 | 出力形式 |
|---|---|
| text(デフォルト) | 人間が読みやすいキーバリュー形式 |
| json | 構造化されたJSON形式 |
実際にkube-schedulerのリーダー選出のログを、フォーマットを切り替えて比較してみます。text形式(デフォルト)では次のように出力されます。
I1008 08:56:19.685458 1 leaderelection.go:258] "Attempting to acquire leader lease..." lock="kube-system/kube-scheduler"
I1008 08:56:19.688682 1 leaderelection.go:272] "Successfully acquired lease" lock="kube-system/kube-scheduler"
同じイベントをjson形式に切り替えて出力させると、次のようになります。
{"ts":1791449829380.4631,"caller":"leaderelection/leaderelection.go:258","msg":"Attempting to acquire leader lease...","v":0,"lock":"kube-system/kube-scheduler"}
{"ts":1791449829383.8164,"caller":"leaderelection/leaderelection.go:272","msg":"Successfully acquired lease","v":0,"lock":"kube-system/kube-scheduler"}
lockフィールドの中身は同じですが、text形式はメッセージと埋め込まれたキーバリューをそのまま1行に並べた形式、json形式はts/caller/msg/vといったキーを持つJSONオブジェクトです。このフォーマットの出し分けを担っているのが、kubeletを含むKubernetesのコンポーネントが共通で使っているロギングライブラリklogです。klogは出力形式だけでなく出力先についても規定しており、公式ドキュメントには次のように明記されています。
> Output will always be written to stderr, regardless of the output format. Output redirection is expected to be handled by the component which invokes a Kubernetes component. This can be a POSIX shell or a tool like systemd.
(参考: System Logs#Klog)
出力形式に関わらず、出力先は常にstderr固定であり、リダイレクトはKubernetesコンポーネントを起動する側(POSIXシェルやsystemdなど)の責任だと明記されています。
この--logging-formatフラグは、次の4コンポーネントで利用できます。いずれもデフォルトはtext形式で、json形式にするには明示的な設定が必要です。
- kube-apiserver
- kube-controller-manager
- kube-scheduler
- kubelet
実際、検証環境のstatic Podマニフェストを見ても--logging-formatは指定されておらず、ログはklogのテキスト形式のままでした。
$ docker exec k8s-logging-demo-control-plane grep -i logging-format /etc/kubernetes/manifests/*.yaml
(該当なし)
有効にする場合、static Podとして動くものはマニフェストのcommandに以下のように引数を追加します。
apiVersion: v1
kind: Pod
metadata:
name: kube-apiserver
namespace: kube-system
spec:
containers:
- name: kube-apiserver
command:
- kube-apiserver
- --logging-format=json
# ...(他の引数は省略)
kubelet自身は起動引数ではなくKubeletConfigurationのlogging.formatフィールドで指定します。
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
logging:
format: json
JSON形式に加えて、Contextual LoggingとStructured Loggingという2つの仕組みも押さえておくとよいと思います。どちらも「有効かどうか」だけでなく、ログ出力箇所のコードが実際にそのAPIを使っているかによって見え方が変わります。kube-schedulerの実装を例に見てみます。
func (sched *Scheduler) scheduleOnePod(ctx context.Context, podInfo *framework.QueuedPodInfo) {
logger := klog.FromContext(ctx)
pod := podInfo.Pod
// TODO(knelasevero): Remove duplicated keys from log entry calls
// When contextualized logging hits GA
// https://github.com/kubernetes/kubernetes/issues/111672
logger = klog.LoggerWithValues(logger, "pod", klog.KObj(pod))
ctx = klog.NewContext(ctx, logger)
logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod))
(参考: pkg/scheduler/schedule_one.go#L93-L101)
- Contextual Logging
- 次の部分がこれです。
```go
klog.LoggerWithValues(logger, "pod", klog.KObj(pod))
```
このPod用に作ったloggerにpodフィールドを紐付け、ctx = klog.NewContext(ctx, logger)でcontextに載せています。これ以降ctxを受け取って動く別の関数がklog.FromContext(ctx)でロガーを取り出すだけで、都度"pod", klog.KObj(pod)を書き直さなくてもpodフィールドが自動的に付いてきます。
- Structured Logging
- 次の"pod", klog.KObj(pod)という部分がこれです。
```go
logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod))
```
メッセージ文字列とは別に、キーと値の組をログ行に付与します。--logging-format=text(デフォルト)なら次のようにキーバリュー形式で、--logging-format=jsonならJSONのフィールドとして出力されます。
実際にkindクラスタのkube-scheduler(-v=4)でこの行を出力させてみると、次のようになりました。
I1007 09:47:56.151071 1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test"
コード中には"pod", klog.KObj(pod)という呼び出し側の明示的なキーがあります。loggerは直前のklog.LoggerWithValuesで既にpodフィールドを持っているので、本来この呼び出し側での指定は冗長なはずです。実際、klogの内部実装(internal/serialize/keyvalues.go)には、同じキーが複数回渡された場合に重複を取り除く処理があり、値が一致していれば後から渡された分は捨てられます。
if bytes.Compare(data[existing[i].start:existing[i].end], data[e.start:e.end]) == 0 {
// The new entry gets obsoleted because it's identical.
// ...
obsolete = append(obsolete, e.interval)
}
(参考: vendor/k8s.io/klog/v2/internal/serialize/keyvalues.go#L100-L109)
試しに--feature-gates=ContextualLogging=falseでContextual Logging自体を無効化し、同じ行を出力させても、結果は変わりませんでした。
I1007 09:49:45.058261 1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test3"
これは、Contextual Loggingが無効ならklog.LoggerWithValuesが実質何もしなくなる一方で、呼び出し側が同じpodキーを明示的にも渡しているため、そちらだけでpodフィールドが出力され続けるからです。コード中のTODOコメントが示すとおり、
// TODO(knelasevero): Remove duplicated keys from log entry calls
// When contextualized logging hits GA
(参考: pkg/scheduler/schedule_one.go#L96-L97)
これは「Contextual LoggingがGAになるまでの間、無効化されても出力が変わらないようにするための意図的な二重指定」だと考えられます。GA後にこの冗長な呼び出し側の指定が取り除かれれば、podフィールドが出力されるかどうかはContextual Loggingの有効/無効に完全に依存するようになると思います。
3.4 システムコンポーネントログのローテーション
第2章で見たコンテナログのローテーションは、あくまでkubeletが管理するContainerLogMaxSize/MaxFilesの仕組みです。journaldに書かれるkubelet・コンテナランタイム自身のログには、この仕組みは適用されません。k8s.io/component-base/logsパッケージ自体にも、サイズや時間を基準にしたローテーション処理は実装されていません。公式ドキュメントも次のように説明しています。
> Similar to the container logs, you should rotate system component logs in the /var/log directory. In Kubernetes clusters created by the kube-up.sh script, log rotation is configured by the logrotate tool. The logrotate tool rotates logs daily, or once the log size is greater than 100MB.
(参考: System Logs#Log location)
static Podとして動く制御コンポーネントについても、公式ドキュメントは次のように注意を促しています。
> If you deploy Kubernetes cluster components (such as the scheduler) to log to a volume shared from the parent node, you need to consider and ensure that those logs are rotated. Kubernetes does not manage that log rotation.
(参考: Logging Architecture)
これは、stdoutに書く(第2章のCRI管理下に入る)のではなく、あえて/var/logをhostPathでマウントしてそこにファイルを書かせるような構成を取った場合の注意で、stdoutに書く一般的な構成であれば、通常のコンテナログと同じくkubeletがローテーションを管理してくれます。
3.5 Log Queryでノードのシステムログを直接取得する
比較的新しい機能として、Log Queryがあります。v1.27でアルファ機能として導入され、v1.36で安定版に昇格しました(参考: System Logs#Log query)。ノードにSSHしたりjournalctlを叩いたりしなくても、kube-apiserver経由でノード上のシステムログを取得できます。
利用にはkubeletのenableSystemLogHandler(デフォルトfalse)を有効にする必要があります。検証環境で実際に有効化して試してみました。
# /var/lib/kubelet/config.yaml に追記
enableSystemLogHandler: true
enableSystemLogQuery: true
kubelet再起動後、次のようにkubectl get --rawでkubeletのログを直接取得できました。
$ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&tailLines=5"
Sep 30 06:29:29.628900 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.628821 ... "Finished populating initial desired state of world"
Sep 30 06:29:29.644381 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.644327 ... "operationExecutor.VerifyControllerAttachedVolume started for volume \"xtables-lock\"..." pod="kube-system/kube-proxy-jqk52"
patternクエリパラメータで正規表現による絞り込みもできます。
$ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&pattern=broadcasted&tailLines=200"
Sep 30 06:07:32.248053 k8s-logging-demo-control-plane kubelet[244]: I0930 06:07:32.248014 244 server.go:177] "Pod update broadcasted" podUID="aec712fda8c04555eaada4085f170287" type="ADDED"
queryにはサービス名だけでなく/var/log配下のファイル名も指定でき、kubeletはjournaldを優先的に探し、無ければ/var/log/<servicename>等を探すヒューリスティックで動作します(参考: System Logs#Log query)。
:::note warn
nodes/proxyへのget権限は、Log Query以外にもノード上で任意のコンテナに対してコマンドを実行できる強力なkubelet APIへのアクセスも同時に許可してしまいます。権限を付与する際はKubelet認証・認可のドキュメントにある注意点を確認しておくのが良いと思います。
:::
第4章: EventとAudit Log
EventとAudit Logは、それぞれ別の記事で扱っているのでそちらを参照してください。
4.1 Event
Podのスケジューリング失敗やヘルスチェック失敗など、Kubernetesの各コンポーネントが状態変化に応じて発行するAPIオブジェクトです。kubectl describe podやkubectl get eventsで確認できますが、デフォルトで1時間(--event-ttl)しか保持されません。Reasonの種類ごとの詳しい整理は、以前書いた記事を参照してください。
参考: Kubernetesのeventログの一覧
Eventを長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを用意します。その実装例として、eventrouterを見てみます。手元で確認したクローンは、Deploymentを使ってクラスタ全体で1つだけPodを用意し、そのPod内のプロセスがSharedInformerでEvent APIをwatchする実装でした。
func NewEventRouter(kubeClient kubernetes.Interface, eventsInformer coreinformers.EventInformer) *EventRouter {
er := &EventRouter{
kubeClient: kubeClient,
eSink: sinks.ManufactureSink(),
}
eventsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: er.addEvent,
UpdateFunc: er.updateEvent,
DeleteFunc: er.deleteEvent,
})
er.eLister = eventsInformer.Lister()
er.eListerSynched = eventsInformer.Informer().HasSynced
return er
}
(参考: eventrouter.go#L50-L63、Informer自体の生成元はmain.go#L112-L113)
受け取ったEventの転送先(sink)は、設定で切り替えられる作りになっています。
func ManufactureSink() (e EventSinkInterface) {
s := viper.GetString("sink")
switch s {
case "glog":
e = NewGlogSink()
case "stdout":
e = NewStdoutSink()
case "http":
// ...
case "kafka":
// ...
}
return e
}
(参考: sinks/interfaces.go#L35-L82)
リポジトリに同梱されているKubernetesマニフェストでは、このsinkがstdoutに設定されています。
apiVersion: v1
data:
config.json: |-
{
"sink": "stdout"
}
kind: ConfigMap
(参考: yaml/eventrouter.yaml)
stdout sinkの実装は、EventをJSONにシリアライズして標準出力に書き出すだけです。コード中のコメントに、その狙いが明記されています。
// StdoutSink is the other basic sink
// By default, Fluentd/ElasticSearch won't index glog formatted lines
// By logging raw JSON to stdout, we will get automated indexing which
// can be queried in Kibana.
(参考: sinks/stdoutsink.go#L27-L46)
つまりEvent専用の転送手段を別途実装するのではなく、第5章・第6章で見たコンテナログ収集と同じ「標準出力に書く→ノードレベルエージェントが拾う」という仕組みにそのまま乗せる設計です。eventrouterのPod自体が標準出力にJSONを書き出すだけで、既存のFluent BitなどのDaemonSetがいつも通り収集し、Eventの長期保存が実現できます。
4.2 Audit Log
「誰が・いつ・何を・どうしようとしたか」というAPIリクエストそのものを記録する、kube-apiserverの仕組みです。Eventとは異なるaudit.k8s.ioという専用のAPI群で表現され、Policyでログの粒度や出力先(ファイル/webhook)を細かく制御できます。設定方法や実際に記録される内容については、以前書いた記事を参照してください。
参考: Kubernetesの監査ログ(Audit Log)入門
第5章: ログの収集方法
ノード上に散らばったログを、クラスタ全体で一元的に扱うための収集方法を整理します。
5.1 3つのアーキテクチャパターン
公式ドキュメントは、クラスタレベルのログ収集について3つのアプローチを挙げています。
- アプリケーションから直接バックエンドへログを送る。
- アプリケーションPodに専用のsidecarコンテナを追加する。
- ノードごとに動くノードレベルのロギングエージェントを使う。
| パターン | 概要 | 向いているケース |
|---|---|---|
| アプリケーションから直接送信 | Kubernetesの管轄外 | アプリケーション側で送信ライブラリを組み込める場合 |
| sidecar(専用エージェント) | Fluentd等のロギングエージェント自体をPodにsidecarコンテナとして追加 | ノードレベルエージェントでは要件を満たせない場合。stdoutに書かない場合はkubectl logsでは読めない点に注意 |
| ノードレベルエージェント(DaemonSet) | ノードごとに1つのエージェントが、そのノード上の全コンテナのログファイルを収集 | アプリケーション側の変更が不要。最も一般的 |
5.2 アプリケーションから直接送信する
アプリケーション自身がログ送信用のライブラリを組み込み、ノードのエージェントを経由せず直接バックエンドへ送信する方法です。Kubernetes自体の管轄外の構成になります。
!Exposing logs directly from the application
アプリケーションがノードやKubernetesを経由せず、直接ログバックエンドへ送信する構成
(参考: Logging Architecture#Exposing logs directly from the application)
5.3 専用のロギングエージェントをPodにsidecarとして追加する
ノードレベルのエージェントでは要件を満たせない場合、Fluentd等のロギングエージェントをPodにsidecarコンテナとして追加し、アプリケーションコンテナのログを直接処理させる方法もあります。
!Sidecar container with a logging agent
専用のロギングエージェントをPodにsidecarコンテナとして追加し、直接バックエンドへ転送する構成
(参考: Logging Architecture#Sidecar container with a logging agent)
5.4 ノードレベルエージェントによるDaemonSetでの収集
最も一般的なのは、Fluent BitやOpenTelemetry CollectorのようなロギングエージェントをDaemonSetとしてすべてのノードにデプロイし、各ノードの/var/log/podsをhostPathでマウントして読む方法です。
!Using a node logging agent
ノードごとに1つのロギングエージェントを配置し、そのノード上の全コンテナのログファイルを収集する構成
(参考: Logging Architecture#Using a node logging agent)
DaemonSetにする理由は単純で、ノードごとに1つのエージェントだけを常駐させれば、そのノード上で動く全Podのログを横断的に拾えるためです。アプリケーション側のマニフェストを変更する必要がありません。
個人的には、まずこの方法から始めるのが良いと思います。アプリケーション側に手を入れる必要がなく、クラスタ側にDaemonSetを1つ用意するだけで、既存のワークロードも含めて横断的にログを収集できるためです。
第6章: ログ収集の実装例: Fluent BitとOpenTelemetry Collector
6.1 ログを収集して、蓄積する場合の例
ログを収集して蓄積する一例として、ここではノードレベルエージェントにFluent Bit・OpenTelemetry Collector・Vector、転送先にGrafana Lokiを使う構成を見ていきます。
- Fluent Bit (C言語, Apache License 2.0)
- ロギングエージェント。CNCF(Cloud Native Computing Foundation)のプロジェクトで、複数のコントリビューターが開発しています(参考: README.md#Authors)。in_tail・filter_kubernetes・out_lokiを使います。
- OpenTelemetry Collector (Go言語, Apache License 2.0)
- ロギングエージェント。同じくCNCFのプロジェクトです(参考: OpenTelemetry)。Collectorのメンテナーは特定の1社が全体の4分の1を超えて占めないようにする規約があり、複数ベンダーのコントリビューターがバランスを取りながら開発しています(参考: README.md#No Over-Representation)。filelog receiver・container operator・k8sattributesprocessor・otlphttp exporterを使います。
- Vector (Rust言語, Mozilla Public License 2.0)
- ロギングエージェント。主にDatadogが開発しています(参考: README.md#What is Vector?)。設定ファイル自体にvector testでユニットテストを書けるように設計されているのが特徴で(参考: Unit tests)、Kubernetes向けに専用化されたkubernetes_logs sourceと、Loki向けのloki sinkを使います。
- Grafana Loki (Go言語, AGPL-3.0-only)
- 収集したログの転送先・蓄積先(一部ファイルはApache License 2.0の例外あり、参考: README.md#License)。主にGrafana Labsが開発しています。Prometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別します。
ロギングエージェントの実装には、大きく分けて次の工程があります。
- ログローテーションへの追従
- CRIログの再構成
- Podメタデータの付与
- Grafana Lokiへの転送
- チェックポイントの永続化
このうち最初の3つは第2章・第3章の内容と直接関わってきます。それぞれについて、Fluent Bit・OpenTelemetry Collector・Vectorがどういう考え方で実装しているかをソースコードを確認しながら見ていきます。続いて転送先であるGrafana Lokiへの送り方の違いを確認し、最後にDaemonSetの更新などでPodが入れ替わっても読み取り位置を引き継げるようにする、チェックポイントの永続化の仕組みも見ていきます。
6.2 ログローテーションへの追従
第2章2.4で見たとおり、kubeletは現在のログファイルをrenameしてから、CRIランタイムに元のパスへ新しいファイルを開き直させます。ログエージェントが単純にファイルパスだけをポーリングしていると、リネームの前後で「今開いているファイル」と「そのパスにある実際のファイル」がずれてしまい、ログを取りこぼす可能性があります。
Fluent Bitの場合
Fluent Bitのin_tailは、この問題をinode比較で解決しています。監視中のパスを定期的にstatし、記録しているinodeと食い違っていればローテーションされていると判断します。
/* Returns FLB_TRUE if a file has been rotated, otherwise FLB_FALSE */
int flb_tail_file_is_rotated(struct flb_tail_config *ctx,
struct flb_tail_file *file)
(参考: plugins/in_tail/tail_file.c#L2154-L2226、ローテーション後も元のファイルディスクリプタで読み切ってから新しいファイルを追加で監視する処理は同ファイルL2432-L2496)
パスのinodeが変わらないまま存在し続けるファイルは同一ファイルとして読み続け、リネーム後に元のパスに現れたinodeの異なるファイルは新規ファイルとして扱われます。「ファイルパスだけでは同一性を保証できない」というkubeletのローテーション方式に対応するための設計で、inodeをそのまま使う直接的なアプローチです。
OpenTelemetry Collectorの場合
filelog receiverの実体はpkg/stanza/fileconsumerパッケージです。ログローテーションへの追従方式はFluent Bitのinode比較とは異なり、ファイル冒頭1000バイト(デフォルト)のチェックサムをフィンガープリントとして使っています。
// Fingerprint is used to identify a file
// A file's fingerprint is the first N bytes of the file
type Fingerprint struct {
firstBytes []byte
key string
}
(参考: pkg/stanza/fileconsumer/internal/fingerprint/fingerprint.go#L20-L29)
フィンガープリントが完全一致すれば同一ファイル、新しいフィンガープリントが古いフィンガープリントで始まっていれば「書き込みが続いている同じファイル」と判定します。
func (f Fingerprint) StartsWith(old *Fingerprint) bool {
l0 := len(old.firstBytes)
// ...
return bytes.Equal(old.firstBytes[:l0], f.firstBytes[:l0])
}
(参考: 同ファイルL93-L113、ポーリングループとフィンガープリントの突き合わせはpkg/stanza/fileconsumer/file.go#L139-L256)
inodeはPOSIX固有の概念のため、Windowsでは使えません。Windowsでも共通の仕組みで動作するように、この方式になっているようです(参考: design.md#Potential failure to consume files when file rotation via move/create is used on Windows)。
Vectorの場合
Vectorも中身はフィンガープリント方式ですが、DevInode(デバイス番号+inode)とFirstLinesChecksum(先頭数行のCRC64チェックサム)の2方式を切り替え可能な設定として両方持っています。
#[derive(Debug, Clone)]
pub enum FingerprintStrategy {
FirstLinesChecksum {
ignored_header_bytes: usize,
lines: usize,
},
DevInode,
}
(参考: lib/file-source-common/src/fingerprinter.rs#L46-L53)
そのうえで、Kubernetesのコンテナログを読むkubernetes_logs sourceはFirstLinesChecksumの方を明示的に選んでいます。コメントにその理由が書かれており、「Kubernetesのログファイルの形は決まっているので、専用に調整したフィンガープリンターを使う」としています。
// The shape of the log files is well-known in the Kubernetes
// environment, so we pick the a specially crafted fingerprinter
// for the log files.
fingerprinter: Fingerprinter::new(
FingerprintStrategy::FirstLinesChecksum {
// Max line length to expect during fingerprinting, see the
// explanation above.
ignored_header_bytes: 0,
lines: fingerprint_lines,
},
resolved_max_line_bytes,
true,
),
(参考: src/sources/kubernetes_logs/mod.rs#L885-L895、fingerprint_linesのデフォルトは1行分です。同ファイルL1125-L1127)
「専用に調整した」の中身は、FirstLinesChecksumという汎用の仕組みに渡している2つの値です。
- ignored_header_bytes: 0: 読み飛ばすヘッダーが無いという指定です。CRIログの各行はヘッダーを持たず、いきなりタイムスタンプから始まるため、0で固定できます。
- lines: fingerprint_lines(デフォルト1行): 指紋に使う行数です。CRIログは1行1エントリの形式だと分かっているため、1行だけで十分に識別できます。
実際の読み取りは、ファイルの先頭からlines回目(デフォルトでは1回目)の改行が見つかった時点で打ち切り、そこまでのバイト列をCRC64でハッシュ化したものをフィンガープリントとします。
for (pos, &c) in buf[..read].iter().enumerate() {
if c == delim {
if count <= 1 {
total_read += pos + 1;
break 'main;
} else {
count -= 1;
}
}
}
(参考: fingerprinter.rs#L247-L274)
さらに、1行目を読み込む最大サイズ(resolved_max_line_bytes)もデフォルトで32KBです(設定で変更可能)。CRIログの各行は必ずタイムスタンプから始まるため、たとえ1行目が32KBで打ち切られても、ファイルを識別する指紋としては十分な情報が確保できます。
このフィンガープリントは、ファイルパスとは別に、監視対象を管理するハッシュマップのキーとして使われます。ポーリングのたびに見つけたファイルのフィンガープリントを計算し、既存のキーと一致すれば同じファイル、一致しなければ新しいファイルとして扱います。OpenTelemetry CollectorのStartsWith(前方一致)のような特別な比較ロジックは無く、単純なハッシュマップの検索で済んでいます。改行を見つけた時点でハッシュが確定し、その後ファイルがどれだけ成長しても1行目の内容が変わらない限りフィンガープリントも変わらないためです。
if let Some(watcher) = fp_map.get_mut(&file_id) {
// file fingerprint matches a watched file
// ...
} else {
// untracked file fingerprint
self.watch_new_file(path, file_id, &mut fp_map, &checkpoints, false).await;
}
(参考: file_server.rs#L189-L234)
OpenTelemetry Collectorが「inodeのようなOS依存の概念は避ける」という一般方針のもと、ファイルの中身によらず固定の1000バイトという汎用的な値をフィンガープリントに使っているのに対し、Vectorは汎用のfile sourceではinode方式も選べるようにしたうえで、「Kubernetesのログ形式は決まっている」という前提が成り立つ場合に限り、行数・行長をCRI形式に合わせて決め打ったチェックサム方式を選んでいる、という違いがあります。
6.3 CRIログの再構成
第2章2.3で見たP(partial)/F(full)タグは、エージェント側で正しく結合しないと1つの長い行が複数行に分断されたまま扱われてしまいます。
Fluent Bitの場合
Fluent Bitはこれを専用のCマルチラインパーサーとして実装しており、_pフィールドがFになるまでlogフィールドの内容を連結し続けます。利用する側はmultiline.parser criを明示的に指定する必要があります。
#define FLB_ML_CRI_REGEX \
"^(?<time>.+?) (?<stream>stdout|stderr) (?<_p>F|P) (?<log>.*)$"
(参考: src/multiline/flb_ml_parser_cri.c#L24-L74)
汎用のマルチラインパーサー機構にCRI用の定義を1つ追加する、という組み込み方です。
OpenTelemetry Collectorの場合
filelog receiverではtype: containerというoperatorを指定するだけで、ログの中身からDocker JSON・cri-o・containerdのどの形式かを自動判定します。
switch {
case dockerMatcher.MatchString(raw):
return dockerFormat, nil
case crioMatcher.MatchString(raw):
return crioFormat, nil
case containerdMatcher.MatchString(raw):
return containerdFormat, nil
}
(参考: pkg/stanza/operator/parser/container/parser.go#L292-L311)
P(partial)/F(full)タグの再結合も、logtag == 'F'になるまで束ねるrecombineという内部トランスフォーマーが自動的に動くため、追加設定は不要です。
recombineSourceIdentifier = attrs.LogFilePath
recombineIsLastEntry = "attributes.logtag == 'F'"
(参考: pkg/stanza/operator/parser/container/config.go#L23-L24)
CRIログを扱うための専用operatorとして、形式判定から再結合までを1つにまとめている点が、汎用パーサーにCRI向けの定義を追加するFluent Bitとの違いです。
Vectorの場合
kubernetes_logs sourceも、まずログの先頭1バイトだけを見て{ならDocker JSON形式、そうでなければCRI形式と判定します。一度判定すると、以降はその形式用のパーサーに固定されます。
self.state = if bytes.len() > 1 && bytes[0] == b'{' {
ParserState::Docker(docker::Docker::new(self.log_namespace))
} else {
ParserState::Cri(cri::Cri::new(self.log_namespace))
};
(参考: src/sources/kubernetes_logs/parser/mod.rs#L66-L70)
P/Fタグの判定と、実際の結合は別の2つのコンポーネントに分かれています。CRI用パーサーはPタグの行に汎用的な_partialフィールドを立てるだけで、
// MULTILINE_TAG
// If the MULTILINE_TAG is 'P' (partial), insert our generic `_partial` key.
if parsed_log.multiline_tag[0] == b'P' {
self.log_namespace.insert_source_metadata(
Config::NAME,
log,
Some(LegacyKey::Overwrite(path!(event::PARTIAL))),
path!(event::PARTIAL),
true,
);
}
(参考: src/sources/kubernetes_logs/parser/cri.rs#L77-L89)
実際の結合は後段のpartial_events_mergerが、ファイルごとのバケツにメッセージを貯めてバイト列として連結する形で行います(肥大化防止の上限・切り詰め処理つき)。
(参考: src/sources/kubernetes_logs/partial_events_merger.rs#L28-L80)
OpenTelemetry Collectorが形式判定と再結合を1つのoperatorにまとめているのに対し、Vectorは「タグを見て印を付ける」パーサーと「印に応じて結合する」マージャーに役割を分けている点が対照的です。
6.4 Podメタデータの付与
/var/log/podsのディレクトリ名(<namespace>_<name>_<uid>)や、/var/log/containersのシンボリックリンク名(<pod名>_<namespace>_<コンテナ名>-<コンテナID>.log)からは、Podのnamespace・名前・コンテナ名を読み取れますが、ラベルやannotationはここには含まれません。
Fluent Bitの場合
filter_kubernetesは、まずこのファイル名を正規表現でパースし、その後kube-apiserverに問い合わせてPodのメタデータを取得します(設定でkubeletへの問い合わせに切り替えることもできますが、namespaceの情報はAPIサーバーからしか取得できません)。ログ行1件ごとに問い合わせるとAPIサーバーへの負荷が大きくなるため、namespace・Pod名・コンテナ名から作ったキャッシュキーで結果を再利用します。
(参考: ファイル名の正規表現はplugins/filter_kubernetes/kube_regex.h#L25、APIサーバーへの問い合わせはplugins/filter_kubernetes/kube_meta.c#L607-L637、キャッシュキーの構成は同ファイルL1910-L1995)
ファイル名パースとAPIサーバーへの問い合わせを1つのfilterプラグインの中にまとめて持たせる、フィルタチェーンの1段として組み込む設計です。
OpenTelemetry Collectorの場合
container operatorは、include_file_path: trueでログの実ファイルパスを取得できる状態にしておけば、デフォルト(add_metadata_from_filepath: true)でファイルパスからk8s.namespace.name/k8s.pod.name/k8s.container.name/k8s.pod.uidをその場で抽出します。Fluent Bitのfilter_kubernetesと違い、この時点ではAPIサーバーへの問い合わせが一切発生しません。
k8sMetadataMapping = map[string]string{
"container_name": "k8s.container.name",
"namespace": "k8s.namespace.name",
"pod_name": "k8s.pod.name",
"restart_count": "k8s.container.restart_count",
"uid": "k8s.pod.uid",
}
(参考: pkg/stanza/operator/parser/container/parser.go#L51-L57、デフォルト値はconfig.go#L42)
:::note warn
このファイルパスからの抽出は、正規表現が/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/<再起動回数>.logという第2章2.1で見た構成を前提にしています。/var/log/containers/側のシンボリックリンクを指定すると、パースに失敗してメタデータが一切付与されません。
:::
ただしcontainer operator単体で取れるのはファイルパス由来の基本属性だけで、Podのラベルやannotationは含まれません。実運用ではラベルでログを絞り込みたい場面が多く、ラベルが無いと検索基盤側での利用に支障が出るため、k8sattributesprocessorを組み合わせるのがほぼ標準的な構成になります。こちらはFluent Bitのfilter_kubernetesと同じく、APIサーバーをSharedInformerでwatchしてローカルにPodを同期させる方式です。
informer := cache.NewSharedInformer(
&cache.ListWatch{
ListWithContextFunc: informerListFuncWithSelectors(client, namespace, ls, fs),
WatchFuncWithContext: informerWatchFuncWithSelectors(client, namespace, ls, fs),
},
// ...
)
(参考: processor/k8sattributesprocessor/internal/kube/informer.go#L54-L63、Podとの紐付けはk8s.pod.uid等のresource属性をキーに行います。pod_association.go#L20-L69)
まずファイルパスという軽量な情報源から基本属性を抽出し、APIサーバーへの問い合わせが要るリッチな情報は別のprocessorに任せる、という段階的な構成です。receiverとプロセッサーを分けてパイプラインを組み立てるOpenTelemetry Collectorらしい役割分担だと言えます。
Vectorの場合
kubernetes_logs sourceは、ファイルパスのパースとAPIサーバーからの情報付与を1つの処理の中で両方行います。まず/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/...というパスをパースして基本情報を取り出し、
pub(super) fn parse_log_file_path(path: &str) -> Option<LogFileInfo<'_>> {
let mut components = path.rsplit(std::path::MAIN_SEPARATOR);
let _log_file_name = components.next()?;
let container_name = components.next()?;
let pod_dir = components.next()?;
let mut pod_dir_components = pod_dir.rsplit(LOG_PATH_DELIMITER);
let pod_uid = pod_dir_components.next()?;
let pod_name = pod_dir_components.next()?;
let pod_namespace = pod_dir_components.next()?;
// ...
}
(参考: src/sources/kubernetes_logs/path_helpers.rs#L32-L52)
そのnamespace/Pod名をキーに、kube crateのreflector(APIサーバーをwatchしてローカルに同期させる、SharedInformerに相当する仕組み)が保持するPodのキャッシュを引きます。見つかればラベル・annotation・Pod spec/statusまで含めてフィールドを追加し、見つからなければ警告ログを出したうえでファイルパス由来の情報だけで処理を続行します。
pub fn annotate<'a>(&self, event: &mut Event, file: &'a str) -> Option<LogFileInfo<'a>> {
let file_info = parse_log_file_path(file)?;
let obj = ObjectRef::<Pod>::new(file_info.pod_name).within(file_info.pod_namespace);
let resource = self.pods_state_reader.get(&obj);
annotate_from_file_info(log, &self.fields_spec, &file_info, self.log_namespace);
let Some(resource) = resource else {
warn!(message = "Pod not found in API store, annotating from file path only.", ...);
annotate_from_file_path(log, &self.fields_spec, &file_info, self.log_namespace);
return Some(file_info);
};
// ラベル・annotation・Pod spec/statusまで付与
// ...
}
(参考: src/sources/kubernetes_logs/pod_metadata_annotator.rs#L206-L238)
Fluent BitとOpenTelemetry Collectorがそれぞれ「ファイルパス解析」と「APIサーバー問い合わせ」を別々のプラグイン・processorとして組み合わせる設計なのに対し、Vectorは1つのkubernetes_logs sourceの中に両方を組み込み、APIサーバー側の情報がまだ無い場合はファイルパス由来の情報だけで処理を続ける、というフォールバックまで内蔵しています。
6.5 Grafana Lokiへの転送
収集したログの転送先には、ログ専用のデータベースであるGrafana Lokiがよく使われます。LokiはPrometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別する設計です。
Fluent Bitの場合
out_lokiという出力プラグインがF
4 likes
Mentions
Want to practise Kubernetes without paying for a cloud cluster? kubernetes-sigs/kind (15.5k ★) runs a local cluster inside Docker. go install sigs.k8s.io kind create cluster kubernetes-sigs/kind What's the first app you'd deploy on it? t.co
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,
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
An Overview of Agent Substrate A Kubernetes Pod is a useful unit of deployment. An agent session has a different lifetime: it may run briefly, wait for minutes, then need its process and filesystem state back for the next turn. Keeping a dedicated environment resident through every wait can consume capacity that other sessions could use. Agent Substrate separates those lifetimes. Kubernetes runs a pool of worker Pods; Substrate places sandboxed agent environments onto them, persists their state, and restores them when work arrives. This article follows one request through that system, then examines the components and a Counter demo you can try locally. TL;DR - Kubernetes manages Workers; Substrate manages Actors. Actor state and assignments live behind a separate API backed by PostgreSQL. - Workers can serve many Actors over time. Capacity-based concurrency is combined with suspend/resume to reuse compute. - atelet and ateom save and restore sandbox state. They use gVisor or microVMs; the snapshot fidelity determines what is preserved. - Credentials can stay outside the Actor. An egress gateway and credential provider can inject API credentials into outgoing requests. - Check the implemented behavior, not just the design. Authorization enforcement is experimental and disabled by default for ordinary Control RPCs. Idle detection and performance claims also require context. Implementation details target Agent Substrate v0.5.0. See [Components and versions](#components-and-versions) for the full scope. This walkthrough assumes familiarity with Pods, Deployments and basic Kubernetes networking. Start with the [request lifecycle](#follow-a-request-into-a-suspended-actor), jump to the [kind demo](#try-the-counter-demo-with-kind), or use the [evaluation questions](#what-to-evaluate-in-your-own-workload) to assess the fit for your platform. The design decision: keep the workers, move the sessions An agent may execute code briefly, wait for inference or an external tool, then wait indefinitely for another user turn. Running untrusted code also creates a need for isolated environments, often with one tenant per sandbox. The combination matters: a large fleet can contain many more waiting environments than actively executing ones. Reserving CPU and memory for every waiting agent wastes capacity. Snapshot-based multiplexing is intended to let those environments retain state without remaining assigned to running compute. Source: docs/architecture.md#L17-L23. What Kubernetes still does—and what Substrate moves elsewhere The architecture document identifies three pressures behind its design: | Pressure | Why it matters for agents | |:---|:---| | API resource count and write traffic | Millions of frequently changing agent records are a different workload from ordinary Kubernetes reconciliation | | Activation latency | Pod scheduling, reconciliation, network hops and image preparation add overhead to very short executions | | State movement | Rapidly saving and restoring many environments is a different problem from attaching conventional persistent volumes | These are the project's design judgments for its intended scale, rather than universal Kubernetes limits or a claim that every Pod takes seconds to start. Kubernetes still handles infrastructure provisioning and worker Pod lifecycles. Substrate introduces a separate control plane for Actor state, assignment and resumption, so an activation does not need to wait for a fresh Pod lifecycle. Sources: docs/architecture.md#L153, docs/architecture.md#L160-L172. The important boundary is the activation path: a new turn can restore an Actor onto an existing Worker without provisioning another Pod. Database write throughput, reconciliation and environment preparation all contribute to that choice. Follow a request into a suspended Actor The execution layer combines routing, state persistence and sandbox isolation: !Agent Substrate adds a gateway, state persistence and kernel isolation above Kubernetes, compute and storage. Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026). Two lifecycles, two APIs | Resource | Meaning | Managed through | |:---|:---|:---| | ActorTemplate | Immutable workload definition, including images and snapshot settings | Substrate API and database | | WorkerPool | Defines a pool that atecontroller reconciles into a Deployment | Kubernetes CRD | | Atespace | Logical grouping and identity scope for Actors; not a Kubernetes namespace | Substrate API and database | | Actor | An instance of an ActorTemplate and the unit of suspend/resume | Substrate API and database | | Worker | A record corresponding to a worker Pod; can host multiple Actors concurrently | Substrate API and database | Sources: docs/glossary.md#L10-L23, docs/glossary.md#L38-L52. An Actor is the stateful environment that belongs to a session. A Worker is the reusable execution capacity. Suspending an Actor releases its assignment; it does not require destroying and recreating the worker Pod. Worker capacity can also permit several Actors to run concurrently. Restore first, forward second For an HTTP request targeting a suspended Actor, the activation path has four steps: 1. The router identifies the Actor from ate-target-actor. 2. The control plane assigns a Worker and coordinates restoration of the Actor's snapshot. 3. Once the assignment is ready, the router forwards the request through the Worker's tunnel to the Actor. 4. The Actor handles the request and returns its response. For an Actor that is already running, ResumeActor returns the existing assignment. The call is idempotent. Source: workflow_resume.go. Returning a response does not automatically suspend the Actor. The application or higher-level system must choose a safe suspension boundary, taking account of outstanding work and network calls. Suspend and resume !Active agent environments on Workers exchange state with idle environments persisted in storage through suspend/resume. Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026). Here, “idle” means state has been persisted and the environment removed from running compute, rather than a process simply sleeping. The illustration is conceptual: it does not imply a fixed one-to-one relationship between Workers and Actors. When a request arrives, atenet-router asks the control plane to resume the target Actor and resolve its Worker assignment. A suspended Actor is restored from its latest snapshot before traffic is forwarded. It may resume on a different Worker. The router itself does not decide whether the Actor is running; that decision belongs to the control plane. Suspending the Actor saves state and releases its assignment for reuse. Source: docs/architecture.md#L193-L204. What runs where | Component | Responsibility | Relationship to Kubernetes | |:---|:---|:---| | ate-api-server (ateapi) | Actor lifecycle, scheduling and snapshot coordination | Separate API, scheduler and workflow layer backed by PostgreSQL | | atecontroller | Reconciles WorkerPool and related CRDs | Standard controller-runtime controller | | atelet / ateom | Coordinates and executes sandbox checkpoint/restore | Additional execution layer inside worker Pod boundaries | | atenet router and egress | Resumption-aware ingress and controlled outbound traffic | Routing plus Actor lifecycle coordination | | podcertcontroller | Issues short-lived certificates for component mTLS | Signer controller for PodCertificateRequest | The threat-model diagram shows how these components connect: !Substrate architecture showing ate-api-server, atelet, ateom, atenet-router, object storage and PostgreSQL. Source: docs/threat-model.md#L30-L40. {% details Repository map: controllers, sidecars and tools %} The repository also includes supporting programs: | Directory | Role | |:---|:---| | cmd/ateapi | gRPC control plane | | cmd/atecontroller | Kubernetes controllers | | cmd/atelet | Node supervisor deployed as a DaemonSet | | cmd/ateom-gvisor | Drives gVisor inside worker Pods | | cmd/ateom-microvm | Runs Actors with Cloud Hypervisor microVMs | | cmd/objectstore-plugin | Snapshot transfer sidecar for node and control-plane operations | | cmd/atenet | Networking daemon and proxy integration | | cmd/dataplane/envoy | Envoy image with Rust Dynamic Modules for egress enforcement | | cmd/credential-provider/kubernetes-secrets | Reference credential provider backed by Kubernetes Secrets | | cmd/podcertcontroller | Pod certificate signers | | cmd/kubectl-ate | Actor and other Substrate resource operations | | cmd/ate-setup | Installation and removal | | cmd/benchmarking | Synthetic resource-consuming test workloads | | tools/setup-gcp | GKE, storage and IAM provisioning | The README's tour is not an exhaustive inventory; the sidecar, provider, dataplane and setup entries are supplemented from their own source and documentation. Sources: README.md#L260-L272, cmd/objectstore-plugin/main.go#L15-L19, cmd/ate-setup/README.md#L3, cmd/dataplane/envoy/README.md. {% enddetails %} Resource ownership and assignment !Agent Substrate between the agent application layer and Kubernetes, with sandbox isolation and storage beneath it. Source: Bringing you Agent Sandbox on GKE and Agent Substrate, Google Cloud Blog (May 20, 2026). The green sandbox-isolation block is the foundation for the checkpoint and restore operations. Agent Substrate incorporates Agent Sandbox's secure runtime and snapshot capabilities into a separate execution layer. classDiagram namespace kube-apiserver { class WorkerPool { <<CRD>> } class Deployment class WorkerPod { ateom runsc } } namespace ate-api-server { class ActorTemplate { <<record>> } class Actor { <<record>> status snapshotRefs } class Worker { <<record>> actorAssignments podIP } } ActorTemplate ..> WorkerPool : worker_selector (labels) WorkerPool ..> Deployment : reconciled by atecontroller Deployment "1" *-- "*" WorkerPod : manages Actor "*" --> "1" ActorTemplate : actor_template Actor "0..*" --> "0..1" Worker : runs on Worker "1" --> "1" WorkerPod : maps to Adapted from docs/architecture.md#L268-L303; the multiplicity reflects the implementation's support for multiple Actors per Worker. The split between API servers is central. Infrastructure resources stay in Kubernetes, while frequent Actor/Worker assignment changes go through ate-api-server and its state store. Two resource layers The architecture gives three reasons for this split: - Keep high-frequency Actor updates away from the Kubernetes control plane. - Use low-latency lookups and atomic assignments for resumption. The document's 100ms figure is a design target, not a result measured here. - Retain Kubernetes RBAC, auditing and policies for infrastructure resources such as WorkerPools and SandboxConfigs. One detail in that document is stale: it says authorization has not been implemented. The same v0.5.0 tag contains OpenFGA-based authorization. --experimental-enable-authz defaults to false and controls enforcement for ordinary Control RPCs. AccessPolicy RPCs are checked regardless of that flag. Sources: docs/architecture.md#L247-L261, cmd/ateapi/main.go#L84-L88, cmd/ateapi/internal/authz/interceptor.go#L28-L60. ate-api-server: the Actor control plane ateapi owns Actor lifecycles, schedules them onto Workers and coordinates snapshots. Its gRPC server connects to PostgreSQL, creates Kubernetes clients and configures its mTLS credentials. Sources: docs/glossary.md#L56-L58, cmd/ateapi/main.go#L509-L521, cmd/ateapi/main.go#L560-L586. templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv, *templateResyncInterval) templateReconciler.Start(shutdownCtx) workerAssignmentReconciler := controlapi.NewWorkerAssignmentReconciler(persistence, workerCache) workerAssignmentReconciler.Start(shutdownCtx) Source: cmd/ateapi/main.go#L326-L330. Despite their names, these reconcilers are internal Substrate loops rather than controller-runtime reconcilers watching custom resources. ActorTemplateReconciler prepares golden snapshots; WorkerAssignmentReconciler handles Actors lost when ateom restarts. This component does not map one-to-one to kube-apiserver: it combines Actor-specific API handling, scheduling and workflows. atecontroller: conventional Kubernetes reconciliation atecontroller uses ctrl.NewManager and registers four reconcilers, including WorkerPoolReconciler. Its core structure is familiar: func (r *WorkerPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := log.FromContext(ctx) wp := &atev1alpha1.WorkerPool{} if err := r.Get(ctx, req.NamespacedName, wp); err != nil { if k8errors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, fmt.Errorf("failed to get worker pool %q: %w", req.NamespacedName, err) } if !wp.GetDeletionTimestamp().IsZero() { log.Info("WorkerPool is being deleted") return ctrl.Result{}, nil } if err := r.reconcileWorkerPool(ctx, wp); err != nil { log.Error(err, "Failed to reconcile worker pool") return ctrl.Result{}, err } return ctrl.Result{}, nil } Sources: cmd/atecontroller/main.go#L233-L276, cmd/atecontroller/internal/controllers/workerpool_controller.go#L105-L129. Reconciliation does more than produce a Deployment. The WorkerPool reconciler protects referenced SandboxConfigs with finalizers; a separate SandboxConfigProtectionReconciler releases protection. Updating the recorded SandboxConfig uses two status writes so that status never references a configuration whose protection has already been released. Owns(&Deployment{}) and Watches(&SandboxConfig{}, ...) connect these resources through normal controller-runtime mechanisms. Sources: cmd/atecontroller/internal/controllers/workerpool_controller.go#L134-L136, cmd/atecontroller/internal/controllers/workerpool_controller.go#L416-L425. atelet and ateom: checkpoint and restore atelet runs on each node as a DaemonSet. ateom runs inside worker Pods and exposes operations such as RunWorkload, CheckpointWorkload and RestoreWorkload. atelet coordinates worker supervision, image caching and CSI volumes. It also reuses kubelet's image credential-provider mechanism through --image-credential-provider-config. Sources: docs/architecture.md#L324-L326, cmd/atelet/main.go#L103-L104. Snapshot upload and download are delegated to objectstore-plugin sidecars over a Unix-socket gRPC API: | Plugin role | Operations | |:---|:---| | NodeProvider, alongside atelet | Upload and fetch snapshot files | | ControlProvider, alongside ate-api-server | Copy and delete whole snapshots | The main processes do not directly transfer snapshots to object storage. This does not mean they hold no credentials at all: mTLS and database authentication remain separate concerns. Source: docs/architecture.md#L330. For gVisor, cmd/ateom-gvisor/runsc.go invokes runsc checkpoint and runsc restore. The microVM implementation uses Kata integration and Cloud Hypervisor, with userfaultfd demand paging when restoring memory. Sources: docs/architecture.md#L328-L338. Kubelet and the container runtime still run the worker Pod itself. Substrate manages sandboxed Actors inside it. Kubernetes already has a Kubelet Checkpoint API; Substrate's distinction is coordinating persistence, assignment, restore and reconnection as an Actor lifecycle. atenet: routing that can resume its destination Ingress requests identify an Actor with the ate-target-actor header, formatted as <atespace>/<actor>. In the Envoy configuration described here, an ext_proc processor calls the control plane's ResumeActor RPC before resolving the destination. Once the assignment is available, the router opens an mTLS CONNECT tunnel to the Worker's atunnel listener on port 8443. atunnel, hosted by ateom, forwards traffic over the Actor's private veth interface. Worker Pod port 80 is not a direct Actor ingress endpoint. Sources: docs/architecture.md#L344-L354. The Kubernetes analogy is Service/Ingress/Gateway routing with an extra orchestration step: restore the destination if necessary before forwarding to it. podcertcontroller: Pod certificate signers podcertcontroller provides three signers: | Signer | Purpose | |:---|:---| | servicedns.podcert.ate.dev/identity | Service DNS certificates backed by a local CA | | podidentity.podcert.ate.dev/identity | Pod identity certificates equivalent to Kubernetes service account identity | | postgres.podcert.ate.dev/identity | PostgreSQL login certificates for ate-api-server | Source: cmd/podcertcontroller/main.go#L15-L22. It watches certificates.k8s.io/v1 PodCertificateRequest objects. The API became GA in Kubernetes v1.37: PodCertificateRequest: { {Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha}, {Version: version.MustParse("1.35"), Default: false, PreRelease: featuregate.Beta}, {Version: version.MustParse("1.37"), Default: true, PreRelease: featuregate.GA}, // LockToDefault in 1.38 }, Source: kube_features.go. The API lets kubelet request certificates and project them into Pods. A signer must still process those requests. The well-known kubernetes.io/kube-apiserver-client-pod signer described in the API comments is unimplemented in this version. Substrate fills its own needs with the three signers above. Its README calls the controller a “polyfill” for the missing signers. Sources: certificate API types, README.md#L268-L269. Keep session state and API credentials separate Isolation, identity and credentials are separate controls !The agent environment combines identity-based ingress and egress, secrets management, kernel isolation and rapid suspend/resume. Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026). Kernel isolation does not by itself protect external API credentials. That also requires the gateway and credential-provider configuration described below. The security document distinguishes Met, In Progress, Future and Out of Scope. Selected principles in v0.5.0 are: | Principle | Meaning | Documented status | |:---|:---|:---| | Always Sandboxed | Run Actors in gVisor or Cloud Hypervisor microVMs rather than relying on ordinary Linux containers alone | Met | | Zero Ambient Credentials | Do not implicitly give Actors infrastructure permissions or external API keys | Met; distinct from demos that explicitly pass keys in environment variables | | No Access by Default | Restrict Actor access to the control plane, separate from required infrastructure traffic | Loopback/private netns: Met; DNS filtering: In Progress | | Fail-Closed Policy-Enforced Egress Gateway | Control outbound traffic through an egress proxy | Gateway: Met; fail-closed behavior: In Progress, with UDP passthrough noted | Sources: docs/security.md#L30-L33, docs/security.md#L41, docs/security.md#L59, docs/security.md#L75-L77. Warning: The same document lists Atespace authorization as In Progress, and node blast-radius containment and storage encryption as Future. The security architecture is not evidence that every control is fully implemented. Ordinary Control RPC authorization still requires the experimental flag. Inject credentials outside the Actor With egress credential injection, an Actor sends an HTTPS request containing a placeholder header. The gateway obtains the real credential and substitutes it outside the sandbox: 1. The Actor sends the request with a placeholder value. 2. The egress gateway terminates TLS and matches an EgressPolicy header-replacement rule. 3. The credential provider authorizes access and reads the secret. 4. The gateway replaces the header and makes a separate TLS connection to the destination. The Actor must trust the gateway's CA. If the request does not contain the header to replace, credential lookup and replacement do not occur. This keeps the API key outside Actor memory and snapshots. It does not prevent misuse of an allowed API; destination restrictions and application authorization still matter. Sources: docs/egress-credential-injection.md#L1-L15, docs/egress-credential-injection.md#L41-L54. The reference kubernetes-secrets provider starts with an empty deny-all policy. Readable namespaces must be explicitly allowed for each Atespace. Actors do not read Kubernetes Secrets themselves. Source: README.md#L132-L136. EgressPolicy defines destinations and header replacement An EgressPolicy specifies allowed traffic and header replacement rules. The credential provider separately decides whether the Actor can use the referenced secret. The API defines the policy as an unordered set of allows: traffic matching no rule is denied. Source: pkg/proto/ateapipb/ateapi.proto#L609-L610. For example, allow HTTPS to api.example.com and replace its Authorization header: rules: - https: hostnames: ["api.example.com"] effects: replaceHeaders: - header: Authorization prefix: "Bearer " credentialUri: ate-secret://k8s.io/default/ns1/example-api/token Source: docs/egress-credential-injection.md#L69-L76. The policy is associated with an Actor rather than embedded in its ActorTemplate: # Create an egress policy. kubectl ate create egress-policy <actor-name> -a <atespace> -f policy.yaml Source: cmd/kubectl-ate/README.md#L247-L253. Note: The Claude Code demo discussed later directly places ANTHROPIC_API_KEY in the ActorTemplate environment. That differs from gateway injection. In either case, the Actor needs an egress policy permitting its HTTPS destination, such as api.anthropic.com. The complete request path With routing and credential injection in place, an HTTP-triggered agent can resume and call an external LLM as follows. This combines the documented flows; Counter itself does not call an LLM. sequenceDiagram actor User as User / higher-level system participant Router as atenet-router participant API as ate-api-server participant Atelet as atelet participant Tunnel as ateom / atunnel participant Agent as Actor participant Egress as atenet-egress participant Cred as credential provider participant LLM as External LLM API User->>Router: HTTP request (ate-target-actor) Router->>API: ResumeActor alt Actor is suspended API->>Atelet: Restore request Atelet->>Tunnel: RestoreWorkload Tunnel-->>Atelet: Restore complete Atelet-->>API: Complete else Actor is running Note over API: Return existing assignment end API-->>Router: Worker assignment Router->>Tunnel: Forward through mTLS CONNECT Tunnel->>Agent: Forward request Agent->>Egress: HTTPS (placeholder header) Egress->>Cred: Request credential Cred-->>Egress: Return API key after authorization Egress->>LLM: Replace header and send over a separate TLS connection LLM-->>Egress: Response Egress-->>Agent: Response Agent-->>Tunnel: Result Tunnel-->>Router: Response Router-->>User: Response Note over User,API: A higher-level system can request SuspendActor to save state and release the assignment Sources: Resume sequence, credential injection. Cold boot without a snapshot and other branches are omitted. The Actor-to-gateway and gateway-to-provider connections use separate TLS sessions. The gateway must terminate the first to replace headers in the second. Try the Counter demo with kind The Counter workload makes state preservation visible: each request increments counters in process memory, a file and the root filesystem. The example below uses gVisor and includes recorded output from a local kind run. Set up the cluster and demo Building v0.5.0 requires Go 1.27.0 and a working Docker-based development environment. The microVM option additionally requires /dev/kvm and suitable virtualization support. Sources: go.mod#L1-L5, hack/create-kind-cluster.sh#L74-L87. Use a dedicated checkout and cluster name. The creation script deletes and recreates an existing cluster with the same name, so choose one that does not overlap with an environment you want to keep. git clone --branch v0.5.0 --depth 1 \ https://github.com/agent-substrate/substrate.git substrate-v0.5.0 cd substrate-v0.5.0 export KIND_CLUSTER_NAME=ate-overview # create cluster and local registry (IPv4; IP_FAMILY=dual|ipv6 overrides) hack/create-kind-cluster.sh # install ate, PostgreSQL, rustfs hack/install-ate-kind.sh --deploy-ate-system \ --credential-provider='{"name":"k8s.io"}' # install counter demo hack/install-ate-kind.sh --deploy-demo-counter # install kubectl-ate go install ./cmd/kubectl-ate # create a counter actor in the demo's atespace (--template names the # actor template, resolved in the actor's atespace) kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter # port-forward the network router to bind to local port `8000` kubectl port-forward -n ate-system svc/atenet-router 8000:80 Source: README.md#L111-L129. The creation script sets up a local registry and a kind cluster with kindest/node:v1.37.0. It explicitly enables certificate-related feature gates, including ClusterTrustBundleProjection; some comments still describe v1.36 requirements. Source: hack/create-kind-cluster.sh#L104-L113. The system installation uses ko to build and push its Go components and deploys PostgreSQL, RustFS, CRDs and SandboxConfig resources. The Counter installation adds its WorkerPool and ActorTemplate and waits for a golden snapshot. WorkerPool and ActorTemplate The demo's WorkerPool includes the following settings. The installer resolves template placeholders and ko:// references before applying the configuration. apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: name: counter namespace: ate-demo-counter labels: workload: counter spec: replicas: 3 workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor sandboxClasses: - name: gvisor template: resources: limits: cpu: "1" memory: 1Gi requests: cpu: 250m memory: 1Gi Source: demos/counter/counter.yaml.tmpl#L26-L63. An ActorTemplate is not a Kubernetes object. This YAML maps to Substrate's protobuf fields and is passed through kubectl ate create actor-template: metadata: atespace: ate-demo-counter name: counter workerSelector: matchLabels: workload: counter containers: - name: counter image: ko://github.com/agent-substrate/substrate/demos/counter wakeupProbe: httpGet: path: /readyz port: 80 volumeMounts: - name: data mountPath: /home/counter resources: limits: - name: cpu quantity: "1" - name: memory quantity: 512Mi snapshotConfig: preferredFidelity: SNAPSHOT_FIDELITY_MEMORY storageLocation: gs://${BUCKET_NAME}/ate-demo-counter/ sandboxConfig: sandboxClass: gvisor configName: gvisor-default volumes: - name: data durableDir: {} Source: demos/counter/counter-template.yaml.tmpl#L22-L69. SNAPSHOT_FIDELITY_MEMORY requests preservation of process memory. gvisor-default names a cluster-scoped SandboxConfig whose assets specify where to download the runtime binaries: apiVersion: ate.dev/v1alpha1 kind: SandboxConfig metadata: name: gvisor-default annotations: sandboxconfig.ate.dev/is-class-default: "true" spec: sandboxClass: gvisor defaultVersion: v3 versions: - name: v3 state: Enabled pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" assets: arm64: gvisor: url: "gs://gvisor/releases/nightly/2026-10-10/aarch64/gvisor.tar.zstd" sha256: "aaf414f06eea7960238e43165c691f0a36cefc0593fb141e011e96211c7b6f57" Source: manifests/ate-install/sandboxconfig-gvisor.yaml#L22-L48. Only the arm64 assets are shown; amd64 assets are omitted. Observe Actor state and preserved counters A newly created Counter Actor starts suspended: its snapshot exists, but it has no Worker assignment yet. kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter Recorded output: ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE ate-demo-counter my-counter-1 ate-demo-counter/counter ACTOR_STATE_SUSPENDED <none> 1 0s Send a request through the router. It resumes the Actor before forwarding, and the recorded response is HTTP 200: curl -X POST \ -H "ate-target-actor: ate-demo-counter/my-counter-1" \ -i http://localhost:8000/ Recorded output: HTTP/1.1 200 OK Content-Length: 111 Content-Type: text/plain; charset=utf-8 hello from: 169.254.17.2 | preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1 A second POST increments the counter to 2. The Actor is now running and assigned to a worker Pod: curl -X POST \ -H "ate-target-actor: ate-demo-counter/my-counter-1" \ http://localhost:8000/ kubectl ate get actor my-counter-1 -a ate-demo-counter Recorded output: ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE ate-demo-counter my-counter-1 ate-demo-counter/counter ACTOR_STATE_RUNNING ate-demo-counter/counter-796d677995-v55tv 10.244.0.23 3 20s Explicitly suspend it, then send another request: kubectl ate suspend actor my-counter-1 -a ate-demo-counter curl -X POST \ -H "ate-target-actor: ate-demo-counter/my-counter-1" \ http://localhost:8000/ Recorded output: hello from: 169.254.17.2 | preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3 The memory, file and root-filesystem counters continue at 3. This illustrates the documented snapshot-and-resume lifecycle, including preserved process memory. Stop the port-forward when finished, then remove the dedicated cluster. The kind-registry container is separate and remains after cluster deletion. hack/kind.sh delete cluster --name ate-overview Plan the deployment around more than Pods Worker Pods and CRDs are only part of the deployment. The following table distinguishes infrastructure that must be supplied from components the installer can deploy: | Dependency | Purpose | Installation considerations | |:---|:---|:---| | PostgreSQL | Actor/Worker state store | The kind setup includes a development database; production operators supply database identities, schema and connection settings | | GCS or S3-compatible object storage | Durable snapshot storage | Configure storage.bucketName / BUCKET_NAME; kind uses ate-snapshots and an in-cluster RustFS service | | Egress credential provider | Resolve credentials outside Actors | Provider selection is required, including explicit {"enabled":false} to disable injection; {"name":"k8s.io"} deploys the reference provider | | Sandbox runtime binaries | Run Actors with gVisor or microVMs | Selected through a cluster-scoped SandboxConfig; default gVisor assets come from a public GCS bucket | | Proxy dataplane image | Router and egress proxies | --atenet-dataplane selects Envoy, requiring its custom build, or agentgateway, using a published image | | Object-storage permissions | Let plugin sidecars transfer snapshots | For GCS, configure the required Workload Identity/IAM bindings; the kind substitute uses local credentials | | CSI driver, when needed | Attach CSI-backed persistent storage to Actors | Not needed for the demo's durableDir; Substrate imposes additional controller/node deployment requirements | External databases are not provisioned by ateapi or ate-setup. Snapshot-bucket selection and credential-provider selection also need explicit configuration, even when other defaults are available. Sources: docs/postgres.md#L8, docs/operator-install.md#L314, docs/egress-credential-injection.md#L128-L131. Missing GCS IAM bindings can produce a 403 on the first suspend/resume, and also prevent control-plane snapshot operations and image pulls. Source: tools/setup-gcp/README.md#L154-L159. Note: Substrate directly orchestrates CSI operations from ateapi and atelet. A CSI controller must be reachable as a cluster-network gRPC service, unlike the usual controller-side Unix socket arrangement. See docs/csi-deployment.md#L5. For the Kubernetes v1.37 setup covered here, Pod certificate and ClusterTrustBundle features are GA and enabled by default. Substrate also includes its certificate controller. Provider-specific installation requirements should still be checked separately. Source: ClusterTrustBundle feature definition. GKE availability Google announced Agent Substrate availability on GKE on September 16, 2026. The GKE offering installs open-source Substrate onto GKE Standard; Autopilot is not supported. Evaluation and non-production use are available, while production support is allowlisted under a private GA program. Substrate has no additional product charge, but the GKE and other resources created still incur their normal charges. Source: Install Agent Substrate on GKE. The announcement also describes ComputeClasses and optional Filestore agent volumes. Shared workspace storage serves a different purpose from snapshots of execution state. Google reports sub-500ms resumption, more than 500 suspend/resume activations per second and 10x density. These are Google's published figures, separate from upstream design targets and the Counter example. Source: Agent Substrate on GKE, Google Cloud Blog (September 16, 2026). What to evaluate in your own workload Substrate is worth examining when a fleet has many isolated, stateful environments but only a fraction need to execute at once. The useful question is how much resident capacity suspend/resume can release, and whether restoration meets the application's latency needs. Start with three checks: - Suspension boundaries: identify when an environment can safely stop, including outstanding network calls and application work. - State requirements: decide what must survive, then select snapshot fidelity and any persistent workspace storage accordingly. - Operational ownership: account for the database, snapshot backend, identity and egress services as well as the Workers. The Counter demo exposes the core mechanism. An agent platform must then connect it to real session events and measure its own activation latency and capacity use. Further implementation details {% details Claude Code demo: workload execution versus UI state %} What the Claude Code demo actually implements claude-code-multiplex defines three Claude Code Actors—luna, mars and orion—with a two-Pod WorkerPool. It uses the control API rather than sending workload HTTP through atenet-router, so it does not use the routing header. Source: demos/claude-code-multiplex/README.md#L14-L21. An ActorTemplate supplies the task and interval as environment variables: env: - name: ACTOR_NAME value: "luna" - name: TASK value: "Tell me one short, surprising fact about the Moon. One sentence." - name: INTERVAL_SECONDS value: "45" - name: ANTHROPIC_API_KEY value: "${ANTHROPIC_API_KEY}" Source: demos/claude-code-multiplex/agent-luna-template.yaml.tmpl#L34-L42. The workload repeatedly runs claude --print and then sleeps: while true; do ... claude --print "${TASK}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] claude exited non-zero" ... sleep "${INTERVAL_SECONDS}" done Source: demos/claude-code-multiplex/workload/run.sh#L17-L49. That idle interval is an opportunity for multiplexing. However, the v0.5.0 UI does not implement everything its README's task-flow description suggests: | UI behavior | Implementation | |:---|:---| | “Give a task” | Records the selected Actor and task in UI-server assignment state | | queued / running / completed | Derived from timestamps and randomized waiting/execution durations | | Actor state and logs | Retrieved from the Substrate and Kubernetes APIs | giveTask does not send a task to the Actor or call ResumeActor / SuspendActor. A comment in computeState identifies the task state as a time-based UI display rather than Substrate state. Source: demos/claude-code-multiplex/ui/server.go#L252-L315. A changing UI status therefore does not establish that a real LLM task completed or automatic multiplexing occurred. The useful pieces are the ActorTemplates, the periodic Claude Code workload and the state-viewing UI. Verify suspend/resume through real API operations and Actor state, as in the [Counter demo](#try-the-counter-demo-with-kind). {% enddetails %} {% details RustFS: the local snapshot backend %} RustFS RustFS is a separate, Rust-based S3-compatible object store. The kind installation uses it as its local snapshot backend. The v0.5.0 manifest selects rustfs/rustfs:1.0.0-beta.3. This development setup does not replace production storage availability, persistence or operational design. objectstore-plugin uses RustFS through its S3 backend; GCS uses the same plugin interface with a different backend. Sources: manifests/ate-install/kind/rustfs.yaml#L55-L75, rustfs/rustfs. {% enddetails %} {% details ReBAC: deriving permissions from relationships %} ReBAC and Kubernetes RBAC Substrate's API authorization uses OpenFGA's relationship-based access control model. In this version, ordinary Control RPC enforcement remains experimental and disabled by default. Source: docs/security.md#L46. Kubernetes RBAC checks applicable RoleBindings and ClusterRoleBindings against Role/ClusterRole rules: API groups, resources, verbs, namespaces and sometimes resource names. It does not automatically derive access from ownership or arbitrary parent-child relationships. Source: Using RBAC Authorization. ReBAC derives access from relations between subjects and objects, and potentially relations between objects. Inheritance is explicit in the authorization model, rather than automatic for every parent-child association. Source: OpenFGA: Modeling—Getting Started. Substrate's .fga model makes a Worker's read permission depend on its parent global object's viewer or controller relation: <!-- OpenFGA DSL; Python fencing provides partial syntax highlighting only. --> type worker relations define parent_global: [global] define host_node: [node] define can_get: viewer from parent_global or controller from parent_global Source: cmd/ateapi/internal/authz/model.fga#L29-L34. An Atespace can inherit its owner from its parent global object: type atespace relations define parent_global: [global] define owner: [user] or owner from parent_global An ActorTemplate can derive read permission from the viewer relation on its parent Atespace: type actor_template relations define parent_atespace: [atespace] define can_get: viewer from parent_atespace Sources: cmd/ateapi/internal/authz/model.fga#L71-L77, cmd/ateapi/internal/authz/model.fga#L101-L106. The relation data connects global → atespace → actor_template and global → worker. Permission checks follow those modelled relations. The practical difference from ordinary Kubernetes RoleBindings is the ability to derive child access from parent relations according to an explicit model. {% enddetails %} Components and versions | Component | Version | Repository | Scope | |:---|:---|:---|:---| | Agent Substrate | v0.5.0 | agent-substrate/substrate | Implementation, configuration and source examples | | Kubernetes | v1.37.1 | kubernetes/kubernetes | Pod certificate APIs and feature gates | | kind | v0.33.0 | kubernetes-sigs/kind | Local example using kindest/node:v1.37.0 | GKE availability information reflects the official documentation checked on October 20, 2026. The GKE installer does not necessarily distribute the same version as the upstream tag used here. Note: Agent Substrate is pre-1.0 and does not guarantee backward compatibility. Unless another repository is named, source links in this article refer to agent-substrate/substrate v0.5.0. References - Kubernetes Podcast #272: Agent Substrate - Agent Substrate repository - Bringing you Agent Sandbox on GKE and Agent Substrate - Agent Substrate on GKE announcement - Install Agent Substrate on GKE - Architecture - Threat model - Glossary - Security guide -
LeaderWorkerSetとDisaggregatedSetの概要 はじめに 以前、Agent Substrate の概要を執筆した流れで、もう1つ気になっていた LeaderWorkerSet (LWS) を確認してみました。LWSはSIG Appsが管理するKubernetesの公式サブプロジェクト(kubernetes-sigs)で、vLLM/SGLangのような分散LLM推論を「leader 1台 + worker N台」のPodグループ単位でデプロイ・スケール・ローリングアップデートするためのAPIです。 関連する仕組みについては、以下の記事も執筆しています。エージェントの Task を管理する Agent Executor、その実行基盤となる Agent Substrate、単一 Pod のサンドボックスを管理する Agent Sandbox について整理しています。 - Agent Executorの概要 - Agent Substrate の概要 - Agent Sandboxの概要 Google Cloudは、Agent Substrateをエージェント実行基盤のスタックの中でどう位置づけているかを、次の図で示しています。 !Agent Substrateを中心とした、エージェント基盤のレイヤー構成図。最上位にAgent Runtime/Agent Harnesses/Custom Agents&Toolsからなる Agent Application Layer、その下にSandbox Isolation(gVisor/MicroVM)を含むAgent Substrateと、Deployments/StatefulSets/LWSなどのOther k8s workloads、最下層にKubernetesとIaaS/Bare metal、Blob storageが並ぶ。 (出典: Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog, 2026-05-20) LWSは、この図の右側のOther k8s workloads (Deployments, StatefulSets, LWS)に入っています。緑色のAgent Substrate(エージェントの実行基盤)の内側ではなく、DeploymentやStatefulSetと並ぶ「その他のKubernetesワークロード」の1つという位置づけです。 この記事では、LeaderWorkerSetがどういう課題を解決するためのAPIなのか、内部でどう実装されているか、そしてその上に作られた新しいAPIであるDisaggregatedSetが何を追加しているのかを整理します。 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |---|---|---|---| | LeaderWorkerSet (LWS) | v1.0.0 | kubernetes-sigs/lws | 本記事全体のソースコード調査対象(LeaderWorkerSet/DisaggregatedSet両APIを含む) | | kubernetes/kubernetes | v1.37.1 | kubernetes/kubernetes | LWSのgo.modが参照するk8s.io/api v0.37.1に対応するリリース | | kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージはkindest/node:v1.37.0 | 本記事の実装説明は LWS v1.0.0 に固定します。LeaderWorkerSet と DisaggregatedSet は、このプロジェクトが CRD とコントローラーで提供する API です。Kubernetes 本体に組み込まれた API とは区別します。 (参考: Distributed Inference APIs | LWS Overview) TL;DR - LWS は Pod グループをレプリケーションの単位にします。leader と worker をまとめてスケール・更新します。 - デフォルトの Ordinal モードは2階層の StatefulSet で構成されます。Hash モードでは leader の管理に Deployment を使います。 - デフォルトでは、Pod の削除やコンテナの再起動を検知するとグループを再作成します。他のグループは再作成の対象になりません。 - 並列作成と gang scheduling は別の機能です。全 Pod をまとめてスケジューリングするには、追加の設定と対応するスケジューラーが必要です。 - DisaggregatedSet は複数 role の LWS をまとめて管理します。role 間の更新進捗を調整しますが、更新中の稼働数の比率が常に一定になるわけではありません。 第1章: LeaderWorkerSetが解決する課題 この章では、分散LLM推論のようなワークロードが、なぜDeploymentやStatefulSetだけでは扱いにくいのかを整理します。 1.1 Podグループを1つの単位として扱う必要性 vLLM や SGLang のようなフレームワークで、tensor 並列・pipeline 並列を複数ホストにまたがって構成する場合、1つの推論インスタンスを複数の Pod に分けて動かすことがあります。各プロセスは、フレームワークに応じた rank や並列数の設定で協調動作します。READMEはLWSの目的を次のように説明しています。 > LeaderWorkerSet (LWS): An API for deploying a group of pods as a unit of replication. It aims to address common deployment patterns of AI/ML inference workloads, especially multi-host inference workloads where the LLM will be sharded and run across multiple devices on multiple nodes. (参考: README.md#L9) StatefulSetは個々のPodに安定した名前とストレージを与えますが、「leaderとworkerで別々のPodテンプレートを使い、それらをまとめて1つのレプリカとしてスケール・更新する」という発想を持っていません。LWSはこのギャップを埋めるためのAPIです。 <p align="center"> <img src="raw.githubusercontent.com" width="550" alt="LWS Concept"> </p> LeaderWorkerSetの概念図。複数のreplica(leader + workerの組)が1つのLeaderWorkerSetにまとまる。 (参考: README.md#L31-L33) 1.2 なぜStatefulSetを土台に選んだか デフォルトの Ordinal モードでは、LWS は StatefulSet を組み合わせて Pod を管理します。公式ドキュメントは、この選択の理由を5点挙げています。 > 1. Deterministic Ordinal Identity and Predictable Networking ... 2. Native Dynamic Storage (volumeClaimTemplates) ... 3. Parallel Pod Management ... 4. Partitioned Rolling Updates ... 5. Kubernetes API Composition Best Practices (参考: Architecture and Relationship with StatefulSet | LWS Concepts) 特に3番目の「並列 Pod 管理」は、1つの Pod が Ready になるまで次の Pod の作成を待つと、NCCL/MPI などの通信相手がそろわず起動が進まないケースに対応します。StatefulSet の Parallel は、前の Pod の Ready を待たずに Pod を作成できる設定です。全 Pod の同時起動や、一括でのリソース確保を保証するものではありません。 また、startupPolicy のデフォルトは LeaderCreated で、leader Pod の作成後に worker 用 StatefulSet を作ります。LeaderReady を選ぶと、leader が Ready になるまで worker の作成を待ちます。 (参考: Startup Policy | LWS Concepts、leaderworkerset_types.go#L650-L656) 1.3 vLLM の並列実行と LWS のグループ管理 LWS が Pod をまとめ、vLLM がモデルを分割して実行します。Pod の数を指定するだけで、モデルの分割方法まで決まるわけではありません。 Google Cloud の2024年11月8日の記事では、Llama 3.1 405B FP16 を、各8基の H100 を持つ2台の A3 ノードで動かしています。ノード内は tensor parallelism (TP) で分割し、ノード間は層を分担する pipeline parallelism (PP) を使う、TP=8・PP=2 の例です。 (参考: How to deploy and serve multi-host gen AI large open models over GKE | Google Cloud Blog) このように1 Pod が1ノードの8 GPU を使う構成を LWS で表すと、設定の対応は次のようになります。 | 設定 | 値 | 意味 | |---|---:|---| | LWS spec.replicas | 1 | 推論インスタンスを1組動かす | | LWS leaderWorkerTemplate.size | 2 | leader を含む2 Pod で1組を構成する | | 各 Pod の GPU 要求 | 8 | 1組あたり合計16 GPU を使う | | vLLM --tensor-parallel-size | 8 | tensor を8分割して実行する | | vLLM --pipeline-parallel-size | 2 | モデルの層を2段に分けて実行する | ここで replicas を2にすると、同じ構成の推論インスタンスが2組、定常状態で4 Pod・32 GPU になります。1つのモデルの分割数を増やす操作とは異なります。また、「TP はノード内、PP はノード間」はこの例の構成です。GPU 間の接続やモデルに応じて別の分割も選べます。 (参考: Parallelism and Scaling | vLLM) Google の記事は Ray を使っています。Ray は、Python の処理を複数の CPU・GPU やノードに分散して実行するためのオープンソースのフレームワークです。この構成では、vLLM の分散実行を担います。 2026年10月21日に確認した vLLM の LWS 手順には multiprocessing と Ray の両方の例があります。Ray は LWS 自体の必須要件ではありません。multiprocessing の例では、LWS が設定する環境変数を vLLM の起動引数に渡します。 | LWS の環境変数 | vLLM の引数 | 渡す情報 | |---|---|---| | LWS_GROUP_SIZE | --nnodes | グループ内の Pod 数。この例ではノード数に対応 | | LWS_WORKER_INDEX | --node-rank | グループ内のインデックス。leader は0 | | LWS_LEADER_ADDRESS | --master-addr | 分散実行の接続先となる leader のアドレス | 同じ手順では、HTTP Service が leader のラベルだけを選択し、worker は分散実行に参加します。外部リクエストの受付先と、計算に参加する Pod を分けている点も、2種類のテンプレートを使う理由です。 (参考: LWS | vLLM、leaderworkerset_types.go#L70-L80) :::note info vLLM の stable ドキュメントは更新されます。ここでは設定の対応を説明しており、この GPU 構成で推論を実行したわけではありません。実際に試す際は、vLLM のイメージと対応する手順を固定し、GPU メモリ、モデルの配布方法、ノード間通信も確認してください。手順にある「2ノード・各8 GPU」はそのデプロイ例の条件で、LWS API 全体の最低要件ではありません。 ::: 第2章: Ordinal モードの2階層 StatefulSet 構成 この章では、LeaderWorkerSetが実際にどのKubernetesリソースを作ってグループを実現しているかを、ソースコードから確認します。 2.1 LeaderWorkerSetSpecの主要フィールド LeaderWorkerSetSpecは、グループ数(replicas)とグループの中身(leaderWorkerTemplate)を分けて持っています。 type LeaderWorkerSetSpec struct { // replicas is the number of leader-workers groups. ... // On scale down, the leader pod as well as the workers statefulset will be deleted. // Default to 1. Replicas *int32 `json:"replicas,omitempty"` // leaderWorkerTemplate defines the template for leader/worker pods LeaderWorkerTemplate LeaderWorkerTemplate `json:"leaderWorkerTemplate"` ... } (参考: leaderworkerset_types.go#L175-L191) LeaderWorkerTemplateの方は、leader用とworker用で別々のPodTemplateを持てるようになっています。 type LeaderWorkerTemplate struct { // leaderTemplate defines the pod template for leader pods. LeaderTemplate *corev1.PodTemplateSpec `json:"leaderTemplate,omitempty"` // workerTemplate defines the pod template for worker pods. WorkerTemplate corev1.PodTemplateSpec `json:"workerTemplate"` // size is the number of pods to create. It is the total number of pods in each group. // The minimum is 1 which represent the leader. ... Size *int32 `json:"size,omitempty"` ... } (参考: leaderworkerset_types.go#L383-L403) sizeが1グループあたりのPod総数(leader含む)で、replicasがグループの数です。更新や再作成が完了した定常状態では、Pod の総数は replicas * size になります。更新・終了処理の途中では、この数と一致しないことがあります。leaderTemplate を省略すると、leader にも workerTemplate が使われます。 2.2 Leader用StatefulSetとWorker用StatefulSetの構築 ここでは、デフォルトの groupIdentity: Ordinal を説明します。LWS は、LeaderWorkerSet そのものを管理するLeaderWorkerSetReconcilerと、個々のleader Podを監視するPodReconcilerという2つのコントローラーで構成されています。Leader用のStatefulSetはLeaderWorkerSetReconciler側、Worker用のStatefulSetはPodReconciler側で構築されます。 Leader用StatefulSetの構築では、replicas個のleader PodをParallelなPod管理方式で作ります。 statefulSetConfig := appsapplyv1.StatefulSet(lws.Name, lws.Namespace). WithSpec(appsapplyv1.StatefulSetSpec(). WithServiceName(lws.Name). WithReplicas(replicas). WithPodManagementPolicy(appsv1.ParallelPodManagement). WithTemplate(podTemplateApplyConfiguration). ... (参考: leaderworkerset_controller.go#L1189-L1226) デフォルトの startupPolicy: LeaderCreated では、leader Pod が1つ作られるたびに、PodReconcilerがそのleaderに対応するWorker用StatefulSetを<lws名>-<replicaのインデックス>という名前で作ります。Pod数はsize - 1(leaderを除いた数)で、オーディナルは0ではなく1から始まります。 statefulSetConfig := appsapplyv1.StatefulSet(workerStatefulSetName(&leaderPod), leaderPod.Namespace). WithSpec(appsapplyv1.StatefulSetSpec(). WithServiceName(serviceName). WithReplicas(*currentLws.Spec.LeaderWorkerTemplate.Size - 1). WithPodManagementPolicy(appsv1.ParallelPodManagement). WithTemplate(&podTemplateApplyConfiguration). WithOrdinals(appsapplyv1.StatefulSetOrdinals().WithStart(1)). ... (参考: pod_controller.go#L1586-L1649) つまり、replicas=2, size=3のLeaderWorkerSetを1つ作ると、実際には次のKubernetesリソースが生成されます。 LeaderWorkerSet (replicas=2, size=3) │ └── Leader StatefulSet "<lws名>" (replicas=2) │ ├── Leader Pod "<lws名>-0" + Worker StatefulSet "<lws名>-0" (replicas=2, startOrdinal=1) │ → Worker Pod "<lws名>-0-1", "<lws名>-0-2" │ └── Leader Pod "<lws名>-1" + Worker StatefulSet "<lws名>-1" (replicas=2, startOrdinal=1) → Worker Pod "<lws名>-1-1", "<lws名>-1-2" (参考: Architecture and Relationship with StatefulSet | LWS Concepts) 5.2節で、この構造を実際にkindクラスタ上で確認します。 第3章: グループ単位の振る舞いを支える主な仕組み この章では、LeaderWorkerSetが「グループを1つの単位として扱う」ために追加している、StatefulSet単体には無い仕組みを見ていきます。 3.1 グループ単位の再作成: RestartPolicy 分散推論では、1つのworker Podが落ちて集団通信が崩れても、そのPodだけを再起動すれば済むとは限りません。LWSはデフォルトで、グループ内のどれか1つのPodが失敗・再起動した場合、グループ全体(leader + 全worker)を作り直すRecreateGroupOnPodRestartというポリシーを使います。 実装では、PodReconciler が Pod の削除やコンテナの再起動を検知し、グループの leader Pod を削除することで再作成を進めます。 // the leader pod will be deleted if the worker pod is deleted or any container was restarted if !isRecreatePending && !podutils.ContainerRestarted(pod) && !podutils.PodDeleted(pod) { return false, ctrl.Result{}, nil } (参考: pod_controller.go#L562-L567) worker 用 StatefulSet の owner reference は leader Pod を指しています。leader の削除に伴って、ガベージコレクションが worker 用 StatefulSet とその Pod を削除します。その後、leader 用 StatefulSet と PodReconciler が新しい leader と worker 群を作ります。削除・終了・再作成は非同期に進むため、全 Pod が同時に停止・起動するという意味ではありません。 (参考: pod_controller.go#L384-L393、pod_controller.go#L684-L699) v1.0.0 では、再作成回数の上限を設ける maxGroupRestarts と、再作成の間隔を延ばす restartBackoff も設定できます。どちらもデフォルトでは未設定で、回数制限やバックオフは有効になりません。5.3節では、これらを設定せずに worker Pod を1つ削除した場合を確認します。 (参考: leaderworkerset_types.go#L413-L439) 3.2 グループ識別方式: OrdinalとHash LeaderWorkerSetSpecのgroupIdentityフィールドは、グループの名前付けとスケールダウン方式を切り替えます。 > groupIdentity determines how group identities are assigned. > Ordinal (default) manages leaders through a StatefulSet: groups are named <lws>-0..<lws>-N-1 and scale down always removes the highest ordinal. > Hash manages leaders through a Deployment: group names are hash-suffixed, scale down prefers unscheduled and not-ready groups over healthy ones, and rollouts are paced by a group readiness gate on the leader pods. (参考: leaderworkerset_types.go#L215-L225) Hashモードの実装コメントは、その仕組みをさらに具体的に説明しています。 > reconcileHash reconciles a LeaderWorkerSet with GroupIdentity=Hash. Leaders are managed through a Deployment instead of a StatefulSet: the ReplicaSet picks scale-down victims (unscheduled and not-ready groups before healthy ones) and the Deployment paces rollouts, throttled by the group-ready readiness gate that the pod controller maintains on leader pods. (参考: leaderworkerset_hash.go#L45-L49) | 項目 | Ordinal(デフォルト) | Hash | |---|---|---| | leaderの実体 | StatefulSet | Deployment | | グループ名 | <lws>-0, <lws>-1, ... の連番 | Deployment が生成するハッシュ・接尾辞を含む名前 | | スケールダウン対象 | 常に最大オーディナルのグループ | 未スケジュール・未Readyなグループを優先 | | ロールアウトの調整方法 | StatefulSetのpartition | leader Podへのreadiness gate | Ordinal は安定したグループ名が必要な構成に適しています。Hash は、連番に依存せず、未スケジュール・未 Ready のグループを優先して減らしたい場合の選択肢です。ただし、アプリケーションの処理内容を見て、任意のグループを確実に残す機能ではありません。groupIdentity は作成後に変更できません。 (参考: Group Identity | LWS Concepts) 3.3 トポロジー配置とgang scheduling 分散推論はノード間の通信帯域がボトルネックになりやすいため、LWS には1つのグループを同じトポロジードメイン(ラック、ブロックなど)に配置するための設定があります。利用する場合は、次のアノテーションにノードのトポロジーラベルのキーを指定します。デフォルトでこの配置制約が付くわけではありません。 > The annotation leaderworkerset.sigs.k8s.io/exclusive-topology defines a 1:1 mapping between an LWS replica and a topology domain. (参考: Exclusive Topology Placement | LWS Concepts) これとは別に、Alpha機能として「グループ内の全Podをall-or-nothingでスケジューリングする」gang schedulingも用意されています。 > Native gang scheduling uses the Kubernetes Workload-Aware Scheduling APIs to admit related pods as a unit. LWS owns the Workload and PodGroup objects and sets each managed pod's spec.schedulingGroup; users express the intent only on the LeaderWorkerSet. (参考: Gang Scheduling | LWS Concepts) v1.0.0 の Kubernetes ネイティブな gang scheduling には、次の設定が必要です。5章の通常のインストールでは有効にしていません。 | 対象 | 必要な設定 | |---|---| | Kubernetes | v1.37 の Workload / PodGroup API。kube-apiserver・kube-controller-manager・kube-scheduler で GenericWorkload=true を有効化し、kube-apiserver の --runtime-config に scheduling.k8s.io/v1beta1=true を指定する | | LWS コントローラー | featureGates.WorkloadAwareScheduling: true と gangSchedulingManagement.schedulerProvider: kubernetes | | LeaderWorkerSet | spec.scheduling を設定する。scheduling: {} ではグループごとの gang scheduling を使う | (参考: Native Gang Scheduling | LWS Installation、Gang Scheduling | LWS Concepts) :::note warn gang scheduling は Alpha 機能です。Kubernetes ネイティブの API を使う構成のほか、Volcano 向けの実装もあります。ネイティブ構成のグループ単位の gang scheduling は startupPolicy: LeaderReady と併用できず、exclusive-topology アノテーションとの併用にも制約があります。並列作成だけを設定した通常の LWS は、Pod ごとにスケジューリングされます。 ::: 3.4 HPA が増減するのもグループ数 LWS は HPA 向けの scale サブリソースを持ち、spec.replicas を通じてグループ数を増減できます。HPA が変更するのはグループ数で、size や vLLM の TP・PP 数ではありません。1.3節の構成なら、1レプリカ追加するたびに2 Pod・16 GPU 分の配置先が必要です。 v1.0.0 の status.hpaPodSelector は leader Pod だけを選択します。Pod の CPU 使用率を使う HPA でも、worker を含むグループ全体の GPU 負荷を自動的に評価するわけではありません。推論負荷に合わせるには、受付待ちリクエスト数など、利用するサービング基盤で取得できる指標とスケール方針を検討します。 (参考: leaderworkerset_types.go#L607-L611、scale サブリソースの定義、Autoscaling | LWS Examples) 第4章: DisaggregatedSet — 複数のLWSをrole単位でまとめる この章では、LWSの上に作られた新しいAPIであるDisaggregatedSetが何を追加しているかを整理します。 4.1 Disaggregated推論という課題 LLM推論は、入力プロンプトからKVキャッシュを作るprefillフェーズと、トークンを1つずつ生成するdecodeフェーズに分けられます。一般に prefill は計算量、decode はメモリ帯域の影響を受けやすいため、それぞれ別のリソース量・別のスケール方針を持つPod群として分離(disaggregate)したい、という要求があります。 > DisaggregatedSet (DS): An API to support advanced multi-node inference. LWS forms the core API for multi-node while DisaggregatedSet builds on it to add advanced disaggregated workload deployment with support for autoscaling, rollouts and failure handling. (参考: README.md#L11) <p align="center"> <img src="raw.githubusercontent.com" width="700" alt="DisaggregatedSet Concept"> </p> DisaggregatedSetの概念図。複数のroleがそれぞれ独立したLeaderWorkerSetとして管理される。 (参考: README.md#L42-L44) 4.2 DisaggregatedSetは子LeaderWorkerSetを作るAPI DisaggregatedSetはLWSを置き換えるAPIではなく、LWSの上に乗るAPIです。 > DisaggregatedSet does not replace LeaderWorkerSet — it orchestrates multiple LeaderWorkerSets. > > Each role defined in a DisaggregatedSet spec maps to an independent LeaderWorkerSet, deployed in the same namespace. (参考: DisaggregatedSet | LWS Concepts) DisaggregatedSetSpec は、role(prefill/decode 等)のリストと、スライス数(同じ role トポロジーをまるごと複製する単位)を持ちます。slices のデフォルトは1です。以下では subrole を使わない基本構成を説明します。 type DisaggregatedSetSpec struct { // Roles defines the list of roles (at least 1 required). Roles []DisaggregatedRoleSpec `json:"roles"` // Slices is the number of independent copies of the whole role topology. Slices *int32 `json:"slices,omitempty"` ... } (参考: disaggregatedset_types.go#L217-L247) DisaggregatedRoleSpecはsigs.k8s.io/lwsのLeaderWorkerSetTemplateSpecをそのまま埋め込んでおり、各roleの実体は中身がLWSのSpecそのものです。 type DisaggregatedRoleSpec struct { // Name is the unique identifier for this role. Name string `json:"name"` ... // LeaderWorkerSetTemplateSpec defines the LWS template for this role. // Note: Spec.RolloutStrategy.Type must be RollingUpdate (or empty) and // Spec.RolloutStrategy.RollingUpdateConfiguration.Partition must not be set. // DisaggregatedSet handles rollouts across roles. leaderworkerset.LeaderWorkerSetTemplateSpec `json:",inline"` } (参考: disaggregatedset_types.go#L184-L216) 定常状態では、各スライスの role ごとに子 LWS が作られます。更新中は旧・新リビジョンの子 LWS が共存するため、常に「role 数と同じ数の LWS」になるわけではありません。子 LWS の名前は、どのDisaggregatedSetのどのスライス・どのリビジョン・どのroleに属するかが分かるように、次の規則で生成されます。 func GenerateName(baseName string, slice int, revision, role string) string { return fmt.Sprintf("%s-%d-%s-%s", baseName, slice, revision, role) } (参考: utils.go#L96-L98) 生成された子LWSには、disaggregatedset.x-k8s.io/name・disaggregatedset.x-k8s.io/role・disaggregatedset.x-k8s.io/sliceといったラベルが付き、ハードコードした名前ではなくこれらのラベルで子リソースを特定することが推奨されています(参考: DisaggregatedSet | LWS Concepts)。5.4節で、実際にDisaggregatedSetを作って子LWSの名前・ラベルを確認します。 4.3 role間で協調するロールアウト DisaggregatedSet は、prefill と decode のような複数 role の更新進捗を調整します。各 role が独立に更新を完了するのではなく、role ごとのレプリカ数に対する更新割合を考慮して、旧・新リビジョンへの移行を進めます。 各 role の LWS Spec は RollingUpdate を使い、partition の値は DisaggregatedSet が管理します。一方、追加を許すグループ数の maxSurge と、利用できなくなることを許すグループ数の maxUnavailable は、role ごとに設定できます。 v1.0.0 のデフォルトは maxSurge: 0・maxUnavailable: 1 です。1グループしかない role は、更新中に全グループが利用できなくなることがあります。たとえば prefill が1、decode が2の構成でも、更新途中の稼働数が常に 1:2 に保たれる保証はありません。停止を許さない role では maxUnavailable: 0 と正の maxSurge、追加分を動かせるリソースを用意する必要があります。 (参考: DisaggregatedSet | LWS Concepts、disaggregatedset_types.go#L184-L216) DisaggregatedSet が管理するのは role の配置・スケール・更新です。prefill から decode への KV キャッシュ転送や、推論リクエストの振り分けには、利用する推論フレームワーク側の設定も必要です。 第5章: kindクラスタで実際に動かす この章では、2026年10月21日に kind v0.33.0・Kubernetes v1.37.0・LWS v1.0.0 で実行した手順と結果を整理します。nginx で API とリソース構成を確認するもので、LLM 推論の性能や GPU 通信、gang scheduling は検証していません。 5.1 インストール Docker・kind・kubectl を用意し、検証専用のクラスタを作ります。lws-demo は既存環境と重複しない名前にしてください。通常の LWS / DisaggregatedSet を使うには、公式のリリースマニフェストを適用します。 kind create cluster --name lws-demo --image kindest/node:v1.37.0 VERSION=v1.0.0 kubectl apply --server-side -f https://github.com/kubernetes-sigs/lws/releases/download/$VERSION/manifests.yaml kubectl wait deploy/lws-controller-manager -n lws-system --for=condition=available --timeout=5m (参考: Install a released version | LWS Installation) このマニフェストにはLeaderWorkerSetとDisaggregatedSet両方のCRDが含まれており、DisaggregatedSetを使うための追加のインストール手順は必要ありません。 > Starting from v0.9.0, DisaggregatedSet is bundled with the LWS controller manager. > > For kubectl and Kustomize installs, the standard v0.9.0+ manifests include the DisaggregatedSet CRD, controller permissions, and validating webhook. No separate DisaggregatedSet installation step is required. (参考: DisaggregatedSet | LWS Installation) 5.2 LeaderWorkerSetを作って2階層のStatefulSetを確認する GPU を使わずに確認できるよう、公式の nginx 例から LWS 部分を使いました。以下を lws-sample.yaml として保存します。HPA は使わないため、この確認に Metrics Server は不要です。 apiVersion: leaderworkerset.x-k8s.io/v1 kind: LeaderWorkerSet metadata: name: leaderworkerset-sample spec: replicas: 2 leaderWorkerTemplate: size: 3 leaderTemplate: spec: containers: - name: leader image: nginx:1.27 resources: requests: {cpu: 100m, memory: 128Mi} limits: {cpu: 500m, memory: 256Mi} workerTemplate: spec: containers: - name: worker image: nginx:1.27 resources: requests: {cpu: 100m, memory: 128Mi} limits: {cpu: 500m, memory: 256Mi} (参考: nginx.yaml#L5-L38、HPA部分は本記事では使用していません) kubectl apply -f lws-sample.yaml kubectl wait pod -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample --for=condition=Ready --timeout=5m replicas=2, size=3 で作成し、全6 Pod が Ready になるまで確認すると、2.2節で説明した通りのStatefulSet構造がそのまま現れました。 以下は今回の実行結果です。 $ kubectl get leaderworkerset,statefulset,pods NAME READY DESIRED UP-TO-DATE AGE leaderworkerset.leaderworkerset.x-k8s.io/leaderworkerset-sample 2 2 2 36s NAME READY AGE statefulset.apps/leaderworkerset-sample 2/2 35s statefulset.apps/leaderworkerset-sample-0 2/2 35s statefulset.apps/leaderworkerset-sample-1 2/2 35s NAME READY STATUS RESTARTS AGE pod/leaderworkerset-sample-0 1/1 Running 0 35s pod/leaderworkerset-sample-0-1 1/1 Running 0 35s pod/leaderworkerset-sample-0-2 1/1 Running 0 35s pod/leaderworkerset-sample-1 1/1 Running 0 35s pod/leaderworkerset-sample-1-1 1/1 Running 0 35s pod/leaderworkerset-sample-1-2 1/1 Running 0 35s leader用StatefulSetが1つ(leaderworkerset-sample)、replicaごとのworker用StatefulSetが2つ(leaderworkerset-sample-0、leaderworkerset-sample-1)作られ、worker Podのオーディナルが0ではなく1から始まっていることも確認できました。あわせて、グループ内のPod間でDNS解決するためのheadless Serviceも作られています。 $ kubectl get svc leaderworkerset-sample -o wide NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR leaderworkerset-sample ClusterIP None <none> <none> 35s leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample 5.3 Pod を1つ削除してグループの再作成を確認する 3.1節で見たRecreateGroupOnPodRestart(デフォルト)の挙動を、replica 0のworker Pod 1つを明示的に削除して確認しました。 削除前後で、次のコマンドから Pod の UID と作成時刻を取得して比較できます。同じ名前の Pod でも、UID が変われば別の Pod です。 kubectl get pods -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample \ -o custom-columns=NAME:.metadata.name,UID:.metadata.uid,CREATED:.metadata.creationTimestamp kubectl delete pod leaderworkerset-sample-0-1 # 再作成は非同期に進むため、グループ0の置き換えと全6 Pod の Ready を確認する kubectl get pods -l leaderworkerset.sigs.k8s.io/name=leaderworkerset-sample -w 一覧の監視は Ctrl-C で終了し、最初の UID・作成時刻の取得コマンドを再実行します。 削除前後でPodのUIDと作成時刻を比較すると、削除したPodだけでなく、同じreplica(グループ0)のleaderと残りのworkerもすべて新しいUID・新しい作成時刻を持つPodに置き換わっていました。 UID は先頭8文字を抜粋しています。 | Pod | 削除前の UID | 削除後の UID | 結果 | |---|---|---|---| | leaderworkerset-sample-0 | 6196dd0c | f8e535fc | 作り直された | | leaderworkerset-sample-0-1 | e41eae20 | 82eebca7 | 作り直された | | leaderworkerset-sample-0-2 | 6985a793 | 539304af | 作り直された | | leaderworkerset-sample-1 | 561f7a2d | 561f7a2d | 変化なし | | leaderworkerset-sample-1-1 | 4353aebd | 4353aebd | 変化なし | | leaderworkerset-sample-1-2 | 464c526f | 464c526f | 変化なし | グループ1(無関係なreplica)のPodは一切再作成されず、UIDも作成時刻も変化しませんでした。worker Podを1つ削除しただけで、そのPodが属するグループ全体(leader含む)だけが作り直され、他のグループには影響しないことを確認できました。 5.4 DisaggregatedSetを作って子LWSの生成を確認する 4.2節で見た子 LWS の生成を、1スライス・prefill/decode の2 role で確認しました。実際の推論処理はせず、nginx で API の挙動だけを見ています。以下を disaggregated-sample.yaml として保存します。 apiVersion: disaggregatedset.x-k8s.io/v1 kind: DisaggregatedSet metadata: name: nginx-disagg spec: roles: - name: prefill spec: replicas: 1 leaderWorkerTemplate: size: 1 workerTemplate: spec: containers: - {name: nginx, image: nginx:1.27, resources: {requests: {cpu: 50m, memory: 64Mi}}} - name: decode spec: replicas: 2 leaderWorkerTemplate: size: 1 workerTemplate: spec: containers: - {name: nginx, image: nginx:1.27, resources: {requests: {cpu: 50m, memory: 64Mi}}} (参考: disaggregatedset_types.go#L184-L247で確認したスキーマに沿って作成) kubectl apply -f disaggregated-sample.yaml kubectl get leaderworkerset -l disaggregatedset.x-k8s.io/name=nginx-disagg -w 各 role が必要数の Ready グループを持つことを確認し、Ctrl-C で監視を終了します。以下は今回の実行結果で、リビジョンハッシュは設定内容によって変わります。 適用すると、4.2節で説明した命名規則の通り、<ds名>-<スライス番号>-<リビジョンハッシュ>-<role名>という名前の子LWSが2つ作られました。 $ kubectl get disaggregatedset,leaderworkerset NAME AGE disaggregatedset.disaggregatedset.x-k8s.io/nginx-disagg 3s NAME READY DESIRED UP-TO-DATE AGE leaderworkerset.leaderworkerset.x-k8s.io/leaderworkerset-sample 2 2 2 43s leaderworkerset.leaderworkerset.x-k8s.io/nginx-disagg-0-69150551-decode 2 2 2 3s leaderworkerset.leaderworkerset.x-k8s.io/nginx-disagg-0-69150551-prefill 1 1 1 3s nginx-disagg-0-69150551-prefillのラベルとowner referenceを見ると、4.2節で引用したdisaggregatedset.x-k8s.io/*ラベルと、親のDisaggregatedSetへのowner referenceが実際に付与されていました。 $ kubectl get leaderworkerset nginx-disagg-0-69150551-prefill --show-labels LABELS: disaggregatedset.x-k8s.io/name=nginx-disagg,disaggregatedset.x-k8s.io/revision=69150551,disaggregatedset.x-k8s.io/role=prefill,disaggregatedset.x-k8s.io/slice=0,... 実際に確認する際は、ハッシュを含む名前を固定せず、ラベルから子 LWS を取得できます。owner reference は次のコマンドで確認できます。 kubectl get leaderworkerset \ -l disaggregatedset.x-k8s.io/name=nginx-disagg,disaggregatedset.x-k8s.io/role=prefill \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{.metadata.ownerReferences}{"\n"}{end}' この例は size: 1 で、各グループには leader だけが存在します。leaderTemplate を省略しているため、leader は workerTemplate の nginx で動きます。今回の size: 1 の構成では worker 用 StatefulSet は作られず、各 role の leader 用 StatefulSet だけが作られました。 また、DisaggregatedSetの状態(status.roleStatuses)には各roleのreplicas/readyReplicas/updatedReplicasがroleごとに記録されていました。 status: roleStatuses: - {name: prefill, desiredReplicas: 1, replicas: 1, readyReplicas: 1, updatedReplicas: 1} - {name: decode, desiredReplicas: 2, replicas: 2, readyReplicas: 2, updatedReplicas: 2} DisaggregatedSetという1つのリソースを操作するだけで、prefillとdecodeという性質の異なる2つのPod群が、それぞれ独立したLWSとして一貫した命名・ラベルの下に管理されることを確認できました。 検証が終わったら、作成したクラスタを削除します。 kind delete cluster --name lws-demo おわりに Agent Substrate が Actor 用の control plane を Kubernetes の外に持つ一方で、LWS は StatefulSet や Deployment を組み合わせて Pod グループを管理します。同じ AI 関連の基盤でも、扱う単位と Kubernetes に任せる範囲が違う点が面白いと感じました。 今回は nginx でリソース構成とグループの再作成を確認しました。実際の分散推論では、GPU の確保や通信、role ごとの可用性も含めた検証が必要で、専用の検証環境も欲しくなります。 参考 参考リンク 本文で登場する順に並べています。 - Agent Executorの概要 - Agent Substrate の概要 - Agent Sandboxの概要 - Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog - kubernetes-sigs/lws - Distributed Inference APIs | LWS Overview - Architecture and Relationship with StatefulSet | LWS Concepts - Startup Policy | LWS Concepts - How to deploy and serve multi-host gen AI large open models over GKE | Google Cloud Blog - Parallelism and Scaling | vLLM - LWS | vLLM - Failure Handling and Restart Policies | LWS Concepts - Group Identity | LWS Concepts - Exclusive Topology Placement | LWS Concepts - Gang Scheduling | LWS Concepts - Autoscaling | LWS Examples - DisaggregatedSet | LWS Concepts - Installation | LWS Docs
Agent Substrateの概要 はじめに Kubernetes Podcast の #272 Agent Substrate で、大量のAIエージェントをKubernetes上で動かすための新しいランタイムとして紹介されているのを聞いて気になったので、確認してまとめてみました。 Agent Substrate(リポジトリ名は substrate)は、Google が公開している OSS で、多数のエージェントを Kubernetes 上の sandbox で効率よく実行することを目指すシステムです。「数百万」という規模や低レイテンシは、プロジェクトが掲げる設計目標として扱います。Kubernetes自体を置き換えるものではなく、Pod・オートスケーリング・NetworkPolicyといった既存の仕組みの上に、エージェント特有のスケジューリング・スナップショット・ID管理・トラフィックのルーティングを追加するレイヤーとして設計されています。 Googleは2026年5月、Agent SandboxのGKEでのGA発表と合わせて、Agent Substrateをエージェント実行基盤のスタックの中でどう位置づけているかを次の図で示しています。 !Agent Substrateを中心としたエージェント基盤のレイヤー構成図。最上位にAgent Runtime/Agent Harnesses/Custom Agents&Toolsからなる Agent Application Layer、その下にSandbox Isolation(gVisor/MicroVM)を含むAgent Substrateと、Deployments/StatefulSets/LWSなどのOther k8s workloads、最下層にKubernetesとIaaS/Bare metal、Blob storageが並ぶ。 (参考: Bringing you Agent Sandbox on GKE and Agent Substrate | Google Cloud Blog, 2026-05-20) > Agent Substrate introduces a new level of abstraction, moving and placing agents in and out of running compute in real time. It incorporates Agent Sandbox's secure runtime and snapshotting capabilities, combined with a minimal control plane that works around Kubernetes's constraints. Where standard Kubernetes is optimized for thousands of long-running services, Agent Substrate is designed for millions of sub-second tool calls. (参考: Bringing you Agent Sandbox on GKE and Agent Substrate) この図の緑色のブロックに書かれているSandbox Isolation (gVisor / MicroVM)が、本記事の2.3節・3.3節で見るActorのcheckpoint/restoreの基盤であり、引用した文の通りAgent SubstrateはAgent Sandboxのセキュアなランタイムとスナップショット機能を取り込む位置づけです。 その後、2026年9月16日に GKE 向けの提供が発表されました。本記事では、この発表で示された構成も取り上げ、OSS v0.5.0 の実装と GKE 向けの提供条件を分けて整理します。 (参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16) この記事では、Agent Substrateが解決しようとしている課題、全体のアーキテクチャ、そして各コンポーネントがKubernetesの既存の仕組みのどれに相当する/何を補完しているのかを整理します。 関連する仕組みも別記事にまとめています。 - Agent Executorの概要:Task を管理し、実行を Substrate の Actor に任せる仕組み - LeaderWorkerSet と DisaggregatedSet の概要:分散推論向けに Pod グループを管理する仕組み - Agent Sandboxの概要:単一 Pod のサンドボックス環境を管理する API 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |---|---|---|---| | Agent Substrate | v0.5.0 | agent-substrate/substrate | 本記事全体のソースコード調査対象 | | Kubernetes | v1.37.1 | kubernetes/kubernetes | PodCertificateRequest API の定義・feature gate の状態を確認 | | kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージは kindest/node:v1.37.0(Agent Substrateの hack/tools/kind/go.mod が固定するバージョン) | GKE 向けの提供条件は公式ドキュメントの確認時点(2026年10月20日)の情報です。以下で扱う OSS のタグと、GKE 向けインストーラの配布バージョンが同一であるとは限りません。 :::note info Agent Substrate は v1.0 未満で、後方互換性を保証していません。本記事の実装・設定・引用は v0.5.0 に固定します。本文でリポジトリ名を省略したソースコードの引用は、すべて agent-substrate/substrate のものです。 ::: TL;DR - Kubernetes 上に Actor 専用の control plane を追加します。Worker Pod の配置・起動は Kubernetes に任せ、Actor の状態管理と Worker への割り当ては独自 API と PostgreSQL で扱います。 - 少数の Worker で多数の Actor を扱います。Worker の容量に応じた同時実行と、Suspend/Resume による時間的な多重化を組み合わせます。 - atelet と ateom が sandbox の状態を保存・復元します。gVisor または micro-VM を利用し、Actor の状態を保ちながら Worker を使い回します。保存範囲は snapshot fidelity によって変わります。 - 外部 API の認証情報を Actor の外で注入できます。egress gateway と credential provider を使う構成では、Actor に実際の API キーを渡さずに呼び出せます。 - 設計目標と実装済みの機能は区別が必要です。認可は実験的な機能として存在しますが、通常の Control RPC への強制はデフォルトで無効です。自動アイドル判定や性能値も、個別に確認する必要があります。 第1章: Agent Substrateが解決しようとしている課題 この章では、Agent Substrateがなぜ「Kubernetesに乗せるだけ」ではなくcontrol planeごと新しく作る必要があったのか、その問題意識を整理します。 1.1 エージェントワークロードの特性 Agent Substrateのアーキテクチャドキュメントは、対象ワークロードの特性を次のように説明しています。 > Agents and "agent-like" workloads are generally very bursty, spending most of their time waiting for input or events, then handling those events, then going back to waiting. The time they spend actually doing work is often very short, and the time they spend waiting can be unbounded. Because they often run untrusted logic, they are often run in sandboxes, which means they are usually single-tenant instances, and there are a great many of them. (参考: architecture.md#L17-L23) ポイントは3つです。 - 実際に処理している時間が短く、待ち時間(LLMやツール呼び出しの応答待ち)が大半を占める - 信頼できないコードを実行するためsandbox実行が前提になり、1インスタンス=1テナントになりがち - そのインスタンスの数が非常に多い この「アイドル時間が長い・数が多い・sandboxが要る」という3条件が、後述するスナップショットベースの多重化モデルの動機になっています。 1.2 Kubernetesの標準機能で足りない理由 READMEとarchitecture.mdは、Kubernetesをそのまま使わない理由を具体的に挙げています。 > * The Kubernetes API server is not designed to handle millions of resources. It excels at reconciling resources asynchronously across many controllers, but it is not so good at storing very large numbers of discrete resources, or for handling a huge volume of write traffic. > * Scheduling a workload on Kubernetes requires several asynchronous processes to converge, plus several network hops, plus image pulling and other steps, which can add up. Getting a Pod running in a couple of seconds is great when that Pod will run for hours or days, but for a workload that will run for milliseconds to low-digit seconds, that latency is unacceptable. > * State management is difficult. Kubernetes provides an API for managing state (PersistentVolumes), but it is not designed for millions of volumes, with widely varying amounts of data, being attached and detached at high Speed. (参考: architecture.md#L160-L172) これは、Agent Substrate が想定する規模と応答時間に対する、プロジェクト側の設計判断です。Kubernetes の一律の上限や、すべての環境で Pod 起動に数秒かかるという測定結果ではありません。一方で、ワーカーPodそのものの起動・ワークロード分離・インフラのプロビジョニングはKubernetesに任せたままにしています(「Agent Substrate leverages Kubernetes for infrastructure provisioning and worker lifecycle management (Kubernetes Pods)」、参考: architecture.md#L153)。Agent Substrateが新しく作っているのは、あくまで「Actorの状態管理とスケジューリングだけに特化した、もう1段速いcontrol plane」です。 なぜetcdへの大量の書き込みが台数を増やしても速くならないのかというメカニズム(Raftはリーダー1台が書き込みを確定させる仕組みであること)は、以前別記事Kubernetes の Control Plane と etcd、何台にすべきかで整理しました。ただし、Agent Substrate の設計理由を Raft だけで説明することはできません。非同期な reconciliation や Pod の準備時間も含め、Actor の再開要求で Kubernetes の更新完了を待たない構成にしている点が重要です。 第2章: 全体アーキテクチャ この章では、Actor のリソースモデルと、起動要求を受けてから応答するまでの流れを整理します。まず、GKE 向け発表の図で、Kubernetes の上に追加される機能を確認します。 !Kubernetes・Compute・Storage の上に、Gateway、Suspend/Resume、gVisor / MicroVM による分離を置く Agent Substrate の全体像。 (参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16) この図を実装上のコンポーネントへ対応づけたものが、次の脅威モデルの構成図です。 <p align="center"> <img src="raw.githubusercontent.com" width="700" alt="Substrate Architecture Diagram"> </p> Agent Substrateのアーキテクチャ図。ate-api-server・atelet・ateom・atenet-router・Object Storage・PostgreSQL(Substrate Database)が、Kubernetes上でどう配置されるかを示している。 (参考: threat-model.md#L30-L32) この図に登場するコンポーネントは、threat-model.mdの「Key Components」節で次のように説明されています。 > * ate-api-server: Actor creation and scheduling and credential issuance. > * atelet: Per-node daemon, performs snapshot/resume. > * ateom: Per-worker Pod sidecar, running inside the worker Pod. ... > * Worker: Preprovisioned Pods that actors get scheduled to. > * Actor: The core compute primitive, gets scheduled to/from worker via Run for cold start and Resume for snapshot resume. (参考: threat-model.md#L36-L40) 以降の節では、この図の個々のコンポーネントがどう実装されているかを、ソースコードから具体的に見ていきます。 2.1 ActorとWorkerの多重化モデル Agent Substrateの用語は次のように定義されています。 - ActorTemplate: actorの「クラス」の定義(コンテナイメージやスナップショット設定)。immutableで、Kubernetesオブジェクトではなく、ate APIのgRPC経由で作成され、control plane側のデータベースに保存される(参考: glossary.md#L10-L19) - WorkerPool: Kubernetesの CRD。atecontrollerによって Deployment へreconcileされる(参考: glossary.md#L21-L23) - Atespace: Actor が属する論理的なグループと識別範囲。Kubernetes namespaceではなくglobal-scopedなcontrol plane DB上のレコード(参考: glossary.md#L38-L43) - Actor: ActorTemplateから作られる1インスタンス。suspend/resumeの単位で、resumeのたびに別のworkerへ移ることがある(参考: glossary.md#L45-L48) - Worker: WorkerPool内の1つのworker Podを表すレコード。1つのWorkerは複数のActorを同時にホストする(参考: glossary.md#L50-L52) これらの関係は、architecture.mdのクラス図に整理されています。 classDiagram namespace kube-apiserver { class WorkerPool { <<CRD>> } class Deployment class WorkerPod { ateom runsc } } namespace ate-api-server { class ActorTemplate { <<record>> } class Actor { <<record>> status snapshotRefs } class Worker { <<record>> actorAssignments podIP } } ActorTemplate ..> WorkerPool : worker_selector (labels) WorkerPool ..> Deployment : reconciled by atecontroller Deployment "1" *-- "*" WorkerPod : manages Actor "*" --> "1" ActorTemplate : actor_template Actor "0..*" --> "0..1" Worker : runs on Worker "1" --> "1" WorkerPod : maps to API リソースの関係。元の図を基に、Worker が複数 Actor をホストできる実装に合わせて多重度を補正したもの。 (参考: architecture.md#L268-L303) WorkerPoolはkube-apiserver、ActorTemplate/Actor/Workerはate-api-serverと、管理するAPIサーバーが分けて設計されている点が、このアーキテクチャの核心です。WorkerPoolはKubernetesのDeploymentとして実体化されますが、「どのActorがどのWorkerで動いているか」という高頻度に変化する状態は、Kubernetesリソースではなくate-api-server側のレコードとして管理されます。 2.2 2層のリソースモデル: 宣言的なCRDと、頻繁に更新される状態の専用ストア architecture.md は、この使い分けの理由を次の 3 点にまとめています。引用中の 100ms は設計上の目標であり、本記事で測定した性能値ではありません。 > 1. Scalability: Offloading the high-frequency, high-scale management of millions of actors to a specialized state store prevents overwhelming the primary cluster control plane with thousands of updates per second. > 2. Latency: Achieving 100ms resumption requires low-latency state lookups and atomic worker assignments that bypass the eventual consistency and variable latency of standard Kubernetes API servers. > 3. Governance: Keeping infrastructure resources (WorkerPools, SandboxConfigs) as Kubernetes objects allows platform teams to apply familiar RBAC, auditing, and policy enforcement to the underlying infrastructure, while workload definitions (ActorTemplates) are managed through the substrate API instead, which authenticates callers itself but does not yet implement authorization (参考: architecture.md#L247-L261) 「インフラ寄りのリソース(WorkerPool・SandboxConfig)はKubernetesに残してRBACや監査を使い回し、ワークロード定義(ActorTemplate)やその実行状態(Actor/Worker)はetcdの外に出して専用ストアに置く」という切り分けです。ただし、このドキュメントの「認可はまだ実装されていない」という説明は、同じタグの実装と一致していません。v0.5.0 には OpenFGA を使う認可機能があり、--experimental-enable-authz (デフォルト false) で通常の Control RPC にも認可を強制できます。AccessPolicy RPC には、このフラグによらずチェックが行われます。 (参考: main.go#L84-L88、interceptor.go#L28-L60) 2.3 Actorのライフサイクル(Resume/Suspend) !Worker 上の Active な Agent Environment と、Storage に状態を保存した Idle な環境を Suspend/Resume で切り替える図。 (参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16) 図の Active は実行中、Idle は状態を保存して計算資源から外した環境を表しています。単にプロセスを待機させる構成とは異なり、再開時には保存した状態を Worker へ復元します。図は概念図であり、Worker と Actor が常に1対1という意味ではありません。 リクエストを受けてからActorが応答するまでの流れは、architecture.mdのHigh-Level Designに説明されています。 > When a request comes in for that actor instance, the Agent Substrate's proxy intercepts the request. It looks in the control-plane to determine if that actor is currently running or not. If not, it assigns the actor to a worker, which involves telling a node-level component (the "atelet") to restore the actor's most-recent snapshot into the worker on that node. The request is then forwarded to the actor. > > Eventually, the user (or the higher-level system) is done with the actor, or it has been idle for a while. It can request the Agent Substrate to suspend the actor, taking a snapshot and freeing up the worker. The next time a request comes in for that actor, it will be resumed again, possibly on a different worker. (参考: architecture.md#L193-L204) つまり、Envoyベースのルーター(atenet-router)がリクエストごとにcontrol planeへ再開(Resume)を依頼し、Actorが止まっていればcontrol planeがスナップショットから復元してから、リクエストが転送される、という流れです。5.1節で見る通り、atenet-router自身はActorが動いているかを判断していません。Actorは特定のworkerに固定されず、suspend/resumeのたびに別のworkerへ移動する可能性があります。 第3章: 主要コンポーネントの役割とKubernetesとの対応関係 ここからは、各コンポーネントの実装コードを見ながら、それぞれがKubernetesの既存コンセプトのどれに対応する/何を代替・補完しているのかを整理します。先に全体像として、主要コンポーネントと対応関係の一覧を示します。 | コンポーネント | 役割 | Kubernetesとの対応関係 | |---|---|---| | ate-api-server(ateapi) | Actorのライフサイクル管理・スケジューリング・スナップショット調整を行う独自control plane | Kubernetes とは別の Actor 用 API・scheduler・workflow をまとめた制御層。状態は PostgreSQL に保存 | | atecontroller | WorkerPool/SandboxConfigなどのCRDをDeployment等へreconcileする | sigs.k8s.io/controller-runtime を使った標準的な Kubernetes Controller | | atelet / ateom | ノードごとのDaemonSet(atelet)とworker Pod内のsandbox駆動プロセス(ateom)で、sandboxのcheckpoint/restoreを行う | Worker Pod の内側で Actor の sandbox を管理する追加の実行層 | | atenet(router / egress) | Actorの起動要求(ingress)とegressトラフィックのルーティング・制御を行う | Service/Ingress/Gatewayに相当するが、宛先Actorの起動待ち合わせを行う追加のオーケストレーション層を持つ | | podcertcontroller | コンポーネント間mTLS用の短命証明書を発行する | PodCertificateRequest APIに対する、Kubernetes本体にまだ存在しないsignerの実装 | 上の表は役割ごとに整理したもので、agent-substrate/substrateリポジトリには、これ以外にもsidecarやインストーラ、CLIなどが同梱されています。v0.5.0のcmd/配下とtools/配下を、実体ごとに並べると次の通りです。 | ディレクトリ | 種別 | 役割 | 本記事の節 | |---|---|---|---| | cmd/ateapi | control plane(gRPC API server) | Actor/Workerのライフサイクル管理とスケジューリング | 3.1節 | | cmd/atecontroller | Kubernetes Controller | WorkerPoolなどのCRDをDeploymentなどへreconcileする | 3.2節 | | cmd/atelet | DaemonSet | ノード上のworker Podを監督し、スナップショットと状態転送を調整する | 3.3節 | | cmd/ateom-gvisor | worker Pod内のプロセス | runscのcheckpoint/restoreを実行する | 3.3節 | | cmd/ateom-microvm | worker Pod内のプロセス | ateom-gvisorのmicro-VM版。Cloud Hypervisorの仮想マシンとしてActorを動かす | 3.3節 | | cmd/objectstore-plugin | sidecar | GCS/S3へのスナップショット転送を担う。nodeモードはatelet、controlモードはate-api-serverのsidecarとして動く | 3.3節 | | cmd/atenet | Deployment(router)など | Envoyのルーティングとproxy sidecarを提供するネットワーキングのデーモン | 3.4節 | | cmd/dataplane/envoy | コンテナイメージ | atenet-egressが使うEnvoyのイメージ。egress policyを強制するRust製のEnvoy Dynamic Moduleを含む | 3.4節, 4.2節 | | cmd/credential-provider/kubernetes-secrets | リファレンス実装 | Kubernetes Secretを解決するegress credential provider | 4.2節 | | cmd/podcertcontroller | Deployment | PodCertificateRequestのsigner(Kubernetes本体にまだ無いものを補う「polyfill」) | 3.5節 | | cmd/kubectl-ate | CLI | Agent Substrateのリソース(Actorなど)を操作する | 7.3節 | | cmd/ate-setup | CLI(インストーラ) | クラスタへのAgent Substrateのインストールと削除 | 6章 | | cmd/benchmarking | 負荷試験用ワークロード | RAM・ディスク・ファイルディスクリプタを任意に消費するgluttonなど | 本記事では扱わない | | tools/setup-gcp | CLI(プロビジョニング) | GKE・GCS・IAMなどGCP側のリソースを用意する | 6章 | (参考: README.md#L260-L272、cmd/objectstore-plugin/main.go#L15-L19、cmd/ate-setup/README.md#L3、cmd/dataplane/envoy/README.md) READMEの「Tour」節の一覧には、objectstore-plugin・credential-provider・dataplane・ate-setupは載っていません。この4つは、cmd/配下のディレクトリと各ディレクトリのREADMEやソースコード冒頭のコメントから補っています。 3.1 ate-api-server: 独自control plane ate-api-server(バイナリ名 ateapi)は、Actorのライフサイクル全体を司るcontrol planeです。 > ate-api-server (binary ateapi): the control plane. It owns the Actor lifecycle, schedules Actors onto Workers, and coordinates their snapshots, all backed by the state store. The kubectl-ate CLI talks to it. (参考: glossary.md#L56-L58) 実装(cmd/ateapi/main.go)を見ると、PostgreSQLへの接続(connectStore)、Kubernetesクライアントの生成(newKubeClients、WorkerPool/SandboxConfig/CSIDriverConfigのinformerはこのクライアントから取得する)、mTLS用のサーバー証明書構築(buildServerCreds)などを単一のgRPCサーバープロセスが担っています(参考: main.go#L509-L521、main.go#L560-L576、main.go#L578-L586)。 templateReconciler := controlapi.NewActorTemplateReconciler(persistence, controlSrv, *templateResyncInterval) templateReconciler.Start(shutdownCtx) workerAssignmentReconciler := controlapi.NewWorkerAssignmentReconciler(persistence, workerCache) workerAssignmentReconciler.Start(shutdownCtx) (参考: main.go#L326-L330) ここで出てくるActorTemplateReconcilerやWorkerAssignmentReconcilerは、名前に"Reconciler"と付いていますが、controller-runtimeのReconcilerとは別物です。Kubernetesのカスタムリソースを監視するものではなく、ate-api-serverプロセス内部で動く独自のループで、前者はActorTemplateから「golden snapshot」を作るフローを、後者はateomの再起動で失われたActorをクラッシュ扱いにする処理を担っています。 Kubernetes との対応関係: ate-api-server は Actor/Worker 専用の gRPC API server で、スケジューリングとライフサイクル調整も担当します。状態の保存先は PostgreSQL です。kube-apiserver と一対一に対応するものではなく、Kubernetes とは別に Actor の制御を引き受ける位置づけです。 3.2 atecontroller: WorkerPool CRDのController atecontrollerは、ここまでのコンポーネントとは対照的に、Kubernetesの標準的なControllerパターンをそのまま踏襲しています。sigs.k8s.io/controller-runtime の ctrl.NewManager を使い、WorkerPoolReconciler など4つのReconcilerを登録しています(参考: main.go#L233-L276)。 WorkerPoolReconciler.Reconcile は、典型的な「Get → DeletionTimestampチェック → reconcile処理」の構造です。 func (r *WorkerPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := log.FromContext(ctx) wp := &atev1alpha1.WorkerPool{} if err := r.Get(ctx, req.NamespacedName, wp); err != nil { if k8errors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, fmt.Errorf("failed to get worker pool %q: %w", req.NamespacedName, err) } if !wp.GetDeletionTimestamp().IsZero() { log.Info("WorkerPool is being deleted") return ctrl.Result{}, nil } if err := r.reconcileWorkerPool(ctx, wp); err != nil { log.Error(err, "Failed to reconcile worker pool") return ctrl.Result{}, err } return ctrl.Result{}, nil } (参考: workerpool_controller.go#L105-L129) 実際の処理(reconcileWorkerPool)は、WorkerPoolをDeploymentへ変換するだけでなく、WorkerPoolが参照するSandboxConfig(クラスタスコープのCRDで、gVisorやmicro-VMのランタイムバイナリを保持する)に保護用のfinalizerを付与する処理も行っています。コメントにはその設計意図が明記されています。 > A class that switches to a new SandboxConfig does so in two status writes. This makes sure status never names a config after its protection finalizer was released. (参考: workerpool_controller.go#L134-L136) SandboxConfigへのfinalizer付与をWorkerPool側のReconcilerが行い、解放は別のSandboxConfigProtectionReconcilerが行うという2段階の設計で、Owns(&Deployment{}) / Watches(&SandboxConfig{}, ...) という標準的なOwns/Watchesパターンで結び付けられています(参考: workerpool_controller.go#L416-L425)。 Kubernetes との対応関係: atecontrollerは「CRD→下位リソースへの変換」というDeployment Controllerと同じパターンを踏襲した、controller-runtime を使った標準的な Kubernetes Controller です。Agent Substrateの他のコンポーネントが独自のcontrol planeを持つのに対し、WorkerPoolとSandboxConfigに関してはKubernetesネイティブな仕組みをそのまま使っています。 3.3 atelet / ateom: ノードエージェントとsandboxのcheckpoint/restore ノード側の実行基盤は、DaemonSetのateletとworker Pod内で動くateomの2層構造です。 > atelet: A lightweight supervisor running on each node as a DaemonSet. It acts as the "Herder," managing a pool of physical pods and communicating with the Control Plane. > > ateom: A specialized sandbox-herder container image — one per sandbox class (ateom-gvisor, ateom-microvm) — that runs inside the physical worker pods. It provides a gRPC interface for atelet to trigger RunWorkload, CheckpointWorkload, and RestoreWorkload operations. This separation ensures that the physical pod lifecycle remains decoupled from the sandboxed agent process. (参考: architecture.md#L324-L326) atelet はノードごとの DaemonSet として動き、イメージキャッシュやCSIボリュームの管理、kubeletのcredential-provider機構を再利用したイメージPull認証を実装しています(--image-credential-provider-config、参考: main.go#L103-L104)。一方でスナップショット本体のアップロード/ダウンロードはatelet本体ではなく、サイドカーの「object-store plugin」が担います。 > External snapshots move through an object-store plugin, a sidecar that serves the objectstore.v1 gRPC API over a Unix socket in its pod. atelet's sidecar (NodeProvider) uploads and fetches a snapshot's files; ate-api-server's sidecar (ControlProvider) copies and deletes whole snapshots. Neither main process talks to object storage for snapshots. (参考: architecture.md#L330) スナップショット転送は object-store plugin に分離され、atelet・ate-api-server 本体はその gRPC API を呼びます。オブジェクトストレージへのアクセス設定はプラグイン側で扱います。これは本体プロセスが mTLS や DB 接続用の認証情報も持たない、という意味ではありません。 sandboxのcheckpoint/restoreは、ateomがgVisorのrunscまたはCloud Hypervisor(micro-VM)を直接操作して実装しています。 > Lifecycle Management: The ateom process invokes the sandbox runtime to checkpoint or restore processes within the physical pod boundaries — runsc for gVisor, or the Kata + Cloud Hypervisor stack for micro-VMs. (参考: architecture.md#L328) gVisor版はcmd/ateom-gvisor/runsc.goのcmdCheckpoint/cmdRestoreがrunsc checkpoint/runsc restoreコマンドを直接起動しています。micro-VM版はcmd/ateom-microvm/checkpoint.goのCheckpointWorkloadとKata Containers連携パッケージ(internal/kata)を使い、Cloud HypervisorのVM状態をスナップショットし、復元時はuserfaultfdによるメモリのデマンドページングで読み戻しています(参考: architecture.md#L338)。 Kubernetes との対応関係: atelet/ateom は、Worker Pod の内側で動く Actor の sandbox を管理します。Worker Pod 自体の実行は引き続き kubelet とコンテナランタイムが担当します。 Kubernetes にも Kubelet Checkpoint API と CRI の checkpoint 操作はあります。Agent Substrate の特徴は checkpoint の存在自体ではなく、保存先・Worker の割り当て・restore・ネットワークの再接続までを Actor のライフサイクルとして調整することです。 (参考: Kubelet Checkpoint API) 3.4 atenet: ルーティングとegress atenetは、ActorのResume要求とトラフィックのルーティングを兼ねるネットワーキングスタックです。 > Ingress Routing: atenet-router runs Envoy with an ext_proc external processor. A higher-order system connects to the router and supplies the Actor target in ate-target-actor as <atespace>/<actor>. The ext_proc calls the Control Plane to resume the Actor and resolve its current worker assignment. (参考: architecture.md#L344-L349) 実装では、cmd/atenet/internal/router/ingress/ingress.goがActorResumer.ResumeActorを呼び出しており、ここでate-api-serverへResumeActor RPCを投げています。つまりatenet-routerは、Envoyのext_proc拡張機構を使って「ルーティング先が止まっていれば先にResumeしてから転送する」という、通常のKubernetes Service/Ingressにはないオーケストレーションを行っています。 workerへの転送は、mTLSのCONNECTトンネル経由です。 > After resolving the assignment, atenet-router opens an mTLS CONNECT tunnel to the worker's atunnel listener on port 8443. atunnel, hosted by ateom, forwards the request to the active Actor over its private veth interface. Worker pod port 80 is not a direct Actor ingress path. (参考: architecture.md#L351-L354) Kubernetes との対応関係: KubernetesのService/Ingress/Gatewayに相当する「エンドポイントへのルーティング」をEnvoyで行う点は同じですが、通常のServiceが単純な負荷分散であるのに対し、atenet-routerは「宛先のActorが止まっていれば先に起動する」という追加のオーケストレーション層を持っています。 3.5 podcertcontroller: PodCertificateRequestのsigner podcertcontrollerは、コンポーネント間の相互TLS認証に使う短命な証明書を発行するコントローラーです。 > Command podcertcontroller is a pod certificate controller that implements three signers. > > - servicedns.podcert.ate.dev/identity: Issues certificate for Kubernetes service DNS names, backed by a local CA. > - podidentity.podcert.ate.dev/identity: Issues certificates equivalent to KSA tokens, backed by a local CA. > - postgres.podcert.ate.dev/identity: Issues PostgreSQL login certificates to ate-api-server. (参考: main.go#L15-L22) このコントローラーは、certificates.k8s.io/v1 の PodCertificateRequest を監視し、証明書の発行・拒否を判断する signer です。API 自体は Kubernetes v1.37 で GA になりました。 PodCertificateRequest: { {Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha}, {Version: version.MustParse("1.35"), Default: false, PreRelease: featuregate.Beta}, {Version: version.MustParse("1.37"), Default: true, PreRelease: featuregate.GA}, // LockToDefault in 1.38 }, (参考: kube_features.go#L1945-L1949) PodCertificateRequestのAPI定義には、signer名についてこう書かれています。 > signerName indicates the requested signer. > > All signer names beginning with kubernetes.io are reserved for use by the Kubernetes project. There is currently one well-known signer documented by the Kubernetes project, kubernetes.io/kube-apiserver-client-pod, which will issue client certificates understood by kube-apiserver. It is currently unimplemented. (参考: types.go#L437-L444) つまり、PodCertificateRequestというAPI自体(kubeletがリクエストを作り、証明書をPodへprojected volumeとしてマウントする仕組み)はKubernetes本体がGA機能として持っていますが、API 定義で挙げられている組み込み signer kubernetes.io/kube-apiserver-client-pod は未実装です。PodCertificateRequest を処理する signer は、利用者や外部コンポーネントが用意します。podcertcontrollerは、このsigner不在の部分を、Substrate独自の3種類のID(Service DNS、Pod Identity、PostgreSQLログイン証明書)向けに自前で実装したものです。READMEがこれを次のように説明しているのは、この実装状況と整合しています。 > cmd/podcertcontroller: A "polyfill" that provides Pod Certificate signers that will eventually ship in upstream Kubernetes (with different names). (参考: README.md#L268-L269) Kubernetes との対応関係: podcertcontrollerは、KubernetesのCertificateSigningRequest(CSR)機構と同じ発想の、PodCertificateRequestに対するsigner controllerです。kube-controller-managerが提供する組み込みsignerがまだ存在しない領域を、Substrate固有の3種類のID向けに自前実装で埋めています。 第4章: セキュリティモデル この章では、sandbox の分離と、外部 API の認証情報を Actor に渡さずに使う仕組みを整理します。設計原則、実装状況、デモの設定は分けて説明します。 4.1 常時sandbox実行とゼロクレデンシャル !Agent Environment の周囲に Identity-based Ingress/Egress、Secrets Management、Kernel Isolation、Rapid Suspend/Resume を配置した図。 (参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16) カーネルの分離と通信制御、認証情報の管理は別々の役割です。sandbox を選ぶだけで外部 API の資格情報まで保護されるわけではなく、4.2節の gateway と credential provider の構成も必要になります。 security.mdのStatusテーブルには、各セキュリティ原則の実装状況が「Met」「In Progress」「Future」「Out of Scope」の4段階で整理されています。それぞれの定義は次の通りです。 - Met: Fully implemented in the current codebase. - In Progress: Partially implemented or under active development. - Future: Planned architecture and roadmap capabilities not yet implemented. - Out of Scope: Not substrate's job, handled by a layer outside substrate. (参考: security.md#L30-L33) 以下は、そのうちゼロトラストの中核にあたる4つの原則です。 | 原則 | 内容 | 状態 | |---|---|---| | Always Sandboxed | すべてのActorは強力なコンピュートsandbox内で実行される。Linuxコンテナ単体では不十分 | Met (gVisor, CHV micro-VM) | | Zero Ambient Credentials | Actor に基盤の権限や外部 API キーを暗黙に引き継がせない。明示的に環境変数へキーを渡すデモとは区別する | Met (no credentials inside sandbox) | | No Access by Default | Actor から control plane へのアクセスを制限する。worker ノード上の atelet など、基盤コンポーネントに必要な通信とは区別する | Met (Loopback, private netns)<br>In-Progress (DNS filtering) | | Fail-Closed Policy-Enforced Egress Gateway | outbound トラフィックを共有 egress proxy で制御する。fail-closed は設計原則であり、UDP passthrough など未完了の項目がある | Met (atenet-egress)<br>In-Progress (fail closed, UDP currently passthrough) | (参考: security.md#L41、security.md#L59、security.md#L75、security.md#L77) :::note warn 同じテーブルでは、Atespace 単位の API 認可 (ReBAC) は「In Progress」、Node Blast Radius Containment やストレージの暗号化は「Future」です。認可のコードは存在するものの、通常の Control RPC への強制は実験的なフラグで有効化する段階です (2.2 節)。zero-trustという設計思想自体は固まっていますが、個々の統制がすべて実装済みというわけではありません。 ::: 4.2 egress gatewayによる認証情報注入 Actorが外部APIを呼び出す際の認証情報は、Actor自身ではなくegress gatewayが注入します。 > Egress credential injection puts a secret into an actor's outbound HTTPS requests without the actor ever holding it. ... The actor never holds the secret. It sends the header with a placeholder value, which the gateway discards, so it cannot read, snapshot, or exfiltrate the secret, nor choose the value that leaves the cluster. (参考: egress-credential-injection.md#L1-L15) 具体的な流れは次の4ステップです(docs/egress-credential-injection.mdのmermaid図および本文を要約)。 1. Actorがプレースホルダー値を入れたヘッダー付きでHTTPSリクエストを送る 2. egress gatewayがActorのTLSを終端し、EgressPolicyのreplace_headersルールに一致すればcredential providerへ問い合わせる 3. credential providerがそのActorにその認証情報を渡してよいか判断し、シークレットストアから読み出す(シークレットストアへの直接アクセス権を持つのはprovider だけ) 4. egress gatewayがプレースホルダーを実際の認証情報に置き換え、新しいTLSコネクションで送信する HTTPS を終端するため、Actor 側が egress gateway の CA を信頼する設定も必要です。また、注入対象のヘッダーがリクエストに含まれなければ、認証情報の取得・置き換えは行われません。 この仕組みは API キーそのものを Actor から隔離しますが、許可された API を使った不適切な操作まで自動的に防ぐわけではありません。宛先や操作の権限、アプリケーション側の認可も必要です。 (参考: egress-credential-injection.md#L41-L54) リファレンス実装であるcmd/credential-provider/kubernetes-secretsはKubernetesのSecretを解決するcredential providerで、デフォルトでは空のdeny-allポリシーから始まり、atespaceごとに読み取り可能なnamespaceを明示的に許可する必要があります(参考: README.md#L132-L136)。Actorが直接Secretを読みに行くことはなく、egress gatewayを経由した時だけ、しかも許可された宛先向けのリクエストに限ってクレデンシャルが挿入される設計です。 第5章: AIエージェントがタスクを実行するまでの流れ ここまでの章は、Actorという「箱」がどう起動・停止するか(2.3節)、egressがどう認証情報を守るか(4.2節)を別々に説明してきました。この章では、実際にAIエージェントとして動くActorが、利用者からのリクエストを受けてから外部LLM APIを呼び出し、応答を返すまでの一連の流れを1つの図にまとめます。 5.1 HTTP リクエストを起点に Actor を Resume する場合 Counter デモのように atenet-router 経由でリクエストを受ける構成では、Actor の起動・復元を待ってから通信を転送します。以下は、その流れに外部 LLM API の呼び出しを組み合わせた例です。Counter デモ自体が LLM を呼ぶわけではありません。 sequenceDiagram actor User as 利用者 / 上位システム participant Router as atenet-router participant API as ate-api-server participant Atelet as atelet participant Tunnel as ateom / atunnel participant Agent as Actor participant Egress as atenet-egress participant Cred as credential provider participant LLM as 外部 LLM API User->>Router: HTTP リクエスト (ate-target-actor) Router->>API: ResumeActor alt Actor が停止中 API->>Atelet: 復元要求 Atelet->>Tunnel: RestoreWorkload Tunnel-->>Atelet: 復元完了 Atelet-->>API: 完了 else Actor が実行中 Note over API: 既存の割り当てを返す end API-->>Router: Worker の割り当て Router->>Tunnel: mTLS CONNECT トンネルで転送 Tunnel->>Agent: リクエストを転送 Agent->>Egress: HTTPS (プレースホルダーのヘッダー) Egress->>Cred: 認証情報を要求 Cred-->>Egress: 認可後、実際の API キーを返す Egress->>LLM: ヘッダーを差し替え、別の TLS 接続で送信 LLM-->>Egress: 応答 Egress-->>Agent: 応答 Agent-->>Tunnel: 処理結果 Tunnel-->>Router: 応答 Router-->>User: 応答 Note over User,API: 上位システムなどが SuspendActor を要求すると保存・解放する 前半は architecture.md の Resume シーケンス、後半は egress credential injection を基に簡略化しています。Actor がスナップショットを使わず cold boot する場合などは省略しています。 atenet-router は転送前に ResumeActor を呼び、実行中なら既存の割り当てを使います。停止中なら、control plane が復元を調整してから割り当てを返します。ResumeActor は実行中の Actor に対して冪等です。 (参考: workflow_resume.go#L67-L113) 外部 LLM API との通信では、Actor 側と egress gateway の間、gateway と LLM API の間に別々の TLS 接続があります。gateway が HTTPS を終端してヘッダーを置き換えるため、エンドツーエンドの 1 本の TLS 接続ではありません。 応答を返しただけでは、Actor が自動的に Suspend されるとは限りません。いつ処理が終わったか、どの待機区間で Suspend できるかは、上位システムやワークロードとの連携も含めて設計する必要があります。LLM の応答待ち中であることと、安全に Suspend できることも同じではありません。 5.2 Claude Code デモで実装されていること すべてのエージェントが外部からのHTTPリクエストを起点に動くわけではありません。READMEが挙げているデモの1つclaude-code-multiplexは、3体のClaude Codeエージェント(luna/mars/orion)を2 Podのworker poolで共有する構成です。 > Three Claude Code agents (luna, mars, orion) registered as Substrate actors. > A WorkerPool of two pods. > > This demo uses the ateapi control plane to manage Actors and does not send workload HTTP traffic through atenet-router, so it does not use the ate-target-actor routing header. (参考: demos/claude-code-multiplex/README.md#L14-L21) このデモのActorTemplateは、タスクの内容(TASK)と実行間隔(INTERVAL_SECONDS)を環境変数としてあらかじめ焼き込んでおくだけのシンプルな作りです。 env: - name: ACTOR_NAME value: "luna" - name: TASK value: "Tell me one short, surprising fact about the Moon. One sentence." - name: INTERVAL_SECONDS value: "45" - name: ANTHROPIC_API_KEY value: "${ANTHROPIC_API_KEY}" (参考: agent-luna-template.yaml.tmpl#L34-L42) コンテナのエントリポイントは、claude --printでタスクを1回実行してはINTERVAL_SECONDS秒だけアイドルになる、というループを回し続けるだけです。 while true; do ... claude --print "${TASK}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] claude exited non-zero" ... sleep "${INTERVAL_SECONDS}" done (参考: run.sh#L38-L49) READMEは、このアイドル区間こそが多重化の余地になると説明しています。 > The idle window is what substrate uses to suspend this actor and multiplex its worker onto another actor. (参考: run.sh#L17-L19) ただし、このデモの README の説明と UI の実装は区別が必要です。README はタスクの投入・完了と自動 Suspend を説明していますが、v0.5.0 の UI は次の動作になっています。 | 処理 | UI の実装 | |---|---| | 「Give a task」 | Actor とタスクを選び、UI サーバー内の assignment に記録する | | queued / running / completed の表示 | 作成時刻とランダムに決めた待ち時間・実行時間から計算する | | Actor の実行状態・ログの表示 | ate API と Kubernetes API から取得する | giveTask はタスクを Actor に送信したり、ResumeActor / SuspendActor を呼び出したりしません。computeState にも、タスク状態が時間経過による表示であり、Substrate 側の状態ではないとコメントされています。 (参考: server.go#L252-L315) したがって、デモ画面のタスク状態表示 (queued / running / completed) が切り替わっても、LLM タスクの実行完了や自動多重化が行われたことを示すわけではありません。このデモから読み取れるのは、Claude Code を起動する ActorTemplate と、周期的に claude --print を実行するワークロード、状態を表示する UI の構成です。Suspend/Resume の動作確認は、7.3 節のように実際の API 操作と Actor の状態で確かめます。 5.3 エージェントが外部LLM APIを呼び出す部分の実体: EgressPolicy 5.1 節の通信では、EgressPolicy が到達先とヘッダー置き換えのルールを定めます。参照された認証情報をその Actor に使わせてよいかは、credential provider 側でも判断します。ate APIのプロトコル定義では、ルールに一致しない通信はすべて拒否されると明記されています。 > Rules are an unordered set of allows. Traffic is denied when no rule matches. (参考: ateapi.proto#L609-L610) したがって、Actorが外部のLLM API(例: api.anthropic.com)へHTTPSで到達するには、そのホスト名を許可するEgressPolicyを別途作成しておく必要があります。ドキュメントが挙げている例は、api.example.comへのHTTPSを許可しつつAuthorizationヘッダーを実際の認証情報に差し替えるものです。 rules: - https: hostnames: ["api.example.com"] effects: replaceHeaders: - header: Authorization prefix: "Bearer " credentialUri: ate-secret://k8s.io/default/ns1/example-api/token (参考: egress-credential-injection.md#L69-L76) EgressPolicy は ActorTemplate の設定ではなく、Actor に対応づけて作成するリソースで、kubectl ateから個別に作成・更新します。 # Create an egress policy. kubectl ate create egress-policy <actor-name> -a <atespace> -f policy.yaml (参考: cmd/kubectl-ate/README.md#L247-L253) :::note info 5.2節のclaude-code-multiplexデモは、ANTHROPIC_API_KEYをActorTemplateの環境変数にそのまま埋め込む方式を取っており、4.2節で説明したegress credential injection(Actorはプレースホルダーしか持たず、egress gatewayが認証情報を差し替える方式)とは異なります。どちらの方式でも、Actorがapi.anthropic.comのようなホストにHTTPSで到達するには、該当ホストを許可するEgressPolicyがそのActorに設定されている必要がある点は共通です。 ::: 第6章: 構築にあたって追加で用意するもの ここまでの章で、Agent SubstrateはKubernetesのPod・CRD・オートスケーリングの上に成り立っていると説明してきましたが、実際に構築するには、Kubernetes クラスタに加えていくつかの周辺コンポーネント・前提条件を別途用意する必要があります。この章では、7章のkindでの検証を通じて実際に目にしたものも含めて、その一覧を整理します。 | 追加で用意するもの | 役割 | Agent Substrate側の扱い | |---|---|---| | PostgreSQL | Actor/Workerの頻繁に更新される状態を保持する専用state store(2.2節) | 開発/kind向けにはhack/install-ate-kind.shがPostgreSQLをPod内に同梱してデプロイする。本番では外部DBの接続文字列(ATE_API_POSTGRES_OWNER_CONNECTION_STRING等)を運用者が用意する必要がある | | オブジェクトストレージ(GCS/S3) | Actorのスナップショット(checkpoint)を永続化する保存先 | 外部スナップショットの保存先が必要。通常のインストールでは storage.bucketName (BUCKET_NAME) を指定し、kind 環境ではスクリプトが ate-snapshots を設定する。kind 環境ではS3互換のrustfsをクラスタ内に建てて代用する | | egress credential provider | Actorへの認証情報注入(4.2節)の実体となるSecret解決サービス | --credential-providerの指定自体が必須(注入を使わない場合も{"enabled":false}の明示が必要)。参照実装はKubernetes Secretを解決するkubernetes-secretsで、{"name":"k8s.io"}を指定するとインストーラが同梱のものをデプロイする | | gVisor / micro-VMのsandboxバイナリ | Actorを実際に動かすsandboxランタイム本体 | クラスタスコープのSandboxConfigリソースで指定する(7.2節で例示)。デフォルト設定はgs://gvisor/...という公開GCSバケットから取得する | | Envoy または agentgatewayのデータプレーンイメージ | atenet-router/atenet-egressが使うプロキシ本体 | --atenet-dataplaneでenvoy(カスタムビルドが必要)かagentgateway(公開イメージをpull)かを選ぶ | | オブジェクトストレージへのアクセス権限 | atelet・ate-api-serverのsidecar(objectstore-plugin)がスナップショットを読み書きするための認証 | プラグインはPodが持つ認証情報(ambient credentials)でGCSまたはS3にアクセスするため、利用するストレージに合わせてその権限をプラグインが使えるように設定する必要がある。GKE(GCS)の場合は、Workload Identity連携のIAMバインディングが前提で、無いと最初のsuspend/resumeでGCSから403が返る。kindではrustfs+ローカル認証情報で代用され、この手当ては不要 | | ストレージ用のCSIドライバ(条件付き) | CSI経由のVolume(永続ストレージ)をActorにアタッチする場合にのみ必要 | 今回使ったdurableDirのようなデフォルトのVolumeタイプには不要。使う場合は、通常のKubernetes CSIと異なりControllerをネットワーク越しのgRPCとして公開する必要があるなど、独自の追加要件がある | PostgreSQLが必須である点は、ドキュメントにも明記されています。 > External databases are never provisioned by ateapi or ate-setup. Their operators must create the database identities and schema described below before deploying Substrate. (参考: postgres.md#L8) オブジェクトストレージのバケット名(storage.bucketName/BUCKET_NAME)も、設定一覧の表で他の多くの設定のようなデフォルト値(—)を持たない項目として記載されています(参考: operator-install.md#L314)。 なお、3.5節のpodcertcontrollerが使うPodCertificateRequest・ClusterTrustBundleは、v1.37でGAになりデフォルトで有効です(PodCertificateRequestは3.5節、ClusterTrustBundleはkube_features.go#L1417-L1421を参照)。本記事はKubernetes v1.37以降を対象としているため、この2つのAPIを有効化するための追加作業はなく、表にも含めていません。podcertcontroller自体も、Agent Substrateがマニフェスト(manifests/ate-install/pod-certificate-controller.yaml)で同梱しており、別途用意する必要はありません。 オブジェクトストレージへのアクセス権限が無いとどうなるかは、GKE(GCS)で構築する場合について、セットアップツールのドキュメントに明記されています。 > On a fresh GCP project these bindings do not exist, and nothing else creates them — without them atelet cannot read or write external snapshots (403 from GCS on the first suspend/resume), ate-api cannot copy or collect them, and nodes cannot pull images. (参考: tools/setup-gcp/README.md#L154-L159) egress credential providerについても、「指定しない」という選択肢自体が無いことが明記されています。 > The provider is selected when the egress gateway is deployed, with --deploy-ate-system or --deploy-atenet, for either the Envoy or agentgateway dataplane. The selection is required: an install that wants no injection says so with {"enabled":false}. (参考: egress-credential-injection.md#L128-L131) :::note info Actorに永続ストレージをアタッチするためのストレージ用CSIドライバ(CreateVolumeなどを持つもの)については、Agent Substrateが通常のKubernetesとは異なる要件を課す点が特徴的です。CSI Controllerを「Podの中のUnixソケット」ではなく「クラスタネットワーク越しのgRPCサービス」として公開する必要があり、ドキュメントはこれを通常のKubernetesのCSI運用との違いとして明示しています。 > Substrate directly orchestrates storage operations from its control plane (ateapi) and node daemon (atelet), introducing specific deployment and networking requirements for both the CSI Controller and the CSI Node DaemonSet. (参考: csi-deployment.md#L5) ::: 7章で実際にkindクラスタ上に構築した際も、ここに挙げたPostgreSQL・オブジェクトストレージ(rustfs)・SandboxConfig・credential providerはすべて何らかの形で登場しています。kind向けのインストーラはこれらの多くを、開発用に同梱された代替物で自動的に用意してくれますが、本番環境では、外部 DB・ストレージ・IAM など利用する構成に応じた準備が必要です。provider や proxy など、インストーラがデプロイするものもあるため、すべてを別途手動で導入するという意味ではありません。 6.1 GKE で利用する場合の提供範囲 GKE 版は GKE Standard に OSS をインストールする構成です。Autopilot は対象外で、評価・非本番用途は利用できますが、本番サポートは allowlist による private GA です。Agent Substrate 自体の追加料金はなくても、作成する GKE などのリソースには料金がかかります。 (参考: Install Agent Substrate on GKE) 発表では、基盤側の ComputeClass と、任意の Filestore agent volumes 連携も紹介されています。後者は共有ファイルシステム向けで、sandbox の実行状態を保存するスナップショットとは役割が異なります。 また、再開500ms未満・毎秒500超の Suspend/Resume・従来比10倍の密度という性能値は Google の公表値です。OSS の設計目標や、この原稿の kind での確認結果とは分けて読みます。 (参考: Agent Substrate brings high-density, scalable, trusted infrastructure to GKE | Google Cloud Blog, 2026-09-16) 第7章: kindクラスタで実際に動かす この章では、公開記事に掲載していた kind での確認手順と実行結果を整理します。出力は既存の検証記録です。 7.1 手順 ビルドには、対象タグの go.mod が指定する Go 1.27.0 と、Docker などの開発環境が必要です。micro-VM を試す場合は /dev/kvm と仮想化の要件もあります。以下は gVisor を使う Counter デモの手順です。 (参考: go.mod#L1-L5、create-kind-cluster.sh#L74-L87) v0.5.0 を使うため、専用ディレクトリと検証用クラスタ名を指定します。クラスタ作成スクリプトは同名の既存クラスタを削除して作り直すため、既存環境と重複しない名前を使います。以降は、そのディレクトリで実行する手順です。 git clone --branch v0.5.0 --depth 1 https://github.com/agent-substrate/substrate.git substrate-v0.5.0 cd substrate-v0.5.0 export KIND_CLUSTER_NAME=ate-overview # create cluster and local registry (IPv4; IP_FAMILY=dual|ipv6 overrides) hack/create-kind-cluster.sh # install ate, PostgreSQL, rustfs hack/install-ate-kind.sh --deploy-ate-system --credential-provider='{"name":"k8s.io"}' # install counter demo hack/install-ate-kind.sh --deploy-demo-counter # install kubectl-ate go install ./cmd/kubectl-ate # create a counter actor in the demo's atespace (--template names the # actor template, resolved in the actor's atespace) kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter # port-forward the network router to bind to local port `8000` kubectl port-forward -n ate-system svc/atenet-router 8000:80 (参考: README.md#L111-L129) hack/create-kind-cluster.shは、ローカルのDockerレジストリを建てたうえでkindest/node:v1.37.0のkindクラスタを作成します。cmd/podcertcontrollerが依存するClusterTrustBundle・PodCertificateRequestのfeature gateも、このスクリプトがkindのクラスタ設定で明示的に有効化しています。 スクリプトのコメントには Kubernetes v1.36 時点の説明が残っていますが、本記事の v1.37 ではこれらの機能は GA・デフォルト有効です。スクリプトは ClusterTrustBundleProjection なども明示的に設定しています。 (参考: create-kind-cluster.sh#L104-L113) hack/install-ate-kind.sh --deploy-ate-systemは、koでateapi・atecontroller・atelet・podcertcontrollerなどのGoバイナリをビルドしてローカルレジストリにpushし、PostgreSQL・rustfs(オブジェクトストレージ)・CRD・SandboxConfigを含めて一括でデプロイします。--deploy-demo-counterはさらにCounterデモ用のWorkerPoolとActorTemplateをデプロイし、golden snapshotの作成を待ちます。 7.2 デモのWorkerPoolとActorTemplate Counter デモが使う WorkerPool の抜粋です。テンプレート内のプレースホルダーや ko:// の参照は、インストーラが解決してから適用します。 apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: name: counter namespace: ate-demo-counter labels: workload: counter spec: replicas: 3 workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor sandboxClasses: - name: gvisor template: resources: limits: cpu: "1" memory: 1Gi requests: cpu: 250m memory: 1Gi (参考: counter.yaml.tmpl#L26-L63) ActorTemplateは前述の通りKubernetesオブジェクトではなく、ate API の protobuf フィールドに対応した YAML として kubectl ate create actor-template に渡します。CLI が読み込んで API に送信します。 metadata: atespace: ate-demo-counter name: counter workerSelector: matchLabels: workload: counter containers: - name: counter image: ko://github.com/agent-substrate/substrate/demos/counter wakeupProbe: httpGet: path: /readyz port: 80 volumeMounts: - name: data mountPath: /home/counter resources: limits: - name: cpu quantity: "1" - name: memory quantity: 512Mi snapshotConfig: preferredFidelity: SNAPSHOT_FIDELITY_MEMORY storageLocation: gs://${BUCKET_NAME}/ate-demo-counter/ sandboxConfig: sandboxClass: gvisor configName: gvisor-default volumes: - name: data durableDir: {} (参考: counter-template.yaml.tmpl#L22-L69) snapshotConfig.preferredFidelity: SNAPSHOT_FIDELITY_MEMORYが、Suspend時にプロセスメモリごとスナップショットを取る設定です。sandboxConfigが名指ししているgvisor-defaultは、インストーラが標準で用意する SandboxConfig (クラスタスコープの CRD) で、gVisorのバイナリをGCSの公開バケットから取得する設定を持っています。 apiVersion: ate.dev/v1alpha1 kind: SandboxConfig metadata: name: gvisor-default annotations: sandboxconfig.ate.dev/is-class-default: "true" spec: sandboxClass: gvisor defaultVersion: v3 versions: - name: v3 state: Enabled pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" assets: arm64: gvisor: url: "gs://gvisor/releases/nightly/2026-10-10/aarch64/gvisor.tar.zstd" sha256: "aaf414f06eea7960238e43165c691f0a36c
Kubernetes Memory on cgroup v2: Debugging OOM Kills and Evictions A container is OOMKilled, but kubectl top never showed it reaching its limit. Or a Pod is evicted while your node dashboard still shows free memory. In both cases, the first question is which memory value the decision actually used. This guide separates cgroup-limit OOM, node-side OOM and kubelet eviction, then shows how to inspect the evidence and review memory requests and limits. It assumes familiarity with Pod resources and basic PromQL. TL;DR - The working set is a heuristic, not a measurement of unreclaimable memory. It subtracts inactive_file from usage but still includes reclaimable active page cache and slab. - Distinguish memcg OOM from global OOM using kernel logs. A container can be below its own limit when an ancestor cgroup reaches its limit or a node-side allocation fails. OOMKilled and SystemOOM alone cannot establish the cause. - Eviction uses the kubelet's memory.available, not Linux MemAvailable. Check the kubelet's statistics and configured thresholds. - Review requests and limits separately. Requests influence placement and victim selection. To address a cgroup limit, reduce usage or adjust the limit after checking node capacity. - Check Memory QoS configuration as well as its feature gate. In v1.37, the gate defaults to enabled, but request-based protection and memory.high remain disabled by default. This article targets cgroup v2, Kubernetes v1.37.1 and containerd. See [Components and versions](#components-and-versions) for exact versions. The kind examples use a v1.37.0 node image. Use the [investigation workflow](#investigation-workflow) for an incident, the [metric map](#quick-reference-contradictions-explained-and-a-metric-map) to understand a dashboard, or [requests and limits](#what-to-do-about-it) for prevention. The [kind examples](#hands-on-verification-with-kind) explore reclaim and Memory QoS in more detail. Identify the failure before choosing a metric | Observation | Mechanism | Evidence to check | |:---|:---|:---| | Container termination reason is OOMKilled | The kernel killed a process; this alone does not distinguish the OOM source | Kernel logs, container and ancestor cgroup limits, node allocation constraints | | Pod is Evicted with The node was low on resource: memory. | The kubelet responded to node memory pressure | Kubelet memory.available, configured thresholds and eviction message | | Exit code is 137 without an OOM reason | The process received SIGKILL; the exit code alone does not establish OOM | Termination details, events and kernel logs | memcg OOM means an allocation cannot fit within a cgroup limit after reclaim. Global OOM means a node-side allocation cannot be satisfied after reclaim, potentially within NUMA or cpuset constraints. A container below its own limit can be affected by either an ancestor limit or a node-side failure. Investigating OOM and eviction Investigation workflow Start with the observed termination or eviction, then compare metrics from the same time. Scrapes can miss the peak; kernel logs are stronger evidence of the OOM source than a sampled usage graph. 1. When a Pod was OOMKilled - Check whether container_memory_usage_bytes is close to the limit. memcg OOM is evaluated against this value - Compare with container_memory_rss to tell whether anonymous pages are the main cause. An OOM can occur even when RSS is low - If the gap between the working set or usage and RSS is large, page cache, shmem or kernel memory may be pushing usage up - If OOMKilled occurs despite headroom under the limit, suspect global OOM. Correlate SystemOOM with kernel logs and inspect ancestor limits. A missing event does not establish memcg OOM - If you see no spike in the metrics, suspect a surge shorter than the scrape interval. See [My working set is below the limit, but the container was OOMKilled](#my-working-set-is-below-the-limit-but-the-container-was-oomkilled) for what to check 2. When a Pod was evicted - If the message is The node was low on resource: memory., node-wide memory pressure is the cause - Use the [node-utilization query](#why-node-exporter-and-kubelet-usage-disagree) to check the kubelet-view node memory utilization. The node-exporter-based utilization alone is not enough to judge - Check whether a Pod with a small (or unset) memory request relative to actual usage is on the same node. Compare each Pod's container_memory_working_set_bytes (cAdvisor) against kube_pod_resource_request{resource="memory"} (kube-scheduler). Note that Pods with large requests make the scheduler keep placement density low, so they are unlikely to be the cause of pressure 3. To prevent recurrence - Set a memory request informed by workload history to avoid excessive placement density. PromQL for checking is collected in [Align memory request with actual usage](#align-memory-request-with-actual-usage) - For containers where memcg OOM occurs, also review the limit - Control placement with Pod anti-affinity or topology spread constraints so that Pods with large memory usage do not gather on the same node Telling which path occurred Using Kubernetes information Start by narrowing down with what you can check using kubectl. | Where to look | What it tells you | |:---|:---| | Last State in kubectl describe pod, or lastState.terminated in kubectl get pod -o yaml | That an OOM kill happened (reason: OOMKilled). It cannot distinguish the path | | Events in kubectl describe node <node name> | Use SystemOOM as a clue and verify the source in kernel logs. If there is no record, that does not make it a memcg OOM; it just means you cannot tell | There is no OOM-specific event on the Pod side. You only see things like BackOff accompanying the restart. Check it as follows. SystemOOM is an event attached to the Node, and since Node is not a namespaced object, the event itself is recorded in the default namespace. # 1. Find which node the OOMKilled Pod is on kubectl get pod -n <namespace> <Pod name> -o wide Output: NAME READY STATUS RESTARTS AGE IP NODE myapp-... 1/1 Running 5 (2m ago) 1h 10.0.0.1 node-1 # 2. Check whether that node's events contain SystemOOM kubectl describe node node-1 Output: ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning SystemOOM 3m kubelet System OOM encountered, victim process: java, pid: 12345 # To list only events kubectl get events -n default \ --field-selector reason=SystemOOM,involvedObject.name=node-1 If SystemOOM exists, inspect kernel messages from the same time: constraint, oom_memcg, task_memcg and the killed PID. A parsing failure can also produce this event. Match the event and kernel log to the container's termination time (lastState.terminated.finishedAt). Not finding SystemOOM is not evidence of memcg OOM. The same state results from event TTL expiry, a kubelet restart, or a missed event. The event retention period is set by kube-apiserver's --event-ttl, and the default is 1 hour (options.go). So an old SystemOOM may no longer be available through kubectl. If you forward events to an external logging platform, check there. On the node, inspect the kernel log: sudo journalctl -k -b -o short-iso \ | rg 'oom-kill:|Out of memory:|Memory cgroup out of memory:|Killed process' CONSTRAINT_MEMCG and oom_memcg identify the limiting cgroup; task_memcg identifies the victim's cgroup. If these differ, inspect the Pod and ancestor limits. CONSTRAINT_CPUSET and CONSTRAINT_MEMORY_POLICY call for checking NUMA and cpuset restrictions. Align timestamps with the container's finishedAt. If logs are unavailable, use the following as supporting evidence rather than proof: - Whether container_memory_usage_bytes was close to the limit at that time (if so, memcg OOM is likely) - Whether [the kubelet-view node memory utilization](#why-node-exporter-and-kubelet-usage-disagree) was high at that time (if so, global OOM is likely) Why OOMKilled cannot distinguish the paths When a task exits with code 137, the container runtime (containerd) checks oom_kill in the cgroup's memory.events, and if it is 1 or more, it updates the termination reason to OOMKilled (events.go). The check is oomMetricsEventOccurred() in the same file. switch v := taskMetricsAny.(type) { case *cg1.Metrics: return v.GetMemoryOomControl().GetOomKill() > 0, nil case *cg2.Metrics: return v.GetMemoryEvents().GetOomKill() > 0, nil The definition of oom_kill in memory.events is "The number of processes belonging to this cgroup killed by any kind of OOM killer". So a process chosen and killed by the kernel in a global OOM is counted too, and both paths produce OOMKilled. {% details How the kubelet parser produces SystemOOM %} When SystemOOM is recorded SystemOOM is an event the kubelet records on the Node object. The kubelet parses OOM messages in the kernel log and records it only when the cgroup where the OOM happened is judged to be the root (/) (oom_watcher_linux.go). for event := range outStream { // Count every OOM kill per container to back the // container_oom_events_total metric. recordOOMKill(event.ContainerName) if event.VictimContainerName == recordEventContainerName { ... ow.recorder.WithLogger(logger).Eventf(ref, v1.EventTypeWarning, systemOOMEvent, "%s", eventMsg) } } The usual association with global OOM comes from the combination of the kernel log format and the parser. For an OOM caused by a memcg limit, the kernel prints oom_memcg=<cgroup path>, whereas for a global OOM it prints ,global_oom instead (mem_cgroup_print_oom_context() in mm/memcontrol.c). if (memcg) { pr_cont(",oom_memcg="); pr_cont_cgroup_path(memcg->css.cgroup); } else pr_cont(",global_oom"); The modern-format regular expression in the kubelet's forked parser matches the format containing oom_memcg= (oomparser_linux.go). containerRegexp = regexp.MustCompile(`oom-kill:constraint=(.*),nodemask=(.*),cpuset=(.*),mems_allowed=(.*),oom_memcg=(.*),task_memcg=(.*),task=(.*),pid=(.*),uid=(.*)`) So no cgroup can be extracted from a global OOM message, and the initial value / of OomInstance is left as is (StreamOoms in oomparser_linux.go). oom := &OomInstance{ ContainerName: "/", VictimContainerName: "/", TimeOfDeath: msg.Timestamp, } In other words, the presence of SystemOOM is not "the result of directly determining the path" but "the result of whether the kernel log could be parsed". Still, in practice it is enough to understand that a recorded event is a reason to inspect node-side OOM, while neither presence nor absence establishes the source. {% enddetails %} How it appears in metrics Metrics do not distinguish the path with a label. The kernel's __oom_kill_process() records the same event regardless of the path. | Metric | Emitted by | memcg OOM | global OOM | |:---|:---|:---|:---| | node_vmstat_oom_kill | node-exporter (oom_kill in /proc/vmstat) | Increases | Increases | | container_oom_events_total | kubelet (result of kernel log parsing) | The series of the container the killed process belonged to increases | The id="/" series increases, not a container series | | oom_kill in cgroup v2 memory.events | (a cgroup file, not a metric; containerd reads it) | Increases | Increases | Only container_oom_events_total behaves differently because, as described earlier, the parser cannot extract a cgroup in a global OOM and treats it as the root (/). This is only a result of whether the log could be parsed, so do not use it to determine the path; confirm the source from constraint and oom_memcg in the kernel log. > Note: Although container_oom_events_total has container_ in its name, in v1.37 it is counted by the kubelet, not cAdvisor (recordOOMKill() in oom_counter.go). The meaning of the value does not change, but you have to look elsewhere when reading the code. What to look at after narrowing down - If you judge it to be a global OOM, inspect node-side memory pressure and any NUMA or cpuset constraints. Compare the memory requests and actual usage of the Pods placed on the same node, and check whether any Pod has too small a request - If you judge it to be a memcg OOM, review that container's memory limit and its actual usage. If the container seems to have headroom under its limit, it may have reached the limit of the Pod as a whole or of /kubepods.slice - If you cannot decide, keep both possibilities open and check both the container's limit and usage and the node's memory utilization {% details Kernel OOM behavior and victim selection %} The two OOM kill paths There are two paths by which a container's process gets OOM killed. Both are SIGKILLs from the kernel's OOM killer, but the conditions under which they occur and the scope they affect differ. As [defined in the terminology notes](#how-this-article-names-the-two-oom-kill-paths), memcg OOM enforces a configured limit and global OOM protects the node, so their purposes differ at the root. | Path | Condition | What gets killed | Node event | |:---|:---|:---|:---| | memcg OOM | memory.max exceeded for a container, Pod or /kubepods.slice | Processes inside the cgroup that hit its limit | Not recorded | | global OOM | A node-side allocation cannot be satisfied after reclaim, possibly within NUMA or cpuset constraints | Chosen from eligible processes under the allocation constraints | SystemOOM is recorded when the kubelet parses the log and treats the triggering cgroup as / | Even if your container is under its limit, it can be OOM killed because of another Pod on the same node. That is global OOM. SystemOOM is a kubelet-generated clue to investigate, not a definitive classification of the OOM source. Events expire according to the API server's --event-ttl (one hour by default), so a missing event does not rule out global OOM. OOM kill from exceeding a cgroup limit (memcg OOM) resources.limits.memory is set as the container cgroup's memory.max. When a cgroup's memory.current reaches memory.max, this is what happens. As described above, limits are set not only on containers but also on Pods and /kubepods.slice, and the same flow applies whichever level reaches its limit. 1. The kernel tries to reclaim memory inside that cgroup. Page cache is basically reclaimed here. 2. If usage still does not fit under memory.max after reclaim, the cgroup's OOM killer SIGKILLs processes in that cgroup. 3. With cgroup v2, the kubelet defaults to singleProcessOOMKill: false and sets memory.oom.group=1 for the container. A memcg OOM therefore normally kills the container's processes together. With singleProcessOOMKill: true, a child process can be killed while the container keeps running. Container restarts follow restartPolicy. See the implementation. The implementation is try_charge_memcg() in mm/memcontrol.c. It is a goto loop returning to the retry: label, and proceeds in this order. | Step | Location | What happens | |:---|:---|:---| | 1. Try to charge | L2177-L2178 | If page_counter_try_charge() succeeds, the allocation completes and it returns | | 2. Reclaim | L2211-L2216 | On failure it reclaims with try_to_free_mem_cgroup_pages(), and if mem_cgroup_margin() satisfies the request, goes back to 1 | | 3. Count the retry | L2244-L2245 | If reclaim is not enough, it decrements nr_retries and goes back to 1. The initial value is MAX_RECLAIM_RETRIES (= 16; mm/internal.h) | | 4. Invoke the OOM killer | L2259-L2264 | Only after the retries are used up does it call mem_cgroup_oom() | MAX_RECLAIM_RETRIES is a retry budget, not a guarantee of exactly 16 reclaim attempts. Allocation flags and reclaim progress change the control flow. The check in step 2 looks like this in code. nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit, nr_pages, gfp_mask, reclaim_options, NULL); psi_memstall_leave(&pflags); if (mem_cgroup_margin(mem_over_limit) >= nr_pages) goto retry; The kernel documentation for memory.max says the same. > Memory usage hard limit. ... If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup. > Warning: memory.current reaching memory.max does not by itself cause an OOM. A cgroup sitting at its limit with page cache is a normal state. For the same reason, the working set reaching memory.max is not an OOM condition either. Only inactive_file is subtracted from the working set, and reclaimable memory remains in the rest: > > - active_file: demoted to inactive under pressure and then reclaimed > - slab_reclaimable (dentry / inode caches): in memcg reclaim too, shrink_slab() is called after shrink_lruvec() (shrink_node_memcgs() in mm/vmscan.c) > > So there is no threshold of "OOM once either value reaches the limit". The actual condition is when the charge fails and reclaim cannot make room. What cannot be reclaimed is mainly anonymous pages (without swap) and shmem, so that is where to look when narrowing down. That said, what the kernel compares against memory.max in a memcg OOM is the whole of memory.current, and it is not decided by any single metric. memory.current includes, besides anonymous pages, kernel memory, shmem (tmpfs and shared memory), and page cache that cannot be reclaimed, or cannot be reclaimed right away. container_memory_rss is an indicator for determining whether anonymous pages are the main cause. If cgroup RSS is close to the limit, anonymous pages are a likely major contributor, but RSS being below the limit is no evidence that an OOM will not occur. When the gap between RSS and the working set is large, check container_memory_usage_bytes (the value of memory.current) together with the breakdown in memory.stat on the node: anon, file, shmem, slab and so on. OOM kill from node memory exhaustion (global OOM) When a node-side allocation cannot be satisfied after reclaim, the kernel evaluates eligible processes using oom_badness(). cpusets, NUMA policies and oom_score_adj=-1000 can restrict the candidates. A container need not exceed its own limit to be selected. There is no guaranteed ordering between kubelet eviction and the kernel's OOM killer. The kubelet evicts Pods to protect the node, and its selection puts "Pods whose usage exceeds their memory request" first (rankMemoryPressure() in helpers.go). // rankMemoryPressure orders the input pods for eviction in response to memory pressure. // It ranks by whether or not the pod's usage exceeds its requests, then by priority, and // finally by memory usage above requests. func rankMemoryPressure(pods []*v1.Pod, stats statsFunc) { orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods) } A Pod with a zero memory request and positive usage belongs to the "exceeds request" group. Within that group, priority and then usage above request determine the order, so it is not necessarily evicted first. If eviction acts in time, the result is Evicted, not OOMKilled. Global OOM happens first when eviction cannot reclaim memory as fast as memory grows. There are several reasons it cannot keep up. | Factor | Value | Effect | |:---|:---|:---| | Evaluation interval | 10 seconds (evictionMonitoringPeriod) | Detection can be up to 10 seconds late | | Pods evicted per evaluation | Only one ("we kill at most a single pod during each eviction interval" in eviction_manager.go) | At most one Pod is evicted per synchronization; notifications and cleanup waits mean this is not a fixed rate | | Waiting for cleanup | Up to 30 seconds (podCleanupTimeout) | The next evaluation may be delayed further | | Default threshold | memory.available<100Mi | Extremely thin on large nodes | | How memory.available is computed | From the root cgroup's anon + file | Contains no kernel memory, so it [overestimates free memory](#values-the-kubelet-uses-for-eviction-decisions) | The official Kubernetes documentation also says (Node out of memory behavior): > If the node experiences an out of memory (OOM) event prior to the kubelet being able to reclaim memory, the node depends on the oom_killer to respond. It is more likely when applications that allocate a lot of memory right at startup, or in response to a sudden surge in requests, share a node. > Warning: Eviction protects the node; it does not protect individual Pods from OOM. Containers without limits have no memory.max backstop at all, so eviction is merely the last line of defense. > > Moreover, rankMemoryPressure() looks at Pod priority before usage size. If the offending Pod has high priority, low-priority Pods with small usage get evicted first, and global OOM can occur without the pressure being resolved. The proper countermeasure is to set limits and requests rather than relying on eviction. Which process gets killed The kernel evaluates eligible processes using resident pages, swap, page tables and oom_score_adj; the adjustment alone does not establish a fixed kill order. The kubelet sets oom_score_adj (a bonus added to oom_score) according to the container's QoS class, so the QoS class and the memory request setting directly become how likely a container is to be killed. You can check the QoS class with kubectl get pod <Pod name> -o jsonpath='{.status.qosClass}', and how it is determined is described in Pod Quality of Service Classes. | Condition | oom_score_adj | Likelihood of being killed | |:---|:---|:---| | system-node-critical (evaluated before the QoS class) | -997 | Least likely | | Guaranteed | -997 | Least likely | | Burstable | 1000 - 1000 × memory request / node memory capacity (clamped to 3–999) | The smaller the request, the more likely | | BestEffort | 1000 | Most likely | The implementation is GetContainerOOMScoreAdjust() in Kubernetes' policy.go. system-node-critical is evaluated before the QoS class decision. if types.IsNodeCriticalPod(pod) { // Only node critical pod should be the last to get killed. return guaranteedOOMScoreAdj } The formula above is the container-level baseline. Pod-level requests and restartable init containers can introduce adjustments. An omitted memory request can also default from the limit, so inspect the applied Pod spec. The Burstable clamp is implemented with a lower bound of 1000 + guaranteedOOMScoreAdj (= 3) and an upper bound of besteffortOOMScoreAdj - 1 (= 999). A Pod whose request is set smaller than its actual usage is more likely to be targeted in both eviction and global OOM. > Warning: PriorityClass works differently for eviction and for global OOM. It is a misreading to think that raising priority protects you from both. > > | PriorityClass | Eviction | global OOM (oom_score_adj) | > |:---|:---|:---| > | User defined (up to 1000000000) | The higher the value, the later it is evicted | Determined by QoS class and request | > | system-cluster-critical | Exempt from node-pressure eviction | Determined by QoS class and request (not protected) | > | system-node-critical | Exempt from node-pressure eviction | Fixed at -997 | > | static / mirror Pod | Exempt from node-pressure eviction | Determined by QoS class and request | > > The three exempt rows identify critical Pods for which IsCriticalPod() is true; the kubelet checks static and mirror Pods as well as system-critical priority classes. > > What oom_score_adj protects is only system-node-critical, for which IsNodeCriticalPod() is true. If a system-cluster-critical Pod is BestEffort, its oom_score_adj is 1000, and it is more likely to be selected in a global OOM. "I raised the priority but it was still OOMKilled" comes from this path. > Warning: Neither an OOM kill nor node-pressure eviction directly deletes the Pod API object. What happens next depends on restartPolicy. > > - Always or OnFailure: the container is recreated on the same node. The Pod stays on the same node, so if the cause is not resolved, OOM kills will happen again > - Never: the container is not recreated and the Pod remains Failed. If a controller such as a Job creates a successor Pod, it may be placed on a different node > > If OOMKilled repeats even though there is headroom under the limit, suspect this path. > Note: Because the OOM kill frees memory, the pressure may already be gone when the kubelet next evaluates free memory. In that case the node may get no MemoryPressure condition and no node.kubernetes.io/memory-pressure:NoSchedule taint, and scheduling of new Pods does not stop. Conversely, if the pressure continues, a later evaluation sets the condition and taint, and eviction also occurs. Note that it is decided by memory.available at evaluation time, not by whether an OOM kill happened. {% enddetails %} Checking with PSI (Pressure Stall Information) This is an auxiliary indicator for the case where things are slow although usage has not reached the limit. It shows how long processes were actually stalled waiting for memory reclaim. {% details How to read PSI and PromQL %} You can obtain the time a container was stalled waiting for memory allocation as PSI. Even when usage has not reached the limit, you can detect being made to wait by memory reclaim. | Metric | Emitted by | Description | |:---|:---|:---| | container_pressure_memory_waiting_seconds_total | cAdvisor (embedded in kubelet) | Time some processes were waiting for memory (total of some in memory.pressure) | | container_pressure_memory_stalled_seconds_total | cAdvisor (embedded in kubelet) | Time all processes were stalled waiting for memory (total of full in memory.pressure) | Note: These metrics are controlled by the KubeletPSI feature gate, which went GA in v1.36 and is enabled by default (it cannot be disabled). The kernel must support PSI, though, and the kubelet decides this by whether cpu.pressure exists at the cgroup root (cadvisor_linux.go). If the metrics do not appear, check whether the kernel was built with CONFIG_PSI=y and whether psi=0 is set as a kernel parameter. memory.pressure also has avg10 / avg60 / avg300, "the average over the last N seconds (%)", but only the cumulative value (total) is exposed as a Prometheus metric. So use rate() to see a ratio. The unit is seconds, so the result of rate() is "how many seconds of waiting per second", a ratio from 0 to 1. Multiply by 100 to get a percentage directly. <!-- PromQL uses Python fencing for syntax highlighting only. --> # Per Pod, the share of time (%) all processes were stalled waiting for memory 100 * max by (namespace, pod) ( rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m]) ) # Per Pod, the share of time (%) some processes were waiting for memory 100 * max by (namespace, pod) ( rate(container_pressure_memory_waiting_seconds_total{container="", image="", pod!=""}[5m]) ) When there is plenty of memory, neither increases, so rate() stays at 0. Under pressure, waiting (some) rises first, and if it gets worse stalled (full) follows. waiting counts even when only some processes waited, so to judge severity, use stalled. It is the time all processes in the container were stopped, so if it shows a value, throughput is directly affected. To see memory pressure on the whole node, use the root cgroup series. # Share of time (%) the whole node was completely stalled waiting for memory 100 * max by (node) ( rate(container_pressure_memory_stalled_seconds_total{id="/"}[5m]) * on(instance) group_left(node) kubelet_node_name ) Containers that are struggling although they are below their limit can be found by combining PSI with utilization. container_spec_memory_limit_bytes is the container limit exposed by cAdvisor. # Containers whose usage is under 80 % of the limit but that are stalled waiting for memory ( 100 * rate(container_pressure_memory_stalled_seconds_total{container!="", image!=""}[5m]) > 1 ) and ( container_memory_usage_bytes{container!="", image!=""} / container_spec_memory_limit_bytes{container!="", image!=""} < 0.8 ) and keeps only series whose label sets match exactly on both sides. A container with no limit has container_spec_memory_limit_bytes of 0, so the division gives +Inf, which does not satisfy < 0.8, and it is excluded automatically. Here is an example alert. It catches stalled that keeps occurring. - alert: ContainerMemoryStalled # Fires when the 5-minute average of fully stalled time is 5 % or more for 10 minutes expr: | 100 * max by (namespace, pod) ( rate(container_pressure_memory_stalled_seconds_total{container="", image="", pod!=""}[5m]) ) > 5 for: 10m labels: severity: warning annotations: summary: "A Pod is stalled waiting for memory allocation (add the namespace and pod labels with your own template)" description: "It may be made to wait by reclaim even though it has not reached its memory limit. Check the gap between usage and RSS and the amount of page cache." The right threshold of 5 % depends on the environment. First look at the distribution of values without an alert to understand the normal level, then decide. For details on PSI see Understand Pressure Stall Information (PSI) Metrics. {% enddetails %} Quick reference: contradictions explained and a metric map The working set, cgroup usage, process RSS and node availability answer different questions. Use this map to identify the value behind the dashboard before comparing it with a limit or threshold. For a non-root cgroup, working set is max(memory.current - inactive_file, 0). It still includes some reclaimable memory. | Scope | Metric | Emitted by | Backing value in cgroup v2 / /proc | Page cache handling | Main use | |:---|:---|:---|:---|:---|:---| | Container | container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | max(memory.current − inactive_file, 0) | Active pages count as in use | kubectl top pod, usage visualization | | Container | container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Not included (anonymous pages only) | Understanding what the app allocated | | Container | container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Everything included | What memcg OOM is evaluated against | | Node | container_memory_working_set_bytes{id="/"} | cAdvisor (embedded in kubelet) | anon + file − inactive_file in the root cgroup's memory.stat (see below) | Active pages count as in use | Eviction decisions, kubectl top node | | Node | node_memory_MemAvailable_bytes | node-exporter | MemAvailable in /proc/meminfo | Reclaimable pages count as free | Visualizing node memory utilization | | Node | kube_node_status_capacity{resource="memory"} | kube-state-metrics | (status.capacity of the Node object) | (capacity, not usage) | Denominator when converting to utilization | Looking at the same node, utilization computed from node_memory_MemAvailable_bytes does not match the utilization the kubelet uses for eviction decisions. With workloads that do a lot of file I/O or logging, the difference can reach several GiB. Different emitters also mean different labels on the metrics. cAdvisor metrics carry no label identifying the node, so when you compute per-node utilization you need to align labels with kubelet_node_name, as shown later. Kubernetes memory usage and cgroup v2 Why the numbers differ A process RSS measurement includes its resident mappings, including shared and file-backed pages. Cgroup accounting charges memory to a group, while working set subtracts one category of file cache from usage. An application's heap metric covers a different subset again. Reclaimability is a separate question: clean file cache can often be reclaimed, dirty pages may need writeback, and anonymous memory normally needs swap or explicit release by the process. Calling free does not necessarily return allocator-held memory to the OS. These differences explain why heap size, process RSS, cgroup RSS and working set need not match. The following sections connect those values to cgroup v2 accounting. Memory breakdown in cgroup v2 Container memory usage is managed in a per-container cgroup. In cgroup v2, memory.current is the current usage and memory.stat is its breakdown. # Example values in a container's cgroup (checked on the node) cat /sys/fs/cgroup/<container cgroup>/memory.current Output: 5368709120 cat /sys/fs/cgroup/<container cgroup>/memory.stat Output: anon 2147483648 # anonymous pages (heap, stack: memory not backed by a file) file 3087007744 # page cache (including tmpfs and shared memory) shmem 268435456 # the part of file that is tmpfs or shared memory ... inactive_anon 134217728 # anon LRU: not accessed for a while active_anon 2013265920 # anon LRU: recently accessed inactive_file 1073741824 # file LRU: not accessed for a while active_file 1744830464 # file LRU: recently accessed unevictable 268435456 # pages that cannot be reclaimed (here, the tmpfs part) ... The following relationships hold in this example. Note that inactive_file + active_file does not equal file; it equals file - shmem. The next section explains why. anon + file + kernel memory = 5368709120 (memory.current) inactive_file + active_file = 2818572288 = file - shmem inactive_anon + active_anon = 2147483648 = anon (shmem is not in here) unevictable = 268435456 = shmem (tmpfs without swap) The main fields of memory.stat are: anon (anonymous pages) - Memory not backed by a file. Regions the application allocated with malloc and similar belong here. - Unlike page cache, the kernel cannot reclaim it independently of the process. With swap it can write pages out and free up space, but on nodes that do not use swap it is memory the kernel cannot reclaim under pressure. When swap is unavailable, the kernel sets scan_balance = SCAN_FILE and excludes the anon LRU from scanning (get_scan_count() in mm/vmscan.c). - On the other hand, this value drops if the process itself returns memory to the OS with munmap or madvise(MADV_DONTNEED). Note that calling free does not reduce it unless the allocator or language runtime returns the memory to the OS. file (page cache) - Contents of files held in memory by the kernel during reads and writes. The application did not allocate it explicitly. - When memory runs short the kernel discards (reclaims) it automatically, so it is basically reclaimable memory. - Cache of files on disk is split into active_file (recently accessed) and inactive_file (not accessed for a while). Reclaim takes inactive_file first. - active_file + inactive_file does not equal file. These are totals of the LRU lists, and as described below shmem is on the anon LRU rather than the file LRU. Besides shmem, pages that fall off the LRU, such as mlocked pages, also show up as a difference. - It includes not only the cache of files on disk but also shmem (tmpfs and shared memory), because Linux treats these as page cache too. shmem is written out to swap rather than to a file, so on nodes without swap it cannot be reclaimed. The breakdown is visible as shmem in memory.stat. > Warning: shmem is counted in file, but it is not on the file LRU. The kernel classifies swap-backed pages as anon, and tmpfs pages are among them. folio_is_file_lru() in mm_inline.h is defined as "0 if @folio is a normal anonymous folio, a tmpfs folio or otherwise ram or swap backed folio". > > The counters they are charged to are different, though. A tmpfs page is added to both NR_FILE_PAGES (= file) and NR_SHMEM (= shmem) (mm/shmem.c): > > ```c > __lruvec_stat_mod_folio(folio, NR_FILE_PAGES, nr); > __lruvec_stat_mod_folio(folio, NR_SHMEM, nr); > ``` > > Furthermore, for a Kubernetes emptyDir with medium: Memory, the kubelet mounts tmpfs with the noswap option when the node supports it (generateTmpfsMountOptions() in empty_dir.go). The kernel makes the inode of a noswap tmpfs unevictable (shmem_get_inode() in mm/shmem.c), so these pages sit on the unevictable LRU, not the anon LRU. > > ```c > if (sbinfo->noswap) > mapping_set_unevictable(inode->i_mapping); > ``` > > So shmem is accounted as follows: > > | mount | LRU | Where it shows up in memory.stat | > |:---|:---|:---| > | emptyDir with medium: Memory (noswap) | unevictable | unevictable | > | Any other tmpfs / shared memory | anon LRU | inactive_anon / active_anon | > > In neither case does it enter inactive_file / active_file. That matters in two ways later: > > - inactive_file + active_file generally corresponds to file - shmem; unevictable pages and asynchronous statistics can cause differences > - Only inactive_file is subtracted from the working set, so shmem stays in the working set in full With workloads that write a lot of logs or read and write large files, the page cache can grow to several GiB even though the application did not intend that. > Note: memory.current includes memory the kernel itself uses (slab, socket buffers and so on) in addition to anon and file. So adding anon and file alone does not give memory.current. How cgroup v2 memory controls map to Pod settings cgroup v2 memory control interface cgroup v2 offers four levels of control over memory usage. memory.min and memory.low are protections ("do not reclaim up to here"), and memory.high and memory.max are limits ("do not allow use beyond here"). | File | Type | Behavior | |:---|:---|:---| | memory.min | Protection (hard) | Protects memory within the effective boundary determined by ancestors and usage. It does not preallocate physical memory | | memory.low | Protection (soft) | Best-effort avoidance of reclaim up to this value. It is reclaimed if nothing else can be | | memory.high | Limit (soft) | Exceeding it applies reclaim pressure and throttling. Crossing memory.high does not itself invoke the OOM killer; memory.max and global OOM still apply | | memory.max | Limit (hard) | Exceeding it triggers reclaim, and if usage still does not fit, the cgroup's OOM killer runs | Other files that report state: - memory.current: current usage (what container_memory_usage_bytes is backed by) - memory.stat: usage breakdown - memory.events: counts of low, high, max, oom, oom_kill and oom_group_kill - memory.swap.max: swap limit - memory.pressure: PSI for waiting on memory allocation For exact definitions of each file see the kernel's Memory Interface Files, and for how Kubernetes treats cgroup v2 see About cgroup v2. How Pod settings are reflected Pod settings are reflected into cgroup v2 as follows. | Pod setting | Target in cgroup v2 | |:---|:---| | resources.limits.memory | memory.max | | resources.requests.memory | Not reflected (with the default kubelet configuration in v1.37; you can reflect it by changing settings → [KEP-2570](#kep-2570-support-memory-qos-with-cgroups-v2)) | | resources.limits.cpu | cpu.max | | resources.requests.cpu | cpu.weight | cgroup v2 is a directory tree. Its top is the root cgroup (/sys/fs/cgroup), where any process that is in no child cgroup belongs, so it contains not only Pods but every process on the node, such as the kubelet and containerd. Kubernetes creates the hierarchy below it: "all Pods on the node → Pod → container". # With the systemd cgroup driver (the kubeadm default; names differ with the cgroupfs driver, see below) /sys/fs/cgroup ← root cgroup (id="/") │ contains every process on the node │ the value used for eviction decisions │ ├── system.slice/ kubelet, containerd, sshd, etc. │ ├── kubelet.service (processes that are not Pods) │ └── containerd.service │ └── kubepods.slice/ ← all Pods on the node │ (id="/kubepods.slice") ├── kubepods-pod<UID>.slice/ ← Pod (Guaranteed) │ ├── cri-containerd-<ID>.scope ← container │ └── cri-containerd-<pause ID>.scope ← pause container │ ├── kubepods-burstable.slice/ (intermediate slice per QoS class) │ └── kubepods-burstable-pod<UID>.slice/ ← Pod (Burstable) │ └── cri-containerd-<ID>.scope ← container │ └── kubepods-besteffort.slice/ └── kubepods-besteffort-pod<UID>.slice/ ← Pod (BestEffort) └── cri-containerd-<ID>.scope ← container When filtering metrics, the root cgroup is id="/", all Pods on the node are id="/kubepods.slice", a Pod is container="", image="", pod!="", and a container is container!="", image!="". What the kubelet uses for eviction decisions is the working set of the topmost root cgroup ([see below](#values-the-kubelet-uses-for-eviction-decisions)). That is a different level from memcg OOM caused by a container or Pod exceeding its limit. Memory limits are set not only on containers but also on higher levels. | Level | cgroup | Memory limit | |:---|:---|:---| | Whole node (root cgroup) | / | Not set (the root has no memory.max → [see below](#values-the-kubelet-uses-for-eviction-decisions)) | | All Pods on the node | /kubepods.slice | Capacity - kube-reserved - system-reserved when Pod allocatable enforcement is enabled | | Pod | Differs by QoS class (see below) | The Pod's effective limit, including Pod-level spec.resources.limits.memory when configured | | Container | cri-containerd-<ID>.scope under the Pod's cgroup | That container's limits.memory | The examples here use container-level resources and omit Pod-level resources. Without a Pod-level limit, the required container limits must be present for a finite Pod limit to be set. A Pod's effective limit is not simply the sum of its regular containers' limits.memory. If there are init containers, their maximum is also considered, and spec.overhead is added if set. For the calculation see Resource sharing within containers and Pod Overhead. So even if a container has not reached its own limit, an OOM kill can occur when the Pod as a whole or /kubepods.slice hits its limit. > Note: The paths above assume the kubelet uses the systemd cgroup driver. With the cgroupfs driver, paths look like /kubepods/burstable/pod<UID>/<container ID>, with no .slice or .scope and no cri-containerd- prefix on the container. > > The kubelet's own default is cgroupfs, but kubeadm sets systemd when the value is empty (defaults.go, kubelet.go). In v1.37, a supported runtime's CRI RuntimeConfig response takes precedence through driver auto-detection. Check the effective driver with: > > ```bash > $ kubectl get --raw "/api/v1/nodes/<node name>/proxy/configz" \ | jq '.kubeletconfig.cgroupDriver' > "systemd" > ``` > > The id label of cAdvisor metrics also changes with the cgroup driver, so when filtering in PromQL, check the value in your own cluster with this query: > > ```bash > group by (id) (container_memory_working_set_bytes{id=~"/kubepods|/kubepods.slice"}) > ``` > > A Pod's cgroup path depends on its QoS class. As the tree above shows, Burstable and BestEffort go through an intermediate slice per QoS class. > > In the <UID> part, systemd escaping replaces the hyphens of the Pod UID with underscores (example: 6983351e-4ede-... → pod6983351e_4ede_...). The UID of a static Pod (mirror Pod) is a hash with no hyphens, so it looks like pod1ff70b81885169da4e9e32c9530b77c9.slice. > > The QoS class and UID of a Pod can be read from the Pod resource, and once you have those two you can assemble the cgroup path. > > ```bash > $ kubectl get pod -n <namespace> <Pod name> -o jsonpath='{.status.qosClass}{"\n"}{.metadata.uid}{"\n"}' > Burstable > 6983351e-4ede-4611-8bab-b986614af18d > > # To list them > $ kubectl get pods -n <namespace> -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass' > ``` Container metrics The cAdvisor built into the kubelet exposes the cgroup values above as Prometheus metrics. They are served from the kubelet's /metrics/cadvisor endpoint, and you do not need to run a separate cAdvisor Pod or DaemonSet. | Metric | Emitted by | Backing value in cgroup v2 | Description | |:---|:---|:---|:---| | container_memory_usage_bytes | cAdvisor (embedded in kubelet) | memory.current | Usage including anonymous pages, page cache and kernel memory. The value memcg OOM is evaluated against | | container_memory_working_set_bytes | cAdvisor (embedded in kubelet) | memory.current - inactive_file | Usage minus inactive_file, clamped to zero. A heuristic that still includes some reclaimable memory | | container_memory_rss | cAdvisor (embedded in kubelet) | anon in memory.stat | Amount of anonymous pages. Unlike true RSS, it does not include mmaped file pages (see below) | | container_memory_cache | cAdvisor (embedded in kubelet) | file in memory.stat | Page cache size. Includes shmem (tmpfs and shared memory) | | container_memory_total_active_file_bytes | cAdvisor (embedded in kubelet) | active_file in memory.stat | Active page cache | | container_memory_total_inactive_file_bytes | cAdvisor (embedded in kubelet) | inactive_file in memory.stat | Inactive page cache | | container_memory_mapped_file | cAdvisor (embedded in kubelet) | file_mapped in memory.stat | Amount of mmaped files | | `cont
Kubernetesのログ入門 はじめに Kubernetesを普段使っていても、kubectl logsでPodのログが見られることは分かるものの、実際にどんな種類のログがあるのか、どういう形で管理されているのか、保存期間はどれくらいなのか、長期間保存するにはどうすればいいのか、といったところまではなかなか分からないと思います。 そこでこの記事では、Podのコンテナログとkubelet・コンテナランタイムなどのシステムコンポーネントログについて、ディレクトリ構成・確認方法・ローテーションの仕組みを中心に整理しました。EventとAudit Logについては、別の記事で扱っているのでそちらを参照してください。 対象コンポーネントとバージョン | コンポーネント | バージョン | リポジトリ | 本記事での役割 | |---|---|---|---| | Kubernetes | v1.37.1 | kubernetes/kubernetes | kubeletのコンテナログ管理・ローテーション・klog連携の実装をソースコードで確認 | | kind | v0.33.0 | kubernetes-sigs/kind | 検証環境。ノードイメージはkindest/node:v1.37.0 | | containerd | v2.3.4 | containerd/containerd | 検証環境のCRIランタイム(kindest/node:v1.37.0に同梱。ノード上でcontainerd --versionを実行して確認) | | eventrouter | commit 0d28dde(2026-09-16) | openshift/eventrouter | Eventを長期保存する実装例として、Event APIの監視方法と転送方式をソースコードで確認 | | Fluent Bit | v5.1.2 | fluent/fluent-bit | ノードレベルロギングエージェントの実装例として、in_tail・filter_kubernetes・out_lokiの実装をソースコードで確認 | | OpenTelemetry Collector Contrib | v0.162.0 | open-telemetry/opentelemetry-collector-contrib | Fluent Bitと並ぶノードレベルロギングエージェントの実装例として、filelog receiver・container operator・k8sattributesprocessorの実装をソースコードで確認 | | OpenTelemetry Collector | v0.162.0 | open-telemetry/opentelemetry-collector | otlphttpexporterの実装をソースコードで確認 | | Vector | v0.58.0 | vectordotdev/vector | Fluent Bit・OpenTelemetry Collectorと並ぶノードレベルロギングエージェントの実装例として、kubernetes_logs source・loki sinkの実装をソースコードで確認 | | Grafana Loki | v3.7.8 | grafana/loki | Fluent Bit・OpenTelemetry Collector・Vectorからのログ転送先として、実際にクラスタ外で動かして検証 | | stern | v1.34.0 | stern/stern | 複数Pod横断でのログ確認ツールの実装例として、Podの追跡方式をソースコードで確認 | TL;DR - コンテナログ・システムコンポーネントログ・Event・監査ログという4分類でログの全体像を整理できます。 - コンテナログは/var/log/pods/<namespace>_<name>_<uid>/<container>/<restartCount>.logに書き込まれ、/var/log/containers/に互換用のシンボリックリンクが張られます。 - コンテナログのローテーションは、kubelet自身がファイルをtruncateするのではなく、CRIランタイムのReopenContainerLogを呼んで新しいファイルへの書き込みを再開させる方式です。 - stdout/stderrを分離して取得できるPodLogsQuerySplitStreamsというFeature Gateもあります。 - kubeletやコンテナランタイムなど「コンテナの外で動くコンポーネント」自身のログには、Kubernetes側のローテーション機構がありません。 - kubectl get --rawでノードのkubeletログやその他のシステムサービスのログを直接取得できるLog Query機能がv1.36で安定版になりました。 - DaemonSetで動くログ収集エージェントは、kubeletのローテーション方式(ファイルのrename)に対応するため、パスを監視しつつファイルの実体を見分ける必要があり、Fluent Bitはinode比較、OpenTelemetry CollectorとVectorはファイル内容のチェックサムと、実装方式が異なります。 - Event(デフォルト1時間で消える)を長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを立てます。 第1章: Kubernetesで扱うログの全体像 まず、この記事とEvent・監査ログの記事で扱う範囲を整理します。コンテナログ・システムコンポーネントログ・Event・監査ログという4種類のログについて、それぞれの位置づけを確認します。 1.1 4種類のログの位置づけ 公式ドキュメントのLogging Architectureは、ノード上のログをどう扱い・集約するかを説明するページで、Kubernetesのログ種別を網羅的に分類するものではありません。見出しも次の3つで、対象はノード上のログに限定されています。 - Pod and container logs - System component logs - Cluster-level logging architectures EventとAudit Logがここに登場しないのは、ノード上のファイルではなくAPIオブジェクトとして記録される別の仕組みだからです。 この記事では、ノード上のログ(コンテナログ+システムコンポーネントログ)とAPIオブジェクトのログ(Event、Audit Log)という区別で、それぞれ次の章で扱います。 | 系統 | 種類 | 記録される場所 | この記事での扱い | |---|---|---|---| | ノード上のログ | コンテナログ | ノードのファイルシステム(/var/log/pods等) | 第2章で詳説 | | ノード上のログ | システムコンポーネントログ(kubelet・コンテナランタイム・static Pod) | journald、/var/log配下のファイル | 第3章で詳説 | | APIオブジェクトのログ | Event | kube-apiserver(デフォルト--event-ttlで1時間保持) | 第4章では概要のみ。詳細はKubernetesのeventログの一覧に記載 | | APIオブジェクトのログ | Audit Log | Policyで指定したファイル/webhookバックエンド | 第4章では概要のみ。詳細はKubernetesの監査ログ(Audit Log)入門に記載 | 第2章: Podとコンテナのログ Podのコンテナがstdout/stderrに書いたログを、kubeletがどのようにノード上のファイルへ落とし込み、どうローテーションしているかを確認します。 2.1 ディレクトリ構成とファイルパスの決定 コンテナログの絶対パスは、Pod単位のディレクトリとコンテナ単位のサブディレクトリの組み合わせで決まります。 // BuildPodLogsDirectory builds absolute log directory path for a pod sandbox. func BuildPodLogsDirectory(podLogsDir, podNamespace, podName string, podUID types.UID) string { return filepath.Join(podLogsDir, strings.Join([]string{podNamespace, podName, string(podUID)}, logPathDelimiter)) } (参考: pkg/kubelet/kuberuntime/helpers.go#L209-L226) podLogsDirのデフォルト値は/var/log/podsで、KubeletConfigurationのpodLogsDirフィールド(デフォルトは空文字列扱いで、起動時にこのデフォルトが補完されます)で変更できます。 DefaultPodLogsDir = "/var/log/pods" (参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L42、フィールド定義はstaging/src/k8s.io/kubelet/config/v1beta1/types.go#L132-L137) 変更する場合は、KubeletConfiguration(--configで指定するファイル、kindクラスタでは/var/lib/kubelet/config.yaml)に次のように指定します。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration podLogsDir: /mnt/log/pods コンテナ単位のファイル名は<コンテナ名>/<再起動回数>.logで、このパスがCRIのContainerConfig.LogPathとしてランタイムに渡され、Pod Sandbox単位で設定されるLogDirectoryと結合されて最終的な絶対パスになります。 containerLogsPath := buildContainerLogsPath(container.Name, restartCount) // ... config := &runtimeapi.ContainerConfig{ // ... LogPath: containerLogsPath, // ... } (参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L360-L382、Sandbox側のLogDirectory設定はpkg/kubelet/kuberuntime/kuberuntime_sandbox.go#L117-L118) 実際にkindクラスタ上でPodを起動して確認すると、/var/log以下は次のような階層になっていました。 $ tree /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4 /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4 `-- app |-- 0.log |-- 0.log.20261007-065856.gz |-- 0.log.20261007-065907.gz |-- 0.log.20261007-065917.gz `-- 0.log.20261007-065927 2 directories, 5 files pods/の下が<namespace>_<name>_<uid>、その下が<コンテナ名>というディレクトリになっており、ソースコードの実装どおりの階層が再現されています。 2.2 /var/log/containers/へのシンボリックリンク /var/log/containers/配下にも、コンテナログのシンボリックリンクが作られます。これはCRI導入前、kubeletがdockerdを直接操作していた頃に使われていたコンテナログの配置をそのまま残したものです。関数名もlegacyLogSymlinkと、ソースコード上はっきり「legacy」と位置づけられています。 // legacyLogSymlink composes the legacy container log path. It is only used for legacy cluster // logging support. func legacyLogSymlink(containerID string, containerName, podName, podNamespace string) string { return logSymlink(legacyContainerLogsDir, kubecontainer.BuildPodFullName(podName, podNamespace), containerName, containerID) } (参考: pkg/kubelet/kuberuntime/legacy.go#L31-L75) シンボリックリンクの作成自体は、コンテナ起動が成功した直後、実際のログファイルが存在することを確認してから行われます。 // Symlink container logs to the legacy container log location for cluster logging // support. // TODO(random-liu): Remove this after cluster logging supports CRI container log path. (参考: pkg/kubelet/kuberuntime/kuberuntime_container.go#L300-L317) :::note warn 上記のTODOコメントにあるとおり、このシンボリックリンクはいずれ削除される可能性があります。 ::: 実際の検証環境では、次のようなシンボリックリンクが張られていました。 $ ls -l /var/log/containers/ | grep demo-logtest lrwxrwxrwx 1 root root 81 Oct 7 06:58 demo-logtest_default_app-a33e8752ef7403a4970f2531374c62e9a657e5b945b3df05f1a8604da9f7d222.log -> /var/log/pods/default_demo-logtest_7f337cdd-9b2c-4cdd-839d-8336181f06a4/app/0.log 2.3 CRIログフォーマットとkubectl logs コンテナランタイムがファイルに書き込むログの各行は、CRIで定められた形式に従います。この形式はkubelet側のk8s.io/cri-clientモジュールでパースされており、ソースコード中のコメントに例が示されています。 // parseCRILog parses logs in CRI log format. CRI Log format example: // // 2016-10-06T00:17:09.669794202Z stdout P log content 1 // 2016-10-06T00:17:09.669794203Z stderr F log content 2 (参考: staging/src/k8s.io/cri-client/pkg/logs/logs.go#L123-L168) タイムスタンプ・ストリーム種別(stdout/stderr)に続く1文字は、そのログ行が1行の出力として完結しているか(F = Full)、まだ続きがあるか(P = Partial)を示すタグです。 LogTagPartial LogTag = "P" LogTagFull LogTag = "F" (参考: staging/src/k8s.io/cri-api/pkg/apis/runtime/v1/constants.go#L49-L52) :::note info このタグは、アプリケーションが改行区切りで書いた複数行のログ(スタックトレース等)を結合するためのものではありません。CRIランタイム側の1行あたりの読み取りバッファ上限(containerdではMaxContainerLogLineSize、デフォルト16KB)を、アプリケーションが1回の書き込みで超えた場合に、その1行を複数の物理行に分割して書き込むための仕組みです。分割された行のうち、最後の断片だけがFになります。 if maxLen > 0 && length > maxLen { // ... writeLineBuffer(partial, buf) // ... } (参考: internal/cri/io/logger.go#L114-L195、バッファ上限の設定はinternal/cri/config/config_unix.go#L102) ::: 検証環境のkube-apiserverのログを見ると、実際にこの形式で書き込まれていることが確認できます。 $ docker exec k8s-logging-demo-control-plane tail -c 300 /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/0.log 2026-09-30T06:07:44.320806626Z stderr F I0930 06:07:44.320423 1 controller.go:667] quota admission added evaluator for: controllerrevisions.apps 2026-09-30T06:07:44.563889999Z stderr F I0930 06:07:44.563773 1 controller.go:667] quota admission added evaluator for: replicasets.apps kubectl logsはこのCRI形式のファイルを直接読み、タグとタイムスタンプを除いた本文だけをクライアントに返します。 CRIログ形式がstdout/stderrをストリーム種別として行ごとに記録していることを利用して、Pod APIからstdout・stderrのどちらか一方だけを取得できる機能もあります。PodLogsQuerySplitStreamsというFeature Gateで、v1.32でアルファとして導入され、v1.37時点でもデフォルトで無効です(参考: pkg/features/kube_features.go#L899-L902、デフォルト状態は同ファイルL1978-L1980)。 kubelet側の実装では、PodLogOptionsのstreamフィールドの値に応じて、CRIログのstdout行・stderr行のどちらを応答に書き込むかを切り替えているだけです。 switch *wantedStream { case v1.LogStreamStdout: stdout, stderr = fw, nil case v1.LogStreamStderr: stdout, stderr = nil, fw case v1.LogStreamAll: stdout, stderr = fw, fw (参考: pkg/kubelet/server/server.go#L888-L901) Feature Gateを有効にして実際に試すと、streamクエリパラメータでstdout/stderrを分離して取得できました。 $ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stdout" stdout-line-1 stdout-line-2 done $ kubectl get --raw "/api/v1/namespaces/default/pods/demo-streams/log?stream=Stderr" stderr-line-1 stderr-line-2 普段のkubectl logsはstreamを指定しない(All)ため、これまでどおり両方のストリームがまとめて返ってきます。 2.4 ログローテーション コンテナログのローテーションはpkg/kubelet/logs/container_log_manager.goが担当します。デフォルト値は次のとおりです。 | 設定項目 | デフォルト値 | 役割 | |---|---|---| | containerLogMaxSize | 10Mi | 1ファイルあたりの上限サイズ | | containerLogMaxFiles | 5 | コンテナあたりの最大ファイル数(現行+ローテーション済み合計) | | containerLogMaxWorkers | 1 | ローテーションを並行実行するワーカー数 | | containerLogMonitorInterval | 10秒 | ログサイズを監視する間隔 | (参考: pkg/kubelet/apis/config/v1beta1/defaults.go#L256-L266) これらもKubeletConfigurationで変更できます。たとえばログの量が多いワークロードが多いノードで、1ファイルあたりの上限を大きくしつつ保持ファイル数を減らす場合は次のようになります。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration containerLogMaxSize: 50Mi containerLogMaxFiles: 3 containerLogMaxWorkers: 2 containerLogMonitorInterval: 10s ローテーションの実体は、containerLogMonitorIntervalごとに実行中の全コンテナを列挙し、ログファイルのサイズがcontainerLogMaxSize以上ならローテーション処理をキューに積む、というシンプルなポーリングです。 func (c *containerLogManager) processContainer(ctx context.Context, worker int) (ok bool) { // ... if info.Size() < c.policy.MaxSize { // rotate不要 return } if err := c.rotateLog(ctx, id, path); err != nil { // ... } return } (参考: pkg/kubelet/logs/container_log_manager.go#L218-L270) ここで重要なのは、kubeletがファイルをtruncateして使い回すのではなく、次の手順を踏む点です。 1. 現在のログファイルをタイムスタンプ付きの名前にrenameする(この時点ではまだ無圧縮)。これはkubelet自身が行う処理で、ファイルの中身ごと退避させるだけです。 2. CRIランタイムのReopenContainerLogを呼ぶ。ここから先はkubeletではなくランタイム側の処理です。 1. リネームによって空になった元のパスに、新しいログファイルを作成する。 2. コンテナのstdout/stderrから受け取ったバイトの書き込み先を、今作った新しいファイルに差し替える。 3. それまで書き込み先として使っていた古いファイルハンドル(リネームされた方のファイルを握っていたハンドル)を閉じる。 3. ReopenContainerLogが失敗したらリネームを元に戻し、次のローテーション周期(デフォルト10秒)で再試行する(ログを失わないための保険)。 コンテナプロセス自身は、自分のstdoutの書き込み先ファイルが新しいファイルに切り替わったことを一切意識しません。ファイルの実体を直接触っているのはコンテナプロセスではなくランタイム側であり、コンテナはあくまでランタイムが用意したパイプに書き込み続けているだけだからです。 (参考: containerd側のReopenContainerLog実装はinternal/cri/server/container_log_reopen.go#L29-L51、新しいログファイルをO_CREATEで作成している箇所はinternal/cri/server/helpers_linux.go#L58-L62) func (c *containerLogManager) rotateLatestLog(ctx context.Context, id, log string) error { // ... timestamp := c.clock.Now().Format(timestampFormat) rotated := fmt.Sprintf("%s.%s", log, timestamp) if err := c.osInterface.Rename(log, rotated); err != nil { return fmt.Errorf("failed to rotate log %q to %q: %w", log, rotated, err) } if err := c.runtimeService.ReopenContainerLog(ctx, id); err != nil { // rename back... } return nil } (参考: pkg/kubelet/logs/container_log_manager.go#L414-L436) リネーム後、まだ圧縮されていないローテーション済みファイルはgzipで非同期に圧縮され、圧縮後は元ファイルが削除されます。ファイル数がcontainerLogMaxFilesを超える場合は、古い順に削除されます(「現在のファイル」と「ローテーション中の1ファイル」を除いたMaxFiles - 2個までが、圧縮済みログとして保持される計算です)。 // removeExcessLogs removes old logs to make sure there are only at most MaxFiles log files. func (c *containerLogManager) removeExcessLogs(logs []string) ([]string, error) { sort.Strings(logs) maxRotatedFiles := c.policy.MaxFiles - 2 // ... } (参考: pkg/kubelet/logs/container_log_manager.go#L272-L371、gzip圧縮の実装は同ファイルL373-L411) 実際にローテーションを起こしてみる kindクラスタ上でstdoutに大量の行を書き続けるだけのPodを動かし、ローテーションの様子を観察しました。開始直後は、ローテーション済みファイルがまだ圧縮されていない状態が見えます。 $ tree -h /var/log/pods/default_demo-logtest_.../app /var/log/pods/default_demo-logtest_.../app |-- [ 17M] 0.log `-- [ 11M] 0.log.20260930-062154 1 directory, 2 files しばらく待つと、ローテーション済みファイルが.gzに置き換わり、さらに新しいローテーションも発生しています。 $ tree -h /var/log/pods/default_demo-logtest_.../app /var/log/pods/default_demo-logtest_.../app |-- [ 11M] 0.log |-- [1005K] 0.log.20260930-062154.gz `-- [ 17M] 0.log.20260930-062214 1 directory, 3 files さらにローテーションが繰り返されると、圧縮済み3ファイル+ローテーション中の1ファイル+現在のファイルの合計5ファイルで安定し、6ファイル目が作られるタイミングで最も古いファイルが削除されました。 $ tree -h /var/log/pods/default_demo-logtest_.../app /var/log/pods/default_demo-logtest_.../app |-- [2.9M] 0.log |-- [1005K] 0.log.20260930-062154.gz |-- [1.5M] 0.log.20260930-062214.gz |-- [1.4M] 0.log.20260930-062234.gz `-- [ 17M] 0.log.20260930-062254 1 directory, 5 files containerLogMaxFiles: 5が「現在のファイルを含めて合計5ファイル」を意味することが、実際の挙動としても確認できました。 :::note warn kubectl logsで読めるのは現在(最新)のログファイルだけです。ローテーション済みの.log.<timestamp>や.gzファイルの内容はkubectl logsでは取得できません。ノード上のファイルを直接見るか、ログ収集エージェント側でローテーション済みファイルまで拾う設計にしておく必要があります(参考: Logging Architecture#Log rotation)。 ::: 第3章: システムコンポーネントのログ Podのコンテナとは別に、kubeletやコンテナランタイム自身、そして制御コンポーネントにもログがあります。kubeletとコンテナランタイムはPodを管理する側なので、常にノードのOS上のプロセスとして動きます。一方、kube-apiserverなどの制御コンポーネントをsystemdのunitとして動かすかstatic Podとして動かすかは、クラスタの構築方法次第です。動かし方によってログの出力先・ローテーション方式が次のように変わります。 | パターン | 実体 | ログの出力先 | ローテーション | 確認コマンド | |---|---|---|---|---| | systemdのunit | ノードOS上で直接動くプロセス | journald | Kubernetes側の機構はなし。journald/OS側に依存(3.4節) | journalctl -u <unit名> | | static Pod | kubeletが管理する「ただのコンテナ」 | 通常のPodと同じ/var/log/pods(stdout) | 通常のコンテナログと同じcontainerLogMaxSize/MaxFiles(第2章2.4) | kubectl logs | この章では、それぞれどこに・どんな形式で出力されるかを以下で詳しく確認します。この記事の検証環境(kind)では、次のように動いています。 - systemd - kubelet - コンテナランタイム(containerd) - static Pod - kube-apiserver - kube-controller-manager - kube-scheduler - etcd 3.1 systemdで管理されるコンポーネント kubeletとコンテナランタイムは、通常コンテナの中では動かず、ノードのOS上で直接動くプロセスです。ノードの初期化・サービス管理の仕組みはsystemdであることが多いため、この記事もsystemdを前提に話を進めます。systemd環境では、デフォルトでjournaldにログを書き込みます。 今回の検証で用いたkindでも、kubeletはsystemdのunitとして動いています。 $ docker exec k8s-logging-demo-control-plane systemctl status kubelet --no-pager | head -8 ● kubelet.service - kubelet: The Kubernetes Node Agent Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; preset: enabled) Active: active (running) since Wed 2026-09-30 06:07:36 UTC; 14min ago Main PID: 766 (kubelet) $ docker exec k8s-logging-demo-control-plane journalctl -u kubelet --no-pager | tail -3 Sep 30 06:21:41 k8s-logging-demo-control-plane kubelet[766]: I0930 06:21:41.349103 766 pod_startup_latency_tracker.go:144] "Observed pod startup duration" pod="default/demo-logtest" ... journalctl -u kubeletでkubelet自身のログが直接読めます。 3.2 static Podとして動くコンポーネント static Podの元になるマニフェストの置き場所は、kubeletのKubeletConfigurationではstaticPodPathというフィールドで指定します。 // staticPodPath is the path to the directory containing local (static) pods to // run, or the path to a single static pod file. // Default: "" StaticPodPath string `json:"staticPodPath,omitempty"` (参考: staging/src/k8s.io/kubelet/config/v1beta1/types.go#L127-L131) デフォルト値は空文字列で、kubelet自体は/etc/kubernetes/manifestsをハードコードしていません。この慣習的なパスはkubeadmなど、kubeletを実際にセットアップするツール側の取り決めで、実際にkubeadmが生成するKubeletConfigurationには次のように明示的に書き込まれています。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration staticPodPath: /etc/kubernetes/manifests 監視の実装はpkg/kubelet/config/file.goのNewSourceFileで、指定したディレクトリをファイルシステム通知で監視し、追加・削除されたマニフェストに応じてPodを起動・停止します。static Podは通常のAPIオブジェクトとしては作成されないため、kubeletはpkg/kubelet/pod/mirror_client.go経由で対応する「mirror Pod」をAPIサーバー上に作り、kubectl get pod等から見えるようにしています。 重要なのは、これらのコンポーネントがただのコンテナとして動いているという点です。static Podとして動くkube-apiserver等がstdoutに書いたログは、通常のPodのコンテナと全く同じ扱い(第2章のCRIログ管理・/var/log/pods・containerLogMaxSize/MaxFilesによるローテーション)を受けます。実際、検証環境でも次のとおり通常のPodと同じディレクトリ構成でした。 $ tree --matchdirs -P '*kube-apiserver*' --prune /var/log/pods /var/log/pods `-- kube-system_kube-apiserver-k8s-logging-demo-control-plane_aec712fda8c04555eaada4085f170287 `-- kube-apiserver 3 directories, 0 files 3.3 ログのフォーマットとklog システムコンポーネントのログは、同じ内容でも次の2つのフォーマットのどちらかで出力されます。 | 値 | 出力形式 | |---|---| | text(デフォルト) | 人間が読みやすいキーバリュー形式 | | json | 構造化されたJSON形式 | 実際にkube-schedulerのリーダー選出のログを、フォーマットを切り替えて比較してみます。text形式(デフォルト)では次のように出力されます。 I1008 08:56:19.685458 1 leaderelection.go:258] "Attempting to acquire leader lease..." lock="kube-system/kube-scheduler" I1008 08:56:19.688682 1 leaderelection.go:272] "Successfully acquired lease" lock="kube-system/kube-scheduler" 同じイベントをjson形式に切り替えて出力させると、次のようになります。 {"ts":1791449829380.4631,"caller":"leaderelection/leaderelection.go:258","msg":"Attempting to acquire leader lease...","v":0,"lock":"kube-system/kube-scheduler"} {"ts":1791449829383.8164,"caller":"leaderelection/leaderelection.go:272","msg":"Successfully acquired lease","v":0,"lock":"kube-system/kube-scheduler"} lockフィールドの中身は同じですが、text形式はメッセージと埋め込まれたキーバリューをそのまま1行に並べた形式、json形式はts/caller/msg/vといったキーを持つJSONオブジェクトです。このフォーマットの出し分けを担っているのが、kubeletを含むKubernetesのコンポーネントが共通で使っているロギングライブラリklogです。klogは出力形式だけでなく出力先についても規定しており、公式ドキュメントには次のように明記されています。 > Output will always be written to stderr, regardless of the output format. Output redirection is expected to be handled by the component which invokes a Kubernetes component. This can be a POSIX shell or a tool like systemd. (参考: System Logs#Klog) 出力形式に関わらず、出力先は常にstderr固定であり、リダイレクトはKubernetesコンポーネントを起動する側(POSIXシェルやsystemdなど)の責任だと明記されています。 この--logging-formatフラグは、次の4コンポーネントで利用できます。いずれもデフォルトはtext形式で、json形式にするには明示的な設定が必要です。 - kube-apiserver - kube-controller-manager - kube-scheduler - kubelet 実際、検証環境のstatic Podマニフェストを見ても--logging-formatは指定されておらず、ログはklogのテキスト形式のままでした。 $ docker exec k8s-logging-demo-control-plane grep -i logging-format /etc/kubernetes/manifests/*.yaml (該当なし) 有効にする場合、static Podとして動くものはマニフェストのcommandに以下のように引数を追加します。 apiVersion: v1 kind: Pod metadata: name: kube-apiserver namespace: kube-system spec: containers: - name: kube-apiserver command: - kube-apiserver - --logging-format=json # ...(他の引数は省略) kubelet自身は起動引数ではなくKubeletConfigurationのlogging.formatフィールドで指定します。 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration logging: format: json JSON形式に加えて、Contextual LoggingとStructured Loggingという2つの仕組みも押さえておくとよいと思います。どちらも「有効かどうか」だけでなく、ログ出力箇所のコードが実際にそのAPIを使っているかによって見え方が変わります。kube-schedulerの実装を例に見てみます。 func (sched *Scheduler) scheduleOnePod(ctx context.Context, podInfo *framework.QueuedPodInfo) { logger := klog.FromContext(ctx) pod := podInfo.Pod // TODO(knelasevero): Remove duplicated keys from log entry calls // When contextualized logging hits GA // https://github.com/kubernetes/kubernetes/issues/111672 logger = klog.LoggerWithValues(logger, "pod", klog.KObj(pod)) ctx = klog.NewContext(ctx, logger) logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod)) (参考: pkg/scheduler/schedule_one.go#L93-L101) - Contextual Logging - 次の部分がこれです。 ```go klog.LoggerWithValues(logger, "pod", klog.KObj(pod)) ``` このPod用に作ったloggerにpodフィールドを紐付け、ctx = klog.NewContext(ctx, logger)でcontextに載せています。これ以降ctxを受け取って動く別の関数がklog.FromContext(ctx)でロガーを取り出すだけで、都度"pod", klog.KObj(pod)を書き直さなくてもpodフィールドが自動的に付いてきます。 - Structured Logging - 次の"pod", klog.KObj(pod)という部分がこれです。 ```go logger.V(4).Info("About to try and schedule pod", "pod", klog.KObj(pod)) ``` メッセージ文字列とは別に、キーと値の組をログ行に付与します。--logging-format=text(デフォルト)なら次のようにキーバリュー形式で、--logging-format=jsonならJSONのフィールドとして出力されます。 実際にkindクラスタのkube-scheduler(-v=4)でこの行を出力させてみると、次のようになりました。 I1007 09:47:56.151071 1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test" コード中には"pod", klog.KObj(pod)という呼び出し側の明示的なキーがあります。loggerは直前のklog.LoggerWithValuesで既にpodフィールドを持っているので、本来この呼び出し側での指定は冗長なはずです。実際、klogの内部実装(internal/serialize/keyvalues.go)には、同じキーが複数回渡された場合に重複を取り除く処理があり、値が一致していれば後から渡された分は捨てられます。 if bytes.Compare(data[existing[i].start:existing[i].end], data[e.start:e.end]) == 0 { // The new entry gets obsoleted because it's identical. // ... obsolete = append(obsolete, e.interval) } (参考: vendor/k8s.io/klog/v2/internal/serialize/keyvalues.go#L100-L109) 試しに--feature-gates=ContextualLogging=falseでContextual Logging自体を無効化し、同じ行を出力させても、結果は変わりませんでした。 I1007 09:49:45.058261 1 schedule_one.go:101] "About to try and schedule pod" pod="default/demo-schedule-test3" これは、Contextual Loggingが無効ならklog.LoggerWithValuesが実質何もしなくなる一方で、呼び出し側が同じpodキーを明示的にも渡しているため、そちらだけでpodフィールドが出力され続けるからです。コード中のTODOコメントが示すとおり、 // TODO(knelasevero): Remove duplicated keys from log entry calls // When contextualized logging hits GA (参考: pkg/scheduler/schedule_one.go#L96-L97) これは「Contextual LoggingがGAになるまでの間、無効化されても出力が変わらないようにするための意図的な二重指定」だと考えられます。GA後にこの冗長な呼び出し側の指定が取り除かれれば、podフィールドが出力されるかどうかはContextual Loggingの有効/無効に完全に依存するようになると思います。 3.4 システムコンポーネントログのローテーション 第2章で見たコンテナログのローテーションは、あくまでkubeletが管理するContainerLogMaxSize/MaxFilesの仕組みです。journaldに書かれるkubelet・コンテナランタイム自身のログには、この仕組みは適用されません。k8s.io/component-base/logsパッケージ自体にも、サイズや時間を基準にしたローテーション処理は実装されていません。公式ドキュメントも次のように説明しています。 > Similar to the container logs, you should rotate system component logs in the /var/log directory. In Kubernetes clusters created by the kube-up.sh script, log rotation is configured by the logrotate tool. The logrotate tool rotates logs daily, or once the log size is greater than 100MB. (参考: System Logs#Log location) static Podとして動く制御コンポーネントについても、公式ドキュメントは次のように注意を促しています。 > If you deploy Kubernetes cluster components (such as the scheduler) to log to a volume shared from the parent node, you need to consider and ensure that those logs are rotated. Kubernetes does not manage that log rotation. (参考: Logging Architecture) これは、stdoutに書く(第2章のCRI管理下に入る)のではなく、あえて/var/logをhostPathでマウントしてそこにファイルを書かせるような構成を取った場合の注意で、stdoutに書く一般的な構成であれば、通常のコンテナログと同じくkubeletがローテーションを管理してくれます。 3.5 Log Queryでノードのシステムログを直接取得する 比較的新しい機能として、Log Queryがあります。v1.27でアルファ機能として導入され、v1.36で安定版に昇格しました(参考: System Logs#Log query)。ノードにSSHしたりjournalctlを叩いたりしなくても、kube-apiserver経由でノード上のシステムログを取得できます。 利用にはkubeletのenableSystemLogHandler(デフォルトfalse)を有効にする必要があります。検証環境で実際に有効化して試してみました。 # /var/lib/kubelet/config.yaml に追記 enableSystemLogHandler: true enableSystemLogQuery: true kubelet再起動後、次のようにkubectl get --rawでkubeletのログを直接取得できました。 $ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&tailLines=5" Sep 30 06:29:29.628900 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.628821 ... "Finished populating initial desired state of world" Sep 30 06:29:29.644381 k8s-logging-demo-control-plane kubelet[1866838]: I0930 06:29:29.644327 ... "operationExecutor.VerifyControllerAttachedVolume started for volume \"xtables-lock\"..." pod="kube-system/kube-proxy-jqk52" patternクエリパラメータで正規表現による絞り込みもできます。 $ kubectl get --raw "/api/v1/nodes/k8s-logging-demo-control-plane/proxy/logs/?query=kubelet&pattern=broadcasted&tailLines=200" Sep 30 06:07:32.248053 k8s-logging-demo-control-plane kubelet[244]: I0930 06:07:32.248014 244 server.go:177] "Pod update broadcasted" podUID="aec712fda8c04555eaada4085f170287" type="ADDED" queryにはサービス名だけでなく/var/log配下のファイル名も指定でき、kubeletはjournaldを優先的に探し、無ければ/var/log/<servicename>等を探すヒューリスティックで動作します(参考: System Logs#Log query)。 :::note warn nodes/proxyへのget権限は、Log Query以外にもノード上で任意のコンテナに対してコマンドを実行できる強力なkubelet APIへのアクセスも同時に許可してしまいます。権限を付与する際はKubelet認証・認可のドキュメントにある注意点を確認しておくのが良いと思います。 ::: 第4章: EventとAudit Log EventとAudit Logは、それぞれ別の記事で扱っているのでそちらを参照してください。 4.1 Event Podのスケジューリング失敗やヘルスチェック失敗など、Kubernetesの各コンポーネントが状態変化に応じて発行するAPIオブジェクトです。kubectl describe podやkubectl get eventsで確認できますが、デフォルトで1時間(--event-ttl)しか保持されません。Reasonの種類ごとの詳しい整理は、以前書いた記事を参照してください。 参考: Kubernetesのeventログの一覧 Eventを長期間残したい場合は、Event APIをwatchして別の場所へ出力する専用のコンポーネントを用意します。その実装例として、eventrouterを見てみます。手元で確認したクローンは、Deploymentを使ってクラスタ全体で1つだけPodを用意し、そのPod内のプロセスがSharedInformerでEvent APIをwatchする実装でした。 func NewEventRouter(kubeClient kubernetes.Interface, eventsInformer coreinformers.EventInformer) *EventRouter { er := &EventRouter{ kubeClient: kubeClient, eSink: sinks.ManufactureSink(), } eventsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: er.addEvent, UpdateFunc: er.updateEvent, DeleteFunc: er.deleteEvent, }) er.eLister = eventsInformer.Lister() er.eListerSynched = eventsInformer.Informer().HasSynced return er } (参考: eventrouter.go#L50-L63、Informer自体の生成元はmain.go#L112-L113) 受け取ったEventの転送先(sink)は、設定で切り替えられる作りになっています。 func ManufactureSink() (e EventSinkInterface) { s := viper.GetString("sink") switch s { case "glog": e = NewGlogSink() case "stdout": e = NewStdoutSink() case "http": // ... case "kafka": // ... } return e } (参考: sinks/interfaces.go#L35-L82) リポジトリに同梱されているKubernetesマニフェストでは、このsinkがstdoutに設定されています。 apiVersion: v1 data: config.json: |- { "sink": "stdout" } kind: ConfigMap (参考: yaml/eventrouter.yaml) stdout sinkの実装は、EventをJSONにシリアライズして標準出力に書き出すだけです。コード中のコメントに、その狙いが明記されています。 // StdoutSink is the other basic sink // By default, Fluentd/ElasticSearch won't index glog formatted lines // By logging raw JSON to stdout, we will get automated indexing which // can be queried in Kibana. (参考: sinks/stdoutsink.go#L27-L46) つまりEvent専用の転送手段を別途実装するのではなく、第5章・第6章で見たコンテナログ収集と同じ「標準出力に書く→ノードレベルエージェントが拾う」という仕組みにそのまま乗せる設計です。eventrouterのPod自体が標準出力にJSONを書き出すだけで、既存のFluent BitなどのDaemonSetがいつも通り収集し、Eventの長期保存が実現できます。 4.2 Audit Log 「誰が・いつ・何を・どうしようとしたか」というAPIリクエストそのものを記録する、kube-apiserverの仕組みです。Eventとは異なるaudit.k8s.ioという専用のAPI群で表現され、Policyでログの粒度や出力先(ファイル/webhook)を細かく制御できます。設定方法や実際に記録される内容については、以前書いた記事を参照してください。 参考: Kubernetesの監査ログ(Audit Log)入門 第5章: ログの収集方法 ノード上に散らばったログを、クラスタ全体で一元的に扱うための収集方法を整理します。 5.1 3つのアーキテクチャパターン 公式ドキュメントは、クラスタレベルのログ収集について3つのアプローチを挙げています。 - アプリケーションから直接バックエンドへログを送る。 - アプリケーションPodに専用のsidecarコンテナを追加する。 - ノードごとに動くノードレベルのロギングエージェントを使う。 | パターン | 概要 | 向いているケース | |---|---|---| | アプリケーションから直接送信 | Kubernetesの管轄外 | アプリケーション側で送信ライブラリを組み込める場合 | | sidecar(専用エージェント) | Fluentd等のロギングエージェント自体をPodにsidecarコンテナとして追加 | ノードレベルエージェントでは要件を満たせない場合。stdoutに書かない場合はkubectl logsでは読めない点に注意 | | ノードレベルエージェント(DaemonSet) | ノードごとに1つのエージェントが、そのノード上の全コンテナのログファイルを収集 | アプリケーション側の変更が不要。最も一般的 | 5.2 アプリケーションから直接送信する アプリケーション自身がログ送信用のライブラリを組み込み、ノードのエージェントを経由せず直接バックエンドへ送信する方法です。Kubernetes自体の管轄外の構成になります。 !Exposing logs directly from the application アプリケーションがノードやKubernetesを経由せず、直接ログバックエンドへ送信する構成 (参考: Logging Architecture#Exposing logs directly from the application) 5.3 専用のロギングエージェントをPodにsidecarとして追加する ノードレベルのエージェントでは要件を満たせない場合、Fluentd等のロギングエージェントをPodにsidecarコンテナとして追加し、アプリケーションコンテナのログを直接処理させる方法もあります。 !Sidecar container with a logging agent 専用のロギングエージェントをPodにsidecarコンテナとして追加し、直接バックエンドへ転送する構成 (参考: Logging Architecture#Sidecar container with a logging agent) 5.4 ノードレベルエージェントによるDaemonSetでの収集 最も一般的なのは、Fluent BitやOpenTelemetry CollectorのようなロギングエージェントをDaemonSetとしてすべてのノードにデプロイし、各ノードの/var/log/podsをhostPathでマウントして読む方法です。 !Using a node logging agent ノードごとに1つのロギングエージェントを配置し、そのノード上の全コンテナのログファイルを収集する構成 (参考: Logging Architecture#Using a node logging agent) DaemonSetにする理由は単純で、ノードごとに1つのエージェントだけを常駐させれば、そのノード上で動く全Podのログを横断的に拾えるためです。アプリケーション側のマニフェストを変更する必要がありません。 個人的には、まずこの方法から始めるのが良いと思います。アプリケーション側に手を入れる必要がなく、クラスタ側にDaemonSetを1つ用意するだけで、既存のワークロードも含めて横断的にログを収集できるためです。 第6章: ログ収集の実装例: Fluent BitとOpenTelemetry Collector 6.1 ログを収集して、蓄積する場合の例 ログを収集して蓄積する一例として、ここではノードレベルエージェントにFluent Bit・OpenTelemetry Collector・Vector、転送先にGrafana Lokiを使う構成を見ていきます。 - Fluent Bit (C言語, Apache License 2.0) - ロギングエージェント。CNCF(Cloud Native Computing Foundation)のプロジェクトで、複数のコントリビューターが開発しています(参考: README.md#Authors)。in_tail・filter_kubernetes・out_lokiを使います。 - OpenTelemetry Collector (Go言語, Apache License 2.0) - ロギングエージェント。同じくCNCFのプロジェクトです(参考: OpenTelemetry)。Collectorのメンテナーは特定の1社が全体の4分の1を超えて占めないようにする規約があり、複数ベンダーのコントリビューターがバランスを取りながら開発しています(参考: README.md#No Over-Representation)。filelog receiver・container operator・k8sattributesprocessor・otlphttp exporterを使います。 - Vector (Rust言語, Mozilla Public License 2.0) - ロギングエージェント。主にDatadogが開発しています(参考: README.md#What is Vector?)。設定ファイル自体にvector testでユニットテストを書けるように設計されているのが特徴で(参考: Unit tests)、Kubernetes向けに専用化されたkubernetes_logs sourceと、Loki向けのloki sinkを使います。 - Grafana Loki (Go言語, AGPL-3.0-only) - 収集したログの転送先・蓄積先(一部ファイルはApache License 2.0の例外あり、参考: README.md#License)。主にGrafana Labsが開発しています。Prometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別します。 ロギングエージェントの実装には、大きく分けて次の工程があります。 - ログローテーションへの追従 - CRIログの再構成 - Podメタデータの付与 - Grafana Lokiへの転送 - チェックポイントの永続化 このうち最初の3つは第2章・第3章の内容と直接関わってきます。それぞれについて、Fluent Bit・OpenTelemetry Collector・Vectorがどういう考え方で実装しているかをソースコードを確認しながら見ていきます。続いて転送先であるGrafana Lokiへの送り方の違いを確認し、最後にDaemonSetの更新などでPodが入れ替わっても読み取り位置を引き継げるようにする、チェックポイントの永続化の仕組みも見ていきます。 6.2 ログローテーションへの追従 第2章2.4で見たとおり、kubeletは現在のログファイルをrenameしてから、CRIランタイムに元のパスへ新しいファイルを開き直させます。ログエージェントが単純にファイルパスだけをポーリングしていると、リネームの前後で「今開いているファイル」と「そのパスにある実際のファイル」がずれてしまい、ログを取りこぼす可能性があります。 Fluent Bitの場合 Fluent Bitのin_tailは、この問題をinode比較で解決しています。監視中のパスを定期的にstatし、記録しているinodeと食い違っていればローテーションされていると判断します。 /* Returns FLB_TRUE if a file has been rotated, otherwise FLB_FALSE */ int flb_tail_file_is_rotated(struct flb_tail_config *ctx, struct flb_tail_file *file) (参考: plugins/in_tail/tail_file.c#L2154-L2226、ローテーション後も元のファイルディスクリプタで読み切ってから新しいファイルを追加で監視する処理は同ファイルL2432-L2496) パスのinodeが変わらないまま存在し続けるファイルは同一ファイルとして読み続け、リネーム後に元のパスに現れたinodeの異なるファイルは新規ファイルとして扱われます。「ファイルパスだけでは同一性を保証できない」というkubeletのローテーション方式に対応するための設計で、inodeをそのまま使う直接的なアプローチです。 OpenTelemetry Collectorの場合 filelog receiverの実体はpkg/stanza/fileconsumerパッケージです。ログローテーションへの追従方式はFluent Bitのinode比較とは異なり、ファイル冒頭1000バイト(デフォルト)のチェックサムをフィンガープリントとして使っています。 // Fingerprint is used to identify a file // A file's fingerprint is the first N bytes of the file type Fingerprint struct { firstBytes []byte key string } (参考: pkg/stanza/fileconsumer/internal/fingerprint/fingerprint.go#L20-L29) フィンガープリントが完全一致すれば同一ファイル、新しいフィンガープリントが古いフィンガープリントで始まっていれば「書き込みが続いている同じファイル」と判定します。 func (f Fingerprint) StartsWith(old *Fingerprint) bool { l0 := len(old.firstBytes) // ... return bytes.Equal(old.firstBytes[:l0], f.firstBytes[:l0]) } (参考: 同ファイルL93-L113、ポーリングループとフィンガープリントの突き合わせはpkg/stanza/fileconsumer/file.go#L139-L256) inodeはPOSIX固有の概念のため、Windowsでは使えません。Windowsでも共通の仕組みで動作するように、この方式になっているようです(参考: design.md#Potential failure to consume files when file rotation via move/create is used on Windows)。 Vectorの場合 Vectorも中身はフィンガープリント方式ですが、DevInode(デバイス番号+inode)とFirstLinesChecksum(先頭数行のCRC64チェックサム)の2方式を切り替え可能な設定として両方持っています。 #[derive(Debug, Clone)] pub enum FingerprintStrategy { FirstLinesChecksum { ignored_header_bytes: usize, lines: usize, }, DevInode, } (参考: lib/file-source-common/src/fingerprinter.rs#L46-L53) そのうえで、Kubernetesのコンテナログを読むkubernetes_logs sourceはFirstLinesChecksumの方を明示的に選んでいます。コメントにその理由が書かれており、「Kubernetesのログファイルの形は決まっているので、専用に調整したフィンガープリンターを使う」としています。 // The shape of the log files is well-known in the Kubernetes // environment, so we pick the a specially crafted fingerprinter // for the log files. fingerprinter: Fingerprinter::new( FingerprintStrategy::FirstLinesChecksum { // Max line length to expect during fingerprinting, see the // explanation above. ignored_header_bytes: 0, lines: fingerprint_lines, }, resolved_max_line_bytes, true, ), (参考: src/sources/kubernetes_logs/mod.rs#L885-L895、fingerprint_linesのデフォルトは1行分です。同ファイルL1125-L1127) 「専用に調整した」の中身は、FirstLinesChecksumという汎用の仕組みに渡している2つの値です。 - ignored_header_bytes: 0: 読み飛ばすヘッダーが無いという指定です。CRIログの各行はヘッダーを持たず、いきなりタイムスタンプから始まるため、0で固定できます。 - lines: fingerprint_lines(デフォルト1行): 指紋に使う行数です。CRIログは1行1エントリの形式だと分かっているため、1行だけで十分に識別できます。 実際の読み取りは、ファイルの先頭からlines回目(デフォルトでは1回目)の改行が見つかった時点で打ち切り、そこまでのバイト列をCRC64でハッシュ化したものをフィンガープリントとします。 for (pos, &c) in buf[..read].iter().enumerate() { if c == delim { if count <= 1 { total_read += pos + 1; break 'main; } else { count -= 1; } } } (参考: fingerprinter.rs#L247-L274) さらに、1行目を読み込む最大サイズ(resolved_max_line_bytes)もデフォルトで32KBです(設定で変更可能)。CRIログの各行は必ずタイムスタンプから始まるため、たとえ1行目が32KBで打ち切られても、ファイルを識別する指紋としては十分な情報が確保できます。 このフィンガープリントは、ファイルパスとは別に、監視対象を管理するハッシュマップのキーとして使われます。ポーリングのたびに見つけたファイルのフィンガープリントを計算し、既存のキーと一致すれば同じファイル、一致しなければ新しいファイルとして扱います。OpenTelemetry CollectorのStartsWith(前方一致)のような特別な比較ロジックは無く、単純なハッシュマップの検索で済んでいます。改行を見つけた時点でハッシュが確定し、その後ファイルがどれだけ成長しても1行目の内容が変わらない限りフィンガープリントも変わらないためです。 if let Some(watcher) = fp_map.get_mut(&file_id) { // file fingerprint matches a watched file // ... } else { // untracked file fingerprint self.watch_new_file(path, file_id, &mut fp_map, &checkpoints, false).await; } (参考: file_server.rs#L189-L234) OpenTelemetry Collectorが「inodeのようなOS依存の概念は避ける」という一般方針のもと、ファイルの中身によらず固定の1000バイトという汎用的な値をフィンガープリントに使っているのに対し、Vectorは汎用のfile sourceではinode方式も選べるようにしたうえで、「Kubernetesのログ形式は決まっている」という前提が成り立つ場合に限り、行数・行長をCRI形式に合わせて決め打ったチェックサム方式を選んでいる、という違いがあります。 6.3 CRIログの再構成 第2章2.3で見たP(partial)/F(full)タグは、エージェント側で正しく結合しないと1つの長い行が複数行に分断されたまま扱われてしまいます。 Fluent Bitの場合 Fluent Bitはこれを専用のCマルチラインパーサーとして実装しており、_pフィールドがFになるまでlogフィールドの内容を連結し続けます。利用する側はmultiline.parser criを明示的に指定する必要があります。 #define FLB_ML_CRI_REGEX \ "^(?<time>.+?) (?<stream>stdout|stderr) (?<_p>F|P) (?<log>.*)$" (参考: src/multiline/flb_ml_parser_cri.c#L24-L74) 汎用のマルチラインパーサー機構にCRI用の定義を1つ追加する、という組み込み方です。 OpenTelemetry Collectorの場合 filelog receiverではtype: containerというoperatorを指定するだけで、ログの中身からDocker JSON・cri-o・containerdのどの形式かを自動判定します。 switch { case dockerMatcher.MatchString(raw): return dockerFormat, nil case crioMatcher.MatchString(raw): return crioFormat, nil case containerdMatcher.MatchString(raw): return containerdFormat, nil } (参考: pkg/stanza/operator/parser/container/parser.go#L292-L311) P(partial)/F(full)タグの再結合も、logtag == 'F'になるまで束ねるrecombineという内部トランスフォーマーが自動的に動くため、追加設定は不要です。 recombineSourceIdentifier = attrs.LogFilePath recombineIsLastEntry = "attributes.logtag == 'F'" (参考: pkg/stanza/operator/parser/container/config.go#L23-L24) CRIログを扱うための専用operatorとして、形式判定から再結合までを1つにまとめている点が、汎用パーサーにCRI向けの定義を追加するFluent Bitとの違いです。 Vectorの場合 kubernetes_logs sourceも、まずログの先頭1バイトだけを見て{ならDocker JSON形式、そうでなければCRI形式と判定します。一度判定すると、以降はその形式用のパーサーに固定されます。 self.state = if bytes.len() > 1 && bytes[0] == b'{' { ParserState::Docker(docker::Docker::new(self.log_namespace)) } else { ParserState::Cri(cri::Cri::new(self.log_namespace)) }; (参考: src/sources/kubernetes_logs/parser/mod.rs#L66-L70) P/Fタグの判定と、実際の結合は別の2つのコンポーネントに分かれています。CRI用パーサーはPタグの行に汎用的な_partialフィールドを立てるだけで、 // MULTILINE_TAG // If the MULTILINE_TAG is 'P' (partial), insert our generic `_partial` key. if parsed_log.multiline_tag[0] == b'P' { self.log_namespace.insert_source_metadata( Config::NAME, log, Some(LegacyKey::Overwrite(path!(event::PARTIAL))), path!(event::PARTIAL), true, ); } (参考: src/sources/kubernetes_logs/parser/cri.rs#L77-L89) 実際の結合は後段のpartial_events_mergerが、ファイルごとのバケツにメッセージを貯めてバイト列として連結する形で行います(肥大化防止の上限・切り詰め処理つき)。 (参考: src/sources/kubernetes_logs/partial_events_merger.rs#L28-L80) OpenTelemetry Collectorが形式判定と再結合を1つのoperatorにまとめているのに対し、Vectorは「タグを見て印を付ける」パーサーと「印に応じて結合する」マージャーに役割を分けている点が対照的です。 6.4 Podメタデータの付与 /var/log/podsのディレクトリ名(<namespace>_<name>_<uid>)や、/var/log/containersのシンボリックリンク名(<pod名>_<namespace>_<コンテナ名>-<コンテナID>.log)からは、Podのnamespace・名前・コンテナ名を読み取れますが、ラベルやannotationはここには含まれません。 Fluent Bitの場合 filter_kubernetesは、まずこのファイル名を正規表現でパースし、その後kube-apiserverに問い合わせてPodのメタデータを取得します(設定でkubeletへの問い合わせに切り替えることもできますが、namespaceの情報はAPIサーバーからしか取得できません)。ログ行1件ごとに問い合わせるとAPIサーバーへの負荷が大きくなるため、namespace・Pod名・コンテナ名から作ったキャッシュキーで結果を再利用します。 (参考: ファイル名の正規表現はplugins/filter_kubernetes/kube_regex.h#L25、APIサーバーへの問い合わせはplugins/filter_kubernetes/kube_meta.c#L607-L637、キャッシュキーの構成は同ファイルL1910-L1995) ファイル名パースとAPIサーバーへの問い合わせを1つのfilterプラグインの中にまとめて持たせる、フィルタチェーンの1段として組み込む設計です。 OpenTelemetry Collectorの場合 container operatorは、include_file_path: trueでログの実ファイルパスを取得できる状態にしておけば、デフォルト(add_metadata_from_filepath: true)でファイルパスからk8s.namespace.name/k8s.pod.name/k8s.container.name/k8s.pod.uidをその場で抽出します。Fluent Bitのfilter_kubernetesと違い、この時点ではAPIサーバーへの問い合わせが一切発生しません。 k8sMetadataMapping = map[string]string{ "container_name": "k8s.container.name", "namespace": "k8s.namespace.name", "pod_name": "k8s.pod.name", "restart_count": "k8s.container.restart_count", "uid": "k8s.pod.uid", } (参考: pkg/stanza/operator/parser/container/parser.go#L51-L57、デフォルト値はconfig.go#L42) :::note warn このファイルパスからの抽出は、正規表現が/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/<再起動回数>.logという第2章2.1で見た構成を前提にしています。/var/log/containers/側のシンボリックリンクを指定すると、パースに失敗してメタデータが一切付与されません。 ::: ただしcontainer operator単体で取れるのはファイルパス由来の基本属性だけで、Podのラベルやannotationは含まれません。実運用ではラベルでログを絞り込みたい場面が多く、ラベルが無いと検索基盤側での利用に支障が出るため、k8sattributesprocessorを組み合わせるのがほぼ標準的な構成になります。こちらはFluent Bitのfilter_kubernetesと同じく、APIサーバーをSharedInformerでwatchしてローカルにPodを同期させる方式です。 informer := cache.NewSharedInformer( &cache.ListWatch{ ListWithContextFunc: informerListFuncWithSelectors(client, namespace, ls, fs), WatchFuncWithContext: informerWatchFuncWithSelectors(client, namespace, ls, fs), }, // ... ) (参考: processor/k8sattributesprocessor/internal/kube/informer.go#L54-L63、Podとの紐付けはk8s.pod.uid等のresource属性をキーに行います。pod_association.go#L20-L69) まずファイルパスという軽量な情報源から基本属性を抽出し、APIサーバーへの問い合わせが要るリッチな情報は別のprocessorに任せる、という段階的な構成です。receiverとプロセッサーを分けてパイプラインを組み立てるOpenTelemetry Collectorらしい役割分担だと言えます。 Vectorの場合 kubernetes_logs sourceは、ファイルパスのパースとAPIサーバーからの情報付与を1つの処理の中で両方行います。まず/var/log/pods/<namespace>_<name>_<uid>/<コンテナ名>/...というパスをパースして基本情報を取り出し、 pub(super) fn parse_log_file_path(path: &str) -> Option<LogFileInfo<'_>> { let mut components = path.rsplit(std::path::MAIN_SEPARATOR); let _log_file_name = components.next()?; let container_name = components.next()?; let pod_dir = components.next()?; let mut pod_dir_components = pod_dir.rsplit(LOG_PATH_DELIMITER); let pod_uid = pod_dir_components.next()?; let pod_name = pod_dir_components.next()?; let pod_namespace = pod_dir_components.next()?; // ... } (参考: src/sources/kubernetes_logs/path_helpers.rs#L32-L52) そのnamespace/Pod名をキーに、kube crateのreflector(APIサーバーをwatchしてローカルに同期させる、SharedInformerに相当する仕組み)が保持するPodのキャッシュを引きます。見つかればラベル・annotation・Pod spec/statusまで含めてフィールドを追加し、見つからなければ警告ログを出したうえでファイルパス由来の情報だけで処理を続行します。 pub fn annotate<'a>(&self, event: &mut Event, file: &'a str) -> Option<LogFileInfo<'a>> { let file_info = parse_log_file_path(file)?; let obj = ObjectRef::<Pod>::new(file_info.pod_name).within(file_info.pod_namespace); let resource = self.pods_state_reader.get(&obj); annotate_from_file_info(log, &self.fields_spec, &file_info, self.log_namespace); let Some(resource) = resource else { warn!(message = "Pod not found in API store, annotating from file path only.", ...); annotate_from_file_path(log, &self.fields_spec, &file_info, self.log_namespace); return Some(file_info); }; // ラベル・annotation・Pod spec/statusまで付与 // ... } (参考: src/sources/kubernetes_logs/pod_metadata_annotator.rs#L206-L238) Fluent BitとOpenTelemetry Collectorがそれぞれ「ファイルパス解析」と「APIサーバー問い合わせ」を別々のプラグイン・processorとして組み合わせる設計なのに対し、Vectorは1つのkubernetes_logs sourceの中に両方を組み込み、APIサーバー側の情報がまだ無い場合はファイルパス由来の情報だけで処理を続ける、というフォールバックまで内蔵しています。 6.5 Grafana Lokiへの転送 収集したログの転送先には、ログ専用のデータベースであるGrafana Lokiがよく使われます。LokiはPrometheusと同じラベル(key-value)でログの集まり(ストリーム)を識別する設計です。 Fluent Bitの場合 out_lokiという出力プラグインがF