Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
56 changes: 46 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,18 @@ 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
# Blue/Green 활성 슬롯을 알 수 없으므로 두 슬롯의 app 포트를 순서대로 시도한다.
ALARM_API_PORTS="8080 9080"

aws ssm start-session \
--target "$API_INSTANCE_ID" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
Expand Down Expand Up @@ -202,10 +229,16 @@ 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_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 +247,9 @@ 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_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
11 changes: 6 additions & 5 deletions environment/prod/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ 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
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
11 changes: 11 additions & 0 deletions environment/prod/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,14 @@ 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)
}
4 changes: 4 additions & 0 deletions environment/stage/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ data "aws_vpc" "default" {
module "stage_stack" {
source = "../../modules/app_stack"

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

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

Expand Down
14 changes: 14 additions & 0 deletions modules/app_stack/security_groups.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ resource "aws_security_group" "api_sg" {
}
}

# DB EC2 는 인터넷 경로가 없어 API EC2 의 app 포트로 백업 실패 알림을 보냅니다.
# Blue/Green 활성 슬롯을 알 수 없어 두 슬롯의 포트를 모두 열고, 소스는 DB EC2 서브넷으로 제한합니다.
# db_ec2_sg 가 이미 api_sg 를 참조하므로 보안 그룹을 소스로 쓰면 순환 참조가 되어 서브넷 CIDR 을 사용합니다.
dynamic "ingress" {
for_each = var.enable_db_ec2 ? toset(var.internal_alarm_api_ports) : toset([])
content {
description = "Internal backup alarm from DB EC2 subnet"
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = [data.aws_subnet.db_ec2[0].cidr_block]
}
}

# [Outbound] 모든 트래픽 허용
egress {
from_port = 0
Expand Down
5 changes: 5 additions & 0 deletions modules/app_stack/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,8 @@ 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)
}
55 changes: 52 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,57 @@ 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 서브넷에서만 허용합니다.
- 알림 전송 실패는 백업 자체를 실패시키지 않고 로그로만 남깁니다.

알림 유형:

| 유형 | 발생 조건 |
|------|-----------|
| `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의 환경 파일을 갱신합니다.

1번과 2번 사이에는 API 서버가 이전 토큰을 사용하므로 알림이 거부됩니다. 백업 자체는 계속 동작하며, 회전은 백업 실패가 없는 시점에 수행합니다.

## dump 실패 처리

- dump 실행 직전에 예상 dump 크기의 2배와 256MiB의 여유 공간을 확인합니다.
Expand Down
43 changes: 26 additions & 17 deletions scripts/mysql_backup/bin/mysql-backup-binlog
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ readonly LIB_DIR="${MYSQL_BACKUP_LIB_DIR:-/usr/local/lib/solid-connection/mysql-
# shellcheck source=../lib/backup-common.sh
source "$LIB_DIR/backup-common.sh"

# 사전 조건 검사 실패도 알리도록 소싱 직후에 등록합니다.
trap 'alarm_on_unexpected_failure BINLOG_UPLOAD_FAILED' EXIT

require_backup_environment
require_commands aws docker flock sha256sum
require_commands aws curl docker flock sha256sum

# 타이머 주기(5분)와 같은 값을 쓰면 정상 동작 중에도 경계에서 매번 지연으로 판정되므로 3주기로 둔다.
readonly UPLOAD_DELAY_THRESHOLD_SECONDS=900

alarm_if_upload_delayed "$STATE_DIR/last-binlog-success" "$UPLOAD_DELAY_THRESHOLD_SECONDS"

exec 9>"$STATE_DIR/binlog.lock"
if ! flock -n 9; then
Expand Down Expand Up @@ -53,8 +61,7 @@ host_binlog_index="$MYSQL_DATA_DIR/$(basename "$binlog_basename").index"

if ! validate_binlog_name "$active_binlog" || \
[[ ! "$database_server_uuid" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
echo "Unexpected MySQL binary log metadata." >&2
exit 1
fail_with_alarm BINLOG_UPLOAD_FAILED "unexpected mysql binary log metadata"
fi
if [[ ! -r "$host_binlog_index" ]]; then
echo "MySQL binary log index is not readable: $host_binlog_index" >&2
Expand Down Expand Up @@ -131,8 +138,8 @@ if [[ -n "$last_uploaded" ]]; then
active_number=$((10#${active_binlog##*.}))
last_uploaded_number=$((10#${last_uploaded##*.}))
if ((active_number <= last_uploaded_number)); then
echo "Binary log numbering moved backwards. Clear the state only after starting a new verified backup chain." >&2
exit 1
fail_with_alarm BINLOG_GAP_DETECTED \
"binary log numbering moved backwards: active=$active_binlog last_uploaded=$last_uploaded"
fi
fi

Expand All @@ -152,8 +159,7 @@ if [[ "$rotation_to" == "-" ]]; then
observed_active_binlog="$(read_active_binlog)"
fi
if ! validate_binlog_name "$observed_active_binlog" || [[ "$observed_active_binlog" == "$rotation_from" ]]; then
echo "MySQL binary log rotation did not advance from $rotation_from." >&2
exit 1
fail_with_alarm BINLOG_UPLOAD_FAILED "binary log rotation did not advance from $rotation_from"
fi
rotation_to="$observed_active_binlog"
write_binlog_state
Expand Down Expand Up @@ -188,15 +194,14 @@ while IFS= read -r indexed_path; do
previous_number=$((10#${previous_binlog##*.}))
current_number=$((10#${binlog_name##*.}))
if ((current_number != previous_number + 1)); then
echo "Binary log gap detected between $previous_binlog and $binlog_name." >&2
exit 1
fail_with_alarm BINLOG_GAP_DETECTED \
"binary log gap detected between $previous_binlog and $binlog_name"
fi
fi

binlog_file="$MYSQL_DATA_DIR/$binlog_name"
if [[ ! -s "$binlog_file" ]]; then
echo "Closed binary log file is missing or empty: $binlog_file" >&2
exit 1
fail_with_alarm BINLOG_GAP_DETECTED "closed binary log file is missing or empty: $binlog_name"
fi

closed_epoch="$(stat -c %Y "$binlog_file")"
Expand All @@ -220,9 +225,13 @@ while IFS= read -r indexed_path; do
EOF

object_prefix="binlog/$key_date/${key_time}-${database_server_uuid}-${binlog_name}"
upload_file_once "$binlog_file" "$object_prefix"
if ! upload_file_once "$binlog_file" "$object_prefix"; then
fail_with_alarm BINLOG_UPLOAD_FAILED "failed to upload the binary log to s3: $binlog_name"
fi
# manifest가 존재하는 binlog만 복구 가능한 업로드 완료 파일로 취급합니다.
upload_file_once "$manifest_file" "$object_prefix.manifest.json"
if ! upload_file_once "$manifest_file" "$object_prefix.manifest.json"; then
fail_with_alarm BINLOG_UPLOAD_FAILED "failed to upload the binary log manifest to s3: $binlog_name"
fi
rm -f "$manifest_file"

last_uploaded="$binlog_name"
Expand All @@ -232,15 +241,15 @@ EOF
done <"$host_binlog_index"

if [[ "$active_found" != "true" ]]; then
echo "Active binary log is not present in the binary log index: $active_binlog" >&2
exit 1
fail_with_alarm BINLOG_GAP_DETECTED \
"active binary log is not present in the binary log index: $active_binlog"
fi
if [[ -n "$previous_binlog" ]]; then
previous_number=$((10#${previous_binlog##*.}))
active_number=$((10#${active_binlog##*.}))
if ((active_number != previous_number + 1)); then
echo "Binary log gap detected between $previous_binlog and active log $active_binlog." >&2
exit 1
fail_with_alarm BINLOG_GAP_DETECTED \
"binary log gap detected between $previous_binlog and active log $active_binlog"
fi
fi

Expand Down
Loading
Loading