본문 바로가기
Book/스프링 부트와 AWS 웹 서비스

[무중단 배포] 24시간 365일 중단 없는 서비스 만들기 (feat. Nginx)

by 달의 조각 2023. 3. 2.
이 글은 이동욱 님의 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 책을 읽으며 정리한 글입니다.

 

무중단 배포란?
EC2에 엔진엑스 설치 및 스프링 부트 연동
무중단 배포 스크립트 만들기

 

  배포 자동화 환경을 구축해 놓고 보니 배포를 하는 동안에는 애플리케이션이 종료된다는 문제가 있었다. 긴 시간은 아니지만 새로운 Jar가 실행되기 전까진 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단된다.

 

🐬 무중단 배포

  • AWS에서 블루 그린(Blue-Green) 무중단 배포
  • 도커를 이용한 웹 서비스 무중단 배포

위의 방법 외에도 L4 스위치를 이용한 무중단 배포 방법도 있지만 고가의 장비이다 보니 대형 인터넷 기업 외에는 쓸 일이 거의 없다.

Nginx

  • 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어
  • 고성능 웹 서버이기 때문에 대부분 서비스들이 사용하며, 저렴하고 쉽다.

엔진엑스의 기능 중 리버스 프록시란, 엔진엑스가 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위를 말한다. 이를 통해 무중단 배포를 구축할 수 있다. AWS 같은 클라우드 인프라가 구축되어 있지 않아도 개인 서버에서도 동일한 방식으로 구축할 수 있다.

위와 같이 하나의 EC2 혹은 리눅스 서버에 엔진엑스 1대와 스프링 부트 Jar를 2대 사용한다.

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

 

🐬 EC2에 엔진엑스 설치 및 스프링 부트 연동하기

sudo amazon-linux-extras install -y nginx1 # 엔진엑스 설치
& sudo service nginx start # 엔진엑스 실행
Redirecting to /bin/systemctl start nginx.service

 

보안 그룹 추가

엔진엑스의 포트 번호(기본 80)를 보안 그룹에 추가한다. [EC2 → 보안 그룹 → EC2 보안 그룹 → 인바운드 편집]

 

리다이렉션 주소 추가

구글과 네이버에서도 변경된 주소를 등록한다. 기존에 등록한 리디렉션 주소에서 8080 포트를 제거하여 추가 등록한다.

포트 번호 없이(80번 포트는 웹 서버 기본 포트) 도메인만 입력해서 접속하면 엔진엑스 웹페이지를 볼 수 있다

 

엔진엑스와 스프링 부트 연동

엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라보도록 프록시 설정을 한다.

sudo vim /etc/nginx/nginx.conf # 엔진엑스 설정 파일 열기

server 아래의 location / 부분을 찾아서 아래와 같이 추가한다.

location / {
        proxy_pass http://localhost:8080; # 엔진엑스로 요청이 오면 해당 주소로 전달
        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;
}
sudo service nginx restart # 엔진엑스 재시작

재시작까지 완료하면 브라우저에 접속했을 때 엔진엑스가 스프링 부트를 프록시 한다.

 

🐬 무중단 배포 스크립트 만들기

Profile API 생성

배포 시에 선택해야 할 포트 번호(8081, 8082)를 판단하는 API를 추가한다.

@RequiredArgsConstructor
@RestController
public class ProfileController {

    private final Environment env;

    @GetMapping("/profile")
    public String profile() {
        List<String> profiles = Arrays.asList(env.getActiveProfiles()); # 현재 실행 중인 ActiveProfile을 모두 가져온다
        List<String> realProfiles = Arrays.asList("real", "real1", "real2");
        String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);

        return profiles.stream()
                .filter(realProfiles::contains)
                .findAny()
                .orElse(defaultProfile);
    }
}
server.port=8081 // application-real1.properties
server.port=8082 // application-real2.properties

 

더보기

테스트 코드 작성

// 스프링 환경 불필요
public class ProfileControllerUnitTest {

    @Test
    public void real_profile이_조회된다() {
        //given
        String expectedProfile = "real";
        MockEnvironment env = new MockEnvironment();
        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("oauth");
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile();

        //then
        assertThat(profile).isEqualTo(expectedProfile);
    }

    @Test
    public void real_profile이_없으면_첫번째가_조회된다() {
        //given
        String expectedProfile = "oauth";
        MockEnvironment env = new MockEnvironment();

        env.addActiveProfile(expectedProfile);
        env.addActiveProfile("real-db");

        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile();

        //then
        assertThat(profile).isEqualTo(expectedProfile);
    }

    @Test
    public void active_profile이_없으면_default가_조회된다() {
        //given
        String expectedProfile = "default";
        MockEnvironment env = new MockEnvironment();

        ProfileController controller = new ProfileController(env);

        //when
        String profile = controller.profile();

        //then
        assertThat(profile).isEqualTo(expectedProfile);
    }
}

/profile API가 인증 없이도 호출될 수 있도록 SecurityConfig에 아래 코드를 추가한다.

.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProfileControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void profile은_인증없이_호출된다() {
        String expected = "default";

        ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(expected);
    }
}

profile이 잘 출력된다

 

엔진엑스 설정 수정

배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘러보내는)이 순식간에 교체된다. 이를 위한 설정을 해 보자!

# sudo vim /etc/nginx/conf.d에 엔진엑스 설정이 모여 있다
$ sudo vim /etc/nginx/conf.d/service-url.inc # 새 파일 생성
set $service_url http://127.0.0.1:8080; # 위 파일 내부에 작성

위에서 생성한 파일을 엔진엑스가 사용할 수 있게 설정한다. 아래와 같이 설정하고 재시작한다.

sudo vim /etc/nginx/nginx.conf

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;
}

 

배포 스크립트 작성

mkdir ~/app/step3 && mkdir ~/app/step3/zip

 

  • stop.sh: 기존 엔진엑스에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료
  • start.sh: 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행
  • health.sh: 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
  • switch.sh: 엔진엑스가 바라보는 스프링 부트를 최신 버전으로 번경
  • profile.sh: 앞선 4개 스크립트 파일에서 공용으로 사용할 'profile'과 포트 체크 로직

앞으로 step3를 사용할 것임과 실행시킬 스크립트를 CodeDeploy 설정을 하는 appspec.yml에 수정한다. Jar 파일이 복사된 이후부터 차례로 아래 스크립트가 실행된다.

version: 0.0 # CodeDeploy 버전
os: linux
files:
  - source: / # CodeDeploy에서 전달해 준 파일 중 destination으로 이동시킬 대상 지정: 루트 경로(전체 파일)
    destination: /home/ec2-user/app/step3/zip/ # source에서 지정한 파일을 받을 위치
    overwrite: yes

...

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

 

🐬 무중단 배포 테스트

배포 테스트 전에 잦은 배포로 Jar 파일명이 겹칠 것을 고려하여 자동으로 버전값이 변경되도록 설정한다.

// build.gradle
group 'com.apupu'
version '1.0.1-SNAPSHOT-' + new Date().format("yyyyMMddHHmmss")
sourceCompatibility = '11'

수정된 코드를 push 한 후 로그를 확인해 보면 아래와 같이 잘 진행되는 것을 확인할 수 있다!
(tail 명령어는 파일의 마지막 행을 기준으로 파일 내용 일부를 출력한다. (기본 10줄))

tall -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log

스프링 부트 로그를 보고 싶다면 아래 명령어를 입력하면 된다.

vim ~/app/step3/nohup.out

배포를 두 번 진행해 보고 아래 명령어를 통해 자바 애플리케이션 실행 여부를 확인하면 두 개의 애플리케이션이 실행되고 있음을 확인할 수 있다.

ps -ef | grep java

 

댓글