본문 바로가기

server/아키텍쳐

gh-ost로 무중단 스키마 변경하기 — 500만 건 테이블에서 ALTER TABLE vs gh-ost 직접 비교 실습기

목차

  1. 왜 gh-ost인가 — 대용량 테이블 ALTER의 문제
  2. 실습 환경 구성 (Docker Compose)
  3. 500만 건 데이터 시딩
  4. 부하 속 일반 ALTER TABLE — INSTANT vs COPY
  5. gh-ost로 같은 변경 무중단 수행
  6. 스로틀링과 인터랙티브 제어
  7. cut-over — 최종 테이블 교체의 순간
  8. 결과 비교와 결론

 

 

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는 원본 테이블에 락을 걸고 복사하는 대신:

  1. 새 스키마의 ghost 테이블(_orders_gho)을 만들고
  2. 원본의 기존 행을 청크 단위(기본 1,000행)로 천천히 복사하면서
  3. 그동안 들어오는 신규 INSERT/UPDATE/DELETE는 binlog를 구독해 ghost 테이블에 반영하고
  4. 둘이 동기화되면 원자적 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