Ускоряем CI/CD с помощью обнаружения изменений зависимостей с использованием GitLab CI, кэша Docker и контрольных сумм

Проблема частичной сборки проекта в системе CI/CD может быть довольно сложно решаемой. Она затрагивает несколько аспектов, но самыми важными из них являются два – время, затрачиваемое на сборку проекта при обнаружении изменений, и влияние развертывания артефактов на продуктовую и тестовые системы.

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

Есть несколько подходов для ограничения влияния избыточных сборок, и они базируются на реализации управления зависимостями:

  • отслеживание зависимостей вручную;
  • сборка и развертывание на основе Dockerfile;
  • автоматическое управление зависимостями на основе контрольного суммирования.

Все эти подходы имеют свои преимущества и недостатки и требуют тщательного анализа перед применением. Давайте кратко рассмотрим каждый из них.

Отслеживание зависимостей вручную

Это понятный способ подразумевающий, что инженеры внимательно управляют каждой зависимостью в файле сборки, что помогает избежать слишком “широких” сборок. Этот подход хорошо работает для проектов с небольшим списком зависимостей. Когда есть десятки или сотни зависимостей, управление ими вручную практически сложно реализуемо.

Сборка и развертывание на основе Dockerfile

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

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

Инструкции ADD, COPY и RUN в Dockerfile кэшируются по умолчанию. Это значит, что слой используется повторно и команда пропускается, когда Docker решает, что кэш синхронизирован с командой.

Самое главное требование для реализации подобного сценария сборки на основе Dockerfile – ограниченное количество компонентов, которые необходимо собрать, с простыми зависимостями между ними. Ограничение – это количество слоев, поддерживаемых Docker для сборки образов. Оно зависит от базовой файловой системы Docker.

Данный подход ведет к созданию сложных файлов Dockerfile, которые может быть сложно поддерживать. Посмотрите на измененный Dockerfile для Apache CloudStack Simulator, который эффективно собирает тесты в целях QA и тестирования:

FROM ubuntu:16.04

RUN apt-get -y update && apt-get install -y \
    genisoimage libffi-dev libssl-dev git sudo ipmitool \
    maven openjdk-8-jdk python-dev python-setuptools 
    python-pip python-mysql.connector supervisor \
    python-crypto python-openssl

RUN echo 'mysql-server mysql-server/root_password password root' |  debconf-set-selections; \
    echo 'mysql-server mysql-server/root_password_again password root' |  debconf-set-selections;

RUN apt-get install -qqy mysql-server && \
    apt-get clean all && \
    mkdir /var/run/mysqld; \
    chown mysql /var/run/mysqld

RUN pip install pyOpenSSL

RUN echo '''sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION"''' >> /etc/mysql/mysql.conf.d/mysqld.cnf
RUN (/usr/bin/mysqld_safe &); sleep 5; mysqladmin -u root -proot password ''

COPY agent /root/agent
COPY api /root/api
COPY build /root/build
COPY client /root/client
COPY cloud-cli /root/cloud-cli
COPY cloudstack.iml /root/cloudstack.iml
COPY configure-info.in /root/configure-info.in
COPY core /root/core
COPY debian /root/debian
COPY deps /root/deps
COPY developer /root/developer
COPY engine /root/engine
COPY framework /root/framework
COPY LICENSE.header /root/LICENSE.header
COPY LICENSE /root/LICENSE
COPY maven-standard /root/maven-standard
COPY NOTICE /root/NOTICE
COPY packaging /root/packaging
COPY plugins /root/plugins
COPY pom.xml /root/pom.xml
COPY python /root/python
COPY quickcloud /root/quickcloud
COPY requirements.txt /root/requirements.txt
COPY scripts /root/scripts
COPY server /root/server
COPY services /root/services
COPY setup /root/setup
COPY systemvm /root/systemvm
COPY target /root/target
COPY test/bindirbak /root/test/bindirbak
COPY test/conf /root/test/conf
COPY test/metadata /root/test/metadata
COPY test/pom.xml /root/test/pom.xml
COPY test/scripts /root/test/scripts
COPY test/selenium /root/test/selenium
COPY test/src /root/test/src
COPY test/systemvm /root/test/systemvm
COPY test/target /root/test/target
COPY tools/pom.xml /root/tools/pom.xml
COPY tools/apidoc /root/tools/apidoc
COPY tools/checkstyle /root/tools/checkstyle
COPY tools/devcloud4/pom.xml /root/tools/devcloud4/pom.xml
COPY tools/devcloud-kvm/pom.xml /root/tools/devcloud-kvm/pom.xml
COPY tools/marvin/pom.xml /root/tools/marvin/pom.xml
COPY tools/pom.xml /root/tools/pom.xml
COPY tools/wix-cloudstack-maven-plugin/pom.xml /root/tools/wix-cloudstack-maven-plugin/pom.xml
COPY ui /root/ui
COPY usage /root/usage
COPY utils /root/utils
COPY version-info.in /root/version-info.in
COPY vmware-base /root/vmware-base

RUN cd /root && mvn -Pdeveloper -Dsimulator -DskipTests -pl "!:cloud-marvin" install

RUN (/usr/bin/mysqld_safe &) && \
    sleep 5 && \
    cd /root && \
    mvn -Pdeveloper -pl developer -Ddeploydb && \
    mvn -Pdeveloper -pl developer -Ddeploydb-simulator

COPY tools/marvin /root/tools/marvin
COPY tools/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY tools/docker/docker_run_tests.sh /root

RUN cd /root && mvn -Pdeveloper -Dsimulator -DskipTests -pl ":cloud-marvin"

RUN MARVIN_FILE=`find /root/tools/marvin/dist/ -name "Marvin*.tar.gz"` && pip install $MARVIN_FILE

COPY test/integration /root/test/integration
COPY tools /root/tools

RUN pip install --upgrade pyOpenSSL

EXPOSE 8080 8096

WORKDIR /root

CMD ["/usr/bin/supervisord"]

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

Автоматическое управление зависимостями на основе контрольных сумм

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

Попробуем создать пример скрипта оболочки, чтобы представить состояние проекта в качестве контрольной суммы. Предположим, есть директория project с файлом a, содержащим текст test:

$ mkdir project
$ echo "test" > project/a
$ find project
project
project/a

Создадим скрипт, который вычисляет значения контрольной суммы по алгоритму MD5 для project:

$ PRJ=project; RES=$(tar -cO $PRJ | md5sum)
$ echo $PRJ $RES
project d971868dc52bbbb689e7935d0b851503 -

Теперь изменим project/a и пересчитаем сумму MD5:

$ echo "test1" > project/a
$ PRJ=project; RES=$(tar -cO $PRJ | md5sum)
$ echo $PRJ $RES
project 7cc28a6a4aaad798cdc2e96a867f5c53 -

Как можно заметить, контрольная сумма изменилась. Теперь создадим внутри поддиректорию и пересчитаем сумму MD5:

$ mkdir project/subdir
$ PRJ=project; RES=$(tar -cO $PRJ | md5sum)
$ echo $PRJ $RES
project 03e26dd8d8883927740ac71e8595818a -

Как видно из примера, любое изменение внутри директории project отображается в контрольной сумме.

Таким образом, идея оптимизации CI/CD заключается в том, чтобы вычислять контрольную сумму для каждого проекта и затем собирать и разворачивать его только в случае, если контрольная сумма отличается от ранее сохраненной.

Теперь посмотрим, как указанные подходы можно применить с GitLab CI.

Оптимизация GitLab CI при отслеживании зависимостей вручную

Процесс оптимизации сборки при использовании ручного управления зависимостями достаточно ясный. Единственное, что нужно организовать, это сохранение конечных и промежуточных артефактов сборки в кэш GitLab CI и настройку системы сборки для использования директории кэша (или импорт этих артефактов обратно в дерево проекта). Шаг развертывания является просто стадией выполнения скрипта сборки и вызывается при необходимости.

Пример использования кэша представлен ниже:

#
# https://gitlab.com/gitlab-org/gitlab-ce/tree/master/lib/gitlab/ci/templates/Nodejs.gitlab-ci.yml
#
image: node:latest

# Модули кэша между задачами.
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
  - node_modules/

before_script:
  - npm install

test_async:
  script:
  - node ./specs/start.js ./specs/async.spec.js

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

Сборка и развертывание на основе Dockerfile для GitLab CI

Данный подход подробно описан в документации GitLab CI и широко применяется в проектах для сборки и тестирования образов Docker. Кэш Docker используется для ускорения сборки слоев. Реестр Docker предлагает экономичный способ для хранения образов Docker.

Автоматическое управление зависимостями на основе контрольного суммирования для GitLab CI

Данный подход требует пояснений, поэтому создадим небольшой .gitlab-ci.yml, использующий созданный ранее скрипт и кэш GitLab CI для отслеживания тех частей проекта, которые будут пересобираться при изменениях.

image: ubuntu:latest

# Кэшир данных между выполнением задач сборки.
# Каждая ветка git имеет отдельный кэш.
#
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
  - build_cache/

# Создаем реестр, который будет использоваться для отслеживания изменений. 
# Реестр создается в кэше, т.е. он сохраняется между выполнением задач.
# Создаем новый реестр или обновляем время последнего изменения уже существующего реестра.
#
before_script:
   - mkdir -p build_cache
   - touch build_cache/registry
 
# Собираем сам процесс для project1 внутри репозитория Git.
# 1. Собираем контрольную сумму для текущего дерева 'project1/*'.
# 2. Сравниваем контрольную сумму с ранее сохраненной.
# 2.1 Если она отличается, делаем пересборку.
# 2.2 Если совпадает, пропускаем пересборку.
# 3. Обновляем реестр.
#
project1:
 variables:
   PRJ: project1
 stage: build
 script:
   - cat build_cache/registry
   - RES=$(tar -cO $PRJ | md5sum)
   - echo "$PRJ $RES"
   - |
        echo "------------------------------------------------"
        if ! grep -P "^$PRJ $RES" build_cache/registry
        then
            echo "XXXXXX BUILD HAPPENS - '$PRJ $RES' not found"
        else
            echo "XXXXXX BUILD CACHED - '$PRJ $RES' found"
        fi
        echo "------------------------------------------------"
   - |
        if ! grep -P "^$PRJ " build_cache/registry
        then
            echo "$PRJ $RES" >> build_cache/registry
        else
            sed -i "s/$PRJ .*\$/$PRJ $RES/g" build_cache/registry || true
        fi
   - cat build_cache/registry

Данный подход можно использовать, если репозиторий Git включает в себя независимые проекты, которые требуют пересборки в случае изменений. Если ваш проект использует внешние артефакты и включает в себя разделяемые директории, то скрипт следует изменить, чтобы отразить дополнительные требования.

Заключение

Мы рассмотрели три различных подхода для управления зависимостями в проектах. Эти подходы эффективны при использовании в конвейерах CI/CD или локально, потому что они могут значительно уменьшить время сборки и положительно повлиять на стабильную работу эксплуатационных серверов. Универсального подхода, который бы работал во всех случаях, не существует – вам придется выбирать наиболее подходящий в зависимости от целей и специфики проекта. Лучше использовать стандартные системы сборки, которые автоматически отслеживают зависимости, например, Maven, GNU Make, Gradle, NPM, Yarn или любую другую доступную вам систему.

Иногда методы, перечисленные выше, удобны, иногда они создают больше сложностей, и проще каждый раз просто полностью пересобирать проект. Следует рассматривать и внедрять подходы, которые обеспечивают высокую продуктивность команды, иначе практика использовани CI/CD может быть неэффективной, и разработчики будут уклоняться от ее широкого использования.

Если статья вам понравилась и была для вас полезной, поделитесь ей с друзьями.