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/subgidconfigurados systemdem modo user habilitado (loginctl enable-linger <user>)- Conhecimento básico de
Dockerfileedocker 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 doruncno 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
runcomIDs already in use. Sempre rodecat /etc/subuid /etc/subgidantes 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.ymloriginal 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, opodman pullvai falhar commount: permission denied. Para detectar suporte:podman info | grep graphDriverNamedeve mostraroverlay, nãovfs.
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