Home 어디GO GitHub Actions 기반 CI/CD 파이프라인 구축기
Post
Cancel

어디GO GitHub Actions 기반 CI/CD 파이프라인 구축기

서론

별도의 자동화를 구축하지 않았다면 개발자가 EC2 인스턴스에 직접 접속하여 git pull을 받고, ./gradlew build로 애플리케이션을 빌드한 뒤, 실행 중이던 프로세스를 죽이고 새 jar 파일을 실행하는 수동 방식으로 배포를 해야한다. 이 방식은 간단하지만, 반복적인 작업으로 인한 시간 소모와 실수로 인한 배포 실패 가능성이라는 명확한 비효율을 지니고 있다.

이 문제를 예방하기 위해, ‘어디GO’ 프로젝트에서는 개발 초기부터 CI/CD 파이프라인을 구축하기로 했다. develop 브랜치에 코드가 병합되면 자동으로 테스트, 빌드, 배포까지 완료되는 안정적인 파이프라인 구축이 목표였다.

CI/CD란?

cicd

CI/CD개발 → 빌드 → 테스트 → 배포에 이르는 소프트웨어 출시 과정을 자동화하는 방법론을 말한다. 이 자동화된 흐름을 ‘CI/CD 파이프라인’이라 부르며, 이를 통해 개발자는 코드의 품질을 일관되게 유지하고 사용자에게 더 빠르고 안정적으로 새로운 기능을 제공할 수 있다.

  • CI (Continuous Integration, 지속적 통합)
    여러 개발자가 작업한 코드를 주기적으로 중앙 저장소에 통합(Merge)하고, 이때마다 빌드와 테스트를 자동으로 실행하여 코드의 문제를 조기에 발견하는 단계이다.

  • CD (Continuous Delivery/Deployment, 지속적 제공/배포)
    CI를 통과한 코드를 사용자에게 전달하는 과정이다. 지속적 제공(Delivery)은 배포 준비까지 자동화하되 실제 배포는 사람이 결정하는 것이고, 지속적 배포(Deployment)는 이 마지막 단계까지 자동화하여 테스트 통과 시 코드가 자동으로 운영 환경에 반영되는 것을 의미한다.

CI/CD 파이프라인 설계

전체 아키텍처 구성도

cicd_pipeline.png CI(초록색)와 CD(빨간색)

기술 스택 선정 이유

  • GitHub Actions: 코드 저장소인 GitHub에 내장되어 있어 별도의 CI/CD 서버 구축 없이 즉시 사용할 수 있고, 다양한 오픈소스 Action들을 활용하여 워크플로우를 쉽게 구성할 수 있었다.
  • Docker & AWS ECR: 애플리케이션을 컨테이너화하여 개발 환경과 운영 환경의 차이를 없애고, 빌드된 이미지를 AWS ECR(Elastic Container Registry)에 안전하게 저장 및 관리하고자 했다.

Github-hosted Runner와 Self-hosted Runner의 역할 분리

파이프라인의 효율과 보안을 위해 두 종류의 Runner를 함께 사용했다.

  • GitHub-hosted Runner: 빌드 및 테스트(CI), Docker 이미지 빌드 및 ECR 푸시(CD의 일부)처럼 외부 인터넷 접근이 필요하고 강력한 컴퓨팅 자원이 요구되는 작업을 담당한다.
  • Self-hosted Runner: 실제 운영 서버인 EC2 인스턴스에 직접 설치하여, ECR에서 이미지를 내려받고 컨테이너를 실행하는 배포(CD) 작업을 담당한다. 이 방식은 배포 서버에 SSH 접속 정보를 노출할 필요가 없어 보안적으로 더 안전하다.

파이프라인 구축을 위한 사전 준비

워크플로우(YAML) 파일을 작성하기에 앞서, GitHub Actions가 AWS 리소스에 접근하고 EC2 서버에 배포 명령을 내릴 수 있도록 두 가지 중요한 사전 준비가 필요했다.

AWS와 GitHub Actions 연동: IAM 사용자 생성

외부 서비스인 GitHub Actions가 내 AWS 계정에 접근하려면 인증이 필요하다. 보안을 위해, CI/CD 작업에 필요한 최소한의 권한만 가진 전용 IAM 사용자를 생성했다.

  1. 권한 정책 생성: 먼저 github-actions-policy라는 이름의 고객 관리형 정책을 생성했다. 이 정책에는 ECR 로그인 및 이미지 푸시, EC2 인스턴스 정보 조회 등 CI/CD에 필요한 최소한의 권한(Action)만 포함시켰다.
  2. 사용자 생성: github-actions-user라는 이름으로 프로그래밍 방식 액세스 유형의 IAM 사용자를 생성하고, 위에서 만든 정책을 연결했다.

    aws_create_github_actions_user.png

  3. GitHub Secrets 등록: 생성된 사용자의 액세스 키 ID비밀 액세스 키를 Repository Secrets로 등록했다. 워크플로우에서는 이 Secrets를 통해 안전하게 AWS에 접근하였다.

    github_secrets.png

EC2에 Self-hosted Runner 설정

다음으로, 배포 작업을 수행할 EC2 인스턴스에 Self-hosted Runner를 설치했다.

  1. Runner 등록 (GitHub): 먼저 GitHub 리포지토리에서 새로운 Runner를 등록했다. OS는 Linux, 아키텍처는 x64를 선택했다.

    github_self_hosted_runner.png

  2. Runner 설치 및 설정 (EC2): GitHub에서 안내하는 명령어들을 EC2 인스턴스 터미널에서 순서대로 실행했다. actions-runner 디렉터리를 만들고, Runner 패키지를 다운로드하여 압축을 푼 뒤, config.sh 스크립트를 실행하여 내 리포지토리와 연결했다.

    1
    2
    3
    4
    5
    
     # EC2 인스턴스에서 실행
     $ mkdir actions-runner && cd actions-runner
     $ curl -o actions-runner-linux-x64-....tar.gz -L https://...
     $ tar xzf ./actions-runner-linux-x64-....tar.gz
     $ ./config.sh --url <https://github.com/user/repo> --token YOUR_TOKEN
    
  3. 서비스로 등록: Runner가 EC2 인스턴스가 재부팅되어도 항상 실행되도록, systemd 서비스로 등록했다.

    1
    2
    
     $ sudo ./svc.sh install
     $ sudo ./svc.sh start
    

self_hosted_runner_status.jpg

CI 파이프라인 구현: Pull Request 코드 검증

develop 브랜치의 코드 품질을 유지하기 위해 모든 merge가 PR을 반드시 거치도록 하고, force push 또한 금지하는 브랜치 보호 규칙(Branch Protection Rule)을 설정했다.

branch_protection_rule.png

ci.yml 워크플로우는 develop 브랜치로의 PR이 생성될 때마다 실행되며, Gradle 캐싱을 적용하여 빌드 속도를 최적화했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
name: CI - Build and Test

on:
  pull_request:
    branches:
      - develop

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      # 1. 소스 코드 체크아웃
      - name: Checkout repository
        uses: actions/checkout@v4

      # 2. JDK 설정
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      # 3. Gradle 캐싱 설정
      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      # 4. gradlew 파일에 실행 권한 부여
      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      # 5. Gradle로 빌드 및 테스트 실행
      - name: Build with Gradle
        run: ./gradlew build

CD 파이프라인 구현: EC2 서버 자동 배포

사전 준비가 완료된 후, develop 브랜치에 코드가 Push될 때 실행되는 CD 파이프라인을 작성했다.

Dockerfile

효율적인 CI/CD를 위해서는 가볍고 빌드가 빠른 Docker 이미지를 만드는 것이 중요하다. 나는 다음과 같은 최적화 전략을 적용하여 Dockerfile을 작성했다.

  • 멀티 스테이지 빌드(Multi-stage build): AS builder 키워드를 사용해 빌드 환경과 실행 환경을 분리했다. 빌드에만 필요한 Gradle과 JDK는 첫 번째 스테이지에서만 사용되고, 최종 이미지에는 실행에 필요한 JRE와 app.jar 파일만 포함된다. 이를 통해 최종 이미지의 크기를 획기적으로 줄이고 보안을 강화했다.
  • 경량 베이스 이미지 사용: 최종 이미지의 베이스로 eclipse-temurin:17-jre-alpine을 선택했다. JDK가 아닌 JRE(Java Runtime Environment)만 포함하고, 최소한의 패키지만 설치된 Alpine Linux 기반이라 이미지 용량이 매우 작다.

    docker_image_jdk.png JDK 용량: 142MB

    docker_image_jre.png JRE 용량: 63MB

  • Docker 레이어 캐싱 활용: COPY src ./src 이전에 COPY build.gradle.kts .와 RUN ./gradlew dependencies를 먼저 실행했다. 이렇게 하면 소스 코드가 변경되더라도 build.gradle.kts 파일이 변경되지 않았다면, 의존성을 다운로드하는 무거운 작업을 건너뛰고 캐시된 레이어를 재사용하여 빌드 속도를 크게 향상시킬 수 있다.

최종 Dockerfile은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# --- 1단계: 애플리케이션 빌드 스테이지 ---
FROM gradle:8.5-jdk17 AS builder

WORKDIR /app

COPY --chown=gradle:gradle gradlew .
COPY --chown=gradle:gradle gradle ./gradle

# 의존성 파일만 먼저 복사하여 빌드 캐시 활용
COPY build.gradle.kts .
COPY settings.gradle.kts .
RUN ./gradlew dependencies

# 소스 코드 복사 후 빌드 실행
COPY src ./src
RUN ./gradlew bootJar --no-daemon

# --- 2단계: 최종 이미지 생성 스테이지 ---
FROM eclipse-temurin:17-jre-alpine

WORKDIR /app

# EC2 t3.small 환경에 맞는 JVM 메모리 옵션 설정
ENV JAVA_TOOL_OPTIONS="-Xms256m -Xmx1024m"

# 빌드 스테이지에서 생성된 jar 파일만 복사
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080

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

이후 워크플로우 파일(dev-ci-cd.yml)을 작성했다. 이 워크플로우는 두 개의 Job으로 구성된다. (최종 dev-ci-cd.yml)

Job 1: Docker 이미지 빌드 및 ECR 푸시

GitHub-hosted Runner에서 실행되며, AWS 자격 증명을 이용해 ECR에 로그인한 뒤 docker buildx build 명령어로 Docker 이미지를 빌드하고 ECR에 푸시한다. 이미지 태그는 github.sha를 사용하여 각 커밋마다 고유한 이미지가 생성되도록 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# .github/workflows/dev-ci-cd.yml (build-and-push Job)

build-and-push:
  runs-on: ubuntu-latest
  steps:
    # ... (Checkout, Configure AWS credentials, Login to ECR)
    - name: Build and push Docker image
      id: build-image
      env:
        ECR_REGISTRY: $
        IMAGE_TAG: $
      run: |
        docker buildx build \\
          --platform linux/amd64 \\
          --tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \\
          --output type=image,push=true \\
          .

Job 2: Self-hosted Runner를 이용한 애플리케이션 배포

build-and-push Job이 성공하면, EC2의 Self-hosted Runner에서 deploy Job이 실행된다. 이 Job은 간단한 쉘 스크립트로 구성되어 있다.

  1. ECR 로그인: aws ecr get-login-password 명령어로 ECR에 로그인한다.
  2. 최신 이미지 Pull: build-and-push Job에서 생성된 이미지 URI를 받아 이미지를 내려받는다.
  3. 기존 컨테이너 교체: 실행 중인 기존 컨테이너가 있다면 중지하고 삭제한다.
  4. 새 컨테이너 실행: 새로운 이미지로 컨테이너를 실행한다.
  5. 헬스체크: curl 명령어로 /actuator/health 엔드포인트를 주기적으로 호출하여, 애플리케이션이 정상적으로 실행되었는지 확인한다. (이 엔드포인트를 활성화하려면 spring-boot-starter-actuator 의존성을 추가해주어야 한다.) 12번의 시도에도 200 OK 응답이 오지 않으면 배포를 실패로 간주하고 컨테이너 로그를 출력한다.

cicd_workflow_success.png

문제 및 해결 과정

파이프라인 구축 과정에서 여러 문제를 겪었다. 그중 가장 의미 있었던 세 가지 문제와 해결 과정을 공유한다.

문제 1: CPU 아키텍처 불일치로 인한 컨테이너 실행 오류

워크플로우가 실패해서 docker ps를 확인했는데 컨테이너가 Restarting (255) 상태를 무한 반복하고 있었다.

1
2
3
sh-5.2$ docker ps
CONTAINER ID   IMAGE                                                                                                        COMMAND               CREATED         STATUS                            PORTS     NAMES
8a27cf1e35e3   032068930858.dkr.ecr.ap-northeast-2.amazonaws.com/dnd-project/app:fb376a81522b1680291158141ad719efc3c88df4   "java -jar app.jar"   2 minutes ago   Restarting (255) 58 seconds ago             dnd-app

cd_workflow_fail_1.png

배포 로그에 찍힌 WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64) 메시지가 결정적인 단서였다. GitHub Runner(x86)에서 빌드된 이미지가 t4g.small EC2(ARM)에서 실행되지 않는 CPU 아키텍처 불일치 문제였다.

초기에는 docker buildx를 이용해 ARM용 이미지를 빌드(크로스 컴파일)하도록 파이프라인을 수정했다.

1
2
3
4
5
6
7
jobs:
    steps:
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

하지만 QEMU 에뮬레이션으로 인해 빌드 시간이 증가했고 setup-buildx-action 사용으로 인한 추가적인 권한 문제도 발생했다. 결국 복잡한 빌드 파이프라인 대신, 문제의 근원인 EC2 인스턴스를 t3.small(x86)로 교체하여 빌드 환경과 배포 환경의 아키텍처를 통일하는 것으로 최종 해결했다.

문제 2: AWS IAM 정책 미비로 인한 ECR 접근 실패

cd_workflow_fail_2.png

아키텍처 문제를 해결한 직후, 이번엔 ECR에 이미지를 푸시하는 과정에서 403 Forbidden 에러가 발생했다. 원인은 CI/CD용 IAM 사용자가 ECR에 이미지를 푸시하는 데 필요한 세부 권한이 부족했기 때문이었다.

modify_aws_github_actions_policy.png

에러 로그를 분석하여 ecr:GetAuthorizationToken, ecr:PutImage, ecr:GetDownloadUrlForLayer 등 꼭 필요한 액션(Action)만 기존의 고객 관리형 정책에 하나씩 추가하는 방식으로 최소 권한 원칙을 지키며 문제를 해결했다.

문제 3: Self-hosted Runner 비활성화로 인한 워크플로우 지연

cd_workflow_queued.png

모든 문제를 해결했다고 생각했을 때, 배포 Job이 Queued 상태로 무한 대기하는 문제가 발생했다. Github Runner 탭에서도 runner가 Offline으로 표시되어 있었다.

github_self_hosted_runner_offline.png

EC2에 접속하여 sudo ./svc.sh status로 상태를 확인하니 inactive (dead) 상태로 멈춰 있었다. EC2 인스턴스를 다시 생성하는 과정에서 Runner 서비스를 systemd에 등록하는 작업을 누락했던 것으로 추정한다.

ec2_self_hosted_runner_inactive.png

sudo ./svc.sh start로 서비스를 재시작하자, 대기 중이던 워크플로우가 즉시 실행되며 최종적으로 배포에 성공할 수 있었다.

결론

파이프라인 도입으로 인해 우리 팀의 개발자들은 더 이상 배포에 신경 쓰지 않고 코드 작성에만 집중할 수 있게 되었다. 모든 배포는 자동화된 프로세스를 통해 일관성 있게 이루어졌고, PR 단계에서 빌드와 테스트를 거치며 develop 브랜치의 안정성이 크게 향상되었다.

CI/CD 파이프라인 구축은 단순히 YAML 파일을 작성하는 작업이 아닌, 다양한 요소와의 상호작용을 이해해야 하는 복합적인 과정이었다. 각종 에러를 겪고 이를 해결해나가며 많은 것을 배운 것 같다.

참고 자료

This post is licensed under CC BY 4.0 by the author.

어디GO AWS 인프라 구축기 (feat. Private Subnet)

-