Большая боль разработчиков, которые приходят на новый проект — для развертывания сервиса локально нужно пообщаться минимум с десятком людей, не говоря уже про интеграцию с CI/CD-сервером. В один момент мы решили реализовать это удобнее, заодно сократив время онбординга новых сотрудников.
При этом мы хотели получить не только быстрый ввод новых сервисов в эксплуатацию и минимальное время развертывании любого сервиса локально — мы хотели, чтобы все наши сервисы использовали более или менее одинаковые версии библиотек, настройки линтеров и конфигурацию. А поскольку мы финтех, то должен был сохраняться высокий уровень безопасности, а риск человеческих ошибок — снижаться.
Меня зовут Олег Чуркин. Я больше 10 лет занимаюсь разработкой на Python и сейчас руковожу разработкой нового процессинга платежей в QIWI. Расскажу, как мы реализовали boilerplate-шаблон для сервисов — на примере небольшого стартапа внутри нашей большой компании.
Мы пишем свой процессинг в парадигме микросервисной архитектуры, сами сервисы написаны на Python 3.7+, используются фреймворки Flask / Django, а способ хостинга — GCP: GKE (тот же самый Kubernetes) и Cloud SQL (managed-версия Postgres).
Мы оперировали простыми и проверенными решениями, потому что тратить время на что-то сложное нашему стартапу не хотелось. В статье весь код не рассмотришь, поэтому большую его часть я выложил на GitHub. То, что по понятным причинам выложить нельзя, я постараюсь объяснить на пальцах.
Структура шаблона выглядит вот так:
Проект построен по шаблону Cookiecutter: каждый сервис живет в своем отдельном git-репозитории:
Настройки для TeamCity CI хранятся в директории .teamcity и версионируются в Git, для их описания используем Kotlin. Там же, в TeamCity, происходит валидация конфигурации, запускаются линтеры, тесты, билд и деплой на разные окружения, а также происходит запуск provisional-задач, например, миграций. Дополнительно в наш типовой пайплайн добавлены сканеры, которые проверяют код и docker-образы на уязвимости. Всё это плюс-минус выполняется в CI.
Содержимое директории infra и файлов .dockerignore, docker-compose.* используется для пайплайнов Build, Test, Deploy, Provision — это всё, что отвечает за сборку проекта, тестирование, развертывание и provision (например, создание базы и миграции схемы данных).
Директории tests и {{cookiecutter.project_slug}} — это скелет приложения с исходным кодом сервиса, а также тесты. Основной фреймворк тестирования — PyTest .
Для синхронизации того, как исходный код выглядит у разработчиков, как настроены стили табуляции пробелов, как выглядят YAML и JSON при редактировании — используем .editorconfig.
Для настроек окружения локальной обработки используется специфичный для Flask файл — .flaskenv.
Makefile — это legacy, где описываются цели, которые запускаются либо локально, либо в CI, вызывая тестирование и проверку линтерами. Также у нас остался setup.cfg для решения каких-то legacy-моментов.
В pyproject.toml версионируются библиотеки и хранятся их конфигурации, а также конфигурация практически всех известных питоновских утилит.
Настройки приложения у нас хранятся в settings.yaml, а за сборку проекта, тестирование, развертывание и provision отвечает знакомый многим набор файлов: docker-compose.provision.yaml, docker-compose.test.yaml, docker-compose.yaml.
Это что касается структуры проекта. Теперь давайте разберем всё более подробно.
Package management
Мы используем Poetry — несмотря ни на что, у него по-прежнему много преимуществ. Для начала — это лучший алгоритм dependency resolution на то время, когда мы начинали его использовать. Вы можете использовать всего один файл для хранения всей конфигурации pyproject.toml, PEP 621 и PEP 517. Также Poetry поддерживает приватные PYPI-репозитории, и нам это важно — потому что глобальный PYPI мы не используем вообще, ниже расскажу, почему.
Благодаря lock files можно делать детерминистические сборки, имея одну и ту же версию и на тестинге, и на стейджинге, и на продакшене. А управление виртуальным окружением позволяет не заморачиваться с тем же virtualenv. Вот пример конфигурации, на особенности которой я бы хотел обратить внимание:
[[tool.poetry.source]]
name = "acme-pypi"
url = "https://registry.acme.com/repository/pypi-acme-pay/simple/"
default = true
[[tool.poetry.source]]
name = "acme-pypi-proxy"
url = "https://registry.acme.com/repository/pypi-proxy/simple/"
[tool.poetry.dependencies]
python = "^3.7"
pyuwsgi = "*" # using wheels to avoid compiling
safety = "*"
acme_common_utils = { version = "*", extras = ["sentry", "prometheus_flask"] }
Во-первых, вместо глобального PYPI-сервера мы используем два приватных. Один из них — прокси на глобальный, а второй — наш репозиторий, в котором мы храним свои библиотеки. И, наверное, многие из вас используют библиотеку safety или сервис pyup для проверки установленных питоновских библиотек на уязвимости. Так вот, нам удалось договориться с PCI DSS аудитором, что наше решение с приватными PYPI-серверами тоже можно использовать и доверять ему.
Второй момент — необходимость избегать уязвимости Dependency Confusion, и для этого мы используем параметр default = true (pyproject.toml), который запрещает использовать в сборке неглобальные PYPI. То есть мы запрещаем доступ напрямую и создаем свой дефолтный приватный репозиторий с нашими библиотеками. Благодаря чему код злоумышленника не может проникнуть в инфраструктуру, выполниться и натворить бед с безграничными возможностями.
Третий момент. Чтобы не тянуть в docker-образ кучу питоновских девелопмент-библиотек, мы всегда стараемся использовать wheels, где они возможны. Сейчас очень редко встречаются питоновские пакеты, которые не имеют бинарного инсталлятора. Один из них — сервер приложений uWSGI, пакет собирается сторонними людьми и называется pyuwsgi. Мы его используем, чтобы у нас ничего не билдилось.
uWSGI как application server
Наш основной application server — uWSGI, и, думаю, многим знакомо это решение. Может быть, оно уже не модное, но зато проверено временем. К тому же у него запредельная кастомизация (которая, правда, иногда мешает)— такого количества настроек я не видел ни у одного приложения.
Мало кого сейчас удивишь поддержкой асинхронных воркеров, но она есть, и она нам нужна: мы используем gevent, чтобы сделать наши синхронные приложения асинхронными. Автоматический recycling воркеров в uWSGI позволяет нам управлять поведением воркеров при достижении определенных лимитов.
Конфигурация нашего uWSGI:
# automated workers recycling
workers: 2
reload-on-rss: 250
harakiri: 45
Видно, что воркер нужно перезагрузить, если он достиг какого-то количества resident memory. Это помогает нам справляться с утечками памяти, так как иногда их тяжело исправить — и проще перезапустить воркер. Здесь это делается автоматически, поэтому когнитивной нагрузки тут минимум.
Еще из интересного, что у нас есть в uWSGI, это основные настройки, без которых практически ничего не будет работать:
enable-threads: true. Включаем threads, потому что без них, например, не работает коллектор Sentry и ничего не репортит.
strict: true. Запрещаем опечатки в конфигурации и неизвестные поля (strict).
Параметр need-app: true запрещает uWSGI стартовать, если приложение не было обнаружено. Без этого параметра uWSGI продолжит работать, даже если что-то пошло не так. Например, при старте вылетело приложение, выдало exception, а uWSGI будет ждать приложения в динамическом режиме.
die-on-term: true требуется для оркестратора Kubernetes. Это включает режим shutdown на сигнал SIGTERM, который присылает оркестратор, когда хочет остановить под. Чаще всего это происходит, когда вы деплоите новую версию и у вас проходит rolling update. Без этого параметра uWSGI будет перезапускать сам себя, и pod никогда не удалится.
Еще один параметр — «lazy-apps: true» — требует отдельного внимания. Многие пользователи uWSGI с ужасом вспоминают сообщение «uWSGI listen queue of socket "0.0.0.0:8888" (fd: 1) full !!! (101/100)». По умолчанию в uWSGI с помощью fork ускоряется запуск воркеров, но если в мастер-процессе попытаться что-то залогировать или Sentry что-то куда-то отправит — это приведет к дедлокам.
Нам пришлось с этим много возиться, пока не нашли баги в Python версии 3.7 логинга (но в 3.8 они должны были быть пофикшены). В результате в настройке lazy-apps мы выставили параметр true, и параметры каждого воркера инициируются отдельно. Это немного тормозит процесс запуска приложения, а воркеры занимают больше памяти, но зато избавляет от проблем.
uWSGI configuration highlights
Кратко скажу, что логи мы стараемся писать в формате JSON, а научить uWSGI писать JSON — это целое отдельное кунг-фу. Настройки логгинга можно полностью посмотреть в репозитории.
Нам важно, что uWSGI из коробки поддерживает множественное окружение с несколькими параметрами. Их можно изменять в зависимости от типов приложений. Например, параметр service_port изменяется и регулируется с помощью директивы set-ph. Естественно, для девелопмента, для стейджинга, для продакшен у нас своя конфигурация, и в разных окружениях это все вызывается по-разному:
poetry run uwsgi --yaml infra/uwsgi.yaml
--yaml infra/uwsgi.yaml:${APP_NAME}
--yaml infra/uwsgi.yaml:${ACME_ENV}
Application configuration
Следующий наш пассажир — это Dynaconf, очень его рекомендую. Этот инструмент позволяет удобно хранить настройки приложения, разделяя их по средам и месту хранения. У него ультимативное, достаточно мощное решение для управления конфигурацией. Также он поддерживает слияние настроек из разных источников (файлы, vault, redis).
Например, некоторые наши несекретные настройки хранятся рядом с приложением (тот самый файл settings.py), а все секретные загружаются из Hashicorp Vault при старте приложения. Еще Dynaconf поддерживает плагины для Django и Flask, и его конфигурация выглядит примерно так:
default:
{{cookiecutter.config_db_name}}:
host: 'localhost'
port: 5432
user: 'postgres'
password: 'postgres'
development.local: &development
domain: 'localhost'
tests.compose:
<<: *development
{{cookiecutter.config_db_name}}:
host: 'database'
staging.kubernetes:
{{cookiecutter.config_db_name}}:
host: 'pgbouncer'
port: 5432
user: __required__
password: __required__
Dynaconf поддерживает несколько форматов: yaml, toml, Python. Мы выбрали yaml, потому что на нем у нас уже была написана конфигурация для деплоймента в Kubernetes. Но yaml нам в целом нравится: у него легкая читаемость, он структурирован и поддерживает наследование. Мы активно используем якоря и псевдонимы: на примере видно, как переиспользуются конфигурации с development.local в конфигурации tests.compose.
В yaml описаны несколько окружений и есть разделение на локальное и docker-compose-окружение. Мы добавили свою валидацию для обязательных полей — обратите внимание, что есть два поля со значением __required__. В Dynaconf есть встроенный валидатор, но он менее очевидный, поэтому мы сделали свой для большей наглядности. С ним по yaml-конфигурации проще понять, какие поля прописать в Vault. В данном случае, например, нужны два поля user и password в Vault на стейджинге.
Это всё, что касается application settings. Перейдем к самому интересному: как мы разрабатываем наше приложение локально, как деплоим и как собираем.
Build, Deploy and Local Development
Мы используем технологию Docker Compose. Прежде всего создаем для своих сервисов отдельную подсеть, чтобы они никак не интерферировали с другими сервисами на машине и запускались в отдельной сети:
docker network create -d bridge
--subnet 192.168.0.0/24 --gateway 192.168.0.1 acme-net
Это достаточно удобно, если у вас запускается много разных контейнеров из разных проектов. Нюанс только в том, что тогда нужно добавлять в каждый compose-файл приписку с тем, что используется отдельная подсеть. После чего происходит локальная разработка:
networks:
acme-subnet:
external:
name: acme-net
Чтобы запустить сервис локально, разработчик:
Запускает сервер баз данных (в данном случае Postgres):
docker-compose -f docker-compose.provision.yaml run database -d
;Создает новую базу данных:
docker-compose -f docker-compose.provision.yaml run --rm create-db
;Запускает миграции:
docker-compose -f docker-compose.provision.yaml run --rm migrate
;Стартует сам сервис:
docker-compose up
.
На этом локальная разработка практически настроена. Остается только запустить и проверить тесты, линтеры и безопасность различных пакетов:
docker-compose -f docker-compose.test.yaml run --rm tests
docker-compose -f docker-compose.test.yaml run --rm check
Docker & Compose highlights
Однажды нам надоело писать длинные баш-строки и несуразицу в makefile, и мы решили поместить общую работу в наш родимый Python. Сделали свой тулинг на основе библиотеки pyinvoke, на котором построен известный многим инструмент fabric. Для примера посмотрим на один из yaml-файлов:
x-environment: &env
PYTHONDONTWRITEBYTECODE: 1
TEAMCITY_VERSION: ${TEAMCITY_VERSION}
ENV_FOR_DYNACONF: tests.compose
services:
check: &base_service
build:
context: .
dockerfile: infra/docker/Dockerfile
target: development
environment: *env
command: poetry run acme-tasks check -a
volumes:
- ${CODE_DIR:-.}:/code
tests:
<<: *base_service
command: poetry run acme-tasks wait-for-tcp --connections=database:5432 tests
depends_on:
- database
В этом коде можно заметить, как этот тулинг используется (в репозитории в docker-файле можно посмотреть, как это выглядит):
Wait-for-tcp ждет, пока запустится БД, чтобы начать тесты в docker-compose.
Переменная TEAMCITY_VERSION контролирует библиотеку teamcity-messages, которая осуществляет очень удобный репортинг результатов тестов teamcity.
Якоря подставляются в другие сервисы, а acme-tasks используются для автоматизации рутинных задач и замены makefile.
В docker-compose используется target: development, потому что мы делаем multi-stage сборку в docker. В девелопмент устанавливается больше библиотек, а в продакшене выключен root, потому что из-под него запускать приложения нельзя.
Перейдем к заключительному этапу.
Container Orchestration
Мы используем Kubernetes в GKE (Google Kubernetes Engine). И самая первая проблема, которая перед нами встала — как разбить конфигурацию на несколько окружений (поменять количество реплик, порты или настройки).
Мы проанализировали Helm, Skaffold и Kapitan, но на тот момент все они показались нам переусложненными и плохо интегрируемыми в CI. Поэтому мы выбрали утилиту, которая раньше жила отдельной жизнью и называлась kustomize, но с версии 1.14+ ее включили в бинарник kubectl. С ее помощью можно писать базовую конфигурацию (слева) и overlays — те изменения, которые произойдут по сравнению с базовой конфигурацией:
В kustomize поддерживаются две стратегии слияния: обычное — patchesStrategicMerge и с возможностью гранулярно удалять, добавлять и изменять поля в конфигурации — patchesJson6902. В результате деплой с локальной машины и из CI может выглядеть как строка вверху примера. Но мы стараемся переносить такие вещи в наш тулинг, чтобы не мозолили глаза — тогда это выглядит как строка внизу примера.
Самый интересный момент — это то, как приложение на uWSGI переживает деплой. У uWSGI нет нормальной реализации graceful shutdown: когда мы начинали передеплоивать наши сервисы, и Kubernetes посылал sigterm сигнал, то uWSGI закрывал все текущие соединения, никого не дожидался и отключался. Естественно, это вызывало «connection refused» и «пятисотки». Чтобы это исправить, мы нашли более или менее понятное решение:
# deployment.yaml
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "${PRE_STOP_SECONDS}"]
Мы используем preStop хуки в Kubernetes, чтобы дать возможность uWSGI завершить все текущие запросы прежде, чем ему придет sigterm-сигнал, и он отключится. Главное — правильно подобрать sleep time, который выполнится прежде, чем uWSGI отключится.
Осталось немного поговорить о сетевых политиках и сделать выводы.
Network policies
Требования PCI DSS: весь трафик, который не разрешен явно — недопустим. В итоге у нас огромные файлы с network policy, которые описывают, какие сервисы по какому порту могут коннектиться к другим сервисам:
# network_policy.yaml
ingress:
- from:
- podSelector:
matchLabels:
app: acme-admin
- podSelector:
matchLabels:
app: acme-reports
ports:
- port: 8000
protocol: TCP
Хотя есть инструменты для автоматизации (например, Inspector Gadget, который можно установить в свой кластер), мы сами прошлись по архитектурной схеме и составили файлы network policy, чтобы ловить трафик.
Конфигурация в репозитории сервиса
Сейчас у нас вся конфигурация хранится в репозитории сервиса, и в каждом она своя. Скажем так, это не совсем удобно: после достижения, к примеру, десяти микросервисов появляется много разных вопросов.
Например, как внести изменение в конфигурацию всех сервисов, чтобы обновить библиотеку, в которой нашли уязвимость? Придется пройти по всем репозиториям, сделать pull request, всё обновить и задеплоить. А если какой-то из сервисов не деплоился полгода и все боятся его трогать, потому что никто не знает, что с ним случится после этого?
Или вопрос version hell — когда есть микросервисы с общими библиотеками, и какой-то микросервис разрабатывается быстрее, чем остальные. Вы добавляете функционал в общую библиотеку, увеличиваете версию, пините ее, а потом выясняется, что ее нужно обновить до последней версии в том микросервисе, который не деплоился уже полгода. И хорошо, если у вас есть тесты, которые делают полное покрытие.
Пока мы стараемся держать в голове, что нужно разрабатывать общие библиотеки так, чтобы они были обратно совместимы между версиями. Естественно, мы поддерживаем Semver.
Еще бывает, что разработчик не может разобраться: код, который он написал — общий или нет? Надо его выносить в общую библиотеку или не надо? Потребуется он в другом сервисе или нет?
Здесь мы пользуемся правилом «два раза можно, на третий перенеси», хотя это дает определенную когнитивную нагрузку на людей.
Планы
В будущем мы хотим разделить конфигурацию сборки и деплоймента в отдельные репозитории, создав отдельный репозиторий для всей конфигурации (GitOps). Сделать конфигурацию сервисов в одном репозитории, туда же перенести terraform, чтобы можно было поменять общую конфигурацию за один pull request.
У нас есть определенная конфигурация сервисов и конфигурация более высокоуровневых вещей. Например, конфигурация балансировщиков, БД и бакетов находятся в отдельном репозитории — и там же лежит код на terraform. И мы хотим создать критерии, чтобы всегда было понятно, какое изменение в какой из этих репозиториев внести — в репозитории с terraform, которым управляют DevOps, или в репозитории с сервисом с управлением от разработчиков.
Еще хотелось бы удобный интерфейс для взаимодействия с шаблоном сервисов. Чтобы через UI всё само задеплоилось согласно выбранным настройкам и на дашборде отразилась вся нужная информация.
Мы смотрим в направлении своего Kubernetes Controllers — условно говоря, плагина, который позволяет писать кастомный yaml для Kubernetes. В нем доступны несколько полей для настройки, а всё остальное за вас делает сам контроллер.
Еще мы думаем над использованием Slack для автоматизации части работы. Это удобство единого окна, когда всё управляется в одном месте (ChatOps).
Другой инструмент — это backstage.io. Это developer portal, который написала компания Spotify. Он занимается хранением шаблонов сервисов, документации к ним, умеет строить дашборды. Всё это кастомизируется и получается единая точка входа с интересным UI.
В общем, мы много чего сделали, но еще есть куда двигаться и развиваться.
Видео моего выступления на Moscow Python Conf++ 2021: