«Восстание машин» часть 1: CD для базовых Docker образов
Всем привет! Меня зовут Леонид Талалаев, я работаю в Одноклассниках в команде Платформы. Более 3-х лет назад мы запустили внутреннее облако one-cloud. Сейчас под его управлением находятся тысячи серверов в 4 дата-центрах, сотни сервисов и более десятка тысяч контейнеров.
Наше облако — это технология, проверенная временем и инцидентами — вплоть до пожара в одном из наших дата-центров. По мере роста числа сервисов росла и сложность управления. Задачи, которые раньше выполнялись вручную, начинали отнимать слишком много времени и сил.
В серии статей «Восстание машин» я расскажу, как автоматизация в one-cloud помогает экономить не только время, но и деньги. Сегодня пойдет речь о том, как мы реализовали процесс непрерывной доставки изменений базовых Docker образов.
Security-патчи в облаке
Представим, что найдена критическая уязвимость в системной библиотеке, например, libssl. И системный администратор, назовём его Джон, хочет выложить секьюрити-патч на весь продакшн.
В нашем случае продакшн — это сервисы, которые работают в Docker контейнерах в облаках под управлением one-cloud.
Корневая файловая система Docker контейнеров является временной, и пересоздается при каждом старте заново (если Вы этого еще не знали, советую почитать, например, эту статью). Поэтому, Джон должен вносить патч непосредственно в Docker образы, из которых создаются контейнеры.
Базовые образы и образы сервисов
У нас несколько сотен сервисов. Поэтому, для упрощения процессов разработки, мы разделяем наши Docker образы на базовые образы и образы сервисов.
Базовые образы создаются системными администраторами. В них выполняется настройка операционной системы, устанавливаются пакеты и т.п. Поскольку используемый стек технологий во всех сервисах у нас очень похож, мы также выносим в базовые образы общие для всех сервисов конфигурационные файлы и скрипты запуска.
Образы сервисов создаются разработчиками. Они наследуются от базовых образов, установка пакетов в них запрещена. Большинство наших образов сервисов собираются через Dockerfile, состоящий из двух строк: наследование от одного из базовых образов и копирование файлов, полученных при сборке этого сервиса в системе CI/CD. Все остальное настраивается в базовом образе.
Вернемся к задаче раскатки security патча на продакшн. Кажется, что тут все просто — Джону достаточно внести патч в базовый образ. Если бы Джон был ленивым, то он мог бы больше ничего не делать, подумав, что сервисы сами рано или поздно будут пересобраны и обновлены при плановых апдейтах.
Но Джон знает, что у нас есть очень много сервисов, и активно разрабатываются далеко не все из них. Есть сервисы, не содержащие бизнес-логики (например, хранилища): их написали один раз, они работают, и лишь изредка в них вносят какие-то фиксы. Ждать планового апдейта для них очень долго.
Для нас было важно, чтобы администраторы могли выкладывать секьюрити-патчи самостоятельно, не отвлекая разработчиков, отвечающих за отдельные сервисы. Поэтому, Джон должен уметь пересобрать и обновить все сервисы сам.
Сложности применения CI/CD для пересборки образов
В «Одноклассниках» более 300 типов образов (даже без учета разных версий). Если допустить, что сборка образа занимает 2 минуты, получится, что только на сборку всех образов Джон потратит более 10 часов. Не говоря о том, что их нужно ещё и выложить. Это слишком много ручной работы.
Мы бы хотели помочь Джону и автоматизировать этот процесс. Но реализовывать его через систему CI/CD было бы слишком сложно: сервисов у нас много, могут быть запущены разные версии образов (для каких-то экспериментов, например).
И если кто-то выложил версию своего сервиса, собранную из бранча, то мы должны её заменить на ту же самую, мы не можем взять её и обновить на другую. То есть нужно откуда-то взять контекст сборки данного образа, включающий не только названия бранча, но и другие параметры, которые влияют на собираемый образ. Поэтому, мы не стали использовать CI/CD для данной задачи, а пошли другим путем.
Представление образов в Docker Registry
Зачем нам пересобирать весь сервис, если мы не меняем его код?
Вспомним, что файловая система Docker-образов состоит из слоёв. В образы, которые мы запускаем в облаке, входят собственно файлы запускаемого сервиса и системные слои: операционная система, пакеты и системные библиотеки.
Все образы хранятся в Docker Registry. Там есть старый образ сервиса, который нужно пропатчить, и новый базовый образ, который подготовил сисадмин Джон.
Получается, что в реестре уже есть все нужные слои. Если добавим к слоям нового базового образа слои сервиса, то мы получим в точности такой образ, как будто мы бы его собирали изначально с новым базовым образом:
Но через Docker Engine мы не сможем сделать такую операцию, поэтому будем работать напрямую с реестром (Docker Registry). Для работы с ним есть документированное API.
Посмотрим, как устроено представление образов в реестре. Он делится на репозитории, название репозитория — это имя образа без тега. Предположим, есть два репозитория: base и service. В каждом из них хранится два типа объектов — манифесты и блобы:
Все объекты — манифесты и блобы имеют дайджесты. Дайджесты вычисляются как sha256-хеш от содержимого, и являются уникальным идентификатором этого объекта. Для иллюстрации, в примерах показан сокращенный дайджест (в реальности строка длиннее): digest = «sha256:» + hex (sha256(data))
Манифесты содержат JSON, и им можно присваивать теги. На картинке выше их два: манифест образа с именем base и тегом 1.1, манифест с именем service и тегом 1.2.3.
Блобы бывают двух видов: содержащие слои файловой системы (зеленые прямоугольники) и конфигурационные блобы (белые прямоугольники в колонке blobs). Конфигурационный блоб есть у каждого образа. Он содержит JSON с конфигурацией Docker-образа.
Теперь вернёмся к задаче сборки Docker-образа из готовых слоёв.
У нас есть новый базовый образ и есть старый образ сервиса. Нужно создать новый образ, который будет ссылаться на слои из этих двух.
Поскольку все слои в реестре уже есть, нам достаточно создать манифест и конфигурацию — это два JSON, которые обведены красным:
Как устроен манифест Docker образа
Посмотрим, как устроен манифест образа. Его можно получить, сделав GET-запрос в реестр с указанием дайджеста:
curl $registry/v2/service/manifests/sha256:CCCC \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json"
Заголовок Accept указывает, что мы ожидаем именно манифест второй версии в спецификации, иначе реестр вернёт совсем другой документ.
Запросив манифест, можно увидеть, что он не содержит никаких дополнительных данных, помимо того, что он собирает вместе другие объекты — конфигурацию и слои:
Остальные поля (schemaVersion, mediaType и так далее) не меняются, их значение можно посмотреть в спецификации.
То есть, если слои и конфигурации нам известны, создать этот документ не составляет никаких проблем.
Конфигурация Docker образа
Теперь посмотрим, как устроена конфигурация. Её можно тоже запросить из реестра по её дайджесту:
curl $registry/v2/service/blobs/sha256:CCC1
В конфигурации есть четыре секции — это метаданные, параметры, история и слои:
Те, кто запускал docker inspect image, видели там что-то, похожее на этот документ. Конфигурация используется рантаймом при запуске контейнера. Формат этого документа описывается стандартом OCI Image.
Чтобы не тратить время, документы будут приводиться в упрощенном виде, опуская всё, что неважно.
Посмотрим на первую половину конфигурации. С метаданными всё более-менее очевидно, с параметрами тоже просто: это те значения, которые меняются одноимёнными командами Dockerfile.
И если мы их поменяем, то эти изменения отразятся на Docker-образе.
То есть на данном этапе Вы уже знаете, как можно поменять параметры в Docker-образах, не используя Docker — напрямую в реестре. Но нам нужно менять не только параметры, но и слои. Кроме того, нужно научиться загружать изменения в реестр, поэтому, пойдём дальше.
Во второй части документа — история (содержит все команды, из которых создан докер-образ, начиная с самой ранней) и слои:
Могут возникнуть вопросы, что за странные идентификаторы вида file: xxxx после команд COPY. Но на самом деле нам не важно знать детали того, как именно формируются эти записи, потому что мы их будем брать из готовых JSON-документов. То есть тут, как у студента на экзамене: не требуется понимать, достаточно знать, у кого списать.
Тут есть две записи истории, которые создают слои (они обведены красным), и есть записи, которые не создают слои (помеченные признаком empty_layer):
Слои описываются списком diff_ids, но это не те же самые хеши, которые в манифесте: diff_id вычисляется на хосте как хеш от распакованного архива со слоем. Но, как я говорил, нам не важно знать детали того, как они вычисляются, потому что мы их берём из готовых документов.
Определяем базовый образ манифеста
Некоторые директивы Dockerfile не попадают в историю, например, директива FROM. И возникает вопрос: если у нас есть только текст конфигурации, но нет исходного Dockerfile, как мы поймём, где в истории заканчивается базовый образ и где начинается образ сервиса?
Мы решили этот вопрос так. Добавили во все наши Dockerfile первой операцией после директивы FROM установку LABEL FROM с именем и тегом базового образа:
В результате, мы теперь знаем, где у нас кончается история базового образа — всё, что идёт выше этой операции:
Кроме того, мы теперь знаем, по какому имени тега мы можем получить актуальную версию базового образа, что нам потребуется для автоматизации.
Создаем манифест образа из готовых слоев
И теперь у нас всё готово, чтобы создать конфигурацию сервиса. Делается это очень просто. Мы берём слои базового образа (мы их определили ранее), и меняем их на слои из нового базового образа:
После этого нам остается подменить параметры.
Для этого нужно понять, какие параметры попали из базового образа, а какие были добавлены в образе сервиса.
Это можно понять по истории. Посмотрим на историю, ищем команды, меняющие параметры (в данном случае это LABEL и ENV):
Эти параметры мы не трогаем, а остальные меняем на параметры из базового образа. Далее остаётся поправить время создания, и конфигурация у нас готова.
Дальше мы создаем манифест, для этого заменяем слои базового образа (их число уже известно) и прописываем дайджест созданной перед этим конфигурации и её размер.
Добавляем манифест образа в Docker Registry
Мы создали два документа, теперь нужно загрузить их в реестр. И прежде чем это сделать, нужно смонтировать слои базового образа.
Что это значит? Как уже было сказано, Docker Registry внутри делится на репозитории, и манифест может ссылаться только на те слои, которые находятся в том же самом репозитории. Например, ссылка, которая подсвечена красным — не валидна, попытка добавить такой манифест завершится с ошибкой.
Что нам нужно сделать? Операцию mount. При ней не происходит физического копирования данных, а просто создаётся ссылка в одном репозитории на слой из другого репозитория. После этого мы можем загрузить наш манифест.
Итого. Для того, чтобы загрузить образ в Docker Registry напрямую, нужно:
Эти процессы хорошо описаны в документации API реестра, поэтому не будем на них подробно останавливаться.
Операция image rebase
В итоге мы получили возможность «на лету» создавать в Docker Registry новые образы, меняя базовый образа сервиса на любой другой. Эта операция у нас называется «image rebase».
image rebase имеет массу преимуществ по сравнению с пересборкой через CI/CD:
- Не создаются новые слои: реестр не разбухает, передачи слоёв по сети тоже нет.
- Файлы приложения не пересобираются заново, а значит, меньше шансов что-то сломать в логике самого приложения.
- Нет зависимости от version control system, хранилищ артефактов и других систем, используемых в процессе сборки.
- Все запросы легковесные и создание образа занимает всего 1 секунду (вместо 2–10 минут как при полной сборке).
- Не нужен отдельный сервер. Достаточно уметь работать с JSON и выполнять HTTP-запросы.
Данную операцию очень удобно использовать для автоматизации, и мы ее вызываем прямо в коде мастера облака.
Также image rebase упрощает тестирование новой версии базового образа. Системный администратор может обновить один или несколько контейнеров на образ, созданный через image rebase. После того, как новый базовый образ проверен, администратор ставит ему тег stable и дальше он автоматически раскатывается на все сервисы.
Где исходный код?
В качестве подарка дочитавшим до этого места мы выложили в открытый доступ Python-скрипт, реализующий операцию замены базового образа. И прямо сейчас Вы его можете скачать и попробовать. Для этого вам нужно:
«Восстание машин»: continuous delivery базовых образов
Мы научились создавать новые образы, дальше надо научиться их выкладывать.
Как мы это сделаем? Мы находим устаревшие базовые образы, делаем новые через image rebase, обновляем сервисы. Мастер облака при этом нотифицирует в чат о том, что происходит обновление:
И разработчики у нас поначалу спрашивали: «А кто это апдейтит мой сервис»? Мы отвечали, что это роботы. Поэтому мы назвали этот процесс «Восстание машин».
«Восстание машин» дисциплинирует писать надёжный код, так как контейнер может быть в любую минуту перезапущен. Раньше разработчик мог прийти к дежурному администратору и сказать, пожалуйста, не трогайте мой сервис, потому что есть какая-то причина. Сейчас, когда роботы обновляют сервис, так уже не прокатит — роботы железные, просьб не понимают.
Этим механизмом мы доставляем не только патчи безопасности, но и любые изменения базовых образов. Например, для расследования инцидентов у нас в каждом контейнере есть инструменты отладки и диагностики. Если мы заходим в контейнер, мы хотим быть уверены, что там более-менее свежие версии этих инструментов.
Процесс обновления прозрачен, есть мониторинг, администраторы могут следить, в каких сервисах уже прошло обновление, в каких — ещё нет.
Мы запустили этот процесс год назад, с тех пор он замечательно работает, и все уже привыкли к тому, что в облаке что-то само собой происходит. На самом деле, это очень удобно и экономит кучу времени.
Джон доволен тем, что «Восстание машин» экономит ему время
Защитные меры
Вы можете спросить:
— «Восстание машин» создает и выкладывает не прошедшие тестирование образы на все контейнеры, и всё это без контроля со стороны человека? Звучит как план положить продакшн.
Разумеется, мы предусмотрели защитные меры.
Во-первых, мы размазываем процесс обновления во времени, то есть перекладываем небольшое число контейнеров, дожидаемся старта и только потом продолжаем. В результате все контейнеры облака у нас обновляются примерно за месяц.
Во-вторых, обновляем только в рабочее время, если что-то пошло не так, команда мониторинга успеет это заметить.
В-третьих, обновляем один дата-центр в день, это снижает возможные эффекты от каких-то отложенных проблем.
И последнее: мы останавливаем контейнеры только в том случае, если это не приведёт к недоступности сервисов. О том, как именно мастер облака определяет, можно ли контейнер сейчас останавливать или нельзя, я расскажу в следующий раз.
I’ll be back…