9.2. Справочник по services
Цели
После этого материала вы сможете:
- осознанно выбирать между
imageиbuildи настраиватьpull_policy; - объяснить, почему задание
entrypointобнуляетCMDобраза; - понимать разницу
environmentиenv_fileи их приоритет; - задавать лимиты ресурсов и знать, какая форма записи работает без Swarm;
- назвать причины всегда брать значения
portsиuserв кавычки; - избегать
container_nameи объяснить, чем он мешает; - настраивать корректное завершение:
stop_grace_period,stop_signal,init.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
pull_policy | Когда Compose скачивает образ |
short syntax | Краткая строковая форма записи ("8000:8000") |
long syntax | Развёрнутая запись отображением |
deploy | Раздел, часть которого используется и без Swarm |
sexagesimal | Шестидесятеричное число — источник проблемы с ports |
Теория
Образ: image и build
services:
a:
image: postgres:17-alpine # только скачать
b:
build: . # только собрать
c:
build:
context: .
dockerfile: Dockerfile.dev
target: dev
args:
UID: "${UID:-1000}"
cache_from:
- myapp:cache
image: myapp:local # собрать И назвать этим именем
Совместное указание build и image — не ошибка, а полезный приём: образ соберётся и получит осмысленное имя вместо <проект>-<сервис>.
pull_policy | Поведение |
|---|---|
missing (умолчание) | Скачать, только если образа нет локально |
always | Скачивать при каждом up |
never | Не скачивать; ошибка, если образа нет |
build | Всегда собирать заново |
if_not_present | Синоним missing |
always нужен для плавающих тегов (latest, main): без него локальная копия устареет и вы будете запускать вчерашний образ, не замечая этого.
command и entrypoint
services:
app:
image: myapp
entrypoint: ["/entrypoint.sh"]
command: ["--port", "8000"]
| Ключ | Что переопределяет |
|---|---|
command | CMD образа |
entrypoint | ENTRYPOINT образа |
Важная деталь: задание entrypoint обнуляет CMD образа. Если в образе был CMD ["python", "app.py"], а вы указали только entrypoint, аргументов не останется — и контейнер запустит точку входа без параметров.
Правило: задали entrypoint — задайте и command, если он нужен.
Обе формы поддерживают строку и список. Список предпочтителен: строковая форма исполняется через shell, и приложение перестаёт быть PID 1 (урок 6.5).
environment и env_file
services:
app:
env_file:
- .env.common
- .env.local
environment:
LOG_LEVEL: debug
DATABASE_URL: "postgresql://user:pass@db:5432/app"
EMPTY_ON_PURPOSE: ""
FROM_SHELL: # без значения — взять из окружения shell
Приоритет, от низшего к высшему:
| Приоритет | Источник |
|---|---|
| 1 | env_file, первый файл в списке |
| 2 | env_file, последующие файлы |
| 3 | environment в файле |
| 4 | Переменная окружения shell — только для ключа без значения |
| 5 | docker compose run -e |
Ключ без значения (FROM_SHELL:) означает «взять из окружения, где запущен Compose». Это отличается от пустой строки: если переменной нет, ключ вообще не попадёт в container.
Файл .env в каталоге проекта играет другую роль: он задаёт переменные для подстановки ${VAR} в сам compose.yaml, а не автоматически передаётся в container'ы (урок 9.5). Это регулярно путают.
ports и expose
services:
web:
ports:
- "8080:80" # host:container
- "127.0.0.1:8081:80" # только с host
- "8082:80/udp"
- "3000-3005:3000-3005"
- target: 80 # длинная форма
published: "8083"
protocol: tcp
mode: host
expose:
- "9000" # только объявление
Кавычки обязательны. Документация Compose рекомендует всегда записывать отображения портов строкой. Историческая причина — разбор xx:yy как шестидесятеричного числа при малых значениях: 22:22 превращалось в 1342. Поведение зависит от версии парсера, и проверить его на своей версии проще, чем помнить исключения.
Та же оговорка касается user: "1000:1000" — там ровно та же форма записи.
expose не публикует порт: внутри сети порты соседей доступны и без него (урок 8.3).
volumes
services:
app:
volumes:
- app-data:/data # named volume
- ./src:/app/src # bind mount, путь от файла
- ./config:/etc/app:ro # только чтение
- /tmp/cache # anonymous volume
- type: bind # длинная форма
source: ./logs
target: /var/log/app
read_only: false
| Форма | Когда предпочтительна |
|---|---|
| Краткая | Обычные случаи, читается компактнее |
| Длинная | Нужны опции: bind.create_host_path, volume.nocopy, tmpfs.size |
Относительные пути разрешаются от каталога Compose-файла — это то, что делает конфигурацию переносимой (урок 7.3).
Ресурсы
Две формы записи, и различие между ними важно:
services:
app:
# Форма 1: работает и без Swarm
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
pids: 200
reservations:
cpus: "0.25"
memory: 128M
# Форма 2: краткие ключи Compose
mem_limit: 512m
cpus: 1.5
pids_limit: 200
Ключ раздела deploy | Работает при docker compose up |
|---|---|
resources.limits | Да |
resources.reservations | Частично |
replicas | Да |
restart_policy | Нет — используйте restart |
placement, update_config, rollback_config | Нет, только Swarm |
Распространённое заблуждение — что весь раздел deploy игнорируется вне Swarm. Лимиты ресурсов работают.
Перезапуск
services:
app:
restart: unless-stopped
| Значение | Поведение |
|---|---|
no (умолчание) | Не перезапускать |
always | Всегда, включая после перезагрузки host |
unless-stopped | Как always, но не поднимать то, что остановлено вручную |
on-failure | Только при ненулевом коде выхода |
on-failure:5 | То же, но не более пяти раз |
Для сервисов unless-stopped — обычно правильный выбор: он переживает перезагрузку, но уважает ручную остановку.
Для одноразовых задач (миграции) restart не задают вовсе: перезапуск завершившейся задачи — почти всегда ошибка.
Завершение
services:
worker:
stop_grace_period: 30s
stop_signal: SIGINT
init: true
| Ключ | Умолчание | Когда менять |
|---|---|---|
stop_grace_period | 10s | Задачи длиннее 10 секунд (урок 6.11) |
stop_signal | SIGTERM | Приложение реагирует на другой сигнал |
init | false | Процесс порождает потомков и не собирает зомби |
init: true запускает крошечный init-процесс как PID 1. Нужен, когда главный процесс не умеет пожинать потомков (урок 6.5).
container_name — почти всегда ошибка
services:
db:
container_name: my-database # не делайте так
| Последствие | Пояснение |
|---|---|
| Масштабирование невозможно | Два container'а не могут иметь одно имя |
| Конфликт между проектами | Имя перестаёт содержать префикс проекта |
| Ломается изоляция | Два стека на одной машине не запустятся |
Компромисса нет: сервисы и так доступны по имени сервиса через DNS (урок 8.4). container_name нужен лишь в редких случаях, когда на имя ссылается внешний инструмент.
Прочие атрибуты
| Ключ | Назначение | Типичная ошибка |
|---|---|---|
working_dir | Рабочий каталог | Переопределяет WORKDIR, ломая относительные пути в CMD |
user | UID и GID процесса | Без кавычек; без записи в /etc/passwd (урок 7.5) |
read_only | Корень только на чтение | Забывают tmpfs для /tmp (урок 7.4) |
tmpfs | Каталоги в памяти | Без size — половина памяти host |
labels | Метки | Путают с deploy.labels (для Swarm) |
extra_hosts | Записи в /etc/hosts | Нужен для host.docker.internal в Linux (урок 8.5) |
dns | Серверы имён | Не заменяет 127.0.0.11, а становится вышестоящим |
cap_drop / cap_add | Capabilities | cap_add без cap_drop: [ALL] бессмыслен |
security_opt | Профили безопасности | seccomp:unconfined снимает защиту |
privileged | Все привилегии | Почти никогда не нужен |
shm_size | Размер /dev/shm | Умолчание 64 MB мало для некоторых баз |
ulimits | Лимиты процесса | Ключ nofile требует soft и hard |
sysctls | Параметры ядра | Разрешены не все |
Внутренний механизм
Как Compose превращает service в docker run
Каждый ключ отображается в аргумент Engine API. Увидеть результат можно через docker inspect уже созданного container'а — или заранее через docker compose config, где краткие формы развёрнуты в длинные.
Порядок применения при конфликте: значения из образа → значения из compose.yaml → флаги команды (run -e, run --user).
Почему entrypoint обнуляет CMD
Так устроен сам Docker, а не Compose: изменение точки входа делает прежние аргументы бессмысленными. Аргументы CMD рассчитаны на конкретный ENTRYPOINT, и передавать их новому — источник трудноуловимых ошибок.
Поведение совпадает с флагом --entrypoint у docker run (урок 5.4).
Команды и примеры
entrypoint обнуляет CMD
mkdir -p /tmp/svcref && cd /tmp/svcref
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["аргумент-из-образа"]
EOF
cat > entrypoint.sh <<'EOF'
#!/bin/sh
echo "точка входа получила: [$*]"
EOF
cat > compose.yaml <<'EOF'
name: svcref
services:
as-is:
build: .
only-command:
build: .
command: ["свой-аргумент"]
only-entrypoint:
build: .
entrypoint: ["/entrypoint.sh"]
both:
build: .
entrypoint: ["/entrypoint.sh"]
command: ["и-точка-входа", "и-аргументы"]
EOF
docker compose build -q > /dev/null 2>&1
for s in as-is only-command only-entrypoint both; do
printf ' %-16s ' "$s"
docker compose run --rm -T "$s" 2>/dev/null
done
Ожидаемый вывод:
as-is точка входа получила: [аргумент-из-образа]
only-command точка входа получила: [свой-аргумент]
only-entrypoint точка входа получила: []
both точка входа получила: [и-точка-входа и-аргументы]
Третья строка — то самое поведение: указали только entrypoint, и CMD образа исчез. Аргументов нет, хотя в Dockerfile они были.
Ошибка коварна тем, что в реальном приложении вместо пустого списка получается запуск без обязательных параметров — и падение с невнятным сообщением.
Приоритет источников переменных
cd /tmp/svcref
cat > .env.common <<'EOF'
LEVEL=из-env-file-1
COMMON_ONLY=только-в-common
EOF
cat > .env.local <<'EOF'
LEVEL=из-env-file-2
LOCAL_ONLY=только-в-local
EOF
cat > compose.yaml <<'EOF'
name: svcref
services:
show:
image: python:3.13-slim
env_file:
- .env.common
- .env.local
environment:
LEVEL: из-environment
FROM_SHELL:
command:
- python
- -c
- |
import os
for k in ("LEVEL", "COMMON_ONLY", "LOCAL_ONLY", "FROM_SHELL"):
print(f" {k:<12} = {os.environ.get(k, '(отсутствует)')}")
EOF
echo "═══ без FROM_SHELL в окружении ═══"
docker compose run --rm -T show 2>/dev/null
echo "═══ с FROM_SHELL в окружении ═══"
FROM_SHELL=значение-из-shell docker compose run --rm -T show 2>/dev/null
echo "═══ переопределение через run -e ═══"
docker compose run --rm -T -e LEVEL=из-флага show 2>/dev/null | head -1
Ожидаемый вывод:
═══ без FROM_SHELL в окружении ═══
LEVEL = из-environment
COMMON_ONLY = только-в-common
LOCAL_ONLY = только-в-local
FROM_SHELL = (отсутствует)
═══ с FROM_SHELL в окружении ═══
LEVEL = из-environment
COMMON_ONLY = только-в-common
LOCAL_ONLY = только-в-local
FROM_SHELL = значение-из-shell
═══ переопределение через run -e ═══
LEVEL = из-флага
Лестница приоритетов видна целиком. LEVEL определён в трёх местах — побеждает environment; флаг -e побеждает и его.
Обратите внимание на FROM_SHELL в первом блоке: переменной нет вообще, а не пустая строка. Ключ без значения не создаёт переменную, если её нет в окружении.
Кавычки в ports и user
cd /tmp/svcref
cat > compose.yaml <<'EOF'
name: svcref
services:
quoted:
image: alpine:3.21
command: ["true"]
ports:
- "22:22"
- "8080:80"
user: "1000:1000"
unquoted:
image: alpine:3.21
command: ["true"]
ports:
- 22:22
- 8080:80
EOF
echo "═══ как разобрал парсер ═══"
docker compose config 2>/dev/null | python3 -c "
import sys, yaml
cfg = yaml.safe_load(sys.stdin)
for name, svc in cfg['services'].items():
print(f' {name}:')
for p in svc.get('ports', []):
pub = p.get('published') if isinstance(p, dict) else p
tgt = p.get('target') if isinstance(p, dict) else ''
print(f' published={pub!r} target={tgt!r}')
if 'user' in svc:
print(f' user={svc[\"user\"]!r}')
"
Ожидаемый вывод:
═══ как разобрал парсер ═══
quoted:
published='22' target=22
published='8080' target=80
user='1000:1000'
unquoted:
published='22' target=22
published='8080' target=80
На текущих версиях Compose оба варианта разобрались одинаково: используемый парсер YAML не применяет шестидесятеричное правило.
Кавычки всё равно ставьте. Три причины, не зависящие от версии парсера:
| Причина | Пояснение |
|---|---|
| Рекомендация документации | Она прямо предписывает строковую форму |
| Другие инструменты | Линтеры, конвертеры и редакторы используют свои парсеры |
user: 1000:1000 без кавычек | Значение, которое YAML может истолковать иначе |
Проверьте на своей версии — команда выше даёт ответ за секунду. Но привычка квотировать надёжнее знания о конкретном парсере.
Лимиты ресурсов без Swarm
cd /tmp/svcref
cat > compose.yaml <<'EOF'
name: svcref
services:
limited:
image: python:3.13-slim
command: ["sleep", "300"]
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
pids: 100
short-form:
image: python:3.13-slim
command: ["sleep", "300"]
mem_limit: 128m
cpus: 0.25
pids_limit: 50
EOF
docker compose up -d > /dev/null 2>&1
sleep 3
for s in limited short-form; do
cid="$(docker compose ps -q "$s")"
printf ' %s:\n' "$s"
printf ' memory.max: %s MiB\n' \
"$(docker exec "$cid" awk '{printf "%.0f", $1/1048576}' /sys/fs/cgroup/memory.max)"
printf ' cpu.max: %s\n' "$(docker exec "$cid" cat /sys/fs/cgroup/cpu.max)"
printf ' pids.max: %s\n' "$(docker exec "$cid" cat /sys/fs/cgroup/pids.max)"
done
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
limited:
memory.max: 256 MiB
cpu.max: 50000 100000
pids.max: 100
short-form:
memory.max: 128 MiB
cpu.max: 25000 100000
pids.max: 50
Обе формы записи применились — вопреки распространённому мнению, что deploy работает только в Swarm. Значения в cgroup подтверждают это (урок 6.13).
Проверим, что часть deploy действительно игнорируется:
cd /tmp/svcref
cat > compose.yaml <<'EOF'
name: svcref
services:
app:
image: alpine:3.21
command: ["sleep", "60"]
deploy:
replicas: 2
restart_policy:
condition: on-failure
placement:
constraints:
- node.role == manager
EOF
docker compose up -d 2>&1 | grep -iE 'warn|ignor' | sed 's/^/ /' || true
printf ' запущено container: %s\n' "$(docker compose ps -q | wc -l)"
docker compose ps --format ' {{.Name}}'
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
запущено container: 2
svcref-app-1
svcref-app-2
replicas: 2 сработал — создано два container'а. placement и restart_policy не применились, но и предупреждения не вызвали: Compose молча игнорирует неприменимые ключи.
Отсюда практическое следствие: отсутствие ошибки не означает, что ключ подействовал. Проверять нужно результат.
container_name ломает масштабирование
cd /tmp/svcref
cat > compose.yaml <<'EOF'
name: svcref
services:
named:
image: alpine:3.21
container_name: fixed-name
command: ["sleep", "60"]
EOF
echo "═══ одна реплика ═══"
docker compose up -d > /dev/null 2>&1
docker compose ps --format ' {{.Name}}'
echo "═══ попытка масштабировать ═══"
docker compose up -d --scale named=3 2>&1 | tail -2 | sed 's/^/ /'
docker compose down > /dev/null 2>&1
echo "═══ без container_name ═══"
cat > compose.yaml <<'EOF'
name: svcref
services:
named:
image: alpine:3.21
command: ["sleep", "60"]
EOF
docker compose up -d --scale named=3 > /dev/null 2>&1
docker compose ps --format ' {{.Name}}'
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
═══ одна реплика ═══
fixed-name
═══ попытка масштабировать ═══
Error response from daemon: Conflict. The container name "/fixed-name" is
already in use. You have to remove (or rename) that container to be able to
reuse that name.
═══ без container_name ═══
svcref-named-1
svcref-named-2
svcref-named-3
Ошибка прямая: два container'а не могут носить одно имя. Без container_name Compose нумерует их автоматически.
Завершение: grace period и сигнал
cd /tmp/svcref
cat > worker.py <<'PY'
"""Worker с долгой задачей: показывает работу stop_grace_period."""
import signal
import sys
import time
_stop = False
def handler(signum, _frame):
global _stop
print(f"получен {signal.Signals(signum).name}", flush=True)
_stop = True
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
print("работаю", flush=True)
while not _stop:
time.sleep(0.2)
print("доделываю задачу (5 секунд)", flush=True)
time.sleep(5)
print("завершён штатно", flush=True)
sys.exit(0)
PY
cat > compose.yaml <<'EOF'
name: svcref
services:
short-grace:
image: python:3.13-slim
volumes:
- ./worker.py:/worker.py:ro
command: ["python", "-u", "/worker.py"]
stop_grace_period: 2s
long-grace:
image: python:3.13-slim
volumes:
- ./worker.py:/worker.py:ro
command: ["python", "-u", "/worker.py"]
stop_grace_period: 30s
sigint:
image: python:3.13-slim
volumes:
- ./worker.py:/worker.py:ro
command: ["python", "-u", "/worker.py"]
stop_signal: SIGINT
stop_grace_period: 30s
EOF
docker compose up -d > /dev/null 2>&1
sleep 3
for s in short-grace long-grace sigint; do
cid="$(docker compose ps -q "$s")"
start="$(date +%s.%N)"
docker compose stop "$s" > /dev/null 2>&1
end="$(date +%s.%N)"
printf ' %-12s время=%.1f c код=%s сигнал=%s\n' \
"$s" \
"$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')" \
"$(docker inspect "$cid" --format '{{.State.ExitCode}}')" \
"$(docker logs "$cid" 2>&1 | grep -o 'SIG[A-Z]*' | head -1)"
done
docker compose down > /dev/null 2>&1
Ожидаемый вывод:
short-grace время=2.2 c код=137 сигнал=SIGTERM
long-grace время=5.3 c код=0 сигнал=SIGTERM
sigint время=5.3 c код=0 сигнал=SIGINT
Первая строка — цена недостаточного grace period: задача требовала 5 секунд, отведено 2, процесс убит SIGKILL с кодом 137.
Вторая — та же задача при 30 секундах: завершилась за 5.3 и вышла с кодом 0. Ожидание заняло ровно столько, сколько понадобилось, а не все 30.
Третья подтверждает работу stop_signal.
init: true и зомби
cd /tmp/svcref
cat > spawner.py <<'PY'
"""Порождает потомков и не пожинает их — классический источник зомби."""
import os
import time
for _ in range(5):
if os.fork() == 0:
time.sleep(0.5)
os._exit(0)
time.sleep(3)
zombies = 0
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open(f"/proc/{pid}/stat") as f:
if f.read().split()[2] == "Z":
zombies += 1
except OSError:
pass
print(f"зомби-процессов: {zombies}", flush=True)
print(f"PID 1: {open('/proc/1/comm').read().strip()}", flush=True)
PY
cat > compose.yaml <<'EOF'
name: svcref
services:
no-init:
image: python:3.13-slim
volumes:
- ./spawner.py:/spawner.py:ro
command: ["python", "-u", "/spawner.py"]
with-init:
image: python:3.13-slim
init: true
volumes:
- ./spawner.py:/spawner.py:ro
command: ["python", "-u", "/spawner.py"]
EOF
for s in no-init with-init; do
printf ' %s:\n' "$s"
docker compose run --rm -T "$s" 2>/dev/null | sed 's/^/ /'
done
docker compose down > /dev/null 2>&1
cd /tmp && rm -rf /tmp/svcref
Ожидаемый вывод:
no-init:
зомби-процессов: 5
PID 1: python
with-init:
зомби-процессов: 0
PID 1: docker-init
Разница наглядна: без init пять завершившихся потомков остались зомби, потому что python как PID 1 их не пожинает. С init: true роль PID 1 берёт docker-init, который делает это автоматически (урок 6.5).
Практическое упражнение
Задание. Напишите сервис, в котором осознанно задан каждый значимый атрибут, и подтвердите действие семи из них.
- Образ собирается и получает осмысленное имя (
buildплюсimage). entrypointиcommandзаданы вместе; показано, что при отсутствииcommandаргументы теряются.- Переменные приходят из трёх источников; показан итоговый приоритет.
- Лимиты памяти, CPU и PID применены — подтверждено чтением cgroup.
- Процесс работает от заданного UID, корень доступен только на чтение,
/tmpсмонтирован какtmpfsс ограничением. stop_grace_periodдостаточен для завершения задачи; код выхода0.init: trueустраняет зомби.
Скрипт проверки возвращает ненулевой код при любом расхождении.
Подсказки
Подсказка 1
Для пункта 2 понадобятся два сервиса из одного образа, различающиеся только наличием command.
Подсказка 2
Пункт 5 потребует tmpfs для /tmp: при read_only: true запись туда иначе невозможна.
Подсказка 3
Проверять лимиты надёжнее чтением /sys/fs/cgroup/, а не через docker inspect.
Решение
Показать решение
mkdir -p /tmp/svcfull && cd /tmp/svcfull
cat > entrypoint.sh <<'EOF'
#!/bin/sh
# Точка входа: печатает полученные аргументы и передаёт управление
echo "ENTRYPOINT args=[$*]"
exec python -u /app/main.py "$@"
EOF
cat > main.py <<'PY'
"""Демонстрирует все проверяемые атрибуты сервиса."""
from __future__ import annotations
import os
import signal
import sys
import time
from pathlib import Path
def read_cgroup(name: str) -> str:
try:
return (Path("/sys/fs/cgroup") / name).read_text().strip()
except OSError:
return "н/д"
def count_zombies() -> int:
n = 0
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open(f"/proc/{pid}/stat") as f:
if f.read().split()[2] == "Z":
n += 1
except OSError:
pass
return n
_stop = False
def handler(signum, _frame):
global _stop
print(f"СИГНАЛ {signal.Signals(signum).name}", flush=True)
_stop = True
def main(argv: list[str]) -> int:
signal.signal(signal.SIGTERM, handler)
print(f"ARGS={argv}", flush=True)
print(f"UID={os.getuid()}:{os.getgid()}", flush=True)
print(f"PID1={Path('/proc/1/comm').read_text().strip()}", flush=True)
for var in ("LEVEL", "COMMON_ONLY", "LOCAL_ONLY"):
print(f"ENV {var}={os.environ.get(var, '(нет)')}", flush=True)
print(f"MEM_MAX={read_cgroup('memory.max')}", flush=True)
print(f"CPU_MAX={read_cgroup('cpu.max')}", flush=True)
print(f"PIDS_MAX={read_cgroup('pids.max')}", flush=True)
# read_only: запись в корень должна падать, в /tmp — работать
try:
Path("/probe-root").write_text("x")
print("ROOT_WRITABLE=да", flush=True)
except OSError:
print("ROOT_WRITABLE=нет", flush=True)
try:
Path("/tmp/probe").write_text("x")
print("TMP_WRITABLE=да", flush=True)
except OSError:
print("TMP_WRITABLE=нет", flush=True)
# Порождаем потомков, чтобы проверить init
for _ in range(5):
if os.fork() == 0:
time.sleep(0.3)
os._exit(0)
time.sleep(1.5)
print(f"ZOMBIES={count_zombies()}", flush=True)
if "--serve" not in argv:
return 0
print("READY", flush=True)
while not _stop:
time.sleep(0.2)
print("доделываю задачу (4 секунды)", flush=True)
time.sleep(4)
print("ЗАВЕРШЁН ШТАТНО", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY main.py /app/main.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["из-образа"]
EOF
cat > .env.common <<'EOF'
LEVEL=из-env-file
COMMON_ONLY=да
EOF
cat > .env.local <<'EOF'
LOCAL_ONLY=да
EOF
cat > compose.yaml <<'EOF'
name: svcfull
services:
# Пункт 1: собирается и получает осмысленное имя
app: &app-base
build:
context: .
dockerfile: Dockerfile
image: svcfull:local
pull_policy: build
# Пункт 2: entrypoint И command заданы вместе
entrypoint: ["/entrypoint.sh"]
command: ["--serve", "свой-аргумент"]
# Пункт 3: три источника переменных
env_file:
- .env.common
- .env.local
environment:
LEVEL: из-environment
# Пункт 4: лимиты ресурсов
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
pids: 150
# Пункт 5: пользователь, read-only корень, tmpfs
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
# Пункт 6: достаточный grace period
stop_grace_period: 30s
stop_signal: SIGTERM
# Пункт 7: сборщик зомби
init: true
restart: "no"
working_dir: /app
labels:
ru.example.role: "demo"
# Тот же образ, но без command — для пункта 2
no-command:
<<: *app-base
command: null
init: true
# Без init — для сравнения в пункте 7
no-init:
<<: *app-base
init: false
command: []
EOF
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
field() { grep -m1 "^$1=" /tmp/svcfull/out.txt | cut -d= -f2-; }
docker compose build -q > /dev/null 2>&1
printf '\n═══ Пункт 1: имя образа ═══\n'
img="$(docker compose config --images | head -1)"
printf ' образ: %s\n' "$img"
[ "$img" = "svcfull:local" ] && ok "образ назван явно" || bad "имя образа: $img"
printf '\n═══ Пункты 2–5, 7: запуск ═══\n'
docker compose run --rm -T no-init 2>/dev/null > /tmp/svcfull/out.txt
sed 's/^/ /' /tmp/svcfull/out.txt
printf '\n проверки:\n'
[ "$(field UID)" = "10001:10001" ] && ok "UID задан (пункт 5)" || bad "UID=$(field UID)"
[ "$(field ROOT_WRITABLE)" = "нет" ] && ok "корень только на чтение (пункт 5)" || bad "корень доступен на запись"
[ "$(field TMP_WRITABLE)" = "да" ] && ok "/tmp доступен (tmpfs, пункт 5)" || bad "/tmp недоступен"
[ "$(field MEM_MAX)" = "268435456" ] && ok "лимит памяти 256 MiB (пункт 4)" || bad "memory.max=$(field MEM_MAX)"
[ "$(field CPU_MAX)" = "50000 100000" ] && ok "лимит CPU 0.5 (пункт 4)" || bad "cpu.max=$(field CPU_MAX)"
[ "$(field PIDS_MAX)" = "150" ] && ok "лимит PID 150 (пункт 4)" || bad "pids.max=$(field PIDS_MAX)"
[ "$(field 'ENV LEVEL')" = "из-environment" ] && ok "приоритет environment (пункт 3)" \
|| bad "LEVEL=$(field 'ENV LEVEL')"
grep -q '^ENV COMMON_ONLY=да' /tmp/svcfull/out.txt && grep -q '^ENV LOCAL_ONLY=да' /tmp/svcfull/out.txt \
&& ok "оба env_file прочитаны (пункт 3)" || bad "переменные из env_file потеряны"
[ "$(field ZOMBIES)" != "0" ] && ok "без init зомби есть: $(field ZOMBIES) (пункт 7, контроль)" \
|| bad "зомби не появились — проверка неинформативна"
printf '\n═══ Пункт 7: с init ═══\n'
docker compose run --rm -T app --no-serve 2>/dev/null > /tmp/svcfull/out.txt || true
printf ' PID1=%s ZOMBIES=%s\n' "$(field PID1)" "$(field ZOMBIES)"
[ "$(field ZOMBIES)" = "0" ] && ok "init устранил зомби" || bad "зомби остались: $(field ZOMBIES)"
[ "$(field PID1)" = "docker-init" ] && ok "PID 1 — docker-init" || bad "PID1=$(field PID1)"
printf '\n═══ Пункт 2: command обнуляется вместе с entrypoint ═══\n'
docker compose run --rm -T app 2>/dev/null | grep -m1 '^ENTRYPOINT args' | sed 's/^/ app: /'
docker compose run --rm -T no-command 2>/dev/null | grep -m1 '^ENTRYPOINT args' | sed 's/^/ no-command: /'
a="$(docker compose run --rm -T app 2>/dev/null | grep -m1 '^ENTRYPOINT args')"
b="$(docker compose run --rm -T no-command 2>/dev/null | grep -m1 '^ENTRYPOINT args')"
[ "$a" != "$b" ] && ok "разница видна: без command аргументов нет" || bad "разницы нет"
printf '\n═══ Пункт 6: graceful shutdown ═══\n'
docker compose up -d app > /dev/null 2>&1
sleep 4
cid="$(docker compose ps -q app)"
start="$(date +%s.%N)"
docker compose stop app > /dev/null 2>&1
end="$(date +%s.%N)"
elapsed="$(awk -v a="$start" -v b="$end" 'BEGIN{printf "%.1f", b-a}')"
code="$(docker inspect "$cid" --format '{{.State.ExitCode}}')"
printf ' время остановки: %s c, код выхода: %s\n' "$elapsed" "$code"
docker logs "$cid" 2>&1 | grep -E 'СИГНАЛ|ЗАВЕРШЁН' | sed 's/^/ /'
[ "$code" = "0" ] && ok "завершение штатное, задача доделана (пункт 6)" || bad "код выхода $code"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все семь атрибутов подтверждены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v > /dev/null 2>&1
docker rmi -f svcfull:local > /dev/null 2>&1
cd /tmp && rm -rf /tmp/svcfull
exit "$fail"
Ожидаемый вывод:
═══ Пункт 1: имя образа ═══
образ: svcfull:local
✓ образ назван явно
═══ Пункты 2–5, 7: запуск ═══
ENTRYPOINT args=[--serve свой-аргумент]
ARGS=['--serve', 'свой-аргумент']
UID=10001:10001
PID1=entrypoint.sh
ENV LEVEL=из-environment
ENV COMMON_ONLY=да
ENV LOCAL_ONLY=да
MEM_MAX=268435456
CPU_MAX=50000 100000
PIDS_MAX=150
ROOT_WRITABLE=нет
TMP_WRITABLE=да
ZOMBIES=5
проверки:
✓ UID задан (пункт 5)
✓ корень только на чтение (пункт 5)
✓ /tmp доступен (tmpfs, пункт 5)
✓ лимит памяти 256 MiB (пункт 4)
✓ лимит CPU 0.5 (пункт 4)
✓ лимит PID 150 (пункт 4)
✓ приоритет environment (пункт 3)
✓ оба env_file прочитаны (пункт 3)
✓ без init зомби есть: 5 (пункт 7, контроль)
═══ Пункт 7: с init ═══
PID1=docker-init ZOMBIES=0
✓ init устранил зомби
✓ PID 1 — docker-init
═══ Пункт 2: command обнуляется вместе с entrypoint ═══
app: ENTRYPOINT args=[--serve свой-аргумент]
no-command: ENTRYPOINT args=[]
✓ разница видна: без command аргументов нет
═══ Пункт 6: graceful shutdown ═══
время остановки: 4.3 c, код выхода: 0
СИГНАЛ SIGTERM
ЗАВЕРШЁН ШТАТНО
✓ завершение штатное, задача доделана (пункт 6)
═══ ИТОГ ═══
все семь атрибутов подтверждены
Все семь атрибутов подтверждены.
Три решения, определяющие качество.
Лимиты проверяются чтением cgroup, а не docker inspect. docker inspect показывает, что Compose запросил у Engine; /sys/fs/cgroup/memory.max показывает, что ядро применило. Различие не теоретическое: на системе с cgroup v1 или при отсутствии части контроллеров запрос уходит, а лимит не действует — и inspect об этом не скажет.
Пункт 7 проверяется в обе стороны: сначала подтверждается, что зомби вообще появляются. Строка «без init зомби есть: 5» — контрольная. Без неё проверка ZOMBIES=0 с init: true ничего не доказывала бы: ноль мог означать, что потомки не породились.
Три сервиса собраны из одного якоря &app-base с точечными переопределениями. Копирование конфигурации трижды означало бы, что различие между app и no-command перестанет быть единственным после первой же правки — и проверка пункта 2 начнёт сравнивать не то. Якорь фиксирует: отличается ровно один ключ (урок 9.6).
Чего решение не делает. Оно не проверяет restart, pull_policy и labels — эти атрибуты заданы, но их действие проявляется в сценариях за рамками одного запуска: перезапуск после сбоя, обновление плавающего тега, выборка по метке. Не проверяется и reservations: в отличие от limits, резервирование при docker compose up применяется частично и зависит от версии.
Проверка результата
mkdir -p /tmp/sr && cd /tmp/sr
cat > compose.yaml <<'EOF'
name: sr
services:
a:
image: alpine:3.21
command: ["sleep", "60"]
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=8m
deploy:
resources:
limits:
memory: 64M
EOF
docker compose up -d
docker compose exec -T a sh -c 'id -u; cat /sys/fs/cgroup/memory.max; touch /probe 2>&1 | tail -1'
docker compose down
cd /tmp && rm -rf /tmp/sr
Ожидается 1000, 67108864 и ошибка Read-only file system.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
entrypoint без command | Не знают о поведении | CMD образа обнуляется; задать оба |
Строковая форма command | Короче | Запуск через shell; приложение не PID 1 |
ports без кавычек | Работает | Рекомендация документации; риск неоднозначного разбора |
user: 1000:1000 без кавычек | То же | Та же форма записи |
container_name | Кажется удобным | Ломает масштабирование и изоляцию проектов |
.env считают источником переменных container'а | Название вводит в заблуждение | Он для подстановки в сам файл |
Ожидают, что deploy игнорируется без Swarm | Слышали упрощение | limits и replicas работают |
| Ожидают ошибку на неприменимый ключ | Логично предположить | Compose молча игнорирует |
restart: always для миграций | Скопировали у сервиса | Задача перезапускается бесконечно |
stop_grace_period по умолчанию для worker'а | Не задумывались | 10 секунд мало; код 137 |
read_only: true без tmpfs | Забыли | Приложение падает на первой записи |
expose в расчёте на доступ с host | Путают с ports | Не публикует |
Контрольные вопросы
На понимание:
- Почему задание
entrypointобнуляетCMDобраза? - Каков приоритет
env_file,environmentи переменных shell? - Какие ключи раздела
deployработают без Swarm? - Чем
container_nameмешает и когда он всё же нужен? - Что делает
init: trueи когда он необходим?
На применение:
- Как собрать образ и дать ему осмысленное имя одновременно?
- Как задать лимит памяти двумя способами?
- Как настроить сервис с read-only корнем, чтобы он мог писать во временные файлы?
На диагностику:
- Container останавливается ровно за 10 секунд с кодом
137. Причина? - Ключ добавлен в
compose.yaml, ошибки нет, эффекта тоже. Что проверить?
Краткое резюме
buildиimageвместе дают сборку с осмысленным именем образа.pull_policy: alwaysнужен для плавающих тегов, иначе локальная копия устареет.- Задание
entrypointобнуляетCMDобраза — указывайтеcommandявно. - Списочная форма
commandпредпочтительна: приложение остаётся PID 1. - Приоритет переменных:
env_file→environment→run -e. - Ключ без значения берёт переменную из окружения shell или не создаёт её вовсе.
- Значения
portsиuserзаписывают строкой — так предписывает документация. exposeне публикует порт; внутри сети он доступен и без объявления.deploy.resources.limitsиdeploy.replicasработают без Swarm;placement— нет.- Неприменимые ключи игнорируются молча: отсутствие ошибки ничего не доказывает.
container_nameломает масштабирование и изоляцию проектов.stop_grace_period,stop_signalиinitопределяют корректность завершения.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: services top-level element | https://docs.docker.com/reference/compose-file/services/ | Полный перечень атрибутов |
| Compose: build | https://docs.docker.com/reference/compose-file/build/ | context, target, args, совместимость с image |
| Compose: deploy | https://docs.docker.com/reference/compose-file/deploy/ | Что применимо без Swarm |
| Compose: environment variables | https://docs.docker.com/compose/how-tos/environment-variables/envvars-precedence/ | Приоритет источников |
| Compose: ports | https://docs.docker.com/reference/compose-file/services/#ports | Формы записи, рекомендация о кавычках |
Docker: --entrypoint | https://docs.docker.com/reference/cli/docker/container/run/#entrypoint | Обнуление CMD |
Docker: --init | https://docs.docker.com/reference/cli/docker/container/run/#init | Сборщик зомби |
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | Лимиты и их применение |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Networks и volumes
Главное оглавление