Spring boot로 회사 프로덕트를 개발 중에 있었다. 초기 스타트업이다보니 이렇다할 인프라가 구축되어있지 않았다. 단순하게 CLI단에서 수동으로 EC2에서 도커 컨테이너로 배포하는 수준이였다. 개발자로서 개발만 해야되지만 배포까지 신경쓰다보니 이에 대한 피로감이 증가하고 있었다. 다행히도, 개발 스프린트를 어느정도 끝낸 상태여서 이번에 인프라를 구축할 시간이 생겼다. 먼저 CI/CD부터 손보기로 했다.
이전에 React 프로젝트를 Github action을 통해 CI/CD를 구축해본 적이 있다. 이 때 고려했던 점이 환경변수를 어떻게 관리할지였는데, 다행히 프론트측에서 관리하는 env 값이 많지 않았다. 그래서 Github Action에서 Environment에 작성한다음 Action 내에서 echo를 통해 .env파일을 생성하는 식으로 관리해줬다.
- name: Create .env
run: |
echo "SERVER_URL=${{ secrets.SERVER_URL }}" >> .env
이렇게 env파일을 생성하는 step에서 생성해줬다. 하지만, 백엔드 프로젝트에서도 똑같이 하려고 했는데 문제가 발생했다.
echo로 때려박기엔 관리할 환경변수가 너무 많았다. 대충 세봤는데 30개 넘었다. 30개의 환경변수를 echo로 넣어준다니 말도 안된다. 유지보수도 굉장히 힘들어질 것이다. 프로덕션/개발/테스트 버전으로 환경변수가 나뉠텐데 이것들을 또 Github Action에서 관리하기엔 너무 많아진다(-> 30 * 3 = 90 즉, 90개의 환경변수를 전부 치기엔.. 선 넘었다고 생각했다).
그래서 이렇게 배포환경을 분리하여 관리해주는 녀석이 없나 싶어서 찾고 있다가 Spring Cloud Config를 찾았다. 참고
Spring Cloud Config는 환경변수들을 HTTP 기반 리소스를 통해 제공한다. 한마디로 환경변수만 관리하는 API 서버라고 보면 된다. 크게 Server와 Client로 나뉘는데, Server가 환경변수들을 중앙에서 관리하고 Client가 Server에게 요청하여 프로젝트이름과 배포환경 이름을 토대로 환경변수들을 참조할 수 있다.
공식 문서에서는 장점을 다음과 같이 말하고 있다.
환경변수 제공을 위한 HTTP 리소스 기반 API(KeyValue 혹은 Yaml 형식)
환경변수의 민감한 정보들 암호화 및 복호화
@EnableConfigServer를 사용하여 SpringBoot 애플리케이션에 쉽게 내장 가능
내가 생각하는 단점과 이곳에서 언급된 단점이다.
통신 장애가 일어나면 환경변수 참조 불가능
설정 정보가 우선순위에 의해 덮어쓰기 가능
우선순위가 덮어쓰인다는 얘기가 어떤 의미인지 몰라서 위 포스터를 들여다보았다. 프로젝트 및 Config 저장소의 적용 순위가 다르다는 것이었다. 결론적으로, 프로젝트 내부에 있는 설정파일이 Config 저장소의 설정파일보다 우선적으로 적용된다(또 새로운 것을 알아간다. 조심하자).
이곳을 참고하여 진행했다. 감사합니다 GOAT.
모든 Config들을 관리할 Server, 이를 참조할 Client로 구성된다.
새로운 프로젝트를 만들고 build.gradle에서 actuator, config-server 의존성을 추가해준다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
...
ext {
set('springCloudVersion', "2023.0.0-RC1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-config-server'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
...
그리고 메인 어플리케이션에 @EnableConfigServer를 추가한다.
...
@EnableConfigServer
@SpringBootApplication
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}
...
기본적인 세팅은 끝났고, 서버에서 관리할 환경변수들을 정리해줘야 한다.
깃허브 private 레포를 따로 만들어야 한다. 콘피그 서버에서 내부 파일로 갖고 있는 것이 아닌, 외부 저장소에서 가져와야하기 때문이다. 필자는 이렇게 만들어줬다.

설정파일 이름의 규칙은 다양한데 그중에서 {application-name}-{profile}로 작성했다(나중에 국가별 서비스를 할 경우 label을 추가해서 구분해줘도 좋을 것 같다!). 내용물은 스프링에서 일반적으로 사용되는 환경변수들을 작성해주면 된다.
하지만 문제가 있다. 일반적으로 미치지 않고서야 민감한 정보들을 public 레포로 생성하지 않을 것이다. 하지만 외부 Config 서버는 private 레포를 참조해야 한다. 콘피그 서버가 깃 저장소를 참조할 때는 대칭키를 사용해야 한다고 한다. 대칭키를 만들고 이를 github deploy key 혹은 ssh key로 등록해야 한다. 필자는 ssh를 통해 콘피그 레포를 참조할 것이기에 ssh key로 하겠다. (참고한 포스터는 deploy key로 진행하는데, 개인의 ssh key보다 프로젝트 단위의 deploy key가 옳은 방식인 것 같다. 조만간 이 방식으로 바꿀 듯 하다). 대칭키를 생성하는 과정은 생략하겠다. .pub에 담긴 공개키를 아래처럼 개인 설정에서 추가해준다.

그리고 콘피그들이 저장된 저장소의 ssh 주소를 가져온다. 레포에 가서 아래 SSH에서 확인할 수 있다.

그리고 다시 Config Server로 돌아와, 설정파일에 아래코드를 추가한다. private-key는 아까 생성한 키 파일 중 개인 키의 내용을 넣어주면 된다. 단, properties파일의 경우, 각 행마다 \n\를 붙여야 한다.
# application.properties
spring.cloud.config.server.git.uri= git@github.com:<your-repo>.git
spring.cloud.config.server.git.ignore-local-ssh-settings=true
spring.cloud.config.server.encrypt.enabled=false
spring.cloud.config.server.git.default-label= main
spring.cloud.config.server.git.private-key= -----BEGIN RSA PRIVATE KEY-----\n\
...생략
-----END RSA PRIVATE KEY-----
encrypt.key= <your key>
이제 서버측으로부터 받아올 준비가 되었다. 포스트맨에서 실험을 해보자. 필자의 어플리케이션 이름은 workit이고, 배포할 프로필은 dev, prod, local이 있다. 만약 local의 환경변수를 받아오고 싶다면 http://배포주소:port/workit/local를 호출하면 된다.

잘 나온다.
이제 환경변수들을 받을 Client 차례다. build.gradle에서 아래 한줄을 작성하여 의존성을 추가한다.
implementation 'org.springframework.cloud:spring-cloud-starter-config'
그리고 application.properties를 아래처럼 작성한다.
spring.application.name= <application-name>
spring.profiles.active= <profile-name>
spring.cloud.config.name= <application-name>
spring.config.import=optional:configserver:http://<config-server-address>:<port>
spring.cloud.config.profile= <proifle>
encrypt.key= <your key>
그 후, 실행해보면 아래처럼 확인할 수 있다.

추가로, Actuator라는 의존성을 추가하면 도움이 된다. 콘피그 서버의 엔드포인트에 /actuator/refresh를 GET메소드로 호출하면 서버가 새로고침되어 외부 콘피그 저장소를 다시 받아올 수 있다. 이러면 깃 저장소에 변화가 생겼을 때마다 새로고침을 해주면 된다.
HTTP 기반 통신으로 환경변수를 주고 받을텐데, Response에 대놓고 정보들을 주고 받아도 될까?라는 의문이 생긴다.
다행히 이를 대칭키를 통해 해결할 수 있다. 바로 위에서 본 encrypt.key인데, 여기에 암/복호화 키를 입력한다. 그후, 배포서버의 엔드포인트에 POST메소드로 /encrypt, /decrypt를 호출하여 암/복호화 결과를 볼 수 있다.


암/복호화가 잘 되었다. 이제 암호화 결과들을 콘피그 저장소에 있는 properties 파일에 넣어주면 된다. 이 때, 스프링에서 "이건 암호화가 된거야"라는 정보를 알려주기 위해 앞에 {cipher}라는 것을 붙여주면 된다.
spring.datasource.username= {cipher}0d7f19880f38ccac77bd81e13afed4cd188fde75cd1d450d7ad0cd23e13e7f84 # test
이러면 Config Client가 자동으로 복호화하여 값을 읽는다.
도커단에서 크게 해줄 것은 없고, 배포하고자 하는 서버(=Config Client)에 도커파일을 아래처럼 작성하자.
FROM openjdk:11
ARG JAR_FILE=/build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
빌드된 bootJar 파일을 컨테이너 내부로 복사한다. 그리고 컨테이너를 실행할 때 복사한 jar파일을 실행한다. 빌드된 jar는 후술할 Github Action에서 보자.
깃허브 액션은 test, build, deploy 등 Github Repo 안에서 이루어지는 CICD 플랫폼이다. 기존 CICD 플랫폼인 젠킨스와는 다르게, 온프레미스가 아닌 깃허브 내 workflow를 통해 관리할 수 있고 젠킨스의 플러그인보다 깃허브의 Action을 관리하는 것이 더 편하다. 따로 설치하는 것이 아닌, Action의 이름과 태그만 가져오면 바로 사용할 수 있다.
다만, Action을 돌리기 위해 self-hosted runner를 설치해야 하는데 이 과정은 굉장히 쉬우므로 생략하겠다.

설정파일에 들어갈 env 변수들은 프로젝트의 레포 Settings -> Secrets and variables -> Actions에서 관리한다.

Enviroment Secretes
환경별 변수를 관리한다. 스프링에서 prod, dev와 같은 프로필 이름으로 나눈 것처럼, 이곳에서도 환경별로 나눌 수 있다. 예를 들어, Product 버전의 HOST와 Develop 버전의 HOST 변수를 만들고 싶다면 Product, Develop 환경을 만들고 각 환경에 HOST라는 변수를 만들어주면 된다.
Repository secrets
해당 Repository에서만 사용할 환경변수이다. 이 레포 안에서 공통적으로 사용할 변수들을 정리하면 된다. encrypt.key와 같은 정보들을 넣어주면 된다.
Organization secrets
이건 Organization에 소속된 레포에만 해당되는데, 필자는 회사 Organization에서 작업하기 때문에 잘 쓰고 있다. 이곳에서 회사의 도커 레지스트리 정보와 같은, Organization에서 재사용될만한 변수들을 작성해주면 되겠다.
이제 CICD를 작동시킬 Workflow를 작성해야한다. 먼저 전제조건은 다음과 같다.
1. Config Client의 application.properties를 action을 통해 작성해줄 것이다
이는 Config Server의 엔드포인트와 암호화 키를 보호하기 위함이다. 이 녀석들을 위에서 말한 secrets에 추가해주도록 하자.
2. Production과 Development 환경으로 배포할 것이다
master와 develop 브랜치에 push가 일어날 경우 자동으로 배포하게끔 할 것이다. 또한 ec2 한 곳에 배포하기 때문에 서로의 포트는 다르게 설정한다.
먼저, 각 브랜치의 push를 추적하는 yml파일을 작성한다
deploy-prod.yml
name: production
on:
push:
branches: [master]
workflow_dispatch:
jobs:
deploy:
uses: ./.github/workflows/deployment.yml
with:
env: Production
secrets: inherit
deploy-dev.yml
name: develop
on:
push:
branches: [develop]
workflow_dispatch:
jobs:
deploy:
uses: ./.github/workflows/deployment.yml
with:
env: Development
secrets: inherit
on push branches : 특정 브랜치에 푸시를 트리거로 설정한다. 만약 dev브랜치로 설정했다면, dev에 푸시가 될 때마다 해당 yml이 실행된다.
on workflow_dispatch : 프로젝트 레포의 action 탭에 가면 버튼이 생길텐데, 이 버튼을 클릭하면 해당 yml을 실행한다.

uses: ./.github/workflows/deployment.yml : . product 환경이나 develop 환경이나 결국 "배포"하는 기능은 똑같기 때문에 deployment.yml이란 것을 따로 작성했다. 함수처럼 재사용할 수 있다는 것이다. 작성한 함수를 호출하겠다 라고 보면 되겠다.
inherit : secrets 정보들을 넘겨준다는 뜻으로, deployment.yml도 시크릿 정보들을 참조할 수 있다. 이제 deployment.yml을 보자.
deployment.yml
name: Deployment Function
on:
workflow_call:
inputs:
env:
type: string
required: true
jobs:
build:
environment: ${{ inputs.env }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
- name: Add Properties
run: |
echo "spring.application.name=${{secrets.APPLICATION_NAME}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.cloud.config.name=${{secrets.APPLICATION_NAME}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.config.import=${{secrets.SPRING_CONFIG_IMPORT}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "encrypt.key=${{secrets.SPRING_CONFIG_ENCRYPT_KEY}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
- name: Set Spring Profile
run: |
if [ ${{inputs.env}} = "Production" ]; then
echo "spring.cloud.config.profile=prod" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.profiles.active=prod" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
elif [ ${{inputs.env}} = "Development" ]; then
echo "spring.cloud.config.profile=dev" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.profiles.active=dev" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
else
echo "spring.cloud.config.profile=default" >> $GITHUB_WORKSPACE/src/main/application.properties
fi
- name: Check Properties
run: |
cat $GITHUB_WORKSPACE/src/main/resources/application.properties
- name: Chmod Gradlew
run: |
chmod +x ./gradlew
- name: Build with Gradle
run: |
./gradlew bootJar
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
version: v0.7.0
- name: Login to Docker-Hub
uses: docker/login-action@v1
with:
username: ${{secrets.DEV_DOCKERHUB_USERNAME}}
password: ${{secrets.DEV_DOCKERHUB_PASSWORD}}
# - name: Build and Push
# id: docker_build
# uses: docker/build-push-action@v2
# with:
# push: true
# tags: ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }}
- name: Build and Push
run: |
docker build -t ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }} .
docker push ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }}
- name: Pull and Restart Docker Container
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PRIVATE_KEY }}
script: |
docker stop ${{ secrets.CONTAINER_NAME }}_${{inputs.env}}
docker rm ${{ secrets.CONTAINER_NAME }}_${{inputs.env}}
docker image rm $(docker images -q)
docker pull ${{ secrets.DOCKER_HUB_REPO }}:${{inputs.env}}
docker run -d --name ${{ secrets.CONTAINER_NAME }}_${{ inputs.env }} -p ${{ secrets.DEPLOY_PORT }}:8080 ${{ secrets.DOCKER_HUB_REPO }}:${{ inputs.env }}
name: Deployment Function
on:
workflow_call:
inputs:
env:
type: string
required: true
해당 yml이 호출될 때, 같이 건너오는 파라미터를 읽어온다. 위 deploy.yml을 통해 건너온 env를 불러온다. 이를 통해 Production인지, Development인지 구분할 수 있다. 이 파라미터를 사용하려면 yml파일 내에서 ${{ inputs.env }}를 참조하면 된다.
jobs:
build:
environment: ${{ inputs.env }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
environment는 위에서 설정한 secrets 중, environment secrets에서 정의된 환경을 설정할 수 있다. Production 버전의 HOST와 PORT를 가져오고 싶다면 environment: Production 이라고 적으면 된다. 하지만 우린 deploy.yml에서 재사용할 것이기에 파라미터를 작성하면 된다. 해당 yml에서 실행되는 step들은 우분투 환경에서 시작되며, 자바 11버전에서 실행될 것이다.
- name: Add Properties
run: |
echo "spring.application.name=${{secrets.APPLICATION_NAME}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.cloud.config.name=${{secrets.APPLICATION_NAME}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.config.import=${{secrets.SPRING_CONFIG_IMPORT}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "encrypt.key=${{secrets.SPRING_CONFIG_ENCRYPT_KEY}}" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
- name: Set Spring Profile
run: |
if [ ${{inputs.env}} = "Production" ]; then
echo "spring.cloud.config.profile=prod" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.profiles.active=prod" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
elif [ ${{inputs.env}} = "Development" ]; then
echo "spring.cloud.config.profile=dev" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
echo "spring.profiles.active=dev" >> $GITHUB_WORKSPACE/src/main/resources/application.properties
else
echo "spring.cloud.config.profile=default" >> $GITHUB_WORKSPACE/src/main/application.properties
fi
- name: Check Properties
run: |
cat $GITHUB_WORKSPACE/src/main/resources/application.properties
아까 전제조건에서 말했듯이, 우린 엔드포인트와 암호화 키를 지키기 위해 설정파일들을 CICD 내에서 작성할 것이다. 이정도 echo는 쓸만하니 이렇게 작성해보았다. 사실 이부분은 정답이 없는 것 같다. Profile을 설정하는 Step의 경우, 쉘스크립트 형식으로 Production과 Development 버전을 나누어 설정파일을 작성했다.
- name: Chmod Gradlew
run: |
chmod +x ./gradlew
- name: Build with Gradle
run: |
./gradlew bootJar
스프링 프로젝트를 빌드하는 곳이다. chmod를 한 이유는 gradlew를 실행할 때 간혹 permission denied 에러가 발생하여 권한 조정을 하기 위함이다. 그리고 빠른 빌드를 위해 bootJar만 빌드하도록 했다(나중에 PR이 올라오면 자동으로 테스트가 실행되는 workflow도 작성해볼 예정이다)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
version: v0.7.0
- name: Login to Docker-Hub
uses: docker/login-action@v1
with:
username: ${{secrets.DEV_DOCKERHUB_USERNAME}}
password: ${{secrets.DEV_DOCKERHUB_PASSWORD}}
# - name: Build and Push
# id: docker_build
# uses: docker/build-push-action@v2
# with:
# push: true
# tags: ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }}
- name: Build and Push
run: |
docker build -t ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }} .
docker push ${{ secrets.DOCKER_HUB_REPO}}:${{ inputs.env }}
도커 허브에 로그인하고 빌드된 도커파일을 등록하는 과정이다. 난 환경이름을 태그로 지정하여 이미지를 굽도록 했다(나중에 버전으로 바꾸고 싶다!). Build and Push 중 한 가지가 주석으로 되어있는데, 이유는 해당 액션을 사용하면 build된 jar를 가져오질 못 한다. 로그를 확인해보니 checkout한 파일들만 추적해서 도커 이미지를 구워서, CICD 내에서 생성된 빌드파일을 추적하지 못하여 no such file or directory 에러가 발생한다. 위 도커 빌드의 버전을 0.7.0으로 변경한 이유 역시 해당 에러를 고쳐보기 위한 디버깅 중 하나였다.

기나긴 삽질이였다..
- name: Pull and Restart Docker Container
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.HOST_USERNAME }}
key: ${{ secrets.PRIVATE_KEY }}
script: |
docker stop ${{ secrets.CONTAINER_NAME }}_${{inputs.env}}
docker rm ${{ secrets.CONTAINER_NAME }}_${{inputs.env}}
docker image rm $(docker images -q)
docker pull ${{ secrets.DOCKER_HUB_REPO }}:${{inputs.env}}
docker run -d --name ${{ secrets.CONTAINER_NAME }}_${{ inputs.env }} -p ${{ secrets.DEPLOY_PORT }}:8080 --network noris_net -v /etc/localtime:/etc/localtime:ro ${{ secrets.DOCKER_HUB_REPO }}:${{ inputs.env }}
해당 step에서 사용되는 HOST, HOST_USERNAME, PRIVATE_KEY는 각각 배포할 서버의 호스트, 유저이름 그리고 ssh 개인 키이다. Action측에서 이 3가지 정보로 ssh접속하여 명령어들을 실행할 것이다. 그러면 우리가 구워둔 이미지를 도커허브를 통해 다운로드되고 컨테이너로 실행될 것이다.
역대급으로 긴 정리였던 것 같다. 개선해야할 점들과 해보고 싶은 것들을 정리해보자면,
이렇게 Spring Cloud Config 서버를 CI/CD에서 접속할 수 있도록 설정하면 Cloud Config Server를 어디에서도 접속할 수 있는거 아닌가요? 관련하여 계속 고민하고 있어 댓글 답니다.....