Ravindra BagaleCourses & study guides मराठी

Chapter 56: Docker CI/CD with Jenkins and ECR, Deploy to Amazon ECS, Troubleshooting, Best Practices and Interview Questions

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.

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.

Base imagefull OS: 1 GB, slim: 200 MBBuild toolscompilers, git, pythonnode_modulesdev and prod packagesYour codeusually tinyFinal imageonly what runs
Picture 1: where the size comes from

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.

node:20 + everything1.12 GBslim base642 MB.dockerignore + omit dev371 MBmulti-stage248 MBdistroless138 MB
Picture 2: the shrinking path

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.

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.

Lab: try at home

  1. Measure one image with docker image ls and docker history.
  2. Switch to a slim base and measure.
  3. Add a .dockerignore and compare the build context size.
  4. Install only production dependencies.
  5. Convert the Dockerfile to multi-stage.
  6. Try a distroless final stage.
  7. 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.