Skip to content

prod DB 를 RDS 에서 자체 관리 EC2 MySQL 로 이관 - #899

Merged
m-a-king merged 7 commits into
devfrom
infra/898-prod-db-rds-to-ec2-mysql
Aug 8, 2026
Merged

prod DB 를 RDS 에서 자체 관리 EC2 MySQL 로 이관#899
m-a-king merged 7 commits into
devfrom
infra/898-prod-db-rds-to-ec2-mysql

Conversation

@m-a-king

@m-a-king m-a-king commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Situation

  • prod DB 는 RDS(db.t4g.micro)인데 자동 백업이 꺼져 있었다. 신규 AWS Free Plan 이 backup_retention_period > 0 을 거부해서이고, 그 결과 복구 지점이 수동 스냅샷 1개뿐이었다.
  • 관리형 요금을 내면서 관리형의 핵심 이점인 백업을 못 받는 상태였다. 정가 기준 월 $20.87(인스턴스 $18.25 + gp3 20GB $2.62)이 나가는데, 실제 데이터는 2.1MB / 6,696행이라 규모 대비 과했다.
  • dev 는 이미 EC2 안 docker MySQL 로 돌고 있어, prod 만 관리형에 남아 있던 셈이다.

Task

  • 백업 주기와 보존을 우리가 통제할 수 있는 구조로 옮긴다.
  • 서비스 중단 없이 옮긴다.
  • 문제가 생기면 되돌릴 수 있는 경로를 남긴다.

Action

배치 결정: 전용 박스

실측이 선택지를 좁혔다. prod 앱 박스는 여유 메모리가 563MB 뿐이고, 앱 컨테이너가 이미 캡(768MB)에 100% 붙어 swap 454MB 를 쓰고 있었다.

월 비용 판단
앱 박스 동거 (dev 와 동일) 0 (RDS 제거로 -$20.87) 앱이 이미 캡 포화 + 상시 스왑이라 불가
DB 전용 t4g.micro $13.06 (-$7.81) 채택. 앱 박스 무영향, 장애 격리
박스를 t4g.medium 으로 확대 $30.37 (본전) 절감 이유가 사라짐

사이즈도 micro 여야 의미가 있었다.

타입 실가용 월 비용 판단
t4g.nano 약 400MB $3.80 OS + docker + MySQL 만으로 초과, 상시 스왑
t4g.micro 약 906MB $7.59 채택. dev 의 같은 구성이 111MB / CPU 0.97%
t4g.small 약 1.8GB $15.18 총액이 $20.65 로 RDS($20.87)와 같아져 이관 명분 소멸

인프라 (terraform/db_ec2.tf)

  • 보안그룹, 전용 IAM role, 백업 버킷, SSM 파라미터, 인스턴스를 한 파일에 모았다. 폐기한 rds.tf 와 대칭이라 무엇이 그 자리를 대신하는지 한눈에 드러난다.
  • 3306 은 앱 EC2 SG 에서만 받는다. DB 를 인터넷에 직접 노출하지 않는다.
  • SG 에 ignore_changes 를 두지 않는다. 처음엔 앱 SG 패턴을 따라 넣었으나 사정이 다르다. 앱 SG 는 콘솔로 추가된 접속 IP 가 쌓여 있어 terraform 이 덮으면 배포가 끊기지만, 이 SG 는 새로 만들어 그런 이력이 없고 terraform 이 유일한 관리자다. 그대로 두면 누가 콘솔에서 3306 을 열어도 apply 가 잡아내지 못한다.
  • IAM 은 앱 role 을 공유하지 않고 전용으로 뒀다. 다른 박스들은 "과권한이지만 blast radius 가 작다"는 트레이드오프로 role 을 공유하지만, 이 박스는 데이터 원본과 그 백업을 함께 들고 있어 사고 반경이 가장 크다.
  • EIP 대신 자동 할당 퍼블릭 IP 를 쓴다. 요금이 같고($0.005/h), 앱은 사설 IP 로만 접속하므로 재기동 시 퍼블릭 IP 가 바뀌어도 무관하다.

MySQL 메모리 튜닝

기본 설정으로 띄웠더니 RAM 234MB + swap 185MB = 419MB 로 컨테이너 캡(384m)을 넘겨 상시 스왑 상태였다. 데이터가 2.1MB 인데 과했다.

튜닝 전 튜닝 후
RAM 234 MB 191 MB
swap 185 MB 0 MB
  • performance_schema=OFF 가 가장 큰 몫이었다. 앱 지표는 Micrometer·Grafana 로 따로 받고 있어 DB 내부 계측을 켜 둘 실익이 없다.
  • 버퍼풀 64M (기본 128M). 데이터 총량이 2.1MB 라 64M 에도 전량이 캐시된다.
  • max_connections=60 (기본 151). HikariCP 기본 풀이 10 이고 blue-green 전환 때만 잠시 2배가 된다.
  • 부작용 하나: performance_schema=OFFinformation_schema.PROCESSLIST 가 비어 나온다. 연결 확인은 SHOW PROCESSLIST 를 쓴다.

자격증명 분리

  • root 와 앱 계정이 같은 비밀번호를 쓰고 있었다. 앱 자격증명이 새는 순간 DB 전체 권한까지 함께 넘어가는 구조다. SSM 에 db-root-password 를 신설해 root 전용으로 분리했다. 앱은 admin 계정으로 붙으므로 서비스 영향이 없었고, root 사용처는 백업 스크립트 하나뿐이다.
  • 분리하면서 root@% 가 있다는 것도 드러났다. 이미지의 MYSQL_ROOT_HOST 기본값이 % 라 네트워크 너머에서도 root 접속이 가능했다. 보안그룹이 앱 박스만 통과시키지만 그 박스가 뚫리면 그대로 DB 전체 권한이 넘어가므로, 계정을 제거하고 MYSQL_ROOT_HOST=localhost 를 명시해 재생성 때도 생기지 않게 했다.
  • 이 구조는 기존 RDS 를 그대로 옮긴 것이다. 앱이 여전히 자기 스키마 전체 권한(GRANT ALL ON team3.*)을 가진 계정을 쓰는 것은 남아 있다.

백업

  • 일 1회 mysqldump --single-transaction -> gzip -> S3, cron 04:00 KST. --single-transaction 이라 백업 중에도 서비스가 그대로 돈다.
  • S3 lifecycle 로 30일 자동 만료. 압축 후 약 262KB 라 30일치를 합쳐도 10MB 수준이다.
  • 덤프 산출물이 1KB 미만이면 업로드를 중단한다. 빈 파일이 성공으로 올라가 복구 시점에야 발각되는 걸 막는다.
  • cron 이 백업 실패를 삼키고 있었다. 스크립트를 logger 로 파이프하면 파이프라인의 종료 코드가 logger 의 것(항상 0)이 되어, 백업이 실패해도 성공으로 보인다. bash -o pipefail 로 감쌌다.
  • 프로비저닝 스크립트가 두 번째 실행부터 죽고 있었다. cron 갱신부에서 grep -v 가 0건 매칭으로 종료 코드 1 을 반환하는데 set -euo pipefail 이 그걸 실패로 처리했다. 첫 실행은 crontab 이 비어 이 경로를 안 타므로 드러나지 않았다. 등록/갱신 분기를 없애고 || true 로 받아 멱등을 단순화했다.
  • 검증 중 박스가 자기 백업을 못 읽어 복구가 막히는 것을 발견해 IAM 에 GetObject·ListBucket 을 더했다. DeleteObject 는 끝까지 주지 않는다. 이 버킷이 유일한 복구 경로라 침해 시에도 지워지지 않아야 한다.

데이터 이관 (무중단)

  • 최근 유입이 10분당 약 0.17행(7일 194행)이라 앱을 멈추지 않고 덤프했다.
  • RDS 마스터 계정에 RELOAD 권한이 없어 FLUSH TABLES WITH READ LOCK 에서 막혔다. GTID 수집이 원인이라 --set-gtid-purged=OFF --no-tablespaces 로 우회했다.
  • 복원 후 14개 테이블의 AUTO_INCREMENT 를 +1000 띄웠다. 이관 창에서 RDS 에 들어간 행을 나중에 원래 id 그대로 넣을 수 있게 번호 구간을 비워 두는 것이다. 이게 없으면 새 DB 가 같은 번호를 재발급해 보정 자체가 PK 충돌로 막힌다.

관측 회복

RDS 를 걷어내며 관측이 후퇴한다는 것을 뒤늦게 알아챘다. 관리형일 때 CloudWatch 가 자동으로 주던 것이 EC2 에는 없다.

지표 RDS EC2 기본
CPU 있음 있음
메모리 FreeableMemory 없음
DB 연결 수 DatabaseConnections 없음
디스크 여유 FreeStorageSpace 없음 (EBS IO 만)
쿼리 지연 Read/WriteLatency 없음
  • 다른 박스(core·extractor·renderer)와 같은 alloy 블록을 붙여 호스트 메트릭을 Grafana 로 보낸다. 팀이 실제로 보는 곳에 쌓여야 관측이 의미가 있어, CloudWatch Agent 대신 이쪽을 택했다.
  • 우려했던 무료 한도 부담은 실측상 작았다: 519 series, alloy 메모리 54MB, 호스트 여유 482MB, 전송 재시도·실패 0.
  • MySQL 컨테이너 로그는 보내지 않는다. 블록의 수집 대상이 docker label opt-in 인데, mysql·redis 로그를 제외하는 기존 정책과 결이 같아 여기서도 유지한다.

데이터 소멸 경로 차단

인스턴스가 사라지는 경로를 층별로 막았다.

설정 막는 것
terraform 속성 ignore_changes = [ami, user_data] 속성 변경으로 인한 파괴·재생성
terraform 계획 prevent_destroy = true destroy 계획 자체를 거부
AWS API disable_api_termination = true 콘솔·CLI·API 직접 종료
  • 앞의 두 층만으로는 부족했다. prevent_destroy 는 terraform 안에서만 유효해 콘솔에서 종료 버튼을 누르는 경로가 열려 있었다.
  • 루트 볼륨 delete_on_termination = false 는 자동 복구 장치가 아니다. 볼륨은 남지만 새 인스턴스에 자동으로 붙지 않는다. 박스를 다시 만들면 새 루트 볼륨으로 떠서 provision-db.sh 가 빈 MySQL 을 기동하므로, 데이터가 남아 있는데도 빈 상태로 운영될 수 있다. 그 한계를 코드 주석에 명시했다. 주 복구 경로는 S3 백업이고, 볼륨 보존은 백업까지 실패했을 때의 마지막 수동 회수 수단이다.
  • 같은 이유로 MySQL 기동·백업 cron 설치를 user_data 가 아니라 별도 멱등 스크립트(provision-db.sh)에 뒀다. user_data 는 첫 부팅에만 돌아 수정 반영에 인스턴스 교체가 필요하다.

RDS 폐기

  • 전환 후 관찰 기간을 두고, 종료 조건을 채운 뒤 지웠다. 조건은 첫 자동 백업의 무인 성공이관 창 유입 행 확인이었다.
  • 삭제는 두 단계로 나눴다. deletion_protection 이 true 인 동안은 terraform 이 삭제를 시도조차 못 하므로, 먼저 그것만 false 로 apply 한 뒤 리소스를 제거했다. 그 마찰은 의도된 것이라 한 번에 지우지 않았다.
  • 함께 걷어낸 것: RDS 보안그룹(db_ec2.tf 의 것이 대신한다), DB 서브넷 그룹, rds_endpoint·rds_address output. private subnet 자체는 vpc.tf 에 남긴다 (비용이 없고 향후 용도가 생길 수 있다).

Result

이관부터 폐기까지 완료했고, 각 단계를 실측으로 검증했다.

검증 결과
행 수 전수 대조 (COUNT(*)) 23테이블 6,696행 전부 일치
새 DB 쿼리 처리 30초간 264건 (실 트래픽)
구 RDS 잔여 앱 연결 0건
Flyway 이력 58건 이관, 재적용 없음
첫 자동 백업 (cron) 사람 손 없이 성공 (19:00 UTC, 2초, 269KB)
이관 창 유입 행 0건 (RDS 마지막 쓰기가 덤프보다 3시간 43분 앞섬)
root 자격증명 분리 앱 비번으로 root 접속 시 Access denied, root@% 제거
cron 실패 전달 기존 구조 종료코드 0, 수정 후 1 (대조 확인)
종료 보호 AWS 실측 DisableApiTermination = True
관측 519 series 전송, 재시도·실패 0
RDS 폐기 인스턴스 0개, 최종 스냅샷 team3-dev-mysql-final 확보
  • 월 비용은 $20.87 에서 $13.06 으로 내려간다.
  • AUTO_INCREMENT 를 띄워 둔 보험은 쓰이지 않았다. 이관 창에 쓰기가 아예 없어 보정할 행이 없었기 때문이다. 다만 그 사실은 사후에야 알 수 있었으므로, 번호 구간 확보는 여전히 필요한 조치였다.

검토 후 채택하지 않은 것

사유
S3 버킷 versioning 객체 키가 타임스탬프라 덮어쓰기가 구조적으로 없고, 박스에 DeleteObject 도 주지 않아 versioning 이 막을 사고가 없다. 켜면 만료를 current/noncurrent 두 축으로 관리해야 해 복잡도만 는다
데이터 볼륨을 aws_ebs_volume 로 분리 주 복구 경로가 S3 백업이고 그쪽은 검증을 마쳤다. 이미 운영 트래픽을 받는 DB 의 저장 구조를 바꾸는 작업이라 위험 대비 이득이 작다
CloudWatch Agent (관측) 메모리·디스크를 RDS 시절과 같은 곳에 쌓지만, 그 시절에도 아무도 안 보던 곳이라 같은 공백이 반복된다

후속

  • 백업 실패 알림이 없다. 지금은 journalctl -t piki-db-backup 으로 사람이 확인하는 구조다. 이것이 유일한 복구 경로이므로 알림이 필요하지만, Grafana 무료 한도가 빠듯해 이번 범위에서 뺐다. 위 cron 수정으로 실패 코드가 올라오게 되어, 알림을 붙이면 실제로 동작한다.
  • MySQL 내부 지표가 없다. 연결 수·쿼리 지연은 mysqld_exporter 가 별도로 필요하다. 이번엔 호스트 메트릭까지만 붙였다.
  • 앱 계정 최소권한화. 앱이 자기 스키마 전체 권한을 가진 계정을 쓰는 구조가 남아 있다. 바꾸려면 SSM 변경과 재배포가 필요해 분리했다.
  • apply 중 이 작업과 무관한 드리프트 1건을 발견했다. github_ecr_push role 의 신뢰 정책이 실제 AWS 는 와일드카드(repo:TeamPiKi/core:*), 코드는 환경 2개 제한으로 어긋나 있다. 코드 쪽이 더 엄격하지만 적용 시 배포가 끊길 수 있어 -target 으로 제외했다. 별도로 다뤄야 한다.

연관 이슈

Summary by CodeRabbit

  • 새 기능

    • 자체 관리형 MySQL 데이터베이스 환경을 추가했습니다.
    • 암호화된 저장소에 데이터베이스를 자동 백업하고 설정된 기간 동안 보관합니다.
    • 인스턴스 유형, 저장 공간, 보존 기간 및 백업 저장소 이름을 설정할 수 있습니다.
  • 운영 개선

    • 데이터베이스 설치, 초기화, 상태 확인 및 백업을 자동화했습니다.
    • 백업 오류나 비정상 파일의 업로드를 방지합니다.
    • 데이터베이스 접근 제어와 네트워크 보안을 강화했습니다.
    • 데이터베이스 및 백업 저장소 정보를 인프라 출력에 추가했습니다.

- 신규 AWS Free Plan 이 RDS 의 backup_retention_period > 0 을 거부해 자동 백업·PITR 을 못 쓰는 상태였다. 관리형 비용을 내면서 관리형의 핵심 이점을 못 받고 있어, 백업을 직접 통제하는 쪽으로 전환한다
- 앱 박스 동거를 먼저 검토했으나 prod 앱이 메모리 캡에 100% 붙어 스왑 454MB 를 쓰고 있어 불가능했다. 별도 박스로 분리한다
- 사이즈는 t4g.micro. dev 의 같은 구성이 111MB / CPU 0.97% 를 쓰고 데이터가 2.1MB 라 버퍼풀에 전량이 올라간다. small 로 올리면 월 $20.65 로 RDS($20.87)와 같아져 이관 명분이 사라지고, nano 는 실가용 400MB 라 상시 스왑이다
- 기본 설정으로 띄웠더니 RAM 234MB + swap 185MB 로 캡(384m)을 넘겨 상시 스왑이었다. performance_schema=OFF · 버퍼풀 64M · max_connections 60 으로 191MB(swap 0)까지 낮췄다
- 백업 검증 중 박스가 자기 백업을 못 읽어 복구가 막히는 걸 발견해 IAM 에 GetObject·ListBucket 을 더했다. Delete 는 계속 주지 않는다 - 이 버킷이 유일한 복구 경로라 침해 시에도 지워지지 않아야 한다
- 데이터 소멸 경로를 막기 위해 루트 볼륨 delete_on_termination=false 와 lifecycle ignore_changes=[ami, user_data] 를 걸었다. 둘 다 terraform 이 인스턴스를 파괴·재생성하는 사유이고, 이 박스에서 그것은 곧 DB 소멸이다
- MySQL 기동·백업 cron 설치를 user_data 가 아니라 별도 멱등 스크립트(provision-db.sh)에 둔 이유도 같다. user_data 는 첫 부팅에만 돌아 수정 반영에 인스턴스 교체가 필요하다
@m-a-king m-a-king added the infra 운영 환경 (IaC·클라우드 리소스·secret·배포 workflow) label Aug 7, 2026
@m-a-king m-a-king linked an issue Aug 7, 2026 that may be closed by this pull request
@m-a-king m-a-king self-assigned this Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc4f6f50-7eca-4a0e-a31e-e730f2949d12

📥 Commits

Reviewing files that changed from the base of the PR and between 95f3bcb and 4ff23c4.

📒 Files selected for processing (5)
  • infra/scripts/provision-db.sh
  • terraform/db_ec2.tf
  • terraform/outputs.tf
  • terraform/rds.tf
  • terraform/security_groups.tf
💤 Files with no reviewable changes (2)
  • terraform/rds.tf
  • terraform/outputs.tf
🚧 Files skipped from review as they are similar to previous changes (1)
  • terraform/db_ec2.tf

Walkthrough

자체 관리 MySQL용 DB EC2와 Docker 런타임을 추가했다. SSM에서 자격증명을 조회하고, 매일 mysqldump 결과를 gzip으로 압축해 S3에 업로드한다. Terraform은 보안 그룹, IAM, 백업 버킷, 인스턴스와 관련 출력값을 구성한다.

Changes

자체 관리 MySQL 전환 기반

Layer / File(s) Summary
AWS 리소스와 백업 계약
terraform/variables.tf, terraform/db_ec2.tf, terraform/security_groups.tf, terraform/rds.tf, terraform/outputs.tf, terraform/terraform.tfvars.example
DB EC2 설정 변수와 백업 보존 기간을 추가했다. DB 전용 보안 그룹, IAM 역할, S3 백업 버킷, SSM 파라미터를 구성했다. 기존 RDS 리소스와 출력값을 제거했다.
DB EC2 런타임 프로비저닝
terraform/db_ec2.tf, infra/scripts/provision-db.sh
Docker와 2GiB swap을 설치하는 DB EC2를 추가했다. 프로비저닝 스크립트는 MySQL 컨테이너를 멱등적으로 생성하고 readiness 및 실제 인증을 확인한다. 백업 cron과 Alloy 수집기 설치를 구성한다.
MySQL 논리 백업 실행
infra/scripts/db-backup.sh
SSM에서 DB 설정을 조회한다. mysqldump --single-transaction 결과를 gzip으로 압축하고 파일 크기를 검증한다. 검증된 백업만 S3에 업로드하며, 업로드 실패 시 로컬 파일을 보존한다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Cron
  participant BackupScript
  participant SSM
  participant MySQL
  participant S3

  Cron->>BackupScript: 19:00 UTC 백업 실행
  BackupScript->>SSM: DB 이름·비밀번호·버킷 조회
  BackupScript->>MySQL: mysqldump 실행
  BackupScript->>S3: gzip 백업 업로드
  S3-->>BackupScript: 업로드 결과 반환
Loading

Assessment against linked issues

Objective Addressed Explanation
DB 전용 EC2, 앱 전용 MySQL 접근, Docker MySQL 구성 [#898] DB EC2, 전용 보안 그룹, Docker MySQL 구성은 추가했다. 제공된 변경 요약만으로는 EIP와 mysql:8.4 이미지 사용을 확인할 수 없다.
일 1회 논리 백업과 S3 보존 정책 구성 [#898]
기존 RDS 데이터의 무중단 이관과 복원 검증 [#898] 덤프 복원, AUTO_INCREMENT 보정, 행 수 대조 로직이 추가되지 않았다.
DB 호스트 전환, 앱 재배포, 관찰 후 RDS 종료 [#898] /piki-core/prod/db-host 변경, 앱 재배포, RDS 관찰 및 종료 절차가 추가되지 않았다.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch infra/898-prod-db-rds-to-ec2-mysql

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@infra/scripts/provision-db.sh`:
- Around line 37-40: Separate the MySQL root credential from the application
credential: update the provisioning flow around DB_NAME/DB_PASSWORD to load a
distinct SecureString parameter named db-root-password and use it only for
MYSQL_ROOT_PASSWORD, while retaining DB_PASSWORD for the application account.
Update db-backup.sh to retrieve db-root-password instead of db-password for root
authentication, and ensure the new parameter is referenced consistently.
- Around line 102-109: Update the cron setup around CRON_LINE so it invokes a
Bash wrapper with pipefail instead of directly piping piki-db-backup.sh to
logger. Ensure the wrapper sends backup output to journald while preserving and
returning piki-db-backup.sh’s exit status, allowing cron monitoring to detect
failures.

In `@terraform/db_ec2.tf`:
- Around line 106-110: Enable S3 versioning for the bucket resource
aws_s3_bucket.db_backup and add a lifecycle rule that expires noncurrent object
versions after the configured retention period. Keep the existing backup access
policy, and ensure the lifecycle configuration applies to the backup objects.
- Around line 60-62: Remove the lifecycle ignore_changes setting for ingress so
Terraform detects and removes unauthorized MySQL access rules. Keep ingress
managed by the security group resource, and manage any required SSH exceptions
through Terraform variables and rules rather than ignoring ingress changes.
- Around line 265-270: Update the lifecycle block for the DB EC2 instance to add
prevent_destroy = true alongside the existing ignore_changes settings. Preserve
the current AMI and user_data ignore behavior while preventing Terraform destroy
or replacement operations outside the documented migration procedure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 769812ed-4125-4b0b-8b7d-8e8a705df465

📥 Commits

Reviewing files that changed from the base of the PR and between 702dbc2 and bccb77a.

📒 Files selected for processing (6)
  • infra/scripts/db-backup.sh
  • infra/scripts/provision-db.sh
  • terraform/db_ec2.tf
  • terraform/outputs.tf
  • terraform/terraform.tfvars.example
  • terraform/variables.tf

Comment thread infra/scripts/provision-db.sh
Comment thread infra/scripts/provision-db.sh Outdated
Comment thread terraform/db_ec2.tf Outdated
Comment thread terraform/db_ec2.tf
Comment thread terraform/db_ec2.tf
@github-actions
github-actions Bot requested a review from sevineleven August 7, 2026 12:32
- cron 이 백업 스크립트를 logger 로 파이프해, 파이프라인 종료 코드가 항상 마지막 명령(logger)의 0 이 되고 있었다. 백업이 실패해도 성공으로 보여 실패 감지를 붙일 수 없는 구조라, bash -o pipefail 로 감싸 실패 코드가 올라오게 했다. 박스에서 대조 확인했다 - 기존 구조는 종료코드 0, 수정 후 1
- DB 보안그룹의 ingress ignore_changes 를 걷어냈다. 앱 SG 를 따라 넣었지만 사정이 다르다. 앱 SG 는 콘솔로 추가된 접속 IP 가 쌓여 있어 terraform 이 덮으면 배포·SSH 가 끊기지만, 이 SG 는 방금 만들어 그런 이력이 없고 terraform 이 유일한 관리자다. 그대로 두면 누가 콘솔에서 3306 을 열어도 apply 가 잡아내지 못한다 (#223 이 기존 SG 의 ignore_changes 를 걷어내는 방향이라 새 리소스가 역행할 이유도 없다)
- 인스턴스에 prevent_destroy 를 더했다. ignore_changes 가 막는 것은 속성 변경으로 인한 재생성뿐이라, destroy 를 직접 부르거나 다른 사유로 replace 가 잡히면 그대로 지워진다. RDS 의 deletion_protection 과 같은 결의 마지막 층이다
- plan 으로 확인한 결과 세 변경 모두 실제 AWS 리소스에는 영향이 없다(No changes). SG 는 drift 가 없었고 lifecycle 은 terraform 메타 설정이다

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@terraform/db_ec2.tf`:
- Around line 268-275: Update the EC2 instance resource containing this
lifecycle block to enable AWS-level termination protection with
disable_api_termination, preserving the existing prevent_destroy behavior.
Ensure the root EBS data survives replacement and is reattached to the
replacement instance before provisioning MySQL, using an explicit preserved
volume and attachment if necessary. Keep termination protection enabled by
default and require an approved workflow to disable it for migration or
retirement.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a26ad71-2be2-46e2-9882-ad9a227d9046

📥 Commits

Reviewing files that changed from the base of the PR and between bccb77a and 0ae6feb.

📒 Files selected for processing (2)
  • infra/scripts/provision-db.sh
  • terraform/db_ec2.tf
🚧 Files skipped from review as they are similar to previous changes (1)
  • infra/scripts/provision-db.sh

Comment thread terraform/db_ec2.tf
- root 와 앱 계정이 같은 비밀번호(db-password)를 쓰고 있었다. 앱 자격증명이 새는 순간 DB 전체 권한까지 함께 넘어가는 구조라, SSM 에 db-root-password 를 신설해 root 전용으로 분리했다. 앱은 admin 계정으로 붙으므로 서비스 영향이 없고, root 사용처는 백업 스크립트 하나뿐이다
- 분리하면서 root@% 가 있다는 것도 드러났다. 이미지의 MYSQL_ROOT_HOST 기본값이 % 라 네트워크 너머에서도 root 로 붙을 수 있었다. 보안그룹이 앱 박스만 통과시키지만 그 박스가 뚫리면 그대로 DB 전체 권한이 넘어가므로, 계정을 제거하고 MYSQL_ROOT_HOST=localhost 를 명시해 재생성 때도 안 생기게 했다. root 는 컨테이너 안에서만 쓰이니 잃는 게 없다
- 검증: 새 비번으로 root 접속 성공, 앱 비번으로는 거부(Access denied), 계정 목록에 root@% 없음, 백업 수동 실행 성공(262KB), 앱 헬스 UP + DB 오류 0건
- 기존 컨테이너에는 ALTER USER 와 DROP USER 로 적용했다. MYSQL_ROOT_PASSWORD 는 컨테이너 최초 생성 때만 쓰여, 스크립트 수정만으로는 이미 뜬 인스턴스에 반영되지 않는다
- prevent_destroy 는 terraform 이 파괴 계획을 거부하게 할 뿐이라, 콘솔·CLI·API 로 직접 종료하는 경로는 그대로 열려 있었다. disable_api_termination 을 더해 두 층에서 막는다
- 함께 root 볼륨 보존의 한계를 주석에 박았다. delete_on_termination=false 는 볼륨을 남길 뿐, 남은 볼륨이 새 인스턴스에 자동으로 붙지는 않는다. 박스를 다시 만들면 새 루트 볼륨으로 떠서 provision-db.sh 가 빈 MySQL 을 기동하므로, 데이터가 있는데도 빈 상태로 운영될 수 있다. 주 복구 경로는 S3 백업이고 볼륨 보존은 백업까지 실패했을 때의 마지막 수동 회수 수단이라는 것을 코드가 말하게 했다
- 적용 직후 describe-instance-attribute 가 한동안 false 를 반환해 미적용으로 오인했다. CloudTrail 상 요청은 에러 없이 성공해 있었고, 시간이 지나자 True 로 반영됐다(AWS 반영 지연). 실측 True + plan No changes 로 정합을 확인했다
- EBS 볼륨을 aws_ebs_volume + aws_volume_attachment 로 분리하는 안은 채택하지 않았다. 운영 중인 DB 의 저장 구조를 바꾸는 작업이라 범위가 크고, 주 복구 경로인 S3 백업이 이미 검증돼 있다
- RDS 를 걷어내면서 관측이 후퇴했다는 것을 뒤늦게 확인했다. 관리형일 때는 CloudWatch 가 메모리(FreeableMemory)·연결 수·디스크 여유·쿼리 지연을 자동으로 줬는데, EC2 기본 메트릭에는 메모리조차 없다. 박스가 죽어가는 걸 알아챌 수단이 아예 없어, 다른 박스(core·extractor·renderer)와 같은 alloy 블록을 붙인다
- 실측으로 부담을 확인했다. 519 series, alloy 메모리 54MB, 호스트 여유 482MB. Grafana 무료 한도(10k)에 대해 여유가 있고, 전송도 정상이다(39KB 송신, 재시도·실패 0)
- MySQL 컨테이너 로그는 보내지 않는다. 블록의 수집 대상이 docker label opt-in 인데, mysql·redis 로그를 제외하는 기존 정책과 결이 같아 여기서도 유지한다. 내부 지표(연결 수·쿼리 지연)는 mysqld_exporter 가 필요한 별도 작업이다
- cron 갱신부가 두 번째 실행부터 조용히 죽고 있었다. crontab 에 우리 줄만 있으면 grep -v 의 출력이 0건이라 종료 코드가 1 이고, set -euo pipefail 이 그것을 실패로 보고 스크립트를 끊는다. 첫 실행은 crontab 이 비어 이 경로를 안 타므로 드러나지 않았다. 등록/갱신 분기를 없애고 || true 로 받아 멱등을 단순화했다 - 2회 연속 실행으로 exit=0 확인
- alloy 블록은 core 에 복사하지 않고 실행 때마다 infra 에서 올린다. 그 블록의 SSOT 는 TeamPiKi/infra 이고, 사본을 두면 조용히 갈라진다

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
infra/scripts/db-backup.sh (1)

50-57: 🩺 Stability & Availability | 🔵 Trivial

백업 성공 조건에 복구 검증을 추가하세요.

파일 크기가 1024바이트 이상이고 S3 업로드가 성공해도 실제 복구 가능성은 확인되지 않습니다. MySQL 권한, 이벤트, 루틴 또는 향후 스키마 변경 때문에 복원이 실패해도 현재 경로는 정상 백업으로 기록합니다.

격리된 MySQL 8.4 컨테이너에 주기적으로 백업을 복원하세요. 복원 후 테이블 목록과 핵심 행 수를 검증하고 실패를 알림으로 연결하세요.

As per path instructions, "데이터 정합성"과 "운영 리스크"를 우선 검토했습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/scripts/db-backup.sh` around lines 50 - 57, 백업 성공 조건에 파일 크기와 S3 업로드뿐
아니라 실제 복구 검증을 포함하세요. db-backup.sh의 WORK_DIR/FILE 처리 흐름에 격리된 MySQL 8.4 컨테이너로 백업을
복원하는 단계를 추가하고, 복원 후 테이블 목록과 핵심 행 수를 검증하세요. 복원 또는 검증이 실패하면 업로드 성공 여부와 관계없이 기존 log
알림을 통해 실패를 전달하고 비정상 종료하도록 하세요.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@infra/scripts/provision-db.sh`:
- Around line 138-140: Update the Alloy provisioning check around ALLOY_DIR and
provision-alloy.sh so a missing required script fails the provisioning flow with
exit 1 instead of continuing. Only allow the installation to be skipped when an
explicit --skip-alloy option is provided, while preserving the existing Alloy
setup path otherwise.
- Around line 44-46: Update the existing-container handling in the MySQL
provisioning flow to validate the rotated SSM credentials with mysqladmin ping
or database connections using both the root and app accounts before treating
provisioning as successful. If either check fails, do not exit successfully; run
the credential-rotation procedure using ALTER USER ... IDENTIFIED BY ... and
FLUSH PRIVILEGES, then verify both accounts with the new passwords.

---

Nitpick comments:
In `@infra/scripts/db-backup.sh`:
- Around line 50-57: 백업 성공 조건에 파일 크기와 S3 업로드뿐 아니라 실제 복구 검증을 포함하세요. db-backup.sh의
WORK_DIR/FILE 처리 흐름에 격리된 MySQL 8.4 컨테이너로 백업을 복원하는 단계를 추가하고, 복원 후 테이블 목록과 핵심 행 수를
검증하세요. 복원 또는 검증이 실패하면 업로드 성공 여부와 관계없이 기존 log 알림을 통해 실패를 전달하고 비정상 종료하도록 하세요.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fdef8ca-6f60-4790-9fb8-3bce1fb21b70

📥 Commits

Reviewing files that changed from the base of the PR and between 0ae6feb and 95f3bcb.

📒 Files selected for processing (3)
  • infra/scripts/db-backup.sh
  • infra/scripts/provision-db.sh
  • terraform/db_ec2.tf
🚧 Files skipped from review as they are similar to previous changes (1)
  • terraform/db_ec2.tf

Comment thread infra/scripts/provision-db.sh
Comment thread infra/scripts/provision-db.sh Outdated
- 자체 관리 MySQL 로 옮긴 뒤 관찰 기간을 뒀고, 종료 조건이 모두 충족돼 폐기한다. 첫 자동 백업이 사람 손 없이 성공했고(19:00 UTC, 2초), 이관 창에서 RDS 에 들어간 행은 0건이었다(마지막 쓰기가 덤프보다 3시간 43분 앞섬)
- 삭제는 두 단계로 나눴다. deletion_protection 이 true 인 동안은 terraform 이 삭제를 시도조차 못 하므로 먼저 false 로 apply 한 뒤 리소스를 제거했다. 이 마찰은 의도된 것이라 한 번에 지우지 않았다
- 최종 스냅샷 team3-dev-mysql-final 이 남았다(20GB, available). 폐기 후의 마지막 되돌림 지점이다
- 함께 사라진 것: RDS 보안그룹(db_ec2.tf 의 것이 대신한다), DB 서브넷 그룹, rds_endpoint·rds_address output. private subnet 자체는 vpc.tf 에 남긴다 - 비용이 없고 향후 다른 용도가 생길 수 있다
- 앱 무영향을 확인했다(헬스 UP, DB 오류 0건). 앱은 이미 새 DB 만 보고 있어 이 삭제가 트래픽 경로에 닿지 않는다
- MYSQL_* 환경변수는 컨테이너 최초 생성 때만 쓰인다. 그 뒤 SSM 값을 바꾸면 DB 안 계정은 옛 비밀번호로 남아 둘이 조용히 갈라지고, 그 상태로 스크립트가 성공하면 나중에 그 자격증명을 쓰는 쪽(앱 배포·백업 cron)이 대신 터진다. readiness 뒤에 root·app 두 계정으로 실제 로그인을 시도해 어긋나면 회전 절차를 안내하고 중단한다
- 자동 회전(ALTER USER)까지는 하지 않는다. 앱 계정 비밀번호를 스크립트가 임의로 바꾸면 붙어 있던 커넥션이 끊기므로, 회전은 사람이 배포 창에서 할 일이다
- alloy 블록이 없을 때 경고만 하고 넘어가던 것을 실패로 바꿨다. 관측이 빠진 채 "프로비저닝 완료"가 찍히면 그 공백을 아무도 모른다 - 실제로 이 박스는 관측 없이 며칠 돌 뻔했다. 의도적 생략은 --skip-alloy 로 명시한다
- 박스에서 네 경로를 확인했다: 정상 exit=0(자격증명 검증 통과 + alloy 기동), alloy 누락 exit=1, --skip-alloy exit=0, 알 수 없는 인자 exit=2. 검증 로직 자체도 틀린 비밀번호로 대조해 exit=1 이 나오는 것을 확인했다
@m-a-king
m-a-king merged commit 498d7cd into dev Aug 8, 2026
14 checks passed
@m-a-king
m-a-king deleted the infra/898-prod-db-rds-to-ec2-mysql branch August 8, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

infra 운영 환경 (IaC·클라우드 리소스·secret·배포 workflow)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

prod DB 를 RDS 에서 자체 관리 EC2 MySQL 로 이관

1 participant