Distroless Container Testing: Debugging and Validating Minimal Images

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/sh or /bin/bash
  • ls, cat, ps, curl, wget
  • apt, yum, apk
  • Any user account management tools
  • /tmp in 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 $PATH

Without 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 inspect

This 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.js

This 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-distroless

In 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 Total

Chainguard 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.

Read more

Start now free