롤링 배포 중 remove_column 이 INSERT 만 깨는 이유
기동 시 db:migrate 는 새 컨테이너만 지킨다. 그동안 트래픽은 구 코드가 받고, partial_inserts = false 라 지운 컬럼을 INSERT 에 넣는다
Rails 를 컨테이너로 배포하면서 기동 스크립트에서 db:migrate 를 돌리고 있다면, 그 구성에서 remove_column 이 왜 장애가 되는지 다룬다. 다 읽으면 컬럼 삭제 · 이름 변경이 내 앱을 실제로 깨는지 콘솔 한 줄로 판단하고, 릴리스 두 번으로 나눠 안전하게 낼 수 있다.
결론부터. 기동 시 migrate 가 지켜 주는 건 새로 뜨는 컨테이너뿐이다. 그 컨테이너가 migrate 하고 헬스체크를 통과하는 1〜3분 동안 트래픽을 받는 건 구 컨테이너고, 구 코드는 지워진 컬럼을 아직 기억한다.
안전하다고 생각했던 이유
회사 프로젝트의 구성은 이랬다.
# Dockerfile
CMD sh /ready.sh && /usr/bin/supervisord -c /etc/supervisord.conf
# ready.sh
bundle exec rake db:migrate
마이그레이션이 끝나야 Puma 가 포트를 연다. 그러니 신 코드가 구 스키마를 볼 일은 없다. 여기까지는 맞다. 그림을 컨테이너 한 대로만 그렸다는 게 문제였다.
빠진 건 구 태스크다
ECS 롤링 배포의 기본값은 minimumHealthyPercent = 100 · maximumPercent = 200 이다. healthy 태스크를 늘 100% 유지하라는 뜻이라, 신 태스크가 먼저 뜨고 구 태스크는 그 뒤에 빠진다.
신 태스크가 migrate 를 끝낸 순간(1) 스키마는 이미 바뀌었다. Puma 가 뜨고 헬스체크를 두 번 통과하고 구 태스크가 드레인돼 사라질 때까지(2) 요청은 전부 구 코드가 받는다. 이게 1〜3분이다.
서버를 한 대로 줄여도 안 없어진다. 대수가 아니라 순서 문제라서다. 한 대면 오히려 그 한 대가 트래픽을 다 받는다.
직접 돌려 보니 INSERT 만 깨졌다
구 프로세스가 실제로 뭘 하는지 보려고 Rails 8.0.4 + SQLite 로 재현했다. 모델을 한 번 읽어 컬럼 목록을 캐시해 둔 다음, 다른 접속에서 legacy_status 를 지우고 note 를 memo 로 바꿨다. 그 뒤 같은 프로세스에서 쿼리를 던진 결과다.
Order.all OK SELECT "orders".* 라 컬럼명을 안 적는다
Order.where(note: 'n') 실패 no such column: orders.note
Order.first.note nil 에러도 없이 빈 값
o.update!(name: 'b') OK 바뀐 name 만 SET
Order.create!(name: 'c') 실패 has no column named legacy_status
INSERT 만 깨지는 건 partial_inserts 때문이다. false 면 INSERT 문에 바뀐 값만이 아니라 모델이 아는 컬럼을 전부 적는다. 로그를 보면 넣지도 않은 legacy_status 가 들어가 있다.
INSERT INTO "orders" ("name", "legacy_status", "note") VALUES (?, ?, ?)
ActiveRecord 자체의 기본값은 true 다. 그런데 load_defaults 7.0 이상이면 false 가 된다(railties 8.0.4 configuration.rb 의 when "7.0" 블록). 이 블로그가 돌아가는 저장소도 load_defaults 7.1 이라 해당한다. 같은 재현에서 partial_inserts = true 로 바꾸면 INSERT 가 name 만 적고 통과했다.
그래서 장애가 나느냐는 한 가지로 갈린다. 그 1〜3분에 그 테이블로 INSERT 가 들어오는가. 들어오면 전부 실패, 안 들어오면 조용히 지나간다. 컬럼 삭제를 몇 번 무사히 넘겼다면 그 테이블에 쓰기가 드물었을 가능성이 크다.
rename_column 은 더 나쁘다. 그 컬럼으로 거르거나 정렬하는 SELECT 까지 깨진다. 읽기는 에러 없이 nil 이라, 500 이 나는 쪽이 차라리 낫다. 빈 값이 멀쩡한 얼굴로 화면에 나간다.
순서를 뒤집으면 거울상이 될 뿐
「그럼 앱을 먼저 바꾸고 migrate 를 나중에 하면?」 이 블로그 저장소가 그 구성이다. Kamal 로 배포하고, migrate 는 배포가 끝난 뒤 post-deploy 훅에서 db:prepare 로 돈다. 그러면 구 컨테이너는 이미 없으니 컬럼 삭제는 오히려 안전하다. 대신 이번엔 신 코드가 구 스키마를 잠깐 본다. add_column 한 컬럼을 새 코드가 바로 읽으면 그 사이는 no such column 이다.
실제로 이 훅이 빠져 있던 동안 서버 DB 가 schema.rb 보다 뒤처졌고, 블로그 그림을 띄우는 화면이 no such column: assets.slug 로 500 을 냈다. 훅 파일 머리 주석에 그 경위가 그대로 남아 있다.
무중단 배포라면 구 코드와 신 스키마(또는 그 반대)가 같이 사는 순간은 못 없앤다. 그래서 순서를 맞추는 대신, 그 순간의 코드가 양쪽 스키마에서 다 돌게 만든다.
해법 — 릴리스를 두 번으로 (expand / contract)
컬럼 삭제는 이렇게 나눈다.
# 릴리스 1 — 마이그레이션 없음. 코드가 컬럼을 잊게 한다
class Order < ApplicationRecord
self.ignored_columns += %w[legacy_status]
end
# 릴리스 2 — 이제 지운다
remove_column :orders, :legacy_status
릴리스 1 에는 마이그레이션이 없으니 위험 구간 자체가 없다. 릴리스 2 에도 구간은 생기지만, 그때 살아 있는 구 코드는 이미 그 컬럼을 모른다. 위 재현에 ignored_columns 를 넣고 다시 돌렸더니, 컬럼을 지운 뒤에도 INSERT 가 ("name") 만 적고 통과했다.
이름 변경은 한 번에 하지 않는다. 새 컬럼 추가 → 양쪽에 쓰기 + 백필 → 읽기를 새 컬럼으로 → 옛 컬럼 삭제(위 두 단계), 이렇게 네 번이다. NOT NULL · UNIQUE 추가 같은 다른 경우는 아래 상세 표에 있다.
규칙을 문서에만 두면 언젠가 샌다. strong_migrations 를 넣으면 remove_column · rename_column 같은 걸 개발할 때 예외로 막는다. 일부러 할 땐 safety_assured { ... } 로 감싸야 해서, 그게 diff 에 보이면 리뷰에서 「두 번으로 나눴어?」 를 묻게 된다.
migrate 를 어디서 돌릴지는 다른 문제다
기동 시 · 배포 전 단독 잡 · 배포 뒤 훅, 어디서 돌려도 위 구간은 남는다. 단독 잡으로 빼는 이유는 따로 있다. 기동 시 migrate 는 오토스케일로 태스크가 늘 때도 돌고, 헬스체크 시한을 넘기면 도중에 죽고, 실패해도 배포를 막지 못한다. 반대로 태스크 한 대 · 짧은 마이그레이션 · 오토스케일 없음이면 기동 시 방식이 제일 간단하고 그걸로 충분하다. 세 방식 비교와 GitHub Actions 로 뺄 때 걸린 함정은 아래 상세에 있다.
내 앱에서 먼저 볼 것
ActiveRecord::Base.partial_inserts # false 면 컬럼 삭제가 INSERT 를 깬다
grep -rlE "remove_column|rename_column" db/migrate | wc -l # 이미 몇 번 했나
grep -rn "ignored_columns" app/models | wc -l # 0 이면 나눠 낸 적이 없다
그리고 migrate 가 트래픽 전환 전에 도는지 뒤에 도는지 확인한다. 전이면 삭제 · 이름 변경이, 뒤면 새로 추가한 컬럼을 새 코드가 바로 읽는 게 위험하다. 배포 중에만 튀는 에러가 있는데 원인을 못 찾았거나, 컬럼 정리를 앞두고 배포 순서를 같이 짜 줄 사람이 필요하면 negabaro 에 물어봐도 된다.
마이그레이션별로 나누는 법
| 변경 | 구 코드에 생기는 일 | 나누는 법 |
|---|---|---|
| remove_column | INSERT 실패 (partial_inserts = false) | ignored_columns 릴리스 → 삭제 릴리스 |
| rename_column | INSERT · WHERE · ORDER 실패, 읽기는 nil | 추가 → 양쪽 쓰기 + 백필 → 읽기 전환 → 삭제 |
| NOT NULL 추가 | 구 코드가 NULL 을 넣으면 INSERT 실패 | NULL 안 넣게 고친 코드 → 백필 → 제약 |
| UNIQUE 인덱스 | 구 코드가 중복을 만들면 실패 | 앱 검증 먼저 → 중복 정리 → 인덱스 |
| 타입 변경 | 넓히기(int → bigint)는 대체로 괜찮다 | 좁히기는 새 컬럼으로 옮기는 rename 절차 |
| drop_table | 그 모델을 쓰는 모든 쿼리 실패 | 코드에서 모델 제거 릴리스 → 삭제 릴리스 |
migrate 를 어디서 돌리나 — 세 방식
| 기동 시 (ready.sh) | 배포 전 단독 잡 | 배포 뒤 훅 (Kamal post-deploy) | |
|---|---|---|---|
| 같이 사는 조합 | 구 코드 × 신 스키마 | 구 코드 × 신 스키마 | 신 코드 × 구 스키마 |
| 위험한 변경 | 삭제 · 이름 변경 | 삭제 · 이름 변경 | 추가한 컬럼을 바로 읽기 |
| 좋은 점 | 이미지만 있으면 어디서 띄워도 스키마가 맞는다. 한 줄 | 파이프라인당 1회, 실패하면 배포가 멈춘다 | 설정이 가볍다 |
| 깨지는 조건 | 태스크 여러 대 · 오토스케일 · 긴 마이그레이션 | CI 에 태스크 기동 권한이 필요하다 | 훅이 빠지면 DB 가 조용히 뒤처진다 |
기동 시 migrate 가 깨지는 네 가지
- 헬스체크 시한 안에서 돈다. 그 프로젝트는 grace 120초 + unhealthy 10회 × 30초 = 420초. 넘기면 ECS 가 마이그레이션 도중의 프로세스를 죽인다.
disable_ddl_transaction!을 쓴 마이그레이션이면 반쯤 적용된 채 남는다 - 배포가 아닐 때도 돈다. 오토스케일로 태스크가 늘 때마다 돈다. 남은 마이그레이션이 있으면 부하가 몰린 순간에 실행된다
- 실패가 배포를 막지 못한다. 실패 → 기동 중단 → 태스크 교체 → 또 실패. 멈췄을 때는 이미 일부가 적용된 뒤다
- 태스크 수만큼 동시에 돈다. PostgreSQL 이면 advisory lock 덕에 한 대만 적용하고 나머지는
ActiveRecord::ConcurrentMigrationError. 재시도 루프를 넣게 되고 기동이 늦어진다
GitHub Actions 로 뺄 때 걸린 것
- ✓
aws-actions/amazon-ecs-deploy-task-definition@v2의run-task: true는 서비스 갱신보다 먼저 원샷 태스크를 돌리고, exit code 가 0 이 아니면 잡을 실패시킨다. 폴링을 직접 짤 필요가 없다 - ⚠
aws ecs wait tasks-stopped는 6초 × 100회 = 10분에서 포기한다. 긴 마이그레이션이면 액션의wait-for-minutes쪽을 쓴다 - ⚠액션은 태스크 안 모든 컨테이너의 exit code 를 본다. 웹 태스크 정의를 그대로 쓰면 datadog 같은 사이드카까지 판정에 들어가, 마이그레이션이 성공해도 배포가 막힐 수 있다. 컨테이너 1개짜리 migrate 전용 태스크 정의를 따로 둔다
- ⚠migrate 잡만 따로 돌리는
workflow_dispatch를 열어 두면, 릴리스 2(삭제)를 구 코드가 다 사라진 걸 확인한 뒤에 돌릴 수 있다
핵심 포인트
콘솔에서 ActiveRecord::Base.partial_inserts 를 본다. false 면 컬럼 삭제가 그 구간의 INSERT 를 전부 깬다
컬럼 삭제는 ignored_columns 만 넣은 릴리스 → remove_column 릴리스, 두 번으로 낸다
이름 변경은 추가 → 양쪽 쓰기 + 백필 → 읽기 전환 → 삭제, 네 번으로 나눈다
strong_migrations 로 위험한 마이그레이션을 개발 시점에 막고, safety_assured 는 리뷰에서 묻는다
migrate 가 트래픽 전환 전인지 뒤인지 확인한다. 전이면 삭제 · 이름 변경이, 뒤면 새 컬럼을 바로 읽는 코드가 위험하다