Troubleshooting 2026.09.14 9 min read

ARM에서 MySQL Operator를 배포하며 이미지와 재조정 동작을 확인한 과정

MySQL 8.4.11을 ARM 노드 세 곳에 배치하면서 이미지 태그, 버전 파서, Operator의 재조정 동작을 확인했다. 배포가 된 뒤에도 장애 복구와 버전 변경은 따로 검증해야 했다.

Ghost DB를 MySQL 3개와 Router 2개로 구성하면서 ARM 노드를 사용했다. 저장 공간이나 vCPU도 검토했지만, 배포 전에 먼저 걸린 부분은 이미지였다. 선택한 태그에 ARM64 이미지가 실제로 들어 있는지 먼저 확인해야 했다.

이번에 확인한 조합은 MySQL Operator Helm chart 2.3.0, Operator 26.7.0-2.3.0, MySQL 8.4.11, Router 8.4.10이다. 이 글의 이미지 생성 및 재조정 동작은 해당 버전을 기준으로 확인했다.

버전 필드에 ARM 태그를 넣을 수 없었다

Oracle 레지스트리에서 사용할 태그의 manifest와 architecture를 직접 확인했다. 이번 버전의 일반 태그는 AMD64 이미지였고, ARM 이미지는 -aarch64가 붙은 별도 태그로 제공됐다. Docker Hub의 mysql:8.4.11은 멀티 아키텍처 인덱스였지만, Operator가 기본적으로 선택하는 Oracle 이미지와는 별개였다.

구성 요소선택한 ARM 태그
Server8.4.11-aarch64
Router8.4.10-aarch64
Operator26.7.0-2.3.0-aarch64

그렇다고 InnoDBCluster의 spec.version8.4.11-aarch64로 적으면 되는 것도 아니었다. CRD의 문자열 검사는 접미사를 허용했지만, 실제 Operator의 Python 버전 파서는 이를 처리하지 못했다. 스키마 검사는 통과했지만 컨트롤러의 버전 처리에서 막혔다.

버전 필드는 8.4.11처럼 숫자로 유지했다. ARM 태그와 digest는 Pod 템플릿의 이미지 재정의에 넣었다. MySQL 본체만 바꾸지 않고 초기화 컨테이너, sidecar, Operator, Router까지 실제 생성되는 이미지를 모두 확인했다. 스케줄링 대상도 ARM64 노드로 제한했다.

아래는 실제 설정에서 Server와 Router 이미지 지정 부분만 발췌한 것이다. 그대로 적용할 수 있는 전체 리소스는 아니다. 실제 배포에는 세 init container, sidecar, Operator 이미지와 배치 조건, 볼륨, 인증 참조도 함께 들어갔다.

spec:
  version: "8.4.11"
  podSpec:
    nodeSelector:
      kubernetes.io/arch: arm64
    containers:
      - name: mysql
        image: container-registry.oracle.com/mysql/community-server:8.4.11-aarch64@sha256:1bbac3194635335e31fcadc1640afd2193a5ba036064f3e698583839f1d50261
  router:
    version: "8.4.10"
    podSpec:
      containers:
        - name: router
          image: container-registry.oracle.com/mysql/community-router:8.4.10-aarch64@sha256:4f1a479d94b973ce464b1a84d434106d4a57d367e53a641dd10ace2693c23cb1

설치 직후의 Pod만 확인하고 끝낼 수 없었다

Operator가 만든 리소스를 직접 수정하면 다음 재조정 때 원래 값으로 돌아갈 수 있다. ARM 이미지로 한 번 기동한 것만으로 장애 복구 후에도 같은 이미지가 유지된다고 판단하지 않았다.

사용한 Operator 이미지의 digest를 고정하고, 그 이미지에 포함된 Python 소스를 꺼내 확인했다. 최신 GitHub 브랜치가 설치한 바이너리와 같다고 가정하지 않았다. 실제 렌더링 함수를 실행해 DB와 Router 템플릿을 만들고, 이미지 변경 함수가 어느 필드 변경에서 호출되는지도 확인했다.

해당 버전에서는 일반적인 컨테이너 재시작, Pod 재생성, 멤버 재합류, Operator 재시작이 이미지 변경 경로와 분리돼 있었다. 반면 버전이나 이미지 저장소, 이미지 관련 필드, Router 버전을 바꾸는 경로는 기본 이름으로 이미지를 다시 만들 수 있었다. 이 경우 ARM 재정의가 덮어써질 수 있다.

또 하나는 리소스 조정이었다. 이 버전의 Operator는 임의의 router.podSpec 수정을 기존 Deployment에 자동 반영하지 않았다. Router의 CPU request를 100m에서 50m으로 조정할 때 CR과 현재 Deployment 템플릿을 함께 맞췄다. CR은 앞으로 다시 생성될 리소스의 기준으로 남기고, 실행 중인 템플릿도 같은지 확인했다.

스키마 검사와 실제 생성 결과를 따로 봤다

아래 리소스 이름, namespace, 파일명은 예시다. 실제 작업에서는 공식 chart를 내려받아 해시를 대조하고, ARM 이미지 재정의가 들어간 설정을 렌더링한 뒤 서버 측 dry-run을 했다.

# 파일 예시: 검토한 chart와 ARM용 values를 사용한다.
sha256sum mysql-operator-2.3.0.tgz
helm template operator-example ./mysql-operator-2.3.0.tgz \
  --namespace database-system \
  --values operator-arm-values.yaml \
  --set disableLookups=true > operator-rendered.yaml

kubectl apply --dry-run=server -f innodbcluster-arm-example.yaml

disableLookups=true는 오프라인 렌더링에만 사용했다. 이 값을 실제 설치 명령에 그대로 넣으면 chart의 설치 충돌 확인을 건너뛰므로 설치 설정에는 넣지 않았다. dry-run 성공 뒤에도 버전 파서와 이미지 생성 함수는 해당 Operator 소스로 별도 확인했다.

NS='blog-example'
CLUSTER='db-example'
kubectl -n "$NS" get innodbcluster "$CLUSTER"
kubectl -n "$NS" get pods \
  -l "mysql.oracle.com/cluster=$CLUSTER" -o json | \
  jq -r '.items[] | .metadata.name as $pod |
    ((.spec.initContainers // []) + .spec.containers)[] |
    [$pod, .name, .image] | @tsv'

본체 컨테이너만 출력하지 않고 init container까지 포함했다. 배포 전 이미지 목록과 실제 Pod의 태그·digest가 같은지 대조했다. Ready 상태여도 어느 컨테이너 하나가 의도한 이미지와 다르면 검증 통과로 처리하지 않았다.

빈 클러스터에서 복구 동작을 먼저 시험했다

운영 Ghost 데이터를 넣기 전에 별도 테스트 스키마를 만들었다. Operator 재시작 후 이미지 digest를 비교했고, Primary Pod를 정상 삭제해 새 Primary 선출과 기존 PVC를 사용한 멤버 재합류를 확인했다. 두 경우 모두 ARM 이미지가 유지됐다.

다음에는 현재 Primary의 컨테이너와 프로세스를 확인한 뒤 MySQL 프로세스 하나만 강제 종료했다. Router를 통해 재접속하며 쓰기를 시도한 클라이언트에서, 새 Primary의 첫 성공 응답은 종료 시점으로부터 약 26.6초 뒤에 나왔다. 전체 멤버가 ONLINE으로 돌아온 것은 약 103초 뒤에 관찰했다.

총 116회 시도 중 성공 응답을 받은 86건은 세 DB에 동일하게 남아 있었다. 실패한 시도는 30건이었다. 이 수치는 한 번의 프로세스 장애 시험 결과다. 노드 전체 장애나 네트워크 분할을 시험한 것은 아니며, Ghost의 기존 DB 연결이 같은 시간 안에 복구된다는 보장도 아니다. Router가 이미 실패한 트랜잭션을 대신 재실행해 주는 것으로 보지도 않았다.

이후 업그레이드한 Ghost DB를 논리 덤프로 가져왔다. 기존 DB는 AMD64, 새 DB는 ARM64여서 서로 다른 아키텍처 사이의 Clone 복사를 사용하지 않았다. MySQL의 원격 Clone 조건도 이 부분을 별도로 제한한다.

재시작 전후에 비교한 값

시험은 운영 데이터를 넣기 전의 빈 클러스터에서만 했다. 아래 명령의 이름은 예시이고, PRIMARY_POD는 SQL로 현재 Primary를 확인한 뒤 지정했다. 정상 Pod 교체 명령을 운영 중 반복 실행하는 절차로 작성한 것은 아니다.

# 격리된 시험 환경의 Operator만 재시작한다.
kubectl -n database-system rollout restart deployment/operator-example
kubectl -n database-system rollout status deployment/operator-example \
  --timeout=120s

# 현재 Primary를 확인한 뒤 정상 종료를 통한 교체를 시험한다.
PRIMARY_POD='db-example-0'
kubectl -n "$NS" delete pod "$PRIMARY_POD"

정상 교체 전후에는 Pod UID, 서버 UUID, PVC, 이미지 digest를 함께 비교했다. Pod UID는 새로 생기고, 같은 PVC와 서버 UUID로 재합류했는지 확인했다. 아래 SQL은 각 멤버의 인증된 로컬 소켓 세션에서 확인한 값을, 공개용으로 묶어 조회하도록 정리한 예시다. 인증 정보는 명령줄에 넣지 않았다.

SELECT @@version, @@server_uuid, @@read_only, @@super_read_only,
       @@group_replication_consistency;

SELECT MEMBER_ROLE, MEMBER_STATE, COUNT(*) AS members
FROM performance_schema.replication_group_members
GROUP BY MEMBER_ROLE, MEMBER_STATE;

SELECT COUNT(*) AS worker_errors
FROM performance_schema.replication_applier_status_by_worker
WHERE LAST_ERROR_NUMBER <> 0;

SELECT id, marker FROM validation_example.markers ORDER BY id;

통과 기준은 ONLINE 3개, PRIMARY 1개, 읽기 전용 SECONDARY 2개, 복제 worker 오류 0개였다. Router에서도 쓰기 대상이 Primary인지 조회했다. 성공 응답을 받은 테스트 레코드를 세 멤버에서 비교했고, 재기동됐다는 상태 표시만으로 복제 완료를 판단하지 않았다.

프로세스 강제 종료 시험에서는 이름으로 검색해 일괄 종료하지 않았다. 현재 역할과 정확한 컨테이너 ID, Kubernetes 라벨, 실행 파일, cgroup을 확인하고 pidfd로 해당 MySQL 프로세스 하나만 종료했다. 이 대상 확인 코드 없이 사용할 수 있는 짧은 종료 명령은 여기 싣지 않았다. 쓰기 재개 시간은 별도 클라이언트가 기록한 성공 응답 시각으로 계산했다.

다음 업그레이드 때 다시 볼 항목

현재 배포는 일반적인 장애 복구에서 ARM 이미지가 유지되는 것까지 확인했다. 버전을 바꾸는 작업은 같은 검증으로 처리하지 않는다. 다음 변경에서는 새 태그의 실제 아키텍처, 렌더링된 모든 컨테이너 이미지, 버전 변경 핸들러, 교체된 Pod의 digest를 다시 확인할 예정이다.

Operator가 자체 생성하는 백업 Job도 별도의 이미지 생성 경로를 사용한다. 이번 구성에서는 그 기능을 켜지 않고, ARM에서 실제 복원을 확인한 외부 백업 절차를 유지했다. 운영 중인 리소스와 저장해 둔 배포 설정이 서로 어긋나지 않게 남기는 것까지 이번 배포에 포함했다.

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 결과 이동