Действие происходит в следующей вселенной:

  • лаборатория тестирования 2ГИС;

  • gitlab CI, тесты всех команд запускаются на общих раннерах, над которыми властвует команда IO;

  • e2e-тесты на различные BE-сервисы — python и vedro.

Однажды инженер Василий (собирательный образ, все совпадения случайны) проснулся и понял, что больше не может ждать эти бесконечные пайплайны. Чтобы отделить ощущения от реальности, он начал собирать статистику — сколько ходят пайпланы, сколько выполняются сами тесты в сервисе фото, а сколько собираются образы.

Какая была картина. От старта пайплайна до непосредственного запуска тестов в сервисе проходило в среднем 7,5 минут. Допустим, за рабочий день каждый член команды (разработчик/тестировщик) запускает 3 пайплайна, а людей в команде — 14. Тогда на сборку образа уходит 5 часов 15 минут.

Собрав статистику и проделав некоторую работу по ускорению самих тестов, Василий заметил, что подозрительно много времени мы тратим на ожидание запуска этих тестов. Он понял: нужно что-то делать непосредственно с самой сборкой — от пайплайна к пайплайну зависимости меняются редко, а пересобирается всё каждый раз, как в первый.

Ручной базовый образ или решение «в лоб»

Первое, о чём подумал Василий: можно же собирать образ с зависимостями заранее (локально), а в CI собраться на его основе. Как это выглядит:

Сам алгоритм:

  1. Фиксируем новую версию (CODE_VERSION)

  2. Локально собираем образ, основываясь на Dockerfile.base

  3. Пушим его в docker-hub

Тогда в CI долгая часть сборки просто не происходит.

Действительно, в CI образ собирался ~30 секунд. Однако в этом случае нужно пересобирать базовый образ на тачке Василия (а это те же 7,5 минут) и руками изменять версию базового образа в коде. И если кто-то в команде внесёт изменения в зависимости образа, то отслеживать всё предстоит вручную. Василию не подходит.

Мастер-образ как источник кэша

А что если собирать тот же базовый образ, но автоматически на мастере, помечая его как latest? Звучало неплохо, и Василий решил попробовать. Выпив чаю, он вспомнил, что сам образ собирается по слоям, поэтому можно использовать --cache-from в docker build — с ним можно пересобирать образ, используя уже собранный образ как источник кэшированных слоёв.

И вроде всё хорошо: образ собирается за те же 30 секунд. Если зависимости не меняются, руками делать ничего не надо. Но если зависимости изменены, то образ собирается очевидно дольше — с самого длинного этапа установки зависимостей. Причём, если разработка тестов подразумевает несколько запусков пайплайнов, то в каждом запуске на ветке будем собирать образ заново.

Василий решил попросить «помощь зала», и те отметили, что способ не работает для multi-staged билдов. Хоть и в текущем сервисе multi-staged отсутствовал, решение-то хотелось найти универсальное! А значит, побрёл Василий в поисках нового пути, не оглядываясь.

Кэширование pip или особенности рептилий

Поразмыслив немного ещё, Василий решил двигаться в сторону кэширования в момент установки зависимостей — pip3 install --cache-dir .cache_pip/

Алгоритм выглядел так:

  1. Нулевая сборка — собираем без кэширования, но полученный кэш будем использовать в дальнейшем.

  2. Последующая сборка:
    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. Идея, простая как дважды два! Хоть и работающая только для скриптовых языков.

Что ж, пробуем:

  1. В gitlab-ci.yml добавляем джобу со сборкой образа с зависимостями (базовый)

  2. Пользуясь магией документации, находим способ** триггерить джобу при изменении определенных файлов — https://docs.gitlab.com/ee/ci/yaml/#ruleschanges
    ** В некоторых случаях может стрелять баг (файлы не изменялись, а джоба стриггерилась), который можно закостылить предварительной проверкой на существование базового образа в docker-hub перед его сборкой.

  3. Добавляем зависимость между сборкой обычного образа: не должен стартовать раньше, чем соберётся база — https://docs.gitlab.com/ee/ci/yaml/#needs

  4. Пишем внутренности для сборки
    4.1. Чтобы по состоянию зависимостей можно было идентифицировать образ, в название образа зашиваем кэш:
    export REQ_HASH ?= $$(shasum tests/e2e/requirements.txt tests/e2e/Dockerfile | shasum | cut -d ' ' -f 1;)
    4.2. Пишем команды для сборки базового образа (с зависимостями)
    4.3. Тогда для того, чтобы собрать обычный образ с тестами, нужно стянуть базовый и сделать (= тегнуть) его как обычный образ

  5. Прокидываем папку с тестами через 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е-образов, но и образов приложения.

Комментарии (3)


  1. Regressor
    24.01.2023 09:50

    Хо-хо. Как это знакомо. Как-то я тоже извел кучу времени (1-2 недели примерно), чтобы тесты старого legacy проекта вместо полутора часов выполнялись за 5-8 минут.

    Пришлось переделать вообще все: сделаны локальные репозитории, матрешка dind заменена монтированием сокета внутри контейнера runner-а (чтобы образы каждый раз заново не качались), postgres стартовал с настройками, жертвующими надежностью в угоду скорости (unlogged, fsync и иже с ними), сами тесты гонялись в несколько потоков в зависимости от числа процессоров, использовались обновляемые подготовленные дампы базы данных, чтобы сэкономить на миграциях, на сборке использовались подготовленные и регулярно обновляемые образы docker с уже установленными зависимостями.

    Ну и в самих тестах довольно много пришлось переделать.


    1. TyVik
      24.01.2023 20:54
      +1

      А можно tablespace Postgres вообще в оперативную память засунуть. Я использовал circleci/postgres:13-postgis-ram (но это как пример).


  1. SabMakc
    24.01.2023 16:36

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

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