ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 10장) 24시간 365일 중단 없는 서비스를 만들자
    Java & Spring/스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2020. 7. 18. 15:40
    반응형
    • 9장까지 진행한 경우, 긴 시간은 아니지만, 새로운 Jar가 실행되기 전까진 기존 Jar를 종료시켜 놓기 때문에 서비스가 중단됨

    무중단 배포 소개

    • 무중단 배포 방식

      • AWS에서 블루 그린(Blue-Green) 무중단 배포
      • 도커를 이용한 웹서비스 무중단 배포
      • L4 스위치 ~> 고가의 장비라 큰 기업 말고는 잘 안씀
      • 엔진엑스
        • 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 SW
        • 대부분의 서비스에서 이용
        • 리버스 프록시 => NginX가 외부의 요청을 받아 백앤드 서버로 요청을 전달
    • 엔진엑스(NginX)

      • 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있다.
      • 하나의 EC2 혹은 리눅스 서버에 NginX 1대와 스프링 부트 Jar 2대를 이용
        • NginX는 80(http), 443(https) 포트를 할당
        • 스프링 부트1은 8081 포트로 실행
        • 스프링 부트2는 8082 포트로 실행
      • 운영 과정
        • 사용자가 서비스 주소로 접속(80 or 443)
        • 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청 전달
          • 스프링 부트1 (8081 포트)로 요청을 전달한다고 가정
        • 스프링 부트2는 NginX와 연결된 상태가 아니니 요청받지 못함
      • 신규 배포
        • 1.1 버전으로 신규 배포가 필요하면, NginX와 연결되지 않은 스프링부트2(8082 포트)로 배포
          • 배포하는 동안 서비스 중단 X (NginX가 스프링부트 1을 바라보기 때문)
          • 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인
          • 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8081 대신 8082를 바라보도록 설정
          • reload는 0.1초 이내에 완료
        • 1.2 버전으로 신규 배포가 필요하면, NginX와 연결되지 않은 스프링부트1(8081 포트)로 배포
          • 현재 NginX와 연결된 것은 스프링 부트2(8082 포트)
          • 스프링 부트1의 배포가 끝났다면 NginX가 스프링 부트1을 바라보도록 변경하고 nginx reload 명령어 실행
          • 이후 요청부터는 NginX가 스프링 부트 1로 요청 전달

    엔진엔스 설치와 스프링 부트 연동하기

    • 엔진엑스 설치
      • EC2 접속 ~> sudo yum install nginx
      • 엔진엑스 실행 ~> sudo service nginx start
        • 실행이 잘 됐다면 Starting nginx: [ OK ] 메세지를 볼 수 있음
    • 보안 그룹 추가
      • 엔진엑스의 포트 번호를 보안 그룹에 추가하기
        • 엔진엑스의 포트번호는 기본적으로 80 이다.
        • EC2 -> 보안 그룹 -> EC2 보안 그룹 선택 -> 인바운드 편집
        • 0.0.0.0/0, ::/0에 TCP, 80 포트 설정 => 이름은 NginX로 하든 Whatever 입력
    • 리다이렉션 주소 추가
      • 기존 8088에서 80 포트로 주소가 변경 ~> 네이버와 구글 로그인에도 적용
      • 구글 승인된 리디렉션 URI
        • public DNS 주소/login/oauth2/code/google 추가
      • 네이버 승인된 리디렉션 URI
        • public DNS 주소/login/oauth2/code/google 추가
    • 엔진엑스와 스프링 부트 연동
      • 위의 과정까지 마쳤으면, 페이지 접속 시에 nginx 페이지를 볼 수 있다.
      • EC2에 접속 -> 엔진엑스 설정 파일 열기
        • sudo vim /etc/nginx/nginx.conf
      • 설정 내용 중 server 아래의 location / 부분을 찾아서 추가하기
        location / {
                proxy_pass http://localhost:8088;
                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;
        }
        • proxy_pass
        • proxy_set_header ~~
          • 실제 요청 데이터를 header의 각 항목에 전달\
          • ex) proxy_set_header XReal-IP $remote_addr
            • Request Header의 X-Real-IP에 요청자의 IP를 저장
      • 엔진엑스 재시작 => sudo service nginx restart
      • 다시 페이지에 접속하면 기존에 구축했던 페이지가 보임

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

    무중단 배포 스크립트 작업 전에 API 하나 추가

    ~> 이 API는 배포 시에 8081을 쓸지, 8082를 쓸지 판단하는 기준이 됨

    • profile API 추가

      • ProfileController 생성

        package com.zin0.book.springboot.web;
        
        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);
            }
        }
        • env.getActiveProfiles()
          • 현재 실행 중인 ActiveProfile을 모두 가져온다.
          • 즉, real, oauth, real-db 등이 활성화되어 있다면(active) 3개가 모두 담김
          • 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록 한다.
          • 실제로 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해 볼 수도 있으니 real도 남겨둠
    • 테스트 코드 작성 -> ProfileControllerUnitTest 생성

      package com.zin0.book.springboot.web;
      
      import org.junit.Test;
      import org.springframework.mock.env.MockEnvironment;
      
      import static org.assertj.core.api.Assertions.assertThat;
      
      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);
          }
      }
      • 스프링 환경이 필요하지 않기때문에 @SpringBootTest 없이 테스트 코드 작성
      • Enviornment는 인터페이스라 가짜 구현체인 MockEnviornment를 이용해서 테스트
      • 만약, Enviornment를 @Autowired로 DI 받았다면, 항상 스프링 테스트를 해야만 했음.
    • SecurityConfig 클래스에 /profile이 인증 없이도 호출될 수 있게 제외코드 추가
      • .antMatchers("/", "/css", "/images/", "/js/", "/h2-console/", "/profile").permitAll()
        • 기존 작성 코드에서, permitAll의 마지막에 "/profile"이 추가됨
    • 테스트 코드로 SecurityConfig 설정 테스트하기

      package com.zin0.book.springboot.web;
      
      import org.junit.Test;
      import org.junit.runner.RunWith;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.test.context.SpringBootTest;
      import org.springframework.boot.test.web.client.TestRestTemplate;
      import org.springframework.boot.web.server.LocalServerPort;
      import org.springframework.http.HttpStatus;
      import org.springframework.http.ResponseEntity;
      import org.springframework.test.context.junit4.SpringRunner;
      
      import static org.assertj.core.api.Assertions.assertThat;
      
      @RunWith(SpringRunner.class)
      @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
      public class ProfileControllerTest {
      
          @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()).isEqualByComparingTo(HttpStatus.OK);
              assertThat(response.getBody()).isEqualTo(expected);
          }
      }
      • 스프링 시큐리티 설정을 불러와야 하기 때문에, @SpringBootTest를 사용
      • 여기까지 진행하고, push한 다음 public DNS/profile로 잘 나오는지 확인 ~> real 이 뜸
    • real1, real2 profile 생성
      • real = Travice CI 배포 자동화를 위한 profile
      • real1,real2 = 무중단 배포를 위한 profile
        • application-real1.properties
          server.port=8081
          spring.profiles.include=oauth,real-db
          spring.jpa.properties.hibernate.dialeect=org.hibernate.dialect.MySQL5InnoDBDialect
          spring.session.store-type=jdbc
        • application-real2.properties
          server.port=8082
          spring.profiles.include=oauth,real-db
          spring.jpa.properties.hibernate.dialeect=org.hibernate.dialect.MySQL5InnoDBDialect
          spring.session.store-type=jdbc
        • 각각 포트 번호만 다르고, 나머지는 같음.
    • 엔진엑스 설정 수정
      • 부중단 배포의 핵심 = 엔진엑스 설정
      • 배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체
      • 프록시 설정이 교체될 수 있도록 설정 추가
      • EC2 접속 => /etc/nginx/conf.d/ 에 service-url.inc라는 파일 생성
        • sudo vim /etc/nginx/conf.d/service-url.inc
          set $service_url http://127.0.0.1:8088;
      • nginx.conf 파일 열기
        • sudo vim /etc/nginx/nginx.conf
        • location / 부분을 찾아 변경
          include /etc/nginx/conf.d/service-url.inc;
          location / {
                  proxy_pass $service_url;
                  ...
          }
      • 재시작 => sudo service nginx restart
      • 브라우저에서 정상적으로 호출되는지 확인 => 엔진엑스 설정까지 끝남
    • 배포 스크립트들 작성

      • EC2에 step3 디렉토리 생성 (step2와 중복되지 않기 위해)

        • mkdir ~/app/step3 && mkdir ~/app/step3/zip
      • appspec.yml 수정 (destination step3로 수정하기)

        version: 0.0
        os: linux
        files: # Travis 구동을 위해 주석 수정
          - source : /
            destination: /home/ec2-user/app/step3/zip/
            overwrite: yes
      • 무중단 배포를 진행할 스크립트

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

        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
        • Jar 파일이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 생각하면 된다.
      • profile.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
        }
        • $(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
          • 현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인
          • 응답값을 HttpStatus로 받는다.
          • 정상이면 200, 오류 => 400~503 사이로 발생 => 400 이상은 모두 예외로 보고 real2를 현재 profile로 사용
        • IDLE_PROFILE
          • 엔진엑스와 연결되지 않은 profile
          • 스프링 부트 프로젝트를 이 profile로 연결하기 위해 반환
        • echo "${IDLE_PROFILE}"
          • bash라는 스크립트는 값을 반환하는 기능 X
          • 제일 마지막 줄에 echo로 결과를 출력 후, 클라이언트에서 그 값을 잡아서 ($(find_idle_profile)) 사용한다.
          • 중간에 echo를 사용해서는 안된다.
      • stop.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
        • ABSDIR=$(dirname $ABSPATH)
          • 현재 stop.sh가 속해 있는 경로를 찾는다.
          • 하단의 코드와 같이 profile.sh의 경로를 찾기 위해 사용
        • source ${ABSDIR}/profile.sh
          • 자바로 보면 일종의 import 구문
          • 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용 가능
      • start.sh

        #!/usr/bin/env bash
        
        ABSPATH=$(readlink -f $0)
        ABSDIR=$(dirname $ABSPATH)
        source ${ABSDIR}/profile.sh
        
        REPOSITORY=/home/ec2-user/app/step3
        PROJECT_NAME=zin0Stduy-springboot2-webservice
        
        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 &
        • 기본적인 스크립트는 step2의 deploy.sh와 유사
        • 다른 점은, IDLE_PROFILE을 통해 properties 파일을 가져오고(application-$IDLE-PROFILE.properties), active profile을 지정하는 것(-Dspring.profiles.active=$IDLE_PROFILE) 두 부분.
        • 여기서도 IDLE_PROFILE을 사용하니까 profile.sh을 가져와야 한다.(source ~)
      • health.sh

        #!/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
        • 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행됐는지 체크한다.
        • 잘 떴는지 확인돼야 엔진엑스 프록시 설정을 변경(switch_proxy)한다.
        • 엔진엑스 프록시 설정 변경은 switch.sh에서 수행
      • swtich.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
        }
        • echo "set $service_url http://127.0.0.1:${IDLE_PORT};"
          • 하나의 문장을 만들어 파이프라인(|)으로 넘겨주기 위해 echo를 사용
          • 엔진엑스가 변경할 프록시 주소를 생성
          • 쌍따옴표 ( " " ) 를 사용해야 한다.
          • 사용하지 않으면, $service_rul을 그대로 인식하지 못하고 변수를 찾게 된다.
        • | sudo tee /etc/nginx/conf.d/service-url.inc
          • 앞에서 넘겨준 문장을 service-url.inc에 덮어쓴다.
        • sudo service nginx reload
          • 엔진엑스 설정을 다시 불러온다.
          • restart와 다름
          • restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러온다.
          • 다만, 중요한 설정들은 반영되지 않으므로 restart를 사용해야 한다.
          • 여기서는 외부의 설정 파일인 service-url을 다시 불러오는 거라 reload로 가능

    무중단 배포 테스트

    • 자동으로 버전값이 변경될 수 있도록 build.gradle 수정

      version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")
      • build.gradle은 Groovy 기반의 빌드 툴
      • 따라서 Groovy 언어의 여러 문법을 사용 가능 ~> new Date()로 빌드할 때마다 그 시간이 버전에 추가되도록 구성
      • 여기까지 진행했으면, 푸시하고 배포가 자동으로 진행되는지 확인
    • CodeDeploy 로그로 진행상태 확인하기

      • tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
        • 로그 메세지를 보면 sh에서 설정한 echo가 저장됨
        • pid 확인, build파일 복사, 새 배포, jar 권한 추가 및 실행, profile이 real1인지 2인지 등
    • 스프링 부트 로그도 확인하기
      • vim ~/app/step3/nohup.out
    • 배포 할 때마다, real1과 real2로 번갈아가면서 배포됨
      • 여러번 배포하면서 브라우저 새로고침을 해보면, 중단이 없는 것을 확인
      • 2번 배포를 진행한 뒤에 자바 애플리케이션 실행 여부 확인
        • ps -ef | grep java
        • 그럼, 2개의 애플리케이션이 실행되고 있음을 볼 수 있다.
          • active=real1, active=real2 각각
          • 만약 3번이면 3개가 실행되고 있을 것

    반응형

    댓글

Designed by Tistory.