Dockerfile best practices: 15 съвета от production
Практически съвети за писане на ефективни Dockerfile образи. Multi-stage builds, caching, сигурност и оптимизация на размера — с реални примери от production среди.
Писал съм стотици Dockerfile-ове. Някои са били елегантни — 80 MB образи, които се build-ват за 30 секунди. Други са били катастрофи — 2 GB чудовища, които се build-ват 15 минути и съдържат node_modules от три различни проекта.
Разликата между добрия и лошия Dockerfile не е в “знаенето на синтаксиса”. Всички знаят FROM, RUN, COPY. Разликата е в разбирането как работи Docker под капака — как се кешират слоевете, как се наследяват, как се оптимизира размерът.
В тази статия ще ти дам 15 съвета, които съм научил от реални production среди. Всеки съвет има пример — “лошо vs добре”, за да видиш разликата веднага.
1. Pin-вай base image версията
Най-честата грешка, която виждам:
FROM node:latest
latest не е версия. Тя е “каквото е най-новото в момента, когато build-ваш”. Утре може да е Node 20, вдругиден Node 22, а след месец — Node 24 с breaking changes. Build-ът, който е работил вчера, днес може да се счупи без никой да е пипал Dockerfile-а.
# Добре — конкретна версия
FROM node:20.11.1-alpine3.19
# Още по-добре — с digest за пълна сигурност
FROM node:20.11.1-alpine3.19@sha256:abc123...
Практическо правило: Винаги pin-вай до minor версия (напр. 20.11), не до major (20). Ако искаш пълна сигурност — използвай digest (@sha256:...), който е immutable.
2. Използвай Alpine или distroless
Големината на base image-а определя долната граница на твоя образ.
ubuntu:22.04 → 77 MB
debian:12 → 124 MB
node:20 → 1.1 GB ← базиран на Debian
node:20-alpine → 130 MB ← базиран на Alpine
node:20-slim → 240 MB ← Debian без излишното
gcr.io/distroless/nodejs20 → 90 MB ← без shell
# Лошо — 1.1 GB само за base
FROM node:20
# Добре — 130 MB
FROM node:20-alpine
# Най-добре за production — 90 MB, без shell
FROM gcr.io/distroless/nodejs20
Защо distroless е по-сигурен: Няма shell, няма package manager, няма curl/wget. Ако някой пробие приложението ти, той не може да изпълни нищо друго освен твоя binary.
Кога Alpine не е добър избор:
- Ако използваш native dependencies, които изискват
glibc(Alpine използваmusl) — може да имаш проблеми с компилация или runtime - Ако трябва да debug-ваш в контейнера — Alpine има само
busybox, не пълния GNU toolset
3. Multi-stage builds
Това е най-мощната техника за намаляване на размера. Идеята: build-ваш в един stage (с всички build tools), копираш само резултата в друг stage (минимален).
# Лошо — 1.4 GB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
# Проблемът: node_modules с dev dependencies, source code, build tools — всичко остава
# Добре — 180 MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/index.js"]
Какво се случва:
- Stage
builder— има всичко: source code, dev dependencies, build tools. Този stage се изхвърля след build-а. - Final stage — взема само
dist/(компилирания код) и productionnode_modules.
Резултат: от 1.4 GB на 180 MB — 8× по-малък образ, по-бърз deploy, по-малко attack surface.
Multi-stage за Go — още по-драматично
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Резултат: 5 MB образ. scratch е празен образ — има само твоя binary. Няма shell, няма нищо друго. Това е максимумът за сигурност и минимализъм.
4. Редът на COPY има значение за caching
Docker кешира всеки слой. Ако даден слой не се е променил, Docker го взема от кеша. Но ако един слой се промени, всички следващи се rebuild-ват.
# Лошо — всеки code change invalidates npm install
COPY . .
RUN npm install
Ако промениш README.md, COPY . . се променя → npm install се rebuild-ва → чакаш 2 минути за нищо.
# Добре — npm install се кешира, ако package.json не се променя
COPY package.json package-lock.json ./
RUN npm install
COPY . .
Сега README.md промяна invalidates само последния COPY . ., а npm install остава в кеша.
Златно правило: Копирай първо нещата, които се променят рядко (package.json), и накрая тези, които се променят често (source code).
5. Комбинирай RUN командите
Всеки RUN създава нов слой. Слоевете се добавят — не се “смаляват” от следващия слой.
# Лошо — 3 слоя, всеки +100 MB
RUN apt update
RUN apt install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Проблемът: rm премахва файловете, но в нов слой.
# Предишният слой все още съдържа файловете. Образът е по-голям.
# Добре — 1 слой, чист
RUN apt update && \
apt install -y curl && \
rm -rf /var/lib/apt/lists/*
Защо работи: Всичко е в един RUN, значи е в един слой. rm премахва файловете преди слоят да бъде финализиран. Резултатът е чист.
6. Използвай .dockerignore
Ако нямаш .dockerignore, всичко в проекта отива в Docker daemon-а при всеки build. Включително node_modules/, .git/, .env.
# .dockerignore
node_modules
.git
.gitignore
.env
.env.*
*.log
dist
build
coverage
.vscode
.idea
README.md
docker-compose*.yml
Dockerfile*
.dockerignore
Защо има значение:
- Скорост — 200 MB
node_modulesне се изпращат на daemon-а при всеки build - Размер —
.git/може да е 100+ MB - Сигурност —
.envфайлове не попадат случайно в образа
Провери дали работи:
docker build -t test .
docker run --rm test ls -la
# Не трябва да виждаш .git, node_modules и т.н.
7. Non-root user
По подразбиране всичко в контейнера работи като root. Това е лошо:
- Ако някой пробие приложението, той е root в контейнера
- Ако има kernel exploit, той може да излезе извън контейнера като root
- Някои cloud платформи блокират root контейнери
# Лошо
FROM node:20
COPY . /app
WORKDIR /app
CMD ["node", "index.js"]
# Всичко като root
# Добре
FROM node:20
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "index.js"]
# Node образът вече има user "node" (UID 1000)
Ако base image-ът няма non-root user:
FROM alpine:3.19
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["./app"]
Провери:
docker run --rm myimage whoami
# Трябва да върне "node" или "app", не "root"
8. Не съхранявай secrets в образа
# КАТЕГОРИЧНО ГРЕШНО
ENV API_KEY=sk-1234567890abcdef
RUN echo "password123" > /app/password.txt
ARG DATABASE_URL=postgres://user:pass@host/db
Защо е грешно:
ENVстойностите се виждат сdocker inspectRUN echoфайловете остават в слоя, дори да ги изтриеш послеARGстойностите се виждат вdocker history
Как да го направиш правилно:
Build-time secrets (BuildKit):
# syntax=docker/dockerfile:1.4
FROM node:20
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
docker build --secret id=npm_token,src=~/.npmrc .
Runtime secrets (environment variables или secrets manager):
docker run -e API_KEY=sk-... myapp
# или
docker run --env-file .env.production myapp
9. HEALTHCHECK
Docker може да проверява дали контейнерът е “здрав”. Ако не е — може да го рестартира (в Swarm или Kubernetes).
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Ако нямаш curl в образа (Alpine, distroless):
# С wget (Alpine)
HEALTHCHECK CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Или собствен healthcheck binary
COPY --from=builder /app/healthcheck /healthcheck
HEALTHCHECK CMD ["/healthcheck"]
Защо има значение: Kubernetes използва healthcheck, за да разбере кога pod-ът е готов да приема трафик. Без него → downtime при deploy.
10. Не използвай apt upgrade
# Лошо
RUN apt update && apt upgrade -y && apt install -y curl
apt upgrade обновява всички пакети в образа. Това:
- Прави build-а недетерминиран (резултатът зависи от деня)
- Може да счупи неща (обновява libc, openssl и т.н.)
- Увеличава размера
# Добре — само каквото ти трябва
RUN apt update && \
apt install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
Ако ти трябват updates — обнови base image-а:
FROM debian:12.5
# вместо
FROM debian:12 + apt upgrade
11. Set WORKDIR, не използвай cd
# Лошо
RUN cd /app && npm install
# cd работи само в текущия RUN, не влияе на следващите
# Добре
WORKDIR /app
RUN npm install
# Работи за всички следващи RUN, COPY, CMD
WORKDIR създава директорията, ако не съществува, и влияе на всички следващи инструкции. cd в RUN влияе само в рамките на този RUN.
12. Форматът на CMD и ENTRYPOINT има значение
Има два формата: shell и exec.
# Shell формат — работи, но не е правилно
CMD node index.js
# Стартира /bin/sh -c "node index.js"
# Node НЕ е PID 1, не получава SIGTERM директно. Проблем при graceful shutdown.
# Exec формат — правилно
CMD ["node", "index.js"]
# Node е PID 1, получава сигнали директно, graceful shutdown работи.
Практическо правило: Винаги използвай exec формат (["...", "..."]). Единственото изключение е, когато трябва shell features (pipe, redirect, env substitution).
ENTRYPOINT vs CMD
# ENTRYPOINT = какво да се изпълни (не се презаписва лесно)
# CMD = аргументи по подразбиране (могат да се презапишат)
ENTRYPOINT ["node"]
CMD ["index.js"]
docker run myapp # изпълнява: node index.js
docker run myapp other.js # изпълнява: node other.js
13. Label-и за metadata
LABEL org.opencontainers.image.title="My App" \
org.opencontainers.image.description="My awesome application" \
org.opencontainers.image.version="1.2.3" \
org.opencontainers.image.authors="team@example.com" \
org.opencontainers.image.source="https://github.com/myorg/myapp" \
org.opencontainers.image.licenses="MIT"
Защо: Label-ите се виждат с docker inspect, помагат за автоматизация, интеграция с container registries, документация. Помагат и на security scanners (trivy, grype) да идентифицират образа.
14. Оптимизация за размер — по-дълбоко
Ако искаш още по-малки образи:
Използвай apk add --no-cache (Alpine):
RUN apk add --no-cache curl
# --no-cache = не запазва apk cache, директно инсталира
Използвай npm ci вместо npm install:
RUN npm ci --only=production
# ci = clean install, само от lockfile, по-бърз, детерминиран
# --only=production = без dev dependencies
Използвай --no-install-recommends (Debian/Ubuntu):
RUN apt install -y --no-install-recommends curl
# Не инсталира "препоръчани" пакети, само hard dependencies
Използвай --strip за Go binary:
RUN go build -ldflags="-s -w" -o myapp .
# -s = strip symbol table
# -w = strip DWARF debug info
# Резултат: 30% по-малък binary
Компресирай с UPX (за Go/C):
RUN upx --best --lzma myapp
# Може да намали binary-то с 50-70%
15. Провери образа за уязвимости
Build-ът е само началото. Провери какво си произвел.
Trivy — безплатен, бърз, най-често използван:
trivy image myapp:latest
Grype:
grype myapp:latest
Docker Scout (вграден в Docker Desktop):
docker scout cves myapp:latest
Какво ще видиш: списък с CVE-та в base image-а и dependencies. Не всички са critical за твоя случай, но трябва да знаеш какво имаш.
Автоматизирай в CI:
# GitHub Actions
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: 'CRITICAL,HIGH'
exit-code: '1' # fail build при critical
Пълен пример — production-ready Node.js Dockerfile
Ето как изглежда всичко заедно:
# syntax=docker/dockerfile:1.4
# ============================================
# Stage 1: Dependencies
# ============================================
FROM node:20.11.1-alpine3.19 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
npm cache clean --force
# ============================================
# Stage 2: Build
# ============================================
FROM node:20.11.1-alpine3.19 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ============================================
# Stage 3: Runtime
# ============================================
FROM node:20.11.1-alpine3.19 AS runtime
# Security: update packages
RUN apk upgrade --no-cache && \
apk add --no-cache tini
WORKDIR /app
# Copy production deps
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Copy built app
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./
# Metadata
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.source="https://github.com/myorg/myapp"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
# Security: non-root
USER node
EXPOSE 3000
# tini = proper init, reaps zombies, forwards signals
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
Какво прави този Dockerfile:
- ✅ Pinned версии (
node:20.11.1-alpine3.19) - ✅ Multi-stage (deps → builder → runtime)
- ✅ Layer caching (package.json първо)
- ✅ Alpine base (малък)
- ✅ Production dependencies само
- ✅ Non-root user
- ✅ tini за PID 1
- ✅ Health check
- ✅ Labels за metadata
- ✅ Exec формат на CMD/ENTRYPOINT
Резултат: ~150 MB образ, който стартира за 2 секунди.
Чеклист за всеки Dockerfile
- Base image е pinned (не
latest) - Multi-stage build, ако има build стъпка
-
.dockerignoreсъществува и е попълнен -
COPY package.jsonпредиCOPY . . - RUN команди са комбинирани с
&& -
--no-install-recommendsили--no-cache - Non-root user (
USER) - Няма secrets в ENV/ARG
- Exec формат за CMD/ENTRYPOINT
- HEALTHCHECK дефиниран
- Labels за metadata
- Cleanup в същия RUN (
rm -rf) - Използва се
npm ci, неnpm install - Образът е сканиран за CVE-та
Заключение
Dockerfile-ът изглежда просто — 5-10 реда. Но разликата между добър и лош Dockerfile е в детайлите. Multi-stage builds, ред на COPY, non-root user, health checks — всичко това определя дали образът е 100 MB или 1 GB, дали deploy-ът е 10 секунди или 10 минути, дали е сигурен или уязвим.
Практическо правило: Всеки път, когато пишеш Dockerfile, си задавай въпросите:
- Какъв е размерът на образа? Ако >200 MB — прегледай за оптимизации
- Кой е потребителят в контейнера? Ако
root— поправи го - Има ли healthcheck? Ако не — добави го
- Кешира ли се добре? Ако всеки build отнема минути — прегледай реда на COPY
Dockerfile-ът е като всеки код — той се подобрява с итерации. Започни с тези 15 съвета, приложи ги към текущите си Dockerfile-ове и виж разликата. Ще се изненадаш колко малко усилия дават голям резултат.