Skip to content

Merge pull request #203 from prgrms-be-devcourse/feat/202-모니터링 #42

Merge pull request #203 from prgrms-be-devcourse/feat/202-모니터링

Merge pull request #203 from prgrms-be-devcourse/feat/202-모니터링 #42

Workflow file for this run

name: deploy
# =========================
# 전역 환경변수
# =========================
env:
IMAGE_REPOSITORY: team01-backend # GHCR 이미지 리포지토리명
CONTAINER_1_NAME: app_blue # Blue 슬롯 컨테이너명 (고정)
CONTAINER_2_NAME: app_green # Green 슬롯 컨테이너명 (고정)
CONTAINER_PORT: 8080 # Spring Boot 포트
EC2_INSTANCE_TAG_NAME: devcos-team01-ec2-main
DOCKER_NETWORK: common # Docker 네트워크
NPM_BASE_URL: https://127.0.0.1:81 # NPMplus 관리 API (EC2 내부에서 접근)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
on:
push:
paths:
- ".github/workflows/*.yml"
- ".github/workflows/*.yaml"
- "src/**"
- "build.gradle.kts"
- "settings.gradle.kts"
- "Dockerfile"
branches:
- dev # 테스트용. 완료 후 main으로 변경
permissions:
contents: write
packages: write
defaults:
run:
shell: bash
jobs:
# ---------------------------------------------------------
# 1) 다음 버전 계산 (dry-run, 태그 생성 X)
# ---------------------------------------------------------
calculateTag:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.dry_run.outputs.new_tag }}
steps:
- uses: actions/checkout@v4
- name: 다음 버전 태그 계산 (dry-run)
id: dry_run
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
dry_run: true
# ---------------------------------------------------------
# 2) 도커 이미지 빌드/푸시
# ---------------------------------------------------------
buildImageAndPush:
name: 도커 이미지 빌드와 푸시
needs: calculateTag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Docker Buildx 설치
uses: docker/setup-buildx-action@v3
- name: 레지스트리 로그인
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 저장소 소유자명 소문자 변환
run: echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}"
env:
OWNER: "${{ github.repository_owner }}"
- name: 빌드 앤 푸시
uses: docker/build-push-action@v6
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:${{ needs.calculateTag.outputs.tag_name }}
ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:latest
# ---------------------------------------------------------
# 3) Blue/Green 무중단 배포 (단일 EC2 + NPMplus 스위치)
# ---------------------------------------------------------
deploy:
name: Blue/Green 무중단 배포
runs-on: ubuntu-latest
needs: [calculateTag, buildImageAndPush]
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# Name 태그로 EC2 인스턴스 ID 조회
- name: 인스턴스 ID 가져오기
run: |
INSTANCE_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=${{ env.EC2_INSTANCE_TAG_NAME }}" "Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceId" --output text)
[[ -n "${INSTANCE_ID}" && "${INSTANCE_ID}" != "None" ]] || { echo "No running instance found"; exit 1; }
echo "INSTANCE_ID=${INSTANCE_ID}" >> "${GITHUB_ENV}"
# .env를 base64로 인코딩해 SSM 스크립트에 안전하게 주입
- name: .env base64 인코딩
env:
DOT_ENV: ${{ secrets.DOT_ENV }}
run: |
echo "DOT_ENV_B64=$(printf '%s' "${DOT_ENV}" | base64 -w0)" >> "${GITHUB_ENV}"
# SSM으로 Blue/Green 스위치 수행
- name: AWS SSM 배포
run: |
DEPLOY_SCRIPT=$(cat << 'SCRIPT_EOF'
set -Eeuo pipefail
# 실행 로그 (타임스탬프 부착)
SCRIPT_TS="$(date +%Y%m%d_%H%M%S)"
LOG="/tmp/ssm-${SCRIPT_TS}.log"
exec > >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG")
exec 2> >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG" >&2)
# jq 설치 확인 (없으면 설치)
which jq || apt-get install -y jq -qq
# /etc/environment 로드
# PASSWORD_1, RDS_HOST, REDIS_HOST, GITHUB_ACCESS_TOKEN_1_OWNER 등 포함
set -a
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^# ]] && continue
printf -v "$key" '%s' "$value"
done < /etc/environment
set +a
OWNER_LC="${{ github.repository_owner }}"
OWNER_LC="${OWNER_LC,,}"
IMAGE_TAG="${{ needs.calculateTag.outputs.tag_name }}"
IMAGE_REPOSITORY="${{ env.IMAGE_REPOSITORY }}"
IMAGE="ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:${IMAGE_TAG}"
SLOT1="${{ env.CONTAINER_1_NAME }}"
SLOT2="${{ env.CONTAINER_2_NAME }}"
PORT_IN="${{ env.CONTAINER_PORT }}"
NET="${{ env.DOCKER_NETWORK }}"
NPM_BASE_URL="${{ env.NPM_BASE_URL }}"
APP_1_DOMAIN="${{ secrets.APP_DOMAIN }}"
echo "🔹 Use image: ${IMAGE}"
docker pull "${IMAGE}"
# .env 파일 생성 (Spring Boot가 /app/.env에서 읽음)
# RDS, Redis는 AWS 관리형 → DOT_ENV secret에 엔드포인트 포함
printf '%s' "${{ env.DOT_ENV_B64 }}" | base64 -d > /tmp/.env
cp /tmp/.env "/tmp/.env.$(date +%Y_%m_%d__%H%M%S)"
chmod 600 /tmp/.env
# NPMplus API 로그인 (PASSWORD_1은 /etc/environment에서 로드)
COOKIE_JAR="/tmp/npm_cookies.txt"
LOGIN_CODE=$(curl -sk -o /dev/null -w "%{http_code}" -c "${COOKIE_JAR}" \
-X POST "${NPM_BASE_URL}/api/tokens" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg id "admin@npm.com" --arg pw "${PASSWORD_1:-}" '{identity:$id, secret:$pw}')")
[[ "${LOGIN_CODE}" == "200" ]] || { echo "NPM login failed (HTTP ${LOGIN_CODE})"; exit 1; }
[[ -n "${APP_1_DOMAIN:-}" ]] || { echo "APP_DOMAIN secret is empty"; exit 1; }
# 프록시 호스트 ID 조회 (없으면 자동 등록)
PROXY_ID=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts" \
| jq ".[] | select(.domain_names[]==\"${APP_1_DOMAIN}\") | .id")
if [[ -z "${PROXY_ID}" || "${PROXY_ID}" == "null" ]]; then
echo "📝 Proxy host not found for ${APP_1_DOMAIN}, creating..."
PROXY_ID=$(curl -sk -b "${COOKIE_JAR}" -X POST "${NPM_BASE_URL}/api/nginx/proxy-hosts" \
-H "Content-Type: application/json" \
-d "$(jq -nc \
--arg domain "${APP_1_DOMAIN}" \
--arg host "${SLOT1}" \
--argjson port ${PORT_IN} \
'{
domain_names: [$domain],
forward_scheme: "http",
forward_host: $host,
forward_port: $port,
access_list_id: 0,
certificate_id: 0,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
caching_enabled: false,
allow_websocket_upgrade: true,
advanced_config: "",
locations: [],
meta: {letsencrypt_agree: false, dns_challenge: false}
}')" | jq '.id')
[[ -n "${PROXY_ID}" && "${PROXY_ID}" != "null" ]] || { echo "❌ Failed to create proxy host"; exit 1; }
echo "✅ Created proxy host ID: ${PROXY_ID}"
fi
# 현재 upstream(컨테이너명) 조회
CURRENT_HOST=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}" \
| jq -r '.forward_host')
echo "🔎 CURRENT_HOST: ${CURRENT_HOST:-none}"
# Blue/Green 역할 판정
if [[ "${CURRENT_HOST:-}" == "${SLOT1}" ]]; then
BLUE="${SLOT1}"; GREEN="${SLOT2}"
elif [[ "${CURRENT_HOST:-}" == "${SLOT2}" ]]; then
BLUE="${SLOT2}"; GREEN="${SLOT1}"
else
BLUE="none"; GREEN="${SLOT1}" # 초기 배포
fi
echo "🎨 role -> blue(now): ${BLUE}, green(next): ${GREEN}"
# Green 컨테이너 재기동
# --network common: NPMplus, Prometheus와 같은 네트워크
# RDS/Redis는 AWS 관리형이므로 별도 컨테이너 불필요
docker rm -f "${GREEN}" >/dev/null 2>&1 || true
echo "🚀 run new container → ${GREEN}"
docker run -d --name "${GREEN}" \
--restart unless-stopped \
--network "${NET}" \
-e TZ=Asia/Seoul \
--env-file /tmp/.env \
"${IMAGE}"
# 헬스체크 (/actuator/health 200 OK까지 대기)
echo "⏱ health-check: ${GREEN}"
TIMEOUT=120
INTERVAL=3
ELAPSED=8
sleep 8
CONTAINER_IP=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${GREEN}")
CODE=000
while (( ELAPSED < TIMEOUT )); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://${CONTAINER_IP}:${PORT_IN}/actuator/health" || echo 000)
[[ "${CODE}" == "200" ]] && { echo "✅ ${GREEN} healthy"; break; }
sleep "${INTERVAL}"
ELAPSED=$((ELAPSED + INTERVAL))
done
[[ "${CODE}" == "200" ]] || { echo "❌ ${GREEN} health failed"; docker logs --tail=200 "${GREEN}" || true; docker rm -f "${GREEN}" || true; exit 1; }
# NPMplus 업스트림 전환
CURRENT_CFG=$(curl -sk -b "${COOKIE_JAR}" -X GET "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}")
NEW_CFG=$(echo "${CURRENT_CFG}" | jq \
--arg host "${GREEN}" \
--argjson port ${PORT_IN} \
'{
access_list_id, advanced_config, allow_websocket_upgrade,
block_exploits, caching_enabled, certificate_id, domain_names,
forward_host: $host, forward_port: $port, forward_scheme,
hsts_enabled, hsts_subdomains, http2_support, locations,
meta, ssl_forced
}')
SWITCH_CODE=$(curl -sk -b "${COOKIE_JAR}" -o /dev/null -w "%{http_code}" \
-X PUT "${NPM_BASE_URL}/api/nginx/proxy-hosts/${PROXY_ID}" \
-H "Content-Type: application/json" \
-d "${NEW_CFG}")
[[ "${SWITCH_CODE}" == "200" ]] || { echo "❌ NPM switch failed (HTTP ${SWITCH_CODE})"; exit 1; }
echo "🔁 switch upstream → ${GREEN}:${PORT_IN}"
# 이전 Blue 종료
if [[ "${BLUE}" != "none" ]]; then
docker stop "${BLUE}" >/dev/null 2>&1 || true
docker rm "${BLUE}" >/dev/null 2>&1 || true
echo "🧹 removed old blue: ${BLUE}"
fi
# 이미지 정리 (현재 태그/latest 제외)
{
docker images --format '{{.Repository}}:{{.Tag}}' \
| grep -F "ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:" \
| grep -v -F ":${IMAGE_TAG}" \
| grep -v -F ":latest" \
| grep -v -F ":<none>" \
| xargs -r docker rmi
} || true
echo "🏁 Blue/Green switch complete. now blue = ${GREEN}"
SCRIPT_EOF
)
PARAMS=$(jq -nc --arg s "$DEPLOY_SCRIPT" '{"commands":["bash << '\''ENDBASH'\''", $s, "ENDBASH"]}')
COMMAND_ID=$(aws ssm send-command \
--region "${{ secrets.AWS_REGION }}" \
--instance-ids "${{ env.INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--parameters "$PARAMS" \
--query "Command.CommandId" \
--output text)
echo "SSM Command ID: ${COMMAND_ID}"
# 완료까지 대기 (최대 15분, 10초 간격)
WAIT_MAX=900
WAIT_INTERVAL=10
WAIT_ELAPSED=0
while (( WAIT_ELAPSED < WAIT_MAX )); do
CMD_STATUS=$(aws ssm get-command-invocation \
--command-id "${COMMAND_ID}" \
--instance-id "${{ env.INSTANCE_ID }}" \
--query 'Status' --output text 2>/dev/null || echo "Unknown")
[[ "${CMD_STATUS}" == "InProgress" || "${CMD_STATUS}" == "Pending" ]] || break
sleep ${WAIT_INTERVAL}
WAIT_ELAPSED=$(( WAIT_ELAPSED + WAIT_INTERVAL ))
done
INVOCATION=$(aws ssm get-command-invocation \
--command-id "${COMMAND_ID}" \
--instance-id "${{ env.INSTANCE_ID }}")
STATUS=$(echo "$INVOCATION" | jq -r '.Status')
echo "===== stdout ====="
echo "$INVOCATION" | jq -r '.StandardOutputContent'
echo "===== stderr ====="
echo "$INVOCATION" | jq -r '.StandardErrorContent'
echo "=================="
echo "Status: ${STATUS}"
[[ "$STATUS" == "Success" ]] || exit 1
# ---------------------------------------------------------
# 4) 배포 성공 후 태그/릴리즈 생성
# ---------------------------------------------------------
makeTagAndRelease:
runs-on: ubuntu-latest
needs: deploy
steps:
- uses: actions/checkout@v4
- name: Create Tag
id: create_tag
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.create_tag.outputs.new_tag }}
name: Release ${{ steps.create_tag.outputs.new_tag }}
body: ${{ steps.create_tag.outputs.changelog }}
draft: false
prerelease: false