최근 프로젝트를 수행하면서 CI/CD를 다루진 않았지만 같이 프로젝트를 하며 이번 기회에 CI/CD의 의미를 정확하게 알고 어떤 방식으로 동작하는지 정리해 보려고 한다.
CI(Continuous Integration)
지속적 통합으로 기존 코드랑 합쳐 새로운 코드가 들어와도 빌드가 잘 되는지 테스트를 하며 검증하는 단계이다.
CD(Continuous Deployment)
CI 과정에서 검증을 끝마친 후 실제 운영 환경 서버에 배포하는 단계이다.
CI/CD 파이프라인을 초기에 구축해 놓으면 개발자는 빌드 테스트, 빌드된 파일을 서버에 배포하는 과정을 없애줘 비즈니스 로직에만 집중할 수 있게 해준다.
주로 사용하는것은 jenkins, github actions 등이 있다.
또한 github actions에서 제공하는 CI/CD를 먼저 알기 위해 4가지 개념을 먼저 알아야 한다.
- workflow : 자동화된 전체 프로세스를 정의하는 단위로 하나의 workflow 안에는 여러개의 job이 존재 가능하다.
- Job : 워크플로우 안에서 실행되는 작업 단위로 병렬 또는 직렬로 작동이 가능하며 기본적으로 병렬로 작동한다.
- Runner : Job이 설정한 구동된 환경을 의미하며 주로 사용되는 환경은 ubuntu-latest를 사용한다.
- step : Runner안에서 순서가 정해진 step 들이 순차적으로 실행된다. (Job과 다른점은 작성한 순서대로만 실행)

위 화면처럼 하나의 workflow 안에 여러 jobs이 있을 수 있으며 하나의 Runner안에 여러 step 순차적으로 실행되는 작업이 있을 수 있다.
먼저 우리 프로젝트에서는 깃허브 브랜치 전략을 이렇게 나누었다.

main : 배포 환경
develop : 다음에 출시할 버전 환경
feature/*, refactor/* : 기능 추가, 코드 가독성 높이는 환경
매번 develop 브랜치에서 새로운 브랜치를 추가해서 기능을 추가하여 develop 브랜치에 머지하는 전략을 사용하였다.
이때 feature/user-service 브랜치를 생성해서 기능을 개발하고 해당 브랜치를 develop 브랜치를 머지하기 위해 pr을 올렸을때 해당 pr에서 특정 트리거를 발생시키면 Runner를 생성시켜서 해당 코드를 빌드하여 컴파일과, 테스트 코드가 다 동작하는지 확인한다.
1. 기능 개발 후 빌드 테스트
2. develop 브랜치에 머지 후 결과물 빌드
3. develop 브랜치에서 main 브랜치로 머지하는 pr 생성
해당 순서로 CI를 구성하였다.
먼저 기능 개발 후 빌드 테스트 1번 내용에 대한 yml 파일 구성을 살펴보면
name: CI - Pull Request
on:
issue_comment:
types: [ created ]
pull_request:
types: [ labeled, ready_for_review ]
해당 workflow는 pr에 댓글이 달렸을때(issue_comment created), pr에 라벨이 달렸을때 또는 Draft -> Ready for Review로 전환했을때 실행된다.
jobs:
# 댓글 트리거 빌드
build-on-comment: # Job ID
name: PR 빌드 검증 (댓글) # Github UI에 표시되는 이름
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '빌드 시작') }}
permissions:
contents: read
pull-requests: write
steps:
- name: PR 브랜치 확인
id: get_branch
run: |
PR_NUMBER=$(jq --raw-output .issue.number "$GITHUB_EVENT_PATH")
BRANCH_NAME=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | \
jq -r .head.ref)
echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Repository 접근
uses: actions/checkout@v4
with:
ref: ${{ steps.get_branch.outputs.branch }}
- name: JDK 25 셋팅
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: 프로젝트 빌드
run: ./gradlew build --build-cache
- name: 빌드 결과 댓글
if: always()
uses: actions/github-script@v7
with:
script: |
const status = '${{ job.status }}';
const message = status === 'success'
? ' **빌드 성공** \n배포 준비 완료!'
: ' **빌드 실패** \n코드를 확인해주세요.';
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
먼저 최상단에 Jobs를 확인하면 build-on-comment라는 Job ID를 설정하고 runs-on을 사용하여 Runner의 서버 환경을 설정하였으며 if를 사용하여 jobs가 실행되는 조건을 걸어주었다.
실행 조건은 이벤트가 댓글이고 pr에 달린 댓글이며 해당 내용이 빌드 시작일 경우 jobs가 실행된다.
여기서 알 수 있는 것은 처음 workflow를 정의할때 트리거 설정은 on을 사용하며
jobs에 대한 트리거 설정은 if를 사용하여 실행되는 조건을 관리할 수 있다.
그 다음 step을 살펴보자
1. 해당 pr의 브랜치 이름 가져오기
2. ref를 설정하여 step1에서 가져온 브랜치의 코드를 서버에 가져오기
3. 해당 실행환경에 jdk 설치하기
4. gradle 설정하기
5. ./gradlew를 실행하여 해당 코드 빌드하기
6. 이전 step들이 다 실행이 완료된 경우 댓글로 빌드 성공, 아닌경우 빌드 실패를 작성하도록 설정
이번엔 develop 브랜치, main 브랜치에 push 된 경우 ci.yml 파일을 살펴보겠다.
name: CI - Push
on:
push:
branches:
- main
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Repository 접근
uses: actions/checkout@v4
- name: JDK 25 셋팅
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: 프로젝트 빌드
run: ./gradlew build --build-cache
해당 워크플로우는 main, develop 브랜치에 push 또는 merge가 된 경우 발생하는 트리거이다.
아까 ci-pr과 마찬가지로 공통된 step이 보인다.
1. 해당 브랜치에 push된 코드를 가져온다.
2. java를 세팅한다.
3. gradle을 설정한다.
4. ./gradlew 를 실행하여 빌드한다.
이제 CI쪽은 다 작성되었고 CD 빌드된 결과물을 가지고 서버에 배포하는 과정을 한번 살펴보자
name: Create Release
on:
push:
branches: [ main ]
release.yml 파일은 CD 파이프라인이 구성되어 있으며 main 브랜치에 push되는 경우에만 실행되도록 구성하였다.
jobs:
calculate-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate version
id: version
uses: ./.github/actions/calculate-version
check-source-changes:
runs-on: ubuntu-latest
outputs:
src: ${{ steps.filter.outputs.src }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.event.before }}
ref: ${{ github.sha }}
filters: |
src:
- 'src/**'
- 'build.gradle.kts'
- 'buildSrc/**'
release:
needs: calculate-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create GitHub Release
run: gh release create "v${{ needs.calculate-version.outputs.version }}" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
첫번째로 calculate-version job은 현재 시간 기준으로 몇번째 배포인지 계속 태그를 붙이며 따로 버전관리를 현재 날짜 기준으로 계산할 수 있도록 구성하였다.
두번째로 check-source-changes job은 main에 변경사항이 push되어도 해당 변경사항이 코드에 대한 것인지 따로 md 파일이나 문서 수정에 대한 것인지를 구분하기 위해서 설정하였다.
세번째로 release job은 앞서 calculate-version job이 성공적으로 실행된 이후 실행되며 깃허브내의 release를 자동으로 생성하기 위한 job으로 calculate-version에서 현재 계산된 날짜로 버전이 작성이 된다.
build-and-deploy:
needs: [ calculate-version, release ]
runs-on: ubuntu-latest
steps:
- name: Repository 접근
uses: actions/checkout@v4
- name: JDK 25 셋팅
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: 애플리케이션 JAR 빌드
run: |
./gradlew bootJar --no-daemon --build-cache
BOOT_JAR=$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)
test -n "$BOOT_JAR"
cp "$BOOT_JAR" build/libs/app.jar
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: ECR 로그인
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Docker Buildx 설정
uses: docker/setup-buildx-action@v3
- name: Docker 이미지 빌드 & 푸시
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.login-ecr.outputs.registry }}/backend:v${{ needs.calculate-version.outputs.version }}
${{ steps.login-ecr.outputs.registry }}/backend:latest
cache-from: type=gha,scope=backend-runtime
cache-to: type=gha,scope=backend-runtime,mode=max
- name: 서버 배포
uses: appleboy/ssh-action@v1.0.3
env:
DATABASE_HOST: ${{ secrets.DATABASE_HOST }}
DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
APP_JWT_SECRET: ${{ secrets.APP_JWT_SECRET }}
APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS: 3600
APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS: 1209600
GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{secrets.GOOGLE_OAUTH_CLIENT_SECRET}}
APP_OAUTH2_REDIRECT_SUCCESS_URL: ${{ secrets.OAUTH2_REDIRECT_SUCCESS_URL }}
APP_OAUTH2_EXTRA_INFO_URL: ${{ secrets.EXTRA_INFO_URL }}
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_S3_REGION: ${{ secrets.AWS_S3_REGION }}
AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }}
AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }}
GEMINI_PROJECT_ID: ${{ secrets.GEMINI_PROJECT_ID }}
GEMINI_ENABLED: ${{ secrets.GEMINI_ENABLED }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: DATABASE_HOST,DATABASE_USERNAME,DATABASE_PASSWORD,APP_JWT_SECRET,APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS,APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS,APP_OAUTH2_REDIRECT_SUCCESS_URL,APP_OAUTH2_EXTRA_INFO_URL,GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET,FIREBASE_SERVICE_ACCOUNT_JSON,AWS_S3_BUCKET,AWS_S3_REGION,AWS_S3_ACCESS_KEY,AWS_S3_SECRET_KEY,GEMINI_PROJECT_ID,GEMINI_ENABLED
script: |
mkdir -p /home/ubuntu/app/secrets
echo "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
curl -fsSL https://raw.githubusercontent.com/JECT-Study/JECT2-4th-Server/main/scripts/setup-docker-caddy.sh \
| bash
curl -fsSL https://raw.githubusercontent.com/JECT-Study/JECT2-4th-Server/main/scripts/deploy.sh \
| bash -s ${{ steps.login-ecr.outputs.registry }}/backend:v${{ needs.calculate-version.outputs.version }}
build-and-deploy job은 앞서 언급했던 첫번째 job과 세번째 job이 성공적으로 실행되어야 하며 ./gradlew 명령어를 사용하여 bootJar 파일을 생성한다 이때 중요한것은 CD 배포를 한다는 것은 이전 CI에서 빌드, 테스트가 성공됬다는 의미이므로 따로 테스트 빌드를 하지 않고 바로 실행파일을 생성하여 시간을 줄일 수 있도록 구성했다.
# ===== 실행 스테이지 =====
FROM eclipse-temurin:25-jre
WORKDIR /app
# HEALTHCHECK에서 wget을 사용하므로 명시적으로 설치
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/app.jar
COPY ${JAR_FILE} app.jar
HEALTHCHECK --interval=10s --timeout=5s --start-period=90s --retries=18 \
CMD wget -q -O /dev/null http://127.0.0.1:8081/actuator/health/readiness || exit 1
EXPOSE 8080
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]
프로젝트의 Dockerfile 스크립트이며 해당 파일에서 JAR_FILE변수에는 빌드가 성공하고 결과물인 jar 파일의 위치가 저장된다.
이후 AWS의 Elastic Container Registry의 도커 이미지 저장소에 도커 이미지를 빌드하고 push 하는 과정을 거친다.
set -euo pipefail
APP_DIR="${APP_DIR:-/home/ubuntu/app}"
DOMAIN="${DOMAIN:-api.vs.io.kr}"
REPO_RAW_BASE="${REPO_RAW_BASE:-https://raw.githubusercontent.com/JECT-Study/JECT2-4th-Server/main}"
NETWORK="${NETWORK:-app-network}"
CADDY_CONTAINER="${CADDY_CONTAINER:-caddy}"
log() {
echo ">>> $*"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo ">>> 필수 명령어를 찾을 수 없습니다: $1" >&2
exit 1
fi
}
require_command curl
require_command docker
if ! docker compose version >/dev/null 2>&1; then
echo ">>> Docker Compose v2 플러그인을 찾을 수 없습니다." >&2
exit 1
fi
mkdir -p "$APP_DIR"
log "docker-compose.yaml 다운로드"
curl -fsSL "$REPO_RAW_BASE/docker-compose.yaml" -o "$APP_DIR/docker-compose.yaml"
log "Caddyfile 다운로드"
curl -fsSL "$REPO_RAW_BASE/Caddyfile" | sed "s/api.vs.io.kr/$DOMAIN/g" > "$APP_DIR/Caddyfile"
log "Docker 네트워크 확인: $NETWORK"
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null
log "Caddy 실행"
cd "$APP_DIR"
docker compose up -d caddy
log "Caddy 상태"
docker compose ps caddy
log "Caddy 설정 완료 (HTTPS 인증서는 첫 요청 시 자동 발급됩니다)"
이후 setup-docker-caddy.sh 파일을 실행한다.
해당 파일은 /home/ubuntu/app 디렉터리를 기본 디렉터리로 설정하며 해당 경로에서 docker-compose.yaml 파일을 다운받고 우리 프로젝트에 존재하는 Caddyfile의 설정파일을 다운받고 네트워크를 생성하여 caddy 컨테이너를 실행시킨다.
그다음 서버를 배포하기 위해 ssh 접속을 하여 깃허브 내의 secrets 환경 변수를 env로 설정해서 관리할 수 있도록 구성하였다.
마지막 줄에 curl 명령어로 deploy.sh 파일을 복사해 온다.
#!/bin/bash
set -euo pipefail
IMAGE="${1:-}"
if [ -z "$IMAGE" ]; then
echo ">>> 사용법: ./deploy.sh <이미지명:태그>"
exit 1
fi
APP_DIR="${APP_DIR:-/home/ubuntu/app}"
NETWORK="${NETWORK:-app-network}"
CADDY_CONTAINER="${CADDY_CONTAINER:-caddy}"
HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-180}"
HEALTH_LOG_INTERVAL_SECONDS="${HEALTH_LOG_INTERVAL_SECONDS:-10}"
log() {
echo ">>> $*"
}
cleanup_new_container() {
if [ -n "${NEW_CONTAINER:-}" ]; then
docker stop "$NEW_CONTAINER" >/dev/null 2>&1 || true
docker rm "$NEW_CONTAINER" >/dev/null 2>&1 || true
fi
}
mkdir -p "$APP_DIR"
REQUIRED_ENV_VARS=(
DATABASE_HOST
DATABASE_USERNAME
DATABASE_PASSWORD
APP_JWT_SECRET
APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS
APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS
)
MISSING_ENV_VARS=()
for ENV_VAR in "${REQUIRED_ENV_VARS[@]}"; do
if [ -z "${!ENV_VAR:-}" ]; then
MISSING_ENV_VARS+=("$ENV_VAR")
fi
done
if [ "${#MISSING_ENV_VARS[@]}" -gt 0 ]; then
echo ">>> 필수 운영 환경 변수가 없습니다: ${MISSING_ENV_VARS[*]}" >&2
echo ">>> GitHub Actions secrets 또는 환경 변수로 값을 설정한 뒤 다시 배포하세요." >&2
exit 1
fi
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null
if ! docker ps --format '{{.Names}}' | grep -qx "$CADDY_CONTAINER"; then
echo ">>> Docker Caddy 컨테이너($CADDY_CONTAINER)가 실행 중이 아닙니다. scripts/setup-docker-caddy.sh를 먼저 실행하세요." >&2
exit 1
fi
CADDY_NETWORKS=$(docker inspect --format='{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CADDY_CONTAINER")
if ! echo "$CADDY_NETWORKS" | grep -qx "$NETWORK"; then
log "Caddy 컨테이너를 $NETWORK 네트워크에 연결"
docker network connect "$NETWORK" "$CADDY_CONTAINER"
fi
# 버전 기반 컨테이너 이름 (이미지 태그 추출)
VERSION=$(echo "$IMAGE" | cut -d':' -f2)
NEW_CONTAINER="app-${VERSION}"
# 현재 활성 컨테이너를 네트워크 alias로 탐색
CURRENT=""
for cid in $(docker ps -q 2>/dev/null); do
aliases=$(docker inspect "$cid" \
--format "{{range \$net, \$cfg := .NetworkSettings.Networks}}{{if eq \$net \"$NETWORK\"}}{{range \$cfg.Aliases}}{{.}} {{end}}{{end}}{{end}}" \
2>/dev/null)
if [[ " $aliases " == *" app "* ]]; then
CURRENT=$(docker inspect "$cid" --format '{{.Name}}' | sed 's/^\///')
break
fi
done
log "현재: ${CURRENT:-없음} → 새로운: $NEW_CONTAINER"
# ECR 이미지인 경우 credential helper로 인증 설정
if [[ "$IMAGE" == *".dkr.ecr."* ]]; then
ECR_REGISTRY=$(echo "$IMAGE" | cut -d'/' -f1)
if ! command -v docker-credential-ecr-login &>/dev/null; then
log "ECR credential helper 설치 중..."
sudo apt-get install -y amazon-ecr-credential-helper
fi
mkdir -p ~/.docker
echo "{\"credHelpers\":{\"${ECR_REGISTRY}\":\"ecr-login\"}}" > ~/.docker/config.json
log "ECR credential helper 설정 완료: $ECR_REGISTRY"
fi
# 새 이미지 pull
log "이미지 pull 중: $IMAGE"
docker pull "$IMAGE"
# 혹시 남아있는 동일 이름 컨테이너 정리
if docker ps -a --format '{{.Names}}' | grep -qx "$NEW_CONTAINER"; then
log "기존 $NEW_CONTAINER 컨테이너 정리"
docker stop "$NEW_CONTAINER" >/dev/null 2>&1 || true
docker rm "$NEW_CONTAINER" >/dev/null 2>&1 || true
fi
# 새 컨테이너 실행 (트래픽 전환 전까지 Docker 기본 bridge에만 둔다)
log "새 컨테이너($NEW_CONTAINER) 실행 중"
# Docker run 옵션 구성
DOCKER_OPTS=(
-d --name "$NEW_CONTAINER"
-e SPRING_PROFILES_ACTIVE=prod
-e DATABASE_HOST
-e DATABASE_USERNAME
-e DATABASE_PASSWORD
-e APP_JWT_SECRET
-e APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS
-e APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS
-e APP_OAUTH2_REDIRECT_SUCCESS_URL
-e APP_OAUTH2_EXTRA_INFO_URL
-e GOOGLE_OAUTH_CLIENT_ID
-e GOOGLE_OAUTH_CLIENT_SECRET
)
# Firebase + GCP 서비스 계정 설정 (선택적)
# 이 파일은 Firebase와 Vertex AI(Gemini) 인증에 모두 사용됨
if [ -f "/home/ubuntu/app/secrets/firebase-service-account.json" ]; then
DOCKER_OPTS+=(
-v /home/ubuntu/app/secrets/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro
-e FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
-e GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/firebase-service-account.json
)
log "Firebase + GCP 서비스 계정 마운트됨"
fi
# S3 설정 (선택적)
if [ -n "${AWS_S3_BUCKET:-}" ]; then
DOCKER_OPTS+=(
-e AWS_S3_BUCKET
-e AWS_S3_REGION
-e AWS_S3_ACCESS_KEY
-e AWS_S3_SECRET_KEY
)
log "S3 설정 추가됨"
fi
# Gemini 설정 (선택적)
if [ -n "${GEMINI_PROJECT_ID:-}" ]; then
DOCKER_OPTS+=(
-e GEMINI_PROJECT_ID
-e GEMINI_ENABLED
-e GEMINI_MODEL
-e GEMINI_LOCATION
)
log "Gemini 설정 추가됨"
fi
# GA4 Measurement Protocol 설정 (선택적)
# 측정 ID가 있을 때만 전달. 없으면 앱은 no-op으로 동작(RDB 적재는 그대로).
if [ -n "${GA_MEASUREMENT_ID:-}" ]; then
DOCKER_OPTS+=(
-e GA_ENABLED
-e GA_MEASUREMENT_ID
-e GA_API_SECRET
-e GA_ENDPOINT
)
log "GA4 설정 추가됨"
fi
docker run "${DOCKER_OPTS[@]}" "$IMAGE" >/dev/null
# 헬스체크 (docker inspect로 healthy 상태 확인)
# Docker health status가 start-period 직후 일시적으로 unhealthy가 될 수 있어
# unhealthy 즉시 실패 대신 전체 타임아웃까지 여유 있게 대기한다.
log "헬스체크 시작 (최대 ${HEALTH_TIMEOUT_SECONDS}초)"
HEALTH_PASSED=false
for i in $(seq 1 "$HEALTH_TIMEOUT_SECONDS"); do
RUNNING=$(docker inspect --format='{{.State.Running}}' "$NEW_CONTAINER" 2>/dev/null || echo "false")
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' "$NEW_CONTAINER" 2>/dev/null || echo "missing")
if [ "$RUNNING" != "true" ]; then
echo ">>> 새 컨테이너가 실행 중이 아닙니다. 롤백합니다." >&2
docker logs --tail=100 "$NEW_CONTAINER" >&2 || true
cleanup_new_container
exit 1
fi
if [ "$STATUS" = "healthy" ]; then
log "헬스체크 통과! (${i}초)"
HEALTH_PASSED=true
break
fi
if [ $((i % HEALTH_LOG_INTERVAL_SECONDS)) -eq 0 ]; then
log "헬스체크 대기 중... status=$STATUS (${i}/${HEALTH_TIMEOUT_SECONDS}초)"
fi
sleep 1
done
if [ "$HEALTH_PASSED" != "true" ]; then
echo ">>> 헬스체크 타임아웃. 롤백합니다." >&2
docker logs --tail=100 "$NEW_CONTAINER" >&2 || true
cleanup_new_container
exit 1
fi
# 새 컨테이너를 네트워크에 연결 (app alias 부여)
log "트래픽 전환 중"
docker network connect --alias app "$NETWORK" "$NEW_CONTAINER"
# 이전 컨테이너 graceful shutdown 후 정리
# stop 먼저: SIGTERM → readiness 503 → Caddy가 health check로 감지 후 pool에서 제거
# disconnect는 그 이후: Caddy가 감지한 뒤 네트워크 분리
if [ -n "$CURRENT" ] && docker ps -a --format '{{.Names}}' | grep -qx "$CURRENT"; then
log "이전 컨테이너($CURRENT) graceful shutdown 중"
docker stop "$CURRENT" >/dev/null 2>&1 || true
docker network disconnect "$NETWORK" "$CURRENT" 2>/dev/null || true
docker rm "$CURRENT" >/dev/null 2>&1 || true
fi
# 오래된 Docker 이미지 정리
docker image prune -f >/dev/null
log "배포 완료! 활성 컨테이너: $NEW_CONTAINER"
해당 스크립트는 인자로 받은 도커 이미지를 기준으로 동작하며 배포에 필요한 필수 환경 변수(DB 정보, JWT 시크릿 등)이 모두 설정되어 있는지 검증하고, 앞서 띄운 Caddy 컨테이너가 정상적으로 동작하는지 확인한다. Caddy가 먼저 실행되어야 하는 이유는 리버스 프록시 역할을 하며 트래픽을 앞단에서 받아줘야 하기 때문이다.
이후 ECR에서 새 이미지를 받고 이미지 태그에서 버전을 추출하여 app-{버전} 형태의 새 컨테이너를 실행한다. 이때 새 컨테이너는 곧바로 서비스 네트워크에 연결하지 않고 격리된 상태로 띄운 뒤, 헬스체크(/acturator/health/readiness)가 통과할 때까지 최대 180초간 대기한다.
해당 파일에서 가장 핵심인 부분은 무중단 배포(blue-green)이 사용된다. 새 컨테이너가 헬스체크를 통과하면 그제서야 app이라는 네트워크 별칭을 부여하여 서비스 네트워크에 연결한다. Caddy는 특정 컨테이너가 아닌 app이라는 별칭으로 트래픽을 전달하도록 설정되어 있으므로 이 시점부터 자동으로 새 컨테이너로 요청이 흘러가게 된다. 트래픽 전환이 완료되면 기존에 떠 있던 이전 컨테이너를 shutdown 후 정리하여 사용하지 않은 도커 이미지를 정리하며 배포를 마무리한다.
반대로 새 컨테이너가 헬스체크를 통과하지 못하면, 새 컨테이너를 그대로 제거하고 기존 컨테이너를 유지한 채 배포를 실패 처리한다. 해당 사유로 배포가 실패하더라도 서비스는 중단 없이 이전 버전으로 계속 운영된다.
