Intro
Production containers are easy to start but harder to operate consistently. A practical guide shows what to configure, which command proves the configuration works, and what failure looks like when the setup is wrong. This article explains how to reduce Docker image size with multi-stage builds, BuildKit, layer caching, and Dockerfile patterns that make builds faster and easier to maintain. The goal is to understand the moving parts, test them locally, and avoid surprises when the same pattern is reused in CI/CD or a production-like environment.
Workflow Overview
Start by identifying the resource you want to optimize (the final image), the configuration change that affects it (the Dockerfile structure and BuildKit features), and the command that proves the setup works (building with BuildKit enabled and measuring the resulting image size). Keep the workflow practical: configure one thing, verify the observed state, then document what breaks when the setup is missing, misconfigured, or used in a production-like environment.
In practice, the Workflow Overview is where teams discover hidden assumptions. Local paths, image tags, network names, environment files, resource limits, and permissions can behave differently across laptops, runners, and production hosts. Make those assumptions explicit before relying on the setup.
Key concepts: Docker image optimization, multi-stage Docker builds, Docker BuildKit, Docker layer caching, Dockerfile best practices. Related areas such as Docker production operations, Dockerfile best practices, and Docker security matter because container behavior is rarely isolated: a storage choice can affect deployment, debugging, backup, and rollback decisions.
Concrete example: a Go service built in a builder stage using the golang:1.22 image, then copied into a minimal scratch stage. Enable BuildKit with DOCKER_BUILDKIT=1 docker build -t myapp:latest . and verify the size with docker images myapp:latest. If the final image is larger than 10 MB, the multi-stage copy likely missed a --chown or left build artifacts in the final layer.
Practical Docker checks: run docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" to see what is running, docker logs <container> --tail 100 to read recent failures, and docker inspect <container> when you need mounts, networks, environment variables, or health status. For Compose projects, use docker compose ps, docker compose logs -f <service>, and docker compose exec <service> sh` to debug without changing the image.
When data is involved, confirm where files are stored before changing containers. A named volume such as app_data:/var/lib/app is managed by Docker and easier to reuse across container rebuilds. A bind mount such as ./data:/var/lib/app maps a host directory directly and is useful for local development, but it can expose permission, portability, and backup problems if the same path does not exist on another machine.
A small production-like local test should include a restart test: stop the container, recreate it, and confirm the application still sees the expected files. If the data disappears, the service was probably writing to the container filesystem instead of a volume or mount.
Local Pilot Plan
The Local Pilot Plan turns the workflow into a repeatable, minimal experiment. Create a temporary directory, add a tiny Go program (main.go that prints "hello" and exits), and write a two-stage Dockerfile:
# syntax=docker/dockerfile:1.4
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app main.go
FROM scratch
COPY --from=builder /app /app
ENTRYPOINT [\\app]
Build with BuildKit enabled: DOCKER_BUILDKIT=1 docker build -t pilot:local .. Measure the image size: docker images pilot:local. Expect a result under 5 MB. Run the container: docker run --rm pilot:local and verify the output. Then stop and remove the container, run it again, and confirm the same output appears — this validates that the binary is self-contained and no hidden layers remain.
During the pilot, watch for common pitfalls:
- Forgetting
CGO_ENABLED=0produces a dynamically linked binary that fails inscratch. - Omitting
--from=buildercopies the whole builder image, inflating size. - Not using
docker/dockerfile:1.4syntax disables BuildKit features such as cache mounts.
Document the exact commands used to build, run, inspect, stop, and recreate the container. Compare the result with related areas such as Docker production operations, Dockerfile best practices, and Docker security so the implementation fits the larger operating model.
Conclusion
Docker image optimization with multi-stage builds and BuildKit works best when the team treats the configuration as something to test, not just something to copy. The safest path is to keep examples small, run the commands locally, and confirm the expected behavior before adding more services or automation.
For a next step, choose one service and document the exact commands used to build, run, inspect, stop, and recreate it. Then compare the result with related areas such as Docker production operations, Dockerfile best practices, and Docker security so the implementation fits the larger operating model.
A reliable container workflow should make failure visible: logs should be easy to find, persistent data should survive container rebuilds, and local behavior should be close enough to production to catch mistakes early.