리눅스 서버에서 수동 jar 배포에서 도커 배포로 개선
개선 이유
기술적 고려사항
프로젝트의 현재 배포 방식은 github action 통한 CI 와 CD가 별도로 동작하는 방식으로
CI 과정 중에서는 실행에 필요한 환경 변수들을 주입 후 ./gradlew build
를 통해 정합성을 검증하고
CD 과정을 통해 jar 파일을 만들어 SSH로 리눅스 서버에 접근해 직접 jar를 복제해 실행 시키는 방식으로 구현했습니다.
이 방식의 문제점은 프로세스가 수동적이고 번거롭다는 것입니다.
매번 jar 파일을 수동으로 배포하고 실행하기 위해 SSH 접속을 해야 하며, 배포 과정 중 발생하는 문제에 대한 접근도 어렵고 , 로그 모니터링을 구축하기 위한 확장도 불편한 점이 많이 느껴졌습니다.
이런 문제들을 해결하고 편의성을 하기 위해. 여러 방법을 모색했지만 결국 도커로 귀결되게 되었습니다.
도커를 활용함으로서 일관성과 격리 수준의 유지가 쉬워지고 또한
위에서 언급한 기능의 확장성 면에서 여러 장점을 가져올 수 있기 때문에.
도커를 학습하고 적용하는 과정에서 느끼고 배운 점들을 정리하는 과정입니다.
도커에 대해 학습하면서 헷갈리는 포인트인대
가장 먼저 집고 넘어가야 하는 부분인 것 같습니다.
가상화는 컴퓨터 내부에서 별도의 독립적인 작은 컴퓨터들을 만드는 것과 같습니다.
독립적으로 만들어진 이 컴퓨터들은 자신들의 운영체제를 가지고 있어 다방면에서 사용이 가능하지만 리소스의 소모량이 많은 것이 단점입니다.
컨테이너화는 한 컴퓨터 내부에서 여러 개의 애플리케이션을 분리해서 실행하는 방식 인대
이 컴퓨터들은 하나의 운영체제를 공유하지만 서로 간 논리적으로 구분하여 독립성을 확보합니다.
둘 다 장점이 비슷하지만 가상화의 경우 완전한 독립성을 가지고 있어 특정 환경에서 장점이 될 수 있습니다.
반대로 컨테이너화는 경량화되어 빠른 환경을 제공하기 때문에 서로 간의 차이점을 비교해
활용을 할 수 있습니다.
가상화 | 컨테이너화 |
---|---|
하나의 서버에 여러 개의 운영체제를 설치하여 사용 | 하나의 서버에 여러 개의 애플리케이션을 분리하여 사용 |
리소스의 소모량이 많음 | 리소스의 소모량이 적음 |
완전한 독립성을 가짐 | 논리적으로 구분되어 독립성을 확보 |
VM ware | 도커 |
만약 둘의 차이를 묻는 질문이 온다면 이렇게 대답하면 될 것 같습니다.
가상화는 각각 독립된 운영 체제를 가진 여러 가상 머신을 하나의 물리적 서버에서 실행하는 기술입니다.
이는 강력한 격리와 보안을 제공하지만, 각 가상 머신에 전체 운영 체제를 설치해야 하므로 자원 소모가 큰 편입니다.
반면, 컨테이너화는 도커와 같은 도구를 사용해, 단일 운영 체제 인스턴스 위에서 여러 격리된 애플리케이션 환경을 실행합니다.
이 방식은 운영 체제를 공유하기 때문에 가볍고 빠르며, 자원 활용도가 높습니다.
따라서, 가상화는 완전한 격리와 호환성이 중요한 시나리오에 적합하고, 컨테이너화는 빠른 배포와 효율적인 자원 사용이 필요한 경우에 유리합니다.
💡 도커는 많은 개발자들이 사랑하는 기술로
리눅스 컨테이너 기반의 가상화 기술을 제공하는 플랫폼입니다.
애플리케이션과 그 종속성을 독립적인 컨테이너로 패키징할 수 있습니다.
이렇게 패키징된 컨테이너는 호스트 시스템에서 실행되며,
필요한 라이브러리 및 환경 설정을 모두 포함하고 있어 이식성이 뛰어납니다.
또한, 컨테이너는 논리적으로 격리된 환경에서 실행되므로 애플리케이션 간의 충돌을 방지하고 확장성을 높일 수 있습니다.
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는 여러 도커 컨테이너를 정의하고 관리하기 위한 도구입니다.
하나 이상의 컨테이너를 정의하는 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 포트에 매핑
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으로 구성되어 있습니다.
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"
빌드 캐시는 추후 속도 비교를 위해 추가하지 않았습니다.
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 는 위와 같이 구성되어 있습니다.
GitHub 저장소에서 코드를 체크아웃하여 워크플로우가 실행되는 환경으로 가져옵니다.
actions/checkout@v4 액션을 사용합니다.
원격 서버(EC2 인스턴스)에 특정 디렉토리를 생성합니다.
appleboy/ssh-action@master를 사용하여 SSH를 통해 서버에 연결하고 mkdir 명령어로 디렉토리를 만듭니다.
현재 GitHub 저장소에 푸시된 소스 코드를 원격 서버에 복사합니다.
burnett01/rsync-deployments@6.0.0를 사용하여 rsync 명령어를 통해 파일을 동기화합니다.
원격 서버에 .env 파일을 생성하고 설정합니다.
appleboy/ssh-action@master를 사용하여 SSH를 통해 서버에 연결하고, .env 파일을 만든 후 필요한 환경 변수를 채웁니다.
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"
도커를 통해 CI CD 과정을 좀더 자연스럽고 편리하게 구성할 수 있었습니다.
하지만 아직도 개선할 점이 많이 남아있습니다.
대표적으로 빌드 속도가 있습니다.
프로젝트의 규모가 커지면 커질수록 빌드 속도가 느려지는 문제가 발생합니다.
현재는 프로젝트의 규모가 작아 크게 체감이 안되지만 이후 규모가 커질 경우에는 빌드 속도가 문제가 될 수 있습니다.
또한 병렬 테스트 실행을 통해 테스트 속도를 높이는 방법도 있습니다.
도커 이미지를 경량화 하여 빌드 속도를 높이는 방법도 있습니다.
이런 문제들을 해결하기 위해 더욱 더 공부하고 노력해야 할 것 같습니다.