diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 9ee29ec..fbb18ae 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -19,8 +19,8 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - STAGING_HEALTH_URL: ${{ secrets.STAGING_HEALTH_URL }} - PRODUCTION_HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} + # 헬스 URL 은 전역 env 에서 뺐다. 배포 잡의 해당 스텝만 시크릿을 env 로 받는다. + # 전역에 두면 배포와 무관한 test/scan 잡 환경에까지 값이 실린다. permissions: contents: read @@ -199,119 +199,271 @@ jobs: needs: build-docker if: github.ref == 'refs/heads/develop' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'staging') environment: staging - + steps: - name: Checkout code uses: actions/checkout@v4 - + + # 어떤 시크릿이 비어 있는지 먼저 이름으로 알려준다. + # 예전에는 `test -n "..."` 하나로 끝나서, 실패해도 무엇이 없는지 로그에 남지 않았다. + - name: Check required secrets + env: + DEPLOY_HOST: ${{ secrets.STAGING_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.STAGING_DEPLOY_USER }} + SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + HEALTH_URL: ${{ secrets.STAGING_HEALTH_URL }} + run: | + missing="" + for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL; do + if [ -z "${!name}" ]; then missing="$missing STAGING_$name"; fi + done + if [ -n "$missing" ]; then + echo "::error::스테이징 배포에 필요한 시크릿이 없습니다:$missing" + exit 1 + fi + echo "필요한 시크릿이 모두 설정되어 있습니다." + + # SSH 개인키를 올리는 스텝이 아예 없어서, 시크릿을 채워도 인증 단계에서 막혔다. + # 키는 env 로 넘긴다. run 본문에 ${{ secrets.* }} 를 그대로 쓰면 값이 스크립트 텍스트에 박힌다. + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }} + DEPLOY_HOST: ${{ secrets.STAGING_DEPLOY_HOST }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + if [ -n "$KNOWN_HOSTS" ]; then + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + else + # StrictHostKeyChecking=no 를 쓰던 자리다. keyscan 도 최초 접속을 그냥 믿는 건 같지만, + # 최소한 이번 실행 안에서는 호스트키가 고정된다. 중간자 공격까지 막으려면 + # STAGING_SSH_KNOWN_HOSTS 에 호스트키를 넣어 고정해야 한다. + echo "::warning::STAGING_SSH_KNOWN_HOSTS 가 없어 ssh-keyscan 으로 대체합니다." + ssh-keyscan -H "$DEPLOY_HOST" > ~/.ssh/known_hosts 2>/dev/null + fi + chmod 600 ~/.ssh/known_hosts + - name: Deploy to staging environment + env: + DEPLOY_HOST: ${{ secrets.STAGING_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.STAGING_DEPLOY_USER }} + IMAGE: ${{ needs.build-docker.outputs.image-tag }} run: | - test -n "${{ secrets.STAGING_DEPLOY_HOST }}" - ssh -o StrictHostKeyChecking=no ${{ secrets.STAGING_DEPLOY_USER }}@${{ secrets.STAGING_DEPLOY_HOST }} \ - "docker pull ${{ needs.build-docker.outputs.image-tag }} && docker stop carecode-staging || true && docker rm carecode-staging || true && docker run -d --name carecode-staging -p 8082:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}" - + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging -p 8082:8082 --env-file /opt/carecode/.env $IMAGE" + - name: Run health check + env: + HEALTH_URL: ${{ secrets.STAGING_HEALTH_URL }} run: | - test -n "$STAGING_HEALTH_URL" for i in {1..20}; do - if curl -fsS "$STAGING_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then + if curl -fsS "$HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then echo "Staging health check passed" exit 0 fi sleep 5 done - echo "Staging health check failed" + echo "::error::스테이징 헬스체크 실패 - 컨테이너 로그를 확인하세요" exit 1 - + + # 예전에는 echo 만 있어서 배포가 실패해도 아무에게도 닿지 않았다. + # OPS_SLACK_WEBHOOK_URL 이 없으면 잡 요약에만 남긴다. - name: Notify deployment status if: always() + env: + SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }} + STATUS: ${{ job.status }} run: | - echo "Staging deployment completed" - # 슬랙, 이메일 등 알림 추가 + line="[staging] 배포 $STATUS - ${{ github.repository }}@${GITHUB_SHA:0:7} (${{ github.run_id }})" + echo "$line" >> "$GITHUB_STEP_SUMMARY" + if [ "$STATUS" != "success" ]; then echo "::error::$line"; fi + if [ -n "$SLACK_WEBHOOK_URL" ]; then + curl -fsS -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg t "$line" '{text:$t}')" || echo "::warning::슬랙 알림 전송 실패" + fi # =========================================== # Deploy to Production (Blue/Green) Job # =========================================== deploy-production: - name: Deploy to Production (Blue/Green) + name: Deploy to Production runs-on: ubuntu-latest needs: build-docker if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') environment: production - + + # 예전에는 Blue/Green 이었다. 두 색을 바꿔치는 마지막 단계가 라우터 HTTP API + # (PRODUCTION_ROUTER_STATUS_URL / _SWITCH_URL) 를 호출했는데, 그 API 를 제공하는 + # 구현이 어디에도 없다. 별도 저장소의 블루/그린 도구(CareCode_Nohub_Deploy)는 + # paramiko 로 서버에 붙어 nginx conf 를 고치는 CLI 이지 HTTP 서버가 아니다. + # 그래서 시크릿을 다 채워도 전환 단계에서 반드시 멈췄다. + # + # 진짜 무중단은 nginx 를 제어할 수 있어야 성립한다. 그때까지는 "새 이미지를 예비 + # 포트에서 먼저 띄워 확인하고, 통과할 때만 교체" 로 간다. 교체 순간에 짧은 순단이 + # 있지만, 깨진 이미지가 운영에 올라가는 일은 없다. steps: - name: Checkout code uses: actions/checkout@v4 - - # blue/green 두 컨테이너는 서로 다른 포트를 점유해야 한다. - # 둘 다 8082 를 쓰면 동시에 뜰 수 없어 무중단 전환 자체가 성립하지 않는다. - - name: Determine deployment strategy - id: strategy + + # 어떤 시크릿이 비어 있는지 먼저 이름으로 알려준다. + # 예전에는 각 스텝의 `test -n "..."` 하나로 끝나서, 실패해도 무엇이 없는지 로그에 남지 않았다. + - name: Check required secrets + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} + run: | + missing="" + for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL; do + if [ -z "${!name}" ]; then missing="$missing PRODUCTION_$name"; fi + done + if [ -n "$missing" ]; then + echo "::error::운영 배포에 필요한 시크릿이 없습니다:$missing" + exit 1 + fi + echo "필요한 시크릿이 모두 설정되어 있습니다." + + # SSH 개인키를 올리는 스텝이 아예 없어서, 시크릿을 채워도 인증 단계에서 막혔다. + # 키는 env 로 넘긴다. run 본문에 ${{ secrets.* }} 를 그대로 쓰면 값이 스크립트 텍스트에 박힌다. + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }} + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} run: | - CURRENT_ENV=$(curl -fsS "${{ secrets.PRODUCTION_ROUTER_STATUS_URL }}" || echo "blue") - if [ "$CURRENT_ENV" = "blue" ]; then - echo "target-env=green" >> $GITHUB_OUTPUT - echo "current-env=blue" >> $GITHUB_OUTPUT - echo "target-port=8083" >> $GITHUB_OUTPUT + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + if [ -n "$KNOWN_HOSTS" ]; then + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts else - echo "target-env=blue" >> $GITHUB_OUTPUT - echo "current-env=green" >> $GITHUB_OUTPUT - echo "target-port=8082" >> $GITHUB_OUTPUT + # StrictHostKeyChecking=no 를 쓰던 자리다. keyscan 도 최초 접속을 그냥 믿는 건 같지만, + # 최소한 이번 실행 안에서는 호스트키가 고정된다. 중간자 공격까지 막으려면 + # PRODUCTION_SSH_KNOWN_HOSTS 에 호스트키를 넣어 고정해야 한다. + echo "::warning::PRODUCTION_SSH_KNOWN_HOSTS 가 없어 ssh-keyscan 으로 대체합니다." + ssh-keyscan -H "$DEPLOY_HOST" > ~/.ssh/known_hosts 2>/dev/null fi + chmod 600 ~/.ssh/known_hosts - - name: Deploy to target environment + # 토큰은 stdin 으로 흘려보낸다. ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보인다. + - name: Log in to registry on the server + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - test -n "${{ secrets.PRODUCTION_DEPLOY_HOST }}" - TARGET="${{ steps.strategy.outputs.target-env }}" - PORT="${{ steps.strategy.outputs.target-port }}" - ssh -o StrictHostKeyChecking=no ${{ secrets.PRODUCTION_DEPLOY_USER }}@${{ secrets.PRODUCTION_DEPLOY_HOST }} \ - "docker pull ${{ needs.build-docker.outputs.image-tag }} \ - && (docker stop carecode-$TARGET || true) \ - && (docker rm carecode-$TARGET || true) \ - && docker run -d --name carecode-$TARGET -p $PORT:8082 --env-file /opt/carecode/.env ${{ needs.build-docker.outputs.image-tag }}" - - # 전환 전에는 아직 라우팅되지 않는 대기(target) 인스턴스를 직접 확인해야 한다. - # 공용 헬스 URL 을 보면 구버전이 UP 이라 항상 통과해버린다. - - name: Wait for target instance to be ready + printf '%s' "$GHCR_TOKEN" | ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker login ${{ env.REGISTRY }} -u '${{ github.actor }}' --password-stdin" + + - name: Deploy + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + IMAGE: ${{ needs.build-docker.outputs.image-tag }} run: | - test -n "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}" - PORT="${{ steps.strategy.outputs.target-port }}" - TARGET_HEALTH_URL=$(echo "${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }}" | sed "s/{port}/$PORT/") - for i in {1..20}; do - if curl -fsS "$TARGET_HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then - echo "Production target ($PORT) is healthy" + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "IMAGE='$IMAGE' bash -s" <<'REMOTE' + set -euo pipefail + + APP=carecode + PROBE=carecode-probe + PORT=8082 + # 예전 blue/green 이 8083 을 쓰므로 검증 포트는 겹치지 않는 곳으로 잡는다. + PROBE_PORT=18082 + ENV_FILE=/opt/carecode/.env + + if [ ! -f "$ENV_FILE" ]; then + echo "$ENV_FILE 이 없습니다." + exit 1 + fi + + echo "===== pull $IMAGE =====" + docker pull "$IMAGE" + + # 이전 실행이 남긴 검증 컨테이너 정리 + docker rm -f "$PROBE" >/dev/null 2>&1 || true + + # 1) 예비 포트에서 먼저 띄워 본다. 살아 있는 컨테이너는 아직 그대로다. + echo "===== 새 이미지 검증 (:$PROBE_PORT) =====" + docker run -d --name "$PROBE" -p "127.0.0.1:$PROBE_PORT:8082" --env-file "$ENV_FILE" "$IMAGE" + + ok=0 + for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:$PROBE_PORT/actuator/health" 2>/dev/null | grep -q '"status":"UP"'; then + ok=1; break + fi + sleep 5 + done + + if [ "$ok" -ne 1 ]; then + echo "새 이미지가 기동하지 못했습니다. 운영 컨테이너는 건드리지 않습니다." + echo "----- 컨테이너 로그 (마지막 100줄) -----" + docker logs --tail 100 "$PROBE" 2>&1 || true + docker rm -f "$PROBE" >/dev/null 2>&1 || true + exit 1 + fi + + echo "검증 통과. 교체합니다." + docker rm -f "$PROBE" >/dev/null 2>&1 || true + + # 2) 교체. 여기서부터 짧은 순단이 있다. + # 예전 워크플로가 만들던 blue/green 이름도 함께 정리한다. 남아 있으면 포트를 잡고 있다. + for name in "$APP" carecode-blue carecode-green; do + docker rm -f "$name" >/dev/null 2>&1 || true + done + + docker run -d --name "$APP" --restart unless-stopped \ + -p "$PORT:8082" --env-file "$ENV_FILE" "$IMAGE" + + for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:$PORT/actuator/health" 2>/dev/null | grep -q '"status":"UP"'; then + echo "===== 교체 완료 =====" + docker image prune -f >/dev/null 2>&1 || true exit 0 fi sleep 5 done - echo "Production target health check failed" + + echo "교체 후 기동에 실패했습니다." + echo "----- 컨테이너 로그 (마지막 100줄) -----" + docker logs --tail 100 "$APP" 2>&1 || true exit 1 - - - name: Switch traffic to new environment - run: | - test -n "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" - curl -fsS -X POST "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" \ - -H "Authorization: Bearer ${{ secrets.PRODUCTION_ROUTER_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "{\"target\":\"${{ steps.strategy.outputs.target-env }}\"}" - - - name: Verify deployment - run: | - curl -fsS "$PRODUCTION_HEALTH_URL/actuator/health" | grep -q '"status":"UP"' - - - name: Rollback if needed - if: failure() + REMOTE + + - name: Verify from outside + env: + HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} run: | - curl -fsS -X POST "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" \ - -H "Authorization: Bearer ${{ secrets.PRODUCTION_ROUTER_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "{\"target\":\"${{ steps.strategy.outputs.current-env }}\"}" - + # 서버 안에서는 UP 인데 밖에서 안 보이면 프록시/보안그룹 문제다. 구분해서 알려준다. + for _ in $(seq 1 12); do + if curl -fsS "$HEALTH_URL/actuator/health" | grep -q '"status":"UP"'; then + echo "외부 확인 완료" + exit 0 + fi + sleep 5 + done + echo "::error::컨테이너는 떴지만 외부 헬스 URL 로 보이지 않습니다. 리버스 프록시 설정을 확인하세요." + exit 1 + + # 예전에는 echo 만 있어서 배포가 실패해도 아무에게도 닿지 않았다. + # OPS_SLACK_WEBHOOK_URL 이 없으면 잡 요약에만 남긴다. - name: Notify deployment status if: always() + env: + SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }} + STATUS: ${{ job.status }} run: | - echo "Production deployment completed" - # 슬랙, 이메일 등 알림 추가 + line="[production] 배포 $STATUS - ${{ github.repository }}@${GITHUB_SHA:0:7} (${{ github.run_id }})" + echo "$line" >> "$GITHUB_STEP_SUMMARY" + if [ "$STATUS" != "success" ]; then echo "::error::$line"; fi + if [ -n "$SLACK_WEBHOOK_URL" ]; then + curl -fsS -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg t "$line" '{text:$t}')" || echo "::warning::슬랙 알림 전송 실패" + fi # =========================================== # Cleanup Job diff --git a/docs/features/operations.md b/docs/features/operations.md index fa92d37..4fdf484 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -204,6 +204,65 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. **중복 발송**이 생깁니다. 이 때문에 [마감 임박 알림](notification-and-retention.md#중복-방지--bluegreen-에서-드러난-결함)에 유니크 제약 기반 발송 이력을 넣었습니다. +### Blue/Green 을 뺀 이유 + +전환 마지막 단계가 라우터 HTTP API 두 개(`PRODUCTION_ROUTER_STATUS_URL`, `_SWITCH_URL`)를 +호출했는데, **그 API 를 제공하는 구현이 어디에도 없습니다.** + +별도 저장소의 블루/그린 도구(`CareCode_Nohub_Deploy`)는 `paramiko` 로 서버에 붙어 +nginx conf 를 고치는 CLI 입니다. 의존성이 `requests` / `python-dotenv` / `paramiko` 뿐이고 +웹 프레임워크가 없습니다. 즉 시크릿을 다 채워도 전환 단계에서 반드시 멈췄고, +그 시점에는 이미 서버의 컨테이너가 갈아치워진 뒤라 **어중간한 상태**로 끝났습니다. + +진짜 무중단은 nginx 를 제어할 수 있어야 성립합니다. 그때까지는 이렇게 갑니다. + +``` +새 이미지 pull + → 예비 포트(127.0.0.1:18082)에서 먼저 기동 운영 컨테이너는 그대로 + → /actuator/health 가 UP 이 될 때까지 대기 (최대 200초) + └ 실패하면 컨테이너 로그를 남기고 중단 운영은 건드리지 않음 + → 통과하면 교체 (여기서 짧은 순단) + → 다시 헬스체크 → 외부 URL 로 재확인 +``` + +**깨진 이미지가 운영에 올라가지 않는다**는 성질은 유지하면서, 순단만 감수합니다. +검증 포트를 18082 로 잡은 건 예전 blue/green 이 쓰던 8083 과 겹치지 않게 하기 위해서입니다. + +### 필요한 GitHub 시크릿 + +배포 잡은 시작하자마자 아래를 확인하고, 비어 있으면 **이름을 찍어서** 실패합니다. +예전에는 `test -n "..."` 하나뿐이라 무엇이 없는지 로그에 남지 않았습니다. + +| 시크릿 | 용도 | +|--------|------| +| `PRODUCTION_DEPLOY_HOST` / `PRODUCTION_DEPLOY_USER` | SSH 접속 대상 | +| `PRODUCTION_SSH_KEY` | SSH 개인키. **이 스텝이 없어서 시크릿을 채워도 인증에서 막혔습니다** | +| `PRODUCTION_HEALTH_URL` | 교체 후 외부에서 최종 확인 | + +네 개면 됩니다. 라우터 시크릿 4종(`_ROUTER_STATUS_URL`, `_ROUTER_SWITCH_URL`, +`_ROUTER_TOKEN`, `_TARGET_HEALTH_URL_TEMPLATE`)은 더 이상 쓰지 않습니다. + +스테이징은 `STAGING_` 접두사로 `DEPLOY_HOST` / `DEPLOY_USER` / `SSH_KEY` / `HEALTH_URL`. + +선택 시크릿: + +| 시크릿 | 없을 때 | +|--------|---------| +| `PRODUCTION_SSH_KNOWN_HOSTS` / `STAGING_SSH_KNOWN_HOSTS` | `ssh-keyscan` 으로 대체하고 경고를 남깁니다. 최초 접속을 그냥 믿는 건 같으므로, 중간자 공격을 막으려면 호스트키를 시크릿으로 고정하세요 | +| `OPS_SLACK_WEBHOOK_URL` | 잡 요약에만 남깁니다. 있으면 성공·실패를 슬랙으로 보냅니다 | + +레지스트리 로그인은 잡 토큰(`GITHUB_TOKEN`)을 **stdin 으로** 서버에 흘려보냅니다. +ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. + +### 서버 쪽 전제 + +- Docker 가 설치돼 있고 배포 사용자가 `docker` 를 실행할 수 있어야 합니다 +- `/opt/carecode/.env` 가 있어야 합니다. 없으면 배포가 그 자리에서 멈춥니다 +- 그 안에 `EMAIL_VERIFICATION_BASE_URL` 이 있어야 합니다. 없으면 애플리케이션이 + 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 +- 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 + `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 + ## 미해결 | 항목 | 내용 | 이슈 |