Saltar a contenido

M8.C4 — Deploy avanzado: Docker, healthz/readyz, K8s, 12-factor

Pre-requisitos: M8.C3 — secrets management. Tu app maneja credenciales sin filtrarlas. Ahora la llevamos a producción real.

Objetivo: usar fitz docker init para generar Dockerfile + compose smart por defecto, entender /healthz y /readyz auto-mount para K8s, manejar SIGTERM drain para zero-downtime deploys, y aplicar los principios 12-factor.

Por qué importa: el último kilómetro entre "tengo un binario" y "está corriendo en producción con monitoring y rolling deploys" es el más friccionado en la mayoría de los stacks. Fitz lo automatiza con detección AST: el sub-comando fitz docker init lee el shape de tu programa y emite Dockerfile + compose adaptados.

Cross-link: cap 35 de la guía para la vista integradora completa.


Por qué Fitz es distinto

Feature Python TypeScript Go Spring Fitz
Dockerfile autogenerado ❌ (cookiecutter) ❌ (Nx generator) ❌ (jib opcional) fitz docker init
Detección AST del shape @server/db/python/cron
/healthz + /readyz automático ❌ (Flask actuator) ❌ (terminus opcional) ✅ (actuator) auto-mount sin código
SIGTERM drain ⚠ manual ⚠ manual ⚠ manual ⚠ manual 30s grace + readyz→503
docker build wrapper fitz docker build [--tag X]
12-factor compliance built-in ⚠ parcial ⚠ parcial ⚠ parcial ⚠ parcial default por design

El diferencial: NO instalás nada extra para Dockerfile, no copiás templates de Stack Overflow, no decidís entre distroless vs slim a mano. Fitz lee tu programa, decide el runtime apropiado, y emite el Dockerfile correcto.


Paso 1 — Generar Dockerfile + compose con fitz docker init

Desde adentro de tu proyecto (con fitz.toml):

$ fitz docker init
 fitz docker init  proyecto `mi-app` en `/path/al/proyecto`
   detectado: @server(port = 3000)
   detectado: uso de DB (db.X(...))  compose suma postgres:16-alpine
   detectado: @cron  compose suma restart: unless-stopped
 escrito: Dockerfile
 escrito: .dockerignore
 escrito: docker-compose.yml

Tres archivos generados:

Dockerfile — multi-stage:

ARG FITZ_TAG=latest

FROM ghcr.io/thegreekman76/fitz:${FITZ_TAG} AS builder
WORKDIR /app
COPY fitz.toml ./
COPY src/ ./src/
RUN fitz build

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/mi-app /usr/local/bin/app
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/app"]

.dockerignore — excluye target/, .git/, .env*, __pycache__/, etc.

docker-compose.yml — service app + service db (porque detectó db.X(...)) + restart: unless-stopped (porque detectó @cron):

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-fitz}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fitz}
      POSTGRES_DB: ${POSTGRES_DB:-fitz}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
      interval: 5s
      retries: 5

  app:
    build: .
    container_name: mi-app
    environment:
      DATABASE_URL: "postgres://${POSTGRES_USER:-fitz}:${POSTGRES_PASSWORD:-fitz}@db:5432/${POSTGRES_DB:-fitz}"
    ports:
      - "3000:3000"
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy

Smart runtime selection — si tu programa usa from python import X, el Dockerfile cae automático a python:3.12-slim-bookworm (con libpython + wget). Si no, queda distroless/cc-debian12 (~22 MB).


Paso 2 — Buildear y correr

$ fitz docker build --tag mi-app:v1.0
 fitz docker build  tag `mi-app:v1.0` en `/path/al/proyecto`
[+] Building 38.4s ...
 build OK  `mi-app:v1.0`

$ docker compose up
[+] Running 2/2
  Container mi-app-db   Healthy  3.0s
  Container mi-app      Started  3.5s

# En otra terminal:
$ curl localhost:3000/healthz
{"status":"ok"}

El fitz docker build es un thin wrapper sobre docker build -t <tag> .. Útil porque toma el tag desde package.name por default y te ahorra escribir el path.


Paso 3 — Healthz + readyz auto-mount

Tu binario Fitz expone /healthz y /readyz automáticamente desde el momento que tiene @server(...). No escribiste código para eso:

$ curl localhost:3000/healthz
{"status":"ok"}         liveness probe K8s

$ curl localhost:3000/readyz
{"status":"ready"}      readiness probe K8s

/healthz (liveness) — responde 200 mientras el proceso esté vivo. K8s lo usa para decidir "¿reiniciar este pod?". Si el binario está deadlocked, axum no responde, K8s reinicia.

/readyz (readiness) — responde 200 cuando el proceso está listo para recibir tráfico, 503 durante el "draining" (SIGTERM ya disparó pero el server sigue terminando requests en vuelo).

Custom logic — para chequear la DB o un cache antes de declarar "ready", usá los decoradores dedicados:

@healthz
fn db_alive() -> Bool {
    return db.is_closed() == false
}

@readyz
async fn cache_warm() -> Bool {
    let count = my_cache.size()
    return count > 0
}

Las custom override el default. Si la fn retorna false, el endpoint devuelve 503.


Paso 4 — SIGTERM drain para zero-downtime deploys

Cuando K8s va a matar un pod (rolling deploy, autoscaling), manda SIGTERM. Tu app Fitz responde así automático:

  1. Flippea /readyz a 503 — K8s deja de rutear tráfico al pod (load balancer remueve este pod del pool).
  2. Sigue procesando requests en vuelo — los que ya estaban ejecutando terminan limpiamente.
  3. Tras 30s (grace period default) o cuando termina el último request, el proceso exitea con código 0.

Para K8s en deployment.yaml:

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 35  # 30s drain + 5s margen
      containers:
        - name: app
          image: mi-app:v1.0
          readinessProbe:
            httpGet:
              path: /readyz
              port: 3000
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
            periodSeconds: 30

Con terminationGracePeriodSeconds=35 y readinessProbe chequeando cada 5s, durante un rolling deploy:

  • T+0s: K8s manda SIGTERM al pod viejo.
  • T+0s: pod viejo flippea /readyz a 503.
  • T+5s: K8s detecta readyz=503, remueve del load balancer.
  • T+5-35s: pod sigue terminando los requests que llegaron antes.
  • T+35s: pod exitea limpio.

Zero requests fallidos durante el deploy.


Paso 5 — Aplicar a Kubernetes

Una vez que tu Dockerfile y tu deployment.yaml están listos:

$ docker push mi-app:v1.0  # subir a registry
$ kubectl apply -f deployment.yaml
$ kubectl apply -f service.yaml
$ kubectl apply -f ingress.yaml
$ kubectl rollout status deployment/mi-app
deployment "mi-app" successfully rolled out

Para rolling deploy de v1.1:

$ docker build --tag mi-app:v1.1 . && docker push mi-app:v1.1
$ kubectl set image deployment/mi-app app=mi-app:v1.1
$ kubectl rollout status deployment/mi-app

K8s crea pods con v1.1, espera que su /readyz retorne 200, y recién después remueve los pods v1.0. Si v1.1 nunca pasa healthcheck, K8s detiene el rollout — tu producción sigue con v1.0.


Paso 6 — 12-factor compliance

Los 12 factores de The Twelve-Factor App (https://12factor.net) son el estándar de facto para apps cloud-native. Fitz cubre los 12 por default:

# Factor Cómo Fitz lo cumple
I Codebase fitz.toml + git.
II Dependencies fitz.lock reproducible.
III Config config(key, default) + secret(key).
IV Backing services db.connect(url_from_env).
V Build/release/run fitz build separa explícitamente.
VI Processes Stateless por default; jobs con persist DB opcional.
VII Port binding @server(port, "0.0.0.0").
VIII Concurrency Tokio multi-thread, scale horizontal.
IX Disposability SIGTERM drain automático (30s grace).
X Dev/prod parity Mismo binario, mismas env vars.
XI Logs JSON estructurado a stderr.
XII Admin processes @command(...) + fitz run ad-hoc.

El único factor que requiere atención manual es V (no podés compartir el target/ entre dev y prod — buildeás separado). Los demás están cubiertos por defaults del lenguaje.


Paso 7 — Patrones canónicos de producción

Pattern 1 — Multi-stage compose para entornos:

# compose.yml (base)
services:
  app:
    image: mi-app:${VERSION:-latest}
    env_file:
      - .env.${ENV:-dev}
    ports:
      - "3000:3000"
$ ENV=staging VERSION=v1.0-rc1 docker compose up
$ ENV=prod    VERSION=v1.0     docker compose up

Pattern 2 — Sidecar para log shipping:

En K8s podés mountar un sidecar que tail-ee los logs y los mande a Loki/Splunk:

spec:
  containers:
    - name: app
      image: mi-app:v1.0
    - name: log-shipper
      image: grafana/promtail:latest
      volumeMounts:
        - name: logs
          mountPath: /var/log

Tu app emite JSON a stderr; el sidecar lo recoge.

Pattern 3 — CDN + edge cache para static:

Si tu app tiene un endpoint @get("/static/...") (assets), poné un CDN (Cloudflare, Bunny, CloudFront) adelante. Tu binario Fitz solo sirve dinámico; el CDN cachea estático.

Pattern 4 — Read replicas para escalar reads:

Usá config("DATABASE_URL_READ", "postgres://...replica...") y config("DATABASE_URL_WRITE", "postgres://...primary...") para dirigir reads a una replica y writes a la primary:

let read_db = db.connect(config("DATABASE_URL_READ", default_url))?
let write_db = db.connect(config("DATABASE_URL_WRITE", default_url))?
// reads usan read_db, writes usan write_db

Validación final del módulo M8

Probá end-to-end el stack completo:

# 1. Buildeá producción.
$ fitz docker init
$ fitz docker build --tag mi-app:v1.0

# 2. Lanzá local con compose.
$ docker compose up -d
$ curl localhost:3000/healthz   # 200
$ curl localhost:3000/readyz    # 200

# 3. Simulá rolling deploy (down/up).
$ docker compose stop app
# `/readyz` ya no responde
$ docker compose start app
# Tras 1-2s, `/readyz` vuelve a responder

# 4. Verificá logs.
$ docker compose logs app
{"timestamp":"...","msg":"server listo"}
{"timestamp":"...","msg":"request","method":"GET","path":"/healthz"}

# 5. Verificá métricas (si activaste Prometheus).
$ curl localhost:3000/metrics | head -10

Si los 5 pasos funcionan, tenés una app production-ready.


Lo que viene en M8.C5

Tu app de M6 (ORM nativo) o de M7 (con interop Python) ya está production-ready en lo conceptual. Pero si usaste interop Python en M7, todavía hay una pieza pendiente para el deploy: distribuir el binario sin requerir Python instalado en el destino. El próximo cap cubre fitz build --bundle-python (CPython embebido) y --bundle-pip (paquetes pip empaquetados) — la promesa "un solo archivo" extendida a apps con interop.

M8.C5 — Deploy real de apps con interop Python


El cierre del curso entero (resumen, diferenciales y agradecimiento) vive en M8.C5, que es el último cap del módulo y del curso. Si tu app NO usa interop Python, podés terminar acá — el M8.C5 es opcional para apps puramente Fitz nativas.