Gitlab CI для Rust (и не только)
Содержание
Методы непрерывной интеграции (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
; кроме того, несколько тегов могут указывать на один образ.
Контейнер (container) — это запущенный экземпляр образа. Контейнер добавляет тонкий доступный для записи слой поверх слоёв образа. Все изменения, сделанные в запущенном контейнере (например, создание, редактирование и удаления файлов), записываются в этот слой.
Для управления образами и контейнерами предназначена утилита 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:
Используем этот токен для создания 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-связан с нашим репозиторием; в дальнейшем мы сможем активировать его и для других репозиториев:
Увеличим число одновременно выполняемых задач с 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. Перейдём на страницу сборок и посмотрим результат:
Мы видим, что для коммита 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 Runner — runner для Gitlab CI.
- Docker — популярная платформа контейнерной виртуализации.
- Docker Hub — регистр образов Docker.
- Rust — язык программирования Rust.
- multirust — version manager для Rust.