[Python Intermediate] Урок 2. Docker и docker-compose
Алоха!
Всё, что написано ниже, является прямым продолжением предыдущего материала, и если ты его пропустил, то многое покажется тебе непонятным или неочевидным. Поэтому я рекомендую не торопиться и соблюдать последовательность.
И ещё важный момент — если ты совсем не знаком с Docker, то прежде чем двигаться дальше, обязательно почитай про основы данной технологии, благо статей в интернете немало, например, вот. Или хотя бы ознакомься с моей памяткой (VK, Github). Просто чтобы понимать, о чём вообще речь.
Docker — это платформа контейнеризации с открытым исходным кодом, с помощью которой можно автоматизировать создание приложений, их доставку и управление. Платформа позволяет быстрее тестировать и выкладывать приложения, запускать на одной машине требуемое количество контейнеров.
Благодаря контейнеризации и использованию Docker, разработчики больше не задумываются о том, в какой среде будет функционировать их приложение и будут ли в этой в среде необходимые для тестирования опции и зависимости. Достаточно упаковать приложение со всеми зависимостями и процессами в контейнер, чтобы запускать в любых системах: Linux, Windows и macOS. Платформа Docker позволила отделить приложения от инфраструктуры. Контейнеры не зависят от базовой инфраструктуры, их можно легко перемещать между облачной и локальной инфраструктурами.
Не переживай, супер глубокое понимание технологии в рамках данного материала не потребуется. Но если всё-таки очень хочется погрузиться, то на Хабре есть перевод серии статей от Джеффа Хейла.
К сожалению, в python-мире до сих пор повсеместно применяется неизолированный запуск приложения и его инфраструктуры на личных устройствах. Боюсь, даже опытные специалисты неохотно используют контейнеризацию, хотя в действительности её плюсы неоспоримы.
Во-первых, она позволяет при локальном запуске повторить среду продакшена, что может уберечь от многих неочевидных ошибок. А во-вторых, при переезде с компа на комп или при появлении нового разработчика не придётся в сотый раз корячиться с настройкой приложения и инфраструктуры. Конфигурация производится лишь однажды и в дальнейшем просто поддерживается в актуальном состоянии.
Разумеется, «контейнерная» разработка, как и всё в нашем мире, имеет свои недостатки. Главный из них сопряжён с локальной отладкой приложения — при запуске через системный интерпретатор сделать это куда проще. Однако технологии тоже не стоят на месте, и такие популярные IDE, как PyCharm Professional и VS Code, уже способны справиться с данной задачей.
Отдельной строкой следует упомянуть ещё один нюанс: при работе с докером на чипах M1 можно напороться на трудности. Не всегда очевидные, но решаемые, и ситуация постоянно улучшается.
Тизер
Чтобы понимать, к чему мы вообще стремимся, предлагаю тебе сначала установить Docker и Docker Compose.
Затем стяни этот репозиторий и перейди в директорию текущего урока:
cd lesson_2
В ней создай папку secrets
и положи туда два файла: event_broker_password
и service_db_password
. В первый файл впиши python_garden
, а во второй — postgres
.
Проверь, всё ли правильно получилось? (Команда cat
выводит содержимое файла).
cat secrets/event_broker_password
> python_garden
cat secrets/service_db_password
> postgres
Введи в терминале из корня проекта следующую команду:
docker compose up -d
После того, как её выполнение закончится, введи:
docker compose ps
В результате у тебя должно получиться следующее:
Если ты счастливый обладатель компа с чипом M1, то смотри README.
Теперь, чтобы убедиться в правдивости напечатанных статусов, выведи логи любого сервиса при помощи команды docker compose logs <service>
(имена в столбце SERVICE
).
Также можешь дёрнуть апишку из предыдущего урока обычным курлом: curl http://0.0.0.0:8000
.
При желании открой адрес http://0.0.0.0:8000 в браузере и убедись в наличии идентичного ответа.
Ну и напоследок небольшой бонус. Зайди на http://0.0.0.0:15672, введи в оба поля python_garden
и нажми «Login».
Добро пожаловать в веб-интерфейс локального RabbitMQ! Вот так просто.
Прежде чем продолжить, давай откатимся в самое начало и удалим всё, что мы создали на данном этапе. Введи поочерёдно следующие команды:
docker compose down
docker system prune --all
Dockerfile
Знакомство с контейнеризацией мы начнём с описания докер-файла (./Dockerfile
), где будут перечислены инструкции для сборки нужного нам образа.
FROM python:3.9-slim as base
LABEL maintainer="Make <russian.it@great.again>"
# Сборка зависимостей
ARG BUILD_DEPS="curl"
RUN apt-get update && apt-get install -y $BUILD_DEPS
# Установка poetry
RUN curl -sSL https://install.python-poetry.org | POETRY_VERSION=1.2.0 POETRY_HOME=/root/poetry python3 -
ENV PATH="${PATH}:/root/poetry/bin"
# Инициализация проекта
WORKDIR /opt/lesson_2
ENTRYPOINT ["./docker-entrypoint.sh"]
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Установка питонячьих библиотек
COPY poetry.lock pyproject.toml /
RUN poetry config virtualenvs.create false && \
poetry install --no-interaction --no-ansi
# Копирование в контейнер папок и файлов.
COPY . .
? В самой первой строке мы ссылаемся на базовый образ из публичного репозитория. Можешь выбрать любой другой из списка, если тебя, скажем, не устраивает версия. Только прошу обратить внимание: лучше брать slim-образы, поскольку при работе с alpine ты неизбежно столкнёшься с трудностями во время установки некоторых библиотек. Также замечу: во всех уважающих себя компаниях образы хранятся в своих, закрытых репозиториях, так что навряд ли тебе придётся где-то, помимо собственных проектов, обращаться к Docker Hub напрямую.
? Во второй строке указан ответственный за создание и поддержку образа. Обычно здесь оставляют name и email команды разработчиков.
? В блоке «Сборка зависимостей» мы сначала объявляем переменную BUILD_DEPS
, в которой перечисляем необходимые утилиты и функции ОС, затем запускаем обновление списков пакетов и устанавливаем перечисленные сущности.
Инструкцию ARG
вместе с переменной можешь опустить, объединив со следующей. Это лишь вопрос эстетики и удобства.
RUN apt-get update && apt-get install -y curl
Главное, никогда без веской причины не запускай в докер-файле команду apt-get upgrade
, которая часто встречается в различных сниппетах рядом с apt-get update
.
Запомни разницу (подробности здесь):
apt-get update
— обновляет списки пакетов, т.е. получает информацию о новейших версиях и зависимостях;apt-get upgrade
— на основе существующих списков (/etc/apt/sources.list
) запускает обновление всех установленных в настоящее время пакетов.
Допустим, мы ожидаем некий пакет версии «1.0», установленный в базовом образе, и ориентируемся на вполне конкретное поведение. А после апгрейда получаем, к примеру, версию «3.5» с совершенно иным поведением. Думаю, ты представляешь степень боли, которую нам предстоит испытать в подобном случае.
? Далее принимаемся за установку poetry. Это модный и полезный менеджер зависимостей для Python. Если ты с ним ещё не знаком, то пока не заморачивайся — обсудим его как-нибудь отдельно. Просто пропускай обе инструкции.
? В «Инициализации проекта» проводим подготовительные мероприятия, а именно:
задаём рабочую директорию для всех последующих директив;
указываем sh-скрипт с вариантами запуска нашего приложения (о них ниже);
устанавливаем две переменные среды, которые очень важны.
PYTHONUNBUFFERED
отвечает за отключение буферизации вывода (output). То есть непустое значение данной переменной среды гарантирует, что мы можем видеть выходные данные нашего приложения в режиме реального времени.
PYTHONDONTWRITEBYTECODE
означает, что Python не будет пытаться создавать файлы .pyc
.
Однажды мне довелось столкнуться с результатами отсутствия этой переменной. Из-за невыясненного сбоя при сборке контейнера исходный код не скомпилировался заново в байт-код. В итоге получилось, что код внутри обновлённого контейнера и код локальный отличались, следовательно, отличалось и поведение. Было очень трудно выявить причину проблемы.
Добавляй эти две переменные в Dockerfile всегда! Даже если пишешь на Go. Просто на всякий случай. ?
? В предпоследнем блоке устанавливаем зависимости. Для poetry, когда речь идёт о контейнере, отключаем создание виртуального окружения (virtualenvs.create false
), интерактив (--no-interaction
) и ANSI-output (--no-ansi
).
Если же от poetry ты решил держаться подальше, то запускай:
RUN pip3 install -r requirements
? Последняя директива в дополнительных комментариях не нуждается: весь проект просто копируется внутрь контейнера, в WORKDIR
.
Итак, мы пробежались по всем инструкциям, указанным в нашем Dockerfile. Теперь давай сбилдим образ! Для этого запусти команду:
docker build -t python_garden .
Здесь -t
означает имя нашего образа, а .
указывает текущую директорию в качестве целевой.
После успешной сборки ты можешь посмотреть список всех имеющихся образов при помощи команды docker images
и найти там наш, свеженький.
Точка входа
Далее приступаем к описанию sh-скрипта так называемой точки входа, она же entrypoint. Точка входа — это парадные ворота в наш сервис, которые мы обозначили в докер-файле командой ENTRYPOINT ["./docker-entrypoint.sh"]
. А sh-скрипт, продолжая аналогию, что-то вроде табличек-указателей или ресепшена в отеле.
Напомню: у нашего нового бэкенд-приложения из первого урока есть два режима работы — API и консьюмер.
Явно опишем оба варианта в sh-скрипте, также размещённом в корне проекта (./docker-entrypoint.sh
), и не забудем оставить возможность для запуска иных команд.
#!/usr/bin/env sh
set -e
case "$1" in
api)
exec bash -c "uvicorn app.api.webapp:app --host 0.0.0.0 --port 8000 --reload --reload-dir app"
;;
consumer)
exec python run_consumer.py
;;
*)
exec "$@"
esac
На данном этапе у тебя может возникнуть вопрос: «А зачем такие сложности? Почему бы не указать команду запуска сервиса прямо в dockerfile через директиву CMD
с возможностью переопределения?». Ты прав — если речь идёт о простом приложении с одним вариантом запуска, то смысла городить огород с точкой входа, разумеется, нет. Однако в природе такие приложения попадаются очень редко. Посуди сам, даже в Django, помимо основной команды запуска python manage.py runserver
, у нас имеются следующие: создание новой миграции, применение миграций, запуск какого-нибудь вспомогательного скрипта, запуск тестов и так далее.
Вспоминай первый урок и оставленную в тезисах для запоминания ссылку на принцип единственной ответственности. При использовании точки входа процесс создания образа отделяется от слоя запуска сервиса и сервисных функций. При изменении списка входных команд мы не будем трогать dockerfile, и как следствие, нам не придётся во время локальной работы пересобирать сам образ.
Что касается продемонстрированного выше скрипта, то он, думаю, трудностей в понимании у тебя не вызовет: имеются две строго декларированные команды и отдельная ветка для всех остальных. Тут следует остановиться подробнее разве что на exec
. По рекомендации разработчиков Docker, запуск процессов внутри контейнера лучше осуществлять через неё, ибо тогда вызываемая команда получит PID 1
и будет корректно работать с сигналами. Если же exec
не указать, то PID 1
достанется процессу bash
.
Docker Compose
Мы почти у цели! Осталось описать инфраструктуру приложения. Для этого воспользуемся инструментом под названием Docker Compose. Если говорить упрощённо, то данная технология позволяет с помощью простых команд контролировать несколько сервисов.
Там же, в корне проекта, разместим файл docker-compose.yml
, где будут храниться конфиги всех сервисов инфраструктуры.
Принимаемся за настройку базы данных.
version: "3.4"
services:
service_db:
container_name: python-garden_db
image: postgres:10
ports:
- "5432:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
Здесь указаны:
имя контейнера;
образ, на основе которого будет собран контейнер;
соответствие портов на хост-машине портам в контейнере, то есть при подключении к порту № 1 мы будем попадать на порт № 2 в контейнере;
и переменные окружения, по сути являющиеся кредами для доступа к БД.
Для нашего примера — да и вообще для большинства локальных развёртываний PostgreSQL — этой конфигурации будет достаточно. Однако если хочешь, можешь покопаться в официальной доке и познакомиться с полным перечнем доступных настроек.
Теперь давай обратим внимание на RabbitMQ.
version: "3.4"
services:
mq:
container_name: python-garden_mq
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: python_garden
RABBITMQ_DEFAULT_PASS: python_garden
Как видишь, различий немного.
Попробуем поднять нашу инфраструктуру. Для этого введи в терминале из корня проекта следующую команду:
docker compose up -d # запуск всех описанных в compose-файлах сервисов
И посмотри результат:
docker compose ps
Если статусы у обоих сервисов — running
, ты всё сделал правильно. Если нет, значит, я что-то упустил в своих объяснениях. Обязательно напиши об этом в комментариях!
Ну что? Вот мы и добрались, наконец-то, до нашего псевдобэкенда. Давай опишем его.
version: "3.4"
services:
api:
container_name: python-garden_api
image: python_garden
build:
context: .
ports:
- "8000:8000"
command: "api"
environment:
PYTHONUNBUFFERED: 1
SERVICE_DB_HOST: service_db
SERVICE_DB_NAME: postgres
SERVICE_DB_USERNAME: postgres
volumes:
- .:/opt/lesson_2
consumer:
container_name: python-garden_consumer
image: python_garden
build:
context: .
command: "consumer"
environment:
PYTHONUNBUFFERED: 1
SERVICE_DB_HOST: service_db
SERVICE_DB_NAME: postgres
SERVICE_DB_USERNAME: postgres
EVENT_BROKER_HOST: mq
EVENT_BROKER_PORT: 5672
EVENT_BROKER_USERNAME: python_garden
volumes:
- .:/opt/lesson_2
Новых ключей здесь немного. Начнём с build
. Это раздел с настройками для сборки образа. То есть, если у тебя на момент запуска контейнера отсутствует образ python_garden
, Docker Compose при помощи параметров в разделе build
соберёт его сам. Параметр context: .
в данном случае сообщает, что Dockerfile располагается в текущей директории.
Ключ command
переопределяет команду по умолчанию, указанную в директиве CMD
. В нашем случае такая инструкция отсутствует, поэтому command
её заменяет. Как ты помнишь, api
и consumer
у нас явно описаны в docker-entrypoint.sh
.
По поводу volumes
смотри уже упомянутую памятку по Docker. Внешний том нам здесь нужен для того, чтобы после каждого изменения кода не приходилось пересобирать образ. Помнишь, при сборке задействовалась инструкция COPY
? После её выполнения в образе сохраняется кодовая база определённой версии, а с помощью вольюмов мы обходим это ограничение. То есть при указанном volumes
образ смотрит в папку с проектом и по команде docker compose restart <service>
поднимает контейнер с обновлённой кодовой базой.
Посмотреть файл с настройками Docker Compose целиком можно по ссылке.
Заключение
Дабы убедиться, что всё работает как надо, вернись в раздел «Тизер» и проделай описанные там действия.
Согласись, это очень удобно, когда ты скачиваешь проект с гитхаба и одной командой можешь запустить его вкупе с зависимыми сервисами. Только не забывай поддерживать актуальность docker-compose.yml
и docker-entrypoint.sh
.
Бывают случаи, когда могут понадобиться какие-то локальные доработки — например, имя контейнера не по душе или ты используешь устройство с процессором M1, тогда как коллеги сидят на православном Intel. Разработчики предусмотрели такую ситуацию, так что не вздумай ломать работающий файл docker-compose.yml
! Вместо этого создай в корне проекта docker-compose.override.yml
и изменяй существующие настройки, как тебе вздумается. Общий, совмещённый результат конфигурации ты можешь посмотреть с помощью команды:
docker compose config
В этой статье намеренно отсутствует конкретика и не используется в качестве примера какой-нибудь полурабочий прототип, чтобы на выходе у тебя получился шаблон микросервиса. Забирай результат, правь под себя и пользуйся.
Тезисы для запоминания
Старайся использовать slim-образы Python, поскольку при работе с alpine ты неизбежно столкнёшься с трудностями во время установки некоторых библиотек.
Не запускай в докер-файле команду
apt-get upgrade
.Всегда добавляй в Dockerfile переменные окружения
ENV PYTHONDONTWRITEBYTECODE 1
иENV PYTHONUNBUFFERED 1
.По рекомендации разработчиков Docker, запуск процессов внутри контейнера лучше осуществлять через
exec
, ибо тогда вызываемая команда получитPID 1
и будет корректно работать с сигналами.Помни про
volumes
. Без указания внешнего тома тебе придётся постоянно пересобирать образ.
Полезные команды
Поднять в фоне все сервисы, описанные в конфигурации:
docker compose up -d
Вывести список контейнеров:
docker compose ps
Вывести логи указанного сервиса (контейнера):
docker compose logs <service>
Рестартануть сервис:
docker compose restart <service>
Посмотреть итоговую конфигурацию контейнеров:
docker compose config
Остановить все контейнеры из текущей конфигурации:
docker compose stop
Удалить все контейнеры из текущей конфигурации:
docker compose rm
Остановить и удалить все контейнеры из текущей конфигурации (последовательное выполнение двух предыдущих команд):
docker compose down
Удалить все не запущенные контейнеры и все не использующиеся образы:
docker system prune --all
Вывести список образов:
docker images