Case detalhado 2026-02 → 2026-03

Pipeline CI/CD Python: 5 stages do zero em 8 dias, 13 ADRs registrados

OCR jurídico FastAPI sem CI/CD virou 5 stages GitLab (validate→test→review→build→deploy), Kaniko + GitLab Registry, 13 ADRs, smoke /health 200.

DevOps Engineer (Estagiário) · Consultoria AWS Select Partner

  • GitLab CI
  • Python 3
  • FastAPI
  • Docker
  • Kaniko
  • Proxmox VE
  • Ubuntu 24.04

TL;DR

  • Pipeline GitLab CI com 5 stages (validate → test → review → build → deploy) entregue do zero para projeto Python FastAPI sem CI/CD prévio.
  • 93% de entrega (14/15 tarefas) em 9 sessões / ~8 dias, com 13 ADRs registrados incluindo uma revisada in-flight quando a decisão original não sobreviveu ao contato com a realidade.
  • Build reprodutível via Kaniko e GitLab Container Registry (após migração mid-project saindo do Docker Hub), deploy por SSH em VM Proxmox com smoke test /health 200 / /docs 200.

Contexto

O projeto era um serviço Python de OCR para documentos jurídicos — FastAPI na borda, embeddings e busca via pgvector no Postgres — mantido por um dev único numa VM Proxmox (2 vCPU, 4GB RAM, 30GB disco, Ubuntu 24.04.3 LTS). O cliente é da área jurídica; o serviço lê documentos, extrai entidades nomeadas e faz busca semântica.

O problema não era ausência de código — era ausência de processo de entrega. Cada merge dependia de revisão manual no GitLab sem gate automático; cada deploy era SSH, git pull, rebuild local, reinício do systemd. Builds não eram reprodutíveis: a imagem no servidor dependia do estado da VM no momento do build.

A entrega aconteceu em 9 sessões focadas em ~8 dias (23/fev → 02/mar de 2026), com um objetivo explícito: deixar o pipeline “verde-todas-stages” antes do deploy de próxima feature, sem congelar o fluxo do time que já mergia MRs.

Ação

O escopo foi fatiado em 15 tarefas e 5 stages do pipeline. Três frentes práticas.

Stages do pipeline

# .gitlab-ci.yml — trecho ilustrativo, anonimizado
stages:
  - validate
  - test
  - review
  - build
  - deploy

validate:
  stage: validate
  image: python:3.12-slim
  script:
    - pip install --no-cache-dir ruff==0.5.0
    - ruff check app/
    - python -c "import tomllib; tomllib.load(open('pyproject.toml','rb'))"
  # tempo real observado: ~113s

sast:
  stage: review
  image: registry.gitlab.com/security-products/sast:latest
  script:
    - /analyzer run
  artifacts:
    reports:
      sast: gl-sast-report.json
  # tempo real observado: ~243s

build:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:v1.20.0-debug
    entrypoint: [""]
  script:
    - /kaniko/executor
      --context "${CI_PROJECT_DIR}"
      --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
      --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
.gitlab-ci.yml — stages validate / review / build tempos observados em runner gitlab-runner v18.9.0; valores ilustrativos, anonimizados

Tempos reais observados no runner: validate 113s, SAST 243s, mr-review 111s. Runner rodava em VM dedicada com tag próprio para isolar o contexto CI do resto da infra do cliente.

Build com Kaniko (e a migração de registry)

A escolha de Kaniko em vez de docker build foi deliberada: runner sem --privileged (regra de segurança do cliente), sem Docker daemon exposto, build containerizado honesto. Em contrapartida, Kaniko não tem cache local entre runs — o primeiro build fica mais lento até subir cache em registry.

Uma das decisões foi revisada in-flight: a primeira ADR apontava Docker Hub como registry por familiaridade. Depois de dois builds, ficou claro que:

  1. Limites de pull anônimo iam bater em horário de pico.
  2. Credencial compartilhada em secret CI significava mais uma rotação para gerenciar.
  3. O próprio GitLab já oferecia Container Registry integrado com IAM do projeto.

A ADR foi reescrita, não apagada — ficou registrado o “porque mudamos” para o próximo dev não refazer a mesma pergunta.

Deploy e debugging

Deploy final é via SSH key dedicada com ssh-agent no runner + docker compose pull && docker compose up -d na VM alvo. Smoke test roda logo em seguida:

# smoke-test.sh — executado no stage deploy após docker compose up
set -euo pipefail
curl --fail --max-time 10 http://localhost:8000/health
curl --fail --max-time 10 http://localhost:8000/docs
echo "smoke test: /health 200 /docs 200"

Até chegar nesse deploy limpo foram 4 iterações de debug — anotadas numa seção deploy-troubleshooting.md que ficou no repo:

  1. Primeira versão do job usava ssh inline sem ssh-agent: host key não cacheava, job falhava na segunda run.
  2. docker compose pull sem login autenticado no registry GitLab: manifest unknown.
  3. .env lido no host não tinha as vars que o docker-compose.yml referenciava: serviço subia mas caía no health.
  4. Health check batia em localhost:8000 enquanto compose mapeava 127.0.0.1:8001 (conflito de porta com outro serviço legado na VM).

Nenhum desses é exótico. Todos estão documentados como sequência real, porque o valor pra quem vai vir depois é ver a ordem dos erros, não o pipeline “pronto”.

Trade-offs e o que NÃO fiz

  • Kaniko em vez de docker build: ganhei reprodutibilidade e segurança (runner sem --privileged); perdi cache local rápido. Vale a pena enquanto a política de segurança do cliente proibir Docker daemon exposto no runner — se mudar, reavaliar.
  • GitLab Container Registry em vez de Docker Hub: decisão revisada in-flight (1 ADR reescrita). O custo foi uma tarde refazendo a parte de auth; o ganho foi eliminar uma rotação de credencial e sumir com risco de rate-limit.
  • Deploy por SSH em vez de kubectl/Helm: só existe uma VM alvo e o cliente não opera Kubernetes. Introduzir k8s pra isso seria overhead puro. Limite escrito: se virarem 3+ VMs ou vier HA no escopo, migrar para Ansible antes de pensar em k8s.
  • SAST com 293 findings “high”: a maioria era falso positivo de dependências em cache (Python transitive deps). Em vez de suprimir em bloco, a decisão foi: criar baseline, triar em janela dedicada e só então configurar allow_failure: false. Entregar SAST hoje com gate frouxo é melhor que prometer gate estrito e não conseguir mergiar nada.
  • Não escrevi testes de integração nessa fase: só unit + lint. A cobertura de integração ficou explícita como próximo ciclo, não como “esqueci”.
  • Não automatizei rollback: deploy novo sobe, deploy velho fica como tag :previous no registry — rollback é docker compose pull <:previous> && up -d. Suficiente pra escala do projeto; automatizar seria prematuro.

Resultado

5validate→…→deploy
Stages do pipeline
evidência
93%14 de 15
Tarefas concluídas
evidência
131 revisada in-flight
ADRs registrados
evidência
4documentadas
Iterações de debug do deploy
evidência

O estado final: pipeline verde nos 5 stages em MR normal, imagem versionada por CI_COMMIT_SHORT_SHA em GitLab Container Registry, deploy automatizado com smoke test retornando /health 200 e /docs 200, e 13 ADRs registrados explicando cada decisão não-óbvia.

Sendo honesto sobre o que não há: não existe baseline de “lead time antes” — o deploy manual nunca foi cronometrado formalmente. O ganho visível está na reprodutibilidade (qualquer MR passa pelos mesmos gates), não num gráfico “antes vs depois” que eu não tenho como provar.

A entrega fechou em 93% (14/15 tarefas) porque a 15ª — rollback automatizado — foi decisão consciente de deixar fora: o custo de implementar contra uma VM única não compensava o ganho marginal sobre o rollback manual de 2 comandos. Escrito na ADR final, não esquecido.

Resultados mensuráveis

5validate→test→review→build→deploy
Stages do pipeline
evidência
14 / 1593%
Tarefas concluídas
evidência
13decisões técnicas
ADRs registrados
evidência
4documentadas
Iterações de debug do deploy
evidência
9em ~8 dias
Sessões focadas
evidência