Docker Images and Registries
An image is a stack of read-only layers. Understanding that one fact explains build caching, image size, and why deleted secrets are still in there.
What is it?#
An image is the packaged, immutable result of a build: your application, its dependencies, and a small filesystem, ready to run anywhere.
It is not one file. It is a stack of layers, each recording the filesystem changes made by one build instruction.
Why layers exist#
Two big wins, and one trap.
Caching. If an instruction and its inputs have not changed, Docker reuses the existing layer instead of re-running it. This is why build order matters enormously.
Sharing. Ten images built FROM node:22-alpine store that base once. Pulling the tenth downloads only its unique layers.
The trap: layers are append-only. A file added in one layer and deleted in a later one is still present in the earlier layer, and anyone with the image can extract it.
How to use it#
Write a Dockerfile that caches well#
Order instructions from least likely to change to most likely:
FROM node:22-alpine
WORKDIR /app
# Changes rarely → stays cached across every source edit
COPY package*.json ./
RUN npm ci --omit=dev
# Changes constantly → put it last
COPY . .
CMD ["node", "server.js"]Multi-stage builds — the fix for huge images#
Compile with a full toolchain, then copy only the result into a clean image:
# ---- stage 1: build (has the full toolchain) ----
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- stage 2: runtime (only what is needed to run) ----
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]Nothing from stage 1 exists in the final image — no compilers, no build caches, no dev dependencies. Typically a 5–10× size reduction, and a much smaller attack surface.
Always add .dockerignore#
Without it, COPY . . uploads your entire directory into the build:
node_modules
.git
.env
*.log
dist
DockerfileThis makes builds faster and stops local secrets and .git history from ending up in the image.
Choosing a base image#
| Base | Size | Trade-off |
|---|---|---|
node:22 | ~1.1 GB | Everything included. Fine for a build stage |
node:22-slim | ~250 MB | Debian, trimmed. Good default |
node:22-alpine | ~150 MB | Smallest with a shell. Uses musl, not glibc |
gcr.io/distroless/nodejs22 | ~120 MB | No shell at all. Most secure, hardest to debug |
Tags, digests, and why latest is dangerous#
docker build -t myapp:1.4.2 -t myapp:latest .
docker push myapp:1.4.2| Reference | Immutable? |
|---|---|
myapp:latest | No — just a label someone can move |
myapp:1.4.2 | Only by convention. It can be overwritten |
myapp@sha256:abc123... | Yes — a content hash |
# Pin exactly, by digest — the same bytes forever
docker pull myapp@sha256:abc123...
# Find the digest of what you have
docker inspect --format='{{index .RepoDigests 0}}' myapp:1.4.2Registries#
docker login registry.example.com
docker tag myapp:1.4.2 registry.example.com/team/myapp:1.4.2
docker push registry.example.com/team/myapp:1.4.2
docker pull registry.example.com/team/myapp:1.4.2An image name is really registry/namespace/name:tag. When the registry is omitted, Docker assumes Docker Hub — which is why nginx works and why hitting Docker Hub's anonymous rate limit in CI is so common. Authenticate, or use a mirror.
Inspecting and shrinking#
docker images # sizes
docker history myapp:1.4.2 # SIZE PER LAYER — find the fat one
docker image inspect myapp:1.4.2
# What is actually inside? A shell in a throwaway container.
docker run --rm -it --entrypoint sh myapp:1.4.2
docker system df # what is using disk
docker builder prune # clear the build cache
docker system prune -a # remove unused images toodocker history is the tool for size problems. It shows exactly which instruction added the 400 MB.
COPY .env /app/.env
RUN rm /app/.env # does NOT remove it from the imageThe earlier layer still contains the file. docker history reveals it and it can be extracted from the layer tarball.
Correct approaches:
- Runtime: pass secrets as environment variables or mounted files.
- Build time: BuildKit secret mounts, which are never persisted:
RUN --mount=type=secret,id=npmrc \
npm ci --userconfig=/run/secrets/npmrcdocker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .If a secret has already been pushed: rotate the secret. Rewriting the image does not help — it may already have been pulled.
COPY .env /app/.env ← طبقة ١: الملف اتحفظ هنا
RUN rm /app/.env ← طبقة ٢: علامة "مش موجود" بسإنت ما مسحتش حاجة. الطبقة الأولى لسه فيها الملف كامل، و docker history بتوريه، وأي حد يقدر يطلّعه من أرشيف الطبقة.
الطريقتين الصح:
١. وقت التشغيل — environment variables أو ملفات مركّبة. ده الطبيعي والمعتاد.
٢. وقت البناء (لو مضطر تستخدم سيكرت عشان تنصّب حاجة خاصة) — BuildKit secret mounts، ودي عمرها ما بتتحفظ في أي طبقة:
RUN --mount=type=secret,id=npmrc \
npm ci --userconfig=/run/secrets/npmrcdocker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .الـ secret هنا بيبقى موجود بس أثناء تنفيذ السطر ده، وبعدها بيختفي ومفيش أي أثر ليه في الصورة.
دي نقطة الناس بتقاوم فيها، فخد بالك.
إنك تعيد بناء الصورة أو تمسحها من الـ registry مش بيحل حاجة، عشان:
- ممكن حد نزّلها خلاص
- ممكن تكون في كاش نودات، أو في mirror، أو في باك أب
- ممكن تكون في لوجز الـ CI
السيكرت اللي خرج من إيدك، خرج. الحل الوحيد إنك تعمله rotate — تولّد واحد جديد وتبطّل القديم.
ودي بالمناسبة إجابة سؤال انترفيو شائع: «لقينا مفتاح في صورة على الـ registry، نعمل إيه؟» — اللي بيقول «نمسح الصورة» بيرسب. اللي بيقول «نبطّل المفتاح الأول، وبعدين نظبّط البناء، وبعدين نشوف اتسرّب لفين» بينجح.
Security basics worth doing#
# Run as a non-root user. Many base images provide one.
USER node
# Or create one
RUN adduser -D -u 10001 appuser
USER appuserdocker scout cves myapp:1.4.2 # scan for known vulnerabilities
# or: trivy image myapp:1.4.2Containers run as root by default. If an attacker escapes the process, root inside the container is a much better starting position than an unprivileged user. Adding two lines removes that.
Common problems#
| Symptom | Cause | Fix |
|---|---|---|
| Every build is slow | COPY . . before dependency install | Reorder |
| Image is 1.5 GB | Build tools in the final image | Multi-stage + slim base |
exec format error | Wrong CPU architecture (arm64 vs amd64) | docker build --platform linux/amd64 |
no such file or directory on a binary that exists | musl vs glibc on Alpine | Use -slim |
| Code change not taking effect | Ran the old image, or cached layer | Check the tag; --no-cache to confirm |
toomanyrequests in CI | Docker Hub anonymous rate limit | Authenticate, or mirror |
| Different pods, different code | Deployed latest | Deploy immutable tags |
جدول سريع لأشهر المشاكل:
| العَرَض | السبب | الحل |
|---|---|---|
| كل بناء بطيء | COPY . . قبل تنصيب الـ dependencies | رتّب تاني |
| الصورة ١.٥ جيجا | أدوات البناء في الصورة النهائية | multi-stage + base أصغر |
exec format error | معمارية معالج غلط (arm64 مقابل amd64) | docker build --platform linux/amd64 |
no such file وبرنامج موجود قصادك | musl مقابل glibc على alpine | استخدم -slim |
| عدّلت الكود ومش بيبان | شغّال الصورة القديمة، أو طبقة من الكاش | شيك التاج، وجرّب --no-cache |
toomanyrequests في الـ CI | حد Docker Hub للمجهولين | اعمل login، أو mirror |
| بودات مختلفة بكود مختلف | نشرت latest | انشر تاج ثابت |
والاتنين اللي بيضيّعوا أطول وقت:
exec format error — دي معناها معمارية، مش صلاحيات ولا ملف ناقص. بتحصل كتير أوي لو إنت على ماك M1/M2/M3 وبتبني صورة وبتنشرها على سيرفر amd64. الحل:
docker build --platform linux/amd64 -t myapp .و no such file or directory على برنامج إنت شايفه بعينك — دي كلاسيكية alpine. البرنامج موجود فعلاً، بس هو مبني على glibc و alpine بتستخدم musl، فالمكتبة اللي بيدوّر عليها مش موجودة، ولينكس بيقول «not found» عن المكتبة مش عن البرنامج. الحل الأسرع: -slim بدل -alpine.
Interview corner · الأسئلة اللي بتتسأل#
Explain Docker layer caching, and how you would order a Dockerfile for a monorepo.
Each instruction produces a layer keyed on the instruction plus its inputs. If the key is unchanged the layer is reused — but invalidating one layer invalidates every layer after it, whether or not those would have changed.
So the rule is: order least-likely-to-change first.
For a monorepo the naive COPY . . is fatal, because a change to any package invalidates the install for every package:
# Copy only the manifests first — one COPY per package, or a glob that
# matches manifests only. A change in service B's source no longer
# invalidates service A's dependency install.
COPY package.json pnpm-lock.yaml ./
COPY packages/a/package.json packages/a/
COPY packages/b/package.json packages/b/
RUN pnpm install --frozen-lockfile
# Only now bring in source
COPY . .
RUN pnpm --filter a buildMention cache mounts and a registry-backed cache, because in CI the local layer cache is empty on every run:
RUN --mount=type=cache,target=/root/.npm npm cidocker buildx build --cache-from=type=registry,ref=myapp:cache \
--cache-to=type=registry,ref=myapp:cache,mode=max .Without one of these, "we optimised our Dockerfile" makes no difference to CI at all — which is the point most candidates miss.
كل سطر بيطلّع طبقة، ومفتاح الطبقة = السطر + مدخلاته. لو المفتاح ما اتغيرش، الطبقة بتتعاد استخدامها — بس أول طبقة تتلغي، كل اللي بعديها بيتلغي، سواء هما اتغيروا ولا لأ.
فالقاعدة: رتّب من الأقل تغيّراً للأكتر تغيّراً.
وفي المونوريبو، الـ COPY . . الساذجة قاتلة: تغيير في أي حزمة بيلغي تنصيب كل الحزم.
الصح إنك تنقل ملفات الـ manifest بس الأول، كل حزمة لوحدها، وبعدين تنصّب، وبعدين تجيب الكود.
والإضافة اللي بتبيّن إنك سينيور: إنك تتكلم عن cache mounts و كاش على الـ registry — عشان في الـ CI الكاش المحلي فاضي كل مرة:
RUN --mount=type=cache,target=/root/.npm npm cidocker buildx build --cache-from=type=registry,ref=myapp:cache \
--cache-to=type=registry,ref=myapp:cache,mode=max .ومن غير واحدة من دي، كل ترتيبك للـ Dockerfile مش هيفرق حاجة في الـ CI خالص — وهي دي النقطة اللي أغلب الناس بتفوّتها.
Alpine or Debian slim for production? · نستخدم alpine ولا slim؟
The expected answer is a trade-off, not a winner.
alpine | -slim (Debian) | |
|---|---|---|
| Size | ~150 MB | ~250 MB |
| libc | musl | glibc |
| Native modules | Sometimes break | Work |
| DNS resolution | musl historically weaker (no search fallback for some cases) | Standard glibc behaviour |
| Package availability | apk, smaller ecosystem | apt, everything |
When alpine is right: Go or Rust static binaries (where libc barely matters), simple interpreted apps, and anywhere image size genuinely dominates — huge fleets, tight bandwidth.
When to use slim: anything with native modules (Python wheels with C extensions, node-gyp builds), anything latency-sensitive to DNS, and any time you find yourself debugging musl for more than half an hour.
Error loading shared library or no such file or directory on a binary you can plainly see with ls. The file exists; the dynamic linker it asks for does not. That message is one of the most misleading in Linux, and recognising it as a musl/glibc mismatch rather than a missing file is the mark of experience.
الإجابة المتوقعة مقايضة، مش فايز.
alpine | -slim | |
|---|---|---|
| الحجم | ١٥٠ ميجا | ٢٥٠ ميجا |
| مكتبة libc | musl | glibc |
| مكتبات native | ساعات بتضرب | شغالة |
| الـ DNS | musl تاريخياً أضعف | سلوك glibc القياسي |
| الحزم المتاحة | apk، أقل | apt، كل حاجة |
alpine صح لما: برامج Go أو Rust مبنية static (فـ libc مش فارقة أصلاً)، تطبيقات بسيطة، وأي مكان حجم الصورة فيه بيفرق فعلاً (أسطول ضخم، أو نت ضعيف).
استخدم slim لما: فيه مكتبات native (حزم بايثون فيها كود C، أو node-gyp)، أو التطبيق حساس لسرعة الـ DNS، أو أول ما تلاقي نفسك قاعد أكتر من نص ساعة بتظبّط مشكلة musl.
والعَرَض اللي لازم تسمّيه بالاسم:
no such file or directory على برنامج إنت شايفه بعينك في ls.
الملف موجود. اللي مش موجود هو الـ dynamic linker اللي البرنامج بيطلبه. والرسالة دي من أكتر الرسايل المضلّلة في لينكس.
وإنك تعرفها على إنها عدم توافق musl/glibc مش «ملف ناقص» — دي علامة الخبرة في السؤال ده.
Key takeaways#
- An image is a stack of append-only layers. Nothing is ever really removed.
- Order the Dockerfile least-changing first. Dependencies before source.
- Multi-stage builds keep compilers out of production — usually 5–10× smaller.
.dockerignorespeeds builds and keeps.gitand.envout.- Never deploy
latest. Use a version or the Git SHA. - A secret in a layer is in the image. Use BuildKit secret mounts; if it leaked, rotate it.
docker historyfinds the layer that made your image huge.- Add
USER— do not run as root.
- الصورة طبقات بتتراكم وبس. مفيش حاجة بتتشال فعلاً منها.
- رتّب الـ Dockerfile: الأقل تغيّراً فوق. الـ dependencies قبل الكود.
- الـ multi-stage بتشيل الكومبايلر من البرودكشن — وعادةً بتصغّر ٥ لـ ١٠ مرات. والفايدة الأمنية أكبر من فايدة الحجم.
.dockerignoreبيسرّع البناء وبيمنع.gitو.envإنهم يدخلوا.- عمرك ما تنشر
latest. انشر تاج ثابت — الـ commit SHA أحسن حاجة. - السيكرت اللي دخل طبقة موجود للأبد. استخدم BuildKit secret mounts، ولو اتسرّب اعمله rotate — مسح الصورة مش بيحل حاجة.
docker historyبتوريك الطبقة اللي كبّرت الصورة.- حُط
USER— متشغّلش كـ root. exec format error= معمارية غلط. استخدم--platform linux/amd64.
Last updated 2026-09-08