Free

SSH 없이, 재배포 없이 프로덕션 Rails 데이터 고치기 (Claude와 함께)

프로덕션 Rails 진단 + 데이터 수정: kamal exec, 전각 물음표, Base64, thread 연속성.


프로덕션 400/500은 로컬이랑 완전히 다르다. 로컬에선 에러 나면 테스트 다시 돌리고, 고치고, 또 돌린다. 프로덕션에선 사용자가 이미 "결제" 누르고 빈 화면 쳐다보고 있다.

세 갈래:

  1. 서버에 SSH—고칠 수 있지만, 컨테이너 shell에 rails console이 없을 수도 있고, 변경이 감사되지 않고, 손 한 번 떨면 끝
  2. 재배포—코드 고쳐서 push, 10분 이상 기본, 잠깐 불가용할 수 있고, 게다가 많은 문제는 코드 문제가 아님(데이터나 credentials 오타)
  3. 실행 중인 컨테이너에서 Rails runner 돌리기—재시작 없음, 재배포 없음, SSH 없음, 메스 수준 정밀도

이 글은 세 번째 경로 얘기. 실전 두 케이스: 전각 가 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초. 재배포보다 두 자릿수 빠름.

케이스 1: 전각 가 일으킨 프로덕션 500

commit eba9ac9.

증상: x402 결제 배포 후 몇 시간 뒤, 모든 결제 요청이 500. 로그는 HTTP 클라이언트 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 편집 중에 IME가 전환되면서 이 문자가 끼어들었다.

로컬 config/credentials.yml.enc(dev/test master.key로 복호화하는 거)에는 이게 없음—Rails 8에선 production과 dev가 별개 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 한 번—production.yml.enc가 바뀌었으니 이 deploy는 필수. 하지만 진단엔 deploy 비용 안 쓴 셈.

규칙: 프로덕션 깨지면 먼저 kamal app exec --reuse읽기. 추측 금지, 먼저 배포 금지.

케이스 2: XQueue::Tweet 재전송 + thread 연속성

증상: 글 발행 후 트윗이 x_queue_tweets 테이블에 스케줄됨. 4개짜리 thread의 2번째가 status: :failed로(X API 레이트 리밋, 콘텐츠 검증, 이유야 뭐든). 재전송 시 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: 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
# => 64Ks7IucIOyNrCDtirjsnZggsrgfjgu2...

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

base64 문자열은 ASCII 전용, 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

reply 대상 달아서 enqueue:

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

worker의 polling_interval은 0.1초—job을 거의 즉시 픽업. 몇 초 뒤 kamal app exec로 status가 scheduled에서 posted로 바뀌고 x_tweet_id가 채워졌으면 thread 연결 확인.

규칙: 프로덕션 데이터 조작은 비즈니스 층 제약을 존중해야 함, "레코드 업데이트 성공"만으론 안 됨. thread 연속성은 비즈니스 제약, Rails runner는 대신 체크 안 해줌.

Claude가 기본값으로 어긋나는 4 방향

이런 작업 시킬 때 Claude 첫 반응은 자주 틀림. 매번 잡아줌:

1. SSH하고 싶어 함

"서버에 SSH해서 좀 볼게요..."

바로잡기: kamal app exec --reuse가 SSH보다 나음—컨테이너 내, Rails env 로드됨, 감사됨(kamal 로그에 남음), 호스트 shell 안 건드림, 컨테이너 드리프트 걱정 없음(reuse가 현재 프로덕션 버전 보장).

2. migration 써서 데이터 고치려 함

"wallet_address의 전각 ? 치환하는 migration 쓸게요..."

바로잡기: credentials 한 값 수정에 migration 필요 없음(DB 안 건드림). 일회용 Rails runner가 10초 안에 해결, migration은 deploy 필요하고 스키마에 영원히 남음. 수정이 나중에 또 필요할 가능성 있을 때만 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 env 로드됨, 왕복 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—같이 enqueue.
  6. "이 일이 미래에 또 일어날 가능성" 있을 때만 migration 쓰기. 일회용 데이터 수정은 runner, 두 자릿수 빠름.

프로덕션 진단의 본질은 개발 흐름을 프로덕션으로 옮기는 게 아님—반복 여지도 없고, 에러 허용도 없음. 진짜 쓰는 건 프로덕션 자체가 제공하는 내성면(Rails runner + kamal exec + credentials:edit), 각 단계에서 가능한 최소 변경만. Claude는 Ruby 코드 정확히 쓸 수 있음—하지만 "프로덕션에서 뭘 바로 해도 되고, 뭘 먼저 진단해야 하는지" 이 판단은 대신 안 해줌. 그건 네 프로덕션 규율이다.