Repository navigation
feat: [복구 분기 A] 로컬 볼륨 정상 시 DB EC2 재구성 워크플로우 구현 #87
Description
어떤 기능인가요?
#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-dbEnvironment를 재사용해 승인을 받습니다(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을 따로 둡니다.
- 복구 role: trust policy의
-
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-toolsmysql: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 서버 설정 변경 없이 다시 연결됩니다.
- 복구 후 백업 체계가 다시 동작합니다.
- 리허설로 소요 시간을 측정해 기록합니다.
참고할만한 자료(선택)
- 상위 이슈: feat: Prod DB 인스턴스·볼륨 장애 복구 GitHub Actions 워크플로우 구성 #68
- Discussion [논의] Prod DB 데이터 논리적 손상 시 시점 복구 절차 #78 — "인스턴스·시스템 장애 경로 (백업을 쓰지 않음)",
-replaceplan 결과 - private IP 고정: chore: DB EC2 의 private IP 를 현재 주소로 고정 #86
- 기존 배포 경로:
.github/workflows/mysql-backup-deploy.yml - user_data:
modules/app_stack/scripts/mysql_setup.sh.tftpl - 계획 검토 피드백: 이 이슈의 첫 번째 코멘트
Activity
Hexeong commentedon Oct 10, 2026
계획 검토 피드백
코드(
modules/app_stack/db_ec2.tf,mysql_setup.sh.tftpl,terraform-apply.yml,mysql-backup-deploy.yml)와 대조해 본 결과입니다.🔴 필수
1.
-target으로 실행 범위를 DB 리소스로 한정environment/prod/provider.tf의mysqlprovider 는 SSM 터널(127.0.0.1:3306)로 DB EC2 에 붙습니다. 이 워크플로우가 도는 상황은 DB EC2 가 비정상일 때라 터널이나mysql_user/mysql_grantrefresh 가 실패하면-replace까지 도달하지 못합니다.-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 로 넘기면 안 됩니다.terraform show -json의 변경 집합 비교어느 쪽이든
resource_changes를 jq 로 검사해db_server·volume_attachmentreplace 외 변경이 있거나aws_ebs_volume에 delete 가 있으면 실패하도록 단언하면 좋겠습니다.🟡 보완
stop_instance_before_detaching = true는 일반 stop 이라 시스템 장애로 stopping 에 걸리면 apply 가 오래 대기합니다. apply 전aws ec2 stop-instances --force단계와 타임아웃, 이미 terminated 된 경우의 분기를 두면 좋겠습니다.InvalidIPAddress.InUse로 실패할 수 있어 대기·재시도 로직이 필요합니다.set -e라 중간 실패 시 MySQL 이 뜨지 않은 채 끝납니다. SSH 접속 후cloud-init status --wait결과부터 확인하는 게 좋겠습니다.systemd-run폴링:--unit=recovery-${run_id}처럼 고유 이름과RemainAfterExit=yes를 주고,systemctl show -p ActiveState,Result,ExecMainStatus로 폴링하면 SSM 세션이 끊겨도 상태를 안정적으로 확인할 수 있습니다.GITHUB_TOKEN으로는 Secret 을 쓸 수 없어 secrets 쓰기 권한이 있는 PAT/App 이 필요합니다.mysql-backup-deploy.yml의 접속·설치 로직을 공용 스크립트(또는 composite action)로 분리하는 것을 제안합니다.terraform-apply(prod),mysql-backup-deploy와 같은 concurrency group 을 공유하면 S3 락 충돌로 중간 실패하는 대신 대기합니다.sub를repo:solid-connection/solid-connection-infra:environment:prod-db로 묶어 승인된 job 만 assume 하도록 하고, plan job 은 별도 읽기 전용 role 을 쓰는 구성을 제안합니다. 권한에iam:PassRole(instance profile)과 state 쓰기가 포함되어야 합니다.체크리스트 수정 제안
prod-dbEnvironment 는 이미 존재합니다(required reviewers, prevent self-review, branch policy). "구성" → "재사용" 으로 바꾸면 됩니다.mysql_setup.sh.tftpl이 그대로 실행되므로(ignore_changes는 update 에만 적용) 스크립트 동작 검증MYSQL_ROOT_PASSWORD가 무시되어, 불일치 시 MySQL 은 떠 있어도 readiness 체크가 실패)ec2-instance-connect가 포함되어 있고 console output 에 SSH host key fingerprint 가 출력되는지Hexeong commentedon Oct 11, 2026
모의 복구 환경 및 모듈 분리 계획 보완
분기 A 구현을 운영 DB에 처음 적용하지 않도록 모의 테스트 구조를 먼저 설계하면서 아래와 같이 계획을 보완합니다.
모의 테스트 환경
별도의
app_stack전체를 만들지 않습니다. 별도 App EC2를 만들면 도메인·인증서·애플리케이션 배포·알림 API까지 복제해야 해 복구 검증보다 환경 구성의 복잡도가 커집니다.대신 기존 stage App은 그대로 두고, 복구 훈련 때만 별도 state의 임시 DB EC2와 데이터 EBS를 생성합니다.
plan-only로 실제 state·IAM·변경 집합까지만 검증합니다.app/db 모듈 분리
prod와 drill이 같은 DB EC2 코드를 사용하도록 기존
app_stack을 app과 DB 하위 모듈로 분리합니다. 기존 환경의 호환성을 유지하기 위해app_stack은 두 모듈을 조립하는 wrapper로 남깁니다.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블록은 없습니다.다만 다음 간접 의존성을 확인합니다.
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 변경 감지 범위에 포함됩니다.검증 단계
No changesNo changesNo changes, 종료 상태이면 기존과 동일한 생성 집합plan-only실행이 구조를 전제로 분기 A 구현을 진행합니다.
1차 구현 결과
feature/87-db-recovery-branch-a브랜치의fd7087a에서 첫 단계인 모듈 분리를 적용했습니다.app_stack은 호환 wrapper로 유지했습니다.modules/app_stack/modules/app으로 이동했습니다.modules/app_stack/modules/db_ec2로 이동했습니다.prevent_destroy를 유지하기 위해 wrapper에 남겼습니다.moved블록으로 기존 API EC2, DB EC2, 볼륨 연결, null_resource의 state 주소를 이전했습니다.mysql_tuning.cnf위치는 변경하지 않았습니다.검증 결과:
0 add, 0 change, 0 destroy0 add, 0 change, 0 destroyterraform validate: 통과모듈 분리로 DB 리소스 주소가 바뀌었으므로 복구 워크플로우의 target은 아래 주소를 사용합니다.
머지 후 apply는 새 plan을 다시 계산하지만 현재 구성에서는 원격 리소스 작업 없이 moved 선언에 따른 state 주소만 기록합니다. PR CI의 Terraform 1.10.5 plan에서도 동일한
0 add, 0 change, 0 destroy인지 최종 확인한 뒤 머지합니다.