O que este guia constrói
Um runner de CI conectado à sua própria forge, que faz checkout de um repositório, constrói uma imagem de contêiner sem daemon e sem root, e envia o resultado para um registry privado rodando na mesma instância. Nenhum socket privilegiado é montado em lugar algum, e nada no pipeline roda como uid zero.
A maioria das configurações de CI self-hosted resolve a construção de imagens montando o daemon de contêineres do host no job. Isso funciona, mas dá a todo pipeline — incluindo aquele contra o qual alguém abriu um pull request — controle total da máquina. Buildah em modo rootless faz o mesmo trabalho sem nada disso, e em núcleos dedicados não é mais lento.
Antes de começar
- Um R-8. Builds de imagem são a coisa mais dependente de núcleo único que a maioria dos times roda, e Zen 5 é o mais rápido por núcleo que vendemos. Quatrocentos gigabytes de NVMe comporta muitas camadas.
- Uma forge que você já roda e que fala o protocolo Actions, e permissão para criar um token de registro de runner nela.
- Hostnames:
ci.example.compara o runner eregistry.example.compara o registry.
1. Um usuário sem privilégios com uma faixa de namespaces
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 runnerEssas duas faixas são o que torna os contêineres rootless possíveis: a conta runner possui sessenta e cinco mil ids subordinados, então um processo que acredita ser root dentro de um contêiner é mapeado para um id sem privilégios fora dele. Lingering mantém a sessão do usuário ativa para que units do systemd sob essa conta sobrevivam ao logout.
Node é instalado porque a maioria das actions reutilizáveis são JavaScript e o runner as executa no host nesta configuração. Descobrir isso no passo do checkout é um desvio comum de dez minutos.
2. Armazenamento rootless na 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}}"Esse último comando deve responder overlay true. Um driver vfs em vez disso significa que falta fuse-overlayfs, e vfs copia cada camada inteira em todo build, o que transforma um pipeline de noventa segundos em um de seis minutos.
3. Um registry privado
apt install -y docker-registry
htpasswd -c /etc/docker/registry/htpasswd ci
certbot certonly --standalone -d registry.example.comLigue o registry ao loopback e deixe o nginx ser dono de tudo que olha para fora, inclusive autenticação. Em /etc/docker/registry/config.yml:
version: 0.1
storage:
filesystem:
rootdirectory: /srv/registry
delete:
enabled: true
http:
addr: 127.0.0.1:5000O próprio registry não carrega credenciais porque nunca recebe uma requisição que não tenha passado pelo proxy. Um lugar para verificar uma senha é melhor que dois lugares que podem discordar.
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 remove o limite de upload. Mantenha o padrão do nginx e toda camada acima de um megabyte falha com um 413 cerca de dois terços do caminho de um push, o que é uma tarde memorável.
systemctl enable --now docker-registry nginx4. O runner
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.yamlEdite o arquivo gerado para que os jobs rodem no host em vez de dentro de um contêiner, porque Buildah já fornece o isolamento e o aninhamento dos dois não adiciona nada além de complexidade:
runner:
capacity: 2
timeout: 1h
labels:
- "debian-13:host"
host:
workdir_parent: /home/runner/work
cache:
enabled: true
dir: /home/runner/cacheCapacidade dois em oito núcleos é deliberada: builds são em grande parte seriais, e dois jobs concorrentes com quatro núcleos cada terminam mais cedo do que quatro jobs brigando pelo mesmo cache. Registre-se na sua forge com o token que ela gerou:
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:hostEntão uma unit, rodando como a conta sem privilégios:
[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.targetSubstitua o uid real da conta runner em XDG_RUNTIME_DIR; id -u runner o imprime. Podman rootless precisa que esse diretório exista, que é o que a configuração de linger no passo um garante.
systemctl daemon-reload && systemctl enable --now act-runner5. Um workflow que constrói e envia
No repositório, em .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 ativa o cache de camadas, que é a diferença entre reconstruir suas dependências em todo commit e reconstruí-las quando elas mudam.
Verifique
O runner deve aparecer como online na lista de runners da forge em poucos segundos depois que a unit iniciar. Então faça um commit e acompanhe o job da máquina:
journalctl -fu act-runnerQuando terminar, confirme que a imagem realmente chegou em vez de apenas relatar sucesso:
skopeo inspect --creds ci:<password> docker://registry.example.com/app:latest | head -20
skopeo list-tags --creds ci:<password> docker://registry.example.com/appVocê quer o digest, a lista de camadas e ambas as tags. Agora prove que ela roda, em uma máquina diferente se você tiver uma à mão:
podman run --rm registry.example.com/app:latest --versionPor último, a afirmação em que todo este build se apoia. Enquanto um job está rodando, olhe quem é o dono dos processos:
ps -eo user,pid,comm | grep -E "buildah|podman" | head
sudo -u runner podman info --format "{{.Host.Security.Rootless}}"Todo processo pertence a runner, e a verificação de segurança responde true. Nada no pipeline detém root, o que significa que um script de build comprometido obtém uma conta sem privilégios e um namespace, não suas chaves de registry e seu hypervisor.
Depois
O armazenamento do registry cresce sem limite a menos que algo remova tags antigas, então rode registry garbage-collect em um timer semanal assim que você tiver uma regra de retenção em que acredite. Se os builds se tornarem o gargalo em vez dos testes, a página de comparação mostra como é o próximo tamanho; mais núcleos ajudam muito menos do que a maioria espera, e núcleos mais rápidos ajudam muito mais.