Третья часть материала о CI/CD в котором мы рассмотрим работу с gitlab container registry.
О серии статей
Все найденные мной русскоязычные гайды не дают базового понимания того, как это работает, по большому счету это просто инструкции по настройке, причем под какой-то конкретный продукт и кейс: .net, Java, Node JS, etc.
Целью серии статей является детальное и схематичное описание того, как все это устроено. Главная задача — вооружить читателя фундаментальным пониманием, что конкретно ему требуется сделать в его конкретном случае. Помимо самой инструкции по настройке, это будет так же справочник для погружения в DevOps, охватывающий:
Максимально много функционала, которыми обладает Gitlab (общие абстракции актуальны и для его аналогов).
Инструменты которые нужны: Bash, Docker, Kubernetes и другие.
Общую теорию и практику с конкретными сценариями.
Если проект переходит из стадии разработки в продакшн где начинает осваивать рынок, появляется запрос на бесперебойную работу. Одним из основным требований будет исключить downtime, разгрузить production сервер от лишней нагрузки, организовать процесс оперативной обработки инцидентов, чтобы в случае доставки нестабильного релиза была возможность откатиться.
Материал разбит на несколько частей.
Настройка GitLab CI/CD: понимаем принципы работы и запускаем первый pipeline
[Вы здесь → ] Работа с registry (GitLab CI/CD)
[todo] Горизонтальное масштабирование CI/CD (высоко-нагруженный продакшн)
Оглавление
Работа с артефактами и registry в
.gitlab-ci.ymlGitlab Container Registry
-
Описание
gitlab-ci.ymlДжоба build-image
Джоба test-image
-
Возвращаемся к практике
Особенность при запуске gitlab runner в SELinux
Проверяем работу
Запуск контейнера на прод-сервере
Работа с артефактами и registry в .gitlab-ci.yml
Расширим шаблонный .gitlab-ci.yml с использованием хранилища артефактов. Представим что наше приложение работает в docker контейнере, контейнер запускается на базе образа. Образу можно назначить версию и хранить его в registry. Это позволит хранить историю наших релизов в проде и в случае проблемы откатываться. Еще одним преимуществом будет то, что мы можем запускать сборку и тесты на отдельном сервере, отдельно от production сервера. В таком случае на прод сервере будет достаточно просто загрузить артефакт и запустить его.
У нас есть 2 варианта как мы поступим.
-
Мы можем создавать артефакт в виде Docker образа и сохранить его в файл. Но это больше подойдет для временного хранения файлов и передачи их между джобами. Если пойти таким путем получиться что-то такое:
build-image: stage: build script: - docker build -t myapp:latest . - docker save myapp:latest -o myapp.tar # образ → файл artifacts: paths: - myapp.tar expire_in: 2 weeks # будет храниться 2 недели, # затем очиститься чтобы не засорять хранилище Gitlab поддерживает специальное хранилище для Docker обзаров: GitLab Container Registry. Именно его мы и будем использовать.
Gitlab Container Registry
Сделаем имитацию нашего приложения в виде bash скрипта который просто выводит какое-то сообщение в консоль:
my-app.sh
#!/bin/bash echo "Message from my-app.sh v1.0.0: Hello world!"
И добавим простейший Dockerfile для запуска нашего приложения:
Dockerfile
FROM alpine:3.23 # Копируем скрипт в контейнер COPY my-app.sh /my-app.sh # Делаем его исполняемым RUN chmod +x /my-app.sh # Запускаем скрипт при старте контейнера CMD ["/my-app.sh"]
Описание gitlab-ci.yml
Теперь опишем .gitlab-ci.yml который будет делать сборку контейнера и проверять его работоспособность в двух разных stages. Так же реализуем автоматическое версионирование наших релизов. Но наша семантика версионирования будет не по стандарту semver, так как semver больше подходит для библиотек. Мы будем использовать в качестве версии дату в формате: YYYY-MM-DD-HH-MM. Именно такой формат удобен тем что он сразу дает понимание о дате релиза и в случае сортировки артефактов по алфавиту сохраняет правильную хронологию.
stages: - build - test default: image: docker:29.4 build-image: stage: build script: - IMAGE_TAG=$(date +%Y-%m-%d-%H-%M) - echo "Версия сборки — $IMAGE_TAG" - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$IMAGE_TAG . - docker push $CI_REGISTRY_IMAGE:$IMAGE_TAG - docker tag $CI_REGISTRY_IMAGE:$IMAGE_TAG $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest test-image: stage: test script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker run --rm $CI_REGISTRY_IMAGE:latest
Подробный разбор всех команд из script
Джоба build-image
IMAGE_TAG=$(date +%Y-%m-%d-%H-%M)
Определяем переменную IMAGE_TAG, в которой будет храниться дата в формате YYYY-MM-DD-HH-MM.
$(...)
Выполняет команду внутри скобок и подставляет её stdout в качестве значения.
date +%Y-%m-%d-%H-%M
date — утилита вывода времени. Умеет принимать шаблон нужного формата вывода.
+ — указывает что мы хотим именно посмотреть время, а не установить, далее идет шаблонизация.
Используемые “токены” в шаблоне:
%Y— год, например2026%m— месяц, например04%d— день, например10%H— часы, например15%M— минуты, например42
Возможные токены шаблона и остальные возможности утилиты.
echo "Версия сборки — $IMAGE_TAG"
Выводит сообщение со значением переменной $IMAGE_TAG, которая будет использована для установки тега.
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
Авторизация в Docker-клиенте с указанием предустановленных переменных (predefined CI/CD variables) в gitlab ci.
docker login — команда входа в Docker registry
-u $CI_REGISTRY_USER — указываем имя пользователя
-p $CI_REGISTRY_PASSWORD — указываем пароль
$CI_REGISTRY — указываем адрес контейнерного registry
Все переменные gitlab сам устанавливает, мы их только читаем.
docker build -t $CI_REGISTRY_IMAGE:$IMAGE_TAG .
docker build -t <...> . — запускаем сборку указывая в качестве build context текущую директорию (точка . на конце указывает на текущую директорию). Build context — это набор всех файлов к которым нужен доступ на этапе сборке. Мы предоставляем полностью всю текущую директорию, но более правильно было бы контролировать какие файлы нужны, а какие следует игнорировать. Сделать это можно через .dockerignore (документация).
-t $CI_REGISTRY_IMAGE:$IMAGE_TAG — задает тег образа в формате имя:тэг.
$CI_REGISTRY_IMAGE — Гитлаб подставит что-то вроде registry.gitlab.com/mygroup/myproject
$IMAGE_TAG — мы определили ранее, получиться например 2026-04-10-15-42
Итого будет подставлено что-то вроде: registry.gitlab.com/mygroup/myproject:2026-04-10-15-42.
docker tag $CI_REGISTRY_IMAGE:$IMAGE_TAG $CI_REGISTRY_IMAGE:latest
Для того же самого локального образа, мы устанавливаем еще один тег :latest. Если мы только что создали например: registry.gitlab.com/mygroup/myproject:2026-04-10-15-42, то мы даем ему так же имя registry.gitlab.com/mygroup/myproject:latest.
docker push $CI_REGISTRY_IMAGE:latest
Публикуем в registry так же наш тег latest. Таким образом в registry есть 1 образ с 2 именами. Когда джоба отработает повторно, она перезапишет указатель latest на более новый образ. Это позволяет нам всегда иметь доступ к последнему артефакту через тег latest. Можно использовать любое название, не обязательно именно latest, но это общепринятый подход нейминга докер образов.
Джоба test-image
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
В джобе test-image мы снова должны авторизоваться в registry, как изображалось ранее на схеме (в первой части статьи или ниже в текущей), под каждую джобу создается отдельный docker контейнер.
docker run --rm $CI_REGISTRY_IMAGE:latest
docker run — создать и запустить контейнер
--rm — после завершения контейнера автоматически удалить его
$CI_REGISTRY_IMAGE:latest — ссылаемся на тот самый latest который мы создали для удобства
На первый взгляд все логично. Но работать такая конфигурация в рамках shared runners не будет.
На шаге docker build мы получим ошибку:
$ docker build -t $CI_REGISTRY_IMAGE:$IMAGE_TAG . ERROR: failed to connect to the docker API at tcp://docker:2375: lookup docker on 169.254.169.254:53: no such host
Причина в том что мы используем image: docker, но это не полноценный докер, а лишь его cli клиент который обращается к docker daemon. Именно ошибку обращения к docker daemon мы и видим.
Shared runner не предоставил нам работающий docker daemon, который готов принимать команды от CLI-клиента. Несмотря на то что он есть и он запускает наши джобы.
Если мы хотим использовать docker-cli в наших джобах, то мы вынуждены следовать одному из двух вариантов:
Использовать рутовый docker daemon (не dind)
Поднимать еще один docker daemon, но конкретно для джоб (dind)
На этом моменте важно понимать, как устроен docker-cli и docker daemon. Есть 2 контейнера:
Docker daemon (dind, например образ
docker:29-dind)Docker CLI (например образ
docker:29-cliили без суффиксаcli)
Чтобы CLI-клиент, отправил какую-то команду, ему нужно связаться с docker daemon. Связь может осуществляться 2-мя способами:
ip + port
docker.sockфайл (linux socket — это альтернатива ip + port, но в виде файла)
Но чтобы указать способ связи (ip+port или docker.sock) с docker daemon, нужно изменять конфигурации ранера, что нам недоступно в контексте shared runner. Если мы хотим иметь доступ к конфигурации ранера, менять его executor и прочие параметры, нужно установить свой self-hosted gitlab runner.
Моя инструкция по настройке self-hosted gitlab runner.
На самом деле, есть еще несколько альтернативных путей
Использовать kaniko — он был наиболее предпочтительным, так как позволял легко сделать билд без запуска привилегированного режима docker. По каким-то причинам поддержка kaniko прекратилась (issue). По этому его отбрасываем.
Использовать buildah, чем-то похоже на kaniko, преподноситься как его альтернатива. Но меня этот вариант тоже отталкивает — судя по issues есть подводные камни которые вынуждают все равно использовать привилегированный режим (что приравнивает его к широко распространенному dind).
Здесь уместно вспомнить схему из первой части данной серии статей.

Эта схема правдива в том случае, если GitLab Runner установлен напрямую на host сервера. Я же предпочитаю поднимать Gitlab Runner в Docker контейнере, как я описывал это в своей инструкции. Теперь актуализируем схему для двух вариантов, которые озвучены выше.
Использование рутового docker daemon (не dind)

В данном случае мы не поднимаем отдельный docker daemon, команды CLI будут направлены к хостовому docker’у, соответственно это будет аффектить рутовую машину. Все сборки будут использовать один общий кэш хоста. Изоляции джобов не будет, это может вызвать race condition (состояние гонки), например если две параллельные джобы попытаются удалить или использовать один и тот же образ.
Но есть плюс — джобы будут быстрее отрабатывать и конфигурация будет проще.
Пример конфигурации `config.toml`
concurrent = 1 check_interval = 0 [[runners]] name = "laptop-dc" url = "https://gitlab.com/" token = "ВАШ_РАННЕР_ТОКЕН_ИЗ_НАСТРОЕК_GITLAB" executor = "docker" [runners.docker] image = "alpine:3.22.4" privileged = false disable_entrypoint_overwrite = false disable_cache = false volumes = [ "/cache", "/var/run/docker.sock:/var/run/docker.sock" ]
Главное здесь: "/var/run/docker.sock:/var/run/docker.sock".
Такой способ с отсутствием изоляции меня не устраивает, как и большинство разработчиков. По этой причине рассмотрим следующую схему, именно ее мы и реализуем на практике.
Использование отдельного docker daemon, но конкретно для джоб (dind)

В данном варианте в целях изоляции запускается внутренний docker daemon, рутовый docker daemon и его контейнеры живут отдельно. Может показаться, что с точки зрения безопасности этот вариант предпочтительнее, но это не совсем так. Чтобы поднять полноценный docker daemon внутри контейнера, необходимо запускать контейнер в привилегированном режиме.
Впрочем, расшаривать соединение с основным docker daemon через тот же docker.sock файл — та же самая брешь в безопасности. Тот, кто забрался внутрь пайплайна, сможет забраться внутрь нашего сервера (container escape подробно на анг.). Дополнительным слоем безопасности будет только переход на podman. Подробнее я писал в статье: “Root в контейнере — это root на хосте? Разбираю важные особенности прав доступов в контейнерах Docker/Podman”
Возвращаемся к практике
Чтобы иметь больше возможностей и был доступ к более тонкой конфигурации, мы запустили собственный раннер на своем сервере. Он будет запускать весь пайплайн.
После готовности нашего runnera и успешного его коннекта с gitlab.com пробуем запустить пайплайн, но так, что бы его выполнял наш self hosted runner. Для этого должен совпадать тег указанный в раннере. В инструкции я использовал тег laptop-dc. Поэтому в gitlab-ci.yml должен фигурировать этот тег для джобов.

В gitlab-ci.yml, к блоку default, нужно добавить вспомогательный сервис с docker daemon:
default: image: docker:29.4-cli services: - docker:29.4-dind
Теперь, чтобы cli и dind смогли поимать соединение друг с другом, нужно указать, по какому адресу следует подключаться:
Перед блоком default, добавляем 2 переменные:
variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: ""
Итоговая конфигурация gitlab-ci.yml
stages: - build - test variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: "" default: image: docker:29.5.3-cli services: - docker:29.5.3-dind build-image: tags: - laptop-dc stage: build script: - IMAGE_TAG=$(date +%Y-%m-%d-%H-%M) - echo "Версия сборки — $IMAGE_TAG" - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$IMAGE_TAG . - docker push $CI_REGISTRY_IMAGE:$IMAGE_TAG - docker tag $CI_REGISTRY_IMAGE:$IMAGE_TAG $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest test-image: tags: - laptop-dc stage: test script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker run --rm $CI_REGISTRY_IMAGE:latest
Не забываем что нужно отредактировать Runner конфиг (config.toml) на возможность запуска dind выставив привилегированный режим:
concurrent = 1 check_interval = 0 [[runners]] name = "laptop-dc" url = "https://gitlab.com/" token = "ВАШ_ТОКЕН" executor = "docker" [runners.docker] image = "alpine:3.22.4" privileged = true tls_verify = false disable_entrypoint_overwrite = false oom_kill_disable = false disable_cache = false volumes = ["/cache"]
Основная правка:
privileged = true tls_verify = false
Особенность при запуске gitlab runner в SELinux
Если у вас обычный ubuntu (не SELinux), то вам достаточно иметь такой volume: /var/run/docker.sock:/var/run/docker.sock
Но мой случай сложнее, так как я запускаю gitlab runner в контейнере podman на fedora (SELinux). В моем случае, docker.sock отсутствует и вместо него нужно пробрасывать такой волум:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:z
Где 1000 это user id под которым я запускаю контейнер с раннером (получить можно командой id -u).
Но и этого недостаточно, так как в пайплайне мы получим ошибку еще до того, как начнут выполнять команды из наших джобов:
ERROR: Preparation failed: getting docker info: permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/info": dial unix /var/run/docker.sock: connect: permission denied (docker.go:1218:0s)
Это связано с тем, что docker executor от gitlab-runner, не может подготовить контейнер внутри которого будет запущена джоба. Причина — нет прав на подключение. Посмотрим записи ли записи в журнале политик доступа SELinux.
sudo ausearch -m avc
В выводе видим:
type=AVC msg=audit(1780685305.517:1634): avc: denied { connectto } for pid=3001141 comm="gitlab-runner" path="/run/user/1000/podman/podman.sock" scontext=system_u:system_r:container_t:s0:c635,c686 tcontext=unconfined_u:unconfined_r:container_runtime_t:s0-s0:c0.c1023 tclass=unix_stream_socket permissive=0
Пробуем включить режим Permissive (без запретов, но с логированием не разрешенных действий).
gtosss@laptop-dc:~/inf-config$ getenforce Enforcing gtosss@laptop-dc:~/inf-config$ sudo setenforce 0 gtosss@laptop-dc:~/inf-config$ getenforce Permissive
В UI gitlab перезапускаем пайплайн и видим, что проблема решилась.
Теперь нужно расширить политики доступа, чтобы можно было вернуться в Enforcing режим.
Я записал строку с отказом, которую получил в sudo ausearch -m avc, в файл audit.log.
Устанавливаем необходимые исходники политик для генерации нашего модуля:
sudo yum install selinux-policy-devel
Теперь генерируем политику:
audit2allow -i audit.log -R > gitlab_runner_connect_for_dind.te
Получился такой результат:
require { type container_t; } #============= container_t ============== container_stream_connect(container_t)
Как узнать, что такое container_stream_connect
Утилита macro-expander позволяет сгенерировать на основе шаблона конкретные разрешения container_stream_connect:
macro-expander "container_stream_connect(container_t)" > macros_output.te
Теперь в файле macros_output.te можно ознакомиться со всеми allow правилами.
На первой строке допишем название модуля итого получим:
policy_module(gitlab_runner_connect_for_dind, 1.0) require { type container_t; } #============= container_t ============== container_stream_connect(container_t)
Компилируем через make:
make -f /usr/share/selinux/devel/Makefile gitlab_runner_connect_for_dind.pp
И устанавливаем:
sudo semodule -i gitlab_runner_connect_for_dind.pp
Так можно убедиться, что модуль установлен:
sudo semodule -l | grep gitlab_runner_connect_for_dind
Теперь можно вернуться в безопасный режим:
sudo setenforce 1
Перезапускаю пайплайн и все работает, значит политика работает.
Проверяем работу
Я запустил пайплайн 2 раза. Поэтому я ожидаю 3 контейнера в Gitlab Container Registry: 2 с конкретным тегом и 1 с тегом latest.

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

Путь к моему контейнеру выглядит так:
registry.gitlab.com/webstap/self-server-config:2026-06-05-22-07
Запуск контейнера на прод-сервере
Напишем простейший bash скрипт для авторизации в gitlab docker registry, назовем его docker-login-gitlab-registry.bash:
#!/usr/bin/env bash echo "$CI_DEPLOY_PASSWORD" | docker login $CI_REGISTRY -u $CI_DEPLOY_USER --password-stdin
Теперь нам нужно получить пользователя и пароль для авторизации. Заходим в UI Gitlab.


Нам отобразится пользователь и пароль, с помощью которых можно получить доступ к registry, запишем их в файл variables.sh.
export CI_REGISTRY="registry.gitlab.com" export CI_DEPLOY_USER="<ПОЛУЧЕННЫЙ_DEPLOY_USER>" export CI_DEPLOY_PASSWORD="ПОЛУЧЕННЫЙ_DEPLOY_PASSWORD"
Оба файла положим в директорию, например user-gitlab-registry. Теперь достаточно перейти в эту директорию через cd и выполнить команду:
source variables.sh
Эта команда установит все переменные окружения в текущий shell process. Теперь достаточно запустить docker-login-gitlab-registry.bash:
./docker-login-gitlab-registry.bash
Пробуем запустить образ:
docker run registry.gitlab.com/webstap/self-server-config:2026-06-05-22-07
$ docker run registry.gitlab.com/webstap/self-server-config:2026-06-05-22-07 Unable to find image 'registry.gitlab.com/webstap/self-server-config:2026-06-05-22-07' locally 2026-06-05-22-07: Pulling from webstap/self-server-config 11ab09ea930b: Pull complete a9b65603287c: Pull complete Digest: sha256:fecc87f73faf11a953caa6479490c8551727b49c0eff024d65080b1a36d87fc0 Status: Downloaded newer image for registry.gitlab.com/webstap/self-server-config:2026-06-05-22-07 Message from my-app.sh v1.0.0: Hello world!
Спасибо, что дочитали!
Кто уже подписан на телеграм-канал — отдельная благодарность. Помимо технических статей поднимаем и социально важные темы.
Если материал полезен и изложен понятным языком — не стесняйтесь ставить плюсы. Это поможет выделиться на фоне остальных статей, так же это хороший способ поддержать меня.
С вами был Тимофей. Кто я?
Разрабатываю с 2015 года. Стартовал как front-end разработчик на React, после 6-лет переключился на full-stack, последние годы — чаще DevOps. Мой публичный WakaTime.
Комментарии (5)

TheBakerCat
07.06.2026 13:47В продакшн, я бы, наверное, предпочёл buildkit. DinD хорошо, но прокидывать сокет звучит сомнительно.
rootless buildkit тоже требует некоторых ухищрений с apparmor, но в целом выглядит интереснее.

tooshcan4ik
07.06.2026 13:47почему не канико? кроме того то это почти легаси, по сути, закрывает большинство проблем и дыр

Granulex
07.06.2026 13:47Отличная серия. `privileged: true` в CI – не изоляция, а делегирование хоста пайплайну. Kaniko и rootless Buildkit существуют именно чтобы этого не делать.
ky0
У вас хоть раз в реальной эксплуатации возникали подобные проблемы? А то выглядит надуманным, особенно про использование одного образа несколькими процессами.
tooshcan4ik
race condition не видел, но вот как стянуть енвы всех под куба.... это да, красиво было..