Сказ о том, как мы Python-микросервисы для облака шаблонизировали

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

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

Меня зовут Олег Чуркин. Я больше 10 лет занимаюсь разработкой на Python и сейчас руковожу разработкой нового процессинга платежей в QIWI. Расскажу, как мы реализовали boilerplate-шаблон для сервисов — на примере небольшого стартапа внутри нашей большой компании.

Мы пишем свой процессинг в парадигме микросервисной архитектуры, сами сервисы написаны на Python 3.7+, используются фреймворки Flask / Django, а способ хостинга — GCP: GKE (тот же самый Kubernetes) и Cloud SQL (managed-версия Postgres).

0fd59004c2ccf4b9a197e7ca22ebb5cd.png

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

Структура шаблона выглядит вот так:

38641725c2bafd2bb54f311ee4a547da.png

Проект построен по шаблону 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, это основные настройки, без которых практически ничего не будет работать:

  1. enable-threads: true. Включаем threads, потому что без них, например, не работает коллектор Sentry и ничего не репортит.

  2. strict: true. Запрещаем опечатки в конфигурации и неизвестные поля (strict).

  3. Параметр need-app: true запрещает uWSGI стартовать, если приложение не было обнаружено. Без этого параметра uWSGI продолжит работать, даже если что-то пошло не так. Например, при старте вылетело приложение, выдало exception, а uWSGI будет ждать приложения в динамическом режиме.

  4. 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 — те изменения, которые произойдут по сравнению с базовой конфигурацией:

d8d35122f48b5d82d986c6beb6edf8f0.png

В 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:

© Habrahabr.ru