Алоха!
Всё, что написано ниже, является прямым продолжением предыдущего материала, и если ты его пропустил, то многое покажется тебе непонятным или неочевидным. Поэтому я рекомендую не торопиться и соблюдать последовательность.
И ещё важный момент — если ты совсем не знаком с Docker, то прежде чем двигаться дальше, обязательно почитай про основы данной технологии, благо статей в интернете немало, например, вот. Или хотя бы ознакомься с моей памяткой (VK, Github). Просто чтобы понимать, о чём вообще речь.
Docker — это платформа контейнеризации с открытым исходным кодом, с помощью которой можно автоматизировать создание приложений, их доставку и управление. Платформа позволяет быстрее тестировать и выкладывать приложения, запускать на одной машине требуемое количество контейнеров.
Благодаря контейнеризации и использованию Docker, разработчики больше не задумываются о том, в какой среде будет функционировать их приложение и будут ли в этой в среде необходимые для тестирования опции и зависимости. Достаточно упаковать приложение со всеми зависимостями и процессами в контейнер, чтобы запускать в любых системах: Linux, Windows и macOS. Платформа Docker позволила отделить приложения от инфраструктуры. Контейнеры не зависят от базовой инфраструктуры, их можно легко перемещать между облачной и локальной инфраструктурами.
Не переживай, супер глубокое понимание технологии в рамках данного материала не потребуется. Но если всё-таки очень хочется погрузиться, то на Хабре есть перевод серии статей от Джеффа Хейла.
К сожалению, в python-мире до сих пор повсеместно применяется неизолированный запуск приложения и его инфраструктуры на личных устройствах. Боюсь, даже опытные специалисты неохотно используют контейнеризацию, хотя в действительности её плюсы неоспоримы.
Во-первых, она позволяет при локальном запуске повторить среду продакшена, что может уберечь от многих неочевидных ошибок. А во-вторых, при переезде с компа на комп или при появлении нового разработчика не придётся в сотый раз корячиться с настройкой приложения и инфраструктуры. Конфигурация производится лишь однажды и в дальнейшем просто поддерживается в актуальном состоянии.
Разумеется, «контейнерная» разработка, как и всё в нашем мире, имеет свои недостатки. Главный из них сопряжён с локальной отладкой приложения — при запуске через системный интерпретатор сделать это куда проще. Однако технологии тоже не стоят на месте, и такие популярные IDE, как PyCharm Professional и VS Code, уже способны справиться с данной задачей.
Отдельной строкой следует упомянуть ещё один нюанс: при работе с докером на чипах M1 можно напороться на трудности. Не всегда очевидные, но решаемые, и ситуация постоянно улучшается.
Тизер
Чтобы понимать, к чему мы вообще стремимся, предлагаю тебе сначала установить Docker и Docker Compose.
Затем стяни этот репозиторий. В корне проекта создай директорию 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
Комментарии (8)
Pavel1114
24.10.2022 15:55Думаю удобнее не прописывать значения переменных окружения прям в docker-compose. Если их оставить там без значений, то они будут проброщены с хоста или с файла .env
ый урок и оставленную в тезисах для запоминания ссылку на принцип единственной ответственности. При использовании точки входа процесс создания образа отделяется от слоя запуска сервиса и сервисных функций. При изменении списка входных команд мы не будем трогать dockerfile, и как следствие, нам не придётся во время локальной работы пересобирать сам образ.
всё равно не понял для чего конкретно этот код в entrypoint.sh. Разве явное не лучше неявного? По мне так entrypoint создан немного для другого — инициализация там всякая или предварительная проверка и ожидание доступности необходимых сервисов.balandin-nick Автор
24.10.2022 20:50Думаю удобнее не прописывать значения переменных окружения прям в docker-compose. Если их оставить там без значений, то они будут проброщены с хоста или с файла .env
Это как вам будет угодно. Думаю, здесь никаких железобетонных правил нет. По большому счёту, смысл всего остального не меняется — используете вы мой вариант или ваш. Лично я не люблю
.env
, использую его редко. Не люблю из-за появляющихся через полгода-годtest.env
,test2.env
,stage.env
и т. д. Мне удобнее сразу в docker-compose энвы наблюдать и переопределять их вoverride
.----
Что касается второй части комментария, то мой ответ сильно зависит от того, что именно вы предлагаете взамен. Предположу, что
CMD
с некой дефолтной командой без использованияdocker-entrypoint.sh
и аналогичнымdocker-compose.yml
, где вместо моихcommand
будет что-то вроде:command: "python <api/consumer>.py"
.создан немного для другого — инициализация там всякая или предварительная проверка и ожидание доступности необходимых сервисов
Да. Видел, что для этого его в основном и используют, тут с вами не поспоришь. И я, признаться, не знаю, есть ли где-то скрижали, запрещающие иное предназначение.
Свой вариант я предложил отчасти из эксперимента, отчасти из эстетических соображений (увидел в одной компании, проникся, стал использовать) и отчасти из соображений некой абстракции, о которой в уроке, к сожалению, не упомянул. Пожертвовал ради упрощения, как и многим другим.
В общем, можно определить только сервис:
service: container_name: python-garden_service image: python_garden build: context: . 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
Эта конфигурация послужит точкой входа вообще для любых команд. То есть вы можете через
docker-compose run
запускать оба инстанса и пробрасывать любую другую допустимую команду:docker-compose run --rm service api docker-compose run --rm service consumer docker-compose run --rm service tests docker-compose run --rm service bash ... # et cetera
В этом случае
docker-entrypoint.sh
становится сугубо декларативным элементом.В моём подходе имеется дублирование смыслов, согласен. То есть я объявляю типы запуска одновременно и в
docker-entrypoint.sh
, и вdocker-compose.yml
, что является избыточным.Pavel1114
25.10.2022 07:48согласен с тем что entrypoint можно использовать по разному. Docker очень гибкий инструмент.
WondeRu
Про alpine - спорно. Нужно делать контейнеры как можно меньше, это уменьшает место для хранения (капитан очевидность), а также сокращает время запуска (привет nvidia с их 5 гиговыми образами).
Я бы рекомендовал делать multistage-билды, чтобы исключить лишние библиотеки и опять же сократить размер образа
Обязательно очищать образ после использования apt-get update
Использовать пользователя для запуска процессов контейнера
Про уменьшение размера- это уменьшение зависимостей, а соотвественно, уменьшение количества уязвимых компонентов на борту образа. Добавлю, что перед сборкой образа мы проверяем зависимости кода через owasp dependency checker, а в самом репозитории уже с помощью snyk. Если что-то выше уровня medium, то чешем репу и обновляем компоненты.
Про не хранить образы на docker hub: с платной подпиской многие компании используют их приватные репы. Там snyk из коробки и возможность автосборки простых образов из gitlab и bitbucket, а также не нужно сопровождать свою инфраструктуру.
balandin-nick Автор
Спасибо за разумные дополнения. Плюсанул.
stepalxser
Alpine для питона не стоит использовать - https://habr.com/ru/post/486202/
Так же есть сценарий, что под альпин будут пропущены бинарные зависимости, для того же pydantic
balandin-nick Автор
Именно поэтому я и написал в статье то, что написал. Нынче на PyCon был доклад по поводу alpine. И там на примере pydantic было показано, что не стоит.
Спасибо за замечание и участие.
WondeRu
Я бы не был так категоричен: для ml - мы тоже используем стандартные образы от nvidia, а вот, всякие django и flask обязательно на alpine. Поэтому правы тут все)