Migrating Standalone MariaDB to a Galera Cluster on Kubernetes

개요

MariaDB Galera 전환 작업을 진행했습니다. 그동안 단일 인스턴스(단일 replica + ReadWriteOnce 볼륨) 구조로 MariaDB를 운영해왔는데, Kubernetes 클러스터 업그레이드 도중 MariaDB가 떠 있는 워커 노드를 drain할 때마다 2~3분 정도 서비스가 끊기는 문제가 반복됐습니다. 단일 replica + RWO 볼륨 조합에서는 drain 시 기존 노드에서 볼륨이 detach되고 새 노드에 attach되는 동안의 지연을 구조적으로 피할 수 없었습니다.

이 글에서는 이 문제를 해결하기 위해 mariadb-operator 기반 3노드 Galera 클러스터로 전환한 전체 과정 — 사전 조사, 클러스터 구성, 데이터 마이그레이션, 최종 트래픽 전환(cutover), 그리고 그 과정에서 만난 트러블슈팅을 정리합니다.

환경

  • Kubernetes 클러스터 (Control-plane 3대, Worker 3대)
  • Storage: Rook-Ceph RBD (StorageClass, ReadWriteOnce)
  • 기존: MariaDB 10.11 단일 인스턴스(Deployment, replica 1)
  • 전환 후: MariaDB 10.11 Galera 3노드 (mariadb-operator 관리, StatefulSet)
  • 데이터 규모: 2개 DB, 총 143개 테이블, 약 2.3GB

단계별 절차

1. 전환 배경 조사

워커 drain 시 실제 단절 시간을 측정해보니, MariaDB가 떠 있는 노드를 drain할 때마다 매번 2~3분씩 서비스가 끊기는 것으로 확인됐습니다. 원인은 단일 replica라 drain되는 순간 파드가 다른 노드로 옮겨가야 하는데, RWO(ReadWriteOnce) 블록 볼륨이라 기존 노드에서 detach가 완료되어야 새 노드에서 attach가 시작되기 때문이었습니다. 이 대기 시간에 InnoDB 복구 시간까지 더해지면서 단절이 발생했습니다.

해결 방법을 두 갈래로 검토했습니다.

  • A안 — cordon-only 절차: drain 대신 cordon만 하고 kubelet을 재기동하는 방식. 컨테이너 런타임이 kubelet 재기동으로 기존 컨테이너를 죽이지 않는다는 점을 이용해, 계획된 업그레이드에 한해서는 단절을 0초로 만들 수 있음. 다만 노드 장애 등 예기치 못한 상황에는 도움이 안 됨
  • B안 — Galera 3노드 전환: 계획된 업그레이드는 물론 노드 장애 시에도 무중단이 되는 진짜 HA 구조

적합성을 미리 조사해보니, 전체 테이블이 InnoDB로 전환 가능했고(예외 1개, 아래에서 다룸), 쓰기 부하도 초당 1~2건 수준으로 매우 낮아 Galera의 동기 복제 오버헤드가 문제 될 수준이 아니었습니다. 워커 노드들의 메모리 여유도 충분해 3노드 확장에 무리가 없다고 판단해 B안으로 진행했습니다.

2. mariadb-operator 설치

OSS mariadb-operator는 Galera 클러스터의 부트스트랩·장애 감지·자동 복구를 대신 처리해주고, 무엇보다 현재 Primary 파드로만 트래픽을 라우팅하는 Service(primaryService)를 자체 제공합니다. Galera는 원래 멀티 마스터 구조라 여러 파드로 쓰기가 분산되면 오히려 인증(certification) 충돌 위험이 있는데, 이 기능 덕분에 별도의 쓰기 라우팅 설계나 MaxScale 도입 없이 “쓰기는 항상 Primary로” 요구사항을 해결할 수 있었습니다.

helm install mariadb-operator-crds oci://ghcr.io/mariadb-operator/charts/mariadb-operator-crds --version 26.6.0 -n mariadb-operator --create-namespace
helm install mariadb-operator oci://ghcr.io/mariadb-operator/charts/mariadb-operator --version 26.6.0 -n mariadb-operator

operator는 DB 워크로드와 별도 네임스페이스에 설치했습니다. 이미 이 클러스터에서 Ingress 컨트롤러, LoadBalancer 컨트롤러 등을 “컨트롤러는 자기 네임스페이스, 워크로드는 자기 네임스페이스”로 분리해온 컨벤션과 동일하며, operator를 재설치/업그레이드할 때 워크로드에 영향을 줄 위험을 줄여줍니다.

3. Galera 클러스터 구성 설계

CRD로 3노드 Galera 클러스터를 선언했습니다. 실제 사용량(메모리 약 1.4Gi)과 여유분을 고려해 request/limit을 산정했고, 워커 3대 = replica 3개인 구조라 topologySpreadConstraints로 반드시 노드당 1개씩 배치되도록 강제했습니다(그래야 노드 장애 내성이 실제로 성립합니다).

apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
  name: mariadb-galera
spec:
  image: mariadb:10.11
  replicas: 3
  galera:
    enabled: true
    sst: mariabackup
  storage:
    size: 10Gi
    storageClassName: rook-ceph-block
  resources:
    requests: { cpu: 250m, memory: 1Gi }
    limits: { cpu: "1", memory: 2Gi }
  primaryService:
    type: LoadBalancer
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app.kubernetes.io/instance: mariadb-galera

4. 데이터 이관 준비

Galera는 InnoDB 테이블만 복제합니다. 기존 DB를 점검해보니 전체 143개 테이블 중 보안 플러그인이 쓰는 캐시 테이블 1개만 MEMORY 엔진이었고, 그마저도 항상 빈 테이블이라 InnoDB로 즉시 전환했습니다.

계정/권한 이관은 평문 비밀번호를 몰라도 되는 방법을 썼습니다. SHOW GRANTS FOR 'user'@'%'로 확인되는 IDENTIFIED BY PASSWORD '*HASH' 값을 그대로 새 클러스터에 CREATE USER ... IDENTIFIED BY PASSWORD '*HASH'로 재생성한 것입니다. 외부 애플리케이션이 쓰는 계정처럼 평문 비밀번호를 알 수 없는 계정도 문제없이 동일하게 이관할 수 있었습니다.

5. 데이터 마이그레이션

논리 덤프(mysqldump)로 실제 데이터를 옮겼습니다. 이 과정에서 트러블슈팅이 하나 있었는데, 아래 트러블슈팅 섹션에서 자세히 다룹니다. 최종적으로는 클러스터 내부에서 실행되는 일회성 Job으로 소스와 타겟 DB를 직접 연결하는 방식으로 안정적으로 완료했습니다.

6. 최종 컷오버

실제 트래픽 전환 직전에는, 쓰기 중인 애플리케이션을 짧게 멈추고 최종 재동기화를 한 번 더 돌려 모든 테이블에서 정확히 0건 차이임을 확인한 뒤 진행했습니다. 외부에서 접속하는 클라이언트가 있었기 때문에, 기존에 쓰던 LoadBalancer IP를 그대로 새 클러스터의 primaryService로 이전해서 그 클라이언트 쪽 설정은 전혀 바꿀 필요가 없도록 했습니다. 클러스터 내부에서 DNS 이름으로 접속하던 애플리케이션(WordPress 등)만 새 Service 이름으로 연결 문자열을 바꿔주면 됐습니다.

트러블슈팅

Galera 설정 볼륨이 StorageClass 미지정으로 Pending

현상: 클러스터를 처음 생성했을 때 3개 파드 모두 Pending 상태로 멈췄습니다. 이벤트를 보니 pod has unbound immediate PersistentVolumeClaims, PVC 쪽에는 no persistent volumes available for this claim and no storage class is set 에러가 나 있었습니다.

원인: 데이터 볼륨 외에 Galera 설정 파일 전용으로 별도 PVC가 하나 더 자동 생성되는데, 이 PVC 템플릿에는 StorageClass가 지정되지 않았고, 클러스터에 기본(default) StorageClass도 지정되어 있지 않아 바인딩될 곳이 없었습니다.

해결: 설정 파일용 볼륨을 따로 만들지 않고 데이터 볼륨을 재사용하도록 옵션을 켰습니다. 다만 이 옵션은 리소스 생성 후에는 변경 불가능한(immutable) 필드라, 처음부터 이 옵션을 켠 상태로 리소스를 삭제 후 재생성해야 했습니다.

  galera:
    enabled: true
    config:
      reuseStorageVolume: true   # 생성 후 변경 불가 - 처음부터 켜야 함

대용량 마이그레이션 중 kubectl exec 스트림 단절

현상: 소스 파드에서 mysqldump를 실행해 그 표준출력을 타겟 파드의 mysql 클라이언트로 바로 파이프 연결하는 방식으로 마이그레이션을 시도했는데, 수백만 줄이 넘어간 시점에 unexpected EOF로 스트림이 끊겼습니다. 타겟 쪽에서는 끊긴 지점의 잘린 SQL 구문 때문에 문법 오류가 발생했습니다.

원인: 두 파드를 각각 별도의 exec 세션으로 열어 로컬 셸의 파이프로 연결하는 방식이라, 데이터가 API 서버를 두 번 거치는 이중 스트리밍 구조가 됩니다. 데이터량이 크고 전송 시간이 길어질수록 이 경로가 끊길 가능성이 커집니다.

해결: 클러스터 내부에서 직접 실행되는 일회성 Job으로 전환했습니다. Job 안에서 소스/타겟 Service의 DNS 이름으로 직접 연결해 mysqldump | mysql을 실행하니, API 서버를 거치지 않고 클러스터 네트워크 안에서 바로 전송되어 안정적으로 완료됐습니다.

apiVersion: batch/v1
kind: Job
metadata:
  name: mariadb-galera-migrate
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: mariadb:10.11
          command: ["sh", "-c"]
          args:
            - |
              mysqldump -h  -uroot -p"$SRC_PW" \
                --databases db1 db2 --single-transaction --routines --triggers --events \
              | mysql -h  -uroot -p"$DST_PW"

결과 확인

전환 후 다음 항목들을 확인했습니다.

  • 마이그레이션 후 모든 테이블에서 정확히 0건 차이 검증 완료
  • 4일 이상 무재시작·무단절 운영 확인 (Galera 3노드 전부 Synced)
  • 기존 인스턴스는 행 수가 더 이상 늘지 않음(트래픽 완전히 이전) vs 신규 클러스터는 계속 증가 — 컷오버가 실제로 적용됐음을 재확인
  • 관찰 기간 이후 기존 단일 인스턴스는 설정을 문서로 백업한 뒤 정리(삭제)

단일 인스턴스 대비 이번 MariaDB Galera 전환으로, 계획된 업그레이드는 물론 예기치 못한 노드 장애 상황에서도 서비스 단절 없이 운영할 수 있는 구조를 갖추게 됐습니다.

참고

관련 포스트:

참고 문서: mariadb-operator (GitHub) · Galera Cluster Documentation

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.