[MySQL] Container MySQL Primary-Replica 셋팅
읽기와 쓰기가 같은 DB 인스턴스를 공유하면 검색 쿼리가 커넥션과 CPU를 독점하여 쓰기 트랜잭션이 타임아웃된다.
이 문서는 기존 데이터를 유지하면서 단일 MySQL 컨테이너를 Primary(쓰기 전용) - Replica(읽기 전용) 구조로 전환하는 전 과정을 다룬다.
아키텍처 개요
[Application]
│
├── Write (INSERT/UPDATE/DELETE) → [Primary :3306] 쓰기 전용
└── Read (SELECT) → [Replica :3307] 읽기 전용
[Primary]
Binlog(ROW) ──── 비동기 복제 ────→ [Replica]
max_connections=60 max_connections=100
쓰기 부하에 최적화 읽기 트래픽 집중 처리
super_read_only=ON
Primary는 모든 쓰기를 처리하고 변경 내역을 Binlog에 기록한다.
Replica는 Binlog를 실시간으로 수신해 동일한 상태를 유지하며 읽기 트래픽만 처리한다.
다이어그램에 등장한 Binlog, super_read_only 등 각 요소가 왜 필요하고 어떻게 설정하는지는 이어지는 섹션에서 하나씩 다룬다.
핵심 원리
1. log_bin / binlog_format = ROW
log_bin = mysql-bin
binlog_format = ROW
Binlog는 Primary에서 발생한 모든 데이터 변경(INSERT/UPDATE/DELETE/DDL)을 파일로 기록한다.
Replica는 이 파일을 읽어 동일한 변경을 자신에게 재실행한다. MySQL Replication 전체가 이 파일을 기반으로 동작한다.
[Primary] 쓰기 요청 → 데이터 변경 → Binlog 기록
↓
[Replica] Binlog 수신 → 동일 변경 적용
binlog_format은 변경 내역을 어떤 형태로 기록할지 결정한다.
| format | 기록 방식 |
| STATEMENT | 실행된 SQL 문장을 그대로 기록 |
| ROW | 변경된 실제 행 데이터(Before/After)를 기록 |
| MIXED | 상황에 따라 STATEMENT와 ROW를 혼용 |
ROW를 사용하는 이유: 변경된 실제 행 데이터를 기록하므로 NOW(), RAND() 같은 비결정적 함수에서도 Primary와 Replica의 결과가 항상 일치한다. STATEMENT는 동일한 SQL이라도 실행 시점에 따라 결과가 달라질 수 있어 복제 불일치가 발생한다.
2. gtid_mode = ON / enforce-gtid-consistency = ON
gtid_mode = ON
enforce-gtid-consistency = ON
GTID(Global Transaction Identifier)는 각 트랜잭션에 고유 ID를 부여하는 방식이다. 형식은 서버UUID:트랜잭션번호다.
116890f4-fb6f-11f0-ad76-fe8f92702259:1-500
이 설정 이전에는 Binlog 파일명과 오프셋을 직접 지정해야 했다. GTID를 사용하면 Replica가 "나는 500번까지 적용했으니 501번부터 보내줘"라고 자동으로 요청한다(SOURCE_AUTO_POSITION=1). Failover 시 새 Primary로 전환할 때도 위치를 수동으로 계산할 필요가 없어진다.
주의할 점: GTID 활성화 이전에 실행된 트랜잭션은 GTID가 없다.
기존 데이터가 있는 상태에서 이 설정을 켜면 그 이전 데이터는 자동 복제 대상에서 제외된다.
3. binlog_row_image = FULL
binlog_row_image = FULL
ROW 형식에서도 Binlog에 기록할 범위를 선택할 수 있다.
| 값 | 기록 범위 |
| MINIMAL | 변경된 컬럼만 기록 |
| NOBLOB | BLOB 제외 전체 |
| FULL | 변경 전/후 전체 행 기록 |
FULL을 사용하는 이유:
- Debezium 등 CDC 도구가 변경 이전 값(before image)을 캡처하려면
FULL이 필수다. - Replica가 어떤 상황에서도 대상 행을 정확히 찾아 업데이트할 수 있다.
복제 흐름
위 설정들이 실제로 어떻게 동작하는지 전체 흐름이다.
[Primary]
변경 발생 → InnoDB 적용 → Binlog(ROW) 기록
↓
Binlog Dump Thread가 전송
[Replica]
IO Thread : Primary 연결 → Binlog 수신 → Relay Log 저장
SQL Thread : Relay Log 읽기 → InnoDB 적용
설정 파일 작성
원리를 이해했다면 이제 이 설정들을 실제 파일로 작성한다.
MySQL은 /etc/mysql/conf.d/ 디렉터리에서 .cnf 또는 .ini 확장자 파일만 로드한다.
설정 파일은 .cnf 확장자로 생성한다.
Primary와 Replica 모두 CPU와 Memory는 2Core, 6GB로 설정해서 진행했다.
1. primary.cnf
[mysqld]
# ── 복제 ───────────────────────────────────────────────────────────
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL # CDC 도구 연동 시 필수
gtid_mode = ON
enforce-gtid-consistency = ON
log_replica_updates = ON # Replica가 받은 변경도 자신의 Binlog에 기록
# ── Binlog 관리 ─────────────────────────────────────────────────────
binlog_expire_logs_seconds = 604800 # 7일 후 자동 삭제 (디스크 고갈 방지)
max_binlog_size = 500M
# ── 식별 ───────────────────────────────────────────────────────────
report_host = search-db-primary
# ── 성능 튜닝 ──────────────────────────────────────────────────────
innodb_buffer_pool_size = 4G
innodb_log_file_size = 512M
thread_cache_size = 50
tmp_table_size = 256M
max_heap_table_size = 256M
max_connections = 60 # 쓰기 전용: 낮게 유지
주요 설정 해설
| 항목 | 역할 |
| server-id = 1 | 복제 토폴로지 내 고유 식별자. Primary와 Replica가 서로 다른 값이어야 복제 루프를 방지한다. |
| inlog_expire_logs_seconds = 604800 | 7일이 지난 Binlog를 자동 삭제한다. 설정하지 않으면 Binlog가 무한 누적되어 디스크가 고갈된다. |
| log_replica_updates = ON | Replica가 수신한 변경도 자신의 Binlog에 기록한다. CDC 도구가 Replica를 소스로 사용하거나 체인 복제(A→B→C) 구성에서 필요하다. |
| max_connections = 60 | Primary는 쓰기 전용이므로 낮게 유지한다. 커넥션이 많을수록 메모리 오버헤드가 커진다. |
2. replica1.cnf
[mysqld]
# ── 복제 ───────────────────────────────────────────────────────────
server-id = 2
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce-gtid-consistency = ON
log_replica_updates = ON
# ── Replica 전용 ────────────────────────────────────────────────────
# read_only / super_read_only 는 Docker 초기화와 충돌하므로 config에 두지 않는다.
# 복제 연결 후 아래 명령으로 영구 적용한다:
# SET PERSIST read_only = ON;
# SET PERSIST super_read_only = ON;
relay_log = mysql-relay-bin
replica_net_timeout = 60 # 60초 내 Primary 응답 없으면 재연결
# ── 식별 ───────────────────────────────────────────────────────────
report_host = search-db-replica
# ── 성능 튜닝 (읽기 집중 워크로드) ─────────────────────────────────
innodb_buffer_pool_size = 4G
innodb_log_file_size = 512M
thread_cache_size = 50
tmp_table_size = 256M
max_heap_table_size = 256M
max_connections = 100 # 읽기 트래픽 집중 → Primary보다 높게
Primary와 다른 항목 해설
| 항목 | 역할 |
| relay_log = mysql-relay-bin | Primary로부터 받은 Binlog를 저장하는 중간 로그. IO Thread가 쓰고, SQL Thread가 읽어 InnoDB에 적용한다. |
| replica_net_timeout = 60 | Primary와 60초 동안 통신이 없으면 IO Thread가 재연결을 시도한다. 네트워크 순단 상황에서 복제가 자동 복구된다. |
| ax_connections = 100 | 읽기 트래픽이 집중되는 Replica는 Primary보다 더 많은 동시 연결이 필요하다. |
read_only와 super_read_only를 conf에 두지 않는 이유:
Docker MySQL 컨테이너는 최초 기동 시 Temporary 서버를 띄워 root 비밀번호 설정, 사용자 생성 등 초기화 SQL을 실행한다.
이 시점에 conf에 super_read_only = ON이 있으면 Temporary 서버도 해당 설정을 적용하여 초기화 SQL이 차단된다.
결과적으로 컨테이너는 기동되지만 root 비밀번호가 설정되지 않아 접속 자체가 불가능해진다.SET PERSIST를 사용하면 초기화 완료 후 적용되므로 이 문제를 피할 수 있다.
Docker Compose 구성
설정 파일을 작성했다면 이제 이 파일들을 마운트하고 두 컨테이너의 기동 순서를 제어하는 Docker Compose를 구성한다.
볼륨 이름과 서비스 이름은 별개다
Docker 볼륨은 서비스 이름이 아닌 볼륨 이름으로 식별된다.
서비스 이름을 변경해도 볼륨 이름을 동일하게 유지하면 기존 데이터가 보존된다.
# 기존
services:
mysql-db:
volumes:
- mysql:/var/lib/mysql # 볼륨 이름: mysql
# 변경 후 (기존 데이터 보존)
services:
mysql-db-primary: # 서비스 이름 변경
volumes:
- mysql:/var/lib/mysql # 볼륨 이름이 같으므로 데이터 그대로 유지
healthcheck와 depends_on: 기동 순서 보장
depends_on만으로는 컨테이너가 "시작됨(started)" 상태인지만 확인한다.
MySQL이 실제로 요청을 받을 수 있는 상태인지는 healthcheck로 확인해야 한다.
Primary가 아직 준비 중인데 Replica가 먼저 연결을 시도하면 복제 설정이 실패한다.
mysql-db-primary:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s # 초기 기동 시간 확보
mysql-db-replica:
depends_on:
mysql-db-primary:
condition: service_healthy # Primary가 healthy 상태일 때만 기동
최종 docker-compose.yml
services:
mysql-db-primary:
image: mysql:8.0.35
container_name: mysql-db-primary
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_REPLICATION_USER: ${MYSQL_REPLICATION_USER}
MYSQL_REPLICATION_PASSWORD: ${MYSQL_REPLICATION_PASSWORD}
TZ: Asia/Seoul
ports:
- "${MYSQL_PORT}:3306"
volumes:
- mysql:/var/lib/mysql
- ./mysql/primary.cnf:/etc/mysql/conf.d/primary.cnf
- ./mysql/init:/docker-entrypoint-initdb.d
networks:
- mysql-network
mem_limit: 6g
cpus: 2.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
mysql-db-replica:
image: mysql:8.0.35
container_name: mysql-db-replica
restart: always
depends_on:
mysql-db-primary:
condition: service_healthy
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
TZ: Asia/Seoul
ports:
- "${MYSQL_REPLICA_PORT}:3306"
volumes:
- mysql-replica:/var/lib/mysql
- ./mysql/replica1.cnf:/etc/mysql/conf.d/replica1.cnf
networks:
- mysql-network
mem_limit: 6g
cpus: 2.0
volumes:
mysql: # Primary: 기존 볼륨 재사용 → 데이터 보존
driver: local
mysql-replica: # Replica: 신규 볼륨
driver: local
.env 필수 항목
MYSQL_ROOT_PASSWORD=<root 비밀번호>
MYSQL_DATABASE=<데이터베이스명>
MYSQL_USER=<애플리케이션 계정>
MYSQL_PASSWORD=<애플리케이션 비밀번호>
MYSQL_PORT=3306
MYSQL_REPLICA_PORT=3307
MYSQL_REPLICATION_USER=<복제 계정명>
MYSQL_REPLICATION_PASSWORD=<복제 비밀번호>
복제 계정 생성
Replica가 Primary에 접속해 Binlog를 읽으려면 REPLICATION SLAVE 권한을 가진 전용 계정이 필요하다.
기존 데이터가 있는 경우 주의:
Docker init 스크립트(/docker-entrypoint-initdb.d/)는 데이터 디렉터리가 비어 있을 때만 실행된다.
Primary가 기존 볼륨을 사용하면 init 스크립트가 실행되지 않으므로 계정을 직접 생성해야 한다.
docker exec -it mysql-db-primary \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "
CREATE USER IF NOT EXISTS '${MYSQL_REPLICATION_USER}'@'%'
IDENTIFIED WITH mysql_native_password
BY '${MYSQL_REPLICATION_PASSWORD}';
GRANT REPLICATION SLAVE ON *.* TO '${MYSQL_REPLICATION_USER}'@'%';
FLUSH PRIVILEGES;
"
mysql_native_password를 지정하는 이유:
MySQL 8.0의 기본 인증 방식은 caching_sha2_password다.
이 방식은 첫 연결 시 RSA 공개키 교환이 필요한데, Replica IO Thread가 공개키를 받지 못하면 연결에 실패한다.mysql_native_password는 이 문제가 없다.caching_sha2_password를 유지하려면 복제 연결 시 GET_SOURCE_PUBLIC_KEY=1 옵션을 함께 지정하면 된다.
계정 생성 확인:
docker exec -it mysql-db-primary \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "
SELECT user, host, plugin FROM mysql.user WHERE user = '${MYSQL_REPLICATION_USER}';
SHOW GRANTS FOR '${MYSQL_REPLICATION_USER}'@'%';
"
기존 데이터 동기화
이 단계가 필요한 경우:
GTID가 비활성화된 상태에서 이미 데이터가 쌓인 MySQL을 Primary-Replica 구조로 전환하는 경우다.
새로 구축하는 환경(데이터가 없거나, GTID 활성화 후 처음으로 데이터가 INSERT된 경우)이라면 이 단계를 건너뛰고 복제 연결부터 진행한다.
왜 수동 동기화가 필요한가
GTID 기반 복제는 GTID가 붙은 트랜잭션만 복제한다.
GTID 활성화 이전에 INSERT된 기존 데이터는 GTID가 없으므로, Replica를 아무리 연결해도 받지 못한다.
Primary에서 확인:
SHOW GLOBAL VARIABLES LIKE 'gtid_executed';
-- 예: 116890f4-...:1 (GTID 활성화 이후 1건만 존재)
-- 기존 수백만 건은 GTID 없음 → 복제 불가
mysqldump --set-gtid-purged=ON을 사용하면 덤프 파일에 다음 구문이 포함된다.
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '116890f4-...:1';
이 구문의 의미: "이 덤프에는 GTID :1까지의 내용이 포함되어 있으니, Replica는 :2부터 복제를 시작해라."
덤프 임포트 후 Replica의 GTID 이력이 Primary와 동기화되어 이후 트랜잭션부터 자동 복제가 이어진다.
1. 동기화 절차
# 1. Replica 상태 초기화 (임포트 전 기존 GTID 이력 제거)
# RESET MASTER는 이름과 달리 실행한 서버(여기서는 Replica) 자신의 GTID_EXECUTED/PURGED를 초기화한다.
# Primary에는 영향을 주지 않는다.
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "
STOP REPLICA;
RESET REPLICA ALL;
RESET MASTER;
"
# 2. Primary 덤프
# --single-transaction : InnoDB 스냅샷 격리, 테이블 락 없이 일관된 덤프
# --set-gtid-purged=ON : 덤프 파일에 GTID 이력 포함
# --databases : 대상 DB 지정 (--all-databases 사용 금지, 아래 주의사항 참고)
docker exec mysql-db-primary mysqldump \
-uroot -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction \
--set-gtid-purged=ON \
--databases ${MYSQL_DATABASE} \
> /tmp/primary_dump.sql
# 3. Replica에 임포트
docker exec -i mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
< /tmp/primary_dump.sql
# 4. 임포트 후 덤프 파일 삭제
rm /tmp/primary_dump.sql
--all-databases 사용 금지
mysql, information_schema, performance_schema 등 시스템 DB까지 포함된다.
Replica에 임포트하면 root 계정 정보와 권한 테이블이 덮어써져 접속 불가 상태가 될 수 있다.
반드시 --databases <DB명>으로 대상을 명시한다.
복제 연결 및 검증
데이터 동기화가 완료되었다면 이제 Replica가 Primary에 연결해 복제를 시작하도록 설정한다.
1. 복제 소스 설정 및 시작
아래 명령은 .env의 환경변수를 사용한다. 실행 전에 변수가 쉘에 설정되어 있는지 확인한다.
# .env 파일이 있는 디렉터리에서 실행
source .env
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='search-db-primary',
SOURCE_USER='${MYSQL_REPLICATION_USER}',
SOURCE_PASSWORD='${MYSQL_REPLICATION_PASSWORD}',
SOURCE_AUTO_POSITION=1,
GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;
"
| 옵션 | 역할 |
| SOURCE_AUTO_POSITION=1 | GTID 기반 자동 위치 추적. Binlog 파일명과 오프셋을 수동으로 지정할 필요가 없다. |
| GET_SOURCE_PUBLIC_KEY=1 | caching_sha2_password 인증 시 RSA 공개키를 자동 획득한다. mysql_native_password 환경에서도 명시하면 안전하다. |
2. 복제 상태 확인
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-e "SHOW REPLICA STATUS\G" 2>/dev/null
정상 상태 핵심 지표
| 항목 | 기대값 | 의미 |
| Replica_IO_Running | Yes | IO Thread가 Primary에 연결되어 Binlog 수신 중 |
| Replica_SQL_Running | Yes | SQL Thread가 Relay Log를 읽어 변경 적용 중 |
| Seconds_Behind_Source | 0 | Replica가 Primary를 완전히 따라잡은 상태 |
| Auto_Position | 1 | GTID 기반 자동 위치 추적 활성 |
| Last_IO_Error | (공백) | 결/수신 에러 없음 |
| Last_SQL_Error | (공백) | 경 적용 에러 없음 |
3. 데이터 동기화 검증
# Primary 행 수 확인
docker exec mysql-db-primary \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-D ${MYSQL_DATABASE} \
-e "SELECT COUNT(*) AS primary_count FROM <테이블명>;" 2>/dev/null
# Replica 행 수 확인
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-D ${MYSQL_DATABASE} \
-e "SELECT COUNT(*) AS replica_count FROM <테이블명>;" 2>/dev/null
보안 강화: super_read_only 적용
복제가 정상 동작하는 것을 확인한 후 Replica를 읽기 전용으로 고정한다.
1. read_only vs super_read_only
| 설정 | 차단 범위 |
| read_only = ON | 일반 사용자의 쓰기 차단. SUPER 권한 계정은 쓰기 가능. |
| super_read_only = ON | SUPER 권한 계정의 쓰기도 차단. 복제 스레드는 예외. |
super_read_only를 켜면 read_only도 자동으로 활성화된다.
2. SET PERSIST로 영구 적용
SET PERSIST는 MySQL 8.0에서 도입된 기능이다.
변수를 현재 세션에 즉시 적용하면서 동시에 데이터 디렉터리 내 mysqld-auto.cnf에 영구 저장한다.
컨테이너가 재시작되어도 MySQL이 mysqld-auto.cnf를 자동으로 읽어 적용한다.
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" -e "
SET PERSIST read_only = ON;
SET PERSIST super_read_only = ON;
"
적용 확인:
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-e "SHOW VARIABLES LIKE '%read_only%';" 2>/dev/null
운영 모니터링
구축이 완료되었다. 이후에는 복제가 정상적으로 유지되고 있는지 주기적으로 확인해야 한다.
복제 지연 모니터링
Seconds_Behind_Source는 Replica의 SQL Thread가 Primary보다 얼마나 뒤처져 있는지를 초 단위로 나타낸다.
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-e "SHOW REPLICA STATUS\G" 2>/dev/null \
| grep -E "IO_Running|SQL_Running|Seconds_Behind|Last_Error"
지연 상태별 원인과 대응
| 상황 | Seconds_Behind_Source | 원인 | 대응 |
| 정상 | 0 | — | — |
| 일시 지연 | 1~수십 | 대량 쓰기 후 따라잡는 중 | 자연 해소 대기 |
| 지속 지연 | 수백 이상 | Replica 처리 성능 부족 | 병렬 복제 설정 검토 |
| NULL | — | IO/SQL Thread 중단 | Last_Error 확인 후 원인 해결 |
Primary에서 연결된 Replica 확인
docker exec mysql-db-primary \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-e "SHOW REPLICAS;" 2>/dev/null
재시작 후 확인
복제 스레드는 컨테이너 재시작 시 자동으로 시작된다. 상태만 확인하면 된다.
docker compose restart mysql-db-replica
sleep 10
docker exec mysql-db-replica \
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" \
-e "SHOW REPLICA STATUS\G" 2>/dev/null \
| grep -E "IO_Running|SQL_Running|Seconds_Behind"