ENTRYPOINT и CMD, кто из них главный:
Обе директивы задают, что запустится при старте контейнера, и путаница между ними рождается из-за того, что по отдельности они похожи. Правило простое. CMD задает команду по умолчанию, которую целиком перетирают аргументы docker run. ENTRYPOINT задает несменяемую программу, к которой аргументы docker run дописываются в конец. В связке ENTRYPOINT это сама программа, а CMD ее аргументы по умолчанию.
Код: Выделить всё
FROM python:3.13-slim
WORKDIR /app
COPY app.py .
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]
У обеих директив две формы записи: exec в виде JSON-массива и shell обычной строкой. Для сервисов всегда используйте exec-форму. Shell-форма (ENTRYPOINT python app.py) оборачивает команду в /bin/sh -c, и PID 1 рискует оказаться шеллом, который SIGTERM от docker stop дочернему процессу не пересылает. Тогда каждый стоп висит 10 секунд и заканчивается SIGKILL, приложение не успевает корректно закрыть соединения и дописать данные. Справедливости ради, dash, bash и busybox sh умеют exec-оптимизацию: единственную простую команду без пайпов, && и редиректов они запускают через exec вместо форка, приложение само становится PID 1 и сигнал получает. Так что shell-форма нередко работает, но это поведение конкретного шелла, а не гарантия Docker: добавили пайп или вторую команду, и оптимизация исчезла. Exec-форма дает предсказуемый результат всегда, поэтому она и стандарт. Если процессу нужен полноценный init (зомби-процессы, форки), запускайте контейнер с docker run --init, демон подсунет tini как PID 1.
У exec-формы есть своя ловушка: она не проходит через шелл, значит никакой подстановки переменных. CMD ["python", "app.py", "--port", "$PORT"] передаст приложению буквальную строку $PORT, а не значение переменной. Варианты: читать переменную в самом приложении (os.environ), либо явно звать шелл, CMD ["sh", "-c", "python app.py --port $PORT"], помня про историю с PID 1 выше.
Производственный паттерн, который закрывает обе задачи сразу и используется в официальных образах postgres и nginx: ENTRYPOINT указывает на скрипт, который готовит окружение и в конце передает управление через exec "$@".
Код: Выделить всё
#!/bin/sh
set -e
# тут подстановка переменных, генерация конфига, ожидание базы
exec "$@"
Код: Выделить всё
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["python", "app.py", "--port", "8000"]
HEALTHCHECK, контейнер жив или только притворяется:
Запущенный процесс еще не значит работающий сервис. Приложение может висеть в дедлоке или потерять коннект к базе, а docker ps все равно покажет Up. HEALTHCHECK дает демону способ проверять это самостоятельно.
Код: Выделить всё
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD wget -qO /dev/null http://127.0.0.1:8000/health || exit 1
Два нюанса. Сам Docker нездоровый контейнер не перезапускает, статус используют другие: Compose через depends_on с condition: service_healthy, балансировщики, скрипты деплоя. А Kubernetes директиву HEALTHCHECK игнорирует полностью, там свои liveness и readiness пробы.
.dockerignore, что не должно попасть в сборку:
docker build отправляет демону контекст сборки, то есть содержимое каталога. Классический билдер слал его целиком, BuildKit (включен по умолчанию с Docker 23.0) передает контекст лениво и докачивает только нужное, но сути это не меняет: все, что не исключено, доступно сборке. Без .dockerignore в нее уезжают .git на сотни мегабайт, node_modules, дампы базы и, самое неприятное, .env с секретами, который через COPY . . оказывается в слое образа навсегда.
Код: Выделить всё
.git
node_modules
*.log
.env*
dump_*.sql
Dockerfile*
compose*.yml
Запуск не от root:
По умолчанию процесс в контейнере работает от root. Уязвимость в приложении превращается в root внутри контейнера, а с примонтированными каталогами хоста и в проблемы снаружи. Лечится директивой USER.
Код: Выделить всё
FROM node:22-alpine
RUN addgroup -S app && adduser -S -G app -u 10001 app
WORKDIR /app
COPY --chown=app:app . .
USER 10001
EXPOSE 8080
CMD ["node", "server.js"]
Про привилегированные порты. Сам Docker начиная с 20.10 выставляет в контейнерах sysctl net.ipv4.ip_unprivileged_port_start=0, поэтому непривилегированный процесс там спокойно слушает и 80, и 443. Но это любезность именно Docker: в Kubernetes и других рантаймах порты ниже 1024 без root по-прежнему требуют отдельной настройки (capability NET_BIND_SERVICE или тот же sysctl). Ради переносимости слушайте 8080 и пробрасывайте наружу как угодно. Многие официальные образы уже несут готового пользователя: в node это node, в postgres свой postgres.
Типичные грабли:
USER поставлен до RUN apt-get install, и сборка падает с permission denied. Все установки делайте от root, USER ближе к концу файла. HEALTHCHECK через curl в образе без curl: контейнер вечно unhealthy, хотя приложение в порядке. ENTRYPOINT ["sh", "-c", "..."] молча съедает аргументы docker run. $PORT в exec-форме уезжает в приложение буквальной строкой. Попытка удалить .env следующей строкой RUN rm не помогает, файл остается в предыдущем слое, спасает только .dockerignore.
Что в итоге:
Exec-форма и связка ENTRYPOINT плюс CMD, а для подготовки окружения entrypoint-скрипт с exec "$@", дают предсказуемый запуск и честную обработку сигналов. HEALTHCHECK превращает "процесс запущен" в "сервис отвечает". .dockerignore бережет секреты и время сборки, USER с числовым uid снимает целый класс рисков. В главе 14 займемся реестрами: поднимем приватный registry, разберем push, pull и чем тег отличается от digest.