Python-сервис в контейнере: конфигурация, stdout и корректная остановка
Автор: Казачкин Даниил Михайлович · Обновлено
Чтобы Python-программа стала удобным контейнерным сервисом, ей нужен понятный контракт: адрес и порт, конфигурация из окружения, логи в стандартные потоки и корректная реакция на…
Чтобы Python-программа стала удобным контейнерным сервисом, ей нужен понятный контракт: адрес и порт, конфигурация из окружения, логи в стандартные потоки и корректная реакция на остановку. Dockerfile из [урока Python и Docker](/lessons/without-university/docker-containerization/docker-developer-08) упаковывает код; здесь улучшим поведение самого процесса.
Самостоятельное приложение
Создайте app.py. Пример использует только стандартную библиотеку Python:
import json
import logging
import os
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port = int(os.getenv("PORT", "8000"))
if not 1 <= port <= 65535:
raise ValueError("PORT must be between 1 and 65535")
greeting = os.getenv("GREETING", "hello-container")
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("service")
stopping = False
def request_stop(signum, frame):
global stopping
stopping = True
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/", "/health"):
self.send_error(404)
return
payload = {"status": "ok"} if self.path == "/health" else {"message": greeting}
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, template, *args):
log.info(template, *args)
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1})
worker.start()
log.info("started port=%s", port)
try:
while not stopping:
time.sleep(0.1)
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
log.info("stopped cleanly")Почему shutdown не находится в signal handler
Python исполняет обработчики сигналов в главном потоке. Внутри handler мы меняем только простой флаг: без логирования, блокировок и Event.set. Эти операции могут захватить lock, который был прерван в том же потоке. Ограничения signal.
server.shutdown() должен выполняться из другого потока, чем serve_forever(), иначе возникает deadlock. Здесь серверный цикл работает в worker, а shutdown вызывает главный поток после проверки флага. Это конкретное условие API, а не рекомендация «добавить threading на всякий случай». Правила socketserver.
ThreadingHTTPServer в этом учебном примере не гарантирует завершение всех длинных запросов до выхода. Для production нужны ограниченные таймауты, контроль активных запросов и выбранный сервер приложения. Но приведённый код позволяет корректно остановить принимающий цикл и завершиться с кодом 0.
Упаковка и наблюдение
Создайте Dockerfile:
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY app.py ./app.py
USER 10001:10001
EXPOSE 8000
CMD ["python", "app.py"]docker build -t dk-python-service:1 .
docker run -d --name dk-python-service -p 127.0.0.1:18092:8000 -e GREETING=service-ready dk-python-service:1
curl http://127.0.0.1:18092/
curl http://127.0.0.1:18092/health
docker logs --tail 20 dk-python-service
docker stop --timeout 5 dk-python-service
docker inspect --format '{{.State.ExitCode}}' dk-python-service
docker logs --tail 5 dk-python-serviceОжидайте service-ready, статус ok, запись started, затем stopped cleanly и код 0. При раннем запросе повторите curl после запуска процесса. Exec-форма CMD запускает Python напрямую; сигнал Docker не застревает в ненужной shell-обёртке. Stop сначала посылает сигнал завершения и только после таймаута может принудительно остановить процесс. Семантика Docker stop.
Настройка и ошибки
Если изменить PORT на 9000, измените и внутреннюю сторону публикации: -p 127.0.0.1:18092:9000. EXPOSE не перенастраивает Python. Неверное значение PORT должно дать понятную ошибку до начала обслуживания, а не тихо включить случайный порт. Не записывайте в логи всё окружение: в нём могут быть пароли и токены.
Добавление /health даёт endpoint, но не создаёт Docker healthcheck автоматически. Настройка проверки с таймаутом и интервалом рассматривается в [уроке Compose healthcheck](/lessons/without-university/docker-containerization/docker-developer-24). Связь PID 1 и restart policy разобрана в [уроке об остановке контейнера](/lessons/without-university/docker-containerization/docker-developer-26).
Упражнение
Запустите сервис с другим GREETING и убедитесь, что образ не пересобирался. Затем проверьте ошибочный PORT, сохранив контейнер для чтения logs. Наконец повторите корректный запуск и stop с таймаутом 5 секунд: критерий успеха — запись завершения и exit code 0, а не просто исчезновение из docker ps. Удалите только созданные контейнеры после чтения их состояния.