Недавно я написал ответ о жизни проекта в Докерах и отладке кода вне него, где мельком упомянул о том, что можно сделать свою систему конфигурирования, чтобы сервис и в Кубере хорошо работал, подтягивал секреты, и локально удобно запускался, в том числе вообще вне Докера. Ничего сложного, но описанный "рецепт" может кому-то пригодится :) Код на Питоне, но логика к языку не привязана.
Предыстория вопроса такова: жил-был один проект, сначала он был маленьким монолитом с утилитами и скриптами, но со временем рос, делился на сервисы, которые в свою очередь стали делиться на микросервисы, а потом ещё и скейлиться. Поначалу это всё выполнялось на голых 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: 26379smtp: 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-mainvolumes: - name: db-main-secret-volume secret: secretName: db-main-secret
То есть, в каждом секрете описана одна секция. Сами секреты создаются так:
apiVersion: v1kind: Secretmetadata: name: db-main-secrettype: OpaquestringData: db_main.yaml: | engine: sqlite filename: main.sqlite3
Вместе это приводит к созданию YAML-файлов по пути
/etc/secrets/db-main/section_name.yaml
А для локальных запусков используется конфиг, расположенный в корневой директории проекта или по пути, указанному в переменной окружения. Код, ответственный за эти удобства, можно лицезреть в спойлере.
__author__ = 'AivanF'__copyright__ = 'Copyright 2020, AivanF'import osimport 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 Falsedef 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 Truedef 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 configdef 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 configconfig = build_config()
Логика здесь довольно простая: объединяем крупные конфиги из директории проекта и пути по переменной окружения, и небольшие конфиги-секции из секретов Кубера, а затем немного их предобрабатываем. Плюс некоторые переменные. Замечу, что при поиске файлов из секретов используется ограничение глубины, т.к K8s в каждом секрете создаёт ещё скрытую папку, где сами секреты и хранится, а уровнем выше находится просто ссылка.
Надеюсь, описанное окажется кому-нибудь полезным :) Принимаются любые комментарии и рекомендации касательно безопасности или других моментов на улучшение. Также интересно мнение сообщества, возможно стоит добавить поддержку ConfigMaps (в нашем проекте они пока не используется) и оформить код на ГитХабе / PyPI? Лично я думаю, что такие вещи слишком индивидуальные для проектов, чтобы быть универсальными, и достаточно небольшого подглядывания на чужие реализации, вроде приведённой здесь, да обсуждения нюансов, советов и best practices, которое я надеюсь увидеть в комментариях ;)