Doze construções

Um runner de CI que compila imagens de contêiner sem root

Um runner self-hosted do Actions em uma instância Ryzen, compilando imagens OCI sem root com Buildah e enviando-as a um registro privado na mesma máquina.

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.com para o runner e registry.example.com para 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 runner

Essas 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.com

Ligue 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:5000

O 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 nginx

4. 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.yaml

Edite 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/cache

Capacidade 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:host

Entã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.target

Substitua 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-runner

5. 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-runner

Quando 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/app

Você 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 --version

Por ú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.

Pronto quando você estiver

Escolha uma cidade. Escolha um tamanho. Pague em cripto.

Sem formulários sobre quem você é, sem esperar aprovação de um humano, sem ligação para verificar nada. O pagamento é confirmado e as credenciais chegam na sua caixa de entrada.