跪拜 Guibai
← Back to the summary

How a 1.2GB Docker Image Shrank to 128MB: The Pitfalls and a Repeatable Playbook

Foreword: A Release Derailed by Image Size

Let me tell you a real story.

We have a Node.js order service. The business code is actually less than 30,000 lines, but the image size ballooned to 1.2GB. The chain reaction was as follows:

In the end, we spent two days squeezing it down to 128MB, and the build time dropped from 6 minutes to 1 minute 40 seconds. This article breaks down the entire process: how to locate where the fat is, how much each step saves, and which optimizations that "look appealing" are actually pitfalls.

The methods here are universal for Node.js / Go / Python / Java. I'll use Node.js as the main thread and Go for comparison.


1. Don't Guess, Quantify First

The first step of optimization is always measurement. Many people immediately switch to alpine, only to find they've saved just 100MB and introduced a bunch of compatibility issues.

1.1 Check the Total Size

docker images myapp --format "{{.Repository}}:{{.Tag}}\t{{.Size}}"

1.2 See Where Each Layer Spends

docker history myapp:latest --no-trunc --format "{{.Size}}\t{{.CreatedBy}}"

The output looks roughly like this (I removed irrelevant lines):

1.02GB   RUN /bin/sh -c npm install
118MB    COPY . /app
0B       WORKDIR /app
997MB    /bin/sh -c #(nop) ADD file:xxx in /

It's immediately obvious: the npm install layer is 1GB, and COPY . /app stuffed another 118MB in. These two are our main battlegrounds.

1.3 A Finer Tool: dive

docker history only shows layer granularity. To see exactly which files were added in each layer, use dive:

# macOS
brew install dive
# Or run directly in a container
docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:latest myapp:latest

dive gives you an efficiency score and highlights files that are "covered/deleted by subsequent layers but still occupy space" in red. The first time I ran it, I saw /app/.git taking up 80MB and immediately understood the problem.

💡 A counter-intuitive point: rm-ing a file in a later layer does not make the image smaller. Because the image is a layered union filesystem, deletion just adds a whiteout marker in the upper layer; the data in the lower layer is still there. This is the root cause of many people wondering "why is it still so big after I clearly deleted things."


2. The First Cut: .dockerignore (5 Minutes, Saves 118MB)

This is the step with the highest ROI, but it's extremely easy to overlook.

The first thing docker build does is package the entire build context (the directory you specified) and send it to the Docker daemon. If your directory contains node_modules, .git, or locally built packages, they will all be sent over, and if you write COPY . /app, they will enter the image intact.

To check if you've been hit, look at the first line of the build output:

Sending build context to Docker daemon  623.4MB

Anything over 10MB should raise an alarm.

A reasonable .dockerignore:

# Dependencies & Build Artifacts
node_modules
dist
build
coverage
*.tsbuildinfo

# Version Control & IDE
.git
.gitignore
.vscode
.idea

# Environment & Secrets (Important!)
.env
.env.*
*.pem
*.key

# Logs & Temp Files
*.log
npm-debug.log*
.DS_Store
tmp/

# Docs & Tests (As needed)
docs/
*.md
!README.md
__tests__/

Note the .env and *.pem lines. Baking secrets into an image is a very common accident — the image will be pushed to a registry, and anyone who can pull it can docker save and then untar it to get your files, even if you rm them later in the Dockerfile (for the reason, see the whiteout note above).

After adding .dockerignore, our build context dropped from 623MB to 2.1MB, and the image immediately lost 118MB. Zero risk, five minutes.


3. The Second Cut: Multi-Stage Builds (Saves 700MB+)

This is the core weapon for slimming down.

3.1 Typical Pre-Optimization Dockerfile

FROM node:20

WORKDIR /app
COPY . .
RUN npm install          # Installed all dependencies, including devDependencies
RUN npm run build        # Needs build tools like typescript / webpack / vite

EXPOSE 3000
CMD ["node", "dist/server.js"]

What's inside this image?

All that's needed at runtime is: Node runtime + production dependencies + dist artifacts. Everything else is dead weight.

3.2 Multi-Stage Refactoring

# syntax=docker/dockerfile:1

# ---------- Stage 1: Install Production Dependencies ----------
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ---------- Stage 2: Build ----------
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                    # devDependencies needed here
COPY . .
RUN npm run build

# ---------- Stage 3: Runtime ----------
FROM node:20-slim AS runner
ENV NODE_ENV=production
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/server.js"]

Key points:

① Why split deps and builder into two stages?

Because production dependencies and build dependencies change at different frequencies. Once split, as long as package-lock.json hasn't changed, the deps stage can fully hit the cache. Moreover, these two stages have no dependency on each other, so BuildKit will execute them in parallel.

npm ci instead of npm install

npm ci installs strictly according to the lockfile, is faster (skips dependency resolution), and deletes existing node_modules first to ensure cleanliness. There's no reason to use npm install in a CI environment.

--omit=dev instead of --only=production

The latter has been deprecated since npm 7. For yarn, use yarn install --production; for pnpm, use pnpm install --prod.

USER node

The official Node image has a built-in node user with uid=1000. Containers running as root by default is a security risk — if the application is compromised via RCE, the attacker is root inside the container, and combined with a kernel exploit, could escape to the host. One line mitigates this.

Note that USER should be placed after all COPY instructions, otherwise you won't be able to write due to permission issues. If your application needs to write files, remember to RUN chown -R node:node /app/data.

After refactoring: 1.2GB → 210MB.


4. The Third Cut: Changing the Base Image (Saves 80MB, But Be Careful)

Three common variants of node:20:

Image Size (uncompressed) Description
node:20 ~1.1GB Debian + full build toolchain
node:20-slim ~200MB Debian slim, build tools removed
node:20-alpine ~135MB Alpine Linux, musl libc

Alpine looks the most appealing, but don't rush to switch.

4.1 Three Real Pitfalls of Alpine

Pitfall 1: musl libc Compatibility

Alpine uses musl instead of glibc. Most pure JS packages are fine, but when native modules are involved (compiled via node-gyp, like sharp, canvas, bcrypt, some database drivers), the precompiled .node binaries are built against glibc and will directly error out on alpine:

Error: Error loading shared library ld-linux-x86-64.so.2: No such file or directory

The fix is to install the build toolchain and compile on the spot:

RUN apk add --no-cache --virtual .build-deps \
      python3 make g++ \
 && npm ci --omit=dev \
 && apk del .build-deps

But this significantly increases build time, and the space saved might be given back.

Pitfall 2: Different DNS Resolution Behavior

musl's resolver does not support multiple attempts for search domains in some environments and does not automatically retry TCP. In K8s, this manifests as sporadic DNS resolution failures, which are very hard to troubleshoot. This issue is more easily triggered in high-concurrency scenarios.

Pitfall 3: Timezone

Alpine does not include timezone data by default. new Date().toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'}) will not get the correct result. You need:

RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai

4.2 My Recommendation

Default to -slim, and only use alpine when size is extremely critical and the dependency tree has no native modules.

Switching from the 1.1GB node:20 to node:20-slim already gives you 80% of the benefit; moving from slim to alpine only saves an extra 65MB, while taking on the three types of risks above. For most teams, this trade-off isn't worth it.

We ultimately chose slim. 210MB → 210MB (because we already used slim in the previous step).


5. The Fourth Cut: BuildKit Cache Mount (Same Size, 3x Faster Builds)

Beyond slimming down, build speed is equally worth optimizing.

The pain point of traditional Dockerfiles is: if package-lock.json changes by even one byte, the npm ci layer is invalidated, and all dependencies must be re-downloaded from the network.

BuildKit's cache mount can mount the npm cache directory as a volume that persists across builds:

# syntax=docker/dockerfile:1

FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Three points to note:

  1. The first line # syntax=docker/dockerfile:1 cannot be omitted, otherwise the --mount syntax won't be recognized;
  2. The contents of the cache mount do not enter the image layer, so it has zero impact on size;
  3. BuildKit is required (enabled by default in Docker 23+; for older versions use DOCKER_BUILDKIT=1 docker build ...).

Corresponding directories for other languages:

Tool cache target
npm /root/.npm
pnpm /root/.local/share/pnpm/store
yarn (classic) /usr/local/share/.cache/yarn
pip /root/.cache/pip
Go /root/.cache/go-build and /go/pkg/mod
Maven /root/.m2
Cargo /usr/local/cargo/registry

After adding this, our dependency installation dropped from 95 seconds to 12 seconds (on cache hit).

5.1 How to Reuse Cache in CI

Locally, cache mount works automatically, but CI gets a fresh runner each time. Use --cache-from / --cache-to to push the cache to a container registry:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/myapp:buildcache \
  --cache-to   type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
  -t registry.example.com/myapp:$GIT_SHA \
  --push .

mode=max pushes all intermediate layers, resulting in a higher cache hit rate at the cost of a larger cache image.


6. The Fifth Cut: distroless (The Killer Move for Go Scenarios)

At this point, the Node.js side has been squeezed to 128MB (I'll explain later how we went from 210 to 128), which is basically the floor — the Node runtime itself is about 40MB.

But if you're writing a language that can compile statically, like Go / Rust, you can go even more extreme.

6.1 Extreme Slimming for Go

# syntax=docker/dockerfile:1

# ---------- Build ----------
FROM golang:1.22 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

# ---------- Runtime ----------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Parameter explanation:

Result: golang:1.22 base image is ~830MB, final image = 2MB + your binary. Our Go gateway service ended up at 14.3MB.

6.2 Node.js Can Also Use distroless

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps    /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["dist/server.js"]

Note that node is not written in the CMD here. The distroless nodejs image has already set ENTRYPOINT to ["/nodejs/bin/node"], so you only need to provide the arguments. Writing CMD ["node", "dist/server.js"] would result in node node dist/server.js, causing the startup to fail immediately — this is the most common pitfall.

This step took us from 210MB down to 128MB.

6.3 How to Debug distroless?

No shell means docker exec -it xxx sh will directly error out. Three approaches:

Method 1: Use the :debug tag

docker run -it --entrypoint sh gcr.io/distroless/static-debian12:debug

The :debug variant has busybox built-in, for troubleshooting only, do not use in production.

Method 2: K8s Ephemeral Containers

kubectl debug -it my-pod \
  --image=busybox:1.36 \
  --target=my-container \
  --share-processes

The ephemeral container shares the PID / network namespace with the target container, allowing you to directly ps, netstat, and look at /proc/1/.

Method 3: Build Observability into the Application

Ultimately, needing to curl inside a container to troubleshoot a problem indicates insufficient observability. With health check endpoints, structured logging, metrics, and tracing properly set up, you won't need to enter the container in the vast majority of cases.


7. Easily Overlooked Details

7.1 Layer Order Determines Cache Hit Rate

Principle: Put things that change less frequently further forward.

# ❌ Wrong: Changing one line of business code requires reinstalling dependencies
COPY . .
RUN npm ci --omit=dev

# ✅ Correct: Reinstall only when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

This change doesn't affect size, but allows 90% of builds to skip dependency installation.

7.2 Combine RUN Instructions, But Don't Overdo It

Each RUN creates a layer. Classic pattern:

# ❌ apt cache stays in the first layer, cannot be deleted
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ Complete installation and cleanup within the same layer
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends is also crucial; it avoids pulling in a bunch of recommended dependencies (sometimes saving 100MB+).

But don't cram everything into one RUN — that way, any single change invalidates the entire layer, making caching less efficient. A reasonable granularity is: group by "frequency of change".

7.3 Don't Use latest as a Base Image

FROM node:latest        # ❌ Not reproducible; builds today and tomorrow might differ
FROM node:20            # ⚠️ Will follow 20.x minor versions
FROM node:20.11.1-slim  # ✅ Explicit version

A stricter approach is to pin the digest:

FROM node:20.11.1-slim@sha256:xxxxxxxx...

Combine this with Renovate / Dependabot to automatically open PRs for upgrades, balancing reproducibility and security updates.

7.4 Add HEALTHCHECK and OCI Labels

LABEL org.opencontainers.image.source="https://github.com/acme/myapp" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.licenses="MIT"

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]

Note that distroless doesn't have curl or wget, so health checks must be implemented using the language runtime itself (the example above uses Node 18+'s built-in fetch). In K8s environments, livenessProbe / readinessProbe are more recommended; the HEALTHCHECK in the Dockerfile is for docker run and Swarm.

7.5 Run a Security Scan as a Side Benefit

Once the size comes down, the CVE count will also drop off a cliff. Verify it:

# Docker official
docker scout cves myapp:latest

# Or Trivy
trivy image --severity HIGH,CRITICAL myapp:latest

Our image's CVEs dropped from 214 (12 HIGH) to 3 LOW. This isn't magic — that 1GB you deleted contained things like perl, git, gcc, and python2, which have nothing to do with your business but continuously have vulnerabilities.


8. Final Scorecard

Stage Action Image Size Build Time (Cold/Hot)
Start FROM node:20 + COPY . . 1.24 GB 6m10s / 5m40s
Step 1 Add .dockerignore 1.12 GB 5m20s / 4m50s
Step 2 Multi-stage build + slim 210 MB 3m40s / 2m30s
Step 3 BuildKit cache mount 210 MB 3m30s / 48s
Step 4 distroless nodejs 128 MB 3m35s / 52s

Control group (Go gateway service):

Stage Image Size
FROM golang:1.22 single stage 968 MB
Multi-stage + debian:12-slim 92 MB
Multi-stage + distroless/static 14.3 MB

Pull time (gigabit LAN): 1.24GB is about 18 seconds, 128MB is about 2.4 seconds. In cross-region pulls or scenarios requiring scaling 50 Pods simultaneously, this gap is amplified significantly.


9. A Checklist You Can Directly Copy

To optimize any image, go through this sequence:


Conclusion

For image slimming, 80% of the benefit comes from the first two steps: .dockerignore and multi-stage builds. These two steps have almost no technical risk and can be done in half an hour.

Alpine, distroless, and cache mounts belong to the "advanced tier." Whether they are worth doing depends on your specific scenario: for a service deployed 50 times a day, build speed is far more valuable than the last few tens of MB; for edge devices or Serverless scenarios sensitive to cold starts, every bit of size reduction is tangible benefit.

The worst thing is doing it backwards — chasing alpine's 65MB saving, introducing sporadic DNS failures from musl, and then spending three days troubleshooting a production issue. Measure first, then decide; don't optimize for optimization's sake.

If this was helpful, feel free to like and bookmark. What pitfalls have you encountered in image optimization? Welcome to share in the comments.