От «работает у меня» к «работает на сервере месяцами и не съедает диск и память»
HEALTHCHECKВсё, что есть в базовом образе, попадает в ваш: размер, время скачивания на каждый сервер и — главное — уязвимости. Пакет, которого в образе нет, не придётся обновлять из-за найденной в нём дыры. Сравним размеры:
$ docker pull -q python:3.13 >/dev/null; docker pull -q python:3.13-alpine >/dev/null; docker pull -q gcr.io/distroless/static-debian12 >/dev/null
$ docker image ls --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" | grep -E "^(python|alpine|debian|gcr.io|golang)"
python:3.13-alpine 45.7MB
python:3.13-slim 118MB
python:3.13 1.11GB
alpine:3.22 8.3MB
debian:trixie-slim 78.8MB
golang:1.25 848MB
gcr.io/distroless/static-debian12:latest 2.11MB
| Вариант | Что внутри | Плюсы | Минусы |
|---|---|---|---|
python:3.13 | Debian с компиляторами и сотнями пакетов | Соберётся всё, даже пакеты с C-расширениями | Гигабайт, много лишнего для работы |
python:3.13-slim | Минимальный Debian | Привычный apt и glibc, разумный размер | Нет компиляторов — для некоторых пакетов нужна многоэтапная сборка |
python:3.13-alpine | Alpine: musl вместо glibc, busybox | Самый маленький | Готовые колёса Python для glibc не подходят — часть пакетов собирается из исходников; отличия musl иногда всплывают в работе |
distroless, scratch | Только программа и минимум файлов, нет оболочки | Почти нечего атаковать | Нет sh для отладки; подходит, когда программа — один файл |
Для Python разумное значение по умолчанию — -slim. Alpine выбирают осознанно, проверив, что все зависимости ставятся и работают.
Для сборки нужны компилятор и инструменты, для работы — только результат. Многоэтапный Dockerfile делает несколько FROM: первый этап собирает, итоговый копирует из него готовое через COPY --from. Всё, что было на этапе сборки, в итоговый образ не попадает. Пример — веб-сервер на Go:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "Привет из Go, контейнер %s\n", host)
})
log.Println("слушаю :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Одноэтапный вариант оставляет в образе компилятор:
FROM golang:1.25
WORKDIR /src
COPY main.go .
RUN go mod init hello && CGO_ENABLED=0 go build -o /hello .
CMD ["/hello"]
Многоэтапный:
# Этап 1: сборка в образе с компилятором
FROM golang:1.25 AS build
WORKDIR /src
COPY main.go .
RUN go mod init hello && CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello .
# Этап 2: в итоговый образ — только программа
FROM scratch
COPY --from=build /hello /hello
USER 65534
ENTRYPOINT ["/hello"]
$ cd go && docker build -q -f Dockerfile.single -t hello:single . >/dev/null && docker build -q -t hello:multi . >/dev/null; cd ..
$ docker image ls hello
REPOSITORY TAG IMAGE ID CREATED SIZE
hello multi a38c487ff583 Less than a second ago 5.58MB
hello single 1c32d1008ba9 29 seconds ago 936MB
$ docker run -d --name hello -p 8080:8080 hello:multi; sleep 1; curl -s http://localhost:8080/
ed9359e60c1fc947eede49eba31cf57848f97347751257184e0a87e2be0d4a27
Привет из Go, контейнер ed9359e60c1f
$ docker exec hello sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown
$ docker rm -f hello
hello
936 МБ против 5,6 МБ, и в итоговом образе нет даже оболочки — docker exec … sh не работает. Атакующему, которому удалось выполнить код в таком контейнере, не с чем работать. CGO_ENABLED=0 собирает программу без зависимости от системных библиотек — иначе она не запустится в пустом scratch. USER 65534 — пользователь nobody по номеру: в scratch нет /etc/passwd, имена не работают.
Для Python приём тот же: на этапе сборки с компиляторами ставят зависимости в виртуальное окружение или собирают колёса, итоговый образ на -slim получает готовое COPY --from=build /venv /venv.
Встроенный сервер Flask обрабатывает запросы по одному и сам предупреждает, что он для разработки. В эксплуатации Python-приложение запускают сервером приложений: gunicorn для синхронных (Flask, Django), uvicorn для асинхронных (FastAPI). Приложение получило адрес проверки /health:
import os
import socket
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return {"app": "notes", "version": os.environ.get("APP_VERSION", "dev"), "host": socket.gethostname()}
@app.get("/health")
def health():
return {"status": "ok"}
flask==3.1.2
gunicorn==23.0.0
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --system --no-create-home app
USER app
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "app:app"]
PYTHONUNBUFFERED=1 — вывод сразу в журнал, без буфера; иначе строки появляются в docker logs с задержкой или теряются при падении. PYTHONDONTWRITEBYTECODE=1 — не писать .pyc.--workers 2 — два процесса-обработчика; обычно 2–4 на ядро процессора, но не больше, чем позволяет лимит памяти.HEALTHCHECK — та же проверка, что в Compose (модуль 06), только встроенная в образ. В python:3.13-slim нет curl, поэтому проверка — на Python; ставить curl ради неё — лишний пакет.$ docker build -q -t notes:2.0.0 . >/dev/null; docker image ls notes:2.0.0
REPOSITORY TAG IMAGE ID CREATED SIZE
notes 2.0.0 7088ca4225a2 1 second ago 125MB
$ docker run -d --name notes -p 8000:8000 notes:2.0.0
73dfc4a3bf8de87ab710c83dbb6d50d37cb1f902bbabfad6d19fdb06df510aa4
$ docker ps --format "table {{.Names}}\t{{.Status}}"
NAMES STATUS
notes Up Less than a second (health: starting)
$ docker ps --format "table {{.Names}}\t{{.Status}}"
NAMES STATUS
notes Up 12 seconds (healthy)
$ docker logs notes 2>&1 | head -4
[2026-10-23 17:13:14 +0000] [1] [INFO] Starting gunicorn 23.0.0
[2026-10-23 17:13:14 +0000] [1] [INFO] Listening at: http://0.0.0.0:8000 (1)
[2026-10-23 17:13:14 +0000] [1] [INFO] Using worker: sync
[2026-10-23 17:13:14 +0000] [8] [INFO] Booting worker with pid: 8
Сначала health: starting — Docker ждёт start-period и первую проверку, затем healthy. Три проваленные проверки подряд — unhealthy. Сам Docker нездоровый контейнер не перезапускает: состояние используют depends_on в Compose, оркестраторы и мониторинг.
gunicorn сам обрабатывает SIGTERM: доделывает текущие запросы, останавливает обработчики и выходит — никаких 10 секунд ожидания:
$ time docker stop notes
notes
real 0m0,346s
user 0m0,020s
sys 0m0,003s
$ docker logs notes 2>&1 | tail -3
[2026-10-23 17:13:25 +0000] [9] [INFO] Worker exiting (pid: 9)
[2026-10-23 17:13:25 +0000] [8] [INFO] Worker exiting (pid: 8)
[2026-10-23 17:13:25 +0000] [1] [INFO] Shutting down: Master
$ docker rm notes
notes
Без ограничений контейнер может занять всю память хоста — утечка в одном приложении уронит соседние. -m (--memory) ставит предел через cgroups. Программа, которая занимает память по 10 МБ:
chunks = []
for mb in range(1, 1001):
chunks.append(b"x" * (10 * 1024 * 1024))
print(f"занято {mb * 10} МБ", flush=True)
$ docker run --name hog -m 64m -v "$PWD/hog.py":/hog.py:ro python:3.13-slim python /hog.py | tail -2; echo "код: ${PIPESTATUS[0]}"
занято 110 МБ
занято 120 МБ
код: 137
$ docker inspect -f "OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}" hog
OOMKilled=true ExitCode=137
$ docker rm hog
hog
$ docker run --rm -m 64m --memory-swap 64m -v "$PWD/hog.py":/hog.py:ro python:3.13-slim python /hog.py | tail -1
занято 60 МБ
При превышении ядро убивает процесс (OOM killer) — код 137, как при SIGKILL, и OOMKilled=true в inspect. Если контейнер внезапно перезапускается с кодом 137 — первым делом смотрите это поле.
Почему программа успела занять 120 МБ при пределе 64? На учебном сервере есть подкачка, и -m 64m по умолчанию разрешает контейнеру ещё столько же подкачки. --memory-swap задаёт общий предел памяти и подкачки; равный -m — подкачка запрещена. Для служб так обычно и лучше: приложение в подкачке работает в сотни раз медленнее, и честная остановка заметнее медленной смерти.
Предел ставят по реальному потреблению под нагрузкой с запасом: посмотрите docker stats в рабочее время, прибавьте треть. Для JVM, Node.js и баз данных у самого приложения есть настройка размера памяти — её согласуют с пределом контейнера, иначе приложение будет рассчитывать на память хоста.
--cpus 0.5 — не больше половины одного ядра. Бесконечный цикл получает ровно столько:
$ docker run -d --name busy --cpus 0.5 alpine:3.22 sh -c "while :; do :; done"
b76ebad73f0003410c043e444d1617ff5a84f62582997bbf86a6ae3ab3c55cb4
$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}" busy
NAME CPU %
busy 49.49%
$ docker rm -f busy
busy
Превышение процессора не убивает контейнер, а замедляет: планировщик просто не даёт ему больше времени. Ещё один полезный предел — --pids-limit 200: число процессов, защита от «форк-бомбы» и утечки потоков.
По умолчанию Docker пишет вывод контейнера драйвером json-file в файл в /var/lib/docker/containers/ — без ограничения размера. Болтливое приложение за несколько месяцев заполняет диск сервера, и падает всё, включая базы данных. Ротацию задают параметрами --log-opt:
$ docker run -d --name chatty --log-opt max-size=1m --log-opt max-file=3 alpine:3.22 sh -c "yes строка журнала | head -c 20000000; sleep 60"
0b2b5f98261c931c5c80861be27570ad7f45f369ae3a07519c1bc6aab30dfc6d
$ docker inspect -f "{{json .HostConfig.LogConfig}}" chatty
{"Type":"json-file","Config":{"max-file":"3","max-size":"1m"}}
$ docker logs chatty 2>&1 | wc -l
29596
$ docker rm -f chatty
chatty
Контейнер записал 20 000 000 байт — больше 700 тысяч строк, а хранятся только последние десятки тысяч — три файла по мегабайту. Задавать это для каждого контейнера легко забыть, поэтому значения по умолчанию ставят для всей службы в /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Настройки по умолчанию применяются к контейнерам, созданным после перезапуска службы; старые нужно пересоздать. Драйвер local хранит журналы компактнее и ротирует сам; journald отправляет их в журнал systemd. Если журналы нужно хранить долго и искать по ним, их отправляют в систему сбора (Loki, ELK, Graylog), а не копят на диске сервера.
root в контейнере уже ограничен: Docker оставляет ему около полутора десятков из сорока с лишним возможностей ядра (capabilities). Но и оставшиеся — смена владельца файлов, привязка к портам ниже 1024, обход прав на файлы — обычному веб-приложению не нужны. --cap-drop ALL убирает все:
$ docker run --rm alpine:3.22 chown nobody /etc/hostname && echo chown выполнен
chown выполнен
$ docker run --rm --cap-drop ALL alpine:3.22 chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted
$ docker run --rm alpine:3.22 grep CapEff /proc/self/status
CapEff: 00000000a80425fb
$ docker run --rm --cap-drop ALL alpine:3.22 grep CapEff /proc/self/status
CapEff: 0000000000000000
CapEff — маска действующих возможностей: у обычного контейнера ненулевая, после --cap-drop ALL — нули. Нужную возможность возвращают точечно: --cap-add NET_BIND_SERVICE для порта 80 без root.
| Параметр | От чего защищает |
|---|---|
USER в образе / -u | Код приложения работает не от root |
--cap-drop ALL | Даже root в контейнере не может менять владельцев, обходить права, работать с сетью на низком уровне |
--security-opt no-new-privileges | Процесс не повысит права через программы с битом setuid (su, sudo) |
--read-only + --tmpfs /tmp | Нельзя подменить файлы приложения и оставить закладку |
Без --privileged | --privileged отдаёт контейнеру все возможности и устройства хоста — это почти root на хосте |
Те же настройки в compose.yaml. Ограничения ресурсов пишутся в deploy.resources.limits — docker compose их применяет:
name: notes
services:
web:
build: .
image: notes:2.0.0
ports:
- "127.0.0.1:8000:8000"
environment:
APP_VERSION: 2.0.0
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 256m
cpus: "1.0"
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
$ docker compose up -d 2>&1 | tail -2
Container notes-web-1 Starting
Container notes-web-1 Started
$ docker compose ps --format "table {{.Service}}\t{{.Status}}"
SERVICE STATUS
web Up 12 seconds (healthy)
$ curl -s http://127.0.0.1:8000/
{"app":"notes","host":"c263351b5c8c","version":"2.0.0"}
$ docker inspect -f "память: {{.HostConfig.Memory}}, CPU: {{.HostConfig.NanoCpus}}, read-only: {{.HostConfig.ReadonlyRootfs}}, cap_drop: {{.HostConfig.CapDrop}}" notes-web-1
память: 268435456, CPU: 1000000000, read-only: true, cap_drop: [ALL]
$ docker compose exec web touch /app/x
touch: cannot touch '/app/x': Read-only file system
$ docker compose down 2>&1 | tail -1
Network notes_default Removed
Приложение работает, проверка проходит, а записать файл в каталог приложения нельзя. Такой файл — хорошая отправная точка для любого сервиса: ослабляйте ограничения по одному, когда приложению действительно что-то нужно, и записывайте, зачем.
Обновление в эксплуатации: собрать или скачать образ с новым тегом, поменять тег в compose.yaml, docker compose up -d. Откат — вернуть прежний тег и снова up -d. Поэтому тег latest на сервере — плохая идея: непонятно, что запущено, и откатываться не на что.
-slim; меньше пакетов — меньше уязвимостейPYTHONUNBUFFERED=1, HEALTHCHECK на /health-m и --memory-swap, --cpus, --pids-limit; код 137 + OOMKilled=true — не хватило памятиmax-size/max-file — в daemon.json до первого контейнераcap_drop: [ALL], no-new-privileges, read_only, не root, никогда --privileged