Методы непрерывной интеграции (continous integration, или CI) широко используются для автоматизации процесса сборки, тестирования и деплоя ПО. Такой подход позволяет раньше выявить ошибки и решить проблемы интеграции.

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

CI Workflow

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

branch build flow
  • разработчик push-ит код в репозиторий;
  • сервис CI запускает связанные с проектом работы (jobs), которые выполняют сборку, тестирование и пр.;
  • [опционально] в случае успеха сервис CI выполняет деплой артефактов (artifacts) — файлов, полученных в результате сборки.
pull request build flow
  • разработчик создаёт pull request;
  • сервис CI запускает связанные с проектом работы;
  • в случае успеха сервис CI сигнализирует, что ветка может быть объединена;
  • разработчик делает merge.

Gitlab CI

Для проектов, которые хостятся на популярном сервисе GitHub, стандартом де-факто для CI является тесно интегрированный с ним сервис Travis CI. Всё, что нужно для старта — это зарегистрироваться, дать Travis доступ к информации о ваших репозиториях, активировать нужный репозиторий и добавить в него файл описания задач (jobs) .travis.yml. После этого следующий выполненный push запустит процесс CI.

В Gitlab сервис CI интегрирован в сам хостинг репозиториев. Он использует для описания задач файл .gitlab-ci.yml, который хранится в репозитории проекта. Для того, чтобы добавить CI для репозитория Gitlab, нужно активировать Builds при создании репозитория либо в настройках уже существующего репозитория.

Далее нужно добавить один или несколько runners (процесс, который выполняет сборку). Gitlab CI позволяет использовать как runners, которые доступны всем пользователям хостинга (shared), так и установленные на машинах пользователя (specific) и доступные только ему.

Gitlab Runner

Gitlab Runner представляет собой свободную программу, написанную на языке Go. Он позволяет выполнять задачи локально, с использованием контейнерной виртуализации либо на удалённом SSH-сервере (deprecated).

Установка

Gitlab Runner поддерживает установку из официальных репозиториев проекта. Я опишу вариант для CentOS.

Добавление репозитория:

  $ INSTALL_SCRIPT=https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.rpm.sh
  $ curl -L ${INSTALL_SCRIPT} | sudo bash

Установка gitlab-ci-multi-runner:

  $ sudo yum install gitlab-ci-multi-runner

Docker

Наиболее простой вариант использования Gitlab Runner — запускать сборки локально. Хотя такой вариант подходит для простых случаев, он не очень оптимален. Для запуска сборок проекта необходимо настраивать рабочее окружение на машине, которая будет запускать сборку. Это в свою очередь приводит к потенциальным сложностям при поддержке на одной машине множества runners с конфликтующими окружениями. Более привлекательный вариант — запускать сборки в изолированных окружениях, которые могут быть получены с помощью средств контейнерной виртуализации. Gitlab Runner поддерживает работу через контейнеры Docker.

Docker — это свободное ПО, который позволяет запускать приложения в изолированных виртуальных контейнерах, которые взаимодействуют друг с другом и с хост-системой посредством настраиваемых сетей (networks) и томов данных (volumes).

Установка

Для установки Docker выполним следующую команду, которая добавит репозиторий Docker и уставит из него само приложение:

  $ curl -fsSL https://get.docker.com/ | sh

Запустим демон docker:

  $ sudo systemctl start docker
  $ sudo systemctl enable docker

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

  $ sudo usermod -aG docker nameless

Docker 101

В основе технологии Docker лежит понятие образа (image). Образ — это упорядоченная коллекция доступных только для чтения слоёв, которые представляют собой изменения файловой системы. Эти слои накладываются друг на друга и формируют основу файловой системы контейнера. Образ не хранит какого-либо состояния и не может быть изменён.

Docker позволяет легко создавать новые образы на основе старых. Для их централизованного хранения существует Docker Hub — регистр образов Docker (существует возможность создавать свои регистры). Каждый образ имеет свой уникальный идентификатор, который имеет формат [username/]image[:tag]:

username
опциональное имя пользователя, которому принадлежит образ; если это имя опущено, то мы имеем дело с официальным образом, т.е. с образом, который поддерживается самой компанией Docker;
image
название образа, например, centos, ubuntu, ruby;
tag
опциональный тег, используемый для обозначения нескольких версий одного и того же образа; отсутствующий тег означает, что мы работаем с тегом latest; кроме того, несколько тегов могут указывать на один образ.
images/image-layers.jpg
Образ ubuntu:15.04 (источник)

Контейнер (container) — это запущенный экземпляр образа. Контейнер добавляет тонкий доступный для записи слой поверх слоёв образа. Все изменения, сделанные в запущенном контейнере (например, создание, редактирование и удаления файлов), записываются в этот слой.

images/container-layers.jpg
Контейнер, основанный на образе ubuntu:15.04 (источник)

Для управления образами и контейнерами предназначена утилита docker. Скачаем из регистра официальный образ ubuntu версии 16.04:

  $ docker pull ubuntu:16.04

Теперь запустим контейнер на основе этого образа:

  $ docker run --name hello ubuntu:16.04 echo Hello world
  Hello world

Рассмотрим эту команду подробнее. Сначала мы указываем имя (--name hello) контейнера (это делать не обязательно, каждому контейнеру соответствует уникальный идентификатор, который можно использовать для того, чтобы ссылаться на контейнер). Затем мы указываем образ (ubuntu:16.04), в конце — команду, которую нужно выполнить (echo Hello world).

Каждый контейнер хранит свой доступный для записи слой, и все изменения сохраняются в этом слое; это позволяет нескольким контейнерам разделять доступ к одному и тому же образу. Запустим ещё один контейнер на основе ubuntu:16.04 и назовём его write-hello:

  $ docker run --name write-hello ubuntu:16.04 \
      /bin/bash -c "echo Hello world > hello.txt"

В отличие от контейнера hello, контейнер write-hello создаёт новый файл (hello.txt). Т.к. изменения хранятся в самом контейнере, образ остаётся без изменения: если мы запустим новый контейнер на основе ubuntu.16.04, то не обнаружим там файла hello.txt. Тем не менее, мы можем зафиксировать изменения в контейнере, превратив их в неизменяемый слой для нового образа с помощь команды docker commit:

  $ docker commit -m "Add hello.txt" write-hello smaximov/ubuntu:16.04-hello.txt
  6b1e7c377bddfefcbf67490a7a3c697668027ba30ef80ba152305b3627e72021

Мы создали образ smaximov/ubuntu:16.04-hello.txt на основе контейнера write-hello. Он использует слои из образа ubuntu:16.04 и добавляет к ним слой из контейнера write-hello. Запустим контейнер на основе нового образа и убедимся, что в нём присутствует файл hello.txt:

  $ docker run --name read-hello smaximov/ubuntu:16.04-hello.txt cat hello.txt
  Hello world

У данного подхода есть недостатки: неудобно каждый раз вручную вносить изменения в образы, чтобы получить новый образ. К счастью, можно создавать новые образы на основе декларативных описаний, которые хранятся в файле Dockerfile. Приведём пример Dockerfile, который создаёт образ, аналогичный образу, который мы создали вручную:

  # Образ, на основе которого мы создаём новый образ
  FROM ubuntu:16.04

  # Команда, выполняемая в контейнере
  RUN echo Hello world > hello.txt

Для сборки образа из Dockerfile используется команда docker build, которой указывается путь к Dockerfile:

  $ docker build --tag smaximov/ubuntu:16.04-dockerfile .
  Sending build context to Docker daemon 2.048 kB
  Step 1 : FROM ubuntu:16.04
   ---> 8af37f0d4b22
  Step 2 : RUN echo Hello world > hello.txt
   ---> Running in 6cef2bb43894
   ---> 586f80e3f3f7
  Removing intermediate container 6cef2bb43894
  Successfully built 586f80e3f3f7

Проверим, что новый образ содержит hello.txt:

  $ docker run smaximov/ubuntu:16.04-dockerfile cat hello.txt
  Hello world

Образы для Rust

Теперь приступим к образам для языка Rust. Мы хотим тестировать приложения с помощью нескольких версий компилятора rustc: stable, beta и nightly. Нам нужно иметь возможность переключаться между разными версиями компилятора; для этого существует multirust — менеджер инсталляций Rust.

Создадим базовый образ multirust.

  FROM ubuntu:latest
  MAINTAINER smaximov <s.b.maximov@gmail.com>

  # Install dependencies
  RUN apt-get update && apt-get install -y \
      build-essential \
      curl \
      git

  # Add Rust signing key
  RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 7B3B09DC

  # Install multirust
  RUN cd /tmp && \
      git clone --recursive https://github.com/brson/multirust && \
      cd multirust && \
      git submodule update --init && \
      ./build.sh && \
      ./install.sh

  # Set default user
  USER root

Образ будет называться smaximov/multirust. Вспомогательный Makefile:

  USER=smaximov
  NAME=multirust
  TAG=latest

  IMAGE=${USER}/${NAME}:${TAG}

  help:
          @echo "Build multirust image."
          @echo
          @echo "Usage:"
          @echo "  make [help]        - display this message"
          @echo "  make image         - build multirust image"
          @echo "  make push          - push built image to the registry"

  image:
          docker build --tag ${IMAGE} .

  push:
          docker push ${IMAGE}

  .PHONY: help image push

Соберём образ и отправим его в регистр (процесс регистрации на Docker Hub я описывать не буду, он тривиален):

  $ make images && make push
  [... build output ...]

Теперь наш образ multirust доступен всем! Мы уже можем использовать его для тестирования проектов с различными версиями Rust. Для этого нужно в контейнере, запущенном из этого образа, установить и активировать средствами multirust необходимую версию компилятора, после чего можно запускать сборку и тестирование проекта.

Однако, будет неэффективно для каждой сборки скачивать и устанавливать несколько версий компилятора, поэтому создадим образы stable, beta и nightly с предустановленными версиями Rust:

  FROM smaximov/multirust:latest

  # Pass a channel (i.e., stable, beta, nightly)
  # as in `$ docker build --build-arg channel=stable ...`.
  ARG channel=none

  # Pick a rust from specified channel
  RUN multirust update $channel
  RUN multirust default $channel

Данный Dockerfile позволяет создавать несколько образов. При сборке образа можно передать параметр (который мы определили с помощью директивы ARG) channel, который указывает, какая версия компилятора будет установлена. Например, мы можем собрать образ с nightly-версией Rust с помощью команды docker build --build-arg channel=nightly ..

Образ будет называться smaximov/rust. Всмопогательный Makefile:

  USER=smaximov
  NAME=rust
  CHANNEL=${TOOLCHAIN}

  help:
          @echo "Build rust image."
          @echo
          @echo "Usage:"
          @echo "    make [help]                      - show this message"
          @echo "    make images                      - build beta, stable, and nightly rust"
          @echo "    make push                        - push built images to the registry"
          @echo "    make (beta | stable | nightly)   - build image for specific rust channel"
          @echo "    make TOOLCHAIN=<toolchain> build - build image for specific rust toolchain"

  images: beta stable nightly

  stable:
          make TOOLCHAIN=stable build

  beta:
          make TOOLCHAIN=beta build

  nightly:
          make TOOLCHAIN=nightly build

  build:
          docker build --tag "${USER}/${NAME}:${CHANNEL}" \
                  --build-arg channel=${CHANNEL} .
  push:
          docker push "${USER}/${NAME}"

  .PHONY: images help stable beta nightly build push

Создадим stable-, beta- и nightly-образы (с соответствующими тегами) и отправим их в регистр:

  $ make images && make push
  [... build output ...]

Регистрируем runner

Теперь, когда у нас есть образы с компиляторами и установленный gitlab-ci-multi-runner, зарегистрируем runner-процесс для нашего проекта. Для этого перейдем на вкладку Runners в настройках репозитория и найдем там registration token:

images/runners-token.png
Токен

Используем этот токен для создания runner:

  $ sudo gitlab-ci-multi-runner register \
    --tag-list=rust,rust-nightly,rust-beta,rust-stable \
    --registration-token=gsexXHk9VPZnK683iXkU \
    --url=https://gitlab.com/ci \
    --executor=docker \
    --name=cirno \
    --docker-image=smaximov/rust:stable \
    --non-interactive

При создании использовались следующие опции:

tag-list
список тегов; эти теги используются для выбора runner-а;
registration-token
токен, который мы нашли на странице Runners нашего проекта;
url
адрес Gitlab CI; если мы установили свой инстанс Gitlab CI, то нужно указать его адрес; в противном случае используем публичный;
executor
указываем, что будем использовать Docker для сборок;
name
имя runner-а;
docker-image
образ Docker, используемый по умолчанию.

Созданный runner-связан с нашим репозиторием; в дальнейшем мы сможем активировать его и для других репозиториев:

images/new-runner.png
Runner cirno

Увеличим число одновременно выполняемых задач с 1 до 4, отредактировав файл /etc/gitlab-runner/config.toml

  concurrent = 4

Файл .gitlab-ci.yml

Для описаний правил сборки проекта (по аналогии с .travis.yml) используется файл .gitlab-ci.yml. Рассмотрим простейший случай:

  test:
    script:
      - cargo test
    tags:
      - rust

Здесь указывается имя test задачи (job), которая будет выполняться, вместе с её параметрами:

script
массив команд, которые должны успешно выполниться, чтобы задача выполнилась успешно;
tags
теги, которые будут использоваться для выбора подходящего runner-а.

Данная задача будет использовать образ по умолчанию Rust (smaximov/rust:stable). Один файл .gitlab-ci.yml может содержать описание нескольких задач. Для каждой задачи можно указать используемый образ с помощью параметра image:

  beta:
    image: smaximov/rust:beta
    script:
      - cargo test
    tags:
      - rust
      - rust-beta

  nightly:
    image: smaximov/rust:nightly
    script:
      - cargo test
    tags:
      - rust
      - rust-nightly

  stable:
    image: smaximov/rust:stable
    script:
      - cargo test
    tags:
      - rust
      - rust-stable

Мы определили три задачи stable, beta, nightly, каждая из которых выполняет сборку и тестирование проекта с использованием соответствующего образа.

Здесь стоит сделать небольшое отступление, чтобы затронуть тему параллельного выполнения задач. Каждая задача относится к определённой стадии (stage). Чтобы указать стадию для задачи, нужно воспользоваться параметром stage (если он не указан, считается, что задача принадлежит к стадии test).

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

  stages:
    - build
    - test
    - deploy

Процесс выполнения задач в соответствии со стадиями выглядит следующим образом:

  • сначала выполняются параллельно все задачи, связанные с первой стадией (в нашем случае, build);
  • если все задачи предыдущей стадии завершились успешно, запускается параллельное выполнение задач следующей стадии (test);
  • и так до тех пор, пока не выполнятся все стадии; если какая-либо задача любой стадии завершается с ошибкой, коммит помечается как неудачный, и процесс сборки прерывается.

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

Закоммитим этот файл и выполним push. Перейдём на страницу сборок и посмотрим результат:

images/builds.png
Список сборок

Мы видим, что для коммита 2644d185 были запущены три задачи, как и указано в .gitlab-ci.yml, причём одна из них (сборка #1082686 для стабильной версии компилятора) завершилась с ошибкой. Перейдём на страницу этой сборки и посмотрим, в чём дело. Фрагмент журнала сборки:

     [... snip ...]
     Compiling ded v0.2.0 (file:///builds/smaximov/ded)
  src/cli.rs:4:14: 4:36 error: environment variable `CARGO_PKG_NAME` not defined
  src/cli.rs:4     App::new(env!("CARGO_PKG_NAME"))
                            ^~~~~~~~~~~~~~~~~~~~~~
  src/cli.rs:6:17: 6:42 error: environment variable `CARGO_PKG_AUTHORS` not defined
  src/cli.rs:6         .author(env!("CARGO_PKG_AUTHORS"))
                               ^~~~~~~~~~~~~~~~~~~~~~~~~
  src/cli.rs:7:16: 7:45 error: environment variable `CARGO_PKG_DESCRIPTION` not defined
  src/cli.rs:7         .about(env!("CARGO_PKG_DESCRIPTION"))
                              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  error: aborting due to 3 previous errors
  Could not compile `ded`.

  To learn more, run the command again with --verbose.

  ERROR: Build failed: exit code 1

Причина ошибки в том, что на этапе компиляции мы пытаемся получить значения некоторых переменных окружения, которые были добавлены в Cargo следующих (beta, nightly) версий.

По некоторым причинам я решаю проигнорировать сборку для stable-версий компилятора, для этого я могу удалить задачу stable, но есть вариант получше. Дело в том, что Gitlab Runner игнорирует задачи, которые начинаются с точки (по аналогии со скрытыми файлами на *nix-системах), поэтому я просто переименовываю задачу stable (до лучших времён):

  # Cannot build with stable Rust at the moment
  .stable:
    image: smaximov/rust:stable
    script:
      - cargo test
    tags:
      - rust
      - rust-stable

Остальные сборки выполняются успешно.

Ссылки

  • Gitlab — хостинг репозиториев Gitlab.
  • Gitlab Runnerrunner для Gitlab CI.
  • Docker — популярная платформа контейнерной виртуализации.
  • Docker Hub — регистр образов Docker.
  • Rust — язык программирования Rust.
  • multirustversion manager для Rust.