도커-컴포즈를 활용한 Redis 의존 블루-그린 배포

konu·2024년 7월 17일
0

DevOps

목록 보기
2/4
post-thumbnail

(고래에 이은 문어..?)

 

 

 

1. 배경

1) Redis를 왜?

우리 프로젝트는 모바일 애플리케이션 환경에서 OAuth2를 통해 얻은 사용자 정보를 기반으로
JWT 토큰을 발행하여 사용자 인증을 실행하는 방식이다.

따라서 토큰을 저장할 공간이 필요했고,
사용자 id-리프레쉬 토큰의 키-값 형태로 데이터의 크기는 가벼웠다.

그리고 사용자의 요청 한 건마다 토큰을 검증해야 했으므로
DB 트랜잭션이 빠르게 일어나야만 했다.

그래서 일반적인 RDB가 아닌 인메모리 캐시를 택했고,
그 중에서도 접근성이 가장 용이한 Redis를 선택했다.

 

2) Docker Compose를 왜?

위에서 살펴 봤듯 Redis를 사용하기 때문에
애플리케이션 서버가 Redis에 의존하는 형태가 된다.

그리고 Redis는 인 메모리 캐시이기 때문에
애플리케이션 서버와 동일한 호스트 OS에 속하는 것이 효율적이다.

그리고 Redis와 애플리케이션 서버 모두 컨테이너로서
통신하기 위해서는 도커 네트워크를 써야 한다.

게다가 블루-그린 배포를 채택했기 때문에
8081번을 사용하는 블루 컨테이너와 8082번을 사용하는 그린 컨테이너를 함께 관리해야 한다.

 

3) 블루-그린 배포를 왜?

무중단 배포에 대한 필요성이 있었다.
짧은 시간 내에 애플리케이션을 완성해야 하기 때문에 중단 시간을 최소화하고 싶었다.

그리고 무중단 배포의 방식에는 블루-그린 외에도
롤링, 카나리 방식 등이 있지만 로드 밸런싱에 대한 설정 부담이 적다는 이유로 블루-그린을 택했다.

 

 

 

2. 과정

1) Github Actions

on:
  push:
    branches: [ "develop" ]

develop 브랜치에 푸시가 오는 기준으로 깃헙 액션을 호출한다.

 

name: docker image 빌드 및 푸시
run: |
	docker build --platform linux/amd64 -t recordy/recordy .
	docker push recordy/recordy

도커 이미지를 빌드해서 도커 허브에 올린다.

 

name: ec2 서버에 deploy.sh
uses: appleboy/scp-action@master
with:
	host: ${{ secrets.SERVER_IP }}
	username: ${{ secrets.SERVER_USER }}
	key: ${{ secrets.SERVER_KEY }}
	source: ./scripts/deploy.sh
	target: /home/ubuntu/
    
name: ec2 서버에 docker-compose.yml 전송
uses: appleboy/scp-action@master
with:
	host: ${{ secrets.SERVER_IP }}
	username: ${{ secrets.SERVER_USER }}
	key: ${{ secrets.SERVER_KEY }}
	source: ./docker-compose.yml
	target: /home/ubuntu/

깃헙에 올린 배포용 쉘 스크립트도커 컴포즈 파일
EC2에 복붙해준다.

(두 파일을 하나의 명령 안에서 한꺼번에 옮기고 싶었지만
알 수 없는 이유로 실패하여 위와 같이 분리했다...)

 

name: docker image 풀 및 deploy.sh 통해 블루-그린 배포 진행
uses: appleboy/ssh-action@master
with:
	host: ${{ secrets.SERVER_IP }}
	username: ${{ secrets.SERVER_USER }}
	key: ${{ secrets.SERVER_KEY }}
	script: |
			sudo docker login -u ${{ secrets.DOCKER_LOGIN_USERNAME }} -p ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }}
            sudo docker pull ${{ secrets.DOCKER_LOGIN_USERNAME }}/recordy
            sudo chmod 777 /home/ubuntu/scripts/deploy.sh
            /home/ubuntu/scripts/deploy.sh
            docker image prune -f

도커 이미지를 풀해서 deploy.sh를 실행함으로써
알맞은 컨테이너를 실행 및 종료하고, 사용하지 않는 이미지는 제거한다.

 

2) docker-compose.yml

version: '3.8'
services:
  redis:
    container_name: redis
    image: redis:alpine
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"
    restart: always
  blue:
    container_name: blue
    image: recordy/recordy
    expose:
      - 8080
    ports:
      - "8081:8080"
    environment:
      - TZ=Asia/Seoul
    depends_on:
      - redis
  green:
    container_name: green
    image: recordy/recordy
    expose:
      - 8080
    ports:
      - "8082:8080"
    environment:
      - TZ=Asia/Seoul
    depends_on:
      - redis

volumes:
  redis-data:

redis는 기본 포트를 사용하고,
도커 볼륨을 사용하여 컨테이너가 불가피하게 종료되는 상황에서도 데이터를 유지할 수 있도록 했다.

blue와 green 컨테이너는 포트만 다르고
사용하는 이미지는 동일하다.

 

3) deploy.sh

#!/bin/bash

IS_GREEN_EXIST=$(docker ps | grep green)
IS_REDIS_EXIST=$(docker ps | grep redis)

if [ -z "$IS_REDIS_EXIST" ];then
  echo "### REDIS ###"
  echo ">>> pull redis image"
  docker compose pull redis
  echo ">>> up redis container"
  docker compose up -d redis
fi

# green up
if [ -z "$IS_GREEN_EXIST" ];then
  echo "### BLUE -> GREEN ####"
  echo ">>> pull green image"
  docker pull recordy/recordy:latest
  echo ">>> remove old green container"
  docker compose rm -fs green
  echo ">>> up green container"
  docker compose up -d green
  while [ 1 = 1 ]; do
    echo ">>> green health check ..."
    sleep 3
    REQUEST=$(curl http://127.0.0.1:8082/actuator/health)
    if [ -n "$REQUEST" ]; then
      echo ">>> health check success !"
      break;
    fi
  done;
  sleep 3
  echo ">>> reload nginx"
  sudo cp /etc/nginx/conf.d/green-url.inc /etc/nginx/conf.d/service-url.inc
  sudo nginx -s reload
  echo ">>> down blue container"
  docker compose stop blue
  docker image prune -f

# blue up
else
  echo "### GREEN -> BLUE ###"
  echo ">>> pull blue image"
  docker pull recordy/recordy:latest
  echo ">>> remove old blue container"
  docker compose rm -fs blue
  echo ">>> up blue container"
  docker compose up -d blue
  while [ 1 = 1 ]; do
    echo ">>> blue health check ..."
    sleep 3
    REQUEST=$(curl http://127.0.0.1:8081/actuator/health)
    if [ -n "$REQUEST" ]; then
      echo ">>> health check success !"
      break;
    fi
  done;
  sleep 3
  echo ">>> reload nginx"
  sudo cp /etc/nginx/conf.d/blue-url.inc /etc/nginx/conf.d/service-url.inc
  sudo nginx -s reload
  echo ">>> down green container"
  docker compose stop green
  docker image prune -f
fi

내용이 길어 보여도 각주도 많고 echo 명령어로 로그 찍는 내용도 많아서 그렇지
실상 로직은 정말 간단하다.

먼저, redis 컨테이너가 떠 있지 않으면
도커 컴포즈를 통해 redis 이미지를 당겨서 실행시킨다.

그리고 if 문을 통해 green 컨테이너의 실행 여부를 확인하는 변수가 비어있는 경우에
green 컨테이너를 당겨서 실행하고 /actuator/health에 API 요청을 날려서 상태를 확인한다.

상태 확인에 성공했을 경우 nginx 설정 파일을 green 컨테이너에 맞게 복붙해서
green 컨테이너의 포트인 8082번으로 라우팅한다.

만약 green 컨테이너의 실행 여부를 확인하는 변수가 존재한다면
반대로 blue 컨테이너에 대해 위와 같은 로직을 적용한다.

 

!참고!
deploy.sh 파일은 리눅스 OS를 기반으로 작성되었다.

 

!트러블 슈팅!
기존에 참고했던 배포용 쉘 파일은 docker compose를 통해 이미지를 pull하고 있었다.
그런데 ec2에 latest 태그가 달린 이미지가 이미 존재할 경우 새롭게 이미지를 pull하지 않았다.

심지어 docker compose 파일 내 pull_policyalways로 두고 있음에도
도커 허브에서 이미지를 pull하지 않아서 결국 직접적으로 pull 명령어를 사용하게 되었다.

 

 

 

3. 이슈와 해결

1) 타이포

오타 이슈가 정말 많았다.
IDE에서 자바나 파이썬 같은 언어가 아닌, 쉘이나 yaml 문법도 다 체크해주진 못했으므로 종종 오타를 그대로 푸쉬했다.

하단에 두 개 예시를 들어주겠지만
이외에도 깃헙 액션 돌려보면서 발견한 문제점이 정말 많았고 이 때문에 시간을 많이 빼앗겼다.

example 1

IS_GREEN_EXIST=$(docker ps | grep green)

# green up
if [ -z "$IS_GREENp_EXIST" ];then
  echo "### BLUE -> GREEN ####"
  echo ">>> pull green image"

자세히 보면 상단의 $IS_GREEN_EXIST와 달리
하단에 $IS_GREENp_EXIST로 표기 되어 있다.

그래서 하단의 if문을 계속 통과하는 오류가 발생했고,
blue 이미지를 끌어와야 할 때도 green을 풀하는 문제를 발견하고 오타를 고칠 수 있었다...

example 2

services:
  redis:
    container_name: redis
    image: redis:alpine
    volume:
      - redis-data:/data
    ports:
      - "6379:6379"
    restart: always

혹시 문제점을 바로 찾았다면
당신은 천재!

services.redis.volume이 아니라
volumes이 정답이었다...

 

2) 도커 네트워크

도커 네트워크는 동일한 도커 데몬 안에서 돌아가는 컨테이너간의
통신을 돕기 위한 가상의 네트워크다.

다시 한 번, 우리 프로젝트는
서버 애플리케이션이 Redis에 데이터를 저장하며 의존하는 구조다.

 

services:
  redis:
    container_name: redis
  ...
  
  blue:
    container_name: blue
    ...
	depends_on:
      - redis

그래서 나는 도커 컴포즈에서
블루, 그린 컨테이너가 redis 컨테이너에 의존한다고 선언한 것으로 족한 줄 알았다...

 

그런데 위와 같은 에러를 마주쳤고
다행히 친절한 로그를 통해 Redis가 원인이라는 것은 빠르게 파악할 수 있었다.

그래서 처음에는 Redis에 문제가 있으니
서버 애플리케이션에서 참조를 하지 못하는구나 싶어서 docker logs redis로 로그를 찍어봤다.

하지만 정상이란다...
그래서 서버 애플리케이션이 문제겠구나 싶어서 비슷한 문제 상황을 겪었던 블로그 글을 찾아다니면서 원인을 찾아봤다.

❇️ 해결 ❇️

위에서 밝혔듯 도커 컨테이너는 도커 데몬 안에서
고유한 논리적 IP 주소를 가진다.

그 얘기인 즉슨 redis 컨테이너와 blue 컨테이너는 동일한 IP 주소를 가질 수 없고
blue 컨테이너는 redis 컨테이너의 IP 주소를 알고 있어야 한다.

 

spring:
    datasource:
	    driver-class-name: com.mysql.cj.jdbc.Driver
    	url: jdbc:mysql://localhost:3306/mysql?serverTimezone=UTC&useSSL=false&characterEncoding=UTF-8
	    username: root
    	password:

우리가 resources/application.yml에 로컬 DB의 주소와 이름, RDS 엔드포인트를 명시하듯
spring.data.redis.host에 Redis 서버의 호스트 주소를 명시해야 한다.

 

이 때, 도커 컴포즈는 자동으로 함께 관리되는 서비스를 동일한 네트워크에서 관리해준다.
그리고 동일 네트워크의 컨테이너끼리는 컨테이너 이름을 통해 DNS처럼 참조할 수 있게 된다.

그래서 application.yml에서 위와 같이 redis.data.host를 컨테이너 이름으로 명시해줬고,
그 결과 스프링 애플리케이션 컨테이너가 redis 컨테이너를 참조할 수 있게 되었다.

 

3) redis 데이터의 영구성 보장

우리는 redis를 도커 컨테이너로서 사용하는데
컨테이너는 삭제되는 순간 내장된 데이터가 모두 날아간다.

그래서 데이터를 저장할 공간이 필요했고,
그 중 현실적으로 도커 볼륨을 사용하는 것이 알맞을 것 같았다.

그래서 redis-data라는 이름으로 볼륨을 만들고

docker compose 파일에서 redis-data 볼륨을
redis 컨테이너의 /data 디렉터리로 마운트하도록 설정했다.

 

 

 

4. 결론

블루-그린 배포 방식을 추상적으로 접했을 때에는 어렵지 않았지만
막상 적용하려고 보니 정말 많은 이슈가 있었다.

위에 적지 않았지만 nginx에서 포트 설정 파일을 제대로 읽지 못하고
기본 설정 파일만 받아들이는 바람에 80 <-> 8081, 8082로 프록시가 제대로 이뤄지지 않기도 했다.

아무튼, 한 번 설정해놓고 나니 편하게 배포할 수 있었고
중단에 따른 문제도 겪지 않게 되었다.

profile
日日是好日

0개의 댓글