Сегодня делимся с вами переводом статьи 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! Если вам нужна дополнительная информация о темах, показанных или обсуждаемых в этой статье, ознакомьтесь с документацией и кодом в моем репозитории, и если у вас есть предложения или проблемы, пожалуйста, отправьте запрос в репозиторий или просто отметьте этот маленький проект звездочкой, если он вам нравится.

image

А по промокоду HABR, можно получить дополнительные 10% к скидке указанной на баннере.



Рекомендуемые статьи