Rust и контейнеры в продакшене

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

Контейнеризация это инструмент

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

Но на самом деле главное, чего мы хотели добиться от контейнеров, это правильный процесс тестирования, и выкатки. А именно:

  1. Развёртывание должно происходить с помощью CI
  2. Образ системы на тестовой машине должен быть такой же как и production
  3. Временные ошибки в процессе выкатки не должны ломать живую систему

Процесс вкратце

Используемые инструменты:

  1. Git
  2. Gitlab + Gitlab-CI -- удобно интегрирует хостинг кода, review и CI
  3. Vagga для контейнеризации в режиме разработки и сборки образов
  4. Ansible (+ rsync) для доставки образов и конфигурации на сервера
  5. Lithos для запуска контейнеров на боевой системе

Далее я поясню почему эти инструменты были выбраны или написаны нами. О том как подготавливается deploy:

  • У нас есть две staging-системы. Исторически одна из них называется trunk другая -- stable.
  • Каждый комит в git выкатывается на trunk
  • Тег типа v1.0.0 выкатывается на stable после отработавших тестов
  • Тег типа v1.0.0-release выкатывается на боевую систему

Сам deploy на staging и production выглядит следующим образом:

  1. Vagga на Gitlab CI собирает контейнер
  2. Gitlab CI запускает ansible который делает синтхронизацию образов на целевые сервера
  3. После доставки контента, постепенно подменяется конфигурация lithos'а (имена образов которые запускать), и шлётся ему сигнал.

Инструменты

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

Git

Не смотря на то, что исторически в компании у нас используется больше mercurial чем git. Для проектов с автоматическим deploy'ем мы стараемся использовать git. В этом также играет роль gitlab, но об этом позже.

Главное что, удобно в git'е для выкатки это теги. Мы используем 2 вида тегов:

  1. Аннотированные теги (git tag -a) для обозначения версий
  2. Не аннотированные теги (без -a), которые используются чтобы подсказать
    CI какой скрипт развёртывания выполнять

Аннотированные теги в git'е имеют описание, что служит описанием релиза. Также аннотированные теги используются командой git describe. Таким образом эта команда показывает правильную, человеко-понятную версию приложения.

Неаннотированные теги это просто комиты имеющие имя. Они не учитываются в git describe и достаточно легковесны. Фактически мы ими обозначаем стадии выкатки. Они создаются с той же ревизией, что и аннотированный тег с версией. Примерно так:

git tag v1.0.0-deploy v1.0.0

Чем плох mercurial? Дело в том, что теги в mercurial'е записываются в файл .hgtags в репозитории. Соответственно, чтобы добавить тег вам нужно сделать комит. Т.е. если вам нужно сделать тег для комита 101:deadbeef01, вы делаете другой комит (скажем 102:abcdef6789), и он становится последним в ветке. Сответственно CI обновляя ветку получает последнюю версию (102) и на ней нет тега (т.к. это обычный комит, который добавляет тег на 101), cоответственно он её не выкатывает. Тaкже вы можете получить конфликт, когда будете публиковать эту ветку, и будете вынуждены делать беcполезный merge. Альтернативы, вроде "запомнить ревизию, переключиться в ветку с тегами и сделать commit там" работают но усложняют весь процесс. Также в git'е можно контролировать, кому можно создавать (а точнее публиковать) теги.

Еще что полезно знать при использовании тегов в git'е:

  1. Нужно не забывать обновлять ветку, в которой делаете релиз
  2. Лучше не использовать git push --tags он отправляет все теги, иногда они
    в удалённом репозитории меняются (у тегов есть много применений, многие из них вполне допускают force push)
  3. Иногда git умудряется опубликовать тег, но не комит, на который он
    ссылается

По-этому правильный способ публикации тега, примерно такой::

git push origin stable v1.0.0

Т.е. вы указываете ветку в которой этот тег создан (stable в примере), и сам тег.

Еще многие делают release-cкрипты, которые делают что-то вроде этого::

git pull
git checkout stable
git merge master
./build.sh
git add ./static
git commit -m "Built static data"
git tag $TAG
git push
git push --tags
git checkout master

Кроме упомянутой выше неправильной работы с тегами, и того что тут собранная статика добавляется в git (что лучше делать с помощью CI). Главная проблема здесь, это отлавливание ошибок. Первое что тут не сделано, это не проверена текущая ветка. Но важнее, что будет если одна из строчек сломается? (а тут много мест где это может быть, это и сетевые pull/push, и merge и сама сборка). Я видел такие скрипты даже без set -e или bash -e. Но даже если вы не забыли про этот костыль, проблема обработки ошибок настолько запутана в shell'е, что почти никто этого не делает правильно.

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

По-этому нужно чтобы процесс сборки был настолько прост, что вам не нужен для этого скрипт::

git tag -a v1.0.0
git push origin master v1.0.0

Переключиться в правильную ветку, сделать pull можно и самому. При этом важно удостовериться что вы выкатываете правильную версию, и 5 секунд на обдумывание команд не усложняет процесс. Важнее то, что каждый раз вы знаете что делаете, и знаете как отменить то, что начали.

Альтернативно, можно выкатывать ветку. Например иметь ветки staging и prod. Этот способ может приемлемо работать и в mercurial'е. Но тут могут возникнуть конфликты при сливании веток (а git merge -s ours в mercurial'е сложен). И опять же нет простого способа удостовериться что код в ветке staging и prod одинаковый так как и там и там последний комит вероятно будет merge'ем.

Gitlab

В целом в этой цепочке обновления gitlab является достаточно не интересной частью. Про хостинг кода я ничего не буду говорить. А вот про Gitlab CI мне есть что сказать.

Дело в том, что традиционные CI-системы, вроде buildbot'а хранят конфигурацию в самом мастер-процессе. Будь это python-файл как в buildbot'е или web-интерфейс как в teamcity, всё-равно это не удобно.

В gitlab вы добавляете .gitlab-ci.yml в репозиторий, и получаете любой необходимый цикл сборки. Вначале вы добавляете туда тесты. Затем сборку статики. Позже выкладку на staging-сервера. И, наконец, развёртывание на боевые сервера. Примерный скелет нашего .gitlab-ci.yml:

    stages:
    - containers
    - check
    - deploy
    - cleanup

    dev_container: {stage: containers, script: ...}
    redis_container: {stage: containers, script: ...}
    publish_docs: {stage: deploy, only: [master], script: ...}
    deploy_to_stable: {stage: deploy, only: [tags@group/project], script: ...}
    deploy_to_prod: {stage: deploy, only: [ /^v\d+\.\d+\.\d+-release$/ ] ...}
    deploy_to_trunk: {stage: deploy, only: [master], script: ... }

Как устроены теги и порядок выкладки описан выше. Из очевидных плюсов здесь:

  1. Все видят как происходит тестирование, сборка документации и выкатывание
    релизов (естественно никаких паролей там не видно)
  2. Логи всех стадий видны
  3. Любой разработчик может что-то поправить, например, добавить зависимость в
    сборку документации можно свободно. Даже возможно временно заставить выкладывать на staging не master, а другую ветку, чтобы что-то протестировать.
  4. Все изменения процесса сборки версионируются

Еще, что важно в использовании Gitlab'а, это то, что любой разработчик может создать новый проект, и подключить любые необходимые этапы сборки проекта не прибегая к помощи системных администраторов.

Мы также настроили отдачу поддоменов *.gitlab.uaprom (.uaprom -- наш внутренний домен) так чтобы проект мог опубликовать статические файлы с помощью простой команды в процессе сборки:

    script:
    - make html  # sphinx Makefile
    - gitlab-publish _build/html doc.myproject.gitlab.uaprom

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

Конечно чтобы сборка была наcтолько удобной на Gitlab CI, нам нужна контейнеризация. И о ней мы поговорим в следующем разделе.

Для ясности: Я не утверждаю что Gitlab CI уникален всеми этими штуками. Здесь просто описаны инструменты которые у нас прижились. Комбинация Github + Travis CI, например, будет работать примерно так же хорошо.

Vagga

Vagga это инструмент для создания рабочих окружений. Особенности vagga которые для нас оказались полезными:

  1. Имеет свою систему описания контейнеров в yaml-файле
    (аналог Dockerfile'а, но немного гибче)
  2. Не требует root'овый доступ к системе (использует user namespaces)
  3. Хранит образы системы в подпапке .vagga проекта
  4. Запускает контейнеры из-под вашего shell'а и не имеет демонизированных
    процессов (в отличие от docker'а)

Почему это важно? Это позволяет минимизировать конфигурацию CI-системы. Например, чтобы запустить тесты в .gitlab-ci.yaml мы просто пишем vagga test. Vagga умеет сама установить зависимости, запустить все нужные
контейнеры (ну например запустить redis для тестового процесса или запустить сервер приложения, для selenium-тестов). При этом vagga следит за тем чтобы процессы обязательно убились в конце тестов и умеет стирать старые контейнеры за собой, оставляя лишь свежие для следующего теста.

Тут стоит упомянуть, что gitlab вроде бы имеет поддержку docker'а из коробки. Но проблема этой конфигурации заключается в том, что вы должны указать конфигурации gitlab-ci-runner'а какие контейнеры он может использовать. Таким образом вам нужно либо использовать отдельный runner на каждый проект либо устанавливать все нужные зависимости при каждой сборке. И то и другое нам показалось неудобным.

Ansible

В целом роль ansible в этом процессе сведена к минимуму. Mы используем "железные" сервера. И ansible хорош для установки минимально небходимого набора програм и конфигурационных файлов для контейнеризации.

Также у нас сейчас ansible используется для доставки имиджей контейнеров с CI-системы на конечные машины. Внутри ansible использует старый добрый
rsync, который может копировать только изменённые файлы.

То что я называю image или образ в данной статье, очень похоже на образ виртуальной машины или файловой системы контейнера. Но на самом деле мы подготавливаем контейнер как обыкновенную папку в которой лежат все файлы "системы". Каждый новый deploy это новая папка. На первый взгляд такой подход кажется избыточным. Но rsync умеет одинаковые файлы не копировать, а связывать hardlink'ами. Это экономит и траффик и место на диске. В итоге такая система хранения получается эффективнее чем слои в docker'е, потому что "связываются" также и файлы внутри того что бы было одним слоем в docker'е.

Lithos

Итак lithos это наша собственная контейнеризация. Но в отличие от других решений, он во много раз проще. Фактически lithos это менеджер процессов, c немного расширеными возможностями настройки окружения. Кратко его функции:

  1. Запускать процессы и перезапускать в случае падения
  2. Настраивать окружение процесса при запуске, включая: переменые окружения,
    пользователя, лимиты процесса, настройки cgroup и прочее
  3. Монтировать файловые системы (по умолчанию всё только для чтения)
  4. Являться pid 1 в каждом контейнере (позволяет избегать некоторых
    проблем)

Также уточним чем не занимается lithos:

  1. Не загружает образы контейнеров (для этого есть rsync/ansible)
  2. Не занимается сборкой контейнеров
  3. Ничего не делает с логами (есть достаточно других инструментов для этого)

Почему это важно? Потому что это позволяет lithos'у быть гораздо более надёжным инструментом для запуска на production серверах.

Для сравнения, вот некоторые из проблем, с которыми мы столкнулись запуская приложения в docker'е (справедливости ради, это было более года назад):

  1. Docker писал логи в память и не освобождал её (съедая всю память в итоге)
  2. Иногда docker не размонитировал файловую систему, и не мог перезагрузить
    контейнер. Приходилось вмешиваться вручную (часто перезагружать весь docker daemon).
  3. При загрузке image'ей, и сильно загруженном жестком диске все запросы в
    docker отваливались по таймауту

Первые 2 проблемы были давно починены. 3-ю почти невозможно починить архитектурно. С другой стороны вот список проблем, которые возникали у известных нам компаний за последний месяц: 7015, 17083, 9665, 14203.

По-этому, мы решили сделать контейнеризацию в которой большинство из этих проблем попросту не возможны. Фактически lithos, после чтения конфигов находится в цикле из sigtimedwait -> fork -> exec. Это позволяет, кроме всего прочего еще и перезапускать lithos без перезагрузки контейнеров, в частности для обновления его версии.

Также он написан на Rust'е, что удобно по следующим причинам:

  1. Собирается запускаемый файл, зависящий только от libc
  2. Просто обращаться к системным вызовам
  3. Rust заставляет обрабатывать все возможные коды ошибок,
    т.к. в языке нет exception'ов
  4. Программист имеет полный контроль над выделением памяти

Для такого базового, инфраструктурного проекта, это важные особенности.

Не смотря на кажущийся безумный объем работ, который проделали разработчики docker'а, сделать свою контейнеризацию для linux, было не сильно сложно. Lithos
содержит меньше 5 тыс. строк кода. Также lithos больше соответствует философии unix, где каждый иструмент выполняет только одну задачу и делает это хорошо.

Итоги

На самом деле это не совсем первый проект, который мы запускаем таким образом. lithos находится в рабочем состоянии уже примерно год. До этого мы запускали им внутрение проекты. И есть еще несколько backend-less проектов с подобным процессом разработки.

Это только начало. Поверх lithos'а мы хотим сделать полноценную систему выкладки и оркестрирования контейнеров. Все иструменты, которые мы используем open-source, включая наши разработки.

Paul Colomiets

Read more posts by this author.