[무중단 배포] Nginx를 사용해 EC2에 무중단 배포 적용하기

무중단 배포란?

단순히 CI / CD만 적용하면 배포단계에서는 애플리케이션이 종료된다는 문제가 있다.

만약 실제 서비스에서 사용자들이 서비스를 이용하는 도중에 갑자기 서비스가 중단된다면 좋지 않은 사용자 경험을 줄 것이다.

이 문제를 해결 해주는 것이 바로 "무중단 배포"이다.

무중단 배포는 말 그대로 서버를 중단 없이 배포하는 것이다.

무중단 배포에 대한 더 자세한 내용은 아래 포스팅에서 설명한다.

 

무중단 배포란? 무중단 배포 전략에 대해 알아보자.

무중단 배포란? 무중단 배포를 사용하지 않을 경우 실제 서비스를 서버 한대만을 사용해 운영한다고 가정해보자. 현재 서비스 중인 서비스는 application-V1을 배포한 서비스이다. 그런데 기능이 업

hstory0208.tistory.com

 

 

무중단 배포를 구현하는 방식은 여러가지가 있다.

예를들어 AWS의 ELB, 쿠버네티스, Nginx 등등...

필자는 여기서 Nginx로 무중단 배포를 구현했다.

 

Nginx를 선택한 이유?

일단 AWS의 서비스를 이용해 무중단 배포를 하는 건 프리티어 계정을 이용하고 있지만 기간이 얼마 남지도 않았고,

과금방지를 위해 리소스를 정리해야하는데 삭제할 리소스들이 많으면 번거로워진다는 이유도 있었다.

그리고 쿠버네티스를 사용하기에는 학습시간을 무시할 수 없었을 것 같았기 때문에 패스했다.

 

Nginx를 선택한 이유는 간단하다.

배포를 위해 추가적인 서버(EC2)를 더 만들 필요가 없이 한 대의 서버를 이용해 배포가 가능했고, 구현하기 쉽고 가장 비용이 저렴했기 때문이다.

Nginx란 무엇인가에 대한 내용은 아래 포스팅에서 설명한다.

 

Nginx란 무엇이고 왜 사용하는가? (Apache와 차이점)

Nginx의 등장 이전 최초의 웹 서버는 1995년 UNIX 기반으로 만들어진 NCSA Httpd 였다. 하지만 처음은 다 그렇듯이 NCSA Httpd에는 버그가 상당히 많아서 개발자들이 사용할 때 많은 불편함을 겪었다고 한

hstory0208.tistory.com

 

Nginx를 이용한 배포 방법

Nginx는 외부의 요청을 받아 서버로 요청을 전달하는 "리버스 프록시" 기능이 있다.

즉,  클라이언트는 nginx의 주소로 접속하고 nignx는 웹서버에 클라이언트의 요청을 전달하는 "클라이언트 -> nginx -> 웹서버" 구조가 된다.

 

출처 : jojoldu/springboot-webservice 

동작 방식을 살펴 보면 다음과 같다.

하나의 EC2 서버에 Nginx 1대와 스프링부트 jar를 2대를 사용하는 방식이다.

  1. 클라이언트는 Nginx 서비스 주소로 접속 (80 포트 or 443 포트)
  2. Nginx는 클라이언트 요청을 받아 현재 연결된 스프링 부트 1로 요청을 전달한다.
    연결되지 않은 스프링 부트2는 전달받지 못한다.
  3. 1.1 버전으로 신규 배포가 필요하면 Nginx 와 연결되지 않은 스프링 부트2로 배포된다. Nginx 는 스프링 부트 1을 바라보고 있으므로 배포하는 동안 서비스가 중단되지 않는다.
  4. 배포 이후 스프링 부트 2가 정상적으로 구동되는지 확인하고, nginx reload 명령어(1초 이내)를 통해 스프링 부트 2를 바라보도록 한다.

이 처럼 Nginx는 서버 내부에서 신규 트래픽을 어디로 라우팅할 것인지 정해 요청을 전달하므로 한대의 서버에서 2개의 애플리케이션을 무중단 배포 할 수 있는 것이다. 


EC2 생성 및 Nginx 설치하기

EC2를 생성하는 방법에 대해서는 간단하므로 생략하고 넘어 가겠다.

다만 주의할점으로는 EC2의 보안 그룹 인바운드 규칙에 Nginx의 포트 번호인 80 포트를 추가해주어야 한다.

(아래의 인바운드 규칙은 레디스, 카프카, mysql 포트도 함께 추가되어 있다.)

 

EC2에 Nginx 설치하기

필자는 EC2를  Amazon Linux AMI로 설치했기 때문에 Linux 명령으로 설치하였다.

sudo yum install nginx

nginx -v

설치가 정상적으로 완료되었다면 nginx를 시작해주자.

sudo systemctl start nginx

sudo systemctl status nginx


Nginx 설정

 

이제 Nginx가 스프링 부트 프로젝트를 바라볼 수 있도록 리버스 프록시 설정이 필요하다.

 

우선 나중에 swtich.sh 스크립트를 통해 포트를 동적으로 바꾸기 위해

sudo vim /etc/nginx/conf.d/service-url.inc  명령어로 해당 경로에 service-url.inc 파일을 만들어 아래처럼 작성해주자.

set $service_url http://127.0.0.1:8081;

 

그리고 sudo vi /etc/nginx/nginx.conf 명령어를 입력하고 nginx 설정 파일을 다음과 같이 수정 & 추가 해주자.

include /etc/nginx/conf.d/service-url.inc;

location / {
        proxy_pass $service_url;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
}

 

수정이 끝났으면 아래 명령어로 Nginx를 재시작 해주자.

sudo service nginx restart

AWS 엑세스 키 생성 ( IAM 사용자 )

외부에서 AWS 서비스에 접근할 수 있도록 AWS 엑세스 Key를 발급받을 것이다.

 

AWS 검색창 IAM 입력 -> 사용자 클릭 -> 사용자 생성 -> 사용자 이름 작성 -> 다음 클릭

이제 권한 설정 창에서 "직접 정책 연결"을 클릭하고 AmazonS3FullAccess 와 AWSCodeDeployFullAccess 를 추가해주고 다음을 클릭하자.

 

이런식으로 추가되면 된다. 사용자 생성을 눌려주자.

 

그리고 생성한 사용자에 들어가 엑세스 키 만들기를 클릭.

그 다음 페이지에서 "기타"를 선택하고 다음 이동.

 

설명 태그 값을 작성학 엑세스 키 만들기를 클릭한다.

그러면 아래처럼 엑세스키와, 비밀 엑세스 키가 발급된다.

이제 이 키를 사용해 Gitaction에서 해당 AWS 서비스들에 접근 가능하도록 할 것이다.

이 창을 나가면 다시 확인하지 못하므로 따로 잘 저장해두는 것을 추천한다.


EC2 역할 추가 ( IAM 역할 )

이제 EC2가 CodeDeploy의 배포를 받을 수 있도록 역할을 추가해줄 것이다.

위 사용자 생성과 동일하게 AWS 검색창 IAM 입력 -> 역할 클릭 -> 역할 만들기 

 

다음과 같이 선택하고 다음으로 넘어간다.

 

아래 AmazonEC2RoleforAWSCodeDeploy 정책을 추가하고 다음으로 넘어간다.

 

역할 이름을 작성해주고 다른 설정은 건들것 없이 역할 생성을 클릭해 역할을 생성해주면 된다.

 

이제 생성한 EC2에 다음과 같이 생성한 역할을 추가해주자.

 

IAM 역할을 업데이트 한다음에는 꼭 인스턴스 재부팅을 해야 역할이 적용된다.


S3 버킷 생성

딱히 건들게 없다 버킷 이름을 지어주고 버킷 만들기를 클릭해 만들어주자

보통은 S3 버킷을 생성할 때 편의상 퍼블릭 액세스를 허용해둘 것이다.

하지만 실제 서비스를 하게 된다면 보안상 문제가 발생할 수 있으므로

여기서는 엑세스를 차단하고 IAM 사용자 키를 발급받아 해당 키로 접근하려고 한다.


EC2 콘솔에 CodeDeploy Agent 설치

 

EC2 콘솔에 접속해 아래 명령어들을 차례대로 입력해주자.

aws s3 cp s3://aws-codedeploy-ap-northeast-2/latest/install . --region ap-northeast-2

chmod +x ./install

sudo ./install auto

sudo service codedeploy-agent status

 

아래처럼 running 메세지가 출력된다면 정상 설치된 것이다.

 

만약 ruby:No such file or directory 에러가 발생한다면 아래 명령어로 ruby를 설치해주고 다시 확인하면 정상 설치된걸 확인할 수 있다.

sudo yum install ruby

CodeDeploy 권한 추가

EC2에 역할을 추가한 것과 마찬가지로

CodeDeploy가 EC2에 접근하기 위해서는 권한이 필요하다.

EC2의 역할을 추가한것과 동일하게 IAM -> 역할 -> 역할 만들기를 클릭하고 다음과 같이 선택해주자.

 

CodeDeploy의 권한 정책은 한개 밖에없다. 다음을 누르고 역할 이름을 작성한 후 역할 생성을 클릭한다.

 


CodeDeploy 애플리케이션 생성

 

AWS 검색창에 CodeDeploy를 검색하고 다음과 같이 애플리케이션 생성을 클릭한다.

 

프로젝트 이름을 작성하고 EC2에 배포하므로 EC/온프레미스를 선택

 

생성된 애플리케이션을 들어가면 "배포 그룹 생성"이 보일 것이다.

 

배포 그룹 생성을 클릭하고 다음과 같이 설정해주자.

서비스 역할은 위에서 만든 CodeDeploy의 역할을 추가해주면 된다.

배포 유형의 경우에는 배포할 서비스가 1대이기 때문에 "현재 위치"를 선택한다. (2대 이상이면 블루/그린)

EC2 인스턴스의 태그그룹은 배포할 EC2의 이름을 클릭해서 설정해주면 된다.

 

배포 그룹 생성을 마쳤다면 이제 아래의 yml 파일을 작성해주자.

 

appspec.yml

이 파일은 AWS의 codedeploy의 배포를 관리하는데 사용되는 yml 파일이다.

파일에 정의된 내용대로 codedeploy가 작동한다.

프로젝트의 최상단 위치에 추가해주면된다.

# CodeDeploy 버전 ( codedeploy 테스트 모드는 0.0 )
version: 0.0

# 배포할 서버의 운영체제
os: linux

files:
  - source: / # CodeDeploy에서 전달해 준 파일 중 destination으로 이동시킬 대상을 지정 (루트 경로 : 전체 파일)
    destination: /home/ec2-user/app/deploy/zip/ # source에서 지정된 파일을 받을 위치
    overwrite: yes # 기존 파일들을 덮어 쓰기

# CodeDeploy에서 EC2로 넘겨준 파일들을 모두 ec2-user 권한 부여.
permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

# CodeDeploy 배포 단계에서 실행할 명령어를 지정. (차례 대로 스크립트들이 실행 )
hooks:
  AfterInstall:
    - location: stop.sh # 엔진엑스와 연결되어 있지 않은 스프링 부트를 종료.
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: start.sh # Nginx와 연결되어 있지 않은 Port로 새 버전의 스프링 부트 시작.
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh # 새 스프링 부트가 정상적으로 실행됬는지 확인.
      timeout: 60
      runas: ec2-user

 

 


GitAction에서 사용할 환경변수 등록

위에서 발급 받은 엑세스 키와 비밀 키를 아래처럼 등록 해주자.

이외에도 gitaction 배포에 사용되는 yml 파일에서 사용할 환경변수 값이 더 있다면,

추가로 key - value 형식으로 등록해주면 된다.


GitAction yml 작성

프로젝트 최상위 경로에서 아래처럼 폴더를 만들고 yml을 만들어주자. ( Github 프로젝트 레포지토리에서 Action 탭으로 만들어도 된다.)

이 yml은 GitAction에서 작동할 명령들을 담고 있다.

 

(자신의 AWS 서비스 이름에 맞게 수정)

name: deploy to ec2

on:
  push:
    branches: [ "master" ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v3

    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

    - name: Grant execute permission for gradlew
      run: chmod +x ./gradlew

    - name: Build with Gradle
      run: ./gradlew clean bootJar

    - name: Generate deployment package
      run: |
        mkdir -p before-deploy
        cp scripts/*.sh before-deploy/
        cp appspec.yml before-deploy/
        cp build/libs/*.jar before-deploy/
        cd before-deploy && zip -r before-deploy *
        cd ../ && mkdir -p deploy
        mv before-deploy/before-deploy.zip deploy/fruit-mall.zip
      shell: bash

    - name: Make zip file
      run: zip -r ./fruit-mall.zip .
      shell: bash

    - name: Deliver to AWS S3
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: us-east-1

    - name: Upload to S3
      run: aws s3 cp ./deploy/fruit-mall.zip s3://fruit-mall-s3/jar-folder/

    - name: Code Deploy
      run: |
        aws deploy create-deployment \
        --application-name fruit-mall-codedeploy \
        --deployment-config-name CodeDeployDefault.AllAtOnce \
        --deployment-group-name fruit-mall-codedeploy-group \
        --file-exists-behavior OVERWRITE \
        --s3-location bucket=fruit-mall-s3,bundleType=zip,key=jar-folder/fruit-mall.zip \
        --region us-east-1

프로필 설정

프로필 API 추가

이 컨트롤러의 profile() 메서드profile.sh 스크립트가 /profile로 get 요청 시real1, real2 프로필중 어떤 프로필을 사용할지 판단하는 코드를 담고 있다.

(스크립트 코드는 다음 목차에)

@RequiredArgsConstructor
@RestController
public class ProfileController {
    private final Environment env;

    @GetMapping("/profile")
    public String profile() {
        List<String> profiles = Arrays.asList(env.getActiveProfiles());
        List<String> realProfiles = Arrays.asList("real1", "real2");
        String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);

        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }
}

 

application.properties

필자의 경우에는 db정보와 그외 api들의 키값을 real-db, oper에 담고 있다.

그래서 기본 프로퍼티에 다음과 같이 설정을 했다.

real1 프로필이 활성화 되면 real1 프로필에 oauth,real-db,oper가 include 되고

real2 프로필이 활성화 되면 real2 프로필에 oauth,real-db,oper가 include 되는 방식이다.

spring.profiles.group.real1=oauth,real-db,oper
spring.profiles.group.real2=oauth,real-db,oper

 

나머지 프로퍼티에 중요한 정보들이 다 담겨 있기 때문에 real1과 real2는 포트번호만 적혀있다.

application-real1.properties
server.port=8081

 

application-real2.properties
server.port=8082

 

EC2에 프로퍼티 추가

만약 애플리케이션이 실행되는데 중요한 정보를 담은 프로퍼티가 gitignore로 배포시에 포함되지 않는다면

필자처럼 EC2상에 배포되지 않는 프로퍼티들을 작성해줘야한다.


배포 스크립트 작성

배포 스크립트를 다 추가하기에는 글이 너무 길어지므로 아래 Gist에 저장해두었다.

 

무중단 배포

무중단 배포. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

해당 스크립트들을 아래처럼 프로젝트의 루트 경로에 scripts 폴더를 만들어 추가해주자.

참고로 start.sh 스크립트의 마지막 부분에서 jar 파일을 실행시키는 옵션을 자신에 설정에 맞게 수정해야한다.

특히 build되지 않는 EC2 상에 있는 프로퍼티의 경로를 잘 지정해줘야 한다.


무중단 배포 완료

여기 까지 진행되었다면 이제 Github 레포지토리의 Master branch에 코드가 푸쉬될 때마다 배포가 진행될 것이다.

도입부에 있는 아키텍처는 RDS, ElastiCache들이 있지만 ElastiCache는 선택 사항이고 RDS를 사용하지 않는 사람도 있을 테니 해당 부분은 제외했다.

CodeDeploy에서 EC2로 배포 성공

 

서로 다른 real 프로필들이 실행되고 있는 모습

 

마지막으로 이 글을 보는 사람들은 무중단 배포를 처음 경험해보는 사람들이 많을 것이다.

그렇다면 보통은 프리티어를 사용할 것인데, 프리티어도 유효기간과 제한 용량이 있기 때문에 이 서비스들을 무한정으로 껴둘수 없다.

배포를 중단할 때 꼭 생성한 서비스들을 다 지우도록 하자 !

하나라도 까먹고 안지웠다간 나중에 야금야금 돈 깍아먹고있을것이다....