From 6e5e941ca7e5edd748f1c6d28443355c20b3b660 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Mon, 24 Aug 2026 11:07:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?FIX=20:=20=EB=B0=B0=ED=8F=AC=20=EC=9E=A1?= =?UTF-8?q?=EC=97=90=20SSH=20=ED=82=A4=20=EB=A1=9C=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 머지해도 배포가 되지 않는 상태였다. 저장소 쪽에서 고칠 수 있는 부분을 정리한다. (시크릿 값 자체는 저장소 밖이라 #94 에 남겨둔다.) 1) SSH 개인키를 올리는 스텝이 아예 없었다. ssh -o StrictHostKeyChecking=no 로 바로 접속하고 있어서, 시크릿을 채워도 인증 단계에서 막힌다. 키를 env 로 받아 ~/.ssh 에 쓰고 -i 로 지정한다. 호스트키는 *_SSH_KNOWN_HOSTS 가 있으면 고정하고, 없으면 keyscan 으로 대체하되 경고를 남긴다. 2) 시크릿이 없을 때 무엇이 없는지 알 수 없었다. 각 스텝의 test -n "..." 는 실패 이유를 남기지 않는다. 잡 시작 시점에 필요한 이름을 전부 확인하고, 빠진 것을 이름으로 찍어 실패시킨다. 3) 롤백이 트래픽 전환 전에도 돌았다. 조건이 failure() 뿐이라, 배포·헬스체크에서 죽어 트래픽이 움직인 적도 없는데 롤백을 시도했고 그 스텝마저 실패해 로그에 실패가 두 번 찍혔다. steps.switch.outcome == 'success' 를 함께 본다. 4) 알림이 echo 뿐이라 배포 실패가 아무에게도 닿지 않았다. 잡 요약에 남기고, 실패는 ::error:: 로 올린다. OPS_SLACK_WEBHOOK_URL 이 있으면 슬랙으로도 보낸다. 5) 시크릿을 run 본문에 ${{ secrets.* }} 로 직접 박아 쓰던 것을 env 로 옮겼다. 값이 스크립트 텍스트에 들어가면 셸 메타문자에 취약하다. 6) 전역 env 의 헬스 URL 을 제거했다. 배포와 무관한 test/scan 잡 환경에까지 값이 실릴 이유가 없다. 필요한 스텝만 시크릿을 받는다. 모든 run 블록을 bash -n 으로 문법 검사했다. --- .github/workflows/ci-cd.yml | 220 +++++++++++++++++++++++++++++------- docs/features/operations.md | 34 ++++++ 2 files changed, 212 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 9ee29ec..e3dec2f 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,35 +199,91 @@ 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 @@ -238,17 +294,65 @@ jobs: needs: build-docker if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production') environment: production - + steps: - name: Checkout code uses: actions/checkout@v4 - + + # 어떤 시크릿이 비어 있는지 먼저 이름으로 알려준다. + # 예전에는 각 스텝의 `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 }} + ROUTER_STATUS_URL: ${{ secrets.PRODUCTION_ROUTER_STATUS_URL }} + ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} + ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} + TARGET_HEALTH_URL_TEMPLATE: ${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }} + run: | + missing="" + for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL \ + ROUTER_STATUS_URL ROUTER_SWITCH_URL ROUTER_TOKEN TARGET_HEALTH_URL_TEMPLATE; 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: | + 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 도 최초 접속을 그냥 믿는 건 같지만, + # 최소한 이번 실행 안에서는 호스트키가 고정된다. 중간자 공격까지 막으려면 + # 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 + # blue/green 두 컨테이너는 서로 다른 포트를 점유해야 한다. # 둘 다 8082 를 쓰면 동시에 뜰 수 없어 무중단 전환 자체가 성립하지 않는다. - name: Determine deployment strategy id: strategy + env: + ROUTER_STATUS_URL: ${{ secrets.PRODUCTION_ROUTER_STATUS_URL }} run: | - CURRENT_ENV=$(curl -fsS "${{ secrets.PRODUCTION_ROUTER_STATUS_URL }}" || echo "blue") + CURRENT_ENV=$(curl -fsS "$ROUTER_STATUS_URL" || echo "blue") if [ "$CURRENT_ENV" = "blue" ]; then echo "target-env=green" >> $GITHUB_OUTPUT echo "current-env=blue" >> $GITHUB_OUTPUT @@ -258,25 +362,30 @@ jobs: echo "current-env=green" >> $GITHUB_OUTPUT echo "target-port=8082" >> $GITHUB_OUTPUT fi + echo "현재 $CURRENT_ENV → 대상 $([ "$CURRENT_ENV" = "blue" ] && echo green || echo blue)" - name: Deploy to target environment + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + IMAGE: ${{ needs.build-docker.outputs.image-tag }} + TARGET: ${{ steps.strategy.outputs.target-env }} + PORT: ${{ steps.strategy.outputs.target-port }} 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 }} \ + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker pull $IMAGE \ && (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 }}" + && docker run -d --name carecode-$TARGET -p $PORT:8082 --env-file /opt/carecode/.env $IMAGE" # 전환 전에는 아직 라우팅되지 않는 대기(target) 인스턴스를 직접 확인해야 한다. # 공용 헬스 URL 을 보면 구버전이 UP 이라 항상 통과해버린다. - name: Wait for target instance to be ready + env: + TARGET_HEALTH_URL_TEMPLATE: ${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }} + PORT: ${{ steps.strategy.outputs.target-port }} 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/") + TARGET_HEALTH_URL=$(echo "$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" @@ -284,34 +393,61 @@ jobs: fi sleep 5 done - echo "Production target health check failed" + echo "::error::대상 인스턴스($PORT)가 뜨지 않았습니다. 트래픽은 전환하지 않습니다." exit 1 - + + # id 를 붙이는 이유는 아래 롤백이 "전환이 실제로 일어났을 때만" 돌게 하기 위해서다. - name: Switch traffic to new environment + id: switch + env: + ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} + ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} + TARGET: ${{ steps.strategy.outputs.target-env }} run: | - test -n "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" - curl -fsS -X POST "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" \ - -H "Authorization: Bearer ${{ secrets.PRODUCTION_ROUTER_TOKEN }}" \ + curl -fsS -X POST "$ROUTER_SWITCH_URL" \ + -H "Authorization: Bearer $ROUTER_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"target\":\"${{ steps.strategy.outputs.target-env }}\"}" - + -d "$(jq -n --arg t "$TARGET" '{target:$t}')" + - name: Verify deployment + env: + HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} run: | - curl -fsS "$PRODUCTION_HEALTH_URL/actuator/health" | grep -q '"status":"UP"' - + curl -fsS "$HEALTH_URL/actuator/health" | grep -q '"status":"UP"' + + # 전환이 성공한 뒤에 실패했을 때만 되돌린다. + # 예전에는 조건이 failure() 뿐이라, 배포·헬스체크 단계에서 죽어 트래픽이 움직인 적도 없는데 + # 롤백을 시도했고, 그 스텝마저 실패해 로그에 실패가 두 번 찍혔다. - name: Rollback if needed - if: failure() + if: failure() && steps.switch.outcome == 'success' + env: + ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} + ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} + CURRENT: ${{ steps.strategy.outputs.current-env }} run: | - curl -fsS -X POST "${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }}" \ - -H "Authorization: Bearer ${{ secrets.PRODUCTION_ROUTER_TOKEN }}" \ + echo "::warning::전환 후 검증에 실패했습니다. $CURRENT 로 되돌립니다." + curl -fsS -X POST "$ROUTER_SWITCH_URL" \ + -H "Authorization: Bearer $ROUTER_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"target\":\"${{ steps.strategy.outputs.current-env }}\"}" - + -d "$(jq -n --arg t "$CURRENT" '{target:$t}')" + + # 예전에는 echo 만 있어서 배포가 실패해도 아무에게도 닿지 않았다. + # OPS_SLACK_WEBHOOK_URL 이 없으면 잡 요약에만 남긴다. - name: Notify deployment status if: always() + env: + SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }} + STATUS: ${{ job.status }} + TARGET: ${{ steps.strategy.outputs.target-env }} run: | - echo "Production deployment completed" - # 슬랙, 이메일 등 알림 추가 + line="[production] 배포 $STATUS - ${{ github.repository }}@${GITHUB_SHA:0:7} → ${TARGET:-?} (${{ 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..f6075b5 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -204,6 +204,40 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. **중복 발송**이 생깁니다. 이 때문에 [마감 임박 알림](notification-and-retention.md#중복-방지--bluegreen-에서-드러난-결함)에 유니크 제약 기반 발송 이력을 넣었습니다. +### 필요한 GitHub 시크릿 + +배포 잡은 시작하자마자 아래를 확인하고, 비어 있으면 **이름을 찍어서** 실패합니다. +예전에는 `test -n "..."` 하나뿐이라 무엇이 없는지 로그에 남지 않았습니다. + +| 시크릿 | 용도 | +|--------|------| +| `PRODUCTION_DEPLOY_HOST` / `PRODUCTION_DEPLOY_USER` | SSH 접속 대상 | +| `PRODUCTION_SSH_KEY` | SSH 개인키. **이 스텝이 없어서 시크릿을 채워도 인증에서 막혔습니다** | +| `PRODUCTION_HEALTH_URL` | 전환 후 최종 확인 | +| `PRODUCTION_TARGET_HEALTH_URL_TEMPLATE` | 전환 **전** 대기 인스턴스 확인. `{port}` 를 포함해야 합니다 | +| `PRODUCTION_ROUTER_STATUS_URL` | 현재 활성 색(blue/green) 조회 | +| `PRODUCTION_ROUTER_SWITCH_URL` / `PRODUCTION_ROUTER_TOKEN` | 트래픽 전환 | + +스테이징은 `STAGING_` 접두사로 `DEPLOY_HOST` / `DEPLOY_USER` / `SSH_KEY` / `HEALTH_URL` 네 개입니다. + +선택 시크릿: + +| 시크릿 | 없을 때 | +|--------|---------| +| `PRODUCTION_SSH_KNOWN_HOSTS` / `STAGING_SSH_KNOWN_HOSTS` | `ssh-keyscan` 으로 대체하고 경고를 남깁니다. 최초 접속을 그냥 믿는 건 같으므로, 중간자 공격을 막으려면 호스트키를 시크릿으로 고정하세요 | +| `OPS_SLACK_WEBHOOK_URL` | 잡 요약에만 남깁니다. 있으면 성공·실패를 슬랙으로 보냅니다 | + +서버의 `/opt/carecode/.env` 에는 `EMAIL_VERIFICATION_BASE_URL` 이 있어야 합니다. +없으면 기동 단계에서 실패합니다(의도된 fail-fast). 자세한 내용은 이슈 #90. + +### 롤백이 도는 조건 + +`Switch traffic` 이 성공한 뒤에 실패했을 때만 되돌립니다. + +예전에는 조건이 `failure()` 뿐이어서, 배포나 헬스체크 단계에서 죽어 **트래픽이 움직인 적도 없는데** +롤백을 시도했고 그 스텝마저 실패해 로그에 실패가 두 번 찍혔습니다. +지금은 `steps.switch.outcome == 'success'` 를 함께 봅니다. + ## 미해결 | 항목 | 내용 | 이슈 | From fe80da6edb145912a36c4631e9f39802e183f735 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Tue, 25 Aug 2026 03:08:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?FIX=20:=20=EC=A1=B4=EC=9E=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?API=20=EC=9D=98=EC=A1=B4=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4?= =?UTF-8?q?=EA=B3=A0=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=8F=84=EB=8A=94=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blue/Green 전환의 마지막 단계가 라우터 HTTP API 두 개를 호출했다. PRODUCTION_ROUTER_STATUS_URL 현재 활성 색 조회 PRODUCTION_ROUTER_SWITCH_URL 트래픽 전환 이 API 를 제공하는 구현이 조직 어디에도 없다. 별도 저장소의 블루/그린 도구 (CareCode_Nohub_Deploy)는 paramiko 로 서버에 붙어 nginx conf 를 고치는 CLI 이고 의존성이 requests/python-dotenv/paramiko 뿐이다. 웹 서버가 아니다. 그래서 시크릿을 다 채워도 전환 단계에서 반드시 멈췄다. 게다가 그 시점에는 이미 Deploy 단계가 서버의 컨테이너를 갈아치운 뒤라, 새 컨테이너는 떠 있는데 트래픽은 넘어가지 않은 어중간한 상태로 끝났다. 무중단은 nginx 를 제어할 수 있어야 성립한다. 그때까지는 검증 후 교체로 간다. 새 이미지 pull → 예비 포트(127.0.0.1:18082)에서 먼저 기동 운영 컨테이너는 그대로 → 헬스체크. 실패하면 로그를 남기고 중단 운영은 건드리지 않음 → 통과하면 교체 (짧은 순단) → 재확인 → 외부 URL 로 최종 확인 깨진 이미지가 운영에 올라가지 않는다는 성질은 유지하고 순단만 감수한다. 검증 포트는 예전 blue/green 의 8083 과 겹치지 않게 18082 로 잡았다. 함께 정리한 것 - 레지스트리 로그인을 추가했다. 토큰은 stdin 으로 넘긴다. ssh 인자로 주면 서버의 프로세스 목록에 그대로 보인다. - 필요한 시크릿이 8개에서 4개로 줄었다. HOST / USER / SSH_KEY / HEALTH_URL 이면 된다. - 컨테이너에 --restart unless-stopped 를 붙였다. 서버가 재부팅돼도 살아난다. - 예전 워크플로가 만들던 carecode-blue / carecode-green 이 남아 포트를 잡고 있으면 교체가 실패하므로 함께 정리한다. - 외부 확인 실패는 메시지를 따로 준다. 서버 안에서는 UP 인데 밖에서 안 보이면 프록시나 보안그룹 문제지 애플리케이션 문제가 아니다. 라우터 방식으로 돌아가려면 전환 API 를 실제로 만든 뒤에 되돌리면 된다. 워크플로 run 블록 16개와 원격 스크립트를 bash -n 으로 문법 검사했다. --- .github/workflows/ci-cd.yml | 170 ++++++++++++++++++++---------------- docs/features/operations.md | 51 ++++++++--- 2 files changed, 131 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index e3dec2f..fbb18ae 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -289,12 +289,21 @@ jobs: # 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 @@ -307,14 +316,9 @@ jobs: DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} - ROUTER_STATUS_URL: ${{ secrets.PRODUCTION_ROUTER_STATUS_URL }} - ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} - ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} - TARGET_HEALTH_URL_TEMPLATE: ${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }} run: | missing="" - for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL \ - ROUTER_STATUS_URL ROUTER_SWITCH_URL ROUTER_TOKEN TARGET_HEALTH_URL_TEMPLATE; do + 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 @@ -345,91 +349,104 @@ jobs: fi chmod 600 ~/.ssh/known_hosts - # blue/green 두 컨테이너는 서로 다른 포트를 점유해야 한다. - # 둘 다 8082 를 쓰면 동시에 뜰 수 없어 무중단 전환 자체가 성립하지 않는다. - - name: Determine deployment strategy - id: strategy + # 토큰은 stdin 으로 흘려보낸다. ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보인다. + - name: Log in to registry on the server env: - ROUTER_STATUS_URL: ${{ secrets.PRODUCTION_ROUTER_STATUS_URL }} + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CURRENT_ENV=$(curl -fsS "$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 - else - echo "target-env=blue" >> $GITHUB_OUTPUT - echo "current-env=green" >> $GITHUB_OUTPUT - echo "target-port=8082" >> $GITHUB_OUTPUT - fi - echo "현재 $CURRENT_ENV → 대상 $([ "$CURRENT_ENV" = "blue" ] && echo green || echo blue)" + printf '%s' "$GHCR_TOKEN" | ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker login ${{ env.REGISTRY }} -u '${{ github.actor }}' --password-stdin" - - name: Deploy to target environment + - name: Deploy env: DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} IMAGE: ${{ needs.build-docker.outputs.image-tag }} - TARGET: ${{ steps.strategy.outputs.target-env }} - PORT: ${{ steps.strategy.outputs.target-port }} run: | - ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ - "docker pull $IMAGE \ - && (docker stop carecode-$TARGET || true) \ - && (docker rm carecode-$TARGET || true) \ - && docker run -d --name carecode-$TARGET -p $PORT:8082 --env-file /opt/carecode/.env $IMAGE" - - # 전환 전에는 아직 라우팅되지 않는 대기(target) 인스턴스를 직접 확인해야 한다. - # 공용 헬스 URL 을 보면 구버전이 UP 이라 항상 통과해버린다. - - name: Wait for target instance to be ready - env: - TARGET_HEALTH_URL_TEMPLATE: ${{ secrets.PRODUCTION_TARGET_HEALTH_URL_TEMPLATE }} - PORT: ${{ steps.strategy.outputs.target-port }} - run: | - TARGET_HEALTH_URL=$(echo "$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 "::error::대상 인스턴스($PORT)가 뜨지 않았습니다. 트래픽은 전환하지 않습니다." - exit 1 - # id 를 붙이는 이유는 아래 롤백이 "전환이 실제로 일어났을 때만" 돌게 하기 위해서다. - - name: Switch traffic to new environment - id: switch - env: - ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} - ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} - TARGET: ${{ steps.strategy.outputs.target-env }} - run: | - curl -fsS -X POST "$ROUTER_SWITCH_URL" \ - -H "Authorization: Bearer $ROUTER_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg t "$TARGET" '{target:$t}')" + echo "교체 후 기동에 실패했습니다." + echo "----- 컨테이너 로그 (마지막 100줄) -----" + docker logs --tail 100 "$APP" 2>&1 || true + exit 1 + REMOTE - - name: Verify deployment + - name: Verify from outside env: HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }} run: | - curl -fsS "$HEALTH_URL/actuator/health" | grep -q '"status":"UP"' - - # 전환이 성공한 뒤에 실패했을 때만 되돌린다. - # 예전에는 조건이 failure() 뿐이라, 배포·헬스체크 단계에서 죽어 트래픽이 움직인 적도 없는데 - # 롤백을 시도했고, 그 스텝마저 실패해 로그에 실패가 두 번 찍혔다. - - name: Rollback if needed - if: failure() && steps.switch.outcome == 'success' - env: - ROUTER_SWITCH_URL: ${{ secrets.PRODUCTION_ROUTER_SWITCH_URL }} - ROUTER_TOKEN: ${{ secrets.PRODUCTION_ROUTER_TOKEN }} - CURRENT: ${{ steps.strategy.outputs.current-env }} - run: | - echo "::warning::전환 후 검증에 실패했습니다. $CURRENT 로 되돌립니다." - curl -fsS -X POST "$ROUTER_SWITCH_URL" \ - -H "Authorization: Bearer $ROUTER_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg t "$CURRENT" '{target:$t}')" + # 서버 안에서는 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 이 없으면 잡 요약에만 남긴다. @@ -438,9 +455,8 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }} STATUS: ${{ job.status }} - TARGET: ${{ steps.strategy.outputs.target-env }} run: | - line="[production] 배포 $STATUS - ${{ github.repository }}@${GITHUB_SHA:0:7} → ${TARGET:-?} (${{ github.run_id }})" + 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 diff --git a/docs/features/operations.md b/docs/features/operations.md index f6075b5..4fdf484 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -204,6 +204,30 @@ 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 시크릿 배포 잡은 시작하자마자 아래를 확인하고, 비어 있으면 **이름을 찍어서** 실패합니다. @@ -213,12 +237,12 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. |--------|------| | `PRODUCTION_DEPLOY_HOST` / `PRODUCTION_DEPLOY_USER` | SSH 접속 대상 | | `PRODUCTION_SSH_KEY` | SSH 개인키. **이 스텝이 없어서 시크릿을 채워도 인증에서 막혔습니다** | -| `PRODUCTION_HEALTH_URL` | 전환 후 최종 확인 | -| `PRODUCTION_TARGET_HEALTH_URL_TEMPLATE` | 전환 **전** 대기 인스턴스 확인. `{port}` 를 포함해야 합니다 | -| `PRODUCTION_ROUTER_STATUS_URL` | 현재 활성 색(blue/green) 조회 | -| `PRODUCTION_ROUTER_SWITCH_URL` / `PRODUCTION_ROUTER_TOKEN` | 트래픽 전환 | +| `PRODUCTION_HEALTH_URL` | 교체 후 외부에서 최종 확인 | -스테이징은 `STAGING_` 접두사로 `DEPLOY_HOST` / `DEPLOY_USER` / `SSH_KEY` / `HEALTH_URL` 네 개입니다. +네 개면 됩니다. 라우터 시크릿 4종(`_ROUTER_STATUS_URL`, `_ROUTER_SWITCH_URL`, +`_ROUTER_TOKEN`, `_TARGET_HEALTH_URL_TEMPLATE`)은 더 이상 쓰지 않습니다. + +스테이징은 `STAGING_` 접두사로 `DEPLOY_HOST` / `DEPLOY_USER` / `SSH_KEY` / `HEALTH_URL`. 선택 시크릿: @@ -227,16 +251,17 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. | `PRODUCTION_SSH_KNOWN_HOSTS` / `STAGING_SSH_KNOWN_HOSTS` | `ssh-keyscan` 으로 대체하고 경고를 남깁니다. 최초 접속을 그냥 믿는 건 같으므로, 중간자 공격을 막으려면 호스트키를 시크릿으로 고정하세요 | | `OPS_SLACK_WEBHOOK_URL` | 잡 요약에만 남깁니다. 있으면 성공·실패를 슬랙으로 보냅니다 | -서버의 `/opt/carecode/.env` 에는 `EMAIL_VERIFICATION_BASE_URL` 이 있어야 합니다. -없으면 기동 단계에서 실패합니다(의도된 fail-fast). 자세한 내용은 이슈 #90. - -### 롤백이 도는 조건 +레지스트리 로그인은 잡 토큰(`GITHUB_TOKEN`)을 **stdin 으로** 서버에 흘려보냅니다. +ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. -`Switch traffic` 이 성공한 뒤에 실패했을 때만 되돌립니다. +### 서버 쪽 전제 -예전에는 조건이 `failure()` 뿐이어서, 배포나 헬스체크 단계에서 죽어 **트래픽이 움직인 적도 없는데** -롤백을 시도했고 그 스텝마저 실패해 로그에 실패가 두 번 찍혔습니다. -지금은 `steps.switch.outcome == 'success'` 를 함께 봅니다. +- Docker 가 설치돼 있고 배포 사용자가 `docker` 를 실행할 수 있어야 합니다 +- `/opt/carecode/.env` 가 있어야 합니다. 없으면 배포가 그 자리에서 멈춥니다 +- 그 안에 `EMAIL_VERIFICATION_BASE_URL` 이 있어야 합니다. 없으면 애플리케이션이 + 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 +- 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 + `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 ## 미해결