Skip to content

feat: [복구 분기 A] 로컬 볼륨 정상 시 DB EC2 재구성 워크플로우 구현 #87

Description

@Hexeong

어떤 기능인가요?

#68 복구 워크플로우의 **분기 A(로컬 볼륨 정상)**를 구현합니다. DB EC2만 새로 만들고 기존 데이터 EBS를 다시 붙여 MySQL을 기동합니다.

인스턴스(compute)만 문제인 경우를 다룹니다. 볼륨에 최신 데이터가 그대로 있으므로 S3 백업으로 복원하지 않습니다. dump를 덮어쓰면 그 이후 데이터가 사라집니다. 커밋 기준 RPO는 0입니다.

분기 B(#88)보다 먼저 진행합니다. 승인 게이트, Terraform 교체, SSH 접속, host key 처리, 백업 재설치 같은 두 분기 공통 기반을 이 이슈에서 만들고, 분기 B는 그 위에 볼륨 교체와 S3 복원을 더합니다.

private IP가 고정(#86)되어 있으므로 Parameter Store 갱신과 API 서버 재배포는 필요 없습니다.

실행 흐름

0. 입력 검증
   - 분기 = A, 확인용으로 입력한 대상 인스턴스 ID 가 실제 DB EC2 와 일치
   - 불일치하거나 분기 B 이면 즉시 실패 (fail-fast)

1. [plan job · 읽기 전용 role]
   - -target plan → resource_changes 단언 → 변경 요약 출력

2. [apply job · prod-db 승인 · 복구 role]
   2-1. 데이터 EBS 스냅샷 생성 및 완료 대기
   2-2. 기존 인스턴스 force stop (타임아웃, 이미 terminated 면 건너뜀)
   2-3. 재 plan → resource_changes 단언 → apply -replace -target
        (InvalidIPAddress.InUse 는 대기 후 재시도)

3. get-console-output 에서 새 SSH host key fingerprint 추출

4. SSH 접속 → cloud-init status --wait → MySQL 기동·기존 데이터 확인

5. 백업 재설치 (공용 스크립트에 fingerprint 를 직접 전달)

6. host key Secret 갱신 안내와 스냅샷 정리 기준 출력

설계 결정

Terraform 실행 범위는 -target으로 DB 리소스에 한정합니다. prod의 mysql provider는 127.0.0.1:3306 SSM 터널로 DB EC2에 붙고, terraform-apply.yml도 apply 전에 이 터널부터 엽니다. 이 워크플로우는 DB EC2가 비정상일 때 실행되므로 터널이나 mysql_user/mysql_grant refresh가 실패하면 -replace까지 가지 못합니다. -target은 dependents를 제외하므로 mysql provider를 호출하지 않습니다(PR #86 작업 때 로컬 plan으로 확인).

-replace=module.prod_stack.aws_instance.db_server[0]
-target=module.prod_stack.aws_instance.db_server[0]
-target=module.prod_stack.aws_volume_attachment.db_data[0]

교체 전에 데이터 EBS 스냅샷을 반드시 만듭니다. mysql_setup.sh.tftpl은 blkid가 실패하면 mkfs.ext4 -F를 실행합니다. blkid는 파일시스템이 없을 때뿐 아니라 I/O 오류나 장치 준비 지연에도 실패할 수 있어, 분기 A의 전제(볼륨 정상)를 되돌릴 수 없게 깨뜨릴 수 있습니다. 증분 스냅샷은 빠르므로 replace 직전에 만들고 완료를 기다립니다. 스크립트의 포맷 조건도 선행 작업으로 함께 고칩니다.

plan 파일은 job 사이에 넘기지 않고, apply job에서 다시 plan해 변경 집합을 단언합니다. user_data에 DB root 비밀번호가 들어가므로 plan 파일을 평문 artifact로 넘기면 안 됩니다. 재 plan 방식은 암호화 키를 따로 관리할 필요가 없고, 승인 시점의 state 기준으로 다시 검사하므로 그 사이의 변경도 잡아냅니다. terraform show -json의 resource_changes를 jq로 검사해 다음 경우에 실패합니다.

  • db_server·volume_attachment의 replace 외에 다른 변경이 있는 경우
  • aws_ebs_volume에 delete가 있는 경우

host key는 같은 run 안에서 값으로 넘기고, Secret 갱신은 사람이 합니다. GITHUB_TOKEN으로는 Secret을 쓸 수 없고, 같은 run에서 갱신한 Secret은 이미 시작된 job에 반영되지 않습니다. 그래서 get-console-output(AWS API로 인증된 경로)에서 추출한 fingerprint를 백업 재설치 단계에 직접 전달합니다. PROD_DB_SSH_HOST_KEY_ED25519 갱신은 워크플로우 마지막에 값과 함께 안내를 출력해 사람이 처리합니다. 이렇게 하면 secrets 쓰기 권한이 있는 PAT를 새로 두지 않아도 됩니다.

작업 상세 내용

선행 작업

  • mysql_setup.sh.tftpl의 포맷 조건 수정: blkid가 "파일시스템 없음"(exit 2)을 반환할 때만 포맷하고, 그 외의 실패는 중단합니다. 분기 B의 새 볼륨 생성에도 적용됩니다.
  • mysql-backup-deploy.yml의 접속·설치 로직을 공용 스크립트(또는 composite action)로 분리하고, host key fingerprint를 입력값으로 받을 수 있게 합니다. 기존 워크플로우의 동작은 바꾸지 않습니다.
  • 코드의 DB AMI 검증(아래 사전 확인 참고)

공통 기반 (분기 B에서 재사용)

  • workflow_dispatch 워크플로우를 만들고 입력값으로 분기(A/B), 확인용 대상 인스턴스 ID, 복구 사유를 받습니다. 이 이슈에서는 분기 A만 동작하고 분기 B 입력은 fail-fast 처리합니다.
  • plan job과 apply job을 나누고, apply job에만 기존 prod-db Environment를 재사용해 승인을 받습니다(required reviewers, prevent self-review, branch policy 설정됨).
  • IAM Role을 AWS에서 수동으로 구성하고 ARN을 GitHub Secret으로 등록합니다.
    • 복구 role: trust policy의 sub를 repo:solid-connection/solid-connection-infra:environment:prod-db로 묶어 승인된 job만 assume합니다. 권한에 iam:PassRole(instance profile), state 쓰기, 스냅샷 생성이 포함되어야 합니다.
    • plan role: 읽기 전용 role을 따로 둡니다.
  • resource_changes 단언 스크립트를 작성해 plan job과 apply job 모두에서 실행합니다.
  • 동시 실행을 막도록 terraform-apply.yml(prod), mysql-backup-deploy.yml과 같은 concurrency group을 씁니다. 현재 terraform-apply.yml에는 concurrency 설정이 없으므로 함께 추가합니다. 지금은 S3 네이티브 락에 걸리면 대기하지 않고 실패합니다.
  • API EC2를 경유한 SSM 포트 포워딩과 EC2 Instance Connect 일회용 키로 새 DB EC2에 SSH로 접속합니다(공용 스크립트 사용).
  • DB EC2에서 실행하는 작업은 systemd-run --unit=recovery-${run_id} -p RemainAfterExit=yes로 띄우고, systemctl show -p ActiveState,Result,ExecMainStatus로 폴링합니다. 상세 로그는 journalctl에 남깁니다.
  • get-console-output에서 SSH host key fingerprint를 추출해 백업 재설치에 직접 전달하고, 마지막에 Secret 갱신 안내를 출력합니다.
  • 복구 후 백업 체계를 재설치하고 타이머가 동작하는지 확인합니다.

분기 A 전용

  • 교체 직전에 데이터 EBS 스냅샷을 만들고 완료를 기다립니다. 정리 기준(검증이 끝난 뒤 수동 삭제 등)을 출력합니다.
  • 기존 인스턴스를 aws ec2 stop-instances --force로 정지합니다. 타임아웃을 두고, 이미 terminated 상태면 건너뜁니다. stop_instance_before_detaching = true는 일반 stop이라 시스템 장애로 stopping에 걸리면 apply가 오래 대기하기 때문입니다.
  • -replace와 -target으로 apply합니다. 고정 IP를 쓰는 기존 ENI가 늦게 해제되어 InvalidIPAddress.InUse가 나면 대기 후 재시도합니다.
  • SSH 접속 후 cloud-init status --wait 결과부터 확인합니다. user_data는 set -euo pipefail이라 중간에 실패하면 MySQL이 뜨지 않은 채 끝납니다.
  • 기존 볼륨이 포맷되지 않고 마운트되었는지, MySQL이 기존 데이터로 기동했는지 확인합니다.
  • MySQL 상태, 주요 테이블, API 서버의 DB 연결을 검증합니다.

사전 확인 (리허설에서 함께 확인)

  • 코드의 DB AMI 검증: 교체하면 운영 인스턴스와 다른 AMI로 뜹니다. DB EC2는 인터넷 경로가 없어 이미지를 받아 올 수 없으므로 AMI에 필요한 것이 모두 들어 있어야 합니다.

    AMI 이름
    운영 중 ami-0d45c1f91adea31b8 (2026-07-09) solid-connection-db-mysql-8.4.8-arm64-ubuntu24.04
    코드 ami-0501a03cd31b53e82 (2026-08-17) solid-connection-db-mysql-8.4.8-arm64-ubuntu24.04-awscli-recovery-tools
    • mysql:8.4.8 이미지 (없으면 user_data의 docker image inspect에서 중단)
    • ec2-instance-connect
    • aws cli, curl (백업 체계 의존성)
    • console output에 SSH host key fingerprint가 출력되는지
  • 교체하면 현재 코드의 mysql_setup.sh.tftpl이 그대로 실행됩니다(ignore_changes는 update에만 적용). 기존 데이터 볼륨에서 스크립트가 의도대로 동작하는지 검증합니다.

  • tfvars의 root 비밀번호와 기존 DB의 root 비밀번호가 같은지 확인합니다. datadir가 이미 있으면 MYSQL_ROOT_PASSWORD가 무시되므로, 다르면 MySQL은 떠 있어도 user_data의 readiness 체크가 실패합니다.

  • 인스턴스를 교체한 뒤 고정한 private IP(172.31.65.116)가 다시 할당되는지 확인합니다.

완료 조건

  • 승인된 사용자만 워크플로우를 실행할 수 있고, 잘못된 입력은 승인 전에 실패합니다.
  • 변경 집합이 DB EC2 교체 범위를 벗어나면 apply 전에 실패합니다.
  • 교체 전 스냅샷이 남아 있어 언제든 되돌릴 수 있습니다.
  • 기존 볼륨을 유지한 채 DB EC2를 재구성하고, 데이터 유실 없이 API 서버 설정 변경 없이 다시 연결됩니다.
  • 복구 후 백업 체계가 다시 동작합니다.
  • 리허설로 소요 시간을 측정해 기록합니다.

참고할만한 자료(선택)

Activity

  1. self-assigned this
    on Oct 10, 2026
  2. Hexeong commented on Oct 10, 2026

    @Hexeong
    ContributorAuthor

    계획 검토 피드백

    코드(modules/app_stack/db_ec2.tf, mysql_setup.sh.tftpl, terraform-apply.yml, mysql-backup-deploy.yml)와 대조해 본 결과입니다.

    🔴 필수

    1. -target 으로 실행 범위를 DB 리소스로 한정

    environment/prod/provider.tf 의 mysql provider 는 SSM 터널(127.0.0.1:3306)로 DB EC2 에 붙습니다. 이 워크플로우가 도는 상황은 DB EC2 가 비정상일 때라 터널이나 mysql_user/mysql_grant refresh 가 실패하면 -replace 까지 도달하지 못합니다.

    -replace=module.prod_stack.aws_instance.db_server[0]
    -target=module.prod_stack.aws_instance.db_server[0]
    -target=module.prod_stack.aws_volume_attachment.db_data[0]
    

    -target 은 dependents(mysql_user 등)를 제외하므로 mysql provider 호출과 터널 없이 실행됩니다. "교체 대상은 DB EC2 관련 리소스로만 제한" 항목을 이 방식으로 구체화하고, 리허설에서 이 경로를 확인하면 좋겠습니다.

    2. 교체 전 데이터 EBS 스냅샷을 필수 단계로

    mysql_setup.sh.tftpl 은 blkid 가 실패하면 mkfs.ext4 -F 를 실행합니다. blkid 는 파일시스템이 없을 때뿐 아니라 I/O 오류나 장치 준비 지연에도 실패할 수 있어, 분기 A 의 전제(볼륨 정상)를 되돌릴 수 없게 깨뜨릴 수 있습니다. 증분 스냅샷은 빠르므로 replace 직전에 생성하고 완료를 기다리는 단계를 두면 어떤 실수에도 되돌릴 수 있습니다.

    3. plan 전달 방식과 변경 집합 검증

    plan → 승인 → apply 를 하려면 plan job 과 prod-db 가 걸린 apply job 을 나눠야 하는데, plan 파일에는 user_data 에 포함된 DB root 비밀번호 등 민감값이 들어 있어 평문 artifact 로 넘기면 안 됩니다.

    • (a) secret 키로 암호화해 artifact 전달, 또는
    • (b) apply job 에서 재 plan 후 terraform show -json 의 변경 집합 비교

    어느 쪽이든 resource_changes 를 jq 로 검사해 db_server·volume_attachment replace 외 변경이 있거나 aws_ebs_volume 에 delete 가 있으면 실패하도록 단언하면 좋겠습니다.

    🟡 보완

    • 기존 인스턴스 정리: stop_instance_before_detaching = true 는 일반 stop 이라 시스템 장애로 stopping 에 걸리면 apply 가 오래 대기합니다. apply 전 aws ec2 stop-instances --force 단계와 타임아웃, 이미 terminated 된 경우의 분기를 두면 좋겠습니다.
    • 고정 IP 재할당: 기존 ENI 해제가 늦으면 InvalidIPAddress.InUse 로 실패할 수 있어 대기·재시도 로직이 필요합니다.
    • user_data 결과 확인: 스크립트가 set -e 라 중간 실패 시 MySQL 이 뜨지 않은 채 끝납니다. SSH 접속 후 cloud-init status --wait 결과부터 확인하는 게 좋겠습니다.
    • systemd-run 폴링: --unit=recovery-${run_id} 처럼 고유 이름과 RemainAfterExit=yes 를 주고, systemctl show -p ActiveState,Result,ExecMainStatus 로 폴링하면 SSM 세션이 끊겨도 상태를 안정적으로 확인할 수 있습니다.
    • host key 갱신
      • GITHUB_TOKEN 으로는 Secret 을 쓸 수 없어 secrets 쓰기 권한이 있는 PAT/App 이 필요합니다.
      • 같은 run 안에서 갱신한 Secret 은 이미 시작된 job 에 반영되지 않으므로, 백업 재설치 단계에는 fingerprint 를 입력값으로 직접 넘기는 편이 안전합니다. 이를 위해 mysql-backup-deploy.yml 의 접속·설치 로직을 공용 스크립트(또는 composite action)로 분리하는 것을 제안합니다.
    • 동시 실행 방지: terraform-apply(prod), mysql-backup-deploy 와 같은 concurrency group 을 공유하면 S3 락 충돌로 중간 실패하는 대신 대기합니다.
    • 오실행 방지: 분기 B 입력은 명시적으로 fail-fast 처리하고, 대상 인스턴스 ID 를 직접 입력하게 하는 확인 input 을 두면 좋겠습니다.
    • IAM Role: trust policy 의 sub 를 repo:solid-connection/solid-connection-infra:environment:prod-db 로 묶어 승인된 job 만 assume 하도록 하고, plan job 은 별도 읽기 전용 role 을 쓰는 구성을 제안합니다. 권한에 iam:PassRole(instance profile)과 state 쓰기가 포함되어야 합니다.

    체크리스트 수정 제안

    • prod-db Environment 는 이미 존재합니다(required reviewers, prevent self-review, branch policy). "구성" → "재사용" 으로 바꾸면 됩니다.
    • 사전 확인에 다음 항목 추가
      • 교체 시 현재 코드의 mysql_setup.sh.tftpl 이 그대로 실행되므로(ignore_changes 는 update 에만 적용) 스크립트 동작 검증
      • tfvars 의 root 비밀번호와 기존 DB 의 비밀번호 일치 여부 (datadir 가 있으면 MYSQL_ROOT_PASSWORD 가 무시되어, 불일치 시 MySQL 은 떠 있어도 readiness 체크가 실패)
      • DB AMI 에 ec2-instance-connect 가 포함되어 있고 console output 에 SSH host key fingerprint 가 출력되는지
  3. Hexeong commented on Oct 11, 2026

    @Hexeong
    ContributorAuthor

    모의 복구 환경 및 모듈 분리 계획 보완

    분기 A 구현을 운영 DB에 처음 적용하지 않도록 모의 테스트 구조를 먼저 설계하면서 아래와 같이 계획을 보완합니다.

    모의 테스트 환경

    별도의 app_stack 전체를 만들지 않습니다. 별도 App EC2를 만들면 도메인·인증서·애플리케이션 배포·알림 API까지 복제해야 해 복구 검증보다 환경 구성의 복잡도가 커집니다.

    대신 기존 stage App은 그대로 두고, 복구 훈련 때만 별도 state의 임시 DB EC2와 데이터 EBS를 생성합니다.

    • stage의 기본 DB 구성은 계속 App EC2 내부 MySQL 컨테이너입니다.
    • drill DB는 prod와 같은 DB EC2 초기화 코드를 사용합니다.
    • 기존 stage API EC2를 jump host와 백업 알림 대상으로 재사용합니다.
    • 분기 A 핵심 검증(EC2 교체, 기존 EBS 재연결, MySQL 기동, 데이터 보존)은 stage datasource를 바꾸지 않고 수행합니다.
    • API 연결까지 확인할 때만 stage를 drill DB로 잠시 연결한 뒤 기존 로컬 MySQL로 되돌립니다.
    • prod에서는 실제 교체 리허설을 하지 않고 plan-only로 실제 state·IAM·변경 집합까지만 검증합니다.

    app/db 모듈 분리

    prod와 drill이 같은 DB EC2 코드를 사용하도록 기존 app_stack을 app과 DB 하위 모듈로 분리합니다. 기존 환경의 호환성을 유지하기 위해 app_stack은 두 모듈을 조립하는 wrapper로 남깁니다.

    • prod: app + 외부 DB EC2
    • stage: app만 생성, 로컬 MySQL 컨테이너는 기존 애플리케이션 배포에서 관리
    • recovery drill: DB EC2만 임시 생성하고 기존 stage App을 사용

    Terraform state 주소가 바뀌는 기존 리소스에는 moved 블록을 선언합니다. 이 리팩터링 PR의 병합 조건은 **prod와 stage 전체 plan 모두 No changes**입니다. replace/create/destroy가 하나라도 나오면 병합하지 않습니다.

    prod 데이터 EBS의 prevent_destroy = true는 유지해야 하고 drill EBS는 테스트 후 삭제할 수 있어야 합니다. 따라서 보호된 prod EBS의 소유 위치와 drill용 임시 EBS를 분리하고, 공용 DB EC2 모듈은 전달받은 volume ID를 연결하는 구조를 사용합니다.

    API SG와 DB SG는 서로 참조하므로 하위 모듈 간 순환 의존성이 생기지 않게 연결용 보안 그룹 규칙은 wrapper/환경 계층에서 조립합니다.

    load-test 영향과 주의사항

    현재 environment/load_test는 app_stack을 사용하지 않고 DB EC2, EBS, 보안 그룹을 독립적으로 관리하므로 이번 분리에서 해당 리소스를 공용 DB 모듈로 옮기지 않습니다. 따라서 load-test state의 주소 변경이나 moved 블록은 없습니다.

    다만 다음 간접 의존성을 확인합니다.

    • load test는 prod/stage EC2를 Terraform 주소가 아니라 Name tag로 조회하므로 기존 태그를 유지합니다.
    • mysql_tuning.cnf를 이동한다면 environment/load_test/main.tf의 파일 경로를 갱신하고 내용은 동일하게 유지합니다. 이 DB는 user_data_replace_on_change = true라 렌더링 결과가 달라지면 replace됩니다.
    • 하위 모듈은 modules/app_stack/modules/app/**, modules/app_stack/modules/db_ec2/**에 두므로 기존 modules/app_stack/** CI 변경 감지 범위에 포함됩니다.
    • 일반 Terraform workflow에 load-test plan job이 없으므로 리팩터링 중 load-test plan을 별도로 실행합니다. 현재 load-test 환경은 종료되어 있어 기존 정의와 같은 생성 집합이 나오는지 확인합니다.
    • load-test 전용 dump 복원, SSM endpoint, ready marker 동작은 이번 공통화 범위에 넣지 않습니다.

    검증 단계

    1. 모듈 분리 후 prod 전체 plan: No changes
    2. 모듈 분리 후 stage 전체 plan: No changes
    3. load-test plan: 환경이 실행 중이면 No changes, 종료 상태이면 기존과 동일한 생성 집합
    4. 임시 drill DB 생성·삭제
    5. drill에서 A1(정상 stop), A2(Terraform 밖 terminate), A3(cloud-init 실패), 잘못된 입력/변경 집합 거부 시나리오 실행
    6. prod plan-only 실행

    이 구조를 전제로 분기 A 구현을 진행합니다.

    1차 구현 결과

    feature/87-db-recovery-branch-a 브랜치의 fd7087a에서 첫 단계인 모듈 분리를 적용했습니다.

    • app_stack은 호환 wrapper로 유지했습니다.
    • API EC2와 nginx/side-infra 실행은 modules/app_stack/modules/app으로 이동했습니다.
    • DB EC2 초기화와 기존 EBS 연결은 modules/app_stack/modules/db_ec2로 이동했습니다.
    • API/DB 보안 그룹은 순환 의존성을 피하기 위해 wrapper에 유지했습니다.
    • prod EBS도 기존 state 주소와 prevent_destroy를 유지하기 위해 wrapper에 남겼습니다.
    • moved 블록으로 기존 API EC2, DB EC2, 볼륨 연결, null_resource의 state 주소를 이전했습니다.
    • load-test 전용 리소스와 mysql_tuning.cnf 위치는 변경하지 않았습니다.

    검증 결과:

    • stage 전체 plan: 0 add, 0 change, 0 destroy
    • prod 전체 refresh plan(CI와 같은 API EC2 경유 SSM 터널, MySQL provider 조회 포함): 0 add, 0 change, 0 destroy
    • load-test plan: 환경이 종료된 상태라 기존 11개 리소스 생성 계획이며, 이번 변경으로 load-test 코드나 입력에는 차이가 없습니다.
    • prod·stage·load-test terraform validate: 통과

    모듈 분리로 DB 리소스 주소가 바뀌었으므로 복구 워크플로우의 target은 아래 주소를 사용합니다.

    -replace=module.prod_stack.module.db_ec2.aws_instance.db_server[0]
    -target=module.prod_stack.module.db_ec2.aws_instance.db_server[0]
    -target=module.prod_stack.module.db_ec2.aws_volume_attachment.db_data[0]
    

    머지 후 apply는 새 plan을 다시 계산하지만 현재 구성에서는 원격 리소스 작업 없이 moved 선언에 따른 state 주소만 기록합니다. PR CI의 Terraform 1.10.5 plan에서도 동일한 0 add, 0 change, 0 destroy인지 최종 확인한 뒤 머지합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions