免费

让 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
# => 6YeN5YaZ5ZCO55qE5o6o5paH5YaF5a655Li7...

# 传进去
kamal app exec --reuse "bin/rails runner \"
  t = XQueue::Tweet.find(87)
  t.update!(content: Base64.decode64('6YeN5YaZ5ZCO55qE5o6o5paH5YaF5a655Li7...'), 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 代码,但"在生产上哪些事可以直接做、哪些必须先诊断"这个判断它不会——那是你的生产纪律。