Container Image Security
Introduction
Container images are the building blocks of modern application deployment. An insecure base image or dependency can compromise every environment where the container runs. Securing the container supply chain requires attention to every layer — from the base image choice to runtime enforcement.
Minimal Base Images
Smaller base images reduce attack surface and vulnerability count.
# BAD: Large base image with unnecessary tools
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 curl wget git build-essential
# GOOD: Minimal Python image
FROM python:3.12-slim
# BETTER: Distroless — no package manager, no shell
FROM gcr.io/distroless/python3-debian12
# BEST: Scratch — completely empty, only your binary
FROM scratch
COPY my-compiled-binary /app/
# Compare image sizes
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# ubuntu:22.04 → 77MB
# python:3.12 → 1.0GB
# python:3.12-slim → 130MB
# gcr.io/distroless/python3 → 90MB
Alpine Considerations
FROM alpine:3.19
# Install only what's needed
RUN apk add --no-cache \
python3=~3.12 \
ca-certificates
# Remove apk cache
RUN rm -rf /var/cache/apk/*
Note: Alpine uses musl libc instead of glibc, which can cause compatibility issues with Python wheels and compiled binaries.
Multi-Stage Builds
Multi-stage builds separate the build environment from the runtime environment, ensuring build tools and source code are not included in the final image.
# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server -ldflags="-s -w"
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
# Python multi-stage build
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-deps -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /root/.local /root/.local
COPY app/ ./app/
ENV PATH=/root/.local/bin:$PATH
USER nobody
CMD ["python", "-m", "app"]
Image Scanning
Scan images for known vulnerabilities before deployment.
# Trivy scan
trivy image myapp:latest --severity CRITICAL,HIGH
# Trivy with output formats
trivy image --format sarif --output trivy-report.sarif myapp:latest
trivy image --format cyclonedx --output sbom.json myapp:latest
# Grype scan
grype myapp:latest --only-fixed --fail-on high
# Scan within CI/CD
grype myapp:latest --fail-on high --add-cpes-if-none
# .trivyignore — exclude accepted risks
# CVE-2023-1234 — accepted, no exploit available, will fix in next sprint
CVE-2023-1234
# CVE-2023-5678 — false positive in this context
CVE-2023-5678
# CI/CD gate: block deployment if critical vulnerabilities found
import subprocess
import json
def scan_and_validate(image_tag):
result = subprocess.run([
'trivy', 'image', '--format', 'json',
'--severity', 'CRITICAL,HIGH',
image_tag
], capture_output=True, text=True)
report = json.loads(result.stdout)
vulnerabilities = report.get('Results', [])
critical_count = sum(
len(vuln.get('Vulnerabilities', []))
for vuln in vulnerabilities
)
if critical_count > 0:
print(f"BLOCKED: {critical_count} critical/high vulnerabilities found")
exit(1)
print(f"PASSED: No critical vulnerabilities")
Image Signing with Cosign
Signing container images ensures integrity and provenance.
# Generate a key pair
cosign generate-key-pair
# Sign an image
cosign sign --key cosign.key registry.example.com/myapp:latest
# Verify a signed image
cosign verify --key cosign.pub registry.example.com/myapp:latest
# Keyless signing with OIDC
cosign sign registry.example.com/myapp:latest
# Verify keyless signature
cosign verify registry.example.com/myapp:latest
# Kubernetes admission controller to verify signatures
apiVersion: cosign.sigstore.dev/v1alpha1
kind: ClusterImagePolicy
metadata:
name: image-signature-policy
spec:
images:
- glob: "registry.example.com/*"
authorities:
- key:
data: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
- keyless:
identities:
- issuer: https://accounts.google.com
subject: "builder@example.com"
# Enforce in CI/CD pipeline
cosign verify \
--key k8s://my-namespace/cosign-public-key \
$IMAGE_TAG || exit 1
Dockerfile Best Practices
# 1. Use specific tags, never :latest
FROM python:3.12-slim@sha256:abc123...
# 2. Run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# 3. Set filesystem read-only
COPY --chown=appuser:appgroup --chmod=0444 app/ ./app/
# 4. Drop capabilities in compose/kubernetes
# In Kubernetes:
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
allowPrivilegeEscalation: false
# 5. HEALTHCHECK instruction
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1
Conclusion
Container image security requires a layered approach: use minimal base images and distroless for production, implement multi-stage builds to exclude build tools, scan images for vulnerabilities before every deployment, and sign images with cosign to ensure integrity. Enforce these practices in CI/CD pipelines and at admission control time in Kubernetes.