Как развернуть свой GitLab с CI/CD, используя IaC

Всем привет! Меня зовут Александр, я обучаюсь в магистратуре СПбПУ. А заодно являюсь младшим разработчиком на C++ и стараюсь использовать и внедрять практики DevOps в мою ежедневную разработку. Недавно я получил зачет за то, что развернул собственный GitLab (именно GitLab, а не аналог) на серверах Selectel с CI/CD и Container Registry. Собственно, об этом и расскажу и в статье.
Мы в Selectel готовим новый сервис. Если арендуете серверы в рабочих или личных проектах, нам очень поможет ваш опыт — записывайтесь на короткое онлайн-интервью. За участие подарим плюшевого Тирекса и бонусы на услуги Selectel.
Используйте навигацию, если не хотите читать текст полностью:
→ Задача и инструменты
→ Поднимаем GitLab
→ Запуск GitLab Runner
→ Тестируем Франкенштейна
→ Выводы
Задача и инструменты
В процессе работы необходимо:
- развернуть GitLab,
- развернуть и зарегистрировать GitLab Runner,
- создать учетные записи для студентов из списка.
На выходе на руках должны оказаться список созданных учетных записей для потока студентов и полностью функционирующие GitLab и GitLab Runner. Все нужно описать кодом для дальнейшего переиспользования.
Для описания выделения ресурсов будем использовать Terraform. Он позволяет в формате кода разворачивать цифровые ресурсы. С точки зрения переиспользования конфигурации инфраструктуры это может быть очень полезным инструментом.
Для описания конфигурации серверов будем использовать Ansible. С его помощью можно настраивать созданные серверы путем описания конфигурационного файла с описанием необходимых компонентов.
Действия по развертыванию можно повторить самому, используя проект, опубликованный на GitHub.

Поднимаем GitLab
Перед этим шагом необходимо выделить публичный IP и зарегистрировать на него домен нашего будущего GitLab.
После того как публичный IP зарегистрирован, заходим в панель управления Selectel → Продукты → Облачные серверы.

Нажимаем Создать сервер.

Кликаем по полю Источник, переходим на вкладку Приложения и выбираем Cloud Gitlab 16.11.10 64-bit.

Использование готового образа поможет нам избежать ручной настройки БД и ручного конфигурирования GitLab. Кроме того, в образ входит конфигурация Container Registry. Она позволит хранить собранные для проекта Docker-образы.
Фактически, установка GitLab и GitLab Runner на серверах автоматизирована, необходимо лишь выделить нужное количество ресурсов под наши нужды. Это делается на той же странице чуть ниже.
Выбираем в конфигурации сервера 8 vCPU в соответствии с техническими требованиями GitLab.

Еще чуть ниже добавляем загрузочный диск (SSD Быстрый, 50 ГБ) и диск для данных (SSD Универсальный v2, 100 ГБ).

Листаем ниже до раздела Автоматизация. Здесь в поле для ввода текста вписываем скрипт cloud-init. Он позволит настроить root-пользователя и корректную временную зону.

#cloud-config
timezone: Europe/Moscow
# Start GitLab Instance
# Configure GitLab root user and DB
write_files:
- path: "/opt/gomplate/values/user-values.yaml"
permissions: "0644"
content: |
gitlabDomain: ""
gitlabRootEmail: ""
gitlabRootPassword: "
"
gitlabPostgresDB: "gitlab"
gitlabPostgresUser: "gitlab"
gitlabPostgresPassword: "gitlab"
useExternalDB: false
Теперь нажимаем Создать сервер и ждем, когда он запустится. GitLab на это нужно около пяти минут, иногда меньше. Если статус сменился на ACTIVE, сервер готов и GitLab запущен.

Все действия выше можно описать с использованием Terraform.
# Создание ключевой пары для доступа к ВМ
module "keypair" {
source = "../modules/keypair"
keypair_name = "ssh_key_ed"
keypair_public_key = file("${var.ssh_key_file}.pub")
region = var.region
}
# Создание приватной сети для ВМ
module "nat" {
source = "../modules/nat"
}
# Создание GitLab-сервера.
module "gitlab_server" {
source = "../modules/server_gitlab"
server_name = "gitlab"
server_zone = var.server_zone
server_vcpus = var.gitlab_vcpus
server_ram_mb = var.gitlab_ram_mb
server_root_disk_gb = var.gitlab_root_disk_gb
server_boot_volume_type = var.gitlab_boot_volume_type
server_volume_type = var.server_volume_type
server_image_name = var.gitlab_image_name
server_ssh_key = module.keypair.keypair_name
region = var.region
network_id = module.nat.network_id
subnet_id = module.nat.subnet_id
attached_disk_gb = var.gitlab_attached_disk_gb
public_ip = var.gitlab_public_ip
user_data = file(var.gitlab_user_data_path)
server_preemptible_tag = var.server_no_preemptible_tag
}
# Создание inventory файла для ansible
resource "local_file" "ansible_inventory" {
content = templatefile("../resources/inventory.tmpl",
{
gitlab_public_ip = module.gitlab_server.floating_ip
ssh_key_file = var.ssh_key_file
}
)
filename = "../../ansible/resources/inventory.ini"
}
Как можно заметить, мы также добавили создание inventory-файла, который содержит публичный IP создаваемого сервера, путь до SSH-ключа и SSH-порт для доступа к серверу:
[gitlab]
${gitlab_public_ip} ansible_ssh_private_key_file=${ssh_key_file} ansible_port=22022
Inventory-файл пригодится нам на следующем этапе для настройки GitLab с помощью Ansible. Пока что будем держать в голове, что он есть.
Все файлы с конфигурацией Terraform и Ansible можно посмотреть в репозитории.
Запускаем создание инфраструктуры и переходим по указанному домену. Нас встретит страница входа.

Заходим с данными root-пользователя, которые мы указали ранее, и радуемся жизни. Первый этап завершен.

У нас теперь есть свой GitLab!
Конфигурируем GitLab
При конфигурации необходимо создать группу пользователей, учетные записи пользователей и зарегистрировать раннер для группы. Для этого воспользуемся сервисом GitLab Rails, который позволяет управлять GitLab с помощью Ruby-скриптов.
Передача требуемых данных, скриптов, а также их запуск производятся посредствам Ansible.
Конфигурация группы и пользователей
Пользователей нужно создать внутри одной группы, чтобы в дальнейшем они могли использовать раннеры, привязанные к группе, а не к каждому пользователю.
def is_invalid_username(username);
is_invalid = false;
if User.find_by_username(username);
is_invalid = true;
end;
is_invalid;
End;
# Читаем список пользователей
users_list = File.read('/tmp/users.txt').split(/\n/);
unique_users = users_list.group_by { |item| item.downcase };
users_creds = {'users' => Array.new};
# Создаем группу
group_name = 'Devops' + Date.today.cwyear.to_s;
unless Group.find_by_path_or_name(group_name);
puts 'Creating group';
group = Group.create;
group.name = group_name;
group.path = group.name.downcase;
group.lfs_enabled = false;
group.add_owner(User.first);
group.save!;
end;
group = Group.find_by_path_or_name(group_name);
# Создаем пользователей
puts 'Creating users';
unique_users.each do |key, names|;
names.each_index do |index|;
name = names[index];
t_username = name.gsub(/[[:space:]]/, '').downcase;
username = t_username;
password = SecureRandom.hex(12);
if is_invalid_username(username)
i = 1;
while not is_invalid_username(username);
username = t_username + i.to_s;
i += 1;
end;
end;
user = User.new(username: "#{username}", email: "#{username}@devops-spbstu.ru", name: "#{name}", password: "#{password}", password_confirmation: "#{password}", admin: false)
user.assign_personal_namespace(Organizations::Organization.default_organization)
user.skip_confirmation! # Пропускаем авторизацию пользователя
user.save!; # Сохраняем пользователя
users_creds['users'].append({"#{user.name}" => {'login'=>"#{user.username}", "email"=>"#{user.email}", "password"=>"#{user.password}"}}) # Сохраняем данные о пользователе
group.add_developer(user) # Добавляем пользователя в группу как разработчика
end;
end;
File.write("/tmp/users-creds.yaml", users_creds.to_yaml); # Сохраняем данные о пользователях в файл
В данном случае имена пользователей читаются из файла /tmp/users.txt. В результате выполнения скрипта данные будут выглядеть следующим образом:
---
users:
- Ivan Ivanov:
login: ivanivanov
email: ivanivanov@devops-spbstu.ru
password: <секретный пароль 1>
- John Cane:
login: johncane
email: johncane@devops-spbstu.ru
password: <секретный пароль 2>

Конфигурация раннера
Для работы с CI/CD в GitLab необходимо веб-приложение (агент) — GitLab Runner. Оно интерпретирует конфигурационный файл CI/CD и автоматически выполняет описанные задачи.
Прежде чем создавать инфраструктуру под веб-приложение через Terraform, необходимо зарегистрировать новую запись об агенте в GitLab и получить ключ доступа.
GitLab Runner регистрируется для всех пользователей и может быть использован для запуска через него CI/CD конвейеров. В данном случае создаем раннер типа Docker с тегом docker:
# Создаем раннер
runner = Ci::Runner.new(description: 'My Shared Runner', active: true, name: 'my-runner' + SecureRandom.hex(4), token: SecureRandom.hex(20), runner_type: Ci::Runner::runner_types["instance_type"]);
runner.docker_executor_type!;
runner.tag_list = ['docker'];
runner.save!; # Сохраняем раннера
runner_cred = {"#{runner.name}" => {"token" => "#{runner.token}"}};
reg_data = {"url"=>"Gitlab.config.gitlab.url", "token"=>"#{runner.token}"}
# Создаем cloud-init файл с токеном раннера
data = '#cloud-config
timezone: Europe/Moscow
write_files:
- path: "/opt/gomplate/values/user-values.yaml"
permissions: "0644"
content: |
'
data += " gitlabURL: \"#{Gitlab.config.gitlab.url}\"\n"
data += " token: \"#{runner.token}\"\n"
File.write("/tmp/runner-metadata.cfg", data);
File.write("/tmp/runner-creds.yaml", runner_cred.to_yaml);
И вуаля, раннер зарегистрирован. На выходе получаем файл конфигурации для cloud-init.
#cloud-config
timezone: Europe/Moscow
write_files:
- path: "/opt/gomplate/values/user-values.yaml"
permissions: "0644"
content: |
gitlabURL: "https://gitlab.devops-spbstu.ru"
token: <супер секретный ключ 1>
А также yaml-файл с данными о ранере: название и ключ доступа.
my-runnere8d7802a:
token: <супер секретный ключ 1>
Кастуем всю магию за раз
Чтобы выполнить это не ручками, а автоматически, повторим все через Ansible.
- name: Configure Gitlab
hosts: gitlab
tasks:
# Меняем временную зону (исправляем 500 код)
- name: Changing systems timezone
community.docker.docker_container_exec:
container: gitlab
command: ln -s -f /usr/share/zoneinfo/Europe/Moscow /etc/localtime
- name: Changing gitlab timezone
community.docker.docker_container_exec:
container: gitlab
command: echo "gitlab_rails['time_zone'] = 'Europe/Moscow'" >> /etc/gitlab/gitlab.rb && gitlab-ctl reconfigure && gitlab-ctl restart
# Создаем пользователей
- name: Copy users-list to server
ansible.builtin.copy:
src: ./resources/users.txt
dest: /tmp/users.txt
- name: Copy users-list to gitlab
community.docker.docker_container_copy_into:
container: gitlab
path: /tmp/users.txt
container_path: /tmp/users.txt
#
- name: Copy users-create script to server
ansible.builtin.copy:
src: ./scripts/create_users.rb
dest: /tmp/users.rb
- name: Copy users-create script to gitlab
community.docker.docker_container_copy_into:
container: gitlab
path: /tmp/users.rb
container_path: /tmp/users.rb
- name: Create users
community.docker.docker_container_exec:
container: gitlab
command: gitlab-rails runner /tmp/users.rb
# Выгружаем данные о пользователях с сервера
- name: Load users creds to server
ansible.builtin.shell: docker cp gitlab:/tmp/users-creds.yaml /tmp/users-creds.yaml
- name: Load users creds locally
ansible.builtin.fetch:
src: /tmp/users-creds.yaml
dest: ./resources/user-creds.yaml
flat: true
# Создаем раннер для группы
- name: Copy runner-create script to server
ansible.builtin.copy:
src: ./scripts/create_runner.rb
dest: /tmp/runner.rb
- name: Copy runner-create script to gitlab
community.docker.docker_container_copy_into:
container: gitlab
path: /tmp/runner.rb
container_path: /tmp/runner.rb
- name: Create group-runner
community.docker.docker_container_exec:
container: gitlab
command: gitlab-rails runner /tmp/runner.rb
# Выгружаем данные о раннере с сервера
- name: Load runner creds from gitlab to server
ansible.builtin.shell: docker cp gitlab:/tmp/runner-creds.yaml /tmp/runner-creds.yaml
- name: Load runner creds locally
ansible.builtin.fetch:
src: /tmp/runner-creds.yaml
dest: ./resources/runner-creds.yaml
flat: true
# Выгружаем cloud-init конфиг раннера с сервера
- name: Load runner config from gitlab to server
ansible.builtin.shell: docker cp gitlab:/tmp/runner-metadata.cfg /tmp/runner-metadata.cfg
- name: Load runner config locally to terraform
ansible.builtin.fetch:
src: /tmp/runner-metadata.cfg
dest: ../terraform/resources/runner_metadata.cfg
flat: true
# Перезагружаем Gitlab
- name: Restarting gitlab
community.docker.docker_container_exec:
container: gitlab
command: gitlab-ctl restart
После запуска плейбука через Ansible произойдет магия. Учетные записи пользователей созданы, раннер зарегистрирован, осталось его запустить.

Проверяем, появился ли зарегистрированный раннер в панели администратора. Для этого возвращаемся к Terraform.
Запуск GitLab Runner
Ну что ж, мы на финишной прямой. После всех финтов ушами мы имеем файл конфигурации для раннера. Нам остается лишь установить GitLab Runner.
Рекомендации по установке GitLab Runner советуют ставить GitLab и Runner на отдельных серверах. Поэтому поднимем инфраструктуру для него через Terraform.
Возьмем за основу образ Cloud GitLab Runner 17.5.4 64-bit. Развернем его на базе сервера с 2 CPU, 4 Gb RAM и загрузочным диском (SSD).
Описание через Terraform имеет следующий вид:
# Создание ключевой пары для доступа к ВМ
module "keypair" {
source = "../modules/keypair"
keypair_name = "ssh_runner_key_ed"
keypair_public_key = file("${var.ssh_key_file}.pub")
region = var.region
}
# Создание приватной сети для ВМ
module "nat" {
source = "../modules/nat"
}
# Создание GitLab-runner сервера.
module "gitlab_runner_server" {
source = "../modules/server_gitlab_runner"
server_name = "runner"
server_zone = var.server_zone
server_vcpus = var.runner_vcpus
server_ram_mb = var.runner_ram_mb
server_root_disk_gb = var.runner_root_disk_gb
server_boot_volume_type = var.server_volume_type
server_image_name = var.runner_image_name
server_ssh_key = module.keypair.keypair_name
region = var.region
network_id = module.nat.network_id
subnet_id = module.nat.subnet_id
user_data = file(var.runner_user_data_path)
server_preemptible_tag = var.server_no_preemptible_tag
}
Запустим развертывание через Terraform. Результатом наших действий будет появившийся в панели облачной платформы сервер с GitLab Runner.

Кроме того, состояние нашего раннера должно измениться на активное в панели CI/CD в GItLab.

Раннер запустился и опознался готовым к работе в GitLab. Можно радоваться, жизнь удалась, GitLab и GitLab Runner работают!
Тестируем Франкенштейна
Запустим простой пайплайн со сборкой Python-приложения в Docker-образ через kaniko и выгрузкой в Container Registry. Также заодно запустим тесты.
Описание тестового конвейера:
stages:
- build
- test
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
build app:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "$IMAGE_TAG"
tags:
- docker
unit tests:
image: python:3.12-alpine
stage: test
before_script:
- python --version
- pip install pytest
- ls
- ls tests
script:
- python -m pytest tests/unit
needs: [ build app ]
tags:
- docker
Структура проекта содержит библиотеку (lib) и тесты на pytest (tests/unit).

Запускаем пайплайн и видим, что он успешно завершился.

Проверяем работу Container Registry и видим, что образ приложения появился в проекте.

Какие были трудности
А теперь небольшой «нытинг». Для меня задача по развертыванию GitLab оказалась новой и была неким вызовом, чтобы попробовать свои силы в области, отличной от моей основной деятельности.
Корявые временные настройки
При запуске образа были проблемы с рассинхронизацией времени сервера и контейнера с GitLab. При любой аутентификации вылетал код ошибки 500 — и живи с этим, как хочешь. Лишь после настройки через Ansible времени у GitLab получилось с этим справиться.
Отсутствие документации GitLab Rails
GitLab Rails открылся для меня очень удобным инструментом для конфигурации каждого из компонент GitLab. Однако каким бы он ни был удобным и крутым, я для него не смог найти подробной документации, описывающей все классы и модули. Справиться с выявлением нужных модулей, классов и методов помог «О, Великий гуглинг».
Выводы
На этом этапе, можно сказать, что работа по развертыванию GitLab и GitLab Runner была выполнена. Задача была интересной и, надеюсь, ее результат пригодится кому-нибудь, кто будет жалеть свое время, развертывая инфраструктуру ручками.
Проект не идеален. В будущем хотелось бы улучшить создание и связь публичного IP-адреса с доменом, а также масштабирование количества раннеров.
Всем спасибо!