CMD vs ENTRYPOINT: что и как выполняется
В каждом Dockerfile должна быть инструкция, которая говорит «что запускается, когда контейнер стартует». Их две: CMD и ENTRYPOINT. Различить их сложно с первого раза — оба «запускают команду», оба могут быть в exec form или shell form, оба можно переопределить через docker run. Но за этим стоит важная семантика: ENTRYPOINT это «как» (всегда выполняется), CMD это «что» (default аргументы, легко переопределить).
В этом уроке: shell form vs exec form, отличия CMD от ENTRYPOINT, и комбо для гибкого CLI-like интерфейса контейнера.
Signals — kill, SIGTERM/SIGKILL и async-signal-safety
Shell form vs exec form
Обе инструкции (RUN тоже) поддерживают две формы:
Shell form — команда как строка:
CMD python app.py --port 8080
При запуске контейнера Docker оборачивает это в /bin/sh -c "python app.py --port 8080". Под капотом:
- Spawns
/bin/sh - Sh парсит команду, делает variable substitution (
$VAR), wildcard expansion (*) - Sh форкает и exec’ит python
Exec form — команда как JSON array:
CMD ["python", "app.py", "--port", "8080"]
Тут НЕТ shell. Docker exec’ит первый элемент массива напрямую как PID 1:
- Spawns
pythonнапрямую - Аргументы передаются как argv[1..n]
- Никаких variable substitution, wildcard expansion
Какую форму выбрать:
Exec form предпочтительнее по двум причинам:
-
Сигналы. В shell form контейнер запускает sh как PID 1, и sh запускает твой процесс как child. Когда docker stop посылает SIGTERM, он идёт PID 1 (sh) — а sh по дефолту не пересылает сигналы детям. Твой Python-процесс не получит SIGTERM и не сможет gracefully shutdown. В exec form Python запускается как PID 1 напрямую — сигналы идут ему.
-
Производительность. На один процесс меньше (без
sh -c).
# Плохо: shell form
CMD python app.py
# Внутри контейнера: PID 1 = /bin/sh, PID 2 = python
# docker stop посылает SIGTERM PID 1 -- sh не передаёт детям
# Хорошо: exec form
CMD ["python", "app.py"]
# Внутри контейнера: PID 1 = python
# docker stop посылает SIGTERM PID 1 = python -- graceful shutdown работает
Shell form имеет одно преимущество: variable substitution и shell-конструкции:
ENV PORT=8080
# Shell form -- работает, sh раскрывает $PORT
CMD python app.py --port $PORT
# Exec form -- НЕ РАБОТАЕТ, $PORT передаётся буквально
CMD ["python", "app.py", "--port", "$PORT"] # python получит литерал "$PORT"
# Workaround: явно вызвать sh
CMD ["sh", "-c", "python app.py --port $PORT"]
В большинстве DE-сценариев лучше переменные читать в самом приложении (os.environ['PORT']), а в Dockerfile использовать exec form.
Hadolint правило DL3025 — “Use arguments JSON notation for CMD and ENTRYPOINT”. Это автоматически блокирует shell form в CI, потому что shell form ломает SIGTERM-handling.
CMD: что выполнить по умолчанию
CMD задаёт команду, которая будет выполнена при docker run image БЕЗ дополнительных аргументов. Её легко переопределить:
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
# Запускается app.py (CMD)
docker run myimage
# CMD переопределён аргументами docker run
docker run myimage python other.py # запустится python other.py
docker run myimage bash # запустится bash (для отладки)
docker run myimage ls /app # запустится ls
В Dockerfile может быть только одна CMD. Если их несколько — действует последняя.
CMD удобна, когда контейнер по умолчанию делает одну вещь, но иногда нужно переопределить. Типичный пример — Postgres:
# Внутри postgres:16 Dockerfile (упрощённо)
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["postgres"]
# По умолчанию запускается postgres
docker run postgres:16
# Можно переопределить CMD для отладки:
docker run postgres:16 psql -V # запустит psql -V вместо postgres
# (но ENTRYPOINT docker-entrypoint.sh всё равно выполнится)
ENTRYPOINT: обёртка, которая всегда выполняется
ENTRYPOINT задаёт команду, которая выполняется всегда, и аргументы docker run или CMD передаются ей как параметры.
FROM python:3.13-slim
COPY etl.py /usr/local/bin/etl
RUN chmod +x /usr/local/bin/etl
ENTRYPOINT ["python", "/usr/local/bin/etl"]
# Запустится: python /usr/local/bin/etl
docker run my-etl
# Запустится: python /usr/local/bin/etl --verbose --date 2026-01-15
docker run my-etl --verbose --date 2026-01-15
# Аргументы добавляются к ENTRYPOINT
Это превращает контейнер в executable, который ведёт себя как обычная CLI-утилита: ты передаёшь ему флаги, он работает.
В Dockerfile может быть только один ENTRYPOINT. Если их несколько — действует последний.
ENTRYPOINT shell form тоже существует, но имеет тот же недостаток с сигналами:
# Плохо: shell form ENTRYPOINT
ENTRYPOINT python app.py
# Все CMD / docker run args ИГНОРИРУЮТСЯ -- sh -c принимает только одну строку
# Хорошо: exec form
ENTRYPOINT ["python", "app.py"]
Комбо ENTRYPOINT + CMD: default args
Магия начинается когда оба используются вместе. ENTRYPOINT задаёт «как» (что всегда выполняется), CMD задаёт «что по умолчанию передать».
FROM python:3.13-slim
COPY etl.py /app/etl.py
WORKDIR /app
ENTRYPOINT ["python", "etl.py"]
CMD ["--mode", "full", "--verbose"]
# Запустится: python etl.py --mode full --verbose
docker run my-etl
# CMD переопределена -- запустится: python etl.py --mode incremental
docker run my-etl --mode incremental
# ENTRYPOINT всегда python etl.py, CMD заменяется аргументами docker run
Это и есть «контейнер как CLI-утилита с default args». Postgres использует тот же паттерн:
docker run postgres:16 # запускает postgres
docker run postgres:16 postgres -D /data --max-connections=200 # postgres с custom args
docker run postgres:16 psql # переопределяет CMD на psql, ENTRYPOINT всё ещё выполняется
docker run -it postgres:16 bash # эквивалентно: docker-entrypoint.sh bash
Переопределение ENTRYPOINT
Иногда нужно полностью переопределить ENTRYPOINT — например, для отладки. Флаг --entrypoint:
# Заменить ENTRYPOINT на bash для отладки
docker run -it --entrypoint /bin/bash my-etl
# Внутри bash, ENTRYPOINT python etl.py НЕ запускается
# Заменить на python без etl.py
docker run -it --entrypoint python my-etl -c "import etl; etl.dry_run()"
--entrypoint принимает только executable; аргументы идут после имени образа (как CMD).
Это полезно для troubleshooting: «образ собрался, но запускается с ошибкой — давай попаду внутрь и посмотрю».
Когда что использовать
Только CMD:
Когда контейнер — простой скрипт, и пользователю может понадобиться его переопределить (для отладки, для запуска тестов внутри образа).
FROM python:3.13-slim
COPY . /app
WORKDIR /app
CMD ["python", "main.py"]
docker run image bash для отладки сработает (заменяет CMD).
Только ENTRYPOINT:
Когда контейнер — это утилита-обёртка, которая ВСЕГДА должна делать одно и то же. Аргументы передаются ей.
FROM python:3.13-slim
COPY etl.py /usr/local/bin/etl
RUN chmod +x /usr/local/bin/etl
ENTRYPOINT ["/usr/local/bin/etl"]
docker run image --date 2026-01-15 запустит etl с этими args.
ENTRYPOINT + CMD:
Когда нужна обёртка с default-аргументами, которые можно перекрыть.
FROM python:3.13-slim
COPY etl.py /usr/local/bin/etl
RUN chmod +x /usr/local/bin/etl
ENTRYPOINT ["/usr/local/bin/etl"]
CMD ["--mode", "full"]
docker run image запустит etl с --mode full. docker run image --mode incremental заменит args.
Wrapper-скрипт как ENTRYPOINT:
Часто в production используется bash-скрипт entrypoint.sh, который делает pre-startup logic (миграции, проверки), а потом exec’ит основную команду:
COPY entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["python", "app.py"]
# entrypoint.sh
#!/bin/bash
set -e
# Pre-startup logic
if [ -n "$WAIT_FOR_DB" ]; then
until pg_isready -h "$DB_HOST"; do sleep 1; done
fi
# Run migrations
if [ "$RUN_MIGRATIONS" = "true" ]; then
python manage.py migrate
fi
# Execute the main command (replaces $0 with $@)
exec "$@"
exec "$@" — критично: заменяет shell-процесс самим Python (PID 1 становится python), сигналы работают правильно.
Попробуй сам
Поэкспериментируй с CMD и ENTRYPOINT:
mkdir cmd-entrypoint && cd cmd-entrypoint
# Вариант 1: только CMD
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
CMD ["python", "-c", "print('hello from CMD')"]
EOF
docker build -t test:cmd .
docker run --rm test:cmd
# hello from CMD
docker run --rm test:cmd python -c "print('overridden')"
# overridden -- CMD переопределена
# Вариант 2: только ENTRYPOINT
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENTRYPOINT ["python", "-c"]
EOF
docker build -t test:ep .
docker run --rm test:ep "print('from entrypoint')"
# from entrypoint -- арг передан в ENTRYPOINT
# Переопределить ENTRYPOINT можно только через --entrypoint
docker run --rm --entrypoint echo test:ep "different command"
# different command
# Вариант 3: ENTRYPOINT + CMD
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENTRYPOINT ["python", "-c"]
CMD ["print('default code')"]
EOF
docker build -t test:both .
docker run --rm test:both
# default code
docker run --rm test:both "print('custom code')"
# custom code -- CMD заменена аргументом
cd .. && rm -rf cmd-entrypoint
docker rmi test:cmd test:ep test:both