🚀 요약

SUMMARY

mariadb-operator로 운영하는 Galera 3노드 클러스터에서 한 노드의 datadir이 깨져 CrashLoop에 빠지면, operator가 그 노드 때문에 reconcile을 끝내지 못해 클러스터 전체가 not-Ready로 묶인다. 정상 노드가 Primary/Synced인지부터 확인하고, operator reconcile을 suspend한 뒤 손상 노드의 PVC만 비우고 재기동해 SST로 다시 받게 하면 데이터 유실 없이 복구된다. 여러 노드가 동시에 깨져 split-brain이면 grastate.dat·availableWhenDonor·startupProbe까지 손봐야 한다.

⚙️ 환경

  • Kubernetes + mariadb-operator
  • MariaDB Galera 3노드 클러스터 (StatefulSet, Pod 3개)
  • 스토리지: 자체호스팅 NFS 백엔드, PVC 100Gi/노드, 마운트 /var/lib/mysql
  • 손상 노드 예시: app-mariadb-2, datadir PVC storage-app-mariadb-2

네임스페이스·호스트명·UUID 등은 전부 가상값으로 바꿔 적는다.

💬 이슈

어느 날 데이터 레이어를 받치던 NFS 서버 Pod가 재기동됐다. 그 스토리지클래스를 쓰던 PVC가 일제히 NFS 단절을 겪었고, 그 위에서 돌던 DB Pod들이 비정상 종료되는 과정에서 Galera 한 노드(app-mariadb-2)의 datadir 일부 파일이 깨졌다. 로그를 보면 이런 식이다.

[ERROR] mariadbd: Got error 'Size of control file is smaller than expected' ... aria_log_control
[ERROR] InnoDB: File ./ib_logfile0 was not found
Installation of system tables failed!

여기서 첫 번째 함정. datadir은 PVC에 그대로 남아 있으니 Pod를 재시작한다고 풀리지 않는다. 깨진 파일이 그 자리에 계속 있으니 몇 번을 재시작해도 같은 자리에서 죽는다. CrashLoopBackOff.

두 번째 함정은 클러스터 전체가 not-Ready로 보인다는 거다. MariaDB CR은 Ready=False, GaleraReady=Falseerror restarting Pod 'app-mariadb-2': context deadline exceeded를 뱉는다. 정상 노드 agent 로그에도 이런 게 계속 찍힌다.

probe.liveness "Galera not ready. Returning OK to facilitate recovery"

처음엔 “정상 노드까지 맛이 갔나” 싶어 심장이 철렁했는데, 이건 고장 신호가 아니었다. 복구 중인 Pod가 kubelet한테 죽지 않도록 liveness가 일부러 OK를 돌려주는 정상 동작이다. 정상 primary 노드도 똑같은 로그를 남긴다. 결국 문제는 하나였다. operator가 손상 노드 하나 때문에 reconcile을 끝내지 못하고, 그 여파로 클러스터를 not-Ready로 잡아두고 있었을 뿐이다. 손상 노드만 복구하면 이 로그도 멈춘다.

NOTE

Galera는 쓰기 가능한 상태를 유지하려면 과반(quorum)이 필요하다. 3노드 중 2노드가 Primary/Synced로 살아 있으면 클러스터 자체는 멀쩡하고, 나머지 1노드는 살아있는 노드에서 SST(State Snapshot Transfer)로 datadir 전체를 다시 받아 합류하면 그만이다. 즉 이 상황은 데이터가 사라진 사고가 아니라, 깨진 노드 하나를 버리고 다시 받는 문제다.

🧗 해결

되돌리기 어려운 작업이라, 손대기 전에 게이트를 하나 둔다. 정상 노드가 정말 Primary/Synced인가. 이게 확인돼야 그 노드를 donor 삼아 손상 노드를 다시 받을 수 있다. 아니라면 아래 단순 절차로는 안 되고, split-brain 쪽으로 넘어가야 한다.

1. 정상 노드 상태 확인

살아있는 노드(app-mariadb-0)에서 wsrep 상태를 본다. root 비밀번호를 커맨드에 박지 않으려고 컨테이너에 들어 있는 healthcheck용 설정 파일을 그대로 쓴다.

# 정상으로 보이는 노드에서 Galera 클러스터 상태를 조회한다.
kubectl -n app-db exec app-mariadb-0 -c mariadb -- \
  mariadb --defaults-extra-file=/var/lib/mysql/.my-healthcheck.cnf -N \
  -e "SHOW STATUS WHERE Variable_name IN \
      ('wsrep_cluster_status','wsrep_local_state_comment','wsrep_cluster_size','wsrep_ready')"
# Primary / Synced / ON 이면 이 노드가 donor 가 될 수 있다 -> 진행

Primary, Synced, ON이 나오면 진행한다. 손상 노드 외의 PVC는 이 시점부터 절대 건드리지 않는다.

2. operator reconcile 중단과 손상 노드 격리

먼저 operator를 재운다. spec.suspend: true를 주면 operator가 리소스에 손을 떼서, 내가 수동으로 하는 조작과 operator의 자동 복구 로직이 충돌하지 않는다. 이걸 안 걸면 operator가 5분 timeout으로 Pod를 계속 재시작시키며 작업을 방해한다.

# 1) operator reconcile 중단 (수동 변경과 충돌 방지)
kubectl -n app-db patch mariadb app-mariadb --type merge -p '{"spec":{"suspend":true}}'
 
# 2) StatefulSet 축소로 손상 Pod 제거 (PVC 는 남는다)
kubectl -n app-db scale statefulset app-mariadb --replicas=2

StatefulSet은 인덱스 역순으로 지운다. replicas=2면 가장 뒤 인덱스인 app-mariadb-2가 먼저 내려간다. 손상 노드가 마침 마지막 인덱스라 운이 좋았던 셈이고, 아니었으면 인덱스 배치를 더 고민해야 했을 거다. PVC는 축소해도 남는다.

3. 손상 노드 datadir 비우기

깨진 파일이 그대로 있으면 재기동해도 또 죽으니, storage-app-mariadb-2 PVC 내부를 통째로 비운다. 임시 Pod로 그 PVC만 마운트해서 지운다. (air-gapped 환경이면 busybox 이미지를 사내 레지스트리 경로로 바꾼다.)

# 손상 노드의 PVC 만 마운트해 내부를 전부 비운다 (숨김 파일 포함).
kubectl -n app-db run mariadb2-wipe --restart=Never --rm -it \
  --image=busybox \
  --overrides='{"spec":{"containers":[{"name":"wipe","image":"busybox","command":["sh","-c","rm -rf /data/* /data/..?* /data/.[!.]* ; ls -la /data"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}],"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"storage-app-mariadb-2"}}]}}'

NFS 서버 호스트나 export를 마운트한 곳에 직접 접근할 수 있으면 파일시스템에서 바로 지우는 게 빠르다. 대신 경로를 틀리면 다른 PVC를 날려버린다. 그래서 PV의 nfs.path로 실제 경로를 확인하고 ls로 눈으로 본 다음에만 지운다.

# PVC -> PV -> 실제 NFS server:path 를 확인한 뒤 그 경로만 비운다.
PV=$(kubectl -n app-db get pvc storage-app-mariadb-2 -o jsonpath='{.spec.volumeName}')
kubectl get pv "$PV" -o jsonpath='{.spec.nfs.server}:{.spec.nfs.path}{"\n"}'
# 예: nfs-01:/exports/app-db/storage-app-mariadb-2
 
DIR=/exports/app-db/storage-app-mariadb-2
ls -la "$DIR"                     # 대상 디렉토리 맞는지 먼저 확인
find "$DIR" -mindepth 1 -delete   # 디렉토리는 두고 내부만 제거
ls -la "$DIR"                     # 비었는지 확인

4. 재기동과 SST 재동기화

빈 datadir로 Pod를 다시 띄우고 operator를 깨우면, operator와 Galera가 알아서 빈 노드에 SST를 태운다. 정상 노드가 donor가 되어 datadir 전체를 넘겨준다.

# 3) StatefulSet 복원 -> 빈 datadir 로 Pod 재생성
kubectl -n app-db scale statefulset app-mariadb --replicas=3
 
# 4) reconcile 재개 -> operator/Galera 가 빈 노드에 SST 수행
kubectl -n app-db patch mariadb app-mariadb --type merge -p '{"spec":{"suspend":false}}'

여기까지가 “한 노드만 깨졌고, 나머지가 멀쩡할 때”의 정석이다. 손댈 게 별로 없다. 진짜 성가신 건 여러 노드가 동시에 깨졌을 때다.

5. 여러 노드가 깨졌을 때: grastate와 safe_to_bootstrap

전에 노드들이 한꺼번에 비정상 종료돼 split-brain이 난 적이 있다. 그때 Pod들 상태가 이랬다.

  • Pod-0: CrashLoopBackOff — 원래 클러스터 UUID를 쥔 채 부트스트랩 거부
  • Pod-1: Running이지만 혼자 새 UUID로 떨어져 나가 단독 클러스터를 형성
  • Pod-2: InnoDB 데이터 파일 손상으로 시작 불가

Pod-0 로그의 핵심은 이거였다.

[ERROR] WSREP: It may not be safe to bootstrap the cluster from this node.
It was not the last one to leave the cluster and may not contain all the updates.
Found saved state: <cluster-uuid-A>:-1, safe_to_bootstrap: 0

Galera는 각 노드의 datadir에 grastate.dat를 두고, 여기 safe_to_bootstrap 플래그로 “이 노드가 클러스터에서 마지막까지 남아 있어 최신 데이터를 보장하는가”를 기록한다. 비정상 종료로 이게 전부 0이 되면 어느 노드도 스스로 클러스터를 부트스트랩하지 못한다. 데이터가 가장 최신인(또는 physical backup이 복원된) 노드를 골라 이 값을 1로 바꿔줘야 한다.

# 부트스트랩 기준으로 삼을 노드의 datadir 에서 safe_to_bootstrap 을 1 로 바꾼다.
# (임시 busybox Pod 로 해당 PVC 를 /data 에 마운트한 상태)
sed -i 's/safe_to_bootstrap: 0/safe_to_bootstrap: 1/g' /data/grastate.dat
cat /data/grastate.dat  # 확인

여기서 배운 게 하나 있다. Pod가 재시작되면 이 값이 다시 0으로 돌아간다. 그래서 operator를 suspend한 채로 이 노드만 replicas=1로 단독 부트스트랩하고, 나머지 노드는 datadir을 비워 SST로 다시 받게 한 뒤, 하나씩 붙이며 순서대로 확장했다(0→1→2→3). “재수 없으면 grastate를 몇 번 다시 고치게 된다”는 걸 몸으로 배웠다.

6. availableWhenDonor와 startupProbe

여기서 두 번 발목을 잡혔다. 둘 다 3노드 클러스터라 생기는 문제다.

첫째, availableWhenDonor. 노드를 다시 붙였는데 SST donor를 못 찾아 다들 NON-PRIMARY로 멈췄다. 원인은 이 값이 기본값 false였다는 것. donor로 동작하는 노드는 SST 중 트래픽을 받지 않는다는 뜻인데, 3노드에서 두 노드가 동시에 SST를 받으려 하면 donor 하나가 빠지는 순간 과반이 깨진다. 큰 클러스터라면 donor 보호에 맞는 기본값이지만, 3노드에선 켜줘야 SST가 돈다.

# 3노드 클러스터에서 SST 가 성립하도록 donor 도 서비스 가능하게 둔다.
kubectl -n app-db patch mariadb app-mariadb \
  --type=merge -p '{"spec":{"galera":{"availableWhenDonor":true}}}'

둘째, startupProbe timeout. SST는 datadir을 통째로 넘기는 작업이라 데이터가 크면 오래 걸린다. 한 케이스에서 SST가 17분 걸렸는데, startupProbe의 failureThreshold가 5분치밖에 안 돼서 SST가 끝나기 전에 Pod가 재시작됐다. 그러면 SST가 중단되고, 다시 시작하고, 또 timeout 나고… 무한 반복이다. 특히 NFS 위에서는 초기화 파일 생성부터 로컬 디스크보다 훨씬 느려서 여유를 넉넉히 잡아야 한다.

# SST 소요 시간보다 훨씬 길게 startupProbe 여유를 준다 (200 * 30s = 100분).
kubectl -n app-db patch mariadb app-mariadb --type=merge \
  -p '{"spec":{"podTemplate":{"spec":{"containers":[{"name":"mariadb","startupProbe":{"failureThreshold":200,"periodSeconds":30}}]}}}}'

SST 실측치의 5배쯤을 잡아뒀다. 과해 보여도, timeout 한 번이면 처음부터 다시라 여유가 곧 안전이다.

✅ 확인

Pod가 다 올라오면 클러스터 크기와 CR 상태를 본다.

kubectl -n app-db get pod -l app.kubernetes.io/instance=app-mariadb
kubectl -n app-db get mariadb app-mariadb \
  -o jsonpath='Ready={.status.conditions[?(@.type=="Ready")].status} GaleraReady={.status.conditions[?(@.type=="GaleraReady")].status}{"\n"}'
kubectl -n app-db exec app-mariadb-0 -c mariadb -- \
  mariadb --defaults-extra-file=/var/lib/mysql/.my-healthcheck.cnf -N \
  -e "SHOW STATUS LIKE 'wsrep_cluster_size'"

wsrep_cluster_size=3, Ready/GaleraReady=True면 끝이다. 앞서 신경 쓰이던 agent의 Galera not ready 로그도 그제서야 멈춘다.

다만 근본 트리거는 이 복구로 못 막는다는 게 남는다. NFS 서버 재기동은 그 위의 데이터 레이어 전체에 동시에 영향을 준다. 노드 하나 되살리는 것과, NFS를 건드릴 때 영향 범위를 함께 보는 건 다른 얘기다. 그래서 재발 방지 쪽으로는 availableWhenDonor: true와 넉넉한 startupProbe를 CR 표준값으로 박고, wsrep_cluster_size 알림을 걸어두는 선에서 정리했다. 이 절차 자체도 문서로 남겨 다음 사람(아마 미래의 나)이 당황하지 않게 해뒀다.

IMPORTANT

여러 노드가 동시에 깨졌거나 어느 노드가 최신인지 불확실하면, 위 단순 절차로 함부로 datadir을 비우면 안 된다. grastate.dat의 seqno를 비교해 부트스트랩 노드를 먼저 정하는 정식 Galera 복구 흐름을 따른다. “정상 노드가 Primary/Synced인가”라는 게이트가 통과되지 않으면, 그건 이미 다른 종류의 사고다.

🔗 참고