Back to Distributed Systems & Kubernetes Series

Part 7: Kubernetes Object Model

May 14, 2026 Wasil Zafar 40 min read

Everything in Kubernetes is an API object. Mastering the object model — Pods, Deployments, Services, ConfigMaps, and beyond — is mastering the language of declarative infrastructure.

Table of Contents

  1. Anatomy of a Kubernetes Object
  2. Pods
  3. Workload Controllers
  4. Configuration Objects
  5. Services & Discovery
  6. Exercises
  7. Conclusion

Anatomy of a Kubernetes Object

YAML Structure

Every Kubernetes object follows the same four-section structure. Understanding this structure means you can read and write any Kubernetes manifest:

# Every Kubernetes object has these four top-level fields:
apiVersion: apps/v1          # API group + version
kind: Deployment             # Object type
metadata:                    # Identity and organisation
  name: payment-service      # Unique within namespace
  namespace: production      # Logical grouping (default: "default")
  labels:                    # Key-value pairs for selection/organisation
    app: payment
    tier: backend
    version: v2.1
  annotations:               # Non-identifying metadata
    deployment.kubernetes.io/revision: "3"
    description: "Handles payment processing"
spec:                        # DESIRED STATE — what you want
  replicas: 3
  selector:
    matchLabels:
      app: payment
  template:
    metadata:
      labels:
        app: payment
    spec:
      containers:
      - name: payment
        image: payment:v2.1
# status:                    # ACTUAL STATE — set by Kubernetes (read-only)
#   availableReplicas: 3
#   readyReplicas: 3
#   conditions:
#   - type: Available
#     status: "True"
The Four Pillars: apiVersion + kind tell Kubernetes what type of object this is. metadata identifies it. spec declares what you want (desired state). status reports what actually exists (set by controllers, never by you).

Labels & Selectors

Labels are the glue that connects Kubernetes objects. A Service finds its pods through labels. A Deployment manages pods through labels. Without labels, objects would be isolated and unmanageable.

# Labels: key-value pairs attached to objects
metadata:
  labels:
    app: payment           # Application name
    tier: backend          # Architecture tier
    environment: prod      # Deployment environment
    version: v2.1          # Current version
    team: platform         # Owning team

# Selectors: how objects find each other
# Equality-based:
selector:
  matchLabels:
    app: payment
    tier: backend

# Set-based (more powerful):
selector:
  matchExpressions:
  - key: environment
    operator: In
    values: ["prod", "staging"]
  - key: tier
    operator: NotIn
    values: ["frontend"]
  - key: version
    operator: Exists
# Label operations with kubectl:

# Add labels to existing objects:
kubectl label pods payment-pod-abc12 version=v2.2 --overwrite

# Select pods by label:
kubectl get pods -l app=payment                      # Exact match
kubectl get pods -l 'tier in (backend, middleware)'  # Set-based
kubectl get pods -l app=payment,tier=backend         # Multiple conditions

# Show labels as columns:
kubectl get pods --show-labels
kubectl get pods -L app,tier,version    # Show specific labels as columns

# Remove a label:
kubectl label pods payment-pod-abc12 version-

Annotations

Annotations store non-identifying metadata — information that tools, libraries, or humans need but that Kubernetes doesn't use for selection:

# Annotations: arbitrary metadata (not used for selection)
metadata:
  annotations:
    # Deployment history
    kubernetes.io/change-cause: "Updated image to v2.1 for security patch"
    # Ingress configuration
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    # Monitoring
    prometheus.io/scrape: "true"
    prometheus.io/port: "9090"
    # Team/ownership
    owner: "platform-team@company.com"
    oncall: "platform-pager"
    # Build info
    git-commit: "a1b2c3d4e5f6"
    build-date: "2026-05-14T10:30:00Z"

Pods

A Pod is the smallest deployable unit in Kubernetes — one or more containers that share networking (same IP) and storage (shared volumes). You rarely create pods directly; controllers manage them for you.

Pod Lifecycle

Pod Lifecycle Phases
stateDiagram-v2
    [*] --> Pending: Pod created
    Pending --> Running: Containers started
    Running --> Succeeded: All containers exit 0
    Running --> Failed: Container exits non-zero
    Running --> Unknown: Node communication lost
    Pending --> Failed: Image pull fails
    Failed --> [*]
    Succeeded --> [*]
                            
# A complete Pod specification:
apiVersion: v1
kind: Pod
metadata:
  name: web-app
  labels:
    app: web
spec:
  # Restart policy: Always (default), OnFailure, Never
  restartPolicy: Always
  
  # Resource requests and limits
  containers:
  - name: web
    image: nginx:1.25-alpine
    ports:
    - containerPort: 80
      name: http
    resources:
      requests:        # Minimum guaranteed resources (scheduling)
        memory: "128Mi"
        cpu: "100m"    # 100 millicores = 0.1 CPU core
      limits:          # Maximum allowed (enforcement)
        memory: "256Mi"
        cpu: "500m"
    
    # Environment variables
    env:
    - name: APP_ENV
      value: "production"
    - name: DB_HOST
      valueFrom:
        configMapKeyRef:
          name: app-config
          key: database.host
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: app-secrets
          key: db-password
    
    # Health probes
    livenessProbe:
      httpGet:
        path: /healthz
        port: 80
      initialDelaySeconds: 10
      periodSeconds: 15
    readinessProbe:
      httpGet:
        path: /ready
        port: 80
      initialDelaySeconds: 5
      periodSeconds: 10
    
    # Volume mounts
    volumeMounts:
    - name: app-data
      mountPath: /data
    - name: config-volume
      mountPath: /etc/config
      readOnly: true
  
  # Volumes
  volumes:
  - name: app-data
    emptyDir: {}
  - name: config-volume
    configMap:
      name: app-config

Multi-Container Patterns

Pods can contain multiple containers that share the same network namespace (localhost communication) and storage volumes. Three common patterns:

Pattern Purpose Example
Sidecar Extend/enhance main container Log shipper, Istio proxy, monitoring agent
Ambassador Proxy external connections Database connection pooler, API gateway
Adapter Transform output for consumption Log format converter, metrics exporter
# Sidecar pattern: main app + log collector
apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  containers:
  # Main application container
  - name: app
    image: my-app:v1.0
    ports:
    - containerPort: 8080
    volumeMounts:
    - name: shared-logs
      mountPath: /var/log/app
  
  # Sidecar: ships logs to central logging
  - name: log-shipper
    image: fluentd:v1.16
    volumeMounts:
    - name: shared-logs
      mountPath: /var/log/app
      readOnly: true
    - name: fluentd-config
      mountPath: /fluentd/etc
  
  volumes:
  - name: shared-logs
    emptyDir: {}
  - name: fluentd-config
    configMap:
      name: fluentd-config

Init Containers

Init containers run sequentially before the main containers start. They're used for setup tasks: waiting for dependencies, populating shared volumes, or running database migrations.

# Init containers: run before main containers
apiVersion: v1
kind: Pod
metadata:
  name: app-with-init
spec:
  initContainers:
  # Wait for database to be ready
  - name: wait-for-db
    image: busybox:1.36
    command: ['sh', '-c', 'until nc -z postgres-svc 5432; do echo waiting for db; sleep 2; done']
  
  # Run database migrations
  - name: run-migrations
    image: my-app:v1.0
    command: ['./migrate', '--direction=up']
    env:
    - name: DATABASE_URL
      valueFrom:
        secretKeyRef:
          name: db-secret
          key: url
  
  containers:
  - name: app
    image: my-app:v1.0
    ports:
    - containerPort: 8080

Workload Controllers

ReplicaSets

A ReplicaSet ensures a specified number of pod replicas are running at all times. If pods die, it creates new ones. If too many exist, it removes extras. You rarely create ReplicaSets directly — Deployments manage them for you.

# ReplicaSet reconciliation in action:
kubectl get rs
# NAME                    DESIRED   CURRENT   READY   AGE
# payment-6f7g8h9j       3         3         3       2d

# Kill a pod — ReplicaSet immediately creates a replacement:
kubectl delete pod payment-6f7g8h9j-abc12
kubectl get pods -w
# payment-6f7g8h9j-abc12   1/1   Terminating   0   2d
# payment-6f7g8h9j-xyz99   0/1   Pending       0   0s
# payment-6f7g8h9j-xyz99   0/1   ContainerCreating   0   1s
# payment-6f7g8h9j-xyz99   1/1   Running       0   3s

Deployments

Deployments are the primary workload controller for stateless applications. They manage ReplicaSets and provide declarative updates with rollout strategies:

# Deployment with rolling update strategy
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  labels:
    app: payment
spec:
  replicas: 5
  # Rolling update: replace pods gradually
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2          # Allow 2 extra pods during update
      maxUnavailable: 1    # At most 1 pod unavailable during update
  selector:
    matchLabels:
      app: payment
  template:
    metadata:
      labels:
        app: payment
        version: v2.1
    spec:
      containers:
      - name: payment
        image: payment:v2.1
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
# Deployment operations:

# Update image (triggers rolling update):
kubectl set image deployment/payment-service payment=payment:v2.2

# Watch rollout progress:
kubectl rollout status deployment/payment-service
# Waiting for deployment "payment-service" rollout to finish:
# 3 out of 5 new replicas have been updated...
# 4 out of 5 new replicas have been updated...
# deployment "payment-service" successfully rolled out

# View rollout history:
kubectl rollout history deployment/payment-service
# REVISION  CHANGE-CAUSE
# 1         Initial deployment
# 2         kubectl set image deployment/payment-service payment=payment:v2.1
# 3         kubectl set image deployment/payment-service payment=payment:v2.2

# Rollback to previous version:
kubectl rollout undo deployment/payment-service

# Rollback to specific revision:
kubectl rollout undo deployment/payment-service --to-revision=1

# Pause/resume rollout (for canary-style testing):
kubectl rollout pause deployment/payment-service
# ... test the partial rollout ...
kubectl rollout resume deployment/payment-service
How Rolling Updates Work: Kubernetes creates a new ReplicaSet with the updated pod template and gradually scales it up while scaling the old ReplicaSet down. At any point during the update, traffic is split between old and new versions. If the new version fails readiness probes, the rollout stalls — protecting users from broken deployments.

StatefulSets

StatefulSets are for applications that need stable identity and persistent storage — databases, message queues, and distributed systems that require ordered startup and unique network names.

# StatefulSet for a PostgreSQL replica set
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres-headless   # Required: headless Service for DNS
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:16
        ports:
        - containerPort: 5432
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: postgres-secret
              key: password
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
  # Each pod gets its own PVC (persistent storage)
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: fast-ssd
      resources:
        requests:
          storage: 50Gi

# Result: pods get stable names and storage:
# postgres-0 → PVC data-postgres-0 (50Gi)
# postgres-1 → PVC data-postgres-1 (50Gi)
# postgres-2 → PVC data-postgres-2 (50Gi)
# DNS: postgres-0.postgres-headless.default.svc.cluster.local
Property Deployment StatefulSet
Pod names Random suffix (pod-7f8g9h-abc12) Ordered (postgres-0, postgres-1, postgres-2)
Storage Shared or ephemeral Per-pod persistent volumes
Startup order All pods start simultaneously Sequential (0 before 1 before 2)
Network identity Via Service (random routing) Stable DNS per pod
Use case Stateless web apps, APIs Databases, Kafka, Elasticsearch

DaemonSets

A DaemonSet ensures one pod runs on every node (or a subset of nodes). Used for node-level agents like log collectors, monitoring, and network plugins:

# DaemonSet: run a monitoring agent on every node
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: node-exporter
  template:
    metadata:
      labels:
        app: node-exporter
    spec:
      tolerations:
      # Run on ALL nodes including masters
      - key: node-role.kubernetes.io/control-plane
        operator: Exists
        effect: NoSchedule
      containers:
      - name: node-exporter
        image: prom/node-exporter:v1.7.0
        ports:
        - containerPort: 9100
          hostPort: 9100
        resources:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "100m"

Jobs & CronJobs

# Job: run a task to completion
apiVersion: batch/v1
kind: Job
metadata:
  name: database-backup
spec:
  backoffLimit: 3          # Retry 3 times on failure
  activeDeadlineSeconds: 300  # Timeout after 5 minutes
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: backup
        image: postgres:16
        command: ["pg_dump", "-h", "postgres-0.postgres-headless", "-U", "admin", "-d", "appdb"]
---
# CronJob: scheduled recurring task
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-backup
spec:
  schedule: "0 2 * * *"     # Every day at 2 AM
  concurrencyPolicy: Forbid  # Don't start new if previous still running
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: backup
            image: postgres:16
            command: ["pg_dump", "-h", "postgres-0.postgres-headless", "-U", "admin", "-d", "appdb"]

Configuration Objects

ConfigMaps

ConfigMaps separate configuration from container images, allowing the same image to run in different environments with different settings:

# Create ConfigMap from literal values:
kubectl create configmap app-config \
  --from-literal=database.host=postgres-svc \
  --from-literal=database.port=5432 \
  --from-literal=log.level=info

# Create ConfigMap from a file:
kubectl create configmap nginx-config --from-file=nginx.conf

# View ConfigMap:
kubectl get configmap app-config -o yaml
# ConfigMap definition:
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  database.host: "postgres-svc"
  database.port: "5432"
  log.level: "info"
  app.properties: |
    server.port=8080
    spring.datasource.url=jdbc:postgresql://postgres-svc:5432/appdb
    spring.redis.host=redis-svc

---
# Using ConfigMap in a Pod:
apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
  - name: app
    image: my-app:v1.0
    # As environment variables:
    env:
    - name: DB_HOST
      valueFrom:
        configMapKeyRef:
          name: app-config
          key: database.host
    # As volume mount (file):
    volumeMounts:
    - name: config
      mountPath: /etc/app/config
      readOnly: true
  volumes:
  - name: config
    configMap:
      name: app-config

Secrets

Secrets store sensitive data (passwords, tokens, certificates). They're similar to ConfigMaps but with additional protections: base64-encoded at rest, can be encrypted in etcd, and access can be restricted via RBAC.

# Create Secret:
kubectl create secret generic db-credentials \
  --from-literal=username=admin \
  --from-literal=password='S3cur3P@ssw0rd!'

# Create TLS Secret:
kubectl create secret tls app-tls \
  --cert=tls.crt \
  --key=tls.key

# View Secret (values are base64-encoded):
kubectl get secret db-credentials -o yaml
# data:
#   username: YWRtaW4=         (base64 of "admin")
#   password: UzNjdXIzUEBzc3cwcmQh

# Decode a secret value:
kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
Security Warning: Kubernetes Secrets are only base64-encoded by default — not encrypted. Anyone with API access can read them. For production: (a) enable etcd encryption at rest, (b) use RBAC to restrict Secret access, (c) consider external secret stores (HashiCorp Vault, AWS Secrets Manager) with the External Secrets Operator.

Services & Discovery

Service Types

A Service provides a stable network endpoint for a set of pods. Pods come and go, but the Service IP and DNS name remain constant:

# ClusterIP Service (default): internal-only access
apiVersion: v1
kind: Service
metadata:
  name: payment-svc
spec:
  type: ClusterIP
  selector:
    app: payment       # Routes to all pods with label app=payment
  ports:
  - port: 80           # Service port (what clients connect to)
    targetPort: 8080   # Pod port (where containers listen)
    protocol: TCP
---
# NodePort Service: accessible from outside on every node's IP
apiVersion: v1
kind: Service
metadata:
  name: web-nodeport
spec:
  type: NodePort
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080    # External port (30000-32767)
---
# LoadBalancer Service: provisions cloud load balancer
apiVersion: v1
kind: Service
metadata:
  name: web-public
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
  - port: 443
    targetPort: 8080
Service Types Comparison
flowchart TD
    subgraph ClusterIP
        C1[Internal DNS] --> CS[Service 10.96.x.x]
        CS --> P1[Pod 10.244.1.5]
        CS --> P2[Pod 10.244.2.8]
    end
    subgraph NodePort
        EXT1[External:30080] --> N1[Node1:30080]
        EXT1 --> N2[Node2:30080]
        N1 --> NS[Service]
        N2 --> NS
        NS --> P3[Pod]
        NS --> P4[Pod]
    end
    subgraph LoadBalancer
        LB[Cloud LB
Public IP] --> N3[Node1] LB --> N4[Node2] N3 --> LS[Service] N4 --> LS LS --> P5[Pod] LS --> P6[Pod] end

DNS Resolution

Kubernetes automatically creates DNS records for Services. Pods can reach any Service by name without knowing IP addresses:

# DNS naming convention:
# <service-name>.<namespace>.svc.cluster.local

# From a pod in the "default" namespace:
curl http://payment-svc                          # Same namespace (short name)
curl http://payment-svc.default                  # Explicit namespace
curl http://payment-svc.default.svc.cluster.local  # Fully qualified

# From a pod in a DIFFERENT namespace:
curl http://payment-svc.production               # Cross-namespace
curl http://payment-svc.production.svc.cluster.local

# Headless Service (no ClusterIP, returns pod IPs directly):
# Useful for StatefulSets where clients need individual pod addresses
apiVersion: v1
kind: Service
metadata:
  name: postgres-headless
spec:
  clusterIP: None     # Makes it headless
  selector:
    app: postgres
  ports:
  - port: 5432

# DNS returns individual pod IPs:
# nslookup postgres-headless → 10.244.1.5, 10.244.2.8, 10.244.3.12
# nslookup postgres-0.postgres-headless → 10.244.1.5 (specific pod)

Exercises

Exercise 1 — Deploy a Multi-Tier App: Create a complete application with: (a) A Deployment for a web frontend (3 replicas, nginx), (b) A Deployment for an API backend (2 replicas), (c) A StatefulSet for PostgreSQL (1 replica with PVC), (d) ClusterIP Services connecting them, (e) A NodePort Service exposing the frontend. Verify you can access the frontend from outside the cluster.
Exercise 2 — Rolling Update: Deploy version v1 of an application with 5 replicas. Update to v2 with maxSurge: 1 and maxUnavailable: 0. Watch the rollout with kubectl rollout status. Then roll back. Explain what happens to the ReplicaSets during each operation.
Exercise 3 — Configuration Injection: Create a ConfigMap with application settings and a Secret with database credentials. Deploy a pod that uses both as environment variables AND as mounted files. Verify the values inside the pod with kubectl exec.
Exercise 4 — Label Surgery: Create 10 pods with various labels (app, tier, version, environment). Practice selecting subsets using equality and set-based selectors. Create a Service that targets only pods with specific label combinations. Change a pod's label and observe it being removed from the Service's endpoints.

Conclusion

The Kubernetes object model gives you a universal vocabulary for declaring infrastructure. Key takeaways:

  • Everything is an API object with apiVersion, kind, metadata, spec, and status
  • Labels are the connective tissue — Services find pods, controllers manage pods, all through label selectors
  • Controllers maintain desired state — Deployments, ReplicaSets, StatefulSets, DaemonSets, Jobs
  • Configuration is separated from images — ConfigMaps for settings, Secrets for sensitive data
  • Services provide stable endpoints — DNS-based discovery regardless of pod IP changes

In Part 8, we'll dive deep into Kubernetes networking — the flat networking model, CNI plugins, pod-to-pod communication, and how network policies control traffic flow between services.