DevOpsIndex

Docker Debugging Scenarios


1. Container Exits Immediately

Symptom: docker run exits instantly, docker ps shows no running container.

flowchart TD
    A[docker run exits] --> B[docker inspect ExitCode]
    B --> C{Exit code?}
    C -->|0| D[CMD completed normally]
    C -->|1| E[App error]
    C -->|137| F["OOM / SIGKILL"]
    C -->|139| G[Segfault]
    E --> H[docker logs]
    F --> H
    G --> H
    H --> I{Logs helpful?}
    I -->|No| J["Override entrypoint:<br/>docker run -it --entrypoint sh"]
    I -->|Yes| K[Fix app error]
# Check exit code and error
docker inspect <container> --format='{{.State.ExitCode}} | {{.State.Error}}'

# View logs of exited container
docker logs <container>

# Override entrypoint to debug interactively
docker run -it --entrypoint sh <image>

Prevention: Use HEALTHCHECK in every production Dockerfile so Docker surfaces unhealthy status before traffic reaches the container. Set restart: unless-stopped in Compose so transient crashes auto-recover. Log to stderr from process start — before any initialization — so logs are always available even on crash.


2. Container OOM Killed

Symptom: Container exits with code 137. App was consuming too much memory.

flowchart TD
    A[Exit code 137] --> B[docker inspect OOMKilled]
    B --> C{OOMKilled=true?}
    C -->|Yes| D[docker stats — live memory]
    C -->|No| E[Killed by external signal]
    D --> F{Over limit?}
    F -->|Yes| G[Raise --memory limit]
    F -->|No| H[Check memory leak in app]
    G --> I["docker run --memory 512m<br/>--memory-swap 512m"]
# Check OOMKilled flag
docker inspect <container> --format='{{.State.OOMKilled}}'

# Live memory stats
docker stats <container>

# Set memory limit
docker run --memory 512m --memory-swap 512m <image>

Prevention: Always set --memory limits in production. For Go: set GOMEMLIMIT slightly below the Docker limit so GC kicks in before the OOM killer does. Monitor docker stats — alert when container memory exceeds 80% of limit. Use --memory-swap equal to --memory to prevent swapping.


3. Can't Connect to Container Port

Symptom: curl localhost:8080 fails even though container runs with -p 8080:80.

flowchart TD
    A[curl localhost:8080 fails] --> B{Container running?}
    B -->|No| C["docker start / docker run"]
    B -->|Yes| D{Port published?}
    D -->|No| E[Add -p 8080:80 flag]
    D -->|Yes| F{App listening inside?}
    F -->|No| G[Fix app bind address]
    F -->|Yes| H{App on 0.0.0.0?}
    H -->|No: 127.0.0.1| I[Bind app to 0.0.0.0]
    H -->|Yes| J{iptables rule exists?}
    J -->|No| K[Restart Docker daemon]
    J -->|Yes| L{Host firewall blocking?}
    L -->|Yes| M[Open host port]
    L -->|No| N[Check docker inspect Ports]
# Check published ports
docker ps --format 'table {{.Names}}\t{{.Ports}}'

# Check what the app is listening on inside the container
docker exec <container> ss -tlnp

# Inspect NAT rules
iptables -t nat -L DOCKER -n

# Inspect port bindings
docker inspect <container> --format='{{json .NetworkSettings.Ports}}'

Prevention: Always bind your app to 0.0.0.0 (not 127.0.0.1) in containerized environments. Validate port bindings in CI with a smoke test: docker run -d -p 8080:8080 image && sleep 2 && curl -f localhost:8080/health.


4. Container Can't Reach the Internet

Symptom: curl inside container times out. DNS resolves fine.

flowchart TD
    A[No internet inside container] --> B[ping 8.8.8.8]
    B --> C{Ping works?}
    C -->|Yes| D[DNS issue only — check resolv.conf]
    C -->|No| E{ip_forward enabled?}
    E -->|No| F[sysctl -w net.ipv4.ip_forward=1]
    E -->|Yes| G{MASQUERADE rule exists?}
    G -->|No| H["iptables -t nat -A POSTROUTING<br/>-j MASQUERADE"]
    G -->|Yes| I{docker0 bridge OK?}
    I -->|No| J[docker network inspect bridge]
    I -->|Yes| K[Custom network routing issue]
# Ping from inside container
docker exec <container> ping -c3 8.8.8.8

# Check IP forwarding
sysctl net.ipv4.ip_forward

# Check MASQUERADE rule
iptables -t nat -L POSTROUTING -n -v

# Inspect docker network
docker network inspect bridge

Prevention: After Docker daemon upgrades or kernel updates, run docker run --rm alpine ping -c1 8.8.8.8 as a post-install smoke test. Persist net.ipv4.ip_forward=1 in /etc/sysctl.d/ — it can revert after reboots if not persisted.


5. Containers on Same Host Can't Reach Each Other

Symptom: Container A can't ping/connect container B on the same host.

flowchart TD
    A[A can't reach B] --> B{Same network?}
    B -->|No| C["Connect both to same<br/>custom network"]
    B -->|Yes| D{Default or custom bridge?}
    D -->|Default bridge| E[No DNS — use IP address]
    D -->|Custom bridge| F[DNS works — use container name]
    E --> G[docker inspect for IP]
    F --> H[ping container-name]
    H --> I{Still fails?}
    I -->|Yes| J["Check DOCKER-ISOLATION<br/>iptables chain"]
    J --> K[docker network inspect]
# Inspect network and connected containers
docker network inspect <network>

# Ping by container name (custom network only)
docker exec <containerA> ping <containerB>

# DNS lookup inside container
docker exec <containerA> nslookup <containerB>

# Check container's network config
docker inspect <container> --format='{{json .NetworkSettings.Networks}}'

Prevention: Always use named custom networks in Compose — never rely on the default bridge. Custom networks provide automatic DNS by container name. Define the network explicitly in docker-compose.yml so all services join it and can resolve each other by service name.


6. docker build Is Slow / Cache Not Working

Symptom: Every build takes full time even with no changes.

flowchart TD
    A[Cache always misses] --> B{COPY . . before RUN?}
    B -->|Yes| C["Move COPY after RUN deps —<br/>cache invalidated on any file change"]
    B -->|No| D{.dockerignore missing?}
    D -->|Yes| E["Add .dockerignore —<br/>exclude node_modules / .git"]
    D -->|No| F{Build args changing?}
    F -->|Yes| G[ARG invalidates all layers below it]
    F -->|No| H{--no-cache flag?}
    H -->|Yes| I[Remove --no-cache from CI]
    H -->|No| J["Correct layer order:<br/>deps --> code --> COPY ."]

Correct Dockerfile layer order:

# Good: stable layers first
COPY go.mod go.sum ./
RUN go mod download          # cached unless go.mod changes
COPY . .                     # only invalidates layers below
RUN go build -o app .

Prevention: Enforce layer order in code review: COPY go.mod go.sumRUN go mod downloadCOPY . .RUN build. Add a .dockerignore to every repo. Run docker build --no-cache only in weekly full-rebuild CI, never in the per-PR pipeline.


7. Image Too Large

Symptom: docker images shows 1.5 GB image. CI pulls take minutes.

flowchart TD
    A[Image too large] --> B[docker history — find fat layer]
    B --> C{Base image bloated?}
    C -->|Yes| D["Switch to alpine / distroless"]
    C -->|No| E{apt cache not cleaned?}
    E -->|Yes| F["Add rm -rf /var/lib/apt/lists/*<br/>in same RUN"]
    E -->|No| G{Single stage build?}
    G -->|Yes| H[Use multi-stage build]
    G -->|No| I{Source files copied?}
    I -->|Yes| J[Add .dockerignore]
# Show layer sizes
docker history --no-trunc <image>

# Interactive layer explorer (install separately)
dive <image>

# List image sizes
docker images --format 'table {{.Repository}}\t{{.Size}}'

Multi-stage example:

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o app .

FROM gcr.io/distroless/static
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]

Prevention: Set a CI size gate: docker images --format '{{.Size}}' myapp:latest — fail the build if it exceeds 100 MB. Use distroless/static or scratch as the final stage for Go binaries. Run dive --ci in CI to catch any layer regressions automatically.


8. Volume Mount Not Working

Symptom: App gets permission denied or file not found on mounted path.

flowchart TD
    A[Volume mount fails] --> B{Bind or named volume?}
    B -->|Bind mount| C{Host path exists?}
    C -->|No| D[mkdir on host path]
    C -->|Yes| E{UID mismatch?}
    E -->|Yes| F["chown on host OR<br/>--user flag in docker run"]
    E -->|No| G{"SELinux / AppArmor?"}
    G -->|Yes| H[Add :z or :Z to mount]
    B -->|Named volume| I[docker volume inspect]
    I --> J{Volume has data?}
    J -->|No| K[Check Dockerfile VOLUME directive]
# Inspect mounts on running container
docker inspect <container> --format='{{json .Mounts}}'

# Check host path permissions
ls -la /host/path

# Mount with read-only
docker run -v /host/path:/container/path:ro <image>

# List and inspect named volumes
docker volume ls
docker volume inspect <volume>

Prevention: Never run containers as root when bind-mounting host paths. Set USER in Dockerfile and match UID with the host path owner. Add :ro to read-only mounts to prevent accidental writes. Document expected UID in the repo README.


9. docker-compose Service Won't Start

Symptom: docker compose up — dependency shows healthy but dependent fails immediately.

flowchart TD
    A[Service fails on start] --> B{depends_on condition met?}
    B -->|No| C["Check healthcheck command —<br/>test / interval / retries"]
    B -->|Yes| D{Env vars correct?}
    D -->|No| E["docker compose config —<br/>validate resolved values"]
    D -->|Yes| F{Port conflict on host?}
    F -->|Yes| G[Change host port in compose]
    F -->|No| H[docker compose logs service]
    H --> I[Fix app startup error]
# View service logs
docker compose logs <service>

# Show all service states
docker compose ps

# Validate and print resolved compose config
docker compose config

# Recreate containers (pick up config changes)
docker compose up --force-recreate

Prevention: Always define healthcheck on dependency services (postgres, redis) in Compose and use depends_on: condition: service_healthy — not just depends_on: db. Add docker compose config as a CI lint step to catch YAML/variable errors before they reach a developer's machine.


10. Disk Space Exhausted by Docker

Symptom: /var/lib/docker consuming 50 GB+. Host disk full.

flowchart TD
    A[Disk full] --> B[docker system df]
    B --> C{Stopped containers?}
    C -->|Yes| D[docker container prune]
    C -->|No| E{Dangling images?}
    E -->|Yes| F[docker image prune]
    E -->|No| G{Unused volumes?}
    G -->|Yes| H[docker volume prune]
    G -->|No| I{Build cache large?}
    I -->|Yes| J[docker builder prune]
    I -->|No| K[docker system prune -af --volumes]
# Show disk usage breakdown
docker system df

# Prune everything unused (containers, images, networks, cache)
docker system prune -af --volumes

# Targeted pruning
docker image prune -f          # dangling images only
docker image prune -af         # all unused images
docker container prune -f      # stopped containers
docker volume prune -f         # unused volumes
docker builder prune -af       # build cache

Prevention: Add a weekly cron job on every CI host: docker system prune -af --volumes --filter "until=168h". Set "log-opts": {"max-size": "50m", "max-file": "3"} in /etc/docker/daemon.json to cap container log sizes. Alert on host disk usage > 70% before Docker fills the drive.


11. Multi-Stage Build Pitfalls

Multi-stage builds reduce image size but have subtle failure modes.

Stage output not copied — silent size bloat:

# WRONG: final stage copies from wrong alias
FROM golang:1.22 AS builder
RUN go build -o /app .

FROM gcr.io/distroless/static
COPY --from=build /app /app   # "build" doesn't exist — Docker silently skips this
                               # or errors depending on version; image has no binary
# FIX:
COPY --from=builder /app /app  # alias must match exactly

Build cache invalidated by COPY order:

# BAD — copies all source first, invalidates go mod cache on any code change
COPY . .
RUN go mod download
RUN go build -o /app .

# GOOD — dependencies cached unless go.sum changes
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app .

Missing CA certificates in distroless/scratch:

FROM scratch
COPY --from=builder /app /app
# PROBLEM: app making HTTPS calls fails — no /etc/ssl/certs
# FIX option 1: copy certs from builder
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# FIX option 2: use distroless/base instead of scratch
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app /app

Timezone data missing:

# App using time.LoadLocation("America/New_York") panics in scratch/distroless/static
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# or use distroless/base which includes tzdata

12. Secret Leaks in Image Layers

Every RUN command that writes a file and every ENV creates a layer. Even if you delete a file in a later layer, it is permanently readable in the earlier layer's blob.

# CATASTROPHIC — AWS key baked into layer forever
RUN aws s3 cp s3://my-bucket/config.yaml /app/config.yaml  # uses ambient creds or...
ENV AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE  # baked in every image pushed

# WRONG — delete in later layer doesn't help
RUN echo "secretpassword" > /tmp/secret && \
    do-something-with /tmp/secret
RUN rm /tmp/secret   # still in previous layer's filesystem snapshot
# CORRECT — use BuildKit secret mounts (never written to any layer)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci

# Build:
docker build --secret id=npm_token,src=~/.npmrc .
# CORRECT — SSH agent forwarding for private git repos
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
RUN --mount=type=ssh \
    go get github.com/myorg/private-module@v1.2.3

# Build:
docker build --ssh default .

Audit an existing image for secrets:

# Inspect all layers for secret patterns
docker save myimage:latest | tar -xO | \
  strings | grep -iE 'AKIA|password|secret|token|BEGIN.*KEY'

# Use dive to inspect layer-by-layer
dive myimage:latest

# Use trivy to scan for exposed secrets
trivy image --scanners secret myimage:latest

13. Distroless and Scratch — Production Hardening

Distroless images contain only the application and its runtime dependencies — no shell, no package manager, no debug tools. This is the correct production posture.

# Go — fully static binary, use scratch
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0: no libc dependency; -ldflags="-w -s": strip debug info
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server .

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
USER 65534:65534   # nobody
EXPOSE 8080
ENTRYPOINT ["/server"]
# Java — use distroless/java
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM gcr.io/distroless/java21-debian12
COPY --from=builder /app/target/app.jar /app.jar
USER nonroot
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
# Python — use distroless/python
FROM python:3.12-slim AS builder
WORKDIR /app
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 --from=builder /app /app
WORKDIR /app
USER nonroot
CMD ["main.py"]

Debugging distroless in production:

# No shell inside distroless — use ephemeral debug containers
kubectl debug -it <pod> \
  --image=gcr.io/distroless/base:debug \   # debug variant has busybox shell
  --target=<container>

# OR use a sidecar debug container
kubectl debug <pod> \
  --image=nicolaka/netshoot \
  --copy-to=debug-pod \   # creates a copy of the pod with the debug container
  -it -- bash

Distroless variants:

Image Use case Shell
distroless/static Static binaries (Go, Rust) No
distroless/base Dynamic linking (libc) No
distroless/java21 JVM apps No
distroless/python3 Python apps No
distroless/static:debug Any — debug variant busybox via sh
scratch Fully static, minimal attack surface No

14. Image Layer Squashing and Size Optimization

# Inspect image layers and their sizes
docker history myimage:latest --no-trunc
docker image inspect myimage:latest | jq '.[0].RootFS.Layers | length'

# Analyze with dive
dive myimage:latest
# Shows: layer sizes, what each layer adds, wasted space (files modified/deleted)

Common size culprits and fixes:

# BAD: separate RUN commands = separate layers, apt cache stays
RUN apt-get update
RUN apt-get install -y curl wget git
RUN rm -rf /var/lib/apt/lists/*   # only cleans the LAST layer; update+install layers still fat

# GOOD: single RUN = single layer, cache cleaned in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl wget && \
    rm -rf /var/lib/apt/lists/*
# BAD: build tools left in final image
FROM ubuntu:22.04
RUN apt-get install -y gcc make libssl-dev
COPY . .
RUN make build
# gcc, make, libssl-dev all in final image — hundreds of MB wasted

# GOOD: builder stage isolates build tools
FROM ubuntu:22.04 AS builder
RUN apt-get install -y gcc make libssl-dev
COPY . .
RUN make build

FROM ubuntu:22.04
COPY --from=builder /app/binary /app/binary
# gcc/make/libssl-dev never in final image

Minimizing node_modules:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production   # no devDependencies

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]