만쥬의 개발일기
article thumbnail

최종 아키텍쳐

 

이제 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 서버에서 몇 가지를 수정해주어야 한다.

  1. 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 yesPubkeyAcceptedKeyTypes=+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

profile

만쥬의 개발일기

@KangManJoo

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!