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
232 changes: 232 additions & 0 deletions .github/workflows/mysql-backup-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
name: MySQL Backup Deploy

on:
workflow_dispatch:
inputs:
mode:
description: validate는 상태만 확인하고 install은 백업 구성을 설치합니다.
required: true
default: validate
type: choice
options:
- validate
- install

permissions:
id-token: write
contents: read

concurrency:
group: prod-mysql-backup-deploy
cancel-in-progress: false

env:
AWS_REGION: ap-northeast-2
LOCAL_SSH_PORT: "2222"
SESSION_MANAGER_PLUGIN_VERSION: "1.2.835.0"

jobs:
deploy:
if: github.ref == 'refs/heads/main'
environment: prod-db
runs-on: ubuntu-latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workflow_dispatch는 이 파일이 존재하는 아무 브랜치에서나 실행됩니다. 지금은 environment:도 브랜치 조건도 없어서, write 권한이 있으면 feature 브랜치에서 install.sh를 수정한 뒤 dispatch → prod DB에 root로 임의 코드 실행이 가능합니다. 리뷰·승인 경로를 전혀 거치지 않습니다.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: prod-db   # required reviewers 지정
    if: github.ref == 'refs/heads/main' || inputs.mode == 'validate'

추가로 secrets.AWS_ROLE_ARN 역할의 trust policy sub 조건이 repo:...:ref:refs/heads/main으로 제한돼 있는지 확인 부탁드립니다. IAM은 코드로 확인이 안 돼서요. 제한돼 있지 않다면 위 게이트가 실질적인 유일한 방어선입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지적해 주신 feature branch 기반 운영 실행 문제는 반영하겠습니다. 다만 validate 모드도 선택한 브랜치의 스크립트를 운영 서버에서 sudo로 실행하므로 main || validate 조건만으로는 충분하지 않은 것으로 확인했습니다. 두 모드 모두 main 브랜치에서만 실행되도록 제한하고, prod-db Environment의 승인 및 배포 브랜치 정책을 함께 적용하겠습니다. Environment 적용에 따라 IAM OIDC 신뢰 정책의 subject도 수동으로 점검하겠습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OIDC와 연결된 Role의 trust policy 확인 결과, repo:...:ref:refs/heads/main로 정상적으로 반영되어 있습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다! validate, install 모두 main 브랜치에서만 실행되도록 제한했고, prod-db Environment의 승인 절차를 거치도록 설정했습니다. Environment에는 required reviewer, self-review 차단, main 배포 브랜치 제한이 적용되어 있습니다.

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}

- name: Install Session Manager plugin
run: |
set -Eeuo pipefail
plugin_dir="$(mktemp -d "$RUNNER_TEMP/session-manager-plugin.XXXXXX")"
trap 'rm -rf -- "$plugin_dir"' EXIT
plugin_base_url="https://s3.amazonaws.com/session-manager-downloads/plugin/${SESSION_MANAGER_PLUGIN_VERSION}/ubuntu_64bit"

curl -fsSL "$plugin_base_url/session-manager-plugin.deb" \
-o "$plugin_dir/session-manager-plugin.deb"
curl -fsSL "$plugin_base_url/session-manager-plugin.deb.sig" \
-o "$plugin_dir/session-manager-plugin.deb.sig"

cat >"$plugin_dir/session-manager-plugin.gpg" <<'EOF'
-----BEGIN PGP PUBLIC KEY BLOCK-----

mFIEZ5ERQxMIKoZIzj0DAQcCAwQjuZy+IjFoYg57sLTGhF3aZLBaGpzB+gY6j7Ix
P7NqbpXyjVj8a+dy79gSd64OEaMxUb7vw/jug+CfRXwVGRMNtIBBV1MgU1NNIFNl
c3Npb24gTWFuYWdlciA8c2Vzc2lvbi1tYW5hZ2VyLXBsdWdpbi1zaWduZXJAYW1h
em9uLmNvbT4gKEFXUyBTeXN0ZW1zIE1hbmFnZXIgU2Vzc2lvbiBNYW5hZ2VyIFBs
dWdpbiBMaW51eCBTaWduZXIgS2V5KYkBAAQQEwgAqAUCZ5ERQ4EcQVdTIFNTTSBT
ZXNzaW9uIE1hbmFnZXIgPHNlc3Npb24tbWFuYWdlci1wbHVnaW4tc2lnbmVyQGFt
YXpvbi5jb20+IChBV1MgU3lzdGVtcyBNYW5hZ2VyIFNlc3Npb24gTWFuYWdlciBQ
bHVnaW4gTGludXggU2lnbmVyIEtleSkWIQR5WWNxJM4JOtUB1HosTUr/b2dX7gIe
AwIbAwIVCAAKCRAsTUr/b2dX7rO1AQCa1kig3lQ78W/QHGU76uHx3XAyv0tfpE9U
oQBCIwFLSgEA3PDHt3lZ+s6m9JLGJsy+Cp5ZFzpiF6RgluR/2gA861M=
=2DQm
-----END PGP PUBLIC KEY BLOCK-----
EOF

export GNUPGHOME="$plugin_dir/gnupg"
install -d -m 700 "$GNUPGHOME"
gpg --batch --import "$plugin_dir/session-manager-plugin.gpg"
signing_key_fingerprint="$(gpg --batch --with-colons --fingerprint 2C4D4AFF6F6757EE | awk -F: '$1 == "fpr" {print $10; exit}')"
if [[ "$signing_key_fingerprint" != "7959637124CE093AD501D47A2C4D4AFF6F6757EE" ]]; then
echo "::error::Unexpected Session Manager plugin signing key fingerprint"
exit 1
fi
gpg --batch --verify \
"$plugin_dir/session-manager-plugin.deb.sig" \
"$plugin_dir/session-manager-plugin.deb"

package_version="$(dpkg-deb --field "$plugin_dir/session-manager-plugin.deb" Version)"
if [[ "$package_version" != "${SESSION_MANAGER_PLUGIN_VERSION}-1" ]]; then
echo "::error::Unexpected Session Manager plugin package version: $package_version"
exit 1
fi
sudo dpkg -i "$plugin_dir/session-manager-plugin.deb"
echo "/usr/local/sessionmanagerplugin/bin" >> "$GITHUB_PATH"

- 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 }}
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}"
if [[ "$DEPLOY_MODE" != "validate" && "$DEPLOY_MODE" != "install" ]]; then
echo "::error::Invalid deployment mode"
exit 1
fi
if [[ ! "$MYSQL_BACKUP_BUCKET" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]]; then
echo "::error::Invalid S3 bucket name"
exit 1
fi
if [[ ! "$MYSQL_DATABASE" =~ ^[A-Za-z0-9_]+$ ]]; then
echo "::error::Invalid database name"
exit 1
fi
if [[ ! "$DB_HOST_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then
echo "::error::Invalid DB EC2 SSH host key fingerprint"
exit 1
fi
KEY_DIR="$(mktemp -d "$RUNNER_TEMP/mysql-backup-eic.XXXXXX")"
SSM_PID=""
cleanup() {
if [[ -n "$SSM_PID" ]]; then
kill "$SSM_PID" 2>/dev/null || true
wait "$SSM_PID" 2>/dev/null || true
fi
rm -rf "$KEY_DIR"
}
trap cleanup EXIT

ssh-keygen -q -t ed25519 -N '' -f "$KEY_DIR/id_ed25519"

API_INSTANCE_ID="$(aws ec2 describe-instances \
--filters \
'Name=tag:Name,Values=solid-connection-server-prod' \
'Name=instance-state-name,Values=running' \
--query 'Reservations[].Instances[].InstanceId' \
--output text)"
DB_INSTANCE_ID="$(aws ec2 describe-instances \
--filters \
'Name=tag:Name,Values=solid-connection-db-mysql-prod' \
'Name=instance-state-name,Values=running' \
--query 'Reservations[].Instances[].InstanceId' \
--output text)"

if [[ "$API_INSTANCE_ID" == *$'\t'* || -z "$API_INSTANCE_ID" || "$API_INSTANCE_ID" == "None" ]]; then
echo "::error::Exactly one running Prod API EC2 instance is required"
exit 1
fi
if [[ "$DB_INSTANCE_ID" == *$'\t'* || -z "$DB_INSTANCE_ID" || "$DB_INSTANCE_ID" == "None" ]]; then
echo "::error::Exactly one running Prod DB EC2 instance is required"
exit 1
fi

DB_PRIVATE_IP="$(aws ec2 describe-instances \
--instance-ids "$DB_INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' \
--output text)"
if [[ -z "$DB_PRIVATE_IP" || "$DB_PRIVATE_IP" == "None" ]]; then
echo "::error::Prod DB EC2 private IP was not found"
exit 1
fi

aws ssm start-session \
--target "$API_INSTANCE_ID" \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters "{\"host\":[\"$DB_PRIVATE_IP\"],\"portNumber\":[\"22\"],\"localPortNumber\":[\"$LOCAL_SSH_PORT\"]}" \
>"$KEY_DIR/ssm-tunnel.log" 2>&1 &
SSM_PID=$!

for _ in $(seq 1 60); do
kill -0 "$SSM_PID" 2>/dev/null || {
cat "$KEY_DIR/ssm-tunnel.log" >&2
exit 1
}
nc -z 127.0.0.1 "$LOCAL_SSH_PORT" 2>/dev/null && break
sleep 1
done
nc -z 127.0.0.1 "$LOCAL_SSH_PORT"

KNOWN_HOSTS_FILE="$KEY_DIR/known_hosts"
ssh-keyscan -t ed25519 -p "$LOCAL_SSH_PORT" 127.0.0.1 >"$KNOWN_HOSTS_FILE" 2>/dev/null
ACTUAL_FINGERPRINT="$(ssh-keygen -lf "$KNOWN_HOSTS_FILE" -E sha256 | awk '{print $2}')"
if [[ "$ACTUAL_FINGERPRINT" != "$DB_HOST_FINGERPRINT" ]]; then
echo "::error::DB EC2 SSH host key fingerprint mismatch"
exit 1
fi

SSH_OPTIONS=(
-p "$LOCAL_SSH_PORT"
-i "$KEY_DIR/id_ed25519"
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$KNOWN_HOSTS_FILE"
-o ConnectTimeout=10
)

if [[ "$DEPLOY_MODE" == "validate" ]]; then
aws ec2-instance-connect send-ssh-public-key \
--region "$AWS_REGION" \
--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[@]}" [email protected] \
"sudo bash -c $(printf '%q' "$REMOTE_VALIDATE_COMMAND")" \
< scripts/mysql_backup/validate-remote.sh
exit 0
fi

CONFIG_FILE="$KEY_DIR/mysql-backup.env"
{
printf 'MYSQL_BACKUP_BUCKET=%s\n' "$MYSQL_BACKUP_BUCKET"
printf 'MYSQL_DATABASE=%s\n' "$MYSQL_DATABASE"
printf 'AWS_REGION=%s\n' "$AWS_REGION"
} >"$CONFIG_FILE"

cp -R scripts/mysql_backup "$KEY_DIR/bundle"
cp "$CONFIG_FILE" "$KEY_DIR/bundle/mysql-backup.env"
tar -C "$KEY_DIR/bundle" -czf "$KEY_DIR/mysql-backup.tar.gz" .

aws ec2-instance-connect send-ssh-public-key \
--region "$AWS_REGION" \
--instance-id "$DB_INSTANCE_ID" \
--instance-os-user ubuntu \
--ssh-public-key "file://$KEY_DIR/id_ed25519.pub" >/dev/null

REMOTE_INSTALL_COMMAND='set -Eeuo pipefail; install_dir="$(mktemp -d /tmp/mysql-backup-install.XXXXXX)"; trap '\''rm -rf -- "$install_dir"'\'' EXIT; tar -xzf - -C "$install_dir"; bash "$install_dir/install.sh" "$install_dir/mysql-backup.env"'
ssh "${SSH_OPTIONS[@]}" [email protected] \
"sudo bash -c $(printf '%q' "$REMOTE_INSTALL_COMMAND")" \
<"$KEY_DIR/mysql-backup.tar.gz"
19 changes: 19 additions & 0 deletions .github/workflows/mysql-backup-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: MySQL Backup Test

on:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Run MySQL backup tests
run: bash scripts/mysql_backup/tests/run.sh
6 changes: 6 additions & 0 deletions modules/app_stack/scripts/mysql_setup.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ DATA_VOLUME_SERIAL="$(printf '%s' "$DATA_VOLUME_ID" | tr -d '-')"
DATA_VOLUME_DEVICE="/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_$DATA_VOLUME_SERIAL"
DATA_VOLUME_MOUNT="/mnt/mysql-data"
MYSQL_DATA_DIR="$DATA_VOLUME_MOUNT/mysql"
MYSQL_BACKUP_DIR="$DATA_VOLUME_MOUNT/mysql-backup"

for i in $(seq 1 120); do
if [ -e "$DATA_VOLUME_DEVICE" ]; then
Expand Down Expand Up @@ -95,6 +96,11 @@ mkdir -p "$MYSQL_DATA_DIR"
chown -R 999:999 "$MYSQL_DATA_DIR"
chmod 750 "$MYSQL_DATA_DIR"

# dump 압축 파일과 업로드 상태는 용량이 작은 루트 볼륨이 아닌 데이터 EBS에 둡니다.
mkdir -p "$MYSQL_BACKUP_DIR/staging" "$MYSQL_BACKUP_DIR/state"
chown -R root:root "$MYSQL_BACKUP_DIR"
chmod 700 "$MYSQL_BACKUP_DIR" "$MYSQL_BACKUP_DIR/staging" "$MYSQL_BACKUP_DIR/state"

mkdir -p /etc/mysql/conf.d
cat > /etc/mysql/conf.d/tuning.cnf <<'CNFEOF'
${mysql_config_content}
Expand Down
35 changes: 35 additions & 0 deletions scripts/mysql_backup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Prod MySQL S3 백업

DB EC2에서 다음 systemd 작업을 실행합니다.

- `mysql-backup-dump.timer`: 매일 03:00 KST에 일관된 전체 dump, 체크섬, manifest를 업로드합니다.
- `mysql-backup-binlog.timer`: 매 5분 경계마다 binlog를 회전하고 닫힌 파일과 manifest를 업로드합니다.

백업 파일은 `/mnt/mysql-data/mysql-backup`에서만 임시 생성합니다. S3에는 manifest를 마지막에 업로드하며, 같은 key가 이미 존재하면 체크섬이 같은 경우에만 업로드를 생략합니다. 이 방식으로 재시도 시 Object Lock 버킷에 불필요한 객체 버전이 생기는 것을 방지합니다.

## 배포 전 GitHub 설정

Repository Secrets:

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

Repository Variables:

- `MYSQL_BACKUP_BUCKET_NAME`: 백업 버킷 이름
- `MYSQL_BACKUP_DATABASE_NAME`: 백업할 DB 이름. 필수값이며 공개 코드에 기본값을 두지 않습니다.
- `PROD_DB_SSH_HOST_KEY_ED25519`: DB EC2의 ED25519 host key SHA-256 fingerprint

Comment thread
coderabbitai[bot] marked this conversation as resolved.
GitHub Environment:

- `prod-db`: required reviewer, self-review 차단, `main` 배포 브랜치 제한을 적용합니다.

> `MySQL Backup Deploy` 워크플로우는 `main` 브랜치에서만 실행되며, `prod-db` 승인을 통과해야 합니다. 기본값인 `validate`는 변경 없이 사전 조건 또는 설치 상태만 검사하고, 실제 설치는 `install`을 명시적으로 선택해야 합니다.

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

## dump 실패 처리

- dump 실행 직전에 예상 dump 크기의 2배와 256MiB의 여유 공간을 확인합니다.
- 실패한 작업은 10분 간격으로 최대 3회 다시 시도합니다.
- 6시간을 초과한 작업은 staging과 포인터를 폐기하고 새 dump를 생성합니다.
- 이미 완성된 dump가 남아 있으면 재생성하지 않고 S3 업로드만 다시 시도합니다.
Loading
Loading