본문 바로가기

DevOps

DevOps)Docker-compose와 GitActions를 이용한 CI-CD(수정)

 

(도커 허브의 계정과 레포지토리, docker의 설치 등은 생략하겠습니다.)

왜 Docker와 Github Actions를 선택했는가.

우리는 배포를 진행할 때 어떤 서비스를 이용할지 고민합니다.

대부분의 개발자 또는 팀들이 그러하듯 저희 팀 또한 Iaas로 Ec2를 선택했습니다.

최근에는 네이버 플랫폼이 출시하기도 했지만 가격적으로보나, 자료를 보나 저희로선 Ec2를 선택하지 않을 이유가 없습니다.

 

저희 팀은 단순한 인스턴스에 도커를 띄우기로 결정했습니다.

도커를 사용하면 많은 장점이 있겠지만 모든 서비스가 완성되지 않은 저희 팀은 아래의 장점을 가장 크게 보았습니다.

 

  • 캐싱으로인한 빌드타임 감소
  • 잘못된 재배포 시 빠른 교체

도커에서 컨테이너는 이미지 기반으로 생성됩니다. 이미지는 여러 개의 레이어로 구성되어 있으며 레이어는 Dockerfile의 명령어가 실행된 결과입니다.

이 과정에서 도커는 레이어를 생성하고 동일한 명령어가 있을 경우 캐싱된 레이어를 사용하게됩니다.

 

이런 캐싱을 사용하는 플랫폼은 도커 외에 다른 것들이 있지만 가장 널리 알려져있는 도커를 위 장점과 함께 선택하게 되었습니다.

 

또한 CI/CD 플랫폼으로는 GitHub Actions를 선택하게되었습니다. 널리 알려진 Jenkins말고 이를 선택하게 된 이유는 비교적 간단한데, 저희 프로젝트는 Jenkins를 사용할만큼 복잡하지 않은 프로젝트라 생각했습니다.

 

 

도커 기본 명령어

예시 이미지

 

(출처)

 

도커에서는 중요한 두 개의 개념이 있습니다.

  • 이미지(image): 컨테이너를 생성하기 위한 실행 가능한 패키지를 의미합니다. 애플리케이션과 그 애플리케이션을 실행하는 데 필요한 종속성, 라이브러리, 환경 변수 등을 포함하고 있습니다.
  • 컨테이너(container): 이미지를 실행하는 런타임 환경을 의미합니다. 컨테이너는 이미지를 기반으로 실행되며, 애플리케이션과 그 애플리케이션이 필요로하는 모든 종속성과 설정을 포함하고 있습니다.

더 쉽게 이야기하자면 이미지는 빌드 파일로 비유할 수 있고, 컨테이너는 인스턴스로 비유할 수 있습니다.

저희는 인스턴스 내부에 도커 컨테이너를 띄울 것이고, 각 컨테이너에는 각각의 서비스가 들어갈 예정입니다.

가끔 하나의 컨테이너에 여러 개의 서비스를 담는 분들이 계시는데, 이는 도커의 원칙 중 하나인 Single Responsibility 에 위배됩니다.

어쩔 수 없는 경우를 제외하고는 하나의 컨테이너에 하나의 서비스만 담도록 하는 것이 이상적입니다.

 

이미지를 생성하는 명령어는 다음과 같습니다.

docker build [옵션] -t <이미지이름>:<태그> <컨텍스트 경로>

 

여기서 어떤 것을 어떻게 빌드할지를 결정하는 것이 바로 Dockerfile입니다.

 

# Dockerfile

# jdk17 Image Start
FROM openjdk:17

# 인자 설정 - JAR_File
ARG JAR_FILE=./build/libs/app.jar

# jar 파일 복제
COPY ${JAR_FILE} app.jar

# 인자 설정 부분과 jar 파일 복제 부분 합쳐서 진행해도 무방
#COPY build/libs/*.jar app.jar

# 실행 명령어
ENTRYPOINT ["java", "-jar", "app.jar"]

 

저희는 스프링을 사용하고 있기에 빌드 파일인 app.jar파일을 이미지로 만들 것입니다.

간단한 스프링 프로젝트(Gradle)를 생성한 후, 이를 빌드한 후에 다음 명령어를 입력해봅시다.

 

docker build -t testing-docker:v01 .
docker images

 

"docker images" 명령은 현재 로컬에 존재하는 이미지를 확인하는 명령어입니다. 해당 명령어로 testing-docker라는 이름의 이미지가 잘 생성되었는지 확인해봅시다.

"testing-docker"뒤에 나온 :v01은 태그이름으로 보통은 릴리즈 버전을 기록하기 위한 용도로 쓰입니다.

릴리즈 버전을 기록하는 이유는 현재 릴리즈 버전에 버그가 발생한 경우 바로 이전 버전으로 교체하기 위함입니다.

제가 앞서 말한 "잘못된 재배포 시 빠른 교체"가 이 뜻입니다.

 

이제 testing-docker이미지를 컨테이너화 해볼 것입니다.

docker run [옵션] <이미지이름>:<태그>

옵션으로는 다음이 있습니다.

  • -d: 컨테이너를 백그라운드에서 실행합니다.
  • -p: 호스트와 컨테이너 간의 포트를 매핑합니다.
  • --name: 컨테이너의 이름을 지정합니다
  • -v: 호스트와 컨테이너간의 볼륨을 매핑합니다. 예를들어 "-v /host/path:/container/path"는 호스트의 경로를 컨테이너의 경로에 매핑합니다.
  • -e: 환경변수를 설정합니다.
  • --rm: 컨테이너가 종료되면 자동으로 삭제합니다.
  • -it: 터미널을 연결하여 상호작용 모드로 실행합니다.

또한 컨테이너를 확인하는 명령어는 다음과 같습니다.

#실행중인 컨테이너
docker ps

#모든 컨테이너
docker ps -a

 

해당 명령으로 컨테이너를 확인한 후 localhost로 접속해보면 스프링앱이 잘 나오는 것을 확인할 수 있습니다.

 

 

docker & docker-compose

우선 둘 모두 컨테이너를 정의하고 관리/실행 시킬 수 있는 컨테이너 관리 도구입니다.

하지만 그 목적이 다릅니다.

 

Docker

  • 목적: 개별 컨테이너의 생성, 실행 등 관리
  • 대상: 하나의 컨테이너
  • 사용 파일: Dockerfile
  • 복잡한 애플리케이션: 각 컨테이너를 개별적으로 관리해야 함

 

Docker-compose

  • 목적: 여러개의 컨테이너를 정의 및 관리
  • 대상: 여러 개의 컨테이너
  • 사용 파일: docker-compose.yml 또는 .yaml
  • 복잡한 애플리케이션: 여러 컨테이너를 하나의 설정 파일에서 정의/실행/관리

즉, 하나의 컨테이너를 관리해야하는 상황이면 Docker를, 여러 개의 컨테이너를 관리해야 할 상황이라면 docker-compose를 사용해야합니다.

물론 하나의 컨테이너만 관리하지만 docker-compose를 사용할 수도 있습니다. 다만 이는 상황과 목적에 맞지 않는 오버엔지니어링이라 생각합니다.

 

저희 팀은 nginx, mariadb, spring을 구동시키고 이를 "단일책임" 원칙에 따라 각각의 컨테이너에 담아 관리해야하므로 docker-compose를 사용했습니다.

 

 

docker-compose 작성

root/
│
├── build/                # 스프링 앱의 빌드 폴더
│
├── nginx.conf            # Nginx 서버 설정 파일
│
├── Dockerfile            # 도커 이미지를 빌드하기 위한 Dockerfile
│
├── docker-compose.yml     # 여러 컨테이너를 관리하기 위한 Docker Compose 파일
│
├── build.gradle          # 프로젝트의 빌드 설정 파일 (Gradle 설정)
│
├── init.sql              # 데이터베이스 초기화 SQL 스크립트
│
├── .github
    ├── workflows
      ├── deploy.yaml   # 배포 명령 스크립트

│
├── ...                   # 기타 프로젝트 관련 파일들

 

우선 프로젝트 구조는 위와 같습니다.

 

Dockerfile은 위에 올린 예시와 같습니다.

# Dockerfile

# jdk17 Image Start
FROM openjdk:17

# 인자 설정 - JAR_File
ARG JAR_FILE=./build/libs/app.jar

# jar 파일 복제
COPY ${JAR_FILE} app.jar

# 인자 설정 부분과 jar 파일 복제 부분 합쳐서 진행해도 무방
#COPY build/libs/*.jar app.jar

# 실행 명령어
ENTRYPOINT ["java", "-jar", "app.jar"]

 

위 파일은 본인의 jdk버전, 빌드파일의 경로 및 빌드파일 명에 맞게 수정하면 됩니다.

 

docker-compose파일입니다.

#docker-compose.yml

version: "3.8"

services:
  db:
    image: mariadb:latest
    container_name: mariadb
    environment:
      MYSQL_ROOT_PASSWORD: 1234
      MYSQL_DATABASE: DBName
      MYSQL_USER: ID
      MYSQL_PASSWORD: PW
    ports:
      - "3308:3306" # 호스트의 3308 포트를 컨테이너의 3306 포트에 매핑
    volumes:
      - db-data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  app1:
    image: iamges:tags
    container_name: idesign
    environment:
      SPRING_DATASOURCE_URL: jdbc:mariadb://db:3306/project
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: 1234
    ports:
      - "8081:8080" 
    depends_on:
      - db

  nginx:
    image: nginx:latest
    container_name: nginx
    ports:
      - "80:80" # Nginx가 사용할 포트
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app1

volumes:
  db-data:

 

우선 저희는 앞서 말씀드린대로 nginx, spring-app, mariadb라는 세개의 컨테이너를 실행시킬 것입니다.

그를 위해서 세개의 컨테이너를 선언했습니다

 

이를 한줄한줄 설명드리면 다음과 같습니다.

  • image: 컨테이너가 사용할 이미지로 image_name이라는 이미지를 사용합니다. 해당 이미지가 로컬에 존재하지 않는다면 허브로부터 pull을 받게됩니다. 제가 스프링 앱만을 Dockerfile을 이용해 이미지를 생성하는데 nginx,mariadb는 이미지를 생성하지 않아 허브로부터 pull을 받게됩니다.
  • container_name: 컨테이너의 이름을 설정합니다
  • enviroment: 컨테이너 내의 환경변수를 정의합니다. 위 예시에서는 mariadb의 환경변수를 정의합니다.
  • ports: 호스트와 컨테이너 간의 포트를 매핑합니다. 앞에 나온 포트는 컨테이너의 input포트로 해당 포트로 연결시 해당 컨테이너로 연결됩니다. 뒤에 나온 포트는 컨테이너 내부의 포트입니다. 해당 컨테이너에서 실행할 mariadb이미지는 3306으로 입력을 받습니다.
  • volumes: 컨테이너 내부의 데이터를 호스트에 저장하여 데이터 저장을 설정합니다. 예시에서는 db-data라는 볼륨을 컨테이너 내부의 MariaDB데이터베이스 경로 /var/lib/mysql에 매핑합니다. 이로써 컨테이너가 재시작 되더라도 데이터는 유지됩니다.
  • depends_on: 예시로, app1에 "depends_on: -db"가 설정되었습니다. 이 설정은 app1서비스가 실행되기 전에 db서비스가 먼저  수행되어야 함을 의미합니다.

 

docker-compose up --build

이제 위 명령어를 입력하면 docker-compose.yml에 명시된 명령어들을 따라 이미지를 생성하게 됩니다.

--build 옵션은 이미지를 다시 빌드하는 옵션입니다. 만약 해당 이미지가 새 이미지와 비교해 변경사항이 없다면 기존의 캐싱된 이미지를 사용합니다.

 

이를 Ec2 인스턴스에서 수행한다면 그것이 배포입니다. 하지만 여전히 불편함을 느낍니다.

코드에 변경사항이 발생했다면 Github에 올린 다음 인스턴스에서는 다시 코드를 pull받아 docker-compose up --build명령을 수행해야합니다.

 

또한 그 이전에 ./gradlew build 명령으로 스프링 앱을 빌드해야하죠.

이를 단순화 하는 것이 CI/CD과정입니다.

 

CI/CD

많은 블로그/기사에서 CI/CD에 관한 정의를 다루었으니 이는 넘어가겠습니다.

저희는 CI/CD도구로 Github Actions를 선택했습니다.

 

Github actions는 docker-compose와 docker-compose.yml파일과 같이 특정 파일에 작성된 명령을 수행합니다. 

이 파일은 깃허브 페이지에서 작성할 수있고 로컬 IDE를 이용해 작성할 수도 있습니다.

 

 

깃헙 페이지는 많은 편리성을 제공합니다. 사진에서처럼 바로바로 해당되는 앱을 선택해 명령을 작성할 수있죠.

저는 로컬에서 작성해보겠습니다.

 

#/.github/workflows/deploy.yaml

name: CI/CD Pipeline

on:
  push:
    branches:
      - your_branch # main 또는 master 또는 원하는 브랜치

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest 

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Write environment files
        run: |
          echo "${{ secrets.APPLICATIONPRIVATE }}" > src/main/resources/application-private.properties
        shell: bash

      - name: create-json
        id: create-json
        uses: jsdaniell/create-json@v1.2.2 #  json파일 변환 외부 api.
        with:
          name: "persuasive-feat-426613-s5-a789fe6d3bc5.json"
          json: ${{ secrets.PERSUASIVE }}
          dir: "src/main/resources/"

      - name: Build with Gradle
        run: ./gradlew clean build

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push Docker images
        run: |
          docker build -t iamges:tags .
          docker push iamges:tags

      - name: SSH Commands
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          port: 22
          script_stop: true
          script: |
            echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
            cd project
            sudo docker-compose down
            sudo docker-compose down || true
            CONTAINERS=$(sudo docker ps -a -q)
            
            #컨테이너가 있다면 실행
            if [ -n "$CONTAINERS" ]; then 
              sudo docker rm -f $CONTAINERS
            fi
            IMAGES=$(sudo docker images -q)

            #이미지가 있다면 실행
            if [ -n "$IMAGES" ]; then
              sudo docker rmi -f $IMAGES
            fi
            sudo docker pull iamges:tags
            DEBUG=1 sudo docker-compose up --build -d

 

name와 그에대한 스크립트가 작성된 것이 마치 테스트 코드 같습니다.

 

각각의 명령은 -jobs또는  - name 항목으로 구분합니다.

명령은 - run 또는 -script를 사용해 선언합니다. 둘의 차이점으로는 -run은 쉘 명령어를 수행할 때 사용하고 -script는 복잡한 쉘 스크립트를 처리할 때 사용됩니다. 

-uses는 Github Actions의 액션을 호출하여 특정 작업을 수행할 때 사용합니다(API)

 

스크립트에 작성된 명령처럼 스크립트만 봐도 무엇을 하는 스크립트인지 확인할 수있고 필요하다면 주석 또는 name으로 설명을 달 수 있습니다.

 

저는 여기서 ${{screts. - }}을 사용했습니다.

실제 외부 API Key값과 같은 민감정보를 .env파일 등의 환경변수 파일로 담아 사용하는 경우가 있습니다. 

옳바른 보안 원칙이며 누구나 그 원칙을 지키고 있습니다.

 

그러나 때때로 직접 파일을 끌어와야할 경우가 있습니다. 이 경우

.gitignore에 해당 파일이 올라와있어 Github Actions에서 해당 값을 인식하지 못합니다

 

친절한 깃허브는 이런 경우를 대비해 깃허브 자체의 환경변수를 마련했습니다.

 

 

 

위 사진대로 repository secrets를 작성하게 된 경우 Github Actions에서 해당 환경변수를 찾아 매핑하게 됩니다.

이제 작성된 브랜치로 push 또는 pr을 하게되면 자동으로 해당 스크립트를 실행하여 ec2에 접속 및 컨테이너 실행을 진행하게됩니다.

해당 과정은 깃의 해당 레포지토리의 actions 탭에서 확인할 수 있습니다.

해당 과정에서는 모든 명령의 진행사항 및 결과 등을 확인할 수 있습니다.

하지만 환경변수는 노출되지 않습니다.

더 정확히는 "****"으로 마스킹하여 안전하게 노출한다는 점입니다.

 

 

 

마지막으로 Github Actions Secrets는 .json파일을 지원하지 않을 뿐더러 권장하지 않습니다. 하지만 때때로 암호화된/비밀이 보장된 .json파일이 필요합니다.

 

때문에 저희는 외부 라이브러리인 jsdaniell/create-json@v1.2.2를 사용했습니다. 

 


 

감사합니다.

 

 


24.10.03

위 스크립트에서 green-blue를 이용해 무중단 배포를 진행했습니다.

 

      - name: SSH Commands
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          port: 22
          script_stop: true
          script: |
            set -x  # Debug mode enabled
            echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
            cd IDesign
            IMAGE_NAME="yoonjaehwan/idesign:latest"

            EXIST_BLUE=$(docker ps --filter "name=idesign_blue" --filter "status=running" -q)
            EXIST_GREEN=$(docker ps --filter "name=idesign_green" --filter "status=running" -q)


            if [ -z "$EXIST_BLUE" ] && [ -z "$EXIST_GREEN" ]; then
              # 블루 그린 둘다 없으면
              docker-compose -f docker-compose.yml up -d
              sudo cp ./nginx/nginx.blue.conf /etc/nginx/nginx.conf
              sudo nginx -s reload


            elif [ -n "$EXIST_BLUE" ]; then
              # Green 환경으로 전환
              # 새로운 컨테이너를 배경에서 실행 (정지하지 않고)
              sudo docker-compose -f ./dockercompose/docker-compose.green.yml up -d --no-recreate

              # Healthcheck 새로운 컨테이너가 준비됐는지 확인
              while ! curl -s http://localhost:8082/healthcheck; do
                echo "Waiting for green container to be ready..."
                sleep 5
              done

              # 기존 Blue 컨테이너 종료
              sudo docker stop idesign_blue
              sudo docker remove idesign_blue

              sudo cp ./nginx/nginx.green.conf /etc/nginx/nginx.conf
              sudo nginx -s reload

            elif [ -n "$EXIST_GREEN" ]; then
              # Blue 환경으로 전환
              sudo docker-compose -f ./dockercompose/docker-compose.blue.yml up -d --no-recreate

              while ! curl -s http://localhost:8081/healthcheck; do
                echo "Waiting for blue container to be ready..."
                sleep 5
              done

              # 기존 Green 컨테이너 종료
              sudo docker stop idesign_green
              sudo docker remove idesign_green

              sudo cp ./nginx/nginx.blue.conf /etc/nginx/nginx.conf
              sudo nginx -s reload
            fi

 

스크립트는 정말 쉽습니다.

현재 띄워져있는 컨테이너를 확인한 후, blue컨테이너 혹은 green 컨테이너를 새로 띄워 해당 포트로 연결하면 됩니다.

하지만 여기에는 문제가 있습니다. 새 컨테이너가 띄워지지 않은 채, 기존 컨테이너를 종료한다면 사용자는 접속을 하지 못하게됩니다.

때문에 헬스체크를 통해 컨테이너가 띄워졌는지 확인해야합니다.

 

"curl" 명령을 사용해 새로운 컨테이너의 /healthcheck 엔드포인트에 주기적으로 요청을 보냅니다. 예를들어, 그린 컨테이너가 실행된 경우 curl -s http://localhost:8082/healthcheck로 요청을 보냅니다

이 과정은 "while ! curl -s ...; do ... done" 구조로 헬스체크가 성공할 때까지 무한 루프가 실행됩니다.

 

헬스체크가 성공하면, 새로운 컨테이너가 정상적으로 작동하고 있다는 신호입니다. 이때 기존에 실행중인 컨테이너를 종료하고 제거합니다.