Конфигурация проекта внутри и вне Kubernetes

от автора

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

Предыстория вопроса такова: жил-был один проект, сначала он был маленьким монолитом с утилитами и скриптами, но со временем рос, делился на сервисы, которые в свою очередь стали делиться на микросервисы, а потом ещё и скейлиться. Поначалу это всё выполнялось на голых VPS, процессы настройки и разворачивания кода на которых были автоматизированны с помощью Ansible, и каждому сервису составлялся YAML-конфиг с нужными настройками и ключами, и аналогичный конфиг-файл использовался для локальных запусков, что было очень удобно, т.к этот конфиг грузится в глобальный объект, доступный из любого места в проекте.

Однако рост числа микросервисов, их связей, а также потребность в централизованном логировании и мониторинге, предвещали переезд в Кубер, который до сих пор ещё в процессе. Вместе с помощью в решении упомянутых задач, Kubernetes предлагает свои подходы к управлению инфраструктурой, в том числе т.н Секреты и способы работы с ними. Механизм стандартный и надёжный, поэтому в буквальном смысле грех им не воспользоваться! Но при этом хотелось бы сохранить свой текущий формат работы с конфигом: во-первых, единообразно использовать его в разных микросервисах проекта, а во-вторых, иметь возможность запускать код на локальной машине используя один простой конфиг-файл.

В связи с этим, механизм построения объекта-конфигурации был доработан так, чтобы уметь работать как с нашим классическим конфиг-файлом, так и с секретами из Кубера. Также была задана более жёсткая структура конфига, говоря языком третьего Питона, такая:

Dict[str, Dict[str, Union[str, int, float]]]

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

adminka:   django_secret: "ExtraLongAndHardCode"  db_main:   engine: mysql   host: 256.128.64.32   user: cool_user   password: "SuperHardPassword"  redis:   host: 256.128.64.32   pw: "SuperHardPassword"   port: 26379  smtp:   server: smtp.gmail.com   port: 465   email: info@test.com   pw: "SuperHardPassword"

При этом, поле engine баз данных можно установить на SQLite, а redis настроить на mock, указав ещё имя файла для сохранения, – эти параметры корректно распознаются и обрабатываются, что позволяет легко запускать код локально для отладки, юнит-тестирования и любых других нужд. Нам это особенно актуально потому, что этих других нужд много – часть нашего кода предназначена для разнообразных аналитических расчётов, запускается не только на серверах с оркестрацией, но и разными скриптами, и на компьютерах аналитиков, которым нужно прорабатывать и отлаживать сложные конвейеры обработки данных не парясь бэкэндерскими вопросами. Кстати, не лишним будет поделиться тем, что наши основные инструменты, включая код компоновки конфига, устанавливаются через setup.py – вместе это объединяет наш код в единую экосистему, не зависящую от платформы и способа использования.

Описание пода в Kubernetes выглядит так:

containers:   - name : enter-api     image: enter-api:latest     ports:       - containerPort: 80     volumeMounts:       - name: db-main-secret-volume         mountPath: /etc/secrets/db-main  volumes:   - name: db-main-secret-volume     secret:       secretName: db-main-secret

То есть, в каждом секрете описана одна секция. Сами секреты создаются так:

apiVersion: v1 kind: Secret metadata:   name: db-main-secret type: Opaque stringData:   db_main.yaml: |     engine: sqlite     filename: main.sqlite3

Вместе это приводит к созданию YAML-файлов по пути /etc/secrets/db-main/section_name.yaml

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

config.py

__author__ = 'AivanF' __copyright__ = 'Copyright 2020, AivanF'  import os import yaml  __all__ = ['config'] PROJECT_DIR = os.path.abspath(__file__ + 3 * '/..') SECRETS_DIR = '/etc/secrets' KEY_LOG = '_config_log' KEY_DBG = 'debug'  def is_yes(value):     if isinstance(value, str):         value = value.lower()         if value in ('1', 'on', 'yes', 'true'):             return True     else:         if value in (1, True):             return True     return False  def update_config_part(config, key, data):     if key not in config:         config[key] = data     else:         config[key].update(data)  def parse_big_config(config, filename):     '''     Parse YAML config with multiple section     '''     if not os.path.isfile(filename):         return False     with open(filename) as f:         config_new = yaml.safe_load(f.read())         for key, data in config_new.items():             update_config_part(config, key, data)         config[KEY_LOG].append(filename)         return True  def parse_tiny_config(config, key, filename):     '''     Parse YAML config with a single section     '''     with open(filename) as f:         config_tiny = yaml.safe_load(f.read())         update_config_part(config, key, config_tiny)         config[KEY_LOG].append(filename)  def combine_config():     config = {         # To debug config load code         KEY_LOG: [],         # To debug other code         KEY_DBG: is_yes(os.environ.get('DEBUG')),     }     # For simple local runs     CONFIG_SIMPLE = os.path.join(PROJECT_DIR, 'config.yaml')     parse_big_config(config, CONFIG_SIMPLE)     # For container's tests     CONFIG_ENVVAR = os.environ.get('CONFIG')     if CONFIG_ENVVAR is not None:         if not parse_big_config(config, CONFIG_ENVVAR):             raise ValueError(                 f'No config file from EnvVar:\n'                 f'{CONFIG_ENVVAR}'             )     # For K8s secrets     for path, dirs, files in os.walk(SECRETS_DIR):         depth = path[len(SECRETS_DIR):].count(os.sep)         if depth > 1:             continue         for file in files:             if file.endswith('.yaml'):                 filename = os.path.join(path, file)                 key = file.rsplit('.', 1)[0]                 parse_tiny_config(config, key, filename)     return config  def build_config():     config = combine_config()     # Preprocess     for key, data in config.items():         if key.startswith('db_'):             if data['engine'] == 'sqlite':                 data['filename'] = os.path.join(PROJECT_DIR, data['filename'])     # To verify correctness     if config[KEY_DBG]:         print(f'** Loaded config:\n{yaml.dump(config)}')     else:         print(f'** Loaded config from: {config[KEY_LOG]}')     return config  config = build_config()

Логика здесь довольно простая: объединяем крупные конфиги из директории проекта и пути по переменной окружения, и небольшие конфиги-секции из секретов Кубера, а затем немного их предобрабатываем. Плюс некоторые переменные. Замечу, что при поиске файлов из секретов используется ограничение глубины, т.к K8s в каждом секрете создаёт ещё скрытую папку, где сами секреты и хранится, а уровнем выше находится просто ссылка.

Надеюсь, описанное окажется кому-нибудь полезным 🙂 Принимаются любые комментарии и рекомендации касательно безопасности или других моментов на улучшение. Также интересно мнение сообщества, возможно стоит добавить поддержку ConfigMaps (в нашем проекте они пока не используется) и оформить код на ГитХабе / PyPI? Лично я думаю, что такие вещи слишком индивидуальные для проектов, чтобы быть универсальными, и достаточно небольшого подглядывания на чужие реализации, вроде приведённой здесь, да обсуждения нюансов, советов и best practices, которое я надеюсь увидеть в комментариях 😉

ссылка на оригинал статьи https://habr.com/ru/post/523432/