Kubernetes on Raspberry Pi 5 : Part 2 - Object Model
The provided text details the core Object Model and API abstractions of Kubernetes within the context of a 4-node Raspberry Pi 5 cluster. It explains how the control plane continuously reconciles the actual state with the desired state specified in declarative YAML or JSON manifests. Furthermore, the material breaks down essential architectural building blocks such as nodes, namespaces, pods, labels, replica sets, and deployments. Practical operations are also demonstrated, including how to handle rolling updates, configure internal service discovery, and troubleshoot container environments using kubectl.
This post is part of a series on Kubernetes on Raspberry Pi 5:
- Kubernetes on Raspberry Pi 5 : Part 1 - Cluster
- Kubernetes on Raspberry Pi 5 : Part 2 - Object Model
- Kubernetes on
Raspberry Pi 5: Part 3 - AI/ML at Scale
- Introduction
- ReplicaSets
- Deployments
- Applications
- YAML manifest for a more complex application
- Step 1: Create the Backend (Redis)
- Step 2: Create the Frontend (Nginx)
- Step 3: Verify Internal Communication
- Step 4: Map the Network Architecture
- Step 5: View the Results
- Step 5: Scale the Frontend
- Step 6: Observe the Load Balancer Endpoints
- Step 7: Verify the Load Balancing
- Debug the application environment
- Important Considerations for
kubectl cp
Introduction
In Part 11 of this series, we built a 4-node Raspberry Pi 5 cluster running Vanilla Kubernetes (v1.30.2), examining control plane components, container runtimes (cri-dockerd and containerd), and low-level overlay networking with Flannel CNI.
While understanding cluster infrastructure and networking is essential, day-to-day operations require mastering Kubernetes API abstractions. This post covers the foundational Kubernetes Building Blocks, aligning with the Linux Foundation’s Introduction to Kubernetes (LFS158) course curriculum2.
The Declarative Object Model
Kubernetes operates on a declarative Object Model. Rather than executing imperative commands to run containers, you declare the desired state of your application using JSON or YAML manifests. The Kubernetes control plane continuously runs control loops (reconciliation) to drive the current state of the cluster toward the desired state stored in etcd.
architecture-beta
group user_space(cloud)[Client & Declarative Manifest]
service manifest(file)[YAML/JSON Spec] in user_space
group control_plane(server)[Kubernetes Control Plane]
service apiserver(internet)[API Server] in control_plane
service etcd(database)[etcd Store] in control_plane
service controller(server)[Controller Manager] in control_plane
group worker_nodes(server)[Raspberry Pi Worker Nodes]
service pods(server)[Running Pods] in worker_nodes
manifest:R --> L:apiserver
apiserver:B --> T:etcd
controller:L --> R:apiserver
controller:B --> T:pods
The Kubernetes Object Model, which defines persistent entities in a cluster to manage containerized applications, host nodes, resource consumption, and operational policies.
Desired vs. Actual State:
spec: Declarative section where you define the intended or desired state of an object.status: Section managed continuously by the Kubernetes Control Plane to record the real-time actual state, working to match it to the desired state.
At any given point in time, the Control Plane tries to match the object’s actual state to the object’s desired state.
Core Object Manifest Fields:
apiVersion: Specifies the API version being referenced.kind: Identifies the type of object (e.g., Pod, Node, Namespace, ReplicaSet, Deployment, DaemonSet).metadata: Holds accounting and organizational data (such as names, labels, and namespaces).data/stringData: Alternative sections that replacespecin specific objects (e.g., ConfigMaps or Secrets) to store raw configuration data.
Deployment Workflow: Object manifests are typically written in YAML format. When submitted via
kubectl, the tool converts the manifest into a JSON payload sent to the Kubernetes API Server.
Node
A node is a unique identity Kubernetes assigns to each system in a cluster, whether that system is a VM, bare-metal machine, or container. The cluster uses these identities for resource accounting and monitoring, which helps it manage workloads.
What runs on every node:
- Container runtime: runs all containerized workloads, including control plane agents and user workloads.
- kubelet and kube-proxy: handle local workload management. They work with the runtime to
- run containers,
- monitor
- container and
- node health,
- report status to the API Server, and
- manage network traffic to containers
- Add-ons: provide container networking, monitoring, logging, DNS, and similar services.
Node types
- Control plane nodes: also run the API Server, Scheduler, Controller Managers, and etcd. A cluster needs at least one, and more can be added for high availability.
- Worker nodes: have no control plane agents. A cluster has one or more, which provide resource redundancy.
- Hybrid (all-in-one) nodes: a single node hosts both the control plane agents and user workloads. This suits setups where high availability and redundancy don’t matter.
Bootstrapping
- Node identities are created and assigned when the cluster is bootstrapped.
- My 4-node Raspberry Pi 5 cluster is bootstrapped with kubeadm.
kubeadm initsets up the control plane onrp1, andkubeadm joinaddsrp2,rp3andrp4as worker nodes.
- Control plane and worker nodes all run the kubelet, kube-proxy, a container runtime and add-ons, but only control plane nodes also run the API Server, Scheduler, Controller Managers and etcd.
- Together, the control plane and worker nodes form the cluster. They can sit on the same private network, across different networks, or across different cloud networks.
Namespaces in Kubernetes
Namespaces split one cluster into virtual sub-clusters, so multiple users and teams can share it. Resource names must be unique within a Namespace, but not across the cluster.
Default Namespaces
| Namespace | Purpose |
|---|---|
default |
Holds objects created by administrators and developers, unless another Namespace is specified |
kube-system |
Holds objects created by the Kubernetes system, mostly control plane agents |
kube-public |
Unsecured and readable by anyone; used for things like exposing non-sensitive cluster information |
kube-node-lease |
Holds node lease objects used for node heartbeat data |
kubectl get namespaces
NAME STATUS AGE
default Active 2y116d
kube-flannel Active 2y116d
kube-node-lease Active 2y116d
kube-public Active 2y116d
kube-system Active 2y116d
kubernetes-dashboard Active 2y116d
To get all in the namespace
# 📋️
kubectl get all -n kube-flannel
NAME READY STATUS RESTARTS AGE
pod/kube-flannel-ds-5k5nx 1/1 Running 13 (40h ago) 2y116d
pod/kube-flannel-ds-c4rfk 1/1 Running 14 (5h38m ago) 2y116d
pod/kube-flannel-ds-jbk4g 1/1 Running 13 (40h ago) 2y116d
pod/kube-flannel-ds-vkc9x 1/1 Running 13 (40h ago) 2y116d
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
daemonset.apps/kube-flannel-ds 4 4 4 4 4 <none> 2y116d
Create a Namespace: kubectl create namespace new-namespace-name
kubectl create namespace ojtest
namespace/ojtest created
kubectl get namespaces
NAME STATUS AGE
default Active 2y116d
kube-flannel Active 2y116d
kube-node-lease Active 2y116d
kube-public Active 2y116d
kube-system Active 2y116d
kubernetes-dashboard Active 2y116d
ojtest Active 20s
To delete the ojtest namespace:
# ❌️
kubectl delete namespace ojtest
namespace "ojtest" deleted
kubectl get namespaces
NAME STATUS AGE
default Active 2y116d
kube-flannel Active 2y116d
kube-node-lease Active 2y116d
kube-public Active 2y116d
kube-system Active 2y116d
kubernetes-dashboard Active 2y116d
Good practice: Create additional Namespaces to isolate users, teams, applications, or tiers.
Limiting resources
- Resource quotas3 limit the total resources consumed within a Namespace.
- LimitRanges4 limit the resources consumed by individual Containers and their enclosing objects in a Namespace.
Pod
A Pod is the smallest workload object and represents a single instance of an application. It holds one or more containers that:
- are scheduled together on the same host
- share one network namespace, and so one IP address
- can mount the same external storage (volumes)
Pods are ephemeral and can’t heal themselves. Controllers such as Deployments, ReplicaSets, DaemonSets and Jobs handle replication, fault tolerance and self-healing, and they embed the Pod spec through a Pod Template.
Manifest
A Pod manifest has four required fields:
| Field | Purpose |
|---|---|
apiVersion |
Must be v1 for a Pod |
kind |
Pod |
metadata |
Name, plus optional labels and annotations |
spec |
Desired state (the PodSpec) |
cat def-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: whoami-pod
labels:
run: whoami-pod
spec:
containers:
- name: whoami-pod
image: traefik/whoami:v1.10.1
ports:
- containerPort: 80
Create the Pod declaratively:
You can create prod either declaratively or imperatively as follows:
kubectl run whoami-pod --image=traefik/whoami:v1.10.1 --port=80 \ --dry-run=client -o yaml > whoami-pod.yaml
kubectl create -f def-pod.yaml
pod/whoami-pod created
verify the pod creation
kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
nginx-daemon-48l6g 1/1 Running 11 (41h ago) 2y113d 10.244.3.44 rp4 <none> <none>
nginx-daemon-7wnmv 1/1 Running 11 (6h47m ago) 2y113d 10.244.1.45 rp2 <none> <none>
nginx-daemon-g84rj 1/1 Running 11 (41h ago) 2y113d 10.244.2.51 rp3 <none> <none>
redis-db-7c5bd788fd-7vxjv 1/1 Running 7 (41h ago) 303d 10.244.3.42 rp4 <none> <none>
web-frontend-5f4cd56f87-t5srs 1/1 Running 7 (41h ago) 303d 10.244.3.43 rp4 <none> <none>
web-frontend-5f4cd56f87-vvbd4 1/1 Running 7 (41h ago) 303d 10.244.2.54 rp3 <none> <none>
whoami-pod 1/1 Running 0 14m 10.244.1.46 rp2 <none> <none>
Pod has been created and in the Running state. Here the pod ip is 10.244.1.46.
curl http://10.244.1.46
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 192 100 192 0 0 111k 0 --:--:-- --:Hostname: whoami-pod
IP: 127.0.0.1
IP: ::1
IP: 10.244.1.46
IP: fe80::949e:84ff:fe14:e331
RemoteAddr: 10.244.1.1:32874
GET / HTTP/1.1
Host: 10.244.1.46
User-Agent: curl/8.5.0
Accept: */*
--:-- --:--:-- 187k
Describe the whoami-pod:
kubectl describe pod whoami-pod
Name: whoami-pod
Namespace: default
Priority: 0
Service Account: default
Node: rp2/192.168.1.12
Start Time: Tue, 03 Nov 2026 16:37:15 +1100
Labels: run=whoami-pod
Annotations: <none>
Status: Running
IP: 10.244.1.46
IPs:
IP: 10.244.1.46
Containers:
whoami-pod:
Container ID: containerd://7d4f47ceac1024b00f7ecb2c5b845ccf3356b291ae81d5aec8cac33303b563a7
Image: traefik/whoami:v1.10.1
Image ID: docker.io/traefik/whoami@sha256:6956a30c7d33f61bb7fdf1c14dcb3192fca4093cd5b91b5f9b38862862072fe9
Port: 80/TCP
Host Port: 0/TCP
State: Running
Started: Tue, 03 Nov 2026 16:37:23 +1100
Ready: True
Restart Count: 0
Environment: <none>
Mounts:
/var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-kkjhn (ro)
Conditions:
Type Status
PodReadyToStartContainers True
Initialized True
Ready True
ContainersReady True
PodScheduled True
Volumes:
kube-api-access-kkjhn:
Type: Projected (a volume that contains injected data from multiple sources)
TokenExpirationSeconds: 3607
ConfigMapName: kube-root-ca.crt
ConfigMapOptional: <nil>
DownwardAPI: true
QoS Class: BestEffort
Node-Selectors: <none>
Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 10m default-scheduler Successfully assigned default/whoami-pod to rp2
Normal Pulling 10m kubelet Pulling image "traefik/whoami:v1.10.1"
Normal Pulled 10m kubelet Successfully pulled image "traefik/whoami:v1.10.1" in 6.732s (6.732s including waiting). Image size: 2561984 bytes.
Normal Created 10m kubelet Created container whoami-pod
Normal Started 10m kubelet Started container whoami-pod
If any pod is in the error state found in the output of the kubectl get pods -o wide, can be investigated under the Events section in the above command.
Retrive the template (either yaml or JSON):
kubectl get pod whoami-pod -o yaml
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: "2026-11-03T05:37:15Z"
labels:
run: whoami-pod
name: whoami-pod
namespace: default
resourceVersion: "398726"
uid: 23bbd811-51b7-4e77-8680-fb606243b8c8
spec:
containers:
- image: traefik/whoami:v1.10.1
imagePullPolicy: IfNotPresent
name: whoami-pod
ports:
- containerPort: 80
protocol: TCP
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount
name: kube-api-access-kkjhn
readOnly: true
dnsPolicy: ClusterFirst
enableServiceLinks: true
nodeName: rp2
preemptionPolicy: PreemptLowerPriority
priority: 0
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
serviceAccount: default
serviceAccountName: default
terminationGracePeriodSeconds: 30
tolerations:
- effect: NoExecute
key: node.kubernetes.io/not-ready
operator: Exists
tolerationSeconds: 300
- effect: NoExecute
key: node.kubernetes.io/unreachable
operator: Exists
tolerationSeconds: 300
volumes:
- name: kube-api-access-kkjhn
projected:
defaultMode: 420
sources:
- serviceAccountToken:
expirationSeconds: 3607
path: token
- configMap:
items:
- key: ca.crt
path: ca.crt
name: kube-root-ca.crt
- downwardAPI:
items:
- fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
path: namespace
status:
conditions:
- lastProbeTime: null
lastTransitionTime: "2026-11-03T05:37:24Z"
status: "True"
type: PodReadyToStartContainers
- lastProbeTime: null
lastTransitionTime: "2026-11-03T05:37:15Z"
status: "True"
type: Initialized
- lastProbeTime: null
lastTransitionTime: "2026-11-03T05:37:24Z"
status: "True"
type: Ready
- lastProbeTime: null
lastTransitionTime: "2026-11-03T05:37:24Z"
status: "True"
type: ContainersReady
- lastProbeTime: null
lastTransitionTime: "2026-11-03T05:37:15Z"
status: "True"
type: PodScheduled
containerStatuses:
- containerID: containerd://7d4f47ceac1024b00f7ecb2c5b845ccf3356b291ae81d5aec8cac33303b563a7
image: docker.io/traefik/whoami:v1.10.1
imageID: docker.io/traefik/whoami@sha256:6956a30c7d33f61bb7fdf1c14dcb3192fca4093cd5b91b5f9b38862862072fe9
lastState: {}
name: whoami-pod
ready: true
restartCount: 0
started: true
state:
running:
startedAt: "2026-11-03T05:37:23Z"
hostIP: 192.168.1.12
hostIPs:
- ip: 192.168.1.12
phase: Running
podIP: 10.244.1.46
podIPs:
- ip: 10.244.1.46
qosClass: BestEffort
startTime: "2026-11-03T05:37:15Z"
Finally delete the pod whoami-pod:
# ❌️
kubectl delete pod whoami-pod
pod "whoami-pod" deleted
kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-daemon-48l6g 1/1 Running 11 (41h ago) 2y113d
nginx-daemon-7wnmv 1/1 Running 11 (6h51m ago) 2y113d
nginx-daemon-g84rj 1/1 Running 11 (41h ago) 2y113d
redis-db-7c5bd788fd-7vxjv 1/1 Running 7 (41h ago) 303d
web-frontend-5f4cd56f87-t5srs 1/1 Running 7 (41h ago) 303d
web-frontend-5f4cd56f87-vvbd4 1/1 Running 7 (41h ago) 303d
Labels
- What they are: key-value pairs attached to Kubernetes objects such as Pods, ReplicaSets, Nodes, Namespaces and Persistent Volumes.
- What they do: they organize objects and let you select a subset of them based on your requirements.
- Not unique: many objects can share the same Label, so Labels don’t identify a single object.
- How controllers use them: controllers group decoupled objects logically by Label, rather than by name or ID.
Label selectors
Label selectors are how controllers (or operators) and Services pick out a subset of Kubernetes objects, and there are two types:
- Equality-based selectors filter on label keys and values using
=or==(equals, used interchangeably) and!=(not equals), soenv==testorenv=testselects objects whoseenvlabel is set totest. - Set-based selectors filter against a set of values using
inandnotinfor label values, plus exist/does not exist operators for label keys. For example,env in (test,qa)selects objects whoseenvlabel is eithertestorqa, while!prodselects objects with noprodlabel key.
ReplicationControllers (RCs)
RC is an older controller that keeps a set number of Pod replicas running which is not anymore recommended, but it’s useful for understanding how Kubernetes controllers work.
It constantly compares the actual state with the desired state.
| Situation | What the RC does |
|---|---|
| Too many Pods | Randomly terminates the extras |
| Too few Pods | Requests new Pods until the count matches |
| Count matches | Nothing, but it keeps watching |
The standalone Pod has no self-healing, so it can’t restart itself if it’s terminated in error. The recommended approach is to run and manage Pods through a controller, or “operator”. RCs also support application updates.
The , which configures a ReplicaSet to manage the Pods’ lifecycle: Deployment → ReplicaSet → Pods.
ReplicaSets
ReplicaSet is the controller that oversees the lifecycle of those instances. It scales the number of Pods running a specific application container image, either manually or through an autoscaler.
ReplicaSets and ReplicationControllers
A ReplicaSet is, in part, the next-generation ReplicationController. It implements the same replication and self-healing behaviour, but supports a wider range of Selectors.
| Controller | Equality-based Selectors | Set-based Selectors |
|---|---|---|
| ReplicationController | Yes | No |
| ReplicaSet | Yes | Yes |
Anatomy of a ReplicaSet
Following figure shows a ReplicaSet with its replica count set to 3. All three Pods are cloned from the same Pod template, so they run the same image with the same configuration. .
The Pod object also allows each instance to be placed individually on any worker node during scheduling.
A ReplicaSet with three replicas:
flowchart TB
RS["ReplicaSet: frontend\ndesired replicas = 3"]
T["Pod template\nimage: gb-frontend:v3"]
RS --> T
T --> P1["Pod-1\nunique name and IP"]
T --> P2["Pod-2\nunique name and IP"]
T --> P3["Pod-3\nunique name and IP"]
P1 --> W["Any worker node\nchosen by the scheduler"]
P2 --> W
P3 --> W
Desired State and Current State
In the course’s example, the current state matches the desired state because three Pods are running and three were requested. Figure 2 extends this idea to show how the ReplicaSet’s self-healing and scaling work when the two differ. The page itself only shows the matching case.
Desired state versus current state:
flowchart LR
A["Desired state\nreplicas in the spec"] --> B{"Current Pods\nmatch desired?"}
B -- Yes --> C["No action needed"]
B -- "No: a Pod crashed or\nreplicas changed" --> D["ReplicaSet adjusts\nthe Pod count"]
D --> B
E["Manual scaling\nor autoscaler"] --> A
Defining a ReplicaSet
A YAML manifest is the declarative way to define the object, and it can serve as a template for more complex definitions:
This manifest creates three Pods running the standard Nginx image:
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
The selector must match the labels in the Pod template. The ReplicaSet uses them to manage its Pods.
Save the manifest as nginx-rs.yaml and create the ReplicaSet:
kubectl apply -f nginx-rs.yaml
replicaset.apps/nginx-rs created
Common operations:
For most applications, use a
Deploymentinstead; it manages aReplicaSetand supports rolling updates.
# List RepicaSets
kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-rs 3 3 3 90s
# List the pods
kubectl get pods -l app=nginx
NAME READY STATUS RESTARTS AGE
nginx-rs-bd2qj 1/1 Running 0 2m11s
nginx-rs-mg6j4 1/1 Running 0 2m11s
nginx-rs-rvfvj 1/1 Running 0 2m11s
# scale to 2 replicas
kubectl scale rs nginx-rs --replicas=2
replicaset.apps/nginx-rs scaled
kubectl get replicasets
NAME DESIRED CURRENT READY AGE
nginx-rs 2 2 2 4m22s
# show details
kubectl describe rs nginx-rs
Name: nginx-rs
Namespace: default
Selector: app=nginx
Labels: <none>
Annotations: <none>
Replicas: 2 current / 2 desired
Pods Status: 2 Running / 0 Waiting / 0 Succeeded / 0 Failed
Pod Template:
Labels: app=nginx
Containers:
nginx:
Image: nginx
Port: <none>
Host Port: <none>
Environment: <none>
Mounts: <none>
Volumes: <none>
Node-Selectors: <none>
Tolerations: <none>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 5m23s replicaset-controller Created pod: nginx-rs-mg6j4
Normal SuccessfulCreate 5m23s replicaset-controller Created pod: nginx-rs-bd2qj
Normal SuccessfulCreate 5m23s replicaset-controller Created pod: nginx-rs-rvfvj
Normal SuccessfulDelete 102s replicaset-controller Deleted pod: nginx-rs-mg6j4
# ❌️
kubectl delete -f nginx-rs.yaml
replicaset.apps "nginx-rs" deleted
# List the pods after delete
kubectl get pods -l app=nginx
No resources found in default namespace.
Deployments
A Deployment provides declarative updates to Pods and ReplicaSets. The DeploymentController*, part of the control plane node’s controller manager, keeps the current state matched to the desired state. It also manages ReplicaSets directly for scaling.
flowchart TD
CM["Controller Manager\n(control plane node)"] --> DC["DeploymentController"]
DC -->|"keeps current state = desired state"| D["Deployment\nnginx-deployment\nreplicas: 3"]
D -->|"directly manages"| RS["ReplicaSet"]
RS --> P1["Pod 1\nnginx"]
RS --> P2["Pod 2\nnginx"]
RS --> P3["Pod 3\nnginx"]
Update strategies
| Update strategy | Behaviour | Status |
|---|---|---|
| RollingUpdate | Seamless updates and rollbacks | Default |
| Recreate | Disruptive update | Less popular |
A Deployment’s strategy determines how Kubernetes replaces the Pods when the Pod template changes, such as when you update the container image.
-
RollingUpdate replaces Pods gradually. Kubernetes creates some new Pods while old Pods are still running, then removes the old Pods as the new ones become ready. This usually keeps the application available during an update. The
maxSurgesetting limits how many extra Pods may be created above the desired replica count;maxUnavailablelimits how many Pods may be unavailable during the update. Both settings can be configured underspec.strategy.rollingUpdate. The default strategy for Deployments isRollingUpdate.spec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0With three replicas, this example permits Kubernetes to create one extra Pod and requires all three desired replicas to remain available while the update proceeds. The actual rollout also depends on new Pods becoming ready; a Pod that never becomes ready can stall the rollout.
-
Recreate first terminates all existing Pods, then creates the new ones. This avoids having old and new versions running at the same time, which can be useful when the versions cannot safely coexist. However, the application will have downtime while the old Pods are stopped and the new Pods start. Choose this strategy only when that interruption is acceptable.
spec: strategy: type: Recreate
For either strategy, Kubernetes records the Deployment’s revisions when its Pod template changes, so a rollout can be inspected or rolled back. A rollback restores a previous Pod template; it does not guarantee that application data or external side effects are reversed.
The Deployment manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx-deployment
spec:
replicas: 3
selector:
matchLabels:
app: nginx-deployment
template:
metadata:
labels:
app: nginx-deployment
spec:
containers:
- name: nginx
image: nginx:1.20.2
ports:
- containerPort: 80
# create Declarative, from a file
kubectl apply -f nginx-deployment.yaml --record
Flag --record has been deprecated, --record will be removed in the future
deployment.apps/nginx-deployment created
A nested Pod keeps its
metadataandspec, but loses its ownapiVersionandkind, which are replaced bytemplate. After creation, Kubernetes attaches and populates astatusfield on the object.
| Goal | Command |
|---|---|
| Declarative, from a file | kubectl create -f def-deploy.yaml (advanced users may prefer apply) |
| Imperative, no file | kubectl create deployment nginx-deployment --image=nginx:1.20.2 --port=80 --replicas=3 |
| Generate a YAML template | Add --dry-run=client -o yaml > nginx-deploy.yaml |
| Generate a JSON template | Add --dry-run=client -o json > nginx-deploy.json |
| Load a generated template | kubectl create -f nginx-deploy.yaml or kubectl create -f nginx-deploy.json |
kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-deployment-b96f9488b-fdrks 1/1 Running 0 5s
nginx-deployment-b96f9488b-mp6nk 1/1 Running 0 5s
nginx-deployment-b96f9488b-nzb6x 1/1 Running 0 5s
# List
kubectl get deploy -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
nginx-deployment 3/3 3 3 17s nginx nginx:1.20.2 app=nginx-deployment
# Scale the Deployment
kubectl scale deploy nginx-deployment --replicas=2 --record
Flag --record has been deprecated, --record will be removed in the future
deployment.apps/nginx-deployment scaled
Scaling and labeling don’t change the Revision number.
# Check rollout history (scaling won't add a revision) ⌛️
kubectl rollout history deploy nginx-deployment
deployment.apps/nginx-deployment
REVISION CHANGE-CAUSE
1 kubectl scale deploy nginx-deployment --replicas=2 --record=true
# inspect 🩺
kubectl describe deploy nginx-deployment
Name: nginx-deployment
Namespace: default
CreationTimestamp: Wed, 04 Nov 2026 21:14:35 +1100
Labels: app=nginx-deployment
Annotations: deployment.kubernetes.io/revision: 1
kubernetes.io/change-cause: kubectl scale deploy nginx-deployment --replicas=2 --record=true
Selector: app=nginx-deployment
Replicas: 2 desired | 2 updated | 2 total | 2 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=nginx-deployment
Containers:
nginx:
Image: nginx:1.20.2
Port: 80/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Node-Selectors: <none>
Tolerations: <none>
Conditions:
Type Status Reason
---- ------ ------
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: <none>
NewReplicaSet: nginx-deployment-b96f9488b (2/2 replicas created)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 44s deployment-controller Scaled up replica set nginx-deployment-b96f9488b to 3
Normal ScalingReplicaSet 18s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 2 from 3
# update the image
kubectl set image deploy nginx-deployment nginx=nginx:1.21.5 --record
Flag --record has been deprecated, --record will be removed in the future
deployment.apps/nginx-deployment image updated
# inspect the updated image 🩺
kubectl describe deploy nginx-deployment
Name: nginx-deployment
Namespace: default
CreationTimestamp: Wed, 04 Nov 2026 21:14:35 +1100
Labels: app=nginx-deployment
Annotations: deployment.kubernetes.io/revision: 2
kubernetes.io/change-cause: kubectl set image deploy nginx-deployment nginx=nginx:1.21.5 --record=true
Selector: app=nginx-deployment
Replicas: 2 desired | 2 updated | 2 total | 2 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=nginx-deployment
Containers:
nginx:
Image: nginx:1.21.5
Port: 80/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Node-Selectors: <none>
Tolerations: <none>
Conditions:
Type Status Reason
---- ------ ------
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: nginx-deployment-b96f9488b (0/0 replicas created)
NewReplicaSet: nginx-deployment-668594868d (2/2 replicas created)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 56s deployment-controller Scaled up replica set nginx-deployment-b96f9488b to 3
Normal ScalingReplicaSet 30s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 2 from 3
Normal ScalingReplicaSet 4s deployment-controller Scaled up replica set nginx-deployment-668594868d to 1
Normal ScalingReplicaSet 3s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 1 from 2
Normal ScalingReplicaSet 3s deployment-controller Scaled up replica set nginx-deployment-668594868d to 2 from 1
Normal ScalingReplicaSet 1s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 0 from 1
# Check the rollout status
kubectl rollout status deploy nginx-deployment
deployment "nginx-deployment" successfully rolled out
# view history ⌛️
kubectl rollout history deploy nginx-deployment
deployment.apps/nginx-deployment
REVISION CHANGE-CAUSE
1 kubectl scale deploy nginx-deployment --replicas=2 --record=true
2 kubectl set image deploy nginx-deployment nginx=nginx:1.21.5 --record=true
Rolling updates, Revisions and rollback
Each recorded state of a Deployment is a Revision. Changing the image from nginx:1.20.2 to nginx:1.21.5 creates ReplicaSet B, and the move from A to B is the rolling update. ReplicaSet A stays at 0 Pods, which preserves the earlier state and makes rollback possible.
flowchart LR
subgraph R1["Revision 1"]
A["ReplicaSet A\nnginx:1.20.2\n3 Pods"]
end
subgraph R2["Revision 2"]
B["ReplicaSet B\nnginx:1.21.5\n3 Pods"]
end
A -->|"Pod Template changed:\nrolling update"| B
B -.->|"rollout undo --to-revision=1"| A
B --> E["After the update:\nA = 0 Pods (kept as history)\nB = 3 Pods (active)"]
Only planned changes to the Pod Template create a new Revision:
flowchart TD
C["Change to a Deployment"] --> Q{"Pod Template\nproperty changed?"}
Q -->|"Yes: image, container port,\nvolumes, mounts"| Y["Rolling update runs\nNew Revision recorded"]
Q -->|"No: scaling, labeling"| N["No rolling update\nRevision number unchanged"]
Key points
- Deployments manage ReplicaSets, and ReplicaSets manage Pods.
--dry-run=client -o yamlis a quick way to get a starter manifest.- Old ReplicaSets scaled to 0 are what make rollback possible.
- Scaling and labeling don’t change the Revision number.
- Not from the course:
--recordis deprecated in current kubectl versions, though it still appears in these lessons. Using thekubernetes.io/change-causeannotation is the current way to record a change reason.
# List 📋️
kubectl get deploy,rs,po -l app=nginx-deployment -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
deployment.apps/nginx-deployment 2/2 2 2 2m26s nginx nginx:1.21.5 app=nginx-deployment
NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
replicaset.apps/nginx-deployment-668594868d 2 2 2 94s nginx nginx:1.21.5 app=nginx-deployment,pod-template-hash=668594868d
replicaset.apps/nginx-deployment-b96f9488b 0 0 0 2m26s nginx nginx:1.20.2 app=nginx-deployment,pod-template-hash=b96f9488b
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
pod/nginx-deployment-668594868d-6gwrf 1/1 Running 0 94s 10.244.3.59 rp4 <none> <none>
pod/nginx-deployment-668594868d-hcfnk 1/1 Running 0 93s 10.244.1.60 rp2 <none> <none>
# Rollback
kubectl rollout undo deploy nginx-deployment --to-revision=1
deployment.apps/nginx-deployment rolled back
# view history ⌛️
kubectl rollout history deploy nginx-deployment
deployment.apps/nginx-deployment
REVISION CHANGE-CAUSE
2 kubectl set image deploy nginx-deployment nginx=nginx:1.21.5 --record=true
3 kubectl scale deploy nginx-deployment --replicas=2 --record=true
# inspect the updated image 🩺
kubectl describe deploy nginx-deployment
Name: nginx-deployment
Namespace: default
CreationTimestamp: Wed, 04 Nov 2026 21:14:35 +1100
Labels: app=nginx-deployment
Annotations: deployment.kubernetes.io/revision: 3
kubernetes.io/change-cause: kubectl scale deploy nginx-deployment --replicas=2 --record=true
Selector: app=nginx-deployment
Replicas: 2 desired | 2 updated | 2 total | 2 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=nginx-deployment
Containers:
nginx:
Image: nginx:1.20.2
Port: 80/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Node-Selectors: <none>
Tolerations: <none>
Conditions:
Type Status Reason
---- ------ ------
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: nginx-deployment-668594868d (0/0 replicas created)
NewReplicaSet: nginx-deployment-b96f9488b (2/2 replicas created)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 2m47s deployment-controller Scaled up replica set nginx-deployment-b96f9488b to 3
Normal ScalingReplicaSet 2m21s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 2 from 3
Normal ScalingReplicaSet 115s deployment-controller Scaled up replica set nginx-deployment-668594868d to 1
Normal ScalingReplicaSet 114s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 1 from 2
Normal ScalingReplicaSet 114s deployment-controller Scaled up replica set nginx-deployment-668594868d to 2 from 1
Normal ScalingReplicaSet 112s deployment-controller Scaled down replica set nginx-deployment-b96f9488b to 0 from 1
Normal ScalingReplicaSet 10s deployment-controller Scaled up replica set nginx-deployment-b96f9488b to 1 from 0
Normal ScalingReplicaSet 9s deployment-controller Scaled down replica set nginx-deployment-668594868d to 1 from 2
Normal ScalingReplicaSet 8s deployment-controller Scaled up replica set nginx-deployment-b96f9488b to 2 from 1
Normal ScalingReplicaSet 7s deployment-controller (combined from similar events): Scaled down replica set nginx-deployment-668594868d to 0 from 1
# list by label 📋️
kubectl get all -l app=nginx-deployment -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
pod/nginx-deployment-b96f9488b-9q5st 1/1 Running 0 21s 10.244.2.70 rp3 <none> <none>
pod/nginx-deployment-b96f9488b-qwkwm 1/1 Running 0 23s 10.244.3.60 rp4 <none> <none>
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
deployment.apps/nginx-deployment 2/2 2 2 3m nginx nginx:1.20.2 app=nginx-deployment
NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
replicaset.apps/nginx-deployment-668594868d 0 0 0 2m8s nginx nginx:1.21.5 app=nginx-deployment,pod-template-hash=668594868d
replicaset.apps/nginx-deployment-b96f9488b 2 2 2 3m nginx nginx:1.20.2 app=nginx-deployment,pod-template-hash=b96f9488b
or run the following command:
# Delete ❌️
kubectl delete deploy nginx-deployment
deployment.apps "nginx-deployment" deleted
# list by label and verify 📋️
kubectl get all -l app=nginx-deployment -o wide
No resources found in default namespace.
Applications
- Deployment: standard resource for running applications in a scalable environment
- StatefulSet: an alternative to the Deployment for services such as databases
- DaemonSet: is an alternative to the Deployment that ensures that one application instance is started on each cluster node
- job: Single shot applications
- CronJob: is used for scheduled applications
Web application test
To test if you can reach your cluster applications from your Mac’s browser, we will create a NodePort service. While a ClusterIP is only reachable inside the cluster, a NodePort opens a specific port (usually 30000–32767) on your machine’s physical IP (192.168.1.11).
Create the deployment:
kubectl create deployment web-test --image=nginx
deployment.apps/web-test created
Expose it as a NodePort service
kubectl expose deployment web-test --type=NodePort --port=80
service/web-test exposed
Find the Assigned Port
Kubernetes will pick a random port in the 30000+ range. You need to find out which one it chose.
Look for the PORT(S) column. It will look like 80:30495/TCP. In this example, 30495 is the port you need.
kubectl get service web-test
Error from server (NotFound): services "web-test" not found
Now you can access the nginx server using the http://192.168.1.11:30495/ url.
Once you’ve verified you can see the “Welcome to nginx” page, you can delete the test resources:
# delete the service ❌️
kubectl delete service web-test
service "web-test" deleted
# delete the deployment ❌️
kubectl delete deployment web-test
deployment.apps "web-test" deleted
YAML manifest for a more complex application
To show how multiple components interact within your cluster, we will create a Guestbook-style setup: a Redis database (backend) and an Nginx web server (frontend).
This will demonstrate Service Discovery, as the frontend will find the backend using only the Service name.
Step 1: Create the Backend (Redis)
We will create a Deployment for Redis and a ClusterIP service. Because this is an internal database, we do not want to expose it to the outside world.
# Create the Redis Deployment
kubectl create deployment redis-db --image=redis
deployment.apps/redis-db created
# Expose it internally
kubectl expose deployment redis-db --port=6379 --target-port=6379
service/redis-db exposed
Step 2: Create the Frontend (Nginx)
Next, we create the web server. We will use a custom configuration (via a one-liner here) to prove it can “see” the Redis service.
# Create the Frontend Deployment
kubectl create deployment web-frontend --image=nginx
deployment.apps/web-frontend created
Here the specification of the above deployment:
kubectl get deploy web-frontend -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
deployment.kubernetes.io/revision: "1"
creationTimestamp: "2026-01-03T10:27:32Z"
generation: 2
labels:
app: web-frontend
name: web-frontend
namespace: default
resourceVersion: "296943"
uid: 882765a3-0a17-4008-bf65-866c20f2f52f
spec:
progressDeadlineSeconds: 600
replicas: 3
revisionHistoryLimit: 10
selector:
matchLabels:
app: web-frontend
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
creationTimestamp: null
labels:
app: web-frontend
spec:
containers:
- image: nginx
imagePullPolicy: Always
name: nginx
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
status:
availableReplicas: 3
conditions:
- lastTransitionTime: "2026-01-03T10:27:32Z"
lastUpdateTime: "2026-01-03T10:27:35Z"
message: ReplicaSet "web-frontend-5f4cd56f87" has successfully progressed.
reason: NewReplicaSetAvailable
status: "True"
type: Progressing
- lastTransitionTime: "2026-01-07T03:20:42Z"
lastUpdateTime: "2026-01-07T03:20:42Z"
message: Deployment has minimum availability.
reason: MinimumReplicasAvailable
status: "True"
type: Available
observedGeneration: 2
readyReplicas: 3
replicas: 3
updatedReplicas: 3
# Expose it via NodePort so you can see it in your browser
kubectl expose deployment web-frontend --type=NodePort --port=80
service/web-frontend exposed
Step 3: Verify Internal Communication
This is the “Magic” of kube-proxy and CoreDNS. The web-frontend pod can reach the redis-db pod simply by using the name redis-db.
Run this to test the connection from frontend to backend:
# Use your proxy to run a 'redis-cli' ping from inside the nginx pod
kubectl exec deployments/web-frontend -- apt-get update -y
Get:1 http://deb.debian.org/debian trixie InRelease [140 kB]
Get:2 http://deb.debian.org/debian trixie-updates InRelease [47.3 kB]
Get:3 http://deb.debian.org/debian-security trixie-security InRelease [43.4 kB]
Get:4 http://deb.debian.org/debian trixie/main arm64 Packages [9607 kB]
Get:5 http://deb.debian.org/debian trixie-updates/main arm64 Packages [5404 B]
Get:6 http://deb.debian.org/debian-security trixie-security/main arm64 Packages [94.3 kB]
Fetched 9938 kB in 1s (6831 kB/s)
Reading package lists...
install redis tools
kubectl exec deployments/web-frontend -- apt-get install redis-tools -y
Reading package lists...
Building dependency tree...
Reading state information...
The following additional packages will be installed:
adduser libatomic1 libjemalloc2 liblzf1
Suggested packages:
liblocale-gettext-perl perl cron quota ruby-redis
The following NEW packages will be installed:
adduser libatomic1 libjemalloc2 liblzf1 redis-tools
0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.
Need to get 1576 kB of archives.
After this operation, 8629 kB of additional disk space will be used.
Get:1 http://deb.debian.org/debian trixie/main arm64 adduser all 3.152 [191 kB]
Get:2 http://deb.debian.org/debian trixie/main arm64 libatomic1 arm64 14.2.0-19 [10.1 kB]
Get:3 http://deb.debian.org/debian trixie/main arm64 libjemalloc2 arm64 5.3.0-3 [216 kB]
Get:4 http://deb.debian.org/debian trixie/main arm64 liblzf1 arm64 3.6-4+b3 [10.1 kB]
Get:5 http://deb.debian.org/debian trixie/main arm64 redis-tools arm64 5:8.0.2-3+deb13u1 [1148 kB]
debconf: unable to initialize frontend: Dialog
debconf: (TERM is not set, so the dialog frontend is not usable.)
debconf: falling back to frontend: Readline
debconf: unable to initialize frontend: Readline
debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC entries checked: /etc/perl /usr/local/lib/aarch64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/aarch64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/aarch64-linux-gnu/perl-base /usr/lib/aarch64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 8, <STDIN> line 5.)
debconf: falling back to frontend: Teletype
debconf: unable to initialize frontend: Teletype
debconf: (This frontend requires a controlling tty.)
debconf: falling back to frontend: Noninteractive
Fetched 1576 kB in 0s (20.7 MB/s)
Selecting previously unselected package adduser.
(Reading database ... 6697 files and directories currently installed.)
Preparing to unpack .../archives/adduser_3.152_all.deb ...
Unpacking adduser (3.152) ...
Setting up adduser (3.152) ...
Selecting previously unselected package libatomic1:arm64.
(Reading database ... 6782 files and directories currently installed.)
Preparing to unpack .../libatomic1_14.2.0-19_arm64.deb ...
Unpacking libatomic1:arm64 (14.2.0-19) ...
Selecting previously unselected package libjemalloc2:arm64.
Preparing to unpack .../libjemalloc2_5.3.0-3_arm64.deb ...
Unpacking libjemalloc2:arm64 (5.3.0-3) ...
Selecting previously unselected package liblzf1:arm64.
Preparing to unpack .../liblzf1_3.6-4+b3_arm64.deb ...
Unpacking liblzf1:arm64 (3.6-4+b3) ...
Selecting previously unselected package redis-tools.
Preparing to unpack .../redis-tools_5%3a8.0.2-3+deb13u1_arm64.deb ...
Unpacking redis-tools (5:8.0.2-3+deb13u1) ...
Setting up libjemalloc2:arm64 (5.3.0-3) ...
Setting up liblzf1:arm64 (3.6-4+b3) ...
Setting up libatomic1:arm64 (14.2.0-19) ...
Setting up redis-tools (5:8.0.2-3+deb13u1) ...
Processing triggers for libc-bin (2.41-12) ...
# Now ping the service name :-)
kubectl exec deployments/web-frontend -- redis-cli -h redis-db ping
PONG
If it returns PONG, your cluster’s internal networking is working correctly!
Step 4: Map the Network Architecture
To help you visualise what we just built, here is how the traffic flows:
| Component | Service Type | Reachable From | Internal DNS |
|---|---|---|---|
| Redis | ClusterIP |
Only inside cluster | redis-db.default.svc.cluster.local |
| Nginx | NodePort |
Everywhere (via Node IP) | web-frontend.default.svc.cluster.local |
Step 5: View the Results
Find the NodePort for your frontend:
kubectl get svc web-frontend
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web-frontend NodePort 10.102.205.31 <none> 80:31804/TCP 8m5s
Access via http://192.168.1.11:31804/ in the browser. You will see the Nginx welcome page, knowing that “under the hood,” this pod is successfully linked to your Redis database.
Summary Checklist
- DNS:
CoreDNSresolved the nameredis-db. - Routing:
kube-proxysent the traffic to the correct Redis pod. - Access:
NodePortallowed your Mac to enter the cluster.
Scaling your deployment is one of the best ways to see kube-proxy and the Service abstraction in action. When you scale up, Kubernetes creates new Pods, and kube-proxy immediately updates the network rules on your remote machine to include these new “endpoints.”
Step 5: Scale the Frontend
Run this command to increase the number of Nginx instances running pods from 1 to 3 in a ReplicaSet or deployment manually:
kubectl scale deployment web-frontend --replicas=3
deployment.apps/web-frontend scaled
To scale the number of nodes in a cluster, add the node using
kubeadm join.
Now we have deployed:
kubectl get deploy
NAME READY UP-TO-DATE AVAILABLE AGE
redis-db 1/1 1 1 4d1h
web-frontend 3/3 3 3 4d1h
- web-front in three pods
- redis-db in one pod
You can use
kubectl edit deploy web-frontendto edit the yaml and deploy again. The latest should be 2 in thekubectl get deploy web-frontendoutput.
Step 6: Observe the Load Balancer Endpoints
When you hit a Service, Kubernetes doesn’t just pick a Pod; it picks an Endpoint. You can watch the list of Pod IPs assigned to your service grow:
kubectl get endpoints web-frontend
NAME ENDPOINTS AGE
web-frontend 10.244.1.20:80,10.244.2.20:80,10.244.3.23:80 13m
Step 7: Verify the Load Balancing
To see load balancing in action without a browser, we can run a loop that repeatedly hits the Service.
# Get the NodePort again
NODEPORT=$(kubectl get svc web-frontend -o jsonpath='{.spec.ports[0].nodePort}')
# Hit the service 10 times and look at the logs of the pods
for i in {1..10}; do
curl -s -I http://192.168.1.11:$NODEPORT | grep "Server"
done
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
Server: nginx/1.29.4
How to see which Pod handled the request:
If you want to prove that different pods are answering, you can check the logs of all pods with the app=web-frontend label simultaneously:
kubectl logs -l app=web-frontend --tail=1
10.244.0.0 - - [03/Jan/2026:10:44:07 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.7.1" "-"
10.244.0.0 - - [03/Jan/2026:10:44:07 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.7.1" "-"
10.244.0.0 - - [03/Jan/2026:10:44:07 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.7.1" "-"
High-Level View of the Traffic Flow
- Your Mac sends a request to
192.168.1.11:NODEPORT. - The Linux Kernel (via rules set by
kube-proxy) intercepts the packet. - The Load Balancer (iptables/IPVS) chooses one of the 3 Pod IPs at random (or via round-robin).
- The Packet is forwarded to the specific Pod on whichever node it lives.
Summary of Scaling Benefits
- High Availability: If one Pod crashes,
kube-proxyremoves its IP from the endpoints list instantly, so users don’t hit a “dead” link. - Seamless Updates: You can update your app version, and Kubernetes will swap Pods in the background while the Service IP stays the same.
Debug the application environment
Identify the Pod Name
Since we scaled the web-frontend to 3 replicas, we first need to pick one of the pod names.
kubectl get pods -l app=web-frontend
NAME READY STATUS RESTARTS AGE
web-frontend-5f4cd56f87-t5srs 1/1 Running 6 (5h28m ago) 302d
web-frontend-5f4cd56f87-vvbd4 1/1 Running 6 (5h28m ago) 302d
You can run a single command (like hostname or ls) without entering the pod fully.
# Example: Check the hostname of the specific pod
kubectl exec web-frontend-5f4cd56f87-t5srs -- hostname
web-frontend-5f4cd56f87-t5srs
Check Environment Variables:
kubectl exec web-frontend-5f4cd56f87-t5srs -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=web-frontend-5f4cd56f87-t5srs
NGINX_VERSION=1.29.4
NJS_VERSION=0.9.4
NJS_RELEASE=1~trixie
PKG_RELEASE=1~trixie
DYNPKG_RELEASE=1~trixie
KUBERNETES_SERVICE_PORT_HTTPS=443
REDIS_DB_PORT_6379_TCP=tcp://10.97.28.65:6379
REDIS_DB_PORT_6379_TCP_PORT=6379
REDIS_DB_PORT_6379_TCP_ADDR=10.97.28.65
REDIS_DB_PORT=tcp://10.97.28.65:6379
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
REDIS_DB_SERVICE_HOST=10.97.28.65
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
REDIS_DB_SERVICE_PORT=6379
REDIS_DB_PORT_6379_TCP_PROTO=tcp
KUBERNETES_SERVICE_PORT=443
HOME=/root
Check Disk Space:
kubectl exec web-frontend-5f4cd56f87-t5srs -- df -h
Filesystem Size Used Avail Use% Mounted on
overlay 117G 6.2G 106G 6% /
tmpfs 64M 0 64M 0% /dev
/dev/mmcblk0p2 117G 6.2G 106G 6% /etc/hosts
shm 64M 0 64M 0% /dev/shm
tmpfs 7.7G 12K 7.7G 1% /run/secrets/kubernetes.io/serviceaccount
tmpfs 3.9G 0 3.9G 0% /proc/asound
tmpfs 3.9G 0 3.9G 0% /proc/scsi
tmpfs 3.9G 0 3.9G 0% /sys/firmware
If you need to explore the pod manually, you can open an interactive terminal (-it). Note that in a Jupyter Bash cell, this might feel a bit laggy, but it works for quick checks.
kubectl exec -it web-frontend-5f4cd56f87-t5srs -- /bin/bash
Because you are using containerd, you cannot easily use docker exec on the host machine anymore. Using kubectl exec is the “K8s-native” way to bridge that gap. Behind the scenes:
- Your request goes to the API Server.
- The API Server talks to the Kubelet on the remote node.
- The Kubelet tells containerd to start a process inside the container namespaces.
kubectl exec $(kubectl get pods -l app=web-frontend -o name | head -n 1) -- ls /usr/share/nginx/html
50x.html
index.html
To copy files from your local macOS environment (or your Jupyter instance) directly into a running container on your remote cluster, we use the kubectl cp command. This is incredibly useful for updating a config file, uploading a script, or adding static assets to a web server without rebuilding the entire image.
Prepare a Local File First, let’s create a simple HTML file:
'echo "<html>
<body style=\"background-color: #f0f0f0; font-family: sans-serif;\">
<h1>Hello from Jupyter!</h1>
<p>This file was copied from my Mac to the remote K8s cluster.</p>
</body>
</html>" > index.html'
POD_NAME=$(kubectl get pods -l app=web-frontend -o name | head -n 1)
echo "Targeting: $POD_NAME"
Targeting: pod/web-frontend-5f4cd56f87-t5srs
The syntax is kubectl cp <local-path> <pod-name>:<remote-path>. Note that for Nginx, the default web directory is /usr/share/nginx/html/.
kubectl cp index.html ${POD_NAME#pod}:/usr/share/nginx/html/index.html
Now, hit that specific pod’s IP or use your browser to see the update. If you visit your 192.168.1.11:<NODEPORT>, you might need to refresh a few times because only one of the three replicas has the new file (since we only copied it to one pod).
To see it instantly via the command line:
kubectl exec $POD_NAME -- cat /usr/share/nginx/html/index.html
<html>
<body style="background-color: #f0f0f0; font-family: sans-serif;">
<h1>Hello from Jupyter!</h1>
<p>This file was copied from my Mac to the remote K8s cluster.</p>
</body>
</html>
Important Considerations for kubectl cp
- Tar Dependency: For
kubectl cpto work, thetarbinary must be installed inside the container image. Most standard images (like Nginx) have it, but “distroless” or very slim images might not. - Ephemeral Nature: Remember that if this pod restarts or is deleted, the file you copied will be lost. For permanent changes, you should use ConfigMaps or update the Docker image.
- Permissions: You must have write permissions to the destination folder inside the container.