From bccb77a26daf83e9ee9ac030bb336a518f49331d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:19:46 +0900 Subject: [PATCH 1/7] =?UTF-8?q?infra:=20prod=20DB=20=EB=A5=BC=20RDS=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9E=90=EC=B2=B4=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?EC2=20MySQL=20=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 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 는 첫 부팅에만 돌아 수정 반영에 인스턴스 교체가 필요하다 --- infra/scripts/db-backup.sh | 67 +++++++ infra/scripts/provision-db.sh | 113 ++++++++++++ terraform/db_ec2.tf | 275 +++++++++++++++++++++++++++++ terraform/outputs.tf | 15 ++ terraform/terraform.tfvars.example | 3 + terraform/variables.tf | 48 +++++ 6 files changed, 521 insertions(+) create mode 100755 infra/scripts/db-backup.sh create mode 100755 infra/scripts/provision-db.sh create mode 100644 terraform/db_ec2.tf diff --git a/infra/scripts/db-backup.sh b/infra/scripts/db-backup.sh new file mode 100755 index 000000000..70bc7adb5 --- /dev/null +++ b/infra/scripts/db-backup.sh @@ -0,0 +1,67 @@ +#!/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; } +DB_PASSWORD="$(ssm_param db-password)" || { log "SSM db-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 로 뜬다(컨테이너 생성 시 root 비밀번호를 db-password 와 같게 넣었다). +# --databases 를 쓰면 CREATE DATABASE / USE 문이 함께 담겨 빈 서버에 그대로 복원된다. +# --routines --triggers --events: 스키마 외 객체까지 포함해 "이 파일 하나면 복구된다"를 지킨다. +# +# 파이프 중간(mysqldump)의 실패를 놓치지 않도록 set -o pipefail 이 위에서 켜져 있다 — +# 이게 없으면 덤프가 깨져도 gzip 성공 코드만 보고 손상된 파일을 업로드한다. +log "덤프 시작: ${DB_NAME}" +if ! docker exec -e MYSQL_PWD="$DB_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 000000000..57802b07c --- /dev/null +++ b/infra/scripts/provision-db.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# DB 박스 런타임 프로비저닝 — 멱등(idempotent). (#898) +# +# terraform 의 user_data 는 docker·swap 까지만 깔고 끝난다. MySQL 기동과 백업 cron 설치는 +# 여기가 맡는다 — user_data 는 첫 부팅에만 실행돼 스크립트를 고쳐도 반영되지 않고, 반영하려면 +# 인스턴스 교체가 필요한데 이 박스에서 교체는 곧 데이터 손실이기 때문이다. +# +# 실행: 같은 디렉터리의 db-backup.sh 와 함께 박스로 올린 뒤 이 스크립트를 돌린다. +# scp -i ~/.ssh/piki-ec2-connect infra/scripts/{provision-db.sh,db-backup.sh} ubuntu@:/tmp/ +# ssh -i ~/.ssh/piki-ec2-connect ubuntu@ 'bash /tmp/provision-db.sh' +# (키페어가 없는 박스라 접속 전에 EC2 Instance Connect 로 공개키를 밀어 넣어야 한다 — README 참고.) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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; } +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). + # + # 아래 세 튜닝은 기본값이 이 박스 크기에 안 맞아서 넣는다. 기본값으로 띄웠을 때 실측이 + # 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_PASSWORD" \ + "$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 + +# ── 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 가 없어 사라진다. +CRON_LINE='0 19 * * * /usr/local/bin/piki-db-backup.sh 2>&1 | /usr/bin/logger -t piki-db-backup' +if sudo crontab -l 2>/dev/null | grep -qF 'piki-db-backup.sh'; then + echo "[backup] cron 이미 등록 — 내용 갱신" + sudo crontab -l 2>/dev/null | grep -vF 'piki-db-backup.sh' | { cat; echo "$CRON_LINE"; } | sudo crontab - +else + echo "[backup] cron 등록 (매일 19:00 UTC = 04:00 KST)" + sudo crontab -l 2>/dev/null | { cat; echo "$CRON_LINE"; } | sudo crontab - +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 000000000..a95a39414 --- /dev/null +++ b/terraform/db_ec2.tf @@ -0,0 +1,275 @@ +# ----------------------------------------------------------------------------- +# 자체 관리 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.tf 의 aws_security_group.rds 와 같은 규율) — 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)와 같은 이유 — 운영 중 콘솔/CLI 로 추가된 접속 IP 를 + # terraform apply 가 되돌려 SSH·배포를 끊는 사고를 막는다. + lifecycle { + ignore_changes = [ingress] + } + + 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 + } + + root_block_device { + volume_size = var.db_ec2_volume_size + volume_type = "gp3" + encrypted = true + # 이 박스의 루트 볼륨이 곧 DB 데이터다(docker named volume 이 여기 있다). + # 인스턴스를 지워도 볼륨은 남겨 실수로 데이터가 함께 사라지지 않게 한다. + delete_on_termination = false + } + + # ami·user_data 변경은 terraform 이 인스턴스를 파괴·재생성하는 사유인데, 이 박스에서는 + # 그게 DB 소멸이다. 둘 다 무시해 사고 경로를 끊는다. AMI 를 의도적으로 올릴 때는 + # 새 박스를 띄워 덤프로 옮기는 절차를 밟는다(이 이관과 같은 방식). + lifecycle { + ignore_changes = [ami, user_data] + } + + tags = { + Name = "piki-prod-db" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index ab56a87e7..85a660b19 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -43,6 +43,21 @@ output "rds_address" { value = aws_db_instance.mysql.address } +output "db_ec2_private_ip" { + description = "자체 관리 DB 박스 사설 IP — SSM /piki-core/prod/db-host 에 넣을 값 (#898). 앱은 이 주소로만 접속한다." + value = aws_instance.db.private_ip +} + +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" { description = "운영(prod) 이미지 버킷명 (prod S3_BUCKET)" value = aws_s3_bucket.images.id diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 50b400443..4f5ebacfe 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 5ea1b1fdf..ea71a9dec 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 From 0ae6febb9d850e8ae2b1daf9ce8028a4c0f332d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:44:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?infra:=20DB=20=EB=B0=95=EC=8A=A4=EC=9D=98?= =?UTF-8?q?=20=EB=B0=B1=EC=97=85=20=EC=8B=A4=ED=8C=A8=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=EB=B3=B4=EC=95=88=EA=B7=B8=EB=A3=B9=C2=B7=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=A9=EC=96=B4=EB=A5=BC=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 메타 설정이다 --- infra/scripts/provision-db.sh | 7 ++++++- terraform/db_ec2.tf | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/infra/scripts/provision-db.sh b/infra/scripts/provision-db.sh index 57802b07c..5b9c8a945 100755 --- a/infra/scripts/provision-db.sh +++ b/infra/scripts/provision-db.sh @@ -99,7 +99,12 @@ echo "[backup] /usr/local/bin/piki-db-backup.sh 설치" # 혹시 모를 부하도 한산할 때 지나가게 한다). # 출력은 journald 로 보내 `journalctl -t piki-db-backup` 으로 성공·실패를 추적한다 — # cron 기본 동작인 로컬 메일은 이 박스에 MTA 가 없어 사라진다. -CRON_LINE='0 19 * * * /usr/local/bin/piki-db-backup.sh 2>&1 | /usr/bin/logger -t piki-db-backup' +# +# 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"' if sudo crontab -l 2>/dev/null | grep -qF 'piki-db-backup.sh'; then echo "[backup] cron 이미 등록 — 내용 갱신" sudo crontab -l 2>/dev/null | grep -vF 'piki-db-backup.sh' | { cat; echo "$CRON_LINE"; } | sudo crontab - diff --git a/terraform/db_ec2.tf b/terraform/db_ec2.tf index a95a39414..fcbc9785d 100644 --- a/terraform/db_ec2.tf +++ b/terraform/db_ec2.tf @@ -55,11 +55,11 @@ resource "aws_security_group" "db_ec2" { cidr_blocks = ["0.0.0.0/0"] } - # 앱 SG(aws_security_group.ec2)와 같은 이유 — 운영 중 콘솔/CLI 로 추가된 접속 IP 를 - # terraform apply 가 되돌려 SSH·배포를 끊는 사고를 막는다. - lifecycle { - ignore_changes = [ingress] - } + # 앱 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" @@ -265,8 +265,14 @@ resource "aws_instance" "db" { # ami·user_data 변경은 terraform 이 인스턴스를 파괴·재생성하는 사유인데, 이 박스에서는 # 그게 DB 소멸이다. 둘 다 무시해 사고 경로를 끊는다. AMI 를 의도적으로 올릴 때는 # 새 박스를 띄워 덤프로 옮기는 절차를 밟는다(이 이관과 같은 방식). + # + # prevent_destroy 는 그 사고 경로의 마지막 층이다. ignore_changes 가 막는 것은 "속성 변경으로 + # 인한 재생성"뿐이라, destroy 를 직접 부르거나 다른 사유로 replace 가 잡히면 그대로 지워진다. + # 이 플래그가 있으면 apply 가 거부되고, 정말 지워야 할 때는 코드에서 이 줄을 먼저 지워야 한다. + # RDS 의 deletion_protection 과 같은 결의 의도된 마찰이다. lifecycle { - ignore_changes = [ami, user_data] + prevent_destroy = true + ignore_changes = [ami, user_data] } tags = { From a57d4688b6df6df31b43cc779d8232cde6a06a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:53:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?infra:=20MySQL=20root=20=EC=9E=90=EA=B2=A9?= =?UTF-8?q?=EC=A6=9D=EB=AA=85=EC=9D=84=20=EC=95=B1=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20root=20=EC=A0=91=EC=86=8D=EC=9D=84=20=EB=8B=AB?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 는 컨테이너 최초 생성 때만 쓰여, 스크립트 수정만으로는 이미 뜬 인스턴스에 반영되지 않는다 --- infra/scripts/db-backup.sh | 8 +++++--- infra/scripts/provision-db.sh | 11 ++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/infra/scripts/db-backup.sh b/infra/scripts/db-backup.sh index 70bc7adb5..f37993363 100755 --- a/infra/scripts/db-backup.sh +++ b/infra/scripts/db-backup.sh @@ -23,21 +23,23 @@ ssm_param() { } DB_NAME="$(ssm_param db-name)" || { log "SSM db-name 조회 실패"; exit 1; } -DB_PASSWORD="$(ssm_param db-password)" || { log "SSM db-password 조회 실패"; 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 로 뜬다(컨테이너 생성 시 root 비밀번호를 db-password 와 같게 넣었다). +# 덤프 → 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_PASSWORD" "$CONTAINER" \ +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")" diff --git a/infra/scripts/provision-db.sh b/infra/scripts/provision-db.sh index 5b9c8a945..675cc8ed2 100755 --- a/infra/scripts/provision-db.sh +++ b/infra/scripts/provision-db.sh @@ -37,6 +37,9 @@ ssm_param() { 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 @@ -47,6 +50,11 @@ else # 박스에서 사설 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· @@ -67,7 +75,8 @@ else -e MYSQL_DATABASE="$DB_NAME" \ -e MYSQL_USER="$DB_USERNAME" \ -e MYSQL_PASSWORD="$DB_PASSWORD" \ - -e MYSQL_ROOT_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 \ From 1b03d43fd9a525be570513f33695469dd6366f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:59:45 +0900 Subject: [PATCH 4/7] =?UTF-8?q?infra:=20DB=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=A2=85=EB=A3=8C=20=EB=B3=B4=ED=98=B8=EB=A5=BC=20?= =?UTF-8?q?AWS=20=EC=B8=B5=EA=B9=8C=EC=A7=80=20=ED=99=95=EC=9E=A5=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 백업이 이미 검증돼 있다 --- terraform/db_ec2.tf | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/terraform/db_ec2.tf b/terraform/db_ec2.tf index fcbc9785d..d31a5d0e2 100644 --- a/terraform/db_ec2.tf +++ b/terraform/db_ec2.tf @@ -253,12 +253,25 @@ resource "aws_instance" "db" { 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 } From 95f3bcb68853ae83b5550389827b3a6a769fd1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:06:27 +0900 Subject: [PATCH 5/7] =?UTF-8?q?infra:=20DB=20=EB=B0=95=EC=8A=A4=EC=97=90?= =?UTF-8?q?=20=EA=B4=80=EC=B8=A1=20=EC=88=98=EC=A7=91=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=9D=B4=EA=B3=A0=20cron=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EC=9D=98=20=EB=A9=B1=EB=93=B1=EC=84=B1=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 이고, 사본을 두면 조용히 갈라진다 --- infra/scripts/provision-db.sh | 57 +++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/infra/scripts/provision-db.sh b/infra/scripts/provision-db.sh index 675cc8ed2..32cbcdf3d 100755 --- a/infra/scripts/provision-db.sh +++ b/infra/scripts/provision-db.sh @@ -5,10 +5,14 @@ # 여기가 맡는다 — user_data 는 첫 부팅에만 실행돼 스크립트를 고쳐도 반영되지 않고, 반영하려면 # 인스턴스 교체가 필요한데 이 박스에서 교체는 곧 데이터 손실이기 때문이다. # -# 실행: 같은 디렉터리의 db-backup.sh 와 함께 박스로 올린 뒤 이 스크립트를 돌린다. +# 실행: 이 스크립트와 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)" @@ -114,12 +118,53 @@ echo "[backup] /usr/local/bin/piki-db-backup.sh 설치" # 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"' -if sudo crontab -l 2>/dev/null | grep -qF 'piki-db-backup.sh'; then - echo "[backup] cron 이미 등록 — 내용 갱신" - sudo crontab -l 2>/dev/null | grep -vF 'piki-db-backup.sh' | { cat; echo "$CRON_LINE"; } | sudo crontab - +# 기존 항목을 걷어낸 뒤 최신 줄을 다시 넣는다 — 등록/갱신을 가르지 않아야 멱등이 단순해진다. +# +# `|| 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 [ ! -f "${ALLOY_DIR}/provision-alloy.sh" ]; then + echo "[alloy] ${ALLOY_DIR} 없음 — 수집기 설치를 건너뛴다 (infra 의 blocks/alloy 를 함께 올릴 것)" else - echo "[backup] cron 등록 (매일 19:00 UTC = 04:00 KST)" - sudo crontab -l 2>/dev/null | { cat; echo "$CRON_LINE"; } | sudo crontab - + 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 박스 프로비저닝 완료" From 4cfeb2e0a1df41c78444ba0afaf9cbeb3c2c1ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:23:39 +0900 Subject: [PATCH 6/7] =?UTF-8?q?infra:=20RDS=20=EB=A5=BC=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=ED=95=98=EA=B3=A0=20=EA=B4=80=EB=A0=A8=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=EB=A5=BC=20=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자체 관리 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 만 보고 있어 이 삭제가 트래픽 경로에 닿지 않는다 --- terraform/db_ec2.tf | 2 +- terraform/outputs.tf | 10 ----- terraform/rds.tf | 84 ------------------------------------ terraform/security_groups.tf | 28 +----------- 4 files changed, 3 insertions(+), 121 deletions(-) delete mode 100644 terraform/rds.tf diff --git a/terraform/db_ec2.tf b/terraform/db_ec2.tf index d31a5d0e2..0114f3a60 100644 --- a/terraform/db_ec2.tf +++ b/terraform/db_ec2.tf @@ -18,7 +18,7 @@ # ----------------------------------------------------------------------------- # DB 박스 Security Group # -# 3306 은 앱 EC2 SG 에서만 받는다(rds.tf 의 aws_security_group.rds 와 같은 규율) — DB 를 +# 3306 은 앱 EC2 SG 에서만 받는다(폐기된 RDS SG 가 지키던 것과 같은 규율) — DB 를 # 인터넷에 직접 노출하지 않는다. SSH 는 EC2 Instance Connect·팀원 접속을 위해 열되 # var.ssh_ingress_cidr 로 좁힌다. # diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 85a660b19..0ab05a5b4 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -33,16 +33,6 @@ 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 "rds_address" { - description = "RDS 호스트" - value = aws_db_instance.mysql.address -} - output "db_ec2_private_ip" { description = "자체 관리 DB 박스 사설 IP — SSM /piki-core/prod/db-host 에 넣을 값 (#898). 앱은 이 주소로만 접속한다." value = aws_instance.db.private_ip diff --git a/terraform/rds.tf b/terraform/rds.tf deleted file mode 100644 index 4123ba26b..000000000 --- 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 e43d9b659..9d476a1e0 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 다. From 4ff23c4f0dc95327a85486f0a66960e72dd51aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:47:52 +0900 Subject: [PATCH 7/7] =?UTF-8?q?infra:=20=ED=94=84=EB=A1=9C=EB=B9=84?= =?UTF-8?q?=EC=A0=80=EB=8B=9D=EC=9D=B4=20=EC=9E=90=EA=B2=A9=EC=A6=9D?= =?UTF-8?q?=EB=AA=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=EC=99=80=20=EA=B4=80?= =?UTF-8?q?=EC=B8=A1=20=EB=88=84=EB=9D=BD=EC=9D=84=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=9E=88=20=EB=84=98=EA=B8=B0=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 이 나오는 것을 확인했다 --- infra/scripts/provision-db.sh | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/infra/scripts/provision-db.sh b/infra/scripts/provision-db.sh index 32cbcdf3d..b0b1f05b3 100755 --- a/infra/scripts/provision-db.sh +++ b/infra/scripts/provision-db.sh @@ -16,6 +16,17 @@ 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" @@ -98,6 +109,29 @@ for i in $(seq 1 30); do [ "$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 의 스크립트와 박스의 것이 어긋나지 않게 한다. @@ -136,8 +170,14 @@ echo "[backup] cron 등록·갱신 (매일 19:00 UTC = 04:00 KST)" # 빼는 기존 정책과 같은 결이라 여기서도 유지한다(로그 볼륨). MySQL 내부 지표(연결 수·쿼리 지연)가 # 필요해지면 mysqld_exporter 를 따로 붙이는 별도 작업이다. ALLOY_DIR="${SCRIPT_DIR}/alloy" -if [ ! -f "${ALLOY_DIR}/provision-alloy.sh" ]; then - echo "[alloy] ${ALLOY_DIR} 없음 — 수집기 설치를 건너뛴다 (infra 의 blocks/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 \