Python

Разработка в Python в локальных средах может стать нелёгкой задачей, если одновременно работать более чем над одним проектом. Бутстрэппинг (начальная загрузка) проекта может потребовать некоторого времени: нужно согласовать версии, а также настроить зависимости и конфигурацию. Раньше мы устанавливали все требования к проектам напрямую в нашу локальную среду разработки и фокусировались на написании кода. Однако при ведении нескольких проектов в одной среде уже возникают сложности, так как мы можем столкнуться с конфликтами конфигурации или зависимостей. Более того, если мы начинаем работать совместно с коллегами, то наши среды разработки уже приходится координировать. Поэтому нам нужно определить среду проекта так, чтобы её могли с лёгкостью использовать другие. 

Для этого можно создать изолированную среду для каждого проекта при помощи контейнеров и Docker Compose  —  инструмента композиции контейнеров, который будет ими управлять. Эту тему мы раскроем в серии статей, первую часть которой вы сейчас и читаете. Каждая из них будет посвящена отдельному аспекту всего этого процесса.

В текущей части мы рассмотрим, как помещать в контейнер Python-службу или инструмент и познакомимся с наилучшими подходами для осуществления данного процесса.

Требования

Для простоты работы с материалами этой и двух последующих статей нужно установить минимальный набор инструментов, которые позволят управлять помещёнными в контейнеры средами локально:

Контейнеризация сервера Python

В качестве примера мы возьмём простой сервер Flask, который можно запустить автономно, не устанавливая другие компоненты.

server.py

from flask import Flask
server = Flask(__name__)

@server.route("/")
 def hello():
    return "Hello World!"

if __name__ == "__main__":
   server.run()

Перед запуском этой программы сначала нужно убедиться в наличии всех необходимых зависимостей. Один из способов управления ими — применение установщика пакетов, например pip. Для этого нужно создать файл requirements.txt и записать в него зависимости. Ниже приведён пример такого файла для нашего простого server.py:

requirements.txt

Flask==1.1.1

Структура проекта:

app
├─── requirements.txt
└─── src
     └─── server.py

Мы создаём выделенную директорию для исходного кода, чтобы изолировать его от других файлов конфигурации. Позже вы увидите, зачем мы это делаем. Для выполнения программы осталось только установить интерпретатор Python и запустить его.

Можно запустить программу локально, но так мы отклонимся от нашей задачи по контейнеризации разработки, которая подразумевает чистую стандартную среду разработки, позволяющую легко переключаться между проектами с разными конфликтующими требованиями. Я расскажу, как легко поместить этот сервис Python в контейнер.

Dockerfile

Чтобы осуществить работу нашего Python-кода в контейнере мы упакуем его как образ Docker, а затем запустим на основе этого образа контейнер:

Для генерации образа Docker нужно создать Dockerfile, содержащий необходимые для сборки образа инструкции. Затем Dockerfile обрабатывается сборщиком Docker, который и сгенерирует нужный образ. После этого с помощью простой команды docker run мы создаём и запускаем контейнер с сервером Python. 

Анализ Dockerfile

Ниже приведён пример Dockerfile, содержащего инструкции по сборке образа для нашего сервиса hello world:

Dockerfile

# установка базового образа (host OS)
FROM python:3.8

# установка рабочей директории в контейнере
WORKDIR /code

# копирование файла зависимостей в рабочую директорию
COPY requirements.txt .

# установка зависимостей
RUN pip install -r requirements.txt

# копирование содержимого локальной директории src в рабочую директорию
COPY src/ .

# команда, выполняемая при запуске контейнера
CMD [ "python", "./server.py" ]

Для каждой инструкции или команды из Dockerfile сборщик Docker генерирует слой образа и накладывает его на предыдущие. Таким образом, получающийся в результате образ Docker представляет собой простой только читаемый стэк, состоящий из разных слоёв. В выводе команды сборки мы видим, как по очереди выполняются инструкции Dockerfile:

$ docker build -t myimage .
Sending build context to Docker daemon 6.144kB
Step 1/6 : FROM python:3.8
3.8.3-alpine: Pulling from library/python
…
Status: Downloaded newer image for python:3.8.3-alpine
---> 8ecf5a48c789
Step 2/6 : WORKDIR /code
---> Running in 9313cd5d834d
Removing intermediate container 9313cd5d834d
---> c852f099c2f9
Step 3/6 : COPY requirements.txt .
---> 2c375052ccd6
Step 4/6 : RUN pip install -r requirements.txt
---> Running in 3ee13f767d05
…
Removing intermediate container 3ee13f767d05
---> 8dd7f46dddf0
Step 5/6 : COPY ./src .
---> 6ab2d97e4aa1
Step 6/6 : CMD python server.py
---> Running in fbbbb21349be
Removing intermediate container fbbbb21349be
---> 27084556702b
Successfully built 70a92e92f3b5
Successfully tagged myimage:latest

Затем можно проверить образ в локальном хранилище образов:

$ docker images
REPOSITORY    TAG       IMAGE ID        CREATED          SIZE
myimage       latest    70a92e92f3b5    8 seconds ago    991MB

В процессе разработки нам может потребоваться повторно собрать образ для Python-сервиса, на что желательно потратить как можно меньше времени. Далее мы проанализируем некоторые лучшие практики, которые могут нам в этом помочь.

Лучшие практики разработки Dockerfile

Базовый образ

Первая инструкция из Dockerfile определяет базовый образ, поверх которого мы добавляем новые слои для приложения. Выбор базового слоя весьма важен, поскольку поставляемые им возможности могут влиять на качество надстроенных слоёв.

По возможности старайтесь работать с официальными образами, которые, как правило, часто обновляются и имеют меньше проблем с безопасностью. 

Выбор базового образа также влияет на размер итогового. Если для вас размер имеет первостепенное значение, то можно выбрать какой-нибудь очень маленький нетребовательный к ресурсам образ. Такие образы обычно основываются на дистрибутиве Alpine и имеют соответствующий тег. Тем не менее для приложений Python в большинстве случаев отлично подходит slim-вариант официального Python-образа Docker (например, python:3.8-slim)

Порядок инструкций влияет на использование кэша сборки

При частой сборке образа мы определённо будем использовать механизм кэширования для ускорения. Как я упоминала ранее, инструкции Dockerfile выполняются в заданном порядке. Для каждой инструкции сборщик сначала проверяет свой кэш на наличие образа для повторного использования. При обнаружении изменения в слое этот и все последующие слои пересобираются. Чтобы кэширование было эффективным, нужно поместить инструкции часто изменяемых слоёв после тех, которые меняются редко.

Посмотрим на пример Dockerfile, чтобы понять, как порядок инструкций влияет на кэширование. Ниже я привела интересующие нас строки:

...
# копирование файла зависимостей в рабочую директорию
COPY requirements.txt .

# установка зависимостей
RUN pip install -r requirements.txt

# копирование содержимого локальной директории src в рабочую директорию
COPY src/ .
...

В процессе разработки зависимости нашего приложения изменяются не так часто, как Python-код. В связи с этим мы устанавливаем их в слое, предшествующем слою кода. То есть мы копируем файл зависимостей, устанавливаем их, а затем копируем исходный код. Это главная причина изолирования исходного кода в отдельную директорию, о котором было сказано в начале статьи. 

Многоэтапные сборки

Хотя это может и не быть существенным в разработке, мы кратко расскажем о подобных сборках, поскольку они интересны в плане итоговой отправки контейнеризованного приложения уже по её завершении.

Многоэтапные сборки используются для очистки итогового образа от ненужных файлов и пакетов ПО, чтобы отправлять только необходимые для выполнения кода файлы. Вот небольшой пример многоэтапного Dockerfile:

# первый этап
FROM python:3.8 AS builder
COPY requirements.txt .

# установка зависимостей в локальную директорию user (например, /root/.local)
RUN pip install --user -r requirements.txt

# второй этап (без названия)
FROM python:3.8-slim
WORKDIR /code

# копирование только установки зависимостей из образа первого этапа 
COPY --from=builder /root/.local/bin /root/.local
COPY ./src .

# обновление переменной среды PATH
ENV PATH=/root/.local:$PATH

CMD [ "python", "./server.py" ]

Обратите внимание, что здесь мы используем двухэтапную сборку, где только первый этап называем builder  —  сборщик. Название этапу мы задаём, добавляя AS <НАЗВАНИЕ> к инструкции FROM и используем это название в инструкции COPY, где хотим скопировать в итоговый образ только необходимые файлы. Результат  —  облегчённый образ:

$ docker images
REPOSITORY    TAG      IMAGE ID       CREATED         SIZE
myimage       latest   70a92e92f3b5   2 hours ago     991MB
multistage    latest   e598271edefa   6 minutes ago   197MB
…

В этом примере мы установили зависимости в локальную директорию user и скопировали эту директорию в итоговый образ с помощью опции pip -user. Однако для выполнения этих действий есть и другие решения вроде virtualenv или сборки в виде пакетов wheel с последующим их копированием и установкой в итоговый образ. 

Запуск контейнера

После написания Dockerfile и сборки образа, мы запускаем контейнер с нашим сервисом:

$ docker images
REPOSITORY   TAG      IMAGE ID       CREATED       SIZE
myimage      latest   70a92e92f3b5   2 hours ago   991MB
...

$ docker ps
CONTAINER ID   IMAGE   COMMAND   CREATED   STATUS   PORTS   NAMES

$ docker run -d -p 5000:5000 myimage
befb1477c1c7fc31e8e8bb8459fe05bcbdee2df417ae1d7c1d37f371b6fbf77f

Мы и поместили в контейнер сервер hello world и теперь можем запросить порт, сопоставленный с localhost:

$ docker ps
CONTAINER     ID        IMAGE        COMMAND        PORTS                   ...
befb1477c1c7  myimage   "/bin/sh -c  'python ..."   0.0.0.0:5000->5000/tcp  ...

$ curl http://localhost:5000
"Hello World!"

Что дальше?

Мы показали, как помещать в контейнер сервер на Python для облегчения разработки. Контейнеризация позволяет не только добиваться одинаковых результатов на разных платформах, но также избегать конфликтов зависимостей и поддерживать в чистоте стандартную среду разработки. Контейнеризованная среда легко управляется и удобна при совместной работе с другими разработчиками: они смогут без проблем развёртывать её в своих стандартных средах, не внося изменений.

В следующей статье вы узнаете, как настроить основанный на контейнерах многосервисный проект, где Python-компонент соединён с внешними компонентами, а также научитесь управлять жизненным циклом всех компонентов проекта при помощи Docker Compose.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи ANCA IORDACHE: Containerized Python Development — Part 1

Предыдущая статьяСобеседование в Facebook. Ценный опыт и открытия
Следующая статьяЗапуск DBT в Azure Functions с помощью Snowflake