Главная/Docker Compose/Урок

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

yaml
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

yaml
services:
  app:
    image: myapp
    entrypoint: ["/entrypoint.sh"]
    command: ["--port", "8000"]
КлючЧто переопределяет
commandCMD образа
entrypointENTRYPOINT образа

Важная деталь: задание entrypoint обнуляет CMD образа. Если в образе был CMD ["python", "app.py"], а вы указали только entrypoint, аргументов не останется — и контейнер запустит точку входа без параметров.

Правило: задали entrypoint — задайте и command, если он нужен.

Обе формы поддерживают строку и список. Список предпочтителен: строковая форма исполняется через shell, и приложение перестаёт быть PID 1 (урок 6.5).

environment и env_file

yaml
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

Приоритет, от низшего к высшему:

ПриоритетИсточник
1env_file, первый файл в списке
2env_file, последующие файлы
3environment в файле
4Переменная окружения shell — только для ключа без значения
5docker compose run -e

Ключ без значения (FROM_SHELL:) означает «взять из окружения, где запущен Compose». Это отличается от пустой строки: если переменной нет, ключ вообще не попадёт в container.

Файл .env в каталоге проекта играет другую роль: он задаёт переменные для подстановки ${VAR} в сам compose.yaml, а не автоматически передаётся в container'ы (урок 9.5). Это регулярно путают.

ports и expose

yaml
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

yaml
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).

Ресурсы

Две формы записи, и различие между ними важно:

yaml
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. Лимиты ресурсов работают.

Перезапуск

yaml
services:
  app:
    restart: unless-stopped
ЗначениеПоведение
no (умолчание)Не перезапускать
alwaysВсегда, включая после перезагрузки host
unless-stoppedКак always, но не поднимать то, что остановлено вручную
on-failureТолько при ненулевом коде выхода
on-failure:5То же, но не более пяти раз

Для сервисов unless-stopped — обычно правильный выбор: он переживает перезагрузку, но уважает ручную остановку.

Для одноразовых задач (миграции) restart не задают вовсе: перезапуск завершившейся задачи — почти всегда ошибка.

Завершение

yaml
services:
  worker:
    stop_grace_period: 30s
    stop_signal: SIGINT
    init: true
КлючУмолчаниеКогда менять
stop_grace_period10sЗадачи длиннее 10 секунд (урок 6.11)
stop_signalSIGTERMПриложение реагирует на другой сигнал
initfalseПроцесс порождает потомков и не собирает зомби

init: true запускает крошечный init-процесс как PID 1. Нужен, когда главный процесс не умеет пожинать потомков (урок 6.5).

container_name — почти всегда ошибка

yaml
services:
  db:
    container_name: my-database        # не делайте так
ПоследствиеПояснение
Масштабирование невозможноДва container'а не могут иметь одно имя
Конфликт между проектамиИмя перестаёт содержать префикс проекта
Ломается изоляцияДва стека на одной машине не запустятся

Компромисса нет: сервисы и так доступны по имени сервиса через DNS (урок 8.4). container_name нужен лишь в редких случаях, когда на имя ссылается внешний инструмент.

Прочие атрибуты

КлючНазначениеТипичная ошибка
working_dirРабочий каталогПереопределяет WORKDIR, ломая относительные пути в CMD
userUID и 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_addCapabilitiescap_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

bash
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

Ожидаемый вывод:

text
  as-is            точка входа получила: [аргумент-из-образа]
  only-command     точка входа получила: [свой-аргумент]
  only-entrypoint  точка входа получила: []
  both             точка входа получила: [и-точка-входа и-аргументы]

Третья строка — то самое поведение: указали только entrypoint, и CMD образа исчез. Аргументов нет, хотя в Dockerfile они были.

Ошибка коварна тем, что в реальном приложении вместо пустого списка получается запуск без обязательных параметров — и падение с невнятным сообщением.

Приоритет источников переменных

bash
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

Ожидаемый вывод:

text
═══ без 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

bash
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}')
"

Ожидаемый вывод:

text
═══ как разобрал парсер ═══
  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

bash
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

Ожидаемый вывод:

text
  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 действительно игнорируется:

bash
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

Ожидаемый вывод:

text
  запущено container: 2
    svcref-app-1
    svcref-app-2

replicas: 2 сработал — создано два container'а. placement и restart_policy не применились, но и предупреждения не вызвали: Compose молча игнорирует неприменимые ключи.

Отсюда практическое следствие: отсутствие ошибки не означает, что ключ подействовал. Проверять нужно результат.

container_name ломает масштабирование

bash
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

Ожидаемый вывод:

text
═══ одна реплика ═══
  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 и сигнал

bash
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

Ожидаемый вывод:

text
  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 и зомби

bash
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

Ожидаемый вывод:

text
  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).


Практическое упражнение

Задание. Напишите сервис, в котором осознанно задан каждый значимый атрибут, и подтвердите действие семи из них.

  1. Образ собирается и получает осмысленное имя (build плюс image).
  2. entrypoint и command заданы вместе; показано, что при отсутствии command аргументы теряются.
  3. Переменные приходят из трёх источников; показан итоговый приоритет.
  4. Лимиты памяти, CPU и PID применены — подтверждено чтением cgroup.
  5. Процесс работает от заданного UID, корень доступен только на чтение, /tmp смонтирован как tmpfs с ограничением.
  6. stop_grace_period достаточен для завершения задачи; код выхода 0.
  7. init: true устраняет зомби.

Скрипт проверки возвращает ненулевой код при любом расхождении.

Подсказки

Подсказка 1

Для пункта 2 понадобятся два сервиса из одного образа, различающиеся только наличием command.

Подсказка 2

Пункт 5 потребует tmpfs для /tmp: при read_only: true запись туда иначе невозможна.

Подсказка 3

Проверять лимиты надёжнее чтением /sys/fs/cgroup/, а не через docker inspect.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Пункт 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 применяется частично и зависит от версии.

Проверка результата

bash
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Не публикует

Контрольные вопросы

На понимание:

  1. Почему задание entrypoint обнуляет CMD образа?
  2. Каков приоритет env_file, environment и переменных shell?
  3. Какие ключи раздела deploy работают без Swarm?
  4. Чем container_name мешает и когда он всё же нужен?
  5. Что делает init: true и когда он необходим?

На применение:

  1. Как собрать образ и дать ему осмысленное имя одновременно?
  2. Как задать лимит памяти двумя способами?
  3. Как настроить сервис с read-only корнем, чтобы он мог писать во временные файлы?

На диагностику:

  1. Container останавливается ровно за 10 секунд с кодом 137. Причина?
  2. Ключ добавлен в compose.yaml, ошибки нет, эффекта тоже. Что проверить?

Краткое резюме

  1. build и image вместе дают сборку с осмысленным именем образа.
  2. pull_policy: always нужен для плавающих тегов, иначе локальная копия устареет.
  3. Задание entrypoint обнуляет CMD образа — указывайте command явно.
  4. Списочная форма command предпочтительна: приложение остаётся PID 1.
  5. Приоритет переменных: env_fileenvironmentrun -e.
  6. Ключ без значения берёт переменную из окружения shell или не создаёт её вовсе.
  7. Значения ports и user записывают строкой — так предписывает документация.
  8. expose не публикует порт; внутри сети он доступен и без объявления.
  9. deploy.resources.limits и deploy.replicas работают без Swarm; placement — нет.
  10. Неприменимые ключи игнорируются молча: отсутствие ошибки ничего не доказывает.
  11. container_name ломает масштабирование и изоляцию проектов.
  12. stop_grace_period, stop_signal и init определяют корректность завершения.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: services top-level elementhttps://docs.docker.com/reference/compose-file/services/Полный перечень атрибутов
Compose: buildhttps://docs.docker.com/reference/compose-file/build/context, target, args, совместимость с image
Compose: deployhttps://docs.docker.com/reference/compose-file/deploy/Что применимо без Swarm
Compose: environment variableshttps://docs.docker.com/compose/how-tos/environment-variables/envvars-precedence/Приоритет источников
Compose: portshttps://docs.docker.com/reference/compose-file/services/#portsФормы записи, рекомендация о кавычках
Docker: --entrypointhttps://docs.docker.com/reference/cli/docker/container/run/#entrypointОбнуление CMD
Docker: --inithttps://docs.docker.com/reference/cli/docker/container/run/#initСборщик зомби
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/Лимиты и их применение

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Networks и volumes
Главное оглавление

Markdown на GitHub ↗