Pods & Services in k0s - Core Kubernetes Workloads

Status: Active
Last Updated: 2026-08-14
Category: Containers - Core k0s
Prerequisites: k0s-installation
Time: 3-4 hours
Tags: kubernetes, k0s, pods, services, kubectl, workloads

Summary

Master the two most fundamental Kubernetes objects: Pods (the smallest deployable unit) and Services (stable networking for ephemeral pods). Learn how Kubernetes differs from running plain Docker containers, create and debug your first pods with kubectl, expose them with every Service type, and map everything back to docker-compose concepts you already know.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


๐Ÿงฑ What is a Pod?

The Mental Model Shift

With Docker, you think in containers:

docker run nginx          โ†’ one container = your app

In Kubernetes, you never run containers directly. You run Pods:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚           Pod               โ”‚
โ”‚                             โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚ container โ”‚ โ”‚ sidecar โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                             โ”‚
โ”‚  Shared:                    โ”‚
โ”‚   - IP address              โ”‚
โ”‚   - port space              โ”‚
โ”‚   - IPC                     โ”‚
โ”‚   - volumes                 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

A Pod is the smallest deployable unit in Kubernetes:

Why not just schedule single containers? Because real apps need helpers: a log shipper next to your app, an auth proxy in front of it, a metrics exporter beside it. The Pod groups tightly-coupled containers so Kubernetes can treat them as one scheduling unit.

Key Consequence: Ports Don't Collide Across Pods, But Do Within a Pod

Because each Pod gets its own IP:

Pod A: 10.244.0.5  โ†’ listens on :80
Pod B: 10.244.0.6  โ†’ also listens on :80   โ† no conflict!

But two containers inside the same Pod share the port space:

# โŒ This Pod fails to start - both want port 8080
containers:
  - name: api
    image: my-api:1.0      # listens on 8080
  - name: proxy
    image: envoy:v1.28     # also tries to bind 8080

๐Ÿš€ Your First Pod

The Imperative Way (For Learning Only)

Assuming you have a working k0s cluster from k0s-installation:

sudo k0s kubectl run hello-pod --image=nginx:1.27-alpine

What Happens:

1. kubectl sends a POST to the API server (/api/v1/namespaces/default/pods)
2. API server validates the request and writes the Pod object to etcd
3. Scheduler sees an unscheduled Pod, picks a node with capacity
4. Kubelet on that node pulls nginx:1.27-alpine via containerd
5. Kubelet starts the container, reports status back to the API server
6. Pod phase moves: Pending โ†’ ContainerCreating โ†’ Running

Watch it happen live:

sudo k0s kubectl get pods -w

Output as the Pod progresses:

NAME        READY   STATUS              RESTARTS   AGE
hello-pod   0/1     ContainerCreating   0          2s
hello-pod   1/1     Running             0          12s

The READY column shows readyContainers / totalContainers.

Inspecting the Pod

# Full details
sudo k0s kubectl describe pod hello-pod

# Just the essentials
sudo k0s kubectl get pods -o wide

Example -o wide output:

NAME        READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE
hello-pod   1/1     Running   0          90s   10.244.0.7   fog-node   <none>

Notice the Pod has its own cluster-internal IP (10.244.0.7). This IP comes from the pod CIDR configured by the CNI plugin (k0s-networking covers this).

Test connectivity from inside the cluster โ€” run a temporary busybox pod:

sudo k0s kubectl run test --rm -it --image=busybox:1.36 --restart=Never -- wget -qO- 10.244.0.7 | head -5

Output:

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

Key insight: Pod IPs are routable inside the cluster but ephemeral โ€” recreate the Pod and it likely gets a new IP. This is exactly why Services exist.


๐Ÿ“„ The Declarative Way (The Real World)

Imperative commands are fine for experiments, but production Kubernetes is declarative YAML. Everything below uses manifests you'd actually commit to git (GitOps is Law).

pod.yaml

apiVersion: v1          # API group/version - v1 is the "core" group
kind: Pod               # object type
metadata:
  name: web             # DNS-compatible name, unique per namespace
  labels:               # key/value pairs used by Services & selectors
    app: web
    tier: frontend
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80    # informational only, does NOT publish anything
      resources:
        requests:              # scheduler guarantee
          cpu: 100m            # 100 millicores = 10% of one core
          memory: 64Mi
        limits:                # hard ceiling
          cpu: 250m
          memory: 128Mi
      livenessProbe:           # restart container if this fails
        httpGet:
          path: /
          port: 80
        initialDelaySeconds: 5
        periodSeconds: 10

Apply and verify:

sudo k0s kubectl apply -f pod.yaml
sudo k0s kubectl get pods -l app=web

Important gotcha: containerPort is metadata, not port publishing. It documents intent and enables discovery features, but nothing is reachable from outside the cluster because of it. Publishing happens via Services.

Exec-ing Into a Pod

Just like docker exec, but scoped to a Pod:

# Shell into the pod
sudo k0s kubectl exec -it web -- sh

# Run a one-off command
sudo k0s kubectl exec web -- nginx -v

# Stream logs (like docker logs -f)
sudo k0s kubectl logs -f web

# Multiple containers in one pod? Pick one:
sudo k0s kubectl logs -f web -c nginx

๐ŸŒ Services Explained

The Problem Services Solve

Pods are cattle, not pets:

Event Effect
Pod crashes Replacement gets a new IP
Node reboots All local Pods rescheduled elsewhere
Scaling up Brand new IPs appear
Rolling update Old IPs vanish entirely

Any client hard-coding a Pod IP breaks constantly. A Service provides a stable virtual IP (VIP) and DNS name that load-balances to whatever healthy pods currently match its selector.

How It Works (kube-proxy)

                โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ Service VIP: 10.96.45.10   โ”‚  โ† stable forever
                โ”‚ DNS: web.default.svc       โ”‚
                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                              โ”‚ iptables/eBPF rules
                              โ”‚ round-robin across endpoints
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ–ผ               โ–ผ               โ–ผ
         Pod 10.244.0.7  Pod 10.244.0.8  Pod 10.244.0.9   โ† change freely

kube-proxy (or an eBPF replacement) programs packet-forwarding rules on every node so traffic to the Service VIP is redirected to one of the current backing pods. The list of matching pod IPs is maintained automatically by watching pod labels.

k0s note: k0s ships kube-router or Calico/KubePrism options; either way the Service abstraction behaves identically. See k0s-networking.

Service Types Overview

Type Reachable From Use Case
ClusterIP (default) Inside cluster only Internal APIs, databases, caches
NodePort <NodeIP>:<30000-32767> from anywhere Quick demos, no LB available
LoadBalancer Cloud LB or MetalLB external IP Production exposure without ingress
ExternalName DNS CNAME redirect Pointing at external services

๐Ÿ”Œ Port Mapping: Compose vs Kubernetes

This trips up everyone coming from docker-compose, so let's be explicit.

docker-compose

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"     # HOST port 8080 โ†’ CONTAINER port 80

ports: publishes to the host. Anyone hitting the VM's port 8080 reaches nginx.

Kubernetes โ€” the wrong way and the right way

# โŒ WRONG assumption - this is compose syntax, invalid in k8s
spec:
  containers:
    - image: nginx
      ports:
        - "8080:80"
# โœ… RIGHT - two separate objects
apiVersion: v1
kind: Pod
metadata:
  name: web
  labels:
    app: web
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80    # just documentation of what's listened on
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: NodePort               # publishes OUTSIDE the cluster
  selector:
    app: web                   # which pods to route to
  ports:
    - port: 80                 # Service's own port (inside cluster)
      targetPort: 80           # container port to forward to
      nodePort: 30080          # host-level port (optional; auto-assigned if omitted)

Save both objects in one file (web.yaml) separated by --- and apply:

sudo k0s kubectl apply -f web.yaml
sudo k0s kubectl get svc web

Output:

NAME   TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
web    NodePort   10.96.45.10    <none>        80:30080/TCP   30s

Now three URLs work simultaneously:

curl http://10.96.45.10:80    # cluster IP - works from inside cluster
curl http://$(hostname -i | awk '{print $1}'):30080   # node IP - works from LAN
curl http://localhost:30080                           # same thing locally

Translation table:

docker-compose concept Kubernetes concept
ports: "8080:80" Service type: NodePort + port/targetPort/nodePort
expose: "80" Service type: ClusterIP + port: 80
service name DNS (http://web:80) http://<svc-name>.<namespace>.svc.cluster.local
depends_on No direct equivalent โ€” use readiness probes
container restart policy Pod restartPolicy + higher-level controllers

๐Ÿ”„ Side-by-Side: docker-compose โ†’ Pod + Service

Take a classic compose stack:

# docker-compose.yml
version: "3.9"
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    depends_on:
      - api
  api:
    image: myorg/api:1.0
    expose:
      - "3000"

Equivalent Kubernetes manifest:

# stack.yaml
apiVersion: v1
kind: Pod
metadata:
  name: web
  labels:
    app: web
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
---
apiVersion: v1
kind: Pod
metadata:
  name: api
  labels:
    app: api
spec:
  containers:
    - name: api
      image: myorg/api:1.0
      ports:
        - containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  selector:
    app: api
  ports:
    - port: 3000
      targetPort: 3000
sudo k0s kubectl apply -f stack.yaml

Now the web pod can reach the API at http://api:3000 โ€” cluster DNS resolves the Service name automatically:

sudo k0s kubectl exec web -- curl -s http://api:3000/health

Honest caveat: raw Pods like these exist purely for learning. In practice you almost never write kind: Pod โ€” you let a Deployment manage replicas for you. That's exactly the next lesson, k0s-deployments. Master the primitives first, then graduate.

DNS Naming Rules

From within the cluster, a Service is reachable at:

<service>.<namespace>.svc.cluster.local
<service>.<namespace>          # short form, same namespace
<service>                      # shortest form, same namespace

Cross-namespace example:

curl http://api.database.svc.cluster.local:3000

๐Ÿ” Reading Pod Events Like a Pro

describe pod is your primary debugging tool. Common event messages and what they mean:

Event message Meaning Typical fix
ImagePullBackOff Can't pull the image (bad name/tag, private registry) Verify tag exists; add imagePullSecrets
ErrImagePull Pull attempted but failed Same as above โ€” check exact error text
CrashLoopBackOff Container starts then exits repeatedly Check kubectl logs --previous
Pending Scheduler can't place it Look for resource limits or taints
OOMKilled Memory limit exceeded Raise memory limit or fix leak
CreateContainerConfigError Bad config reference Missing ConfigMap/Secret (see next lessons)

Full diagnosis workflow:

sudo k0s kubectl describe pod web          # read Events section at bottom
sudo k0s kubectl logs web                  # current attempt
sudo k0s kubectl logs web --previous       # crashed attempt
sudo k0s kubectl get events --sort-by=.lastTimestamp | tail -20

Example of a healthy Events section:

Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  60s   default-scheduler  Successfully assigned default/web to fog-node
  Normal  Pulled     59s   kubelet            Container image "nginx:1.27-alpine" already present
  Normal  Created    59s   kubelet            Created container nginx
  Normal  Started    58s   kubelet            Started container nginx

๐Ÿ› ๏ธ Hands-On Lab

Work through this end-to-end on your k0s node:

Lab 1 โ€” Multi-container pod (sidecar pattern):

cat <<'EOF' | sudo k0s kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  containers:
    - name: main
      image: busybox:1.36
      command: ["sh", "-c", "while true; do date >> /var/log/index.html; sleep 2; done"]
      volumeMounts:
        - name: shared-logs
          mountPath: /var/log
    - name: sidecar
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80
      volumeMounts:
        - name: shared-logs
          mountPath: /usr/share/nginx/html
  volumes:
    - name: shared-logs
      emptyDir: {}
EOF

# Both containers share the volume - nginx serves what main writes
POD_IP=$(sudo k0s kubectl get pod app-with-sidecar -o jsonpath='{.status.podIP}')
sudo k0s kubectl run curl-test --rm -it --image=curlimages/curl:8.7.1 \
  --restart=Never -- curl -s $POD_IP | tail -3

You should see timestamp lines served by nginx โ€” written by one container, served by another, over a shared emptyDir volume.

Lab 2 โ€” Service load balancing:

Scale matters even with plain pods โ€” run two identical web pods behind one Service:

for i in 1 2; do
  sed "s/name: web/name: web-$i/" > /tmp/pod-$i.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: web
  labels:
    app: web
spec:
  containers:
    - name: nginx
      image: nginx:1.27-alpine
EOF
  sudo k0s kubectl apply -f /tmp/pod-$i.yaml
done

# Differentiate responses so we can SEE the balancing
sudo k0s kubectl exec web-1 -- sh -c 'echo "pod-1" > /usr/share/nginx/html/index.html'
sudo k0s kubectl exec web-2 -- sh -c 'echo "pod-2" > /usr/share/nginx/html/index.html'

cat <<'EOF' | sudo k0s kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: lb-demo
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
EOF

SVC_IP=$(sudo k0s kubectl get svc lb-demo -o jsonpath='{.spec.clusterIP}')
for i in $(seq 1 6); do sudo k0s kubectl run c$i --rm --quiet -it \
  --image=curlimages/curl:8.7.1 --restart=Never -- curl -s $SVC_IP; done

Expected output alternates between pod-1 and pod-2 โ€” proof the Service load-balances by label selector, independent of individual pod identities.


๐Ÿฉบ Troubleshooting & Common Issues

"My pod is stuck in Pending"

sudo k0s kubectl describe pod <name> | grep -A 10 Events

Usually means insufficient CPU/memory requests, an unfulfilled nodeSelector, or taints on nodes. On a fresh 2GB single-node k0s install, big requests: values are the #1 cause.

"Service has no endpoints"

sudo k0s kubectl get endpoints <svc-name>

If it shows <none>, the Service's selector matches zero pods. Compare labels byte-for-byte:

sudo k0s kubectl get pods --show-labels

A typo (app: Web vs app: web) silently selects nothing โ€” selectors are case-sensitive.

"I can't reach my pod from my laptop"

Correct behavior! Pod IPs and ClusterIPs only exist inside the cluster network. To reach a workload externally you need NodePort, LoadBalancer, or an Ingress controller (k0s-ingress).

"CrashLoopBackOff but logs look fine"

The process might be exiting after doing its work. Kubernetes expects long-running processes (PID 1 must not exit). Batch-style scripts need Jobs, not Pods.

Cleanup checklist

sudo k0s kubectl delete svc lb-demo web
sudo k0s kubectl delete pods --all

โœ… Checkpoint

Before moving on, confirm you can:


๐Ÿ”— Related

Prerequisites:

Next in course:

Related topics:


๐Ÿ“ Change Log

2026-08-14


Next Article: k0s-deployments - Stop managing pods by hand!

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse