Executando verificação de segurança...
4

Podman Rootless em Produção: Substituindo Docker

Introdução#

Podman rootless em produção deixou de ser uma curiosidade técnica e virou uma exigência prática em ambientes que precisam atender a controles de compliance como CIS Benchmarks, PCI-DSS e LGPD. Quando rodo containers Docker em um servidor, o dockerd precisa de um socket privilegiado e qualquer usuário no grupo docker é, na prática, root na máquina. Em auditorias internas eu vi esse ponto barrar deploys inteiros, e a resposta mais limpa que encontrei nos últimos dois anos foi migrar para Podman rodando sem daemon e sem privilégios.

A tese deste artigo é direta: Podman rootless substitui Docker em produção sem perda funcional na maioria dos cenários web e de workers, desde que você aceite três trocas explícitas — usar systemd (via Quadlet) no lugar de docker compose, mapear UIDs com subuid/subgid, e tratar redes e volumes pensando em namespaces de usuário. Em troca, o servidor passa a ser auditável: nenhum processo de container roda como root no host, o blast radius de uma RCE cai drasticamente e a superfície de ataque do daemon simplesmente some.

Vou mostrar como saí de um host Docker tradicional para um host Podman 5.x rootless rodando workloads .NET, n8n e Postgres em produção, qual é a estrutura de diretórios que sobreviveu a três meses de operação, como fica o pipeline de deploy via SSH e quais armadilhas custam horas se você não souber onde olhar (porta 80, linger, pasta networking, SELinux). Tudo testado em Ubuntu 24.04 LTS e RHEL 9 com crun como runtime padrão.

Pré-requisitos#

  • Linux com kernel ≥ 5.13 (Ubuntu 22.04+, Debian 12+, RHEL 9+)
  • Podman ≥ 4.4 (para suporte completo a Quadlet); idealmente 5.x
  • Usuário não-root com subuid/subgid configurados
  • systemd em modo user habilitado (loginctl enable-linger <user>)
  • Conhecimento básico de Dockerfile e docker compose

Por Que Rootless Não É “Docker com Outro Nome”#

A confusão mais comum que encontro é tratar Podman como um drop-in de Docker apenas porque alias docker=podman funciona. Funciona para run, build e pull, mas o modelo de execução é radicalmente diferente.

Docker depende de um daemon (dockerd) que escuta em /var/run/docker.sock rodando como root. Esse daemon é quem executa os containers. Se um atacante consegue qualquer escrita no socket, ele consegue um container --privileged montando / do host — game over. É por isso que CIS Docker Benchmark 1.2.x dedica uma seção inteira ao socket.

Podman é daemonless. Cada podman run é um processo filho do seu shell (ou do systemd --user). Não existe socket privilegiado por padrão. Em modo rootless, o container roda dentro de um user namespace mapeado via subuid/subgid: o UID 0 dentro do container é, na verdade, um UID alto e sem privilégios no host (ex: 100000). Se o processo escapa do container, ele aterrissa como um usuário comum sem permissão de escrita em quase nada.

ℹ️ Informação: Em testes do Red Hat com CVE-2019-5736 (escape clássico do runc), o exploit em Docker rootful conseguia sobrescrever o binário do runc no host. Em Podman rootless o mesmo exploit aterrissa como UID 100000 sem permissão de escrita — o container “escapa”, mas não causa dano.

Essa diferença muda como você pensa em backups, logs e orquestração. Logs de container rootless vão para o journal do usuário, não para /var/log/. Backups de volumes ficam em ~/.local/share/containers/storage/volumes/. E systemctl restart precisa do flag --user.

Configurando o Host: subuid, subgid e Linger#

A configuração base do host é o ponto onde 80% dos problemas aparecem em produção. Sem ela, o container até sobe, mas reinicia depois do logout, perde acesso a portas, ou falha em montar volumes com o erro confuso lchown: operation not permitted.

# Criar usuário dedicado para a aplicação
sudo useradd -m -s /bin/bash app

# Reservar 65536 UIDs e GIDs subordinados para o usuário 'app'
# Sem isso, podman rootless falha ao mapear o UID do container
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 app

# Habilitar systemd --user mesmo sem sessão SSH ativa
# Essencial para containers em produção que precisam sobreviver ao logout
sudo loginctl enable-linger app

# Permitir bind em portas baixas (80/443) sem CAP_NET_BIND_SERVICE
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-podman.conf
sudo sysctl --system

O loginctl enable-linger é o comando que mais esqueço — sem ele, qualquer systemctl --user start morre quando a sessão SSH cai. A linha ip_unprivileged_port_start=80 evita ter que recorrer a setcap ou proxy reverso só para servir HTTP.

⚠️ Atenção: Se você reaproveitar UIDs subordinados que já estão alocados a outro usuário, o Podman vai recusar o run com IDs already in use. Sempre rode cat /etc/subuid /etc/subgid antes de adicionar ranges.

Quadlet: o Substituto Real do docker-compose#

docker-compose foi a peça mais difícil de substituir na minha migração. podman-compose existe, mas é mantido pela comunidade e tem inconsistências em redes e healthchecks. A solução oficial é Quadlet, integrado ao Podman 4.4+.

Quadlet permite descrever containers, redes e volumes em arquivos .container, .network e .volume que o systemd traduz automaticamente em units. Isso significa: restart policies, dependências (After=, Requires=), logs no journal, healthchecks supervisionados pelo systemd. Tudo grátis, sem dockerd.

A localização para units rootless é ~/.config/containers/systemd/. O systemd --user faz parsing toda vez que você roda systemctl --user daemon-reload.

# ~/.config/containers/systemd/api.container
[Unit]
Description=API .NET 9 em Podman rootless
After=network-online.target

[Container]
Image=ghcr.io/lincoln/api:1.4.0
AutoUpdate=registry
ContainerName=api
PublishPort=8080:8080
Environment=ASPNETCORE_ENVIRONMENT=Production
Volume=api-data.volume:/data:Z
Network=apps.network
HealthCmd=curl -f http://localhost:8080/health || exit 1
HealthInterval=30s

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=default.target

Após criar o arquivo, basta systemctl --user daemon-reload && systemctl --user start api.service. O systemd cria a unit, baixa a imagem, sobe o container e monitora o healthcheck.

Migrando docker-compose.yml para Quadlet#

Para projetos com vários serviços, traduzir manualmente é viável, mas há atalhos. A ferramenta podlet converte um docker-compose.yml em arquivos Quadlet em segundos:

# Instalar podlet (binário Go, sem dependências)
curl -L -o /tmp/podlet.tar.gz \
  https://github.com/containers/podlet/releases/latest/download/podlet-x86_64-unknown-linux-gnu.tar.gz
tar -xzf /tmp/podlet.tar.gz -C ~/.local/bin/

# Converter compose existente para Quadlet
podlet --file ~/.config/containers/systemd/ compose docker-compose.yml

A conversão acerta volumes, redes, dependências e portas em ~90% dos casos. Os 10% restantes geralmente são depends_on com condition: service_healthy (Quadlet usa Requires= + After=) e extra_hosts (que vira AddHost= no bloco [Container]).

💡 Dica: Mantenha o docker-compose.yml original no repositório por enquanto, com um README apontando para os arquivos Quadlet. Devs locais continuam usando Docker Desktop; o servidor de produção usa Podman/Quadlet. Os dois descrevem a mesma topologia.

Rede, Volumes e SELinux: As Três Pegadinhas#

Rede em modo rootless#

Por padrão, Podman rootless usa pasta (em versões 5.x) ou slirp4netns (versões anteriores) como backend de rede. Funciona, mas tem limitações: o IP de origem do tráfego visto pelo container é sempre 10.0.2.100 (com slirp4netns), o que quebra rate-limiting e logs de auditoria baseados em IP. Com pasta, o IP real é preservado, e por isso eu sempre forço Podman 5.x em produção nova.

Volumes e o flag :Z#

Em hosts com SELinux (RHEL, Fedora, Rocky), montar um volume sem o flag :Z no Quadlet ou -v ./data:/data:Z no run causa Permission denied silencioso dentro do container. O :Z aplica a label SELinux correta no diretório do host. Sem ele, debugging vira pesadelo porque ls -l mostra permissões corretas, mas o processo do container não consegue ler.

Mapeamento de UID em volumes#

Quando o container escreve num volume bind-mounted, o arquivo no host aparece com UID 100000 (o UID subordinado mapeado). Para sincronizar com seu usuário local, use podman unshare:

# Entrar no namespace de usuário do podman
podman unshare chown -R 1000:1000 /home/app/data

# Equivalente a "chown 100999:100999" visto do host

Configuração Otimizada: containers.conf, registries.conf, storage.conf#

Os defaults do Podman funcionam, mas em produção rootless há três arquivos que decidem performance e segurança operacional. Em modo rootless, ficam em ~/.config/containers/. Esta é a configuração que rodo num RHEL 9.7 com disco dedicado para /userapps e SSD limitado em IOPS — ela elimina escritas temporárias massivas e usa journald em RAM como sink de log.

containers.conf — runtime e logging#

# ~/.config/containers/containers.conf
# Referência: man 5 containers.conf

[containers]
tz = "America/Sao_Paulo"

# Driver de log: journald armazena em RAM via journal, não em arquivo no disco
log_driver = "journald"
log_size_max = 10485760    # 10 MB por container

# Limite de PIDs por container (mitigação de fork bomb)
pids_limit = 2048

[engine]
# TMPDIR em /dev/shm (tmpfs em RAM) elimina milhões de writes temporários no disco
env = ["TMPDIR=/dev/shm/podman-tmp"]

# Eventos do Podman vão para journald, não para arquivos no disco
events_logger = "journald"

[network]
# Netavark é o backend moderno (substitui CNI desde Podman 4.x)
network_backend = "netavark"
network_config_dir = "/userapps/zocateli/containers/networks"

O ganho prático mais visível é o TMPDIR=/dev/shm/...: em servidor com muitos podman pull e podman build, o disco recebia milhões de arquivos temporários que sumiam segundos depois. Movendo para tmpfs, o I/O do disco caiu drasticamente sem perder funcionalidade.

registries.conf — anti supply-chain#

# ~/.config/containers/registries.conf
[registries.search]
registries = ['meu-docker.artifacts.zocate.li']

# Recusa qualquer "podman run nginx" sem prefixo de registry
short-name-mode = "enforcing"

O short-name-mode = "enforcing" é um controle subestimado: sem ele, podman run nginx pode resolver para qualquer registry da lista de busca, abrindo brecha para supply-chain attack. Com enforcing, o usuário precisa especificar o registry completo (docker.io/library/nginx) — força que toda imagem seja explicitamente atribuída a uma fonte.

storage.conf — overlay nativo (60–80% menos I/O)#

# ~/.config/containers/storage.conf
# Referência: man 5 containers-storage.conf

[storage]
driver = "overlay"

# Storage persistente (imagens, layers) → disco dedicado
graphroot = "/userapps/zocateli/containers/storage"
rootless_storage_path = "/userapps/zocateli/containers/storage"

# runroot (PIDs, sockets, locks) é resolvido automaticamente via $XDG_RUNTIME_DIR
# que aponta para /run/user/<UID>/containers (tmpfs em RAM) — não definir aqui

[storage.options]
pull_options = {enable_partial_images = "false", use_hard_links = "false"}

[storage.options.overlay]
# SEM mount_program → Podman usa overlay NATIVO do kernel (5.11+)
# Habilitar fuse-overlayfs apenas se overlay nativo não funcionar:
# mount_program = "/usr/bin/fuse-overlayfs"

# SEM metacopy=on → evita copy-up overhead em workloads write-heavy
mountopt = "nodev"

O ponto crítico é a ausência de mount_program. Em kernel 5.11+ (RHEL 9, Ubuntu 22.04+, Fedora 35+), o overlay nativo suporta rootless sem fuse-overlayfs. Cortar o FUSE elimina syscalls de userspace para cada operação de filesystem — em workloads I/O-bound, vejo 60–80% de redução no tempo de operações de layer (build, pull, run de imagens grandes).

⚠️ Atenção: Se o backing filesystem for ext4 sem suporte a overlay rootless, ou kernel < 5.11, descomente mount_program = "/usr/bin/fuse-overlayfs". Sem essa fallback, o podman pull vai falhar com mount: permission denied. Para detectar suporte: podman info | grep graphDriverName deve mostrar overlay, não vfs.

Exemplo Prático: API .NET + Postgres + Worker em Produção#

Um caso real que rodo hoje: API .NET 9, worker de processamento e Postgres 17, todos em Podman rootless, supervisionados por systemd --user. Estrutura:

~/.config/containers/systemd/
├── apps.network
├── postgres.volume
├── postgres.container
├── api.container
└── worker.container
# postgres.container — banco persistente
[Unit]
Description=Postgres 17
After=network-online.target

[Container]
Image=docker.io/library/postgres:17-alpine
ContainerName=postgres
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/pg_pass
Secret=pg_pass,type=mount,target=pg_pass
Volume=postgres.volume:/var/lib/postgresql/data:Z
Network=apps.network
HealthCmd=pg_isready -U postgres
HealthInterval=10s

[Service]
Restart=always

[Install]
WantedBy=default.target
# api.container — depende do Postgres saudável
[Unit]
Description=API .NET 9
Requires=postgres.service
After=postgres.service

[Container]
Image=ghcr.io/lincoln/api:1.4.0
AutoUpdate=registry
ContainerName=api
PublishPort=8080:8080
Environment=ConnectionStrings__Db=Host=postgres;Username=postgres;Password=...
Network=apps.network

[Service]
Restart=always

[Install]
WantedBy=default.target

Deploy é um git pull && systemctl --user restart api.service. Sem daemon, sem sudo, sem socket aberto. O update da imagem é orquestrado por podman auto-update (habilitado com AutoUpdate=registry no Quadlet) que checa registry diariamente e reinicia containers automaticamente em caso de nova tag.

📂 Código Fonte: Os manifests Quadlet completos e o Containerfile do worker estão no repositório de exemplos do blog:
BlogSamples/Workers/


📖 Artigo completo com exemplos de código: Podman Rootless em Produção: Substituindo Docker

Carregando publicação patrocinada...
1