MongoDB 샤드 클러스터 쓰기 경로 시리즈의 5/6편이다.
이 글이 답하는 질문: primary가 쓰기를 하나도 끝내지 못하는데 왜 자동 failover가 일어나지 않는가? Flow Control과 Storage Node Watchdog은 왜 이 상태를 잡지 못하는가? 그러면 무엇을 감시해야 하는가?
기준 버전: MongoDB 4.2(공식 문서
v4.2브랜치, 서버 소스r4.2.12태그). 7.0에서 달라진 점은 해당 절에 따로 적었다.
MongoDB 4.2에서 primary 이상에 자동으로 반응하는 장치는 셋이다. 세 장치는 primary가 살아 있는지, secondary가 뒤처졌는지를 본다. primary가 대답은 하는데 쓰기를 끝내지 못하는 상태는 어느 판정 기준에도 걸리지 않는다.
| 장치 | 보는 것 | 쓰기가 멈춘 primary를 보면 |
|---|---|---|
| 자동 failover(선거) | heartbeat 응답 여부 | heartbeat가 응답하므로 선거가 없다 |
| Flow Control | majority commit 지연(lag) | lag가 0에 가까워 정상으로 판정한다 |
| Storage Node Watchdog | 파일시스템 무응답 | 파일시스템은 정상이므로 발동하지 않는다 |
그래서 운영자가 진행(progress)을 직접 감시해야 한다. 감시할 질문은 "대답하는가"가 아니라 "쓰기를 끝내는가"다.
이 상태를 만드는 원인은 스토리지 엔진 WiredTiger(이하 엔진) 안에 있다. 구체적으로는 WiredTiger 스냅샷 pause 루프의 livelock이다. livelock은 스레드가 CPU를 쓰며 계속 돌지만 작업은 끝나지 않는 상태다. 이 원인은 3/6편이 다룬다. 이 글은 세 장치가 이 상태에 반응하지 않는 이유와 감시 설계를 다룬다.
레플리카셋 멤버는 서로 heartbeat를 보낸다. 4.2 문서는 이렇게 적는다.
Replica set members send heartbeats (pings) to each other every two seconds. If a heartbeat does not return within 10 seconds, the other members mark the delinquent member as inaccessible.
| 항목 | 값 |
|---|---|
| heartbeat 주기 | 2초 |
| 응답이 없으면 접근 불가로 보는 시간 | 10초 |
| 선거를 일으키는 연결 단절 시간 | electionTimeoutMillis, 기본 10초 |
선거는 secondary가 primary와 연결을 electionTimeoutMillis보다 오래 잃었을 때 시작된다. 선거 규칙의 입력은 "heartbeat가 돌아오는가" 하나다. primary가 쓰기를 처리하는지는 입력에 없다.
heartbeat 명령을 처리하는 코드는 글로벌 락도 ticket도 잡지 않는다. ticket은 엔진에 동시에 들어갈 수 있는 작업 수를 제한하는 입장권이다. 처리 함수는 src/mongo/db/repl/repl_set_commands.cpp(r4.2.12)의 CmdReplSetHeartbeat::run이다. 이 함수는 인자를 파싱한 뒤 processHeartbeatV1만 부른다.
일반 쓰기는 다르다. insert·update·delete·findAndModify는 글로벌 락을 IX(쓰기 의도) 모드로 잡으면서 write ticket을 하나 받는다(src/mongo/db/concurrency/lock_state.cpp:262-266 (r4.2.12)). 4.2의 write ticket 기본값은 128개다(src/mongo/db/storage/wiredtiger/wiredtiger_kv_engine.cpp:474-475 (r4.2.12)).
write ticket 128개를 받은 작업이 모두 엔진 안에서 멈춰 있어도 heartbeat는 ticket을 기다리지 않는다. 그래서 엔진이 멈춰도 heartbeat는 정상 응답할 수 있다. 이 결론은 소스를 읽고 내린 추론이다. 직접 재현하지는 않았다.
예를 들어 store_inventory 컬렉션에 쓰는 writer 서비스의 쓰기가 하나도 끝나지 않는 동안에도 secondary는 2초마다 heartbeat 응답을 받는다. 선거 규칙의 입력에는 변화가 없다.
Flow Control은 4.2에서 들어온 기능이다. majority commit 지연은 primary가 적용한 쓰기를 과반수 멤버가 확인하기까지 뒤처진 시간이다. Flow Control은 이 지연을 일정 값 아래로 유지하려고 primary의 쓰기 속도를 늦춘다.
With flow control enabled, as the lag grows close to the flowControlTargetLagSeconds, writes on the primary must obtain tickets before taking locks to apply writes.
기본값은 enableFlowControl: true, flowControlTargetLagSeconds: 10이다. FCV(featureCompatibilityVersion)가 4.2이고 majority read concern이 켜져 있어야 작동한다.
lag 계산식은 소스에 있다(src/mongo/db/storage/flow_control.cpp:77-82 (r4.2.12)).
return static_cast<std::uint64_t>(durationCount<Milliseconds>(myLastApplied - lastCommitted));
myLastApplied는 primary가 마지막으로 적용한 쓰기의 시각이다. lastCommitted는 과반수 멤버가 확인한 마지막 쓰기의 시각이다. 이 차이가 임계치(flowControlTargetLagSeconds)보다 작으면 Flow Control은 정상으로 판정하고 ticket을 늘린다(같은 파일 328-350행). 두 시각 사이에 작업이 거의 없을 때도 정상으로 판정한다.
primary가 쓰기를 못 하면 이렇게 흘러간다.
myLastApplied가 멈춘다.lastCommitted가 myLastApplied와 같아진다.위 흐름은 계산식에서 끌어낸 추론이다. 예시값으로 따져 본다. primary가 10:00:00에 마지막 쓰기를 적용했다. secondary가 이것을 복제해 lastCommitted도 10:00:00이 됐다. 그 뒤로 primary가 아무것도 적용하지 못하면 lag는 10:00:00 − 10:00:00 = 0초로 고정된다. 임계치 10초에 한참 못 미친다.
lag가 커져 Flow Control이 쓰기를 늦추는 상황이라도 이 상태는 풀리지 않는다. Flow Control은 엔진 입구에서 들어오는 쓰기의 양을 줄인다. 이미 ticket을 받고 엔진 안에 들어간 작업은 건드리지 못한다.
Storage Node Watchdog(이하 Watchdog)은 4.2부터 Community 판에도 있다. 감시 대상은 다음 디렉터리의 파일시스템 무응답이다.
--dbpath 디렉터리--dbpath 안의 journal 디렉터리(journaling을 켠 경우)--logpath 파일이 있는 디렉터리--auditPath 파일이 있는 디렉터리파일시스템이 응답하지 않으면 Watchdog이 mongod를 종료한다(종료 코드 61). 그 mongod가 primary였다면 failover가 일어난다.
Watchdog은 기본으로 꺼져 있다. watchdogPeriodSeconds의 기본값은 -1(비활성)이고 켜려면 mongod 시작 시 60 이상의 정수를 줘야 한다.
CPU가 엔진 안의 루프에 묶인 상태에서도 디스크와 파일시스템은 정상으로 응답한다. 그래서 Watchdog을 켜 두었더라도 이 상황에서는 발동하지 않는다. 이 결론은 문서의 감시 대상에서 끌어낸 추론이다.
그 밖에 엔진이 진행하지 못할 때 primary를 강등하는 기능은 4.2 문서에서 찾지 못했다. 문서와 파라미터를 전수 확인하지는 않았다.
대답은 하는데 일은 끝내지 못하는 구조는 쿠버네티스 liveness probe에서도 흔히 생긴다. probe가 /health만 확인한다고 하자. HTTP 스레드가 대답하면 작업 큐가 멈춰 있어도 probe는 정상으로 판정한다. MongoDB의 heartbeat는 이 /health probe와 같은 역할을 한다.
진행을 보려면 세 신호를 함께 본다.
하나만 보면 오탐이 난다. 처리량만 보면 쓰기가 원래 적은 새벽에 울린다. 대기열만 보면 정상적인 순간 폭주에도 울린다.
아래 경로는 db.serverStatus() 결과의 경로다. 각 shard의 primary에서 읽는다.
| serverStatus 경로 | 뜻 | 멈춘 primary에서 예상되는 모양 |
|---|---|---|
wiredTiger.concurrentTransactions.write.available |
남은 write ticket 수 | 0 근처에 머문다 |
opcounters.insert·.update·.delete |
누적 처리 건수 | 증가가 멈추거나 급감한다 |
opLatencies.writes.latency·.ops |
누적 쓰기 지연·건수 | 구간 평균 지연이 급증한다 |
globalLock.currentQueue.writers |
대기 중인 쓰기 수 | 계속 늘어난다 |
호스트에서 예상되는 모양은 셋이다.
iowait가 0에 가까우면 CPU는 디스크를 기다리지 않고 명령을 실행하는 데 쓰인다. 문맥 전환이 줄어드는 것은 pause 루프가 시스템 콜을 하지 않아 스레드가 CPU를 스스로 내놓지 않기 때문이다. 이 두 해석은 3/6편의 엔진 소스 분석에서 끌어낸 추론이다.
globalLock.currentQueue.writers는 락 대기와 ticket 대기를 함께 센다. r4.2.12 소스에서 이 값은 상태가 kQueuedWriter인 클라이언트 수다(src/mongo/db/stats/lock_server_status_section.cpp:76-83). lock_state.cpp에서 이 상태가 되는 경우는 셋이다.
이 값이 늘었다고 락 경합으로 단정하면 안 된다. ticket이 바닥나도 같은 값이 오른다.
아래는 의사 코드다. 지표 이름은 serverStatus 경로로 썼다. 수집기(exporter)가 내는 실제 지표 이름은 확인하지 않았으니 환경에 맞게 바꾼다. 임계치는 모두 예시값이다.
# 의사 코드. 각 shard primary마다 평가한다. 임계치는 예시값.
ALERT MongoPrimaryWriteStalled
WHEN
# 1) 엔진 입구가 막혔다
wiredTiger.concurrentTransactions.write.available <= 2
# 2) 들어오려는 쓰기가 있다
AND globalLock.currentQueue.writers > 10
# 3) 처리량이 늘지 않는다 (1분 증가율이 지난주 같은 시각의 10% 미만)
AND rate(opcounters.insert + opcounters.update + opcounters.delete, 1m)
< 0.1 * baseline_same_hour_last_week
# 4) CPU는 명령 실행에 묶여 있다
AND host.cpu.user >= 90%
AND host.cpu.iowait < 5%
FOR 2m
SEVERITY page
NOTE "heartbeat가 정상이어도 자동 failover는 일어나지 않는다. 대응 절차 문서를 연다."
구간 평균 쓰기 지연은 누적값의 차이로 구한다.
avg_write_latency_us(1m) =
delta(opLatencies.writes.latency, 1m) / delta(opLatencies.writes.ops, 1m)
쓰기가 하나도 끝나지 않으면 분모가 0이다. 이때는 완료 건수가 0이라는 사실을 그대로 신호로 쓴다.
7.0부터는 1번 조건을 그대로 쓸 수 없다. 7.0은 ticket 수를 동적으로 조절한다. 그래서 7.0 문서는 남은 ticket 수(ticket availability)가 오래 0이어도 과부하 신호로 보지 않는다고 적는다. 6.0 이하에서는 같은 상황이 과부하 신호다. 7.0 이상에서는 2~4번 조건을 중심으로 본다.
serverStatus 지표는 간접 신호다. 작은 쓰기를 주기적으로 보내 끝나는지 재는 프로브(probe)가 더 직접적이다. 아래는 실행으로 검증하지 않은 mongo shell 예시다.
// 예시: 외부 감시 프로세스가 30초마다 실행한다.
// ops_probe 데이터베이스의 probe 컬렉션에 문서 한 건을 갱신한다.
const started = new Date();
db.getSiblingDB("ops_probe").probe.updateOne(
{ _id: "writer-probe" },
{ $set: { at: started } },
{ upsert: true }
);
const elapsedMs = new Date() - started;
// elapsedMs가 예시 임계치(2000ms)를 넘거나 클라이언트 타임아웃으로 실패하면 알린다.
maxTimeMS는 협조형 중단이다. 작업이 스스로 중단 요청을 확인해야 멈추므로 엔진 안에서 멈춘 작업을 끊지 못한다(4/6편 참고).primary 지표보다 애플리케이션에서 이상이 먼저 보이는 경우가 많다. 일반론으로 예상되는 순서는 다음과 같다.
애플리케이션 재시도가 서버에 대기 작업을 더 쌓는 구조는 2/6편이 다룬다.
- heartbeat와
rs.status()의 초록불만 보고 DB를 원인에서 빼면 안 된다. heartbeat는 엔진을 거치지 않으므로 primary 상태 표시도 정상으로 남는다(소스 기반 추론).- Flow Control 설정을 바꿔도 이 상태는 풀리지 않는다. Flow Control은 lag가 클 때 입구에서 쓰기를 늦출 뿐이다.
- Watchdog을 켜도 이 상태는 감지되지 않는다. Watchdog은 파일시스템 무응답만 본다.
- 처리량 하락 단독 알림은 오탐이 많다. 대기열 증가와 묶는다.
- 4.2에 엔진 진행 여부로 primary를 강등하는 기능이 정말 없는지 문서와 파라미터를 전수 확인하지 않았다.
- 엔진이 멈춘 동안 heartbeat가 응답한다는 결론은 r4.2.12 소스를 읽고 내린 추론이다. 직접 재현하지 않았다.
opcounters·opLatencies.writes의 필드 정의를 4.2 문서 원문과 대조하지 않았다.- mongos가 shard 연결을 얻지 못할 때 내는 오류의 정확한 코드와 이름을 확인하지 않았다.
- 수집기(exporter)가 쓰는 실제 지표 이름을 확인하지 않았다.
- 샤드 클러스터에서 shard별 쓰기 프로브를 두는 구체적인 방법과 부작용을 검증하지 않았다.
- 7.0 이후 버전에서 heartbeat 처리 경로와 Flow Control 계산식이 바뀌었는지 확인하지 않았다.