diff --git a/.github/workflows/mysql-backup-deploy.yml b/.github/workflows/mysql-backup-deploy.yml index 3c12049..0c6035b 100644 --- a/.github/workflows/mysql-backup-deploy.yml +++ b/.github/workflows/mysql-backup-deploy.yml @@ -30,10 +30,15 @@ jobs: if: github.ref == 'refs/heads/main' environment: prod-db runs-on: ubuntu-latest + # 설치는 진행 중인 백업 작업을 기다려도 수 분이면 끝납니다. + # 기본값 6시간을 그대로 두면 터널이 끊긴 채로 오래 매달릴 수 있어 짧게 제한합니다. + timeout-minutes: 20 steps: - 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: @@ -92,16 +97,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 @@ -162,6 +180,28 @@ 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::Could not read internal_alarm_api_ports from $ALARM_TFVARS_PATH. Declare it on a single line, for example: internal_alarm_api_ports = [8080, 9080]" + exit 1 + fi + if [[ -z "$ALARM_API_HEALTH_PORTS" ]]; then + echo "::error::Could not read internal_alarm_api_management_ports from $ALARM_TFVARS_PATH. Declare it on a single line, for example: internal_alarm_api_management_ports = [8081, 9081]" + exit 1 + fi + aws ssm start-session \ --target "$API_INSTANCE_ID" \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ @@ -194,6 +234,10 @@ jobs: -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS_FILE" -o ConnectTimeout=10 + # 설치가 진행 중인 백업 작업을 기다리는 동안에는 트래픽이 없습니다. + # SSM 세션이 유휴로 끊기지 않도록 주기적으로 keepalive 를 보냅니다. + -o ServerAliveInterval=30 + -o ServerAliveCountMax=6 ) if [[ "$DEPLOY_MODE" == "validate" ]]; then @@ -202,10 +246,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 @@ -214,6 +265,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" diff --git a/config/secrets b/config/secrets index c9f90e3..cbceeaa 160000 --- a/config/secrets +++ b/config/secrets @@ -1 +1 @@ -Subproject commit c9f90e38261e50b7ffab749a37afc25dade1721a +Subproject commit cbceeaaba7de5fa1c944732d2223759fe4a44d2a diff --git a/environment/prod/main.tf b/environment/prod/main.tf index ab7a423..4ea1a90 100644 --- a/environment/prod/main.tf +++ b/environment/prod/main.tf @@ -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 diff --git a/environment/prod/mysql_backup.tf b/environment/prod/mysql_backup.tf index 2bb5c55..508a336 100644 --- a/environment/prod/mysql_backup.tf +++ b/environment/prod/mysql_backup.tf @@ -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"] + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } diff --git a/environment/prod/provider.tf b/environment/prod/provider.tf index 52f4aec..998c321 100644 --- a/environment/prod/provider.tf +++ b/environment/prod/provider.tf @@ -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" diff --git a/environment/prod/variables.tf b/environment/prod/variables.tf index 9236616..f300e67 100644 --- a/environment/prod/variables.tf +++ b/environment/prod/variables.tf @@ -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 +} + +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) +} diff --git a/environment/stage/main.tf b/environment/stage/main.tf index 4921402..551131a 100644 --- a/environment/stage/main.tf +++ b/environment/stage/main.tf @@ -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 diff --git a/modules/app_stack/db_ec2.tf b/modules/app_stack/db_ec2.tf index cac27bb..c575e86 100644 --- a/modules/app_stack/db_ec2.tf +++ b/modules/app_stack/db_ec2.tf @@ -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 diff --git a/modules/app_stack/security_groups.tf b/modules/app_stack/security_groups.tf index 0de5d32..6b406d4 100644 --- a/modules/app_stack/security_groups.tf +++ b/modules/app_stack/security_groups.tf @@ -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 @@ -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" diff --git a/modules/app_stack/variables.tf b/modules/app_stack/variables.tf index c4bb113..890b234 100644 --- a/modules/app_stack/variables.tf +++ b/modules/app_stack/variables.tf @@ -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) +} diff --git a/scripts/mysql_backup/README.md b/scripts/mysql_backup/README.md index 91a999a..2be50ef 100644 --- a/scripts/mysql_backup/README.md +++ b/scripts/mysql_backup/README.md @@ -7,14 +7,27 @@ DB EC2에서 다음 systemd 작업을 실행합니다. 백업 파일은 `/mnt/mysql-data/mysql-backup`에서만 임시 생성합니다. S3에는 manifest를 마지막에 업로드하며, 같은 key가 이미 존재하면 체크섬이 같은 경우에만 업로드를 생략합니다. 이 방식으로 재시도 시 Object Lock 버킷에 불필요한 객체 버전이 생기는 것을 방지합니다. +## 설계 원칙 + +백업 체계를 고치거나 항목을 추가할 때는 **설치 과정의 멱등성**과 **중단 위험**을 항상 함께 검토합니다. 백업은 평소 조용히 동작하다가 복구가 필요한 순간에만 결과가 드러나므로, 설치와 교체 과정에서 생긴 문제가 오래 발견되지 않습니다. + +**멱등성**: 설치는 몇 번을 다시 실행해도 같은 결과여야 합니다. 상태가 누적되거나, 두 번째 실행이 첫 번째와 다르게 동작하거나, 이전 설치의 잔재가 남으면 안 됩니다. 파일이나 설정 항목을 추가할 때는 재설치 시 어떻게 되는지 먼저 확인합니다. + +**중단 위험**: 설치가 어느 지점에서 끊기더라도 백업이 멈춘 채 방치되거나 서로 다른 버전이 섞여 동작해서는 안 됩니다. 다음을 확인합니다. + +- 교체 구간에 타이머가 발화하면 그 주기의 백업이 어떻게 되는가 +- 중단됐을 때 이전 상태로 되돌아가는가 +- 되돌리지 못했다면 그 사실이 드러나는가 +- 무기한 대기하는 지점이 있는가 + +특히 dump는 하루 한 번뿐이라 한 주기를 놓치면 그날 복구 기준점이 사라집니다. binlog처럼 다음 주기가 따라잡아 주지 않습니다. 구체적인 동작은 [재설치가 백업에 주는 영향](#재설치가-백업에-주는-영향)에 정리했습니다. + ## 배포 전 GitHub 설정 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 @@ -27,6 +40,120 @@ GitHub Environment: `MySQL Backup Test` 워크플로우는 AWS 권한이나 운영 환경 접근 없이 백업 스크립트 단위 테스트를 수동으로 실행합니다. +## 백업 실패 알림 + +백업이 실패하거나 지연되면 API 서버의 내부 전용 API를 거쳐 Discord로 알립니다. + +```text +DB EC2 (private subnet, 인터넷 경로 없음) +└─ 백업 실패 감지 + └─ POST http://:<8080 또는 9080>/internal/alarms/db-backup + └─ API 서버 → Discord Webhook +``` + +- DB EC2가 있는 서브넷의 라우팅 테이블에는 NAT와 IGW가 없어 Discord를 직접 호출할 수 없으므로 API 서버가 중계합니다. +- Blue/Green 활성 슬롯을 알 수 없으므로 두 슬롯의 app 포트를 순서대로 시도하고 먼저 응답한 쪽으로 보냅니다. +- API EC2 보안 그룹은 DB EC2 전용 클라이언트 보안 그룹에서 오는 요청만 이 포트들로 허용합니다. 서브넷을 소스로 두지 않으므로 같은 서브넷의 다른 인스턴스는 접근할 수 없습니다. +- 알림 전송 실패는 백업 자체를 실패시키지 않고 로그로만 남깁니다. + +### 알림이 백업을 막지 않는 범위 + +전송 실패와 전제 조건 부재는 다르게 다룹니다. + +- **런타임 전송 실패**(API 서버 다운, 네트워크 일시 장애)는 일시적이고 외부 요인이므로 백업을 실패시키지 않습니다. +- **전제 조건 부재**(`curl` 미설치 등)는 구성 자체가 깨진 상태이므로 사전 조건 검사에서 백업을 중단시킵니다. + +두 번째를 관용적으로 처리하면 알림 경로가 죽은 채 백업만 도는 상태가 되고, 그 사실이 어디에도 드러나지 않습니다. 중단시키면 S3 업로드가 끊겨 외부 freshness 모니터링이 감지하므로, 이미 있는 관찰 경로로 잡힙니다. `curl`은 AMI에 포함되어 있고 설치와 검증 단계에서 매번 확인하므로, 없는 상태는 운영 중 인스턴스에서 의도적으로 제거해야만 만들어집니다. + + +### 사용하는 포트 + +포트는 `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`이 오는지 확인합니다. 경로가 없는 서버는 핸들러를 찾지 못해 정적 리소스로 처리하다 `500`을 반환하므로 배포 여부를 구분할 수 있습니다. + +이 검증은 알림 API가 이미 운영 API 서버에 배포되어 있어야 통과합니다. 백업 설치보다 서버 배포가 먼저입니다. + +토큰 값이 실제로 맞는지는 알림을 발생시키지 않고 확인할 수 없어 검증 대상에서 제외합니다. 알림 경로는 `@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의 마지막 객체 시각을 외부에서 관찰하는 모니터링이 별도로 필요합니다. 이 외부 모니터링의 방식과 도입 여부는 별도 이슈에서 논의합니다. + +### 알림 중복과 유형 보존 + +스크립트는 실패를 명시적으로 알린 뒤 종료하고, 종료 시점에 한 번 더 확인해 알리지 못한 실패를 잡습니다. 전송에 성공했으면 종료 시점 알림은 보내지 않습니다. + +전송에 실패했을 때는 **처음 알리려던 유형과 원인을 그대로 두고 한 번 더 시도합니다.** 두 가지를 동시에 피해야 하기 때문입니다. + +- 기본 유형으로 바꿔 보내면 `BINLOG_GAP_DETECTED`가 `BINLOG_UPLOAD_FAILED`로 둔갑하고 원인 설명도 사라집니다. binlog 연속성이 깨진 것은 PITR 자체가 불가능해진 상황이라 대응이 전혀 다릅니다. +- 재시도 없이 포기하면 dump처럼 하루 한 번 실행되는 작업의 알림이 그날 사라집니다. + +포트를 순회하는 전송을 두 번 시도하므로 알림에 최악 약 84초가 걸립니다. 포트당 `--max-time` 5초에 3초 간격 재시도 2회를 더해 21초이고, 포트 두 개를 순회하면 42초입니다. binlog 작업의 `TimeoutStartSec`이 4분이라 여유가 있습니다. + +지연 알림(`BINLOG_UPLOAD_DELAYED`)은 실패가 아니므로 전송 후 기록을 되돌립니다. 같은 실행에서 실제 실패가 발생하면 그 실패는 따로 알립니다. + +### 알림 인증 토큰 + +호출자 인증 토큰은 두 곳에서 읽습니다. 각 구성 요소가 자기 설정 체계를 따르므로 값 자체는 두 곳에 존재합니다. + +| 사용처 | 위치 | +|--------|------| +| 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 서버가 아직 이전 토큰을 들고 있어 알림이 정상 동작합니다. 백업 자체는 회전 중에도 계속 동작하며, 회전은 백업 실패가 없는 시점에 수행합니다. + +## 재설치가 백업에 주는 영향 + +설치는 몇 번을 다시 실행해도 같은 결과가 됩니다. 디렉터리 생성은 있으면 넘어가고, 파일은 `.new`로 만든 뒤 `mv`로 바꿔치기하며, 설정 파일은 부분 병합 없이 통째로 덮어씁니다. 검증이나 교체가 실패하면 이전 파일과 타이머 상태로 되돌립니다. + +교체 구간에 타이머가 발화하면 스크립트가 락을 얻지 못해 그 주기를 건너뜁니다. 이때 스크립트는 `exit 0`으로 끝나므로 실패로 기록되지 않고 알림도 나가지 않습니다. binlog는 다음 주기가 놓친 파일까지 올려 따라잡지만, **dump는 하루 한 번이라 건너뛰면 그날 복구 기준점이 사라집니다.** 재시도도 걸리지 않습니다. `exit 0`이라 `Restart=on-failure`가 동작하지 않고 타이머도 실행한 것으로 기록하기 때문입니다. + +그래서 설치는 락을 잡기 전에 타이머를 먼저 멈춥니다. 멈춘 사이에 놓친 발화는 `Persistent=true` 설정에 따라 타이머를 다시 켜는 시점에 즉시 실행되므로, 건너뛰는 주기 없이 교체됩니다. + +| 구간 | 시간 | +|------|------| +| 타이머 정지부터 재개까지 (백업이 실행되지 않는 구간) | 보통 수 초 | +| 이미 실행 중인 작업을 기다리는 상한 | 락별 300초 (dump와 binlog 합쳐 최대 600초) | +| 건너뛰는 백업 | 없음 | + +이미 실행 중인 dump나 binlog는 중간에 끊지 않고 끝날 때까지 기다립니다. 한 작업이 서로 다른 버전의 스크립트를 섞어 쓰지 않게 하기 위해서입니다. dump와 binlog의 락을 차례로 기다리며 각각 300초를 넘기면 설치를 중단하고 이전 상태로 되돌립니다. dump는 최대 2시간까지 실행될 수 있으므로, 이 경우 dump 시간대(03:00 KST)를 피해 다시 실행합니다. + +대기하는 지점에는 모두 상한을 둡니다. 상한 없이 기다리면 SSM 세션이 유휴로 끊겨 설치가 실패하는데, 대기 중에는 아무 출력이 없어 원인을 알 수 없는 실패가 됩니다. 같은 이유로 배포 워크플로우는 SSH keepalive를 보내고 작업 시간 제한을 둡니다. + ## dump 실패 처리 - dump 실행 직전에 예상 dump 크기의 2배와 256MiB의 여유 공간을 확인합니다. diff --git a/scripts/mysql_backup/bin/mysql-backup-binlog b/scripts/mysql_backup/bin/mysql-backup-binlog index 0317003..25bef51 100644 --- a/scripts/mysql_backup/bin/mysql-backup-binlog +++ b/scripts/mysql_backup/bin/mysql-backup-binlog @@ -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 @@ -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 @@ -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 @@ -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 @@ -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")" @@ -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" @@ -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 diff --git a/scripts/mysql_backup/bin/mysql-backup-dump b/scripts/mysql_backup/bin/mysql-backup-dump index 3fa57b6..1c60cdb 100755 --- a/scripts/mysql_backup/bin/mysql-backup-dump +++ b/scripts/mysql_backup/bin/mysql-backup-dump @@ -5,8 +5,11 @@ 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 DUMP_FAILED' EXIT + require_backup_environment -require_commands aws docker flock gzip sha256sum +require_commands aws curl docker flock gzip sha256sum readonly MAX_DUMP_JOB_AGE_SECONDS=21600 @@ -64,15 +67,22 @@ install -d -m 700 "$JOB_DIR" if [[ ! -s "$DUMP_FILE" ]]; then partial_dump="$DUMP_FILE.partial" rm -f "$partial_dump" - space_status="$(require_dump_staging_space)" + # 성공 시 stdout 만 파싱해야 하므로 stderr 를 섞지 않고, 실패 원인은 스크립트 로그에 남긴다. + if ! space_status="$(require_dump_staging_space)"; then + fail_with_alarm DUMP_FAILED "insufficient staging space for the mysql dump" + fi read -r database_bytes available_bytes required_bytes <<<"$space_status" echo "MySQL dump staging space is sufficient: database_bytes=$database_bytes available_bytes=$available_bytes required_bytes=$required_bytes" - docker exec "$MYSQL_CONTAINER" sh -lc \ + if ! docker exec "$MYSQL_CONTAINER" sh -lc \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --quick --source-data=2 --routines --events --triggers --hex-blob --set-gtid-purged=OFF --no-tablespaces "$1"' \ - sh "$MYSQL_DATABASE" | gzip -1 >"$partial_dump" + sh "$MYSQL_DATABASE" | gzip -1 >"$partial_dump"; then + fail_with_alarm DUMP_FAILED "mysqldump failed for database $MYSQL_DATABASE" + fi - test -s "$partial_dump" + if [[ ! -s "$partial_dump" ]]; then + fail_with_alarm DUMP_FAILED "mysqldump produced an empty dump for database $MYSQL_DATABASE" + fi mv "$partial_dump" "$DUMP_FILE" fi @@ -81,8 +91,7 @@ printf '%s %s\n' "$dump_checksum" "$(basename "$DUMP_FILE")" >"$CHECKSUM_FILE" source_status="$(gzip -dc "$DUMP_FILE" | grep -m1 '^-- CHANGE REPLICATION SOURCE TO' || true)" if [[ -z "$source_status" ]]; then - echo "The dump does not contain a binary log recovery position." >&2 - exit 1 + fail_with_alarm DUMP_FAILED "the dump does not contain a binary log recovery position" fi source_file="$(sed -n "s/.*SOURCE_LOG_FILE='\([^']*\)'.*/\1/p" <<<"$source_status")" @@ -92,8 +101,7 @@ dump_size="$(stat -c %s "$DUMP_FILE")" created_at="${job_id:0:4}-${job_id:4:2}-${job_id:6:2}T${job_id:9:2}:${job_id:11:2}:${job_id:13:2}Z" if [[ ! "$source_file" =~ ^binlog\.[0-9]{6}$ || ! "$source_position" =~ ^[0-9]+$ || ! "$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 "Failed to parse dump recovery metadata." >&2 - exit 1 + fail_with_alarm DUMP_FAILED "failed to parse dump recovery metadata" fi cat >"$MANIFEST_FILE" <"$MANIFEST_FILE" <"$STATE_DIR/last-dump-success.tmp" mv "$STATE_DIR/last-dump-success.tmp" "$STATE_DIR/last-dump-success" diff --git a/scripts/mysql_backup/bin/mysql-backup-validate b/scripts/mysql_backup/bin/mysql-backup-validate index a072981..fd94658 100755 --- a/scripts/mysql_backup/bin/mysql-backup-validate +++ b/scripts/mysql_backup/bin/mysql-backup-validate @@ -6,7 +6,8 @@ readonly LIB_DIR="${MYSQL_BACKUP_LIB_DIR:-/usr/local/lib/solid-connection/mysql- source "$LIB_DIR/backup-common.sh" require_backup_environment -require_commands aws docker flock gzip sha256sum +require_alarm_environment +require_commands aws curl docker flock gzip sha256sum mountpoint -q /mnt/mysql-data docker inspect "$MYSQL_CONTAINER" >/dev/null @@ -42,5 +43,7 @@ aws s3api head-bucket \ --bucket "$MYSQL_BACKUP_BUCKET" \ --region "$AWS_REGION" >/dev/null +verify_alarm_endpoint + echo "MySQL backup validation succeeded." echo "database_bytes=$database_bytes available_bytes=$available_bytes required_bytes=$required_bytes" diff --git a/scripts/mysql_backup/install.sh b/scripts/mysql_backup/install.sh index db79395..b59f800 100755 --- a/scripts/mysql_backup/install.sh +++ b/scripts/mysql_backup/install.sh @@ -9,6 +9,10 @@ readonly INSTALL_BIN_DIR="/usr/local/libexec/solid-connection" readonly CONFIG_DIR="/etc/solid-connection" readonly CONFIG_FILE="$CONFIG_DIR/mysql-backup.env" readonly INSTALL_LOCK_FILE="/run/lock/solid-connection-mysql-backup-install.lock" +# 대기 상한입니다. 진행 중인 백업 작업과 다른 설치 트랜잭션을 기다릴 때 함께 사용합니다. +# binlog는 최대 4분, dump는 최대 2시간 실행되므로, dump가 도는 중이라면 +# 기다리기보다 중단하고 다른 시각에 다시 실행하는 편이 낫습니다. +readonly LOCK_WAIT_SECONDS=300 readonly -a TIMER_UNITS=( mysql-backup-binlog.timer mysql-backup-dump.timer @@ -23,7 +27,7 @@ if [[ ! -f "$CONFIG_SOURCE" ]]; then exit 1 fi -for command_name in aws bash cp docker flock gzip install mountpoint mv sha256sum systemctl systemd-analyze; do +for command_name in aws bash cp curl docker flock gzip install mountpoint mv sha256sum systemctl systemd-analyze; do command -v "$command_name" >/dev/null || { echo "Required command is not installed: $command_name" >&2 exit 1 @@ -37,7 +41,10 @@ fi # GitHub Actions와 수동 실행이 겹쳐도 하나의 설치 트랜잭션만 진행합니다. exec 200>"$INSTALL_LOCK_FILE" -flock 200 +if ! flock -w "$LOCK_WAIT_SECONDS" 200; then + echo "Another installation is already in progress; aborting." >&2 + exit 1 +fi install -d -m 755 -o root -g root "$INSTALL_LIB_DIR" "$INSTALL_BIN_DIR" "$CONFIG_DIR" install -d -m 700 -o root -g root \ @@ -66,7 +73,7 @@ load_candidate_config() { while IFS='=' read -r key value || [[ -n "$key" ]]; do [[ -z "$key" || "$key" == \#* ]] && continue case "$key" in - MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION) + MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION|ALARM_API_HOST|ALARM_API_PORTS|ALARM_API_HEALTH_PORTS|ALARM_API_TOKEN) printf -v "$key" '%s' "$value" export "$key" ;; @@ -182,11 +189,32 @@ for unit in "$CANDIDATE_DIR"/systemd/*; do done transaction_started=true -# 실행 중인 dump/binlog가 끝난 뒤 교체하여 한 작업에서 서로 다른 버전이 섞이지 않게 합니다. +# 교체 구간에 타이머가 발화하면 스크립트가 락을 얻지 못해 그 주기의 백업을 건너뜁니다. +# binlog는 다음 주기가 따라잡지만 dump는 하루 한 번이라 그날 복구 기준점이 사라집니다. +# 락을 잡기 전에 타이머를 멈춰 새 발화를 막고, 멈춘 사이에 놓친 발화는 Persistent=true 로 +# 타이머를 다시 켜는 시점에 즉시 실행되게 합니다. 실패하면 cleanup 이 이전 상태로 되돌립니다. +# 최초 설치에는 유닛 파일이 아직 없어 stop 이 실패하므로, 앞에서 확인한 활성 상태를 기준으로 멈춥니다. +for timer in "${TIMER_UNITS[@]}"; do + if [[ "${TIMER_WAS_ACTIVE[$timer]}" == "true" ]]; then + systemctl stop "$timer" + fi +done + +# 이미 실행 중인 dump/binlog가 끝난 뒤 교체하여 한 작업에서 서로 다른 버전이 섞이지 않게 합니다. +# 무기한 대기하면 SSM 세션이 유휴로 끊겨 원인을 알 수 없는 실패가 되므로 상한을 둡니다. exec 198>/mnt/mysql-data/mysql-backup/state/dump.lock exec 199>/mnt/mysql-data/mysql-backup/state/binlog.lock -flock 198 -flock 199 +echo "Waiting for any running backup job to finish (up to ${LOCK_WAIT_SECONDS}s)..." +if ! flock -w "$LOCK_WAIT_SECONDS" 198; then + echo "A mysqldump backup is still running after ${LOCK_WAIT_SECONDS}s; aborting the installation." >&2 + echo "Retry outside the dump window (03:00 KST, up to 2h)." >&2 + exit 1 +fi +if ! flock -w "$LOCK_WAIT_SECONDS" 199; then + echo "A binlog backup is still running after ${LOCK_WAIT_SECONDS}s; aborting the installation." >&2 + echo "A binlog job normally finishes within 4 minutes, so check whether it is stuck." >&2 + exit 1 +fi atomic_install "$CANDIDATE_DIR/lib/backup-common.sh" "$INSTALL_LIB_DIR/backup-common.sh" 644 for script in "$CANDIDATE_DIR"/bin/*; do diff --git a/scripts/mysql_backup/lib/backup-common.sh b/scripts/mysql_backup/lib/backup-common.sh index 077a131..43436bc 100644 --- a/scripts/mysql_backup/lib/backup-common.sh +++ b/scripts/mysql_backup/lib/backup-common.sh @@ -7,6 +7,18 @@ readonly STATE_DIR="$BACKUP_ROOT/state" readonly MYSQL_DATA_DIR="${MYSQL_DATA_DIR:-/mnt/mysql-data/mysql}" readonly MYSQL_CONTAINER="${MYSQL_CONTAINER:-mysql-server}" readonly DUMP_SPACE_RESERVE_BYTES=268435456 +readonly ALARM_PATH="/internal/alarms/db-backup" +readonly ALARM_TIMEOUT_SECONDS=5 +readonly ALARM_RETRY_COUNT=2 +readonly ALARM_DETAIL_MAX_LENGTH=1000 + +# 같은 실패로 알림이 두 번 나가지 않도록 전송 여부를 기록합니다. +alarm_sent=false +# 명시적으로 알리려던 실패의 유형과 원인을 보존합니다. +# 전송이 실패하면 EXIT 트랩이 기본 유형으로 바꾸지 않고 같은 내용으로 한 번 더 시도합니다. +# 유형이 바뀌면 실패 원인과 대응 방법이 함께 달라지고, 그대로 포기하면 그날의 알림이 사라집니다. +failed_alarm_type="" +failed_alarm_detail="" require_backup_environment() { : "${MYSQL_BACKUP_BUCKET:?MYSQL_BACKUP_BUCKET is required}" @@ -130,3 +142,223 @@ upload_file_once() { --no-progress \ --metadata "sha256=$checksum" } + +require_alarm_environment() { + : "${ALARM_API_HOST:?ALARM_API_HOST is required}" + : "${ALARM_API_PORTS:?ALARM_API_PORTS is required}" + : "${ALARM_API_HEALTH_PORTS:?ALARM_API_HEALTH_PORTS is required}" + : "${ALARM_API_TOKEN:?ALARM_API_TOKEN is required}" + + validate_alarm_target "$ALARM_API_HOST" "$ALARM_API_PORTS" + validate_alarm_target "$ALARM_API_HOST" "$ALARM_API_HEALTH_PORTS" +} + +# 8진수로 해석되지 않도록 10# 을 붙여 비교합니다. +validate_alarm_target() { + local host="$1" + local ports="$2" + local octet + local port + + if [[ ! "$host" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + echo "Invalid alarm api host: $host" >&2 + return 1 + fi + for octet in ${host//./ }; do + if ((10#$octet > 255)); then + echo "Invalid alarm api host: $host" >&2 + return 1 + fi + done + + if [[ ! "$ports" =~ ^[0-9]+( [0-9]+)*$ ]]; then + echo "Invalid alarm api ports: $ports" >&2 + return 1 + fi + for port in $ports; do + if ((10#$port < 1 || 10#$port > 65535)); then + echo "Invalid alarm api ports: $ports" >&2 + return 1 + fi + done +} + +instance_id() { + local metadata_token + + metadata_token="$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 60" \ + --max-time 2 2>/dev/null)" || return 1 + curl -fsS -H "X-aws-ec2-metadata-token: $metadata_token" \ + "http://169.254.169.254/latest/meta-data/instance-id" \ + --max-time 2 2>/dev/null +} + +# sed 의 N 명령은 GNU 와 BSD 동작이 달라 한 줄 입력에서 결과가 사라지므로 bash 치환만 사용합니다. +json_escape() { + local value="$1" + + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\t'/ }" + value="${value//$'\n'/\\n}" + printf '%s' "$value" +} + +# 활성 슬롯을 알 수 없으므로 blue, green 순서로 시도하고 먼저 응답한 쪽으로 보냅니다. +# 알림 전송 실패가 백업 자체를 실패시키지 않도록 항상 0으로 종료합니다. +send_backup_alarm() { + local alarm_type="$1" + local detail="$2" + local target_instance_id + local header_config + local payload + local port + + if [[ -z "${ALARM_API_HOST:-}" || -z "${ALARM_API_TOKEN:-}" ]]; then + echo "Alarm target is not configured; skipping the backup alarm." >&2 + return 0 + fi + + target_instance_id="$(instance_id)" || target_instance_id="unknown" + payload="$(printf '{"type":"%s","instanceId":"%s","occurredAt":"%s","detail":"%s"}' \ + "$alarm_type" \ + "$target_instance_id" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$(json_escape "${detail:0:ALARM_DETAIL_MAX_LENGTH}")")" + + # 토큰이 프로세스 목록에 남지 않도록 헤더를 설정 파일로 전달합니다. + # 준비 단계가 실패해도 백업 자체는 계속되어야 하므로 항상 0 으로 돌아갑니다. + if ! header_config="$(mktemp 2>/dev/null)"; then + echo "Failed to create a temporary file for the backup alarm request." >&2 + return 0 + fi + if ! chmod 600 "$header_config" 2>/dev/null \ + || ! printf 'header = "X-Internal-Alarm-Token: %s"\n' "$ALARM_API_TOKEN" >"$header_config" 2>/dev/null; then + echo "Failed to prepare the backup alarm request." >&2 + rm -f "$header_config" + return 0 + fi + + for port in ${ALARM_API_PORTS}; do + if curl -fsS \ + --config "$header_config" \ + --max-time "$ALARM_TIMEOUT_SECONDS" \ + --retry "$ALARM_RETRY_COUNT" \ + --retry-delay 3 \ + -X POST "http://${ALARM_API_HOST}:${port}${ALARM_PATH}" \ + -H "Content-Type: application/json" \ + -d "$payload" >/dev/null 2>&1; then + rm -f "$header_config" + alarm_sent=true + echo "Sent a backup alarm: type=$alarm_type port=$port" + return 0 + fi + done + + rm -f "$header_config" + echo "Failed to send a backup alarm: type=$alarm_type" >&2 + return 0 +} + +fail_with_alarm() { + local alarm_type="$1" + local detail="$2" + + echo "$detail" >&2 + # 전송이 실패해도 EXIT 트랩이 같은 유형으로 재시도할 수 있도록 남겨둡니다. + failed_alarm_type="$alarm_type" + failed_alarm_detail="$detail" + send_backup_alarm "$alarm_type" "$detail" + exit 1 +} + +# 명시적으로 처리하지 않은 실패도 알리기 위해 스크립트 종료 시점에 한 번 더 확인합니다. +alarm_on_unexpected_failure() { + local exit_code=$? + local default_alarm_type="$1" + + if ((exit_code == 0)) || [[ "$alarm_sent" == "true" ]]; then + return 0 + fi + if [[ -n "$failed_alarm_type" ]]; then + # 이미 알리려던 실패이므로 유형과 원인을 그대로 두고 한 번 더 시도합니다. + send_backup_alarm "$failed_alarm_type" "$failed_alarm_detail" + else + send_backup_alarm "$default_alarm_type" "unexpected failure with exit code $exit_code" + fi + return 0 +} + +# 스크립트는 돌고 있지만 업로드가 계속 실패해 마지막 성공이 오래된 경우를 알립니다. +# EC2 나 타이머 자체가 멈춘 경우는 이 방식으로 감지할 수 없어 외부 모니터링이 필요합니다. +alarm_if_upload_delayed() { + local success_file="$1" + local threshold_seconds="$2" + local last_success_epoch + local elapsed_seconds + + [[ -s "$success_file" ]] || return 0 + last_success_epoch="$(<"$success_file")" + [[ "$last_success_epoch" =~ ^[0-9]+$ ]] || return 0 + + elapsed_seconds=$(( $(date -u +%s) - last_success_epoch )) + if ((elapsed_seconds > threshold_seconds)); then + send_backup_alarm BINLOG_UPLOAD_DELAYED \ + "the last successful binlog upload was $elapsed_seconds seconds ago" + # 지연은 실패가 아니므로, 이번 실행이 실제로 실패하면 다시 알릴 수 있도록 되돌립니다. + alarm_sent=false + fi +} + +# 알림 경로가 실제로 동작하는지 확인합니다. +# - management 포트의 health 로 api 서버가 기동했는지 확인합니다. tcp 연결만으로는 앱 기동 여부를 알 수 없습니다. +# - 잘못된 토큰으로 알림 경로를 호출해 401 이 오는지 확인합니다. +# 경로가 배포되지 않은 서버는 핸들러를 찾지 못해 정적 리소스로 처리하다 500 을 반환합니다. +# - 토큰 값이 실제로 맞는지는 알림을 발생시키지 않고 확인할 수 없어 검증 대상에서 제외합니다. +verify_alarm_endpoint() { + local port + local health_response + local status + local last_status="none" + local is_healthy=false + local is_endpoint_deployed=false + + for port in ${ALARM_API_HEALTH_PORTS}; do + health_response="$(curl -fsS --max-time 3 "http://${ALARM_API_HOST}:${port}/actuator/health" 2>/dev/null)" || health_response="" + case "$health_response" in + *'"status":"UP"'*) + is_healthy=true + echo "Api server is healthy on management port $port." + break + ;; + esac + done + if [[ "$is_healthy" != "true" ]]; then + echo "No api server responded as UP on the management ports: $ALARM_API_HEALTH_PORTS" >&2 + echo "Check whether the api server is running, whether the security group allows these ports" >&2 + echo "from this instance, and whether the management port convention has changed." >&2 + return 1 + fi + + for port in ${ALARM_API_PORTS}; do + status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ + -X POST "http://${ALARM_API_HOST}:${port}${ALARM_PATH}" \ + -H "Content-Type: application/json" \ + -H "X-Internal-Alarm-Token: invalid-token-for-validation" \ + -d '{"type":"DUMP_FAILED","instanceId":"validation","occurredAt":"2026-01-01T00:00:00Z"}' 2>/dev/null)" \ + || status="000" + last_status="$status" + if [[ "$status" == "401" ]]; then + is_endpoint_deployed=true + echo "Alarm endpoint is deployed on app port $port." + break + fi + done + if [[ "$is_endpoint_deployed" != "true" ]]; then + echo "The alarm endpoint did not reject an invalid token on any app port: $ALARM_API_PORTS" >&2 + echo "The last response status was $last_status." >&2 + echo "A 404 or 500 means the api server in service does not have the endpoint yet; deploy it first." >&2 + return 1 + fi +} diff --git a/scripts/mysql_backup/tests/run.sh b/scripts/mysql_backup/tests/run.sh index 4aa0934..055805b 100755 --- a/scripts/mysql_backup/tests/run.sh +++ b/scripts/mysql_backup/tests/run.sh @@ -78,10 +78,13 @@ set -Eeuo pipefail readonly BACKUP_ROOT="$TEST_BACKUP_ROOT" readonly MYSQL_CONTAINER="mysql-server" require_backup_environment() { :; } +require_alarm_environment() { :; } require_commands() { :; } mountpoint() { :; } docker() { :; } aws() { :; } +# 알림 경로 검증은 별도 테스트에서 다루므로 여기서는 통과시킨다 +verify_alarm_endpoint() { :; } require_dump_staging_space() { printf '%s\n' '1024 9999999999 268437504'; } mysql_query() { if [[ "$1" == *'@@log_bin'* ]]; then @@ -102,6 +105,10 @@ EOF MYSQL_BACKUP_BUCKET="test-bucket" \ MYSQL_DATABASE="test_database" \ AWS_REGION="ap-northeast-2" \ + ALARM_API_HOST="172.31.0.10" \ + ALARM_API_PORTS="8080 9080" \ + ALARM_API_HEALTH_PORTS="8081 9081" \ + ALARM_API_TOKEN="test-token" \ MYSQL_BACKUP_LIB_DIR="$fixture_dir/lib" \ bash "$PROJECT_DIR/scripts/mysql_backup/bin/mysql-backup-validate" >/dev/null @@ -110,6 +117,10 @@ EOF MYSQL_BACKUP_BUCKET="test-bucket" \ MYSQL_DATABASE="missing_database" \ AWS_REGION="ap-northeast-2" \ + ALARM_API_HOST="172.31.0.10" \ + ALARM_API_PORTS="8080 9080" \ + ALARM_API_HEALTH_PORTS="8081 9081" \ + ALARM_API_TOKEN="test-token" \ MYSQL_BACKUP_LIB_DIR="$fixture_dir/lib" \ bash "$PROJECT_DIR/scripts/mysql_backup/bin/mysql-backup-validate" >/dev/null 2>&1; then echo "Validation must reject a missing backup database." >&2 @@ -136,6 +147,38 @@ readonly AWS_REGION="${AWS_REGION:-ap-northeast-2}" require_backup_environment() { :; } require_commands() { :; } flock() { return 0; } +curl() { return 0; } +instance_id() { printf '%s' 'i-test'; } +alarm_sent=false +failed_alarm_type="" +failed_alarm_detail="" +send_backup_alarm() { + if [[ -n "${TEST_ALARM_LOG:-}" ]]; then + printf '%s\n' "$1" >>"$TEST_ALARM_LOG" + fi + alarm_sent=true + return 0 +} +fail_with_alarm() { + echo "$2" >&2 + failed_alarm_type="$1" + failed_alarm_detail="$2" + send_backup_alarm "$1" "$2" + exit 1 +} +alarm_on_unexpected_failure() { + local exit_code=$? + if ((exit_code == 0)) || [[ "$alarm_sent" == "true" ]]; then + return 0 + fi + if [[ -n "$failed_alarm_type" ]]; then + send_backup_alarm "$failed_alarm_type" "$failed_alarm_detail" + else + send_backup_alarm "$1" "unexpected failure with exit code $exit_code" + fi + return 0 +} +alarm_if_upload_delayed() { :; } aws() { printf '%s' "${TEST_S3_KEYS:-}"; } require_dump_staging_space() { if [[ -n "${TEST_SPACE_CHECK_LOG:-}" ]]; then @@ -500,6 +543,394 @@ EOF fi } +test_backup_alarm_port_fallback() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + export ALARM_API_HOST="172.31.0.10" + export ALARM_API_PORTS="8080 9080" + export ALARM_API_TOKEN="test-token" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + local attempt_log="$TEST_ROOT/alarm-attempts" + : >"$attempt_log" + instance_id() { printf '%s' 'i-test'; } + # 활성 슬롯만 응답하는 상황을 재현한다. blue 는 닫혀 있고 green 만 열려 있다. + curl() { + local argument + for argument in "$@"; do + case "$argument" in + http://*:8080/*) echo "8080" >>"$attempt_log"; return 7 ;; + http://*:9080/*) echo "9080" >>"$attempt_log"; return 0 ;; + esac + done + return 0 + } + + send_backup_alarm DUMP_FAILED "test detail" >/dev/null + assert_equals \ + "8080 9080" \ + "$(tr '\n' ' ' <"$attempt_log" | sed 's/ $//')" \ + "the alarm must try the blue port first and fall back to the green port" + assert_equals "true" "$alarm_sent" "a delivered alarm must mark the sent flag" + ) +} + +test_backup_alarm_failure_does_not_break_backup() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + export ALARM_API_HOST="172.31.0.10" + export ALARM_API_PORTS="8080 9080" + export ALARM_API_TOKEN="test-token" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + instance_id() { printf '%s' 'i-test'; } + curl() { return 7; } + + if ! send_backup_alarm DUMP_FAILED "test detail" >/dev/null 2>&1; then + echo "An alarm delivery failure must not fail the backup." >&2 + exit 1 + fi + assert_equals "false" "$alarm_sent" "an undelivered alarm must not mark the sent flag" + ) +} + +test_backup_alarm_skipped_without_configuration() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + curl() { echo "The alarm must not be sent without configuration." >&2; return 99; } + instance_id() { printf '%s' 'i-test'; } + + send_backup_alarm DUMP_FAILED "test detail" >/dev/null 2>&1 + assert_equals "false" "$alarm_sent" "an alarm without configuration must not be marked as sent" + ) +} + +test_backup_alarm_detail_escaping() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + assert_equals \ + 'say \"hi\"' \ + "$(json_escape 'say "hi"')" \ + "double quotes in the detail must be escaped for json" + assert_equals \ + 'a\\b' \ + "$(json_escape 'a\b')" \ + "backslashes in the detail must be escaped for json" + assert_equals \ + 'first\nsecond' \ + "$(json_escape "$(printf 'first\nsecond')")" \ + "newlines in the detail must be escaped for json" + ) +} + +# 명시적으로 알린 실패를 EXIT 트랩이 기본 유형으로 다시 보내지 않는지 본다. +# 전송에 실패한 경우까지 확인한다. 이때 재전송이 일어나면 알림 유형과 원인이 함께 뒤바뀐다. +test_unexpected_failure_alarm_is_sent_once() { + run_failure_alarm_case() { + local delivery_succeeds="$1" + local send_log="$2" + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + export ALARM_API_HOST="172.31.0.10" + export ALARM_API_PORTS="8080 9080" + export ALARM_API_TOKEN="test-token" + export DELIVERY_SUCCEEDS="$delivery_succeeds" + export SEND_LOG="$send_log" + + instance_id() { printf '%s' 'i-test'; } + # 실제 스크립트와 같은 순서로 트랩을 걸어 기본 유형이 덮어쓰는지 확인한다. + trap 'alarm_on_unexpected_failure BINLOG_UPLOAD_FAILED' EXIT + # 요청 본문에서 알림 유형만 뽑아 기록해, 시도 횟수와 유형을 함께 확인할 수 있게 한다. + curl() { + local argument + local payload="" + local expects_payload=false + local is_alarm_request=false + local port + + for argument in "$@"; do + if [[ "$expects_payload" == "true" ]]; then + payload="$argument" + expects_payload=false + continue + fi + case "$argument" in + -d) expects_payload=true ;; + *"$ALARM_PATH") + is_alarm_request=true + # 알림 전송은 app 포트로만 나가야 한다. + port="${argument#http://*:}" + port="${port%%/*}" + case " $ALARM_API_PORTS " in + *" $port "*) ;; + *) + echo "the alarm path must be requested on an app port, got $port" >&2 + return 1 + ;; + esac + ;; + esac + done + if [[ "$is_alarm_request" == "true" ]]; then + payload="${payload#*\"type\":\"}" + printf '%s\n' "${payload%%\"*}" >>"$SEND_LOG" + fi + [[ "$DELIVERY_SUCCEEDS" == "true" ]] + } + + fail_with_alarm BINLOG_GAP_DETECTED "binlog chain is broken" 2>/dev/null + ) + } + + # 전송에 성공하면 한 번만 시도한다. + local delivered_log="$TEST_ROOT/alarm-send-delivered" + : >"$delivered_log" + run_failure_alarm_case true "$delivered_log" || true + assert_equals \ + "BINLOG_GAP_DETECTED" \ + "$(cat "$delivered_log")" \ + "an already reported failure must not be alarmed twice" + + # 전송에 실패하면 트랩이 같은 유형으로 한 번 더 시도한다. + # 포트 두 개를 순회하는 시도가 두 번이므로 4회이고, 유형은 처음 알린 것이 그대로 유지되어야 한다. + local failed_log="$TEST_ROOT/alarm-send-failed" + : >"$failed_log" + run_failure_alarm_case false "$failed_log" || true + # 유형이 바뀌는 것이 근본 문제이므로 먼저 확인한다. + assert_equals \ + "BINLOG_GAP_DETECTED" \ + "$(sort -u "$failed_log")" \ + "an undelivered explicit alarm must keep its type when the exit trap retries" + assert_equals \ + "4" \ + "$(wc -l <"$failed_log" | tr -d ' ')" \ + "an undelivered explicit alarm must be retried once by the exit trap" +} + +test_binlog_delay_alarm_threshold() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + local alarm_log="$TEST_ROOT/delay-alarm.log" + local success_file="$TEST_ROOT/last-binlog-success" + send_backup_alarm() { printf '%s\n' "$1" >>"$alarm_log"; alarm_sent=true; return 0; } + date() { + if [[ "$*" == "-u +%s" ]]; then + printf '%s\n' '1784170800' + else + command date "$@" + fi + } + + # 한 주기(5분)만 지난 상태는 정상 범위로 보고 알리지 않는다. + : >"$alarm_log" + printf '%s\n' '1784170500' >"$success_file" + alarm_if_upload_delayed "$success_file" 900 + assert_equals "" "$(cat "$alarm_log")" "a single missed cycle must not raise a delay alarm" + + # 세 주기를 넘기면 지연으로 알린다. + : >"$alarm_log" + printf '%s\n' '1784169600' >"$success_file" + alarm_if_upload_delayed "$success_file" 900 + assert_equals \ + "BINLOG_UPLOAD_DELAYED" \ + "$(cat "$alarm_log")" \ + "an upload delayed beyond three cycles must be alarmed" + assert_equals \ + "false" \ + "$alarm_sent" \ + "a delay alarm must not suppress the alarm for an actual failure in the same run" + + # 마지막 성공 기록이 없으면 판단하지 않는다. + : >"$alarm_log" + rm -f "$success_file" + alarm_if_upload_delayed "$success_file" 900 + assert_equals "" "$(cat "$alarm_log")" "a missing success record must not raise a delay alarm" + ) +} + +# 설치 검증이 tcp 연결이 아니라 health 응답과 401 응답을 확인하는지 본다. +# 설치 전 검증은 공용 라이브러리를 읽을 수 없어 알림 경로를 각자 들고 있다. +# 두 값이 어긋나면 설치 검증이 배포되지 않은 경로를 호출해 잘못된 판정을 내리므로 같은지 확인한다. +test_alarm_path_is_consistent() { + local lib_alarm_path + local remote_alarm_path + + lib_alarm_path="$(sed -n 's/^readonly ALARM_PATH="\(.*\)"$/\1/p' \ + "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh")" + remote_alarm_path="$(sed -n 's/^readonly ALARM_PATH="\(.*\)"$/\1/p' \ + "$PROJECT_DIR/scripts/mysql_backup/validate-remote.sh")" + + if [[ -z "$lib_alarm_path" ]]; then + echo "Could not read ALARM_PATH from the shared library." >&2 + exit 1 + fi + assert_equals \ + "$lib_alarm_path" \ + "$remote_alarm_path" \ + "the pre-installation validator must call the same alarm path as the shared library" +} + +test_verify_alarm_endpoint() { + local endpoint_stderr="$TEST_ROOT/verify-endpoint-stderr" + + run_endpoint_case() { + local health_ok="$1" + local alarm_status="$2" + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + export ALARM_API_HOST="172.31.0.10" + export ALARM_API_PORTS="8080 9080" + export ALARM_API_HEALTH_PORTS="8081 9081" + + # health 는 본문을, 알림 경로는 상태 코드를 돌려주도록 흉내낸다. + # 경로마다 허용하는 포트를 제한해, 구현이 두 포트 목록을 뒤바꿔 써도 통과하지 않게 한다. + # actuator 는 management 포트에만, 알림 경로는 app 포트에만 열려 있어 교차 호출은 운영에서 실패한다. + curl() { + local argument + local url="" + local port + local path + + for argument in "$@"; do + case "$argument" in + http://*) url="$argument" ;; + esac + done + port="${url#http://*:}" + port="${port%%/*}" + path="/${url#http://*/}" + + case "$path" in + /actuator/health) + case " $ALARM_API_HEALTH_PORTS " in + *" $port "*) ;; + *) + echo "health must be requested on a management port, got $port" >&2 + return 1 + ;; + esac + if [[ "$FAKE_HEALTH_OK" == "true" ]]; then + printf '%s\n' '{"status":"UP"}' + return 0 + fi + return 22 + ;; + "$ALARM_PATH") + case " $ALARM_API_PORTS " in + *" $port "*) ;; + *) + echo "the alarm path must be requested on an app port, got $port" >&2 + return 1 + ;; + esac + printf '%s' "$FAKE_ALARM_STATUS" + return 0 + ;; + *) + echo "unexpected request path: $path" >&2 + return 1 + ;; + esac + } + + # mock 이 남기는 위반 사유를 실패 시 보여주기 위해 stderr 를 파일로 받는다. + FAKE_HEALTH_OK="$health_ok" FAKE_ALARM_STATUS="$alarm_status" \ + verify_alarm_endpoint >/dev/null 2>"$endpoint_stderr" + ) + } + + if ! run_endpoint_case true 401; then + echo "A healthy api server that rejects an invalid token must pass verification." >&2 + cat "$endpoint_stderr" >&2 + exit 1 + fi + # 애플리케이션이 기동하지 않았다면 tcp 가 열려 있어도 통과하면 안 된다. + if run_endpoint_case false 401; then + echo "Verification must fail when no management port reports UP." >&2 + exit 1 + fi + # 경로가 배포되지 않아 404 가 오면 통과하면 안 된다. + if run_endpoint_case true 404; then + echo "Verification must fail when the alarm endpoint is not deployed." >&2 + exit 1 + fi + # 실제로 미배포 서버는 정적 리소스 처리로 넘어가 500 을 반환하므로 이 경우도 막아야 한다. + if run_endpoint_case true 500; then + echo "Verification must fail when the api server answers the alarm path with 500." >&2 + exit 1 + fi +} + +test_alarm_target_validation() { + ( + export MYSQL_BACKUP_BUCKET="test-bucket" + export MYSQL_DATABASE="test_database" + export AWS_REGION="ap-northeast-2" + # shellcheck source=../lib/backup-common.sh + source "$PROJECT_DIR/scripts/mysql_backup/lib/backup-common.sh" + + if ! validate_alarm_target "172.31.56.245" "8080 9080" 2>/dev/null; then + echo "A valid alarm target must pass validation." >&2 + exit 1 + fi + + # 옥텟 범위를 넘는 주소는 형식만 맞아도 거부한다. + if validate_alarm_target "999.999.999.999" "8080" 2>/dev/null; then + echo "An out-of-range octet must be rejected." >&2 + exit 1 + fi + if validate_alarm_target "172.31.56" "8080" 2>/dev/null; then + echo "An incomplete address must be rejected." >&2 + exit 1 + fi + + # 포트 범위를 벗어나면 거부한다. + if validate_alarm_target "172.31.56.245" "0" 2>/dev/null; then + echo "Port 0 must be rejected." >&2 + exit 1 + fi + if validate_alarm_target "172.31.56.245" "65536" 2>/dev/null; then + echo "Port 65536 must be rejected." >&2 + exit 1 + fi + if validate_alarm_target "172.31.56.245" "8080 70000" 2>/dev/null; then + echo "An out-of-range port in the list must be rejected." >&2 + exit 1 + fi + ) +} + test_upload_idempotency test_dump_space_calculation test_validate_requires_schema @@ -507,4 +938,13 @@ test_binlog_chain test_dump_retry_manifest test_dump_rejects_insufficient_space test_dump_discards_stale_job +test_backup_alarm_port_fallback +test_backup_alarm_failure_does_not_break_backup +test_backup_alarm_skipped_without_configuration +test_backup_alarm_detail_escaping +test_unexpected_failure_alarm_is_sent_once +test_binlog_delay_alarm_threshold +test_alarm_target_validation +test_verify_alarm_endpoint +test_alarm_path_is_consistent echo "All MySQL backup tests passed." diff --git a/scripts/mysql_backup/validate-remote.sh b/scripts/mysql_backup/validate-remote.sh index 7d2f850..a1c7344 100755 --- a/scripts/mysql_backup/validate-remote.sh +++ b/scripts/mysql_backup/validate-remote.sh @@ -2,6 +2,8 @@ set -Eeuo pipefail readonly CONFIG_FILE="/etc/solid-connection/mysql-backup.env" +# lib/backup-common.sh 의 ALARM_PATH 와 같은 값이어야 합니다. 설치 전에는 그 파일을 읽을 수 없어 여기에 둡니다. +readonly ALARM_PATH="/internal/alarms/db-backup" readonly VALIDATE_BIN="/usr/local/libexec/solid-connection/mysql-backup-validate" if ((EUID != 0)); then @@ -10,11 +12,11 @@ if ((EUID != 0)); then fi if [[ -x "$VALIDATE_BIN" && -f "$CONFIG_FILE" ]]; then - unset MYSQL_BACKUP_BUCKET MYSQL_DATABASE AWS_REGION + unset MYSQL_BACKUP_BUCKET MYSQL_DATABASE AWS_REGION ALARM_API_HOST ALARM_API_PORTS ALARM_API_HEALTH_PORTS ALARM_API_TOKEN while IFS='=' read -r key value || [[ -n "$key" ]]; do [[ -z "$key" || "$key" == \#* ]] && continue case "$key" in - MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION) + MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION|ALARM_API_HOST|ALARM_API_PORTS|ALARM_API_HEALTH_PORTS|ALARM_API_TOKEN) printf -v "$key" '%s' "$value" export "$key" ;; @@ -24,6 +26,12 @@ if [[ -x "$VALIDATE_BIN" && -f "$CONFIG_FILE" ]]; then ;; esac done <"$CONFIG_FILE" + # 이 경로는 설치된 값 그대로 검증하므로, 새 항목이 추가된 뒤 재설치하지 않은 환경을 구분해 알려줍니다. + if [[ -z "${ALARM_API_HEALTH_PORTS:-}" ]]; then + echo "ALARM_API_HEALTH_PORTS is missing in $CONFIG_FILE." >&2 + echo "Run the deploy workflow with the install action to refresh the environment file." >&2 + exit 1 + fi "$VALIDATE_BIN" systemctl is-enabled --quiet mysql-backup-binlog.timer mysql-backup-dump.timer systemctl is-active --quiet mysql-backup-binlog.timer mysql-backup-dump.timer @@ -31,7 +39,7 @@ if [[ -x "$VALIDATE_BIN" && -f "$CONFIG_FILE" ]]; then exit 0 fi -for command_name in aws docker flock gzip sha256sum; do +for command_name in aws curl docker flock gzip sha256sum; do command -v "$command_name" >/dev/null || { echo "Required command is not installed: $command_name" >&2 exit 1 @@ -40,6 +48,34 @@ done : "${MYSQL_BACKUP_BUCKET:?MYSQL_BACKUP_BUCKET is required for pre-installation validation}" : "${MYSQL_DATABASE:?MYSQL_DATABASE is required for pre-installation validation}" : "${AWS_REGION:?AWS_REGION is required for pre-installation validation}" +: "${ALARM_API_HOST:?ALARM_API_HOST is required for pre-installation validation}" +: "${ALARM_API_PORTS:?ALARM_API_PORTS is required for pre-installation validation}" +: "${ALARM_API_HEALTH_PORTS:?ALARM_API_HEALTH_PORTS is required for pre-installation validation}" +: "${ALARM_API_TOKEN:?ALARM_API_TOKEN is required for pre-installation validation}" +# 설치 전에는 공용 라이브러리가 없으므로 같은 범위 검증을 여기에 둡니다. +# 8진수로 해석되지 않도록 10# 을 붙여 비교합니다. +if [[ ! "$ALARM_API_HOST" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + echo "Invalid alarm api host: $ALARM_API_HOST" >&2 + exit 1 +fi +for alarm_host_octet in ${ALARM_API_HOST//./ }; do + if ((10#$alarm_host_octet > 255)); then + echo "Invalid alarm api host: $ALARM_API_HOST" >&2 + exit 1 + fi +done +for alarm_port_list in "$ALARM_API_PORTS" "$ALARM_API_HEALTH_PORTS"; do + if [[ ! "$alarm_port_list" =~ ^[0-9]+( [0-9]+)*$ ]]; then + echo "Invalid alarm api ports: $alarm_port_list" >&2 + exit 1 + fi + for alarm_port in $alarm_port_list; do + if ((10#$alarm_port < 1 || 10#$alarm_port > 65535)); then + echo "Invalid alarm api ports: $alarm_port_list" >&2 + exit 1 + fi + done +done if [[ ! "$MYSQL_BACKUP_BUCKET" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]]; then echo "Invalid S3 bucket name." >&2 exit 1 @@ -86,5 +122,50 @@ if ((available_bytes < required_bytes)); then exit 1 fi aws s3api head-bucket --bucket "$MYSQL_BACKUP_BUCKET" --region "$AWS_REGION" >/dev/null + +# 설치 전에는 공용 라이브러리가 없으므로 lib/backup-common.sh 의 verify_alarm_endpoint 와 같은 검증을 여기에 둡니다. +# 알림 경로와 판정 기준을 바꿀 때는 두 곳을 함께 고쳐야 합니다. +# 비활성 슬롯은 내려가 있으므로 설정된 포트 중 하나라도 응답하면 통과합니다. +alarm_api_healthy=false +for alarm_health_port in ${ALARM_API_HEALTH_PORTS}; do + alarm_health_response="$(curl -fsS --max-time 3 "http://${ALARM_API_HOST}:${alarm_health_port}/actuator/health" 2>/dev/null)" || alarm_health_response="" + case "$alarm_health_response" in + *'"status":"UP"'*) + alarm_api_healthy=true + echo "Api server is healthy on management port $alarm_health_port." + break + ;; + esac +done +if [[ "$alarm_api_healthy" != "true" ]]; then + echo "No api server responded as UP on the management ports: $ALARM_API_HEALTH_PORTS" >&2 + echo "Check whether the api server is running, whether the security group allows these ports" >&2 + echo "from this instance, and whether the management port convention has changed." >&2 + exit 1 +fi + +alarm_endpoint_deployed=false +alarm_last_status="none" +for alarm_port in ${ALARM_API_PORTS}; do + alarm_status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \ + -X POST "http://${ALARM_API_HOST}:${alarm_port}${ALARM_PATH}" \ + -H "Content-Type: application/json" \ + -H "X-Internal-Alarm-Token: invalid-token-for-validation" \ + -d '{"type":"DUMP_FAILED","instanceId":"validation","occurredAt":"2026-01-01T00:00:00Z"}' 2>/dev/null)" \ + || alarm_status="000" + alarm_last_status="$alarm_status" + if [[ "$alarm_status" == "401" ]]; then + alarm_endpoint_deployed=true + echo "Alarm endpoint is deployed on app port $alarm_port." + break + fi +done +if [[ "$alarm_endpoint_deployed" != "true" ]]; then + echo "The alarm endpoint did not reject an invalid token on any app port: $ALARM_API_PORTS" >&2 + echo "The last response status was $alarm_last_status." >&2 + echo "A 404 or 500 means the api server in service does not have the endpoint yet; deploy it first." >&2 + exit 1 +fi + df -h / /mnt/mysql-data echo "Pre-installation validation succeeded."