diff --git a/infra/scripts/db-backup.sh b/infra/scripts/db-backup.sh new file mode 100755 index 00000000..f3799336 --- /dev/null +++ b/infra/scripts/db-backup.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# DB 박스 논리 백업 — mysqldump 를 gzip 해 S3 에 올린다 (#898). +# +# cron 이 하루 한 번 호출한다(설치는 provision-db.sh). RDS 를 걷어내면서 사라진 관리형 백업을 +# 대신하는 유일한 복구 경로이므로, 실패를 조용히 넘기지 않고 로그에 남기고 비정상 종료한다. +# +# --single-transaction 이 핵심이다. InnoDB 의 일관된 스냅샷을 트랜잭션으로 떠서 테이블 락을 +# 걸지 않으므로, 백업 중에도 서비스는 그대로 읽고 쓴다. +set -euo pipefail + +AWSCLI_IMAGE="public.ecr.aws/aws-cli/aws-cli:2.35.21" +CONTAINER="piki-prod-mysql" +REGION="ap-northeast-2" +SSM_PREFIX="/piki-core/prod" +WORK_DIR="/var/tmp/piki-db-backup" + +log() { echo "[db-backup] $(date -u +%Y-%m-%dT%H:%M:%SZ) $*"; } + +ssm_param() { + docker run --rm --network host "$AWSCLI_IMAGE" ssm get-parameter \ + --name "${SSM_PREFIX}/$1" --with-decryption \ + --region "$REGION" --query Parameter.Value --output text +} + +DB_NAME="$(ssm_param db-name)" || { log "SSM db-name 조회 실패"; exit 1; } +# 덤프는 root 로 뜬다(전 스키마 접근). root 비밀번호는 앱 계정(db-password)과 분리돼 있으므로 +# 여기서 db-root-password 를 읽는다 — 앱 자격증명이 새도 백업 경로의 권한은 함께 넘어가지 않는다. +DB_ROOT_PASSWORD="$(ssm_param db-root-password)" || { log "SSM db-root-password 조회 실패"; exit 1; } +BUCKET="$(ssm_param db-backup-bucket)" || { log "SSM db-backup-bucket 조회 실패"; exit 1; } + +TS="$(date -u +%Y%m%dT%H%M%SZ)" +FILE="${DB_NAME}-${TS}.sql.gz" +mkdir -p "$WORK_DIR" + +# 덤프 → gzip. root 로 뜬다(스키마 전체와 routine·trigger 까지 읽어야 한다). +# --databases 를 쓰면 CREATE DATABASE / USE 문이 함께 담겨 빈 서버에 그대로 복원된다. +# --routines --triggers --events: 스키마 외 객체까지 포함해 "이 파일 하나면 복구된다"를 지킨다. +# +# 파이프 중간(mysqldump)의 실패를 놓치지 않도록 set -o pipefail 이 위에서 켜져 있다 — +# 이게 없으면 덤프가 깨져도 gzip 성공 코드만 보고 손상된 파일을 업로드한다. +log "덤프 시작: ${DB_NAME}" +if ! docker exec -e MYSQL_PWD="$DB_ROOT_PASSWORD" "$CONTAINER" \ + mysqldump -u root --single-transaction --routines --triggers --events \ + --databases "$DB_NAME" 2>"${WORK_DIR}/dump.err" | gzip > "${WORK_DIR}/${FILE}"; then + log "덤프 실패: $(tail -3 "${WORK_DIR}/dump.err")" + rm -f "${WORK_DIR}/${FILE}" + exit 1 +fi + +SIZE="$(stat -c %s "${WORK_DIR}/${FILE}")" +# 빈 파일·헤더만 있는 산출물이 "성공"으로 올라가 복구 시점에야 발각되는 걸 막는다. +# 정상 덤프는 gzip 후에도 최소 수 KB 다. +if [ "$SIZE" -lt 1024 ]; then + log "덤프 산출물이 비정상적으로 작다(${SIZE} bytes) — 업로드 중단" + rm -f "${WORK_DIR}/${FILE}" + exit 1 +fi + +log "업로드: s3://${BUCKET}/${FILE} (${SIZE} bytes)" +if ! docker run --rm --network host -v "${WORK_DIR}:/backup" "$AWSCLI_IMAGE" \ + s3 cp "/backup/${FILE}" "s3://${BUCKET}/${FILE}" --region "$REGION" > /dev/null; then + log "S3 업로드 실패 — 로컬 파일은 남겨 둔다(${WORK_DIR}/${FILE})" + exit 1 +fi + +# 업로드가 끝난 로컬 사본은 지운다. 루트 볼륨이 10GB 라 쌓이면 곧 찬다. +# (실패 시엔 위에서 남겨 두고 종료해 수동 회수 여지를 준다.) +rm -f "${WORK_DIR}/${FILE}" "${WORK_DIR}/dump.err" +log "완료" diff --git a/infra/scripts/provision-db.sh b/infra/scripts/provision-db.sh new file mode 100755 index 00000000..b0b1f05b --- /dev/null +++ b/infra/scripts/provision-db.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# DB 박스 런타임 프로비저닝 — 멱등(idempotent). (#898) +# +# terraform 의 user_data 는 docker·swap 까지만 깔고 끝난다. MySQL 기동과 백업 cron 설치는 +# 여기가 맡는다 — user_data 는 첫 부팅에만 실행돼 스크립트를 고쳐도 반영되지 않고, 반영하려면 +# 인스턴스 교체가 필요한데 이 박스에서 교체는 곧 데이터 손실이기 때문이다. +# +# 실행: 이 스크립트와 db-backup.sh, 그리고 공용 alloy 블록을 함께 박스로 올린 뒤 돌린다. +# scp -i ~/.ssh/piki-ec2-connect infra/scripts/{provision-db.sh,db-backup.sh} ubuntu@:/tmp/ +# scp -i ~/.ssh/piki-ec2-connect -r /blocks/alloy ubuntu@:/tmp/ +# ssh -i ~/.ssh/piki-ec2-connect ubuntu@ 'bash /tmp/provision-db.sh' +# (키페어가 없는 박스라 접속 전에 EC2 Instance Connect 로 공개키를 밀어 넣어야 한다 — README 참고.) +# +# alloy 블록을 core 에 복사해 두지 않고 매번 infra 에서 올리는 이유는 배포 워크플로와 같다 — +# 그 블록의 SSOT 는 TeamPiKi/infra 이고, 사본을 두면 조용히 갈라진다. 안 올리면 4절이 건너뛴다. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --skip-alloy: 관측 수집기 설치를 의도적으로 생략한다(긴급 복구 등). 기본은 생략하지 않으며, +# 블록이 없으면 실패한다 — 관측 공백이 조용히 생기는 걸 막는 게 기본값이어야 한다. +SKIP_ALLOY=false +for arg in "$@"; do + case "$arg" in + --skip-alloy) SKIP_ALLOY=true ;; + *) echo "알 수 없는 인자: $arg" >&2; exit 2 ;; + esac +done + +AWSCLI_IMAGE="public.ecr.aws/aws-cli/aws-cli:2.35.21" +MYSQL_IMAGE="mysql:8.4" +CONTAINER="piki-prod-mysql" +VOLUME="piki-prod-mysql-data" +REGION="ap-northeast-2" +SSM_PREFIX="/piki-core/prod" + +# 컨테이너 메모리 캡. t4g.micro(실가용 약 906MB)에서 OS·docker 몫을 남기려면 상한이 필요하다. +# 384m 은 dev 박스 같은 구성의 실사용(111MB)의 3배 이상이고, InnoDB 버퍼풀 기본값(128MB)에 +# 데이터 전량(2.1MB)이 올라가는 상태를 여유 있게 덮는다. swap 은 캡의 2배로 둬 순간 초과를 흡수한다. +MEM_LIMIT="384m" +MEM_SWAP="768m" + +ssm_param() { + docker run --rm --network host "$AWSCLI_IMAGE" ssm get-parameter \ + --name "${SSM_PREFIX}/$1" --with-decryption \ + --region "$REGION" --query Parameter.Value --output text +} + +# ── 1) MySQL ──────────────────────────────────────────────────────────────── +# 자격증명은 컨테이너를 "최초 생성"할 때만 쓰인다. 이미 있으면 값이 안 쓰이지만 조회는 매번 한다 — +# 재생성 때만 도는 경로로 두면 조용히 썩은 채 가장 필요한 순간에 터진다(provision-runtime.sh 와 같은 규율). +DB_NAME="$(ssm_param db-name)" || { echo "[mysql] SSM db-name 조회 실패 — IAM(piki-prod-db-policy)·파라미터 존재 확인"; exit 1; } +DB_USERNAME="$(ssm_param db-username)" || { echo "[mysql] SSM db-username 조회 실패"; exit 1; } +DB_PASSWORD="$(ssm_param db-password)" || { echo "[mysql] SSM db-password 조회 실패"; exit 1; } +# root 는 앱 계정과 다른 비밀번호를 쓴다. 같은 값을 공유하면 앱 자격증명이 새는 순간 DB 전체 +# 권한까지 함께 넘어간다 — 앱은 자기 스키마에만 권한이 있는 계정으로 붙고, root 는 백업·관리용으로만 남긴다. +DB_ROOT_PASSWORD="$(ssm_param db-root-password)" || { echo "[mysql] SSM db-root-password 조회 실패"; exit 1; } +echo "[mysql] DB 자격증명 SSM 로드 완료 (${SSM_PREFIX}/db-*)" + +if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "[mysql] ${CONTAINER} 이미 존재 — skip" +else + echo "[mysql] ${CONTAINER} 기동 (named volume: ${VOLUME})" + # 포트는 모든 인터페이스에 연다. dev 박스가 172.17.0.1 로 묶는 것과 달리 여기서는 앱이 다른 + # 박스에서 사설 IP 로 접속하기 때문이다. 노출 범위는 보안그룹이 책임진다 — 3306 인바운드는 + # 앱 EC2 SG 에서 온 것만 허용한다(terraform/db_ec2.tf). + # + # MYSQL_ROOT_HOST=localhost 를 명시하는 이유: 이미지 기본값이 `%` 라 그냥 두면 네트워크 너머에서도 + # root 로 붙을 수 있는 root@% 계정이 생긴다. 보안그룹이 앱 박스만 통과시키더라도, 앱 박스가 뚫리면 + # 그대로 DB 전체 권한이 넘어간다. root 를 쓰는 곳은 컨테이너 안에서 도는 백업 스크립트뿐이라 + # localhost 로 좁혀도 잃는 게 없다. + # + # 아래 세 튜닝은 기본값이 이 박스 크기에 안 맞아서 넣는다. 기본값으로 띄웠을 때 실측이 + # RAM 234MB + swap 185MB = 약 419MB 로 캡(384m)을 넘어 상시 스왑 상태였다. + # --performance-schema=OFF 기본 ON 이고 이 구성에서 가장 큰 몫이다. 앱 지표는 Micrometer· + # Grafana 로 따로 받고 있어 DB 내부 계측을 켜 둘 실익이 없다. + # (진단이 필요하면 그때 켜서 재기동한다.) + # --innodb-buffer-pool-size 기본 128M. 데이터 총량이 2.1MB 라 64M 에도 전량이 캐시된다. + # --max-connections 기본 151. 앱 HikariCP 는 기본 풀 10 이고 blue-green 전환 때만 + # 잠시 2배가 되므로, 백업·관리 여유를 포함해도 60 이면 남는다. + # 값을 바꾸면 컨테이너 재생성이 필요하다(옵션은 기동 인자다). 데이터는 named volume 에 있어 + # 재생성 자체는 안전하지만, 재생성 동안 앱 연결이 끊기므로 배포 창을 잡아 수행한다. + docker run -d \ + --name "$CONTAINER" \ + --restart unless-stopped \ + --memory "$MEM_LIMIT" \ + --memory-swap "$MEM_SWAP" \ + -p 3306:3306 \ + -v "${VOLUME}:/var/lib/mysql" \ + -e MYSQL_DATABASE="$DB_NAME" \ + -e MYSQL_USER="$DB_USERNAME" \ + -e MYSQL_PASSWORD="$DB_PASSWORD" \ + -e MYSQL_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \ + -e MYSQL_ROOT_HOST=localhost \ + "$MYSQL_IMAGE" \ + --performance-schema=OFF \ + --innodb-buffer-pool-size=64M \ + --max-connections=60 +fi + +# readiness — 최초 기동은 DB·유저 생성 후 내부 재시작이 있어, 바로 접속하면 실패한다. +echo "[mysql] readiness 대기" +for i in $(seq 1 30); do + if docker exec "$CONTAINER" mysqladmin ping -h 127.0.0.1 --silent 2>/dev/null; then + echo "[mysql] ready (attempt $i)" + break + fi + sleep 2 + [ "$i" -eq 30 ] && { echo "[mysql] readiness timeout (60s)"; exit 1; } +done + +# 자격증명이 실제로 통하는지 확인한다. 위 ping 은 서버 응답만 보고 인증을 안 거치므로, +# 그것만으로 "프로비저닝 성공"을 말하면 거짓이 될 수 있다. +# +# 이 검사가 필요한 이유: MYSQL_* 환경변수는 컨테이너를 "최초 생성"할 때만 쓰인다. 그 뒤 SSM 값을 +# 바꾸면 DB 안 계정은 옛 비밀번호로 남고 SSM 만 새 값이 되어, 둘이 조용히 갈라진다. 그 상태로 +# 스크립트가 성공하면 다음에 그 자격증명을 쓰는 쪽(앱 배포·백업 cron)이 대신 터진다. +# +# 어긋났을 때 여기서 자동으로 회전하지는 않는다. 앱 계정 비밀번호를 스크립트가 임의로 바꾸면 +# 붙어 있던 커넥션이 끊기므로, 회전은 사람이 배포 창에서 절차대로 수행할 일이다. +for role in root app; do + case "$role" in + root) user=root; pw="$DB_ROOT_PASSWORD" ;; + app) user="$DB_USERNAME"; pw="$DB_PASSWORD" ;; + esac + if ! docker exec -e MYSQL_PWD="$pw" "$CONTAINER" mysql -u "$user" -e "SELECT 1" >/dev/null 2>&1; then + echo "[mysql] ${role}(${user}) 자격증명이 SSM 값과 어긋난다 — 컨테이너 안 계정은 최초 생성 시의 비밀번호를 유지한다." + echo "[mysql] 회전 절차: docker exec -it ${CONTAINER} mysql -uroot -p 로 접속해" + echo "[mysql] ALTER USER ''@'' IDENTIFIED BY ''; 을 실행한 뒤 이 스크립트를 다시 돌린다." + exit 1 + fi +done +echo "[mysql] 자격증명 검증 완료 (root·${DB_USERNAME} 모두 SSM 값으로 접속 가능)" + +# ── 2) 백업 스크립트 + cron ───────────────────────────────────────────────── +# RDS 의 자동 백업을 대신하는 유일한 복구 경로다. 매 실행마다 최신본으로 덮어써 +# repo 의 스크립트와 박스의 것이 어긋나지 않게 한다. +if [ ! -f "${SCRIPT_DIR}/db-backup.sh" ]; then + echo "[backup] ${SCRIPT_DIR}/db-backup.sh 가 없다 — provision-db.sh 와 함께 올렸는지 확인" + exit 1 +fi +sudo install -m 0755 "${SCRIPT_DIR}/db-backup.sh" /usr/local/bin/piki-db-backup.sh +echo "[backup] /usr/local/bin/piki-db-backup.sh 설치" + +# 19:00 UTC = 04:00 KST. 트래픽이 가장 적은 시간대에 둔다(백업 자체는 무중단이지만 +# 혹시 모를 부하도 한산할 때 지나가게 한다). +# 출력은 journald 로 보내 `journalctl -t piki-db-backup` 으로 성공·실패를 추적한다 — +# cron 기본 동작인 로컬 메일은 이 박스에 MTA 가 없어 사라진다. +# +# bash -o pipefail 로 감싸는 이유: 파이프라인의 종료 코드는 마지막 명령(logger)의 것이라, +# 그냥 파이프하면 백업이 실패해도 cron 이 보는 결과는 항상 성공이 된다. logger 는 거의 언제나 +# 0 을 반환하기 때문이다. pipefail 이 있어야 백업 스크립트의 실패 코드가 그대로 올라와, +# 나중에 실패 감지(cron 모니터링·systemd timer 등)를 붙일 때 그것이 실제로 동작한다. +CRON_LINE='0 19 * * * /bin/bash -o pipefail -c "/usr/local/bin/piki-db-backup.sh 2>&1 | /usr/bin/logger -t piki-db-backup"' +# 기존 항목을 걷어낸 뒤 최신 줄을 다시 넣는다 — 등록/갱신을 가르지 않아야 멱등이 단순해진다. +# +# `|| true` 가 필요한 이유: crontab 에 우리 줄만 있으면 grep -v 의 출력이 0건이라 종료 코드가 1 이고, +# set -euo pipefail 이 그걸 실패로 보고 스크립트를 끊는다. 즉 이 가드가 없으면 "두 번째 실행부터 +# 조용히 죽는" 스크립트가 된다(첫 실행은 crontab 이 비어 이 경로를 안 타므로 드러나지 않는다). +echo "[backup] cron 등록·갱신 (매일 19:00 UTC = 04:00 KST)" +{ sudo crontab -l 2>/dev/null | grep -vF 'piki-db-backup.sh' || true; echo "$CRON_LINE"; } | sudo crontab - + +# ── 3) 관측 수집기(alloy) ─────────────────────────────────────────────────── +# RDS 를 걷어내면서 잃은 것을 메운다. 관리형일 때는 CloudWatch 가 메모리·연결 수·디스크 여유· +# 쿼리 지연을 자동으로 줬는데, EC2 기본 메트릭에는 메모리조차 없다. 이 박스가 죽어가는 걸 +# 알아챌 수단이 아예 없어지는 셈이라, 다른 박스(core·extractor·renderer)와 같은 수집기를 붙인다. +# +# 이 박스에서 실제로 걷히는 것은 호스트 메트릭(CPU·메모리·디스크)뿐이다. 블록의 수집 대상은 +# docker label(piki.observe) opt-in 인데 MySQL 컨테이너에는 그 라벨이 없다 — redis·mysql 로그를 +# 빼는 기존 정책과 같은 결이라 여기서도 유지한다(로그 볼륨). MySQL 내부 지표(연결 수·쿼리 지연)가 +# 필요해지면 mysqld_exporter 를 따로 붙이는 별도 작업이다. +ALLOY_DIR="${SCRIPT_DIR}/alloy" +if [ "$SKIP_ALLOY" = true ]; then + echo "[alloy] --skip-alloy 지정 — 수집기 설치를 건너뛴다" +elif [ ! -f "${ALLOY_DIR}/provision-alloy.sh" ]; then + # 조용히 건너뛰지 않는다. 관측이 빠진 채 "프로비저닝 완료"가 찍히면 그 공백을 아무도 모른다 — + # 실제로 이 박스는 관측 없이 며칠 돌 뻔했다. 의도적으로 생략할 때만 --skip-alloy 로 명시한다. + echo "[alloy] ${ALLOY_DIR}/provision-alloy.sh 가 없다 — infra 의 blocks/alloy 를 함께 올릴 것" + echo "[alloy] 의도적으로 생략하려면 --skip-alloy 를 붙여 실행한다" + exit 1 +else + obs_param() { + docker run --rm --network host "$AWSCLI_IMAGE" ssm get-parameter \ + --name "/piki/observability/$1" --with-decryption \ + --region "$REGION" --query Parameter.Value --output text + } + # 필수 5종은 하나라도 없으면 중단한다. 빈 값으로 넘기면 provision-alloy.sh 가 skip(성공 0)으로 + # 조용히 지나가, 관측이 없는 상태가 "설치 완료"로 보고된다. + GRAFANA_METRICS_URL="$(obs_param grafana-metrics-url)" || { echo "[alloy] SSM grafana-metrics-url 조회 실패"; exit 1; } + GRAFANA_METRICS_USER="$(obs_param grafana-metrics-user)" || { echo "[alloy] SSM grafana-metrics-user 조회 실패"; exit 1; } + GRAFANA_LOGS_URL="$(obs_param grafana-logs-url)" || { echo "[alloy] SSM grafana-logs-url 조회 실패"; exit 1; } + GRAFANA_LOGS_USER="$(obs_param grafana-logs-user)" || { echo "[alloy] SSM grafana-logs-user 조회 실패"; exit 1; } + GRAFANA_CLOUD_TOKEN="$(obs_param grafana-cloud-token)" || { echo "[alloy] SSM grafana-cloud-token 조회 실패"; exit 1; } + # 트레이스는 이 박스에 앱이 없어 쓰이지 않는다. 빈 값이어도 블록이 무해 처리한다. + GRAFANA_TRACES_URL="$(obs_param grafana-traces-url)" || GRAFANA_TRACES_URL="" + GRAFANA_TRACES_USER="$(obs_param grafana-traces-user)" || GRAFANA_TRACES_USER="" + export GRAFANA_METRICS_URL GRAFANA_METRICS_USER GRAFANA_LOGS_URL GRAFANA_LOGS_USER + export GRAFANA_TRACES_URL GRAFANA_TRACES_USER GRAFANA_CLOUD_TOKEN + echo "[alloy] Grafana 자격 SSM 로드 완료" + + # --box piki-db: 호스트 메트릭에는 service/instance 축이 없어, 같은 environment 의 박스들을 + # 이 라벨로만 가른다. 대시보드에서 DB 박스를 골라 보려면 이 값이 유일한 구분자다. + bash "${ALLOY_DIR}/provision-alloy.sh" \ + --config "${ALLOY_DIR}/config.alloy" \ + --name piki-alloy \ + --environment prod \ + --box piki-db +fi + +echo "DB 박스 프로비저닝 완료" +echo " 백업 수동 실행: sudo /usr/local/bin/piki-db-backup.sh" +echo " 백업 로그 확인: journalctl -t piki-db-backup --since '1 day ago'" diff --git a/terraform/db_ec2.tf b/terraform/db_ec2.tf new file mode 100644 index 00000000..0114f3a6 --- /dev/null +++ b/terraform/db_ec2.tf @@ -0,0 +1,294 @@ +# ----------------------------------------------------------------------------- +# 자체 관리 MySQL 을 얹는 DB 전용 EC2 (#898) — RDS(rds.tf) 대체 +# +# 왜 관리형을 버리는가: 신규 AWS Free Plan 이 RDS 의 backup_retention_period > 0 을 거부해 +# 자동 백업·PITR 을 못 쓴다(rds.tf 참고). 관리형 비용을 내면서 관리형의 핵심 이점인 백업을 +# 못 받는 상태라, 백업을 우리가 직접 통제하는 편이 낫다는 판단이다. 비용도 월 $20.87 → +# 약 $13 으로 내려간다. +# +# 이 파일은 rds.tf 와 대칭을 이루도록 DB 한 기능의 리소스(SG·인스턴스·백업 버킷·IAM)를 한곳에 +# 모았다. 이관이 끝나 RDS 를 걷어낼 때 rds.tf 만 지우면 되고, 그때 무엇이 그 자리를 대신하는지 +# 이 파일 하나로 드러난다. +# +# dev 는 이미 앱 박스 안 docker MySQL 로 돌고 있다(infra/scripts/provision-runtime.sh 3절). +# prod 는 앱 박스 여유 메모리가 563MB 뿐이고 앱 컨테이너가 캡에 100% 붙어 있어 동거가 불가능해, +# 별도 박스로 분리한다. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# DB 박스 Security Group +# +# 3306 은 앱 EC2 SG 에서만 받는다(폐기된 RDS SG 가 지키던 것과 같은 규율) — DB 를 +# 인터넷에 직접 노출하지 않는다. SSH 는 EC2 Instance Connect·팀원 접속을 위해 열되 +# var.ssh_ingress_cidr 로 좁힌다. +# +# egress 는 열어 둔다. RDS 와 달리 이 박스는 스스로 바깥에 나가야 한다 — SSM 에서 DB 자격증명을 +# 읽고, 백업을 S3 에 올리고, docker 이미지를 받는다. 사설 서브넷 + NAT Gateway 로 가리는 선택지는 +# NAT 만 월 $40 이 넘어 이 이관의 절감액을 통째로 삼킨다. +# ----------------------------------------------------------------------------- +resource "aws_security_group" "db_ec2" { + name = "piki-prod-db-sg" + description = "Allow MySQL from app EC2 SG only, SSH from team CIDR" + vpc_id = aws_vpc.main.id + + ingress { + description = "MySQL from app EC2" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [aws_security_group.ec2.id] + } + + ingress { + description = "SSH (EC2 Instance Connect / team)" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.ssh_ingress_cidr] + } + + egress { + description = "Allow all outbound (SSM pull, S3 backup upload, docker pull)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + # 앱 SG(aws_security_group.ec2)와 달리 ingress 에 ignore_changes 를 걸지 않는다. + # 앱 SG 는 콘솔/CLI 로 추가된 접속 IP 가 이미 쌓여 있어 terraform 이 덮으면 배포·SSH 가 끊기지만, + # 이 SG 는 방금 만들어 그런 이력이 없고 terraform 이 유일한 관리자다. 여기에 ignore_changes 를 + # 두면 누군가 콘솔에서 3306 을 열어도 apply 가 잡아내지 못한다 — DB 포트에서 그 사각은 크다. + # (#223 이 기존 SG 의 ignore_changes 를 걷어내는 방향이라, 새 리소스가 역행할 이유도 없다.) + + tags = { + Name = "piki-prod-db-sg" + } +} + +# ----------------------------------------------------------------------------- +# DB 박스 IAM — 앱 role(aws_iam_role.app)을 공유하지 않고 전용으로 둔다. +# +# 앱 role 은 ECR pull·이미지 버킷 RW·/piki-core/* 전 경로 읽기를 갖는데, DB 박스에는 하나도 +# 필요 없다. 다른 박스들이 "과권한이지만 blast radius 가 작다"는 트레이드오프로 role 을 공유하는 +# 것과 달리, 이 박스는 데이터 원본과 그 백업을 함께 들고 있어 사고 반경이 가장 크다. 여기서만은 +# 최소 권한을 지킨다. +# ----------------------------------------------------------------------------- +resource "aws_iam_role" "db" { + name = "piki-prod-db-role" + assume_role_policy = data.aws_iam_policy_document.ec2_assume.json +} + +data "aws_iam_policy_document" "db_instance" { + # DB 자격증명만 읽는다 — 앱 role 처럼 /piki-core/* 전체가 아니라 prod/db-* 로 좁힌다. + # MySQL 컨테이너 최초 생성 시의 초기 자격증명과, 백업 스크립트의 접속 자격이 여기서 나온다. + statement { + sid = "ReadDbCredentials" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = ["arn:aws:ssm:${var.aws_region}:*:parameter/piki-core/prod/db-*"] + } + + # 관측 수집기(alloy)가 쓰는 Grafana Cloud 자격 공유 경로 (#771). + statement { + sid = "ReadObservabilityCredentials" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = ["arn:aws:ssm:${var.aws_region}:*:parameter/piki/observability/*"] + } + + # 백업 업로드(Put) + 복구를 위한 조회(Get/List). + # + # Get·List 를 함께 주는 이유: 복구는 급할 때 하는 일인데, 이게 없으면 사람이 로컬로 내려받아 + # 다시 박스로 올리는 우회를 타야 한다. 자기가 쓴 백업을 자기가 읽는 것이라 권한 확대 폭도 작다. + # (ListBucket 없이 Get 만 주면 "어떤 백업이 있는지"를 못 봐서 복원할 파일을 고르지 못한다.) + # + # Delete 는 끝까지 주지 않는다 — 보존 만료는 S3 lifecycle 이 하고, 박스가 침해되더라도 + # 이미 올라간 백업은 지우지 못하게 남긴다. 이 버킷이 유일한 복구 경로이기 때문이다. + statement { + sid = "WriteAndReadBackups" + actions = ["s3:PutObject", "s3:GetObject"] + resources = ["${aws_s3_bucket.db_backup.arn}/*"] + } + + # ListBucket 은 객체(/*)가 아니라 버킷 ARN 에 걸어야 한다(S3 API 사양, iam.tf 의 이미지 버킷과 동일). + statement { + sid = "ListBackups" + actions = ["s3:ListBucket"] + resources = [aws_s3_bucket.db_backup.arn] + } +} + +resource "aws_iam_role_policy" "db_instance" { + name = "piki-prod-db-policy" + role = aws_iam_role.db.id + policy = data.aws_iam_policy_document.db_instance.json +} + +resource "aws_iam_instance_profile" "db" { + name = "piki-prod-db-profile" + role = aws_iam_role.db.name +} + +# ----------------------------------------------------------------------------- +# 백업 버킷 — mysqldump | gzip 산출물을 날짜별로 쌓는다. +# +# 이미지 버킷(s3.tf)과 정반대로 공개를 전면 차단한다. 객체 이름이 날짜라 덮어쓸 일이 없어 +# versioning 은 두지 않고, 보존은 lifecycle 만료로 끝낸다. +# ----------------------------------------------------------------------------- +resource "aws_s3_bucket" "db_backup" { + bucket = var.db_backup_bucket_name + + tags = { + Name = "piki-prod-db-backup" + } +} + +resource "aws_s3_bucket_public_access_block" "db_backup" { + bucket = aws_s3_bucket.db_backup.id + + block_public_acls = true + ignore_public_acls = true + block_public_policy = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "db_backup" { + bucket = aws_s3_bucket.db_backup.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "db_backup" { + bucket = aws_s3_bucket.db_backup.id + + rule { + id = "expire-old-backups" + status = "Enabled" + + filter {} + + expiration { + days = var.db_backup_retention_days + } + + # 업로드가 중간에 끊긴 멀티파트 조각이 과금 대상으로 남는 걸 막는다(이미지 버킷과 같은 규율). + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +# 백업 스크립트가 "어느 버킷에 올릴지"를 알아야 하는데, 버킷명은 계정번호를 포함해 퍼블릭 repo 의 +# 스크립트에 박을 수 없다. 위 db_instance 정책이 이미 /piki-core/prod/db-* 를 읽게 하므로, +# 그 경로에 얹어 스크립트가 다른 자격증명과 같은 방식으로 꺼내 쓰게 한다(인자 전달 대비 자족적). +# 비밀이 아니라 String 으로 둔다. +resource "aws_ssm_parameter" "db_backup_bucket" { + name = "/piki-core/prod/db-backup-bucket" + description = "MySQL 논리 백업 업로드 대상 버킷 (#898, infra/scripts/db-backup.sh 가 읽는다)" + type = "String" + value = aws_s3_bucket.db_backup.id + + tags = { + Name = "piki-prod-db-backup-bucket" + } +} + +# ----------------------------------------------------------------------------- +# DB 박스 인스턴스 +# +# key_name 을 두지 않는다 — extractor·headless 박스와 같이 EC2 Instance Connect 로만 접근한다. +# 키페어 파일을 팀이 나눠 갖는 경로가 없어지고, 접근 권한이 IAM 하나로 모인다. +# +# EIP 를 붙이지 않고 associate_public_ip_address 로 자동 할당 IP 를 받는다. 요금은 EIP 와 +# 동일($0.005/h)한데, 앱은 이 박스를 사설 IP 로만 보므로 재기동 시 퍼블릭 IP 가 바뀌어도 무관하다. +# (SSH 할 때만 현재 IP 를 조회하면 된다.) +# ----------------------------------------------------------------------------- +resource "aws_instance" "db" { + ami = var.ec2_ami_id != null ? var.ec2_ami_id : data.aws_ami.ubuntu_2404_arm64[0].id + instance_type = var.db_ec2_instance_type + subnet_id = aws_subnet.public.id + availability_zone = var.azs[0] + vpc_security_group_ids = [aws_security_group.db_ec2.id] + iam_instance_profile = aws_iam_instance_profile.db.name + associate_public_ip_address = true + + # 첫 부팅 부트스트랩은 docker 와 swap 까지만 한다. MySQL 기동·백업 cron 설치는 + # infra/scripts/provision-db.sh 가 멱등으로 맡는다 — user_data 는 첫 부팅에만 돌아 + # 스크립트를 고쳐도 반영되지 않고, 반영하려면 인스턴스 교체가 필요한데 이 박스는 교체가 + # 곧 데이터 손실이기 때문이다. 아래 lifecycle 이 그 교체를 막는다. + user_data = <<-EOF + #!/bin/bash + set -eux + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y ca-certificates curl + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list + apt-get update + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + usermod -aG docker ubuntu + systemctl enable --now docker + + # swap — 1GiB 박스라 완충이 필수다. extractor·headless 박스는 swap 이 없어 메모리가 + # 모자라면 곧장 OOM kill 인데, DB 에서 그 일이 나면 데이터 정합이 걸린다. + # swappiness=10 은 앱 박스와 같은 값 — 완충으로만 쓰고 평시엔 RAM 에 머물게 한다. + fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048 + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf + sysctl -w vm.swappiness=10 + EOF + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + # 2 — bridge 네트워크 컨테이너가 IMDS 에 닿아야 한다(앱 박스와 동일). + http_put_response_hop_limit = 2 + } + + # AWS 층의 종료 보호. 아래 lifecycle 의 prevent_destroy 는 terraform 이 파괴 계획을 거부하게 + # 할 뿐이라, 콘솔·CLI·API 로 직접 종료하는 경로는 그대로 열려 있다. 두 층을 함께 둬야 + # "실수로 지워지지 않는다"가 성립한다. 의도적으로 종료할 때는 이 값을 false 로 바꿔 apply 한 뒤 진행한다. + disable_api_termination = true + + root_block_device { + volume_size = var.db_ec2_volume_size + volume_type = "gp3" + encrypted = true + # 이 박스의 루트 볼륨이 곧 DB 데이터다(docker named volume 이 여기 있다). + # 인스턴스가 종료돼도 볼륨은 남긴다. + # + # 다만 이것은 자동 복구 장치가 아니다 — 남은 볼륨은 새 인스턴스에 자동으로 붙지 않는다. + # 박스를 다시 만들면 새 루트 볼륨으로 뜨고, provision-db.sh 는 빈 MySQL 을 기동한다. + # 즉 "데이터가 사라지지 않는다"까지가 이 설정의 보장이고, 되살리려면 사람이 옛 볼륨을 + # 연결하는 수동 절차가 필요하다. + # + # 그래서 주 복구 경로는 이 볼륨이 아니라 S3 백업(db-backup.sh)이다. 볼륨 보존은 백업까지 + # 실패했을 때 남는 마지막 회수 수단으로 둔다. + delete_on_termination = false + } + + # ami·user_data 변경은 terraform 이 인스턴스를 파괴·재생성하는 사유인데, 이 박스에서는 + # 그게 DB 소멸이다. 둘 다 무시해 사고 경로를 끊는다. AMI 를 의도적으로 올릴 때는 + # 새 박스를 띄워 덤프로 옮기는 절차를 밟는다(이 이관과 같은 방식). + # + # prevent_destroy 는 그 사고 경로의 마지막 층이다. ignore_changes 가 막는 것은 "속성 변경으로 + # 인한 재생성"뿐이라, destroy 를 직접 부르거나 다른 사유로 replace 가 잡히면 그대로 지워진다. + # 이 플래그가 있으면 apply 가 거부되고, 정말 지워야 할 때는 코드에서 이 줄을 먼저 지워야 한다. + # RDS 의 deletion_protection 과 같은 결의 의도된 마찰이다. + lifecycle { + prevent_destroy = true + ignore_changes = [ami, user_data] + } + + tags = { + Name = "piki-prod-db" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index ab56a87e..0ab05a5b 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -33,14 +33,19 @@ output "dev_ec2_public_dns" { value = aws_eip.dev_app.public_dns } -output "rds_endpoint" { - description = "RDS 엔드포인트 (host:port)" - value = aws_db_instance.mysql.endpoint +output "db_ec2_private_ip" { + description = "자체 관리 DB 박스 사설 IP — SSM /piki-core/prod/db-host 에 넣을 값 (#898). 앱은 이 주소로만 접속한다." + value = aws_instance.db.private_ip } -output "rds_address" { - description = "RDS 호스트" - value = aws_db_instance.mysql.address +output "db_ec2_public_ip" { + description = "자체 관리 DB 박스 퍼블릭 IP — EC2 Instance Connect SSH 용. EIP 가 아니라 자동 할당이라 재기동 시 바뀐다." + value = aws_instance.db.public_ip +} + +output "db_backup_bucket_name" { + description = "MySQL 논리 백업 저장 버킷명 (#898)" + value = aws_s3_bucket.db_backup.id } output "image_bucket_name" { diff --git a/terraform/rds.tf b/terraform/rds.tf deleted file mode 100644 index 4123ba26..00000000 --- a/terraform/rds.tf +++ /dev/null @@ -1,84 +0,0 @@ -# ----------------------------------------------------------------------------- -# DB Subnet Group — RDS 는 최소 2개 AZ 의 서브넷이 필요하다 -# ----------------------------------------------------------------------------- -resource "aws_db_subnet_group" "main" { - name = "${local.name_prefix}-db-subnet-group" - subnet_ids = aws_subnet.private[*].id - - tags = { - Name = "${local.name_prefix}-db-subnet-group" - } -} - -# ----------------------------------------------------------------------------- -# RDS MySQL 8.4 (LTS) — db.t4g.micro -# ----------------------------------------------------------------------------- - -# RDS 마스터 비밀번호는 SSM 의 앱 DB 비밀번호를 소스로 읽는다 (앱 시크릿 SSM 단일화의 후속). -# prod 앱이 마스터 계정(admin)으로 이 RDS 에 접속하므로 /piki-core/prod/db-password 가 곧 마스터 비밀번호다. -# 아래 lifecycle.ignore_changes=[password] 로 이 값은 인스턴스 "생성 시점"에만 쓰이며, -# 이후 SSM 값과 실제 비밀번호가 어긋나도 apply 가 비밀번호를 건드리지 않는다. -data "aws_ssm_parameter" "db_password" { - name = "/piki-core/prod/db-password" - with_decryption = true -} - -resource "aws_db_instance" "mysql" { - identifier = "${local.name_prefix}-mysql" - engine = "mysql" - engine_version = var.db_engine_version - instance_class = var.db_instance_class - - allocated_storage = var.db_allocated_storage - max_allocated_storage = 100 - storage_type = "gp3" - storage_encrypted = true - - db_name = var.db_name - username = var.db_username - password = data.aws_ssm_parameter.db_password.value - port = 3306 - - db_subnet_group_name = aws_db_subnet_group.main.name - vpc_security_group_ids = [aws_security_group.rds.id] - publicly_accessible = false - multi_az = false - - # EC2 와 동일 AZ 에 고정해서 cross-AZ 데이터 전송 요금을 피한다. - # DB Subnet Group 은 2개 AZ 가 필요하지만 실제 인스턴스는 여기 한 곳만 사용. - availability_zone = var.azs[0] - - # AWS 신규 Free Plan(2025-07-15 이후 가입) 은 retention > 0 을 거부한다(FreeTierRestrictionError). - # 자동 백업·PITR 을 쓸 수 없으므로 복구 지점은 수동 스냅샷이 전담한다(#813). - # Paid plan 승격 시 7 로 복구할 것. - backup_retention_period = 0 - backup_window = "17:00-18:00" # KST 02:00-03:00, retention=0 이면 무시됨 - maintenance_window = "sun:18:00-sun:19:00" - - # identifier 는 dev 지만 운영 트래픽을 받는 유일한 DB 다(#813 실측). 자동 백업이 없는 상태라 - # 실수로 지우면 복구 경로가 없어, 아래 값들로 "지우기 어렵게" 만든다. - # - # 둘이 적용되는 층이 다르다. 헷갈리면 삭제 보호를 푸는 절차를 잘못 이해하게 된다. - # deletion_protection: RDS 인스턴스 속성이다. apply 가 ModifyDBInstance 로 AWS 에 실제 전송하며, - # 콘솔·CLI·SDK 등 경로를 가리지 않고 삭제를 막는다. - # skip_final_snapshot / final_snapshot_identifier: terraform 이 DeleteDBInstance 를 호출할 때만 - # 쓰는 인자다. AWS 에 저장되는 상태가 아니라서 terraform 을 거치지 않는 삭제에는 영향이 없다. - auto_minor_version_upgrade = true - deletion_protection = true # 명시적으로 false 로 바꿔 apply 해야만 삭제된다. 의도된 마찰이다. - skip_final_snapshot = false # terraform 으로 지울 때는 최종 스냅샷을 반드시 남긴다. - - # 위 skip_final_snapshot = false 가 provider 레벨에서 요구하는 짝. 이름을 고정값으로 둬서 - # 같은 이름의 스냅샷이 이미 있으면 삭제가 실패한다. 이 역시 마찰로 남겨 둔다. - final_snapshot_identifier = "${local.name_prefix}-mysql-final" - - # 실제 비밀번호는 콘솔/운영에서 바뀔 수 있어 state·SSM 값과 어긋날 수 있다. - # terraform 이 apply 마다 비번을 SSM 값으로 강제 변경해 앱 DB 연결이 끊기는 사고를 막기 위해 - # password 변경은 무시한다. 비번을 의도적으로 바꿀 때는 콘솔/CLI 로 직접 수행하고 SSM 파라미터도 함께 갱신한다. - lifecycle { - ignore_changes = [password] - } - - tags = { - Name = "${local.name_prefix}-mysql" - } -} diff --git a/terraform/security_groups.tf b/terraform/security_groups.tf index e43d9b65..9d476a1e 100644 --- a/terraform/security_groups.tf +++ b/terraform/security_groups.tf @@ -56,29 +56,5 @@ resource "aws_security_group" "ec2" { } } -# ----------------------------------------------------------------------------- -# RDS Security Group — EC2 SG 로부터의 3306 만 허용 -# ----------------------------------------------------------------------------- -resource "aws_security_group" "rds" { - name = "${local.name_prefix}-rds-sg" - description = "Allow MySQL from EC2 SG only" - vpc_id = aws_vpc.main.id - - ingress { - description = "MySQL from EC2" - from_port = 3306 - to_port = 3306 - protocol = "tcp" - security_groups = [aws_security_group.ec2.id] - } - - # egress 블록을 의도적으로 생략한다. - # aws_security_group 은 VPC 내 생성 시 AWS 가 자동으로 붙이는 기본 all-allow 아웃바운드 룰을 - # Terraform 이 제거해 주므로, 블록을 두지 않으면 RDS SG 는 송신이 전부 차단된 상태가 된다. - # RDS 는 인바운드 응답만으로 동작하고 공식 backup/snapshot 은 AWS 관리 경로라 SG egress 와 무관. - # 추후 외부 연동(S3 Data Export 등) 이 필요하면 그때 최소 대상만 명시적으로 열 것. - - tags = { - Name = "${local.name_prefix}-rds-sg" - } -} +# RDS Security Group 은 #898 로 RDS 자체를 폐기하면서 함께 제거했다. +# 그 자리를 대신하는 것은 db_ec2.tf 의 aws_security_group.db_ec2 다. diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 50b40044..4f5ebacf 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -10,3 +10,6 @@ ssh_ingress_cidr = "203.0.113.1/32" # 이미지 버킷 실명 — 계정번호 파생. 는 aws sts get-caller-identity --query Account 로 확인 image_bucket_name = "piki-images-" + +# DB 백업 버킷 실명 — 위와 같은 계정번호 파생 (#898, 자체 관리 MySQL 의 mysqldump 산출물 보관) +db_backup_bucket_name = "piki-db-backup-" diff --git a/terraform/variables.tf b/terraform/variables.tf index 5ea1b1fd..ea71a9de 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -133,6 +133,54 @@ variable "db_allocated_storage" { default = 20 } +# ----- DB EC2 (RDS 대체, #898) ----- + +variable "db_ec2_instance_type" { + description = <<-EOT + 자체 관리 MySQL 을 얹을 DB 전용 EC2 인스턴스 타입 (ARM / Graviton). + micro(1GiB) 로 충분하다는 실측 근거: dev 박스의 같은 구성(docker mysql:8.4)이 상시 111MB / + CPU 0.97% 를 쓰고, 데이터가 2.1MB 라 InnoDB 버퍼풀 기본값(128MB)에 전량이 올라간다. + micro 의 baseline 은 vCPU 당 10% 라 실사용 대비 10배 여유다. + + 사이즈를 올리면 이 이관의 명분이 사라진다 — small 은 월 $20.65 로 RDS(db.t4g.micro + 인스턴스 $18.25 + gp3 20GB $2.62 = $20.87)와 사실상 같아진다. 반대로 nano(0.5GiB)는 + 실가용이 약 400MB 인데 OS + docker + mysql 만으로 그 이상이라 상시 스왑이 된다. + EOT + type = string + default = "t4g.micro" +} + +variable "db_ec2_volume_size" { + description = <<-EOT + DB 박스 루트 볼륨 크기(GB). OS(약 3GB) + docker 이미지(mysql·aws-cli·alloy 약 1.5GB) + + 데이터·백업 임시파일 기준으로 10GB 면 절반이 남는다. 앱 박스(20GB)와 달리 앱 이미지를 + 받지 않아 소비가 작다. EBS 는 확대만 가능하고 축소가 안 되므로 작게 시작한다. + EOT + type = number + default = 10 +} + +variable "db_backup_bucket_name" { + # 계정번호를 포함하므로 default 를 두지 않는다(퍼블릭 repo). TF_VAR_db_backup_bucket_name 또는 terraform.tfvars 로 주입. + description = "MySQL 논리 백업(mysqldump gzip) 저장 버킷명. 이미지·state 버킷과 일관되게 piki-db-backup-{account} 사용." + type = string + + # image_bucket_name 과 같은 규율 — placeholder·형식 오류가 apply 까지 가서 엉뚱한 버킷을 만들지 않게 plan 에서 차단. + validation { + condition = can(regex("^piki-db-backup-[0-9]{12}$", var.db_backup_bucket_name)) + error_message = "db_backup_bucket_name 은 piki-db-backup-<12자리 AWS 계정번호> 형식이어야 합니다." + } +} + +variable "db_backup_retention_days" { + description = <<-EOT + 백업 객체 보존 일수. 덤프가 gzip 약 300KB 라 30일치를 합쳐도 10MB 수준이고 S3 비용은 + 사실상 0 이므로, 보존을 짧게 잡아 아낄 실익이 없다. 값은 복구 시점 선택 폭이 결정한다. + EOT + type = number + default = 30 +} + variable "github_repo" { description = "OIDC 신뢰 조건에 쓸 GitHub 저장소 (org/repo). 이 repo 의 Actions 만 push 역할을 assume 할 수 있다." type = string