최종 아키텍쳐
이제 CI부분까지는 완료됐으니, 배포 부분 yml만 작성해주면 끝이다.
먼저 빌드 부분 yml 파일을 작성하기 전에 러너를 선택해야되는데,
깃허브 액션의 기본 러너를 사용하면 깃허브 액션을 실시할 때마다 러너의 IP가 바뀌기 때문에 서버의 인바운드 규칙을 설정하기가 상당히 애매해진다.
따라서 내 서버를 러너로서 사용하는 self-hosted runner를 선택했다.
Self-hosted 러너 설정
actions ➡️ runners ➡️ new self-hosted runner를 선택해준다.
배포 서버의 운영체제에 맞게 선택해주고,
하단에 있는 스크립트들을 배포서버의 배포 폴더에서 실행시켜 주면 준비 끝!
Self-hosted 러너 테스트
name: nsfServer Github Action
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions: write-all
jobs:
# jobs는 build, deploy 2개가 있습니다.
# jobs는 다른 러너(환경)에서 구동됩니다.
# step는 같은 러너에서 구동됩니다.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: make application-database.yml
run: |
cd ./src/main/resources
# application-database.yml 파일 생성
touch ./application-mysql.properties
# GitHub-Actions 에서 설정한 값을 application-mysql.properties 파일에 쓰기
echo "${{ secrets.MYSQL_PROPERTIES }}" >> ./application-mysql.properties
shell: bash
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and Test with Gradle
run : ./gradlew clean build
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install docker buildx
uses: docker/setup-buildx-action@v1
# 도커 빌드
- name: Build Docker Image
run: |
docker buildx create --use
docker buildx inspect
docker buildx build --platform linux/arm64/v8,linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/nsfserver:v2 --push .
deploy:
needs: build
runs-on: self-hosted
if: ${{ needs.backend-docker-build-and-push.result == 'success' }}
needs: [ backend-docker-build-and-push ]
steps:
- name: ✨ 테스트 스크립트 실행
run: |
sh /home/ubuntu/test.sh //echo "1" 등 아무거나 테스트
만약 ./run.sh를 했을 때 다음과 같은 오류가 난다면, 실행중인 다른 러너가 있는 것이다.
√ Connected to GitHub
A session for this runner already exists.
Stop retry on SessionConflictException after retried for 240 seconds.
Failed to create session. The actions runner potatonet already has an active session for owner kdh.
다음 명령어로 실행중인 러너를 확인하고 전부 종료해주고, 다시 실행하자.
$ ps -aux | grep runner
성공 !
여기까지 정상적으로 됐다면, 이제 서버 접속 관련 문제는 해결된 것이다.
확실히 Self-hosted runner가 SSH보다는 간단한 부분이 있다!
환경 테스트가 끝났으니 이제 실제 배포 스크립트를 짜보자.
남은 스텝은 다음과 같다.
- 도커허브에 올라간 도커 이미지를 각 배포 서버에서 다운받는다.
- 배포 시 Blue 그룹 서버가 실행 중이면, Green 그룹 서버를 실행시킨다.
- Green 그룹 서버가 정상적으로 실행됐는지를 테스트하고, 정상 실행되었다면 Blue 그룹 서버를 종료한다.
healthcheck를 위한 Spring 서버, docker 파일 수정
먼저 Blue 그룹과 Green 그룹에 대한 정보를 위해서, Spring 서버에서 몇 가지를 수정해주어야 한다.
application.properties
수정
application.properties에 다음 코드를 추가한다.
spring.profiles.active=local
spring.profiles.group.local=common, local-db
spring.profiles.group.prod1=common, prod-db, prod1-server
spring.profiles.group.prod2=common, prod-db, prod2-server
server.env=blue
2.healthCheck API
구현
다음과 같이 healthCheck용 컨트롤러를 만들고, 어떤 그룹인지를 판별하는 API를 구현한다.
@RestController
public class HealthCheckApi {
@Value("${server.env}")
private String env;
@GetMapping("/getServerInfo")
public ResponseEntity<Map<String, String>> getServerInfo() {
Map<String, String> serverInfo = new HashMap<>();
serverInfo.put("env:", env);
return ResponseEntity.ok(serverInfo);
}
//profile 조회
@GetMapping("/env")
public String getEnv() {
return env;
}
}
3. dockerfile 수정
실행 시 env 값을 변경시켜주어야 하기 때문에 dockerfile의 entrypoint를 다음과 같이 수정한다.
# 1. Gradle을 사용하여 소스 코드 빌드를 위한 이미지
FROM gradle:jdk17 as builder
# 소스 코드를 이미지에 복사
WORKDIR /app
COPY . .
# Gradle을 사용하여 애플리케이션 빌드
RUN gradle build
# 최종 이미지를 위한 OpenJDK 이미지
FROM openjdk:17-jdk
# 빌드된 JAR 파일을 복사
ARG JAR_FILE=./build/libs/nsfServer-0.0.1-SNAPSHOT.jar
COPY --from=builder /app/${JAR_FILE} app.jar
EXPOSE 8080
# 시스템 진입점 정의
ENTRYPOINT ["java", "-Dspring.profiles.active=${PROFILES}", "-Dserver.env=${ENV}", "-jar", "/app.jar"]
4. docker-compose.yml 수정
version: '3'
services:
nginx:
image: eogns47/mynginx
ports:
- '9090:80'
extra_hosts:
- 'host.docker.internal:host-gateway'
container_name: nginx
volumes:
- nginx:/logs
tserver1:
image: eogns47/nsfserver:v1
ports:
- '8082:8080'
container_name: tserver1
environment:
- ENV=blue # 환경 설정 시 앞 뒤 공백이 있으면 error 발생
volumes:
- nsf_log:/logs
tserver2:
image: eogns47/nsfserver:v1
ports:
- '8081:8080'
container_name: tserver2
environment:
- ENV=blue
volumes:
- nsf_log:/logs
volumes:
nginx:
driver: local
nsf_log:
driver: local
위 docker-compose 파일을 실행할 때 다음 에러가 발생했다.
[ERROR], 2024-02-26 01:13:17 [dispatcherServlet]:175 - Servlet.service() for servlet [dispatcherServlet]
in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException:
Could not resolve placeholder 'ENV' in value "${ENV}"] with root cause
그 원인은 environment 설정에 있었는데, 초기에는 ENV = blue
이렇게 작성되어있었다.
docker-compose에서는 환경 변수를 설정할 때 앞뒤에 공백을 넣으면 에러가 발생할 수 있다고 하니, 이 점을 주의하자.
테스트
포스트맨으로 테스트 해보니 정상적으로 blue 인스턴스가 실행되었다.
green group 서버 배포
version: '3'
services:
nginx:
image: eogns47/mynginx
ports:
- '9090:80'
extra_hosts:
- 'host.docker.internal:host-gateway'
container_name: nginx
volumes:
- nginx:/logs
tserver1:
image: eogns47/nsfserver:v1
ports:
- '8082:8080'
container_name: tserver1
environment:
- ENV=green # 환경 설정 시 앞 뒤 공백이 있으면 error 발생
volumes:
- nsf_log:/logs
tserver2:
image: eogns47/nsfserver:v1
ports:
- '8081:8080'
container_name: tserver2
environment:
- ENV=green
volumes:
- nsf_log:/logs
volumes:
nginx:
driver: local
nsf_log:
driver: local
그린 그룹에는 앞서 작성한 docker-compose 파일에서 ENV 값만 변경해서 만들어준다.
⚠️각 배포 서버에서 인바운드 규칙을 수정해주는 것을 잊지 말자.
⚠️ 각 서버에는 docker와 docker-compose를 설치해주는 것을 잊지 말자.
### docker, docker-compose 설치 스크립트 ###
#docker 설치
$ curl -fsSL https://get.docker.com/ | sudo sh
$ sudo usermod -aG docker $USER
$ newgrp docker
#docker-compose 설치
$ sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
최종 github action yml 파일 작성
이제 배포 스크립트를 추가해 yml 파일을 수정해보자.
name: nsfServer Github Action
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions: write-all
jobs:
# jobs는 build, deploy 2개가 있습니다.
# jobs는 다른 러너(환경)에서 구동됩니다.
# step는 같은 러너에서 구동됩니다.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: make application-database.yml
run: |
cd ./src/main/resources
# application-database.yml 파일 생성
touch ./application-mysql.properties
# GitHub-Actions 에서 설정한 값을 application-mysql.properties 파일에 쓰기
echo "${{ secrets.MYSQL_PROPERTIES }}" >> ./application-mysql.properties
shell: bash
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and Test with Gradle
run: ./gradlew clean build
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }} # 요건 Github에서 관리하는 시크릿, 아래서 다루겠습니다.
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install docker buildx
uses: docker/setup-buildx-action@v1
# 도커 빌드
- name: Build Docker Image
run: |
echo "CURRENT_IP=${{ secrets.GREEN_IP }}" >> $GITHUB_ENV
docker buildx create --use
docker buildx inspect
docker buildx build --platform linux/arm64/v8,linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/nsfserver:v2 --push .
deploy:
needs: build
runs-on: self-hosted
steps:
- name: ✨ 배포 스크립트 실행
run: |
sh /home/ubuntu/deploy.sh
- name: Blue/Green health check
run: |
echo "INSTANCE_ENV=$(curl -s "http://${{ secrets.NGINX_IP }}/env")" >> $GITHUB_ENV
- name: Set target ip
# prod1, prod2 둘 다 검사해야 하지만, 현재는 prod1만 검사
# CURRENT_UPSTREAM : env로 요청 보내서 blue 인지, green인지 알아옴. 이걸 curl로 해서 변수로 만든다. 즉 blue,green,오류 중에 하나
# CURRENT_IP : 만약 환경이 blue라면 blue 인스턴스의 prod1의 ip(111.111.111.111:8080)가 들어감
# STOPPED_IP : 만약 환경이 blue라면 멈춰있는 green의 prod1 ip가 들어감.
# TARGET_UPSTREAM : 바꿀 env
run: |
CURRENT_UPSTREAM=$(curl -s "http://${{ secrets.NGINX_IP }}/env")
echo $CURRENT_UPSTREAM
if [ $CURRENT_UPSTREAM = "blue" ]; then
echo "CURRENT_IP=${{ secrets.BLUE_IP }}" >> $GITHUB_ENV
echo "STOPPED_IP=${{ secrets.GREEN_IP }}" >> $GITHUB_ENV
echo "TARGET_UPSTREAM=green" >> $GITHUB_ENV
echo ${{ env.CURRENT_IP }}
echo ${{ env.STOPPED_IP }}
elif [ $CURRENT_UPSTREAM = "green" ]; then
echo "CURRENT_IP=${{ secrets.GREEN_IP }}" >> $GITHUB_ENV
echo "STOPPED_IP=${{ secrets.BLUE_IP }}" >> $GITHUB_ENV
echo "TARGET_UPSTREAM=blue" >> $GITHUB_ENV
echo "green"
else
echo "error"
exit 1
fi
- name: Execute Server Docker compose
uses: appleboy/ssh-action@v0.1.4
with:
username: ${{ secrets.SSH_USER }}
host: ${{ env.STOPPED_IP }} # 위에서 지정한 환경변수
key: ${{ secrets.SSH_KEY }}
script_stop: true
script: |
cd ~/workspace/kdh
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/nsfserver:v2
docker-compose up -d
#새로운 인스턴스 헬스체크
- name: Check the deployed service URL for 8081 port
uses: jtalk/url-health-check-action@v3
with:
url: http://${{ env.STOPPED_IP }}:8081/env
# 총 5번 하는데, 5초의 간격을 두고함. 이때까지 응답이 정상이 아니라면 배포 실패
max-attempts: 5 # Optional, defaults to 1
retry-delay: 5s # Optional, only applicable to max-attempts > 1
timeout: 30s
- name: Check the deployed service URL for 8082 port
uses: jtalk/url-health-check-action@v3
with:
url: http://${{ env.STOPPED_IP }}:8082/env
# 총 5번 하는데, 5초의 간격을 두고함. 이때까지 응답이 정상이 아니라면 배포 실패
max-attempts: 5 # Optional, defaults to 1
retry-delay: 5s # Optional, only applicable to max-attempts > 1
timeout: 30s
# 엔진엑스의 프록시 변경
- name: Change nginx upstream
uses: appleboy/ssh-action@master
with:
username: ${{ secrets.NGINX_USER }}
host: ${{ secrets.NGINX_IP }}
key: ${{ secrets.NGINX_KEY }}
script_stop: true
# 도커로 들어가서 service_env를 바꿔주고 reload
# 여기서 -i가 아닌 -it로 진행하면 오류가 발생하고, -c가 없으면 도커가 아닌 호스트에서 경로를 찾는다. 주의
script: |
docker exec -i nginx bash -c 'echo "set \$service_env ${{ env.TARGET_UPSTREAM }};" > /etc/nginx/conf.d/service-env.inc && service nginx reload'
# 기존 인스턴스 중단
- name: Terminate prev instance
uses: appleboy/ssh-action@master
with:
username: ${{ secrets.SSH_USER }}
host: ${{ env.CURRENT_IP }}
key: ${{ secrets.SSH_KEY }}
script_stop: true
script: |
docker stop tserver1
docker stop tserver2
docker rm tserver1
docker rm tserver2
발생한 에러
- name: Execute Server Docker compose
uses: appleboy/ssh-action@master
with:
username: ${{ secrets.SSH_USER }}
host: ${{ env.STOPPED_IP }} # 위에서 지정한 환경변수
key: ${{ secrets.SSH_KEY }}
script_stop: true
script: |
cd ~/workspace/kdh
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/nsfserver:v2
docker-compose up -d
이 작업을 수행하던 중 다음과 같은 에러가 발생했다.
2024/02/26 05:03:28 ssh: handshake failed: ssh: unable to authenticate, attempted methods [none], no supported methods remain
처음엔 secrets를 잘못 작성한줄 알고 다시 작성해보고 SSH key를 다시 만들고 했었으나, pubkey가 ssh-rsa를 지원하지 않아 발생하는 문제였다.
배포서버에서 ssh 설정값을 수정해주자.
우선 다음 명령어로 파일 설정에 접근해준다.
sudo nano /etc/ssh/sshd_config
이후 기존 PasswordAuthentication
설정값이 있다면 no로 변경해주고, PubkeyAuthentication yes
와PubkeyAcceptedKeyTypes=+ssh-rsa
를 추가해주자.
PasswordAuthentication no
#PermitEmptyPasswords no
PubkeyAuthentication yes
PubkeyAcceptedKeyTypes=+ssh-rsa
이후 ctrl+O → enter → ctrl+x 로 저장해준 뒤, 다음 명령어로 ssh를 재시작해준다.
sudo service ssh restart
정상 작동되는 모습!
생각해볼 점
현재 구조는 메인 서버 + ec2의 형태이고,
메인서버를 러너로 사용하여 빌드와 nginx 서버, blue group 서버로서 활용하고 ec2를 green group 서버로서 활용한다.
우선 첫번째 문제로, 현재는 docker-compose에 nginx도 함께 배포중이기 때문에
만약 nginx만 서버를 독립적으로 두고 blue와 green 그룹을 따로 배포한다면, 현재의 docker-compose 파일에서 nginx를 제외하고, nginx를 따로 dockerfile로 관리해야 한다. (현재 직면한 문제로도 메인서버가 아닌 green group 서버에도 nginx가 배포되는 문제가 있다. nginx 설정을 뺀 docker-compose 파일을 따로 만들자 !)
또한 만약 메인 서버가 빌드하기에 적절하지 않은 컴퓨팅 자원이라면 깃허브 러너를 사용해야 하므로, 변동되는 IP에 따른 ec2 인바운드 규칙 설정에 대해서는 다음 포스팅 을 참고하여 해결하자.
현재는 썩 좋은 아키텍쳐라고 생각하지 않지만, 최대한 작은 환경(적은 서버)에서 깃허브 액션을 통한 CI/CD를 연습하는데에는 큰 무리가 없을 것이라 생각한다.
reference
'♾️DevOps > ♾️CI & CD' 카테고리의 다른 글
[CI/CD] - 무중단 배포 with CI/CD 2: github action을 활용한 CI 편 (0) | 2024.02.26 |
---|---|
[CI/CD] - 무중단 배포 with CI/CD 1: nginx + spring 로드 밸런싱 편 (0) | 2024.02.26 |
[CI/CD] - 도커와 젠킨스를 사용한 CI/CD -4 (도커의 설치부터 자동배포까지) (5) | 2024.01.09 |
[Trouble Shooting] - 젠킨스 빌드 시 error: external filter 'git-lfs filter-process' failed 에러 해결하기 (1) | 2023.12.30 |
[CI/CD] - 도커와 젠킨스를 사용한 CI/CD -3 (도커의 설치부터 자동배포까지) (0) | 2023.10.22 |