Что получается в итоге
CI-раннер, привязанный к вашему собственному форджу (forge), который клонирует репозиторий, собирает контейнерный образ без демона и без root, и отправляет результат в приватный реестр, работающий на том же инстансе. Нигде не монтируется привилегированный сокет, и ни один процесс в пайплайне не работает с uid 0.
Большинство самостоятельных CI-установок решают проблему сборки образов, монтируя демон контейнеров хоста в задание. Это работает, но это даёт каждому пайплайну, включая тот, к которому кто-то открыл pull request, полный контроль над машиной. Buildah в rootless-режиме делает то же самое, но без этого, и на выделенных ядрах он не медленнее.
Перед началом
- Инстанс R-8. Сборка образов — самое зависимое от одного ядра занятие у большинства команд, и Zen 5 — самый быстрый на ядро среди того, что мы продаём. Четырёхсот гигабайт NVMe хватит на очень много слоёв.
- Фордж на вашей стороне, говорящий на протоколе Actions, и разрешение создать токен регистрации раннера в нём.
- Имена хостов:
ci.example.comдля раннера иregistry.example.comдля реестра.
1. Непривилегированный пользователь с диапазоном имён пространств
apt update && apt install -y podman buildah skopeo fuse-overlayfs uidmap slirp4netns git nodejs nginx apache2-utils
useradd -m -s /bin/bash runner
echo "runner:200000:65536" >> /etc/subuid
echo "runner:200000:65536" >> /etc/subgid
loginctl enable-linger runnerЭти два диапазона позволяют работать rootless-контейнерам: у аккаунта runner есть шестьдесят пять тысяч подчинённых идентификаторов, так что процесс, считающий себя root внутри контейнера, отображается на непривилегированный идентификатор снаружи. Параметр linger ("задержка при выходе") держит пользовательскую сессию живой, чтобы systemd-юниты от этого аккаунта пережили выход из системы.
Node установлен, потому что многие переиспользуемые actions написаны на JavaScript, и раннер выполняет их на хосте в этой конфигурации. Обнаружить это на шаге checkout — обычный десятиминутный зигзаг.
2. Rootless-хранилище на NVMe
sudo -u runner mkdir -p /home/runner/.config/containers
sudo -u runner tee /home/runner/.config/containers/storage.conf <<EOF
[storage]
driver = "overlay"
graphroot = "/home/runner/.local/share/containers/storage"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
sudo -u runner podman info --format "{{.Store.GraphDriverName}} {{.Host.Security.Rootless}}"Последняя команда должна ответить overlay true. Если предпочтён драйвер vfs, значит fuse-overlayfs отсутствует, а vfs копирует каждый слой целиком при каждой сборке, превращая полутораминутный пайплайн в шестиминутный.
3. Приватный реестр
apt install -y docker-registry
htpasswd -c /etc/docker/registry/htpasswd ci
certbot certonly --standalone -d registry.example.comПривяжите реестр к loopback, и пусть nginx владеет всем, что смотрит наружу, включая аутентификацию. В /etc/docker/registry/config.yml:
version: 0.1
storage:
filesystem:
rootdirectory: /srv/registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000Сам реестр не несёт никаких учётных данных, потому что он никогда не получает запрос, не прошедший через прокси. Одно место для проверки пароля лучше двух, которые могут расходиться.
server {
listen 443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
auth_basic "restricted";
auth_basic_user_file /etc/docker/registry/htpasswd;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900s;
}
}client_max_body_size 0 снимает ограничение на размер загрузки. Оставьте значение nginx по умолчанию, и каждый слой больше мегабайта будет падать с 413 примерно на двух третях пути при загрузке — это запоминающийся вечер.
systemctl enable --now docker-registry nginx4. Раннер
cd /usr/local/bin
wget -O act_runner https://code.forgejo.org/forgejo/runner/releases/download/v6.3.1/forgejo-runner-6.3.1-linux-amd64
chmod +x act_runner
sudo -u runner mkdir -p /home/runner/.runner-cfg
cd /home/runner/.runner-cfg && sudo -u runner /usr/local/bin/act_runner generate-config > config.yamlОтредактируйте сгенерированный файл так, чтобы задания выполнялись на хосте, а не внутри контейнера, потому что Buildah уже даёт изоляцию, а вложенность не добавляет ничего, кроме сложности:
runner:
capacity: 2
timeout: 1h
labels:
- "debian-13:host"
host:
workdir_parent: /home/runner/work
cache:
enabled: true
dir: /home/runner/cacheЁмкость два на восьми ядрах — это осознанный выбор: сборки в основном последовательны, и два параллельных задания по четыре ядра каждое справляются быстрее, чем четыре задания, соперничающих за кэш. Зарегистрируйтесь на вашем фордже с токеном, который он сгенерировал:
cd /home/runner/.runner-cfg
sudo -u runner /usr/local/bin/act_runner register --no-interactive \
--instance https://forge.example.com --token <registration token> \
--name ci-ams --labels debian-13:hostЗатем юнит, работающий от непривилегированного аккаунта:
[Unit]
Description=Actions runner
After=network-online.target
[Service]
User=runner
WorkingDirectory=/home/runner/.runner-cfg
ExecStart=/usr/local/bin/act_runner daemon --config /home/runner/.runner-cfg/config.yaml
Restart=always
Environment=HOME=/home/runner
Environment=XDG_RUNTIME_DIR=/run/user/3001
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.targetПодставьте реальный uid аккаунта runner в XDG_RUNTIME_DIR; id -u runner выводит его. Rootless-режиму podman нужна эта директория, которую гарантирует настройка linger из первого шага.
systemctl daemon-reload && systemctl enable --now act-runner5. Workflow, который собирает и публикует
В репозитории в .forgejo/workflows/image.yaml:
on:
push:
branches: [main]
jobs:
image:
runs-on: debian-13
steps:
- uses: actions/checkout@v4
- name: Build
run: |
buildah bud --layers --format oci -t app:${{ github.sha }} .
- name: Push
run: |
buildah login -u ci -p ${{ secrets.REGISTRY_PASSWORD }} registry.example.com
buildah push app:${{ github.sha }} docker://registry.example.com/app:${{ github.sha }}
buildah push app:${{ github.sha }} docker://registry.example.com/app:latest--layers включает кэширование слоёв, и это разница между пересборкой зависимостей при каждом коммите и пересборкой только когда они меняются.
Проверка
Раннер должен появиться в списке раннеров форджа как онлайн в течение нескольких секунд после запуска юнита. Затем отправьте коммит и наблюдайте за заданием с машины:
journalctl -fu act-runnerКогда оно завершится, подтвердите, что образ действительно прибыл, а не просто отчитался об успехе:
skopeo inspect --creds ci:<password> docker://registry.example.com/app:latest | head -20
skopeo list-tags --creds ci:<password> docker://registry.example.com/appВам нужен digest, список слоёв и оба тега. Теперь докажите, что он работает, на другой машине, если есть под рукой:
podman run --rm registry.example.com/app:latest --versionИ последнее — утверждение, на котором строится вся эта схема. Пока задание выполняется, посмотрите, кто владеет процессами:
ps -eo user,pid,comm | grep -E "buildah|podman" | head
sudo -u runner podman info --format "{{.Host.Security.Rootless}}"Каждый процесс принадлежит runner, и проверка безопасности отвечает true. Ни в одном пайплайне нет root, а значит, скомпрометированный скрипт сборки получает непривилегированный аккаунт и пространство имён, а не ключи реестра и гипервизор.
После
Хранилище реестра растёт без ограничений, если что-то не удаляет старые теги, поэтому запускайте registry garbage-collect по еженедельному таймеру, как только у вас появится правило хранения, в которое вы верите. Если узким местом становятся сборки, а не тесты, страница сравнения показывает, как выглядит следующий размер; больше ядер помогает гораздо меньше, чем ожидают, а быстрее — гораздо больше.