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
95 changes: 80 additions & 15 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,27 +205,46 @@ jobs:
uses: actions/checkout@v4

# 어떤 시크릿이 비어 있는지 먼저 이름으로 알려준다.
# 예전에는 `test -n "..."` 하나로 끝나서, 실패해도 무엇이 없는지 로그에 남지 않았다.
- name: Check required secrets
# 운영 쪽과 같은 규칙이다 — 전부 있으면 배포, 하나도 없으면 건너뜀, 일부만 있으면 실패.
# 자세한 이유는 deploy-production 의 같은 스텝에 적어 두었다.
- name: Check deploy secrets
id: 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: |
required="DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL"
missing=""
for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL; do
if [ -z "${!name}" ]; then missing="$missing STAGING_$name"; fi
present=""
for name in $required; do
if [ -z "${!name}" ]; then missing="$missing STAGING_$name"; else present="$present STAGING_$name"; fi
done
if [ -n "$missing" ]; then
echo "::error::스테이징 배포에 필요한 시크릿이 없습니다:$missing"

if [ -z "$missing" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
echo "스테이징 배포 시크릿이 모두 설정되어 있습니다."
exit 0
fi

if [ -n "$present" ]; then
echo "::error::스테이징 배포 시크릿이 일부만 설정되어 있습니다. 없는 것:$missing"
exit 1
fi
echo "필요한 시크릿이 모두 설정되어 있습니다."

echo "configured=false" >> "$GITHUB_OUTPUT"
{
echo "### 스테이징 배포를 건너뜀"
echo
echo "배포 시크릿이 설정되지 않았습니다. 설정 방법: \`docs/operations/deployment-secrets.md\`"
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::스테이징 배포 시크릿 미설정 — 배포를 건너뜁니다."

# SSH 개인키를 올리는 스텝이 아예 없어서, 시크릿을 채워도 인증 단계에서 막혔다.
# 키는 env 로 넘긴다. run 본문에 ${{ secrets.* }} 를 그대로 쓰면 값이 스크립트 텍스트에 박힌다.
- name: Set up SSH
if: steps.secrets.outputs.configured == 'true'
env:
SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
Expand All @@ -246,6 +265,7 @@ jobs:
chmod 600 ~/.ssh/known_hosts

- name: Deploy to staging environment
if: steps.secrets.outputs.configured == 'true'
env:
DEPLOY_HOST: ${{ secrets.STAGING_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.STAGING_DEPLOY_USER }}
Expand All @@ -255,6 +275,7 @@ jobs:
"docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -p 8082:8082 --env-file /opt/carecode/.env $IMAGE"

- name: Run health check
if: steps.secrets.outputs.configured == 'true'
env:
HEALTH_URL: ${{ secrets.STAGING_HEALTH_URL }}
run: |
Expand All @@ -276,6 +297,10 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }}
STATUS: ${{ job.status }}
run: |
if [ "${{ steps.secrets.outputs.configured }}" != "true" ]; then
echo "[staging] 배포 건너뜀 (시크릿 미설정)" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
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
Expand Down Expand Up @@ -308,28 +333,61 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

# 어떤 시크릿이 비어 있는지 먼저 이름으로 알려준다.
# 예전에는 각 스텝의 `test -n "..."` 하나로 끝나서, 실패해도 무엇이 없는지 로그에 남지 않았다.
- name: Check required secrets
# 시크릿 상태를 세 가지로 나눈다.
#
# 전부 있음 → 배포한다
# 하나도 없음 → 건너뛴다 (실패가 아니다)
# 일부만 있음 → 실패한다 (설정 사고다)
#
# "하나도 없음" 을 실패로 두면 배포 대상이 정해지기 전까지 main 의 모든 머지가 빨간불이 된다.
# 그 상태가 다섯 번 연속되면 빨간불이 정상처럼 보이고, 그때부터는 진짜 실패도 눈에 띄지 않는다
# (#94 가 적은 그대로다). 그래서 미설정은 잡 요약에 남기고 통과시킨다 — 설정이 없다는 사실은
# 조용히 지나가지 않고 모든 실행의 요약에 적힌다.
#
# 반대로 일부만 채워져 있으면 넘어가면 안 된다. 호스트는 있는데 키가 없는 식이면 누군가
# 설정하다 멈춘 것이고, 그건 조용히 건너뛸 일이 아니다.
- name: Check deploy secrets
id: 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: |
required="DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL"
missing=""
for name in DEPLOY_HOST DEPLOY_USER SSH_KEY HEALTH_URL; do
if [ -z "${!name}" ]; then missing="$missing PRODUCTION_$name"; fi
present=""
for name in $required; do
if [ -z "${!name}" ]; then missing="$missing PRODUCTION_$name"; else present="$present PRODUCTION_$name"; fi
done
if [ -n "$missing" ]; then
echo "::error::운영 배포에 필요한 시크릿이 없습니다:$missing"

if [ -z "$missing" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
echo "운영 배포 시크릿이 모두 설정되어 있습니다."
exit 0
fi

if [ -n "$present" ]; then
echo "::error::운영 배포 시크릿이 일부만 설정되어 있습니다. 없는 것:$missing"
echo "설정된 것:$present" >&2
exit 1
fi
echo "필요한 시크릿이 모두 설정되어 있습니다."

echo "configured=false" >> "$GITHUB_OUTPUT"
{
echo "### 운영 배포를 건너뜀"
echo
echo "배포 시크릿이 설정되지 않아 이번 커밋은 **운영에 반영되지 않았습니다.**"
echo "테스트·빌드·이미지는 정상입니다."
echo
echo "설정 방법: \`docs/operations/deployment-secrets.md\`"
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::운영 배포 시크릿 미설정 — 배포를 건너뜁니다. docs/operations/deployment-secrets.md 참고"

# SSH 개인키를 올리는 스텝이 아예 없어서, 시크릿을 채워도 인증 단계에서 막혔다.
# 키는 env 로 넘긴다. run 본문에 ${{ secrets.* }} 를 그대로 쓰면 값이 스크립트 텍스트에 박힌다.
- name: Set up SSH
if: steps.secrets.outputs.configured == 'true'
env:
SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
Expand All @@ -351,6 +409,7 @@ jobs:

# 토큰은 stdin 으로 흘려보낸다. ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보인다.
- name: Log in to registry on the server
if: steps.secrets.outputs.configured == 'true'
env:
DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
Expand All @@ -360,6 +419,7 @@ jobs:
"docker login ${{ env.REGISTRY }} -u '${{ github.actor }}' --password-stdin"

- name: Deploy
if: steps.secrets.outputs.configured == 'true'
env:
DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
Expand Down Expand Up @@ -447,6 +507,7 @@ jobs:
REMOTE

- name: Verify from outside
if: steps.secrets.outputs.configured == 'true'
env:
HEALTH_URL: ${{ secrets.PRODUCTION_HEALTH_URL }}
run: |
Expand All @@ -469,6 +530,10 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.OPS_SLACK_WEBHOOK_URL }}
STATUS: ${{ job.status }}
run: |
if [ "${{ steps.secrets.outputs.configured }}" != "true" ]; then
echo "[production] 배포 건너뜀 (시크릿 미설정)" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
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
Expand Down
4 changes: 4 additions & 0 deletions docs/features/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다.
Redis 는 운영에서 선택이 아닙니다 — 리프레시 토큰 폐기, 레이트 리밋, 이메일 인증코드가 여기에 있습니다.
준비 명령은 [1GB 인스턴스(프리티어)에 올리기](#1gb-인스턴스프리티어에-올리기) 에 있습니다

> **배포 시크릿이 없으면 배포는 일어나지 않습니다.** 지금 리포지토리에는 하나도 설정돼 있지
> 않아서 `main` 머지마다 운영 배포가 건너뛰어집니다(잡 요약에 그렇게 남습니다).
> 설정 방법은 [배포 시크릿 설정](../operations/deployment-secrets.md) 에 있습니다. 이슈 #94

## 1GB 인스턴스(프리티어)에 올리기

t2.micro·t3.micro 는 **메모리 1GB** 입니다. 여기서 앱·MariaDB·Redis 를 함께 돌릴 수 있는지
Expand Down
129 changes: 129 additions & 0 deletions docs/operations/deployment-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 배포 시크릿 설정

`main` 에 머지해도 운영에 반영되지 않는 상태를 끝내기 위한 문서입니다. (#94)

## 지금 상태

리포지토리 시크릿이 **하나도 설정돼 있지 않습니다**(확인: `gh secret list` → 0개).
그래서 `main` 머지마다 `Deploy to Production` 이 첫 스텝에서 멈췄습니다.

```
success Test and Build
success Security Scan
success Build Docker Image
failure Deploy to Production ← Check required secrets
```

#143~#147 까지 **다섯 번 연속** 이 모양이었습니다. 컨테이너를 띄우기 전에 멈추므로 운영
인스턴스를 건드리지는 않지만, 문제는 그 반대입니다 — **아무것도 배포되지 않는데 매번
빨간불로 끝나서 그 빨간불이 정상처럼 보입니다.**

그래서 워크플로를 먼저 고쳤습니다. 시크릿 상태를 세 가지로 구분합니다.

| 상태 | 결과 |
|------|------|
| 전부 설정됨 | 배포한다 |
| **하나도 없음** | **건너뛴다.** 실패가 아니고, 잡 요약에 "운영 배포를 건너뜀" 이 남는다 |
| 일부만 있음 | **실패한다.** 누군가 설정하다 멈춘 것이므로 조용히 넘어가면 안 된다 |

이제 미설정 상태에서도 파이프라인은 초록불이고, 배포가 안 됐다는 사실은 **모든 실행의
요약에 적힙니다.** 빨간불을 무시하게 되는 것보다 낫습니다.

## 설정할 것

### 필수 4개

| 이름 | 값 | 예 |
|------|-----|-----|
| `PRODUCTION_DEPLOY_HOST` | 배포 대상 서버 주소 | `43.200.1.2` 또는 `api.carecode.kr` |
| `PRODUCTION_DEPLOY_USER` | SSH 계정 | `ubuntu` |
| `PRODUCTION_SSH_KEY` | **SSH 개인키 전문** (`-----BEGIN ...` 부터 끝까지) | 파일에서 읽어 넣는다 |
| `PRODUCTION_HEALTH_URL` | 외부에서 보이는 주소. 워크플로가 뒤에 `/actuator/health` 를 붙인다 | `https://api.carecode.kr` |

### 권장 2개

| 이름 | 없으면 | 왜 넣는 게 좋은가 |
|------|--------|-------------------|
| `PRODUCTION_SSH_KNOWN_HOSTS` | `ssh-keyscan` 으로 대체하고 경고를 남긴다 | keyscan 은 최초 접속을 그냥 믿는다. 호스트키를 고정해야 중간자 공격을 막는다 |
| `OPS_SLACK_WEBHOOK_URL` | 잡 요약에만 남는다 | 배포 실패와 운영 알림이 사람에게 닿는다 |

### 넣는 방법

개인키를 **채팅·이슈·PR 에 붙여넣지 마세요.** 아래처럼 파일에서 바로 읽습니다.

```bash
cd CareCode_Interface

gh secret set PRODUCTION_DEPLOY_HOST --body '배포서버주소'
gh secret set PRODUCTION_DEPLOY_USER --body 'ubuntu'
gh secret set PRODUCTION_HEALTH_URL --body 'https://배포서버주소'

# 개인키: 값을 터미널에 찍지 않고 파일에서 읽는다
gh secret set PRODUCTION_SSH_KEY < ~/.ssh/carecode_deploy

# 호스트키 고정 (서버가 떠 있어야 한다)
ssh-keyscan -H 배포서버주소 | gh secret set PRODUCTION_SSH_KNOWN_HOSTS
```

확인:

```bash
gh secret list # 이름만 보인다. 값은 다시 볼 수 없다
```

## 배포용 키를 새로 만드는 경우

로그인용 키를 그대로 쓰지 말고 배포 전용 키를 따로 만듭니다. 유출 시 그 키만 폐기하면 됩니다.

```bash
# 로컬에서
ssh-keygen -t ed25519 -f ~/.ssh/carecode_deploy -C 'github-actions-deploy' -N ''

# 공개키를 서버에 등록
ssh-copy-id -i ~/.ssh/carecode_deploy.pub ubuntu@배포서버주소
```

서버에서 이 키의 권한을 좁히려면 `~/.ssh/authorized_keys` 의 해당 줄 앞에 제약을 붙입니다.

```
restrict,pty,command="..." ssh-ed25519 AAAA...
```

다만 지금 워크플로는 `docker login`·`docker run`·`curl` 등 여러 명령을 실행하므로
`command=` 로 하나만 묶을 수 없습니다. 묶으려면 서버에 배포 스크립트를 두고 그것만
허용하는 쪽으로 바꿔야 합니다 — 지금은 범위를 좁히지 못한 상태입니다.

## 서버에 미리 있어야 하는 것

워크플로는 서버에서 `docker` 를 직접 부릅니다.

```bash
# 서버에서 한 번
docker --version # 없으면 설치
sudo usermod -aG docker ubuntu # sudo 없이 docker 를 쓸 수 있어야 한다 (재로그인 필요)
free -h # 스왑 확인 — 1GB 인스턴스는 스왑 2GB 가 필요하다
```

스왑이 없으면 기동 중 OOM 으로 죽습니다. 설정 명령과 근거(메모리 실측 포함)는
[`docs/features/operations.md` 의 "1GB 인스턴스(프리티어)에 올리기"](../features/operations.md#1gb-인스턴스프리티어에-올리기)
를 보세요.

## 첫 배포에서 확인할 것

시크릿을 넣은 뒤 `main` 에 머지하거나 `workflow_dispatch` 로 돌립니다. 순서는 이렇습니다.

1. `Check deploy secrets` — 초록. 여기서 "일부만" 이 나오면 빠진 이름이 로그에 찍힙니다
2. `Set up SSH` — 실패하면 키·계정 문제입니다
3. `Deploy` — 새 이미지를 예비 포트에서 먼저 띄워 헬스체크하고, 통과할 때만 교체합니다.
실패하면 **이전 이미지 ID 로 되돌립니다**(태그가 아니라 ID 로 되돌리므로 같은 태그가
덮어써졌어도 안전합니다)
4. `Verify from outside` — 서버 안에서는 UP 인데 밖에서 안 보이면 리버스 프록시나
보안그룹 문제입니다. 메시지가 그렇게 구분해 알려줍니다

## 아직 남은 것

- **무중단은 아닙니다.** 교체 순간에 짧은 순단이 있습니다. 진짜 무중단은 nginx 를 제어할 수
있어야 성립하고, 그 도구가 아직 없습니다. 깨진 이미지가 올라가는 일은 막습니다
- **SSH 키 권한을 좁히지 못했습니다.** 위 `command=` 항목 참고
- 운영 DB·Redis·메일·카카오 등 **애플리케이션 환경 변수**는 이 문서의 범위가 아닙니다.
서버의 `.env` 로 넣고 있으며, 그쪽을 시크릿으로 옮기는 일은 따로 다뤄야 합니다
Loading