MongoDB 샤드 클러스터 쓰기 경로 시리즈의 4/6편이다.
이 글이 답하는 질문: primary의 쓰기가 멈췄을 때 killOp·maxTimeMS·replSetStepDown·정상 종료(SIGTERM)는 왜 듣지 않는가? 그때 무엇을 어떤 순서로 해야 하는가?
기준 버전: MongoDB 4.2. 공식 문서v4.2브랜치와 서버 소스 태그r4.2.12를 읽었다. 최신 버전은 확인하지 않았다.
MongoDB가 작업을 멈추는 수단은 대부분 협조형이다. 종료 표시를 남기고 대상 스레드가 그 표시를 확인하기를 기다린다. 스토리지 엔진(WiredTiger) 안에서 도는 스레드는 표시를 확인하지 못하므로 killOp도 replSetStepDown도 정상 종료도 그 스레드를 멈추지 못한다. 남는 수단은 공식 문서가 금지한 kill -9뿐이다. 그래서 쓰기 주체(클러스터에 쓰기를 보내는 API 서버·배치·컨슈머)를 먼저 멈추고 primary를 내리는 순서를 지켜야 한다.
interrupt point는 작업이 안전하게 중단될 수 있는 자리다. 4.2 용어집의 정의다.
"A point in an operation's lifecycle when it can safely abort. MongoDB only terminates an operation at designated interrupt points."
서버 소스에서 작업 하나는 OperationContext 객체 하나다. killOp은 이 객체에 markKilled를 부를 뿐이다. 소스 주석에 따르면 그 뒤로 작업 스레드가 checkForInterrupt를 부를 때마다 지정한 오류 코드를 돌려받는다(src/mongo/db/operation_context.h:261-272 (r4.2.12)).
killOp 문서의 표현은 이렇다.
"Operations may not actually be terminated until they reach an appropriate interruption point."
작업이 끝났는지는 db.currentOp()로 다시 확인해야 한다. killOp이 돌려주는 ok: 1은 표시에 성공했다는 응답이다.
쓰기 작업은 엔진에 들어가기 전에 write ticket(동시 쓰기 입장권, 4.2 기본 128개)을 받는다. ticket을 기다리는 동안은 중단할 수 있다. 4.2 소스는 ticket 대기에 OperationContext를 넘겨서 대기 중에도 종료 표시를 확인하게 한다(src/mongo/db/concurrency/lock_state.cpp:343-349 (r4.2.12)).
maxTimeMS도 같은 방식이다. 문서는 마감이 지나면 "MongoDB interrupts the operation at the next interrupt point"라고 적는다.
killOp과 maxTimeMS가 끊는 것은 ticket 대기 줄에 선 작업까지다. 이미 ticket을 받고 엔진 안에 들어간 작업은 끊지 못한다. WiredTiger의 스냅샷 대기 루프에는 checkForInterrupt 호출이 없다(src/third_party/wiredtiger/src/txn/txn.c:196-235 (r4.2.12)). 이 루프가 어떻게 끝없이 도는지는 WiredTiger 스냅샷 pause 루프 livelock에서 다룬다.
샤드 클러스터에서는 함정이 하나 더 있다. mongos에서 보낸 killOp은 쓰기 작업을 shard로 전파하지 않는다.
"The killOp command from the mongos does not propagate to the shards when the operation to be killed is a write operation."
쓰기 작업은 그 shard의 primary mongod에 직접 접속해 끊어야 한다.
락 모드 두 가지부터 본다.
| 모드 | 뜻 | 여러 작업이 동시에 쥘 수 있나 |
|---|---|---|
| IX (intent exclusive) | 쓰기 의도를 밝히는 공유 모드 | 쥘 수 있다 |
| X (exclusive) | 배타 모드 | 혼자만 쥔다 |
X를 원하는 쪽은 IX를 쥔 작업이 전부 놓을 때까지 기다린다. 예를 들어 IX를 쥔 작업이 128개면 128개가 모두 놓아야 X를 얻는다. 127개가 끝나도 1개가 남으면 얻지 못한다.
4.2에서 primary가 secondary로 내려가려면 RSTL(ReplicationStateTransitionLock, 복제 상태 전환 락)을 X로 쥐어야 한다. 읽기·쓰기 작업은 모두 글로벌 락(mongod 인스턴스 전체에 거는 락)을 쥐기 전에 RSTL을 IX로 쥔다.
secondaryCatchUpPeriodSecs(기본 10초)는 secondary가 primary를 따라잡기를 기다리는 시간이다.
replication_coordinator_impl.cpp (r4.2.12)의 stepDown 흐름은 다음과 같다.
force가 없으면 secondaryCatchUpPeriodSecs까지, force: true면 stepDown 기간 전체까지다(2054-2058행).Starting to kill user operations를 남기고 쓰기와 충돌하는 작업에 종료 표시를 한다(1887-1913행, 1946행).LockTimeout(Unable to acquire lock ... within ...)으로 실패한다(lock_state.cpp:964-966).3단계의 표시도 killOp과 같은 협조형이다. 엔진 안 스레드는 표시를 확인하지 못하므로 RSTL IX를 놓지 않는다. 그래서 4단계에서 마감에 걸린다. force: true는 마감을 늘릴 뿐 RSTL X가 필요한 점은 같다.
문서에 따르면 secondaryCatchUpPeriodSecs 안에 따라잡은 secondary가 없어도 stepDown은 오류를 낸다. 오류 메시지가 Unable to acquire lock이면 catch-up이 아니라 RSTL X에서 막힌 것이다.
4.2 문서는 primary가 SIGTERM을 받으면 종료 전에 stepDown을 시도한다고 적는다. stepDown이 실패해도 종료는 계속한다(SERVER-38994에서 3.6.15·4.0.8·4.1.10에 도입).
src/mongo/db/db.cpp (r4.2.12)의 종료 경로는 다음 순서로 진행한다.
Enqueuing the ReplicationStateTransitionLock for shutdown # RSTL X 요청
Killing all operations for shutdown # 전체 종료 표시
Acquiring the ReplicationStateTransitionLock for shutdown # RSTL X 대기, Date_t::max() (1013-1029행)
Acquiring the global lock for shutdown # 글로벌 X 대기, Date_t::max() (1102-1106행)
Date_t::max()는 기한을 두지 않는 값이다. 그래서 엔진 안 스레드가 RSTL과 글로벌 락을 놓지 않으면 정상 종료는 끝나지 않는다. 소스를 읽고 내린 추론이다.
SIGKILL을 보내면 운영체제가 협조를 구하지 않고 프로세스를 바로 끝낸다. 4.2 문서는 경고 상자에 이렇게 적는다.
"Never use kill -9 (i.e. SIGKILL) to terminate a mongod instance."
kill -9 뒤의 journal 복구 동작은 확인하지 않았다. 그래서 kill -9는 판별(아래 「대응 순서」 1단계)을 마친 뒤에 쓰는 마지막 수단으로 둔다.
| 명령 | 하는 일 | 엔진 안 스레드에 닿나 | 대신 끊기는 것 |
|---|---|---|---|
killOp |
대상 작업에 종료 표시 | 닿지 않음 | ticket 대기 중인 작업 |
maxTimeMS |
마감 뒤 다음 interrupt point에서 중단 | 닿지 않음 | ticket 대기 줄에 선 작업 |
replSetStepDown |
쓰기 작업에 종료 표시 후 RSTL X 대기 | 닿지 않음(마감 뒤 LockTimeout) |
interrupt point에 오는 쓰기 작업 |
| SIGTERM | stepDown 시도, 전체 종료 표시, 락 무기한 대기 | 닿지 않음(종료가 끝나지 않음, 추론) | interrupt point에 오는 작업 |
kill -9 |
운영체제가 프로세스를 즉시 종료 | 닿음 | 진행 중인 작업 전부 |
협조형 수단이 대상 스레드를 멈추지 못하면 다음과 같이 보인다.
ok: 1을 돌려줬는데 db.currentOp()에 같은 opid가 계속 남는다.replSetStepDown은 마감(기본 secondaryCatchUpPeriodSecs)이 지난 뒤 실패한다. 오류는 LockTimeout이고 로그에는 Starting to kill user operations가 남아 있다.primary가 엔진 안에서 멈춘 것으로 보이면 운영자는 아래 순서를 따른다.
kill -9를 보낸다. 확인은 각 주체의 처리 지표가 0이 됐는지로 한다. 확신이 없으면 kill -9를 미루고 기다린다. kill -9 뒤에는 heartbeat가 끊기므로 secondary들이 선거를 시작한다(electionTimeoutMillis 기본 10초).rs.status()에서 다른 멤버가 PRIMARY인지 본다. 그 뒤 쓰기 주체를 하나씩 켠다. 하나를 켤 때마다 write ticket 여유와 처리량을 확인하고 그다음 주체로 넘어간다.하지 말아야 할 일은 셋이다.
쓰기를 남긴 채 primary만 내리지 않는다. 2단계를 건너뛰고 kill -9부터 하면 같은 정지가 새 primary로 옮겨 갈 수 있다.
"이 클러스터에 누가 쓰는가, 각각 어떻게 멈추는가"를 한 페이지 목록으로 미리 둔다. 아래는 형식 예시이며 값은 모두 가상이다.
| 쓰기 주체 | 종류 | 멈추는 방법 | 재개 순서 |
|---|---|---|---|
store-inventory-writer |
Kafka 컨슈머 | 컨슈머 레플리카 수를 0으로 줄임 | 2 |
| 변경 이벤트 CDC 커넥터 | CDC | 커넥터 일시 정지 | 1 |
목록에는 멈춘 것을 확인하는 지표도 함께 적는다. 목록에서 빠진 쓰기 주체는 대응 중에도 새 primary에 계속 쓴다.
실행으로 검증하지 않은 절차다. 아래 결과는 문서와 소스로 예상한 것이다.
엔진 안 spin 재현은 3/6편 WiredTiger 스냅샷 pause 루프 livelock에서 다룬다. 여기서는 interrupt point를 지나는 긴 쓰기에 각 수단이 어떻게 반응하는지 테스트용 3멤버 레플리카셋에서 본다.
컬렉션에 문서 100만 건(예시값)을 넣는다.
use test
for (let b = 0; b < 100; b++)
db.store_inventory.insertMany(Array.from({ length: 10000 },
(_, j) => ({ _id: { productId: j % 1000, storeId: b * 10000 + j }, qty: 0 })))
셸 A에서 오래 걸리는 쓰기를 실행한다.
db.store_inventory.updateMany({}, { $inc: { qty: 1 } })
셸 B에서 opid를 찾아 killOp을 보낸다.
db.currentOp({ op: "update", ns: "test.store_inventory" }).inprog.map(o => o.opid)
db.killOp(<opid>)
예상 결과: killOp은 ok: 1을 돌려준다. 셸 A의 updateMany는 다음 interrupt point에서 중단 오류로 끝난다.
2단계를 다시 실행하고 셸 B에서 stepDown을 보낸다.
db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 10 })
예상 결과: 로그에 Starting to kill user operations가 남는다. updateMany는 종료 표시를 받고 끝난다. primary는 secondary로 내려간다.
2단계를 다시 실행하고 그 mongod에 SIGTERM(kill -15)을 보낸다.
예상 결과: 종료 경로의 네 줄이 로그에 차례로 남고 프로세스가 끝난다.
샤드 클러스터라면 2·3단계를 mongos에서 해 본다. 예상 결과는 shard primary에 작업이 남는 것이다.
kill -9뒤 mongod가 journal과 마지막 체크포인트로 어디까지 복구하는지 확인하지 않았다.- SIGTERM 종료가 끝나지 않는다는 결론은
db.cpp를 읽고 내린 추론이다. 실제 로그는 보지 않았다.- 4.2 이후 버전의 stepDown·종료 경로는 확인하지 않았다.