Сегодня делимся с вами переводом статьи DevOps инженера из IBM, об автоматизации сборки быстро собираемых и удобно отлаживаемых образов Docker для проектов на Python с помощью Makefile. Этот проект не только упрощает отладку в Docker, но и заботится о качестве кода вашего проекта. Подробности, как всегда, под катом.
Каждый проект — независимо от того, работаете ли вы над веб-приложением, с Data Science или искусственным интеллектом может извлечь выгоду из хорошо настроенных CI/CD, образов Docker, которые одновременно отлаживаются в процессе разработки и оптимизируются для производственной среды, или инструментов обеспечения качества кода, таких как CodeClimate или SonarCloud. Все эти вещи рассматриваются в статье и показывается, как они добавляются в проект на Python.
Отлаживаемые контейнеры для разработки
Некоторым людям не нравится Docker, потому что контейнеры может быть трудно отлаживать, или потому, что образы требуют много времени на сборку. Итак, давайте начнем с того, что построим образы, идеально подходящие для разработки — быстрые при сборке и легкие в отладке. Чтобы сделать образ легко отлаживаемым, понадобится базовый образ, включающий в себя все инструменты, которые могут когда-нибудь понадобиться при отладке. Это bash, vim, netcat, wget, cat, find, grep и другие.
Образ python:3.8.1-buster кажется идеальным кандидатом для этой задачи. Он включает множество инструментов из коробки, легко установить недостающие инструменты. Образ большой, но здесь это не имеет значения: он будет применяться только в разработке. Как вы, вероятно, заметили, образы очень специфичны. Блокировка версий Python и Debian делается намеренно: хочется минимизировать риск поломки, вызванной новыми, возможно, несовместимыми версиями Python или Debian. Как альтернатива возможен образ на основе Alpine, но он может вызвать некоторые проблемы: внутри него используется musl lib вместо glibc, на которую полагается Python. Имейте это в виду, если решите выбрать Alpine. Что касается скорости, воспользуемся многоступенчатыми сборками, чтобы кешировать как можно больше слоев. Так зависимости и инструменты вроде gcc, а также все необходимые приложению зависимости не загружаются из requirements.txt каждый раз. Для дальнейшего ускорения пользовательский базовый образ создается из ранее упомянутого python:3.8.1-buster, в котором есть все необходимое, так как мы не можем кешировать шаги, необходимые при загрузке и установке этих инструментов в окончательный образ
runner
. Но хватит болтать, посмотрим на Dockerfile:# dev.Dockerfile
FROM python:3.8.1-buster AS builder
RUN apt-get update && apt-get install -y --no-install-recommends --yes python3-venv gcc libpython3-dev && python3 -m venv /venv && /venv/bin/pip install --upgrade pip
FROM builder AS builder-venv
COPY requirements.txt /requirements.txt
RUN /venv/bin/pip install -r /requirements.txt
FROM builder-venv AS tester
COPY . /app
WORKDIR /app
RUN /venv/bin/pytest
FROM martinheinz/python-3.8.1-buster-tools:latest AS runner
COPY --from=tester /venv /venv
COPY --from=tester /app /app
WORKDIR /app
ENTRYPOINT ["/venv/bin/python3", "-m", "blueprint"]
USER 1001
LABEL name={NAME}
LABEL version={VERSION}
Выше видно, что код перед созданием финального образа
runner
пройдет через 3 промежуточных образа. Первый — builder. Он загружает все необходимые для сборки приложения библиотеки, включая gcc и виртуальную среду Python. После установки создается настоящая виртуальная среда, используемая следующими образами. Далее идет builder-venv, копирующий список зависимостей (requirements.txt) в образ и затем устанавливающий их. Этот промежуточный образ необходим для кэширования: хочется установить библиотеки только в случае изменения requirements.txt, в противном случае просто используем кеш. Перед созданием окончательного образа протестируем приложение. Прежде чем мы создадим наш окончательный образ, сперва запустим тесты нашего приложения. Копируем исходный код и запускаем тесты. Когда тесты проходят, переходим к образу runner. Здесь применяется пользовательский образ с некоторыми дополнительными инструментами, отсутствующими в обычном образе Debian: vim и netcat. Этот образ находится на Docker Hub, а также можно посмотреть очень простой Dockerfile в base.Dockerfile. Итак, что мы делаем в этом окончательном образе: сначала копируем виртуальную среду, где хранятся все установленные нами зависимости из образа tester, затем копируем протестированное приложение. Теперь, когда все исходники в образе, перемещаемся в каталог, где находится приложение, и устанавливаем ENTRYPOINT так, чтобы при запуске образа запускалось приложение. Из соображений безопасности USER устанавливается в 1001: лучшая практика рекомендует никогда не запускать контейнеры под root. Заключительные 2 строки устанавливают метки образа. Они будут заменены при выполнении сборки через цель
make
, что мы увидим немного позже.Оптимизированные контейнеры для производственной среды
Когда дело доходит до образов производственного класса, хочется убедиться, что они маленькие, безопасные и быстрые. Мой личный фаворит в этом смысле — образ Python из проекта Distroless. Но что такое «Distroless»? Скажем так: в идеальном мире каждый строил бы свой образ, используя FROM scratch в качестве базового (то есть пустой образ). Но это не то, чего хочется большинству из нас, поскольку это требует статической привязки двоичных файлов и т.д. Вот где вступает в игру Distroless: это FROM scratch для всех. А теперь я действительно расскажу, что такое «Distroless». Это набор созданных Google образов, содержащие необходимый приложению абсолютный минимум. Это означает, что в них нет никаких оболочек, менеджеров пакетов или других инструментов, которые раздували бы образ и создавали сигнальный шум для сканеров безопасности (например, CVE), затрудняющий установление соответствия требованиям. Теперь, когда известно, с чем мы имеем дело, посмотрим на производственный Dockerfile. На самом деле не нужно сильно менять код, нужно изменить всего лишь 2 строки:
# prod.Dockerfile
# 1. Line - Change builder image
FROM debian:buster-slim AS builder
# ...
# 17. Line - Switch to Distroless image
FROM gcr.io/distroless/python3-debian10 AS runner
# ... Rest of the Dockefile
Все, что нам нужно было изменить, — это наши базовые образы для создания и запуска приложения! Но разница довольно велика — образ для разработки весил 1,03 ГБ, а этот — всего лишь 103 МБ, и это большая разница! И я уже слышу вас: «Alpina может весить еще меньше!». Да, это так, но размер не имеет такого большого значения. Вы заметите размер образа только при загрузке/выгрузке, она происходит не так уж часто. Когда образ работает, размер неважен. Что важнее размера, так это безопасность, и в этом отношении Distroless, безусловно, превосходит Alpine: Alpine имеет множество дополнительных пакетов, увеличивающих поверхность атаки. Последнее, о чем стоит упомянуть, рассказывая о Distroless — это отладка образов. Учитывая, что Distroless не содержит никакой оболочки (даже «sh»), отладка и исследование становятся довольно сложными. Для этого существуют «отладочные» версии всех образов Distroless. Таким образом, когда случается неприятность, возможно построить свой рабочий образ с помощью тега
debug
и развернуть его вместе с вашим обычным образом, выполнить необходимое в отладочном образе и сделать, к примеру, дамп потока. Возможно использовать отладочную версию образа python3 вот так:docker run --entrypoint=sh -ti gcr.io/distroless/python3-debian10:debug
Одна команда для всего
Со всеми готовыми Dockerfile можно автоматизировать весь этот кошмар с помощью Makefile! Первое, что мы хотим сделать — собрать приложение с помощью Docker. Поэтому для построения образа разработки напишем
make build-dev
, выполняющую такой код:
# The binary to build (just the basename).
MODULE := blueprint
# Where to push the docker image.
REGISTRY ?= docker.pkg.github.com/martinheinz/python-project-blueprint
IMAGE := $(REGISTRY)/$(MODULE)
# This version-strategy uses git tags to set the version string
TAG := $(shell git describe --tags --always --dirty)
build-dev:
@echo "\n${BLUE}Building Development image with labels:\n"
@echo "name: $(MODULE)"
@echo "version: $(TAG)${NC}\n"
@sed -e 's|{NAME}|$(MODULE)|g' -e 's|{VERSION}|$(TAG)|g' dev.Dockerfile | docker build -t $(IMAGE):$(TAG) -f- .
Эта цель строит образ, сначала заменяя метки в нижней части
dev.Dockerfile
именем образа и тегом, который создается путем запуска git describe
, затем запускается docker build
. Далее — сборка для производственной среды с помощью make build-prod VERSION=1.0.0
:
build-prod:
@echo "\n${BLUE}Building Production image with labels:\n"
@echo "name: $(MODULE)"
@echo "version: $(VERSION)${NC}\n"
@sed -e 's|{NAME}|$(MODULE)|g' -e 's|{VERSION}|$(VERSION)|g' prod.Dockerfile | docker build -t $(IMAGE):$(VERSION) -f- .
Эта цель очень похожа на предыдущую, но вместо тега git в качестве версии используется переданная как аргумент версия, в приведенном выше примере это 1.0.0. Когда все запускается в Docker, в какой-то момент нужно также отладить всё в Docker. Для этого есть такая цель:
# Example: make shell CMD="-c 'date > datefile'"
shell: build-dev
@echo "\n${BLUE}Launching a shell in the containerized build environment...${NC}\n"
@docker run -ti --rm --entrypoint /bin/bash -u $$(id -u):$$(id -g) $(IMAGE):$(TAG) $(CMD)
В коде выше видно, что точка входа переопределяется bash, а команда контейнера переопределяется аргументом в CMD. Таким образом, мы можем либо просто войти в контейнер и пошарить вокруг, либо выполнить какую-то команду, как в приведенном выше примере. Закончив программировать и отправляя образ в реестр Docker мы сможем использовать
make push VERSION=0.0.2
. Посмотрим, что делает эта цель:
REGISTRY ?= docker.pkg.github.com/martinheinz/python-project-blueprint
push: build-prod
@echo "\n${BLUE}Pushing image to GitHub Docker Registry...${NC}\n"
@docker push $(IMAGE):$(VERSION)
Сначала она запускает рассмотренную ранее цель
build-prod
, а затем просто docker push
. Это предполагает вход в реестр Docker, поэтому перед запуском этой цели нужно выполнить docker login
. Последняя цель — очистка артефактов Docker. Здесь используется метка name, которая была заменена внутри файлов сборки образа Docker, чтобы фильтровать и находить артефакты, которые необходимо удалить:
docker-clean:
@docker system prune -f --filter "label=name=$(MODULE)"
Весь код Makefile находится в репозитории.
CI/CD с помощью GitHub Actions
В проекте для настройки CI/CD используется make, Github Actions и реестр пакетов Github для построения конвейеров (задач), а также хранения наших образов. Но что это такое?
- GitHub Actions — это задачи/пайплайны, помогающие автоматизировать рабочие процессы разработки. Возможно использовать их для создания отдельных задач, а затем объединить в пользовательские рабочие процессы, которые выполняются, например, при каждой отправке данных в репозиторий или при создании релиза.
- Реестр пакетов Github — это полностью интегрированный с GitHub сервис хостинга пакетов. Он позволяет хранить различные типы пакетов, например, гемы Ruby или пакеты npm. В проекте он применяется, чтобы хранить образы Docker. Узнать больше о реестре пакетов Github можно здесь.
Чтобы использовать GitHub Actions, в проекте создаются рабочие процессы, выполняемые на основе выбранных триггеров (пример триггера — отправка в репозиторий). Эти рабочие процессы — файлы YAML в каталоге
.github/workflows
:
.github
L-- workflows
+-- build-test.yml
L-- push.yml
Файл build-test.yml содержит 2 задания, запускаемых при каждой отправке кода в репозиторий, они показаны ниже:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Run Makefile build for Development
run: make build-dev
Первая задача под названием build проверяет, что приложение можно собрать, запустив цель
make build-dev
. Однако прежде чем запуститься, она проверяет репозиторий, выполняя checkout
, публикуемый на GitHub.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.8'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Makefile test
run: make test
- name: Install Linters
run: |
pip install pylint
pip install flake8
pip install bandit
- name: Run Linters
run: make lint
Вторая задача немного сложнее. Она запускает тесты рядом с приложением, а также 3 контролирующие качество кода линтера (контролеры качества кода). Как и в предыдущей задаче для получения исходного кода используется действие
checkout@v1
. После запускается еще одно публикуемое действие под названием setup-python@v1
, настраивающее среду python (подробнее об этом здесь). Теперь, когда у нас есть среда Python, нужны зависимости приложений из requirements.txt
, которые устанавливается с помощью pip. На этом этапе приступим к запуску цели make test
, она запускает набор тестов Pytest. Если тесты набора проходят, то переходим к установке упомянутых ранее линтеров — pylint, flake8 и bandit. Наконец, запускаем цель make lint
, в свою очередь, запускающую каждый из этих линтеров. Это все о задании сборки/тестирования, но как насчет отправки кода? Давайте поговорим о ней:
on:
push:
tags:
- '*'
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set env
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
- name: Log into Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin
- name: Push to GitHub Package Registry
run: make push VERSION=${{ env.RELEASE_VERSION }}
Первые 4 строки определяют момент запуска задания. Мы указываем, что это задание должно запускаться только при перемещении тегов в репозиторий (* указывает шаблон имени, здесь это все теги). Так делается для того, чтобы мы не помещали образ Docker в реестр пакетов GitHub при каждой отправке данных в репозиторий, а только тогда, когда отправляется указывающий новую версию нашего приложения тег. Теперь о теле этой задачи — она начинается с проверки исходного кода и установки значения переменной среды RELEASE_VERSION равным отправленному тегу git. Это делается с помощью встроенной в GitHub Actions функции ::setenv (подробнее здесь). Затем задача входит в реестр Docker с хранимым в репозитории секретом REGISTRY_TOKEN и логином инициировавшего рабочий процесс пользователя (github.actor). Наконец, в последней строке запускается цель push, которая строит производственный образ и помещает его в реестр с ранее отправленным тегом git в качестве тега образа. Посмотрите весь код в файлах моего репозитория.
Проверка качества кода с помощью CodeClimate
И последнее, но не менее важное, чем всё остальное: добавим проверку качества кода с помощью CodeClimate и SonarCloud. Они будут срабатывать вместе с показанной выше задачей тестирования. Добавляем несколько строк кода:
# test, lint...
- name: Send report to CodeClimate
run: |
export GIT_BRANCH="${GITHUB_REF/refs\/heads\//}"
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
chmod +x ./cc-test-reporter
./cc-test-reporter format-coverage -t coverage.py coverage.xml
./cc-test-reporter upload-coverage -r "${{ secrets.CC_TEST_REPORTER_ID }}"
- name: SonarCloud scanner
uses: sonarsource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Начинаем с CodeClimate: экспортируем переменную
GIT_BRANCH
, извлеченную с помощью переменной среды GITHUB_REF
. Затем загружаем инструмент отчета о тестировании CodeClimate и делаем его исполняемым. После используем его для форматирования отчета о покрытии от набора тестов. В последней строке отправляем его в CodeClimate с идентификатором инструмента для отчета о тестировании, который хранится в секретах репозитория. Что касается SonarCloud, нужно создать файл sonar-project.properties
. Значения для этого файла можно найти на панели мониторинга SonarCloud в правом нижнем углу, а выглядит этот файл так:
sonar.organization=martinheinz-github
sonar.projectKey=MartinHeinz_python-project-blueprint
sonar.sources=blueprint
Кроме того, возможно просто использовать делающий работу за нас
sonarcloud-github-action
. Все, что нам нужно сделать, это предоставить два токена: для GitHub тот, который находится в репозитории по умолчанию, и для SonarCloud тот, что мы получили с сайта SonarCloud. Примечание: шаги получения и установки всех упомянутых токенов и секретов описываются в README репозитория.Заключение
Вот и всё! С помощью инструментов, конфигураций и кода вы готовы настроить и автоматизировать все аспекты вашего следующего проекта на Python! Если вам нужна дополнительная информация о темах, показанных или обсуждаемых в этой статье, ознакомьтесь с документацией и кодом в моем репозитории, и если у вас есть предложения или проблемы, пожалуйста, отправьте запрос в репозиторий или просто отметьте этот маленький проект звездочкой, если он вам нравится.
А по промокоду HABR, можно получить дополнительные 10% к скидке указанной на баннере.
- Обучение профессии Data Science с нуля
- Онлайн-буткемп по Data Science
- Обучение профессии Data Analyst с нуля
- Онлайн-буткемп по Data Analytics
- Курс «Python для веб-разработки»
Eще курсы
- Курс по аналитике данных
- Курс по DevOps
- Профессия Веб-разработчик
- Профессия iOS-разработчик с нуля
- Профессия Android-разработчик с нуля
- Профессия Java-разработчик с нуля
- Курс по JavaScript
- Курс по Machine Learning
- Курс «Математика и Machine Learning для Data Science»
- Продвинутый курс «Machine Learning Pro + Deep Learning»
Paskin
Два вопроса знатокам Python + Docker:
— Зачем в примере нужны промежуточные образы, если Docker и так кэширует слои?
— Можно ли (и как) упаковать в образ прекомпилированные файлы (.pyc) чтобы не тратить время при каждом запуске контейнера и не создавать ad-hoc слой
Drakosh
2) Возможно, поможет
python -m compileall
: compileall — Byte-compile Python librariesrazielvamp
Можно пользуясь случаем дозадать вопросы?
PS а что касается
Насколько мне хватает знаний, в контексте данного примера (если мы всегда используем только докерфайл из статьи) — бессмысленная операция. Возможно это задел на будущее в рабочей среде автора статьи. Чтобы можно было пересобирать образы с определенного чекпоинта (образа), а не с самого начала.
Paskin
По поводу «протухания» — такое действительно случается. Но статистически — либо вы сервис не трогаете (пользуясь собранным ранее образом), либо что-то меняете — тогда все равно приходится тесты гонять и бОльшая часть проблем выявляется еще у девелоперов.
В особых случаях (типа TensorFlow или numpy для RaspberryPi, строящихся по пол-дня — или каких-то редких драйверов) мы хранили wheels нужных версий.
mskotyn
Там два разных базовых образа — для тестера и для раннера. И venv копируется с тестера в раннер:
Zagrebelion
я так понимаю, что с промежуточными слоями мы избавляемся от артефактов сборки в образе.
Например, пип кеширует скачанные файлы, и вот этот кэш останется в билд-образе, а в рабочий образ пойдёт только venv. Ну или если какая-то либа собиралась из исходников, то ошмётки её билда тоже не нужны.
Paskin
Возможно.