서론
Github Actions와 Docker를 이용하면 손쉽게 CI/CD를 구축할 수 있다. 구축 과정에서 발생한 여러 에러들을 어떻게 해결했는지 공유를 해보고자 한다.
기본적인 서버 환경은 아래와 같다.
- Springboot v3.1.3
- Java 17
- Ubuntu 22.04.2 (메인 서버, AWS EC2)
- Ubuntu 22.04.6 (테스트 서버, GCP Compute Engine)
- Github Container Registry
CI/CD 전체 과정
Github Actions와 SSH를 사용하는 CI/CD 구축 과정은 간단하다.
- 작성한 코드를 commit하고 자신의 Github Repository에 push한다.
- Github Actions가 내가 main.yml에 작성한 내용에 따라 작업을 수행한다.
(1) Springboot 어플리케이션 build
(2) Docker 이미지로 build
(3) 이미지를 Github Container Registry로 push
(4) SSH로 EC2에 접속
(5) Github Container Registry에서 업로드했던 이미지를 pull 하고 실행
배포할 서버가 1개라면 위의 방법대로 진행하면 된다. 이 글에서는 브랜치에 따른 프로덕션 서버와 테스트 서버 분기 처리를 포함하는데, 전체적인 과정은 위와 같다는 사실만 알면 쉽게 이해할 수 있을 것이다.
yml 파일 작성하기
1) 작업 설정
name: Build and Deploy
on:
push:
branches:
- main
- develop
- name: 어떤 작업을 할 건지 명시할 수 있다.
- on: 작업이 실행되는 이벤트를 지정할 수 있다.
- ‘push’, ‘pull_request’, ‘schedule’, ‘release’ 등의 이벤트를 지정할 수 있다.
예시 코드에서는 main 혹은 develop 브랜치에 코드가 push 된다면 작업을 수행하도록 작성했다. 만약 main 브랜치만 명시를 해준다면, develop 브랜치에 코드가 push 되는 경우엔 yml 파일이 실행되지 않는다. 브랜치에 따른 분기 처리는 당연히 가능하다.
2) 환경 설정
# 작업 설정 생략
# ...
jobs:
build-and-deploy:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
distribution: 'zulu'
java-version: '17'
- jobs: 어떤 작업을 할지 정의한다. build 하고 deploy 하는 작업을 할 것이므로 이름은 “build-and-deploy”라고 설정했다. 원한다면 다른 이름으로 바꿔도 된다.
- runs-on: 작업이 실행될 환경을 정의한다. 서버 환경이 ubuntu 20.04 버전이므로 “ubuntu-20.04”로 명시해 주었다.
- steps: “build-and-deploy” 작업을 수행하기 위한 단계들을 정의한다. 각 단계는 하나의 작업을 나타내며, 순차적으로 진행된다.
- name: 단계의 이름을 정의한다.
- uses: Github Actions에서 제공하는 Action을 사용하여 작업을 수행한다.
- with: 액션에 전달할 매개변수를 정의한다.
2-1) steps.uses
steps의 uses에 대해 좀 더 자세히 살펴보자.
Action이란?
Action은 Github Actions에서 미리 정의해 둔 명령어들이다. Github Actions에서 미리 Action을 정의해 두고 사용하라고 던져주지 않았더라면, 우리는 명령어들을 일일이 run 블록에 코드를 작성해야 한다.
(추가로 "Github Action"이라고 잘못 표기되는 경우가 많은데, Action이 무엇인지 알면 Github Action이라고 잘못 말하는 경우는 없을 것이다.)
jobs:
build-and-deploy:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
위의 “Checkout code”라는 이름을 가진 작업에서 uses: actions/checkout@v2는 Github Actions가 제공하는 “checkout”이라는 액션을 v2버전으로 사용하겠다는 뜻이다.
# Github Actions가 액션을 제공하지 않는다면..
run: |
github-actions login
... 코드를 가져오기까지의 수많은 명령어들
github-actions pull codes
# 액션을 제공한다면 한 줄이면 끝난다.
uses: actions/checkout@v2
checkout은 Repository에 있는 코드를 Gitube Actions가 작동하는 작업 환경으로 가져오겠다는 것인데, 만약 checkout 액션이 없었다면 우리는 위처럼 일일이 명령어를 작성하여 코드를 가져왔을 것이다.
현재 v2를 사용하고 있는데, 찾아보니 가장 최근 버전은 v4이다. v4에서는 전체 코드뿐만 아니라 1개의 파일만 불러올 수 있는 기능 등이 추가되었으니 필요에 따라 버전을 업그레이드하여 사용하면 될 것 같다.
3) Build & Push
# 작업 설정 생략
# ...
jobs:
build-and-deploy:
runs-on: ubuntu-20.04
steps:
# 환경 설정 생략
# ...
- name: Build JAR file
run: ./gradlew build -x test
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build & Push Docker Dev Image
if: contains(github.ref, 'develop')
run: |
docker build -f Dockerfile.dev -t ghcr.io/musicpedia/mupie-api-dev:${{ secrets.IMAGE_VERSION }} .
docker push ghcr.io/musicpedia/mupie-api-dev:${{ secrets.IMAGE_VERSION }}
- name: Build & Push Docker Prod Image
if: contains(github.ref, 'main')
run: |
docker build -f Dockerfile.prod -t ghcr.io/musicpedia/mupie-api:${{ secrets.IMAGE_VERSION }} .
docker push ghcr.io/musicpedia/mupie-api:${{ secrets.IMAGE_VERSION }}
총 4개의 작업이 있다. 순서대로 작업을 설명하자면,
- Build JAR file: 테스트를 실행하지 않고, 프로젝트를 Jar 파일로 빌드한다. 테스트를 실행해야 한다면 -x test 옵션을 빼면 된다.
- Login to Github Container Registry: Github Container Registry에 로그인한다.
‣ docker/login-action: Github Container Registry나 Docker Hub 같은 Docker 레지스트리에 로그인할 수 있도록 해주는 Action이다.
‣ username & password: username은 github.actor 변수를 이용하여 계정 이름을 가져오고, password는 깃허브 토큰을 secrets에 등록하여 불러온다. - Build & Push Docker Dev, Prod Image: 브랜치에 따라 Registry레지스트리와 이미지를 구분해 준다. 레지스트리를 구분할 때는 이미지 이름으로 구분한다.
‣ 작업 설정에서 main과 develop 브랜치인 경우 yml 파일이 실행되도록 설정하였는데, Github Actions에서는 github.ref를 사용하여 브랜치 이름을 가져와서 분기처리를 할 수 있다.
4) 배포
# 작업 설정 생략
# ...
jobs:
build-and-deploy:
runs-on: ubuntu-20.04
steps:
# 환경 설정 생략
# ...
# Build & Push 생략
# ...
# Dev 서버 배포 생략
# ...
- name: Deploy to Prod Server
uses: appleboy/ssh-action@v1.0.3
if: contains(github.ref, 'main')
with:
host: ${{ secrets.MAIN_HOST }}
username: ${{ secrets.MAIN_USERNAME }}
key: ${{ secrets.MAIN_SSH_KEY }}
port: 22
script: |
docker-compose pull
docker-compose down
docker-compose up -d mupie
Registry에 이미지까지 성공적으로 push 했다면, 남은 것은 배포할 서버에 접속하여 이미지를 pull 하고 실행하는 것이다.
uses를 보면 appleboy/ssh-action이라고 쓰여있는데, 이름에서 알 수 있듯이 Github Actions에서 제공하는 액션이 아니다. 기본적인 동작 방식은 SSH로 Github Actions가 원격으로 내 서버에 접속해서 내가 작성한 script를 실행하는 것이다.
해당 액션의 사용법은 아래 링크의 Readme를 보면 나와있다.
https://github.com/appleboy/ssh-action
이 오픈소스를 사용하다 보면 수많은 에러를 만나게 될 것인데, 내가 겪은 여러 에러들과 해결법을 설명하기 전에 각 매개 변수가 어떤 걸 의미하고, Github Secrets에 어떤 걸 등록해야 하는지 알아보자.
- host: 배포할 서버의 ip를 의미한다. EC2를 사용한다면, “퍼블릭 IPv4 DNS”를 사용하면 된다. (ec2-12-245..처럼 생긴 것)
- username: 배포할 서버에 로그인할 사용자를 의미한다. ubuntu를 사용한다면 일반적으로 “ubuntu”를 등록하면 된다. 다른 사용자로 로그인하고 싶다면, 해당 사용자로 등록하면 된다.
- port: SSH 포트 번호를 의미하고 22가 기본 값이다. 다른 포트로 SSH를 사용하고 싶다면 변경할 수 있다.
- script: 실행할 스크립트를 의미한다. 서버에 접속하여 실행할 명령어를 적어주면 된다.
- key: SSH Key를 의미한다. 생성한 개인키를 등록하면 된다.
4-1) SSH Key 등록하기
이 부분에서 가장 많이 헤매고 에러가 많이 나기 때문에 제대로 알아볼 필요가 있다.
SSH 프로토콜을 사용하여 서버에 원격 접속하려면, (1) 요청한 서버가 올바른 서버인지 확인하고, (2) 접속하려는 클라이언트가 인증된 클라이언트인지 확인할 방법이 필요하다.
일반적으로 SSH Key는 (1)을 위해서 공개키만 필요한 대칭키 방식을 사용하고, (2)을 위해서 공개키와 개인키가 필요한 비대칭키 방식을 사용한다. 또한 데이터를 암호화, 복호화할 때 (1)에서 받은 공개키를 사용한다.
SSH 프로토콜을 사용하려면 두 개의 키를 필요로 하는데 왜 Key 매개변수가 한 개일까? pub-key, pvt-key 두 개여야 작동을 해야 되는 것 아닌가? 하는 의문이 생길 수 있다.
먼저 SSH의 전체적인 동작 방식을 확인해 보자.
(1) 올바른 서버인지 확인하기 위해서는 아래와 같은 과정을 거쳐야 한다.
- 클라이언트 (GitHub Actions)는 SSH로 서버(EC2)에 연결 요청을 보낸다. 이때 host, username 등이 포함된다.
- 서버는 클라이언트에게 공개키를 제공한다.
- 클라이언트는 난수값으로 해시값을 생성해 저장한다. 난수값을 받은 공개키로 암호화하여 서버에 전송한다.
- 서버에서는 암호화된 데이터를 비밀키로 복호화한 후 난수값을 알아내고, 복호화된 난수값을 통해 해시값을 다시 만든 후 클라이언트에 다시 전송한다.
- 클라이언트에서는 저장하고 있는 해시값과 서버로부터 받은 해시값을 비교해 정상적인 서버인지 확인한다.
이 과정은 Github Actions에 key 매개변수를 넘겨주지 않아도 정상적으로 작동할 것이다.
(2) 인증된 클라이언트인지 확인하기 위해서는 아래와 같은 과정을 거쳐야 한다.
- 클라이언트가 공개키와 개인키를 생성한다. (로컬에서 생성해도 되고 서버에서 생성해도 되지만, 로컬에서 생성하는 것을 권장하고 있다.)
- 생성된 공개키를 서버의 .ssh/authorized_keys에 복사해 둔다.
- 이번엔 서버에서 난수값으로 해시값을 생성해 저장한다. 공개키로 난수값을 암호화해 클라이언트에 전송한다.
- 클라이언트에서 암호화된 난수값을 비밀키로 복호화하고, 복호화된 난수값으로 해시값을 다시 만들어 서버에 전송한다.
- 서버에 저장하고 있던 해시값과 클라이언트로부터 받은 해시값을 비교해 인증된 사용자인지 확인한다.
앞서 개인키만 제공하는 의문점에 대해 직접 공개키와 개인키를 생성하고 공개키를 서버에 복사를 해둬야 하기 때문에 액션의 매개변수에 공개키를 따로 받을 필요가 없다는 것을 알 수 있다.
1번과 2번 과정에 대해서는 Readme #Setting up a SSH Key에 잘 나와있고, rsa로 키만 생성해서 사용하면 되기 때문에 어렵지 않다.
https://github.com/appleboy/ssh-action?tab=readme-ov-file#setting-up-a-ssh-key
자주 발생하는 에러
사실 이렇게 모두 마무리했으면 정상적으로 동작해야 된다. 하지만 인생이 그렇게 쉽지 않다(?).
ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey]
- .ssh/authorized_key 파일에 대한 권한을 확인해 봐야 될 수 있다.
- .ssh/authorized_key에 앞서 생성한 공개키(.pub 키)가 제대로 쓰여있는지 확인한다.
일단 구글링 하면 제일 먼저 나오는 해결법이 위의 두 개다. 하지만 가장 먼저 확인해봐야 할 것은 내가 OpenSSH를 사용하고 있는지이다.
로컬에서 공개키와 개인키를 생성했다면, 터미널에 ssh -V 명령어로 OpenSSH 사용 여부를 확인할 수 있다. 혹은 생성한 개인키를 cat 명령어로 확인해도 된다.
만약 OpenSSH를 사용하고 있다면, Readme #If you are using OpenSSH를 따라 하면 해결될 것이다.
https://github.com/appleboy/ssh-action?tab=readme-ov-file#if-you-are-using-openssh
rsa 알고리즘이 아닌 ed25519 알고리즘으로 키를 생성하는 부분과 서버에서 /etc/ssh/sshd_config 파일에 “CASignatureAlgorithms +ssh-rsa”를 추가해주기만 하면 된다.