Github actions & docker를 사용한 자동 배포

JeongYong Park·2023년 8월 26일
3

이번에 github actions와 docker를 활용해 자동 배포 환경을 구축해보았는데 그 과정을 기록으로 남기려 합니다.

도입

이전 프로젝트에서는 github actions를 통해 jar 파일을 빌드해 S3에 업로드하고 AWS Code Deploy를 통해 EC2에 배포하는 작업을 수행했습니다.

그런데 일반적인 웹 서비스는 WAS를 다중화하고 Load Balancing을 통해 HA를 확보할 것입니다. 이렇게 다중화한 매 EC2 서버의 환경을 맞추고, Code Deploy를 설정해주고 배포하는 것은 여간 귀찮은 작업이 아닐 수 없습니다.

또한 앞으로의 확장된 서버의 OS 환경이 항상 동일할 것이라는 보장이 없습니다.

이를 위해 컨테이너 기술인 Docker를 사용해보려 합니다. Docker를 사용하면 애플리케이션이 구동될 수 있는 환경을 미리 구축해두고 호스트 머신의 환경과 무관하게 애플리케이션을 항상 동일한 환경에서 구동할 수 있게 됩니다. 또한 애플리케이션이 구동될 수 있는 환경을 이미지로 빌드해 도커 registry 에 등록한 후 서버를 늘릴 때 해당 이미지를 pull 받아 손쉽게 scale-out을 할 수 있는 장점이 있습니다.

Dockerfile을 통한 이미지 생성

Dockerfile을 통해 docker image를 만들 수 있습니다. 어떤 docker base image에 파일을 작성하고 컨테이너를 실행시키는 과정으로 이미지를 빌드할 수 있습니다. Dockerfile은 결국 이 과정을 담는 스크립트의 파일명이 됩니다.

문법

  • FROM : 베이스 이미지 지정
  • RUN : 커맨드를 실행하기 위해 사용
  • ENV : 환경 변수 설정
  • ADD : 파일/디렉토리 추가
  • COPY : 파일 복사
  • WORKDIR : 작업 디렉토리
  • CMD : 컨테이너 실행 명령
  • ENTRYPOINT : 컨테이너 실행 명령

먼저 작성한 Dockerfile은 다음과 같습니다.

# server base image - java 11
FROM adoptopenjdk/openjdk11

# copy .jar file to docker
COPY ./build/libs/novelpark-0.0.1-SNAPSHOT.jar app.jar

# always do command
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]

주요한 문법 몇 가지를 알아보겠습니다.

FROM

FROM 절을 수행할 때 마치 여러 조각을 쌓는 것처럼 보이는데 이를 레이어(Layer)라고 합니다.

FROM을 이해하기 위해서는 먼저 컨테이너의 레이어구조에 대해서 아는 것이 좋습니다. 우리가 여러 컨테이너를 실행시키고 싶을 때는 어떻게 해야할까요?

docker 컨테이너가 실행되면 모든 읽기 전용 레이어들을 순서대로 쌓은 후 쓰기 가능한 신규 레이어를 쌓게 됩니다. 그 다음 컨테이너 안에서 발생한 결과물들이 쓰기 가능 레이어를 기록되게 합니다.

즉, 아무리 많은 도커 컨테이너를 실행해도 기존 읽기 전용 레이어는 변하지 않고 컨테이너마다 생성된 쓰기 가능 레이어에 데이터가 쌓여 서로 겹치지 않게 됩니다.

FROM 명령어는 이 Base image를 지정하기 위한 명령어 입니다.

RUN

컨테이너에서 커맨드를 실행하기 위해 사용됩니다.

RUN echo "HELLO WORLD!"

ENV

Dockerfile에서 고정된 값을 사용하고 싶은 경우가 있을 수 있는데, 이때 사용할 수 있습니다.

ENV KEY VALUE
ENV KEY=VALUE

ADD

파일 혹은 디렉토리를 추가할 수 있는 명령어입니다.

COPY

호스트 컴퓨터의 파일을 도커 컨테이너 내부의 파일 시스템으로 복사하기 위해 사용되는 명령어 입니다.

WORKDIR

작업 디렉토리의 전환을 위해 사용되는 명령어 입니다. 이 명령어를 사용하면 이후 사용되는 모든 명령어는 지정한 디렉토리에서 수행되게 됩니다.

ENTRYPOINT

이미지가 실행되었을 때 항상 실행되어야 하는 커맨드를 정의할 때 사용됩니다. 컨테이너를 실행파일처럼 사용할 수 있을 때 유용합니다.

# shell
ENTRYPOINT java -jar app.jar

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

CMD

ENTRYPOINT와 유사하게 생성된 이미지를 바탕으로 컨테이너 내부에서 수행될 작업이나 명령을 실행합니다. 주의할 점은 Dockerfile에 하나의 CMD 명령만 기술 가능하기 때문에 여러 개를 기록하게 된다면 마지막 명령만 유효합니다.

아래와 같이 shell 형식과 exec형식을 지원합니다.

# shell
CMD java -jar app.jar

# exec
CMD ["java", "-jar", "app.jar"]

Dockerfile

# server base image - java 11
FROM adoptopenjdk/openjdk11

# copy .jar file to docker
COPY ./build/libs/novelpark-0.0.1-SNAPSHOT.jar app.jar

# always do command
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]

다시 Dockerfile로 돌아와서 각 명령어를 보겠습니다.

  • Base image로 adoptopenjdk/openjdk11를 사용했습니다.
  • 빌드된 .jar파일을 컨테이너의 파일시스템에 app.jar라는 이름으로 복사합니다.
  • 컨테이너가 올라가면 스프링부트 애플리케이션을 구동합니다.

github actions

name: CI/CD

on:
  push:
  pull_request:
    branches:
      - main

jobs:
  backend-deploy:
    runs-on: ubuntu-latest
    steps:
      # SOURCE 단계 - 저장소 Checkout
      - name: Checkout-source code
        uses: actions/checkout@v3

      # Gradle 실행권한 부여
      - name: Grant execute permission to gradlew
        run: chmod +x ./gradlew

      # Spring boot application 빌드
      - name: Build with gradle
        run: ./gradlew clean build

      # docker image 빌드
      - name: Build docker image
        run: docker build -t <docker_hub_username>/<docker_image_name> .

      # docker hub 로그인
      - name: Login docker hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # docker hub 퍼블리시
      - name: Publish to docker hub
        run: docker push <docker_hub_username>/<docker_image_name>

      # WAS 인스턴스 접속 & 애플리케이션 실행
      - name: Connect to WAS & Execute Application
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.WAS_HOST }}
          username: ${{ secrets.WAS_USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          port: ${{ secrets.WAS_SSH_PORT }}
          script: |
            docker stop $(docker ps -a -q) 
            docker rm $(docker ps -a -q) 
            docker pull <docker_hub_username>/<docker_image_name>
            docker run -d -p 8080:8080 --name <container_name> <docker_hub_username>/<docker_image_name>

이후 github actions의 workflow 파일을 작성합니다. 각 내용에 대해 설명하면 다음과 같습니다.

  • checkout
    • 코드 저장소로부터 CI 서버로 코드를 내려받도록 합니다.
  • gradle 명령어를 수행할 수 있도록 gradlew에 실행권한을 부여합니다.
  • 스프링 애플리케이션을 빌드해 build/libs 디렉토리에 .jar파일에 생성되게 합니다.
  • 이후 Dockerfile을 통해 docker image를 빌드합니다.
    • 이미지 이름은 <docker_hub_username>/<docker_image_name> 과 같이 구성합니다.
  • docker hub 로그인
    • 빌드된 docker image를 hub에 publish 하기 위해 로그인을 수행합니다.
    • 이때 password는 docker hub에서 발급한 token입니다. (Account Settings > Security > Access Tokens)
  • docker hub publish
    • push 명령어를 통해 docker hub에 docker image를 push 합니다.
  • appleboy/ssh-action을 사용해 WAS 접속 및 애플리케이션 실행
    • ssh를 통해 EC2에 접속하고 EC2 도커 명령어를 수행합니다.
      • host : EC2 IP
      • usename : EC2 username
      • key : pem 파일 (이때 START, END 포함시키기)
      • port : ssh 포트
      • script : EC2에서 수행할 명령

그런데 여전히 문제가 하나 남아있습니다. 프로젝트에 노출되면 안되는 JWT 토큰 값이나 OAuth Secret Key등이 존재합니다. 이런 민감한 파일들이 docker hub에 public하게 올라가게 되면 누군가 악의적으로 Jar파일을 압축해제하여 정보를 추출할 수 있게 됩니다.

이를 해결하는 방법은 다음 포스팅에서 다뤄보도록 하겠습니다.

결론

최종적으로 수행되는 흐름은 다음과 같습니다.

참고 자료

https://woochan-autobiography.tistory.com/468

https://www.44bits.io/ko/post/how-docker-image-work

https://tech.cloudmt.co.kr/2022/06/29/%EB%8F%84%EC%BB%A4%EC%99%80-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%9D%98-%EC%9D%B4%ED%95%B4-3-3-docker-image-dockerfile-docker-compose/

https://hudi.blog/deploy-with-docker-and-github-actions/

profile
다음 단계를 고민하려고 노력하는 사람입니다

0개의 댓글