본문 바로가기
Backend/Spring

[Spring] Github Action과 Docker를 활용한 CI 작성기

by chickenman 2025. 7. 18.

프로젝트 구조

- 언어 및 프레임워크: Java, Spring Boot

- 어플리케이션 배포 Platform: Docker

- Source 관리: Git & Github(저장소) 

 

현재 멀티 모듈로 구성되어 있는 Api 서버 제작 프로젝트를 하고 있습니다. 어플리케이션은 Spring framework를 사용하며 Docker에서 동작하도록 구현했습니다.

 

그리고 소스 코드의 관리는 Git, 저장소는 Github을 사용하고 있습니다. 따라서 자연스럽게 CI 및 CD 도구로 Github Action을 사용하게 되었습니다.

 

이 프로그램의 설정은 application.yml 파일을 활용해서 진행합니다. 해당 파일에는 DB url과 DB 비밀번호와 같은 비밀 값들이 있을 수 있는데, 비용 절감을 위해 AWS KMS 등과 같은 서비스는 사용하지 않고 environment variable이나 system properties를 활용하고 있습니다. 

spring:

  ...

  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

  flyway:
    enabled: true
    url: ${DB_URL}
    user: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    baseline-on-migrate: true
    baseline-version: 1
    default-schema: store_manager

...

 

수정 전 문제의 코드

Github action 설정 파일(yml)은 아래와 같습니다.

이 Github action은 PR이 생성/재오픈/동기화 되었을 때 트리거되는 action으로, 빌드에 필요한 모든 서브모듈의 소스코드를 불러와서 docker 이미지를 만들어보고 오류없이 이미지 생성이 완료되면 CI하는데 문제 없음을 체크하는 로직입니다. 

# 워크플로우 이름
name: Backend CI on PR Created, Reopened, or Synchronized

# 트리거: 'develop' 브랜치로 향하는 PR이 생성/재오픈/동기화 될 때 실행
on:
  pull_request:
    branches: [develop]
    types: [opened, reopened, synchronize]

jobs:
  build-and-validate:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4
        with:
          submodules: 'recursive'

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Build Docker image for validation
        env:
          DB_URL: ${{ secrets.DB_URL }}
          DB_USERNAME: ${{ secrets.DB_USERNAME }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
        run: |
          echo "Building Docker image for validation purposes..."

          # 빌드 실행
          docker build \
                --build-arg DB_URL=${{ secrets.DB_URL }} \
                --build-arg DB_USERNAME=${{ secrets.DB_USERNAME }} \
                --build-arg DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
                -t store-manager:test . --progress=plain

          echo "Docker image build successful."

 

아래가 문제의 Dockerfile 코드입니다. 간략한 실행 단계의 설명은 아래와 같습니다.

1. 빌드 단계에서 파라미터를 전달받은 시크릿 값을 환경 변수로 지정하고, 소스 코드를 Docker로 복사하고 빌드합니다.

2. 마지막 단계에서 빌드된 jar를 가지고 application을 실행합니다.

FROM eclipse-temurin:21-jdk-jammy AS builder

WORKDIR /app

ARG DB_URL
ARG DB_USERNAME
ARG DB_PASSWORD

ENV DB_URL=$DB_URL
ENV DB_USERNAME=$DB_USERNAME
ENV DB_PASSWORD=$DB_PASSWORD

COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .

COPY StoreManager_be_api         ./StoreManager_be_api
COPY StoreManager_be_common      ./StoreManager_be_common
COPY StoreManager_be_money       ./StoreManager_be_money
COPY StoreManager_be_test        ./StoreManager_be_test
COPY StoreManager_be_user        ./StoreManager_be_user

RUN ./gradlew build


FROM eclipse-temurin:21-jre-jammy

WORKDIR /app

COPY --from=builder /app/StoreManager_be_api/build/libs/*-SNAPSHOT.jar app.jar

# 애플리케이션이 사용할 포트 명시
EXPOSE 8080

# 컨테이너가 시작될 때 애플리케이션 실행
ENTRYPOINT ["java", "-jar", "app.jar"]

 

 

이 dockerfile 코드의 문제점은 ENV와 ARG를 사용한다는 점입니다.  ENV는 docker image의 마지막 stage에 대한 빌드 layer에 그 기록이 남습니다. (1)

docker 공식 문서

위 dockerfile 코드는 Multi Stage로 구성되어 있고 빌드 단계는 최종 이미지를 빌드하는 단계가 아니기 때문에 layer나 history에 남지 않습니다. 

** 이미지 빌드 시 layer에 남겨진 env 변수들은 "docker inspect 이미지 id" / "docker history 이미지 id"(1) 명령어를 사용해서 확인해볼 수 있습니다. 

 

하지만 final stage에서 builder stage의 이미지를 사용한다면 builder 지정했던 ENV 값들이 그대로 노출됩니다.

# 예시 코드
FROM eclipse-temurin:21-jdk-jammy AS builder

... 빌드 코드들 ...

FROM builder AS final

WORKDIR /app

COPY --from=builder /app/StoreManager_be_api/build/libs/*-SNAPSHOT.jar app.jar

EXPOSE 8080

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

노출된 env

** 이런 비밀키를 가지고 있을 것 같은 이름을 ENV나 ARG에 지정하면, docker build 시 CLI에서는 첫번째와 같은 주의 문구가, Docker Desktop으로 빌드하면 두번째와 같은 주의 문구가 뜹니다. (2)

개선안

Docker에서는 이런 경우를 대비해서 Docker secret를 사용할 것을 권장하고 있습니다.(3)

그 방법으로는 1. secret mount,  2. SSH mount 3. Git authentication for remote contexts 가 있는데, 저는 1번 방법을 사용하였습니다. 이 방식을 사용하면 build 동안에만 사용가능한 secret을 전달할 수 있다고 공식 문서에 나와 있습니다. 

secret mount로 secret를 전달하는 것은 "파일" 혹은 "환경변수"로 전달할 수 있는데, 아직 이 어플리케이션에는 전달해야 할 비밀값들이 많지 않기 때문에 환경 변수를 통한 전달을 사용하였습니다. 

 

그 결과 탄생한 Github Action yml 파일과 Dockerfile은 아래와 같습니다. (각각의 설명을 주석으로 담았습니다.) (4), (5)

# 워크플로우 이름
name: Backend CI on PR Created, Reopened, or Synchronized

# 트리거: 'develop' 브랜치로 향하는 PR이 생성/재오픈/동기화 될 때 실행
on:
  pull_request:
    branches: [develop]
    types: [opened, reopened, synchronize]

jobs:
  build-and-validate:
    runs-on: ubuntu-latest

    steps:
      # Base branch: develop 브랜치로 merge하려는 브랜치
      - name: Checkout source code
        uses: actions/checkout@v4
        # 서브 모듈에 있는 모든 리포지토리의 코드를 불러옵니다.
        with:
          submodules: 'recursive'

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin' # 필수값

      - name: Build Docker image for validation
        # step에서 사용할 환경 변수를 지정해주어, docker build 시 환경 변수를 secret으로 전달할 수 있도록 합니다. 
        env:
          DB_URL: ${{ secrets.DB_URL }}
          DB_USERNAME: ${{ secrets.DB_USERNAME }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
        run: |
          echo "Building Docker image for validation purposes..."

          # 환경 변수를 secret option을 통해 secret 값들을 Docker build container로 전달합니다.
          docker build \
                --secret id=db_url,env=DB_URL \
                --secret id=db_username,env=DB_USERNAME \
                --secret id=db_password,env=DB_PASSWORD \
                -t store-manager:local . --progress=plain

          echo "Docker image build successful."
FROM eclipse-temurin:21-jdk-jammy AS builder

WORKDIR /app

COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .

COPY StoreManager_be_api         ./StoreManager_be_api
COPY StoreManager_be_common      ./StoreManager_be_common
COPY StoreManager_be_money       ./StoreManager_be_money
COPY StoreManager_be_test        ./StoreManager_be_test
COPY StoreManager_be_user        ./StoreManager_be_user

# 보안을 위한 시크릿 파일을 컨테이너에 마운트
# RUN 명령어서 secret을 사용하기 위해 "--mount=type=secret" 옵션을 넣어줍니다. 
RUN --mount=type=secret,id=db_url \
    --mount=type=secret,id=db_username \
    --mount=type=secret,id=db_password \
    # Shell 문법의 지역 환경 변수 설정 및 application 빌드
    DB_URL=$(cat /run/secrets/db_url) \
    DB_USERNAME=$(cat /run/secrets/db_username) \
    DB_PASSWORD=$(cat /run/secrets/db_password) \
    ./gradlew build


FROM eclipse-temurin:21-jre-jammy

WORKDIR /app

COPY --from=builder /app/StoreManager_be_api/build/libs/*-SNAPSHOT.jar app.jar

# 애플리케이션이 사용할 포트 명시
EXPOSE 8080

# 컨테이너가 시작될 때 애플리케이션 실행
ENTRYPOINT ["java", "-jar", "app.jar"]

 

위와 같은 코드를 통해 Github Action에서도 secret을 Docker build container 안에 넣고 동작시킬 수 있었습니다. 

Github Action 성공

 

 

** Github Action 중 사용되는 Secret은 Github Repository에서 아래 영역에서 세팅하였습니다.

Settings -> Security Section의 Secrets and Variables -> Actions 진입 -> Repository secrets에 secret 지정

 

출처

(1) https://docs.docker.com/reference/dockerfile/#env

 

Dockerfile reference

Find all the available commands you can use in a Dockerfile and learn how to use them, including COPY, ARG, ENTRYPOINT, and more.

docs.docker.com

(2) https://docs.docker.com/reference/build-checks/secrets-used-in-arg-or-env/

 

SecretsUsedInArgOrEnv

Sensitive data should not be used in the ARG or ENV commands

docs.docker.com

(3) https://docs.docker.com/build/building/secrets/ 

 

Secrets

Manage credentials and other secrets securely

docs.docker.com

(4) https://docs.docker.com/reference/cli/docker/buildx/build/#secret

 

docker buildx build

 

docs.docker.com

(5) https://docs.gradle.org/current/userguide/build_environment.html#sec:project_properties

 

Configuring the Build Environment

Configuring the build environment is a powerful way to customize the build process. There are many mechanisms available. By leveraging these mechanisms, you can make your Gradle builds more flexible and adaptable to different environments and requirements.

docs.gradle.org