Repository navigation
feat: [복구 분기 A] 로컬 볼륨 정상 시 DB EC2 재구성 워크플로우 구현 #87
Description
Activity
- added a parent issue
on 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까지 도달하지 못합니다.-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_attachmentreplace 외 변경이 있거나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-dbEnvironment 는 이미 존재합니다(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 가 출력되는지
- 교체 시 현재 코드의
Hexeong commented
on Oct 11, 2026 ContributorAuthorMore actions모의 복구 환경 및 모듈 분리 계획 보완
분기 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 동작은 이번 공통화 범위에 넣지 않습니다.
검증 단계
- 모듈 분리 후 prod 전체 plan:
No changes - 모듈 분리 후 stage 전체 plan:
No changes - load-test plan: 환경이 실행 중이면
No changes, 종료 상태이면 기존과 동일한 생성 집합 - 임시 drill DB 생성·삭제
- drill에서 A1(정상 stop), A2(Terraform 밖 terminate), A3(cloud-init 실패), 잘못된 입력/변경 집합 거부 시나리오 실행
- 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인지 최종 확인한 뒤 머지합니다.
어떤 기능인가요?
인스턴스(compute)만 문제인 경우를 다룹니다. 볼륨에 최신 데이터가 그대로 있으므로 S3 백업으로 복원하지 않습니다. dump를 덮어쓰면 그 이후 데이터가 사라집니다. 커밋 기준 RPO는 0입니다.
분기 B(#88)보다 먼저 진행합니다. 승인 게이트, Terraform 교체, SSH 접속, host key 처리, 백업 재설치 같은 두 분기 공통 기반을 이 이슈에서 만들고, 분기 B는 그 위에 볼륨 교체와 S3 복원을 더합니다.
private IP가 고정(#86)되어 있으므로 Parameter Store 갱신과 API 서버 재배포는 필요 없습니다.
실행 흐름
설계 결정
Terraform 실행 범위는
-target으로 DB 리소스에 한정합니다. prod의mysqlprovider는127.0.0.1:3306SSM 터널로 DB EC2에 붙고,terraform-apply.yml도 apply 전에 이 터널부터 엽니다. 이 워크플로우는 DB EC2가 비정상일 때 실행되므로 터널이나mysql_user/mysql_grantrefresh가 실패하면-replace까지 가지 못합니다.-target은 dependents를 제외하므로 mysql provider를 호출하지 않습니다(PR #86 작업 때 로컬 plan으로 확인).교체 전에 데이터 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를 입력값으로 받을 수 있게 합니다. 기존 워크플로우의 동작은 바꾸지 않습니다.공통 기반 (분기 B에서 재사용)
workflow_dispatch워크플로우를 만들고 입력값으로 분기(A/B), 확인용 대상 인스턴스 ID, 복구 사유를 받습니다. 이 이슈에서는 분기 A만 동작하고 분기 B 입력은 fail-fast 처리합니다.prod-dbEnvironment를 재사용해 승인을 받습니다(required reviewers, prevent self-review, branch policy 설정됨).sub를repo:solid-connection/solid-connection-infra:environment:prod-db로 묶어 승인된 job만 assume합니다. 권한에iam:PassRole(instance profile), state 쓰기, 스냅샷 생성이 포함되어야 합니다.resource_changes단언 스크립트를 작성해 plan job과 apply job 모두에서 실행합니다.terraform-apply.yml(prod),mysql-backup-deploy.yml과 같은 concurrency group을 씁니다. 현재terraform-apply.yml에는 concurrency 설정이 없으므로 함께 추가합니다. 지금은 S3 네이티브 락에 걸리면 대기하지 않고 실패합니다.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 전용
aws ec2 stop-instances --force로 정지합니다. 타임아웃을 두고, 이미 terminated 상태면 건너뜁니다.stop_instance_before_detaching = true는 일반 stop이라 시스템 장애로 stopping에 걸리면 apply가 오래 대기하기 때문입니다.-replace와-target으로 apply합니다. 고정 IP를 쓰는 기존 ENI가 늦게 해제되어InvalidIPAddress.InUse가 나면 대기 후 재시도합니다.cloud-init status --wait결과부터 확인합니다. user_data는set -euo pipefail이라 중간에 실패하면 MySQL이 뜨지 않은 채 끝납니다.사전 확인 (리허설에서 함께 확인)
코드의 DB AMI 검증: 교체하면 운영 인스턴스와 다른 AMI로 뜹니다. DB EC2는 인터넷 경로가 없어 이미지를 받아 올 수 없으므로 AMI에 필요한 것이 모두 들어 있어야 합니다.
ami-0d45c1f91adea31b8(2026-07-09)solid-connection-db-mysql-8.4.8-arm64-ubuntu24.04ami-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교체하면 현재 코드의
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)가 다시 할당되는지 확인합니다.완료 조건
참고할만한 자료(선택)
-replaceplan 결과.github/workflows/mysql-backup-deploy.ymlmodules/app_stack/scripts/mysql_setup.sh.tftpl