56.4 Image size: make images small, fast and safe
Friends, a big image is slow to push, slow to pull, slow to start a new server, and carries hundreds of extra packages that attackers can use. A small image is faster and safer. Today, step by step, we take one fat Node.js image of about 1.1 GB and shrink it below 150 MB. We measure first, then change one thing at a time and measure again.
मित्रांनो, मोठी image push करायला हळू, pull करायला हळू, नवीन server सुरू करायला हळू, आणि attackers वापरू शकतील असे शेकडो जास्तीचे packages घेऊन येते. छोटी image जलद आणि सुरक्षित असते. आज step by step आपण सुमारे 1.1 GB ची एक जाड Node.js image 150 MB च्या खाली आणू. आधी मोजू, मग एका वेळी एकच गोष्ट बदलू आणि पुन्हा मोजू.
दोस्तों, बड़ी image push करने में धीमी, pull करने में धीमी, नया server शुरू करने में धीमी होती है, और सैकड़ों extra packages लाती है जिन्हें attackers इस्तेमाल कर सकते हैं. छोटी image तेज़ और सुरक्षित होती है. आज step by step हम लगभग 1.1 GB की एक मोटी Node.js image को 150 MB से नीचे लाएँगे. पहले नापेंगे, फिर एक बार में एक चीज़ बदलेंगे और फिर नापेंगे.
1. Start from a story
At Swiggy, a team noticed that new servers took four minutes to start serving during evening peak. Most of that time went into pulling one image of 1.2 GB on every new machine.
An engineer named Arjun opened the Dockerfile. It used the full node image, copied the whole project folder including node_modules and test files, installed dev dependencies and kept the build tools.
He changed four things: a slim base, a .dockerignore file, a multi-stage build and production-only dependencies. The image dropped to 140 MB. New servers started serving in under a minute, and the scanner found 90 percent fewer holes.
Today we repeat Arjun's work, one measured step at a time.
2. Measure first
Step 1: docker image ls
Why: See the size.
What: Always measure before you change anything, so you can prove each step helped.
Example 1: Size of one image
Type this:
docker image ls shop-web --format '{{.Tag}} {{.Size}}'
You will see:
fat 1.12GB
What it means: Our starting point.
Example 2: Compare base images
Type this:
docker pull -q node:20 && docker pull -q node:20-slim && docker pull -q node:20-alpine && docker image ls node --format '{{.Tag}} {{.Size}}'
You will see:
20 1.1GB
20-slim 219MB
20-alpine 135MB
What it means: The base alone decides most of the size.
Example 3: Total disk
Type this:
docker system df --format '{{.Type}} {{.Size}}' | head -1
You will see:
Images 6.42GB
What it means: How much all images use.
Step 2: docker history
Why: Which layer is heavy?
What: Each Dockerfile instruction creates a layer. history shows each layer's size, so you can see which line added the weight.
Example 4: Layers
Type this:
docker history shop-web:fat --format '{{.Size}}\t{{.CreatedBy}}' | head -5
You will see:
412MB RUN npm install
38MB COPY . .
...
What it means: npm install is the heavy layer.
Example 5: No truncation
Type this:
docker history --no-trunc --format '{{.CreatedBy}}' shop-web:fat | grep -c RUN
You will see:
3
What it means: Three RUN layers in this image.
Example 6: Sum of our layers
Type this:
docker history shop-web:fat --format '{{.Size}}' | head -4
You will see:
412MB
38MB
0B
0B
What it means: Our lines add 450 MB on top of the base.
Step 3: dive for deeper looks
Why: See wasted files.
What: dive is a free tool that shows the files in each layer and a wasted space score. Run it as a container so you install nothing.
Example 7: Run dive
Type this:
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive shop-web:fat --ci
You will see:
efficiency: 87.4 %
wastedBytes: 96 MB
What it means: 96 MB is files added and later deleted.
Example 8: Set a limit in CI
Type this:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive shop-web:fat --ci --lowestEfficiency=0.95 ; echo exit=$?
You will see:
FAIL: lowestEfficiency
exit=1
What it means: CI can block wasteful images.
Example 9: Read the socket warning
Type this:
echo 'the docker.sock mount gives dive full Docker access; use it only on your own machine'
You will see:
the docker.sock mount gives dive full Docker access; use it only on your own machine
What it means: Never mount the socket in shared environments.
3. Shrink step by step
Step 4: Use a slim base
Why: The biggest single win.
What: Change FROM node:20 to node:20-slim. Slim is Debian with the extras removed. Alpine is smaller but uses musl, which can break some native packages.
Example 10: Change the line
Type this:
sed -i 's/^FROM node:20$/FROM node:20-slim/' Dockerfile && head -1 Dockerfile
You will see:
FROM node:20-slim
What it means: One line changed.
Example 11: Rebuild
Type this:
docker build -q -t shop-web:slim . && docker image ls shop-web --format '{{.Tag}} {{.Size}}'
You will see:
slim 642MB
fat 1.12GB
What it means: Almost half gone.
Example 12: Check it still runs
Type this:
docker run --rm -d -p 3000:3000 --name t shop-web:slim && sleep 2 && curl -s localhost:3000/health && docker rm -f t
You will see:
{"status":"ok"}
t
What it means: Smaller and still working.
Step 5: Add .dockerignore
Why: Do not send junk to the build.
What: .dockerignore lists files that must not go into the build context: node_modules, .git, tests, logs and local env files. This makes builds faster and keeps secrets out.
Example 13: Create the file
Type this:
printf '%s\n' node_modules .git tests '*.log' .env coverage Dockerfile > .dockerignore && cat .dockerignore
You will see:
node_modules
.git
tests
*.log
.env
coverage
Dockerfile
What it means: Seven lines.
Example 14: Build context size
Type this:
docker build -t shop-web:ign . 2>&1 | grep -m1 'transferring context'
You will see:
=> => transferring context: 1.20MB
What it means: Was 310 MB before.
Example 15: Size now
Type this:
docker image ls shop-web:ign --format '{{.Size}}'
You will see:
590MB
What it means: Old node_modules and .git are gone.
Step 6: Only production dependencies
Why: Dev tools do not run in production.
What: npm ci --omit=dev installs exactly the lock file versions without test and build tools. For Python use pip install --no-cache-dir.
Example 16: Change install
Type this:
sed -i 's/RUN npm install/RUN npm ci --omit=dev/' Dockerfile && grep npm Dockerfile
You will see:
RUN npm ci --omit=dev
What it means: Exact and smaller.
Example 17: Rebuild and measure
Type this:
docker build -q -t shop-web:prod . && docker image ls shop-web:prod --format '{{.Size}}'
You will see:
371MB
What it means: Dev packages removed.
Example 18: Python version
Type this:
printf '%s\n' 'RUN pip install --no-cache-dir -r requirements.txt' >> Dockerfile.py && tail -1 Dockerfile.py
You will see:
RUN pip install --no-cache-dir -r requirements.txt
What it means: No pip cache saved in the layer.
Step 7: Multi-stage build
Why: Build in one stage, ship another.
What: The first stage has all the tools and builds the app. The final stage starts from a clean small base and copies only the built output and production modules.
Example 19: The Dockerfile
Type this:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node","dist/server.js"]
You will see:
(Dockerfile)
What it means: Build tools never reach the final image.
Example 20: Build
Type this:
docker build -q -t shop-web:ms . && docker image ls shop-web:ms --format '{{.Size}}'
You will see:
248MB
What it means: Only runtime files.
Example 21: Build tools gone
Type this:
docker run --rm shop-web:ms sh -c 'which gcc || echo no gcc'
You will see:
no gcc
What it means: Nothing for an attacker to compile with.
Step 8: Order layers so the cache helps
Why: Small images and fast rebuilds go together.
What: Docker reuses a layer only if that line and every line above it did not change. Copy the package files and install dependencies first, then copy the source code. A code change then rebuilds only the last small layer instead of downloading every package again.
Example 22: Slow order
Type this:
COPY . .
RUN npm ci --omit=dev
You will see:
(every code change reinstalls all packages)
What it means: Any edited file breaks the cache for npm ci.
Example 23: Fast order
Type this:
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
You will see:
(packages cached until package.json changes)
What it means: Dependencies come from cache on most builds.
Example 24: See the cache work
Type this:
echo '// tiny change' >> src/server.js && docker build -t shop-web:ms . 2>&1 | grep -c CACHED
You will see:
4
What it means: Four layers reused; only the code layer was rebuilt in seconds.
Step 9: Combine RUN lines and clean in the same layer
Why: Deleted files still count otherwise.
What: If you install packages in one RUN and delete the cache in the next, the cache is still stored in the earlier layer. Install and clean in the same RUN.
Example 25: Wrong
Type this:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
You will see:
(three layers)
What it means: The lists stay in layer one.
Example 26: Right
Type this:
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
You will see:
(one layer)
What it means: Cache removed before the layer is saved.
Example 27: Measure
Type this:
docker build -q -t t:one -f Dockerfile.one . && docker build -q -t t:three -f Dockerfile.three . && docker image ls t --format '{{.Tag}} {{.Size}}'
You will see:
one 97.1MB
three 128MB
What it means: 31 MB saved by one line.
Step 10: Go further: alpine and distroless
Why: For the last megabytes.
What: Alpine or distroless bases remove the shell and package manager. Distroless is great for production; use docker debug or a debug variant when you need to look inside.
Example 28: Distroless final stage
Type this:
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["dist/server.js"]
You will see:
(final stage)
What it means: No shell, no apt, very small.
Example 29: Size
Type this:
docker build -q -t shop-web:dl -f Dockerfile.dl . && docker image ls shop-web:dl --format '{{.Size}}'
You will see:
138MB
What it means: Below our 150 MB goal.
Example 30: No shell
Type this:
docker run --rm --entrypoint sh shop-web:dl ; echo exit=$?
You will see:
exec: "sh": executable file not found
exit=127
What it means: Exactly what we want in production.
4. Prove it
Step 11: Compare and scan
Why: Show the result.
What: List all versions side by side and scan the old and new image. Fewer packages means fewer holes.
Example 31: All tags
Type this:
docker image ls shop-web --format '{{.Tag}}\t{{.Size}}'
You will see:
dl 138MB
ms 248MB
prod 371MB
slim 642MB
fat 1.12GB
What it means: From 1.12 GB to 138 MB.
Example 32: Scan before
Type this:
trivy image -q --severity HIGH,CRITICAL shop-web:fat | grep Total
You will see:
Total: 214 (HIGH: 187, CRITICAL: 27)
What it means: Many holes in the fat image.
Example 33: Scan after
Type this:
trivy image -q --severity HIGH,CRITICAL shop-web:dl | grep Total
You will see:
Total: 3 (HIGH: 3, CRITICAL: 0)
What it means: Almost nothing left.
Step 12: Pull time
Why: Why it matters.
What: Smaller images start faster on new servers. Measure the pull on a clean machine.
Example 34: Fat pull
Type this:
docker rmi -f shop-web:fat >/dev/null; time docker pull -q registry.example.com/shop-web:fat
You will see:
real 0m48.2s
What it means: Almost a minute.
Example 35: Small pull
Type this:
time docker pull -q registry.example.com/shop-web:dl
You will see:
real 0m6.1s
What it means: Eight times faster.
Example 36: Clean test images
Type this:
docker rmi shop-web:slim shop-web:prod shop-web:ign t:one t:three | wc -l
You will see:
12
What it means: Keep only what you ship.
5. Common mistakes and how Arjun avoids them
Mistake one: no .dockerignore, so node_modules and .git go into the image. Arjun adds it first in every project.
Mistake two: deleting files in a later RUN line. He installs and cleans in the same RUN.
Mistake three: jumping to alpine and breaking native packages. He tries slim first and tests before going further.
Mistake four: never measuring. He records the size after every change.
Check yourself
Q1. What is usually the biggest win?
Changing to a slim or smaller base image.
Q2. Why must you clean the cache in the same RUN line?
A deleted file in a later layer still takes space in the earlier layer.
Q3. What does a multi-stage build give you?
Build tools stay in the first stage; only runtime files ship.
Q4. Which command shows the size of each layer?
docker history.
Friends, measure first, then slim base, .dockerignore, production dependencies, multi-stage, one-layer cleanup and finally distroless. Did you understand? Let us try it once more. Take one image of your own, write the size after each step in a table, and stop when it is at least half the size.
मित्रांनो, आधी मोजा, मग slim base, .dockerignore, production dependencies, multi-stage, एका layer मध्ये cleanup आणि शेवटी distroless. तुमची स्वतःची एक image घ्या, प्रत्येक step नंतरचा size एका table मध्ये लिहा, आणि ती किमान अर्धी झाल्यावर थांबा.
दोस्तों, पहले नापिए, फिर slim base, .dockerignore, production dependencies, multi-stage, एक layer में cleanup और आखिर में distroless. अपनी एक image लीजिए, हर step के बाद का size एक table में लिखिए, और जब वह कम से कम आधी हो जाए तब रुकिए.
Ravindra Bagale's Tip
Put .dockerignore in the project before you write the first line of the Dockerfile. It is the cheapest speed-up you will ever get.
Dockerfile ची पहिली ओळ लिहिण्याआधी project मध्ये .dockerignore ठेवा. तुम्हाला मिळणारा हा सर्वात स्वस्त speed-up आहे.
Dockerfile की पहली line लिखने से पहले project में .dockerignore रखिए. यह आपको मिलने वाला सबसे सस्ता speed-up है.
Lab: try at home
- Measure one image with docker image ls and docker history.
- Switch to a slim base and measure.
- Add a .dockerignore and compare the build context size.
- Install only production dependencies.
- Convert the Dockerfile to multi-stage.
- Try a distroless final stage.
- Scan the first and last image with Trivy and compare.
Recap
- Measure with image ls, history and dive.
- Slim base and .dockerignore give the first big wins.
- Production dependencies and multi-stage remove build tools.
- Install and clean in one RUN line.
- Smaller images pull faster and have fewer holes.