Your Docker image builds take 15 minutes in CI/CD. The Dockerfile has 20 layers and the final image is 2.3GB. Walk through your complete optimization strategy to achieve sub-2-minute builds and sub-200MB images.
Quick Answer
Implement multi-stage builds to separate build and runtime, optimize layer ordering for cache hits, use BuildKit with cache mounts for package managers, switch to distroless/Alpine base images, leverage remote cache (registry-based), and parallelize independent build stages.
Detailed Answer
Analysis Phase
First, understand where time is spent: docker build --progress=plain shows per-layer timing. docker history <image> shows layer sizes. Common culprits: package installation without cache mounts (re-downloads every build), copying entire codebase before installing dependencies (invalidates cache), using full OS base images (ubuntu:22.04 = 77MB vs alpine = 7MB vs distroless = 2MB).
Layer Caching Optimization
Docker caches are invalidated when a layer's inputs change AND all subsequent layers are rebuilt. Order from least-changing to most-changing: 1. Base image (rarely changes) 2. System dependencies (apt-get, weekly changes) 3. Language dependencies (package.json/go.mod, changes with features) 4. Application code (changes every commit)
BuildKit Advanced Features
- --mount=type=cache: Persist package manager caches between builds (pip, npm, apt) - --mount=type=secret: Pass secrets without baking into layers - --mount=type=ssh: Forward SSH agent for private repo access - Parallel stage execution: Independent stages run concurrently - Remote cache: Push/pull cache to registry for CI/CD cache sharing
Image Size Reduction
- Multi-stage: Build in golang/node image, copy binary to distroless - Use .dockerignore (exclude .git, node_modules, test files) - Combine RUN commands with && to reduce layer count - Use --no-install-recommends for apt - Remove cache files in the same layer they're created
CI/CD Cache Strategy
Use BuildKit's --cache-from and --cache-to with registry-backed cache. Push cache on main branch, pull cache on PRs. This gives PR builds access to the main branch's layer cache.
Code Example
# Optimized multi-stage Dockerfile for Go service
# syntax=docker/dockerfile:1.7
# Stage 1: Dependencies (cached independently)
FROM golang:1.22-alpine AS deps
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Stage 2: Build (only rebuilds when source changes)
FROM deps AS builder
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /app/server ./cmd/server
# Stage 3: Runtime (minimal image)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
COPY --from=builder /app/migrations /migrations
EXPOSE 8080
ENTRYPOINT ["/server"]
# Build with remote cache for CI/CD
docker buildx build \
--platform linux/amd64 \
--cache-from type=registry,ref=myregistry.io/myapp:buildcache \
--cache-to type=registry,ref=myregistry.io/myapp:buildcache,mode=max \
--tag myregistry.io/myapp:$(git rev-parse --short HEAD) \
--push .
# Node.js optimized example
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
# .dockerignore
.git
node_modules
*.md
tests/
coverage/
.env*Interview Tip
Build optimization questions test practical experience. Quantify improvements (15min -> 2min, 2.3GB -> 180MB). Know BuildKit cache mounts - they're the single biggest optimization for package manager steps. Mention the layer ordering principle and why copying package.json before source code matters for caching.