Ir al contenido

Casos de estudio: Diagnóstico entre Entornos — QA, Staging y Producción (nivel senior)

Casos de troubleshooting centrados en la pregunta más frustrante de la ingeniería: “¿por qué se comporta distinto aquí que allá?”. Un bug que solo aparece en un entorno nunca es magia: es una diferencia concreta entre entornos — código, configuración, datos, infraestructura, tráfico, dependencias o feature flags — que nadie ha inventariado. El entrevistador evalúa si el candidato tiene un método para encontrar esa diferencia con evidencia (no a base de redeploys esperanzados) y si sabe diseñar entornos cuya validación signifique algo.


1. El bug que solo se reproduce en QA (nunca en local ni en prod)

Sección titulada «1. El bug que solo se reproduce en QA (nunca en local ni en prod)»

Categoría: Entornos de QA · Tipo: [CASO] Diagnóstico multi-entorno

“Un test E2E del flujo de alta de clientes falla en el entorno de QA compartido desde hace 9 días, de forma intermitente: ~40% de las ejecuciones. En local pasa siempre (500/500 en un bucle). En producción no hay un solo error relacionado en 30 días. Tres desarrolladores han mirado el código y ‘no puede fallar’. QA está bloqueando releases por este test. En QA conviven 6 equipos, se despliega ‘lo que cada uno quiere probar’ y la base de datos tiene 2 años de datos acumulados de pruebas manuales. Diagnostica.”

Un fallo que solo ocurre en QA compartido y además es intermitente apunta casi siempre al estado del entorno, no al código: datos contaminados por años de pruebas manuales, versiones mezcladas de servicios (otro equipo probando una rama vieja de tu dependencia), mocks desactualizados respecto al contrato real, o interferencia entre tests de varios equipos que mutan los mismos datos. El método: dejar de mirar el código y fotografiar el entorno — versiones exactas desplegadas, el dato concreto sobre el que falla, y quién más ejecuta qué en ese momento. El fix definitivo no es arreglar este test: es rediseñar QA con entornos efímeros por PR y datos creados por el propio test, porque un QA compartido y mutado a mano nunca dará señal fiable.

Taxonomía aplicada: recorrer los ejes: código (¿misma versión de todo?), configuración, datos (local: semilla limpia; prod: datos validados por el propio sistema; QA: 2 años de mutaciones manuales), concurrencia de uso (local: un usuario; QA: 6 equipos pisándose), dependencias (versiones arbitrarias + mocks). QA compartido es el único entorno donde coinciden datos sucios + versiones mezcladas + interferencia — y el único donde falla. La intermitencia del 40% descarta un bug determinista de código y apunta a estado variable.

Cronología de diagnóstico:

  1. Capturar la evidencia del fallo, no re-ejecutar a ciegas. ¿Qué assertion falla, con qué valor recibido vs esperado, sobre qué registro/ID? Los que “miran el código” tres veces no suelen haber mirado ni una vez el dato concreto sobre el que falló. Si el test crea un cliente y falla con “duplicate key” o con un cliente inesperado en la respuesta, el diagnóstico ya casi está hecho.
  2. Fotografiar versiones. Inventario de qué imagen corre cada servicio implicado: kubectl get deploy -o jsonpath='...image...' comparado contra main. Hallazgo típico: el servicio de validación lleva 3 semanas con la rama experimental de otro equipo, con contrato distinto. Sin política de despliegue, “QA” no es una versión: es una combinación aleatoria de versiones que no existirá jamás en prod.
  3. Auditar el dato. Query sobre la BD de QA buscando el estado que el test asume: ¿duplicados? ¿registros en estados “imposibles” (flujos abandonados a mano, columnas null que el código actual jamás produce, formatos anteriores a una migración)? La BD de QA contiene formas de datos que ni local ni prod han visto jamás.
  4. Correlacionar la intermitencia. Cruzar timestamps de los runs fallidos con ejecuciones/despliegues de los otros equipos. Patrón clásico: el test de otro equipo, cada hora, trunca o muta la tabla de clientes en su setup — el 40% es la probabilidad de solapamiento temporal.
  5. Experimento de aislamiento (la prueba concluyente): ejecutar el test contra un esquema de datos exclusivo creado al vuelo, o congelar el entorno 1 hora y correrlo 20 veces. Si pasa 20/20 en aislamiento, el código es inocente — y esa demostración es la palanca política para el rediseño.

Hipótesis descartadas: race condition en el código — la favorita porque es la única causa “interesante”, pero 500/500 en local la hace improbable y las races también asomarían en prod bajo carga real (30 días sin un caso). Flakiness de infraestructura — los assertion errors con datos incorrectos no son timeouts. Bug de la versión desplegada en QA — a veces parcialmente cierto, pero es un subcaso de “el entorno es la variable”.

Root cause típico (uno o varios): (a) el test asume datos que otro equipo muta; (b) datos legacy en formas que el código no contempla y que ningún entorno realista contendrá; (c) versión incompatible de una dependencia desplegada por otro equipo; (d) mock desactualizado respecto al contrato que local (mock nuevo) y prod (servicio real) sí cumplen. Causa estructural común: QA compartido sin dueño, sin política de versiones y sin gestión de datos — un entorno cuyo estado nadie puede describir no puede validar nada.

Fix inmediato vs definitivo:

  • Inmediato: desbloquear releases con evidencia: correr el test en aislamiento y documentarlo; regenerar el dato que rompe; fijar por escrito qué versión de cada servicio está en QA durante la ventana de release.
  • Definitivo: (1) entornos efímeros por PR: namespace levantado por el pipeline con versiones exactas declaradas, destruido al terminar — el entorno pasa de ser un lugar a ser un artefacto reproducible; (2) datos por test: cada test crea lo que necesita (factories); prohibido depender de datos que no creó; semillas versionadas junto al código; (3) si un QA persistente debe existir para exploración manual, se reconstruye periódicamente desde semillas — ganado, no mascota; (4) contract tests contra los servicios reales para que los mocks no deriven; (5) dashboard de “qué versión corre en QA y quién la puso”.

Prevención: regla de runbook: ante un test que falla solo en un entorno, la primera pregunta es “¿qué contiene ese entorno que los otros no?”, respondible con herramientas, no con arqueología. Métrica de flakiness por test y entorno: un 40% de fallos espurios cuesta releases bloqueados y entrena el hábito corrosivo de “re-lanza a ver si pasa” — el peor daño, porque el día que el rojo sea real también lo re-lanzarán.

Qué espera oír el entrevistador: el reflejo de fotografiar el entorno (versiones + datos + actividad concurrente) en vez de releer el código; la intermitencia leída como estado variable; el experimento de aislamiento como prueba concluyente; la crítica estructural al QA compartido mutable; y el paquete efímeros + datos por test + reconstrucción periódica — arreglar “este test” sin tocar el entorno garantiza repetir el caso.


2. Todo verde en staging, cae en producción a la primera hora pico

Sección titulada «2. Todo verde en staging, cae en producción a la primera hora pico»

Categoría: Staging vs realidad · Tipo: [CASO] Diagnóstico multi-entorno

“Lanzamos una versión con un buscador de pedidos rediseñado. En staging pasó todo: E2E, una semana de QA manual y ‘pruebas de carga’ con 50 usuarios virtuales — latencias de 80 ms. Salió el martes a las 09:00; a las 11:30, con el tráfico pico habitual, la latencia del buscador subió a 12 s, el pool de conexiones a PostgreSQL se agotó (100/100) y arrastró a todo el servicio, incluido lo que no era el buscador. Rollback a las 12:10. La query nueva, ejecutada a mano en staging, tarda 40 ms. ¿Qué pasó y qué cambia en la manera de validar?”

Staging mintió en los tres ejes que casi nunca replica: volumen de datos (con miles de filas el planner de PostgreSQL elige un plan; con 80 millones y otra distribución elige otro — la misma query pasa de index scan a seq scan o nested loop catastrófico), concurrencia (50 VUs no saturan un pool; 3.000 usuarios reales con una query de segundos lo agotan, y el pool agotado convierte un problema local en caída global) y tráfico real (búsquedas humanas: prefijos cortos, wildcards, paginación profunda que el script no ejercía). Diagnóstico: EXPLAIN (ANALYZE, BUFFERS) de la misma query en prod comparado con staging — la diferencia de plan es la prueba. Fix: índice/reescritura para el plan real y pools compartimentados. Prevención: validar queries sobre datos de escala productiva, load testing con perfiles reales y shadow traffic.

Taxonomía aplicada: código idéntico; los ejes divergentes son datos (staging tiene el 0,5% de las filas y distribución sintética uniforme; prod tiene skew real), carga y comportamiento de usuarios. Recordatorio central: para una base de datos, el dato es parte del programa — el plan de ejecución se decide con las estadísticas de la tabla, así que la misma query sobre datos distintos es otro programa.

Cronología de diagnóstico (post-rollback):

  1. Confirmar con evidencia. En una réplica de prod (o prod en valle con statement_timeout): EXPLAIN (ANALYZE, BUFFERS) con parámetros reales capturados de los logs del incidente; lo mismo en staging. Comparar: staging, Index Scan ... actual time=0.4..38ms; prod, Seq Scan on pedidos ... 11800ms o un Nested Loop con estimación errada por 4 órdenes de magnitud (rows=120 estimadas vs 2.4M reales). Esa discrepancia estimado-vs-real es el hallazgo: el planner eligió con estadísticas que no representan la consulta, o el índice que la query necesita no es usable con el predicado nuevo (ILIKE '%texto%', función sobre la columna, un OR que rompe el índice compuesto).
  2. Reconstruir la caída total. Query de 12 s × N búsquedas → las 100 conexiones ocupadas por el buscador → los endpoints sanos esperan conexión → timeout → el health check también necesita BD → pods unhealthy → reinicios. Confirmable con la espera de adquisición del pool y pg_stat_activity. Lección paralela: un pool compartido sin compartimentar convierte cualquier endpoint lento en fallo global (ausencia de bulkheads).
  3. Autopsia del load test. 50 VUs buscando 20 términos fijos (resultados pequeños, cacheados tras la primera pasada), sin paginación profunda, sobre 400K filas: valida que “el servicio responde con 50 usuarios amables sobre datos de juguete” — pregunta que nadie necesitaba responder. La carga real: 3.000 concurrentes, cola larga de términos, clientes con millones de pedidos.
  4. Verificar el timing (11:30, no 09:05). La correlación con la curva de RPS confirma que el detonante fue el tráfico cruzando el umbral donde query-lenta × concurrencia agota el pool. Descarta causas del deploy en sí (arranque frío, migración).

Hipótesis descartadas: “la BD de prod está mal dimensionada” — servía el mismo tráfico el lunes con la versión anterior; el hardware es constante, el plan cambió. “Falta caché” — cachear una query de 12 s es esconder el bug, y las búsquedas tienen cardinalidad alta. “Pico de tráfico anómalo” — el RPS era el de cualquier martes; lo anómalo era la versión.

Root cause: query nueva cuyo plan es razonable con los datos de staging e inviable con el volumen y distribución de prod, amplificada por un pool único sin aislamiento. Root cause de proceso: el pipeline no tiene ninguna etapa capaz de detectar problemas que solo existen a escala — staging valida corrección funcional, pero la organización creía que validaba “que funciona”.

Fix inmediato vs definitivo:

  • Inmediato: índice correcto para el patrón real (CREATE INDEX CONCURRENTLY) o reescritura para el plan bueno (índice trigram/textsearch si es texto libre); validar el plan en réplica antes de relanzar; statement_timeout por servicio para que ninguna query secuestre conexiones minutos.
  • Definitivo: (1) pools compartimentados (bulkheads): el buscador con cuota propia — si se satura, se degrada él solo; health checks que no dependan del recurso saturable; (2) validación de queries a escala en el pipeline: réplica/snapshot de prod (o sintético con la misma cardinalidad y skew) donde toda query nueva pasa EXPLAIN ANALYZE con presupuesto; (3) load testing honesto: perfiles derivados de logs reales, concurrencia del pico +50%, datos de escala real, criterio de éxito en p99 y saturación de pool; (4) shadow traffic para rutas de lectura nuevas: duplicar tráfico real hacia la candidata (respuestas descartadas) — habría revelado los 12 s sin un solo usuario afectado; (5) canary con latencia por endpoint y auto-rollback, para que lo que se escape cueste 5 minutos y 5% del tráfico.

Prevención: institucionalizar en la revisión la pregunta “¿qué diferencias entre staging y prod son relevantes para este cambio?” — para un cambio de query, la respuesta es el volumen y distribución de datos, y eso dicta dónde validar. Documentar qué valida cada entorno para que “pasó staging” deje de significar “está validado”.

Qué espera oír el entrevistador: que los planes dependen de datos y estadísticas — “la misma query” no existe entre datasets distintos; EXPLAIN (ANALYZE, BUFFERS) comparado, leyendo la discrepancia estimado/real; la mecánica pool-agotado→caída-global y bulkheads; la autopsia del load test de juguete; shadow traffic y canary; y la conclusión de proceso: staging no es un entorno de rendimiento, y pretenderlo sin datos de escala es autoengaño institucional.


3. Config drift: el hotfix manual de hace 6 meses

Sección titulada «3. Config drift: el hotfix manual de hace 6 meses»

Categoría: Configuration drift · Tipo: [CASO] Diagnóstico multi-entorno

“Hoy desplegamos a producción una versión menor del servicio de notificaciones, probada sin incidencias en staging. En prod, nada más arrancar, el servicio rechaza el 100% de los webhooks de un proveedor con 401. Rollback: los 401 continúan. El equipo jura que ‘no hemos tocado la autenticación en meses’. Tras 3 horas, alguien descubre que en prod había una variable WEBHOOK_AUTH_MODE=legacy-hmac puesta a mano en el deployment hace 6 meses durante un incidente, que nunca se llevó al repositorio. El deploy de hoy la borró. Staging nunca la tuvo. Reconstruye el caso y di cómo se evita la categoría entera de problema.”

Caso de libro de configuration drift: un cambio manual “temporal” en prod que nunca volvió al repo. Durante 6 meses prod y staging fueron entornos distintos sin que nadie lo supiera; el deploy de hoy no rompió nada — reconcilió el deployment con la fuente declarada, borrando la config huérfana. El rollback no arregló porque también parte de la fuente declarada (pista clave: rollback que no revierte el síntoma = el problema no está en la versión del código). Fix inmediato: restaurar la variable, esta vez por el pipeline. Fix de categoría: todo cambio pasa por el pipeline (también las emergencias, por una vía rápida), detección continua de drift (plan/diff de IaC en cron), reconciliación automática estilo GitOps para que el drift viva minutos y no meses, y auditoría de accesos manuales.

Taxonomía aplicada: eje configuración, en su variante más peligrosa: la diferencia no está entre lo declarado para cada entorno (visible en el repo) sino entre lo declarado y lo realmente corriendo. Es invisible para todo proceso que razona sobre el repositorio: reviews, diffs de release, staging. Staging validaba fielmente… la configuración declarada, que llevaba 6 meses sin ser la real.

Cronología de diagnóstico (cómo llegar en 20 minutos y no en 3 horas):

  1. Leer la firma del rollback fallido. Si revertir el código no revierte el síntoma, quedan: datos, estado externo y configuración fuera del artefacto versionado. Esta inferencia debe dispararse en el minuto 5 y reorienta de “¿qué hay en el diff de la release?” a “¿qué tenía prod que ya no tiene?”.
  2. Comparar el runtime real antes-vs-después, no el repo. kubectl rollout history deployment/notifications y diff entre revisiones (--revision=N -o yaml). Ahí aparece en minutos: la revisión previa tenía WEBHOOK_AUTH_MODE=legacy-hmac en env; la nueva no. En infra no-K8s: diff de la config real contra lo que el IaC declara — terraform plan llevaba 6 meses mostrando el cambio pendiente… si alguien lo hubiera mirado.
  3. Reconstruir el origen con la auditoría. Audit log del cluster (quién hizo kubectl edit y cuándo), correlacionado con el postmortem del incidente de hace 6 meses. Se aplicó a las 02:00 para volver al esquema de firma antiguo del proveedor, con un “mañana lo llevamos al repo” que nunca ocurrió — y sin el cual staging llevó 6 meses probando un código path distinto al de prod (el modo nuevo, que en el sandbox del proveedor funcionaba porque acepta ambos).
  4. Cerrar el bucle funcional: confirmar que la producción del proveedor sigue firmando con el esquema legacy — la variable no era un vestigio: era la configuración correcta, existiendo solo en la memoria de un deployment.

Hipótesis descartadas: regresión en el código de auth — descartada por el rollback fallido (la evidencia más barata del caso). El proveedor cambió algo hoy — la coincidencia exacta con nuestro deploy la hace inverosímil; status page y otros consumidores lo confirman. Secreto rotado/expirado — plausible con 401 (comprobar edad y validez), pero no explica la correlación con el deploy ni la persistencia tras rollback.

Root cause: técnico: configuración aplicada a mano fuera de la fuente de verdad, eliminada por el siguiente despliegue que reconcilió contra lo declarado. De proceso: la organización permite mutar prod a mano sin mecanismo que fuerce la vuelta al repo, no detecta drift y no audita accesos manuales de forma visible. El incidente de hoy no lo causó el deploy: lo causó el hotfix de hace 6 meses; el deploy solo cobró la deuda.

Fix inmediato vs definitivo:

  • Inmediato: restaurar la variable con commit y por el pipeline (no a mano otra vez: repetir el pecado durante su propio funeral es el clásico del caso); verificar recuperación; barrido inmediato de más drift en servicios críticos (deployments, configmaps, secrets, firewall, crontabs).
  • Definitivo: (1) “todo por el pipeline”, incluida la emergencia: una vía rápida legítima (revisión de 1 persona, deploy directo) para que a las 02:00 el camino correcto sea casi tan rápido como kubectl edit — si el proceso de emergencia no existe, la gente edita a mano y tiene razón en hacerlo; el cambio manual puro queda para “arde el edificio”, con PR de reconciliación obligatorio en 24 h rastreado por el proceso de incidentes; (2) detección continua de drift: terraform plan -detailed-exitcode en cron con alerta; en Kubernetes, GitOps (Argo CD/Flux) con OutOfSync visible y alertado; (3) reconciliación automática donde el riesgo lo permita: revierte el cambio manual en minutos — convierte el drift de deuda silenciosa de 6 meses en fallo inmediato del hotfix manual, infinitamente mejor porque falla delante de quien lo hizo, con el contexto fresco; (4) accesos manuales escasos y auditados: elevación temporal (break-glass) con registro y revisión periódica — cada uso es síntoma de que al pipeline le falta algo.

Prevención: el postmortem debe atacar el incidente de hace 6 meses, no el de hoy: la acción correctiva es el proceso de emergencia + reconciliación, no “tener más cuidado”. Checklist de cierre de incidentes: “¿se aplicó algún cambio manual? ¿está ya en el repo?”. Y regla de diagnóstico: ante un deploy que rompe algo que ‘no se tocó’, sospechar que el deploy ha borrado drift.

Qué espera oír el entrevistador: la lectura inmediata del rollback-que-no-revierte; el diff del runtime real (rollout history) frente a releer el repo; la reformulación “el deploy no rompió: reconcilió”; que la reconciliación automática es deseable precisamente porque hace fallar el drift rápido; la vía rápida de emergencia como pieza clave (sin ella, la política es papel mojado); y la identificación de que el incidente real ocurrió hace 6 meses.


4. “Funciona en mi máquina”: el contenedor pasa CI pero falla en el cluster

Sección titulada «4. “Funciona en mi máquina”: el contenedor pasa CI pero falla en el cluster»

Categoría: Contenedores / Runtime · Tipo: [CASO] Diagnóstico multi-entorno

“Un servicio nuevo de generación de PDFs pasa toda la CI: build, unit, integración con testcontainers, y corre perfecto en las máquinas de los devs con docker run. En el cluster de staging: (1) CrashLoopBackOff con OOMKilled al procesar cualquier PDF de más de 3 páginas; (2) con más memoria, falla la conexión al servicio de plantillas: getaddrinfo ENOTFOUND templates.internal; (3) arreglado eso, los PDFs salen con fechas en UTC y ‘???’ donde van tildes; (4) y en producción ni arranca: EACCES: permission denied, open '/app/cache'. El dev dice, literalmente, ‘pero si es el mismo contenedor’. ¿Lo es? Diagnostica los cuatro fallos.”

Es la misma imagen, no el mismo entorno de ejecución: la imagen fija filesystem y binario, pero el cluster impone lo que docker run local no impone — límites de memoria/CPU (cgroups → OOMKilled y throttling), DNS propio (templates.internal no existe para CoreDNS), entorno mínimo (sin locale ni TZ heredados de la máquina del dev) y securityContext (no-root, filesystem read-only → EACCES). Diagnóstico: comparar el runtime efectivo, no la imagen — kubectl describe pod (límites, OOMKilled), kubectl exec para probar DNS/env/permisos desde dentro. Fix por eje y, como prevención, ejecutar en local y CI con las mismas restricciones del cluster: límites, no-root, read-only, env mínimo.

Taxonomía aplicada: cuatro sub-ejes del eje infraestructura/runtime, que como checklist cubren el 90% de los “funciona en mi máquina” con contenedores: (a) recursos: local sin límites; el cluster impone resources.limits vía cgroups; (b) red/DNS: el pod resuelve con CoreDNS y el search path de su namespace, no con el DNS del host; (c) entorno implícito: locale, TZ, variables que el shell del dev exporta sin saberlo; (d) seguridad: local corre como root con FS escribible; el cluster impone runAsNonRoot, readOnlyRootFilesystem, capabilities recortadas. “El mismo contenedor” es cierto como el mismo pez está igual en el río y en la orilla.

Cronología de diagnóstico, fallo a fallo:

  1. OOMKilled. kubectl describe pod → Reason: OOMKilled, Exit Code: 137; límite: memory: 256Mi. Renderizar PDFs usa cientos de MB por documento — en local nadie lo notó porque Docker Desktop regala 8 GB. Medir el pico real (kubectl top pod, métricas) y decidir: límite a lo medido + margen, y/o streaming/troceo, y/o cola con concurrencia 1-2 por pod. Matices senior: el CPU limit — el throttling no mata el pod pero explica latencias absurdas y no genera evento visible (mirar container_cpu_cfs_throttled_periods_total); y si el runtime es JVM/Node, comprobar que respeta el cgroup para su heap (JVM vieja se dimensiona contra la RAM del nodo y muere por OOM “habiendo memoria”).
  2. ENOTFOUND templates.internal. Desde dentro: kubectl exec -it <pod> -- sh → nslookup templates.internal, cat /etc/resolv.conf. Diagnóstico típico: el nombre correcto es templates.<namespace>.svc.cluster.local (o templates en el mismo namespace), y templates.internal era un alias del /etc/hosts del dev o del DNS de la oficina. Comprobar también NetworkPolicy de egreso y namespaces. Regla: la conectividad se prueba desde donde falla, no desde el portátil.
  3. UTC y ‘???’. El pod arranca con entorno mínimo: sin TZ, sin LANG, y la imagen slim ni trae locales generados ni la fuente con glifos latinos que el renderizador necesita. En local, el entorno del dev tapaba todo. Fix correcto: no depender del entorno implícito — timezone explícita en el código (la del usuario/documento; el servidor en UTC es lo deseable), UTF-8 explícito, fuentes dentro de la imagen. Regla: si cambiar el entorno del host cambia el output, el servicio tiene dependencias no declaradas.
  4. EACCES /app/cache solo en prod. Prod impone runAsNonRoot + readOnlyRootFilesystem (staging aún no — nota: debería, precisamente para que esto no llegue a prod). Fix: emptyDir en la ruta de caché, y arreglar la imagen: USER 10001 en el Dockerfile, rutas de escritura declaradas. Correr como root “porque funciona” era el bug latente; prod solo lo reveló.

Hipótesis descartadas: imagen distinta entre CI y cluster — verificable en 1 minuto comparando digests (docker inspect --format '{{index .RepoDigests 0}}' vs kubectl get pod -o jsonpath='{.status.containerStatuses[0].imageID}'); hacerlo siempre primero, porque cuando difieren (tag reutilizado) explica todo; aquí coinciden. Bug del orquestador — todo es de restricciones que el orquestador aplica correctamente. “Testcontainers debería haberlo pillado” — valida integración de dependencias, no el runtime: corre igual de sin-límites y como-root que todo lo demás en CI.

Root cause común: el servicio dependía de propiedades implícitas del entorno de desarrollo (memoria ilimitada, DNS del host, locale/TZ/fuentes, root con FS escribible) que ningún manifiesto declaraba y ningún entorno previo negaba. CI validó el código; nadie validó el contrato entre el proceso y su runtime.

Fix inmediato vs definitivo: los inmediatos son los cuatro puntuales. El definitivo: (1) paridad de restricciones en local y CI — un docker run de referencia con --memory=256m --cpus=0.5 --read-only --user 10001 --tmpfs /tmp y env mínimo, usado en CI como smoke test (“¿arranca y genera un PDF con las restricciones de prod?”); (2) securityContext estricto en todos los entornos — que staging sea más laxo que prod invierte su propósito; (3) checklist de containerización en el template de servicio: límites medidos, no-root, read-only con escrituras declaradas, TZ/locale/fuentes autocontenidos, nombres de servicio del cluster desde el día uno; (4) despliegue a un entorno real como parte del pipeline del primer día del servicio: cada fallo costaba minutos el día uno y costó días en la semana del release.

Qué espera oír el entrevistador: la distinción imagen vs runtime dicha explícitamente; el checklist de sub-ejes recorrido con evidencia concreta (describe pod, exit code 137, exec + nslookup, digests comparados); los matices de throttling invisible y runtime-que-no-ve-el-cgroup; la inversión de culpa en el fallo 4 (prod tenía razón); y la prevención por paridad de restricciones — reproducir el cluster en local no es levantar Kubernetes en el portátil, es imponer localmente las mismas negaciones que el cluster impone.


5. Certificados y DNS: SSLHandshakeException solo en producción

Sección titulada «5. Certificados y DNS: SSLHandshakeException solo en producción»

Categoría: TLS / Redes · Tipo: [CASO] Diagnóstico multi-entorno

“Integramos la API de un partner de scoring crediticio. En staging lleva 3 semanas funcionando contra su sandbox (sandbox.api.partner.com). El pase a producción (api.partner.com): el 100% de las llamadas falla con SSLHandshakeException: unable to find valid certification path to requested target desde los pods de prod. Desde el portátil de un dev, curl https://api.partner.com funciona. Desde un pod de staging apuntando a la URL de prod del partner… también funciona. El partner dice que ‘todo está bien en su lado’. ¿Cómo lo diagnosticas sin adivinar y cuáles son los sospechosos ordenados?”

unable to find valid certification path = el cliente no puede construir la cadena de confianza hasta una CA de su truststore. Que falle solo desde pods de prod (y funcione desde staging contra el mismo endpoint — dato de oro) reduce los sospechosos a diferencias del lado cliente en prod: (1) truststore distinto en la imagen/JVM de prod (imagen base diferente o truststore corporativo congelado sin la CA del partner); (2) proxy corporativo de egreso solo en prod haciendo intercepción TLS y presentando su propio certificado, en el que el pod no confía; (3) cadena incompleta del servidor (sin intermedios) que unos clientes toleran (curl con AIA/caché) y otros no. Metodología: openssl s_client desde el pod de prod y desde staging, comparando qué certificado y cadena llega en cada caso: si difieren, hay un interceptor; si son iguales, el problema es el truststore local. Evidencia en 10 minutos, sin adivinar.

Taxonomía aplicada: doble eje. Configuración/artefacto: el truststore es parte del runtime (imagen base, sistema, fichero corporativo montado) y puede diferir entre entornos sin decisión consciente. Infraestructura de red: el egreso desde prod (proxy con TLS inspection, firewall, NAT) puede ser distinto del de staging — las redes corporativas suelen endurecer solo prod. La trampa: “staging funcionó 3 semanas” validó el egreso de staging contra el sandbox; ni el endpoint de prod del partner ni el egreso de prod fueron ejercidos jamás.

Cronología de diagnóstico (cada paso descarta medio árbol):

  1. Reproducir desde el punto exacto del fallo. Nada de portátiles: kubectl exec en un pod de prod (o pod debug con la misma imagen): openssl s_client -connect api.partner.com:443 -servername api.partner.com -showcerts El -servername es obligatorio: sin SNI muchos frontales devuelven el certificado por defecto y el diagnóstico queda contaminado (y es en sí un sospechoso: cliente viejo sin SNI). Leer: ¿qué certificado llega (subject, issuer)? ¿cuántos certs en la cadena? ¿Verify return code?
  2. La misma llamada desde un pod de staging hacia el mismo api.partner.com, y comparar los certificados recibidos — la bifurcación central:
    • Si difieren (prod recibe issuer=CN=Corporate-Proxy-CA, staging la CA pública del partner): intercepción TLS en el egreso de prod — proxy corporativo, firewall con inspección o egress gateway del mesh. Confirmar con HTTPS_PROXY en el pod y preguntando a networking. Fix: confiar en la CA del proxy (decisión consciente) o excepcionar el destino de la inspección.
    • Si son idénticos: el problema es el truststore del cliente en prod. Siguiente: ¿cuántos certs envía el servidor? Si solo el leaf (cadena incompleta), curl funciona porque OS/navegadores cachean intermedios o siguen AIA, pero una JVM no: falla exactamente con este error. Aunque el fallo sea del partner (“todo está bien” y no lo está), nuestra evidencia (-showcerts mostrando 1 cert donde debía haber 2-3) desbloquea la conversación.
  3. Auditar el truststore real del proceso. ¿Cuál usa? (JVM: cacerts de la imagen o corporativo vía -Djavax.net.ssl.trustStore). keytool -list -keystore ... | grep -i <ca>. Hallazgos típicos: truststore corporativo congelado hace 3 años sin la CA nueva, o directamente imagen base distinta entre entornos (drift de artefacto: el pecado del caso 8 asomando aquí).
  4. Descartar DNS como variable oculta: getent hosts api.partner.com desde ambos entornos. Si resuelven a IPs distintas (split-horizon: el DNS interno de prod apunta al proxy o a otro endpoint regional), el “mismo hostname” no es el mismo servidor. Barato, elimina una clase entera de confusión.
  5. Sospechosos menores: pinning de un cert que el partner rotó; TLS/ciphers viejos (daría error de protocolo, distinguible); reloj del nodo desviado (“not yet valid”).

Hipótesis descartadas: “el partner tiene mal producción” — a veces cierto (cadena incompleta), pero el curl del dev funcionando obliga a explicar por qué unos clientes sí: la explicación siempre está en las diferencias de cliente/camino, y “es culpa del partner” sin evidencia es la hipótesis-parálisis que regala días. “Es DNS” — comprobado y descartado en 1 minuto en el paso 4, en vez de flotar como maldición. “Certificado caducado” — daría certificate has expired; leer el mensaje exacto ahorra horas.

Root cause típico (los dos más frecuentes): (1) egreso de prod con inspección TLS corporativa que staging no tiene, con la CA del proxy ausente del truststore del pod — nadie del equipo de producto sabía que la inspección existía; (2) truststore de la imagen de prod desactualizado/distinto, sin la CA o intermediate con la que el partner firma su producción — el sandbox usaba otra CA y por eso staging nunca lo detectó.

Fix inmediato vs definitivo:

  • Inmediato: añadir la CA que falta vía el build de la imagen y el pipeline — no con keytool a mano dentro del pod, que es fabricar el caso 3 de este documento; si es cadena incompleta del partner, presionar con la evidencia de s_client y, mientras, incluir la intermediate en nuestro truststore como puente temporal con fecha de retirada.
  • Definitivo: (1) truststore como artefacto gestionado: una única fuente (capa base común o paquete versionado) para todos los entornos, actualizada regularmente — “¿qué CAs confía el servicio X en prod?” debe salir de un repo, no de una autopsia; (2) paridad de egreso: staging sale a internet por el mismo tipo de camino que prod (mismo proxy/inspección) para que esta clase de sorpresa caiga en staging; (3) smoke test de conectividad de salida en el pase a prod: handshake TLS real contra cada dependencia externa de producción (no el sandbox) desde el entorno de prod antes de habilitar tráfico; (4) monitorización de expiración y cambios de cadena de dependencias externas (alerta a 30 días, alerta si la cadena presentada cambia).

Prevención: documentar la topología de egreso por entorno (¿qué hay entre un pod e internet?) como documentación de plataforma — el desconocimiento de la intercepción TLS es la causa cultural del caso. Regla citable: en problemas de TLS, la primera acción es openssl s_client desde el punto exacto que falla, comparado contra un punto que funciona; cada minuto teorizando antes es un minuto perdido.

Qué espera oír el entrevistador: interpretación correcta del error (camino de certificación del cliente); la comparación pod-de-prod vs pod-de-staging contra el mismo endpoint como bifurcación (¿certs distintos → interceptor; iguales → mi truststore?); s_client con -servername y -showcerts y saber leer el output; conocer la inspección TLS corporativa y las cadenas incompletas (y por qué curl “miente”); DNS comprobado y no maldecido; y los fixes de plataforma: truststore gestionado, paridad de egreso, smoke test contra endpoints reales antes del pase.


6. La migración de BD que pasó en staging y revienta en producción

Sección titulada «6. La migración de BD que pasó en staging y revienta en producción»

Categoría: Migraciones de BD · Tipo: [CASO] Diagnóstico multi-entorno

“Release con una migración: añadir una columna status con default 'active' y un índice sobre orders(customer_id, status). En staging corrió en 11 segundos. En producción, el pipeline la lanzó a las 10:00 de un miércoles: a los pocos segundos, el checkout y todo lo que escribe en orders empezó a dar timeouts; a los 60 s el pipeline mató la migración por su propio timeout, la transacción hizo rollback… y el servicio siguió degradado unos minutos más. Además, la réplica de lectura acumuló 4 minutos de lag y los dashboards mostraron datos viejos. orders tiene 2.100 filas en staging y 340 millones en prod. Explica la mecánica exacta de cada síntoma y cómo se hace esto bien.”

Staging con el 0,001% de los datos convirtió en instantáneo lo que en prod es una operación de minutos que además bloquea: el CREATE INDEX normal toma un lock que impide escrituras en orders durante toda la construcción — de ahí los timeouts del checkout; peor, el DDL encolado detrás de transacciones vivas hace que la cola de locks bloquee incluso a quien no conflige directamente. El timeout del pipeline dejó el peor mundo: todo el dolor, nada del resultado. El lag de réplica: el torrente de WAL de la operación masiva satura la replicación. Hacerlo bien: DDL online (CREATE INDEX CONCURRENTLY, defaults no-reescribientes de PG11+), lock_timeout corto para no encolar detrás de nadie, expand-contract con backfill por lotes, y ensayar toda migración contra un dataset de escala productiva midiendo duración y locks.

Taxonomía aplicada: eje datos en su dimensión más brutal: el volumen. Una migración es una operación física cuyo coste (tiempo, locks, IO, WAL) escala con los datos: staging con 2.100 filas no ensaya la migración, ensaya su sintaxis. Eje secundario: infraestructura — la réplica existe solo en prod, así que la clase entera “efectos sobre la replicación” era invisible antes.

Mecánica exacta de cada síntoma:

  1. Timeouts de escritura. CREATE INDEX (sin CONCURRENTLY) permite lecturas pero bloquea INSERT/UPDATE/DELETE durante la construcción — minutos sobre 340M de filas. Y el detalle que distingue nivel: la cola de locks. El DDL espera detrás de cualquier transacción abierta que toque orders (una analítica larga, un SELECT perezoso); mientras espera su lock exclusivo, todo lo que llega detrás se encola tras él. La tabla queda congelada aunque el índice ni haya empezado a construirse — por eso la degradación llegó “a los pocos segundos”. (La columna: en PostgreSQL ≥11, ADD COLUMN ... DEFAULT <constante> es solo metadato; en versiones viejas o con default volátil habría reescrito la tabla entera — hay que saber qué operaciones son baratas en qué versión del motor.)
  2. El timeout del pipeline empeoró las cosas. Matar la migración a los 60 s: rollback del DDL → se pagó el bloqueo sin obtener el índice; posible índice residual INVALID; y la “degradación unos minutos más”: backlog de requests + retries drenándose y pools recuperándose. Un timeout genérico de pipeline aplicado a migraciones produce interrupciones a mitad de operaciones pesadas: el peor momento para interrumpir.
  3. Lag de réplica. La actividad masiva genera un torrente de WAL; la réplica lo aplica esencialmente en single-thread y se queda atrás → 4 minutos → los consumidores de la réplica leen el pasado. Nadie pensó en la réplica porque staging no tiene: topologías distintas hacen invisibles clases enteras de efecto.

Cronología de diagnóstico en vivo: (1) correlación migración↔síntomas inmediata (el pipeline dice qué corría); (2) mecanismo confirmado con pg_stat_activity + pg_locks: el CREATE INDEX con wait_event_type=Lock y la cola detrás (pg_blocking_pids() sobre las sesiones del checkout); (3) decisión correcta en caliente: cancelar la migración — el timeout del pipeline “acertó” por accidente, de forma descontrolada; (4) post-incidente: buscar residuos — índices INVALID, DROP INDEX del residuo antes de reintentar.

Hipótesis descartadas: “la BD de prod está infradimensionada” — ejecutaba el mismo tráfico sin la migración perfectamente; el problema es la operación, no la capacidad. “Mala suerte, hora punta” — a las 03:00 el bloqueo habría durado igual (menos víctimas, mismo bug); programar migraciones bloqueantes de madrugada es mitigación, no solución. “Bug del framework de migraciones” — hizo exactamente lo que el fichero decía; el fichero era el bug.

Root cause: técnico: migración escrita en su forma bloqueante (sin CONCURRENTLY, sin lock_timeout) porque en staging la diferencia entre la forma bloqueante y la online es indetectable — ambas tardan milisegundos. De proceso: el pipeline no distingue migraciones triviales de peligrosas, no las ensaya contra volumen realista y no exige plan online ni de rollback para tablas grandes.

Fix inmediato vs definitivo:

  • Inmediato: limpiar residuos; re-ejecutar bien: SET lock_timeout = '2s' (si no consigue el lock, falla ella en vez de congelar la tabla — con reintentos) + CREATE INDEX CONCURRENTLY (sin lock de escrituras; fuera de transacción; puede dejar índice INVALID si falla — se limpia y reintenta); ventana de menor tráfico igualmente, monitorizando lag de réplica.
  • Definitivo: (1) doctrina de migraciones online por motor y versión: qué DDL bloquea y sus formas seguras (PG: concurrently, defaults de metadato, NOT VALID + VALIDATE CONSTRAINT; MySQL: gh-ost/pt-online-schema-change); (2) expand-contract por defecto para cambios incompatibles: expand (añadir lo nuevo sin tocar lo viejo), migrate (backfill por lotes de 1.000-10.000 filas con pausas y monitorización de lag/locks, reanudable — nunca un UPDATE de 340M filas en una transacción), contract (retirar lo viejo releases después), cada fase desplegable y reversible por separado, compatible con N y N+1 conviviendo durante el rolling deploy; (3) ensayo obligatorio a escala: toda migración sobre tablas grandes corre primero contra un clon de prod (snapshot/restore o thin clone) midiendo duración real y locks — el resultado (11 s vs 40 min) decide si entra al pipeline normal o a proceso especial; (4) timeouts con sentido: presupuesto de duración declarado y validado en el ensayo, con política explícita al excederlo; (5) linter de migraciones (tipo squawk para PG) que rechaza DDL bloqueante sobre tablas listadas como grandes.

Prevención: métrica organizacional: cero migraciones bloqueantes en tablas calientes, auditable en linter y ensayo. Lección citable: staging valida la sintaxis de una migración; solo el volumen valida la migración. Un snapshot restaurado cuesta minutos-horas de máquina y previene incidentes de horas de negocio.

Qué espera oír el entrevistador: la mecánica de locks contada con precisión — incluida la cola detrás de transacciones vivas (el detalle que separa a quien lo ha vivido); qué DDL bloquea y las alternativas online del motor concreto; lock_timeout como cinturón de seguridad; expand-contract y backfill por lotes; el ensayo contra clon de prod como gate; el lag de réplica como efecto de topología invisible en staging; y la crítica al timeout genérico del pipeline.


7. Feature flags divergentes: el bug imposible de reproducir

Sección titulada «7. Feature flags divergentes: el bug imposible de reproducir»

Categoría: Feature flags · Tipo: [CASO] Diagnóstico multi-entorno

“Soporte escala un caso: un cliente concreto ve el carrito vaciarse al aplicar un cupón. Nadie lo reproduce: ni en local, ni en QA, ni en staging, ni siquiera en producción con cuentas de prueba. Solo ese cliente (y, se descubre después, otros 200 usuarios que no habían protestado). El sistema usa un servicio de feature flags con 47 flags activos, con targeting por porcentaje, por segmento y por overrides individuales. QA y staging tienen los flags ‘más o menos como prod’ — nadie puede afirmar más. El bug lleva 3 semanas con etiqueta ‘no reproducible’. ¿Cómo lo cazas y qué cambias para que la categoría no vuelva?”

Con 47 flags con targeting, “producción” no es un entorno: son 2^47 entornos potenciales, y cada usuario vive en uno definido por su combinación de flags. “No reproducible” significa “no sabemos en qué entorno vive ese usuario”. Método: obtener la evaluación exacta de flags para ese usuario (de logs/traces si se registran las evaluaciones — y si no, esa es la primera lección del caso), reproducir con esa combinación forzada, y encontrar el conflicto: típicamente dos flags que nadie probó juntos (checkout nuevo + motor de cupones legacy) que interactúan mal. Fix del bug: corregir la interacción o excluir la combinación por targeting. Fix de categoría: evaluaciones en logs/traces siempre, paridad de flags gestionada entre entornos, matriz de combinaciones críticas probada, e higiene agresiva: los flags caducan.

Taxonomía aplicada: los flags son el eje más resbaladizo porque crean entornos dentro de los entornos: dos usuarios en la misma prod, mismo código y misma BD, ejecutan programas efectivamente distintos. Y entre QA/staging y prod, el estado de flags casi nunca se gestiona con el rigor del código: “más o menos como prod” es la confesión — nadie hace diff de flags, así que QA valida una combinación que quizá no existe en prod para nadie. El bug “no reproducible” es casi siempre un bug perfectamente determinista en un estado que no se ha reproducido.

Cronología de diagnóstico:

  1. Reformular. “No reproducible” → “no hemos igualado el estado”. Estado = código (versión) + datos (su carrito, su cupón) + flags evaluados para él. Los dos primeros se miran; el tercero es el punto ciego de las 3 semanas.
  2. Obtener la evaluación real del usuario afectado. En orden de preferencia: (a) logs/traces de sus requests, si registran evaluaciones (un atributo flags.checkout_v2=true en el span — el estándar deseable); (b) la API del servicio de flags evaluando su contexto (id, segmento, plan) — con la trampa de que la config actual puede no ser la de hace 3 semanas: consultar el historial; (c) reconstruir a mano las reglas — la peor opción. Resultado típico: el usuario cae en checkout_v2=true (rollout al 10% por hash de user-id) y coupon_engine_legacy=true (override por segmento puesto hace 5 meses como workaround de otro bug).
  3. Reproducir forzando la combinación exacta. Cuenta de prueba con overrides que igualan la evaluación del afectado. Con checkout_v2 + coupon_engine_legacy, el bug sale a la primera: el checkout nuevo espera del motor de cupones el carrito recalculado; el legacy responde con el formato viejo (solo el descuento); el checkout nuevo lo interpreta como “carrito vacío” y lo persiste. Determinista al 100% — nunca fue flaky, era invisible.
  4. Dimensionar el impacto: query sobre las evaluaciones (o los criterios de targeting): ¿cuántos usuarios están en la intersección? Los ~200 silenciosos. Ese número convierte el ticket en incidente con comunicación proactiva y reparación de datos.
  5. Arqueología: checkout_v2 se probó en QA con los demás flags en default; coupon_engine_legacy es un override cuyo bug original ya se arregló — debió morir hace 4 meses. Nadie es dueño de la matriz: cada equipo prueba su flag contra el mundo por defecto.

Hipótesis descartadas: corrupción de datos del cliente — con sus datos exactos y flags por defecto no se reproduce (test que aísla el eje flags); bug de concurrencia — los 200 lo sufren el 100% de las veces y el resto el 0%: eso es partición por estado, no aleatoriedad — la distribución de un bug entre usuarios (todos-a-veces vs algunos-siempre) es de las señales diagnósticas más valiosas: algunos-siempre = busca qué tienen esos usuarios de diferente; versión de app vieja — mismo comportamiento desde web y móvil actualizado.

Root cause en tres capas: (1) del bug: contrato incompatible entre checkout_v2 y coupon_engine_legacy, combinación jamás probada; (2) del “no reproducible”: las evaluaciones no se registraban — una dimensión entera de estado fuera de la observabilidad; (3) sistémico: flags sin ciclo de vida (workaround de 5 meses sin dueño ni caducidad), sin paridad gestionada entre entornos, sin noción de qué combinaciones existen en prod ni cuáles se probaron.

Fix inmediato vs definitivo:

  • Inmediato: romper la combinación por targeting (excluir del rollout de checkout_v2 al segmento con el legacy) — arregla a los 200 en minutos sin deploy, la gran virtud de los flags; reparar los carritos dañados; después, arreglar el contrato de verdad y matar coupon_engine_legacy, que ya no tenía razón de existir.
  • Definitivo: (1) evaluaciones observables siempre: cada request registra en trace/log las flags evaluadas y su valor — convierte “¿qué ve este usuario?” en un clic y habilita la query inversa “¿quién está en la combinación X?”; (2) flags como configuración gestionada por entorno: estado versionado/exportable con diff QA↔staging↔prod visible y sincronización deliberada; los E2E declaran contra qué combinación corren; (3) matriz de combinaciones críticas: no se prueban 2^47, pero sí las intersecciones entre flags del mismo dominio (checkout×cupones×pagos), con alerta cuando dos flags del mismo dominio tienen targeting solapado; (4) higiene y caducidad: todo flag nace con dueño, propósito y fecha de revisión; alertas de flags viejos; los flags de release se retiran con el release — un flag permanente es configuración y se gestiona como tal; (5) monitorización segmentada por cohorte de flag (error-rate on vs off) — habría detectado a los 200 el primer día sin esperar a soporte.

Prevención: el conjunto de flags activos como parte de la definición del sistema: campo obligatorio en postmortems (“¿qué combinación estaba activa?”), presente en revisiones de arquitectura. Métricas de higiene con umbral: edad media de flags activos y flags sin dueño.

Qué espera oír el entrevistador: la reformulación de “no reproducible” como “estado no igualado” y los flags como dimensión de estado (2^N entornos); la señal algunos-siempre vs todos-a-veces; el método evaluación-real→reproducción-forzada→dimensionar-afectados; el targeting como fix instantáneo sin deploy; y el paquete sistémico — observabilidad de evaluaciones, paridad gestionada, matriz por dominio, caducidad — con la frase clave: un flag sin fecha de muerte es deuda con interés compuesto.


8. Dependencias distintas por entorno: el mismo commit produce artefactos diferentes

Sección titulada «8. Dependencias distintas por entorno: el mismo commit produce artefactos diferentes»

Categoría: Builds reproducibles · Tipo: [CASO] Diagnóstico multi-entorno

“Un bug de redondeo en los importes de facturas aparece en producción. El commit desplegado es a3f9c21. Un dev hace checkout de ese commit, builda en su máquina: el bug no está. CI rebuilds el mismo commit: tampoco está en el artefacto nuevo. Pero el artefacto que corre en prod — buildado desde ese mismo commit hace 3 semanas — sí lo tiene. Comparando: la imagen de prod contiene decimal-utils 2.4.1; los builds nuevos traen 2.4.3. El package.json dice ^2.4.0. Además, staging, desplegado hace 4 días, lleva 2.4.2 — tres entornos, el mismo commit, tres artefactos distintos. Explica todas las formas en que esto pasa y cómo se elimina la categoría.”

El commit no determina el artefacto porque el build no es reproducible: rangos abiertos (^2.4.0 = “lo que haya el día que buildes”), lockfile ausente o ignorado (instalar sin --frozen-lockfile), imagen base por tag mutable (node:20 de hoy ≠ el de hace 3 semanas), mirror/registry interno con cachés divergentes según el runner, y — la estructural — rebuild por entorno en vez de promover un único artefacto. Con eso, “el mismo commit” produce artefactos distintos según el día y la máquina, y el diagnóstico de cualquier bug queda envenenado: no puedes reproducir lo que corre en prod. Solución en una política: build once & promote — un artefacto por commit, identificado por digest inmutable, y ese mismo binario (no “el mismo código”) viaja QA→staging→prod. Lockfiles con dientes, bases por digest y SBOM para saber qué hay dentro de cada artefacto.

Taxonomía aplicada: eje artefacto/dependencias — el más traicionero porque invalida la premisa de todos los demás diagnósticos: al comparar entornos asumes que “misma versión desplegada” = “mismo software”, y aquí es falso. Tres entornos, mismo commit, tres artefactos = la organización no sabe qué software ejecuta, y cualquier “en staging funciona” es incomparable por construcción.

Cronología de diagnóstico:

  1. Confirmar la divergencia con evidencia de artefacto, no de repo. Del contenedor de prod: versiones reales instaladas (docker run --rm <imagen-prod> cat node_modules/decimal-utils/package.json, o pip freeze/mvn dependency:list según stack) contra los builds nuevos. Ya hecho: 2.4.1 vs 2.4.2 vs 2.4.3. Changelog de la librería: 2.4.2 cambió el redondeo por defecto (banker’s rounding) — “patch” según el autor, cambio de comportamiento financiero según la realidad. Prod tiene el comportamiento viejo, los builds nuevos el nuevo — y nadie decidió ninguno de los dos.
  2. Auditar el build — las cinco goteras clásicas: (a) ¿hay lockfile? Sí, pero CI instala con npm install (que puede actualizarlo) en vez de npm ci (que falla si no coincide) — el lockfile estaba de adorno; (b) Dockerfile: FROM node:20 — tag mutable: imágenes distintas según el día del pull; (c) ¿de dónde vienen los paquetes? Registry interno con cachés en estados distintos por pool de runners — dos builds del mismo día pueden diferir; (d) ¿cuántas veces se builda un release? El pipeline rebuilda por entorno — tres builds, tres momentos, tres resultados, la explicación directa del enunciado; (e) otros no-determinismos: postinstall que descarga binarios sin versión fijada, herramientas del build en latest.
  3. Reconstruir el timeline: prod buildado hace 3 semanas resolvió 2.4.1 (lo último entonces), staging hace 4 días 2.4.2, hoy 2.4.3. Todo consistente con rango abierto + lockfile inoperante + rebuild por entorno. Misterio resuelto — sin ningún bug en el código propio.

Hipótesis descartadas: el commit desplegado no es el que creemos — verificable comparando el digest de la imagen de prod con el que el registry asocia al build de ese pipeline (si esa trazabilidad no existe, apuntarlo como hallazgo grave aparte); aquí el commit era correcto. Hotfix manual en la imagen de prod (el fantasma del caso 3) — la imagen es idéntica a la del build original; nadie la tocó: nació distinta. “El bug lo introduce 2.4.3” — al revés: establecer con negocio cuál es el comportamiento de redondeo correcto antes de llamar “bug” a ninguno; puede que prod llevara 3 semanas facturando mal — pregunta de negocio que el caso obliga a formular.

Root cause: el proceso trata la resolución de dependencias como algo que puede ocurrir en cualquier momento con resultado “equivalente” — falso en cuanto un rango abierto cruza una versión con cambio de comportamiento. Causas apiladas: lockfile no aplicado, base por tag mutable, registry con cachés divergentes y, la estructural, rebuild por entorno. Cualquiera basta para romper la reproducibilidad; juntas garantizan el caos.

Fix inmediato vs definitivo:

  • Inmediato: decidir el redondeo correcto con negocio; pinear decimal-utils a la versión elegida; auditar los importes del periodo divergente (¿facturas con redondeo distinto según el día? posible rectificación); un solo build nuevo, verificado, desplegado a todos los entornos para restaurar la igualdad.
  • Definitivo: (1) build once & promote: un build por commit; el artefacto (imagen OCI) identificado por digest (sha256:…) y firmado; la promoción QA→staging→prod mueve ese digest; los manifiestos referencian digest, no tags mutables — “¿qué corre en prod?” se responde con un digest verificable; (2) lockfiles con dientes: npm ci / --frozen-lockfile / pip con hashes; CI falla si manifest y lockfile discrepan; actualizaciones de dependencias solo como PRs explícitos (Renovate/Dependabot) con diff, review y tests — las subidas de versión vuelven a ser cambios visibles en vez de loterías del día de build; (3) base images por digest (FROM node:20-slim@sha256:…), actualizadas también vía PR automatizado; (4) SBOM por artefacto (syft o similar, generado en el build y almacenado): “¿qué versión de X lleva la imagen de prod?” pasa de arqueología a consulta, y habilita respuesta a CVEs (“¿qué artefactos desplegados contienen log4j 2.14?” en segundos); (5) verificación periódica de reproducibilidad: rebuild del mismo commit en runner limpio y diff contra el original — las goteras nuevas se detectan solas.

Prevención: la regla citable: se despliegan artefactos, no commits — el commit es una intención; el artefacto es el hecho; todo razonamiento entre entornos se apoya en digests iguales, no en SHAs de git. Y en postmortems, ante “funciona en un entorno y no en otro con la misma versión”, el paso cero pasa a ser comparar digests — 30 segundos que aquí habrían ahorrado media investigación.

Qué espera oír el entrevistador: el inventario completo de no-determinismos (rango abierto, lockfile decorativo, tag mutable, mirror con cachés, rebuild por entorno); npm install vs npm ci como detalle concreto; build-once-&-promote con digests como solución estructural (no “pinear esta librería” y ya); SBOM con su doble uso; la finura de preguntar cuál comportamiento era el correcto antes de “arreglar”; y la consecuencia epistemológica: sin builds reproducibles, toda comparación entre entornos es sospechosa.


9. Datos de prueba vs datos reales: el buscador perfecto que enloquece en prod

Sección titulada «9. Datos de prueba vs datos reales: el buscador perfecto que enloquece en prod»

Categoría: Datos de prueba · Tipo: [CASO] Diagnóstico multi-entorno

“Rediseñamos el ranking del buscador de productos (texto + popularidad + recencia). En QA, sobre el dataset de prueba de 5.000 productos, equipo y producto lo validaron a mano: ‘claramente mejor’; métricas offline: +18% de relevancia. Sale a prod (2,1M de productos): en 48 horas el CTR del buscador cae un 31%, soporte recibe quejas de ‘busco taladro y me salen fundas de móvil’, y la búsqueda de la marca más vendida devuelve primero productos descatalogados de 2019. En QA es imposible reproducir ninguno de los casos malos. Diagnostica y define cómo se valida un cambio así de verdad.”

El dataset de QA no compartía con prod ninguna propiedad de las que el ranking depende: distribuciones (5.000 productos limpios y equilibrados vs 2,1M con popularidad de cola larga extrema, títulos con keyword stuffing, categorías desbalanceadas), calidad (sin nulls, sin descatalogados, sin duplicados — prod está lleno) y escala (con 5.000 ítems cualquier scoring razonable ordena bien; con 2,1M los outliers y el spam dominan). Para un algoritmo que pondera sobre datos, los datos son el programa: validarlo sobre datos irreales es validar otro programa. Diagnóstico: tomar las queries quejadas, ejecutarlas en prod con el score desglosado por componente, y contrastar las distribuciones QA vs prod. Fix: defensas en los términos del score (normalización, gates de estado, capping). Prevención: evaluación offline sobre datos reales, golden queries de logs, shadow/interleaving y rollout gradual — con los límites legales (GDPR) gestionados, no ignorados ni usados de excusa.

Taxonomía aplicada: eje datos en su dimensión cualitativa (el caso 2 era la cuantitativa: volumen y planes; aquí es contenido). El más subestimado porque el dataset de QA “se parece” a prod a simple vista — tiene productos, títulos, precios — pero no comparte ninguna propiedad estadística: popularidad power-law (el top 1% concentra la mayoría de clicks), texto adversarial (vendedores con 40 keywords en el título), estados sucios (descatalogados con popularidad histórica alta), duplicados, unicode inconsistente, nulls. Un ranking es una función de esas propiedades: cambiarlas cambia la función.

Cronología de diagnóstico:

  1. Convertir las quejas en casos de test. Recolectar 30-50 queries malas de soporte y de logs (CTR desplomado post-release). “taladro → fundas” y “marca-top → descatalogados” son oro: reproducibles en prod, donde el bug sí existe. Instrumentación imprescindible: ejecutar una query con score desglosado por componente (texto: 3.2, popularidad: 8.1, recencia: −0.4) por resultado. Si ese “explain del ranking” no existe, construirlo es el primer paso — sin él solo hay vibes.
  2. Autopsia de cada patología con el desglose:
    • “taladro → fundas”: títulos spam (“funda compatible taladro Bosch Makita…”) — el componente de texto premia la aparición del término sin castigar el stuffing, y la popularidad (las fundas baratas se venden mucho) remata. En QA invisible: los 5.000 títulos los escribió el propio equipo, limpios.
    • “marca-top → descatalogados de 2019”: popularidad calculada sobre histórico acumulado — un producto estrella descatalogado conserva años de señal, y la penalización por estado discontinued no existe o pesa menos que la montaña de popularidad. En QA no había descatalogados.
    • CTR general: con 2,1M de ítems, los outliers reales (popularidad sin capping, fechas basura tipo 1970-01-01 de un import viejo) mandan resultados absurdos a posiciones altas en la cola larga de queries.
  3. Contrastar distribuciones QA vs prod para el informe: histograma de popularidad (QA casi uniforme; prod power law brutal), repetición de términos en títulos, % de nulls, % de descatalogados/duplicados. La tabla es la evidencia de que QA no podía detectar nada de esto — importante para que la lección no quede en “el algoritmo era malo”.
  4. Entender la validación fallida: “+18% de relevancia” era verdad… sobre un dataset sin ninguna patología. Validación no inválida: irrelevante — respondía a “¿ordena bien datos limpios?” en vez de “¿ordena bien nuestros datos?”.

Hipótesis descartadas: bug de implementación — el desglose muestra que el código hace exactamente lo diseñado: el diseño es lo que no sobrevive a los datos reales; distinción clave porque el fix es de diseño/pesos, no un patch. Índice corrupto / motor de búsqueda — resultados deterministas y explicables por el score. “Los usuarios se acostumbrarán” — el CTR −31% es la medida objetiva de que no; se descarta con datos, y rápido, porque es la hipótesis-excusa que retrasa rollbacks.

Root cause: del algoritmo: componentes de score sin defensas contra las propiedades reales de los datos — sin saturación del término de texto (stuffing premiado), popularidad sin capping ni decaimiento temporal, estado del producto sin gate duro. Del proceso: validación íntegra sobre un dataset de juguete, sin evaluación offline sobre datos reales y sin rollout gradual (salió al 100% de golpe; un canary habría gritado en horas con el 5%).

Fix inmediato vs definitivo:

  • Inmediato: rollback del ranking (o flag al anterior — un cambio de ranking sin kill-switch es temeridad); triaje del score: gate duro por estado (descatalogado no rankea), capping/log-scaling de popularidad, saturación del score de texto (aparecer 40 veces no vale 40 veces), decaimiento temporal de la señal. Re-validar contra las golden queries antes de relanzar.
  • Definitivo: (1) evaluación offline sobre datos reales: corpus = snapshot del catálogo real + golden queries de logs reales (top-N por volumen + muestra de cola larga + las históricamente problemáticas) con juicios de relevancia mantenidos; toda iteración se mide ahí (nDCG/CTR proxy) antes de acercarse a prod; (2) shadow evaluation o interleaving en prod: ranking nuevo en sombra sobre tráfico real comparando con el actual — señal real sin riesgo real; (3) rollout por porcentaje con CTR segmentado por variante y auto-rollback; (4) estrategia de datos de prueba honesta y por capas: para lógica funcional, sintéticos generados con las distribuciones y patologías reales (perfiles estadísticos de prod: cardinalidades, nulls, skew, duplicados); para evaluación de relevancia, el catálogo real — que rara vez es dato personal: señalarlo es senior, porque el pánico GDPR indiscriminado empobrece los datasets sin necesidad. Donde sí hay datos personales (queries de usuarios pueden contenerlos; historiales de compra lo son): minimización y anonimización sin destruir la señal — hashear los términos de búsqueda mata el dataset para relevancia; alternativas: agregación (queries con frecuencia ≥ k), filtrado de PII, base legal y retención definidas con legal. El límite existe y se gestiona, no se ignora ni se usa de excusa para validar con juguetes.

Prevención: los datasets de evaluación tienen dueño, se versionan y se refrescan (un golden set congelado 2 años deriva de la realidad igual que el dataset de QA del caso); métrica guardián del buscador (CTR, tasa de “sin clicks”) con alerta por variante en todo cambio de ranking. Lección citable: para sistemas cuyo comportamiento depende de los datos, el dataset de prueba es parte del código — un dataset de juguete es un stub, y nadie valida un sistema entero contra un stub.

Qué espera oír el entrevistador: la tesis “los datos son el programa” aplicada; el desglose de score por componente como herramienta central (y construirla si no existe); las patologías concretas de datos reales (power law, stuffing, señal histórica de descatalogados, fechas basura); la crítica precisa a la validación (“+18% verdad e irrelevante”); golden queries + shadow/interleaving + rollout gradual; y el tratamiento adulto de GDPR — dónde aplica de verdad y cómo anonimizar sin matar la señal.


10. Diseña la estrategia de entornos desde cero para 15 microservicios

Sección titulada «10. Diseña la estrategia de entornos desde cero para 15 microservicios»

Categoría: Diseño de entornos · Tipo: [CASO] Diagnóstico multi-entorno

“Llegas como staff engineer a una scale-up: 15 microservicios, 8 equipos, ~30 deploys/semana y subiendo. Hoy tienen: local con docker-compose (roto para la mitad de los servicios), un QA compartido en el que nadie confía (los tests fallan un 30% de las veces por razones ajenas al código), un ‘staging’ que lleva 5 versiones de retraso en tres servicios, y producción. Los incidentes post-deploy son semanales y la respuesta habitual es ‘en staging funcionaba’. Diseña la estrategia de entornos desde cero: cuántos, qué pregunta responde cada uno, qué garantiza, y cómo se paga. Presupuesto finito y sin equipo de plataforma dedicado aún.”

Principio de diseño: cada entorno existe para responder una pregunta concreta con fiabilidad; un entorno que no responde nada es coste puro (su QA actual). Propuesta: (1) local/CI — “¿mi código es correcto en aislamiento?”: compose por servicio con dueño, dependencias dockerizadas y contract tests; (2) entornos efímeros por PR — “¿esta versión se integra?”: namespace levantado por pipeline con la versión del PR + últimas estables del resto, datos por test, destruido al mergear — sustituyen al QA compartido, que se elimina; (3) staging como pre-prod estricto — “¿el release funciona en condiciones de prod?”: paridad de config/infra/artefactos garantizada por pipeline, siempre al día, datos de escala representativa; (4) producción con testing seguro — “¿funciona de verdad?”: canary con análisis automático, flags observables y shadow traffic para lo de alto riesgo — asumiendo que hay diferencias que solo prod enseña y aprendiéndolas con blast radius mínimo. El coste se paga eliminando el QA zombi, con efímeros que viven horas y con staging de paridad de forma, no de tamaño — salvo los datos, donde el tamaño es la señal.

Marco: la taxonomía como especificación. Los casos 1-9 son el catálogo de diferencias que matan: datos (volumen, distribución, suciedad), configuración (drift, flags), artefactos (builds no reproducibles), infraestructura (límites, red, TLS, topología), tráfico (concurrencia, patrones) y dependencias (versiones mezcladas, mocks derivados). Diseñar la estrategia de entornos es decidir, para cada eje, en qué entorno se paga el coste de replicarlo con fidelidad — replicarlo todo en todas partes es incosteable; no replicarlo en ninguna es el status quo del enunciado. Formato de la respuesta: por entorno, su pregunta, sus garantías (invariantes con verificación) y su coste.

Diagnóstico del punto de partida: el QA compartido no responde nada (30% de fallos espurios = señal indistinguible del ruido, que entrena a ignorar rojos); staging con 5 versiones de retraso responde una pregunta sobre un pasado que nadie va a desplegar; y “en staging funcionaba” es la confesión de que nadie definió qué garantiza staging. Regla previa: un entorno sin invariantes definidas y verificadas no valida — solo retrasa.

El diseño, entorno a entorno:

  1. Local + CI — “¿mi servicio es correcto en aislamiento?” Unit tests, integración con dependencias de infraestructura reales dockerizadas (testcontainers) y contract tests contra consumidores/proveedores (Pact o verificación de esquemas): la pieza que permite que casi toda la validación de integración NO necesite levantar 15 servicios — con 8 equipos, la integración por contratos es lo que evita que el resto explote de coste. Restricciones de runtime de prod ya aquí (límites, no-root, read-only: caso 4). Compose reparado y mantenido con dueño por servicio y smoke test en CI — compose roto es síntoma de que nadie es responsable de la experiencia local. Garantiza: corrección del servicio y sus contratos. No garantiza: nada emergente de la integración real ni de la escala.

  2. Entornos efímeros por PR — “¿esta versión se integra con las últimas estables de los demás?” Namespace creado por el pipeline: la versión del PR + la última estable (digest promovido, caso 8) de los otros 14, datos semilla versionados + datos por test (caso 1), servicios externos con mocks verificados por contrato contra el sandbox real periódicamente. E2E de flujos críticos aquí. Vida: horas; coste: elástico y proporcional a la actividad. El QA compartido se apaga — no se arregla, se elimina: su función la cubren mejor los efímeros, y mantenerlo “por si acaso” perpetúa la señal ruidosa y el coste. Garantiza: integración funcional contra un conjunto de versiones conocido y declarado. No garantiza: rendimiento, escala de datos, tráfico real.

  3. Staging como pre-producción estricta — “¿el release, como artefacto, funciona en condiciones de producción?” La palabra clave: paridad garantizada, no aspiracional. Invariantes con verificación automática: (a) mismos manifiestos/IaC que prod con diferencias solo parametrizadas y auditables (diff CI entre entornos: vacío salvo tamaños y secretos); (b) siempre al día: todo lo mergeado se despliega a staging automáticamente — el retraso de 5 versiones es imposible por construcción, no por disciplina; (c) mismos artefactos por digest que irán a prod (build once & promote); (d) paridad de forma de infraestructura: misma topología (réplicas de BD para ver lag — caso 6, mismo mesh, mismo tipo de egreso/TLS — caso 5, mismas políticas de seguridad — caso 4) aunque el tamaño sea menor, con una excepción deliberada: (e) datos de escala representativa (clon enmascarado de prod o sintético con distribuciones reales — casos 2 y 9), porque en los datos el tamaño ES la señal; aquí se ensayan migraciones (caso 6) y planes de query; (f) paridad de flags con diff visible (caso 7); (g) load tests con perfiles reales, programados, no heroicos. Garantiza: que el release desplegado con el proceso de prod sobre config de prod y datos de escala funciona. No garantiza: tráfico real, combinaciones reales de flags/usuarios, escala plena.

  4. Producción con testing seguro — “¿funciona de verdad?” Se asume como principio, no como derrota: hay diferencias (tráfico real, datos plenos, combinaciones de flags) que solo prod contiene; la madurez no es evitar aprender en prod sino aprender con blast radius mínimo y detección rápida. Piezas: canary con análisis automático segmentado (global + por endpoint + top clientes) y auto-rollback; feature flags con evaluaciones en traces, caducidad e higiene (caso 7) para desacoplar deploy de release; shadow traffic para cambios de alto riesgo en rutas de lectura (rankings, queries nuevas — casos 2 y 9); y observabilidad que haga baratas las preguntas de diagnóstico (¿qué digest corre? ¿qué flags vio este usuario? ¿qué plan usó esta query?). Guardarraíles transversales: reconciliación GitOps contra drift (caso 3), digests inmutables (caso 8), migraciones online ensayadas (caso 6).

El argumento de coste (la pregunta trampa): el diseño no añade coste neto — lo reubica del desperdicio hacia la señal: se elimina un QA permanente que costaba infra + releases bloqueados + confianza; los efímeros cuestan horas de cómputo elástico; staging ya existía — se le añade sobre todo proceso (pipelines de paridad, despliegue continuo, verificaciones de diff), y su partida cara real son los datos de escala (storage del clon + pipeline de enmascarado: inversión justificada por los incidentes tipo 2/6/9 que previene); el testing en prod es principalmente tooling. Contra la métrica del enunciado — incidentes post-deploy semanales — el retorno es directo y medible. Honestidad final que un staff engineer debe decir en voz alta: ninguna combinación de entornos elimina las sorpresas de prod; el presupuesto se optimiza para que cada clase de sorpresa se detecte en el entorno más barato capaz de detectarla, y las restantes, en prod, con radio pequeño. Secuenciación pragmática sin equipo de plataforma: primero build-once-&-promote + staging siempre-al-día + canary (barato, ataca “en staging funcionaba” de raíz), después efímeros y apagado de QA, después datos de escala y shadow.

Qué espera oír el entrevistador: el principio “cada entorno responde una pregunta” con las preguntas explícitas; la valentía de eliminar el QA compartido en vez de arreglarlo; paridad de staging como invariantes verificadas por pipeline (diff de manifiestos, despliegue continuo, digests), no como aspiración; la distinción paridad de forma vs tamaño, con los datos como excepción; contratos como el mecanismo que hace escalable la integración con 15 servicios; testing en producción como ingeniería madura con guardarraíles, no como resignación; el razonamiento de coste con reubicación del gasto y secuenciación; y las referencias cruzadas a las clases de fallo que cada pieza previene — señal de que el diseño viene de cicatrices y no de un diagrama de blog.