이전에 작성한 Jenkins + CodeDeploy를 활용한 EC2에 스프링 부트 프로젝트 배포 자동화를 약간 더 개선한 버전입니다.
사용한 프로젝트의 경우 단순한 테스트용으로 작성했기 때문에 도커 컴포즈를 사용하지는 않았습니다.
과정은 다음과 같습니다.
jenkins
와 webhook
으로 연동된 깃허브 리포지토리 브랜치(main
)에 push
합니다.webhook
으로 인해 jenkins
가 호출되고, 빌드를 진행합니다.jenkins
가 빌드한 파일을 s3
에 전달해 업로드합니다.jenkins
가 codedeploy
에게 배포를 요청합니다.codedeploy
는 s3
에 업로드된 빌드 파일을 가져옵니다.codedeploy
가 ec2
에 배포를 진행합니다.nginx
를 통해 무중단 배포를 진행합니다.무중단 배포의 경우 rolling
방식을 사용했습니다.
이를 위해 스프링 부트 애플리케이션을 두 개 실행하고, 이를 profile
로 구분했습니다.
무중단 배포의 흐름은 다음과 같습니다.
현재 동작 중인 애플리케이션의
profile
은real1/8081
, 동작하고 있지 않은 애플리케이션의profile
은real2/8082
,nginx
의 리버스 프록시는포트 8081
을 가리키고 있다고 가정하겠습니다.
codedeploy
에서 배포 할 때 현재 동작하고 있는 애플리케이션을 확인합니다.real1
이 동작 중인 것을 확인합니다.real2
이기 때문에 real2 컨테이너 종료 -> 컨테이너 삭제 -> 도커 이미지 삭제
순으로 진행합니다.nginx
에서 기존에 사용하고 있는 이전 버전 애플리케이션의 포트를 새롭게 배포한 애플리케이션의 포트로 변경합니다.real2 도커 이미지 생성 -> 도커 컨테이너 실행
이후 nginx
가 바라보고 있는 포트를 8081
에서 8082
로 변경합니다.클라이언트 입장에서는 다음과 같이 애플리케이션을 사용합니다.
real2
가 배포되고 실행되는 도중에는 real1/8081
로 접속해 기존 서비스를 사용합니다. real2
의 배포가 완료되고 nginx
의 리버스 프록시가 적용되면 real2/8082
로 접속해 새로운 서비스를 사용할 수 있습니다.배포용 Jenkins
서버와 실제 애플리케이션 서버를 분리하는 걸 권장하지만, 프리 티어로 사용하기 위해 하나의 인스턴스를 사용했습니다.
nginx
ec2
에서 직접 설치하는 식으로 진행했습니다.https
로 배포하는 것까지 고려하고 있기 때문입니다.https
로 배포한다고 해도 ec2
에 설정을 하지 않으면 의미가 없다고 생각했기 때문입니다.ec2
에서 자바 관련 세팅을 생략하고 도커로 애플리케이션 의존성을 관리하고자 했습니다.Jenkins
서버와 애플리케이션 전용 서버를 분리할 경우를 고려했습니다.스프링 부트 프로젝트를 생성합니다.
간단하게 web
과 lombok
만 의존하도록 했습니다.
추가적으로 actuator
도 의존할 예정입니다.
implementation 'org.springframework.boot:spring-boot-starter-actuator'
actuator
의존성을 추가합니다.
실행되고 있는 애플리케이션의 상태를 파악할 수 있는 기능으로, 무중단 배포에서 사용할 예정입니다.
jar {
enabled = false
}
애플리케이션을 배포할 때 plain
버전은 필요없으니 이에 대해 설정해줬습니다.
management:
endpoint:
endpoints:
web:
base-path: /application
---
spring:
config:
activate:
on-profile: real1
server:
port: 8081
---
spring:
config:
activate:
on-profile: real2
server:
port: 8082
management
의 경우 actuator
의 관련 설정입니다.
actuator
의 경우 기본적으로 /actuator/health
경로로 실행되고 있는 애플리케이션의 상태를 확인할 수 있습니다.
이를 /application/health
로 확인할 수 있도록 변경했습니다.
설정한 대로 /application/health
로 요청하면 현재 애플리케이션의 상태를 확인할 수 있습니다.
actuator
관련 설정을 추가하지 않았기 때문에 기본적인 애플리케이션 상태(status
)만 확인할 수 있으며, 실행 중이기 때문에 UP
이라고 출력되었음을 확인할 수 있습니다.
밑에는 각 profile
에 대한 설정입니다.
real1 / real2
로 이름 지었으며, 각각 8081 / 8082
포트로 동작합니다.
@RestController
@RequiredArgsConstructor
public class ProfileController {
private final Environment env;
@GetMapping("/profile")
public String getProfile() {
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);
}
}
현재 동작하고 있는 profile
이 무엇인지 확인하기 위한 컨트롤러입니다.
real1
아니면 real2
로 고정되겠지만, 기본 profile
인 default
를 추가해 배포 후 애플리케이션 실행 상황에서의 예외 처리를 해주었습니다.
이전에 작성한 Jenkins + CodeDeploy를 활용한 EC2에 스프링 부트 프로젝트 배포 자동화의 설정과 거의 동일합니다.
그렇기 때문에 공통적인 부분은 제외하고 진행하도록 하겠습니다.
도커 이미지로 애플리케이션을 관리하기 때문에, ec2
에 직접 자바를 설치할 필요가 없어졌습니다.
보안 그룹, nginx
, jenkins
를 제외한 나머지는 이전 글과 동일하게 진행합니다.
22(ssh)
, 80(nginx)
, 9000(jenkins)
만 허용해주면 됩니다.
**/*.jar, **/appspec.yml, **/scripts/* Dockerfile
기존 설정에서 빌드 파일 배포 후 도커 이미지를 생성하기 위한 Dockerfile
도 포함했습니다.
# aws ec2 linux nginx 설치(nginx1)
sudo amazon-linux-extras install nginx1 -y
# nginx 설치 확인
nginx -v
nginx version: nginx/1.20.0
# nginx 시작
sudo service nginx start
Redirecting to /bin/systemctl start nginx.service
nginx
가 정상적으로 동작하는지 확인한 뒤 나머지 작업을 진행합니다.
# 리버스 프록시 설정
# 기본 설정은 유지
sudo vim /etc/nginx/nginx.conf
server {
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-Forworded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
}
# 리버스 프록시 설정
# 배포할 때 마다 포트 변경
# real1 -> 8081, real2 -> 8082
# 첫 번째 배포 시에 정상적으로 포트가 설정되는지 확인하기 위해 8082로 설정
set $service_url http://127.0.0.1:8082;
# nginx 재시작
sudo service nginx restart
Redirecting to /bin/systemctl restart nginx.service
/etc/nginx/nginx.conf
의 경우 다음과 같이 작성됩니다.
FROM openjdk:11-jdk
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} cicd-test.jar
ARG IDLE_PROFILE
ENV ENV_IDLE_PROFILE=$IDLE_PROFILE
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${ENV_IDLE_PROFILE}", "/cicd-test.jar"]
-Dspring.profiles.active
를 통해 실행할 profile
을 지정했습니다.
Dspring.profiles.active=${IDLE_PROFILE}
와 같이 환경변수를 직접 지정하는 경우 인식을 못하기 때문에 ARG
와 ENV
를 사용해 우회했습니다.# appspec.yml
version: 0.0
os: linux
files:
- source: /
destination: /home/ec2-user/cicd-test
overwrite: yes
permissions:
- object: /
pattern: "**"
owner: ec2-user
group: ec2-user
hooks:
ApplicationStop:
- location: scripts/stop.sh
timeout: 360
runas: ec2-user
ApplicationStart:
- location: scripts/start.sh
timeout: 360
runas: ec2-user
ValidateService:
- location: scripts/health.sh
timeout: 360
runas: ec2-user
code deploy
의 hook
에 따라 실행할 스크립트를 분리했습니다.
ApplicationStop
hook
의 경우 이전 버전 애플리케이션을 종료할 때 사용합니다.stop.sh
스크립트를 실행시켜 기존에 실행되고 있던 도커 컨테이너를 종료하고, 이미지를 삭제해주었습니다.ApplicationStart
hook
의 경우 최신 버전 애플리케이션을 실행할 때 사용합니다.start.sh
스크립트를 실행시켜 jenkins
가 빌드한 jar
를 통해 새로운 도커 이미지를 생성하고, 도커 컨테이너를 실행하도록 했습니다.ValidateService
hook
의 경우 배포가 성공적으로 완료되었는지에 대한 검증 로직을 실행할 때 사용합니다.health.sh
을 통해 최신 버전 애플리케이션이 정상적으로 동작하는지 확인하고, nginx
의 리버스 프록시 설정을 변경하도록 했습니다.#!/bin/bash
function find_profile()
{
CURRENT_PROFILE=$(curl -s http://localhost/profile)
if [ $CURRENT_PROFILE == real1 ]
then
IDLE_PROFILE=real2
elif [ $CURRENT_PROFILE == real2 ]
then
IDLE_PROFILE=real1
else
IDLE_PROFILE=real1
fi
echo "${IDLE_PROFILE}"
}
function find_port()
{
IDLE_PROFILE=$(find_profile)
if [ $IDLE_PROFILE == real1 ]
then
IDLE_PORT=8081
elif [ $IDLE_PROFILE == real2 ]
then
IDLE_PORT=8082
fi
echo "${IDLE_PORT}"
}
function find_switch_port()
{
CONTAINER_ID=$(sudo docker ps -f "ancestor=real2" -q)
if [ -z $CONTAINER_ID ]
then
echo "8081"
else
find_port
fi
}
각 스크립트에서 자주 사용하는 기능들을 따로 함수로 분리했습니다.
find_profile
profile
로 새 버전의 애플리케이션을 실행할지 확인하는 함수입니다.real1
이 동작 중이면 real2
로, real2
가 동작 중이면 real1
이 동작하도록 했습니다.real1 / real2
모두 동작하고 있지 않기 때문에 real1
을 반환하도록 했습니다.find_port
find_profile
을 호출하도록 했습니다.real1
일 때는 8081
, real2
일 때는 8082
로 실행하도록 했습니다.find_switch_port
real1
을 반환하므로 real2
가 도커 컨테이너로 실행되어 있는지 확인합니다.real2
를 실행하는 도커 컨테이너가 없다면 첫 배포이므로, 8081
을 반환합니다.real2
를 실행하는 도커 컨테이너가 있다면 첫 배포가 아니므로, find_port
를 호출합니다.#!/bin/bash
echo "> stop 시작" >> /home/ec2-user/deploy.log
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
IDLE_PROFILE=$(find_profile)
echo "> 실행할 profile : $IDLE_PROFILE " >> /home/ec2-user/deploy.log
CONTAINER_ID=$(sudo docker ps -f "ancestor=${IDLE_PROFILE}" -q)
if [ -z $CONTAINER_ID ]
then
echo "> 기존에 실행되고 있던 컨테이너가 없습니다. " >> /home/ec2-user/deploy.log
else
echo "> 기존에 실행되고 있던 컨테이너 : $CONTAINER_ID" >> /home/ec2-user/deploy.log
sudo docker stop $CONTAINER_ID
sudo docker rm $CONTAINER_ID
echo "> 컨네이너 종료 완료, $CONTAINER_ID" >> /home/ec2-user/deploy.log
sudo docker image rm $IDLE_PROFILE
echo "> 기존 이미지 삭제 완료, $IDLE_PROFILE" >> /home/ec2-user/deploy.log
sleep 10
fi
profile.sh
에서 실행해야 할 profile
을 조회합니다.
이 profile
로 최신 버전의 애플리케이션을 배포해야 하므로, 해당 profile
이 도커에 있는지를 확인하고, 있다면 종료합니다.
도커 이미지의 경우 이름이 동일하면 이전 버전은 댕글링 이미지가 되기 때문에 삭제해주었습니다.
echo "> start 시작" >> /home/ec2-user/deploy.log
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
IDLE_PROFILE=$(find_profile)
IDLE_PORT=$(find_port)
echo "> 도커 이미지 파일을 생성합니다." >> /home/ec2-user/deploy.log
sudo docker build --build-arg IDLE_PROFILE=${IDLE_PROFILE} -f /home/ec2-user/cicd-test/Dockerfile -t ${IDLE_PROFILE} /home/ec2-user/cicd-test >> /home/ec2-user/deploy.log 2>/home/ec2-user/deploy_err.log
sleep 30
echo "> 도커 컨테이너를 실행합니다." >> /home/ec2-user/deploy.log
sudo docker run -p ${IDLE_PORT}:${IDLE_PORT} ${IDLE_PROFILE} >> /home/ec2-user/spring.log 2>/home/ec2-user/deploy_err.log &
sleep 10
profile.sh
에서 실행해야 할 profile
과 port
를 가져와서 도커 이미지를 생성한 후, 도커 컨테이너를 실행합니다.
도커 이미지의 경우 간단하게 profile
명으로 지정했습니다.
도커 컨테이너는 &
으로 백그라운드로 실행하도록 했습니다.
#!/bin/bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
function switch_nginx_proxy()
{
IDLE_PORT=$(find_switch_port)
echo "> 실행 포트 : $IDLE_PORT" >> /home/ec2-user/deploy.log
echo "> /etc/nginx/conf.d/service-url.inc 변경" >> /home/ec2-user/deploy.log
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
echo "> nginx 재시작" >> /home/ec2-user/deploy.log
sudo service nginx restart
}
profile.sh
를 통해 실행 포트를 찾아옵니다.
이후 nginx
의 리버스 프록시 설정을 변경해줍니다.
변경 이후 nginx
를 재시작합니다.
#!/bin/bash
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
source ${ABSDIR}/profile.sh
source ${ABSDIR}/switch.sh
IDLE_PORT=$(find_switch_port)
echo "> 새롭게 실행한 애플리케이션 health 확인 " >> /home/ec2-user/deploy.log
echo "> 실행 포트 : $IDLE_PORT" >> /home/ec2-user/deploy.log
for CNT in {1..10}
do
echo "> health 확인용 반복문 시작... $CNT 회" >> /home/ec2-user/deploy.log
UP=$(curl -s http://127.0.0.1:${IDLE_PORT}/application/health | grep 'UP')
if [ -z "${UP}" ]
then
echo "> 아직 애플리케이션이 시작되지 않았습니다." >> /home/ec2-user/deploy.log
else
echo "> 애플리케이션이 정상적으로 실행되었습니다." >> /home/ec2-user/deploy.log
switch_nginx_proxy
break ;
fi
sleep 10
done
if [ $(CNT) -eq 10 ]
then
echo "> 애플리케이션 실행에 실패했습니다." >> /home/ec2-user/deploy.log
exit 1
fi
반복문을 사용해 최신 버전 애플리케이션이 정상적으로 실행되었는지 확인합니다.
이 때 actuator
을 사용합니다.
지정해놓은 uri
인 /application/health
로 요쳥했을 때 값이 있다면 정상적으로 애플리케이션이 실행된 것이므로, switch_nginx_proxy
를 통해 service-uri.inc
를 변경해줍니다.
반복문을 10번 반복할 때 까지 애플리케이션이 제대로 실행되지 않았다면, 배포에 실패한 것으로 간주합니다.
sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
jenkins/jenkins jdk11 ceea1a18418a 4 days ago 460MB
# service-url.inc
set $service_url ;
첫 번째 배포 전에는 실행 중인 도커 이미지는 jenkins
뿐입니다.
service-uri.inc
의 경우 확인하기위해 비워둔 상태입니다.
sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
real1 latest 35428282fa06 About a minute ago 679MB
jenkins/jenkins jdk11 ceea1a18418a 4 days ago 460MB
openjdk 11-jdk 72d6966f5c18 7 days ago 660MB
첫 번째 배포를 완료한 뒤 도커 이미지를 확인해보면, real1
이미지가 추가된 것을 확인할 수 있습니다.
80
포트로 접근하면 real1
을 반환합니다.
# service-url.inc
set $service_url http://127.0.0.1:8081;
service-url.inc
의 경우 real1
의 포트인 8081
로 변경되었습니다.
sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
real2 latest 82d9af46531b About a minute ago 679MB
real1 latest 35428282fa06 4 minutes ago 679MB
jenkins/jenkins jdk11 ceea1a18418a 4 days ago 460MB
openjdk 11-jdk 72d6966f5c18 7 days ago 660MB
real2
이미지가 추가되었습니다.
80
포트로 접근하면 real2
를 반환합니다.
# service-url.inc
set $service_url http://127.0.0.1:808;
service-url.inc
의 경우 real2
의 포트인 8082
로 변경되었습니다.
sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
real1 latest 2c1a5e8310cf About a minute ago 679MB
real2 latest 82d9af46531b 12 minutes ago 679MB
jenkins/jenkins jdk11 ceea1a18418a 4 days ago 460MB
openjdk 11-jdk 72d6966f5c18 7 days ago 660MB
real1 컨테이너 종료 -> real1 컨테이너 삭제 -> real1 이미지 삭제 -> 최신 버전의 애플리케이션으로 real1 이미지 재생성
의 과정을 거치기 때문에 댕글링 이미지가 발생하지 않고 정상적으로 real1
이미지가 생성되었습니다.
80
포트로 접근하면 real1
을 반환합니다.
# service-url.inc
set $service_url http://127.0.0.1:8081;
service-url.inc
의 경우 real1
의 포트인 8081
로 변경되었습니다.
이를 통해 첫 번째 배포 시에도 정상적으로 동작하고, 이후 동일한 profile
의 이미지를 생성하고 실행할 때에도 정상적으로 동작하는 것을 확인할 수 있었습니다.