Skip to content
Back to blog What Actually Happens When You kubectl apply – The Full Chain From YAML to Running Pod

What Actually Happens When You kubectl apply – The Full Chain From YAML to Running Pod

K8s

TL;DR

  • kubectl apply sends a PATCH request to the API server, not a PUT – the merge strategy matters
  • Client-side apply uses a 3-way merge (local file, last-applied annotation, live state); server-side apply tracks field ownership per manager
  • The API server validates, runs admission controllers, and persists to etcd – then returns success
  • At this point, nothing is running yet – you’ve only declared intent
  • Controllers watch etcd via the API server, detect drift, and create/modify resources
  • The scheduler assigns pods to nodes; kubelet on that node actually starts containers
  • The whole system is eventually consistent – kubectl apply succeeding doesn’t mean your pod is running

The Mental Model

When you run kubectl apply -f deployment.yaml, you’re not “deploying” anything. You’re submitting a declaration of intent to a database. The Kubernetes control plane then works asynchronously to make reality match that intent.

This distinction matters because:

  1. kubectl apply can succeed while your pod fails to start
  2. The API server doesn’t know or care if your image exists
  3. Your deployment might take minutes to fully reconcile
  4. Errors can appear long after kubectl has exited

Let’s trace the full path.


Phase 1: Client-Side Processing

kubeconfig Resolution

Before anything hits the network, kubectl needs to know where to send the request:

# kubectl checks these in order:
# 1. --kubeconfig flag
# 2. $KUBECONFIG environment variable
# 3. ~/.kube/config

kubectl config view --minify  # Shows active context

The kubeconfig contains:

  • Cluster: API server URL and CA certificate
  • User: Authentication credentials (client cert, token, exec plugin)
  • Context: Binds a user to a cluster and namespace

YAML Parsing and Validation

kubectl parses your YAML and performs client-side validation:

# See what kubectl will send (without sending it)
kubectl apply -f deployment.yaml --dry-run=client -o yaml

This catches:

  • Malformed YAML
  • Missing required fields (apiVersion, kind, metadata.name)
  • Type mismatches (string where int expected)

But it doesn’t catch:

  • Invalid image references
  • Non-existent namespaces
  • RBAC violations
  • Admission controller rejections

Client-Side Apply: The 3-Way Merge

By default, kubectl apply uses client-side apply. Here’s what happens:

┌─────────────────────────────────────────────────────────────────────┐
│                     Client-Side Apply (Default)                      │
├─────────────────────────────────────────────────────────────────────┤
│  1. Fetch live resource from API server                              │
│  2. Read last-applied-configuration annotation from live resource    │
│  3. Compare: local file vs last-applied vs live state                │
│  4. Calculate strategic merge patch                                  │
│  5. Send PATCH request to API server                                 │
│  6. Update last-applied-configuration annotation                     │
└─────────────────────────────────────────────────────────────────────┘

The 3-way merge is crucial. It allows kubectl to distinguish between:

  • Fields you’ve removed from your YAML (should be deleted)
  • Fields that were added by controllers (should be preserved)
  • Fields you’ve never managed (should be ignored)
# The annotation that makes this work
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"apps/v1","kind":"Deployment",...}

Server-Side Apply: The Modern Alternative

Since Kubernetes 1.22, you can use server-side apply:

kubectl apply -f deployment.yaml --server-side

The key differences:

AspectClient-Side ApplyServer-Side Apply
Merge locationkubectl binaryAPI server
Conflict detectionLast-applied annotationField managers
Multi-actor safetyPoor (silent overwrites)Good (explicit conflicts)
Dry-run accuracyApproximateExact (runs admission)

Server-side apply tracks field ownership:

metadata:
  managedFields:
  - manager: kubectl
    operation: Apply
    apiVersion: apps/v1
    time: "2026-01-20T10:00:00Z"
    fieldsType: FieldsV1
    fieldsV1:
      f:spec:
        f:replicas: {}
        f:template:
          f:spec:
            f:containers: {}

If two managers try to modify the same field, server-side apply returns a conflict:

error: Apply failed with 1 conflict: conflict with "kubectl" using apps/v1:
  .spec.replicas

You can force the change with --force-conflicts, or fix the underlying issue (usually HPA fighting with your deployment manifest over replicas).


Phase 2: API Server Processing

The request leaves kubectl and hits the API server. Here’s the chain:

┌─────────────────────────────────────────────────────────────────────┐
│                        API Server Pipeline                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────┐                                                    │
│  │ TLS Termination │                                                 │
│  └───────┬──────┘                                                    │
│          ▼                                                           │
│  ┌──────────────┐                                                    │
│  │ Authentication │  ← Who are you? (certs, tokens, OIDC)            │
│  └───────┬──────┘                                                    │
│          ▼                                                           │
│  ┌──────────────┐                                                    │
│  │ Authorization │  ← Can you do this? (RBAC, ABAC, Webhook)         │
│  └───────┬──────┘                                                    │
│          ▼                                                           │
│  ┌──────────────────────┐                                            │
│  │ Mutating Admission   │  ← Modify the request (inject sidecars,    │
│  │ Controllers          │     set defaults, add labels)              │
│  └───────┬──────────────┘                                            │
│          ▼                                                           │
│  ┌──────────────────────┐                                            │
│  │ Schema Validation    │  ← Does this match the OpenAPI spec?       │
│  └───────┬──────────────┘                                            │
│          ▼                                                           │
│  ┌──────────────────────┐                                            │
│  │ Validating Admission │  ← Should we allow this? (policies,        │
│  │ Controllers          │     quotas, security checks)               │
│  └───────┬──────────────┘                                            │
│          ▼                                                           │
│  ┌──────────────┐                                                    │
│  │ etcd Write   │  ← Persist to distributed key-value store          │
│  └──────────────┘                                                    │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Authentication

The API server verifies identity using one or more methods:

# Client certificate (most common for kubectl)
--client-certificate=/path/to/cert.pem
--client-key=/path/to/key.pem

# Bearer token (common for service accounts)
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

# OIDC (common for human users)
--oidc-issuer-url=https://accounts.google.com
--oidc-client-id=kubernetes

Authentication determines who you are, not what you can do.

Authorization (RBAC)

RBAC checks if the authenticated user can perform this action:

# Can user "mo" create deployments in namespace "production"?
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: deployment-admin
rules:
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: mo-deployment-admin
  namespace: production
subjects:
- kind: User
  name: mo
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: deployment-admin
  apiGroup: rbac.authorization.k8s.io

If RBAC denies the request:

Error from server (Forbidden): deployments.apps is forbidden:
  User "mo" cannot create resource "deployments" in API group "apps"
  in the namespace "production"

Mutating Admission Controllers

These modify the request before validation. Common examples:

ControllerWhat It Does
DefaultStorageClassAdds default storage class to PVCs
DefaultTolerationSecondsAdds default tolerations for taints
LimitRangerApplies default resource requests/limits
ServiceAccountMounts service account token
PodPreset (deprecated)Injected env vars, volumes

And webhook-based mutators:

# Istio sidecar injection – mutates your Pod to add envoy
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: istio-sidecar-injector
webhooks:
- name: sidecar-injector.istio.io
  clientConfig:
    service:
      name: istiod
      namespace: istio-system
      path: /inject
  rules:
  - operations: ["CREATE"]
    apiGroups: [""]
    apiVersions: ["v1"]
    resources: ["pods"]

This is why a Pod you submitted with one container ends up with two – the mutating webhook added the sidecar.

Schema Validation

The API server validates your resource against the OpenAPI schema:

# See the schema for a resource
kubectl explain deployment.spec.replicas
# KIND:     Deployment
# VERSION:  apps/v1
# FIELD:    replicas <integer>
# DESCRIPTION:
#      Number of desired pods.

This catches type errors, unknown fields (with strict validation), and structural issues.

Validating Admission Controllers

These can reject requests but not modify them:

ControllerWhat It Does
NamespaceLifecyclePrevents operations in terminating namespaces
ResourceQuotaEnforces quota limits
PodSecurityEnforces pod security standards
ValidatingAdmissionWebhookCustom policy enforcement

Example: Kyverno policy validation

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-team-label
    match:
      resources:
        kinds:
        - Deployment
    validate:
      message: "The label 'team' is required."
      pattern:
        metadata:
          labels:
            team: "?*"

If your deployment lacks the team label:

Error from server: error when creating "deployment.yaml":
  admission webhook "validate.kyverno.svc" denied the request:
  resource Deployment/default/nginx was blocked due to the following policies:
  require-labels:
    check-team-label: 'validation error: The label ''team'' is required.'

etcd Persistence

If all checks pass, the API server writes to etcd:

# Conceptual etcd key structure
/registry/deployments/default/nginx
/registry/pods/default/nginx-abc123
/registry/replicasets/default/nginx-5d4c6f7b8

etcd is:

  • A distributed key-value store
  • The single source of truth for cluster state
  • Where your “declaration of intent” becomes durable

At this point, kubectl apply returns success. But nothing is running yet.


Phase 3: Controller Reconciliation

Controllers watch the API server for changes and reconcile state.

The Watch Mechanism

Controllers don’t poll. They use watch – a streaming connection that pushes changes:

// Simplified controller loop
func (c *DeploymentController) Run() {
    for {
        // 1. Watch for Deployment changes
        event := <-c.watchChannel
        
        // 2. Get desired state
        deployment := event.Object
        desiredReplicas := deployment.Spec.Replicas
        
        // 3. Get actual state
        replicaSets := c.listReplicaSets(deployment)
        actualReplicas := countReadyPods(replicaSets)
        
        // 4. Reconcile
        if actualReplicas < desiredReplicas {
            c.scaleUp(deployment, desiredReplicas - actualReplicas)
        } else if actualReplicas > desiredReplicas {
            c.scaleDown(deployment, actualReplicas - desiredReplicas)
        }
    }
}

The Deployment Controller Chain

When you apply a Deployment, multiple controllers react:

┌─────────────────────────────────────────────────────────────────────┐
│                     Controller Chain                                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  You apply: Deployment                                               │
│       │                                                              │
│       ▼                                                              │
│  Deployment Controller                                               │
│       │ Creates/updates ReplicaSet                                   │
│       ▼                                                              │
│  ReplicaSet Controller                                               │
│       │ Creates Pod objects                                          │
│       ▼                                                              │
│  Scheduler                                                           │
│       │ Assigns Pods to Nodes (sets spec.nodeName)                   │
│       ▼                                                              │
│  Kubelet (on assigned node)                                          │
│       │ Creates actual containers                                    │
│       ▼                                                              │
│  Container Runtime (containerd/CRI-O)                                │
│       │ Pulls image, starts process                                  │
│       ▼                                                              │
│  Running container                                                   │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Each controller only cares about its level of abstraction:

  • Deployment controller: “I need this ReplicaSet to exist”
  • ReplicaSet controller: “I need this many Pod objects”
  • Scheduler: “This Pod needs a node”
  • Kubelet: “I need this container running on my node”

Phase 4: Scheduling

The scheduler watches for Pods with no spec.nodeName and assigns them to nodes.

Scheduling Algorithm

┌─────────────────────────────────────────────────────────────────────┐
│                     Scheduler Pipeline                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Filtering (which nodes CAN run this Pod?)                        │
│     ├─ NodeSelector matches?                                         │
│     ├─ Tolerations match taints?                                     │
│     ├─ Sufficient CPU/memory?                                        │
│     ├─ PV availability?                                              │
│     ├─ Node affinity rules?                                          │
│     └─ Pod anti-affinity satisfied?                                  │
│                                                                      │
│  2. Scoring (which node is BEST?)                                    │
│     ├─ LeastRequestedPriority (spread load)                          │
│     ├─ BalancedResourceAllocation                                    │
│     ├─ NodeAffinityPriority                                          │
│     ├─ PodAffinityPriority                                           │
│     └─ ImageLocalityPriority (image already cached)                  │
│                                                                      │
│  3. Binding (assign Pod to winning node)                             │
│     └─ PATCH pod with spec.nodeName = selected-node                  │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

If no node passes filtering:

Warning  FailedScheduling  pod/nginx-abc123  0/3 nodes are available:
  1 node(s) had taint {node-role.kubernetes.io/control-plane: },
  that the pod didn't tolerate,
  2 node(s) didn't match pod anti-affinity rules.

The scheduler doesn’t create containers. It just updates the Pod object:

# Before scheduling
spec:
  nodeName: ""  # empty

# After scheduling
spec:
  nodeName: "worker-node-1"

Phase 5: Kubelet Execution

The kubelet on each node watches for Pods assigned to it.

Container Creation

┌─────────────────────────────────────────────────────────────────────┐
│                     Kubelet Processing                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Detect new Pod assigned to this node                             │
│  2. Pull image (if not cached)                                       │
│  3. Create sandbox (pause container for network namespace)           │
│  4. Configure CNI networking                                         │
│  5. Mount volumes                                                    │
│  6. Create application containers                                    │
│  7. Run startup/liveness/readiness probes                            │
│  8. Report status back to API server                                 │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

The kubelet talks to the container runtime via CRI (Container Runtime Interface):

# Kubelet → containerd → containers
kubelet --container-runtime-endpoint=unix:///run/containerd/containerd.sock

Status Reporting

The kubelet continuously reports Pod status:

status:
  phase: Running
  conditions:
  - type: Ready
    status: "True"
  - type: ContainersReady
    status: "True"
  containerStatuses:
  - name: nginx
    state:
      running:
        startedAt: "2026-01-20T10:00:05Z"
    ready: true
    restartCount: 0
    image: nginx:1.25
    imageID: "docker.io/library/nginx@sha256:abc123..."

The Full Timeline

For a simple Deployment, here’s a realistic timeline:

TimeEvent
T+0mskubectl apply sends PATCH request
T+5msAPI server authenticates, authorizes
T+10msMutating webhooks run (sidecar injection, etc.)
T+15msValidating webhooks run
T+20msObject written to etcd
T+25mskubectl returns “deployment.apps/nginx configured”
T+50msDeployment controller sees change, creates ReplicaSet
T+100msReplicaSet controller creates Pod objects
T+150msScheduler assigns Pods to nodes
T+200msKubelet on node detects new Pod
T+500msImage pull starts (if not cached)
T+5000msImage pull completes (varies wildly)
T+5100msContainer starts
T+5500msReadiness probe passes
T+5500msPod marked Ready

That’s a 5+ second gap between kubectl returning and your Pod being ready. On first deployment with cold image caches, it can be minutes.


Debugging the Chain

Check Each Phase

# 1. Did the API server accept it?
kubectl apply -f deployment.yaml
# deployment.apps/nginx configured ← Success at API level

# 2. What did admission controllers do?
kubectl get deployment nginx -o yaml | grep -A5 "annotations:"
# Check for injected sidecars, modified fields

# 3. Did the controller create child resources?
kubectl get replicaset -l app=nginx
kubectl get pods -l app=nginx

# 4. Is the pod scheduled?
kubectl get pod nginx-abc123 -o jsonpath='{.spec.nodeName}'
# Empty = not yet scheduled

# 5. What's the pod status?
kubectl describe pod nginx-abc123
# Events section shows the full history

# 6. Kubelet logs on the node
journalctl -u kubelet -f --grep="nginx"

Common Failure Points

SymptomPhaseCause
”Forbidden” errorAuthorizationMissing RBAC
”admission webhook denied”AdmissionPolicy violation
Pod stuck in PendingSchedulingNo suitable nodes
Pod stuck in ContainerCreatingKubeletImage pull, volume mount
Pod in CrashLoopBackOffRuntimeApplication crash
Pod Running but not ReadyProbesReadiness probe failing

What kubectl apply Doesn’t Tell You

The kubectl apply command returns success when etcd accepts the write. It doesn’t wait for:

  • Controllers to reconcile
  • Pods to be scheduled
  • Containers to start
  • Probes to pass
  • Traffic to flow

For production deploys, use additional checks:

# Wait for rollout to complete
kubectl rollout status deployment/nginx --timeout=5m

# Watch pods come up
kubectl get pods -l app=nginx -w

# Check events for issues
kubectl get events --sort-by='.lastTimestamp' | tail -20

Or use Helm with --atomic --wait (as covered in my previous post).


Conclusion

When you kubectl apply:

  1. kubectl parses YAML, calculates a patch, sends to API server
  2. API server authenticates, authorizes, mutates, validates, persists to etcd
  3. etcd stores your declaration of intent – kubectl returns here
  4. Controllers watch for changes, reconcile state, create child resources
  5. Scheduler assigns Pods to nodes
  6. Kubelet pulls images, creates containers, reports status

Understanding this chain helps you:

  • Debug deployments that “succeed” but don’t work
  • Know where to look when pods don’t start
  • Appreciate why Kubernetes is eventually consistent
  • Build proper CI/CD with appropriate wait conditions

The gap between “API server accepted it” and “it’s actually running” is where most production incidents hide.


References


Found this useful? Find me on LinkedIn or check out more deep dives on the CoderCo blog.

Found this helpful?

Comments