Минимальные контейнерные образы
Updated Jul 2026
Принцип: каждый лишний пакет — это поверхность атаки
Каждый бинарный файл, библиотека и шелл в контейнерном образе — это потенциальная уязвимость, ожидающая обнаружения. Полный базовый образ Ubuntu поставляется с сотнями пакетов, которые ваше приложение никогда не использует — но атакующий может эксплуатировать любой из них. Минимальные образы сокращают поверхность атаки, уменьшают время загрузки и снижают затраты на хранение.
Цель проста: ваш контейнер должен содержать ваше приложение и его зависимости времени выполнения — ничего более.
Сравнение размеров образов и уязвимостей
| Базовый образ | Сжатый размер | Пакеты | CVE (типично) | Применение |
|---|---|---|---|---|
node:20 |
~350 МБ | 400+ | 50-200 | Только для разработки |
node:20-slim |
~80 МБ | ~100 | 10-30 | Этапы сборки |
node:20-alpine |
~50 МБ | ~30 | 5-15 | Продакшен (если совместимо с musl) |
distroless/nodejs20 |
~40 МБ | ~10 | 0-5 | Продакшен (рекомендуется) |
scratch + статический бинарник |
<10 МБ | 0 | 0 | Приложения на Go/Rust |
Разница между 200 CVE и 0 CVE — не в более качественном патчинге, а в том, что нечего патчить. Distroless-образы достигают почти нулевого количества уязвимостей за счёт удаления всего, кроме среды выполнения языка.
Многоэтапная сборка: основа
Многоэтапная сборка разделяет среду сборки и среду выполнения. Этап сборки может содержать компиляторы, менеджеры пакетов и инструменты разработки. Этап выполнения содержит только скомпилированный результат.
Плохо: одноэтапная сборка
# BAD: 900MB+ image with shell, package managers, compilers
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# This image contains:
# - npm (can be used to install malware)
# - apt-get (can install arbitrary packages)
# - bash, sh (can run arbitrary commands)
# - gcc, make (compilation tools)
# - wget, curl (can download payloads)
# - /etc/passwd, /etc/shadow (user database)
Хорошо: многоэтапная сборка
# GOOD: Multi-stage build, ~150MB distroless image
# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]
# No shell, no package manager, no curl -- minimal attack surface
Многоэтапная сборка для Python
# Stage 1: Build with pip and compilation tools
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . .
# Stage 2: Runtime with only the installed packages
FROM python:3.12-slim
WORKDIR /app
# Copy only the installed packages from the builder
COPY --from=builder /install /usr/local
COPY --from=builder /app .
# Security hardening
RUN useradd --create-home --shell /bin/false appuser
USER appuser
CMD ["python", "-m", "app.main"]
Приложение на Go: образ scratch
# Go produces static binaries -- no runtime needed
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build a statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
# The scratch image is literally empty -- 0 bytes
FROM scratch
# Copy CA certificates for HTTPS calls
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Total image size: ~15MB (just the Go binary + certs)
Distroless-образы: оптимальный выбор
Distroless-образы от Google содержат только среду выполнения языка и ваше приложение. Без шелла, без менеджера пакетов, без утилит ОС. Это делает их идеальными для продакшена, потому что даже если атакующий получит возможность выполнения кода, у него не будет инструментов для работы.
Доступные distroless-образы
| Образ | Содержимое | Применение |
|---|---|---|
gcr.io/distroless/static-debian12 |
CA-сертификаты, tzdata | Статические бинарники (Go, Rust) |
gcr.io/distroless/base-debian12 |
glibc, libssl, CA-сертификаты | Приложения на C/C++ |
gcr.io/distroless/cc-debian12 |
glibc, libgcc, libstdc++ | Приложения на C++ |
gcr.io/distroless/nodejs20-debian12 |
Среда выполнения Node.js 20 | Приложения на Node.js |
gcr.io/distroless/python3-debian12 |
Среда выполнения Python 3 | Приложения на Python |
gcr.io/distroless/java21-debian12 |
OpenJDK 21 | Приложения на Java |
Отладка distroless-контейнеров
Отсутствие шелла усложняет отладку. Используйте debug-варианты в не-продакшен окружениях:
# Debug variant includes busybox shell
# Use ONLY in development/staging, never production
docker run -it gcr.io/distroless/nodejs20-debian12:debug sh
# For production debugging, use ephemeral containers in K8s
kubectl debug -it myapp-pod --image=busybox --target=myapp-container
Alpine-образы: компактнее, но с компромиссами
Alpine Linux использует musl libc вместо glibc, что вызывает проблемы совместимости с некоторыми приложениями. Разберитесь в компромиссах перед выбором Alpine.
Когда Alpine работает хорошо
- Чистые JavaScript/TypeScript приложения Node.js (без нативных аддонов)
- Go-приложения, скомпилированные с CGO_ENABLED=0
- Простые Python-приложения без C-расширений
- Утилитарные контейнеры (curl, wget, инструменты отладки)
Когда Alpine создаёт проблемы
- Python-пакеты с C-расширениями (numpy, pandas, scipy) — компиляция медленная и иногда неуспешная
- Node.js-пакеты с нативными аддонами (bcrypt, sharp) — нужны специфичные для Alpine сборки
- Приложения, зависящие от glibc-специфичного поведения (крайние случаи DNS-резолвинга)
- Java-приложения (используйте образы Eclipse Temurin JDK)
# Alpine with native Node.js addons requires extra build tools
FROM node:20-alpine AS builder
# Need to install build tools for native modules
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
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
USER node
CMD ["node", "dist/server.js"]
Чек-лист по усилению безопасности
Помимо выбора минимального базового образа, применяйте следующие практики усиления безопасности:
Запуск от не-root пользователя
# Create a non-root user
RUN useradd --create-home --shell /bin/false --uid 1001 appuser
USER appuser
# or for Alpine:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Файловая система только для чтения
# Kubernetes: mount filesystem as read-only
securityContext:
readOnlyRootFilesystem: true
# If the app needs to write temp files:
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Сброс всех capabilities
securityContext:
capabilities:
drop: ["ALL"]
# Add back only what you need:
# add: ["NET_BIND_SERVICE"] # If binding to port < 1024
Фиксация дайджестов образов
# BAD: tags can be overwritten
FROM node:20-slim
# GOOD: digests are immutable
FROM node:20-slim@sha256:abc123def456...
# Best practice: use a .env or CI variable for the digest
ARG NODE_IMAGE_DIGEST=sha256:abc123def456...
FROM node:20-slim@${NODE_IMAGE_DIGEST}
Тестирование минимальных образов
Проверьте, что ваши минимальные образы соответствуют требованиям безопасности:
# Test 1: No shell available
docker run --rm myapp:latest /bin/sh -c "echo pwned" 2>&1 | grep -q "not found"
echo "PASS: No shell in image"
# Test 2: Running as non-root
USER_ID=$(docker run --rm myapp:latest id -u 2>/dev/null || echo "no id command")
if [ "$USER_ID" != "0" ]; then
echo "PASS: Not running as root (UID: $USER_ID)"
fi
# Test 3: Image size under threshold
SIZE=$(docker image inspect myapp:latest --format '{{.Size}}')
MAX_SIZE=$((200 * 1024 * 1024)) # 200MB
if [ "$SIZE" -lt "$MAX_SIZE" ]; then
echo "PASS: Image size $(($SIZE / 1024 / 1024))MB is under 200MB"
fi
# Test 4: Scan for vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
echo "PASS: No critical vulnerabilities"
Автоматическая проверка соответствия образов в CI
# .github/workflows/image-compliance.yml
- name: Check image size
run: |
SIZE=$(docker image inspect myapp:${{ github.sha }} --format '{{.Size}}')
MAX=$((200 * 1024 * 1024))
if [ "$SIZE" -gt "$MAX" ]; then
echo "Image size $(($SIZE / 1024 / 1024))MB exceeds 200MB limit"
exit 1
fi
- name: Check non-root user
run: |
docker run --rm myapp:${{ github.sha }} whoami | grep -v root
Инвестиции в минимальные образы окупаются в области безопасности (меньше CVE), производительности (быстрее загрузка) и затрат (меньше места для хранения). Сделайте это стандартом, а не оптимизацией.