목차
- 왜 gh-ost인가 — 대용량 테이블 ALTER의 문제
- 실습 환경 구성 (Docker Compose)
- 500만 건 데이터 시딩
- 부하 속 일반 ALTER TABLE — INSTANT vs COPY
- gh-ost로 같은 변경 무중단 수행
- 스로틀링과 인터랙티브 제어
- cut-over — 최종 테이블 교체의 순간
- 결과 비교와 결론
1. 왜 gh-ost인가 — 대용량 테이블 ALTER의 문제
운영 중인 서비스의 수백만~수억 건 테이블에 ALTER TABLE을 실행하면 어떤 일이 벌어질까?
MySQL 8.0은 상당수 DDL을 INSTANT/INPLACE로 처리하지만, 컬럼 타입 변경처럼 COPY 알고리즘이 강제되는 변경은 여전히 테이블 전체를 복사하며 쓰기를 막는다. gh-ost는 binlog 기반으로 원본 테이블을 건드리지 않고 ghost 테이블에 복사한 뒤 원자적으로 교체해 이 문제를 해결한다.
이 글은 로컬 Docker 환경에서 500만 건 테이블을 놓고 두 방식을 직접 비교한 실습 기록이다. 모든 수치는 실측값이다.
2. 실습 환경 구성 (Docker Compose)
최종 실습 트리 구조
mysq-gh-ost-poc/
├── docker-compose.yml # 전체 5개 서비스 (mysql·seeder·api·loadgen·ghost)
│
├── mysql/
│ ├── conf.d/
│ │ └── my.cnf # binlog ROW/FULL, server-id — gh-ost 필수 설정
│ └── init/
│ ├── 01-schema.sql # shop DB: users, orders 테이블
│ └── 02-users.sql # app/ghost 유저 + 권한 (performance_schema 포함)
│
├── seeder/ # Step 1 — 데이터 시딩
│ ├── Dockerfile
│ └── seed.py # users 10만 + orders 500만 (48.7초 실측)
│
├── api/ # Step 2 — 웹서비스
│ ├── Dockerfile
│ ├── main.py # FastAPI + 커넥션 풀 10, elapsed_ms 포함
│ └── requirements.txt
│
├── loadgen/ # Step 2 — 부하생성기
│ ├── Dockerfile
│ └── loadgen.py # ~30 rps, 1초 단위 컬러 통계
│
├── ghost/ # Step 4 — gh-ost 실행 컨테이너
│ └── Dockerfile # v1.1.10, amd64/arm64 자동 분기
│
├── ghost-flags/ # throttle.flag / postpone.flag / 소켓 (런타임 공유, 평소 비어있음)
│
├── scripts/ # gh-ost 명령 (전 옵션 한글 주석)
│ ├── ghost-dry-run.sh # 검증만 (--execute 없음)
│ ├── ghost-execute.sh # 실제 마이그레이션
│ └── ghost-postpone-cutover.sh # 스로틀·cut-over 수동 제어 실습용
│
├── README.md # 시나리오 0~4 명령 + 실측 + 트러블슈팅 + 뒷정리
├── roadmap.html # 6단계 진행 로드맵 (전체 완료)
├── blog.html # 실습편 블로그 글 (코드·실측 포함)
└── gh-ost.html # 딥다이브편 — 내부 구조·정합성·cut-over·대안 도구
전체 환경은 Docker Compose 구성했다. 다음은 gh-ost가 요구하는 MySQL binlog 설정이다. gh-ost는 트리거 대신 binlog를 구독해 원본 테이블의 변경을 따라잡기 때문에, ROW 포맷과 FULL row image가 필수다.
MySQL 설정 (mysql/conf.d/my.cnf)
[mysqld]
# ===== gh-ost 동작 필수 설정 =====
server-id=1 # binlog 읽기에 필요한 서버 식별자
log_bin=mysql-bin # binary log 활성화
binlog_format=ROW # gh-ost는 ROW 포맷 필수
binlog_row_image=FULL # 행 전체 이미지 기록 (gh-ost 필수)
# ===== binlog 디스크 보호 =====
binlog_expire_logs_seconds=86400 # 1일 지난 binlog 자동 삭제
max_binlog_size=256M
# ===== 실습용 속도 최적화 (운영 설정 아님!) =====
innodb_buffer_pool_size=1G
innodb_flush_log_at_trx_commit=2 # 커밋마다 fsync 안 함
sync_binlog=0 # binlog fsync 생략
max_connections=300
default_authentication_plugin=mysql_native_password
컨테이너 구성 — docker-compose.yml (전체)
컨테이너는 5개다: mysql(대상 DB), seeder(500만 건 시딩), api(FastAPI 서비스), loadgen(부하생성기), ghost(gh-ost 실행용).
services:
mysql:
image: mysql:8.0
container_name: ghost-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shop
ports:
- "3306:3306"
volumes:
- ./mysql/conf.d:/etc/mysql/conf.d
- ./mysql/init:/docker-entrypoint-initdb.d
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
timeout: 3s
retries: 30
seeder:
build: ./seeder
profiles: ["seed"] # `docker compose up` 때 자동 실행 방지
environment:
DB_HOST: mysql
DB_USER: root
DB_PASSWORD: root
DB_NAME: shop
USERS_TARGET: "100000"
ORDERS_TARGET: "5000000"
depends_on:
mysql:
condition: service_healthy
api:
build: ./api
container_name: ghost-api
ports:
- "8000:8000"
environment:
DB_HOST: mysql
DB_USER: app
DB_PASSWORD: app_pw
DB_NAME: shop
depends_on:
mysql:
condition: service_healthy
loadgen:
build: ./loadgen
container_name: ghost-loadgen
profiles: ["load"] # `docker compose up` 때 자동 실행 방지
environment:
API_URL: http://api:8000
RPS: "30"
WRITE_RATIO: "0.3"
depends_on:
- api
ghost:
build: ./ghost
container_name: ghost-runner
volumes:
- ./ghost-flags:/tmp/ghost # 플래그 파일·소켓 공유 (호스트에서 touch/rm으로 제어)
- ./scripts:/scripts
depends_on:
mysql:
condition: service_healthy
volumes:
mysql-data:
gh-ost 컨테이너 (ghost/Dockerfile)
FROM debian:bookworm-slim
ARG TARGETARCH # buildx가 자동 주입 (Apple Silicon → arm64)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# 최신 릴리스에서 아키텍처에 맞는 gh-ost 바이너리 다운로드
RUN curl -fsSL https://api.github.com/repos/github/gh-ost/releases/latest \
| grep browser_download_url \
| grep "binary-linux-${TARGETARCH}" \
| cut -d '"' -f 4 \
| xargs curl -fsSL \
| tar xz -C /usr/local/bin \
&& gh-ost --version
# 상주 컨테이너 — 실행은 docker compose exec ghost ... 로
CMD ["sleep", "infinity"]
스키마 (mysql/init/01-schema.sql)
USE shop;
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
product_name VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_user_id (user_id),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
gh-ost 전용 유저 권한 (mysql/init/02-users.sql)
gh-ost는 ghost 테이블 생성(CREATE/ALTER/DROP)과 binlog 구독(REPLICATION SLAVE/CLIENT) 권한이 필요하다. 단일 인스턴스(master 직접 실행)에서는 SUPER도 필요하다. 마지막 두 줄의 performance_schema 권한은 gh-ost 1.1.10부터 필수다 — cut-over 전에 metadata lock을 감시하는 기능이 추가되어, 이 권한이 없으면 bailing out because metadata lock instrument not enabled 에러로 시작조차 못 한다.
-- 애플리케이션 유저 (API/부하생성기용)
CREATE USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY 'app_pw';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app'@'%';
-- gh-ost 전용 유저
CREATE USER 'ghost'@'%' IDENTIFIED WITH mysql_native_password BY 'ghost_pw';
GRANT ALTER, CREATE, DELETE, DROP, INDEX, INSERT, LOCK TABLES, SELECT, TRIGGER, UPDATE
ON shop.* TO 'ghost'@'%';
GRANT REPLICATION CLIENT, REPLICATION SLAVE ON *.* TO 'ghost'@'%';
-- 단일 인스턴스(master에 직접 실행)이므로 SUPER 필요
GRANT SUPER ON *.* TO 'ghost'@'%';
FLUSH PRIVILEGES;
-- gh-ost 1.1.10+ metadata lock 체크용 (cut-over 시 MDL 감시)
GRANT SELECT ON performance_schema.* TO 'ghost'@'%';
GRANT UPDATE ON performance_schema.setup_instruments TO 'ghost'@'%';
3. 500만 건 데이터 시딩 (먼저 데이터 넣기)
orders 500만 건을 목표로, 1만 행짜리 multi-row INSERT를 반복하는 Python 시더를 사용했다.
시딩 dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir pymysql
COPY seed.py .
CMD ["python", "-u", "seed.py"]
시딩 스크립트 핵심 (seeder/seed.py)
import os
import random
import time
import pymysql
DB_HOST = os.getenv("DB_HOST", "mysql")
DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv("DB_PASSWORD", "root")
DB_NAME = os.getenv("DB_NAME", "shop")
USERS_TARGET = int(os.getenv("USERS_TARGET", "100000"))
ORDERS_TARGET = int(os.getenv("ORDERS_TARGET", "5000000"))
BATCH_SIZE = 10_000 # multi-row INSERT 한 문장당 행 수
REPORT_EVERY = 100_000 # 진행률 출력 간격
PRODUCTS = [
"노트북", "무선마우스", "기계식키보드", "모니터", "USB허브",
"웹캠", "이어폰", "스피커", "외장SSD", "노트북거치대",
]
STATUSES = ["PENDING", "PAID", "SHIPPED", "DELIVERED", "CANCELLED"]
def connect():
return pymysql.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME,
autocommit=True, charset="utf8mb4",
)
def current_count(cur, table):
cur.execute(f"SELECT COUNT(*) FROM {table}")
return cur.fetchone()[0]
def seed_users(cur):
cnt = current_count(cur, "users")
if cnt >= USERS_TARGET:
print(f"[users] 이미 {cnt:,}건 존재 — 스킵")
return
start = time.time()
remaining = USERS_TARGET - cnt
inserted = 0
while inserted < remaining:
n = min(BATCH_SIZE, remaining - inserted)
base = cnt + inserted
values = ",".join(
f"('user{base + i:06d}', 'user{base + i:06d}@example.com')"
for i in range(1, n + 1)
)
cur.execute(f"INSERT INTO users (name, email) VALUES {values}")
inserted += n
elapsed = time.time() - start
print(f"[users] {inserted:,}건 삽입 완료 ({elapsed:.1f}s, {inserted / elapsed:,.0f} rows/s)")
def seed_orders(cur):
cnt = current_count(cur, "orders")
if cnt >= ORDERS_TARGET:
print(f"[orders] 이미 {cnt:,}건 존재 — 스킵")
return
start = time.time()
remaining = ORDERS_TARGET - cnt
inserted = 0
next_report = REPORT_EVERY
while inserted < remaining:
n = min(BATCH_SIZE, remaining - inserted)
rows = []
for _ in range(n):
user_id = random.randint(1, USERS_TARGET)
product = random.choice(PRODUCTS)
amount = random.randint(1000, 500000) / 100
status = random.choice(STATUSES)
days_ago = random.randint(0, 89)
secs = random.randint(0, 86399)
rows.append(
f"({user_id},'{product}',{amount:.2f},'{status}',"
f"NOW() - INTERVAL {days_ago} DAY - INTERVAL {secs} SECOND)"
)
cur.execute(
"INSERT INTO orders (user_id, product_name, amount, status, created_at) VALUES "
+ ",".join(rows)
)
inserted += n
if inserted >= next_report or inserted == remaining:
elapsed = time.time() - start
rate = inserted / elapsed
eta = (remaining - inserted) / rate
pct = inserted / remaining * 100
print(
f"[orders] {inserted:>9,}/{remaining:,} ({pct:5.1f}%) "
f"{rate:,.0f} rows/s ETA {eta:,.0f}s"
)
next_report += REPORT_EVERY
elapsed = time.time() - start
print(f"[orders] {inserted:,}건 삽입 완료 ({elapsed:.1f}s, {inserted / elapsed:,.0f} rows/s)")
def main():
conn = connect()
cur = conn.cursor()
# 시딩 속도 최적화 (이 세션 한정)
cur.execute("SET SESSION unique_checks = 0")
cur.execute("SET SESSION foreign_key_checks = 0")
seed_users(cur)
seed_orders(cur)
cur.execute(
"SELECT table_name, table_rows, ROUND((data_length+index_length)/1024/1024) "
"FROM information_schema.tables WHERE table_schema = %s",
(DB_NAME,),
)
print("\n=== 테이블 현황 ===")
for name, rows, size in cur.fetchall():
print(f"{name}: ~{rows:,} rows, {size} MB")
conn.close()
if __name__ == "__main__":
main()
docker compose up -d mysql # MySQL 기동 (healthy 될 때까지 대기)
docker compose run --rm seeder # 시딩 실행 (진행률 출력)
실측 결과
| users 100,000건 | 0.3초 (339,184 rows/s) |
| orders 5,000,000건 | 48.7초 (102,658 rows/s) |
| orders 테이블 크기 | 605 MB (인덱스 포함) |
항목결과
[orders] 5,000,000/5,000,000 (100.0%) 102,658 rows/s ETA 0s
[orders] 5,000,000건 삽입 완료 (48.7s, 102,658 rows/s)
binlog ROW 포맷 때문에 시딩 자체도 binlog를 대량 생성한다.
실습 전용으로 innodb_flush_log_at_trx_commit=2, sync_binlog=0을 줘서 fsync 병목을 제거했다
4. 부하 속 일반 ALTER TABLE — INSTANT vs COPY
"서비스가 멈추는가"를 눈으로 보려면 두 가지가 필요하다.
① orders 테이블을 읽고 쓰는 실제 서비스(FastAPI, 커넥션 풀 10개)
② 초당 ~30건을 계속 보내면서 1초마다 통계를 찍는 부하생성기(읽기 70% / 쓰기 30%)
- read_timeout=5 — ALTER 락에 쿼리가 물리면 5초 뒤 에러가 나서 부하생성기에 err로 집계된다.
- 커넥션 풀 고정 10개 — 락에 커넥션이 물려 풀이 고갈되면 503이 뜬다. 운영에서 흔한 "커넥션 다 잡아먹혀 서비스 전체 다운" 패턴이 그대로 재현된다.
loadgen Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir aiohttp
COPY loadgen.py .
CMD ["python", "-u", "loadgen.py"]
부하생성기 통계 출력 (loadgen/loadgen.py )
import asyncio
import os
import random
import time
from datetime import datetime
import aiohttp
API_URL = os.getenv("API_URL", "http://api:8000")
RPS = int(os.getenv("RPS", "30")) # 초당 요청 수
WRITE_RATIO = float(os.getenv("WRITE_RATIO", "0.3")) # 쓰기 비율
MAX_ORDER_ID = int(os.getenv("MAX_ORDER_ID", "5000000"))
TIMEOUT = aiohttp.ClientTimeout(total=5)
RED = "\033[91m"
YELLOW = "\033[93m"
RESET = "\033[0m"
# 1초 윈도우 통계 (단일 이벤트루프라 락 불필요)
window = {"lat": [], "err": 0}
async def one_request(session):
r = random.random()
start = time.perf_counter()
ok = False
try:
if r < WRITE_RATIO:
# 쓰기 30%: 주문 생성
async with session.post(f"{API_URL}/orders", json={}) as resp:
await resp.read()
ok = resp.status < 500
elif r < WRITE_RATIO + 0.5:
# 읽기 50%: 최근 주문 목록
async with session.get(f"{API_URL}/orders/recent") as resp:
await resp.read()
ok = resp.status < 500
else:
# 읽기 20%: 단건 조회 (404는 정상 취급)
oid = random.randint(1, MAX_ORDER_ID)
async with session.get(f"{API_URL}/orders/{oid}") as resp:
await resp.read()
ok = resp.status < 500
except Exception:
ok = False
lat_ms = (time.perf_counter() - start) * 1000
if ok:
window["lat"].append(lat_ms)
else:
window["err"] += 1
async def reporter():
while True:
await asyncio.sleep(1)
lats, errs = window["lat"], window["err"]
window["lat"], window["err"] = [], 0
total = len(lats) + errs
if total == 0:
print(f"{RED}[{datetime.now():%H:%M:%S}] 응답 없음 (전 요청 대기/실패 중){RESET}", flush=True)
continue
succ = len(lats) / total * 100
avg_ms = sum(lats) / len(lats) if lats else 0.0
max_ms = max(lats) if lats else 0.0
line = (
f"[{datetime.now():%H:%M:%S}] ok={len(lats):>3} err={errs:>3} "
f"succ={succ:5.1f}% avg={avg_ms:7.1f}ms max={max_ms:8.1f}ms"
)
if errs > 0 or max_ms > 1000:
line = f"{RED}{line} << 지연/에러 발생!{RESET}"
elif max_ms > 300:
line = f"{YELLOW}{line}{RESET}"
print(line, flush=True)
async def main():
connector = aiohttp.TCPConnector(limit=100)
async with aiohttp.ClientSession(timeout=TIMEOUT, connector=connector) as session:
asyncio.create_task(reporter())
interval = 1 / RPS
while True:
asyncio.create_task(one_request(session))
await asyncio.sleep(interval)
if __name__ == "__main__":
asyncio.run(main())
docker compose up -d --build api # API 기동 (localhost:8000)
docker compose --profile load up --build loadgen # 부하 시작 — 이 터미널을 계속 관찰
정상 상태 기준선 (baseline)
[08:53:41] ok= 29 err= 0 succ=100.0% avg= 4.6ms max= 5.7ms
[08:53:42] ok= 27 err= 0 succ=100.0% avg= 4.3ms max= 13.1ms
| 처리량 | ~28 req/s |
| 성공률 | 100% |
| 평균 응답 | ~4.6ms |
| 최대 응답 | 6~13ms |
위의 지표가 평균적인 응답에 대한 지표이다.
실험 1a — ADD COLUMN
-- 최초 빌드 상태: memo 컬럼 없음
ALTER TABLE orders ADD COLUMN memo VARCHAR(255), ALGORITHM=INSTANT;
-- Query OK, 0 rows affected (0.27 sec)
500만 건 테이블인데 0.27초. 부하생성기 지표도 전혀 흔들리지 않았다. MySQL 8.0의 INSTANT DDL은 데이터를 건드리지 않고 메타데이터만 바꾼다. "컬럼 추가는 이제 무섭지 않다" — 이런 변경에는 gh-ost도 필요 없다.
실험 1b — 컬럼 타입 변경은 COPY가 강제된다
-- 최초 빌드 상태: amount DECIMAL(10,2)
-- INPLACE 시도 → 거부됨
ALTER TABLE orders MODIFY amount DECIMAL(12,2) NOT NULL, ALGORITHM=INPLACE;
-- ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported.
-- Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.
-- COPY로 실제 실행: DECIMAL(10,2) → DECIMAL(12,2), 500만 건 전체 복사
ALTER TABLE orders MODIFY amount DECIMAL(12,2) NOT NULL;
-- Query OK, 5001845 rows affected (26.20 sec)
ALTER는 26.2초 만에 끝났다. 그동안 서비스는 어땠을까. 부하생성기 출력:
[08:57:34] ok= 2 err= 0 succ=100.0% ← ALTER 시작
[08:57:35] 응답 없음 (전 요청 대기/실패 중)
[08:57:37] ok= 5 err= 25 succ= 16.7% avg= 2923.2ms ← 타임아웃 시작
[08:57:43] ok= 0 err= 27 succ= 0.0%
[08:57:47] ok= 0 err= 29 succ= 0.0% ← 완전 다운
[08:57:51] ok= 0 err= 28 succ= 0.0% (succ 0% 약 15초 지속)
[08:57:55] ok=184 err= 9 succ= 95.3% avg= 2565.1ms max= 5955.4ms ← 종료, 대기 요청 일괄 해소
[08:57:56] ok= 27 err= 0 succ=100.0% avg= 3.8ms ← 정상 회복
약 24초간 서비스 전면 장애.
- COPY 알고리즘은 원래 읽기는 허용한다. 그런데 읽기까지 다 죽었다. 블로킹된 쓰기(POST)가 커넥션 풀 10개를 전부 점유해서 읽기가 커넥션을 못 얻었기 때문이다. 운영 장애에서 흔히 보는 "커넥션 고갈 연쇄 장애" 패턴 그대로다.
- 요청은 사라지지 않고 대기했다가 ALTER 종료 순간 ok=184로 한꺼번에 풀렸다. 운영이었다면 이 순간이 2차 부하 폭탄이다.
- 겨우 26초짜리 ALTER가 이 정도다. 테이블이 10배 크면 장애도 10배 길어진다. 이게 gh-ost가 필요한 이유다.
5. gh-ost로 같은 변경 무중단 수행
gh-ost 동작 원리
gh-ost는 원본 테이블에 락을 걸고 복사하는 대신:
- 새 스키마의 ghost 테이블(_orders_gho)을 만들고
- 원본의 기존 행을 청크 단위(기본 1,000행)로 천천히 복사하면서
- 그동안 들어오는 신규 INSERT/UPDATE/DELETE는 binlog를 구독해 ghost 테이블에 반영하고
- 둘이 동기화되면 원자적 RENAME으로 테이블을 교체(cut-over)한다.
pt-online-schema-change와 달리 트리거를 쓰지 않아 원본 테이블 부하가 적다. binlog_format=ROW가 필수인 이유가 여기 있다.
실행 명령 (전체 옵션 주석)
앞의 실험 1b에서 amount는 이미 DECIMAL(12,2)가 됐다. 그래서 gh-ost 실험은 같은 성격의 변경(COPY가 강제되는 컬럼 타입 변경)을 DECIMAL(12,2) → DECIMAL(14,2)로 수행한다. 아래 주석은 설명용이고, 실제 저장소의 scripts/*.sh는 bash 배열로 작성돼 있다 (셸에서 \ 뒤에는 주석을 붙일 수 없다).
docker compose exec ghost gh-ost \
--host=mysql --port=3306 \
--user=ghost --password=ghost_pw \ # 전용 유저 (REPLICATION + DDL 권한)
--database=shop --table=orders \
--alter="MODIFY amount DECIMAL(14,2) NOT NULL" \ # ALTER TABLE 뒷부분만
--allow-on-master \ # 레플리카 없이 마스터 직접 실행 (단일 인스턴스 필수)
--exact-rowcount --concurrent-rowcount \ # 정확한 진행률/ETA
--chunk-size=1000 \ # 청크당 복사 행 수
--max-load=Threads_running=25 \ # MySQL 부하 초과 시 자동 스로틀
--initially-drop-ghost-table --initially-drop-old-table \ # 이전 실행 잔재 정리
--serve-socket-file=/tmp/ghost/gh-ost.sock \ # 실행 중 인터랙티브 제어 소켓
--throttle-flag-file=/tmp/ghost/throttle.flag \ # 파일 존재 시 일시정지
--verbose \
--execute # 없으면 dry-run
먼저 --execute를 빼고 dry-run으로 연결·권한·계획을 검증한 뒤, 붙여서 실제 실행한다. (2장의 02-users.sql에 넣어둔 performance_schema 권한이 없으면 dry-run 단계에서 bailing out because metadata lock instrument not enabled로 중단된다 — 실습 중 실제로 겪고 권한을 추가했다.)
진행률 로그 읽는 법
Copy: 2377000/5005756 47.5%; Applied: 165; Backlog: 1/1000;
Time: 20s(total), 20s(copy); Lag: 0.01s, State: migrating; ETA: 18s
- Copy — ghost 테이블로 복사된 행 / 전체 (분모가 계속 느는 건 부하생성기가 주문을 넣고 있어서)
- Applied — binlog로 따라잡은 신규 변경 건수. 마이그레이션 내내 증가 = 서비스가 살아있다는 증거
- Backlog — 아직 적용 못 한 binlog 이벤트 큐. 1000에 가까우면 쓰기를 못 따라가는 중
- Lag — binlog 스트리밍 지연. 이 값이 크면 gh-ost가 자동 스로틀
실측 결과 — 49초, 에러 0건
마이그레이션 내내 부하생성기는 succ=100%를 유지했다. 복사 청크 경합으로 간헐적 스파이크(max 200~540ms)만 보였다:
[09:05:19] ok= 31 err= 0 succ=100.0% avg= 15.1ms max= 280.4ms ← 복사 중
[09:05:44] ok= 28 err= 0 succ=100.0% avg= 34.7ms max= 436.1ms
[09:05:45] ok= 4 err= 0 succ=100.0% avg= 110.5ms max= 438.2ms ← cut-over 시작
[09:05:46] 응답 없음 (전 요청 대기/실패 중) ← 원자적 RENAME (~2.2초)
[09:05:47] ok= 80 err= 0 succ=100.0% avg= 871.4ms max= 2185.6ms ← 대기 요청 전부 성공
[09:05:48] ok= 28 err= 0 succ=100.0% avg= 4.8ms ← 즉시 정상
gh-ost 로그의 마지막: Lock & rename duration: 2.211s. During this time, queries on `orders` were blocked. 이 2.2초가 gh-ost 마이그레이션의 유일한 블로킹이고, 그마저도 요청이 실패하지 않고 대기 후 전부 성공했다. 완료 후 SHOW CREATE TABLE orders로 DECIMAL(14,2) 반영과 건수 5,006,432(마이그레이션 중 유입분 포함, 무유실)를 확인했다. 두 방식의 수치 비교는 8장의 최종 표로 정리한다.
6. 스로틀링과 인터랙티브 제어 (중간에 멈추기)
gh-ost의 진짜 매력은 실행 중인 마이그레이션을 밖에서 제어할 수 있다는 점이다. 일반 ALTER는 시작하면 끝나거나 죽이거나 둘 중 하나지만, gh-ost는 일시정지·재개·속도조절이 전부 가능하다.
이 장과 다음 장의 실습은 별도의 마이그레이션 한 번으로 진행했다 — 변경 내용은 원상복구를 겸한 DECIMAL(14,2) → DECIMAL(12,2)이고, 7장에서 다룰 --postpone-cut-over-flag-file=/tmp/ghost/postpone.flag 옵션을 추가해 시작했다. 나머지 옵션은 5장의 명령과 동일하다.
플래그 파일로 일시정지
복사 42% 지점에서 touch ghost-flags/throttle.flag 파일 생성하면 아래와 같이 멈춘상태가 지속된다.
Copy: 2104000/5008078 42.0%; Backlog: 16/1000; State: throttled, flag-file
Copy: 2104000/5008078 42.0%; Backlog: 148/1000; State: throttled, flag-file ← Copy 정지
Copy: 2104000/5008078 42.0%; Backlog: 548/1000; State: throttled, flag-file ← Backlog만 증가
복사만 멈추고 binlog 수집은 계속된다.
-- 스로틀 30초 동안 Backlog가 16 → 548로 증가했다
— 부하생성기가 계속 넣는 주문들이 큐에 쌓이는 것. rm ghost-flags/throttle.flag로 재개하자 Applied가 159 → 601로 급증하며 쌓인 이벤트를 일괄 소화하고 복사가 재개됐다.
반대로 서비스 입장에서 스로틀은 즉각적인 부하 반납이다. 같은 시각의 부하생성기 출력을 gh-ost 상태와 대조하면:
[09:10:14~40] throttle 중 : max 6~10ms ← 복사 경합 스파이크 완전 소멸 (baseline 수준)
[09:10:43~] 재개 후 : max 200~535ms ← 청크 복사 경합 스파이크 복귀
[09:11:07~23] 소켓 throttle: 다시 잠잠
피크 트래픽 시간에 복사를 멈춰 DB 자원을 서비스에 돌려주고, 한가해지면 재개한다
유닉스 소켓으로 실시간 제어
소켓 파일은 ghost 컨테이너 안(/tmp/ghost/gh-ost.sock)에 있으므로 docker compose exec ghost로 명령을 던진다:
$ docker compose exec ghost sh -c 'echo status | nc -U /tmp/ghost/gh-ost.sock'
Copy: 4482000/5008623 89.5%; Applied: 704; State: migrating; ETA: 4s
$ docker compose exec ghost sh -c 'echo throttle | nc -U /tmp/ghost/gh-ost.sock'
# Note: you may only throttle for as long as your binary logs are not purged
...State: throttled, commanded by user ← flag-file과 구분되는 상태
$ docker compose exec ghost sh -c 'echo no-throttle | nc -U /tmp/ghost/gh-ost.sock' # 재개
소켓으로는 이 밖에도 chunk-size=2000(속도 조절), max-load 변경, unpostpone(cut-over 즉시 실행), panic(즉시 중단) 등을 실행 중에 던질 수 있다. 운영 중 DB 부하가 튀면 ALTER를 죽이는 대신 잠깐 멈췄다 가면 된다.
7. cut-over — 최종 테이블 교체의 순간
cut-over는 gh-ost 마이그레이션에서 유일하게 원본 테이블이 잠기는 순간이다. gh-ost는 "magic table"(_orders_del)을 만들어 잠그고, 원자적 RENAME (orders → _orders_del, _orders_gho → orders)이 그 락에 블로킹되도록 건 다음, binlog를 끝까지 따라잡은 시점에 락을 풀어 RENAME이 통과되게 한다. 덕분에 어떤 클라이언트도 "테이블이 없는 순간"을 보지 못한다.
cut-over 시점을 내가 정하기
--postpone-cut-over-flag-file을 주면 복사가 100% 끝나도 gh-ost는 State: postponing cut-over로 대기한다. 그동안에도 binlog는 계속 따라잡으므로 몇 시간을 기다려도 데이터는 최신이다. 트래픽이 가장 적은 시각, 담당자가 지켜보는 순간에:
rm ghost-flags/postpone.flag # 이 순간 cut-over 수행
Step 4 실측에서 cut-over 블로킹은 2.2초였고 (Lock & rename duration: 2.211s), 그 사이 요청들은 실패가 아니라 대기 후 전부 성공했다 (응답 없음 1초 → ok=80, err=0).
8. 결과 비교와 결론
| 일반 ALTER (COPY) | gh-ost | |
| 소요 시간 | 26.2초 | 49초 (~1.9배) |
| 서비스 에러 | 24초간 succ 0% — 전면 장애 | 0건 |
| 블로킹 | 실행 내내 쓰기 차단 → 커넥션 풀 고갈 → 읽기까지 사망 | cut-over 2.2초 (대기 후 전부 성공) |
| 진행 제어 | 불가 | 일시정지/재개/속도/cut-over 시점 자유 |
| 진행률 가시성 | 없음 | Copy %, Applied, Backlog, ETA 실시간 |
| 데이터 정합성 | 5,001,845행 반영 | 5,006,432행 + 마이그레이션 중 binlog 383건 무유실 |
| 롤백 | 역방향 ALTER 재실행 (또 장애) | 원본 _orders_del 보존 |
결론
- MySQL 8.0에서 모든 DDL이 위험한 건 아니다. ADD COLUMN 같은 INSTANT DDL은 500만 건에서도 0.27초. 먼저 ALGORITHM=INSTANT(안 되면 INPLACE)를 명시해서 시도하고, 거부될 때 gh-ost를 사용하자
- COPY가 강제되는 변경(컬럼 타입 변경 등)은 테이블 크기에 비례한 전면 장애다. 26초짜리 ALTER가 서비스를 24초 죽였다. 실제 운영의 수억 건 테이블이라면 수십 분이 된다.
- gh-ost는 2배 느린 대신 장애가 0이다.
- 스로틀링·postpone cut-over까지 써야 운영 도구다. 피크 시간엔 멈추고, 교체는 새벽에 사람이 보면서. 이 제어권이 gh-ost의 본질이다.
'server > 아키텍쳐' 카테고리의 다른 글
| gh-ost는 어떻게 무중단으로 스키마를 바꾸는가? (1) | 2026.07.29 |
|---|---|
| 2. airflow + dbt (1) | 2025.08.31 |
| 1. dbt tutorial (0) | 2025.08.30 |
| 로또 시스템 아키텍처 (3) | 2025.08.03 |
| rabbitmq 심화 (persistent / cluster) (0) | 2024.05.05 |