Skip to content
DBDeependra Bhatta~/notes
Kubernetes#kubectl · #virtualbox · #kubernetes · #minikube

Kubernetes with minikube: Pods, Deployments and Services

Set up a local Kubernetes cluster with minikube, then create pods, ReplicaSets and Deployments from YAML, roll updates forward and back, and expose nginx with Services.

· updated · 16 min read
ON THIS PAGE

A local cluster is the cheapest place to learn Kubernetes, and the same kubectl commands and YAML files carry over to kubeadm and Amazon EKS. This guide sets up minikube on Ubuntu and works through the core objects: pods, ReplicaSets, Deployments, Services and namespaces. By the end, you will write a Deployment, roll it forward and back, and reach it in a browser through a Service.

Prerequisites

  • An Ubuntu machine (x86-64) with VirtualBox or Docker installed for the minikube driver.
  • The cluster components from part 1, which this guide refers to.

Install minikube

minikube runs a whole cluster (control plane and node in one) on your machine. Download links for every OS are on the minikube start page. On Linux x86-64:

terminal
$ curl -LO https://github.com/kubernetes/minikube/releases/latest/download/minikube-linux-amd64
$ sudo install minikube-linux-amd64 /usr/local/bin/minikube && rm minikube-linux-amd64

Choose a driver

A driver decides where the cluster runs: inside a VM (VirtualBox, KVM) or inside a container (Docker). You can pass it once or make it the default. See the driver list.

terminal
$ minikube start --driver=virtualbox
$ minikube config set driver virtualbox

If you do not pass a driver, minikube picks one automatically from what is installed on your machine. The cluster in this guide runs on the VirtualBox driver, which is why the node IP below is 192.168.59.101 (a VirtualBox host-only network). The Docker driver also works:

Terminal running minikube config set driver docker and minikube start, showing minikube v1.36.0 on Ubuntu 24.04

The driver change only takes effect after minikube delete and a new minikube start.

Useful minikube commands

terminal
$ minikube status
$ minikube start
$ minikube dashboard

minikube dashboard enables the web UI and opens it in your browser:

Kubernetes Dashboard in the default namespace with the Workloads menu listing Deployments, Pods and Replica Sets

Next, install kubectl by following the official Linux guide.

kubectl basics

kubectl (often said "kube control") is only a client. It does not create or run a cluster. It reads ~/.kube/config to find a cluster and sends requests to that cluster's API server. The same commands work on minikube, EKS, AKS or any other cluster.

terminal
$ kubectl get nodes
$ kubectl cluster-info
Kubernetes control plane is running at https://192.168.59.101:8443
CoreDNS is running at https://192.168.59.101:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
$ kubectl get pods
$ kubectl get pods -n kube-system
$ kubectl get namespaces

kubectl get namespaces output listing default, kube-node-lease, kube-public, kube-system and kubernetes-dashboard

kubectl get pods shows only the default namespace. The cluster's own pods (CoreDNS, kube-proxy and so on) are in kube-system.

Run a pod with one command

The fastest way to start a pod is an imperative ("ad hoc") command:

terminal
$ kubectl run mynginx --image=nginx --port=80
$ kubectl get pods
$ kubectl describe pod mynginx
$ kubectl delete pod mynginx

kubectl describe is the Kubernetes version of docker inspect. It shows the node, IP, labels, status and, at the bottom, the events:

kubectl describe pod output showing node minikube/192.168.59.101, label run=pod, status Running and pod IP 10.244.0.8

Add -o wide to see the pod IP and the node it runs on:

kubectl get pods -o wide showing pod myapp Running with IP 10.244.0.14 on node minikube

Imperative commands suit testing. For anything you intend to keep, write YAML.

Kubernetes objects

Objects are records in the cluster that describe the desired state. The objects used in this series:

ObjectPurpose
PodThe smallest unit Kubernetes runs: one or more containers sharing network and storage.
ReplicaSetKeeps a fixed number of identical pods running.
DeploymentManages ReplicaSets and adds rolling updates and rollbacks. Use this for stateless apps.
ServiceA stable IP and DNS name in front of a changing group of pods.
StatefulSetLike a Deployment, but each pod keeps a stable name and its own storage (databases).
DaemonSetRuns one copy of a pod on every node (log or monitoring agents).
ConfigMap and SecretConfiguration and sensitive data kept outside the image.
NamespaceDivides one cluster into isolated groups of resources.
VolumeStorage attached to a pod.

Pods

Most pods run one container. Kubernetes manages the pod, not the container directly, so think of the pod as a thin wrapper.

Some pods run several containers that must work together, for example the main app plus a helper (a sidecar) that ships its logs. Containers in the same pod share the network (they reach each other on localhost) and can share volumes. A pod can also have init containers, which run to completion before the app containers start.

Pods are mortal. They get a new name and a new IP every time they are recreated. For that reason, production workloads rarely use bare pods. A Deployment creates the pods, and a Service sits in front of them.

Write a manifest

Every Kubernetes YAML file has four top-level fields:

FieldMeaning
apiVersionThe API group and version of the object: v1 for Pod and Service, apps/v1 for ReplicaSet and Deployment.
kindThe type of object. The apiVersion must match it.
metadataThe object's name, its labels (key-value pairs you choose) and optional annotations.
specThe desired state: images, ports, replicas, arguments and so on.

To look up the version for a kind, query the cluster:

terminal
$ kubectl explain pod
$ kubectl explain deployment
$ kubectl explain configmap

kubectl explain pod output showing KIND Pod and VERSION v1 followed by field descriptions

A minimal pod manifest:

YMLpod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: myapp
  labels:
    owner: frontend
    app: myapp
spec:
  containers:
    - name: mynginx-app
      image: nginx
      ports:
        - containerPort: 80
terminal
$ kubectl create -f pod.yaml
$ kubectl get -f pod.yaml
$ kubectl delete -f pod.yaml

create or apply

kubectl create -fkubectl apply -f
Creates the object. Fails with "already exists" if you run it again.Creates the object if it is missing, or updates it to match the file.
Imperative: "make this".Declarative: "make the cluster look like this file".

Use kubectl apply for both the first run and every later change.

ReplicationController and ReplicaSet

A ReplicationController keeps a set number of pod copies running: it starts more if there are too few and removes extras if there are too many. It is the legacy way. A ReplicaSet does the same job with more flexible label selectors, and in practice you do not create either one directly: you create a Deployment, which manages ReplicaSets for you. Writing a ReplicaSet once shows what a Deployment does underneath.

A ReplicaSet needs three fields:

  • replicas: how many pods to keep.
  • selector: which labels identify "my" pods.
  • template: what a new pod should look like.

The template is the reason it can self-heal. If one of five pods dies, the ReplicaSet must create a replacement, and the template tells it which image, labels and ports to use.

YMLreplicaset.yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: myappreplicaset
  labels:
    app: myappnew
    type: dev
    owner: dipendra
spec:
  replicas: 5
  selector:
    matchLabels:
      owner: dipendra   # manage every pod with this label
  template:
    metadata:
      labels:           # must match the selector
        owner: dipendra
        app: myappnew
        type: dev
    spec:
      containers:
        - name: mynewnginxapp
          image: nginx
          ports:
            - containerPort: 80
terminal
$ kubectl create -f replicaset.yaml
$ kubectl get rs
$ kubectl describe rs myappreplicaset
$ kubectl delete -f replicaset.yaml

kubectl create -f replicaset.yaml then kubectl get rs and get pods, showing the myapp pod plus four new myappreplicaset pods

The manifest asks for 5 replicas, but only 4 new pods appeared. The existing myapp pod had no owner and its labels matched the selector owner: dipendra, so the ReplicaSet adopted it and counted it as the fifth pod. Selectors match labels, not names. Keep selectors specific, or a controller will take over pods you did not mean to give it.

Scaling

The clean way to scale is to change replicas: in the file and run kubectl apply -f replicaset.yaml. Because the ReplicaSet was first created with kubectl create, apply prints this warning:

kubectl apply warning that the replicaset is missing the last-applied-configuration annotation, which will be patched automatically

kubectl apply stores the last applied file in an annotation. Objects made with create do not have it, so apply adds it the first time. The warning is harmless, and it goes away if you use apply from the start.

For quick tests you can also scale or edit from the command line:

terminal
$ kubectl scale --replicas=9 replicaset myappreplicaset
replicaset.apps/myappreplicaset scaled
$ kubectl scale --replicas=9 -f replicaset.yaml
$ kubectl edit replicaset myappreplicaset
replicaset.apps/myappreplicaset edited

Deployments

A Deployment sits on top of ReplicaSets. You declare the desired state and the Deployment controller moves the cluster there at a controlled rate. It adds what a ReplicaSet lacks: rolling updates, rollout history, rollback, and pause and resume. When you use a Deployment you do not write a ReplicaSet yourself.

The manifest matches the ReplicaSet except for the kind and the name:

YMLdeployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-deployment
  labels:
    app: myappnew
    type: dev
    owner: dipendra
spec:
  replicas: 5
  selector:
    matchLabels:
      owner: dipendra
  template:
    metadata:
      labels:
        owner: dipendra
        app: myappnew
        type: dev
    spec:
      containers:
        - name: mynewnginxapp
          image: nginx
          ports:
            - containerPort: 80
terminal
$ kubectl apply -f deployment.yaml
$ kubectl get deployments
$ kubectl describe deployment myapp-deployment
$ kubectl delete -f deployment.yaml

kubectl describe deployment showing selector owner=dipendra, 5 replicas, RollingUpdate strategy and the pod template

Key fields in this output:

  • StrategyType: RollingUpdate with 25% max unavailable, 25% max surge is the default update strategy.
  • NewReplicaSet: myappreplicaset. The ReplicaSet from the previous section was still running with the same selector and the same pod template, so the Deployment adopted it instead of creating a new one. Normally the ReplicaSet is named <deployment>-<hash>, which appears after the first update.
  • Tolerations and Node-Selectors control which nodes the pods may run on. Part 3 covers taints, tolerations and node affinity.

Get a shell in a pod

kubectl exec works like docker exec:

terminal
$ kubectl exec --stdin --tty myappreplicaset-2jxm8 -- /bin/bash

kubectl exec into a pod, then cat /etc/os-release showing Debian 12 bookworm and nginx -v showing nginx 1.29.0

Everything after -- is the command to run in the container. You can also run a single command without opening a shell:

terminal
$ kubectl exec myappreplicaset-2jxm8 -- nginx -v
nginx version: nginx/1.29.0

Roll out an update

To practice updates, pin the image to a version in deployment.yaml:

YMLdeployment.yaml
          image: nginx:1.28.0

Delete the Deployment, apply it again and check the rollout:

terminal
$ kubectl delete -f deployment.yaml
$ kubectl apply -f deployment.yaml
$ kubectl rollout status deployment myapp-deployment
deployment "myapp-deployment" successfully rolled out
$ kubectl rollout history deployment myapp-deployment

kubectl rollout history for myapp-deployment showing only revision 1 with CHANGE-CAUSE none

History shows only revision 1, because deleting a Deployment also deletes its history. To keep history, change the file and apply it without deleting.

Rolling update strategy

With a rolling update, Kubernetes replaces pods a few at a time and only sends traffic to pods that are ready, so users see no downtime. 25% max unavailable means at most a quarter of the desired pods can be down at once. 25% max surge means it can run up to a quarter extra pods during the update. This is the default and fits most stateless apps.

To watch a rolling update, change the image in the file again (the file stays the source of truth) and apply it:

±changes.diff+1−1
-          image: nginx:1.28.0
+          image: nginx:1.29.0
terminal
$ kubectl apply -f deployment.yaml
$ kubectl rollout status deployment myapp-deployment

kubectl rollout status showing new replicas being updated and old replicas pending termination until successfully rolled out

The events in kubectl describe deployment myapp-deployment show the mechanism. A new ReplicaSet scales up one step while the old one scales down one step, until the old one reaches 0:

Deployment events showing the new ReplicaSet scaling up from 0 to 5 while the old ReplicaSet scales down from 5 to 0

Roll back

terminal
$ kubectl rollout undo deployment myapp-deployment
deployment.apps/myapp-deployment rolled back
$ kubectl rollout history deployment myapp-deployment

kubectl rollout undo printing rolled back, then rollout history showing revisions 2 and 3

The rollback becomes a new revision (3), and the image is back to the previous version:

Pod template in describe output showing image nginx:1.28.0 after the rollback

Recreate strategy

The other strategy, Recreate, stops all old pods first and then starts the new ones. That means downtime, so use it only when two versions must never run at the same time (for example, a database schema change the old version cannot handle).

YMLdeployment.yaml
spec:
  strategy:
    type: Recreate

Services

Port-forwarding to a single pod is not a stable way to reach an app, because pods are mortal. A Deployment creates and deletes pods all the time, and each new pod gets a new IP. A Service gives that changing group of pods one stable IP and DNS name. It finds its pods with a label selector, the same way a ReplicaSet does.

TypeReachable fromTypical use
ClusterIP (default)Inside the cluster onlyInternal traffic: frontend to backend, Tomcat to MySQL.
NodePort<any node IP>:<port 30000-32767>Testing, or behind your own load balancer. Like docker run -p.
LoadBalancerA cloud load balancer's addressPublic apps on AWS, Azure or GCP.
ExternalNameInside the clusterA DNS CNAME alias to an outside hostname. No proxying.

For HTTP routing of many apps behind one address, look at Ingress or the Gateway API.

NodePort

YMLservice.yaml
apiVersion: v1
kind: Service
metadata:
  name: service
spec:
  type: NodePort
  selector:
    owner: dipendra   # send traffic to pods with this label
  ports:
    - port: 80          # the port the Service listens on
      targetPort: 80    # the port the app listens on inside the pod (containerPort)
      nodePort: 30007   # the port opened on every node (30000-32767)

The manifest defines three ports, one each for the Service, the pod and the node. Only port is required. targetPort defaults to the same value as port, and nodePort is picked at random if you leave it out.

terminal
$ kubectl apply -f service.yaml
$ kubectl get services

kubectl get services showing the service of type NodePort with ports 80:30007/TCP

With the nodePort line removed and the Service renamed to svc, Kubernetes assigns a random node port:

kubectl get svc showing svc of type NodePort with a random port 80:30287/TCP

minikube can print the URL for a Service:

terminal
$ minikube service svc --url
http://192.168.59.101:30287

minikube service svc --url printing http://192.168.59.101:30287

Opening that URL shows nginx, served by one of the Deployment's pods. The port in the address is the node port:

Browser at 192.168.59.101:30287 showing the Welcome to nginx page

ClusterIP

Change only the type and apply again:

YMLservice.yaml
spec:
  type: ClusterIP   # internal only: frontend to backend, backend to database

kubectl get services showing svc changed to type ClusterIP with port 80/TCP and no node port

LoadBalancer

YMLservice.yaml
spec:
  type: LoadBalancer   # reachable from outside through a load balancer

kubectl get services showing svc of type LoadBalancer with EXTERNAL-IP pending and ports 80:30115/TCP

EXTERNAL-IP stays <pending> because minikube has no cloud provider to create a load balancer. A LoadBalancer Service also gets a node port, so it still works through the node IP. On minikube, running minikube tunnel in another terminal assigns an external IP. On EKS, AWS creates a real load balancer, as shown in part 3.

Namespaces

Namespaces isolate groups of resources inside one cluster, for example dev and prod, or one namespace per team. Names only need to be unique within a namespace, and you can attach quotas and RBAC rules to a namespace.

kubectl get namespaces listing default, kube-node-lease, kube-public, kube-system and kubernetes-dashboard, all Active

terminal
$ kubectl create namespace dev
$ kubectl get all
$ kubectl get all -n dev
$ kubectl get all --all-namespaces
$ kubectl get services -n dev

The kubeconfig file

kubectl finds clusters through ~/.kube/config, the kubeconfig file. minikube start writes it for you:

terminal
$ cat ~/.kube/config

kubeconfig showing a minikube cluster at https://192.168.59.101:8443, a minikube context and a user with client certificate paths

It has three lists: clusters (API server address and CA certificate), users (credentials) and contexts (a cluster plus a user plus a default namespace). current-context is the one kubectl uses now. Switching between minikube and EKS is a context change (kubectl config use-context <name>).

Troubleshooting a pod

When a pod misbehaves, work through these checks in order:

  1. kubectl get pod <pod> to see the status (Pending, CrashLoopBackOff, ImagePullBackOff).
  2. kubectl describe pod <pod> and read the Events at the bottom: scheduling failures, image pull errors, failed probes.
  3. kubectl logs <pod> (add --previous for the last crashed container).
  4. kubectl get events --sort-by=.metadata.creationTimestamp for the namespace timeline.
  5. Check the YAML: image name and tag, ports, labels that match the Service selector.
  6. kubectl exec -it <pod> -- sh to test DNS and network connections from inside.
  7. kubectl top pod <pod> for CPU and memory (needs metrics-server).
  8. kubectl rollout restart deployment/<name> once the cause is fixed.

Key takeaways

  • minikube gives you a full cluster on a laptop. Pick the driver with --driver and it writes your kubeconfig for you.
  • Write YAML and use kubectl apply. Keep imperative commands for experiments.
  • ReplicationController is legacy. Use a Deployment, which manages ReplicaSets for you.
  • Selectors match labels, so a controller can adopt pods or ReplicaSets you did not expect. Keep labels specific.
  • Rolling update is the default: no downtime, and kubectl rollout undo brings back the previous version.
  • Pods come and go, so put a Service in front of them: ClusterIP inside the cluster, NodePort for tests, LoadBalancer on a cloud.

Next in this series: Kubernetes Cluster Setup with kubeadm and Amazon EKS.