Distroless Container Testing: Debugging and Validating Minimal Images
Distroless images contain only your application and its runtime dependencies — no shell, no package manager, no ls, no cat. They're dramatically more secure (Google reports 50%+ CVE reduction) and smaller than Alpine. But they're harder to debug and test. This guide covers how to validate distroless images, debug them without a shell, and build test pipelines around them.
What Distroless Images Are
Google's distroless images strip the container down to the absolute minimum:
gcr.io/distroless/static # No runtime (static binaries only)
gcr.io/distroless/base # glibc + OpenSSL
gcr.io/distroless/nodejs20 # Node.js 20 runtime only
gcr.io/distroless/java21 # JRE 21 only
gcr.io/distroless/python3 # CPython only
gcr.io/distroless/cc # libgcc + libstdc++What's NOT in a distroless image:
/bin/shor/bin/bashls,cat,ps,curl,wgetapt,yum,apk- Any user account management tools
/tmpin some variants
Building a Distroless Image
# Multi-stage: build in full image, run in distroless
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12 AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["dist/server.js"]
# Note: no "node" prefix needed — entrypoint is already "node"The Testing Challenge
# This doesn't work on distroless:
docker run --rm myapp:distroless ls /app
# Error: exec: "ls": executable file not found in $PATH
# This doesn't work either:
docker run --rm myapp:distroless sh -c 'echo test'
# Error: exec: "sh": executable file not found in $PATH
# And exec doesn't work:
docker exec myapp-container bash
# Error: exec: "bash": executable file not found in $PATHWithout a shell, traditional debugging and validation techniques fail. You need a different approach.
Validation Without a Shell
Use the Debug Variant
Distroless provides debug images with BusyBox shell:
# Debug variant adds busybox shell
docker pull gcr.io/distroless/nodejs20-debian12:debug
# Run your app in debug mode for inspection
docker run --rm --entrypoint sh gcr.io/distroless/nodejs20-debian12:debug -c "ls /"For your own image during development:
# Use debug variant locally, production in CI
ARG DISTROLESS_TAG=latest
FROM gcr.io/distroless/nodejs20-debian12:${DISTROLESS_TAG} AS production# Local debugging with debug variant
docker build --build-arg DISTROLESS_TAG=debug -t myapp:debug .
docker run --rm --entrypoint sh myapp:debug -c 'ls /app && node dist/server.js --version'
# CI uses production (no shell)
docker build -t myapp:production .Sidecar Container for File Inspection
Copy files out of a distroless container without executing inside it:
# Create a container (don't run it)
docker create --name inspect myapp:production
# Copy the filesystem
docker export inspect | tar -tv | grep "^./app" | head -50
# Or copy specific directory
docker cp inspect:/app /tmp/app-extract
# List contents
ls -la /tmp/app-extract
# Cleanup
docker rm inspectThis technique validates:
- Expected files are present
- No extra files leaked in (source code, .env files)
- Symlinks are correct
- File permissions match expectations
# Validation script using docker cp
docker create --name validate myapp:production
# Check expected files exist
docker cp validate:/app/dist/server.js /tmp/validate-server.js
[ -f /tmp/validate-server.js ] || (echo "MISSING: server.js" && exit 1)
echo "OK: server.js present ($(wc -c < /tmp/validate-server.js) bytes)"
# Check no dev files leaked
docker export validate | tar -t | grep -E "\.(env|test\.|spec\.)" && \
echo "SECURITY: dev files found in image" && exit 1
docker rm validate
echo "Validation passed"Ephemeral Debug Container (Kubernetes)
In production, use kubectl debug with an ephemeral container:
# Attach debug container to running distroless pod
kubectl debug -it myapp-pod-abc123 \
--image=busybox \
--target=myapp \
-- sh
# Now you're in a sidecar container sharing the process namespace
ls /proc/1/root/app # See distroless container's filesystem
cat /proc/1/root/app/dist/server.jsThis doesn't modify the distroless container at all — it creates an ephemeral sidecar that shares the same PID and network namespaces.
Testing That the Application Actually Starts
The most critical test for distroless: does the app run?
# Start the container
docker run -d --name test-distroless -p 3000:3000 myapp:production
# Wait for readiness
timeout 30 bash -c 'until curl -sf http://localhost:3000/health; do sleep 1; done'
# Run functional tests against it
curl -f http://localhost:3000/health
curl -f http://localhost:3000/api/version
# Check container didn't crash
STATUS=$(docker inspect test-distroless --format='{{.State.Status}}')
[ "$STATUS" = "running" ] || (docker logs test-distroless && exit 1)
# Cleanup
docker stop test-distroless && docker rm test-distrolessIn GitHub Actions:
- name: Start distroless container
run: |
docker run -d --name app -p 3000:3000 myapp:production
# Wait up to 30s for startup
for i in $(seq 1 30); do
curl -sf http://localhost:3000/health && break
sleep 1
if [ $i -eq 30 ]; then
echo "Container failed to start:"
docker logs app
exit 1
fi
done
- name: Run smoke tests
run: |
# Smoke test endpoints
curl -sf http://localhost:3000/health | jq '.status == "ok"'
curl -sf http://localhost:3000/api/version | jq '.version'
echo "Smoke tests passed"
- name: Verify no shell in production image
run: |
# Confirm it's actually distroless (no shell)
docker run --rm --entrypoint sh myapp:production -c "echo fail" 2>&1 | \
grep -q "executable file not found" && echo "OK: No shell in production image"Running Tests Against Distroless via Playwright
End-to-end tests work great against distroless — they test through HTTP, not inside the container:
// e2e/distroless.spec.js
const { test, expect } = require('@playwright/test');
test.beforeAll(async () => {
// Container should already be running from CI setup
// Verify it's up
const response = await fetch('http://localhost:3000/health');
if (!response.ok) throw new Error('Container not healthy');
});
test('homepage loads correctly from distroless', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveTitle(/My App/);
});
test('API responds correctly', async ({ request }) => {
const response = await request.get('http://localhost:3000/api/data');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('items');
});
test('app handles 404 correctly', async ({ request }) => {
const response = await request.get('http://localhost:3000/nonexistent');
expect(response.status()).toBe(404);
});Comparing Distroless CVE Count
The security benefit of distroless is quantifiable:
# Scan Alpine
docker pull node:20-alpine
trivy image --severity HIGH,CRITICAL node:20-alpine 2>/dev/null | grep "^Total:"
# Scan Debian
docker pull node:20
trivy image --severity HIGH,CRITICAL node:20 2>/dev/null | grep "^Total:"
# Scan Distroless
docker pull gcr.io/distroless/nodejs20-debian12
trivy image --severity HIGH,CRITICAL gcr.io/distroless/nodejs20-debian12 2>/dev/null | grep "^Total:"Typical results (numbers vary by date):
node:20 Total: 87 (HIGH: 67, CRITICAL: 20)
node:20-alpine Total: 12 (HIGH: 9, CRITICAL: 3)
distroless/nodejs Total: 4 (HIGH: 3, CRITICAL: 1)Include this comparison in your CI reports to track security posture over time.
Chainguard Images as an Alternative
Chainguard images are similar to distroless but updated more frequently and often have zero CVEs:
FROM cgr.dev/chainguard/node:latest AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]# Compare CVE counts
trivy image --severity HIGH,CRITICAL gcr.io/distroless/nodejs20-debian12 | grep Total
trivy image --severity HIGH,CRITICAL cgr.io/chainguard/node:latest | grep TotalChainguard rebuilds images daily with the latest security patches, targeting zero HIGH/CRITICAL CVEs.
CI Pipeline for Distroless
name: Distroless Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Build and test in full image
- name: Build and test
run: |
docker build --target builder -t myapp:builder .
docker run --rm myapp:builder npm test
# Build distroless production image
- name: Build distroless production
run: docker build --target production -t myapp:production .
# Validate image contents
- name: Validate image contents
run: |
docker create --name validate myapp:production
# Check expected files
docker cp validate:/app/dist /tmp/dist-check
test -f /tmp/dist-check/server.js || (echo "MISSING server.js" && exit 1)
# Check no shell (truly distroless)
docker run --rm --entrypoint sh myapp:production 2>&1 | \
grep -q "not found" && echo "OK: No shell"
docker rm validate
# Start container for E2E
- name: Start container
run: |
docker run -d --name app -p 3000:3000 myapp:production
timeout 30 bash -c 'until curl -sf http://localhost:3000/health; do sleep 1; done'
# Run E2E tests
- name: E2E tests
run: npx playwright test tests/e2e/
# Security scan
- name: CVE scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:production
severity: CRITICAL
exit-code: '1'
ignore-unfixed: true
# Report image size
- name: Image size report
run: |
SIZE=$(docker image inspect myapp:production --format='{{.Size}}')
echo "Distroless image size: $((SIZE / 1024 / 1024))MB"Distroless images require changing your mental model of container validation: instead of exec-ing into containers to verify state, you validate through HTTP endpoints, extract files with docker cp, and inspect manifests. The payoff — dramatically fewer CVEs and a smaller attack surface — is worth the adjustment.