리눅스 서버에서 수동 jar 배포에서 도커 배포로 개선

DeadWhale·2023년 11월 28일
0

리눅스 서버에서 수동 jar 배포에서 도커 배포로 개선

개선 이유

  • 배포 속도 개선
  • 환경 변수의 통합 관리
  • 배포 환경의 일관성 유지와 격리 수준
  • 확장성 및 관리 용이성

기술적 고려사항

  • 도커 파일 관리
  • 이미지 저장 및 버전 관리
  • 네트워킹 및 스토리지
  • 자동화 및 CI/CD 통합
  • 모니터링 및 로깅

Why

프로젝트의 현재 배포 방식은 github action 통한 CI 와 CD가 별도로 동작하는 방식으로

CI 과정 중에서는 실행에 필요한 환경 변수들을 주입 후 ./gradlew build 를 통해 정합성을 검증하고

CD 과정을 통해 jar 파일을 만들어 SSH로 리눅스 서버에 접근해 직접 jar를 복제해 실행 시키는 방식으로 구현했습니다.

이 방식의 문제점은 프로세스가 수동적이고 번거롭다는 것입니다.

매번 jar 파일을 수동으로 배포하고 실행하기 위해 SSH 접속을 해야 하며, 배포 과정 중 발생하는 문제에 대한 접근도 어렵고 , 로그 모니터링을 구축하기 위한 확장도 불편한 점이 많이 느껴졌습니다.

이런 문제들을 해결하고 편의성을 하기 위해. 여러 방법을 모색했지만 결국 도커로 귀결되게 되었습니다.

도커를 활용함으로서 일관성과 격리 수준의 유지가 쉬워지고 또한

위에서 언급한 기능의 확장성 면에서 여러 장점을 가져올 수 있기 때문에.

도커를 학습하고 적용하는 과정에서 느끼고 배운 점들을 정리하는 과정입니다.


가상화 or 컨테이너화

도커에 대해 학습하면서 헷갈리는 포인트인대

가장 먼저 집고 넘어가야 하는 부분인 것 같습니다.

가상화는 컴퓨터 내부에서 별도의 독립적인 작은 컴퓨터들을 만드는 것과 같습니다.

독립적으로 만들어진 이 컴퓨터들은 자신들의 운영체제를 가지고 있어 다방면에서 사용이 가능하지만 리소스의 소모량이 많은 것이 단점입니다.

컨테이너화는 한 컴퓨터 내부에서 여러 개의 애플리케이션을 분리해서 실행하는 방식 인대

이 컴퓨터들은 하나의 운영체제를 공유하지만 서로 간 논리적으로 구분하여 독립성을 확보합니다.

둘 다 장점이 비슷하지만 가상화의 경우 완전한 독립성을 가지고 있어 특정 환경에서 장점이 될 수 있습니다.

반대로 컨테이너화는 경량화되어 빠른 환경을 제공하기 때문에 서로 간의 차이점을 비교해

활용을 할 수 있습니다.

활용예시

가상화컨테이너화
하나의 서버에 여러 개의
운영체제를 설치하여 사용
하나의 서버에 여러 개의
애플리케이션을 분리하여 사용
리소스의 소모량이 많음리소스의 소모량이 적음
완전한 독립성을 가짐논리적으로 구분되어 독립성을 확보
VM ware도커

만약 둘의 차이를 묻는 질문이 온다면 이렇게 대답하면 될 것 같습니다.

가상화는 각각 독립된 운영 체제를 가진 여러 가상 머신을 하나의 물리적 서버에서 실행하는 기술입니다.

이는 강력한 격리와 보안을 제공하지만, 각 가상 머신에 전체 운영 체제를 설치해야 하므로 자원 소모가 큰 편입니다.

반면, 컨테이너화는 도커와 같은 도구를 사용해, 단일 운영 체제 인스턴스 위에서 여러 격리된 애플리케이션 환경을 실행합니다.

이 방식은 운영 체제를 공유하기 때문에 가볍고 빠르며, 자원 활용도가 높습니다.

따라서, 가상화는 완전한 격리와 호환성이 중요한 시나리오에 적합하고, 컨테이너화는 빠른 배포와 효율적인 자원 사용이 필요한 경우에 유리합니다.


Docker

💡 도커는 많은 개발자들이 사랑하는 기술로

리눅스 컨테이너 기반의 가상화 기술을 제공하는 플랫폼입니다.

애플리케이션과 그 종속성을 독립적인 컨테이너로 패키징할 수 있습니다.

이렇게 패키징된 컨테이너는 호스트 시스템에서 실행되며,

필요한 라이브러리 및 환경 설정을 모두 포함하고 있어 이식성이 뛰어납니다.

또한, 컨테이너는 논리적으로 격리된 환경에서 실행되므로 애플리케이션 간의 충돌을 방지하고 확장성을 높일 수 있습니다.


Dockerfile

Dockerfile은 도커 이미지를 빌드하기 위한 지침을 포함한 텍스트 파일입니다.

Dockerfile을 사용하여 도커 이미지를 생성할 때 필요한 소프트웨어 패키지, 설정 및 실행 명령을 정의할 수 있습니다.

이를 통해 이미지 빌드 과정을 자동화하고, 일관성 있는 환경을 구성할 수 있습니다.

Dockerfile을 작성하고 이미지를 빌드하면 도커 이미지가 생성되며, 이를 사용하여 컨테이너를 생성하고 실행할 수 있습니다.

  # 베이스 이미지    FROM <이미지 이름>:<태그> 
  # 이 컨테이너의 기반으로 사용된 환경 
  # Springboot 환경을 사용하기 위해 java runtime 환경을 사용
  FROM openjdk:17

  # 애플리케이션 파일 복사   COPY <로컬 파일 경로> <컨테이너 내부 파일 경로>
  # 컨테이너가 시작될 때 사용할 변수 선언 jar 파일의 위치를 지정
  ARG JAR_FILE=build/libs/*.jar

  # 바로 위에서 선언한 ARG 변수를 사용하여 jar 파일을 복사
  # COPY <로컬 파일 경로> <컨테이너 내부 파일 경로>
  COPY ${JAR_FILE} app.jar

  # 포트 설정
  # 이 컨테이너를 실행할 때 외부에 노출할 포트 번호
  EXPOSE 8080


  # 컨테이너가 시작되었을 때 실행할 명령어 (Exec 형식 , shell 형식)
  # 어플리케이션의 실행 명령어를 지정
  # Exec 형식으로 수행  : ENTRYPOINT ["java","-jar","/app.jar"]
  # shell 형식으로 수행 : ENTRYPOINT java -jar /app.jarl
  # Exec 형식은 쉘을 거치지 않고 바로 명령어를 수행 Shell 형식은 쉘을 거쳐서 명령어를 수행
  # Exec 형식을 사용하는 것이 더 좋다고 함 (쉘을 거치지 않기 때문에)
  ENTRYPOINT ["java","-jar","/app.jar"]

docker - compose

docker-compose는 여러 도커 컨테이너를 정의하고 관리하기 위한 도구입니다.

하나 이상의 컨테이너를 정의하는 YAML 파일을 사용하여 다양한 서비스를 정의하고,

이를 한 번에 시작 및 정지시키는 등의 작업을 수행할 수 있습니다.

docker-compose를 사용하면 복잡한 다중 컨테이너 애플리케이션을 쉽게 관리하고 확장할 수 있으며,

각 컨테이너 간의 네트워크 및 데이터 볼륨 설정도 관리할 수 있습니다.

이를 통해 애플리케이션의 구성 및 배포가 간단해집니다.

version: '3.8' # docker-compose 파일 버전 지정

services:
  app: # 'app' 서비스 정의
    build: . # 현재 디렉토리의 Dockerfile을 사용하여 이미지 빌드
    ports:
      - "8080:8080" # 호스트의 8080 포트를 컨테이너의 8080 포트에 매핑
    env_file:
      - .env # 환경 변수를 포함하는 .env 파일 사용
    depends_on:
      - redis # 'app' 서비스가 시작되기 전에 'redis' 서비스가 시작되어야 함을 명시 (컨테이너 간의 의존성 설정)

  redis: # 'redis' 서비스 정의
    image: redis:alpine # alpine 버전의 redis 이미지 사용
    ports:
      - "6379:6379" # 호스트의 6379 포트를 컨테이너의 6379 포트에 매핑

CI (Continuous Integration)

name: CI

on:
  push:
    branches:
      - main
      - feature/cicd
  pull_request:
    types: [ opened, reopened, synchronize ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

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

      - name: Cache Gradle dependencies
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
            ~/.gradle/caches/modules-2/files-2.1
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Set up environment variables
        run: |
          echo "DB_URL=${{ secrets.DB_URL }}" >> $GITHUB_ENV
          echo "DB_ID=${{ secrets.DB_ID }}" >> $GITHUB_ENV
          echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> $GITHUB_ENV
          echo "ACCESS_KEY=${{ secrets.ACCESS_KEY }}" >> $GITHUB_ENV
          echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> $GITHUB_ENV

      - name: Build and test with Gradle
        run: |
          ./gradlew build

기존 CI 과정은 위와 같이 구성되어 있습니다.

몇개의 step으로 구성되어 있습니다.

  1. checkout
    • 코드를 체크아웃 해온다.
  2. setup-java
    • jdk 17 환경을 설정.
  3. cache
    • gradle 의존성을 캐싱한다.
    • 캐싱을 통해 빌드 속도를 높인다.
  4. chmod
    • gradlew에 실행 권한을 부여한다.
    • gradlew는 gradle wrapper를 통해 gradle을 실행하는 스크립트이다.
  5. env
    • 환경 변수를 설정한다.
    • 이 환경 변수들은 gradle build 과정에서 사용된다.
  6. build
    • gradlew build
    • gradle wrapper를 통해 gradle build를 실행한다.

Continuous Integration 는 지속적인 통합이라는 뜻으로,

개발자들이 코드를 통합하는 과정을 자동화하는 것을 의미합니다.

이 과정에서 주요한 역할은 테스트 검증 과정입니다.

병합하기 정당한지 여부를 확인하기 위해 테스트를 수행하고,

테스트를 통과하지 못한 코드는 병합되지 않도록 막습니다.

이런 기본적인 구상을 가져가며 새로 CI를 구성했습니다.

name: Docker CI

# 이 워크플로우는 'main' 브랜치와 'feature/cicd' 브랜치에 푸시되거나
# 풀 리퀘스트가 열리거나 재개되거나 동기화될 때 실행됩니다.
on:
  push:
    branches:
      - main
  pull_request:
    types: [ opened, reopened, synchronize ]

jobs:
  build-and-push:
    runs-on: ubuntu-latest  # 워크플로우는 최신 Ubuntu 환경에서 실행됩니다.

    steps:
      - name: Checkout code # 최신 코드를 체크아웃합니다.
        uses: actions/checkout@v4

      - name: Set up JDK 17 # JDK 17 환경을 설정합니다.
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Build with Gradle # jar 파일을 빌드하고 테스트를 실행합니다.
        run: ./gradlew build

      - name: Set up Docker Buildx # Docker Buildx를 설정합니다.
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub # Docker Hub에 로그인합니다.
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Build and push # Docker 이미지를 빌드하고 푸시합니다.
        # 'main' 브랜치에만 적용하고, 테스트가 성공한 경우에만 실행
        if: success() && github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: deadwhale/petlink:latest  # 'latest' 태그로 이미지를 푸시

      - name: Trigger CD Workflow # CD 워크플로우를 트리거합니다.
        if: success() && github.ref == 'refs/heads/main'
        uses: benc-uk/workflow-dispatch@v1
        with:
          workflow: "Docker CD"
          token: ${{ secrets.ACTION_TOKEN }}
          ref: "main"

빌드 캐시는 추후 속도 비교를 위해 추가하지 않았습니다.

  • Docker CI 는 위와 같이 구성되어 있습니다.
  • 기존 CI와 다른 점은 Dockerfile을 통해 도커 이미지를 빌드하고 푸시하는 과정이 추가되었습니다.
  • 이 과정은 main 브랜치에 푸시되고 테스트가 성공한 경우에만 실행됩니다.
  • 또한, Docker CD 워크플로우를 트리거하는 과정이 추가되었습니다.
    • 이 과정은 main 브랜치에 푸시되고 테스트가 성공한 경우에만 실행됩니다.
    • CI를 통해 코드 검증 성공 시 빌드 된 이미지를 기반으로 CD를 통해 배포를 진행합니다.

CD (Continuous Deployment)


name: CD

on:
  push:
    branches:
      - main
      - feature/cicd

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3 # 코드를 체크아웃합니다.

      - name: Set up JDK 17 # JDK 17 환경을 설정합니다.
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Set up environment variables # 환경 변수를 설정합니다.
        run: |
          echo "DB_URL=${{ secrets.DB_URL }}" >> $GITHUB_ENV
          echo "DB_ID=${{ secrets.DB_ID }}" >> $GITHUB_ENV
          echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> $GITHUB_ENV
          echo "ACCESS_KEY=${{ secrets.ACCESS_KEY }}" >> $GITHUB_ENV
          echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> $GITHUB_ENV

      - name: Build with Gradle # jar 파일을 빌드합니다.
        run: |
          chmod +x ./gradlew
          ./gradlew clean build 

      - name: Copy files over ssh # jar 파일을 리눅스 서버로 복사합니다.
        uses: appleboy/scp-action@v0.1.4
        with:
          host: ${{ secrets.SERVER_IP_ADDRESS }}
          username: ${{ secrets.SERVER_USERNAME }}
          password: ${{ secrets.SERVER_PASSWORD }}
          source: "build/libs/*.jar"
          target: "/var/www/petLink"


      - name: Execute commands over ssh # 리눅스 서버에서 jar 파일을 실행합니다.
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.SERVER_IP_ADDRESS }}
          username: ${{ secrets.SERVER_USERNAME }}
          password: ${{ secrets.SERVER_PASSWORD }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /var/www/petLink/build/libs 
            pkill java
            nohup java -jar petLink-0.0.1-SNAPSHOT.jar  --DB_URL=${{ secrets.DB_URL }}  --DB_ID=${{ secrets.DB_ID }}  --DB_PASSWORD=${{ secrets.DB_PASSWORD }} --ACCESS_KEY=${{ secrets.ACCESS_KEY }} --SECRET_KEY=${{ secrets.SECRET_KEY }}
                          > output.log 2>&1 &            
            if [[ $(find . -name "output.log" -size +500k) ]]; then
              echo "Log file is larger than 500kb, deleting..."
              rm output.log
            fi
            disown 
            exit

기존 CD 과정은 위와 같이 구성되어 있습니다.

몇개의 step으로 구성되어 있습니다.

몇가지 문제점이 있었습니다.

직접적인 서버 접근
: 기존 과정에서는 직접 서버에 접근하여 파일을 복사하고 명령어를 실행합니다. 이는 보안상의 위험을 수반할 수 있습니다.

수동 빌드 프로세스
: gradlew clean build를 사용하여 수동으로 빌드하는 과정은 시간이 많이 소요되고, 오류 발생 가능성이 높습니다.

로그 관리
: 로그 파일의 크기가 500kb를 넘으면 삭제하는 방식은 잠재적으로 중요한 정보를 잃을 수 있습니다.

배포 자동화의 부족
: 서버에 직접 접근하여 명령어를 실행하는 방식은 자동화된 배포 프로세스의 이점을 충분히 활용하지 못합니다.

이런 문제점들을 해결하기 위해 도커를 활용한 CD를 구성했습니다.

name: Docker CD

on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: checkout code
        uses: actions/checkout@v4

      - name: create remote directory
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.APP_SERVER_IP }}
          username: ubuntu
          key: ${{ secrets.AWS_GENERAL_SSH_KEY }}
          script: mkdir -p /home/ubuntu/app/petlink

      - name: copy project
        uses: burnett01/rsync-deployments@6.0.0
        with:
          switches: -avzr --delete
          remote_path: /home/ubuntu/app/petlink
          remote_host: ${{ secrets.APP_SERVER_IP }}
          remote_user: ubuntu
          remote_key: ${{ secrets.AWS_GENERAL_SSH_KEY }}

      - name: Set up .env file
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.APP_SERVER_IP }}
          username: ubuntu
          key: ${{ secrets.AWS_GENERAL_SSH_KEY }}
          script: |
            cd /home/ubuntu/app/petlink
            touch ./.env
            echo "${{ secrets.ENV }}" > ./.env

      - name: docker compose up
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.APP_SERVER_IP }}
          username: ubuntu
          key: ${{ secrets.AWS_GENERAL_SSH_KEY }}
          script: |
            cd /home/ubuntu/app/petlink
            sudo docker ps -q | xargs -r sudo docker rm -f         
            sudo docker pull ${{ secrets.DOCKER_ID }}/petlink:latest  
            docker-compose up -d                                      
            docker image prune -f

개선된 Docker CD 는 위와 같이 구성되어 있습니다.

  • checkout code

GitHub 저장소에서 코드를 체크아웃하여 워크플로우가 실행되는 환경으로 가져옵니다.

actions/checkout@v4 액션을 사용합니다.

  • create remote directory

원격 서버(EC2 인스턴스)에 특정 디렉토리를 생성합니다.

appleboy/ssh-action@master를 사용하여 SSH를 통해 서버에 연결하고 mkdir 명령어로 디렉토리를 만듭니다.

  • copy project

현재 GitHub 저장소에 푸시된 소스 코드를 원격 서버에 복사합니다.

burnett01/rsync-deployments@6.0.0를 사용하여 rsync 명령어를 통해 파일을 동기화합니다.

  • Set up .env file

원격 서버에 .env 파일을 생성하고 설정합니다.
appleboy/ssh-action@master를 사용하여 SSH를 통해 서버에 연결하고, .env 파일을 만든 후 필요한 환경 변수를 채웁니다.

  • docker compose up

Docker Compose를 사용하여 애플리케이션을 배포합니다.

먼저 실행 중인 모든 Docker 컨테이너를 제거하고,

새 Docker 이미지를 풀링한 후 docker-compose up -d 명령어로 컨테이너를

시작합니다. 마지막으로 docker image prune -f 명령어로 사용하지 않는 이미지를 정리합니다.

기본적으로 이전 CD.yml과 비슷한 형태이지만

기존에 직접 서버에서 배포 하던 방식을 docker-compose를 통해 배포를 진행합니다.

이를 통해 위에서 언급한 문제점들을 해결할 수 있습니다.

직접적인 서버 접근 : docker-compose를 통해 배포를 진행하므로 직접적인 서버 접근이 필요하지 않습니다.

수동 빌드 프로세스 : 빌드나 테스트 과정을 거치지 않고 바로 배포를 진행합니다. 이는 이전 검증 완료된 CI 과정에서 마무리 되었기 때문입니다.

로그 관리 : 이 부분은 아직 진행되진 않았지만 도커를 통해 더욱 쉽게 연동 가능해졌습니다.

또한 암호화 파일을 .env 파일로 관리하므로 보안성도 높아졌습니다.
(이 부분 이전 배포 방식에서도 가능하지만 도커를 통해 더욱 쉽게 연동 가능해졌습니다.)

이 때 이전 빌드가 완료 된 후 CD 가 진행되어야 검증된 최신 버전이 배포 되서

on:
  workflow_dispatch:

이 부분을 추가하여 이전 CI에서 트리거를 통해 CD를 호출했습니다.

  - name: Trigger CD Workflow # CD 워크플로우를 트리거합니다.
    if: success() && github.ref == 'refs/heads/main'
    uses: benc-uk/workflow-dispatch@v1
    with:
      workflow: "Docker CD"
      token: ${{ secrets.ACTION_TOKEN }}
      ref: "main"

Next Step

업로드중..

도커를 통해 CI CD 과정을 좀더 자연스럽고 편리하게 구성할 수 있었습니다.

하지만 아직도 개선할 점이 많이 남아있습니다.

대표적으로 빌드 속도가 있습니다.

프로젝트의 규모가 커지면 커질수록 빌드 속도가 느려지는 문제가 발생합니다.

현재는 프로젝트의 규모가 작아 크게 체감이 안되지만 이후 규모가 커질 경우에는 빌드 속도가 문제가 될 수 있습니다.

또한 병렬 테스트 실행을 통해 테스트 속도를 높이는 방법도 있습니다.

도커 이미지를 경량화 하여 빌드 속도를 높이는 방법도 있습니다.

이런 문제들을 해결하기 위해 더욱 더 공부하고 노력해야 할 것 같습니다.

0개의 댓글