Skip to content

Portal | Level: L1: Foundations | Topics: Container Base Images, Docker / Containers, Container Image Optimization (alias → container_images), Container Image Scanning (alias → container_images) | Domain: Kubernetes

Container Base Images — Primer

Why This Matters

Your base image choice determines container size, security surface, compatibility, and debugging capability. The difference between a good and bad choice can be hundreds of MB of attack surface, mysterious runtime failures, or the inability to troubleshoot a production issue.


Base Image Options

The Lineup

Base Image Size libc Shell Package Manager Best For
Ubuntu ~75MB glibc bash apt General purpose, familiar tooling
Debian ~125MB glibc bash apt Stable, well-tested, wide compat
Debian-slim ~75MB glibc bash apt Production apps needing glibc
Alpine ~7MB musl ash apk Tiny images, Go/Rust binaries
Distroless ~20MB glibc none none Maximum security, static binaries
scratch 0MB none none none Statically compiled Go/Rust
UBI (Red Hat) ~200MB glibc bash dnf (microdnf) Enterprise, RHEL compat
Chainguard ~15MB glibc/musl none apk Supply chain security
Wolfi ~15MB glibc none apk Undistro (glibc + minimal)

Alpine Linux

Why Alpine?

# Tiny base — 7MB compressed
FROM alpine:3.20

# Package manager is fast
RUN apk add --no-cache python3 py3-pip

# Result: much smaller than ubuntu or debian-based

Alpine Internals

  • musl libc instead of glibc — smaller but not 100% compatible
  • BusyBox provides coreutils — ash shell, not bash
  • apk package manager — fast, simple, no caching by default
  • OpenRC init (not systemd) — irrelevant in containers

Common Alpine Patterns

# Python on Alpine (careful — compiling C extensions is slow)
FROM python:3.12-alpine
RUN apk add --no-cache gcc musl-dev linux-headers
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Go on Alpine (great fit — Go binaries are static by default)
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /server

FROM alpine:3.20
COPY --from=build /server /server
CMD ["/server"]

# Node.js on Alpine
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

Name origin: Alpine Linux is named after the Alpine climate zone — small, tough, high-altitude. Created by Natanael Copa in 2005, it was originally a fork of LEAF (Linux Embedded Appliance Framework). Its adoption exploded around 2015 when Docker popularized minimal container base images.

musl vs glibc — Compatibility Issues

Issue Symptom Fix
DNS resolution Intermittent failures, no search domain support Use glibc-based image
Python C extensions Build fails (missing headers) apk add gcc musl-dev
Node native modules Compilation errors apk add python3 make g++
Go with CGO Linking errors CGO_ENABLED=0 or use glibc
Java JVM performance differences Use Eclipse Temurin Alpine JDK
Locale support Missing locales, encoding errors apk add musl-locales
Thread performance Different thread stack defaults Usually not noticeable

Debian-Slim

The Sweet Spot

# glibc compatibility + much smaller than full Debian
FROM debian:bookworm-slim

# ~75MB vs ~125MB full Debian, ~300MB Ubuntu
# Includes apt, so you can install what you need
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    curl \
    && rm -rf /var/lib/apt/lists/*

When to Choose Debian-Slim

  • You need glibc (Python C extensions, Java, .NET)
  • You need apt for installing packages
  • You want to debug (has a shell)
  • Alpine's musl is causing issues
  • You want a good size-to-compatibility ratio

Distroless

Maximum Security

# No shell, no package manager, no OS-level tools
# Only your binary + runtime libraries

# Python distroless
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /app
COPY --from=build /usr/local/lib/python3.12 /usr/local/lib/python3.12
WORKDIR /app
CMD ["app.py"]

# Go distroless
FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /server

FROM gcr.io/distroless/static-debian12
COPY --from=build /server /server
CMD ["/server"]

# Java distroless
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./gradlew build

FROM gcr.io/distroless/java21-debian12
COPY --from=build /app/build/libs/app.jar /app.jar
CMD ["app.jar"]

Distroless Pros and Cons

Pros: - Minimal attack surface (no shell = no shell exploits) - No package manager (can't install malware) - Fewer CVEs to patch - Forces multi-stage builds (good practice)

Cons: - Cannot exec into container — no shell for debugging - Need debug images for troubleshooting: gcr.io/distroless/base-debian12:debug

Debug clue: When you need to troubleshoot a distroless container in production, use kubectl debug to attach an ephemeral debug container with a shell: kubectl debug -it podname --image=busybox --target=containername. This gives you shell access in the pod's network and PID namespace without modifying the original container image. - Harder to test locally - Some apps need runtime dependencies that are missing


scratch — The Zero Base

# Literally nothing — 0 bytes
# Only for statically compiled binaries

FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /server

FROM scratch
# Need CA certs if making HTTPS calls
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /server /server
ENTRYPOINT ["/server"]

scratch is for: Statically compiled Go, Rust, or C binaries only. No libc, no certs, no timezone data, no /tmp, no users. You copy in everything your binary needs.


Red Hat UBI (Universal Base Image)

# Enterprise-grade, RHEL-compatible, free to use
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest

# microdnf instead of dnf (smaller)
RUN microdnf install -y python3 pip && microdnf clean all

# UBI variants:
# ubi9/ubi         — full (~215MB, includes dnf, systemd)
# ubi9/ubi-minimal — smaller (~95MB, microdnf)
# ubi9/ubi-micro   — tiny (~28MB, no package manager)
# ubi9/ubi-init    — with systemd (for multi-process containers)

When to use UBI: - Running on OpenShift (certified base) - Enterprise requirements for RHEL compatibility - Need vendor support for the base image - Compliance requirements (FIPS, STIG)


Chainguard & Wolfi

# Chainguard — hardened, minimal, signed, SBOM'd
FROM cgr.dev/chainguard/python:latest

# Wolfi — glibc-based "undistro" (Alpine-style but glibc)
FROM cgr.dev/chainguard/wolfi-base:latest
RUN apk add python-3.12

Key features: - Zero known CVEs (aggressively patched) - Signed images with provenance - SBOM (Software Bill of Materials) built-in - glibc-based (no musl issues) - Very small


Multi-Stage Build Pattern

# Stage 1: Build (large image with build tools)
FROM python:3.12-slim AS build
WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

COPY . .

# Stage 2: Runtime (small image, no build tools)
FROM python:3.12-slim
WORKDIR /app

# Copy only what we need from build stage
COPY --from=build /install /usr/local
COPY --from=build /app /app

# Non-root user
RUN useradd -r -s /sbin/nologin appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Decision Tree

Do you need a shell for debugging?
├── No → Do you need glibc?
│         ├── No  → scratch (Go/Rust static binary)
│         └── Yes → distroless or Chainguard
└── Yes → Does your app need glibc?
          ├── No  → Alpine (smallest with shell)
          └── Yes → Does your company require RHEL support?
                    ├── Yes → UBI minimal
                    └── No  → Debian-slim (best all-around)

Image Size Comparison (Python app example)

ubuntu:22.04        + Python + deps = ~450MB
python:3.12         (full Debian)   = ~350MB
python:3.12-slim    (Debian-slim)   = ~150MB
python:3.12-alpine                  = ~60MB
distroless/python3                  = ~50MB
chainguard/python                   = ~45MB

Every 100MB removed: - Faster pulls (especially cold starts on new nodes) - Less disk usage across the fleet - Fewer CVEs to scan and patch - Smaller attack surface


Security Scanning

# Scan with Trivy
trivy image myapp:latest

# Compare CVE counts across base images
trivy image alpine:3.20
trivy image debian:bookworm-slim
trivy image ubuntu:22.04
trivy image cgr.dev/chainguard/python:latest

# Fix: update base image, rebuild
# Most CVEs are in the base image, not your code

Rule: Rebuild containers regularly even if your code hasn't changed. Base image CVEs are discovered daily.

Gotcha: Scanning only your final image misses vulnerabilities introduced in the build stage (e.g., a compromised build tool). Use trivy fs . to scan your Dockerfile and source code, and trivy image to scan the built image. Multi-stage builds reduce the attack surface but do not eliminate supply chain risks in the build layer.


Image Optimization

Multi-Stage Builds

The most impactful optimization. Build dependencies stay in the build stage; only the runtime binary goes into the final image.

# Go — results in 5-15MB image
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
# Python — pip wheel pattern
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && rm -rf /wheels
COPY . .
USER 1000:1000
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Layer Optimization

# BAD: Each RUN creates a layer; intermediate files persist
RUN apt-get update
RUN apt-get install -y curl wget git
RUN rm -rf /var/lib/apt/lists/*   # too late  still in previous layers

# GOOD: Single RUN, cleanup in same layer
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl wget git \
    && rm -rf /var/lib/apt/lists/*

# Order layers by change frequency (least changed first)
COPY go.mod go.sum ./              # changes rarely
RUN go mod download                 # cached when go.mod unchanged
COPY . .                            # changes every build

BuildKit Caching

# Cache apt packages
RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt \
    apt-get update && apt-get install -y build-essential

# Cache pip downloads
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Layer Analysis Tools

docker history myapp:latest          # layer sizes
dive myapp:latest                    # interactive layer explorer
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | sort -k3 -h

Image Scanning

How Scanners Work

Scanners extract the SBOM (Software Bill of Materials) from an image, then match it against CVE databases:

Image -> layer extraction -> SBOM -> CVE database lookup -> findings report

Trivy (Most Common)

# Scan an image
trivy image myapp:latest
trivy image --severity CRITICAL,HIGH myapp:latest

# CI gate — exit 1 on critical CVEs
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Generate SBOM
trivy image --format cyclonedx --output sbom.cdx.json myapp:latest

# Scan filesystem (before building image)
trivy fs --security-checks vuln,secret .

Suppress known false positives with .trivyignore:

# One CVE-ID per line
CVE-2022-0778    # not exploitable in our usage

Grype (Alternative)

grype myapp:latest --fail-on critical

# Scan from SBOM (no Docker daemon needed)
syft packages myapp:latest -o cyclonedx-json > sbom.cdx.json
grype sbom:sbom.cdx.json

CI Integration (GitHub Actions)

- name: Run Trivy scan
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    severity: CRITICAL,HIGH
    exit-code: 1
    ignore-unfixed: true

Base Image Update Automation

Use Renovate or Dependabot to automatically create PRs when base images are updated:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"
Severity CVSS Range CI Gate
CRITICAL 9.0-10.0 Always block
HIGH 7.0-8.9 Block (with --ignore-unfixed)
MEDIUM 4.0-6.9 Report, schedule fix
LOW 0.1-3.9 Track

Wiki Navigation

Prerequisites