이 포스팅은 nginx를 불필요하게 프로젝트마다 설치한다.
개선된 deploy.sh 는 이곳에 있다.
이전 포스팅 에서 말한 제로타임배포를 구현하기 위해 가장 유명한 Blue-Green전략을 사용하기로 했다.
개념은 나보다 잘 설명할 사람이 많으니 직접 찾아보시길..
이번 프로젝트?는 가능한 많은 사람이 간편하게 사용하는 것을 목표로 만들었다.
Repository가 바뀔때 마다 설정해야하는 값들이나 변경가능성이 있는 변수들 모두 쪼개서 환경변수로 관리했다.
config.yml을 작성하는 것 보다 각종 파일들을 동적으로 만들거나 target port 관리 nignx설정 등에 시간을 많이 들였다.
현재 방식은 nginx를 불필요하게 프로젝트마다 설치한다.
개선된 deploy.sh 는 이곳에 있다.
파일 구성도
내가 만들 config.yml을 이용하려면 필요한 파일 구성도이다.
.circleci
- config.yml
ci-cd
- docker
- spring.Dockerfile
- sh
- deploy.sh
- nginx_start_and_change_port.sh
- create_main_docker_compose.sh
- create_nginx_docker_compose.sh
- create_default_conf.sh
config.yml
ssh접속시 '-o ConnectTimeout=30' 옵션을 넣어주지 않으면 무한히 접속을 시도하기에 추가했습니다.
참고로 description은 아무런 동작도 하지 않습니다.
주석보다 이쁜데 에러는 안나길래 그냥 내버려 뒀습니다.
version: 2.1
workflows:
build:
jobs:
- main:
context: # 공통적으로 사용되는 환경변수를 가져옴
- server
filters:
branches: # Push시 트리거 작동을 막기 위해선 Project Settings > Advanced 에서 Only build pull requests 활성화
only:
- main
jobs:
main:
machine: true
resource_class:<리소스 클래스명>
steps:
- run:
name: 환경변수 설정
description: |
각 레포마다 해당 부분만 세팅하면 됩니다.
Blue-Green 배포 방식과 nignx를 사용해서 제로타임배포를 진행합니다.
NGINX_PORT부분이 도메인과 연결되어 외부와 소통할 port입니다.
command: |
# 어플리케이션 정보
echo 'export JAVA_VERSION=17' >> $BASH_ENV
echo 'export BLUE_PORT=000' >> $BASH_ENV
echo 'export GREEN_PORT=000' >> $BASH_ENV
echo 'export NGINX_PORT=000' >> $BASH_ENV
# 포트 변경 후 몇 초뒤에 서버를 내릴지 결정
echo 'export DOWN_DELTA_TIME=120' >> $BASH_ENV
# 서버가 올라간 뒤 상태코드 200을 받을 경로
echo 'export HEALTH_CHECK_PATH=login' >> $BASH_ENV
# 도커 이미지명 (대문자가 허용되지 않습니다.)
echo 'export DOCKER_IMAGE="'${CIRCLE_PROJECT_REPONAME}'"' >> $BASH_ENV
# 서버 내에서 사용할 폴더명 및 경로
echo 'export DIR_ROOT=/home/circleci' >> $BASH_ENV # 마지막에 '/' 제외 필수!!!
echo 'export FOLDER_NAME="'${CIRCLE_PROJECT_REPONAME}'"' >> $BASH_ENV
echo 'export DIR_PROJECT=${DIR_ROOT}/${FOLDER_NAME}' >> $BASH_ENV
# 어플리케이션을 실행할 서버 정보
echo 'export PROD_SERVER_HOST="'${REMOTE_HOST_223}'"' >> $BASH_ENV
echo 'export PROD_SERVER_USERNAME="'${REMOTE_USERNAME_223}'"' >> $BASH_ENV
echo 'export PROD_SERVER_ADDR=${PROD_SERVER_USERNAME}@${PROD_SERVER_HOST}' >> $BASH_ENV
echo 'export PROD_SERVER_PASSWORD="'${REMOTE_PASSWORD_223}'"' >> $BASH_ENV # 해당 변수가 할당되지 않았다면 key_pair를 사용합니다.(공백으로 세팅해주세요.)
source $BASH_ENV
- run:
name: 환경변수 확인
command: |
echo "JAVA_VERSION: ${JAVA_VERSION}"
echo "BLUE_PORT: ${BLUE_PORT}"
echo "GREEN_PORT: ${GREEN_PORT}"
echo "NGINX_PORT: ${NGINX_PORT}"
echo "DOWN_DELTA_TIME: ${DOWN_DELTA_TIME}"
echo "HEALTH_CHECK_PATH: ${HEALTH_CHECK_PATH}"
echo "DOCKER_IMAGE: ${DOCKER_IMAGE}"
echo "FOLDER_NAME: ${FOLDER_NAME}"
echo "DIR_ROOT: ${DIR_ROOT}"
echo "DIR_PROJECT: ${DIR_PROJECT}"
echo "PROD_SERVER_HOST: ${PROD_SERVER_HOST}"
echo "PROD_SERVER_USERNAME: ${PROD_SERVER_USERNAME}"
echo "PROD_SERVER_ADDR: ${PROD_SERVER_ADDR}"
echo "PROD_SERVER_PASSWORD: ${PROD_SERVER_PASSWORD}"
- add_ssh_keys:
fingerprints:
- "xxxxxxxx"
- run:
name: Install Dependencies
description: |
프로젝트에 필요한 의존성 설치
JAVA의 경우에는 버전별로 설치되어야 하므로, 설치되어있는지 확인 후 설치
command: |
if [ ! -z "$PROD_SERVER_PASSWORD" ]; then
sudo apt-get install -y sshpass
fi
# Check the output of "java -version"
JAVA_CMD=$(java -version 2>&1 | head -n 1)
JAVA_VERSION_INSTALLED=$(echo $JAVA_CMD | sed -n ';s/.* version "\(.*\)\.\(.*\)\..*".*/\1\2/p;')
# Check if the desired version is installed
if [ "$JAVA_VERSION_INSTALLED" != "$JAVA_VERSION" ]; then
sudo apt-get install -y openjdk-"${JAVA_VERSION}"-jdk
JAVA_PATH=$(update-alternatives --list java | grep "java-${JAVA_VERSION}" | head -n 1)
JAVA_HOME=$(dirname $(dirname $JAVA_PATH))
echo "JAVA_HOME=\"$JAVA_HOME\"" | sudo tee -a /etc/environment
source /etc/environment
fi
- run:
name: known_hosts에 접속하려는 서버정보 추가
description: |
ssh 첫 접속시 yes/no 물어보는 것을 방지하기 위해 known_hosts에 추가
command: |
RSA_KEY=$(ssh-keyscan -t rsa -H ${PROD_SERVER_HOST})
if ! grep -q "$(echo ${RSA_KEY} | awk '{print $NF}')" ~/.ssh/known_hosts; then
echo ${RSA_KEY} >> ~/.ssh/known_hosts
fi
- run:
name: 배포 서버에 프로젝트 폴더 생성
description: |
서버에 프로젝트를 배포할 디렉토리 생성
command: |
if [ -z "$PROD_SERVER_PASSWORD" ]; then
SUDO_COMMAND="sudo"
else
SUDO_COMMAND="echo '${PROD_SERVER_PASSWORD}' | sudo -S "
fi
SSH_COMMAND="\
(if [ ! -d '${DIR_PROJECT}' ]; then
${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}
${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}
fi &&
if [ ! -d '${DIR_PROJECT}/nginx' ]; then
${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/nginx
${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/nginx
fi &&
if [ ! -d '${DIR_PROJECT}/nginx/conf.d' ]; then
${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/nginx/conf.d
${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/nginx/conf.d
fi &&
if [ ! -d '${DIR_PROJECT}/sh' ]; then
${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/sh
${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/sh
fi &&
if [ ! -d '${DIR_PROJECT}/docker' ]; then
${SUDO_COMMAND} mkdir -p ${DIR_PROJECT}/docker
${SUDO_COMMAND} chmod 777 ${DIR_PROJECT}/docker
fi) || exit 1"
if [ -z "$PROD_SERVER_PASSWORD" ]; then
ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
else
sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
fi
- run:
name: 배포할 Color 확인
command: |
SSH_COMMAND="if docker ps --format '{{.Names}}' | \
grep -q "${DOCKER_IMAGE}-blue"; then \
echo 'green'; \
else \
echo 'blue'; \
fi"
if [ -z "$PROD_SERVER_PASSWORD" ]; then
echo 'export TARGET_COLOR='$(ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}") >> $BASH_ENV
else
echo 'export TARGET_COLOR='$(sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}") >> $BASH_ENV
fi
source $BASH_ENV
echo ${TARGET_COLOR}
- checkout # git에서 코드를 가져옴
- run:
name: 프로젝트 빌드
command: |
chmod +x ./gradlew
./gradlew build
# command: |
# chmod +x ./mvnw
# ./mvnw clean install
- run:
name: Docker 빌드 후 푸쉬
description: |
spring.Dockerfile을 이용하여 이미지를 빌드하고, Docker Hub에 푸시
로그인은 docker-compose에 작성 후 머신 재시작시 자동으로 로그인
Docker Hub ID : rmodev
command: |
echo "-----------"
sudo docker build -t $DOCKERHUB_USERNAME/${DOCKER_IMAGE}:${TARGET_COLOR} -f ./ci-cd/docker/spring.Dockerfile .
echo "-----------"
sudo docker push $DOCKERHUB_USERNAME/${DOCKER_IMAGE}:${TARGET_COLOR}
echo "-----------"
echo ${TARGET_COLOR}
echo "-----------"
- run:
name: 배포 서버로 파일 전송
description: |
프로젝트 파일들을 서버로 전송
command: |
if [ -z ${PROD_SERVER_PASSWORD} ]; then
scp -o ConnectTimeout=30 -r ./ci-cd/* ${PROD_SERVER_ADDR}:${DIR_PROJECT}
else
sshpass -p ${PROD_SERVER_PASSWORD} scp -o ConnectTimeout=30 -r ./ci-cd/* ${PROD_SERVER_ADDR}:${DIR_PROJECT}
fi
- run:
name: CD 시작
description: |
서버에서 deploy.sh 실행
deploy.sh 에서 다른 모든 sh파일을 실행시킵니다.
command: |
SSH_COMMAND="\
cd ${DIR_PROJECT}
echo ${DOCKER_IMAGE}
echo ${TARGET_COLOR}
bash ./sh/deploy.sh ${DOCKER_IMAGE} ${TARGET_COLOR} ${BLUE_PORT} ${GREEN_PORT} ${NGINX_PORT} ${DIR_PROJECT} ${DOWN_DELTA_TIME} ${HEALTH_CHECK_PATH} ${PROD_SERVER_HOST}|| exit 1"
if [ -z "$PROD_SERVER_PASSWORD" ]; then
ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
else
sshpass -p $PROD_SERVER_PASSWORD ssh -o ConnectTimeout=30 ${PROD_SERVER_ADDR} "${SSH_COMMAND}"
fi
deploy.sh
target_color를 입력받아서 해당 color를 올린 뒤 기존 live color를 내린뒤 최종적으로 실행되고 있는 port를 nginx.sh파일에 입력하는 sh파일이다.
처리 중인 작업이 있을 수 있으니 서버를 바로 down시키지 않고 변수로 받은 값만큼 딜레이를 준 뒤 서버를 내리게 만들었다.
딜레이가 클 수록 중복 실행될 가능성도 고려해서 pid를 저장시켜 종료시키게끔 구성했다.
모든 과정을 자동화 하기 위해 docker-compose파일 또한 이 곳에서 직접 만든다.
매번 파일을 재생성한다는 단점이 있기는 하지만 큰 자원이 소모된다고 생각되지 않는다.
#!/bin/bash
REPO_NAME=$1
TARGET_COLOR=$2
BLUE_PORT=$3
GREEN_PORT=$4
NGINX_PORT=$5
DIR_PROJECT=$6
DOWN_DELTA_TIME=$7
HEALTH_CHECK_PATH=$8
PROD_SERVER_HOST=$9
echo "===================="
# 실행중인 백그라운드 종료
if [ -f "$DIR_PROJECT/sh/down_live_after_delay.pid" ]; then
OLD_PID=$(cat "$DIR_PROJECT/sh/down_live_after_delay.pid")
if ps -p "$OLD_PID" > /dev/null; then
echo "Terminating existing background process..."
kill -9 "$OLD_PID"
fi
fi
echo "===================="
if [ -z "$REPO_NAME" ] || [ -z "$TARGET_COLOR" ] || [ -z "$BLUE_PORT" ] || [ -z "$GREEN_PORT" ] || [ -z "$NGINX_PORT" ] || [ -z "$DIR_PROJECT" ] || [ -z "$DOWN_DELTA_TIME" ]; then
echo "One or more variables are empty."
exit 1
fi
echo "REPO_NAME: $REPO_NAME"
echo "TARGET_COLOR: $TARGET_COLOR"
echo "BLUE_PORT: $BLUE_PORT"
echo "GREEN_PORT: $GREEN_PORT"
echo "NGINX_PORT: $NGINX_PORT"
echo "DIR_PROJECT: $DIR_PROJECT"
echo "DOWN_DELTA_TIME: $DOWN_DELTA_TIME"
echo "===================="
if [ "$TARGET_COLOR" == "blue" ]
then
LIVE_PORT=$GREEN_PORT
LIVE_COLOR="green"
TARGET_PORT=$BLUE_PORT
TARGET_COLOR="blue"
else
LIVE_PORT=$BLUE_PORT
LIVE_COLOR="blue"
TARGET_PORT=$GREEN_PORT
TARGET_COLOR="green"
fi
cd "$DIR_PROJECT" || exit 1
echo "===================="
echo "create-docker-compose.sh"
sh "$DIR_PROJECT/sh/create_main_docker_compose.sh" "$REPO_NAME" "$BLUE_PORT" "$GREEN_PORT" "$DIR_PROJECT"
echo "===================="
echo "now state"
Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT/$HEALTH_CHECK_PATH")
Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")
echo "API Target: $Target"
echo "API Live: $Live"
# Deploy the new version to the new environment
echo "===================="
echo "docker compose up"
docker-compose pull $TARGET_COLOR
docker-compose -f "$DIR_PROJECT/docker-compose.yml" up -d $TARGET_COLOR
Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT"/$HEALTH_CHECK_PATH)
Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")
echo "API Target: $Target"
echo "API Live: $Live"
echo "===================="
run_tests() {
# shellcheck disable=SC2034
for i in {1..10}; do
Target=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$TARGET_PORT/$HEALTH_CHECK_PATH")
Live=$(curl --write-out '%{http_code}' --silent --output /dev/null "$PROD_SERVER_HOST:$LIVE_PORT/$HEALTH_CHECK_PATH")
echo "API Target: $Target"
echo "API Live: $Live"
if [ "$Target" -eq 200 ]; then
return 0
fi
echo "API is not ready yet cnt: [$i/10]]"
# Wait for 6 seconds before the next try
sleep 6
done
echo "API has not become ready within 60 seconds"
# If the API did not respond with 200 OK within 60 seconds, return a failure code
return 1
}
if run_tests; then
# If Lives passed, switch the traffic to the new environment
echo "Success!! Change Target: $TARGET_COLOR"
NGINX_TARGET_PORT=$TARGET_PORT
IS_SUCCESS=0
else
# If Lives failed, stop the new environment
echo "Fail.. Change LIVE: $LIVE_COLOR"
NGINX_TARGET_PORT=$LIVE_PORT
IS_SUCCESS=1
fi
echo "===================="
sh "$DIR_PROJECT/sh/nginx_start_and_change_port.sh" "$NGINX_TARGET_PORT" "$REPO_NAME-nginx" "$DIR_PROJECT" "$NGINX_PORT"
echo "===================="
down_live_after_delay() {
sleep "$DOWN_DELTA_TIME"
echo "Down Live after delay: $LIVE_COLOR"
docker stop "$REPO_NAME-$LIVE_COLOR"
}
if [ $IS_SUCCESS ]; then
if docker ps --format '{{.Names}}' | grep "$REPO_NAME-$LIVE_COLOR"; then
nohup down_live_after_delay &
# 백그라운드 작업의 PID를 파일에 저장
echo $! > "$DIR_PROJECT/sh/down_live_after_delay.pid"
echo "Down Live after delay: $LIVE_COLOR, PID: $!, $DOWN_DELTA_TIME"
fi
else
if docker ps --format '{{.Names}}' | grep "$REPO_NAME-$TARGET_COLOR"; then
echo "Down Target: $TARGET_COLOR"
docker stop "$REPO_NAME-$TARGET_COLOR"
fi
fi
echo "===================="
create_main_docker_compose.sh
실행될 서비스의 docker-compose 파일이다.
docker-compose 파일을 분리하거나 image 태그를 매번 다르게 설정하는등 많은 시도 끝에 성공한 방법이다.
신기하게도 다른 방법 사용시 docker-compose up시 live와 target 컨테이너가 모두 down 된 후에 target만 올라온다.
그 외에 현재는 network를 따로 분리 해두었는데 nignx와 모두 같은 network를 사용하게 해서 어플리케이션의 port를 컨테이너 외부로 빼지 않아도 되는 방법이 있을것 같다.
#!/bin/bash
REPO_NAME=$1
BLUE_PORT=$2
GREEN_PORT=$3
DIR_PROJECT=$4
cat << EOF > "$DIR_PROJECT/docker-compose.yml"
version: '3.3'
services:
blue:
image: rmodev/${REPO_NAME}:blue
container_name: ${REPO_NAME}-blue
networks:
- ${REPO_NAME}-blue-network
ports:
- "${BLUE_PORT}:${BLUE_PORT}"
volumes:
- ${DIR_PROJECT}/logs:/logs
environment:
- TZ=Asia/Seoul
green:
image: rmodev/${REPO_NAME}:green
container_name: ${REPO_NAME}-green
networks:
- ${REPO_NAME}-green-network
ports:
- "${GREEN_PORT}:${BLUE_PORT}"
volumes:
- ${DIR_PROJECT}/logs:/logs
environment:
- TZ=Asia/Seoul
networks:
${REPO_NAME}-blue-network:
driver: bridge
${REPO_NAME}-green-network:
driver: bridge
EOF
nginx_start_and_change_port.sh
단순히 volume으로 conf 파일을 연결해서 port를 target으로 바꾸는 로직이다.
최초 실행시 생길 많은 변수를 고려해서 작성했다.
#!/bin/bash
# Port to assign
TARGET_PORT=$1
NGINX_CONTAINER=$2
DIR_PROJECT=$3
NGINX_PORT=$4
# Path to Nginx configuration file
NGINX_CONF=$DIR_PROJECT/nginx/conf.d/default.conf
cat "$NGINX_CONF"
cd "$DIR_PROJECT" || exit 1
if [ ! -f "$NGINX_CONF" ]; then
echo "create_default_conf.sh"
sh "$DIR_PROJECT/sh/create_default_conf.sh" "$TARGET_PORT" "$DIR_PROJECT"
fi
echo "create_nginx_docker_compose.sh"
sh "$DIR_PROJECT/sh/create_nginx_docker_compose.sh" "$NGINX_CONTAINER" "$DIR_PROJECT" "$NGINX_PORT"
# Use sed to replace the port
sed -i "s/proxy_pass http:\/\/host.docker.internal:[0-9]*;/proxy_pass http:\/\/host.docker.internal:$TARGET_PORT;/" "$NGINX_CONF"
cat "$NGINX_CONF"
# Check if Nginx container is running
if docker ps --filter "status=running" --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
echo "Nginx container is already running"
elif docker ps --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
echo "Nginx container already exists, but is not running"
docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" down
docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" up -d
else
docker-compose -f "$DIR_PROJECT/nginx/docker-compose.nginx.yml" up -d
fi
# Check if the container is running
for i in {1..10}
do
if docker ps --filter "status=running" --filter "name=$NGINX_CONTAINER" | grep -qw "$NGINX_CONTAINER"; then
echo "Nginx container is ready"
break
fi
if [ "$i" -eq 10 ]; then
echo "Failed to start Nginx container"
exit 1
fi
echo "Waiting for Nginx container to be ready ($i/10)"
sleep 1
done
# Reload Nginx to apply the changes
docker exec -i "$NGINX_CONTAINER" nginx -s reload
create_default_conf.sh
이곳에 사용한 host.docker.internal 은 도커 컨테이너 내부에서 외부의 port에 접근하는 방식이다.
다만 이 방식은 window와 mac에서만 동작하므로 docker-compose 파일에서 추가 작업이 필요하다.
#!/bin/bash
TARGET_PORT=$1
DIR_PROJECT=$2
cat << EOF > "$DIR_PROJECT/nginx/conf.d/default.conf"
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
proxy_pass http://host.docker.internal:$TARGET_PORT;
}
}
EOF
create_nginx_docker_compose.sh
extra_host에 아래 파일처럼 작성하면 window나 mac과 동일하게 동작한다.
#!/bin/bash
NGINX_CONTAINER=$1
DIR_PROJECT=$2
NGINX_PORT=$3
cat << EOF > "$DIR_PROJECT/nginx/docker-compose.nginx.yml"
version: '3.8'
services:
nginx:
image: nginx:1.25.1
container_name: $NGINX_CONTAINER
restart: unless-stopped
ports:
- "$NGINX_PORT:80"
volumes:
- $DIR_PROJECT/nginx/conf.d:/etc/nginx/conf.d
extra_hosts:
- "host.docker.internal:host-gateway"
EOF
'CI CD' 카테고리의 다른 글
Gitlab CI/CD 실전예제 (1) | 2023.09.27 |
---|---|
Gitlab Runner (Docker, shell) 실전예제 (0) | 2023.09.25 |
Circle CI의 Self-Hosted Runner (Docker) 실전 예제 (0) | 2023.08.06 |
Spring + Github Actions + AWS CodeDeploy + AWS S3 + AWS EC2 (1) | 2022.06.22 |