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
156 changes: 156 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: CD Pipeline

on:
push:
branches: [ "main" ]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI 워크플로우(ci.yml)도 push: [ "main" ]에 걸려 있어서, main에 머지되는 순간 CI와 CD가 서로를 모른 채 동시에 출발합니다.

여기서 나오는 시나리오입니다. 테스트를 깨뜨리는 커밋이 main에 들어옴 → CI job과 CD job이 같이 시작 → CD는 -x test라 먼저 끝나 EC2에 배포 완료 → 몇 분 뒤 CI가 빨간불. 이미 깨진 빌드가 prod에서 돌고 있는 상태가 됩니다.

ci.yml은 jacoco 커버리지 게이트(80%)까지 걸어둔 워크플로우인데, 정작 배포 경로가 그 게이트를 통과하지 않고 우회하는 구조입니다.

두 가지 방향이 있습니다.

  1. workflow_run 트리거 (가장 권장): CD를 on: workflow_run: { workflows: ["CI PipeLine"], types: [completed] }로 바꾸고, job에 if: github.event.workflow_run.conclusion == 'success'를 겁니다.
  2. CD 안에 test job을 하나 두고 build-and-pushneeds로 받게 합니다. 대신 테스트가 두 번 돕니다.

1번을 제안드립니다.


concurrency:
group: mople-production-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: mople-app
CONTAINER_NAME: mople-app

jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.build-image.outputs.image }}

steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- name: JDK 17 설정
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'

- name: Gradle 설정 (캐시 포함)
uses: gradle/actions/setup-gradle@v4

- name: gradlew 실행 권한 부여
run: chmod +x ./gradlew

- name: bootJar 빌드
run: ./gradlew bootJar --no-daemon -x test

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 스텝이 만든 jar가 어디에서도 쓰이지 않습니다.

DockerfileCOPY src ./srcRUN ./gradlew bootJar로 컨테이너 안에서 처음부터 다시 빌드하기 때문에, 여기서 나온 build/libs/*.jar는 그대로 버려집니다. 위의 JDK 설정 / setup-gradle / chmod / bootJar 네 개 스텝이 통째로 무용해지고 빌드 시간만 두 배가 됩니다.

더 아쉬운 건 캐시 쪽입니다. setup-gradle이 복원해준 러너의 ~/.gradle 캐시는 docker build 컨테이너 안에서 보이지 않습니다. 캐시가 있는 쪽에서는 버려질 jar를 만들고, 캐시가 없는 쪽에서 실제 배포용 jar를 만드는 구조입니다.

둘 중 하나로 정리하시는 걸 제안드립니다.

  • A (권장): Dockerfile을 실행 스테이지만 남기고 COPY build/libs/*.jar app.jar로 러너가 만든 jar를 담습니다. gradle 캐시가 그대로 살고 이미지 빌드는 몇 초로 끝납니다.
  • B: Dockerfile 멀티스테이지를 유지하고, 이 스텝을 포함한 위 네 개 스텝을 지웁니다.

덧붙여, 현재 레포에 .dockerignore가 없어서 docker build ..git과 방금 이 스텝이 만든 build/까지 통째로 빌드 컨텍스트로 전송하고 있습니다. A안으로 가시면 build/만 예외로 두는 .dockerignore가 함께 필요합니다.


- name: AWS 자격 증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}

- name: ECR 로그인
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2

- name: Docker 이미지 빌드 및 ECR 푸시
id: build-image
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker tag $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $REGISTRY/$ECR_REPOSITORY:latest
docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$ECR_REPOSITORY:latest
echo "image=$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"

deploy:
needs: build-and-push
runs-on: ubuntu-latest

steps:
- name: SSH로 EC2에 배포
uses: appleboy/ssh-action@v1
env:
IMAGE: ${{ needs.build-and-push.outputs.image }}
AWS_REGION: ${{ env.AWS_REGION }}
CONTAINER_NAME: ${{ env.CONTAINER_NAME }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: IMAGE,AWS_REGION,CONTAINER_NAME
script: |
set -e

CANDIDATE_NAME="${CONTAINER_NAME}_candidate"
CANDIDATE_PORT=18080

aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin ${IMAGE%%/*}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문 하나 드립니다. 이 시점에 EC2는 어떤 자격증명으로 ECR에 로그인하고 있나요?

두 갈래인데 실패의 폭발 반경이 많이 다릅니다.

  • IAM 인스턴스 프로파일인 경우: 자격증명이 인스턴스 메타데이터에서 자동 회전됩니다. 이쪽이면 문제 없습니다.
  • aws configure로 access key를 박아둔 경우: ~/.aws/credentials에 만료 없는 키가 평문으로 남습니다. 이때는 EC2_SSH_KEY 시크릿 하나가 유출되면 SSH 접근에서 끝나지 않고 그 IAM 사용자가 가진 AWS 권한 전체까지 이어집니다.

후자라면 인스턴스 프로파일(AmazonEC2ContainerRegistryReadOnly 정도의 최소 권한)로 옮기시는 걸 제안드립니다. EC2에 장기 access key를 두지 않는 것이 실무 기본값입니다.

같은 맥락으로, secrets.EC2_USERNAME 계정이 docker 그룹에 속해 있다면 사실상 root와 동일한 권한입니다. 이 파이프라인이 실제로 필요한 최소 권한이 무엇인지 한 번 정리해두시면 좋겠습니다.

docker pull $IMAGE

PREV_IMAGE=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "none")
echo "현재 서비스 중인 이미지: $PREV_IMAGE (교체 실패 시 그대로 유지됨)"

docker rm -f "$CANDIDATE_NAME" 2>/dev/null || true
docker run -d --name "$CANDIDATE_NAME" -p 127.0.0.1:${CANDIDATE_PORT}:8080 --env-file /home/ubuntu/mople.env "$IMAGE"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 readiness 확인용 컨테이너가 프로덕션에 부수효과를 냅니다.

candidate는 실제 서비스 컨테이너와 **완전히 같은 --env-file, 같은 SPRING_PROFILES_ACTIVE=prod**로 뜹니다. 포트만 loopback 18080으로 다를 뿐, 애플리케이션 입장에서는 prod 인스턴스가 하나 더 붙은 것과 구분되지 않습니다.

이 컨테이너가 기동하면서 실제로 하는 일들입니다.

  • application-prod.yamlddl-auto: updatecandidate 기동만으로 prod DB 스키마 변경이 실행됩니다. 기존 컨테이너와 겹치는 구간이라 DDL 락 경합도 생길 수 있습니다.
  • @KafkaListener 빈이 뜨면서 prod 컨슈머 그룹에 조인해 실제 메시지를 소비합니다. 그리고 30초쯤 뒤 117번 줄에서 docker rm -f로 죽이므로, 그 사이 처리 중이던 메시지의 offset 상태가 애매해지고 rebalance가 왕복 두 번 일어납니다.
  • @Scheduled 잡이 기동 직후 실행 조건에 걸리면 한 번 더 돕니다.

readiness만 보고 싶으신 거라면 candidate는 컨슈머·스케줄러를 끈 상태로 띄우는 게 맞습니다. -e SPRING_PROFILES_ACTIVE=prod,probe를 덧붙이고 해당 빈들에 @Profile("!probe")를 거는 방향을 제안드립니다.


READY=false
for i in $(seq 1 30); do
if curl -fs "http://localhost:${CANDIDATE_PORT}/actuator/health" | grep -q '"status":"UP"'; then
READY=true
break
fi
sleep 2
done

if [ "$READY" != true ]; then
echo "새 컨테이너 readiness 실패 — 배포 중단, 기존 컨테이너($PREV_IMAGE) 그대로 유지"
docker logs "$CANDIDATE_NAME" --tail 100 || true
docker rm -f "$CANDIDATE_NAME" || true
exit 1
fi

echo "새 컨테이너 readiness 확인 완료, 기존 컨테이너 교체 시작"
docker rm -f "$CANDIDATE_NAME"

ROLLBACK_NAME="${CONTAINER_NAME}_rollback"
docker rm -f "$ROLLBACK_NAME" 2>/dev/null || true
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rename "$CONTAINER_NAME" "$ROLLBACK_NAME"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 줄에서 기존 컨테이너를 stop + rename 한 시점부터 서비스가 내려가 있는데, 정작 이 구간의 실패는 rollback()으로 이어지지 않습니다.

rollback()은 137번·151번 줄에서 명시적으로 호출될 때만 동작합니다. 그런데 스크립트 맨 위에 set -e가 걸려 있어서, 그 두 곳이 아닌 지점에서 명령이 하나라도 실패하면 롤백을 타지 않고 스크립트가 즉시 죽습니다.

시나리오를 펼쳐보면 이렇습니다. docker rename이 실패한다고 해봅시다(이전 배포가 중간에 죽어 _rollback 이름이 남아 있거나, 데몬 일시 오류). 바로 앞줄의 docker stop은 이미 성공했으니 기존 컨테이너는 내려간 상태입니다. set -e가 발동해 스크립트 종료 → 워크플로우는 실패로 끝나고, EC2에는 돌아가는 컨테이너가 하나도 없습니다. 로그에 "롤백" 문구조차 남지 않아서 원인 파악도 늦어집니다.

rollback() 정의를 이 if 블록보다 위로 올리고, rename 직후에 trap rollback ERR를 걸어두시는 걸 제안드립니다. 156번 줄 정리 직전에 trap - ERR로 해제하면 정상 경로에는 영향이 없습니다.

fi

rollback() {
echo "새 컨테이너 기동/readiness 실패 — 이전 컨테이너($PREV_IMAGE)로 즉시 롤백"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
if docker inspect "$ROLLBACK_NAME" >/dev/null 2>&1; then
docker rename "$ROLLBACK_NAME" "$CONTAINER_NAME"
docker start "$CONTAINER_NAME"
fi
exit 1
}

if ! docker run -d --name "$CONTAINER_NAME" --restart unless-stopped -p 8080:8080 --env-file /home/ubuntu/mople.env "$IMAGE"; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 본문에서 다음 단계를 미리 밝혀주셨습니다.

현재는 EC2를 사용하여 배포를 진행한 상황입니다. Nginx를 추가한 뒤 ECS로 전환예정입니다.

방향은 맞습니다. 다만 candidate 검증 구조 때문에 지금 파이프라인이 "무중단에 가깝다"고 읽히기 쉬워서, 한 번 정확히 짚어드립니다. 현재 구조에는 애플리케이션 기동 시간만큼의 다운타임이 그대로 있습니다.

시간축으로 펼쳐보면 이렇습니다. 122번 줄 docker stop으로 기존 컨테이너가 내려감 → 이 줄에서 새 컨테이너 기동 → 141147번 줄에서 최대 60초 동안 UP을 기다림. 이 구간 전체가 커넥션 거부입니다. Kafka·Redis·OAuth2 클라이언트까지 붙어 있는 앱이라 기동에 2040초는 잡으셔야 합니다.

candidate 단계가 막아주는 것은 "이미지가 애초에 안 뜨는 경우"이지 전환 자체의 공백이 아닙니다. 오히려 확인 후 죽였다가 8080으로 다시 띄우므로 기동을 두 번 하게 됩니다.

원인은 -p 8080:8080 고정입니다. 포트가 고정이면 두 컨테이너가 같은 자리를 동시에 점유할 수 없어 stop → start 외의 선택지가 없어집니다. 앞에 리버스 프록시를 두고 컨테이너를 동적 포트로 두 개 띄운 뒤 upstream만 갈아끼우는 순서가 되어야 공백이 사라집니다. nginx 도입 PR에서 이 부분을 함께 설계하시는 걸 제안드립니다.

rollback
fi

READY=false
for i in $(seq 1 30); do
if curl -fs "http://localhost:8080/actuator/health" | grep -q '"status":"UP"'; then
READY=true
break
fi
sleep 2
done

if [ "$READY" != true ]; then
docker logs "$CONTAINER_NAME" --tail 100 || true
rollback
fi

echo "배포 완료 — 이전 컨테이너 정리"
docker rm -f "$ROLLBACK_NAME" 2>/dev/null || true
docker image prune -f
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# --- 1단계 : 빌드 ---
FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /app

# 의존성 캐시를 위해 gradle 관련 파일 먼저 복사
COPY gradlew build.gradle settings.gradle ./
COPY gradle ./gradle
RUN chmod +x ./gradlew
RUN ./gradlew dependencies --no-daemon || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 줄은 의도하신 캐시 효과를 거의 못 냅니다. 세 가지가 겹쳐 있습니다.

  1. || true가 실패를 삼킵니다. 의존성 해석이 실패해도 다음 RUN으로 그냥 넘어가고, 13번 줄에서 어차피 다시 받게 됩니다. 캐시 효과는 사라지는데 실패 시그널도 남지 않습니다.
  2. gradlew dependencies는 빌드에 필요한 의존성을 다 받아오지 않습니다. annotationProcessor 같은 일부 configuration이 빠져서 결국 bootJar 단계에서 다운로드가 다시 일어납니다.
  3. 가장 큰 문제: GitHub Actions 러너는 매 실행마다 새 VM입니다. 이전 실행의 도커 레이어 캐시가 아예 존재하지 않습니다. 6~9번 줄의 레이어 분리는 로컬 반복 빌드에서는 의미가 있지만, CI에서는 매번 0에서 시작합니다.

3번 때문에 이 Dockerfile을 CI에서 쓰는 한 캐시 이야기 자체가 성립하지 않습니다. docker/build-push-action + cache-from/to: type=gha로 레이어 캐시를 GitHub Actions 캐시에 태우거나, cd.yml 44번 줄 코멘트의 A안(러너가 만든 jar를 COPY)으로 가면 이 세 줄이 통째로 필요 없어집니다.

후자를 제안드립니다.


# 소스 복사 후 빌드 (테스트는 CI에서 검증되므로 스킵)
COPY src ./src
RUN ./gradlew bootJar --no-daemon -x test

# --- 2단계 : 실행 ---
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app

# non-root 사용자로 실행
RUN useradd -r -u 1001 appuser
USER appuser

COPY --from=build /app/build/libs/mople-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8080
ENV SPRING_PROFILES_ACTIVE=prod

ENTRYPOINT ["java", "-jar", "app.jar"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ENTRYPOINT를 exec form으로 쓰신 건 잘 하셨습니다. ["sh","-c","java …"]로 쓰면 PID 1이 sh가 되어 SIGTERM이 JVM까지 전달되지 않고 graceful shutdown이 무력화되는데, 그 함정을 피해 가셨습니다 👍

다만 JVM 힙 설정이 없습니다. 컨테이너 배포에서는 컨테이너 메모리 제한과 -XX:MaxRAMPercentage짝으로 잡아주셔야 합니다.

지금 상태를 보면 docker run에도 --memory 제한이 없습니다. 제한이 없으면 JVM은 호스트 메모리 전체를 기준으로 힙 상한을 계산하므로, 실질적으로 JVM 하나가 인스턴스 메모리 전부를 상한으로 잡은 채 도는 셈이 됩니다.

여기에 배포 흐름이 겹칩니다. cd.yml 98번 줄에서 candidate를 띄우는 동안 기존 컨테이너와 candidate의 JVM 두 개가 동시에 떠 있습니다. 이 구간에 메모리가 몰리면 OOM Killer가 멀쩡히 서비스 중이던 기존 컨테이너를 골라 죽일 수도 있습니다. 배포하다가 서비스가 죽는 경로입니다.

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70.0", "-XX:+ExitOnOutOfMemoryError", "-jar", "app.jar"]

그리고 docker run 쪽에 --memory를 함께 거시는 걸 제안드립니다. 두 값이 짝이 되어야 계산이 성립합니다.

Loading