Действие происходит в следующей вселенной:
лаборатория тестирования 2ГИС;
gitlab CI, тесты всех команд запускаются на общих раннерах, над которыми властвует команда IO;
e2e-тесты на различные BE-сервисы — python и vedro.
Однажды инженер Василий (собирательный образ, все совпадения случайны) проснулся и понял, что больше не может ждать эти бесконечные пайплайны. Чтобы отделить ощущения от реальности, он начал собирать статистику — сколько ходят пайпланы, сколько выполняются сами тесты в сервисе фото, а сколько собираются образы.
Какая была картина. От старта пайплайна до непосредственного запуска тестов в сервисе проходило в среднем 7,5 минут. Допустим, за рабочий день каждый член команды (разработчик/тестировщик) запускает 3 пайплайна, а людей в команде — 14. Тогда на сборку образа уходит 5 часов 15 минут.
Собрав статистику и проделав некоторую работу по ускорению самих тестов, Василий заметил, что подозрительно много времени мы тратим на ожидание запуска этих тестов. Он понял: нужно что-то делать непосредственно с самой сборкой — от пайплайна к пайплайну зависимости меняются редко, а пересобирается всё каждый раз, как в первый.
Ручной базовый образ или решение «в лоб»
Первое, о чём подумал Василий: можно же собирать образ с зависимостями заранее (локально), а в CI собраться на его основе. Как это выглядит:
Сам алгоритм:
Фиксируем новую версию (CODE_VERSION)
Локально собираем образ, основываясь на Dockerfile.base
Пушим его в docker-hub
Тогда в CI долгая часть сборки просто не происходит.
Действительно, в CI образ собирался ~30 секунд. Однако в этом случае нужно пересобирать базовый образ на тачке Василия (а это те же 7,5 минут) и руками изменять версию базового образа в коде. И если кто-то в команде внесёт изменения в зависимости образа, то отслеживать всё предстоит вручную. Василию не подходит.
Мастер-образ как источник кэша
А что если собирать тот же базовый образ, но автоматически на мастере, помечая его как latest? Звучало неплохо, и Василий решил попробовать. Выпив чаю, он вспомнил, что сам образ собирается по слоям, поэтому можно использовать --cache-from
в docker build — с ним можно пересобирать образ, используя уже собранный образ как источник кэшированных слоёв.
И вроде всё хорошо: образ собирается за те же 30 секунд. Если зависимости не меняются, руками делать ничего не надо. Но если зависимости изменены, то образ собирается очевидно дольше — с самого длинного этапа установки зависимостей. Причём, если разработка тестов подразумевает несколько запусков пайплайнов, то в каждом запуске на ветке будем собирать образ заново.
Василий решил попросить «помощь зала», и те отметили, что способ не работает для multi-staged билдов. Хоть и в текущем сервисе multi-staged отсутствовал, решение-то хотелось найти универсальное! А значит, побрёл Василий в поисках нового пути, не оглядываясь.
Кэширование pip или особенности рептилий
Поразмыслив немного ещё, Василий решил двигаться в сторону кэширования в момент установки зависимостей — pip3 install --cache-dir .cache_pip/
Алгоритм выглядел так:
Нулевая сборка — собираем без кэширования, но полученный кэш будем использовать в дальнейшем.
Последующая сборка:
2.1. Стягиваем образ (нулевой) с кэшем.
2.2. Поднимаем контейнер по этому образу и копируем из него папку с кэшем.
2.3. Начинаем сборку.
2.3.1. Копируем папку с кэшем в контейнер.
2.3.2. Собираем зависимости с учетом кэша.
Как стал выглядеть Dockerfile:
FROM myhub.ru/team/e2e-python:3.9-alpine
RUN apk add --no-cache ...
COPY tests/e2e/requirements.txt requirements.txt
ENV PIP_CACHE_DIR=.cache_pip/
COPY *.cache_pip .env-base $WORKDIR/.cache_pip/
RUN pip3 install --cache-dir .cache_pip/ -r requirements.txt
COPY tests/e2e/ $WORKDIR
USER ubuntu
Локальная сборка полного образа с --cache-dir
взлетела до 30 секунд! «Вот это скорость», — подумал Василий и начал затаскивать в CI.
Чтобы все заработало в gitlab CI, необходимо завести cache:
.pip-cache-pull-push:
cache:
paths:
- .cache_pip/
key: pip-e2e-cache
policy: pull-push
.pip-cache-pull:
extends: .pip-cache-pull-push
cache:
policy: pull
build-e2e:
stage: build
script:
- docker-compose build e2e
- docker-compose push e2e
extends:
- .pip-cache-pull
# обновляем кэш на мастере
refresh-pip-cache:
stage: build
script:
- docker-compose pull --quiet e2e
- docker-compose up --no-build -d e2e
- docker cp $(COMPOSE_PROJECT_NAME)_e2e_1:/home/ubuntu/workdir/.cache_pip/ .
- docker-compose down --remove-orphans
extends:
- .pip-cache-pull-push
needs: [ "build-e2e" ]
tags: [ ... ]
only: [ master ]
Как это выглядело в CI:
Step 7/10 : COPY *.cache_pip .env-base $WORKDIR/.cache_pip/
---> 147386c24b6a
Step 8/10 : RUN pip3 install --cache-dir .cache_pip/ -r requirements.txt
---> Running in e91cee37440a
Collecting vedro==1.5.0
Using cached vedro-1.5.0-py3-none-any.whl (84 kB)
...
При таком способе скорость сборки варьировалась от 2 до 3,5 минут. Казалось бы, прекрасный способ! Но есть нюансы. Добавив кэш, Василий увеличил вес самого образа почти на 30%! Соответственно, время стягивания образа в джобы для запусков тестов тоже увеличилось.
Buildx или method temporary unavailable
Василий приуныл немного, но пошел гуглить дальше. Новая надежда — Buildx — docker-плагин, расширяющий возможности сборки с Buildkit (полезная статья). Закатав рукава и попробовав локально, Василий преисполнился:
docker buildx build -t myhub.ru/ugc/photo-e2e:${IMAGE_VERSION} \\
--file tests/e2e/Dockerfile \\
--push \\
.
Первый запуск локально — 4m 15,975s:
docker buildx create --name mybuilder --use
docker buildx build -t photo-e2e:fab69ffb99195d171097c10.
[+] Building 255.8s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 547B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 143B 0.0s
=> [internal] load metadata for e2e-python:3.9-alpine 0.0s
=> [1/5] FROM e2e-python:3.9-alpine@sha256:e156b5ccb 23.1s
=> => resolve e2e-python:3.9-alpine@sha256:e156b5ccb 0.0s
=> => sha256:06f67f905aa444e0414502e5cd 21.23MB / 21.23MB 22.5s
=> => sha256:24e85e6a20ef97c449024bcbb2 2.35MB / 2.35MB 8.2s
=> => sha256:f5e5def5d5f7d9958b318f1fa5 11.47MB / 11.47MB 18.6s
=> => sha256:29291e31a76a7e560b9b7ad3ca 2.81MB / 2.81MB 12.4s
=> => extracting sha256:29291e31a76a7e560b9b7ad3ca 0.1s
=> => extracting sha256:7905544193a12a0403a4adefeb 0.1s
=> => extracting sha256:f5e5def5d5f7d9958b318f1fa5 0.3s
=> => extracting sha256:cef6c1fdc9e4cac180568966c3 0.0s
=> => extracting sha256:24e85e6a20ef97c449024bcbb2 0.3s
=> => extracting sha256:06f67f905aa444e0414502e7fe 0.5s
=> [internal] load build context 0.4s
=> => transferring context: 214.62kB 0.3s
=> [2/5] RUN apk add --no-cache ... 30.0s
=> [3/5] COPY tests/e2e/requirements.txt requirements.txt 0.1s
=> [4/5] RUN apk add --no-cache --virtual .build-deps ... 185.7s
=> [5/5] COPY tests/e2e/ /home/ubuntu/workdir 0.4s
=> exporting to image 16.5s
=> => exporting layers 3.6s
=> => exporting manifest sha256:a2faf6bc7d5a464f8e9e9a1e55df216fd150ba82cdb1e3 0.0s
=> => exporting config sha256:9c8d46901722b88e40fb80167246347dd43176498498c5 0.0s
=> => pushing layers 12.8s
Повторная сборка — меньше секунды:
[+] Building 0.7s (10/10) FINISHED
=> [internal] load .dockerignore 0.0s
=> => transferring context: 143B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 547B 0.0s
=> [internal] load metadata for e2e-python:3.9-alpine 0.3s
=> [1/5] FROM e2e-python:3.9-alpine@sha256:e156b5ccb5537a81c 0.0s
=> => resolve e2e-python:3.9-alpine@sha256:e156b5ccb5537a81c 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 214.10kB 0.1s
=> CACHED [2/5] RUN apk add --no-cache ... 0.0s
=> CACHED [3/5] COPY tests/e2e/requirements.txt ... 0.0s
=> CACHED [4/5] RUN apk add --no-cache --virtual ... 0.0s
=> CACHED [5/5] COPY tests/e2e/ /home/ubuntu/workdir 0.0s
=> exporting to image 0.3s
=> => exporting layers 0.0s
=> => exporting manifest sha256:a2faf6bc7d5a464f8 0.0s
=> => exporting config sha256:9c8d46901722b88e40 0.0s
=> => pushing layers 0.2s
=> => pushing manifest for photo-e2e:fab69ffb99195d17 0.1s
Дело за малым — засунуть в CI. Беда пришла откуда не ждали: permission denied
А всё потому, что директория .docker была скрыта властелинами раннеров — однажды они столкнулись с багом использования buildkit и прикрыли эту возможность.
Тогда Василий решил поднять собственный раннер и использовать его. Сложности возникали на каждом шагу — так, Василий узнал, что:
для каждой группы в gitlab нужно создавать свой групповой раннер;
поднять раннер не так просто, как описано в https://docs.gitlab.com/runner/install/kubernetes.html, нужно выполнить ещё много приседаний.
А ещё поднятные раннеры предстоит поддерживать. Так что Василий, поборовшись какое-то время, бросил эту идею с мыслью вернуться к buildx, когда властелины раннеров излечат баг (а они обещали сделать это вскоре).
Да здравствуют словари и правила!
В одно прекрасное солнечное утро Василий пришел к осознанию, что python-то — скриптовый язык! А значит, образ с зависимостями можно собрать только в случае изменения этих самых зависимостей, а тесты просто прокидывать через volume. Идея, простая как дважды два! Хоть и работающая только для скриптовых языков.
Что ж, пробуем:
В gitlab-ci.yml добавляем джобу со сборкой образа с зависимостями (базовый)
Пользуясь магией документации, находим способ** триггерить джобу при изменении определенных файлов — https://docs.gitlab.com/ee/ci/yaml/#ruleschanges
** В некоторых случаях может стрелять баг (файлы не изменялись, а джоба стриггерилась), который можно закостылить предварительной проверкой на существование базового образа в docker-hub перед его сборкой.Добавляем зависимость между сборкой обычного образа: не должен стартовать раньше, чем соберётся база — https://docs.gitlab.com/ee/ci/yaml/#needs
Пишем внутренности для сборки
4.1. Чтобы по состоянию зависимостей можно было идентифицировать образ, в название образа зашиваем кэш:export REQ_HASH ?= $$(shasum tests/e2e/requirements.txt tests/e2e/Dockerfile | shasum | cut -d ' ' -f 1;)
4.2. Пишем команды для сборки базового образа (с зависимостями)
4.3. Тогда для того, чтобы собрать обычный образ с тестами, нужно стянуть базовый и сделать (= тегнуть) его как обычный образПрокидываем папку с тестами через volume
Получаем gitlab-ci.yml:
build-e2e-base:
stage: build
script:
- docker build -t ${E2E_BASE_IMAGE_PATH}-${REQ_HASH} tests/e2e
- docker push ${E2E_BASE_IMAGE_PATH}-${REQ_HASH}
rules:
- if: '$CI_PIPELINE_SOURCE != "merge_request_event"'
changes:
- tests/e2e/requirements.txt
- tests/e2e/Dockerfile
tags: [ ... ]
build-e2e:
stage: build
environment: build
needs:
- job: build-e2e-base
optional: true
script:
- docker pull --quiet ${E2E_BASE_IMAGE_PATH}-${REQ_HASH}
- docker tag ${E2E_BASE_IMAGE_PATH}-${REQ_HASH} ${E2E_IMAGE_PATH}:$$IMAGE_VERSION
docker-compose push e2e
tags: [ ... ]
В итоге получаем ощутимый буст при сборке, когда зависимости/способ сборки не меняется — образ собирается за 5−10 cекунд. При изменении зависимостей или Dockerfile время остается тем же.
Чтобы не мотать вверх-вниз, сопоставим все варианты:
Кстати, ещё один из вариантов, который Василий не стал рассматривать, — ускорение через https://github.com/uber-archive/makisu. Deprecated — значит, всегда что-то может пойти не так.
Стало лучше?
Да! Можно посмотреть на цифры выше (или на Василия, который перестал засыпать в ожидании сборки). В целом на все эксперименты ушло меньше недели (а чтобы написать эту статью — вечность).
Если у вас этап сборки стал узким горлышком, то эти способы помогут быстро сдвинуться с места.
В дальнейшем мы планируем попробовать buildx и оптимизировать сборку не только е2е-образов, но и образов приложения.