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
62 changes: 62 additions & 0 deletions .github/workflows/deploy-develop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Deploy Develop to GSMSV

on:
push:
branches:
- develop
workflow_dispatch:
inputs:
dry_run:
description: Run build and validation without SSH, deployment, or Discord notification
required: true
type: boolean
default: true

permissions:
contents: read
packages: write

concurrency:
group: gsmsv-develop-deployment
cancel-in-progress: false

jobs:
deploy:
if: >-
github.repository == 'CKLOB/MUDDA-Server' &&
github.ref == 'refs/heads/develop' &&
((github.event_name == 'workflow_dispatch' && inputs.dry_run == true) ||
vars.CD_ENABLED == 'true')
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- name: Check out trusted develop revision
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and deploy
env:
CD_ENABLED: ${{ vars.CD_ENABLED }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/mudda' }}
DRY_RUN: ${{ inputs.dry_run || false }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: bash ops/deploy/deploy.sh
2 changes: 1 addition & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: mudda-app:prod
image: ${APP_IMAGE:-mudda-app:prod}
env_file:
- .env.production
environment:
Expand Down
50 changes: 50 additions & 0 deletions docs/GSMSV_CD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# GSMSV CD 운영 가이드

이 문서는 `develop`에 반영된 MUDDA 이미지를 GHCR에 저장하고 GSMSV VM으로 배포하는 수동 확인 가능한 CD Workflow의 운영 절차를 정의한다. 실제 Secret 등록과 서버 배포는 이 변경에 포함하지 않는다.

## 동작 범위

`.github/workflows/deploy-develop.yml`은 `develop` push와 수동 `workflow_dispatch`를 대상으로 한다. 자동 배포는 Repository Variable `CD_ENABLED`가 정확히 `true`일 때만 실행된다. 값이 없거나 `false`이면 push 배포 Job은 skip된다. 수동 실행은 기본 `dry_run=true`이며, 이 모드에서는 Docker 이미지 빌드와 검증만 수행하고 SSH, 서버 변경, GHCR push, Discord 전송을 수행하지 않는다.

실제 배포를 활성화하기 전 다음 Secret을 등록해야 한다.

- `DEPLOY_HOST`
- `DEPLOY_PORT`
- `DEPLOY_USER`
- `DEPLOY_SSH_KEY` (비밀번호가 아닌 전용 SSH private key)
- `DISCORD_DEPLOY_WEBHOOK_URL`

선택 Variable은 `DEPLOY_PATH`이며 기본값은 `/opt/mudda`이다. `CD_ENABLED=true` 설정 전에는 dry-run으로 Workflow와 이미지 빌드만 확인한다. Secret 값은 이 저장소, Workflow 로그, Issue, PR, Discord에 기록하지 않는다.

## 이미지와 서버 구조

기존 `Dockerfile`을 Buildx로 빌드하며 commit의 40자리 SHA를 불변 이미지 태그로 사용한다.

```text
ghcr.io/cklob/mudda-server:<commit-sha>
ghcr.io/cklob/mudda-server:develop
```

배포 서버에는 `/opt/mudda/docker-compose.prod.yml`, `/opt/mudda/.env.production`, PostGIS 초기화 SQL 파일과 Firebase credential 디렉터리가 이미 준비되어 있어야 한다. 서버의 `.env.production`은 Actions로 가져오거나 출력하지 않는다. GHCR private package를 사용할 경우 서버 Docker daemon에 최소 read 권한의 사전 인증을 구성해야 하며, 이 작업에서 PAT나 Secret을 생성하지 않는다.

`docker-compose.prod.yml`은 `APP_IMAGE`가 있으면 해당 이미지를 사용하고, 없으면 기존 `mudda-app:prod` fallback을 사용한다. 따라서 기존 수동 `docker compose -f docker-compose.prod.yml build` 및 `up -d` 방식은 유지된다. 배포 스크립트는 PostgreSQL·Redis 볼륨을 삭제하거나 재생성하지 않으며 `down -v`, prune 명령을 실행하지 않는다.

## 배포 판정과 rollback

새 이미지를 pull한 뒤 app만 갱신하고 Compose healthcheck를 최대 30회, 5초 간격으로 확인한다. readiness가 healthy가 되어야 성공으로 판정한다. 실패하면 이전에 실행 중이던 SHA 이미지가 확인되는 경우에만 app을 이전 이미지로 되돌리고 readiness를 다시 확인한다. rollback은 app 컨테이너에만 적용되며 DB migration을 되돌리지 않는다.

Flyway migration이 이미 적용된 뒤 이전 애플리케이션으로 rollback하면 스키마와 애플리케이션 간 비호환이 생길 수 있다. 따라서 breaking migration은 사전 호환성을 검토하고, 자동 DB rollback은 수행하지 않는다. 실패 시 Actions 로그에는 Compose 상태와 app의 최근 일부 정제 로그만 남긴다.

## Discord 알림

배포 시작·성공·실패를 `DISCORD_DEPLOY_WEBHOOK_URL` Secret으로만 전송한다. 실패 알림에는 실패 단계, 종료 코드, rollback 결과, 실행 URL과 최대 약 2,500자의 정제 로그만 포함한다. webhook URL, JWT, Authorization, Cookie, password, AWS key, 이메일 등 민감정보 패턴을 마스킹하며 전체 로그나 `.env.production`은 전송하지 않는다. Webhook 전송 실패는 원래 배포 결과를 성공으로 바꾸지 않는다.

## 최초 활성화 절차

1. 서버에 Docker Compose 파일, 운영 `.env.production`, credential 파일을 준비하고 권한을 확인한다.
2. GitHub Actions에 전용 SSH key와 위 Secret을 등록한다. private GHCR package라면 서버 pull 인증도 별도로 준비한다.
3. `workflow_dispatch`에서 기본 `dry_run=true`를 실행해 Workflow와 이미지 빌드를 확인한다.
4. 테스트 결과를 확인한 뒤 Repository Variable `CD_ENABLED=true`를 설정한다.
5. 다음 `develop` 반영에서 실제 배포와 readiness, Discord 알림을 확인한다.

실제 Secret 등록, Workflow 실행, SSH 접속, 서버 배포는 이 작업에서 수행하지 않았다. 장애 시 서버에서 `docker compose -f docker-compose.prod.yml ps`, `docker compose -f docker-compose.prod.yml logs --tail 100 app`으로 확인하고, 필요하면 문서화된 SHA 이미지로 수동 rollback하되 DB migration 위험을 먼저 검토한다. Nginx와 Certbot은 사용하지 않는다.
133 changes: 133 additions & 0 deletions ops/deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dir="$(cd "$script_dir/../.." && pwd)"
step="initialization"
remote_output=""
rollback_result="not attempted"
started_at="$(date +%s)"

mask_value() {
local value="$1" line
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -n "$line" ]] && printf '::add-mask::%s\n' "$line"
done <<< "$value"
}

required_secret() {
local name="$1" value="${!1:-}"
if [[ -z "$value" ]]; then
echo "Missing required secret: $name" >&2
return 1
fi
mask_value "$value"
}

redact_output() {
MAX_CHARS=2500 "$script_dir/redact-log.sh"
}

notify() {
local event="$1"
[[ "$DRY_RUN" == true ]] && return 0
set +e
DEPLOY_LOG_EXCERPT="${REMOTE_OUTPUT:-}" \
DEPLOY_ROLLBACK_RESULT="$rollback_result" \
DEPLOY_FAILED_STEP="$step" \
DEPLOY_EXIT_CODE="${DEPLOY_EXIT_CODE:-1}" \
"$script_dir/discord-notify.sh" "$event"
local result=$?
set -e
if (( result != 0 )); then echo "Discord notification status: failed" >&2; fi
return 0
}

on_error() {
local exit_code=$?
DEPLOY_EXIT_CODE="$exit_code"
if [[ "$DRY_RUN" != true ]]; then
REMOTE_OUTPUT="$(printf '%s' "${REMOTE_OUTPUT:-}" | redact_output)"
notify failed
fi
echo "Deployment failed at step: $step (exit code: $exit_code)" >&2
exit "$exit_code"
}
trap on_error ERR

DRY_RUN="${DRY_RUN:-false}"
[[ "$DRY_RUN" == true || "$DRY_RUN" == false ]] || { echo "DRY_RUN must be true or false" >&2; exit 2; }

git_sha="${GITHUB_SHA:-$(git rev-parse HEAD)}"
[[ "$git_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "GITHUB_SHA must be a 40-character SHA" >&2; exit 2; }
short_sha="${git_sha:0:7}"
commit_message="$(git -C "$root_dir" log -1 --format=%s "$git_sha" 2>/dev/null || git -C "$root_dir" log -1 --format=%s)"
repo="${GITHUB_REPOSITORY:-CKLOB/MUDDA-Server}"
repo_lc="${repo,,}"
image_sha="ghcr.io/${repo_lc}:${git_sha}"
image_branch="ghcr.io/${repo_lc}:develop"

export DEPLOY_SHORT_SHA="$short_sha"
export DEPLOY_COMMIT_MESSAGE="$commit_message"
export DEPLOY_IMAGE_TAG="$image_sha"
export DEPLOY_TARGET="${DEPLOY_HOST:-GSMSV}"
export DEPLOY_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export DEPLOY_ENVIRONMENT="develop"

step="validation"
if [[ "$DRY_RUN" == true ]]; then
echo "Dry-run enabled: SSH, GHCR push, deployment, and Discord notification are disabled."
else
required_secret DEPLOY_HOST
required_secret DEPLOY_PORT
required_secret DEPLOY_USER
required_secret DEPLOY_SSH_KEY
required_secret DISCORD_DEPLOY_WEBHOOK_URL
[[ "$DEPLOY_HOST" != *[[:space:]]* ]] || { echo "DEPLOY_HOST contains whitespace" >&2; exit 2; }
[[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "DEPLOY_PORT is invalid" >&2; exit 2; }
[[ "$DEPLOY_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || { echo "DEPLOY_USER is invalid" >&2; exit 2; }
[[ "${DEPLOY_PATH:-/opt/mudda}" =~ ^/opt/mudda(/[A-Za-z0-9._-]+)*$ ]] || { echo "DEPLOY_PATH is invalid" >&2; exit 2; }
fi

step="build"
docker buildx build --file "$root_dir/Dockerfile" --tag "$image_sha" --load "$root_dir"
if [[ "$DRY_RUN" == true ]]; then
echo "Dry-run build completed for commit $short_sha."
exit 0
fi

step="notification-start"
notify started
step="registry-login"
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io --username "${GITHUB_ACTOR:-github-actions[bot]}" --password-stdin >/dev/null
step="image-push"
docker buildx build --file "$root_dir/Dockerfile" \
--tag "$image_sha" --tag "$image_branch" \
--cache-from type=gha --cache-to type=gha,mode=max --push "$root_dir"

step="server-deployment"
key_file="$(mktemp)"
remote_output_file="$(mktemp)"
cleanup() { rm -f "$key_file" "$remote_output_file"; }
trap cleanup EXIT
chmod 600 "$key_file"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/mudda}"
ssh -i "$key_file" -p "$DEPLOY_PORT" \
-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"$DEPLOY_USER@$DEPLOY_HOST" bash -s -- "$image_sha" "$DEPLOY_PATH" \
< "$script_dir/remote-deploy.sh" > "$remote_output_file" 2>&1 || {
REMOTE_OUTPUT="$(cat "$remote_output_file")"
rollback_result="$(printf '%s\n' "$REMOTE_OUTPUT" | sed -n 's/^ROLLBACK_RESULT=//p' | tail -n 1)"
rollback_result="${rollback_result:-not attempted}"
exit 1
}
REMOTE_OUTPUT="$(cat "$remote_output_file")"
rollback_result="$(printf '%s\n' "$REMOTE_OUTPUT" | sed -n 's/^ROLLBACK_RESULT=//p' | tail -n 1)"
rollback_result="${rollback_result:-not needed}"

step="notification-success"
finished_at="$(date +%s)"
export DEPLOY_DURATION="$((finished_at - started_at)) seconds"
notify succeeded
echo "Deployment succeeded: $image_sha"
99 changes: 99 additions & 0 deletions ops/deploy/discord-notify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail

event="${1:-}"
case "$event" in
started|succeeded|failed) ;;
*) echo "usage: discord-notify.sh <started|succeeded|failed>" >&2; exit 2 ;;
esac

webhook_url="${DISCORD_DEPLOY_WEBHOOK_URL:-}"
[[ -n "$webhook_url" ]] || { echo "Discord deployment webhook is not configured" >&2; exit 1; }
[[ "$webhook_url" =~ ^https://discord(app)?\.com/api/webhooks/ ]] || {
echo "Discord deployment webhook has an invalid host" >&2
exit 1
}
command -v jq >/dev/null || { echo "jq is required for Discord deployment notifications" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl is required for Discord deployment notifications" >&2; exit 1; }

payload_file="$(mktemp)"
config_file="$(mktemp)"
error_file="$(mktemp)"
trap 'rm -f "$payload_file" "$config_file" "$error_file"' EXIT

trim() {
local value="$1"
printf '%s' "$value" | awk 'BEGIN { ORS="" } { if (length(out)) out=out "\\n"; out=out $0 } END { print substr(out, 1, 1000) }'
}

commit_message="$(trim "${DEPLOY_COMMIT_MESSAGE:-unknown}")"
log_excerpt="$(printf '%s' "${DEPLOY_LOG_EXCERPT:-}" | MAX_CHARS=2500 "$(dirname "$0")/redact-log.sh")"
image_tag="${DEPLOY_IMAGE_TAG:-unknown}"
target="${DEPLOY_TARGET:-GSMSV}"
run_url="${GITHUB_RUN_URL:-unknown}"
now="${DEPLOY_TIME:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"

case "$event" in
started)
payload="$(jq -n \
--arg env "${DEPLOY_ENVIRONMENT:-develop}" \
--arg branch "${GITHUB_REF_NAME:-develop}" \
--arg sha "${DEPLOY_SHORT_SHA:-unknown}" \
--arg message "$commit_message" \
--arg actor "${GITHUB_ACTOR:-unknown}" \
--arg url "$run_url" \
--arg time "$now" \
'{embeds: [{title: "GSMSV 배포 시작", color: 16753920, fields: [
{name: "환경", value: $env, inline: true},
{name: "브랜치", value: $branch, inline: true},
{name: "Commit", value: ($sha + "\\n" + $message), inline: false},
{name: "요청자", value: $actor, inline: true},
{name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true},
{name: "시작 시각", value: $time, inline: true}
]}]}')" ;;
succeeded)
payload="$(jq -n \
--arg image "$image_tag" \
--arg target "$target" \
--arg endpoint "${DEPLOY_HTTPS_URL:-https://mudda-api.https.gsmsv.site}" \
--arg duration "${DEPLOY_DURATION:-unknown}" \
--arg url "$run_url" \
--arg time "$now" \
'{embeds: [{title: "GSMSV 배포 성공", color: 5763719, fields: [
{name: "상태", value: "성공", inline: true},
{name: "이미지", value: $image, inline: false},
{name: "대상", value: $target, inline: true},
{name: "주소", value: $endpoint, inline: false},
{name: "소요 시간", value: $duration, inline: true},
{name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true},
{name: "완료 시각", value: $time, inline: true}
]}]}')" ;;
failed)
payload="$(jq -n \
--arg step "${DEPLOY_FAILED_STEP:-unknown}" \
--arg code "${DEPLOY_EXIT_CODE:-unknown}" \
--arg rollback "${DEPLOY_ROLLBACK_RESULT:-not attempted}" \
--arg logs "$log_excerpt" \
--arg url "$run_url" \
--arg time "$now" \
'{embeds: [{title: "GSMSV 배포 실패", color: 15548997, fields: [
{name: "실패 단계", value: $step, inline: true},
{name: "종료 코드", value: $code, inline: true},
{name: "Rollback", value: $rollback, inline: true},
{name: "요약 로그", value: (if $logs == "" then "(없음)" else ("```\\n" + $logs + "\\n```") end), inline: false},
{name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true},
{name: "발생 시각", value: $time, inline: true}
]}]}')" ;;
esac

printf '%s' "$payload" > "$payload_file"
{
printf 'url = "'
printf '%s' "$webhook_url"
printf '"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = @%s\n' "$payload_file"
} > "$config_file"

if ! curl --silent --show-error --fail --max-time 15 --config "$config_file" >/dev/null 2>"$error_file"; then
echo "Discord deployment notification failed" >&2
exit 1
fi
15 changes: 15 additions & 0 deletions ops/deploy/redact-log.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail

max_chars="${MAX_CHARS:-2500}"
[[ "$max_chars" =~ ^[1-9][0-9]*$ ]] || max_chars=2500

sed -E \
-e 's#https://discord(app)?\.com/api/webhooks/[[:alnum:]_/-]+#[REDACTED_URL]#gI' \
-e 's/(Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/gI' \
-e 's/((password|passwd|secret|token|jwt|authorization|cookie|api[-_]?key|client[-_]?secret|access[-_]?key|secret[-_]?key)[[:space:]]*[:=][[:space:]]*)[^,[:space:]]+/\1[REDACTED]/gI' \
-e 's/AKIA[0-9A-Z]{16}/[REDACTED_AWS_KEY]/g' \
-e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \
-e 's/[[:alnum:]._%+-]+@[[:alnum:].-]+\.[A-Za-z]{2,}/[REDACTED_EMAIL]/g' \
-e 's/::/ : :/g' |
awk -v limit="$max_chars" 'BEGIN { ORS="" } { remaining=limit-length(out); if (remaining > 0) out=out substr($0, 1, remaining) "\n" } END { printf "%s", substr(out, 1, limit) }'
Loading