Docker basics · Lesson 8 of 8 · about 35 minutes

From Docker to Kubernetes

Share your image through a registry, build it for the right kind of processor, run it on a Kubernetes cluster on your laptop, and see how every Docker idea maps to Kubernetes.

By the end you'll know

  • How an image gets from your laptop to a server, through a registry
  • How to build an image for a different kind of processor, and fix exec format error
  • How to run your own image on a Kubernetes cluster on your laptop
  • How each Docker idea in this track has a Kubernetes equivalent

The last step

Everything so far has run on your laptop. Real apps run on servers, often many of them, managed by Kubernetes: a system that runs containers across a group of machines and keeps them running. Kubernetes doesn't build images. It runs the images you give it, so the question is how your image gets there, and what changes when it does.

The answer has two parts. Your image travels through a registry (Lesson 3), and the docker run flags you've learned become entries in Kubernetes files. By the end of this lesson, the app you built in Lessons 4 to 7 will be running on Kubernetes.

Think of moving from a home kitchen to a restaurant chain. At home, you cook each dish yourself (that's docker run). A chain writes the recipe down once, ships ingredients from a central warehouse to every location (that's the registry), and has managers at each restaurant who keep every station staffed, replace a cook who leaves, and open more stations when it's busy (that's Kubernetes). The dish is the same. Who keeps it coming is what changes.
How an image gets to a clusterOn your laptop, docker build turns the Dockerfile into an image. docker push sends the image to a registry such as Docker Hub or GitHub Container Registry. A Kubernetes cluster's nodes pull the image from the registry and run it as pods. For a cluster on your own laptop, kind load copies the image straight into the cluster, skipping the registry.Your laptopDockerfiledocker buildImagehello-app:3.0docker pushRegistryDocker Hub, GitHubContainer Registry...pullKubernetes clusterPodPodPodkind load: a shortcut for a cluster on your own laptopKubernetes never builds images. It pulls the ones you've pushed, and runs them.
Build on your laptop, push to a registry, and every node in a cluster can pull it. A cluster on your own laptop can skip the registry with kind load.

Try it: push your image to a registry

This part is optional: it needs a free Docker Hub (opens in a new tab) account. If you'd rather skip it, go straight to running on Kubernetes below, which works without one.

Log in, then set a variable with your Docker ID (your Docker Hub username), so the next commands use it:

BASH
docker login
DOCKER_ID=yourname

Replace yourname with your own Docker ID. To push an image, its name must include where it's going (Lesson 3): your Docker ID as the owner. docker tag adds a second name to an image you already have, without copying it:

BASH
docker tag hello-app:3.0 "$DOCKER_ID/hello-app:3.0"
docker push "$DOCKER_ID/hello-app:3.0"

Docker uploads each layer. Push again and it skips them all ("Layer already exists"): the same layer sharing you saw in Lesson 3. Your image is now on Docker Hub, and any machine in the world can docker pull it.

Two things to know before you push anything real:

  • Images on free Docker Hub accounts are public by default. Anyone can download them and look inside. That's why Lesson 7 kept secrets out of the image: once pushed, assume everyone can see every layer.
  • For private images, clusters need permission to pull. In Kubernetes that's a Secret holding your registry login, referred to as an imagePullSecret. Public images need nothing.

Building for the server's processor

Lesson 3 warned about this: an image you build on an M-series Mac is for arm64 processors, and most cloud servers use amd64. Push it, run it on such a server, and it fails to start with exec format error.

The fix is to tell Docker which processor to build for, with --platform:

BASH
docker build --platform linux/amd64 -t hello-app:3.0-amd64 .

On an M-series Mac this takes longer, because your laptop has to imitate an amd64 processor while it builds. Check the result with the command from Lesson 3: docker image inspect hello-app:3.0-amd64 --format '{{.Architecture}}' says amd64. You can also build for both at once, and push them under one tag, as the official images do. That uses buildx, Docker's extended builder, which pushes as it builds:

BASH
docker buildx build --platform linux/amd64,linux/arm64 -t "$DOCKER_ID/hello-app:3.0" --push .
docker buildx imagetools inspect "$DOCKER_ID/hello-app:3.0"

(This one needs the Docker Hub login from above, because it pushes as it builds.) The second command, from Lesson 3, now lists both platforms behind your tag. If the build stops with an error saying multi-platform builds aren't supported for the docker driver, create a builder that supports them, then run the build again:

BASH
docker buildx create --name multi --use

Try it: run your image on Kubernetes

You'll use kind, which runs a small Kubernetes cluster inside Docker on your laptop, and kubectl, the command-line tool for talking to any Kubernetes cluster. On macOS with Homebrew:

BASH
brew install kind kubectl

On other systems, Your first cluster in the Kubernetes track walks through installing both. Then create a cluster:

BASH
kind create cluster --name learn

Your hello-app:3.0 image from Lesson 7 is on your laptop, but the cluster can't see your laptop's images: it would try to download hello-app:3.0 from Docker Hub, and fail. kind load copies it straight in, the shortcut in the diagram:

BASH
kind load docker-image hello-app:3.0 --name learn

Now Redis. These two commands are the Kubernetes version of the cache service in Lesson 6's compose.yaml: run it, and give it the name cache on the cluster's network:

BASH
kubectl create deployment cache --image=redis:7.2.7-alpine
kubectl expose deployment cache --port=6379

And your app. Save this as web.yaml. Read it slowly: almost every line is something you already know from a docker run flag or a compose.yaml entry, and the comments say which.

YAML
apiVersion: apps/v1
kind: Deployment                 # keeps the app running (like compose up, but it never stops checking)
metadata:
  name: web
spec:
  replicas: 1                    # how many copies to run
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      securityContext:
        runAsNonRoot: true       # refuse to run as root (Lesson 7)
        runAsUser: 1000          # the image's USER is a name, so Kubernetes needs its number to check
      containers:
        - name: web
          image: hello-app:3.0   # the image you loaded
          ports:
            - containerPort: 8000
          env:                   # like -e (Lesson 5)
            - name: GREETING
              value: Hello
            - name: REDIS_HOST
              value: cache       # Redis's name, exactly as in Compose (Lesson 6)
          resources:             # like --memory and --cpus (Lesson 5)
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi
          readinessProbe:        # Kubernetes' own health checks; it ignores HEALTHCHECK (Lesson 7)
            httpGet:
              path: /healthz
              port: 8000
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8000
---
apiVersion: v1
kind: Service                    # a stable name for the app's pods, like a Compose service name
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - port: 8000
      targetPort: 8000

Apply it, and watch the pod start:

BASH
kubectl apply -f web.yaml
kubectl get pods

When web shows 1/1 and Running, reach it from your laptop. kubectl port-forward is the Kubernetes cousin of -p: it forwards your laptop's port 8000 to the Service, for as long as it runs:

BASH
kubectl port-forward service/web 8000:8000

In a second terminal:

BASH
curl http://localhost:8000

Hello from web-...! This page has been visited 1 times. Your image, built with your Dockerfile, running on Kubernetes, talking to Redis by name.

See what Kubernetes adds

So far it looks like Compose with more typing. Here's the difference. Ask for three copies:

BASH
kubectl scale deployment web --replicas=3
kubectl get pods

Three web pods, from one command. Now send six requests from inside the cluster, through the Service:

BASH
kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- sh -c 'for i in 1 2 3 4 5 6; do wget -qO- http://web:8000; done'

The pod names change between requests: the Service is spreading them across all three copies. And the visit count keeps climbing in order, because every copy shares the same Redis. Now delete all three web pods at once, and look again straight away:

BASH
kubectl delete pod -l app=web --wait=false
kubectl get pods -l app=web

Three new pods are already starting, with new names. You never told Kubernetes to restart anything: you told it "three copies", and it keeps that true. That's the idea the whole Kubernetes track is built on.

Every Docker idea, in Kubernetes

In DockerIn KubernetesLearn it in
docker run one containerA Pod, usually kept running by a DeploymentPods, Deployments
compose.yamlKubernetes YAML files, applied with kubectl applyReading YAML
-p 8000:8000A Service, plus kubectl port-forward for testingServices
Service names on a Compose networkService names, through the cluster's DNSServices
-e, --env-fileenv, ConfigMaps and SecretsConfiguration and Secrets
A named volumeA PersistentVolumeClaimStorage
--memory, --cpusresources: requests and limitsRequests and limits
HEALTHCHECKReadiness and liveness probesHealth checks
USERsecurityContext, such as runAsNonRootThis lesson's web.yaml, and the break-it below
Publishing a web app to the worldA Gateway and its routesThe Gateway API

In my real setup

CivicGrid's API takes exactly this journey, with no laptop involved. When I merge a change, GitHub Actions, my CI pipeline, runs the tests and checks first: a failing test never produces an image. Then it builds the image and pushes it to GitHub Container Registry, tagged with the change's ID, like sha-664d196 (Lesson 3).

It builds for amd64 only, with a comment in the file saying why: my cluster's node is amd64, and also building for arm64 doubled the build time. That's the trade-off in this lesson, made on purpose: build for the machines you actually run on.

To ship a version, I change one line in my GitOps repository, the image tag, and Argo CD tells the cluster to pull it. The pipeline also pushes a latest tag, and nothing I run uses it, for the reason in Lesson 3.

Break it on purpose

Ask for an image the cluster doesn't have. Change image: hello-app:3.0 to image: hello-app:3.1 in web.yaml, and apply:

BASH
kubectl apply -f web.yaml
kubectl get pods

The new pods show ErrImagePull, then ImagePullBackOff. You never loaded hello-app:3.1, so the cluster tried Docker Hub, which has no such image. Your old pods keep serving the whole time, because the rollout can't finish. kubectl describe pod on a new pod says exactly which image failed. When a pod can't pull its image, check three things: the name and tag, whether the image is in a registry the cluster can reach (or loaded, on kind), and, for private images, its pull permission. Change it back to 3.0 and apply.

Leave out the user's number. In web.yaml, delete the line runAsUser: 1000, keep runAsNonRoot: true, and apply:

BASH
kubectl apply -f web.yaml
kubectl get pods

The new pods are stuck at CreateContainerConfigError. Ask why:

BASH
kubectl describe pod -l app=web | grep -i "non-root"

The message says the container has runAsNonRoot and the image has a non-numeric user (app), so Kubernetes cannot verify the user is non-root. In Lesson 7 you created the user by name, and a name could secretly be root. Kubernetes only trusts a number, so it refuses to start the container rather than guess. Two fixes: give the number in the YAML with runAsUser: 1000, as you had it, or write USER 1000 instead of USER app in the Dockerfile. Put the line back and apply.

Clean up

Press Ctrl+C in the terminal running the port forward, then:

BASH
kind delete cluster --name learn

You're ready for Kubernetes

That's the end of Docker basics. You can package an app into an image, keep it small and safe, configure it, connect it to a database, and get it onto Kubernetes. The Kubernetes basics track picks up from exactly here, and explains everything in web.yaml one piece at a time.

Check yourself

  1. Does Kubernetes build your image from your Dockerfile?

  2. What does docker tag hello-app:3.0 yourname/hello-app:3.0 do?

  3. You build on an M-series Mac, and the image fails on an amd64 server with exec format error. How do you fix it?

  4. Your Deployment uses an image that's only on your laptop, and the pod shows ErrImagePull on kind. Why?

  5. Which Kubernetes object plays the role of -p 8000:8000 and of a service name in Compose?

  6. You delete all three of a Deployment's pods. What happens?

Key terms

  • Kubernetes: a system that runs containers across many machines and keeps them running.
  • kind: "Kubernetes in Docker", a small Kubernetes cluster that runs on your laptop.
  • kubectl: the command-line tool for working with a Kubernetes cluster.
  • docker push / docker tag: upload an image to a registry / give an image another name.
  • --platform: which operating system and processor to build an image for.
  • buildx: Docker's extended builder, which can build for several platforms at once.
  • kind load: copies an image from your laptop into a kind cluster.
  • imagePullSecret: a Kubernetes Secret with registry login details, for pulling private images.

Go deeper