본문으로 건너뛰기

셀럽잇 CI/CD 발전기 - (3) docker와 github actions를 통한 배포 자동화

· 약 34분

셀럽잇 CI/CD 발전기 - (3) docker와 github actions를 통한 배포 자동화


셀럽잇 CI/CD 발전기 시리즈




🧐 서론

안녕하세요. 셀럽잇의 백엔드 말랑입니다.

다른 팀들과 마찬가지로 셀럽잇 팀도 지난주에 실제 서버에 배포하는 과정을 진행했는데요, 그 과정에서 서버가 거의 죽어가는 상황이 발생했습니다 😢

front

그 당시 서버의 여유 메모리를 확인해보니 이미 Swap Memory2GB 쓰고 있는 상황이었음에도 20~50mb 정도밖에 남지 않았습니다.

이 문제는 일시적으로 발생했어서 지금 다시 실행해보면 최소 500Mb 정도의 여유 메모리가 남게 되는데요,
지금 당장은 문제가 되지 않더라도 언젠가는 문제가 될 수 있기 때문에 이를 해결해보려 합니다.






🧐 해결 방법 생각하기

가장 간단히 생각할 수 있는 방법으로는 Swap Memory를 더 많이 할당하는 것입니다.

다음 RedHat의 문서에 따라 서버의 RAM이 2GiB인 저희는 2배인 4GB 정도를 할당하는 것으로 가장 무난하게 문제를 해결할 수 있습니다.
스토리지 용량도 20GB를 할당하여 사용하고 있으므로, 4GB의 Swap Memory로 인해 문제가 생길 것이라고는 판단되지 않습니다.

위 방법으로 해결하는 것이 가장 간단하겠지만, 이왕 문제가 발생한김에 기존에 불편하다고 느꼈던 부분들도 모두 해결하기 위해 다른 방법을 사용하기로 결정했습니다.






🧐 추가적인 불편 사항

(절대 도커 쓰고싶어서 느끼는 억지 불편함이 아님을 밝힙니다)

셀럽잇은 배포 자동화를 위해 프론트엔드와 백엔드별로 배포 스크립트를 만들어둔 후, Github Actions의 Self Hosted Runner를 통해 각 서버의 배포 스크립트를 실행하고 있습니다.

이를 위해서는 다음과 같은 사전 작업이 필요합니다.

  1. 각 서버(dev, prod)에 javayarn, node 등 프로그램을 실행하기 위한 환경이 미리 설정되어 있어야 합니다.
  2. 각 서버에 필요한 배포 스크립트를 미리 작성해서 넣어주어야 합니다.
  3. 배포 과정에 변경 사항이 생긴다면 각 서버의 스크립트를 동일하게 변경해주어야 합니다.

위는 모두 다 셀럽잇 팀이 배포를 진행하며 겪었던 어려움과 귀찮음이며, 무엇보다 문제인 점은 우테코 교육장 내에서만 EC2에 ssh 로 접근할 수 있기에 위 3개의 작업 모두 퇴근한 뒤 집에서는 수행할 수 없다는 것이었습니다.



우테코가 끝난 뒤 프로젝트를 지속하기 위해 서버를 따로 사용할 경우에도 위와 같은 설정을 또 진행해 주어야 하는데요, 상상만 해도 너무 귀찮은 나머지 이러한 불편함을 해소하고자 🐳 Docker를 도입하기로 했습니다.






🧐 Docker를 사용한 CD 아키텍처

이번에 구성한 CD 아키텍처는 다음과 같습니다. cicd

전체적인 Flow는 다음과 같습니다.

  1. main 브랜치에 pr에 merge되는 순간 github actions의 CD workflow가 동작합니다.
  2. workflow에서는 Dockerfile을 통해 react 혹은 spring 애플리케이션 이미지를 생성한 뒤 DockerHub에 Push합니다.
  3. 이후 EC2의 self hosted runners를 동작시켜 DockerHub에서 Dockerfile을 Pull 받습니다.
  4. Pull 받은 Dockerfile을 실행시킵니다.

이제부터 위 아키텍처를 차근차근 구성해 나가도록 할텐데요, 그 전에 Docker에 대해서 정말 간단히만 알아보도록 하겠습니다. 자세한 내용은 공식문서를 포함한 여러 좋은 자료들이 있으므로 이들을 참고해주시면 좋을 것 같습니다.😎



🚀 Dockerfile이 뭔가요?

Docker Image를 만들기 위한 설정 파일입니다.



🚀 Docker Image는 뭔가요?

코드, 런타임, 시스템 도구, 라이브러리 및 설정과 같은 응용 프로그램을 실행하는 데 필요한 모든 것을 포함하는 실행 가능한 패키지입니다. Docker Image를 실행시킴으로써 Docker Container가 생성됩니다.



🚀 Docker Container는 뭔가요?

컨테이너는 이미지의 실행 가능한 인스턴스입니다.
이미지로부터 컨테이너가 생성되며, 컨테이너는 코드와 모든 종속성을 패키지화하여 응용 프로그램이 하나의 컴퓨팅 환경에서 실행될 수 있도록 합니다.

즉 쉽게 말해 컨테이너 속에 Spring 등의 애플리케이션이 들어있고, 컨테이너를 실행함으로써 내부의 애플리케이션이 실행되는 것이라 생각하면 편합니다.








🧐 백엔드 CD 플로우를 로컬에서 실행해보기

Docker를 통한 CD workflow를 작성하기 전에, 어떤 식으로 동작하는지를 알아야겠죠?
그래서 우선 전체적인 과정을 로컬에서 직접 진행한 뒤, 해당 과정을 바탕으로 workflow를 작성하여 실행하도록 하겠습니다.
(이 과정은 로컬에 도커가 깔려 있다고 가정하고 진행합니다. 이에 대해서는 이미 여러 블로그나 공식문서에서 설치법을 알려주기 때문에 넘어가도록 하겠습니다.)



🚀 Dockerfile 작성하기

작성된 Dockerfile은 아래와 같습니다.

FROM amazoncorretto:17-alpine-jdk

WORKDIR /app

COPY ./build/libs/celuveat-0.0.1-SNAPSHOT.jar /app/celuveat.jar

CMD ["java", "-jar", "celuveat.jar"]

한 줄 한 줄 살펴보도록 하겠습니다.



FROM amazoncorretto:17-alpine-jdk

Docker 이미지 생성 시 기반이 되는 이미지 레이어를 정의하는 부분입니다.
저희의 프로젝트는 자바 17버전을 사용하므로 amazoncorretto의 jdk 17버전을 Base image로 가지고 오도록 합니다.



WORKDIR /app

도커 컨테이너 내에서의 작업 디렉토리를 /app으로 설정합니다.
이후 진행되는 작업들은 모두 /app 내부에서 진행됩니다.



COPY ./build/libs/celuveat-0.0.1-SNAPSHOT.jar /app/celuveat.jar

COPY를 통해 컨테이너 외부의 파일을 컨테이너 내부로 복사할 수 있습니다.

빌드된 jar파일은 컨테이너 내부에서 실행해야 하므로, COPY 명령어를 통해 컨테이너의 /app 디렉토리(WORKDIR)의 내부로 이동시켜줍니다.



CMD ["java", "-jar", "celuveat.jar"]

Jar 파일을 실행시키는 부분입니다.






🚀 Dockerfile을 통해 Docker Image 빌드하기

이제 위 Dockerfile을 빌드하여 Docker Image를 생성해보도록 하겠습니다.

Dockerfile의 위치는 다음과 같습니다. Dockerfile


Dockerfile을 통해 Docker Image를 빌드하기 전에 Jar파일을 우선 빌드해야 합니다.

./gradlew bootJar


이후 다음 명령어를 통해 이미지를 빌드합니다.

docker build -t celuveat/celuveat ./

-t <name>:<tag> : 빌드할 Image의 이름과 tag를 정해줍니다.
이때 tag는 생략 가능하며, 이름의 경우 편의를 위해 이후 생성할 DockerHub의 namespace/repository으로 지정하였습니다.
저는 tag를 생략했으며, 이 경우 latest가 default tag로 붙습니다.

build

위와 같이 문제 없이 성공할 것인데요, 이제 생성된 이미지를 확인해보도록 하겠습니다.



다음 명령어를 입력합니다.

docker images

images

위와 같이 이미지도 잘 생성된 것을 확인할 수 있습니다.


이제 해당 이미지를 DockerHub에 올린 뒤, EC2에서 이를 다운받아 실행해야 합니다. 우선 해당 이미지가 문제없는지 확인해보기 위해 로컬에서 실행해보도록 하겠습니다.






🚀 DockerImage Local에서 실행해보기

다음 명령어를 입력합니다.

docker run \
-d \
--rm \
--name backend \
-p 8080:8080 \
-e "SPRING_PROFILE=local" \
celuveat/celuveat
  • -d : 백그라운드로 실행합니다.
  • --name <이름> : 컨테이너의 이름을 지정합니다.
  • --rm : 컨테이너가 종료되면 자동으로 컨테이너를 제거합니다.
  • -p 8080:8080 : 호스트의 포트(외부 포트)(좌측 8080)를 컨테이너의 포트 8080으로 매핑합니다.
  • -e : 환경변수를 설정합니다.
  • celuveat/celuveat : 실행할 Docker Image의 이름(정확히는 태그)을 명시합니다.

위 명령어를 실행했을 때 다음과 같이 실행된다면 문제가 없는 것입니다. run


이어서 잘 실행되었는지 로그를 확인해보겠습니다.

docker logs backend

log

잘 실행되는 것을 확인할 수 있습니다.


이제 로컬에서 실행중인 애플리케이션은 멈춘 뒤 이를 DockerHub에 올리도록 하겠습니다.

실행중인 애플리케이션은 다음과 같이 멈출 수 있습니다.

docker stop backend || true

여기서 backendcontainer의 이름입니다.

  • || true : 도중에 오류가 발생해도 스크립트를 중단하지 않고 계속 진행하도록 합니다.





🚀DockerHub에 Docker Image Push하기

이제 DockerHub에 Docker Image를 Push하는 과정을 알아보도록 하겠습니다.

우선 이를 위해 DockerHub에 가입한 뒤, 다음과 같이 Repository를 생성합니다. repo



해당 Repository에 push하는것은 간단합니다.

우선 로그인을 진행합니다.

docker login

login



docker push celuveat/celuveat

push

(이때 만약 build 시 tag를 다르게 주었다면, 다음 명령어를 통해 tag를 새로 달아주어야 합니다.

docker tag <로컬이미지이름>:<태그> <dockerhub namespace>/<repository 이름>:<태그>

또한 이때 태그는 생략 가능하며, 생략하는 경우 latest가 default로 붙습니다.)



push 이후 DockerHub의 Repository로 들어가보면, 다음과 같이 이미지가 push되어 있는것을 확인 수 있습니다. pushed






🚀 EC2에서 Image를 Pull 받아 실행해보기

이제 EC2에서 DockerHub에 올라간 이미지를 Pull받아 이를 실행시키도록 하겠습니다.
우선 이를 위해 테스트용 EC2를 하나 새로 생성해 주었습니다.

Docker를 사용하기 때문에 EC2에 java를 따로 설치할 필요는 없지만, docker는 설치해주어야 합니다.
다음 문서에 따라 설치를 진행합니다.

sudo apt-get update
sudo apt-get install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin


이제 DockerHub에서 image를 pull 받아 사용하도록 하겠습니다.

저희는 private repository를 사용하므로 우선 로그인을 진행해야 합니다.

docker login

그런데 위 명령어를 실행해서 인증을 진행하면, 다음과 같이 permission denied 오류가 뜰 것입니다. denied

위 오류는 Docker의 데몬 소켓 파일인 /var/run/docker.sock에 대한 접근 권한이 부족하여 발생하는 오류입니다.


이를 해결하는 방법은 크게 두가지가 있는데, 하나는 sudo를 사용하는 것이고, 다른 하나는 Docker 그룹에 사용자를 추가하는 것입니다.
저는 두번째 방법으로 해결해 보도록 하겠습니다.

sudo usermod -aG docker $USER

위는 현재 사용자($USER)를 docker 그룹에 추가하는 명령어입니다.


이후 다음 명령어를 실행합니다.

newgrp docker

이제 다시 로그인을 시도하면 성공하는 것을 확인할 수 있습니다. login



DockerHub에서 Image를 Pull 하도록 하겠습니다.

docker pull celuveat/celuveat

(만약 태그를 붙여주었다면 태그도 같이 명시해주어야 합니다. 명시하지 않으면 latest가 default로 붙습니다.)

pulled


이제 해당 이미지를 실행해보도록 하겠습니다.

docker run \
-d \
--rm \
--name backend \
-p 8080:8080 \
-e "SPRING_PROFILE=local" \
celuveat/celuveat

docker run2

애플리케이션이 잘 실행되는 것을 확인할 수 있습니다. 이제 이를 stop 명령어를 통해 종료해준 후, 테스트를 끝마치도록 하겠습니다.






🧐 백엔드 CD workflow 작성하기

이제 위 과정을 Github Actions와 self-hosted runners를 통해 자동화할 수 있도록 workflow를 작성하도록 하겠습니다. (dev와 prod는 대체로 동일하므로, prod용 workflow만을 작성하도록 하겠습니다.)

name: ✨ Celuveat backend PROD CD ✨

env:
PROFILE: prod
IMAGE_TAG: back-prod-${{ secrets.APP_VERSION_TAG }}
DOCKER_CONTAINER_NAME: backend
DOCKER_HUB_REPOSITORY: celuveat/celuveat

on:
workflow_dispatch:
push:
branches:
- main
paths:
- "backend/**"

jobs:
backend-docker-build-and-push:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend

steps:
- name: ✨ Checkout repository
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.ACTION_TOKEN }}

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

- name: ✨ Gradlew 권한 설정
run: chmod +x ./gradlew

- name: ✨ Jar 파일 빌드
run: ./gradlew bootJar

- name: ✨ DockerHub에 로그인
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}

- name: ✨ Docker Image 빌드 후 DockerHub에 Push
uses: docker/build-push-action@v4
with:
context: ./backend
file: ./backend/Dockerfile
push: true
platforms: linux/arm64
tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}

backend-docker-pull-and-run:
runs-on: [self-hosted, prod]
if: ${{ needs.backend-docker-build-and-push.result == 'success' }}
needs: [ backend-docker-build-and-push ]
steps:
- name: ✨ DockerHub에서 Image Pull
run: |
docker login --username ${{ secrets.DOCKER_HUB_USERNAME }} --password ${{ secrets.DOCKER_HUB_PASSWORD }}
docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
docker container prune -f
docker image prune -f

- name: ✨ Docker Image 실행
run: |
docker run \
-d \
--name ${{ env.DOCKER_CONTAINER_NAME }} \
-p 8080:8080 \
-e "SPRING_PROFILES_ACTIVE=${{ env.PROFILE }}" \
${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}

이를 하나하나 살펴보도록 하겠습니다.



env:
PROFILE: prod
IMAGE_TAG: back-prod-${{ secrets.APP_VERSION_TAG }}
DOCKER_CONTAINER_NAME: backend
DOCKER_HUB_REPOSITORY: celuveat/celuveat

yml 내에서 사용할 변수들을 정의해주는 부분입니다.

  • PROFILE: 스프링을 실행할 때 SPRING_PROFILES_ACTIVE의 값으로 줄 값입니다. 프로덕션용 workflow이므로 prod로 설정합니다.
  • IMAGE_TAG: 도커 이미지를 환경&버전별로 관리하기 위한 태그를 만드는 부분으로, prod-1.0.0 의 형식으로 생성됩니다.
  • DOCKER_CONTAINER_NAME: Docker Image 로부터 생성될 Container의 이름을 정의해주는 부분입니다.
  • DOCKER_HUB_REPOSITORY: DockerHub Repository의 namespace/repository이름 입니다.


on:
workflow_dispatch:
push:
branches:
- main
paths:
- "backend/**"

해당 워크플로우가 언제 동작할지를 정의하는 부분입니다.
main 브랜치에 push가 발생하였을 때, backend의 하위 디렉토리에 변경이 생겼을 경우 해당 workflow가 동작합니다.

  • workflow_dispatch: 워크플로우를 수동으로 트리거할 수 있도록 합니다.


jobs:
backend-docker-build-and-push:
# ...

backend-docker-pull-and-run:
# ...

workflow에서 실행할 작업(job)들을 정의하는 부분입니다. 해당 workflow에서는 backend-docker-build-and-pushbackend-docker-pull-and-run 라는 이름의 작업이 두개 존재합니다.



backend-docker-build-and-push:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
  • backend-docker-build-and-push : 작업이 수행되는 환경은 ubuntu-latest입니다.
  • defaults.run.working-directory: ./backend : defaults 를 통해 아래의 단계에서 공통으로 사용되는 설정을 지정하였습니다. 해당 작업에서는 working-directory를 설정하여 이후의 단계들은 모두 ./backend 디렉토리 내에서 실행됩니다.


steps:
- name: ✨ Checkout repository
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.ACTION_TOKEN }}

on에 정의된 main 브랜치로 checkout하며, 동시에 submodules을 포함하여 가져오도록 설정하였습니다.

이때 Secret에 ACTION_TOKEN으로 정의된 값을 사용하는데, 해당 값은 아래와 같이 발급하여 사용할 수 있습니다.

token1 (깃허브의 우측 상단 프로필 이미지를 클릭 -> Settings)



token2 (Developer Settings 클릭)



token3 (Personal Access Token -> Tokens (classic) -> Generate new token -> Generate new token (classic))



token4 repo에 대해서만 체크해주시면 됩니다.

이렇게 해서 생성된 Token을 Repository Secret으로 설정해주면 됩니다.
(workflow를 사용할 Repository -> Settings -> Secrets and variables -> New repository secret을 통해 설정할 수 있습니다.)



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

사용할 자바 버전을 설정하는 부분입니다. 17버전을 사용하도록 설정하였습니다.



- name: ✨ Gradlew 권한 설정
run: chmod +x ./gradlew

- name: ✨ Jar 파일 빌드
run: ./gradlew bootJar

gradlew 실행 권한을 부여한 후 jootJar을 통해 jar 파일을 빌드하는 부분입니다.



- name: ✨ DockerHub에 로그인
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}

Secrets에 정의한 username과 password를 통해 DockerHub에 로그인하는 부분입니다.



- name: ✨ Docker Image 빌드 후 DockerHub에 Push
uses: docker/build-push-action@v4
with:
context: ./backend
file: ./backend/Dockerfile
push: true
platforms: linux/arm64
tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
  • docker/build-push-action@v4를 통해 DockerHub에 이미지를 push하는 과정을 진행합니다.
  • context: Docker Image를 빌드할 컨텍스트 경로를 지정합니다. ./backend를 통해 백엔드 디렉토리로 지정했습니다.
  • file: 이미지를 빌드할 Dockerfile의 위치를 지정합니다.
  • push: DockerHub에 이미지를 푸쉬할지 여부를 지정합니다. true로 설정하여 푸쉬하도록 해주었습니다.
  • 빌드할 이미지 플랫폼을 지정합니다. 저희의 서버는 ubuntu(Linux/UNIX) 64-bit(ARM) 이므로 linux/arm64로 지정하였습니다.
  • 빌드될 이미지의 태그를 지정합니다.


위 과정을 진행하면 DockerHub에 다음과 같이 이미지가 올라가게됩니다.

pushed

이어서 EC2에서 Self hosted Runner를 통해 해당 이미지를 Pull 받아 실행하는 부분을 살펴보겠습니다.



backend-docker-pull-and-run:
runs-on: [self-hosted, prod]
if: ${{ needs.backend-docker-build-and-push.result == 'success' }}
needs: [ backend-docker-build-and-push ]
  • runs-on: 작업이 실행될 환경을 진행합니다. prod 환경의 self-hosted-runner를 실행시키기 위해 이와 같이 지정했습니다. (prod는 이전 글에서 살펴보았듯이, 직접 self hosted runners에 직접 붙여준 label입니다.)
  • if: 이전 작업인 backend-docker-build-and-push가 성공했을 경우에만 실행되도록 if를 통해 조건을 지정해주었습니다.
  • needs: 해당 작업이 의존하는 작업을 지정합니다. 해당 작업은 backend-docker-build-and-push 뒤에 실행되야 하므로 needs를 통해 지정해주었습니다.


steps:
- name: ✨ DockerHub에서 Image Pull
run: |
docker login --username ${{ secrets.DOCKER_HUB_USERNAME }} --password ${{ secrets.DOCKER_HUB_PASSWORD }}
docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
docker container prune -f
docker image prune -f
  • docker login ~ : dockerhub에 로그인하는 부분입니다.
  • docker pull ~ : 이전 작업에서 dockerhub에 push한 이미지를 pull하는 부분입니다.
  • docker stop ~ : EC2에 실행중인 docker container(여기서는 Spring Application)가 있으면 이를 종료합니다.
    • || true 를 통해 backend라는 컨테이너가 없더라도 오류가 발생하지 않고 진행하도록 합니다.
  • docker container prune -f: 사용하지 않는 도커 컨테이너를 제거합니다. -f를 통해 사용자의 확인을 묻지 않고 진행합니다.
  • docker image prune -f: 이름 없는 모든 이미지를 삭제합니다.


- name: ✨ Docker Image 실행
run: |
docker run \
-d \
--name ${{ env.DOCKER_CONTAINER_NAME }} \
-p 8080:8080 \
-e "SPRING_PROFILES_ACTIVE=${{ env.PROFILE }}" \
${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}

위 옵션들은 모두 이전에 설명한 옵션들이므로 따로 설명하지 않겠습니다.

이렇게 해서 백엔드의 CD 설정이 끝났습니다. 이어서 프론트엔드로 넘어가도록 하겠습니다.







🧐 프론트엔드 Dockerfile 작성하기

FROM node:18-alpine

WORKDIR /app

COPY package.json yarn.lock .

RUN yarn install

COPY . .

RUN yarn build

CMD ["yarn", "start", "--port", "3000"]

한 줄 한 줄 살펴보도록 하겠습니다.



FROM node:18-alpine

Docker 이미지 생성 시 기반이 되는 이미지 레이어를 정의하는 부분입니다.
저희의 프로젝트는 node 18.16.1버전을 사용하므로 node:18-alpine을 사용했습니다.



WORKDIR /app

도커 컨테이너 내에서의 작업 디렉토리를 /app으로 설정합니다.
이후 진행되는 작업들은 모두 /app 내부에서 진행됩니다.



COPY package.json yarn.lock .

이 부분은 사용자의 컴퓨터(호스트)의 package.json과 yarn.lock 파일을 컨테이너 내부로 복사합니다. 이 두 파일만 먼저 수행하는 이유는 성능상의 이유 때문인데, 위 두 파일을 자주 변경되지 않으므로 먼저 복사하여 도커 레이어를 캐싱해두면 이후 빌드가 더 빨라집니다.



RUN yarn install

yarn install 명령을 실행하여 package.json에 명시된 종속성들을 설치합니다.



COPY . .

이 부분은 사용자의 컴퓨터(호스트)의 모든 파일을 컨테이너 내부로 복사합니다.



RUN yarn build

애플리케이션을 빌드합니다.



CMD ["yarn", "start", "--port", "3000"]

3000번 포트로 애플리케이션을 실행합니다.




🧐 프론트엔드 CD workflow 작성하기

name: 🍔 Celuveat frontend PROD CD 🍔

env:
PROFILE: prod
IMAGE_TAG: front-prod-${{ secrets.APP_VERSION_TAG }}
DOCKER_CONTAINER_NAME: frontend
DOCKER_HUB_REPOSITORY: celuveat/celuveat

on:
workflow_dispatch:
push:
branches:
- main
paths:
- "frontend/**"

jobs:
frontend-build-and-push:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend

steps:
- name: 🍔 Checkout repository
uses: actions/checkout@v3

- name: 🍔 .env 파일 세팅
run: |
touch .env
echo GOOGLE_MAP_API_KEY=${{ secrets.GOOGLE_MAP_API_KEY }} > .env
echo BASE_URL=${{ secrets.PROD_BASE_URL }} >> .env

- name: 🍔 DockerHub에 로그인
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}

# Dockerfile에서 yarn 사용하려면 필요함
- name: 🍔 Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: 🍔 Docker Image 빌드 후 DockerHub에 Push
uses: docker/build-push-action@v4
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
platforms: linux/arm64
tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max

frontend-docker-pull-and-run:
runs-on: [self-hosted, prod]
if: ${{ needs.frontend-build-and-push.result == 'success' }}
needs: [ frontend-build-and-push ]

steps:
- name: 🍔 DockerHub에서 Image Pull
run: |
docker login --username ${{ secrets.DOCKER_HUB_USERNAME }} --password ${{ secrets.DOCKER_HUB_PASSWORD }}
docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
docker container prune -f
docker image prune -f

- name: 🍔 Docker Image 실행
run: |
docker run \
-d \
--name ${{ env.DOCKER_CONTAINER_NAME }} \
-p 3000:3000 \
${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
  • cache-from, cache-to: 다음 글에서 자세히 설명할 예정이지만, 간단하게는 도커 레이어의 캐싱을 위해 사용합니다. 이를 사용하지 않으면 매번 10분정도 걸리는 빌드 시간이 (yarn install의 캐싱으로 인해) 절반가량 줄어들게 됩니다.

나머지 부분은 백엔드와 거의 동일하므로 별다른 설명 없이 마치도록 하겠습니다.






🧐Gradle Caching을 통한 속도 향상

현재 github actions의 workflow가 전체 작업을 수행하는데 걸리는 시간은 다음과 같습니다.

no-caching-1 no-caching-2 no-caching-3 no-caching-4

평균적으로 1분 10초 정도의 시간이 걸리는 것을 알 수 있습니다.

이를 줄이기 위해 Gradle Caching을 해보도록 하겠습니다.




🚀 Gradle Caching이란?

(참고 - 공식문서)
Gradle은 빌드 시에 작업(Task)의존성 패키지들을 모두 가져온 뒤, 이들을 dependency cache라 불리는 로컬 캐시에 보관하여 이후 호출에서 불필요한 네트워크 호출을 방지하는 전략을 사용합니다.

그러나 Github Actions의 workflow에서는 매 실행마다 새롭게 의존성들을 가지고 오게 됩니다.
의존성을 가져오기 위해 대체로 네트워크 호출을 사용하기 때문에, 해당 과정이 없어진다면 전체 빌드 시간이 매우 단축될 것이라 예상할 수 있습니다.

한번 Gradle Caching을 적용하여 성능 향상이 얼마나 이루어지는지 확인해보도록 하겠습니다. 방법은 매우 간단합니다.

- name: ✨ Gradle Caching
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-

위 코드를 추가해주면 되는데요, 이를 적용한 전체 workflow는 다음과 같습니다.



name: ✨ Celuveat backend PROD CD ✨

env:
PROFILE: prod
IMAGE_TAG: back-prod-${{ secrets.APP_VERSION_TAG }}
DOCKER_CONTAINER_NAME: backend
DOCKER_HUB_REPOSITORY: celuveat/celuveat

on:
workflow_dispatch:
push:
branches:
- main
paths:
- "backend/**"

jobs:
backend-docker-build-and-push:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend

steps:
- name: ✨ Checkout repository
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.ACTION_TOKEN }}

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

- name: ✨ Gradle Caching
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-

- name: ✨ Gradlew 권한 설정
run: chmod +x ./gradlew

- name: ✨ Jar 파일 빌드
run: ./gradlew bootJar

- name: ✨ DockerHub에 로그인
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}

- name: ✨ Docker Image 빌드 후 DockerHub에 Push
uses: docker/build-push-action@v4
with:
context: ./backend
file: ./backend/Dockerfile
push: true
platforms: linux/arm64
tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}

backend-docker-pull-and-run:
runs-on: [self-hosted, prod]
if: ${{ needs.backend-docker-build-and-push.result == 'success' }}
needs: [ backend-docker-build-and-push ]
steps:
- name: ✨ DockerHub에서 Image Pull
run: |
docker login --username ${{ secrets.DOCKER_HUB_USERNAME }} --password ${{ secrets.DOCKER_HUB_PASSWORD }}
docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}
docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
docker container prune -f
docker image prune -f

- name: ✨ Docker Image 실행
run: |
docker run \
-d \
--name ${{ env.DOCKER_CONTAINER_NAME }} \
-p 8080:8080 \
-e "SPRING_PROFILES_ACTIVE=${{ env.PROFILE }}" \
${{ env.DOCKER_HUB_REPOSITORY }}:${{ env.IMAGE_TAG }}


캐싱을 적용한 github actions의 workflow가 (jar 파일의 변경 없이) 전체 작업을 수행하는데 걸리는 시간은 다음과 같습니다.

첫 실행

yes-caching-1

이후 실행

yes-caching-2 yes-caching-3 yes-caching-4 yes-caching-5


간단한 그래이들 캐싱만으로도 (첫 실행 제외) 성능이 대략 25%정도 향상된 것을 확인할 수 있습니다.




이렇게 해서 이번 글에서는 저희 셀럽잇 팀의 Docker를 통한 CD 환경 구축 과정을 살펴보았습니다.

읽어주셔서 감사합니다 😊