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. Quick Start: Hello Kubernetes
  2. Anatomy of a Kubernetes Object
  3. Pods
  4. Workload Controllers
  5. Configuration Objects
  6. Services & Discovery
  7. Exercises
  8. Conclusion

Quick Start: Hello Kubernetes

Before diving into object types, let's run through a complete exercise on the cluster you built in Part 6. This demonstrates every core Kubernetes capability in 7 commands — we'll then spend the rest of this article understanding what each object is and why it works.

1. Deploy & Expose

# Create a Deployment (3 replicas of nginx) and expose it:
kubectl create deployment hello-k8s --image=nginx:1.25 --replicas=3
kubectl expose deployment hello-k8s --type=NodePort --port=80

# Watch pods schedule across worker nodes:
kubectl get pods -o wide
# NAME                         READY   STATUS    NODE
# hello-k8s-59bb5f8cdc-abc12   1/1     Running   worker-1
# hello-k8s-59bb5f8cdc-def34   1/1     Running   worker-2
# hello-k8s-59bb5f8cdc-ghi56   1/1     Running   worker-1

# Access via NodePort (extract the assigned port dynamically):
NODE_PORT=$(kubectl get svc hello-k8s -o jsonpath='{.spec.ports[0].nodePort}')
curl http://localhost:$NODE_PORT
# <!DOCTYPE html>...<h1>Welcome to nginx!</h1>...
What just happened: kubectl create deployment created a Deployment → which created a ReplicaSet → which created 3 Pods. kubectl expose created a Service. Four object types, two commands. The rest of this article explains each one.

2. Scale & Self-Heal

# Scale to 6 replicas:
kubectl scale deployment hello-k8s --replicas=6
kubectl get pods -o wide
# NAME                          READY   STATUS    IP               NODE
# hello-k8s-59bb5f8cdc-7gv6r    1/1     Running   10.244.254.202   worker-2
# hello-k8s-59bb5f8cdc-cnfpj    1/1     Running   10.244.126.10    worker-1
# hello-k8s-59bb5f8cdc-tq662    1/1     Running   10.244.126.9     worker-1
# hello-k8s-59bb5f8cdc-vgwwv    1/1     Running   10.244.254.203   worker-2
# hello-k8s-59bb5f8cdc-xfgks    1/1     Running   10.244.126.11    worker-1
# hello-k8s-59bb5f8cdc-z9x4z    1/1     Running   10.244.254.201   worker-2
# 6 pods spread across both worker nodes (scheduler balances load)

# Scale down to 2:
kubectl scale deployment hello-k8s --replicas=2
kubectl get pods -o wide
# hello-k8s-59bb5f8cdc-cnfpj    1/1     Running   10.244.126.10    worker-1
# hello-k8s-59bb5f8cdc-z9x4z    1/1     Running   10.244.254.201   worker-2
# Kubernetes terminated 4 pods, keeping 1 per node for balance

# Self-healing — delete a pod and watch it recover:
# NOTE: -o name returns "pod/name", so use `kubectl delete` without `pod`:
kubectl delete $(kubectl get pods -l app=hello-k8s -o name | head -1)
# pod "hello-k8s-59bb5f8cdc-cnfpj" deleted

kubectl get pods -o wide
# hello-k8s-59bb5f8cdc-vrmfl    1/1     Running   10.244.126.12    worker-1   (new!)
# hello-k8s-59bb5f8cdc-z9x4z    1/1     Running   10.244.254.201   worker-2
# The ReplicaSet detected desired(2) != actual(1) and created a replacement.

3. Rolling Update & DNS

# Zero-downtime update (nginx 1.25 → 1.26):
kubectl set image deployment/hello-k8s nginx=nginx:1.26
kubectl rollout status deployment/hello-k8s
# deployment "hello-k8s" successfully rolled out

# Rollback:
kubectl rollout undo deployment/hello-k8s

# Service discovery via DNS (from inside the cluster):
kubectl run test-dns --image=busybox:1.36 --rm -it --restart=Never -- sh
/ # nslookup hello-k8s
# Name:   hello-k8s.default.svc.cluster.local
# Address: 10.107.50.16
/ # wget -qO- http://hello-k8s | head -5
# <!DOCTYPE html>...<h1>Welcome to nginx!</h1>...
/ # exit

# Cleanup:
kubectl delete deployment hello-k8s
kubectl delete svc hello-k8s
Single-Node Users: If you set up a single-node cluster in Part 6, remove the control-plane taint first: kubectl taint nodes --all node-role.kubernetes.io/control-plane-. All exercises work identically — pods just all schedule on the same node.

In 7 commands we used Deployments, ReplicaSets, Pods, and Services — plus labels, selectors, and DNS discovery under the hood. Now let's understand each object type in depth.

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"
Hands-On Labels & Selectors
# Create a pod with labels:
kubectl run label-demo --image=nginx:1.25 --labels="app=demo,tier=frontend,env=dev"

# Query by label:
kubectl get pods -l app=demo              # Exact match
kubectl get pods -l 'env in (dev,staging)' # Set-based
kubectl get pods --show-labels             # See all labels

# Add/change/remove labels on existing pods:
kubectl label pod label-demo version=v1.0
kubectl label pod label-demo env=staging --overwrite
kubectl label pod label-demo version-   # Remove label

# Annotate:
kubectl annotate pod label-demo owner="platform-team"
kubectl describe pod label-demo | grep Annotations

# Cleanup:
kubectl delete pod label-demo

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
Hands-On Pods
# Run a standalone pod:
kubectl run my-pod --image=nginx:1.25

# Inspect it:
kubectl get pod my-pod -o wide
kubectl describe pod my-pod

# Exec into the running container:
kubectl exec -it my-pod -- bash
  root@my-pod:/# curl localhost
  root@my-pod:/# exit

# View logs:
kubectl logs my-pod

# Watch lifecycle states (in another terminal: kubectl delete pod my-pod):
kubectl get pod my-pod -w
# my-pod   1/1   Running       0   2m
# my-pod   1/1   Terminating   0   2m

# Multi-container pod (sidecar pattern):
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: sidecar-demo
spec:
  containers:
  - name: app
    image: nginx:1.25
    volumeMounts:
    - name: logs
      mountPath: /var/log/nginx
  - name: log-reader
    image: busybox:1.36
    command: ["sh", "-c", "tail -f /var/log/nginx/access.log"]
    volumeMounts:
    - name: logs
      mountPath: /var/log/nginx
      readOnly: true
  volumes:
  - name: logs
    emptyDir: {}
EOF

# Both containers share the volume:
kubectl logs sidecar-demo -c log-reader

# Cleanup:
kubectl delete pod my-pod sidecar-demo

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
Hands-On ReplicaSets
# Create a Deployment (which creates a ReplicaSet):
kubectl create deployment rs-demo --image=nginx:1.25 --replicas=3

# See the ReplicaSet it created:
kubectl get rs
# NAME                  DESIRED   CURRENT   READY   AGE
# rs-demo-59bb5f8cdc    3         3         3       10s

# Delete a pod — watch the RS recreate it:
# NOTE: -o name returns "pod/name", so use `kubectl delete` without `pod`:
kubectl delete $(kubectl get pods -l app=rs-demo -o name | head -1)
kubectl get pods -l app=rs-demo
# Still 3 pods — the ReplicaSet replaced the deleted one instantly

# Cleanup:
kubectl delete deployment rs-demo

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.
Hands-On Deployments
# Create a deployment:
kubectl create deployment deploy-demo --image=nginx:1.25 --replicas=3

# Rolling update (1.25 → 1.26):
kubectl set image deployment/deploy-demo nginx=nginx:1.26
kubectl rollout status deployment/deploy-demo
# deployment "deploy-demo" successfully rolled out

# View history:
kubectl rollout history deployment/deploy-demo
# REVISION  CHANGE-CAUSE
# 1         
# 2         

# Rollback:
kubectl rollout undo deployment/deploy-demo
kubectl describe deployment deploy-demo | grep Image
#     Image:         nginx:1.25

# See both ReplicaSets (old and new):
kubectl get rs -l app=deploy-demo
# One has 3 replicas (active), one has 0 (previous version)

# Cleanup:
kubectl delete deployment deploy-demo

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
Hands-On StatefulSets
# Create a headless Service (required by StatefulSets):
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Service
metadata:
  name: sts-demo-headless
spec:
  clusterIP: None
  selector:
    app: sts-demo
  ports:
  - port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: sts-demo
spec:
  serviceName: sts-demo-headless
  replicas: 3
  selector:
    matchLabels:
      app: sts-demo
  template:
    metadata:
      labels:
        app: sts-demo
    spec:
      containers:
      - name: nginx
        image: nginx:1.25
EOF

# Observe ORDERED creation (0, then 1, then 2):
kubectl get pods -w -l app=sts-demo
# sts-demo-0   0/1   Pending       0   0s
# sts-demo-0   1/1   Running       0   3s
# sts-demo-1   0/1   Pending       0   0s
# sts-demo-1   1/1   Running       0   3s
# sts-demo-2   0/1   Pending       0   0s
# sts-demo-2   1/1   Running       0   3s

# Stable DNS names:
kubectl run dns-check --image=busybox:1.36 --rm -it --restart=Never -- \
  nslookup sts-demo-0.sts-demo-headless
# Address: 10.244.x.x  (specific pod IP)

# Delete pod-1 — it comes back as sts-demo-1 (same name!):
kubectl delete pod sts-demo-1
kubectl get pods -l app=sts-demo
# sts-demo-0, sts-demo-1 (recreated), sts-demo-2

# Cleanup:
kubectl delete statefulset sts-demo
kubectl delete svc sts-demo-headless

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"
Hands-On DaemonSets
# Create a DaemonSet:
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: ds-demo
spec:
  selector:
    matchLabels:
      app: ds-demo
  template:
    metadata:
      labels:
        app: ds-demo
    spec:
      tolerations:
      - effect: NoSchedule
        operator: Exists
      containers:
      - name: agent
        image: busybox:1.36
        command: ["sh", "-c", "while true; do echo $(hostname); sleep 60; done"]
EOF

# Verify: one pod per node (including master with toleration):
kubectl get pods -l app=ds-demo -o wide
# ds-demo-abc12   1/1   Running   master-1
# ds-demo-def34   1/1   Running   worker-1
# ds-demo-ghi56   1/1   Running   worker-2

# Cleanup:
kubectl delete daemonset ds-demo

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"]
Hands-On Jobs
# Run a one-shot Job:
kubectl create job pi-calc --image=perl:5.34 \
  -- perl -Mbignum=bpi -wle 'print bpi(500)'

# Watch it complete:
kubectl get jobs -w
# NAME      COMPLETIONS   DURATION   AGE
# pi-calc   0/1           5s         5s
# pi-calc   1/1           12s        12s

# View the result:
kubectl logs job/pi-calc
# 3.14159265358979323846...

# The pod stays in Completed state (not deleted):
kubectl get pods -l job-name=pi-calc
# pi-calc-abc12   0/1   Completed   0   30s

# Cleanup:
kubectl delete job pi-calc

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
Hands-On ConfigMaps
# Create a ConfigMap from literals:
kubectl create configmap demo-config \
  --from-literal=database.host=postgres-svc \
  --from-literal=log.level=info

# Inspect it:
kubectl get configmap demo-config -o yaml

# Mount in a pod and verify:
kubectl run cm-demo --image=busybox:1.36 --restart=Never \
  --overrides='{
    "spec": {
      "containers": [{
        "name": "cm-demo",
        "image": "busybox:1.36",
        "command": ["sh", "-c", "cat /etc/config/database.host; sleep 3600"],
        "volumeMounts": [{"name": "config", "mountPath": "/etc/config"}]
      }],
      "volumes": [{"name": "config", "configMap": {"name": "demo-config"}}]
    }
  }'

kubectl logs cm-demo
# postgres-svc

# Cleanup:
kubectl delete pod cm-demo
kubectl delete configmap demo-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.
Hands-On Secrets
# Create a Secret:
kubectl create secret generic demo-secret \
  --from-literal=username=admin \
  --from-literal=password='K8s-S3cret!'

# Values are base64-encoded (not encrypted!):
kubectl get secret demo-secret -o yaml
# data:
#   password: SzhzLVMzY3JldCE=
#   username: YWRtaW4=

# Decode:
kubectl get secret demo-secret -o jsonpath='{.data.password}' | base64 -d
# K8s-S3cret!

# Cleanup:
kubectl delete secret demo-secret

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)
Hands-On Services & DNS
# Create a deployment + ClusterIP service:
kubectl create deployment svc-demo --image=nginx:1.25 --replicas=2
kubectl expose deployment svc-demo --port=80

# Check the ClusterIP:
kubectl get svc svc-demo
# NAME       TYPE        CLUSTER-IP     PORT(S)   AGE
# svc-demo   ClusterIP   10.96.x.x      80/TCP    5s

# Test DNS from inside the cluster:
kubectl run dns-test --image=busybox:1.36 --rm -it --restart=Never -- sh
/ # nslookup svc-demo
# Name:   svc-demo.default.svc.cluster.local
# Address: 10.96.x.x
/ # wget -qO- http://svc-demo | head -3
# <!DOCTYPE html>...
/ # exit

# Compare: create a NodePort service:
kubectl expose deployment svc-demo --name=svc-demo-np --type=NodePort --port=80
NODE_PORT=$(kubectl get svc svc-demo-np -o jsonpath='{.spec.ports[0].nodePort}')
curl http://localhost:$NODE_PORT

# Cleanup:
kubectl delete deployment svc-demo
kubectl delete svc svc-demo svc-demo-np

Exercises

Going Deeper — Skaffold Track: Once you're comfortable deploying manifests manually, Skaffold automates the inner development loop — rebuilding your image, pushing it, and re-deploying on every file save. See the Skaffold Track → in this series for a full hands-on walkthrough.
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.