본문 바로가기

Study

AWS EC2, Docker, GithubActions 를 사용한 자동배포 과정

Docker 와 GithubActions 를 사용해 프로젝트를 EC2 에 배포하는 과정에 대한 기록이다. 구현 과정 중에 모르는 부분에 대한 개념, 문제 해결 과정을 간단히 정리하면서 구현해 나가기로 하였다.

먼저, 배포를 처음 한다면 한 번쯤 들어보게 되는 AWS EC2 를 간단하게 알아보자. 실제로 배포와 EC2를 사용하는 것은 처음이어서 어느 정도 학습이 필요했다.

왜 배포에 EC2 를 많이들 사용하는가?
-> EC2는 AWS Elastic Compute Cloud 의 약자다. 즉 아마존에서 제공하는 클라우드 컴퓨팅 기능을 말한다. EC2는 어플리케이션 요구사항에 따라 여러 유형의 인스턴스를 사용할 수 있으며, AWS 프리티어를 통해 제한된 성능이지만 1년간 무료 사용이 가능하다. 우리 프로젝트에는 General Perpose 의 t3 인스턴스를 사용했다.
얼마 전 개발자 지인과 얘기해 보다가 ECS (Elastic Container Service) 에 대해서도 알게 되었는데, 나중에 좀 더 알아보고 컨테이너로 배포하는 것도 시도해 볼 예정이다.

 

전체적인 배포 흐름

배포의 큰 흐름은 배포하고자 하는 어플리케이션을 .jar 파일로 압축하고, 배포하고자 하는 환경에서 이 .jar 파일을 압축 해제하여 실행시키는 것이다. 
이 과정에 도커를 같이 사용한다면, 다음과 같은 흐름이 된다.
(도커를 사용하는 이유는 어떤 환경에서든 일관된 실행을 보장하기 위함이다. 도커는 어플리케이션 실행에 필요한 모든 종속성과 구성을 컨테이너에 캡슐화 함으로써 컨테이너가 실행되는  운영체제나 환경에 상관 없이 동일한 실행을 보장받는다.)

  1. 어플리케이션의 .jar 파일을 포함하는 도커 이미지를 작성한다.
  2. (1) 을 위해서는 Dockerfile 을 작성하여야 한다.(Dockerfile 은 도커 이미지를 작성하기 위한 지침이라고 볼 수 있다.)
  3. 루트 디렉터리에서 Dockerfile 을 작성하고, docker build 명령어를 사용하면 도커가 알아서 루트 디렉터리에서 Dockerfile 을 탐색해 해당 지침대로 도커 이미지를 빌드한다. 
  4. 빌드한 이미지를 도커 허브에 push 한다. -> 도커 허브에 나의 어플리케이션 도커 이미지가 저장되어 있다.
  5. 배포 환경(EC2 인스턴스)에 접속하여 docker run 명령어를 통해 이미지를 가져와서 컨테이너로서 실행시킨다.

 

위와 같이 작업의 흐름을 정리하고, GithubActions 를 사용해 이를 워크플로우로 자동화하였다. 


배포를 위한 예제에서는 bootJar 액션을 사용해 build.lib 하위에 .jar 파일을 생성하는 워크플로우를 사용했지만, 프로젝트에는 도커로 배포하기 위해 도커 이미지로 말아주는 액션을 사용하였다. 
.jar 파일이 아닌 도커 이미지를 빌드하기 위해서는 도커 파일을 작성해야 한다.
자동배포를 구현하는 자세한 과정은 다음과 같다.

 

구현과정

1. 깃허브 리포지토리의 root 디렉토리에 도커파일을 작성한다.
다음은 내가 사용한 도커파일 프레임이다.

# OpenJDK official runtime image
FROM openjdk:17-jdk-slim

# Set the working directory in the container
WORKDIR /app

# Copy the executable JAR file to the container
COPY build/libs/*.jar app.jar

# Make port 8080 available to the world outside this container
EXPOSE 8080

# Run the JAR file
ENTRYPOINT ["java", "-jar", "app.jar"]

 

중요한 것은 도커파일에게도 .jar 파일을 알려줘야 한다는 것이다. 이미지를 사용해 배포하더라도 해당 이미지를 실행했을 때 컨테이너로 .jar 파일이 복사되어야 하고 결국 .jar 파일을 실행하는 것이기 때문이다.

COPY build/libs/*.jar app.jar 명령어를 보면, build/libs 하위의 .jar 파일을 복사하여 app.jar 이름으로 컨테이너에 컨테이너 디렉터리에 저장하는 것을 뜻한다. 

따라서 build/libs 디렉터리 하위에 .jar 파일을 작성하기 위해, 현재 적용중인 workflow 의 build 를 수행하는 action 에 인수로 bootJar 를 추가해 줬다.

 

그런데 build/lib 위치에 생성된다던 .jar 파일이 보이지 않았다. 

bootJar Task가 성공하는 걸 봤기 때문에 build/lib 하위에 .jar 파일이 생성되었어야 하는데, 없었다. 

아하!

원격 저장소에 .jar 파일이 생성되는 게 아니라, 호스팅된 runner 환경에서 체크아웃한 브랜치의 build/lib 하위에 .jar 파일이 생겼을 것이다. 나는 원격 저장소에서 찾고 있었으니 발견할 수 있을리가 없다.

2. 도커 허브에 가입 하고, 사용자 이름과 저장소 이름을 환경변수로 저장한다.

도커 허브에 가입하고 그대로 비밀번호를 사용하려 했는데, docker login action 의 공식문서를 보니 githubActions 로 도커허브에 접속할 때(워크플로우를 사용하여 접속할 때)는 개인 비밀번호를 그대로 사용하지 말고 개인 액세스 토큰을 사용하는 것을 권장하였다.
해서 도커 허브에서 개인 액세스 토큰을 발급받고, 이를 github secret 에 저장해 사용하였다.

 

3. 기존 워크플로우에, 내 도커 허브 저장소에 깃허브 리포지토리의 도커파일을 이미지로 빌드하여 저장하는 job 을 추가한다.(이미지 빌드용 새 workflow 를 작성할 필요는 없을 것 같아 우선 기존 workflow 에 통합하지만, 분리할 필요가 있다고 생각되면 분리할 계획이다)

 

기존  workflow 하단에 위와 같은 job 들을 추가하였다. secret 의 환경변수를 사용해 Docker hub 에 로그인하고, 
루트 디렉토리에서 Dockerfile 을 찾아 이를 빌드하여 이미지로 생성한 뒤 도커 허브에 푸시하는 명령이다. -t 옵션은 이미지에 태그를 설정하는 옵션인데, docker build -t seungheyon/myfirsthub:yourtag . 같은 방법으로 사용할 수 있다. 존재하지 않는 태그를 지정하면 빌드에 실패하니 주의하자. 위 코드는 기본 태그(latest) 가 설정된 코드다.

 

띠용

도커 파일을 찾지 못했다는 에러다.
도커 파일은 기본적으로 루트 디렉터리에서 탐색하고, 분명히 루트 디렉터리에 있는데 왜 못찾지? 싶어서 혹시 확장자가 필요한건가 찾아봤지만, 그것도 아니었다. 

도커는 도커 파일을 빌드할 때 루트 디렉토리에서 가장 먼저 Dockerfile 이라는 이름으로 도커 파일을 인식한다고 한다. 나는 처음에 습관대로 DockerFile 이라는 이름으로 저장했는데, 이를 Dockerfile 로 바꾸니 드디어 빌드에 성공했다.

도커 허브 저장소에 저장된 프로젝트 어플리케이션의 이미지다. 이제 이것을 EC2 인스턴스에 접속하여 실행시키는 것까지 자동화하면 CI/CD 완성이다.

 

4. 자동배포용 새로운 워크플로우를 작성하여 이후 작업을 실행한다.

※ 도커 이미지가 아닌 도커 파일로도 배포가 가능?!
몇 기가(?) 씩이나 되는 이미지를 배포하는 것보다, 파일로 배포하고 인스턴스 내에서 파일을 빌드하여 이미지로 만들고 실행시키는 방법도 있고, 이 방법을 많이들 사용하는 것 같다. 우선 Action 에서 이미지로 말아보고, 이후에 파일배포 방식도 시도해 보자.

지금까지 어플리케이션의 .jar 파일을 말아서 build/libs 경로에 저장하고, 해당 경로에 접근하여 어플리케이션 이미지를 작성하여 도커 허브에 저장하는 작업까지 자동화하였다. CI 의 범위는 빌드와 테스트의 자동화 까지라고 보지만, 빌드 과정에서 배포를 준비할 수 있도록 .jar 파일을 말아주는 것까지를 CI 로 보기도 한다. 나는 .jar 파일을 도커 이미지로 빌드하여 저장하는것까지를 배포 준비과정이라고 생각하고, 이제부터는 별개로 자동배포(CD)용 워크플로우를 만들어 배포를 자동화 하려고 한다.
그 전에, EC2 key 와 EC2 인스턴스 퍼블릭 IPv4 주소를 repository secret 에 설정해 놓자.
설정이 완료됐다면, 

1. ssh 를 사용해 해당 설정정보에 접근하여 EC2 인스턴스에 접속한다.

2. 접속하여 나의 도커 이미지파일(또는 .jar 파일) 을 복사하고 실행시킨다.

이 과정이 바로 배포 과정이다. 나는 ssh 를 직접 사용하지 않고, 새로운 배포용 workflow 에 job 으로 등록하여 배포를 자동화 하고자 했다. 

처음에는 그저 ssh 로 EC2 인스턴스에 접속하여 이미지 복사하고 컨테이너 실행시키면 끝! 이라고 생각했는데, 두 가지 문제가 있었다. 먼저 데이터베이스인데, 현재 로컬에서는 h2 내장 데이터베이스를 사용하고 있지만 운영 환경에서는 MySQL 을 사용하기로 했다. 그냥 CI 작업이었다면 secrets 에 설정정보를 저장하고 workflow 에서 서비스로 띄워주면 됐는데, EC2 에서는 어떻게 연결해야 할지 막막했다. 
두 번째는 환경변수인데, 첫 번째 문제와 같은 맥락으로 github secret 에만 저장된 환경변수 정보들을 EC2 에서도 분명 사용해야 할 텐데 이 환경변수들을 어떻게 처리해야 하는 것인지 몰랐다. 

두 문제는 결국 환경변수 문제로 귀결된다(고 생각한다). 따라서 환경변수를 관리하는 방법을 찾아보았는데, dotenv(.env) 와 vault 를 많이 사용한다고 한다.

이에 환경변수 관리 방법에 대해 먼저 학습을 시도해 봤으나 내용이 간단하지 않고, github secret 만으로 EC2 에 환경변수를 전달하는 방법이 있는 것을 확인하여 (우아한 방법은 아니지만)우선 구현하고자 하는 문제를 해결하고, 이후에 더 나은 방식으로 리팩토링하는 것으로 방향을 결정하였다.

 

ssh 접속에는 appleboy 라는 action 을 사용하였다. ssh Host이름과 연결 key 값을 입력하여 ssh 로 접속할 수 있다.
이후 무작정 도커 명령어로 이미지를 가져와 실행시키는 명령어를 추가하고 실행을 시켜보았는데, 여러 가지 이유로 실패했다.

1차 실패 - EC2 인스턴스에 도커가 설치되어 있지 않은 문제

인스턴스에 도커가 설치돼 있지도 않은데 스크립트로 도커 명령어를 실행시켰으니 당연히 실패했다. 도커 설치 명령어를 ssh 스크립트에 추가함으로써 해결하였다. 그러나 스크립트로 매번 도커를 설치하는 것은 당연히 잘못된 것이고, 이후에 스크립트에서 제거하였다. 이 때는 ssh 를 직접 사용해 본 적이 없어서 일회성 명령어들도 다 스크립트에 넣어버렸다.

2차 실패 - 도커 명령어 권한이 없는 문제

도커 소켓에 연결하는 데 실패 - 결국 도커 명령어를 실행할 권한이 없다는 에러이다. 해당 문제는 docker 명령어 앞에 sudo 를 추가하면 되지만, ubuntu 유저가 매번 sudo 명령어를 사용하지 않아도 되도록 하려면 두 가지 해결 방법이 있다. 

  1. sudo usermod -aG docker ubuntu 스크립트 사용 -
    도커 그룹에 ubuntu 유저를 추가하는 명령어이다. (ubuntu 는 고유한 사용자 이름이 아니라 지정한 이름이다) 도커 그룹에 추가된 사용자는 sudo 없이도 도커 명령어를 사용할 수 있다. 
  2. sudo chmod 666 /var/run/docker.sock 스크립트 사용 -
    모든 사용자에게 도커 소켓에 대한 접근을 허용하는 명령어이다. 특정 유저에게만 접근을 허용하는 것이 아니고 모든 유저에게 열어놓는 것이기 때문에 보안상 좋은 방법은 아니지만, 1번으로 해결이 안되는 상황에서 확실하게 문제를 해결할 수 있다. 

권한 문제는 도커 그룹에 ubuntu 유저를 추가함으로써 해결하였다.

3차 실패 - 어떤 이미지를 실행(run) 시킬지 지정하지 않은 문제

docker run 명령어 뒤에 어떤 이미지를 실행시킬지를 지정하지 않아서 실행에 실패했다. 로컬이 아닌 도커 허브에 있는 이미지를 실행하려면, run 명령어 뒤에 도커 허브 리포지토리 이름과 이미지 이름을 추가해 줘야 한다. 따라서 
run 명령어 마지막에 seungheyon/myfirsthub/latest (<- 이미지 저장소와 이름) 을 추가하여 해결하였다.

 

. . .

 

성공! (어? 그런데 환경변수를 따로 설정해 주지 않아도 배포에 성공한다!)

 

드디어 배포에 성공한 것 같다. 어플리케이션이 돌아가는지 확인하고자 인스턴스의 퍼블릭 주소의 8080 포트로 접속을 시도했다. 그랬더니

뭔가 안된 모양이다. 포트가 안열려있는 문제인 것 같아서 인바운드 규칙을 수정했다.
(EC2 인스턴스의 인바운드 규칙에서 8080 포트를 개방)

그러나 포트도 열어놓고, 배포에도 성공했는데 EC2 인스턴스의 DNS 주소 8080포트로 접근이 실패했다.

어떻게 해결해야 할지 막막했는데, 리눅스에서 도커 명령어로 현재 실행중인 컨테이너와 컨테이너 실행 로그를 보는 방법이 있었다. 먼저 docker ps 명령어로 실행중인 컨테이너를 확인했는데 아무 것도 나타나지 않았다. 어플리케이션 실행에 실패했음을 깨닫고 docker logs myapp(내 컨테이너 이름) 명령어로 스프링 어플리케이션 실행 로그를 볼 수 있었다. 

 

처음 환경변수 설정 없이 githubActions 를 빌드했을 때 발생한 그 에러다. 환경변수 관리 문제였다는 것을 깨닫고 환경변수를 추가해 주었다. 앞서 말한 것처럼 우선 배포환경에서 실행이 먼저, 그리고 나중에 더 좋은 방식으로 관리하도록 리팩토링 하자. 
따라서 우선은 도커 이미지를 실행시킬 때 필요한 환경변수들을 주입하는 방식으로 진행했다.

도커 실행을 위한 스크립트에서 docker run 명령어에 환경변수를 추가해 준다.

4차 실패 - redis 의존 문제

환경변수를 추가해 주었지만 여전히 어플리케이션 실행에는 성공하지 못했다. 원인은 배포환경에 의존할 레디스가 없는 것이었다.
로컬에서는 로컬 레디스가 있고, githubActions runner 환경에서는 service 명령어를 사용해 도커로 최신 redis 이미지를 띄워서 사용했다. 그러나 운영환경에서는 redis 를 고려하지 않고 있었다.

1. redis 실행 명령어를 스크립트에 추가(실패)
 해결을 위해 도커 허브에서 최신 redis 이미지를 가져와서 컨테이너로 실행시키는 스크립트를 추가하였다. 이후 확인해 보니 컨테이너로 레디스가 실행되어 돌아가고는 있었지만 여전히 똑같은 에러 로그를 뱉어내며 어플리케이션 실행에는 실패하였다. 
실행 중인 redis 포트에 접속하지 못하는 것이 문제인데, 찾아보니 redis 컨테이너와 내 어플리케이션 컨테이너가 동일한 네트워크를 사용하고 있지 않을 수도 있으며 이것이 문제가 된다는 것을 알게 되었다.

2. docker-compose 도입(성공)
그리고 네트워크 문제를 해결하기 위해 docker compose 를 사용하는 것을 생각할 수 있었다. docker  compose 를 사용함으로써, 여러 컨테이너를 동일한 네트워크 및 환경변수의 관리를 받도록 실행할 수 있다. 이것으로 문제를 해결할 수 있겠다고 생각했다.

docker compose 는 여러 컨테이너를 사용할 때나 필요한거고 지금 나한테는 필요하지 않다고 생각해서 공부에 대한 필요성을 못 느꼈다. 중요성이 와닿지 않은 이유는 도커에 대해 처음 배우기 때문에 생소하기도 했고, '하나의 파일로 여러 컨테이너를 관리한다는 것' 이 왜 필요한 것이고 어떤 상황에 이렇게 관리해야 하는지 와닿지 않아서인데, 실제 도커를 사용해 보면서 이렇게 "Spring 어플리케이션 컨테이너가 redis 컨테이너를 의존하며, 같은 네트워크 상에서 통신해야 한다" 는 상황에서 이러한 관리가 필요하다는 것이 느껴지니 더 공부해야 할 부분이라고 느껴졌다.

2 - 1) 운영 환경으로 안전한 환경변수 정보를 가지도록 docker-compose 파일 전달(실패)
따라서 도커 컴포즈에 대해 간단히 학습해 보고 적용을 시도하였다. 도커 컴포즈 파일은 배포환경(어플리케이션이 실행될 환경)에서 실행되어야 한다. 그러기 위해선 "원격 저장소의 도커 컴포즈 파일을 EC2 인스턴스로 전송 → EC2 인스턴스에서 컨테이너들의 실행 정보를 가지는 도커 컴포즈 파일을 실행시켜서 컨테이너를 띄우자!" 고 생각했다. 이 때 환경변수들도 도커 컴포즈 파일에서 관리하도록 하고 싶었으나, 어떻게 안전하게 래핑된 환경변수를 전달할 수 있을까가 고민이었다. 이를 위해 "원격에서 github secrets 에서 값을 가져오는 도커 컴포즈 파일을 작성하고, 이를 SCP (보안 전송 프로토콜) 로 전송하면 안전하게 전달할 수 있지 않을까?" 의 흐름으로, SCP 를 사용하여 도커 컴포즈 파일을 전송하고자 하였다.

그래서 워크플로우에서 SCP 액션을 사용하여 파일 전송에는 성공했지만, 어플리케이션 실행에는 실패하였다. 같은 환경변수 문제인데, github secret 로 접근하는 환경변수는 해당 리포지토리에 종속된 변수이기 때문에 리포지토리 전체를 체크아웃하지 않는 한 컴포즈파일 만으로는 환경 변수에 접근할 수 없었다.

2 - 2) 운영 환경에서 환경변수 정보를 가지고 있는 docker-compose 파일 별도 작성(성공)
처음 목표대로 구현이 먼저였기에, 도커 컴포즈 파일에 기입할 환경변수는 로컬 application.yml 의 내용을 하드코딩하여 주입하였다. 도커 컴포즈 파일 하나로 모든 환경 변수를 관리할 수 있기는 하지만, 환경변수가 추가될 때마다 인스턴스에 접속하여 일일이 관리해야 하는 문제도 있고, 당장 구현을 위해 선택한 방법이지만 여러모로 좋지 않은 방법이다. 이 환경변수를 모두가 쉽게 추가할 수있고, 외부에 노출되지 않게 안전하며 여러 환경(개발환경, 테스트 환경, 운영 환경)에서 일관적으로 적용될 수 있도록 개선하는 것이 이후에 필요한 최우선 과제가 되겠다. 이후에 로드밸런서를 도입하여 무중단 배포도 도전 과제로 고려하고 있다.