해당 글에서는 Github Actions로 도커 이미지를 이용한 스프링 애플리케이션 자동 배포 방법에 대해 다룹니다.
사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다.
해당 프로젝트 github: https://github.com/mocacong/Mocacong-Backend
들어가며
작업을 하고 PR이 머지됐을 때, 업데이트된 코드 내용으로 바로 배포가 되면 얼마나 좋을까요? Github Actions를 이용하면 main (그 외 브랜치도 가능) 브랜치에 머지되자마자 자동으로 배포되게 할 수 있습니다. 모카콩에서는 Github Actions와 Docker를 이용하여 자동 배포되도록 구현했습니다.
Code Deploy와 S3를 이용한 배포 방법도 있지만, S3는 이미지 파일이나 로그와 같은 정적 파일들을 올리는 용도로 사용하는 것이 맞다고 판단하였습니다. 그 외에 JIB를 이용하여 스프링 애플리케이션 배포를 하는 방법도 생각해보았습니다. JIB는 구글에서 자바 애플리케이션 전용 컨테이너로 만들어 배포할 수 있게 지원해준 플러그인입니다. 그렇기 때문에 docker 기반 배포보다 더 빠르다는 장점이 존재합니다. 그럼에도 불구하고 도커 기반 배포 방식을 선택한 이유는, 이후에 새로운 환경들이 추가될 때 이에 구애받지 않고 도커 컨테이너 명령어로 편리하게 관리할 수 있다는 장점을 고려했기 때문입니다. 그리고 도커 관련 공부를 진행해보고 싶었던 것도 있습니다 ㅎㅎ
이러한 이유로 도커 기반으로 배포를 수행했습니다.
동작 원리
간단한 구성도를 보면서 동작 원리를 설명드리겠습니다.
- PR이 develop 브랜치에 머지가 되면 Github Actions 스크립트가 수행됩니다. 모카콩은 현재 배포하지 않은 상태이기 때문에 임시로 develop 브랜치에 트리거를 걸어두었으며, 이후 배포가 되면 트리거를 main 브랜치로 이동시킬 예정입니다.
- Github Actions에서 스프링 애플리케이션 jar 파일을 빌드합니다.
- DockerFile을 바탕으로 생성된 jar 파일을 도커 이미지로 만듭니다.
- 만들어진 도커 이미지를 docker hub에 로그인 후 push합니다.
- Github Actions 스크립트로 EC2에 접근하고, push한 도커 이미지를 pull하여 실행합니다.
- 배포가 완료됩니다.
dockerfile
1
2
3
4
5
|
FROM openjdk:11
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar", "-Dspring.profiles.active=prod", "/app.jar"]
|
cs |
- 모카콩 앱의 서버는 스프링으로 개발하고 있으며, Java 11을 이용합니다. 따라서 openjdk:11 이미지를 기반으로 사용할 것입니다.
- jar 파일은 build/libs 폴더에 생성되므로 JAR_FILE을 ARG로 위와 같이 지정해줍니다.
- jar 파일명을 신경쓰지 않고 app.jar 로 통일하도록 COPY 명령어를 사용합니다.
- ENTRYPOINT 로 컨테이너가 시작될 때 수행할 명령어를 적어줍니다. prod 환경 배포 목적 이미지이므로 -Dspring.profiles.active=prod 와 함께, jar 파일 수행 명령어를 적어줍니다.
docker-compose.yml
1
2
3
4
5
6
7
8
9
10
|
version: '3'
services:
app:
container_name: app
image: mocacong/mocacong
expose:
- "8080"
ports: # host - container 포트 매핑
- "8080:8080"
|
cs |
모카콩은 Nginx, 온프레미스 RDB 없이 AWS ALB, RDS를 이용하고 있습니다.
그렇기 때문에 스프링 애플리케이션만 컨테이너로 만들어 수행해줍니다.
- version은 3으로 지정해주면 3점대 버전 중 최신 버전으로 자동으로 등록됩니다.
- 모카콩은 앱이기 때문에 app 으로 컨테이너명을 지정했습니다. 다른 이름으로 해도 무방합니다.
- image는 도커 허브에 올라간 ${도커 허브 유저명}/${도커 repository명} 으로 지정합니다.
- 스프링 애플리케이션을 띄울 포트를 expose에 적어줍니다.
- 요청이 들어오는 host와 도커 컨테이너 포트를 매핑해줍니다. 로드밸런서인 AWS ALB에서 80 포트나 443 포트 요청은 8080으로 보내주도록 했으므로 host 포트, 컨테이너 포트 모두 8080으로 작성했습니다.
build.gradle
jar {
enabled = false
}
스프링부트 2.5 버전부터는 bootJar뿐만 아니라 Jar로부터도 jar 파일이 생성됩니다. 따라서 jar 파일이 두 개가 됩니다. 이렇게 되면 `Step 3/4 : COPY ${JAR_FILE} app.jar` 부분에서 `When using COPY with more than one source file, the destination must be a directory and end with a /` 에러가 발생하며 Github Actions CD workflow가 실패합니다.
Jar로부터 생성된 plain jar 파일은 애플리케이션 의존성을 모두 포함하는 것이 아닌, 클래스 소스코드와 리소스 파일 정도만 포함합니다. 그렇기 때문에 plain jar 파일은 생성되지 않도록 build.gradle에 jar { enabled = false }을 추가해줍시다.
cd-backend.yml
CI 과정은 생략하고 CD 관련 내용만 존재합니다. 코드와 주석으로 설명을 대체합니다.
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
name: Java CD with Gradle and Docker
on:
push:
branches:
- develop # develop 브랜치에 push될 경우를 트리거로 설정
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Java JDK 11 버전을 이용합니다.
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
# github secret 환경변수로 적어둔 PROD_YML로 application-prod.yml파일을 생성합니다.
# 환경변수가 지나치게 많아짐을 방지하기 위해 PROD_YML 변수를 만들었습니다.
- name: make application-prod.yml
run: |
cd ./src/main/resources
touch ./application-prod.yml
echo "${{ secrets.PROD_YML }}" >> ./application-prod.yml
shell: bash
# gradlew에 실행 권한을 부여합니다.
- name: Grant execute permisson for gradlew
run: chmod +x gradlew
# test는 CI 과정에서 수행되므로 여기서는 `-x`로 테스트를 생략했습니다.
# `--stacktrace`로 더 자세한 로그가 출력되게 해줍니다.
- name: Build with Gradle (without Test)
run: ./gradlew clean build -x test --stacktrace
# docker hub에 로그인하고 이미지를 빌드합니다. 이후에 push를 진행합니다.
# docker_username을 적지 않으면 push 시에 요청이 거부될 수 있습니다.
- name: Docker Hub build & push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }} .
docker images
docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}
# EC2에 접속하고 배포합니다.
- name: Deploy to Prod WAS Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.WAS_HOST }}
username: ${{ secrets.WAS_USERNAME }}
key: ${{ secrets.WAS_KEY }}
port: ${{ secrets.WAS_SSH_PORT }}
# docker-compose가 있는 디렉토리로 이동해야 수행됩니다.
script: |
cd /home/ubuntu/Mocacong-Backend/
sudo docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
sudo docker rm -f $(sudo docker ps -qa)
sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}
sudo docker-compose up -d
sudo docker image prune -f
|
cs |
여기서 주의할 점은, Deploy to Prod WAS Server 에서 script를 수행할 때 docker-compose가 있는 환경으로 이동해야된다는 점입니다. 그렇지 않으면 Github Actions CD workflow는 통과하지만, 애플리케이션 배포가 제대로 되지 않을 수 있습니다.
이제 성공적으로 자동 배포가 진행됩니다~
트러블 슈팅 - 502 Bad Gateway
Github Actions CD workflow가 통과했습니다. 그렇기 때문에 제대로 배포가 됐는지 확인하기 위해 사이트를 들어가보았습니다. 하지만 저를 반기는 것은 업데이트된 버전이 아닌, 502 Bad Gateway였습니다…
처음에는 에러 코드가 502인 만큼 로드밸런서 및 포트 문제를 의심했습니다.
Github Actions workflow에서도 out이 아닌 err로 출력되길래 더욱 의심했던 것 같습니다. docker-compose.yml을 올바르지 않게 작성했기 때문에 발생한 문제라 생각했으며, 계속 원인을 찾아보았습니다.
하지만 사실 docker-compose.yml에 문제가 없음은 물론이고, err로 출력된다 하더라도 Creating App은 문제없이 진행된 것이었습니다! 포트에 문제는 없었죠. 아는 지인이 해당 문제 상황을 보고, 컨테이너가 실행됐다가 바로 꺼졌을 확률도 있으니 docker ps -a 명령어로 확인해보라고 조언을 해주었습니다.
docker ps는 현재 실행되고 있는 컨테이너만 보여주고, docker ps -a는 모든 컨테이너 목록을 다 보여줍니다. 보시는 것과 같이, docker ps로는 아무것도 뜨지 않았지만 docker ps -a로 조회하니 생성되자마자 Exited된 모습을 확인할 수 있습니다.
컨테이너 실행이 되긴 했으니, docker logs 명령어로 로그를 확인해볼 수 있습니다. docker logs {컨테이너 아이디} 명령어를 통해 로그를 확인해보았습니다.
이럴수가, 환경변수를 삽입하지 않았던 것입니다! PROD_YML 환경변수가 있어야 application-prod.yml 이 만들어지는데, PROD_YML 환경변수가 아예 없었던 것이죠.
환경변수를 만들어주고, 다시 re-run 시켜서 배포하니 502 bad gateway가 사라지고 health check에 통과한 모습을 볼 수 있었습니다! 로그의 중요성을 다시 한 번 느끼는 순간이었습니다.