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
230 changes: 230 additions & 0 deletions .github/workflows/mysql-backup-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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:
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은 코드로 확인이 안 돼서요. 제한돼 있지 않다면 위 게이트가 실질적인 유일한 방어선입니다.

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[@]}" ubuntu@127.0.0.1 \
"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"; "$install_dir/install.sh" "$install_dir/mysql-backup.env"'
ssh "${SSH_OPTIONS[@]}" ubuntu@127.0.0.1 \
"sudo bash -c $(printf '%q' "$REMOTE_INSTALL_COMMAND")" \
<"$KEY_DIR/mysql-backup.tar.gz"
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
22 changes: 22 additions & 0 deletions scripts/mysql_backup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 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.
> `MySQL Backup Deploy` 워크플로우는 기본값인 `validate`에서 변경 없이 사전 조건 또는 설치 상태만 검사합니다. 실제 설치는 `install`을 명시적으로 선택해야 합니다.
Loading
Loading