免費

讓 Claude 不 SSH 不重啟修生產 Rails 資料

生產 Rails 排查修資料:kamal exec、全形問號、Base64、thread 連貫。


生產 400/500 和本地完全不同。本地報錯你重跑測試、改程式、再跑;生產報錯的時候,使用者已經按了「付款」然後看著空白畫面。

三條路:

  1. SSH 進伺服器——能修,但 shell 在容器裡可能沒 rails console、你改的東西沒有稽核、手抖一次就完蛋
  2. 重新 deploy——改程式推一次,10 分鐘起跳,期間可能瞬時不可用,而且很多問題不是程式問題(是資料或 credentials 錯了)
  3. 在執行中的容器裡跑 Rails runner——不重啟、不重 deploy、不 SSH,手術刀級別的改動

本文講第 3 條怎麼做。兩個真實案例:一個是全形 塞進 credentials 導致 x402 支付 500,一個是 thread 中間一條推文 status: :failed 要重發還得保證 thread 連貫。讓 Claude 做這些事時它預設會走錯 4 個方向,每一個都得你攔下來。


工具:kamal app exec --reuse

Kamal 是 37signals 出的 Rails 部署工具。它有一個指令:

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

--reuse 的意思是:不起新容器,直接在正在跑的那個 web 容器裡執行。沒有新 build、沒有 docker pull、沒有重啟、沒有 ENV 重新注入。執行完指令,容器繼續原來的請求處理。

它的輸出直接走 stdout 回到你終端,相當於你在生產機的 Rails console 裡打了一行 puts——只是不用 SSH、不用 tmux、不用離開本地。

一個典型會話:

$ 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

3-6 秒一次。比 deploy 快兩個數量級。

案例 1:全形 引發的生產 500

commit eba9ac9

症狀:上線 x402 支付幾小時後,發現每次付款請求都 500。日誌裡是 HTTP client 的 Net::HTTPBadResponse。本地一切正常。

診斷:讓 Claude 先在生產環境列印 x402 設定:

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

輸出:

"0xAbC123...def?"

尾巴多了一個 ——全形問號(U+FF1F),不是半形 ?。某次編輯 config/credentials/production.yml.enc 的時候輸入法切換過一次,塞了這個字元進去。

本地的 config/credentials.yml.enc(用 dev/test 那個 master.key 解的)沒這個問題——production 和 dev 在 Rails 8 裡是兩套獨立的 encrypted credentials,內容不共享。

修法:不能直接 ssh 進去改檔(加密的)、也不能拉下來本地改(master.key 不在本地)。正解是讓 Claude 寫一個一次性 ruby 指令稿,透過 EDITOR= 注入到 credentials:edit

# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# 去掉尾部全形問號
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

credentials:edit 流程:解密 → 寫到暫存檔 → 呼叫 $EDITOR 編輯 → 重新加密 → 刪暫存檔。把 EDITOR 換成我們的 ruby 指令稿,就能自動化這一步,不需要在本地肉眼看密文。

改完 git commit + kamal deploy 一次——這次 deploy 是必須的,因為 production.yml.enc 變了。但診斷那一步沒花 deploy。

規律:生產出問題先用 kamal app exec --reuse ,不要先猜、更不要先 deploy。

案例 2:XQueue::Tweet 失敗重發的 thread 連續性

症狀:文章發布後推文排期進 x_queue_tweets 表。有一條因為 X API 限流或內容校驗失敗變成 status: :failed,但它是 thread 中間第 2 條,需要重發並保證接在第 1 條後面。

查失敗的那條

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

找到是 id=87,thread_id=15,thread_position=2。

坑 1:shell 跳脫。推文內容常常有引號、換行、backtick。如果你直接寫:

# 會炸,引號和反斜線被 shell 啃掉
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'

Base64 dance——本地把新內容編碼,傳 base64 串,在 runner 裡解:

# 本地產生
echo -n '重寫後的推文內容...' | base64
# => 6YeN5a+r5b6M55qE5o6o5paH5YWn5a655Li7...

# 傳進去
kamal app exec --reuse "bin/rails runner \"
  t = XQueue::Tweet.find(87)
  t.update!(content: Base64.decode64('6YeN5a+r5b6M55qE5o6o5paH5YWn5a655Li7...'), status: :scheduled)
  puts t.status
\""

base64 串是 ASCII-only,shell 安全。

坑 2:thread 連續性XQueue::PostTweetJob.perform_later(87) 會發一條獨立推文,不會接在 thread 第 1 條後面——因為 X API 需要 reply_to_tweet_id 參數,而 Job 預設不知道。

找前一條的 x_tweet_id(已發送成功的會填這個欄位):

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

入隊時帶上:

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

worker 的 polling_interval 是 0.1 秒,入隊後幾乎立即執行。幾秒後再 kamal app exec 一次查 status 從 scheduledpostedx_tweet_id 被填上——說明 thread 續上了。

規律:生產資料操作要考慮資料層的語意約束,不只是「紀錄更新成功」。thread 連續性是業務約束,Rails runner 不會替你檢查。

Claude 預設走錯的 4 個方向

做這類工作時 Claude 的第一反應經常錯。攔下來:

1. 想 SSH 進伺服器

「我 ssh 到伺服器上看一下...」

攔下來kamal app exec --reuse 比 SSH 好——在容器內、有 Rails 環境、指令有稽核(kamal 日誌保留)、不碰主機 shell、不用擔心容器漂移(reuse 保證是當前生產版本)。

2. 想寫 migration 修資料

「我寫個 migration 把 wallet_address 裡的全形 ? 替換掉...」

攔下來:改一條 credentials 不需要 migration(資料庫都沒碰)。一次性 Rails runner 10 秒搞定,migration 要 deploy + 留在 schema 裡永久。只有你要改的事未來可能再來一次才用 migration——這次是打字錯誤,沒有「未來」。

3. 把特殊字元塞 shell 字串

「我直接 update!(content: "...") 就行...」

攔下來:任何使用者產生的內容(推文、評論、使用者輸入的 markdown)都走 Base64 中轉。shell 對引號、反斜線、$、backtick 的解析是經典地雷——在生產上拿來練手成本太高。

4. perform_later 不帶業務參數

「我重新 PostTweetJob.perform_later(87) 就行...」

攔下來:先問一句「這條和別的條有沒有關係」。thread 有 reply_to 關係、批次任務有 batch_id 關係、帶分頁的任務有 cursor 關係——Job 的參數清單就是業務關係的載體,少一個就斷一個鏈。

清單

讓 Claude 在生產 Rails 上排查 + 修資料的 6 條:

  1. 先讀後寫kamal app exec --reuse 'bin/rails runner "puts X"',先把問題定位清楚再動。
  2. kamal app exec --reuse 是預設手段,不是 SSH、不是 deploy。容器內跑、有 Rails 環境、3-6 秒回應。
  3. credentials 問題走 EDITOR=ruby-script bin/rails credentials:edit --environment production。Ruby 指令稿自動改,不在本地肉眼看密文。
  4. shell 跳脫坑大,特殊內容走 Base64 中轉echo -n 'X' | base64 本地產生,Rails runner 裡 Base64.decode64
  5. 業務約束 Rails runner 不會替你檢查。thread 的 reply_to、批任務的 batch_id、分頁的 cursor——一起入隊。
  6. 只有「這事未來可能再發生」才寫 migration。一次性修資料用 runner,快兩個數量級。

生產排查的本質不是把開發流程搬到生產上——你既沒有迭代空間、也沒有錯誤容忍。真正要用的是生產本身提供的內省面(Rails runner + kamal exec + credentials:edit),每一步只做該做的最小改動。Claude 能寫對 Ruby 程式,但「在生產上哪些事可以直接做、哪些必須先診斷」這個判斷它不會——那是你的生產紀律。