From 360f65ac95bb0b4210511dbb071efd741f9855e3 Mon Sep 17 00:00:00 2001 From: Hirley Date: Tue, 1 Sep 2026 18:21:28 -0300 Subject: [PATCH] Separa migrations do boot do web, com uma etapa de release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/docker-entrypoint roda db:prepare antes do Puma toda vez que o container sobe. É o que faz `docker compose up --build` funcionar de primeira, e continua sendo o default — mas passou a ser desligável com DB_PREPARE_ON_BOOT=false, que é o que um deploy de verdade deve fazer, rodando as migrations numa etapa de release antes de subir as réplicas. Sobre a premissa da issue, que falava em "todas as réplicas tentam preparar o banco ao mesmo tempo": o schema nunca esteve em risco. As migrations do Rails pegam um advisory lock no Postgres (verificado em activerecord-8.1.3.1: Migrator#with_advisory_lock). O que acontece de verdade é pior de um jeito diferente, e vale a correção: o lock é obtido com pg_try_advisory_lock, que NÃO bloqueia. Quem perde recebe false e o Rails levanta ConcurrentMigrationError; como o entrypoint roda com `bash -e`, o db:prepare que falha derruba o container. A réplica morre no boot em vez de subir depois da que migrou. Somado ao acoplamento que a issue já apontava — boot preso no tempo e no sucesso da migration —, é o argumento inteiro. O default continua ligado de propósito. Inverter deixaria o `docker run` documentado no README e o compose local subindo contra um banco vazio, que é um jeito pior de falhar do que o problema evitado. A etapa de release não precisou de comando novo: o entrypoint só prepara o banco quando o comando é `./bin/rails server`, então `docker compose run --rm web ./bin/rails db:prepare` já passa direto. Isso virou um passo do smoke test — sem ele seria um caminho documentado que nada exercita, a mesma lacuna que o smoke test veio fechar na #81. Verificado: a condição do entrypoint nas cinco combinações que importam (server com a variável vazia/true/false, db:prepare, bin/jobs), o docker compose config com e sem a variável, e os gates de sempre no container — RuboCop 110 arquivos sem ofensas, zeitwerk:check limpo, RSpec 394 exemplos e 0 falhas. O comportamento dentro do container quem verifica é o smoke test do CI, que é onde ele de fato roda. Closes #82 Co-Authored-By: Claude Opus 5 --- .env.example | 11 +++++++++++ .github/workflows/ci.yml | 12 ++++++++++++ README.md | 24 ++++++++++++++++++++++++ bin/docker-entrypoint | 29 ++++++++++++++++++++++++++--- docker-compose.yml | 5 +++++ 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1e38383..bcaaa2f 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,14 @@ JOB_CONCURRENCY=1 # (ver .github/workflows/ci.yml); também serve para rodar a stack contra # uma imagem publicada, ex.: ghcr.io/hirley/task_keeper_api:2.0.1. APP_IMAGE= + +# Opcional — "false" desliga o `bin/rails db:prepare` que o entrypoint roda +# antes de iniciar o Puma (ver bin/docker-entrypoint). Ligado por padrão, +# porque é o que faz `docker compose up` funcionar sem passo manual. +# +# Num deploy de verdade, defina "false" e rode as migrations numa etapa de +# release, antes de subir as réplicas — ver README, seção "Docker", +# subseção "Migrations: boot ou release". Com mais de uma réplica isso +# deixa de ser preferência: quem perde o advisory lock da migration morre +# no boot com ConcurrentMigrationError. +DB_PREPARE_ON_BOOT= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13fa623..166beab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,18 @@ jobs: - name: O entrypoint gerou a chave efêmera (caminho sem SECRET_KEY_BASE) run: docker compose logs web | grep -q 'gerando uma chave' + # A etapa de release que o README manda usar em deploy de verdade + # (DB_PREPARE_ON_BOOT=false + migrations antes de subir as réplicas). + # Sem isto ela seria um caminho documentado que nada exercita — a + # mesma lacuna que este smoke test veio fechar para a imagem inteira. + # + # `compose run` passa pelo ENTRYPOINT, e o entrypoint só prepara o + # banco quando o comando é `./bin/rails server`: é justamente isso + # que se verifica aqui, além de o db:prepare ser idempotente contra + # um banco que a stack já preparou. + - name: O caminho de release prepara o banco sem subir servidor + run: docker compose run --rm web ./bin/rails db:prepare + # Desde a adoção do Solid Queue o deploy tem DOIS processos, e o # segundo nunca foi exercitado por nada automatizado. Um worker que # sobe e não consome nada é invisível: a tela responde normalmente e diff --git a/README.md b/README.md index c46fd65..f2b195f 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,32 @@ docker run -p 3000:3000 \ task_keeper_api ``` +### Migrations: boot ou release + `bin/docker-entrypoint` roda `bin/rails db:prepare` (idempotente) toda vez que o container sobe, antes de iniciar o Puma — então o banco é criado/migrado automaticamente, sem passo manual (mas o PostgreSQL em si precisa já estar de pé e acessível; o Dockerfile não sobe um banco dentro do próprio container da aplicação). +Isso é **conveniência de demonstração**, e é o que faz `docker compose up --build` funcionar de primeira. Num deploy de verdade, o modo certo é o outro: + +| | Quando | Como | +|---|---|---| +| **No boot** (default) | `docker compose up`, `docker run`, demo de uma réplica só | nada a fazer — o entrypoint cuida | +| **Etapa de release** | qualquer deploy real, e obrigatório com mais de uma réplica | `DB_PREPARE_ON_BOOT=false` no serviço web, e as migrations rodam antes de subir as réplicas | + +O comando da etapa de release é o mesmo binário, com outro argumento — o entrypoint só prepara o banco quando o comando é `./bin/rails server`, então qualquer outro comando passa direto: + +```bash +docker compose run --rm web ./bin/rails db:prepare +``` + +Fora do compose, é a mesma ideia: `docker run --rm -e DATABASE_URL=... ghcr.io/hirley/task_keeper_api:latest ./bin/rails db:prepare`. No Railway, um *pre-deploy command* com `bin/rails db:prepare`. + +**O que se ganha desligando não é evitar corrupção de schema.** As migrations do Rails pegam um advisory lock no Postgres, então o schema está protegido de qualquer jeito. São duas outras coisas: + +- **com mais de uma réplica, quem perde o lock não espera**: o Rails usa `pg_try_advisory_lock`, que é não-bloqueante, e levanta `ConcurrentMigrationError`. Como o entrypoint roda com `bash -e`, o `db:prepare` que falha derruba o container — a réplica **morre no boot** em vez de simplesmente subir depois da que migrou; +- **a disponibilidade do web deixa de depender das migrations**: uma migration longa atrasa o boot, e uma que falha impede a aplicação de subir mesmo que o código já rodasse contra o schema antigo. + +O default continua sendo ligado de propósito. Inverter deixaria o `docker run` acima e o compose local subindo contra um banco vazio — um jeito pior de falhar do que o problema que se quer evitar. O CI exercita os dois caminhos: o smoke test sobe a stack pelo boot automático e, em seguida, roda a etapa de release isolada (ver "Integração contínua"). + Este projeto não tem `config/master.key`/`config/credentials.yml.enc`, então `SECRET_KEY_BASE` (variável de ambiente) é obrigatória em produção — sem ela, o container não sobe. As variáveis de conexão com o banco (`DATABASE_URL` ou `DB_HOST`/`DB_PORT`/`DB_USERNAME`/`DB_PASSWORD`/`DB_NAME`) e as demais (`TELEGRAM_BOT_TOKEN`, `APP_HOST`, `RAILS_MAX_THREADS`) são opcionais/têm default; ver `.env.example` para a lista completa e o que cada uma faz. **Sobre a plataforma do `Gemfile.lock`**: o lockfile deste projeto foi gerado originalmente numa máquina Windows — a seção `PLATFORMS` só tem `x64-mingw-ucrt`, sem a plataforma Linux. Sem isso, `bundle install` falha dentro de um container Linux ao tentar resolver as gems com extensão nativa (`pg`, `nokogiri`). O `Dockerfile` já corrige isso sozinho (roda `bundle lock --add-platform x86_64-linux` antes do `bundle install`, dentro da própria imagem), então não é preciso fazer nada manualmente por causa disso — mas é bom saber que esse ajuste existe, caso apareça algum erro de plataforma ao rodar `bundle install` fora do Docker também (nesse caso, `bundle lock --add-platform x86_64-linux` resolve, e o mesmo vale se você desenvolver num Mac Apple Silicon: `bundle lock --add-platform arm64-darwin`). diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index a607d40..a80d6c3 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -31,9 +31,32 @@ if [ -z "${SECRET_KEY_BASE}" ]; then fi # Prepara o banco (cria se não existir, aplica migrations pendentes) toda -# vez que o servidor sobe — bin/rails db:prepare é idempotente, então é -# seguro rodar isso a cada restart do container. -if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then +# vez que o servidor sobe. É idempotente, então é seguro a cada restart — +# e é o que faz `docker compose up` funcionar sem nenhum passo manual. +# +# Isso é conveniência de DEMONSTRAÇÃO, e num deploy de verdade deve ser +# desligado com DB_PREPARE_ON_BOOT=false, rodando as migrations numa etapa +# de release própria, antes de subir as réplicas (ver README, seção +# "Docker", subseção "Migrations: boot ou release"). +# +# O que se ganha desligando não é evitar corrupção de schema — as +# migrations do Rails pegam um advisory lock no Postgres, então o schema +# está protegido de qualquer jeito. São duas outras coisas: +# +# * com mais de uma réplica, quem PERDE o lock não espera: o Rails usa +# pg_try_advisory_lock (não-bloqueante) e levanta +# ConcurrentMigrationError. Como este script roda com `bash -e`, o +# db:prepare que falha derruba o container inteiro — a réplica morre +# no boot em vez de simplesmente subir depois; +# * a disponibilidade do web deixa de depender do tempo e do sucesso das +# migrations. Uma migration longa atrasa o boot; uma que falha impede +# a aplicação de subir, mesmo que o código já estivesse pronto para +# rodar contra o schema antigo. +# +# O default continua sendo ligado: desligar sem uma etapa de release no +# lugar deixaria o `docker run` documentado no README (e o compose local) +# subindo contra um banco vazio, que é um jeito pior de falhar. +if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ] && [ "${DB_PREPARE_ON_BOOT:-true}" != "false" ]; then ./bin/rails db:prepare fi diff --git a/docker-compose.yml b/docker-compose.yml index 0b4d139..4c37e37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,11 @@ x-app-env: &app-env # variável não é definida, e o default de production.rb (ligado) # vale. Ver config/environments/production.rb. FORCE_SSL: ${FORCE_SSL:-false} + # Vazio = ligado (default do entrypoint), que é o que mantém o + # `docker compose up` sem passo manual. Defina "false" para exercitar + # aqui o modo de produção: migrations numa etapa de release separada, + # antes de subir o web. Ver bin/docker-entrypoint e o README. + DB_PREPARE_ON_BOOT: ${DB_PREPARE_ON_BOOT:-} DB_HOST: db DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres}