아침7시개발·2023년 1월 17일
0

서버

목록 보기
12/12

무중단 배포

  • 서비스를 유지하면서, 배포하는 방법
  • AWS - 블루 그린 무중단 배포
  • 도커를 이용한 웹서비스 무중단 배포 (Nginx)
    • Ngix의 여러 기능 중 리버스 프록시가 있다.
    • 리버스 프록시 : 외부의 요청을 받아 백앤드 서버로 요청 전달하는 행위
    • 가장 저렴하고 쉬운 편
      L4 스위치를 이용한 무중단 배포 (고가의 장비로 거의 사용하지 않는다.)

무중단 배포 구조 - Nginx

  • 사용자는 서비스 주소로 접속 (80 또는 443 포트)
  • Nginx는 요청을 받아 스프링 부트로 요청 (8081포트로 가정)
  • 8082는 Nginx와 연결된 상태가 아니기 때문에 요청받지 못한다.
  • 1.1 버전으로 신규 배포가 필요하면, 연결되지 않은 8082포트로 배포

  • 배포하는 동안에도 서비스는 중단되지 않는다. (8081을 바라보고 있기 때문)
  • 배포가 끝난 후 8082포트가 정상 작동하는지 확인
  • 8082포트가 정상 작동한다면, nginx reload를 통해 8081 대신 8082를 바라보게 한다. (0.1초 이내)
  • 이후 새로운 배포가 생기면 8082를 유지한채로 새로운 배포를 하며 그곳에 다시 nginx reload를 한다.
  • 전체 시스템 구조는 위 이미지와 같다.
  • EC2 내부에서 Nginx를 통해 무중단 배포가 가능하다.

Nginx 연동

Nginx 설치

sudo amazon-linux-extras install nginx1
sudo service nginx start
  • Amazon Linux2는 yum을 통한 nginx 설치가 지원되지 않는다.
  • 정상 실행시 Redirecting to /bin/systemctl start nginx.service 와 같은 메시지를 볼 수 있다.

80포트 설정

  • Nginx 는 기본적으로 80포트를 지원한다.
  • EC2 - 보안그룹 - 80포트 추가해주자.

  • 구글, 네이버 URL 모두 8080포트 부분을 지워준다.
  • 기존의 8080포트를 제외한 DNS 주소만 이용해서 접속시 Nginx 웹페이지를 볼 수 있다.

스프링부트 연동

sudo vim /etc/nginx/nginx.conf

   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;
    }
  • 해당 부분에 다음과 같이 server 안에 location 을 추가 한다.
  • proxy_pass : 엔진엑스 요청이 오면 해당 url로 전달
  • proxy_set_header XXX : 실제 요청 데이터를 header의 각 항목에 할당

    8080포트 없이 DNS 주소만으로 접속이 가능해진다.

무중단 배포스크립트

import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.Arrays;
import java.util.List;
 
@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("real", "real1", "real2");
        String defaultProfile = profiles.isEmpty()? "default" : profiles.get(0);
 
        return profiles.stream().filter(realProfiles::contains).findAny().orElse(defaultProfile);
    }
}
  • ProfileController 추가
  • 실행 중인 ActiveProfile을 모두 가져온다.
  • real, oauth, real-db 등 활성화 되어있다면 3개가 모두 담겨 있다.
  • 배포에 사용될 profile(real1, real2)가 하나라도 있으면 그 값을 반환 (step2 재사용을 위해 real도 남겨둔다.)
package com.crawler.web;
 
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.security.core.parameters.P;
 
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
 
class ProfileControllerTest {
 
    @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);
    }
}
  • ProfileController 테스트 작성
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
    private final CustomOAuth2UserService customOauth2UserService;
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                    .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                    .userInfoEndpoint()
                    .userService(customOauth2UserService);
    }
}
  • Security.config에 인증없이 호출될 수 있게 andMatcher에 /profile 추가
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SecurityConfigTest {
 
    @LocalServerPort
    private int port;
 
    @Autowired
    private TestRestTemplate restTemplate;
 
    @Test
    public void profile_인증없이_호출된다() throws Exception {
        String expected = "default";
        ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(expected);
    }
}
  • Security.config 테스트 - profile 인증없 이도 호출 되는지 여부 테스트
  • 해당 테스트까지 통과 되었다면 깃 푸쉬후 배포하자.
  • DNS/profile 했을 때 profile이 나오면 성공

profile 생성

  • 현재 EC2 환경에서 실행되는 profile은 real 밖에 없다.
  • 무중단 배포를 위해 profile 2개를 생성하자.
server.port=8081
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc
  • application-real1.properties 파일 생성
server.port=8082
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc
  • application-real2.properties 파일 생성
  • 2 파일 모두 만들었다면 깃허브 푸쉬

Nginx 설정 수정

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, proxy_pass 수정
  • sudo service nginx restart 로 Nginx 재시작

배포 스크립트 작성

mkdir ~/app/step3 && mkdir ~/app/step3/zip
  • step3 디렉토리 생성
version: 0.0
os: linux
files:
  - source:   /
    destination: /home/ec2-user/app/step3/zip/
    overwrite:  yes
 
permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user
 
hooks:
  AfterInstall:
    - location: stop.sh # 엔진엑스와 연결되어 있지 않은 스프링 부트를 종료합니다.
      timeout: 60
      runas: ec2-user
  ApplicationStart:
    - location: start.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
      timeout: 60
      runas: ec2-user
  ValidateService:
    - location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인 합니다.
      timeout: 60
      runas: ec2-user
  • appspec.yml 을 step3 변경 및 hooks 추가 설정
#!/usr/bin/env bash
 
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
source ${ABSDIR}/switch.sh
 
IDLE_PORT=$(find_idle_port)
 
echo "> Health Check Start!"
echo "> IDLE_PORT: $IDLE_PORT"
echo "> curl -s http://localhost:$IDLE_PORT/profile "
sleep 10
 
for RETRY_COUNT in {1..10}
do
  RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
  UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)
 
  if [ ${UP_COUNT} -ge 1 ]
  then # $up_count >= 1 ("real" 문자열이 있는지 검증)
      echo "> Health check 성공"
      switch_proxy
      break
  else
      echo "> Health check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
      echo "> Health check: ${RESPONSE}"
  fi
 
  if [ ${RETRY_COUNT} -eq 10 ]
  then
    echo "> Health check 실패. "
    echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi
 
  echo "> Health check 연결 실패. 재시도..."
  sleep 10
done
  • health.sh
#!/usr/bin/env bash
 
# bash는 return value가 안되니 *제일 마지막줄에 echo로 해서 결과 출력*후, 클라이언트에서 값을 사용한다
 
# 쉬고 있는 profile 찾기: real1이 사용중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
function find_idle_profile()
{
    RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
 
    if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면 (즉, 40x/50x 에러 모두 포함)
    then
        CURRENT_PROFILE=real2
    else
        CURRENT_PROFILE=$(curl -s http://localhost/profile)
    fi
 
    if [ ${CURRENT_PROFILE} == real1 ]
    then
      IDLE_PROFILE=real2
    else
      IDLE_PROFILE=real1
    fi
 
    echo "${IDLE_PROFILE}"
}
 
# 쉬고 있는 profile의 port 찾기
function find_idle_port()
{
    IDLE_PROFILE=$(find_idle_profile)
 
    if [ ${IDLE_PROFILE} == real1 ]
    then
      echo "8081"
    else
      echo "8082"
    fi
}
  • profile.sh
#!/usr/bin/env bash
 
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
 
REPOSITORY=/home/ec2-user/app/step3
PROJECT_NAME=crawler-web
 
echo "> Build 파일 복사"
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY/"
 
cp $REPOSITORY/zip/*.jar $REPOSITORY/
 
echo "> 새 어플리케이션 배포"
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
 
echo "> JAR Name: $JAR_NAME"
 
echo "> $JAR_NAME 에 실행권한 추가"
 
chmod +x $JAR_NAME
 
echo "> $JAR_NAME 실행"
 
IDLE_PROFILE=$(find_idle_profile)
 
echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."
nohup java -jar \
    -Dspring.config.location=classpath:/application.properties,classpath:/application-$IDLE_PROFILE.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties \
    -Dspring.profiles.active=$IDLE_PROFILE \
    $JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
  • start.sh
#!/usr/bin/env bash
 
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
 
IDLE_PORT=$(find_idle_port)
 
echo "> $IDLE_PORT 에서 구동중인 애플리케이션 pid 확인"
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})
 
if [ -z ${IDLE_PID} ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
  echo "> kill -15 $IDLE_PID"
  kill -15 ${IDLE_PID}
  sleep 5
fi
  • stop.sh
#!/usr/bin/env bash
 
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
 
function switch_proxy() {
    IDLE_PORT=$(find_idle_port)
 
    echo "> 전환할 Port: $IDLE_PORT"
    echo "> Port 전환"
    echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
 
    echo "> 엔진엑스 Reload"
    sudo service nginx reload
}
  • switch.sh

무중단 배포테스트

version = '1.0.1-SNAPSHOT-' + new Date().format("yyyyMMddHHmmss")
  • build.gradle 버전을 수정하여 빌드 할 때마다 버전명에 현재 시간이 들어가도록 추가 (다른 jar 명생성)
  • 깃 푸쉬 후 CodeDeploy 로그 확인 , 해당 결과가 나오면 정상 동작
  • 2개의 어플리케이션이 실행 중임을 확인 - 테스트 완료

출처

무중단 배포 - Nginx

profile
쉬엄쉬엄하는 개발자

0개의 댓글