Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 57 additions & 10 deletions .github/workflows/mysql-backup-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
submodules: recursive
token: ${{ secrets.GH_PAT }}

- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
Expand Down Expand Up @@ -92,16 +94,29 @@ jobs:
- name: Validate or install MySQL backup
env:
DEPLOY_MODE: ${{ inputs.mode }}
MYSQL_BACKUP_BUCKET: ${{ vars.MYSQL_BACKUP_BUCKET_NAME }}
MYSQL_DATABASE: ${{ vars.MYSQL_BACKUP_DATABASE_NAME }}
DB_HOST_FINGERPRINT: ${{ vars.PROD_DB_SSH_HOST_KEY_ED25519 }}
MYSQL_BACKUP_BUCKET: ${{ secrets.MYSQL_BACKUP_BUCKET_NAME }}
MYSQL_DATABASE: ${{ secrets.MYSQL_BACKUP_DATABASE_NAME }}
DB_HOST_FINGERPRINT: ${{ secrets.PROD_DB_SSH_HOST_KEY_ED25519 }}
ALARM_TFVARS_PATH: config/secrets/prod_db.tfvars
run: |
set -Eeuo pipefail
umask 077

: "${MYSQL_BACKUP_BUCKET:?MYSQL_BACKUP_BUCKET_NAME repository variable is required}"
: "${MYSQL_DATABASE:?MYSQL_BACKUP_DATABASE_NAME repository variable is required}"
: "${DB_HOST_FINGERPRINT:?PROD_DB_SSH_HOST_KEY_ED25519 repository variable is required}"
: "${MYSQL_BACKUP_BUCKET:?MYSQL_BACKUP_BUCKET_NAME repository secret is required}"
: "${MYSQL_DATABASE:?MYSQL_BACKUP_DATABASE_NAME repository secret is required}"
: "${DB_HOST_FINGERPRINT:?PROD_DB_SSH_HOST_KEY_ED25519 repository secret is required}"

# 알림 토큰은 tfvars 를 단일 원천으로 두므로 secrets submodule 에서 읽는다.
if [[ ! -f "$ALARM_TFVARS_PATH" ]]; then
echo "::error::$ALARM_TFVARS_PATH is missing; check the secrets submodule checkout"
exit 1
fi
ALARM_API_TOKEN="$(sed -n 's/^[[:space:]]*mysql_backup_fail_alarm_request_token[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$ALARM_TFVARS_PATH" | head -1)"
if [[ -z "$ALARM_API_TOKEN" ]]; then
echo "::error::mysql_backup_fail_alarm_request_token is missing in $ALARM_TFVARS_PATH"
exit 1
fi
echo "::add-mask::$ALARM_API_TOKEN"
if [[ "$DEPLOY_MODE" != "validate" && "$DEPLOY_MODE" != "install" ]]; then
echo "::error::Invalid deployment mode"
exit 1
Expand Down Expand Up @@ -162,6 +177,27 @@ jobs:
exit 1
fi

# DB EC2 는 인터넷 경로가 없어 API EC2 의 private ip 로 알림을 보낸다.
ALARM_API_HOST="$(aws ec2 describe-instances \
--instance-ids "$API_INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' \
--output text)"
if [[ -z "$ALARM_API_HOST" || "$ALARM_API_HOST" == "None" ]]; then
echo "::error::Prod API EC2 private IP was not found"
exit 1
fi
# 포트도 tfvars 를 단일 원천으로 두고 읽는다. Blue/Green 활성 슬롯을 알 수 없어 두 슬롯을 모두 시도한다.
ALARM_API_PORTS="$(sed -n 's/^[[:space:]]*internal_alarm_api_ports[[:space:]]*=[[:space:]]*\[\(.*\)\][[:space:]]*$/\1/p' "$ALARM_TFVARS_PATH" | tr -d ' ' | tr ',' ' ')"
ALARM_API_HEALTH_PORTS="$(sed -n 's/^[[:space:]]*internal_alarm_api_management_ports[[:space:]]*=[[:space:]]*\[\(.*\)\][[:space:]]*$/\1/p' "$ALARM_TFVARS_PATH" | tr -d ' ' | tr ',' ' ')"
if [[ -z "$ALARM_API_PORTS" ]]; then
echo "::error::internal_alarm_api_ports is missing in $ALARM_TFVARS_PATH"
exit 1
fi
if [[ -z "$ALARM_API_HEALTH_PORTS" ]]; then
echo "::error::internal_alarm_api_management_ports is missing in $ALARM_TFVARS_PATH"
exit 1
fi

aws ssm start-session \
--target "$API_INSTANCE_ID" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
Expand Down Expand Up @@ -202,10 +238,17 @@ jobs:
--instance-id "$DB_INSTANCE_ID" \
--instance-os-user ubuntu \
--ssh-public-key "file://$KEY_DIR/id_ed25519.pub" >/dev/null
REMOTE_VALIDATE_COMMAND="env MYSQL_BACKUP_BUCKET=$(printf '%q' "$MYSQL_BACKUP_BUCKET") MYSQL_DATABASE=$(printf '%q' "$MYSQL_DATABASE") AWS_REGION=$(printf '%q' "$AWS_REGION") bash -s"
ssh "${SSH_OPTIONS[@]}" ubuntu@127.0.0.1 \
"sudo bash -c $(printf '%q' "$REMOTE_VALIDATE_COMMAND")" \
< scripts/mysql_backup/validate-remote.sh
# 토큰이 원격 프로세스 인자와 sudo 감사 로그에 남지 않도록 표준 입력으로만 전달한다.
{
printf 'export MYSQL_BACKUP_BUCKET=%q\n' "$MYSQL_BACKUP_BUCKET"
printf 'export MYSQL_DATABASE=%q\n' "$MYSQL_DATABASE"
printf 'export AWS_REGION=%q\n' "$AWS_REGION"
printf 'export ALARM_API_HOST=%q\n' "$ALARM_API_HOST"
printf 'export ALARM_API_PORTS=%q\n' "$ALARM_API_PORTS"
printf 'export ALARM_API_HEALTH_PORTS=%q\n' "$ALARM_API_HEALTH_PORTS"
printf 'export ALARM_API_TOKEN=%q\n' "$ALARM_API_TOKEN"
cat scripts/mysql_backup/validate-remote.sh
} | ssh "${SSH_OPTIONS[@]}" ubuntu@127.0.0.1 "sudo bash -s"
exit 0
fi

Expand All @@ -214,6 +257,10 @@ jobs:
printf 'MYSQL_BACKUP_BUCKET=%s\n' "$MYSQL_BACKUP_BUCKET"
printf 'MYSQL_DATABASE=%s\n' "$MYSQL_DATABASE"
printf 'AWS_REGION=%s\n' "$AWS_REGION"
printf 'ALARM_API_HOST=%s\n' "$ALARM_API_HOST"
printf 'ALARM_API_PORTS=%s\n' "$ALARM_API_PORTS"
printf 'ALARM_API_HEALTH_PORTS=%s\n' "$ALARM_API_HEALTH_PORTS"
printf 'ALARM_API_TOKEN=%s\n' "$ALARM_API_TOKEN"
} >"$CONFIG_FILE"

cp -R scripts/mysql_backup "$KEY_DIR/bundle"
Expand Down
2 changes: 1 addition & 1 deletion config/secrets
12 changes: 7 additions & 5 deletions environment/prod/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ module "prod_stack" {
db_instance_class = var.db_instance_class

# DB EC2 설정
enable_db_ec2 = true
db_instance_type = var.db_ec2_instance_type
db_ami_id = var.db_ec2_ami_id
db_subnet_id = var.db_ec2_subnet_id
db_data_volume_size = var.db_data_volume_size
enable_db_ec2 = true
internal_alarm_api_ports = var.internal_alarm_api_ports
internal_alarm_api_management_ports = var.internal_alarm_api_management_ports
db_instance_type = var.db_ec2_instance_type
db_ami_id = var.db_ec2_ami_id
db_subnet_id = var.db_ec2_subnet_id
db_data_volume_size = var.db_data_volume_size

# 보안 그룹 규칙
api_ingress_rules = var.api_ingress_rules
Expand Down
3 changes: 3 additions & 0 deletions environment/prod/mysql_backup.tf
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "mysql_backup" {
bucket = aws_s3_bucket.mysql_backup.id

rule {
# 백업은 SSE-S3로 고정합니다. 선언하지 않으면 apply 시 SSE-C 차단이 해제됩니다.
blocked_encryption_types = ["SSE-C"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
Expand Down
5 changes: 3 additions & 2 deletions environment/prod/provider.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ terraform {

required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
source = "hashicorp/aws"
# blocked_encryption_types 는 6.22.0 부터 지원합니다.
version = ">= 6.22.0"
}
mysql = {
source = "petoju/mysql"
Expand Down
16 changes: 16 additions & 0 deletions environment/prod/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,19 @@ variable "alloy_version" {
description = "Docker image tag for Grafana Alloy"
type = string
}

variable "mysql_backup_fail_alarm_request_token" {
description = "백업 실패 알림 API 호출에 사용하는 공유 토큰. Terraform은 이 값을 사용하지 않고 배포 워크플로우가 tfvars에서 직접 읽는다."
type = string
sensitive = true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

variable "internal_alarm_api_ports" {
description = "DB EC2가 백업 실패 알림을 보내는 API 서버의 Blue/Green app 포트"
type = list(number)
}

variable "internal_alarm_api_management_ports" {
description = "DB EC2가 설치 검증에서 /actuator/health를 확인하는 API 서버의 Blue/Green management 포트"
type = list(number)
}
5 changes: 5 additions & 0 deletions environment/stage/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ data "aws_vpc" "default" {
module "stage_stack" {
source = "../../modules/app_stack"

# stage 는 DB 가 API 인스턴스의 컨테이너로 떠 있어 별도 DB EC2 가 없다.
# enable_db_ec2 가 false 라 알림 인그레스가 생성되지 않으므로 빈 목록을 넘긴다.
internal_alarm_api_ports = []
internal_alarm_api_management_ports = []

env_name = "stage"
vpc_id = data.aws_vpc.default.id

Expand Down
5 changes: 4 additions & 1 deletion modules/app_stack/db_ec2.tf
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ resource "aws_instance" "db_server" {
instance_type = var.db_instance_type
subnet_id = var.db_subnet_id

vpc_security_group_ids = [aws_security_group.db_ec2_sg[count.index].id]
vpc_security_group_ids = [
aws_security_group.db_ec2_sg[count.index].id,
aws_security_group.db_ec2_alarm_client_sg[count.index].id,
]
associate_public_ip_address = false
iam_instance_profile = var.ec2_iam_instance_profile
key_name = var.key_name
Expand Down
41 changes: 40 additions & 1 deletion modules/app_stack/security_groups.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ resource "aws_security_group" "api_sg" {
}
}

# DB EC2 는 인터넷 경로가 없어 API EC2 를 거쳐 백업 실패 알림을 보냅니다.
# Blue/Green 활성 슬롯을 알 수 없어 두 슬롯의 app 포트를 모두 열고,
# 설치 검증에서 /actuator/health 를 확인하기 위해 management 포트도 함께 엽니다.
# 소스는 DB EC2 에만 붙는 알림 전용 보안 그룹이므로 같은 서브넷의 다른 인스턴스는 접근할 수 없습니다.
dynamic "ingress" {
for_each = var.enable_db_ec2 ? toset(concat(
var.internal_alarm_api_ports,
var.internal_alarm_api_management_ports
)) : toset([])
content {
description = "Internal backup alarm from DB EC2"
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
security_groups = [aws_security_group.db_ec2_alarm_client_sg[0].id]
}
}

# [Outbound] 모든 트래픽 허용
egress {
from_port = 0
Expand All @@ -28,7 +46,28 @@ resource "aws_security_group" "api_sg" {
}
}

# 2. DB EC2용 보안 그룹 (API Server만 믿음)
# 2. DB EC2 알림 클라이언트용 보안 그룹
# - DB EC2 가 API 서버의 알림 경로를 호출할 때 출처를 특정하기 위한 그룹입니다.
# - 인바운드 규칙이 없어 api_sg 를 참조하지 않으므로, db_ec2_sg 와 달리 순환 참조가 생기지 않습니다.
resource "aws_security_group" "db_ec2_alarm_client_sg" {
count = var.enable_db_ec2 ? 1 : 0
name = "sc-${var.env_name}-db-ec2-alarm-client-sg"
description = "Client Security Group for DB EC2 backup alarm requests"
vpc_id = var.vpc_id

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}

tags = {
Name = "solid-connection-db-ec2-alarm-client-${var.env_name}-sg"
}
}

# 3. DB EC2용 보안 그룹 (API Server만 믿음)
resource "aws_security_group" "db_ec2_sg" {
count = var.enable_db_ec2 ? 1 : 0
name = "sc-${var.env_name}-db-ec2-sg"
Expand Down
10 changes: 10 additions & 0 deletions modules/app_stack/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,13 @@ variable "alloy_version" {
description = "Docker image tag for Grafana Alloy"
type = string
}

variable "internal_alarm_api_ports" {
description = "DB EC2가 백업 실패 알림을 보내는 API 서버의 Blue/Green app 포트"
type = list(number)
}

variable "internal_alarm_api_management_ports" {
description = "DB EC2가 설치 검증에서 /actuator/health를 확인하는 API 서버의 Blue/Green management 포트"
type = list(number)
}
75 changes: 72 additions & 3 deletions scripts/mysql_backup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ DB EC2에서 다음 systemd 작업을 실행합니다.
Repository Secrets:

- `AWS_ROLE_ARN`: 배포 워크플로우가 AssumeRole할 IAM 역할 ARN

Repository Variables:

- `GH_PAT`: secrets submodule을 체크아웃할 토큰
- `MYSQL_BACKUP_BUCKET_NAME`: 백업 버킷 이름
- `MYSQL_BACKUP_DATABASE_NAME`: 백업할 DB 이름. 필수값이며 공개 코드에 기본값을 두지 않습니다.
- `PROD_DB_SSH_HOST_KEY_ED25519`: DB EC2의 ED25519 host key SHA-256 fingerprint
Expand All @@ -27,6 +25,77 @@ GitHub Environment:

`MySQL Backup Test` 워크플로우는 AWS 권한이나 운영 환경 접근 없이 백업 스크립트 단위 테스트를 수동으로 실행합니다.

## 백업 실패 알림

백업이 실패하거나 지연되면 API 서버의 내부 전용 API를 거쳐 Discord로 알립니다.

```text
DB EC2 (private subnet, 인터넷 경로 없음)
└─ 백업 실패 감지
└─ POST http://<API EC2 private ip>:<8080 또는 9080>/internal/alarms/db-backup
└─ API 서버 → Discord Webhook
```

- DB EC2가 있는 서브넷의 라우팅 테이블에는 NAT와 IGW가 없어 Discord를 직접 호출할 수 없으므로 API 서버가 중계합니다.
- Blue/Green 활성 슬롯을 알 수 없으므로 두 슬롯의 app 포트를 순서대로 시도하고 먼저 응답한 쪽으로 보냅니다.
- API EC2 보안 그룹은 DB EC2 전용 클라이언트 보안 그룹에서 오는 요청만 이 포트들로 허용합니다. 서브넷을 소스로 두지 않으므로 같은 서브넷의 다른 인스턴스는 접근할 수 없습니다.
- 알림 전송 실패는 백업 자체를 실패시키지 않고 로그로만 남깁니다.

### 사용하는 포트

포트는 `config/secrets/prod_db.tfvars`를 단일 원천으로 두고, 보안 그룹과 배포 워크플로우가 같은 값을 읽습니다.

| tfvars 변수 | 값 | 용도 |
|-------------|-----|------|
| `internal_alarm_api_ports` | `[8080, 9080]` | 알림 경로 `POST /internal/alarms/db-backup` 호출 |
| `internal_alarm_api_management_ports` | `[8081, 9081]` | 설치 검증에서 `GET /actuator/health` 확인 |

management 포트는 app 포트에서 규칙으로 유도하지 않고 별도 변수로 둡니다. 두 포트의 관계가 바뀌어도 한쪽만 고치면 되기 때문입니다.

### 설치 검증이 확인하는 것

TCP 연결만으로는 애플리케이션이 기동했는지, 알림 경로가 배포되었는지 알 수 없으므로 두 단계로 확인합니다.

1. management 포트의 `/actuator/health`가 `"status":"UP"`을 반환하는지 확인합니다. 애플리케이션 기동 여부를 확인합니다.
2. 잘못된 토큰으로 알림 경로를 호출해 `401`이 오는지 확인합니다. 경로가 배포되지 않았다면 `404`가 오므로 배포 여부를 구분할 수 있습니다.

토큰 값이 실제로 맞는지는 알림을 발생시키지 않고 확인할 수 없어 검증 대상에서 제외합니다. 알림 경로는 `@Valid`가 먼저 동작해 본문이 잘못되면 토큰 검사 전에 `400`을 반환하므로, 검증 요청은 형식이 올바른 본문에 잘못된 토큰만 담아 보냅니다.

알림 유형:

| 유형 | 발생 조건 |
|------|-----------|
| `DUMP_FAILED` | 여유 공간 부족, mysqldump 실패, 복구 기준점 누락, dump 업로드 실패 |
| `BINLOG_UPLOAD_FAILED` | binlog 회전 실패, binlog 업로드 실패 |
| `BINLOG_GAP_DETECTED` | binlog 번호 불연속, 번호 역행, 닫힌 파일 누락 |
| `BINLOG_UPLOAD_DELAYED` | 마지막 성공 업로드가 900초(타이머 3주기)를 초과 |

`BINLOG_UPLOAD_DELAYED`의 임계값을 타이머 주기와 같은 300초로 두면 정상 동작 중에도 경계에서 매번 지연으로 판정되므로 3주기인 900초를 사용합니다. 판정은 binlog 작업이 실행되는 시점에 이루어지므로 실제 알림은 다음 실행에서 발생할 수 있습니다.

`BINLOG_UPLOAD_DELAYED`는 스크립트가 실행되고 있을 때만 감지할 수 있습니다. EC2나 타이머 자체가 멈춘 경우는 감지할 수 없으므로 S3의 마지막 객체 시각을 외부에서 관찰하는 모니터링이 별도로 필요합니다.

### 알림 인증 토큰

호출자 인증 토큰은 두 곳에서 읽습니다. 각 구성 요소가 자기 설정 체계를 따르므로 값 자체는 두 곳에 존재합니다.

| 사용처 | 위치 |
|--------|------|
| DB EC2의 백업 스크립트 | `config/secrets/prod_db.tfvars`의 `mysql_backup_fail_alarm_request_token` |
| API 서버 | Parameter Store의 `/solid-connection/{env}/internal-alarm.token` |

배포 워크플로우가 secrets submodule에서 값을 읽어 DB EC2의 `/etc/solid-connection/mysql-backup.env`에 기록하므로, 스크립트 쪽 값을 바꿀 때 Terraform apply는 필요하지 않습니다.

### 토큰 회전 절차

두 곳의 값이 어긋나면 모든 알림이 401로 거부되므로 다음 순서를 지킵니다.

1. Parameter Store의 `/solid-connection/{env}/internal-alarm.token`을 새 값으로 변경합니다.
2. API 서버를 재배포해 새 토큰을 읽게 합니다.
3. `config/secrets/prod_db.tfvars`의 `mysql_backup_fail_alarm_request_token`을 같은 값으로 변경하고 커밋합니다.
4. `MySQL Backup Deploy` 워크플로우를 `install`로 실행해 DB EC2의 환경 파일을 갱신합니다.

2번과 4번 사이에는 API 서버가 새 토큰을, DB EC2가 이전 토큰을 사용하므로 알림이 거부됩니다. 1번만 수행한 시점에는 API 서버가 아직 이전 토큰을 들고 있어 알림이 정상 동작합니다. 백업 자체는 회전 중에도 계속 동작하며, 회전은 백업 실패가 없는 시점에 수행합니다.

## dump 실패 처리

- dump 실행 직전에 예상 dump 크기의 2배와 256MiB의 여유 공간을 확인합니다.
Expand Down
Loading
Loading