외부에서 접근이 불가능한 EC2에 Self Hosted Runner를 적용해서 CI/CD 구축하기
사용기술
- AWS EC2
- Git Actions
- Nginx
- Springboot/Java
AWS 보안 그룹 및 권한 제한으로 인한 고민
ec2는 우테코에서 기본 제공해주는 VPC, Subnet, 보안그룹을 사용해야했으며, 여러 기능에 대해서 권한 제한이 걸려있었다.
우아한형제들 계정의 IAM 사용자 계정이기 때문에 혹시라도 AWS 사용에 미숙한 크루원이 pem키 같은 중요한 정보를 Github에 업로드하는 등의 실수를 하게 되면, 회사에 큰 재산적 피해가 갈 수 있기 때문인 것 같았다.
AWS IAM 에서 SecretKey를 발급받을 수 있는 권한이 없었고, EC2 접근도 80,443 포트만 전체 접근으로 열려있었고 ssh 프로토콜 통신을 위한 22번 포트는 우테코 캠퍼스 내부 LAN에 연결된 상태로만 접근 가능했다.
때문에 원래 ssh를 통해 EC2에 접근하는 방식으로 CD 스크립트를 작성하려던 나는, 어떤 방식으로 CD 스크립트를 작성해야할 지에 대한 고민이 커지게 되었다.
배포 도구로 Git Actions를 사용할 예정이었는데,
Git Actions이 돌아가고 있는 Runner는 Github 가 구축한 네트워크 환경에서 작동 중일 것이라서
우테코 LAN에서만 접근 가능한 땅콩의 EC2 환경에, 일반적인 Git Actions 환경을 통해 접근하기란 불가능할 것이기 때문이었다.
나는 이를 해결하기 위해 Self hosted Runner를 사용해서 문제에 접근해보았고,
다행히 Git Actions를 사용하여 우리의 프로젝트 땅콩의 API 서버를 정상적으로 배포하는 것에 성공할 수 있었다.
그리고 그 과정들을 기록해보고자 글을 작성하게 되었다.
프로젝트 패키지 구조
프로젝트 패키지 구조는 조금 특이한데 최상위 패키지 아래에 backend, frontend 패키지가 나뉘어있는 구조이다.
클라이언트와 백엔드는 다른 Repository에 구현하는게 일반적으로 알고 있지만, 이러한 상태로 구현하도록 환경을 제공받아서 이렇게 구현하게 되었다.
CI/CD 워크플로우는 ~/.github/workflows/ 내부에 존재한다.
(이곳에 작성해야 Repository의 Action 탭에서 워크플로우를 인식할 수 있다.)
이제 본격적으로 내용을 살펴보자
EC2에 외부 접근이 불가능한 문제 해결하기
위에서 말했듯이, Self-Hosted Runner 설정을 통해 EC2에 외부 접근이 안되는 문제를 해결했다.
그럼 Self-Hosted Runner란 무엇이며, 어떻게 문제를 해결할 수 있었을까?
GitHub Actions - Self-Hosted Runner
Self-Hosted Runner는 기본적으로 Github Actions 환경에서 제공하는 Runner가 아닌,
커스텀 환경에서 Runner를 Self로 hosting 하여 사용하는 방식이다.
우테코 AWS 계정의 권한 이슈로 인하여
외부로부터 ssh 프로토콜로 EC2에 접속하여 배포 스크립트를 수행시키는 등의 작업이 제한되었기 때문에,
애초에 Runner 자체를 우리 EC2에서 Hosting하고 그 Runner를 사용함으로써
외부에서 EC2에 접근하여 배포작업을 시키는 방식이 아닌,
EC2 내부에서 Git Action 스크립트를 수행시키는 것이다.
추가로 EC2에 SSH 접속을 통해 배포를 수행하는 방법은 다음과 같은 오버헤드 발생
보통 SSH를 통해 배포하는 경우, EC2에 Github Repository를 clone 받아둔 상태로 사용하고
배포할 때마다 git pull , artifact build 작업을 dev 환경에서 수행해야한다.
반면 Self Hosted Runner를 사용하면 artifact actions를 활용해 오버헤드를 줄여볼 수 있음.
Actions Runner에서 build를 수행하고 artifact 파일을 흭득한 후, 배포할 EC2에서 artifact 파일을 artifact actions로 다운로드 받기만 하면 된다.
Self-Hosted Runner 설정 과정
설정 권한
우선 개인 레포지토리에 Self-Hosted Runner를 설정하려면 Repository의 Owner여야 한다.
조직 레포지토리의 경우, 조직의 소유자이거나 레포지토리에 대한 관리자 엑세스 권한이 있어야한다.
Repository - Settings 접속
설정을 원하는 Repository의 Settings에 접속한다.
Actions - Runners 탭 접속
Actions 의 Runners 탭을 눌러 들어간다.
New self-hosted runner 버튼 클릭
runner 추가를 위해 해당 버튼을 클릭한다.
EC2에 맞는 환경 설정 및 Runner 생성 스크립트 입력
Runner를 설치 및 세팅할 EC2의 환경과 동일한 설정을 해준 뒤에,
아래 나와있는 스크립트를 EC2에서 실행해주면 설정이 끝난다.
Download 스크립트를 실행하여 self-hosted runner application을 EC2에 설치하고
구성 스크립트에는 요청을 인증하기 위해 대상 URL과 자동 생성된 시간 제한 토큰이 설정되어 있는데, 검정색으로 가린 부분이다.
[Self-Hosted Runner Name, Label 설정]
Configure 설정에서는 self-hosted runner의 '구별자(이름)'와 '레이블(Label)'을 지정할 수 있다.
레이블은 self-hosted, Linux, X64가 기본값으로 주어진다.
설정 내용은 Github Runners에서 다음과 같이 보여진다.
성공 문구 확인
√ Connected to GitHub
2019-10-24 05:45:56Z: Listening for Jobs
EC2에서 위 문구가 출력되면 성공이다.
워크플로우가 동작할 Runner 설정
jobs:
deploy:
runs-on: [dev]
워크플로에서 사용할 자체 호스트 Runner를 지정할 수 있다.
입력된 모든 레이블을 가진 Runner에서 job의 스크립트가 실행된다.
백그라운드 실행 설정
Configure 설정을 마치면 run.sh 명령을 통해 Self-Hosted Runner를 실행하게 된다.
그리고 편의를 위해 해당 sh 스크립트를 백그라운드에서 실행할 수 있는 run-bg.sh 를 만들어두고자 한다.
sudo vi run-bg.sh
위와 같이 run-bg.sh 를 생성하고
#!/bin/bash
nohup ./run.sh &
내부에 위와 같이 백그라운드 실행 코드를 작성한다.
nohup이 아니라 sh로 설정해도 상관은 없다만, sh로 설정 시 가끔 runner가 종료되는 이슈가 있어서 nohup으로 설정했다
설정법 또한 친절하게 작성해두었는데, 그래도 잘 이해가 안간다면 댓글을 남겨주세요!
CI 스크립트
name: BE CI for Dev
on:
workflow_dispatch:
pull_request:
branches: [ "develop" ]
paths:
- backend/**
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build
이는 CI 스크립트이다.
코드가 길기 때문에 스니펫 형태로 설명하겠다.
on: workflow_dispatch
on:
workflow_dispatch:
workflow_dispatch 설정이다.
workflow_dispatch는 Repository의 Actions 탭에서 수동으로 스크립트를 실행시킬 수 있는 기능이다.
현재 스크립트의 trigger path가 ./backend 패키지로 구분되어있어서 최상위에 있는 .github 디렉토리에 대한 변경 정보에 대해서는 CI/CD 가 작동하지 않는다.
때문에 workflow_dispatch 설정을 통해 버튼으로 Actions에 있는 스크립트를 실행시킬 수 있도록 구현한다.
패키지 구조가 backend, frontend로 구분되어 있어서 어쩔 수 없이 발생하는 문제였는데, 브랜치를 통해 be, fe 작업 단위를 나눌 것이 아니라면 이렇게 관리해야했다.
이번 스프린트가 끝나고 브랜치 관리 전략이 be, fe 단위로 변경될 가능성이 있으나
아직은 그렇지 않으니 이렇게 처리하는 것이 최선이었다. 자세한 내용은 여기에서 확인하자.
on: pull_request
on:
pull_request:
branches: [ "develop" ]
paths:
- backend/**
on: <시점> 은 언제 해당 스크립트가 동작할 지에 대한 설정이다.
시점은 pull_request로 설정하였고, branches 옵션의 값을 develop, path를 backend/** 로 두었기 때문에 develop 브랜치에 backend 디렉토리 내부 변경이 포함된 PR이 올라오면 해당 스크립트를 동작시킨다.
만약 PR 이후, PR에 커밋이 추가되어도 스크립트가 작동된다.
공식문서에서 자세한 내용을 확인할 수 있다.
build job
jobs:
build: # build 작업
runs-on: ubuntu-latest #ubuntu 최신 버전에서 동작
defaults: # 기본 설정
run:
working-directory: ./backend # 작업 디렉토리를 ./backend 로 선언
steps:
- name: checkout # PR 브랜치 checkout
uses: actions/checkout@v4
- name: Set up JDK 17 # Oracle Temurin JDK 17 설치
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Grant execute permission for gradlew # gradlew 실행 권한 설정
run: chmod +x gradlew
- name: Build with Gradle # Gradle을 사용하여 빌드
run: ./gradlew build # clean build 명령을 통해 애플리케이션 테스트 및 컴파일 과정 수행
jobs는 스크립트에서 수행할 작업을 나열한다.
CI 스크립트에는 build 작업만이 존재하는데 내용이 길어 주석으로 설명하겠다.
따로 설명할 가장 주요한 포인트는 Build with Gradle인데
빌드를 위한 세팅을 완료한 이후, 최종적으로 테스트와 컴파일 과정을 포함한 ./gradlew build 를 작동시켜 CI 과정을 완료한다.
CI에서 build Task를 수행하는 이유는 다음과 같다.
build Task는 모든 테스트 실행, 프로덕션 artifact 생성을 포함하여 모든 것을 빌드한다고 공식문서에 작성되어 있다. 이를 통해서 정상적으로 프로덕트가 정상 작동 가능한 지 검증하는 것이다.
그리고 이 과정을 CI에서 이미 거치기 때문에 성능적인 측면을 고려하여,
CD에서는 bootJar task만 동작시켜 실행가능한 artifact 생성만 수행하도록 구현하였다.
이제 CD 스크립트를 살펴보자.
CD 스크립트
name: BE CD for Dev
on:
workflow_dispatch:
push:
branches: [ "develop" ]
paths:
- backend/**
jobs:
deploy:
runs-on: [self-hosted, linux, ARM64] # Self hosted runner 사용
steps:
- name: Execute script on EC2 to deploy
run: sudo sh ~/deploy-script-dev.sh
CD 스크립트는 Github에서 내려받아 CI 스크립트보다 훨씬 짧다.
이 코드도 스니펫 형식으로 자세히 알아볼 건데, 중복된 부분은 설명하지 않고 넘어가겠다.
on: push
on:
push:
branches: [ "develop" ]
paths:
- backend/**
pull_request 시점에 작동하는 CI 스크립트와는 달리,
CD 스크립트는 develop 브랜치의 backend 패키지에 변경사항이 push되는 시점에 동작하도록 만들었다.
deploy job
jobs:
deploy:
runs-on: [self-hosted, linux, ARM64] # Self hosted runner 사용
steps:
- name: Execute script on EC2 to deploy
run: sudo sh ~/deploy-script-dev.sh
이번 배포의 가장 중요한 부분인 deploy job이다.
위에서 설명한대로 우테코 AWS 계정 EC2는 ssh 접근이 특정 네트워크로 제한되어 있었다.
나는 이를 해결하기 위해 Self hosted Runner를 사용해서 문제에 접근해보았고, 정상적으로 배포하는 것에 성공할 수 있었다.
Self hosted Runner는 기본적으로 Github Actions 환경에서 제공하는 Runner가 아닌,
직접 자신의 환경에서 Runner를 Self로 host하여 사용하는 방식이다.
Git Actions에서 EC2에 ssh로 접근하여 스크립트를 수행시키는 등의 작업이 불가능하기 때문에,
애초에 Runner 자체를 우리 EC2에서 Host하고 그 Runner를 사용함으로써
외부에서 EC2에 접근하는 방식이 아닌 EC2 내부에서 스크립트를 수행시킬 수 있는 것이다.
runs-on 에 들어간 내용은 내가 설정한 Self hosted runner의 Label이다.
Label을 통해 동작시킬 Self hosted Runner를 지정할 수 있다.
그럼 마지막으로 이러한 설정을 통해 실행시키려는 deploy-script-dev.sh 에는 어떤 스크립트가 작성되어 있는지 확인해보자.
deploy-script-dev.sh
#!/bin/bash
# 1. 프로젝트 디렉터리로 이동
cd /home/ubuntu/2024-ddangkong/backend/ || { echo "Directory not found"; exit 1; }
# 2. develop 브랜치로 체크아웃
git checkout develop || { echo "Failed to checkout develop branch"; exit 1; }
# 3. 원격 저장소의 최신 상태를 가져와서 병합
git pull origin develop || { echo "Failed to pull latest changes"; exit 1; }
# 4. gradlew bootJar 명령을 실행하여 빌드
./gradlew clean bootJar || { echo "Build failed"; exit 1; }
# 5. 빌드된 JAR 파일을 실행
cd build/libs || { echo "Build directory not found"; exit 1; }
JAR_FILE=$(ls *.jar | head -n 1)
if [ -z "$JAR_FILE" ]; then
echo "No JAR file found"
exit 1
fi
# 이전에 돌아가고 있던 Spring 애플리케이션 종료
PID=$(lsof -t -i:8080)
if [ -n "$PID" ]; then
echo "Killing process $PID running on port 8080"
kill -9 $PID
echo "Process $PID has been killed"
else
echo "No process is running on port 8080"
fi
# 우선 nohup에 로그 출력하고 이후에 logback 붙이기
nohup java -Dspring.profiles.active=dev -Duser.timezone=Asia/Seoul -Dserver.port=8080 -jar "$JAR_FILE" &
~
EC2 ~/ 경로에 작성해둔 dev 환경의 deploy 쉘 스크립트 파일이다.
이 sh 파일을 실행하면 [Github에서 땅콩의 Repository를 Pull] → [build] → [기존에 작동하던 스프링부트 애플리케이션 종료] → [새로운 애플리케이션 실행] 과정을 수행한다.
이 스크립트를 Runner에서 작동시킴으로써 CD 과정을 마무리할 수 있었다.
마치며
이 글에서 작성된 내용은 아직 부족한 점이 많은 초기의 CI/CD 스크립트들이다.
가장 중요한 문제는 CD 과정의 핵심적인 부분인 deploy script가 EC2 환경에 의존적이라는 점인데,
다음 글에서는 이러한 부족한 점과, 부족한 점들을 개선해본 CI/CD 파이프라인을 가지고 돌아오려고 한다.