Текстовая версия разбора документа 12 Factor App:

Часто бывает так, что работа с кодовой базой проекта приносит боль в самых разных аспектах: от внесения изменений и добавления новых фич до развертывания на стенде. Может показаться, что какие-то проблемы являются данностью, и с этим принципиально сложно что-то сделать. К счастью, это относится не ко всему.

Адам Виггинс и другие сотрудники компании Heroku однажды решили составить документ, который помог бы решить многие проблемы с разработкой и эксплуатацией информационных систем. Этот документ называется “The Twelve-Factor App” или «12-факторное приложение». Я предлагаю пройтись по нему и разобрать каждый пункт.

Часто бывает так, что работа с кодовой базой проекта приносит боль в самых разных аспектах: от внесения изменений и добавления новых фич до развертывания на стенде. Может показаться, что какие-то проблемы являются данностью, и с этим принципиально сложно что-то сделать. К счастью, это относится не ко всему.

Адам Виггинс и другие сотрудники компании Heroku однажды решили составить документ, который помог бы решить многие проблемы с разработкой и эксплуатацией информационных систем. Этот документ называется “The Twelve-Factor App” или «12-факторное приложение». Я предлагаю пройтись по нему и разобрать каждый пункт.

Данный текст — во многом пересказ и местами вольный перевод оригинального документа. Я в курсе, что “The Twelve-Factor App” уже был переведен на русский язык, но на мой взгляд перевод выполнен настолько плохо, что даже при низком уровне английского воспринимать оригинал проще. Кроме того, некоторые части оригинального текста я немного адаптировал под современные реалии.

Что такое 12-факторное приложение?

The Twelve-Factor App — это методология для построения приложений, которые:

  • Используют декларативный подход к автоматизации для минимизации временных и денежных затрат на онбординг новых членов команды;
  • Обладают явным контрактом взаимодействия с операционной системой, предоставляя тем самым максимальную портативность между средами выполнения;
  • Готовы к развёртыванию на современных облачных платформах, снижая или вовсе предотвращая потребность оперировать отдельными серверами;
  • Минимизируют различия между окружениями для разработки и продакшеном, позволяя для гибкости деплоить приложение непрерывно;
  • Масштабируются, не требуя значительных изменений в тулинге, архитектуре и культуре разработки.

Все эти аспекты мы разберём подробно ниже.

Данная методология применима к любому языку программирования и инфраструктурным сервисам, таким как СУБД, очереди и т.д.

1. Кодовая база

Код хранится в системе контроля версий

Начнём с совсем простых и очевидных вещей. Код вашей системы обязательно должен жить в репозитории в системе контроля версий, такой как Git, например. Да, можно довериться облакам, и это будет работать. Но только пока вы работаете один, не стремитесь отрезать осмысленные снапшоты кодовой базы, деплоите приложение руками (потому что никакая CI/CD-система не будет интегрироваться с облачными хранилищами), а также вам не страшна потеря всего вашего кода.

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

Перед тем как двигаться дальше необходимо определиться с терминами:

Кодовая база — это репозиторий с централизованным управлением (систем типа Subversion) или набор копий репозитория с общим началом (для децентрализованных систем, таких как Git). Также под кодовой базой можно понимать набор файлов с исходным кодом, который превращается в приложение или доставляемый артефакт на этапе сборки.

Одному приложению соответствует ровно одна кодовая база

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

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

Одна кодовая база на все окружения

Итак, мы определились с тем, что приложение может иметь только одну кодовую базу. Но при этом деплоев (развёрнутых и запущенных экземпляров приложения) может быть сколько угодно. Обычно это продакшен, т.е. публичный и основной инстанс, а также один или несколько инстансов для разработки и тестирования. Деплоем так же можно считать локально развернутое приложение.

Из текста выше следует, что все деплои одного и того же приложения так же имеют одну кодовую базу. Хотя при этом необязательно, чтобы это была одна и та же версия кодовой базы.

Чем может быть полезно данное ограничение? Так мы обеспечиваем валидность и состоятельность процесса тестирования и доставки артефакта. Если мы собрали одно, проектировали другое, доставили клиенту третье (это, например, происходит в случае пересборки Docker контейнеров при развертывании на разных окружениях), то ни о каких гарантиях качества, безопасности и соблюдения SLA приложения речь идти не может.

2. Зависимости

У большинства современных языков программирования есть инфраструктура для работы с зависимостями. Например:

ЯзыкМенеджер зависимостейРепозиторий
NodeJSNPMNPM
PythonpipPyPI
RustCargocrates.io
GoGo modulesGo proxy
ClojureLeiningenMaven Central

Часто менеджер зависимостей не ограничивает нас в том, как устанавливаются зависимости — для всей системы сразу (site packages) или для контретного проекта (vendored).

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

Здесь, конечно же, имеется ввиду, что разные кодовые базы используют разные копии зависимостей, даже если используется одна и та же версия.

Важно, чтобы выполнялись оба условия: зависимости и изолированы, и объявлены явно.

Одно из преимуществ такого подхода — это упрощает настройку локального окружения для нового разработчика, т.к. все зависимости объявлены явно и в одном месте, и расположены рядом с проектом. Всё что нужно настроить новому члену команды — это тулчейн для языка программирования.

Не стоит забывать и про системные утилиты, такие как, например, curl или git. Да, они с большой вероятностью будут присутствовать в том окружении, в котором разворачивается приложение. Но нет никакой гарантии, что так будет всегда и что версия, установленная на уровне системы будет совместима с вашим приложеним. Системные утилиты — это тоже зависимости. И если вашему приложению они нужны, то лучше поставлять приложение вместе с ними. Например, если приложение разворачивается как Docker-контейнер, то вы можете установить нужные версии программ на этапе сборки образа.

3. Конфигурация

Давайте называть конфигурацией всё то, что может меняться в зависимости от деплоя. Это могут быть параметры подключения к базе данных, различные хостнеймы, пути в файловой системе и т.п. Иногда можно встретить приложения, которые хранят конфигурацию частично или полностью в коде. Это плохая практика по нескольким причинам:

  • Противоречит пункту «Кодовая база», т.к. для разных окружений придётся иметь немного, но всё же разную кодовую базу;
  • Часто (а точнее рано или поздно) это приводит к проблемам с безопасностью — если кто-то посторонний увидит кодовую базу, он сможет заполучить доступ к чувствительным данным и прочим приложениям, в том числе инфраструктурным;
  • Усложняет развёртывание приложения в новых окружениях, т.к. для каждого нового деплоя скорее всего придётся изменять кодовую базу.

Если не хранить файлы конфигурации в репозитории, то где? Ведь иначе они будут разбредаться по куче разных мест, и потом можно не найти концов. Вместо файлов можно использовать другой способ хранения — переменные окружения. Они обладают целым рядом преимуществ:

  • Уже привязаны к окружению, в котором запущено приложение, т.е. к деплою, и нет необходимости заниматься их менеджментом в системе контроля версий или файловой системе;
  • Универсальный способ конфигурации, который поддерживается практически во всех языках программирования;
  • Риск попадания чувствительных данных в кодовую базу значительно снижается.

Важно отметить, что переменные окружения не являются серебряной пулей — проблемы с безопасностью никуда не денутся, но по крайней мере не будут такими серьёзными.

Также, даже если приложение использует переменные окружения для конфигурации, проблема масштабирования всё ещё присутствует, если задано всего несколько конфигураций для разных стендов. Например, dev, staging и prod. Конфигурация не должна быть жестко привязана к конкретному окружению. Вместо этого настроить новый инстанс приложения должно быть легко в любом окружении.

4. Внешние сервисы

Внешними сервисами можно считать всё, с чем приложение взаимодействует по сети: СУБД, брокеры/менеджеры очередей, API других приложений, внешние интеграции и т.д. Такие сервисы могут быть развернуты как внутри вашего кластера, так и находиться где-то ещё.

Для приложения не должно быть разницы, в каком контуре развёрнут внешний сервис. Например, мы должны иметь возможность с помощью конфигурации переключиться с инстанса PostgreSQL в нашем контуре на managed PostgreSQL от Amazon или Digital Ocean без изменения кодовой базы. Аналогично, приложение, запущенное локально на машине разработчика и использующее подключение к СУБД, развёрнутой на этой же машине, должно точно так же вести себя, будучи развёрнутым в любом другом окружении.

Ко внешним сервисам стоит относиться как к ресурсам, которые можно присоединять и отсоединять. Это в первую очередь полезно при сбоях: например, при выходе из строя основного инстанса СУБД, в теории можно без проблем переключиться на резервный.

5. Сборка, релиз и выполнение

Мы сформулировали понятия кодовая база и деплой. Но как первое превращается во второе? Для этого кодовой базе нужно пройти через три шага:

  1. Сборка. На этом этапе кодовая база приложения трансформируется в исполняемый артефакт в том или ином виде: бинарник для операционной системы, Docker-образ или что-то ещё.
  2. На этапе релиза исполняемый артефакт, полученный на прошлом этапе комбинируется с конфигурацией. В итоге мы имеем, собственно, релиз — то, что готово к развертыванию.
  3. Выполнение — непосредственно запуск релиза с соответствующей конфигурацией.

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

У каждого релиза должен быть свой собственный уникальный идентификатор. Это может быть тег в Git, точные дата и время создания релиза или даже просто порядковое число. Желательно, чтобы идентификаторы были упорядочены, чтобы уже по нему можно было понять, какой идентификатор у релиза до и после текущего.

Релизы — иммутабельная сущность, т.е. не должно быть возможности изменить содержимое релиза после того, как он был создан. Если это всё таки необходимо, создаётся новый релиз.

Этап сборки должен триггериться вручную разработчиками по необходимости. В случае с выполнением всё немного сложнее. С одной стороны приложение должно автоматически начать выполняться в случае перезагрузкки сервера или сбоя. С другой стороны, автоматически развернутая и запущенная по среди ночи новая версия приложения может сломаться, и кому-то придётся с этим разбираться.

6. Процессы без состояния

По возможности приложение не должно обладать внутренним состоянием — данные (или состояние) должны храниться в соответствующем внешнем сервисе — СУБД, кэше или очереди сообщений, но не в памяти приложения. Это делает возможным горизонтальное масштабирование приложения.

Конечно, есть ряд кейсов, в которых это невозможно частично или полностью. Отбросим сценарии, в которых вы разрабатываете СУБД или что-то похожее, потому как тогда хранение состояния — это прямое назначение подобного приложения. Представьте, что приложение скачивает по сети большой файл, затем обрабатывает его и сохраняет результат в базу данных. В данном случае у приложения есть состояние, но оно существует только внутри одной транзакции. После и во время обработки этого файла другие транзакции не имеют доступа к состоянию текущей.

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

7. Привязка портов

Сегодня часто приложения исполняются внутри контейнеров, не имея информации о том, что происходит на уровне хост-машины (т.е. окружения, где запущены контейнеры). В такой изоляции, собственно, и заключается одно из преимуществ использования контейнеров. Однако приложениям в кластере нужно договариваться о том, какой порт каждое приложение должно обслуживать. В разных окружениях приложения могут слушать разные порты, и мы не можем знать точно, какие именно.

Чтобы это не было проблемой, приложение должно выставлять наружу свой внутренний веб-сервер с привязкой портов. Например, приложение слушает HTTP-трафик по адресу 0.0.0.0:5000. При этом снаружи оно доступно по адресу localhost:6042. Это значит, что порты 6042 и 5000 связаны. В такой схеме приложению не нужно знать об окружении, в котором запущены контейнеры и о других приложениях. Скорее всего в вашем языке программирования уже есть всё необходимое для запуска встроенного веб-сервера — либо в стандартной библиотеке (как в Go), либо в качестве внешней библиотеки (например, Jetty для Java).

Кроме снижения связности, это позволяет подключать другие приложения в вашем кластере как [[#4. Внешние сервисы|внешние сервисы]]. Более того, HTTP — это лишь один из многих протоколов, с которым можно производить такие манипуляции.

8. Конкурентность

В 12-факторном приложении процессы — граждане первого класса (first class citizen). Они опираются на модель процессов в UNIX-системах. Используя эту модель, разработчик может спроектировать своё приложение так, чтобы оно могло обрабатывать различные типы нагрузки в зависимости от типа процесса. Например, HTTP-запросы могут обрабатываться процессом, играющего роль веб-сервера, а длительные фоновые задачи могут назначаться на процессы-воркеры.

Это не значит, что процессы не могут и не должны мультиплексировать запросы внутри одного экземпляра, как, например, при распределении нагрузки по потокам внутри JVM или при асинхронной модели NodeJS. Но в таком случае приложение может масштабироваться только вертикально, т.е. резервируя всё больше ресурсов. Должна быть также предусмотрена возможность горизонтального масштабирования, т.е. распределения нагрузки между несколькими инстансами приложения, которые могут исполняться даже на разных физических машинах.

9. Одноразовые процессы

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

Процессы должны стремиться к минимизации времени запуска. В идеале с момента запуска и до момента, когда приложение полностью готово выполнять свою работу должно пройти максимум несколько секунд. Короткое время запуска обеспечивает большую гибкость процесса релизов и масштабирования. Кроме того, это повышает надежность, поскольку при необходимости оркестратор процессов может легко переносить процессы на новые физические машины. Да, оптимизация времени запуска не всегда возможна напрямую. Для этого можно использовать различные хитрости и стратегии. Одна из таких стратегий — blue/green deployment, когда трафик постепенно переносится на инстансы новой версии приложения со старой.

Процессы должны корректно завершать работу при получении сигнала SIGTERM от оркестратора процессов. Для веб-приложения корректное завершение работы достигается путем прекращения работы HTTP-сервера, позволяя любым текущим запросам завершиться, а затем завершая работу самому. В этой модели подразумевается, что HTTP-запросы являются короткими, не более нескольких секунд. В случае, если необходимо держать постоянное соединение с клиентами, у клиента не должно возникать проблем с тем, чтобы быстро переподключиться к серверу.

Для воркера корректное завершение работы означает возврат текущего задания в рабочую очередь. Например, в RabbitMQ воркер для этого может отправить сигнал NACK.

Процессы также должны быть устойчивы к внезапному и резкому завершению работы, в случае сбоя в базовом оборудовании. Хотя это гораздо менее распространенное явление, чем корректное завершение работы с помощью SIGTERM, произойти это все же может. 12-факторное приложение — это приложение, спроектированное таким образом, чтобы обрабатывать неожиданные ситуации и аварийную остановку работы. Может показаться, что из этого утверждения следует, что приложение должно по-умному уметь реагировать на различные ситуации. Но здесь имеется ввиду обратное — приложение должно быть как можно более «тупым». Задачу по восстановлению работоспособности кластера стоит делегировать оркестратору приложений, такому как Kubernetes или Nomad.

10. Паритет окружений

Исторически сложилось так, что существовали заметные разрывы между окружением для разработки, в том числе локальном и продакшеном. Эти пробелы проявляются в трех аспектах:

  • Разрыв во времени. Разработчик может работать над кодом, на который уходят дни, недели или даже месяцы, прежде чем тот оказывается на продакшене;
  • Разрыв между сотрудниками. Разработчики пишут код, а развёртывают его операционные инженеры;
  • Разрыв в инструментарии. Разработчики могут использовать локально такие инструменты, как Nginx, SQLite и macOS, в то время как на проде Apache, MySQL и Linux.

12-факторное приложение спроектировано под непрерывный деплой, сохраняя минимальный разрыв между окружением для разработки и продакшеном.

Рассмотрим три пункта, описанных выше:

  • Сделайте временной разрыв небольшим: разработчик должен иметь возможность написать код и развернуть его через несколько часов или даже минут;
  • Сделайте человеческий разрыв небольшим: разработчики, написавшие код, плотно вовлечены в его деплой и наблюдают за его поведением в продакшене;
  • Уменьшите разрыв в инструментах: сделайте девелоперские и продакшен-окружения как можно более похожими.

Это можно представить в виде следующей таблицы:

«Традиционное» приложение12-факторное приложение
Время между деплоямиНеделиЧасы или минуты
Разработчики vs. операционные инженерыРазные людиОдни и те же люди
Окружения для разработки vs. продакшенОтличаютсяМаксимально похожи

Паритет окружений особенно важен для инфраструктурных сервисов, таких как СУБД, очереди и кэш. Существует множество библиотек для разных языков, которые упрощают взаимодействие с такими инфраструктурными сервисами. Их принято называть драйверами, а ещё они часто умеют взаимодействовать сразу с несколькими типами сервисов. Так, например, многие ORM умеют работать сразу и с PostgreSQL, и с MySQL и другими реляционными СУБД. Часто разработчики для удобства в локальной разработке используют легковесные альтернативы тех решений, что крутятся у них на продакшене. Популярный вариант такой пары — SQLite на локальной машине и PostgreSQL на стенде.

Разработчику стоит по возможности не использовать такие инструменты, даже если адаптеры теоретически абстрагируются от любых различий. В конце концов неизбежно возникают крошечные несовместимости, приводящие к тому, что код, который прошёл тесты и успешно собрался в окружениях для разработки, в итоге разваливается на продакшене. Хуже всего, если приложение собралось и запустилось, а ошибка возникла спустя долгое время (например, если возникла несовместимость между двумя реализациями SQL). Такие ошибки создают трения, которые препятствуют непрерывному деплою. Стоимость этих трений и последующего ослабления пайплайна чрезвычайно высока, если рассматривать их в совокупности на протяжении всего жизненного цикла приложения.

Сегодня уже нет такой необходимости использовать легковесные аналоги приложений на локальной машине разработчика. Современные инструменты, такие как PostgreSQL, RabbitMQ, Kafka, Redis и т.д. легко настраиваются локально, особенно если ранее они уже были кем-то настроены и описаны, например, в формате Docker Compose. Лучше потратить время на описание компоуза, чем тратить время на отладку приложения в продакшене.

Бывает, что у инфраструктуры приложения есть особенности, которые необходимо учитывать при разработке, но крайне тяжело воспроизвести в локальном окружении. В таком случае можно воспользоваться инструментарием для подключения локальной среды к кластеру. От форвардинга портов в kubectl до mirrord и telepresence.

11. Логи

Логи необходимы для большей ясности того, что происходит с приложением на сервере.

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

12-факторное приложение никогда не занимается маршрутизацией или хранением логов. Оно не должно пытаться записывать логи в файлы или управлять ими. Вместо этого каждый запущенный процесс записывает свой поток событий напрямую в stdout. Локально разработчик может просматривать этот поток в своём терминале, чтобы наблюдать за поведением приложения.

На всех стендах, включая продакшен всё, что попадает в stdout, должно перехватываться, агрегироваться и архивироваться. Эти задачи выполняются не приложением, а специальными системами для работы с логами, такими как Kibana или Loki. Эти системы обеспечивают большую мощность и гибкость для анализа поведения приложения, в том числе:

  • Поиск конкретных событий в прошлом;
  • Построение графиков трендов (например, запросов в минуту);
  • Алёртинг.

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

12. Инструменты администрирования

Кроме непосредственно запуска своих сервисов разработчикам часто приходится выполнять простые одноразовые действия, связанные с администрированием и обслуживанием приложения, такие как:

  • Применение миграций;
  • Запуск REPL или интерактивной консоли для выполнения произвольного кода и отладки;
  • Другие заранее заданные команды для манипуляций над данными: перечитка очереди, создание пользователей, удаление старых записей и т.д.

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

Ещё лучше, если у вашего языка есть REPL — так вы сможете обернуть всю нужную административную функциональность в примитивы языка и использовать интерактивно.

Полезные ссылки