Собираем релиз за минуту

C чего мы начинали

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

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

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

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

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

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

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

Около 10-20 лет назад релизы обновлений коробочного софта делались примерно раз в полтора года; считалось, что чаще это делать не стоит. Сейчас такой подход устарел, и все поголовно от него отказываются. Выпуск объёмного обновления раз в полтора года - это очень тяжёлая, трудоёмкая и болезненная задача как для вендора, так и для клиента. Надо понимать, что каждое такое обновление софта - это фактически его внедрение заново. Поэтому с распространением интернета и увеличением подключенных к нему компьютеров, разработчики уже давно делают релизы более плавно и более часто. Мы совершили переход от просто сборки пакета к фактическому релизу за счёт гигантского корпуса тестов, за счёт чего у нас релизы могут делаться несколько раз в день, и каждый день мы собираем так называемые nightly builds, промежуточные релизы, которые не менее стабильны, чем публичные ежемесячные, которые в итоге и попадают к клиенту.

Очевидно, что собирать релизы вручную мы каждый день не можем. Мы внедрили у себя Gitlab CI - опенсорсный продукт, который позволяет нам на каждый коммит и каждую ветку запускать скрипты на сервере и полностью, с нуля, каждый раз собирать новую версию нашего Flussonic. Использование Docker очень сильно облегчило нам задачу и принесло много радости в этом процессе.

docker run или docker build?

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

Есть docker run - это команда для запуска контейнера. Именно она подразумевается чаще всего, когда разработчики говорят “у нас всё в Docker”. В рамках сборки мы этой командой не пользуемся вообще, хотя частенько некоторые девелоперы пытаются собирать именно через docker run и монтируемые volume.

Есть docker build - это команда для сбора образа, который потом, по идее, должен запускаться с помощью docker run. По сути, это makefile, но более надёжный и стабильный. Нам он даёт изоляцию окружения. Мы в CI вообще не используем docker run, и все образы, которые мы собираем, фактически вообще с самого начала не предназначены для запуска. Есть частый паттерн использования docker run - подсовывать ему локальную систему для монтирования образов. Если мы захотим параллельно собирать две ветки, то легко может получиться, что на одних файлах работает несколько систем сборки. Плюс к этому надо уметь очищать старые результаты предыдущих прогонов, иначе будет жуткая каша. Мы фактически запускаем две программы, которые работают с одними и теми же данными и неизменно начинают их модифицировать. Нам этого не надо, мы хотим иметь возможность гонять параллельно сборки и тесты, именно поэтому мы используем только docker build. Для этого приходится иногда переписывать старые скрипты сборки, потому что они больше не работают. По сути речь идет о переезде с классических систем сборки на ещё одну, но с гораздо большей степенью изоляции. Похожие системы создавались для сборки дистрибутивов и были необычайно хрупкими. Docker build можно воспринимать именно как виртуализированный makefile с шикарным кэшированием.

Сборка в изоляции при помощи docker build позволяет защитить процесс компиляции от старых, ненужных или чужих данных, которые мешают сборке и могут вызвать ошибки.

Как мы подружились с docker build

Кэширование шагов докера - важнейшая функция, без которой всё было бы бессмысленно. Раз у нас больше нет способа никуда подложить результат сборки, получается что никак нельзя не пересобирать то, что уже собрано? Нет, docker build агрессивно кэширует шаги. Если результат предыдущего шага не менялся и не менялась команда на текущем шаге, то результат тоже закэшируется.

С docker build, само собой, ещё нужно научиться работать: нужно смотреть логи, учитывать, сколько команд и шагов по сборке прописано, каковым будет объём shell скриптов, которые мы вызываем. Эту грань надо аккуратно подобрать. У нас может остаться от старого процесса здоровенный shell скрипт, который мы вызываем внутри docker и который компилирует что-то минут двадцать, а потом в конце делает какую-то операцию, которую мы в итоге хотим поменять. В такой ситуации мы сбрасываем кэш и заново запускаем процесс компиляции. Чтобы не выбрасывать 20 минут сборки, стоит разбить этот скрипт на части, но тогда где остановиться? Мы пришли к тому, что те операции, которые занимают у нас явно много времени и явно редко меняются, выносятся отдельно и повыше в докерфайле.

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

В процессе нашей сборки есть обязательный шаг - компиляция всех зависимостей нашего софта. Все зависимости размером примерно в гигабайт у нас компилируются и загружаются в репозиторий за 4 секунды. Docker пробегает по всем шагам, видит, что вводные не изменились - значит, перекомпилировать ничего не нужно и можно перейти к загрузке. Дальше начинается наша фирменная хитрость. У нас самописная система загрузки пакетов в deb/rpm репозиторий, которая умеет не загружать пакет на сервер, если пакет там уже есть.

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

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

Гигиена работы с ветками

При работе с docker build часто приходится использовать многостадийную работу с образами и копировать данные из образа в образ напрямую, минуя выгрузку наружу и загрузку через контекст. Это бережет минуты времени при сборке. Разделение длинной сборки на шаги может помочь распараллелить её: одновременно прогонять тесты и собирать пакеты может быть удобно.

Работая с ветками и множественными образами, мы столкнулись с проблемой указания родительских образов в докерфайлах. Там естественным образом рождается что-то вида: FROM flussonic-source

Мы пришли к чёткому пониманию, что так делать нельзя и надо использовать не фиксированные имена, а контекстно зависимые. Прежде всего речь идет о ветке в которой собирается всё. Надо через аргументы сборки (build-arg) указывать полное имя родительских образов для зависимости, включающее имя ветки:

docker build –build-arg SOURCE_IMAGE=flussonic-src:${CI_COMMIT_REF_SLUG}
ARG SOURCE_IMAGE
FROM ${SOURCE_IMAGE}

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

Тестируем

Мы можем спокойно собирать ветки, мы можем в них экспериментировать, не опасаясь, что что-то сломается в мастере. Не опасаемся мы за счёт того, что прогоняем много тестов, и как раз в них мы используем docker run. При этом мы берём контейнер, который не оставит следов, артефактов, которые могли бы привести к проблеме. Почему прогон тестов в докере важен и на этом особо акцентируется внимание? Дело в том, что докер помимо изоляции по диску дает ещё и изоляцию по сети. То есть, если мы в двух тестах написали «слушай порт 8080», то эти два теста параллельно в одном пространстве портов уже выполнять нельзя. С докером это легко можно сделать, они не пересекутся и девелоперу не нужно заниматься увеселительным процессом подбора свободных портов для тестов.

После сборки у нас прогоняется целая система тестов. Самое первичное - это приёмочные тесты, которые занимают секунд 5-7 и проверяют базовые функциональности нашего Flussonic - что он транскодирует, отвечает, проверяет авторизацию. Этот небольшой набор тестов глубоко интегральный и проверяет одним заходом максимально большое количество подсистем. Такая маленькая хитрость позволяет экономить огромное количество времени (многие минуты) на ожидании прогона остальных тестов. Если девелопер что-то сломал, он об этом узнает очень быстро.

После этого собирается пакет и начинают прогоняться большие тесты, занимающие примерно 20-40 минут при высоком уровне параллелизма. Сейчас мы это время опустили до 10 минут и это радикально меняет удобство итераций. У некоторых проектов полный прогон может занимать день, что делает невозможным ожидание результатов тестов в интерактивном режиме. Внешние высокоуровневые тесты обеспечивают постоянное автоматизированное тестирование и постоянный деплой. Каждый раз мы проверяем, что всё собралось, поставилось, запустилось и работает так, как надо. Все тесты мы проводим на Intel и планируем сделать такую же инфраструктуру для ARM 64.

Планы на будущее

Во-первых, мы хотим переделать как можно больше тестов на blackbox тестирование, то есть проверять Flussonic без исходников вообще. Грубо говоря, чтобы тесты были написаны на питоне, а не на эрланге. Такие тесты будут очень надежными и долгоживущими, потому как мы работаем с протоколами, которые не меняются по 10-15 лет.

Во-вторых, мы собираемся попробовать автоматический запуск Flussonic в Kubernetes. Это механизм в Gitlab выглядит очень подходящим для ручного тестирования. Из отдельной ветки в репозитории можно было запустить отдельный кластер со своими хостнеймами. После этого ветка удаляется и больше не засоряет память.

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