Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# JWT — 256비트 이상 랜덤 문자열 권장
JWT_SECRET=CHANGE_ME_BASE64_32_BYTES_MINIMUM_STRING
JWT_ACCESS_EXPIRATION=1800000
JWT_REFRESH_EXPIRATION=604800000

# server port, 유레카 클라이언트 호스트명
SERVER_PORT=도메인_서비스별_포트번호
HOSTNAME=localhost

# 아래의 환경변수들은 .env 파일에만 포함하여 빌드 시에만 사용되며, .env.runtime에서는 생략됨
# 배포 환경에서도 공통 모듈을 적용하기 위해 Dockerfile에 추가해야 할 환경변수
GPR_USER=GitHub_ID
GPR_TOKEN=GitHub_Personal_Access_Token(PAT)

EUREKA_SERVER_URL=http://localhost:8761/eureka/
ZIPKIN_ENDPOINT=http://localhost:9411/api/v2/spans
185 changes: 185 additions & 0 deletions .github/actions/deploy-vm/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
name: Deploy VM
description: GCP VM에 Docker Compose 기반으로 배포하고, 실패 시 stable 태그로 롤백합니다

inputs:
vm:
description: "VM 이름"
required: true
zone:
description: "VM 존"
required: true
gcp_project:
description: "GCP 프로젝트 ID"
required: true
ar_image_path:
description: "Artifact Registry 이미지 경로"
required: true
image_tag:
description: "배포할 이미지 태그"
required: true
ar_token:
description: "Artifact Registry 액세스 토큰"
required: true
work_dir:
description: "VM 내 작업 디렉토리"
required: true
health_retries:
description: "헬스체크 최대 재시도 횟수"
required: true
health_interval:
description: "헬스체크 재시도 간격(초)"
required: true

runs:
using: composite
steps:
# TERMINATED 상태면 이후 step 전체 스킵
- name: Check VM status
id: vm-status
shell: bash
run: |
STATUS=$(gcloud compute instances describe ${{ inputs.vm }} \
--zone="${{ inputs.zone }}" \
--project="${{ inputs.gcp_project }}" \
--format="value(status)" 2>/dev/null || echo "NOT_FOUND")
if [ "$STATUS" = "NOT_FOUND" ]; then
echo "::error::${{ inputs.vm }} (${{ inputs.zone }}) 을(를) 찾을 수 없습니다."
exit 1
fi
echo "status=$STATUS" >> "$GITHUB_OUTPUT"
echo "${{ inputs.vm }} → $STATUS"

- name: Resolve rollback target
id: rollback
if: steps.vm-status.outputs.status == 'RUNNING'
shell: bash
run: |
STABLE=$(gcloud artifacts docker tags list "${{ inputs.ar_image_path }}" \
--project="${{ inputs.gcp_project }}" \
--filter="tag=stable" \
--format="value(tag)" 2>/dev/null | head -n1)

if [ -n "$STABLE" ]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=" >> "$GITHUB_OUTPUT"
echo "No stable tag yet — rollback will be skipped if deploy fails."
fi

- name: Sync compose file
if: steps.vm-status.outputs.status == 'RUNNING'
shell: bash
run: |
gcloud compute scp deploy/docker-compose.prod.yaml \
${{ inputs.vm }}:/tmp/docker-compose.prod.yaml \
--zone="${{ inputs.zone }}" \
--project="${{ inputs.gcp_project }}" \
--tunnel-through-iap

gcloud compute scp deploy/promtail-config.yml \
${{ inputs.vm }}:/tmp/promtail-config.yml \
--zone="${{ inputs.zone }}" \
--project="${{ inputs.gcp_project }}" \
--tunnel-through-iap

- name: Deploy and verify
id: deploy_run
if: steps.vm-status.outputs.status == 'RUNNING'
shell: bash
run: |
REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1)

gcloud compute ssh ${{ inputs.vm }} \
--zone="${{ inputs.zone }}" \
--project="${{ inputs.gcp_project }}" \
--tunnel-through-iap \
--command="
set -e

echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST

sudo mkdir -p ${{ inputs.work_dir }}
sudo mv /tmp/docker-compose.prod.yaml ${{ inputs.work_dir }}/docker-compose.prod.yaml
sudo mv /tmp/promtail-config.yml ${{ inputs.work_dir }}/promtail-config.yml
cd ${{ inputs.work_dir }}

if ! sudo docker network inspect pgsg-network > /dev/null 2>&1; then
echo 'pgsg-network not found. Creating...'
sudo docker network create pgsg-network
fi

sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \
docker compose -f docker-compose.prod.yaml pull
sudo env IMAGE_TAG=\"${{ inputs.image_tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \
docker compose -f docker-compose.prod.yaml up -d

echo 'Checking actuator health (max ${{ inputs.health_retries }} x ${{ inputs.health_interval }}s)...'
HEALTHY=0
for i in \$(seq 1 ${{ inputs.health_retries }}); do
if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then
echo \"Actuator UP (attempt \$i)\"
HEALTHY=1
break
fi
echo \"Waiting for service... (\$i/${{ inputs.health_retries }})\"
sleep ${{ inputs.health_interval }}
done

if [ \"\$HEALTHY\" != '1' ]; then
echo 'Actuator health check failed.'
exit 1
fi
"

- name: Rollback
if: failure() && steps.deploy_run.conclusion == 'failure' && steps.rollback.outputs.tag != ''
shell: bash
run: |
set +e
REGISTRY_HOST=$(echo "${{ inputs.ar_image_path }}" | cut -d/ -f1)

attempt_rollback() {
gcloud compute ssh ${{ inputs.vm }} \
--zone="${{ inputs.zone }}" \
--project="${{ inputs.gcp_project }}" \
--tunnel-through-iap \
--command="
set -e

echo '${{ inputs.ar_token }}' | sudo docker login -u oauth2accesstoken --password-stdin https://$REGISTRY_HOST
cd ${{ inputs.work_dir }}

sudo docker compose -f docker-compose.prod.yaml down --remove-orphans -t 5 || true

sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \
docker compose -f docker-compose.prod.yaml pull
sudo env IMAGE_TAG=\"${{ steps.rollback.outputs.tag }}\" AR_IMAGE_PATH=\"${{ inputs.ar_image_path }}\" \
docker compose -f docker-compose.prod.yaml up -d

for i in \$(seq 1 ${{ inputs.health_retries }}); do
if curl -sf http://localhost:8090/actuator/health | grep -q '\"status\":\"UP\"'; then
echo \"Rollback container UP (attempt \$i)\"
exit 0
fi
sleep ${{ inputs.health_interval }}
done

echo 'Rollback container did not become healthy.'
exit 1
"
}

for attempt in 1 2 3; do
echo "::group::Rollback attempt $attempt/3 on ${{ inputs.vm }}"
attempt_rollback
RC=$?
echo "::endgroup::"

[ $RC -eq 0 ] && echo "Rollback succeeded on attempt $attempt." && exit 0

echo "Rollback attempt $attempt failed (exit=$RC)."
[ $attempt -lt 3 ] && echo "Retrying in 15s..." && sleep 15
done

echo "::error::All rollback attempts failed on ${{ inputs.vm }}. Manual intervention required."
exit 1
88 changes: 88 additions & 0 deletions .github/actions/scale-vm/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Scale VM
description: GCP VM 인스턴스를 기동하거나 중지합니다

inputs:
direction:
description: "'out' = 기동 | 'in' = 중지"
required: true
targets:
description: "공백 구분 'vm이름:zone' 목록 (예: server-2:asia-northeast3-b server-3:asia-northeast3-c)"
required: true
gcp_project:
description: "GCP 프로젝트 ID"
required: true

runs:
using: composite
steps:
- name: Validate direction
shell: bash
run: |
if [[ "${{ inputs.direction }}" != "out" && "${{ inputs.direction }}" != "in" ]]; then
echo "::error::direction은 'out' 또는 'in' 이어야 합니다"
exit 1
fi

- name: Start / Stop VMs
shell: bash
run: |
DIRECTION="${{ inputs.direction }}"
ACTION=$([ "$DIRECTION" = "out" ] && echo "start" || echo "stop")
WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED")

for ENTRY in ${{ inputs.targets }}; do
VM="${ENTRY%%:*}"
ZONE="${ENTRY##*:}"

CURRENT=$(gcloud compute instances describe "$VM" \
--zone="$ZONE" \
--project="${{ inputs.gcp_project }}" \
--format="value(status)" 2>/dev/null || echo "NOT_FOUND")

if [ "$CURRENT" = "NOT_FOUND" ]; then
echo "::error::$VM ($ZONE) 을 찾을 수 없습니다 — VM을 먼저 생성해 주세요"
exit 1
fi

if [ "$CURRENT" = "$WAIT_STATUS" ]; then
echo "✅ $VM is already $CURRENT — skipping"
continue
fi

echo "▶ ${ACTION}ing $VM ($ZONE)..."
gcloud compute instances "$ACTION" "$VM" \
--zone="$ZONE" \
--project="${{ inputs.gcp_project }}"
done

- name: Wait for target status
shell: bash
run: |
DIRECTION="${{ inputs.direction }}"
WAIT_STATUS=$([ "$DIRECTION" = "out" ] && echo "RUNNING" || echo "TERMINATED")

for ENTRY in ${{ inputs.targets }}; do
VM="${ENTRY%%:*}"
ZONE="${ENTRY##*:}"

echo "⏳ Waiting for $VM to be $WAIT_STATUS..."
for attempt in $(seq 1 20); do
CURRENT=$(gcloud compute instances describe "$VM" \
--zone="$ZONE" \
--project="${{ inputs.gcp_project }}" \
--format="value(status)")

if [ "$CURRENT" = "$WAIT_STATUS" ]; then
echo "✅ $VM is $WAIT_STATUS"
break
fi

if [ "$attempt" = "20" ]; then
echo "::error::$VM did not reach $WAIT_STATUS in time"
exit 1
fi

echo " $VM is $CURRENT... ($attempt/20)"
sleep 15
done
done
92 changes: 92 additions & 0 deletions .github/workflows/_build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: _build

on:
workflow_call:
inputs:
force_apply_retention_policy:
type: boolean
default: false
outputs:
image_tag:
value: ${{ jobs.build-and-push.outputs.image_tag }}

env:
GCP_PROJECT: ${{ vars.GCP_PROJECT_ID }}
AR_IMAGE_PATH: ${{ vars.AR_IMAGE_PATH }}

jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4

- name: Authenticate to GCP
id: auth
uses: google-github-actions/auth@v2
with:
token_format: 'access_token'
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.GCP_SA_EMAIL }}
project_id: ${{ vars.GCP_PROJECT_ID }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2

- name: Resolve image metadata
id: meta
run: |
echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
REGISTRY_HOST=$(echo "$AR_IMAGE_PATH" | cut -d/ -f1)
echo "registry_host=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"

- name: Apply AR Image Retention Policy
if: |
contains(toJSON(github.event.commits.*.modified), 'deploy/ar-image-retention-policy.json') ||
contains(toJSON(github.event.commits.*.added), 'deploy/ar-image-retention-policy.json') ||
inputs.force_apply_retention_policy
run: |
REPO_NAME=$(echo "$AR_IMAGE_PATH" | cut -d/ -f3)
POLICY_FILE="deploy/ar-image-retention-policy.json"

if [ ! -f "$POLICY_FILE" ]; then
echo "Error: $POLICY_FILE not found"
exit 1
fi

REGION=$(echo "$AR_IMAGE_PATH" | sed 's/-docker\.pkg\.dev.*//')
gcloud artifacts repositories set-cleanup-policies "$REPO_NAME" \
--project="$GCP_PROJECT" \
--location="${REGION}" \
--policy="$POLICY_FILE" \
--quiet
echo "Successfully synced retention policy from $POLICY_FILE"

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

- name: Login to Artifact Registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.registry_host }}
username: 'oauth2accesstoken'
password: ${{ steps.auth.outputs.access_token }}

- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.AR_IMAGE_PATH }}:${{ steps.meta.outputs.tag }}
${{ env.AR_IMAGE_PATH }}:latest
secrets: |
GPR_USER=${{ secrets.GPR_USER }}
GPR_TOKEN=${{ secrets.GPR_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
Loading
Loading