본문 바로가기

Study

[Nginx, Docker, GithubActions] 무중단 배포 구현하기

프로젝트에 무중단배포를 도입해 보자.

기본적으로 무중단배포를 구현하는 방식은 다음과 같다.

우선 어플리케이션을 두 개 이상 실행시켜서 트래픽을 분산(트래픽 분배는 로드밸런서 사용)시킨다. 그리고 각각의 서버에 순차적으로 배포를 진행하면서 배포가 적용되지 않은 서버에서 배포 서버로 트래픽을 옮겨나가는방법으로 다운타임을 없애야 한다. (이러한 배포 방식에도 블루-그린, 롤링, 카나리 등 여러 가지 전략이 있다.)

위와 같이 무중단 배포의 전체적인 그림을 알게 되었을때 가장 먼저 생긴 궁금증은 "그렇다면 하나의 EC2 인스턴스에서 어플리케이션을 포트만 나눠서 두 개의 서버로 실행시켜도 되는가? 아니면 두 개의 인스턴스가 있어야 하는가? " 였다.
결론적으로는 여러 인스턴스를 운용하며 로드밸런서로 각각의 어플리케이션에 요청을 보내는 방법도 사용할 수 있고, 하나의 인스턴스 내에서 여러 개의 포트를 사용하여 어플리케이션을 여러 개 실행하는 방법으로도 구현이 가능하다.
→ 사이드 프로젝트 레벨에서는 하나의 인스턴스에 jar 어플리케이션 2개를 실행하는 방식을 많이 채택하는 것 같았다.

 

배포 전략은 조금 뒤에 선택하기로 하고, 우선 어떤 로드밸런서를 사용할지, 인스턴스는 어떻게 관리하는 게 좋을지 배경지식 공부와 의사결정이 필요하다.

nginx, EB(Elastick BeanStalk) 라는두 가지 후보군이 있다. 각 후보군에 대해 알아보고, 장단점을 비교해 보며 현재 프로젝트의 특성을 고려하여 적합한 것을 고르자.

nginx

장점
1. 예제와 레퍼런스가 가장 많다.
2. 스프링에서 기본으로 사용하는 아파치 톰캣 보다 훨씬 많은 요청을 빠르고 안정적으로 처리할 수 있다.(비동기, non-blocking 방식으로 인한 구조적 차이) → 동기 비동기, blocking /non-blocking 에 대해 정확한 이해를 가져가기
→  아파치의 C10k 문제, 동기 비동기는 무적인가?
(해당 키워드에 대한 자세한 정리는 따로 글로 작성할 예정)
3. 로드밸런서로 활용 시 리버스 프록시 구조로 직접 서버 주소를 노출하는 것보다 보안성이 좋다.
  → 왜 "리버스" 프록시인가?
        → 클라이언트 측에 위치하여 요청을 외부 임의의 서버로 전달하는 것이 "정방향 프록시(forward proxy)", 리버스 프록시는 이와 반대로 서버 측에 위치하여 내부망의 다른 서버에 요청을 전달한다고 해서 붙여진 이름이다.

단점
1.정적 파일에 최적화 되어 있어, 동적 컨텐츠 처리에 한계가 있다.(기본적으로 톰캣과 같은 WAS 가 아닌 웹 서버이기 때문) → 다른 백엔드 어플리케이션 서버로 극복 가능(톰캣 같은 WAS 와 같이 사용)
2. 요청 기반이 아닌 연결 기반 -> 공부 필요
3. 설정 복잡성(러닝커브)

 

EB(Elastick BeanStalk) 

AWS 에서 제공하는 어플리케이션 관리 서비스이다.인프라 관리의 대부분을 높은 수준으로 추상화하여 간단한 설정만으로 인스턴스와 배포 정책, 로드밸런서 등을 제어할 수 있으며, 인프라 지식이 없어도 최소한의 노력으로 무중단 배포를 구현할 수 있다. 또한 AWS CloudWatch 와 통합되어 어플리케이션 서버를 모니터링하고, 트래픽 증가에 따라 자동으로 리소스를 확장한다.

가장 큰 장점은 추상화된 간단한 설정으로 개발자가 인프라 구축이 아닌 코드에 집중할 수 있다는 것이다. 배포, 로드밸런싱 뿐만이 아닌 모니터링에 이은 확장 기능까지 가지고 있기 때문에 관리 작업 전체를 통합할 수 있다.
그러나 인프라 세부 사항을 완벽하게 제어할 수 없기 때문에, 복잡하고 정교한 인프라 요구사항이 있을 때 완벽하고 유연하게 대처하기 힘들며 문제가 발생했을 때 디버깅으로 원인을 정확히 파악하기 어렵다는 단점이 있다. 그 밖에 자동 스케일링 등에 의한 비용 문제가 발생할 가능성이 있다.

 

Nginx vs Elastick Beanstalk

개발자 입장에서는 인프라 관리 전체를 통합하여 제공해 주며 최소한의 학습으로 다양한 관리 기능을 제공하는 EB 가 합리적인 선택일 수 있다. 그러나 배포 자동화, 무중단 배포, 오토 스케일링 등 다양한 인프라 요구사항을 해결하는 과정을 직접 경험을 통해 학습하고 싶었고, 이를 위해서는 추상화된 편리함을 제공하는 EB 를 도입하는 것 보다는 여러 도구들(Nginx, Docker, GithubActions 등) 을 사용하여 요구사항을 구현하는 것이 더 도움이 될 것이라고 판단하였다. 위 맥락에서는 무중단 배포 도구로서 nginx 와 EB를 비교하였지만, 정확하게는 "EB 를 사용하여 배포, 로드밸런싱, 오토스케일링 등 인프라 관리 전반적인 문제를해결할 것이냐" 와 "직접 부딫히며 구현할 것이냐" 에서의 고민이 있었고, 학습을 위해 후자를 택했다고 볼 수 있을 것 같다.

 

두 개 이상의 인스턴스를 사용할 것인지 하나의 인스턴스에서 여러 포트를 사용할 것인지에 대해서도 고민이 있었는데,
현재 시점에서 1차적인 목표는 무중단 배포를 실현하는 것이기 때문에 간단하게 하나의 인스턴스를 사용하기로 하였다. 여러 인스턴스를 사용하여 관리나 구현 복잡성이 따라오는 문제를 배제하고, 우선 구현을 목표로 한 다음 확장성과 단일 실패지점 문제를 고려해 인스턴스를 확장시키는 방향으로 리팩토링 진행하면 좋을 것 같다.

무중단 배포 전략으로는 구현이 직관적인 블루-그린 방식을 선택하였다. 구현 복잡성 외에도 롤링, 카나리 배포 전략은 호환성 문제(배포가 진행되는 중에 발생한 요청이 배포가 적용된 서버 또는 배포가 적용되지 않은 서버로 일관되지 않게 전달될 가능성)를 가지고 있기 때문에 배제하려고 했다.
또한 블루-그린 전략이 가지고 있는 자원 문제는 현재 어플리케이션 규모에서는 치명적으로 작용하지 않을 것이라 판단하여 크게 고려하지 않았다.

 

블루-그린 배포 방식

 

 

구현을 시작하기에 앞서 어떤 흐름으로 다운타임 없이 배포가 진행되는지 큰 그림을 알아보자.

  1. 어플리케이션이 배포된 인스턴스에 nginx 를 설치하고, 서버 앞단에서 리버스 프록시 역할을 하도록 한다.
    → 여기서 nginx 의 역할은 리버스 프록시 + 로드밸런서이다. 리버스 프록시와 로드밸런서의 역할이 비슷하다고 생각하는 경우가 있는데(나도 같은 것으로 생각하던 때가 있었다), 둘의 개념은 명확히 다르다. 리버스 프록시는 서버 앞에 위치하여 요청을 수신하고 내부의 다른 서버로 요청을 전달하는 역할을, 로드밸런서는 의도한 포트(서버)로 요청을 적절하게(전략에 따라) 분배하는 역할 한다. nginx 는 두 가지 기능을 모두 가진 웹 서버이다.
  2. 어플리케이션이 실행되고 있는 포트로 요청을 라우팅한다.
    ex) 현재 8081 포트에서 어플리케이션이 실행되고 있다면, nginx 가 수신한 어플리케이션으로의 요청은 모두 8081 포트로 라우팅한다. 해당 설정은 nginx 에서 변경할 수 있다.
  3. 배포가 트리거되면, 어플리케이션이 실행되고 있지 않은 포트로 새로운 버전을 배포한다.
    ex) 8081 포트에서 어플리케이션이 실행되고 있으므로 8082 포트에 새로운 버전을 배포한다.
  4. 배포가 진행되는 동안 Health Check 요청을 전송하여 요청을 받을 수 있는 상태인지 확인한다.
    ex) 배포가 진행 중인 8082 포트로 HealthCheck 요청을 일정 간격으로 송신한다.
  5. 배포가 성공하여 Health Check 요청이 정상적인 응답을 반환하면, 현재 8081 포트로 라우팅 되고 있는 요청을 8082 포트로 전환한다.

 

 

위 작업은 모두 자동화 되도록 workflow 로 작성할 것이다. 이제 이 흐름을 토대로 본격적인 구현을  시작해 보자.

먼저 다음 명령어로 nginx를 설치한다. 

 sudo apt-get install nginx -y


nginx 를 설치하면 루트 디렉토리의 etc/nginx 경로에 nginx.conf 라는 설정 파일과, nginx.conf.d 라는 설정 디렉토리가 생성된다.

(디렉토리 구조 ↓ )

또 etc/nginx/sites-available 이라는 디렉토리 안에 default 설정 파일이 있다. (각 설정 파일이 어떻게 다른지, 무엇을 뜻하는지 알아보고 넘어가자 -> 아마 site-available 디렉토리의 설정파일은 http 요청에 관한 설정 파일로 보인다.)
NO! -> sites-available 디렉토리는 추가 가상 호스트에 대한 구성 파일을 저장하는 디렉토리다.

nginx 의 설정 파일

더보기

nginx 가 요청을 어떻게 제어할지 결정하는 일은 설정 정보를 변경함으로써 이루어진다. 따라서 nginx 가 어떤 종류의 설정 파일들을 가지고 있는지, 각 설정 파일이 어떤 정보를 담고 있고 이를 어떻게 활용할 수 있는지 알고 있는 것이 중요하다. 앞서 말했듯이 nginx 에는 etc/ngnix 경로의 .conf 파일과 sites-available 하위의 구성 파일들이 있다. 각 설정 파일이 어떻게 다른지 알아보자.


.conf 파일

.conf 파일은 nginx 에 대한 전역 설정 정보를 담고 있다. 로깅, http 서버 구성, ssl 설정 등이 포함되어 있으며, sites-available 하위의 구성 파일 중 어떤 파일을 설정 정보로 사용할 지(site-enabled 내에서 심볼릭 링크로 지정)에 대한 설정도 포함하고 있다.(→ 쉽게 말해 sites-available 구성 정보도 포함하고 있다) 또한 etc/nginx 경로의 다른 정보들(커스텀 설정 파일, mime 타입 등) 도 포함하고 있다.

.conf 파일은 nginx의 전역 설정 정보를 가지고 있다.

 

site-available(site-enable) 디렉토리

site-available 디렉터리 하위에는 가상 호스트에 대한 구성 정보를 저장하는 파일들을 저장한다. 기본적으로 default 라는 이름의 구성 파일이 저장되어 있으며, 이 안에는 간단한 설명과 함께 기본적인 가상 호스트 설정이 포함되어 있다.
여기서 nginx 에서의 가상 호스트가 무엇이며, default 에서는 어떤 정보들을 설정하는 걸까? 에 대한 것이 궁금해졌다. 
가상 호스트란, nginx 서버가 다른 어플리케이션의 호스트 역할을 가상으로 수행할 수 있음을 뜻한다. 단일 nginx 서버 하나로 여러 어플리케이션 웹 사이트를 호스팅할 수 있다. 여기서 사용자는 직접 어플리케이션으로 요청을 보내지 않고, nginx 를 거쳐 각 어플리케이션마다의 설정 및 인증 정보 등을 가지고 해당 어플리케이션으로 전달된다. 즉 가상으로 다른 웹 어플리케이션을 호스팅 할 수 있고, 이에 대한 구성 정보를 저장하는 파일이 site-available 하위의 파일이다.(기본적으로 default 파일이 정의되어 있지만, 일반적으로 이를 사용하지 않고 자신의 구성 파일을 만들어 사용한다.) site-available 하위에 여러 구성 파일이 있다면, site-enabled 내부에서 심볼릭 링크를 지정하여 어떤 구성 파일을 사용할것인지( = 어떤 구성 파일이 .conf 파일에 포함되도록 할 것인지) 를 정할 수 있다.

 

site-available 하위의 설정 파일에서 어떤 포트로 http 요청을 수신하고, 어떤 내부 포트로 요청을 전달할지 결정할 수 있다. 기본 설정파일인 default 가 어떻게 생겼는지, 어떤 설정을 담고 있는지 확인해 보자.(default 파일은 설정을 잘 사용하기 위한 틀일 뿐이다. 이것을 직접 사용하기보다 적절한 설정 파일을 새로 작성해서 사용하자.)

server {
       listen 80;			# IPv4에서 80포트로 들어오는 요청을 수신
       listen [::]:80;		# IPv6에서 80포트로 들어오는 요청을 수신

       server_name example.com;	# 이 서버 블록이 example.com 이라는 도메인에 대해 작동하도록 지정

       root /var/www/example.com;	# 서버가 웹 컨텐츠를 제공할 디렉토리의 경로를 지정
       index index.html;	# 디렉토리 내 index.html 파일이 있으면 이를 기본 파일로 사용

       location / {			# 루트(/)경로에 해당하는 요청 처리 방법을 정의하는 블록
               try_files $uri $uri/ =404;	# 요청 처리 방법 정의
       }
}

주석으로 각 라인의 의미에 대한 설명을 달아놨다.

 

설정 정보에 대한 이해를 바탕으로 이제 본격적으로 무중단 배포를 구현해 보려고 한다.

우선 스프링부트 어플리케이션을 두 개 실행해야 한다. 현재 도커 컴포즈파일을 실행하는 방식으로 컨테이너를 띄우고 있었기 때문에, 도커 컴포즈 파일을 아래와 같이 수정한 다음 실행시켰다.

version: '3.8'
services:

  myapp1:
    image: seungheyon/myfirsthub:latest
    container_name: myapp1
    ports:
      - 8081:8080
    environment:
      # my env 
    depends_on:
      # - mysql
      - redis

  myapp2: # 추가된 스크립트
    image: seungheyon/myfirsthub:latest
    container_name: myapp2
    ports:
      - 8082:8080
    environment:
      # my env
    depends_on:
      # - mysql
      - redis

  redis:
    image: redis:latest
    ports:
      - 6379:6379


위 방식은 하나의 도커 컴포즈 파일을 사용해 두 개의 myapp 어플리케이션을 컨테이너로 실행한 모습이다. 요청은 하나의 컨테이너로만 받을 것이기 때문에 자원 문제는 현재로선 고려하지 않는다.

포트 부분의 8081:8080, 8082:8080 이 어떤 의미인지 정확히 알고 넘어가자. 
→ 간단하게 8081:8080 이 뜻하는 바는 호스트 시스템의(EC2 인스턴스) 8081 포트가 컨테이너의 8080 포트로 매핑됨 을 말한다. 처음에 8081:8080 과 8082:8080 설정이 공존하는 것을 보고 "도커 컨테이너의 같은 8080 포트를 바라보기 때문에 다같이 중지되는 거 아닌가?" 하는 생각이 들어서 찾아 보고 새로 알게 된 사실은(아마 도커를 처음 공부할때 분명 봤겠지만 대충만 이해하고 넘어갔을 것이다.) 컨테이너는 독립적으로 동작한다는 사실이다. 그렇기 때문에 위와 같이 설정하더라도 각각의 컨테이너의 8080 포트를 바라보고 있는 것이고, 배포 시 서버가 다운되더라도 요청을 반대쪽으로 옮긴다면 큰 문제가 없을 것이다.

 

 

컨테이너 두개가 실행됨을 확인(이미지)

이제 nginx 설정을 건드리자.

아까 말한것처럼 개별 가상 호스트에 대한 설정은 sites-available 의 myapp 파일을 만들어서 설정하고, .conf 파일에서 해당 설정을 포함하는 것으로 한다. 그러나 작성한 myapp 파일에서는 개별 서버에 대한 구성 정보만을 포함하기 때문에, 전역 http 에 대한 설정이 필요하다면 이는 .conf 파일에서 해 주어야 한다.

블루-그린 배포 방식에서는 .conf 파일에서 별도의 설정을 해주지 않아도, 서버 구성에서 location 블록 하위에 proxy_pass 설정으로 어떤 포트로 요청을 전달할지 설정할 수 있다. 따라서 .conf 파일에서 upstream 지시어를 사용하는 것이 절대적으로 필요하진 않다. 
그러나 nginx 의 로드밸런서의 기능을 활용하거나, 부하를 분산하는 방식은 추후에 변경되거나 새로 도입될 가능성이 충분하기 때문에, http 하위의 upstream 블록을 사용해 nginx 의 로드밸런서 기능을 활용하기로 했다. 

(nginx.conf 사진)

(sites-available/myapp1, myapp2 사진)

위 설정을 통해 기본적으로 myapp1 서버에서 모든 요청을 처리하도록 하였다.

 

이제 githubActions workflow 스크립트를 수정하여 자동화를 해보자.

이를 위해 현재 nginx 설정에서 요청을 전달중인 포트를 검색하고, 사용중이지 않은 반대쪽 포트의 어플리케이션에서 배포를 실행한 다음 해당 포트로 요청을 이전시켜야 한다.

그러기 위해 nginx 설정 파일에서 현재 어떤 포트로 트래픽을 전달하고 있는지 확인하고, 포트 넘버에 따라 반대쪽 포트에 배포하는 로직이 필요하다. 리눅스 쉘 명령어 사용법을 찾아 가며 아래와 같은 스크립트를 작성했다.

(워크플로우 스크립트)

 

또 여기서 확인해야 할 것은, 배포 이후 배포된 포트로 트래픽을 전달하려면 어플리케이션이 요청을 받을 수 있는 상태가 되기까지 기다리고, 요청을 처리할 수 있게 될 때 트래픽을 이전시켜야 한다.

따라서 5초마다 요청을 보내서 정상 응답이 돌아올때까지 확인하는(Health check) 로직을 추가하였다.

(스크립트)

 

그러나 워크플로우 실행 시 타임아웃이 계속 발생했다. 워크플로우 상에서는 별다른 로그가 없이 타임아웃만 출력돼 있었기 때문에, nginx 의 에러 로그 파일을 뒤져서 어떤 이유로 요청이 실패했는지를 확인했다.

 

 

서버 이름이 예제의 서버 이름으로 되어 있었다. 이를 ec2 인스턴스의 퍼블릭 주소로 바꾸고 다시 실행하니, 다음과 같이 새로운 에러 로그가 발생했다.

 

대략 nginx 에서 어플리케이션이 실행되는 포트로 요청을 전달할 때 생긴 문제로 보였다. 더 정확한 원인을 파악하기 위해, 간단한 api 를 전송해 응답을 확인하고자 했다. 

 

401 에러!
이제야 구체적인 문제를 확인할 수 있었다. 인증 문제였다. 현재 어플리케이션은 Spring Security 설정으로 몇몇 요청을 제외하고는 인증 필터를 거치도록 구성되어 있다. Security Config 파일에서 해당 옵션을 수정하여 헬스체크용 api 를 열어놓으면 해결될 것이다.

 

해결되지 않은 문제

1. 헬스체크용 엔드포인트와 api 를 작성했지만 여전히 401 에러를 뱉고 있다.

1 -1) 컨테이너를 중지하고 요청을 보냈을 때는 502 응답이 돌아오고, 컨테이너가 실행 중일 때는 401 응답이 돌아온다. 따라서 배포 자체는 정상적으로 되고 있으며 요청을 처리할 수 있는 상태임을 알 수 있다.

1 - 2) 그렇다면 Security config 설정에 잘못된 부분이 있을 수도 있다. 이를 확인하기 위해 로컬에서 서버를 실행하고 로컬 호스트로 헬스체크 요청을 전송하였다. 그 결과 로컬에서는 정상 응답(200)을 확인할 수 있었다. 따라서 코드상의 문제는 없어보이고, 배포 환경에서 어떠한 문제가 있다고 보여진다

1 - 3) 지금까지 흐름에서 가장 의심되는 것은, 배포 환경에서 최신 버전이 아닌 어플리케이션을 실행하고 있는 것이다. 코드의 문제는 아니고 배포가 잘못되거나 요청을 받지 못하는 것도 아니니, 이렇게밖에 생각할 수 없다.
그렇다면 정말 최신 버전의 어플리케이션을 배포하지 않고 있을까? 실험을 위해, 정상적으로 응답을 반환하는 api 의 출력을 수정해 봤다.

Global Exception Handler 에서 출력하는 메세지의 포맷을 임시로 수정하고 배포했다. 다시 배포환경으로 api 를 쐈을 때 위와 같은 메세지가 출력되어야 한다.

postman 응답

업데이트 이전의 응답이 온다.
최신 버전의 커밋이 반영되지 않고 있을 확률이 매우 높다. 
가장 먼저 확인한 것은, "체크아웃 액션이 default 브랜치의 내용만을 페치하는 건 아닐까?" 하는 것이었다. 그래서 코드에서 사용하고 있던 actions/checkout@v4 에 대해 알아보니, 기본적으로 워크플로우를 트리거한 커밋에 대해서 페치를 한다고 한다. 따라서 개발 브랜치의 변경사항을 반영할 것이므로 체크아웃 방식은 문제가 없었다.

다음으로 확인한 것은 컨테이너의 실행 방식, 정확하게는 "도커에서 항상 도커 허브의 최신 이미지를 pull 해 와서 실행하는가" 였다. 

~~~~~

~~~~~

"pull policy" 를 추가해서 해결!

관련 공식문서 내용 확인해서 근거 찾기 -> 도커 컴포즈는 기본적으로 로컬에서 해당 이미지를 찾고, 로컬에 없을 경우에만 도커 허브와 같은 레지스트리에서 이미지를 가져온다! -> pull policy : always 로 설정함으로써 항상 도커 허브에서 이미지를 가져오도록 할 수 있음

pull_policy 추가

 

성공! 

 

2. 워크플로우 스크립트로 nginx 가상호스트 설정파일의 내용을 변경하는 것이 적용되지 않는다.
(현재 트래픽이 전달되는 포트를 판단하고 반대쪽에 배포하는 건 ok, 그러나 이후 설정파일의 내용을 변경하는 것이 반영되지 않음)

아니? 설정파일 변경도 잘 적용되는 중이고, 트래픽 이전도 잘 되는중. 그러나 sites-avlailable 의 파일이 아닌 sites-enabled 의 파일만 변경됨. 그럼에도 트래픽은 정상적으로 변경됨.

2 - 1) 왜 sites-enabled 의 설정파일만 변경해도 트래픽이 바뀌나?

2 - 2) 헬스체크를 안해도 되는걸까? 확인해보자.

 

남은 할 일

1. 배포 안하는 쪽 어플리케이션 내리기

2. 이미지를 매번 pull 해오면 로컬에 이미지가 쌓인다. 이를 비워주자.

3. 환경변수 관리

4. 도커 컴포즈로 레디스를 띄우면 매번 재시작 해줘야 한다. 이 문제를 일반적으로 어떻게 해결하는 지 알아보고, 프로젝트에 적합한 방식으로 풀어보자.

5. 어플리케이션 서버가 두 개 이상 운영중일 때, 배치 작업의 처리는 어떻게? 정확하게 이해하고 해결하자

 

.

.

 

 

우선순위가 높음에도 계속 미뤄왔던 환경변수 문제를 해결하고자 한다. 

일단 환경변수와 secret 을 구분하여 사용해야 할 것 같다. 현재는 일반적인 환경변수를 포함해 민감한 정보(secret 으로 다뤄야 할 정보)들까지 모두 환경변수처럼 취급하여 보안에 상당히 취약하다. 따라서 이들 중 secret 으로 저장해야 하는 정보들을 따로 관리해 줘야 한다. 현재 배포 환경에서 도커 컴포즈를 사용하여 컨테이너를 실행하고 있으므로, 비밀 관리에 도커 컴포즈 secret 을 사용하기로 했다.