Github Action과 EC2를 이용해 Spring Boot 서버를 배포하고 CD(Continuous Deployment)를 시도하면서, 별별 에러 때문에 난항을 겪고 있었다. 다른 사람의 고통에 조금이라도 도움이 될 수 있을 것 같아, 에러 해결의 일대기를 한 번 적어보려 한다.
여기서 설명하는 레포지토리는 아래 링크를 참고하면 된다.
https://github.com/EAT-IT-DOG/eatitdog-server-v2
먼저, 나는 아래 그림과 같은 방식으로 CD를 처리하려고 계획했다.
EC2 프리티어는 굉장히 작고 소중하기 때문에 EC2에서 빌드하기 보다는 빌드된 docker 이미지를 풀받아서 컨테이너를 올리는 방식을 채택했다. 또한 Jenkins 말고 Github Actions를 사용한 이유는, EC2 서버를 하나 더 마련하기에 비용이 부담되기도 하고 이 서비스가 성공할지 모르니 일단 가볍게 CD 파이프라인을 구축하자는 생각이었다. 이게 정답은 절대 아니다.
(저기 저 docker-compose up 을 실행하는 것에 관해서는 깊게 생각하지 않았다... 나중에 나오겠지만 이것 때문에 에러가 났다.)
저 구조로 짜기 위해 스프링 서버의 환경변수(application.yml) 파일을 아래와 같이 세팅했다. (외부 주입할 수 있는 방식)
server:
port: ${PORT}
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_URL}/eatitdog?serverTimezone=Asia/Seoul
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jackson:
property-naming-strategy: SNAKE_CASE
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
show-sql: true
hibernate:
format_sql: true
ddl-auto: update
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
mvc:
pathmatch:
matching-strategy: ant_path_matcher
logging:
level:
org.springframework.boot.autoconfigure: ERROR
com.amazonaws.util.EC2MetadataUtils: error
cloud:
aws:
credentials:
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
stack:
auto: false
region:
static: ${REGION}
s3:
bucket: ${BUCKET_NAME}
jwt:
secret:
access: ${JWT_ACCESS_SECRET}
refresh: ${JWT_REFRESH_SECRET}
그리고 도커 이미지를 만들기 위한 Dockerfile을 아래처럼 작성했다.
FROM openjdk:11
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENV TZ=Asia/Seoul
ENTRYPOINT ["java","-jar","/app.jar","-Duser.timezone=Asia/Seoul"]
또 도커 컨테이너를 띄우기 위한 docker-compose.yml을 아래처럼 작성했다.
version: '3'
services:
eatitdog:
image: wjs0518/eatitdog:latest
ports:
- 8080:8080
restart: always
environment:
DB_URL: ${DB_URL}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
REGION: ${REGION}
BUCKET_NAME: ${BUCKET_NAME}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
PORT: ${PORT}
나는 Github Actions를 사용했기 때문에, 아래 그림과 같이 Github Secret Variables를 사용해 CD 작업 시에 환경 변수들을 주입했다.
(IntelliJ 환경에서는 설정에서 환경 변수들을 입력했고, 배포 환경에서는 Variables를 쓴다.)
마지막으로 처음에 내가 Github Actions CD 작업에 사용한 deploy.yml 스크립트이다.
(아래 파일은 작동하지 않는 CD 테스트다. 따라하지 말자)
name: Spring Boot & Gradle & Docker & EC2 CD
on:
push:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# 기본 체크아웃
- name: Checkout
uses: actions/checkout@v3
# JDK version 설정
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
# 환경변수 .env 파일 생성 및 write
- name: Set .env for configuration
run: |
touch ./.env
echo "DB_URL=${{ secrets.DB_URL }}" >> ./.env
echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> ./.env
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> ./.env
echo "S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}" >> ./.env
echo "S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}" >> ./.env
echo "REGION=${{ secrets.REGION }}" >> ./.env
echo "BUCKET_NAME=${{ secrets.BUCKET_NAME }}" >> ./.env
echo "JWT_ACCESS_SECRET=${{ secrets.JWT_ACCESS_SECRET }}" >> ./.env
echo "JWT_REFRESH_SECRET=${{ secrets.JWT_REFRESH_SECRET }}" >> ./.env
echo "PORT=${{ secrets.PORT }}" >> ./.env
shell: bash
# Gradle build
- name: Build with Gradle
run: ./gradlew bootJar
# Spring 어플리케이션 Docker Image 빌드
- name: Build Docker Image For Spring
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -t ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
# 서버에서 Docker 이미지 실행
- name: EC2 Docker Run
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
password: ${{ secrets.EC2_PASSWORD }}
script: |
docker rm eatitdog
docker rmi ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
docker pull ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
docker-compose up -d
위 파일들이 초기에 내가 세팅한 배포를 위한 파일들이다.
Github Action에서 Variables로 만든 .env -> docker-compose.yml -> application.yml 방향으로 환경 변수가 주입되는 것으로 설계했다. (docker-compose.yml의 환경 변수는 같은 루트 경로의 .env 파일을 참고하기 때문)
왜 이렇게 설계했나면, 나는 Jenkins가 아닌 Github Actions를 사용하고 있고, 추후에 환경 변수를 변경할 때 Github Variables를 바꾸는 것이 훨씬 유연하고 편리하다고 생각했기 때문이다.
아래에는 그동안 배포하면서 겪은 에러들을 어떻게 해결했는지 순서대로 설명했다.
Can't find a suitable configuration file in this directory or any parent.
검색해본 결과 docker-compose.yml 파일을 찾지 못할 때 나오는 오류였다.
기존에 내가 구현한 방법은 docker image를 만들어서 dockerhub를 이용해 EC2에 pull받는 방법인데, docker-compose.yml은 이미 docker image화 되었기 때문에 docker-compose up 명령어를 사용하면 컴포즈 파일을 찾지 못해 오류가 난다.
스프링 개발자 친구들에게도 도움을 요청해봤지만, 같은 오류를 경험하고 scp로 docker-compose, dockerfile을 그 서버로 전송시켜서 서버에서 빌드했다는 방법밖에 없었다. 글을 찾아봐도 EC2 서버에 리모트 디렉토리를 생성하고, 소스를 서버에 복사해서 서버에서 빌드하는 방법들밖에 없었다. (대표적으로 이 글)
나는 굳이 docker-compose.yml 하나를 쓰겠다고 작디 작은 EC2 서버에서 그런 방법까지 써야 하나 싶어서, 조금 야매스러운 방법을 사용했다. 바로 Github Actions Deploy를 위한 deploy.yml에서 docker-compose up 대신 docker run 명령어를 사용하는 것이다. (docker-compose.yml 을 사용하지 않는 방법) 스크립트에 run 한 번만 작성하면 알아서 실행해주는데, 그걸 또 docker-compose로 빼내야 하는 필요성을 느끼지 못했달까. 물론 mysql과 같은 의존하는 컨테이너를 같이 띄우려면 compose를 써야겠지만, 나는 AWS RDS를 사용하기에 필요가 없었다.
이 에러는 없어졌지만, docker run에서 -e 옵션으로 환경 변수들을 일일이 주입해주다 보니 코드 한 줄이 굉장히 길어졌다. 더티하기 때문에, 이는 개선을 해야할 것 같다. (수정된 deploy.yml 파일은 아래로 스크롤 하다 보면 있다.)
AWS EC2 서버 CPU 사용률 100%
위 에러를 해결하기 위해 구글링을 하다가, 어떤 분이 스왑 파일때문에 나는 오류라고 해서 스왑 설정한 것을 없애봤다.
역시 우리 작고 소중한 EC2 프리티어는 컨테이너를 올릴 때 CPU 100%를 찍으며 터져버렸고, 다시 스왑을 설정했다.
스왑 설정에 관해서는 내가 작성한 이 글을 참고하면 된다.
그 과정에서 EC2 CPU 사용률이 내려올 생각을 안해서 인스턴스를 중지하고 다시 실행했다.
다들 아시겠지만, EC2 인스턴스 사용 중지 후 다시 실행하면 퍼블릭 IP가 바뀌어버린다. 그때는 클라들과 작업을 안한 상태여서 괜찮지만, API 연결 작업에 들어가거나 서비스 배포 후 운영할 때 이런 불상사가 생기면 안되기에, AWS Elastic IP(탄력적 IP) 주소를 바로 생성해 인스턴스에 연결했다. 이를 사용하면 IP 주소 하나를 고정으로 사용할 수 있다.
connect: connection refused
이 에러가 난 시점에서 작업하고 있던 github action 스크립트는 아래와 같다.
name: Spring Boot & Gradle & Docker & EC2 CD
on:
push:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# 기본 체크아웃
- name: Checkout
uses: actions/checkout@v3
# JDK version 설정
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
# Gradle build
- name: Build with Gradle
run: ./gradlew bootJar
# Spring 어플리케이션 Docker Image 빌드
- name: Build Docker Image For Spring
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -t ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
# 서버에서 Docker 이미지 실행
- name: EC2 Docker Run
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
password: ${{ secrets.EC2_PASSWORD }}
port: 22
script: |
docker rm eatitdog
docker rmi ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
docker pull ${{ secrets.DOCKER_USERNAME }}/eatitdog:latest
docker run -d -p 8080:${{ secrets.APP_PORT }} --name eatitdog -e DB_URL=${{ secrets.DB_URL }} -e DB_USERNAME=${{ secrets.DB_USERNAME }} -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} -e S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }} -e S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }} -e REGION=${{ secrets.REGION }} -e BUCKET_NAME=${{ secrets.BUCKET_NAME }} -e JWT_ACCESS_SECRET=${{ secrets.JWT_ACCESS_SECRET }} -e JWT_REFRESH_SECRET=${{ secrets.JWT_REFRESH_SECRET }} -e PORT=${{ secrets.APP_PORT }} ${{ secrets.DOCKER_USERNAME}}/eatitdog:latest
지금은 github variable에서 변수를 땡겨와서, docker run 명령어에서 모든 환경 변수를 주입하는 방식으로 사용하고 있다. 환경 변수가 10개 정도 되는 이 프로젝트로써는 굉장히 효율적이지 않은 방법이다.
나는 appleboy/ssh-action 프로젝트의 깃헙 레포지토리와 이슈들을 살펴보면서, 혹시나 힌트가 있을까 봤다.
나는 스프링 앱의 환경변수들을 github variables로 뺐는데, ssh-action이 ssh 연결 port를 기본적으로 secrets.PORT로 불러오나 싶어서 PORT 키값의 이름도 APP_PORT로 바꿔봤다. (물론 고쳐지지 않았다.)
다른 방법들을 찾아봐도 안되면 ssh-action 레포지토리에 이슈를 내가 직접 올려보려고 결심까지 했다.
네가 이기나 내가 이기나 해보자...
그러다가, 같은 에러를 겪으신 분의 벨로그 글을 보고 그것도 시도했는데, 결국 해결됐다.
EC2 인스턴스의 호스트(주소)를 github secret variable로 주입할 때, 그 값을 일반 IPv4 주소로 작성하면 안된다는 것이다.
꼭 서버의 퍼블릭 DNS 주소를 넣어주라고 한다. 예를 들어 123.123.123.123 이런 형태가 아니라 aws에서 지정해준 ec2-123-123-123-123.ap-no.....compute.amazon.com 이런 주소로 설정해야 한다.
(내가 알아들은 게 이게 맞는진 모르겠지만, 적용한 당장엔 해결이 안 됐지만 몇분이 지나니 해결됐다. 좀 어처구니가 없긴 했다. 프라이빗 IP도 아니고 퍼블릭 IP인데 일반 IPv4 주소는 왜 안돼지?)
no space left on device
위 아래를 해결하니 write /var/lib/docker/tmp/GetImageBlob3028485401: no space left on device. 이라는 다른 오류가 떴다.
이는 비교적 쉽게 해결할 수 있었는데, 장치에 공간이 부족하다는 말이었다.
해당 스택오버플로우 글에서 방법을 찾고, docker system prune이라는 명령을 통해 쓰지 않는 이미지들을 삭제했다.
안 쓰는 것들이 속 시원하게 삭제됐다.
결국 오류 해결, CD 성공
하... 드디어 초록 체크가 떠버렸다..
몇 달동안 매달렸던 CI/CD 작업이라 더 희열감이 느껴진다. 이 맛에 코딩한다.
결과적으로 위 사진과 같이 환경 변수 주입 플로우를 간결화해서 해결했다.
아직 완벽하게 이상적인 방법으로 해결된 것이 아니기 때문에, 더 리서치해보고 이상적인 방법을 찾아야 한다.
이제 CI/CD 기본적인 오류를 해결했으니 gradle 캐싱 등의 작업을 추가해서 성능을 끌어올려야겠다.
또한 docker-compose.yml을 안 쓰게 되면서 지저분해진 점들을 고쳐보며 좀 더 깔끔한 CD 스크립트를 짜봐야겠다.
백엔드 개발자 거의 다 기본적으로 하는 CD를 나는 왜 헤매고 있나. 몇 달을 여기에 쏟아부었나. 왜 코드는 다 짜놓고 배포때문에 실제 서비스를 못하고 있나. 많이 현타가 왔지만, 나를 위한 시간(운동)을 꾸준히 지켜서 멘탈을 잡고, 지금 해결 못하면 CD는 영원히 나를 괴롭힐 것이라는 생각으로 끝까지 고쳤다.
다들 수고하세요 :)