Free

Debug Rails production ด้วย Claude — ไม่ SSH ไม่ redeploy

วินิจฉัย + แก้ข้อมูล Rails production: kamal exec, `?` full-width, Base64, thread ต่อเนื่อง


400/500 ใน production ไม่เหมือน local เลย ใน local รัน test ใหม่ แก้ รันใหม่ ใน production ผู้ใช้กด "ชำระเงิน" ไปแล้วจ้องหน้าจอว่าง ๆ

สามทาง:

  1. SSH เข้า server — ทำได้ แต่ shell ใน container อาจไม่มี rails console, การเปลี่ยนแปลงไม่มี audit, พิมพ์ผิดครั้งเดียวก็แย่
  2. Redeploy — push fix, อย่างน้อย 10+ นาที, อาจมี downtime สั้น ๆ และปัญหาจำนวนมากไม่ใช่ปัญหาโค้ด (เป็นเรื่อง data หรือ credentials)
  3. รัน Rails runner ใน container ที่กำลังรัน — ไม่ restart ไม่ redeploy ไม่ SSH ความแม่นยำระดับมีดผ่าตัด

บทความนี้เกี่ยวกับทางที่สาม สองกรณีจริง: full-width ที่ติดใน credentials แล้วทำให้การจ่าย x402 500 หมด และทวีตกลาง thread ที่ status: :failed ต้องส่งใหม่โดยรักษา thread ต่อเนื่อง ระหว่างทาง Claude จะพยายามออกนอกเส้นทาง 4 ทิศที่ผิดโดย default ต้องสกัดทุกอัน


เครื่องมือ: kamal app exec --reuse

Kamal เป็นเครื่องมือ deploy Rails จาก 37signals มีคำสั่ง:

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

--reuse หมายถึง: ไม่ต้องยก container ใหม่ ให้รันใน web container ที่กำลังทำงานอยู่ ไม่มี build ใหม่ ไม่มี docker pull ไม่มี restart ไม่มีการ inject ENV ใหม่ คำสั่งรัน container กลับไปจัดการ request ต่อ

ผลลัพธ์ไหลผ่าน stdout กลับมาที่ terminal — เหมือน puts ใน Rails console production โดยไม่ต้อง SSH, tmux หรือออกจาก laptop

Session ทั่วไป:

$ 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 วินาที เร็วกว่า redeploy สองอันดับขนาด

กรณี 1: full-width ทำให้ 500 ใน production

Commit eba9ac9

อาการ: สองสามชั่วโมงหลังปล่อยการชำระ x402, ทุก request การจ่ายได้ 500 log เต็มไปด้วย Net::HTTPBadResponse จาก HTTP client local ทำงานสมบูรณ์

วินิจฉัย: ให้ Claude print config x402 ใน production ก่อน:

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

Output:

"0xAbC123...def?"

มี เกินที่หาง — เครื่องหมายคำถามรูปแบบเต็มความกว้าง (U+FF1F) ไม่ใช่ ? ครึ่งความกว้าง IME ของใครสักคนสลับระหว่างแก้ไข config/credentials/production.yml.enc และตัวอักษรนี้ก็แทรกเข้ามา

config/credentials.yml.enc local (ถอดรหัสด้วย master.key ของ dev/test) ไม่มีตัวนี้ — ใน Rails 8 production กับ dev เป็น encrypted credentials แยกกัน เนื้อหาไม่ share

แก้ไข: SSH เข้าไปแก้ไฟล์ตรง ๆ ไม่ได้ (เข้ารหัสแล้ว) ดึงมาแก้ local ก็ไม่ได้ (master.key ไม่อยู่ใน laptop) วิธีถูกคือให้ Claude เขียน Ruby script ใช้ครั้งเดียว inject ผ่าน EDITOR= เข้า credentials:edit:

# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# ลบเครื่องหมายคำถาม full-width ที่หาง
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

Flow ของ credentials:edit: ถอดรหัส → เขียนไฟล์ temp → เรียก $EDITOR → เข้ารหัสใหม่ → ลบ temp เปลี่ยน EDITOR เป็น Ruby script ของเรา ทำให้การแก้ไขอัตโนมัติ ไม่ต้องจ้องดู ciphertext ใน local

หลังจากนั้น git commit + kamal deploy หนึ่งครั้ง — deploy นี้จำเป็นเพราะ production.yml.enc เปลี่ยน แต่การวินิจฉัยไม่เปลือง deploy

กฎ: เมื่อ production พัง อ่านก่อน ด้วย kamal app exec --reuse อย่าเดา อย่า redeploy ก่อน

กรณี 2: ส่งทวีต XQueue::Tweet ที่ล้มเหลวกลาง thread ใหม่

อาการ: หลังบทความ publish, ทวีตเข้าตาราง x_queue_tweets หนึ่งในนั้น — ตัวที่ 2 ของ thread 4 อัน — จบด้วย status: :failed (rate limit X API, validation เนื้อหา หรืออะไรก็ตาม) ส่งใหม่ต้องรักษา thread ต่อเนื่องกับทวีตที่ 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: escape ของ shell. เนื้อหาทวีตมักมีเครื่องหมายคำพูด, newline, backtick ถ้าเขียนแบบนี้:

# ระเบิด — shell กินเครื่องหมายคำพูดและ backslash
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'

ระบำ Base64 — encode ที่ local, ส่ง string base64, decode ข้างใน runner:

# Encode local
echo -n 'เนื้อหาทวีตที่เขียนใหม่...' | base64
# => 4LiE4LiZIOC4iuC4rOC4l+C4teC5iOC4iOC4sOC5gOC4guC4teC4ouC4meC5g+C4q+C4oy4uLg==

# ส่ง
kamal app exec --reuse "bin/rails runner \"
  t = XQueue::Tweet.find(87)
  t.update!(content: Base64.decode64('4LiE4LiZIOC4iuC4rOC4l+C4teC5iOC4iOC4sOC5gOC4guC4teC4ouC4meC5g+C4q+C4oy4uLg=='), status: :scheduled)
  puts t.status
\""

string Base64 เป็น ASCII ล้วน shell ปลอดภัย

กับดัก 2: thread ต่อเนื่อง. XQueue::PostTweetJob.perform_later(87) publish ทวีตเดี่ยว ๆ — ไม่ต่อกับทวีต #1 — เพราะ X API ต้องการ reply_to_tweet_id แต่ Job default ไม่พาค่านี้มา

หา x_tweet_id ของทวีตก่อนหน้า (ตัวที่ส่งสำเร็จจะเติม field นี้):

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

Enqueue พร้อมเป้า reply:

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

polling_interval ของ worker คือ 0.1 วินาที — หยิบ job แทบจะทันที ไม่กี่วินาทีต่อมา kamal app exec ดูว่า status เปลี่ยนจาก scheduled เป็น posted และ x_tweet_id ถูกเติม — thread ต่อเนื่อง

กฎ: การทำงานกับข้อมูล production ต้องเคารพ ข้อจำกัดระดับธุรกิจ ไม่ใช่แค่ "record อัปเดตสำเร็จ" thread ต่อเนื่องคือข้อจำกัดทางธุรกิจ Rails runner ไม่ตรวจสอบให้คุณ

4 ทิศที่ Claude เบนออกโดย default (และวิธีเปลี่ยนเส้นทาง)

ในงานแบบนี้ ปฏิกิริยาแรกของ Claude มักผิด จับทีละตัว:

1. อยาก SSH เข้า server

"ผมขอ SSH ไปดูหน่อย..."

เปลี่ยนเส้นทาง: kamal app exec --reuse ชนะ SSH — ใน container, env Rails โหลดแล้ว, มี audit (log kamal เก็บ record), ไม่แตะ shell host, ไม่กังวล container drift (reuse รับประกันเวอร์ชัน production ปัจจุบัน)

2. อยากเขียน migration แก้ data

"ผมเขียน migration ลบ full-width ออกจาก wallet_address..."

เปลี่ยนเส้นทาง: เปลี่ยนค่า credentials หนึ่งค่าไม่ต้องใช้ migration (DB ไม่ถูกแตะ) Rails runner ครั้งเดียวเสร็จใน 10 วินาที migration ต้อง deploy และอยู่ใน schema ตลอดไป ใช้ migration เฉพาะเมื่อ fix อาจต้องรันซ้ำ — typo เป็นเรื่องครั้งเดียว

3. ยัดตัวอักษรพิเศษเข้า shell string

"แค่เรียก update!(content: "...") ก็พอ..."

เปลี่ยนเส้นทาง: เนื้อหาที่ผู้ใช้สร้าง (ทวีต, comment, markdown ที่ user พิมพ์) ควรผ่าน Base64 การ parse shell ของเครื่องหมายคำพูด, backslash, $ และ backtick เป็นทุ่งระเบิดคลาสสิก — production ไม่ใช่ที่ฝึกซ้อม

4. perform_later ไม่ส่ง parameter ธุรกิจ

"ก็ PostTweetJob.perform_later(87) ใหม่..."

เปลี่ยนเส้นทาง: ถามก่อน "record นี้เกี่ยวข้องกับตัวอื่นไหม?" thread มีความสัมพันธ์ reply_to, batch job มี batch_id, paginated job มี cursor — รายการ argument ของ Job คือผู้พาความสัมพันธ์เหล่านั้น ลืม argument หนึ่งก็ขาดหนึ่งสาย

Checklist

Debug + แก้ data Rails production ด้วย Claude — 6 กฎ:

  1. อ่านก่อนเขียน kamal app exec --reuse 'bin/rails runner "puts X"' ระบุปัญหาก่อนเปลี่ยนอะไร
  2. kamal app exec --reuse คือเครื่องมือ default ไม่ใช่ SSH ไม่ใช่ redeploy ใน container, Rails โหลด, ไปกลับ 3-6 วินาที
  3. เปลี่ยน credentials ผ่าน EDITOR=ruby-script bin/rails credentials:edit --environment production Ruby script แก้ไข ไม่ต้องจ้อง ciphertext ที่ local
  4. Escape shell คือทุ่งระเบิด เส้นทางเนื้อหาพิเศษผ่าน Base64 echo -n 'X' | base64 ที่ local, Base64.decode64 ใน runner
  5. Rails runner ไม่บังคับข้อจำกัดธุรกิจให้คุณ reply_to ของ thread, batch_id, cursor pagination — enqueue พร้อมกันหมด
  6. เขียน migration เฉพาะเมื่อ "อาจเกิดอีกครั้ง" แก้ data ครั้งเดียวผ่าน runner เร็วกว่าสองอันดับขนาด

Debug บน production ไม่ใช่ย้าย dev flow ไป production — คุณไม่มีพื้นที่วนซ้ำ ไม่มีความอดทนต่อ error สิ่งที่ใช้จริงคือ surface การสำรวจตัวเองที่ production ให้อยู่แล้ว (Rails runner + kamal exec + credentials:edit) แต่ละขั้นเปลี่ยนแปลงน้อยที่สุดที่ทำได้ Claude เขียน Ruby ถูกได้ — แต่การรู้ว่า "อันไหนทำตรงได้ vs อันไหนต้องวินิจฉัยก่อน" คือการตัดสินใจที่เขาไม่ทำแทนคุณ นั่นคือวินัย production ของคุณ