Free

Rails in Produktion mit Claude debuggen — ohne SSH, ohne Redeploy

Prod-Rails-Diagnose + Datenfixes: kamal exec, vollbreites `?`, Base64, Thread-Kontinuität.


Produktions-400/500 haben nichts mit lokal gemein. Lokal lässt du Tests neu laufen, editierst, lässt neu laufen. In Produktion hat der Nutzer schon auf „Bezahlen" geklickt und starrt auf einen weißen Bildschirm.

Drei Wege:

  1. SSH auf den Server — funktioniert, aber die Shell im Container hat vielleicht keine rails console, deine Änderungen haben kein Audit, ein Tippfehler und du hast ein Problem
  2. Neudeployment — Fix pushen, mindestens 10+ Minuten, kurzzeitige Nichterreichbarkeit möglich, und viele Probleme sind keine Code-Probleme (sondern Daten- oder Credentials-Fehler)
  3. Rails runner im laufenden Container ausführen — kein Neustart, kein Redeploy, kein SSH, skalpellartige Präzision

Dieser Artikel behandelt den dritten Weg. Zwei reale Fälle: ein in die Credentials geratenes in voller Breite, das x402-Zahlungen mit 500er-Fehlern lahmlegte, und ein Tweet in der Mitte eines Threads mit status: :failed, der wiederholt werden muss ohne die Thread-Kontinuität zu brechen. Unterwegs wird Claude per Default in 4 falsche Richtungen ziehen wollen — jede muss abgefangen werden.


Das Tool: kamal app exec --reuse

Kamal ist das Rails-Deploy-Tool von 37signals. Es hat einen Befehl:

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

--reuse bedeutet: keinen neuen Container hochziehen, sondern im laufenden Web-Container ausführen. Kein neuer Build, kein docker pull, kein Neustart, keine ENV-Neueinspeisung. Befehl läuft, Container macht weiter mit Requests.

Die Ausgabe fließt per stdout zurück auf dein Terminal — praktisch puts in einer produktiven Rails-Konsole, ohne SSH, tmux oder den Laptop zu verlassen.

Typische Sitzung:

$ 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

Hin und zurück in 3-6 Sekunden. Zwei Größenordnungen schneller als ein Redeploy.

Fall 1: Das in voller Breite, das Produktion-500er verursachte

Commit eba9ac9.

Symptom: Einige Stunden nach dem Livegang von x402-Zahlungen kam jeder Zahlungs-Request mit 500 zurück. Logs voll mit Net::HTTPBadResponse vom HTTP-Client. Lokal lief alles perfekt.

Diagnose: Claude soll zuerst die x402-Konfiguration in Produktion ausgeben:

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

Ausgabe:

"0xAbC123...def?"

Am Ende ein zusätzliches — ein Fragezeichen in voller Breite (U+FF1F), nicht das halbe ?. Jemandes IME hat mitten beim Editieren von config/credentials/production.yml.enc umgeschaltet und dieses Zeichen hineingemogelt.

Das lokale config/credentials.yml.enc (entschlüsselt mit dem Dev/Test-Master-Key) hat das nicht — in Rails 8 sind Production und Dev separate verschlüsselte Credentials, Inhalte werden nicht geteilt.

Fix: Du kannst nicht per SSH die Datei direkt bearbeiten (verschlüsselt) und nicht runterziehen und lokal bearbeiten (der Master-Key ist nicht auf deinem Laptop). Der Zug ist, Claude ein einmaliges Ruby-Skript schreiben zu lassen und per EDITOR= in credentials:edit einzuschleusen:

# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# Vollbreitiges Fragezeichen am Ende entfernen
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

Ablauf von credentials:edit: entschlüsseln → in temporäre Datei schreiben → $EDITOR aufrufen → neu verschlüsseln → Temp löschen. EDITOR durch unser Ruby-Skript ersetzen und die Bearbeitung ist automatisiert, ohne den Chiffretext lokal zu beäugen.

Danach git commit + kamal deploy einmal — dieser Deploy ist nötig, weil production.yml.enc sich geändert hat. Aber die Diagnose hat keinen Deploy gekostet.

Regel: Wenn Produktion kaputt geht, erst lesen mit kamal app exec --reuse. Nicht raten, nicht zuerst neu deployen.

Fall 2: Fehlgeschlagenen XQueue::Tweet mitten im Thread wiederholen

Symptom: Nach Artikelveröffentlichung landen Tweets in der Tabelle x_queue_tweets. Einer davon — der 2. eines 4er-Threads — endet mit status: :failed (X-API-Ratenbegrenzung, Inhaltsvalidierung, was auch immer). Wiederholung erfordert, die Thread-Kontinuität zum 1. Tweet zu wahren.

Den fehlgeschlagenen finden:

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
"'

Ergibt id=87, thread_id=15, thread_position=2.

Falle 1: Shell-Escaping. Tweet-Inhalt hat oft Anführungszeichen, Zeilenumbrüche, Backticks. Wenn du schreibst:

# fliegt um die Ohren — Shell frisst Anführungszeichen und Backslashes
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'

Der Base64-Tanz — lokal kodieren, Base64-String übergeben, im Runner dekodieren:

# Lokal kodieren
echo -n 'Umgeschriebener Tweet-Inhalt...' | base64
# => VW1nZXNjaHJpZWJlbmVyIFR3ZWV0LUluaGFsdC4uLg==

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

Base64-Strings sind reines ASCII, shell-sicher.

Falle 2: Thread-Kontinuität. XQueue::PostTweetJob.perform_later(87) postet einen eigenständigen Tweet — verkettet nicht zum Tweet #1 — weil die X-API reply_to_tweet_id braucht und der Job per Default keinen solchen Wert führt.

x_tweet_id des Vorgänger-Tweets holen (erfolgreiche Sendungen füllen dieses Feld):

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

Mit Reply-Ziel in die Queue legen:

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

Der polling_interval des Workers ist 0,1 Sekunden — er schnappt den Job fast sofort. Ein paar Sekunden später kamal app exec, um zu sehen, wie der Status von scheduled auf posted umkippt und x_tweet_id gefüllt wird — Thread durchgängig.

Regel: Datenoperationen in Produktion müssen Businessebenen-Constraints respektieren, nicht nur „Datensatz erfolgreich aktualisiert". Thread-Kontinuität ist ein Business-Constraint; Rails runner prüft das nicht für dich.

Die 4 Richtungen, in die Claude per Default abschweift (und wie man umleitet)

Bei dieser Art Arbeit ist Claudes erster Reflex oft falsch. Jeden einzeln erwischen:

1. Will per SSH auf den Server

„Ich mach SSH und schau mal rein..."

Umleiten: kamal app exec --reuse schlägt SSH — im Container, Rails-Umgebung geladen, auditiert (Kamal-Logs halten es fest), rührt die Host-Shell nicht an, keine Container-Drift-Sorgen (reuse garantiert die aktuelle Produktionsversion).

2. Will eine Migration schreiben, um Daten zu reparieren

„Ich schreib eine Migration, die das vollbreitige aus wallet_address entfernt..."

Umleiten: Ein Credentials-Wert ändern braucht keine Migration (DB wurde nicht angefasst). Ein einmaliger Rails runner erledigt das in 10 Sekunden; eine Migration braucht Deploy und bleibt für immer im Schema. Migration nur, wenn der Fix möglicherweise wiederkommt; ein Tippfehler ist einmalig.

3. Stopft Sonderzeichen in Shell-Strings

„Einfach update!(content: "...") aufrufen..."

Umleiten: Jeder benutzergenerierte Inhalt (Tweets, Kommentare, eingegebenes Markdown) sollte über Base64 laufen. Shell-Parsing von Anführungszeichen, Backslash, $ und Backticks ist ein klassisches Minenfeld — Produktion ist der falsche Ort zum Üben.

4. perform_later ohne Business-Parameter

„Ich mach einfach PostTweetJob.perform_later(87) nochmal..."

Umleiten: Erst fragen „Steht dieser Datensatz in Beziehung zu anderen?" Threads haben reply_to-Beziehungen, Batch-Jobs haben batch_id, paginierte Jobs haben cursor — die Argumentliste eines Jobs ist der Träger dieser Business-Beziehungen. Ein Argument weglassen heißt eine Kette brechen.

Checkliste

Rails-Produktion mit Claude debuggen + Daten mutieren — 6 Regeln:

  1. Lesen vor Schreiben. kamal app exec --reuse 'bin/rails runner "puts X"'. Problem lokalisieren, bevor irgendetwas geändert wird.
  2. kamal app exec --reuse ist das Standardwerkzeug, nicht SSH, nicht Redeploy. Im Container, Rails geladen, 3-6 Sekunden hin und zurück.
  3. Credentials-Änderungen über EDITOR=ruby-script bin/rails credentials:edit --environment production. Ruby-Skript macht die Bearbeitung, kein Beäugen von Chiffretext lokal.
  4. Shell-Escaping ist ein Minenfeld; Sonderinhalte über Base64 leiten. Lokal echo -n 'X' | base64, im Runner Base64.decode64.
  5. Rails runner erzwingt Business-Constraints nicht für dich. reply_to eines Threads, batch_id, Paginations-Cursor — alle zusammen einstellen.
  6. Migration nur schreiben, wenn „das könnte wieder passieren". Einmalige Datenfixes via Runner, zwei Größenordnungen schneller.

Produktionsdebuggen ist nicht das Transplantieren deines Dev-Workflows in die Produktion — du hast keinen Iterationsraum und keine Fehlertoleranz. Was du wirklich nutzt, ist die Introspektionsfläche, die Produktion bereits bietet (Rails runner + kamal exec + credentials:edit), jeder Schritt die kleinstmögliche Änderung. Claude kann korrekten Ruby-Code schreiben — aber zu wissen „was direkt gemacht werden kann vs. was zuerst diagnostiziert werden muss" ist eine Entscheidung, die es nicht für dich trifft. Das ist deine Produktionsdisziplin.