GitHub Actions CI/CD: 실제로 작동하는 무중단 배포
완전한 GitHub Actions 설정: 병렬 테스트 작업, Docker 빌드 캐싱, VPS SSH 배포, PM2 reload 무중단 배포, 시크릿 관리, 2년간 다듬어온 워크플로우 패턴.
제가 작업한 모든 프로젝트는 결국 같은 변곡점에 도달합니다: 배포 프로세스가 수동으로 하기에 너무 고통스러워지는 것입니다. 테스트 실행을 잊어버립니다. 로컬에서 빌드하지만 버전 범프를 잊어버립니다. 프로덕션에 SSH로 접속했더니 마지막으로 배포한 사람이 오래된 .env 파일을 남겨둔 것을 발견합니다.
GitHub Actions가 2년 전에 이 문제를 해결해 줬습니다. 첫날부터 완벽하지는 않았습니다 — 처음 작성한 워크플로우는 절반의 시간 동안 타임아웃되고 아무것도 캐시하지 않는 200줄짜리 YAML 악몽이었습니다. 하지만 반복 작업을 거듭하며, 이 사이트를 4분 이내에 무중단으로 안정적으로 배포하는 무언가에 도달했습니다.
이것이 그 워크플로우입니다, 섹션별로 설명합니다. 문서 버전이 아닙니다. 프로덕션과의 접촉에서 살아남는 버전입니다.
빌딩 블록 이해하기#
전체 파이프라인에 들어가기 전에, GitHub Actions가 어떻게 작동하는지에 대한 명확한 멘탈 모델이 필요합니다. Jenkins나 CircleCI를 사용해 본 적이 있다면, 알고 있는 것의 대부분을 잊으세요. 개념은 느슨하게 매핑되지만, 실행 모델이 충분히 달라서 혼란을 줄 수 있습니다.
트리거: 워크플로우가 실행되는 시점#
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # 매주 월요일 오전 6시 UTC
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production네 가지 트리거, 각각 다른 목적을 가지고 있습니다:
- **
push**를main에 하면 프로덕션 배포 트리거입니다. 코드가 머지되었나요? 배포하세요. - **
pull_request**는 모든 PR에서 CI 검사를 실행합니다. 여기에 린트, 타입 체크, 테스트가 있습니다. - **
schedule**은 저장소를 위한 cron입니다. 주간 의존성 감사 스캔과 오래된 캐시 정리에 사용합니다. - **
workflow_dispatch**는 입력 파라미터가 있는 수동 "배포" 버튼을 GitHub UI에 제공합니다. 코드 변경 없이 스테이징을 배포해야 할 때 — 환경 변수를 업데이트했거나 베이스 Docker 이미지를 다시 가져와야 할 때 — 매우 유용합니다.
사람들을 당황하게 하는 한 가지: pull_request는 PR 브랜치 HEAD가 아닌 머지 커밋에 대해 실행됩니다. 이는 CI가 머지 후 코드가 어떻게 보일지 테스트한다는 의미입니다. 이것은 실제로 원하는 동작이지만, 그린 브랜치가 리베이스 후에 레드로 변할 때 사람들을 놀라게 합니다.
잡, 스텝, 그리고 러너#
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint잡은 기본적으로 병렬로 실행됩니다. 각 잡은 새로운 VM("러너")을 받습니다. ubuntu-latest는 상당히 좋은 머신을 제공합니다 — 2026년 기준 4 vCPU, 16 GB RAM. 퍼블릭 저장소는 무료이고, 프라이빗은 월 2000분입니다.
스텝은 잡 내에서 순차적으로 실행됩니다. 각 uses: 스텝은 마켓플레이스에서 재사용 가능한 액션을 가져옵니다. 각 run: 스텝은 셸 명령을 실행합니다.
--frozen-lockfile 플래그는 중요합니다. 이것 없이는 pnpm install이 CI 중에 lockfile을 업데이트할 수 있으며, 이는 개발자가 커밋한 것과 같은 의존성을 테스트하지 않는다는 의미입니다. 개발자의 머신에서는 lockfile이 이미 올바르기 때문에 로컬에서는 사라지는 유령 테스트 실패가 발생하는 것을 본 적이 있습니다.
환경 변수 vs 시크릿#
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > key.pem
chmod 600 key.pem
ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"워크플로우 수준에서 env:로 설정한 환경 변수는 일반 텍스트이며 로그에 표시됩니다. 민감하지 않은 설정에 사용하세요: NODE_ENV, 텔레메트리 플래그, 기능 토글.
시크릿(${{ secrets.X }})은 저장 시 암호화되고, 로그에서 마스킹되며, 같은 저장소의 워크플로우에서만 사용 가능합니다. Settings > Secrets and variables > Actions에서 설정합니다.
environment: production 줄은 중요합니다. GitHub Environments는 시크릿을 특정 배포 대상에 범위를 지정할 수 있게 합니다. 스테이징 SSH 키와 프로덕션 SSH 키 모두 SSH_PRIVATE_KEY라는 이름을 가질 수 있지만 잡이 대상으로 하는 환경에 따라 다른 값을 갖습니다. 이것은 또한 필수 검토자를 활성화합니다 — 프로덕션 배포를 수동 승인 뒤에 게이트할 수 있습니다.
전체 CI 파이프라인#
CI 파이프라인의 구조는 다음과 같습니다. 목표: 가능한 가장 빠른 시간에 모든 카테고리의 오류를 잡는 것.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1이 구조의 이유#
린트, 타입체크, 테스트가 병렬로 실행됩니다. 서로 의존성이 없습니다. 타입 에러가 린트 실행을 차단하지 않고, 실패한 테스트가 타입 체커를 기다릴 필요가 없습니다. 일반적인 실행에서 세 가지 모두 동시에 실행되면서 30-60초 안에 완료됩니다.
빌드는 세 가지 모두를 기다립니다. needs: [lint, typecheck, test] 줄은 린트, 타입체크, 그리고 테스트가 모두 통과해야만 빌드 잡이 시작된다는 의미입니다. 린트 에러나 타입 실패가 있는 프로젝트를 빌드할 의미가 없습니다.
**concurrency와 cancel-in-progress: true**는 시간을 크게 절약합니다. 빠르게 연속으로 두 커밋을 푸시하면, 첫 번째 CI 실행이 취소됩니다. 이것 없이는 오래된 실행이 분 예산을 소비하고 체크 UI를 어지럽힙니다.
if: always()로 커버리지 업로드는 테스트가 실패해도 커버리지 리포트를 얻는다는 의미입니다. 디버깅에 유용합니다 — 어떤 테스트가 실패했고 무엇을 커버했는지 볼 수 있습니다.
빠른 실패 vs. 모두 실행#
기본적으로 매트릭스의 한 잡이 실패하면, GitHub는 나머지를 취소합니다. CI에서는 실제로 이 동작을 원합니다 — 린트가 실패하면, 테스트 결과에는 관심이 없습니다. 린트를 먼저 고치세요.
하지만 테스트 매트릭스(예를 들어 Node 20과 Node 22에 걸쳐 테스트)에서는 모든 실패를 한 번에 보고 싶을 수 있습니다:
test:
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm testfail-fast: false는 두 매트릭스 레그 모두 완료되도록 합니다. Node 22가 실패하지만 Node 20이 통과하면, 다시 실행하지 않고도 그 정보를 즉시 볼 수 있습니다.
속도를 위한 캐싱#
CI 속도에 대해 할 수 있는 가장 큰 개선은 캐싱입니다. 중간 규모 프로젝트에서 콜드 pnpm install은 30-45초 걸립니다. 웜 캐시로는 3-5초 걸립니다. 네 개의 병렬 잡에 곱하면 매 실행에서 2분을 절약합니다.
pnpm 스토어 캐시#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"이 한 줄이 pnpm 스토어(~/.local/share/pnpm/store)를 캐시합니다. 캐시 히트 시 pnpm install --frozen-lockfile은 다운로드 대신 스토어에서 하드링크만 합니다. 이것만으로 반복 실행에서 설치 시간을 80% 줄입니다.
더 많은 제어가 필요하다면 — 예를 들어 OS도 기준으로 캐시하고 싶다면 — actions/cache를 직접 사용하세요:
- uses: actions/cache@v4
with:
path: |
~/.local/share/pnpm/store
node_modules
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-restore-keys 폴백이 중요합니다. pnpm-lock.yaml이 변경되면(새 의존성), 정확한 키는 매칭되지 않지만 프리픽스 매칭이 여전히 대부분의 캐시된 패키지를 복원합니다. 차이분만 다운로드됩니다.
Next.js 빌드 캐시#
Next.js는 .next/cache에 자체 빌드 캐시를 가지고 있습니다. 실행 간에 이것을 캐시하면 증분 빌드가 됩니다 — 변경된 페이지와 컴포넌트만 다시 컴파일됩니다.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-이 3단계 키 전략의 의미:
- 정확한 매칭: 동일한 의존성 그리고 동일한 소스 파일. 전체 캐시 히트, 빌드는 거의 즉시.
- 부분 매칭(의존성): 의존성은 같지만 소스가 변경됨. 빌드는 변경된 파일만 다시 컴파일.
- 부분 매칭(OS만): 의존성이 변경됨. 빌드는 가능한 것을 재사용.
제 프로젝트의 실제 수치: 콜드 빌드는 ~55초, 캐시된 빌드는 ~15초. 73% 감소입니다.
Docker 레이어 캐싱#
Docker 빌드는 캐싱이 정말 큰 영향을 미치는 곳입니다. 전체 Next.js Docker 빌드 — OS 의존성 설치, 소스 복사, pnpm install 실행, next build 실행 — 는 콜드 상태에서 3-4분 걸립니다. 레이어 캐싱으로는 30-60초입니다.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha는 GitHub Actions의 내장 캐시 백엔드를 사용합니다. mode=max는 최종 레이어뿐만 아니라 모든 레이어를 캐시합니다. 이것은 중간 레이어(예: pnpm install)가 재빌드하기에 가장 비싼 멀티 스테이지 빌드에서 중요합니다.
Turborepo 원격 캐시#
Turborepo를 사용하는 모노레포에 있다면, 원격 캐싱은 혁신적입니다. 첫 빌드는 태스크 출력을 캐시에 업로드합니다. 후속 빌드는 다시 계산하는 대신 다운로드합니다.
- run: pnpm turbo build --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Turbo 원격 캐시로 모노레포 CI 시간이 8분에서 90초로 줄어드는 것을 봤습니다. 단점: Vercel 계정이나 셀프 호스팅 Turbo 서버가 필요합니다. 단일 앱 저장소에는 과잉입니다.
Docker 빌드 및 푸시#
VPS(또는 어떤 서버)에 배포한다면, Docker는 재현 가능한 빌드를 제공합니다. CI에서 실행되는 것과 동일한 이미지가 프로덕션에서 실행됩니다. 머신이 이미지 그 자체이기 때문에 더 이상 "내 컴퓨터에서는 작동합니다"가 없습니다.
멀티 스테이지 Dockerfile#
워크플로우에 들어가기 전에, Next.js에 사용하는 Dockerfile입니다:
# 1단계: 의존성
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# 2단계: 빌드
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# 3단계: 프로덕션
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]세 단계, 명확한 분리. 최종 이미지는 모든 것을 복사했을 때의 ~1.2GB 대신 ~150MB입니다. 프로덕션 아티팩트만 러너 스테이지에 도달합니다.
빌드 앤 푸시 워크플로우#
name: Build and Push Docker Image
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max중요한 결정들을 풀어보겠습니다.
GitHub Container Registry (ghcr.io)#
Docker Hub 대신 ghcr.io를 사용하는 세 가지 이유:
- 인증이 무료입니다.
GITHUB_TOKEN은 모든 워크플로우에서 자동으로 사용 가능합니다 — Docker Hub 자격 증명을 저장할 필요가 없습니다. - 근접성. 이미지가 CI가 실행되는 것과 같은 인프라에서 풀됩니다. CI 중 풀이 빠릅니다.
- 가시성. 이미지가 GitHub UI에서 저장소에 연결됩니다. Packages 탭에서 볼 수 있습니다.
멀티 플랫폼 빌드#
platforms: linux/amd64,linux/arm64이 줄은 빌드에 약 90초를 추가하지만, 그만한 가치가 있습니다. ARM64 이미지는 다음에서 네이티브로 실행됩니다:
- Docker Desktop을 사용한 로컬 개발 시 Apple Silicon Mac (M1/M2/M3/M4)
- AWS Graviton 인스턴스 (x86 동급 대비 20-40% 저렴)
- Oracle Cloud의 무료 ARM 티어
이것 없이는 M 시리즈 Mac을 사용하는 개발자들이 Rosetta 에뮬레이션으로 x86 이미지를 실행합니다. 작동하지만 눈에 띄게 느리고 때때로 이상한 아키텍처 특정 버그가 나타납니다.
QEMU가 크로스 컴파일 레이어를 제공합니다. Buildx가 멀티 아키텍처 빌드를 오케스트레이션하고 Docker가 자동으로 올바른 아키텍처를 풀하도록 매니페스트 목록을 푸시합니다.
태깅 전략#
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}모든 이미지에 세 개의 태그가 붙습니다:
abc1234(커밋 SHA): 불변. 정확한 커밋을 항상 배포할 수 있습니다.main(브랜치 이름): 가변. 해당 브랜치의 최신 빌드를 가리킵니다.latest: 가변. 기본 브랜치에서만 설정됩니다. 서버가 풀하는 것입니다.
SHA를 어딘가에 기록하지 않고 프로덕션에 latest를 배포하지 마세요. 뭔가 고장나면 어떤 latest인지 알아야 합니다. 저는 배포된 SHA를 서버의 파일에 저장하고 헬스 엔드포인트에서 읽습니다.
VPS SSH 배포#
여기서 모든 것이 합쳐집니다. CI가 통과하고, Docker 이미지가 빌드되고 푸시되었으며, 이제 서버에 새 이미지를 풀하고 재시작하라고 알려야 합니다.
SSH 액션#
deploy:
name: Deploy to Production
needs: [build-and-push]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
DEPLOY_SHA="${{ github.sha }}"
echo "=== Deploying $DEPLOY_SHA ==="
# 최신 이미지 풀
docker pull "$IMAGE"
# 이전 컨테이너 중지 및 제거
docker stop akousa-app || true
docker rm akousa-app || true
# 새 컨테이너 시작
docker run -d \
--name akousa-app \
--restart unless-stopped \
--network host \
-e NODE_ENV=production \
-e DATABASE_URL="${DATABASE_URL}" \
-p 3000:3000 \
"$IMAGE"
# 헬스 체크 대기
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Health check failed after 30 attempts"
exit 1
fi
sleep 2
done
# 배포된 SHA 기록
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# 오래된 이미지 정리
docker image prune -af --filter "until=168h"
echo "=== Deploy complete ==="배포 스크립트 대안#
단순한 풀-앤-리스타트 이상의 것에는, 워크플로우에 인라인하기보다 서버의 스크립트로 로직을 이동합니다:
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "Starting deployment..."
# GHCR 로그인
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# 재시도와 함께 풀
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Image pulled successfully on attempt $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "ERROR: Failed to pull image after 3 attempts"
exit 1
fi
log "Pull attempt $attempt failed, retrying in 5s..."
sleep 5
done
# 헬스 체크 함수
health_check() {
local port=$1
local max_attempts=30
for i in $(seq 1 $max_attempts); do
if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# 대체 포트에서 새 컨테이너 시작
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# 새 컨테이너가 건강한지 확인
if ! health_check 3001; then
log "ERROR: New container failed health check. Rolling back."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "New container healthy. Switching traffic..."
# Nginx 업스트림 전환
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# 이전 컨테이너 중지
docker stop akousa-app || true
docker rm akousa-app || true
# 새 컨테이너 이름 변경
docker rename akousa-app-new akousa-app
log "Deployment complete."그러면 워크플로우는 단일 SSH 명령이 됩니다:
script: |
cd /var/www/akousa.net && ./deploy.sh이것이 더 나은 이유: (1) 배포 로직이 서버에서 버전 관리되고, (2) 디버깅을 위해 SSH로 수동 실행할 수 있고, (3) YAML 안의 YAML 안의 bash를 이스케이프할 필요가 없습니다.
무중단 배포 전략#
"무중단"은 마케팅 용어처럼 들리지만, 정확한 의미가 있습니다: 배포 중에 어떤 요청도 연결 거부나 502를 받지 않는 것입니다. 여기 가장 간단한 것부터 가장 견고한 것까지 세 가지 실제 접근 방법이 있습니다.
전략 1: PM2 클러스터 모드 리로드#
Docker가 아닌 Node.js를 직접 실행하고 있다면, PM2의 클러스터 모드가 가장 쉬운 무중단 경로를 제공합니다.
# ecosystem.config.js에 이미 다음이 있음:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload(restart가 아님)는 롤링 재시작을 합니다. 새 워커를 시작하고, 준비될 때까지 기다린 다음, 이전 워커를 하나씩 종료합니다. 어떤 시점에서도 트래픽을 서빙하는 워커가 0개가 되지 않습니다.
--update-env 플래그는 에코시스템 설정에서 환경 변수를 다시 로드합니다. 이것 없이는 .env를 변경한 배포 후에도 이전 환경이 유지됩니다.
워크플로우에서:
- name: Deploy and reload PM2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/akousa.net
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --update-env이것이 이 사이트에 사용하는 방식입니다. 간단하고, 안정적이며, 다운타임은 문자 그대로 0입니다 — 배포 중에 100 req/s를 실행하는 로드 제너레이터로 테스트했습니다. 단 하나의 5xx도 없었습니다.
전략 2: Nginx 업스트림을 사용한 블루/그린#
Docker 배포에서 블루/그린은 이전 버전과 새 버전 사이에 깔끔한 분리를 제공합니다.
개념: 이전 컨테이너("블루")를 포트 3000에서, 새 컨테이너("그린")를 포트 3001에서 실행합니다. Nginx는 블루를 가리킵니다. 그린을 시작하고, 건강한지 확인하고, Nginx를 그린으로 전환한 다음, 블루를 중지합니다.
Nginx 업스트림 설정:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /etc/nginx/sites-available/akousa.net
server {
listen 443 ssl http2;
server_name akousa.net;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}전환 스크립트:
#!/bin/bash
set -euo pipefail
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
if [ "$CURRENT_PORT" = "3000" ]; then
NEW_PORT=3001
OLD_PORT=3000
else
NEW_PORT=3000
OLD_PORT=3001
fi
echo "Current: $OLD_PORT -> New: $NEW_PORT"
# 대체 포트에서 새 컨테이너 시작
docker run -d \
--name "akousa-app-$NEW_PORT" \
--env-file /var/www/akousa.net/.env.production \
-p "$NEW_PORT:3000" \
"ghcr.io/akousa/akousa-net:latest"
# 헬스 대기
for i in $(seq 1 30); do
if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
echo "New container healthy on port $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
sleep 2
done
# Nginx 전환
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# 이전 컨테이너 중지
sleep 5 # 진행 중인 요청이 완료되도록 대기
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
echo "Switched from :$OLD_PORT to :$NEW_PORT"Nginx 리로드 후 5초 sleep은 게으름이 아닙니다 — 유예 시간입니다. Nginx의 리로드는 그레이스풀하지만(기존 연결은 유지됨), 일부 롱 폴링 연결이나 스트리밍 응답은 완료할 시간이 필요합니다.
전략 3: 헬스 체크가 있는 Docker Compose#
더 구조화된 접근 방식으로, Docker Compose가 블루/그린 전환을 관리할 수 있습니다:
# docker-compose.yml
services:
app:
image: ghcr.io/akousa/akousa-net:latest
restart: unless-stopped
env_file: .env.production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 0
order: stop-first
ports:
- "3000:3000"order: start-first가 핵심 줄입니다. "이전 것을 중지하기 전에 새 컨테이너를 시작하라"는 의미입니다. parallelism: 1과 결합하면 롤링 업데이트를 얻습니다 — 한 번에 하나의 컨테이너, 항상 용량을 유지합니다.
다음으로 배포:
docker compose pull
docker compose up -d --remove-orphansDocker Compose는 헬스체크를 감시하고 통과할 때까지 새 컨테이너로 트래픽을 라우팅하지 않습니다. 헬스체크가 실패하면 failure_action: rollback이 자동으로 이전 버전으로 되돌립니다. 이것은 단일 VPS에서 Kubernetes 스타일 롤링 배포에 가장 가까운 것입니다.
시크릿 관리#
시크릿 관리는 "대부분 맞게" 하기 쉽지만 나머지 에지 케이스에서 재앙적으로 잘못될 수 있는 것 중 하나입니다.
GitHub 시크릿: 기본#
# GitHub UI를 통해 설정: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# 값은 로그에서 마스킹됩니다
echo "Connecting to database..."
# 로그에 "Connecting to ***"로 출력됩니다
echo "Connecting to $DB_URL"GitHub는 로그 출력에서 시크릿 값을 자동으로 수정합니다. 시크릿이 p@ssw0rd123이고 어떤 스텝이 그 문자열을 출력하면, 로그에 ***로 표시됩니다. 이것은 잘 작동하지만 한 가지 주의사항: 시크릿이 짧으면(4자리 PIN 같은), GitHub가 무해한 문자열과 매칭될 수 있으므로 마스킹하지 않을 수 있습니다. 시크릿은 적절히 복잡하게 유지하세요.
환경 범위 시크릿#
jobs:
deploy-staging:
environment: staging
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = staging.akousa.net
deploy-production:
environment: production
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = akousa.net같은 시크릿 이름, 환경별로 다른 값. 잡의 environment 필드가 어떤 시크릿 세트가 주입되는지 결정합니다.
프로덕션 환경에는 필수 검토자가 활성화되어야 합니다. 이는 main에 대한 푸시가 워크플로우를 트리거하고, CI는 자동으로 실행되지만, 배포 잡은 일시 중지되고 누군가가 GitHub UI에서 "승인"을 클릭할 때까지 기다립니다. 솔로 프로젝트에서는 이것이 부담처럼 느껴질 수 있습니다. 사용자가 있는 모든 것에서는 실수로 깨진 것을 머지하는 첫 번째 순간에 생명줄이 됩니다.
OIDC: 더 이상 정적 자격 증명 없음#
GitHub Secrets에 저장된 정적 자격 증명(AWS 액세스 키, GCP 서비스 계정 JSON 파일)은 위험 요소입니다. 만료되지 않고, 특정 워크플로우 실행에 범위를 지정할 수 없으며, 유출되면 수동으로 교체해야 합니다.
OIDC(OpenID Connect)가 이것을 해결합니다. GitHub Actions가 ID 제공자 역할을 하고, 클라우드 제공자가 이를 신뢰하여 즉석에서 단기 자격 증명을 발급합니다:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC에 필요
contents: read
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-central-1
- name: Push to ECR
run: |
aws ecr get-login-password --region eu-central-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com액세스 키 없음. 시크릿 키 없음. configure-aws-credentials 액션이 GitHub의 OIDC 토큰을 사용하여 AWS STS에서 임시 토큰을 요청합니다. 토큰은 특정 저장소, 브랜치, 환경에 범위가 지정됩니다. 워크플로우 실행 후 만료됩니다.
AWS 측에서 이것을 설정하려면 IAM OIDC ID 제공자와 역할 신뢰 정책이 필요합니다:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
}
}
}
]
}sub 조건이 중요합니다. 이것 없이는 어떻게든 OIDC 제공자 세부 정보를 얻은 어떤 저장소도 역할을 인수할 수 있습니다. 이것이 있으면 특정 저장소의 main 브랜치만 가능합니다.
GCP에는 Workload Identity Federation이라는 동등한 설정이 있습니다. Azure에는 페더레이션된 자격 증명이 있습니다. 클라우드가 OIDC를 지원하면 사용하세요. 2026년에 정적 클라우드 자격 증명을 저장할 이유가 없습니다.
배포 SSH 키#
SSH를 통한 VPS 배포의 경우, 전용 키 쌍을 생성하세요:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""제한이 있는 서버의 ~/.ssh/authorized_keys에 공개 키를 추가합니다:
restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy
restrict 접두사는 포트 포워딩, 에이전트 포워딩, PTY 할당, X11 포워딩을 비활성화합니다. command= 접두사는 이 키가 오직 배포 스크립트만 실행할 수 있다는 의미입니다. 개인 키가 유출되더라도 공격자는 배포 스크립트를 실행할 수 있을 뿐 그 외에는 아무것도 할 수 없습니다.
GitHub Secrets에 개인 키를 SSH_PRIVATE_KEY로 추가합니다. 이것은 제가 수용하는 유일한 정적 자격 증명입니다 — 강제 명령이 있는 SSH 키는 매우 제한된 폭발 반경을 가집니다.
PR 워크플로우: 미리보기 배포#
모든 PR은 미리보기 환경을 가질 자격이 있습니다. 유닛 테스트가 놓치는 시각적 버그를 잡고, 디자이너가 코드를 체크아웃하지 않고 리뷰할 수 있게 하며, QA의 삶을 극적으로 쉽게 만듭니다.
PR 오픈 시 미리보기 배포#
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment:
name: preview-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Build preview image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy preview
id: deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
PORT=$((4000 + PR_NUM))
IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
docker pull "$IMAGE"
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker run -d \
--name "preview-${PR_NUM}" \
--restart unless-stopped \
-e NODE_ENV=preview \
-p "${PORT}:3000" \
"$IMAGE"
echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
const body = `### Preview Deployment
| Status | URL |
|--------|-----|
| :white_check_mark: Deployed | [${url}](${url}) |
_Last updated: ${new Date().toISOString()}_
_Commit: \`${{ github.sha }}\`_`;
// 기존 댓글 찾기
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview Deployment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}포트 계산(4000 + PR_NUM)은 실용적인 핵입니다. PR #42는 포트 4042를 받습니다. 수백 개 이상의 열린 PR이 없는 한 충돌이 없습니다. Nginx 와일드카드 설정이 pr-*.preview.akousa.net을 올바른 포트로 라우팅합니다.
PR 닫힐 때 정리#
정리되지 않는 미리보기 환경은 디스크와 메모리를 먹습니다. 정리 잡을 추가하세요:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
echo "Preview for PR #${PR_NUM} cleaned up."
- name: Deactivate environment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-${{ github.event.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}필수 상태 검사#
저장소 설정(Settings > Branches > Branch protection rules)에서 머지 전에 이 검사들을 필수로 지정하세요:
lint— 린트 에러 없음typecheck— 타입 에러 없음test— 모든 테스트 통과build— 프로젝트가 성공적으로 빌드됨
이것 없이는 누군가가 실패한 검사가 있는 PR을 반드시 머지합니다. 악의적으로가 아니라 — "4개 중 2개 검사 통과"를 보고 나머지 두 개가 아직 실행 중이라고 가정합니다. 잠가두세요.
또한 **"머지 전에 브랜치가 최신이어야 함"**을 활성화하세요. 이것은 최신 main으로 리베이스 후 CI 재실행을 강제합니다. 두 PR이 개별적으로 CI를 통과하지만 합치면 충돌하는 경우를 잡습니다.
알림#
아무도 모르는 배포는 아무도 신뢰하지 않는 배포입니다. 알림이 피드백 루프를 닫습니다.
Slack 웹훅#
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Repository:*\n${{ github.repository }}"
},
{
"type": "mrkdwn",
"text": "*Branch:*\n${{ github.ref_name }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"type": "mrkdwn",
"text": "*Triggered by:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Run"
},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}if: always()가 중요합니다. 이것 없이는 배포가 실패하면 알림 스텝이 건너뛰어집니다 — 정확히 가장 필요할 때입니다.
GitHub Deployments API#
더 풍부한 배포 추적을 위해 GitHub Deployments API를 사용하세요. 이것은 저장소 UI에 배포 히스토리를 제공하고 상태 배지를 활성화합니다:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploying ${context.sha.substring(0, 7)} to production`,
});
return deployment.data.id;
- name: Deploy
run: |
# ... 실제 배포 스텝 ...
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
description: '${{ job.status }}' === 'success'
? 'Deployment succeeded'
: 'Deployment failed',
});이제 GitHub의 Environments 탭에 완전한 배포 히스토리가 표시됩니다: 누가 무엇을 언제 배포했는지, 성공했는지 여부.
실패 시에만 이메일#
중요한 배포의 경우 실패 시 이메일도 트리거합니다. GitHub Actions의 내장 이메일(너무 시끄러움)이 아닌 타겟 웹훅을 통해서:
- name: Alert on failure
if: failure()
run: |
curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY FAILED: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}'이것은 마지막 방어선입니다. Slack은 좋지만 시끄럽기도 합니다 — 사람들은 채널을 음소거합니다. 실행 링크가 포함된 "DEPLOY FAILED" 이메일은 주의를 끕니다.
전체 워크플로우 파일#
여기 모든 것이 하나의 프로덕션 준비된 워크플로우로 연결됩니다. 이것은 실제로 이 사이트를 배포하는 것과 매우 유사합니다.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
skip_tests:
description: "Skip tests (emergency deploy)"
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
NODE_VERSION: "22"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ============================================================
# CI: 린트, 타입 체크, 테스트를 병렬로
# ============================================================
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run TypeScript compiler
run: pnpm tsc --noEmit
test:
name: Unit Tests
if: ${{ !inputs.skip_tests }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test -- --coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
# ============================================================
# 빌드: CI 통과 후에만
# ============================================================
build:
name: Build Application
needs: [lint, typecheck, test]
if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-
- name: Build Next.js application
run: pnpm build
# ============================================================
# Docker: 이미지 빌드 및 푸시 (main 브랜치만)
# ============================================================
docker:
name: Build Docker Image
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU for multi-platform builds
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ============================================================
# 배포: VPS에 SSH 접속하여 업데이트
# ============================================================
deploy:
name: Deploy to Production
needs: [docker]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: production
url: https://akousa.net
steps:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploy ${context.sha.substring(0, 7)}`,
});
return deployment.data.id;
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
command_timeout: 5m
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
SHA="${{ github.sha }}"
echo "=== Deploy $SHA started at $(date) ==="
# 새 이미지 풀
docker pull "$IMAGE"
# 대체 포트에서 새 컨테이너 실행
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# 헬스 체크
echo "Running health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
echo "Health check passed (attempt $i)"
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Health check failed"
docker logs akousa-app-new --tail 50
docker stop akousa-app-new && docker rm akousa-app-new
exit 1
fi
sleep 2
done
# 트래픽 전환
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# 진행 중인 요청을 위한 유예 기간
sleep 5
# 이전 컨테이너 중지
docker stop akousa-app || true
docker rm akousa-app || true
# 이름 변경 및 포트 리셋
docker rename akousa-app-new akousa-app
sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
# 참고: 여기서 Nginx를 리로드하지 않습니다. 컨테이너 이름이 변경된 것이지
# 포트가 아닙니다. 다음 배포가 올바른 포트를 사용합니다.
# 배포 기록
echo "$SHA" > "$APP_DIR/.deployed-sha"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
# 오래된 이미지 정리 (7일 이상)
docker image prune -af --filter "until=168h"
echo "=== Deploy complete at $(date) ==="
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
});
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
},
{
"type": "mrkdwn",
"text": "*Actor:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Run" },
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}
- name: Alert on failure
if: failure()
run: |
curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY FAILED: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}' || true흐름 따라가기#
main에 푸시할 때:
- Lint, Type Check, Test가 동시에 시작됩니다. 세 개의 러너, 세 개의 병렬 잡. 하나라도 실패하면 파이프라인이 중지됩니다.
- Build는 세 가지 모두 통과해야만 실행됩니다. 애플리케이션이 컴파일되고 작동하는 출력을 생성하는지 검증합니다.
- Docker가 프로덕션 이미지를 빌드하고 ghcr.io에 푸시합니다. 멀티 플랫폼, 레이어 캐시됨.
- Deploy가 VPS에 SSH로 접속하고, 새 이미지를 풀하고, 새 컨테이너를 시작하고, 헬스 체크하고, Nginx를 전환하고, 정리합니다.
- 알림은 결과에 관계없이 발송됩니다. Slack이 메시지를 받습니다. GitHub Deployments가 업데이트됩니다. 실패했다면 알림 이메일이 나갑니다.
PR을 열 때:
- Lint, Type Check, Test가 실행됩니다. 동일한 품질 게이트.
- Build가 프로젝트가 컴파일되는지 확인하기 위해 실행됩니다.
- Docker와 Deploy는 건너뜁니다 (
if조건이main브랜치에만 제한합니다).
긴급 배포가 필요할 때 (테스트 건너뛰기):
- Actions 탭에서 **"Run workflow"**를 클릭합니다.
skip_tests: true를 선택합니다.- Lint와 typecheck는 여전히 실행됩니다 (건너뛸 수 없습니다 — 저 자신을 그만큼 신뢰하지 않습니다).
- 테스트가 건너뛰어지고, 빌드가 실행되고, Docker가 빌드하고, 배포가 실행됩니다.
이것이 2년간 제 워크플로우였습니다. 서버 마이그레이션, Node.js 메이저 버전 업그레이드, npm을 pnpm으로 교체, 이 사이트에 15개 도구 추가를 견뎌냈습니다. 푸시부터 프로덕션까지의 총 엔드 투 엔드 시간: 평균 3분 40초. 가장 느린 단계는 ~90초의 멀티 플랫폼 Docker 빌드입니다. 나머지는 모두 캐시되어 거의 즉시입니다.
2년간의 반복에서 얻은 교훈#
제가 한 실수를 공유하며 마무리하겠습니다. 여러분이 하지 않아도 되도록.
액션 버전을 고정하세요. uses: actions/checkout@v4는 괜찮지만, 프로덕션에서는 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (전체 SHA)을 고려하세요. 침해된 액션이 시크릿을 유출할 수 있습니다. 2025년의 tj-actions/changed-files 사건이 이것이 이론적이지 않다는 것을 증명했습니다.
모든 것을 캐시하지 마세요. 한번은 node_modules를 직접 캐시했더니(pnpm 스토어만이 아니라) 오래된 네이티브 바인딩으로 인한 유령 빌드 실패를 디버깅하는 데 두 시간을 보냈습니다. 패키지 매니저 스토어를 캐시하세요, 설치된 모듈이 아니라.
타임아웃을 설정하세요. 모든 잡에 timeout-minutes가 있어야 합니다. 기본값은 360분(6시간)입니다. SSH 연결이 끊겨서 배포가 멈추면, 6시간 후 월간 분 예산이 소진됐을 때 발견하고 싶지 않을 것입니다.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestconcurrency를 현명하게 사용하세요. PR의 경우 cancel-in-progress: true가 항상 맞습니다 — 이미 force-push로 덮어쓴 커밋의 CI 결과는 아무도 신경 쓰지 않습니다. 프로덕션 배포에서는 false로 설정하세요. 빠르게 따라오는 커밋이 롤아웃 중인 배포를 취소하는 것을 원하지 않습니다.
워크플로우 파일을 테스트하세요. 워크플로우를 로컬에서 실행하려면 act (https://github.com/nektos/act)을 사용하세요. 모든 것을 잡지는 못하지만(시크릿은 사용 불가, 러너 환경이 다름) YAML 구문 오류와 명백한 로직 버그를 푸시 전에 잡습니다.
CI 비용을 모니터링하세요. GitHub Actions 분은 퍼블릭 저장소에는 무료, 프라이빗에는 저렴하지만 누적됩니다. 멀티 플랫폼 Docker 빌드는 2배의 분입니다(플랫폼당 하나). 매트릭스 테스트 전략은 실행 시간을 곱합니다. 청구 페이지를 주시하세요.
최고의 CI/CD 파이프라인은 여러분이 신뢰하는 것입니다. 신뢰는 안정성, 관찰 가능성, 점진적 개선에서 옵니다. 간단한 lint-test-build 파이프라인으로 시작하세요. 재현성이 필요할 때 Docker를 추가하세요. 자동화가 필요할 때 SSH 배포를 추가하세요. 확신이 필요할 때 알림을 추가하세요. 첫날부터 전체 파이프라인을 구축하지 마세요 — 추상화를 잘못 만들 것입니다.
오늘 필요한 파이프라인을 구축하고, 프로젝트와 함께 성장하도록 하세요.