Free

Debugowanie produkcyjnego Rails z Claude — bez SSH i bez redeployu

Diagnostyka + fix danych produkcyjnego Rails: kamal exec, `?` pełnej szerokości, Base64, ciągłość wątku.


Produkcyjne 400/500 nie mają nic wspólnego z lokalnymi. Lokalnie uruchamiasz testy ponownie, edytujesz, znowu uruchamiasz. Na produkcji użytkownik już kliknął „Zapłać" i gapi się na pustą stronę.

Trzy drogi:

  1. SSH na serwer — działa, ale shell wewnątrz kontenera może nie mieć rails console, twoje zmiany bez audytu, jeden literówka i masz kłopot
  2. Redeploy — pushnij fix, minimum 10+ minut, możliwa krótka niedostępność, a sporo problemów to nie problemy kodu (to dane albo credentials)
  3. Uruchomienie Rails runnera wewnątrz działającego kontenera — bez restartu, bez redeployu, bez SSH, precyzja skalpela

Ten artykuł o trzeciej drodze. Dwa realne przypadki: pełnej szerokości wciśnięty w credentials, który rozłożył płatności x402 błędami 500, oraz tweet w środku wątku z status: :failed, który trzeba powtórzyć zachowując ciągłość wątku. Po drodze Claude będzie próbował pójść w 4 złe strony domyślnie — każdą trzeba przejąć.


Narzędzie: kamal app exec --reuse

Kamal to narzędzie deployu Rails od 37signals. Ma polecenie:

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

--reuse znaczy: nie podnoś nowego kontenera, wykonaj wewnątrz uruchomionego kontenera web. Bez nowego builda, bez docker pull, bez restartu, bez ponownego wstrzykiwania ENV. Polecenie się wykonuje, kontener wraca do obsługi requestów.

Wyjście leci przez stdout do twojego terminala — praktycznie puts wewnątrz produkcyjnej konsoli Rails, bez SSH, tmuxa ani opuszczania laptopa.

Typowa sesja:

$ 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

Tam i z powrotem 3-6 sekund. O dwa rzędy wielkości szybciej niż redeploy.

Przypadek 1: pełnej szerokości wywołujący produkcyjne 500

Commit eba9ac9.

Objaw: Kilka godzin po wydaniu płatności x402, każdy request płatności zwracał 500. Logi pełne Net::HTTPBadResponse z klienta HTTP. Lokalnie działało idealnie.

Diagnoza: Niech Claude najpierw wypisze konfigurację x402 na produkcji:

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

Wyjście:

"0xAbC123...def?"

Na końcu jest dodatkowy znak zapytania pełnej szerokości (U+FF1F), nie półszerokości ?. Komuś IME przełączył się podczas edycji config/credentials/production.yml.enc, i ten znak się wślizgnął.

Lokalny config/credentials.yml.enc (odszyfrowany master.key dev/test) tego nie ma — w Rails 8 production i dev to osobne encrypted credentials, zawartość nie jest dzielona.

Fix: Nie możesz zrobić SSH i edytować pliku bezpośrednio (zaszyfrowany) ani ściągnąć i edytować lokalnie (master.key nie jest na laptopie). Ruch jest taki, żeby Claude napisał jednorazowy skrypt Ruby i wstrzyknął go przez EDITOR= do credentials:edit:

# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# Usuń znak zapytania pełnej szerokości z końca
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

Przepływ credentials:edit: odszyfruj → zapisz do pliku tymczasowego → wywołaj $EDITOR → ponownie zaszyfruj → usuń tymczasowy. Zamiana EDITOR na nasz skrypt Ruby automatyzuje edycję, bez potrzeby patrzenia na szyfrogram lokalnie.

Potem git commit + kamal deploy raz — ten deploy jest obowiązkowy, bo production.yml.enc się zmienił. Ale diagnoza nie kosztowała deployu.

Zasada: Gdy produkcja się psuje, najpierw czytaj przez kamal app exec --reuse. Nie zgaduj, nie redeployuj jako pierwszy.

Przypadek 2: Ponowienie nieudanego XQueue::Tweet w środku wątku

Objaw: Po opublikowaniu artykułu tweety idą do tabeli x_queue_tweets. Jeden z nich — 2-gi z 4-tweetowego wątku — kończy ze status: :failed (rate limit X API, walidacja treści, cokolwiek). Ponowienie wymaga zachowania ciągłości wątku z 1-szym tweetem.

Znaleźć nieudany:

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

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

Pułapka 1: escape shellowy. Treść tweetów często ma cudzysłowy, nowe linie, backticki. Jeśli napiszesz:

# wybuchnie — shell pożre cudzysłowy i backslashe
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'

Taniec Base64 — koduj lokalnie, przekaż string base64, dekoduj wewnątrz runnera:

# Koduj lokalnie
echo -n 'Przepisana treść tweeta...' | base64
# => UHJ6ZXBpc2FuYSB0cmXFm8SHIHR3ZWV0YS4uLg==

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

Stringi Base64 to czyste ASCII, bezpieczne dla shella.

Pułapka 2: ciągłość wątku. XQueue::PostTweetJob.perform_later(87) publikuje tweet samodzielny — nie łączy się z tweetem #1 — bo X API potrzebuje reply_to_tweet_id, a Job domyślnie takiej wartości nie niesie.

Znajdź x_tweet_id poprzedniego tweeta (udane wysyłki wypełniają to pole):

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

Dodaj do kolejki z celem reply:

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

polling_interval workera to 0,1 sekundy — podejmuje joba niemal natychmiast. Parę sekund później kamal app exec patrzy, że status przeszedł ze scheduled na posted, a x_tweet_id się wypełnił — wątek ciągły.

Zasada: Operacje na danych produkcji muszą respektować ograniczenia warstwy biznesowej, a nie tylko „rekord zaktualizowany pomyślnie". Ciągłość wątku to ograniczenie biznesowe; Rails runner nie sprawdzi go za ciebie.

4 kierunki, w które Claude domyślnie schodzi (i jak przekierować)

W takiej pracy pierwszy odruch Claude'a często jest błędny. Łapiemy każdy:

1. Chce SSH na serwer

„Zaloguję się przez SSH i rzucę okiem..."

Przekieruj: kamal app exec --reuse bije SSH — wewnątrz kontenera, env Rails załadowany, audytowany (logi kamala zostawiają ślad), nie rusza shella hosta, bez driftu kontenera (reuse gwarantuje aktualną wersję produkcyjną).

2. Chce pisać migrację, żeby naprawić dane

„Napiszę migrację, która usunie pełnej szerokości z wallet_address..."

Przekieruj: Zmiana jednej wartości credentials nie potrzebuje migracji (DB nie było ruszone). Jednorazowy Rails runner robi to w 10 sekund; migracja wymaga deployu i zostaje w schemacie na zawsze. Migracja tylko jeśli fix może się powtórzyć; literówka to rzecz jednorazowa.

3. Wciska znaki specjalne w stringi shellowe

„Po prostu wywołam update!(content: "...")..."

Przekieruj: Jakakolwiek treść wygenerowana przez użytkownika (tweety, komentarze, wpisany markdown) powinna iść przez Base64. Parsowanie shellowe cudzysłowów, backslashy, $ i backticków to klasyczne pole minowe — produkcja to nie miejsce na trening.

4. perform_later bez parametrów biznesowych

„Po prostu ponowię PostTweetJob.perform_later(87)..."

Przekieruj: Najpierw spytaj „czy ten rekord jest powiązany z innymi?" Wątki mają relacje reply_to, joby batch mają batch_id, joby paginowane mają cursor — lista argumentów Joba to nośnik tych relacji biznesowych. Pominiesz argument, zerwiesz łańcuch.

Lista kontrolna

Debugowanie + mutowanie danych produkcyjnego Rails z Claude — 6 reguł:

  1. Czytaj, zanim piszesz. kamal app exec --reuse 'bin/rails runner "puts X"'. Zlokalizuj problem, zanim cokolwiek zmienisz.
  2. kamal app exec --reuse to narzędzie domyślne, nie SSH, nie redeploy. Wewnątrz kontenera, Rails załadowany, 3-6 sekund tam i z powrotem.
  3. Zmiany credentials przez EDITOR=ruby-script bin/rails credentials:edit --environment production. Skrypt Ruby robi edycję, bez patrzenia na szyfrogram lokalnie.
  4. Escape shellowy to pole minowe; routuj specjalną zawartość przez Base64. echo -n 'X' | base64 lokalnie, Base64.decode64 w runnerze.
  5. Rails runner nie wymusza ograniczeń biznesowych za ciebie. reply_to wątku, batch_id, cursor paginacji — wstaw do kolejki wszystko razem.
  6. Pisz migrację tylko jeśli „to może się powtórzyć". Jednorazowe fiksy danych przez runner, o dwa rzędy wielkości szybsze.

Debugowanie na produkcji to nie przesadzanie twojego flow dev na produkcję — nie masz miejsca na iterację, nie masz tolerancji błędów. To, czego naprawdę używasz, to powierzchnia introspekcji, którą produkcja już oferuje (Rails runner + kamal exec + credentials:edit), każdy krok to najmniejsza możliwa zmiana. Claude potrafi napisać poprawny Ruby — ale wiedza „co można zrobić bezpośrednio vs. co trzeba najpierw zdiagnozować" to decyzja, której za ciebie nie podejmie. To twoja dyscyplina produkcji.