6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Deployment を apply してから Pod が Ready になるまで

6
Last updated at Posted at 2026-09-29

はじめに

Deployment を kubectl apply でデプロイして、Pod が作成されて Ready になるまでの一連の流れを確認します。

  1. kube-apiserver: 認証・認可・admission を経て etcd に書き込まれるまで
  2. kube-controller-manager: Deployment → ReplicaSet → Pod が作られるまで
  3. kube-scheduler: 生成された Pod にノードが割り当てられるまで
  4. kubelet: 自ノードの Pod を検知してコンテナランタイムに起動を指示するまで
  5. kubelet: probe の実行から Pod Ready condition が確定するまで
  6. kube-controller-manager: EndpointSlice controller が Ready な Pod だけを Service のエンドポイントに含めるまで
  7. kube-proxy: EndpointSlice の変更がノード上の iptables/nftables ルールに反映されるまで

全体の流れ

先に全体像をシーケンス図で示します。各章の詳細を読む前に、どのコンポーネントがどの順番で kube-apiserver 経由でやり取りしているかを掴んでおくと、以降の章が追いやすくなります。

kubectl から kube-apiserver への1回のリクエストの裏側で、実際には「etcd に書き込む → watch cache 経由で次のコントローラーに通知する → そのコントローラーがまた kube-apiserver に書き込む」というサイクルが4〜5回繰り返されていることが分かります。各コンポーネントは常に kube-apiserver 経由でしかやり取りせず、コントローラー同士が直接通信することはありません。

対象コンポーネントとバージョン

コンポーネント バージョン リポジトリ 本記事での役割
Kubernetes v1.37.1 https://github.com/kubernetes/kubernetes kube-apiserver の認証・認可・admission・etcd 書き込み、Deployment/ReplicaSet controller、kube-scheduler、kubelet、EndpointSlice controller、kube-proxy
containerd v2.4.1 https://github.com/containerd/containerd CRI 実装側での Pod sandbox 作成・CNI 呼び出し

kubelet がコンテナランタイムと通信するインターフェースは CRI (Container Runtime Interface) です。CRI の Go インターフェース定義自体は Kubernetes 本体 (staging/src/k8s.io/cri-api) にありますが、その実装 (Pod sandbox の作成、CNI プラグインの呼び出し) はランタイムごとに異なります。この記事では実装例として containerd を確認していますが、CRI-O など他のランタイムでは呼び出し箇所のファイル・行番号が異なります。

TL;DR

  • kubectl apply の時点では Pod はまだ存在しません。 kube-apiserver 側では認証→認可→admission (mutating→validating) を経て Deployment オブジェクトが etcd に書き込まれるだけです
  • Deployment は直接 Pod を作りません。 Deployment controller は新しい ReplicaSet を作るだけで、実際に Pod オブジェクトを作成するのは ReplicaSet controller です
  • 生成直後の Pod は spec.nodeName が空です。 どのノードで動かすかはまだ決まっておらず、この状態で etcd に書き込まれます
  • ノードへの割り当ては kube-scheduler が Filter → Score → Bind の3段階で行います。 最終的に Pod の Binding サブリソースへ書き込むことで spec.nodeName が確定します
  • kubelet は自ノードに bind された Pod だけを watch しています。 spec.nodeName を条件にした field selector で絞り込んでいるため、他ノード宛の Pod は最初から見えません
  • kubelet 自身はコンテナやネットワークを直接作りません。 CRI 経由でコンテナランタイム (containerd 等) に Pod sandbox の作成を指示し、CNI プラグインの呼び出しもランタイム側の責務です
  • readiness probe の結果は、コンテナ単位の専用 goroutine が定期的に判定し、閾値を超えたところで status manager 経由で Pod の Ready condition に反映されます。 Ready は「全コンテナの ContainersReady」と「readinessGates の全条件」の両方が揃って初めて True になります
  • Pod が Ready になって初めて、EndpointSlice controller はその Pod を Service の Endpoint に含めます。 Ready になる前の Pod は Endpoint に含まれません
  • EndpointSlice ができただけではまだ疎通しません。 各ノードの kube-proxy も EndpointSlice を watch しており、変更のたびに自ノードの iptables (または nftables) ルールを書き換えて初めて、実際のパケット転送が有効になります

第1章: kube-apiserver — 認証・認可・admission を経て etcd に書き込まれるまで

以降の章では、次のような Deployment を kubectl apply -f deployment.yaml で apply した場合を例に説明します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: default
spec:
  replicas: 3
  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

1.1 リクエストが通過するフィルタの順序

kube-apiserver に届いた HTTP リクエストは、まず Pod や Deployment といったリソースの種類を問わずに共通して適用されるハンドラチェーンを通過します。認証や認可は「対象が Pod か Deployment か」を区別する必要がないため、リソースごとに実装せず、この共通のチェーンにまとめて実装されています。このチェーンの構築処理を見ると、各フィルタは "handler をラップして新しい handler を作る" 形で積み重なっているため、ソースコード上の記述順序と実際の実行順序は逆になります (後から代入されたフィルタほど外側 = 実行が先になります)。

// staging/src/k8s.io/apiserver/pkg/server/config.go
handler = genericapifilters.WithAuthorization(handler, c.Authorization.Authorizer, c.Serializer)
...
handler = impersonation.WithImpersonation(handler, c.Authorization.Authorizer, c.Serializer)
...
handler = genericapifilters.WithAudit(handler, c.AuditBackend, c.AuditPolicyRuleEvaluator, c.LongRunningFunc)
...
handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences, c.Authentication.RequestHeaderConfig)

(参考: config.go#L1036-L1076)

WithAuthorization が先にラップされて内側 (最後に実行) になり、WithAuthentication は最後にラップされるため外側 (最初に実行) になります。つまり実行時の順序は 認証 → 監査・なりすまし → 認可 → (リソースごとの REST ハンドラで) admission → etcd 書き込み です。この順序は公式ドキュメントの図とも一致します。

Once TLS is established, the HTTP request moves to the Authentication step. This is shown as step 1 in the diagram.

(参考: Controlling Access to the Kubernetes API)

1.2 認証: union authenticator が複数の Authenticator を順に試す

認証フィルタの実体は WithAuthentication で、内部で Authenticator.AuthenticateRequest を呼び出し、失敗すれば 401 を返します。

// staging/src/k8s.io/apiserver/pkg/endpoints/filters/authentication.go
resp, ok, err := auth.AuthenticateRequest(req)

(参考: authentication.go#L42-L100)

kube-apiserver はクライアント証明書・トークン・ServiceAccount 用の JWT など複数の Authenticator を同時に有効化できます。これらは union authenticator で束ねられ、先頭から順に試して最初に成功したものを採用します。

// staging/src/k8s.io/apiserver/pkg/authentication/request/union/union.go
func (authHandler *unionAuthRequestHandler) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) {
	// Handlers を順に試し、成功した時点で返す
}

(参考: union.go#L26-L71)

Multiple authentication modules can be specified, in which case each one is tried in sequence, until one of them succeeds.

(参考: Controlling Access to the Kubernetes API)

1.3 認可: RBAC authorizer がユーザーの Role/RoleBinding を評価する

認証でユーザー名が確定すると、次に認可フィルタが Authorizer.Authorize を呼びます。DecisionAllow であれば次のハンドラへ、それ以外は 403 を返します。

// staging/src/k8s.io/apiserver/pkg/endpoints/filters/authorization.go
authorized, reason, err := a.Authorize(ctx, attributes)
if authorized.String() != string(authorizer.DecisionAllow) {
    // 403 Forbidden
}

(参考: authorization.go#L50-L98)

複数の authorization モードを設定した場合も union authorizer が使われ、いずれかが Allow を返した時点で許可されます。

// staging/src/k8s.io/apiserver/pkg/authorization/union/union.go
func (authzHandler unionAuthzHandler) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
	// 各 authorizer を順に試し、Allow/Deny で短絡、NoOpinion なら次へ
}

(参考: union.go#L76-L100)

一般的なクラスタで使われる RBAC authorizer は、VisitRulesFor でユーザーに紐づく Role/ClusterRole のルールを評価し、いずれかが一致すれば DecisionAllow を返します。

// plugin/pkg/auth/authorizer/rbac/rbac.go
func (r *RBACAuthorizer) Authorize(ctx context.Context, requestAttributes authorizer.Attributes) (authorizer.Decision, string, error) {
	ruleCheckingVisitor := &authorizingVisitor{requestAttributes: requestAttributes}
	r.authorizationRuleResolver.VisitRulesFor(...)
	if ruleCheckingVisitor.allowed {
		return authorizer.DecisionAllow, ruleCheckingVisitor.reason, nil
	}
	...
}

(参考: rbac.go#L78-L131)

Kubernetes supports multiple authorization modules, such as ABAC mode, RBAC Mode, and Webhook mode. ... If more than one authorization modules are configured, Kubernetes checks each module, and if any module authorizes the request, then the request can proceed.

(参考: Controlling Access to the Kubernetes API)

1.4 admission: mutating が先、validating は書き込み直前

認可を通過したリクエストは、リソースごとの REST ハンドラに渡ります。Create リクエストの場合、まず mutating admission がオブジェクトを書き換え、その後 (実際に etcd へ書き込む直前に) validating admission が走ります。

// staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go
if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && mutatingAdmission.Handles(admission.Create) {
    if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
        return nil, err
    }
}

(参考: create.go#L182-L219)

validating admission は createValidation というコールバックとして r.Create(...) に渡され、実際にストレージへ書き込む直前に呼ばれます。

// staging/src/k8s.io/apiserver/pkg/registry/rest/create.go
func AdmissionToValidateObjectFunc(admit admission.Interface, staticAttributes admission.Attributes, o admission.ObjectInterfaces) rest.ValidateObjectFunc {
	mutatingAdmission, _ := admit.(admission.ValidationInterface)
	return func(ctx context.Context, obj runtime.Object) error {
		return mutatingAdmission.Validate(ctx, ...)
	}
}

(参考: create.go#L218-L243)

admission チェーン自体は mutating/validating それぞれ別のインターフェースとして扱われており、chainAdmissionHandler がどちらのインターフェースを実装しているハンドラだけを呼び分けます。

// staging/src/k8s.io/apiserver/pkg/admission/chain.go
func (admissionChain chainAdmissionHandler) Admit(ctx context.Context, a Attributes, o ObjectInterfaces) error {
	for _, handler := range admissionChain {
		if !handler.Handles(a.GetOperation()) { continue }
		if mutator, ok := handler.(MutationInterface); ok {
			if err := mutator.Admit(ctx, a, o); err != nil { return err }
		}
	}
	return nil
}

(参考: chain.go#L21-L70)

Once a request passes all admission controllers, it is validated using the validation routines for the corresponding API object, and then written to the object store (shown as step 4).

(参考: Controlling Access to the Kubernetes API)

1.5 etcd への書き込みと watch cache による伝播

admission を通過したオブジェクトは、etcd3 store の Create から etcd へ書き込まれます。ここで使われるのは "キーがまだ存在しないこと" を条件にした CAS (compare-and-swap) 書き込みです。

// staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go
txnResp, err := s.client.Kubernetes.OptimisticPut(ctx, preparedKey, newData, 0, kubernetes.PutOptions{LeaseID: lease})
...
if !txnResp.Succeeded {
	return storage.NewKeyExistsError(preparedKey, 0)
}

(参考: store.go#L284-L349)

Update/Patch (すでに存在するオブジェクトの更新) では GuaranteedUpdate が使われ、直前に読み取った etcd のリビジョンを期待値にした CAS 書き込みになります。他のクライアントが先に更新していてリビジョンが食い違っていれば書き込みは失敗し、最新の状態を読み直してから tryUpdate を再実行します (これが resourceVersion に基づく楽観的排他制御です)。

// staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go
txnResp, err := s.client.Kubernetes.OptimisticPut(ctx, preparedKey, newData, origState.rev, ...)
if !txnResp.Succeeded {
	// 現在の状態を読み直してリトライ
}

(参考: store.go#L473-L637)

etcd への書き込みが成功すると、kube-apiserver の watch cache (cacher) が etcd 側の watch イベントを受け取り、そのイベントを各 watcher (Deployment controller や ReplicaSet controller が張っている watch connection) に配信します。この配信の仕組みがあるからこそ、コントローラーは etcd を直接ポーリングせずに変更を検知できます。

// staging/src/k8s.io/apiserver/pkg/storage/cacher/cacher.go
func (c *Cacher) dispatchEvent(event *watchCacheEvent) {
	for _, watcher := range c.watchersBuffer {
		watcher.nonblockingAdd(event)
	}
}

(参考: watch_cache.go#L172-L211、cacher.go#L896-L1030)

第2章: kube-controller-manager — Deployment から Pod が作られるまで

2.1 Deployment controller は新しい ReplicaSet を作るだけ

Deployment controller は Deployment オブジェクトの追加・更新を informer で watch しており、変更を検知すると workqueue にキーを積むだけです。実際の処理は worker が非同期に syncDeployment を呼び出すタイミングで行われます。

// pkg/controller/deployment/deployment_controller.go
func (dc *DeploymentController) addDeployment(obj interface{}) {
	d := obj.(*apps.Deployment)
	dc.enqueueDeployment(d)
}

(参考: deployment_controller.go#L201-L212、deployment_controller.go#L399-L407)

syncDeployment の実処理 (ローリングアップデート・Recreate 戦略の分岐など) は sync.go/rolling.go/recreate.go に委譲されますが、Pod テンプレートに変更があった場合にたどり着くのは「新しい ReplicaSet を作る」処理です。ここで Deployment を OwnerReference として設定した ReplicaSet オブジェクトが組み立てられます。

// pkg/controller/deployment/sync.go
newRS := apps.ReplicaSet{
	ObjectMeta: metav1.ObjectMeta{
		...
		OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(d, controllerKind)},
	},
	Spec: apps.ReplicaSetSpec{
		Replicas: &(*newRSReplicas),
		Template: newRSTemplate,
	},
}
...
createdRS, err := dc.client.AppsV1().ReplicaSets(d.Namespace).Create(ctx, &newRS, metav1.CreateOptions{})

(参考: sync.go#L196-L218、sync.go#L232)

先ほどの nginx-deployment からは、実際には次のような ReplicaSet が作られます。ownerReferences で親の Deployment が指されていること、pod-template-hash というラベルが Pod テンプレートのハッシュ値から自動生成されて selector/labels/Pod テンプレートの3箇所に埋め込まれていることがポイントです (この pod-template-hash が、ローリングアップデートのたびに新旧の ReplicaSet を区別するキーになります)。

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: nginx-deployment-7d9fc9b7b7
  namespace: default
  ownerReferences:
    - apiVersion: apps/v1
      kind: Deployment
      name: nginx-deployment
      uid: <Deployment の UID>
      controller: true
      blockOwnerDeletion: true
  labels:
    app: nginx
    pod-template-hash: 7d9fc9b7b7
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
      pod-template-hash: 7d9fc9b7b7
  template:
    metadata:
      labels:
        app: nginx
        pod-template-hash: 7d9fc9b7b7
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          readinessProbe:
            httpGet:
              path: /
              port: 80

つまり Deployment controller が直接触るリソースは ReplicaSet までで、Pod を作るのはこの ReplicaSet controller ではなく別のコントローラーです。

The Deployment creates a ReplicaSet that creates three replicated Pods, indicated by the .spec.replicas field.

(参考: Deployments)

2.2 ReplicaSet controller が Pod を作成する

ReplicaSet controller も Deployment controller と同じ構造で、ReplicaSet の変更を検知して syncReplicaSet を呼び出します。実際に Pod 数を調整するのは manageReplicas で、現在の Pod 数と spec.replicas の差分を計算し、不足していれば podControl.CreatePods を呼びます。ここでも OwnerReference として ReplicaSet 自身が設定されます。

// pkg/controller/replicaset/replica_set.go
diff := len(activePods) - int(*(rs.Spec.Replicas))
...
if diff < 0 {
	// 不足分を slowStartBatch で作成
	err := rsc.podControl.CreatePods(ctx, rs.Namespace, &rs.Spec.Template, rs, metav1.NewControllerRef(rs, rsc.GroupVersionKind))
}

(参考: replica_set.go#L649-L699)

2.3 生成直後の Pod は spec.nodeName が空

CreatePods の内部で Pod オブジェクトを組み立てる GetPodFromTemplate は、Deployment の Pod テンプレート (Spec.Template.Spec) をそのままコピーするだけで、NodeName フィールドへの代入は一切行いません。

// pkg/controller/controller_utils.go
pod := &v1.Pod{...}
pod.Spec = *template.Spec.DeepCopy()
...
newPod, err := r.KubeClient.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{})

(参考: controller_utils.go#L567-L590、controller_utils.go#L592-L617)

先ほどの ReplicaSet から作られる Pod は、次のようになります。ownerReferences は ReplicaSet を指しており、spec には nodeName フィールド自体が存在しません (Pod テンプレートをそのままコピーしただけで、誰も値を設定していないため)。

apiVersion: v1
kind: Pod
metadata:
  name: nginx-deployment-7d9fc9b7b7-x8k2p
  namespace: default
  ownerReferences:
    - apiVersion: apps/v1
      kind: ReplicaSet
      name: nginx-deployment-7d9fc9b7b7
      uid: <ReplicaSet の UID>
      controller: true
      blockOwnerDeletion: true
  labels:
    app: nginx
    pod-template-hash: 7d9fc9b7b7
spec:
  # nodeName はまだ設定されない
  containers:
    - name: nginx
      image: nginx:1.27
      ports:
        - containerPort: 80
      readinessProbe:
        httpGet:
          path: /
          port: 80
status:
  phase: Pending

Pod の spec に nodeName を直接指定するような特殊なケースを除けば、この時点で etcd に書き込まれる Pod オブジェクトの spec.nodeName は空文字列です。ここまでで、Deployment → ReplicaSet → Pod という3段のオブジェクトが etcd に揃いましたが、まだどのノードでも Pod は起動していません。

第3章: kube-scheduler — Pod にノードを割り当てる

3.1 未スケジュール Pod の検知

kube-scheduler は Pod の追加イベントのたびに spec.nodeName が空かどうかを確認し、空であればスケジューリングキューに積みます。

// pkg/scheduler/eventhandlers.go
func assignedPod(pod *v1.Pod) bool {
	return len(pod.Spec.NodeName) != 0
}

(参考: eventhandlers.go#L458-L461、eventhandlers.go#L129-L144)

A scheduler watches for newly created Pods that have no Node assigned. For every Pod that the scheduler discovers, the scheduler becomes responsible for finding the best Node for that Pod to run on.

(参考: Kubernetes Scheduler)

3.2 Filter と Score

メインループはキューから次の Pod を取り出し、スケジューリングサイクルを回します。

// pkg/scheduler/schedule_one.go
func (sched *Scheduler) scheduleOnePod(ctx context.Context, ...) {
	scheduleResult, ... := sched.schedulingCycle(ctx, ...)
	...
	go sched.runBindingCycle(ctx, ...)
}

(参考: schedule_one.go#L67-L90、schedule_one.go#L93-L142)

割り当て先ノードの決定は「フィルタリング → スコアリング」の2段階です。

// pkg/scheduler/schedule_one.go
feasibleNodes, ... := sched.findNodesThatFitPod(ctx, ...) // Filter
...
priorityList, err := prioritizeNodes(ctx, ...) // Score

(参考: schedule_one.go#L572-L626、Filter プラグイン群の実行本体は framework.go#L1105、Score プラグイン群の実行本体は framework.go#L1405)

kube-scheduler selects a node for the pod in a 2-step operation: 1. Filtering 1. Scoring ... Finally, kube-scheduler assigns the Pod to the Node with the highest ranking.

(参考: Kubernetes Scheduler)

3.3 Bind: Binding サブリソースへの書き込みで spec.nodeName が確定する

スコアが最も高いノードが決まると、スケジューラは Bind プラグインを実行します。既定の DefaultBinder は v1.Binding オブジェクトを組み立て、Pod の Binding サブリソースに対して書き込みます。

// pkg/scheduler/framework/plugins/defaultbinder/default_binder.go
func (b DefaultBinder) Bind(ctx context.Context, state fwk.CycleState, p *v1.Pod, nodeName string) *fwk.Status {
	binding := &v1.Binding{
		ObjectMeta: metav1.ObjectMeta{Namespace: p.Namespace, Name: p.Name, UID: p.UID},
		Target:     v1.ObjectReference{Kind: "Node", Name: nodeName},
	}
	err := util.BindPod(ctx, b.handle.ClientSet(), binding)
	...
}

(参考: default_binder.go#L52-L76)

// pkg/scheduler/util/utils.go
func BindPod(ctx context.Context, cs kubernetes.Interface, binding *v1.Binding) error {
	return cs.CoreV1().Pods(binding.Namespace).Bind(ctx, binding, metav1.CreateOptions{})
}

(参考: utils.go#L111-L117)

The scheduler then notifies the API server about this decision in a process called binding.

(参考: Kubernetes Scheduler)

Binding サブリソースへの書き込みは、kube-apiserver 側で通常の Pod 更新と同様に spec.nodeName を設定して etcd に反映されます。ここまでで、Pod オブジェクトには初めて「どのノードで動かすか」が書き込まれたことになります。先ほどの Pod は、この時点で次のように spec.nodeName が埋まります。

apiVersion: v1
kind: Pod
metadata:
  name: nginx-deployment-7d9fc9b7b7-x8k2p
  namespace: default
  ownerReferences:
    - apiVersion: apps/v1
      kind: ReplicaSet
      name: nginx-deployment-7d9fc9b7b7
      uid: <ReplicaSet の UID>
      controller: true
      blockOwnerDeletion: true
  labels:
    app: nginx
    pod-template-hash: 7d9fc9b7b7
spec:
  nodeName: node-1 # Bind によって kube-scheduler が書き込んだ値
  containers:
    - name: nginx
      image: nginx:1.27
      ports:
        - containerPort: 80
      readinessProbe:
        httpGet:
          path: /
          port: 80
status:
  phase: Pending # kubelet がまだ Pod を起動していないため、nodeName 確定後も Pending のまま

第4章: kubelet — Pod を検知してコンテナランタイムに起動を指示する

4.1 kubelet は自ノード宛の Pod だけを watch する

kubelet は Pod の一覧取得・watch に、自身のノード名を条件にした field selector を使っています。これにより、他ノードに bind された Pod はそもそも kubelet 側に届きません。

// pkg/kubelet/config/apiserver.go
lw := cache.NewListWatchFromClient(kubeClient.CoreV1().RESTClient(), "pods", metav1.NamespaceAll,
	fields.OneTermEqualSelector("spec.nodeName", string(nodeName)))

(参考: apiserver.go#L36-L37)

受け取った Pod の追加・更新イベントは syncLoop/syncLoopIteration の select ループで捌かれます。

// pkg/kubelet/kubelet.go
func (kl *Kubelet) syncLoop(ctx context.Context, updates <-chan kubetypes.PodUpdate, handler SyncHandler) {
	for {
		if !kl.syncLoopIteration(ctx, updates, handler, ...) {
			break
		}
	}
}

(参考: kubelet.go#L2671-L2712、kubelet.go#L2746-L2770)

4.2 SyncPod から CRI の RunPodSandbox 呼び出しまで

kubelet は Pod ごとに SyncPod を呼び、コンテナランタイムマネージャ (kuberuntimeManager) に処理を委譲します。

// pkg/kubelet/kubelet.go
result := kl.containerRuntime.SyncPod(sctx, pod, podStatus, pullSecrets, kl.crashLoopBackOff, restartingAllContainers)

(参考: kubelet.go#L2267、SyncPod の定義は kuberuntime_manager.go#L1583)

kuberuntimeManager は現在の Pod sandbox の状態と Pod spec を比較し (PodSandboxChanged)、サンドボックスの再作成が必要なら createPodSandbox を呼び、そこから CRI の RunPodSandbox を呼び出します。

// pkg/kubelet/kuberuntime/kuberuntime_sandbox.go
func (m *kubeGenericRuntimeManager) createPodSandbox(ctx context.Context, pod *v1.Pod, attempt uint32) (string, string, error) {
	...
	podSandBoxID, err := m.runtimeService.RunPodSandbox(ctx, podSandboxConfig, runtimeHandler)
	...
}

(参考: kuberuntime_manager.go#L1283、kuberuntime_manager.go#L1680-L1732、kuberuntime_sandbox.go#L42-L79)

RunPodSandbox は Kubernetes 本体が定義する CRI の Go インターフェースで、実体は gRPC 経由でコンテナランタイムのプロセス (containerd 等) に処理を委譲します。kubelet 自身がコンテナやネットワーク namespace を直接作るわけではありません。

// staging/src/k8s.io/cri-api/pkg/apis/services.go
type PodSandboxManager interface {
	RunPodSandbox(ctx context.Context, config *runtimeapi.PodSandboxConfig, runtimeHandler string) (string, error)
	...
}

(参考: services.go#L67-L72、gRPC クライアント側の定義は api_grpc.pb.go#L99-L104)

4.3 CNI の呼び出しはコンテナランタイム側の責務

Pod のネットワーク namespace 作成と CNI プラグインの呼び出しは、dockershim が削除されて以降 (v1.24 以降)、kubelet 本体には存在しません。CRI 実装であるコンテナランタイム側の責務です。containerd では RunPodSandbox の実装の中で setupPodNetwork が呼ばれ、そこから go-cni 経由で CNI プラグインを起動しています。

// internal/cri/server/sandbox_run.go (containerd)
func (c *criService) RunPodSandbox(ctx context.Context, r *runtime.RunPodSandboxRequest) (...) {
	...
	if err := c.setupPodNetwork(ctx, &sandbox); err != nil {
		...
	}
	...
}

func (c *criService) setupPodNetwork(ctx context.Context, sandbox *sandboxstore.Sandbox) (retErr error) {
	...
	result, err = netPlugin.Setup(ctx, id, path, opts...)
	...
}

(参考: sandbox_run.go#L54、sandbox_run.go#L266、sandbox_run.go#L456-L509)

Pod sandbox の作成・ネットワーク設定・ボリュームマウントが完了すると、kubelet はこの段階を PodReadyToStartContainers という PodCondition として記録します。

PodReadyToStartContainers: the Pod sandbox has been successfully created, networking configured, storage volumes mounted, and any dynamic resources (if requested) allocated.

(参考: Pod Lifecycle)

第5章: probe から Pod Ready condition が確定するまで

5.1 コンテナ単位の probe worker

サンドボックスとコンテナが起動すると、kubelet の prober manager は startup/readiness/liveness の各 probe ごとに専用の goroutine (worker) を起動します。

// pkg/kubelet/prober/prober_manager.go
func (m *manager) AddPod(ctx context.Context, pod *v1.Pod) {
	for _, c := range pod.Spec.Containers {
		if c.ReadinessProbe != nil {
			w := newWorker(m, readiness, pod, c)
			go w.run(ctx)
		}
		...
	}
}

(参考: prober_manager.go#L185-L241)

各 worker は FailureThreshold/SuccessThreshold に達したところで結果を確定させ、resultsManager に反映します。

// pkg/kubelet/prober/worker.go
w.resultsManager.Set(w.containerID, result, w.pod)

(参考: worker.go#L360-L379、results_manager.go#L113-L115)

5.2 status manager が Pod status を組み立てて apiserver へ PATCH する

resultsManager の更新は syncLoop の select ループでも受信され、readiness probe の結果は statusManager.SetContainerReadiness に渡されます。

// pkg/kubelet/kubelet.go
case update := <-kl.readinessManager.Updates():
	ready := update.Result == proberesults.Success
	kl.statusManager.SetContainerReadiness(logger, update.PodUID, update.ContainerID, ready)

(参考: kubelet.go#L2809-L2831、status_manager.go#L485、status_manager.go#L511)

status manager はコンテナごとの状態から Pod 全体の PodStatus を組み立て、変更があれば非同期のバッチ処理から apiserver へ PATCH を送ります。

// pkg/kubelet/status/status_manager.go
newPod, patchBytes, unchanged, err := statusutil.PatchPodStatus(ctx, m.kubeClient, pod.Namespace, pod.Name, pod.UID, pod.Status, mergedStatus)

(参考: status_manager.go#L1168、status_manager.go#L1244-L1276)

このため、kubectl や自作コントローラーから Pod の status を直接書き換えることはできません。status サブリソースへの書き込みは kubelet のこの経路に一本化されています。

The kubectl patch command does not support patching object status. To set these status.conditions for the Pod, applications and operators should use the PATCH action.

(参考: Pod Lifecycle)

5.3 Ready は ContainersReady と readinessGates がすべて揃って初めて True になる

Pod の Ready condition を計算する関数は、まず全コンテナの ContainersReady を確認し、それが True でなければそのまま False を返します。ContainersReady が True の場合のみ、続けて readinessGates に指定された各条件を確認し、いずれかが未達なら ReadinessGatesNotReady として False を返します。両方が揃って初めて True です。

// pkg/kubelet/status/generate.go
func GeneratePodReadyCondition(pod *v1.Pod, oldPodStatus *v1.PodStatus, conditions []v1.PodCondition, containerStatuses []v1.ContainerStatus, podPhase v1.PodPhase) v1.PodCondition {
	containersReady := GenerateContainersReadyCondition(pod, oldPodStatus, containerStatuses, podPhase)
	if containersReady.Status != v1.ConditionTrue {
		return v1.PodCondition{Type: v1.PodReady, Status: containersReady.Status, Reason: containersReady.Reason, ...}
	}
	for _, rg := range pod.Spec.ReadinessGates {
		_, c := podutil.GetPodConditionFromList(conditions, rg.ConditionType)
		if c == nil || c.Status != v1.ConditionTrue {
			// unreadyMessages に積む
		}
	}
	if len(unreadyMessages) != 0 {
		return v1.PodCondition{Type: v1.PodReady, Status: v1.ConditionFalse, Reason: ReadinessGatesNotReady, ...}
	}
	return v1.PodCondition{Type: v1.PodReady, Status: v1.ConditionTrue}
}

(参考: generate.go#L127-L169)

For a Pod that uses custom conditions, that Pod is evaluated to be ready only when both the following statements apply: All containers in the Pod are ready. All conditions specified in readinessGates are True.

(参考: Pod Lifecycle)

readiness probe が成功し、status manager による PATCH が反映されると、先ほどの Pod の status は次のようになります。

apiVersion: v1
kind: Pod
metadata:
  name: nginx-deployment-7d9fc9b7b7-x8k2p
  namespace: default
  ownerReferences:
    - apiVersion: apps/v1
      kind: ReplicaSet
      name: nginx-deployment-7d9fc9b7b7
      uid: <ReplicaSet の UID>
      controller: true
      blockOwnerDeletion: true
  labels:
    app: nginx
    pod-template-hash: 7d9fc9b7b7
spec:
  nodeName: node-1
  containers:
    - name: nginx
      image: nginx:1.27
      ports:
        - containerPort: 80
      readinessProbe:
        httpGet:
          path: /
          port: 80
status:
  phase: Running
  conditions:
    - type: PodScheduled
      status: "True"
    - type: PodReadyToStartContainers
      status: "True"
    - type: Initialized
      status: "True"
    - type: ContainersReady
      status: "True"
    - type: Ready
      status: "True" # readiness probe 成功 + readinessGates 全通過で True になった
  containerStatuses:
    - name: nginx
      ready: true
      started: true
      state:
        running:
          startedAt: "2026-09-29T00:00:10Z"

第6章: EndpointSlice controller — Ready な Pod だけが Service に含まれる

Pod の Ready condition が True になって etcd に反映されると、EndpointSlice controller (Service selector にマッチする Pod を watch しているコントローラー) がその変更を検知します。EndpointSlice を組み立てる podToEndpoint は、Pod の Ready condition を確認したうえで Endpoint の ready フィールドを設定します。

// staging/src/k8s.io/endpointslice/utils.go
func podToEndpoint(pod *v1.Pod, node *v1.Node, service *v1.Service, addressType discovery.AddressType) discovery.Endpoint {
	serving := endpointutil.IsPodReady(pod)
	terminating := pod.DeletionTimestamp != nil
	ready := service.Spec.PublishNotReadyAddresses || (serving && !terminating)
	return discovery.Endpoint{
		...
		Conditions: discovery.EndpointConditions{Ready: &ready, Serving: &serving, Terminating: &terminating},
	}
}

(参考: utils.go#L38-L49、IsPodReady の実体は Pod の Ready condition の確認です (controller_utils.go#L358-L362))

先ほどの Pod が Ready になり、app: nginx を selector にした nginx という Service が存在する場合、EndpointSlice には次のように Endpoint が追加されます。targetRef で先ほどの Pod が指されており、conditions.ready が true になっていることがポイントです。

apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: nginx-8xzql
  namespace: default
  labels:
    kubernetes.io/service-name: nginx
addressType: IPv4
ports:
  - name: ""
    protocol: TCP
    port: 80
endpoints:
  - addresses:
      - 10.244.1.5
    conditions:
      ready: true # Pod の Ready condition が True になったことで true になる
      serving: true
      terminating: false
    nodeName: node-1
    targetRef:
      kind: Pod
      name: nginx-deployment-7d9fc9b7b7-x8k2p
      namespace: default

このロジックを呼び出す endpointslice パッケージは、Kubernetes 本体の pkg/controller/endpointslice からインポートされて使われています。

// pkg/controller/endpointslice/endpointslice_controller.go
import endpointslicerec "k8s.io/endpointslice"
...
c.reconciler = endpointslicerec.NewReconciler(...)

(参考: endpointslice_controller.go#L44、endpointslice_controller.go#L178、reconciler.go#L228)

The serving condition indicates that the endpoint is currently serving responses, and so it should be used as a target for Service traffic. For endpoints backed by a Pod, this maps to the Pod's Ready condition.

(参考: EndpointSlices)

Pod が Running になっただけでは Service からトラフィックを受けられません。readiness probe が成功して Ready condition が True になり、それが EndpointSlice に反映されて初めて、Service 経由のトラフィックを受けられる準備が整います。ただし、EndpointSlice に反映された時点でもまだ実際のパケット転送はできません。それを担うのが次の kube-proxy です。

第7章: kube-proxy — EndpointSlice をノード上のルールへ反映する

EndpointSlice に Ready な Pod が反映されたのは、あくまで「どの Pod にトラフィックを転送してよいか」という情報が Kubernetes のリソースとして存在するようになっただけです。実際にパケットをその Pod へ転送するのは、各ノード上で動く kube-proxy の役割です。

7.1 kube-proxy も EndpointSlice/Service を watch している

kube-proxy は起動時に EndpointSlice と Service の informer を登録し、変更があるたびに登録済みのハンドラ (= 各プロキシ実装) へイベントを配信します。

// pkg/proxy/config/config.go
func NewEndpointSliceConfig(ctx context.Context, endpointSliceInformer discoveryv1informers.EndpointSliceInformer, resyncPeriod time.Duration) *EndpointSliceConfig {
	...
	endpointSliceInformer.Informer().AddEventHandlerWithResyncPeriod(cache.ResourceEventHandlerFuncs{
		AddFunc:    result.handleAddEndpointSlice,
		UpdateFunc: result.handleUpdateEndpointSlice,
		DeleteFunc: result.handleDeleteEndpointSlice,
	}, resyncPeriod)
	...
}

(参考: config.go#L79-L97、config.go#L130-L145)

実際にこの informer へハンドラとして登録されるのは、kube-proxy 本体 (s.Proxier) です。

// cmd/kube-proxy/app/server.go
endpointSliceConfig := config.NewEndpointSliceConfig(ctx, endpointSliceInformerFactory.Discovery().V1().EndpointSlices(), ...)
endpointSliceConfig.RegisterEventHandler(s.Proxier)
go endpointSliceConfig.Run(ctx.Done())

(参考: server.go#L609-L615)

s.Proxier は iptables・nftables・ipvs など、プロキシの実装ごとに差し替え可能な Provider インターフェースとして定義されています。

// pkg/proxy/types.go
type Provider interface {
	config.EndpointSliceHandler
	config.ServiceHandler
	config.NodeTopologyHandler
	config.ServiceCIDRHandler

	Sync()
	SyncLoop()
}

(参考: types.go#L27-L40)

7.2 watch イベントから syncProxyRules が呼ばれるまで

iptables 版の Proxier では、OnEndpointSliceUpdate が変更を検知すると Sync() を呼び出し、BoundedFrequencyRunner 経由でルール再計算 (syncProxyRules) をスケジュールします。

// pkg/proxy/iptables/proxier.go
func (proxier *Proxier) OnEndpointSliceUpdate(_, endpointSlice *discovery.EndpointSlice) {
	if proxier.endpointsChanges.EndpointSliceUpdate(endpointSlice, false) && proxier.isInitialized() {
		proxier.Sync()
	}
}

(参考: proxier.go#L487-L493、Sync()/SyncLoop() の実装は proxier.go#L412-L432、本体は proxier.go#L625)

nftables 版の Proxier も OnEndpointSliceUpdate → Sync() → syncProxyRules という同じ構造を持っています。

// pkg/proxy/nftables/proxier.go
func (proxier *Proxier) OnEndpointSliceUpdate(_, endpointSlice *discovery.EndpointSlice) {
	if proxier.endpointsChanges.EndpointSliceUpdate(endpointSlice, false) && proxier.isInitialized() {
		proxier.Sync()
	}
}

(参考: proxier.go#L875-L881、syncProxyRules の本体は proxier.go#L1174)

v1.37.1 時点では、--proxy-mode を指定しなかった場合のデフォルトは iptables です (参考: server_linux.go#L49-L57)。nftables は選択可能なモードの1つであり、まだデフォルトではありません。

7.3 ノード上のルールとしてカーネルへ適用される

iptables モードでは、syncProxyRules が組み立てたルール群をバッファへ書き出したうえで、最後にまとめてカーネルへ適用します。

// pkg/proxy/iptables/proxier.go
// DNAT to final destination.
natRules.Write(args, "-j", "DNAT", "--to-destination", epInfo.String())
...
proxier.iptables.RestoreAll(proxier.iptablesData.Bytes(), ...)

(参考: proxier.go#L922-L930 (ClusterIP の capture)、proxier.go#L1240-L1241 (Pod IP への DNAT)、proxier.go#L1384 (カーネルへの適用))

nftables モードでは、ルールは knftables のトランザクションとして組み立てられ、Run で一括適用されます。

// pkg/proxy/nftables/proxier.go
// DNAT to final destination.
tx.Add(&knftables.Rule{
	Chain: endpointChain,
	Rule:  knftables.Concat("meta l4proto", protocol, "dnat to", epInfo.String()),
})
...
proxier.nftables.Run(context.TODO(), tx)

(参考: proxier.go#L1811-L1818、proxier.go#L1872)

公式ドキュメントも、kube-proxy が新しい Service を見つけるたびに iptables ルールをインストールし、Virtual IP 宛のパケットを Pod へ DNAT することを説明しています。

When kube-proxy on a node sees a new Service, it installs a series of iptables rules which redirect from the virtual IP address to more iptables rules... the per-endpoint rules redirect traffic (using destination NAT) to the backends.

(参考: Virtual IPs and Service Proxies)

このように、EndpointSlice が更新されてから、kube-proxy がそれを検知し、実際にノード上の iptables/nftables ルールへ反映されるまでの間にも非同期のラグがあります。iptables モードと nftables モードでのルールの構造・書き換え方の違いについては、別記事 Kubernetes の nftables モードについて で扱っているので、そちらを参照してください。

おわりに

似たような内容の記事や資料は昔からあった気がするのですが、いざ人に説明しようとすると URL がすぐに出てこず、結局そのたびに口頭で説明していたので、一度自分でまとめておこうと思ってこの記事を書きました。未来の自分のための記事のつもりです。

記事としての需要はあまり気にしていません。自分が必要になったときにすぐ見つけられればそれで十分ですし、ネットに公開しておけば AI に質問したときにこの記事を見つけて回答に使ってくれるかもしれない、という期待も込めて公開しています。

参考

参考リンク

本文で登場する順に並べています。

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?