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:
- โ Explain what a Pod is and why it wraps containers
- โ Create, inspect, exec into, and delete pods with kubectl
- โ Read pod events to diagnose scheduling and crash issues
- โ Expose workloads using ClusterIP, NodePort, and LoadBalancer Services
- โ Understand kube-proxy and ClusterIP routing at a practical level
- โ Translate any docker-compose file into equivalent Pod + Service YAML
- โ Troubleshoot the most common Pod and Service failures
๐งฑ 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:
- One or more containers that are scheduled together, on the same node
- Containers in a Pod share a network namespace โ they reach each other on
localhost - Containers in a Pod share storage volumes declared by the Pod
- A Pod lives and dies as a unit โ if the node dies, all containers in the Pod die
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:
- Explain why a Pod exists between "the cluster" and "a container"
- Write a Pod manifest from scratch with labels, probes, and resources
- Diagnose ImagePullBackOff vs CrashLoopBackOff vs Pending in under a minute
- Publish an internal service externally with NodePort
- Convert a simple docker-compose file to Pod + Service YAML
๐ Related
Prerequisites:
- k0s-installation - Getting your cluster up
Next in course:
- k0s-deployments - Replica management, rolling updates, health-checked rollouts
- k0s-configmaps-secrets - Feeding config into these pods properly
Related topics:
- k0s-networking - What's underneath Service routing
- kubernetes-concepts - Big-picture architecture refresher
๐ Change Log
2026-08-14
- Created lesson covering README spec: Pods vs containers, creating pods, Services, port mapping, docker-compose comparison
- Included hands-on labs (sidecar pattern, load-balancing demo)
- Added troubleshooting matrix for common pod/service failures
Next Article: k0s-deployments - Stop managing pods by hand!