Эта статья содержит краткую выжимку из моего собственного опыта и опыта моих коллег, с которыми мне днями и ночами доводилось разгребать инциденты. И многих инцидентов не возникло бы никогда, если бы всеми любимые микросервисы были написаны хотя бы немного аккуратнее.
К сожалению, некоторые невысокие программисты всерьёз полагают, что Dockerfile с какой-нибудь вообще любой командой внутри — это уже сам по себе микросервис и его можно деплоить хоть сейчас. Докеры крутятся, лавешка мутится. Такой подход оборачивается проблемами начиная с падения производительности, невозможностью отладки и отказами обслуживания и заканчивая кошмарным сном под названием Data Inconsistency.
Если вы ощущаете, что пришло время запустить ещё одну аппку в Kubernetes/ECS/whatever, то мне есть чем вам возразить.
English version is also available.
Я сформировал для себя некий набор критериев оценки готовности приложений к запуску в продакшн. Некоторые пункты этого чек-листа не могут быть применены ко всем приложениям, а только к особенным. Иные в целом применимы ко всему. Уверен, вы сможете добавить свои варианты в комментариях или оспорить какие-то из этих пунктов.
Если ваш микро-сервис не соответствует хотя бы одному из критериев, я не допущу его в свой идеальный кластер, построенный в бункере в 2000 метрах под землёй с подогревом полов и замкнутой самодостаточной системой подачи интернета.
Поехали....
Примечание: порядок пунктов не имеет никакого значения. Во всяком случае, для меня.
Короткое описание в Readme
Содержит короткое описание себя в самом начале Readme.md в своём репозитории.
Боже, это кажется так просто. Но как же часто я сталкивался, что репозиторий не содержит ни малейшего объяснения зачем он нужен, какие задачи решает и так далее. Про что-то более сложное вроде вариантов конфигурации и говорить не приходится.
Интеграция с системой мониторинга
Шлёт метрики в DataDog, NewRelic, Prometheus и так далее.
Анализ потребления ресурсов, утечек памяти, stacktraces, взаимозависимости сервисов, частота ошибок — без понимания всего этого (и не только) крайне сложно контролировать что происходит в большом распределённом приложении.
Оповещения настроены
Для сервиса включены оповещения (alerts) которые покрывают все стандартные ситуации плюс известные уникальные ситуации.
Метрики это хорошо, но следить за ними никто не будет. Поэтому автоматически получаем звонки/пуши/смс если:
- Потребление CPU/памяти резко возросло.
- Трафик резко возрос / упал.
- Количество обрабатываемых транзакций в секунду резко изменилось в любую сторону.
- Размер артефакта после сборки резко изменился (exe, app, jar, ...).
- Процент ошибок или их частота превысила допустимый порог.
- Сервис перестал слать метрики (часто пропускаемая ситуация).
- Регулярность определённых ожидаемых событий нарушена (cron job-а не срабатывает, не все ивенты обрабатываются etc.)
- ...
Runbooks созданы
Для сервиса создан документ, описывающий известные или ожидаемые нештатные ситуации.
- как убедиться, что ошибка внутренняя и не зависит от third-party;
- если зависит, куда, кому и что писать;
- как его безопасно перезапустить;
- как восстановить из бекапа и где лежат бекапы;
- какие специальные dashboards/queries созданы для мониторинга этого сервиса;
- есть ли у сервиса своя админ-панель и как туда попасть;
- есть ли API / CLI и как пользоваться для исправления известных проблем;
- и так далее.
Список может сильно отличаться в разных организациях, но хотя бы базовые вещи там должны быть.
Все логи пишутся в STDOUT/STDERR
Сервис не создаёт никаких файлов с логами в режиме работы в production, не отправляет их в какие-либо внешние сервисы, не содержит никаких избыточных абстракций для log rotation и т.п.
Когда приложение создаёт файлы с логами — эти логи бесполезны. Вы не будете заходить в 5 запущенных параллельно контейнеров, в надежде поймать нужную ошибку (а вот и будете, плачет...). Перезапуск контейнера приведёт к полной потере этих журналов.
Если приложение пишет логи самостоятельно в стороннюю систему, например в Logstash — это создаёт бесполезную избыточность. Соседний сервис не умеет этого делать, т.к. у него другой фреймворк? Вы получаете зоопарк.
Приложение пишет часть логов в файлы, а часть в stdout потому, что разработчику удобно видеть INFO в консоли, а DEBUG в файлах? Это вообще худший вариант. Никому не нужные сложности и совершенно лишний код и конфигурации, которые нужно знать и поддерживать.
Логи — это Json
Каждая строчка лога написана в формате Json и содержит согласованный набор полей
До сих пор практически все пишут логи в plain text. Это настоящая катастрофа. Я был бы счастлив никогда не знать про Grok Patterns. Они мне снятся иногда и я замерзаю, стараясь не шевелиться, чтобы не привлечь их внимание. Просто попробуйте однажды распарсить исключения Java в логах.
Json — это благо, это огонь, подаренный с небес. Просто добавьте туда:
- timestamp с миллисекундами согласно RFC 3339;
- level: info, warning, error, debug
- user_id;
- app_name,
- и другие поля.
Загрузите в любую подходящую систему (правильно настроенный ElasticSearch, например) и наслаждайтесь. Соедините логи множества микросервисов и снова почувствуйте чем были хороши монолитные приложения.
(А ещё можно добавить Request-Id и получить tracing...)
Логи с уровнями verbosity
Приложение должно поддерживать переменную окружения, например LOG_LEVEL, с как минимум двумя режимами работы: ERRORS и DEBUG.
Желательно, чтобы все сервисы в одной экосистеме поддерживали одну и ту же переменную окружения. Не опция конфига, не опция в командной строке (хотя это оборачиваемо, конечно), а сразу by default из окружения. Вы должны иметь возможность получить как можно больше логов, если что-то идёт не так и как можно меньше логов, если всё хорошо.
Фиксированные версии зависимостей
Зависимости для пакетных менеджеров указаны фиксированно, включая минорные версии (Например, cool_framework=2.5.3).
Об этом много где уже говорилось, конечно. Некоторые фиксируют зависимости на мажорных версиях, надеясь, что в минорных будут только bug fixes и security fixes. Это неправильно.
Каждое изменение каждой зависимости должно быть отражено отдельным коммитом. Чтобы его можно было отменить в случае проблем. Тяжело контролировать руками? Есть полезные роботы, вроде этого, которые проследят за обновлениями и создадут вам Pull Requests на каждое из них.
Dockerized
Репозиторий содержит production-ready Dockerfile и docker-compose.yml
Докер давно стал стандартом для многих компаний. Бывают исключения, но даже если в production у вас не Docker, то любой инженер должен иметь возможность просто выполнить docker-compose up и ни о чём больше не думать, чтобы получить дев-сборку для локальной проверки. А системный администратор должен иметь уже выверенную разработчиками сборку с нужными версиями библиотек, утилит и так далее, в которой приложение хотя бы как-то работает, чтобы адаптировать её под production.
Конфигурация через окружение
Все важные опции конфигурации читаются из окружения и окружение имеет приоритет выше чем у конфигурационных файлов (но ниже чем у аргументов командной строки при запуске).
Никто и никогда не захочет читать ваши файлы конфигурации и изучать их формат. Просто примите это.
Детальнее тут: https://12factor.net/config
Readiness and Liveness probes
Содержит соответствующие endpoints или команды cli для проверки готовности обслуживать запросы при старте и работоспособности в течение жизни.
Если приложение обслуживает HTTP запросы, оно должно по-умолчанию иметь два интерфейса:
Для проверки, что приложение живое и не зависло, используется Liveness-проба. Если приложение не отвечает, оно может быть автоматически остановлено оркестраторами вроде Kubernetes, "но это не точно". На самом деле убийство зависшего приложения может вызвать эффект домино и насовсем положить ваш сервис. Но это не проблема разработчика, просто сделайте этот endpoint.
Для проверки, что приложение не просто запустилось, но уже готово принимать запросы, выполняется Readiness-проба. Если приложение установило соединение с базой данных, системой очередей и так далее, оно должно ответить статусом от 200 до 400 (для Kubernetes).
Ограничения ресурсов
Содержит лимиты потребления памяти, CPU, дискового пространства и любых других доступных ресурсов в согласованном формате.
Конкретная реализация этого пункта будет очень разной в разных организациях и для разных оркестраторов. Однако эти лимиты обязаны быть заданы в едином формате для всех сервисов, быть разными для разных окружений (prod, dev, test, ...) и находиться вне репозитория с кодом приложения.
Сборка и доставка автоматизирована
CI/CD-система, используемая в вашей организации или проекте, сконфигурирована и может доставить приложение на нужное окружение согласно принятому рабочему процессу (workflow).
Никогда и ничего не доставляется в production вручную.
Как бы ни было сложно автоматизировать сборку и доставку вашего проекта, это должно быть сделано до того, как этот проект попадёт в production. Этот пункт включает сборку и запуск Ansible/Chef cookbooks/Salt/..., сборку приложений для мобильных устройств, сборку форка операционной системы, сборку образов виртуальных машин, что угодно.
Не можете автоматизировать? Значит вам нельзя это запускать в мир. После вас уже никто это не соберёт.
Graceful shutdown – корректное выключение
Приложение умеет обрабатывать SIGTERM и другие сигналы и планомерно прерывать свою работу после окончания обработки текущей задачи.
Это крайне важный пункт. Docker-процессы становятся осиротевшими и месяцами работают в фоне, где их никто не видит. Нетранзакционные операции обрываются в середине выполнения, создавая несогласованность данных между сервисами и в базах данных. Это приводит к ошибкам, которые невозможно предусмотреть и может стоить очень, очень дорого.
Если вы не контролируете какие-то зависимости и не можете гарантировать, что ваш код корректно обработает SIGTERM, воспользуйтесь чем-то вроде dumb-init.
Больше информации здесь:
- https://12factor.net/disposability
- https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods
Соединение с базой данных регулярно проверяется
Приложение постоянно пингует базу данных и автоматически реагирует на исключение "потеря соединения" при любых запросах, пытаясь восстановить его самостоятельно или корректно завершает свою работу
Я видел множество случаев (это не просто оборот речи) когда сервисы, созданные для обработки очередей или событий теряли соединение по таймауту и начинали бесконечно сыпать ошибки в логи, возвращая сообщения в очереди, отправляя их в Dead Letter Queue или попросту не выполняя свою работу.
Масштабируется горизонтально
При росте нагрузки достаточно запустить больше экземпляров приложения, чтобы обеспечить обработку всех запросов или задач.
Далеко не все приложения могут масштабироваться горизонтально. Ярким примером могут служить Kafka Consumers. Это не обязательно плохо, но если конкретное приложение не может быть запущено дважды, об этом нужно знать заранее всем заинтересованным лицам. Эта информация должна быть бельмом в глазу, висеть в Readme и везде, где только можно. Некоторые приложения вообще нельзя запускать параллельно ни при каком обстоятельстве, что создаёт серьёзные трудности в его поддержке.
Гораздо лучше если приложение само контролирует эти ситуации или для него написана обёртка, которая эффективно следит за "конкурентами" и просто не даёт процессу запуститься или начать работу пока другой процесс не завершит свою или пока какая-то внешняя конфигурация не позволит работать N процессам одновременно.
Dead letter queues и устойчивость к "плохим" сообщениям
Если сервис слушает очереди или реагирует на события, изменение формата или содержимого сообщений не приводит к его падению. Неудачные попытки обработать задачу повторяются N раз, после чего сообщение отправляется в Dead Letter Queue.
Множество раз я видел бесконечно перезапускаемые consumers и очереди, раздувшиеся до таких размеров, что их последующая обработка занимала много дней. Любой слушатель очереди должен быть готовым к изменению формата, к случайным ошибкам в самом сообщении (типизация данных в json, например) или же при его обработке дочерним кодом. Я даже сталкивался с ситуацией, когда стандартная библиотека по работе с RabbitMQ для одного крайне популярного фреймворка вообще не поддерживала retries, счётчики попыток и т.п.
Ещё хуже, когда сообщение просто уничтожается в случае неудачи.
Ограничение на количество обрабатываемых сообщений и задач одним процессом
Поддерживает переменную окружения, которой можно принудительно ограничить максимальное количество обрабатываемых задач, после которого сервис корректно завершит работу.
Всё течёт, всё меняется, особенно память. Непрерывно растущий график потребления памяти и OOM Killed в итоге — это норма жизни современных кубернетических разумов. Имплементация примитивной проверки, которая бы просто избавила вас даже от самой необходимости исследовать все эти утечки памяти сделала бы жизнь легче. Не раз видел, как люди тратят уйму сил и времени (и денег), чтобы приостановить эту текучесть, но нет никаких гарантий, что следующий коммит вашего коллеги не сделает всё ещё хуже. Если приложение может выживать неделю — это прекрасный показатель. Пусть потом оно просто само завершится и будет перезапущено. Это лучше чем SIGKILL (про SIGTERM см. выше) или исключение "out of memory". На пару десятков лет вам этой затычки хватит.
Не использует third-party интеграции с фильтрацией по IP адресам
Если приложение делает запросы к стороннему сервису, который допускает обращения с ограниченных IP-адресов, сервис выполняет эти обращения опосредованно через обратный прокси.
Это редкий случай, но крайне неприятный. Очень неудобно когда один крошечный сервис блокирует возможность смены кластера или переезда в другой регион всей инфраструктуре. Если необходимо общаться с кем-то, кто не умеет в oAuth или VPN, заранее настройте reverse proxy. Не реализуйте в своей программе динамическое добавление/удаление подобных внешних интеграций, так как делая это вы гвоздями прибиваете себя к единственной доступной среде выполнения. Лучше сразу автоматизируйте эти процессы для управления конфигами Nginx, а в своём приложении обращайтесь на него.
Очевидный HTTP User-agent
Сервис подменяет заголовок User-agent на кастомизированный для всех запросов к любым API и этот заголовок содержит достаточно информации о самом сервисе и его версии.
Когда у вас 100 разных приложений общаются друг с другом, можно сойти с ума, видя в логах что-то вроде "Go-http-client/1.1" и динамический IP-адрес контейнера Kubernetes. Всегда идентифицируйте своё приложение и его версию явным образом.
Не нарушает лицензии
Не содержит зависимости, чрезмерно ограничивающие применение, не является копией чужого кода и так далее.
Это самоочевидный кейс, но доводилось такое видеть, что даже юрист, писавший NDA, сейчас икает.
Не использует неподдерживаемые зависимости
При первом запуске сервиса в него не включены зависимости, которые уже устарели.
Если библиотека, которую вы взяли в проект, больше никем не поддерживается — ищите другой способ достичь цели или занимайтесь развитием самой библиотеки.
Заключение
В моём списке есть ещё несколько крайне специфичных проверок для конкретных технологий или ситуаций, а что-то я просто забыл добавить. Уверен, вы тоже найдёте что вспомнить.