# RUNTIME_DOCKER — runtime de containers e configuração sistemática (dev · homolog · prod)

> **Dono:** Edge/Infra · **Status:** Em execução — **D0–D7 fechadas** (45/51 tasks; as 6 da
> **D8** ativas); **D8** aberta (medição no host + decisões) · **Data:** 2026-10-23
> **Épico:** `epic_00vr694c` · **Working doc:** [`wiki/backlog/concluido/update_docker/PLAN.md`](../backlog/concluido/update_docker/PLAN.md)
> **Veredito que originou:** [`wiki/backlog/concluido/update_docker/09-veredito.md`](../backlog/concluido/update_docker/09-veredito.md)
> (63 itens do catálogo `wiki/backlog/concluido/update_docker/` julgados contra os ADRs vigentes)
> **Relação com as fases:** roda **depois** de F6 (como `UPDATE_INFRA`); complementa
> `wiki/specs/16-edge-apache-whm.md` e `wiki/specs/17-up-prod.md` — toda mudança que altera o
> contrato do runtime atualiza essas specs (D2.T6).
>
> **Próxima sessão (ordem do plano §7):** **D8** (medição no host + decisões de produto) — a
> única aberta. Depende de uma semana de `docker stats` no WHM e das decisões de nome do
> projeto (D8.T3), `X-Request-ID` (D8.T4) e retenção/medições pendentes (D8.T5). O destino
> offsite da mídia é decisão do cliente (`OFFSITE_ENDPOINT`/`OFFSITE_MEDIA_BUCKET`) — ver §6.
>
> **Desvio de sequência registrado (2026-10-21):** o `kd` bloqueava a **D6** pela **D5**, mas a
> D6 **não toca topologia** (log do edge, conf do cache, `php.ini`) e a sessão 4 do plano §7
> agrupava `D4 + D6` — a D6 foi executada antes da D5, e a D5 continua por último da onda B.

---

## 0. Escopo e o que **não** é escopo

**É escopo:** o runtime de containers (Docker/Compose) e a configuração sistemática que mantém o
projeto em bom estado em **dev**, **homolog** e **prod** — timeouts, rede, imagem, Postgres,
observabilidade, `.env`, backup e retenção.

**Não é escopo:** o `UPDATE_INFRA` (épico `epic_00dw7kyx`, pipeline I0–I8) — que já tratou o
`wiki/backlog/concluido/_INFRA/` do V6 e está **fechado**. Este programa não repete nenhum item dele: o que já foi
resolvido aparece aqui apenas como **estado atual medido** (ex.: a retenção do WAL, `I7.T2`, já
existe — falta só a chamada no `scripts/update_prod.sh`).

### 0.1 Regras transversais (idem em `wiki/backlog/concluido/update_docker/PLAN.md` §0)

| # | Regra | Origem |
|---|---|---|
| **R1** | `APP_ENV` no `.env` é o **único** interruptor. Proibido `docker-compose.dev/prod.yml` e `.env.<env>` | ADR-032 |
| **R2** | **`homolog` = `prod`**: mesmo runtime, mesmas timeouts, mesma rede, mesma imagem, mesmo Postgres. Só diferem OPcache `validate_timestamps=1` e log em `debug` | ADR-032 |
| **R3** | Artefato único; logs ligados em produção | ADR-031/032/024 |
| **R4** | `.env` mínimo (identidade + segredos); default vive em `config/` | ADR-032 |
| **R5** | Determinismo: números explícitos, nunca "o default do Docker" | ADR-001 |
| **R6** | Zero CDN; zero strong-copyleft (AGPL/GPL bloqueado) | ADR-002 |
| **R7** | Não-breaking: nada de rota, contrato HTTP, formato persistido ou nome de tabela | ADR-042 |
| **R8** | Toda task fecha com **gate + teste + `make check` 2× verde** | ADR-038/001 |
| **R9** | Execução no container (`make exec cmd='…'`), nunca no host | AGENTS.md |
| **R10** | Nunca `rm -rf`; descarte em `.pi/trash/` | AGENTS.md |

### 0.2 Podman rootless no dev (opcional — ADR-044/045)

O runtime **padrão** é Docker (dev e produção); produção é **somente** Docker + Apache do
WHM. Podman é **opt-in de desenvolvimento** (`make podman-prepare` ou `make runtime
RUNTIME=podman`), atrás dos mesmos alvos:

- `tools/runtime.sh` resolve o motor em um lugar (`CONTAINER_RUNTIME` → `.env` → `docker`) e
  devolve `ENGINE`, `COMPOSE_FILES`, `RUN_ARGS` e `MOUNT_LABEL`; Makefile e `tools/*.sh` não
  chamam `docker` literal. O `:Z` é o default (mais seguro); só o opt-in podman o dispensa
  (`label=disable`).
- `docker-compose.podman.yml` (só no podman) aplica `userns_mode: keep-id` em todo serviço com
  `user:` e `label=disable`; `postgres` fica sem keep-id (o entrypoint faz `chown` do PGDATA).
- `make podman-prepare` prepara o host (podman ≥ 5, `podman.socket`, provider, subuid/subgid,
  `.env`, diretórios de dados) e valida o Compose mesclado. `--check` só verifica. O deploy
  (`scripts/update_prod.sh`) **recusa** podman.
- `make env` deriva `WEB_PORT` (`50700..50789`, determinístico por nome do projeto) e sincroniza
  `APP_URL`; `make env INSTANCE=1` isola stack (project/volumes/porta) para branches paralelas.
- Gate: `tools/podman-check.sh` no `make check` (não exige podman), cobrindo keep-id, SELinux,
  `postgres` sem userns, ausência de portas/redes no override e `${MOUNT_LABEL-:Z}`.

Medido (podman 5.8.9 rootless, SELinux Enforcing): `postgres`, `valkey`, `storage` e `web`
**healthy**; `podman inspect -f '{{.State.StartedAt}}'` e `compose exec` respondem como no
Docker; `security-check` e `network-check` verdes.

---

## 1. Estado atual medido (2026-10-21, stack local)

Medido antes de planejar — é a base de cada veredito.

| Achado | Medida |
|---|---|
| **Upload cortado em ~10 s** | POST 20 MiB a 1 MB/s → `HTTP=504 t=10.00`; log do Caddy `read tcp …: i/o timeout` |
| **Segundo corte (piso do tempo)** | `write 30s` é prazo **total** do request: com `read_body 300s` e `write 30s` → `HTTP=100` + `writing: … i/o timeout` |
| **Controle-positivo** | com `read_body 300s` **e** `write 300s`: upload de 50 MiB a 2 MB/s **chega ao app** (`HTTP=405` = Laravel) |
| **Limite de 110 MiB inalcançável** | `LimitRequestBody 115343360` + `max_size 110MiB` + `upload_max_filesize=110M` — mas os prazos cortam antes |
| **Duas imagens** | `docker compose config --images` ⇒ `concordia-php-fpm` **e** `concordia-queue`; 6 imagens ≈ 5 GB no host (ADR-031 manda 1) |
| **Postgres fora do ADR-007** | `wal_compression`, `max_wal_size`, `checkpoint_timeout` **ausentes**; `/dev/shm` = **64 MB** vs `shared_buffers=256MB` |
| **WAL sem poda no host** | 151 arquivos / 2,4 GB no `wal_archive`; a ferramenta existe (`I7.T2`), o cron está no runbook — mas `scripts/update_prod.sh` não chama |
| **Telemetria para host inexistente** | `OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318`; o `jaeger` só existe no profile `telemetry` (dev-only) |
| **`RUSTFS_OBS_USE_STDOUT` silencia** | 1.0.1: **60+** linhas JSON no stdout **sem** a var, **0** com ela (A/B medido) |
| **Storage só liveness** | `/health` 200 (liveness) · `/health/ready` **200** no 1.0.1 (readiness real) · `/cluster/health` 403 |
| **`web` alcança os dados** | `web → storage:9000` **ALCANÇÁVEL** (e o banco/cache pela mesma bridge) |
| **`cap_drop: ALL` quebra o Postgres** | `chown: … '/var/lib/postgresql/18/docker': Operation not permitted` ⇒ o entrypoint roda como root |
| **Matriz de rede (2 redes) validada** | `web→db` **bloqueado** · `app→db` alcança · `db→internet` **bloqueado** · `app→internet` alcança (o `composer install` precisa) |
| **`.env.example` incompleto** | 7 vars do Compose ausentes: `UID`, `GID`, `DATA_PATH`, `STORAGE_PATH`, `WEB_PORT`, `AWS_DEFAULT_REGION`, `COMPOSE_PROJECT_NAME` |
| **Caddy sem access log** | o `Caddyfile` não tem bloco `log` ⇒ **nenhuma** linha de acesso é emitida |
| **Valkey com PII no log** | sem `hide-user-data-from-log`; healthcheck com `-a "$REDIS_PASSWORD"` ⇒ senha em `argv` (`docker top`) |
| **RustFS 1.0.2 disponível** | tag e digest `sha256:1617cf459224b161bde874144f96969421ad07b4eb086a44c38d1fe31f36c595` **confirmados** por `imagetools inspect` |
| **Mídia sem backup** | single-drive, EC:0, sem versioning, sem cópia fora do host ⇒ **P0** |

> **Correção pós-D1 (23/10):** a D1 zerou o destino em `production`/`homolog`, mas o `dev`
> continuou apontando para `http://jaeger:4318` sem o profile `telemetry` no ar. Cada tentativa
> de exportação bloqueava **~5–8 s** na resolução de DNS (o `CURLOPT_TIMEOUT_MS=50` não cobre o
> resolver síncrono — medido: 7,8 s) e o breaker de 30 s apenas adiava o próximo stall; era a
> lentidão sentida no navegador. O endpoint agora nasce **vazio em todos os perfis**; o jaeger é
> opt-in (`--profile telemetry` + `OTEL_EXPORTER_OTLP_ENDPOINT`).

---

## 2. O programa (D0 → D8)

Pipeline **estritamente sequencial**: cada fase fecha com `make check` verde 2× e a evidência do
gate correspondente; a próxima só começa depois.

| Fase | Issue | `kd` issue id | Objeto | Onda |
|---|---|---|---|---|
| **D0** | Cadeia de timeout de upload (defeito medido) | `task_0028o2ww` | `Caddyfile`, `app.conf`, `php.ini`, `zz-custom.conf` | A |
| **D1** | Telemetria e storage: parar desperdício e ruído | `task_01pts3x0` | `config/observability.php`, `config/environments.php`, Compose | A |
| **D2** | Ambiente e operação contínua | `task_004g2nwa` | `.env.example`, `scripts/update_prod.sh`, runbook, specs 16/17 | A |
| **D3** | Imagem única e cadeia de suprimentos | `task_00pvl75o` | Compose, `Dockerfile`, `scripts/update_prod.sh`, CI, ADR-031 | B |
| **D4** | Postgres conforme ADR-007 | `task_01hast3b` | `postgresql.conf`, Compose, `migration-check` | B |
| **D5** | Isolamento de rede e hardening | `task_01ewcw05` | Compose, `security-check.sh` | B |
| **D6** | Observabilidade do edge e do cache | `task_01bnnc7g` | `Caddyfile`, `valkey.conf`, Compose, `php.ini` | B |
| **D7** | Backup da mídia e upgrade do RustFS | `task_01ivqk3b` | `src/core/…/Storage/`, Compose, ADR-030/031 | C |
| **D8** | Medição no host e decisões de produto | `task_01hf054c` | Compose, `scripts/update_prod.sh`, `config/logging.php`, spec 18 | C |

```
D0 ─▶ D1 ─▶ D2 ─▶ D3 ─▶ D4 ─▶ D5 ─▶ D6 ─▶ D7 ─▶ D8
```

**Por que esta ordem:** D0 é o defeito de produção e os prazos precisam subir **juntos**; D1 antes
de B (não se mede garbage); D3 antes de D4/D5/D6 (imagem única e build reprodutível são
pré-requisito de todo rebuild); D4 antes de D5 (Postgres reinicia — tuning com o pool quieto); D5
por último da onda B (maior blast radius); D7 por último de tudo (toca o engine que guarda os
dados — só depois do backup).

**Sequência de ataque por sessão** (ver `wiki/backlog/concluido/update_docker/PLAN.md` §7): D0 → D1+D2 → D3 → D4+D6 →
D5 → D7.T1–T3 → D7.T4–T5 → D8.

---

## 3. O "3 em 1" de cada task

Toda task do `kd` responde a três perguntas, e o plano exige as três:

1. **Implementar** — o que muda, em quais arquivos, e por quê.
2. **Testar/validar** — como se prova que funciona, **nesta sessão**.
3. **Não-regressão** — o que impede a melhoria de voltar a ser perdida (gate + teste + onde).

Sem o item 3 a task **não fecha** (`kd task close` exige evidência; ADR-038/001).

### 3.1 Regras de não-regressão que valem para o programa inteiro

1. **Nenhuma task fecha sem guard** (gate + teste). Sem isso a melhoria é um snapshot.
2. **O guard verifica a *ordem*, não só a presença.** O defeito do upload existia porque
   `read_body 10s` era *presente*; a correção só se sustenta porque os prazos estão *ordenados*.
   `require_text` não basta — precisa de comparação de valores.
3. **Gate negativo onde a armadilha é a ausência**: `RUSTFS_OBS_USE_STDOUT` (silencia),
   `cap_drop` no Postgres (quebra o boot), `docker-compose.prod.yml` (quebra o interruptor
   único), `pm.status_path` exposto (vaza o status do pool).
4. **Toda mudança de runtime é verificada nos 3 perfis** — e, por R2, **homolog e prod têm que
   dar o mesmo resultado**. Se divergirem, a task está errada.
5. **Mudança de `config/` e de `docker/` também é mudança de comportamento** ⇒ gate obrigatório
   mesmo sem tocar PHP.
6. **Determinismo:** nenhum teste depende de ordem de container, relógio ou rede externa.

### 3.2 Protocolo de validação (por task)

```
1. validação estática do artefato   (caddy validate / apachectl -t / php -l / docker compose config)
2. smoke do serviço afetado          (sobe, healthcheck healthy, um request real)
3. a prova específica da task        (upload/download lento, A/B de log, matriz de rede)
4. make check                        (2× — sem flaky, ADR-001)
5. make check E2E=1                  (se tocou rota/HTML/CSS/JS)
6. evidência gravada                 (build/check/, com o comando e a saída)
```

---

## 4. Riscos do programa

| Risco | Mitigação |
|---|---|
| Timeout maior ⇒ worker preso por mais tempo | As defesas são **taxa mínima** (Apache `MinRate=500`) + **idle** (Caddy `read_body_idle 30s`) + rate limit da app (ADR-021) — entram **juntas** com D0 |
| Segmentação de rede quebra egress legítimo (`composer install`, IndexNow, feeds) | Matriz medida antes; `composer install` validado no CI; guard de rede permanente no `make check` |
| Rebuild da imagem quebra o runtime | D3 é sessão isolada, com `make check E2E=1` logo depois |
| `cap_drop` no Postgres | Já medido que quebra ⇒ aplicado seletivamente, com gate **negativo** |
| Upgrade do RustFS perde acesso à mídia | D7.T4 só depois do backup verde; a prova é **ler um objeto existente** |
| Cadeia de timeout divergindo entre homolog e prod | R2: os dois são verificados juntos em toda task de runtime |
| `make check` verde com e2e quebrado | E2E roda quando a task toca rota/HTML/JS; no resto, smoke do serviço afetado + prova específica |

---

## 5. O que este programa **não** afirma

- Não mediu p50/p95 de produção real nem `docker stats` do host WHM ⇒ P-08/RS-09/SK-12 ficam em
  D8, **não** aprovados.
- Não validou o build da imagem com `libfcgi-bin`: o `ping.path` do FPM responde ao path
  (prova de vida), mas o corpo é vazio no PHP 8.4.26 — prová-lo exigiria o binário
  `cgi-fcgi`, que não está na imagem. O build da imagem com PECL pinado **foi** validado
  (D3: 812 MB, extensões com as versões exatas, `make check` 2× e E2E=1 verdes).
- O inventário da mídia (D7.T1) não existia quando o programa foi escrito; foi feito depois:
  `media:inventory` mediu 943 objetos / 97,9 MiB, com SHA-256 por prefixo
  (`build/media-inventory.json` — artefato de build, não versionado). **Ressalva (23/10):** a
  stack local foi recriada depois e o bucket está com **0 objetos**; a medição é de 21/10 e não
  se reproduz neste estado (o comando regenera o arquivo com os números atuais).
- **Nada foi implementado ainda** no momento em que este programa foi escrito; o estado real
  está em `wiki/backlog/concluido/update_docker/PLAN.md` (seções de execução) e no `kd` (epic `epic_00vr694c`).
  O `git` é somente-leitura: quem commita é o humano (AGENTS.md §0).

---

## 6. A D7 em números (o que foi medido, não o que foi estimado)

| O quê | Antes | Depois (medido) |
|---|---|---|
| Cópia da mídia fora do host | **nenhuma** | 943 objetos / 97,9 MiB copiados, **manifesto** com SHA-256 por objeto |
| Idempotência do backup | — | 2ª rodada: **0 copiado**, 943 em dia, mesmo checksum de manifesto |
| Caminho de volta | — | restaurou **943/943**, **0** SHA-256 divergente, num bucket de scratch |
| Estado do bucket | sem versioning, sem PAB | versioning **Enabled** + PAB com os 4 flags (o app segue servindo: 200) |
| RustFS | 1.0.1 | **1.0.2** (digest pinado no ADR-031) — inventário antes/depois **idêntico** (0 ausentes, 0 divergentes) |
| `RUSTFS_OBS_USE_STDOUT` | 1.0.1: 60+ linhas sem a var, **0** com ela | 1.0.2: **95 linhas nos dois casos** (a variável virou inócua) |

O que a D7 **não** afirma: com o destino offsite apontado para o mesmo RustFS, a cópia protege
contra apagamento, corrupção e *overwrite* — **não** contra a perda do host. Isso é
configuração (`OFFSITE_ENDPOINT`/`OFFSITE_MEDIA_BUCKET`) e é decisão do cliente (D8).

**Estado operacional (23/10):** os números acima são de **21/10**. Nesta instância o bucket
local está vazio (0 objetos) e o destino offsite **não** está configurado (`OFFSITE_*` ausente
do `.env`, que segue identidade + segredos): `media:backup` avisa/falha alto e o agendamento
diário (`routes/console.php`) não tem destino. Código, manifesto, restore e agendamento estão
implementados e testados (`media:backup`/`media:restore`/`media:inventory`); a proteção fora do
host depende da decisão de destino — não é código faltando.
