가스 254
코스모스 허브 밸리데이터가 02:00 에 서명을 멈췄고 11:29 에 재개했습니다. jail 되지도 슬래싱되지도 않았지만 임계까지 37% 를 남긴 상태였습니다. 원인은 숫자 하나였습니다 — 가스 254.
무엇이 깨졌나
노드를 직접 빌드한 이미지로 돌리고 있었고, 그 빌드가 linux/arm64 로 나왔습니다. 코스모스 허브 공식 릴리스는 linux/amd64 전용이고, 네트워크의 다른 밸리데이터는 전부 그것으로 돕니다.
세 시간 동안은 아무 차이가 없었습니다. 그러다 32,454,059 번 블록에 우리 빌드에서만 다르게 계량되는 트랜잭션이 실려 왔습니다.
panic recovered in runTx
err="out of gas in location: WritePerByte;
gasWanted: 259076, gasUsed: 259330: out of gas"
한도를 254 가스 넘겼습니다. 다른 모든 노드에서는 한도 안에 들어와 성공한 트랜잭션이 우리 노드에서만 실패했고, 실패한 트랜잭션은 상태를 바꾸지 않습니다. 그 블록부터 우리 앱 상태는 체인의 것과 다른 물건이 되었습니다.
CONSENSUS FAILURE!!! wrong Block.Header.AppHash Expected D018907A... ← 우리 노드가 계산한 값 got 56826755... ← 네트워크가 합의한 값
합의는 바이트 단위로 같은 결과를 요구합니다. "같은 버전"을 돌리는 것과 같은 바이너리를 돌리는 것은 다릅니다.
왜 여섯 시간을 몰랐고, 세 시간을 더 헤맸나
상태가 갈라진 뒤로 우리 노드는 받은 정상 블록을 전부 잘못된 블록으로 판단하고, 그것을 보내준 피어를 끊었습니다.
Stopping peer for error err="reactor validation error: wrong Block.Header.AppHash"
로그의 99% 가 피어 연결과 해제였습니다. 겉보기 증상은 네트워크가 우리를 거부한다 였습니다. 실제로는 우리가 네트워크를 거부하고 있었습니다.
노드는 그동안 내내 catching_up: false 를 보고했습니다. 블록 동기화 리액터가 이미 종료된 뒤라 스스로는 따라잡을 게 없다고 믿었습니다. 실제로는 4,000 블록 뒤에서 멈춰 있었습니다.
명령 한 줄이면 panic 이 바로 보였습니다.
tail -400 <노드 로그> 2>&1 | grep -v 'module=p2p' | tail -40
이제 노드 장애에서 가장 먼저 하는 일입니다.
가는 길에 두 번 잘못 짚었습니다
메모리 탓이라고 봤습니다. 호스트가 32GB 중 31GB 사용에 스왑이 심해 노드에 메모리 상한을 걸었고, 멀쩡한 노드 셋이 이유 없이 재시작됐습니다. 제대로 재보니 노드 합계는 20 GiB 중 15.6, 코스모스 노드는 8 중 3.1 이었습니다. 호스트 수치는 블록체인 디스크 I/O 의 페이지 캐시였고, 이미 우리 문서에 적어둔 내용인데 다시 읽어볼 생각을 못 했습니다.
다음엔 피어 탓이라고 봤습니다. 멀쩡히 돌던 피어 목록을 공개 RPC 에서 긁은 주소 20개로 바꿨습니다. 상당수가 모르는 연결을 즉시 끊는 센트리였고, persistent peer 는 무한 재시도라 다이얼 슬롯을 점유해 정상적인 피어 탐색까지 막았습니다. 원래 목록은 처음부터 문제가 아니었습니다.
두 번의 우회 모두 로그가 아니라 증상을 읽어서 생긴 일입니다.
무엇이 실제로 고쳤나
state-sync 는 세 번 실패했습니다 — 한 번은 전송 도중, 두 번은 라이트 클라이언트 검증에서. 43GB 스냅샷 다운로드는 준비만 하고 쓰지 않았습니다.
통한 것은 이미 공식 amd64 이미지로 돌고 있던 다른 머신에서 데이터 디렉토리를 복사해 오는 것이었습니다. 복사 전에 그 노드를 반드시 멈춰야 합니다. 돌아가는 노드의 DB 를 복사하면 쓰기 도중 상태가 잘려 들어와, 지금 벗어나려는 바로 그 불일치가 재현됩니다.
첫 복사는 그래도 실패했습니다. 데이터만 갈고 arm64 바이너리를 그대로 뒀더니 233 블록을 다시 실행하다 같은 트랜잭션에서 또 갈라졌습니다. 바이너리를 먼저 바꾸고, 그다음에 데이터입니다.
공식 이미지로 바꾸는 데 두 가지 오버라이드가 필요했습니다 — entrypoint 가 ["gaiad", "start"] 인데 우리는 ["gaiad"] 였고, 실행 유저가 non-root 인데 데이터는 root 소유였습니다. 그다음엔 uname -m 이 x86_64 를 뱉는지가 유일하게 중요한 확인이었습니다.
무엇을 바꿨나
메인넷 노드는 공식 릴리스 이미지만 씁니다. 자체 빌드 금지. 호스트 아키텍처가 릴리스와 다르면 공식 빌드를 에뮬레이션으로 돌립니다. 느려지지만 그럴 값어치가 있습니다 — 느린 노드는 늦게 서명하고, 틀린 노드는 아예 못 합니다. 셀레스티아 노드가 무사했던 이유도 처음부터 공식 이미지였기 때문입니다.
모니터는 65분 만에 알렸고, 그 뒤 여섯 시간을 침묵했습니다. 모든 규칙이 쓰인 대로 작동했습니다 — 규칙이 틀렸습니다. 조건마다 한 번만 알리는 설계는 사람이 알림을 꺼버리는 걸 막지만, 그 결과 장애가 가장 시끄러워야 할 구간이 가장 조용했습니다. 그 침묵 동안 놓친 블록은 689 에서 5,989 로 올랐습니다.
그래서 이제 멈춘 노드와 가끔 놓치는 노드를 구분합니다. 놓친 블록 증가분을 체인이 나아간 블록 수와 대조해서, 그 비율이 0.9 이상이면 서명이 사실상 0 입니다. 카운터가 위협적으로 보일 때까지 기다리지 않고 한 번의 폴링으로 판정됩니다. 이 알림은 정지가 이어지는 동안 매 폴링마다 반복되고, jail 임계의 25/50/75% 사다리가 함께 울립니다. 모든 알림에 예상 jail 시각이 붙습니다.