Free

Debuggear Rails en producción con Claude, sin SSH ni redespliegue

Diagnóstico + arreglo de datos Rails en prod: kamal exec, `?` ancho completo, Base64, continuidad de thread.


Los 400/500 de producción no se parecen en nada al local. En local corres los tests de nuevo, editas, vuelves a correr. En producción, un usuario ya dio clic en "Pagar" y está mirando una pantalla en blanco.

Tres caminos:

  1. SSH al servidor — funciona, pero el shell dentro del contenedor puede no tener rails console, tus cambios no tienen auditoría, un typo y te mete en problemas
  2. Redesplegar — empujar un fix, 10+ minutos mínimo, posible indisponibilidad breve, y muchos problemas no son de código (son de datos o credentials)
  3. Ejecutar Rails runner dentro del contenedor en marcha — sin reinicio, sin redespliegue, sin SSH, precisión quirúrgica

Este artículo va del tercer camino. Dos casos reales: un de ancho completo encajado en los credentials que rompía los pagos x402 con 500, y un tweet en medio de un thread con status: :failed que hay que reintentar preservando la continuidad del thread. Por el camino, Claude intentará salirse por 4 direcciones equivocadas por defecto — hay que interceptar cada una.


La herramienta: kamal app exec --reuse

Kamal es la herramienta de deploy de Rails de 37signals. Tiene un comando:

kamal app exec --reuse 'bin/rails runner "..."'

--reuse significa: no levantes un contenedor nuevo, ejecuta dentro del contenedor web que ya está corriendo. Sin nuevo build, sin docker pull, sin reinicio, sin reinyección de ENV. Comando corre, contenedor vuelve a atender requests.

La salida sale por stdout hasta tu terminal — efectivamente un puts dentro de una consola Rails de producción, sin SSH, tmux ni salir del portátil.

Sesión típica:

$ kamal app exec --reuse 'bin/rails runner "puts User.count"'
Launching command with version abc123 from existing container...
  INFO [ok] Finished in 3.8 seconds with exit status 0
App Host: deploy.how2claude.com
12847

Ida y vuelta en 3-6 segundos. Dos órdenes de magnitud más rápido que un redespliegue.

Caso 1: El de ancho completo que provocó los 500

Commit eba9ac9.

Síntoma: Unas horas después de lanzar los pagos x402, cada request de pago devolvía 500. Logs llenos de Net::HTTPBadResponse del cliente HTTP. Local funcionaba perfecto.

Diagnóstico: Que Claude imprima primero la config x402 en producción:

kamal app exec --reuse 'bin/rails runner "puts X402.configuration.wallet_address.inspect"'

Salida:

"0xAbC123...def?"

Hay un de más al final — un signo de interrogación de ancho completo (U+FF1F), no el ? de ancho medio. El método de entrada de alguien cambió al editar config/credentials/production.yml.enc y coló ese carácter.

El config/credentials.yml.enc local (desencriptado con el master.key de dev/test) no lo tenía — production y dev son credentials encriptadas separadas en Rails 8, contenidos no compartidos.

Fix: No puedes hacer SSH y editar el archivo (está encriptado) ni puedes bajarlo y editarlo local (el master.key no está en tu portátil). La jugada es que Claude escriba un script Ruby desechable e inyectarlo vía EDITOR= en credentials:edit:

# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# Quita el signo de interrogación de ancho completo del final
content.gsub!(/(wallet_address: 0x[0-9a-fA-F]+)?\s*$/, '\1')
File.write(ARGV[0], content)
EDITOR="ruby script/fix_prod_wallet.rb" \
  bin/rails credentials:edit --environment production

Flujo de credentials:edit: desencriptar → escribir a archivo temporal → invocar $EDITOR → re-encriptar → borrar temporal. Cambiar EDITOR por nuestro script Ruby automatiza la edición, sin necesidad de mirar el cifrado en local.

Después, git commit + kamal deploy una vez — este deploy sí es obligatorio porque production.yml.enc cambió. Pero el diagnóstico no costó un deploy.

Regla: Cuando producción se rompe, lee primero con kamal app exec --reuse. No adivines, no redespliegues primero.

Caso 2: Reintento de XQueue::Tweet fallado en medio del thread

Síntoma: Tras publicar un artículo, los tweets entran a la tabla x_queue_tweets. Uno de ellos — el 2º de un thread de 4 — termina con status: :failed (rate limit de X API, validación de contenido, lo que sea). Reintentarlo requiere preservar la continuidad del thread con el 1º tweet.

Encontrar el fallido:

kamal app exec --reuse 'bin/rails runner "
  XQueue::Tweet.where(status: :failed).each do |t|
    puts \"#{t.id}: thread=#{t.thread_id} pos=#{t.thread_position} content=#{t.content.inspect}\"
  end
"'

Resulta ser id=87, thread_id=15, thread_position=2.

Trampa 1: escape de shell. El contenido de los tweets suele tener comillas, saltos de línea, backticks. Si escribes:

# revienta — el shell se come comillas y barras invertidas
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'

El baile de Base64 — codifica local, pasa la cadena base64, decodifica dentro del runner:

# Codificar local
echo -n 'Contenido reescrito del tweet...' | base64
# => Q29udGVuaWRvIHJlZXNjcml0byBkZWwgdHdlZXQuLi4=

# Pasarlo
kamal app exec --reuse "bin/rails runner \"
  t = XQueue::Tweet.find(87)
  t.update!(content: Base64.decode64('Q29udGVuaWRvIHJlZXNjcml0byBkZWwgdHdlZXQuLi4='), status: :scheduled)
  puts t.status
\""

Las cadenas Base64 son ASCII solo, seguras para shell.

Trampa 2: continuidad del thread. XQueue::PostTweetJob.perform_later(87) publica un tweet suelto — no encadena con el tweet #1 — porque la X API necesita reply_to_tweet_id y el Job por defecto no lo lleva.

Encontrar el x_tweet_id del tweet anterior (los enviados con éxito llenan este campo):

kamal app exec --reuse "bin/rails runner \"
  t = XQueue::Tweet.find(87)
  prev = XQueue::Tweet.where(thread_id: t.thread_id, thread_position: t.thread_position - 1).first
  puts 'prev x_tweet_id: ' + prev&.x_tweet_id.to_s
\""
# => prev x_tweet_id: 1834567890123456789

Encolar con el target de reply:

kamal app exec --reuse 'bin/rails runner "
  XQueue::PostTweetJob.perform_later(87, reply_to_tweet_id: \"1834567890123456789\")
  puts \"enqueued\"
"'

El polling_interval del worker es 0.1 segundos — recoge el job casi de inmediato. Unos segundos después, kamal app exec para ver el status pasar de scheduled a posted y x_tweet_id lleno — thread continuo.

Regla: Las operaciones de datos en producción deben respetar las restricciones a nivel de negocio, no solo "registro actualizado con éxito". La continuidad del thread es restricción de negocio; Rails runner no la chequea por ti.

Las 4 direcciones por las que Claude se va por defecto (y cómo redirigir)

Al hacer este tipo de trabajo, el primer instinto de Claude suele estar mal. Atrapa cada uno:

1. Quiere hacer SSH al servidor

"Déjame hacer SSH y echar un vistazo..."

Redirige: kamal app exec --reuse le gana al SSH — en el contenedor, env de Rails cargado, auditado (los logs de kamal dejan registro), no toca el shell del host, sin drift de contenedor (reuse garantiza la versión de producción actual).

2. Quiere escribir una migration para arreglar datos

"Escribo una migration para eliminar el de ancho completo del wallet_address..."

Redirige: Cambiar un valor de credentials no necesita migration (no se tocó la DB). Un Rails runner desechable lo hace en 10 segundos; una migration necesita deploy y vive en el schema para siempre. Solo usa migration si el arreglo podría repetirse; un typo es cosa de una sola vez.

3. Mete caracteres especiales en cadenas de shell

"Con update!(content: "...") basta..."

Redirige: Cualquier contenido generado por usuario (tweets, comentarios, markdown de usuario) debe pasar por Base64. El parseo del shell de comillas, barras invertidas, $ y backticks es un campo minado clásico — producción no es el sitio para practicarlo.

4. perform_later sin parámetros de negocio

"Relanzo con PostTweetJob.perform_later(87) y ya..."

Redirige: Primero pregunta "¿este registro se relaciona con otros?" Los threads tienen relaciones de reply_to, los jobs por lotes tienen batch_id, los jobs paginados tienen cursor — la lista de argumentos del Job es el portador de esas relaciones. Omite un argumento y rompes una cadena.

Checklist

Debuggear + mutar datos de Rails en producción con Claude — 6 reglas:

  1. Leer antes de escribir. kamal app exec --reuse 'bin/rails runner "puts X"'. Localiza el problema antes de tocar nada.
  2. kamal app exec --reuse es la herramienta por defecto, no SSH ni redespliegue. Dentro del contenedor, Rails cargado, ida y vuelta 3-6 segundos.
  3. Cambios de credentials vía EDITOR=ruby-script bin/rails credentials:edit --environment production. El script Ruby hace la edición, sin mirar el cifrado en local.
  4. El escape de shell es un campo minado; enruta contenido especial por Base64. echo -n 'X' | base64 en local, Base64.decode64 en el runner.
  5. Rails runner no impone las restricciones de negocio por ti. reply_to del thread, batch_id, cursor de paginación — encola con todos.
  6. Solo escribe migration si "esto podría volver a pasar". Arreglos de datos puntuales van por runner, dos órdenes de magnitud más rápido.

Debuggear en producción no es trasplantar tu flujo de dev — no tienes espacio para iterar ni tolerancia al error. Lo que de verdad usas es la superficie de introspección que producción ya ofrece (Rails runner + kamal exec + credentials:edit), cada paso haciendo el cambio más pequeño posible. Claude puede escribir Ruby correcto — pero saber "qué se puede hacer directo vs. qué hay que diagnosticar primero" es una decisión que no toma por ti. Esa es tu disciplina de producción.