Модуль 07 — Образы для эксплуатации

От «работает у меня» к «работает на сервере месяцами и не съедает диск и память»

Прогресс курса Модуль 7 из 9

Что вы освоите в этом модуле

01

Базовый образ

Всё, что есть в базовом образе, попадает в ваш: размер, время скачивания на каждый сервер и — главное — уязвимости. Пакет, которого в образе нет, не придётся обновлять из-за найденной в нём дыры. Сравним размеры:

$ 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.13Debian с компиляторами и сотнями пакетовСоберётся всё, даже пакеты с C-расширениямиГигабайт, много лишнего для работы
python:3.13-slimМинимальный DebianПривычный apt и glibc, разумный размерНет компиляторов — для некоторых пакетов нужна многоэтапная сборка
python:3.13-alpineAlpine: musl вместо glibc, busyboxСамый маленькийГотовые колёса Python для glibc не подходят — часть пакетов собирается из исходников; отличия musl иногда всплывают в работе
distroless, scratchТолько программа и минимум файлов, нет оболочкиПочти нечего атаковатьНет sh для отладки; подходит, когда программа — один файл

Для Python разумное значение по умолчанию — -slim. Alpine выбирают осознанно, проверив, что все зависимости ставятся и работают.

02

Многоэтапная сборка

Для сборки нужны компилятор и инструменты, для работы — только результат. Многоэтапный 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.

03

Сервер приложений и HEALTHCHECK

Встроенный сервер 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"]
$ 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
04

Ограничение памяти

Без ограничений контейнер может занять всю память хоста — утечка в одном приложении уронит соседние. -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 и баз данных у самого приложения есть настройка размера памяти — её согласуют с пределом контейнера, иначе приложение будет рассчитывать на память хоста.

05

Ограничение процессора

--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: число процессов, защита от «форк-бомбы» и утечки потоков.

06

Журналы не должны съесть диск

По умолчанию 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), а не копят на диске сервера.

07

Урезанные права

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 на хосте
08

Всё вместе в Compose

Те же настройки в 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; меньше пакетов — меньше уязвимостей
  • Многоэтапная сборка: компиляторы на этапе сборки, в итоговом образе — только результат
  • Python — через gunicorn или uvicorn, 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
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 06: Docker Compose Все модули Модуль 08: Реестры и безопасность