본문 바로가기
개발관련 이것저것

어플리케이션 자동 배포 파이프란인 구축 (Docker + Git Actions)

by 이상한나라의개발자 2025. 6. 18.

이번 글에서는 프로젝트를 Docker + GitHub Actions + Self-Hosted Runner + SSH 조합으로 자동 배포하는 과정을 단계별로 정리했습니다.

 

개요

  • 개발환경은 GCP에 두대의 VM 서버를 준비하여 작업을 진행하였습니다.
  • Actions Runner 실행할 서버 한대 ( 여기는 여러개의 Runner을 작동하기 위한 Runner 서버 입니다.) 
  • 어플리케이션 서버 한대 
  • develop 브랜치 push 시 자동으로 아래와 같은 작업이 이루어집니다.
    • JAR 빌드
    • SSH 로 어플리케이션 서버에 전송
    • build.sh sandbox 실행
    • Docker 컨테이너 재시작

Docker 설정

Dockerfile 작성

Dockerfile는 버전 관리 가능한 스크립트로 해당 파일에는 "어떤 베이스 이미지 위에, 어떤 패키지를 설치하고, 어떤 파일을 복사한 뒤, 어떤 명령을 실행하라" 라는 단계별 지시문이 들어가게 됩니다. 또한,  사람마다 다른 로컬 환경 대신, 이미지 빌드를 통해 모든 개발 및 운영 서버에 동일한 환경을 손쉽게 재현할 수 있습니다.

  • FROM openjdk:15-slim 으로 베이스 이미지 선택하고 컨테이너를 만들때 어떤 os + 런타임 위에 어플리케이션을 올릴지 선택
  • RUN apt-get update...
    • 컨테이너 빌드 중에 실행할 쉘 명령을 지정합니다.
    • apt-get update 로 패키지 목록을 갱신
    • tzdata (타임존 데이터 설치)
    • /etc/localtime 을 Asia/Seoul로 심볼릭 링크
    • /etc/timezone에 타임존 기록
    • 불필요해진 APT 캐시 파일 삭제
    • 결과적으로 컨테이너 내부의 os 타임존을 한국 표준시로 맞추는 작업
  • WORKDIR /app
    • 작업 디렉토리 설
    • 이후 나오는 COPY, RUN, ENTRYPOINT 등 모든 명령은 /app 폴더 안에서 수행
    • 예를들어 COPY app.jar app.jar는 호스트의 app.jar를 컨테이너의 /app/app.jar 로 복사하게 됩니다.
  • EXPOSE 8092
    • 메타데이터로, 이 컨테이너가 내부에서 8092 포트를 열어서 수신할 거라는 걸 선언합니다.
    • 실제 호스트-컨테이너 포트 매핑은 docker run 때 -p 호스트포트:컨테이너포트 때 지정 하지만 EXPOSE를 써 두면 나중에 문서화도 되고 Docker Compose 같은 툴에서 자동으로 포트를 끌어다 쓸 수 있습니다.
  • ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]
    • 컨테이너가 시작될 때 기본으로 실행할 명령 을 정의합니다.
    • java -Duser.timezone=Asia/Seoul -jar app.jar
      • 이라는 프로세스를 /app 디렉터리에서 자동으로 실행하게 됩니다.
      • 별도 docker run 커맨드를 주지 않아도, 컨테이너가 부팅되면 바로 JAR이 구동되도록 하는 역할입니다.
# 1) 베이스 이미지: OpenJDK17 슬림
FROM openjdk:17-slim

# 2) tzdata 설치 시 대화형 프롬프트 건너뛰기
ARG DEBIAN_FRONTEND=noninteractive
ENV DEBIAN_FRONTEND=${DEBIAN_FRONTEND} \
    TZ=Asia/Seoul

# 3) 타임존 설정 및 캐시 정리
RUN apt-get update \
 && apt-get install -y --no-install-recommends tzdata \
 && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
 && echo $TZ > /etc/timezone \
 && rm -rf /var/lib/apt/lists/*

# 4) 애플리케이션 작업 폴더 설정
WORKDIR /app

# 5) 빌드된 JAR 복사
COPY app.jar app.jar

# 6) 컨테이너 외부 노출 포트
EXPOSE 8092

# 7) JVM 타임존 지정 후 JAR 실행
ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]

 

build.sh 작성

build.sh 스크립트는 dockerfile을 이용해 이미지를 빌드하고 기존에 올라와 있던 동일 이름의 컨테이너가 있으면 깔끔하게 remove한 뒤 지정된 프로파일에 맞춰 새 컨테이너를 실행하는 역할을 합니다.

  • docker ps -a -q 로 stopped/running 구분 없이 해당 컨테이너 제거
  • --newwork app-net 으로 nginx 컨테이너와 이름 해석 지원
    • docker network create app-net 네트워크 생성 
#!/usr/bin/env bash
# └─ 이 스크립트를 bash 인터프리터로 실행

set -e   # 에러 발생 즉시 중단
# └─ 에러(any exit code ≠ 0) 발생 시 즉시 스크립트 실행 중단

# 1) 배포 프로파일 받기 (파라미터 없으면 local)
PROFILE=${1:-local}

# 2) 프로파일별 포트 결정
case "$PROFILE" in
  local|sandbox) 
  PORT=8092 # local 또는 sandbox 프로파일이면 8092
  ;;
  prod1)         
  PORT=8093 # prod1 프로파일이면 8093
  ;;
  prod2)         
  PORT=8094 # prod2 프로파일이면 8094
  ;;
  # 정의되지 않은 프로파일이 들어오면 오류 메시지 출력 후 종료
  echo "Unknown profile: $PROFILE"; 
  exit 1 ;;
esac

# 3) 이미지 이름/태그/컨테이너 이름 변수 설정
IMAGE_NAME="app" # Docker 이미지 이름
IMAGE_TAG="1.3" # 태그
CONTAINER_NAME="${IMAGE_NAME}-${PROFILE}" # 예: app-sandbox

# 4) Docker 이미지 빌드 실제 Dockerfile을 읽는 역할 . 태크는 현재 디렉토리안에 Dockerfile 찾음
#    --no-cache : 레이어 캐시를 전혀 쓰지 않고 새로 빌드
#    -t         : 이름:태그 형태로 이미지에 태그 달기
docker build --no-cache -t ${IMAGE_NAME}:${IMAGE_TAG} .

# 5) 이전 컨테이너 제거
#    docker ps -a -q -f name=NAME  : 해당 이름의 모든 컨테이너 ID 나열
#    xargs --no-run-if-empty        : ID가 하나라도 있으면 뒤 명령 실행
#    docker rm -f                    : 강제 종료 및 삭제
docker ps -a -q -f name=${CONTAINER_NAME} \
  | xargs --no-run-if-empty docker rm -f

# 5) 새로운 컨테이너 실행
docker run -d \
  --name ${CONTAINER_NAME} \      # 컨테이너 이름 지정
  --network cmsapi-net \          # 사용자 정의 네트워크 이 부분은 nginx 사용 안할시 삭제, 사용자 정의 브리지 네트워크
  -e SPRING_PROFILES_ACTIVE=${PROFILE} \ # Spring 프로파일 환경변수
  -e SERVER_PORT=${PORT} \        # 애플리케이션이 쓸 포트 환경변수
  -p ${PORT}:${PORT} \            # 호스트:컨테이너 포트 매핑
  ${IMAGE_NAME}:${IMAGE_TAG}       # 실행할 이미지:태그

# 7) 완료 메시지 출력
echo "✅ '${PROFILE}' 프로파일이 ${PORT} 포트로 실행되었습니다."

 

Self-Hosted Runner  설치 ( Action Runner 서버 )

러너 설치는 Github 레파지토리에 접속하여 Setting -> Actions 에서 설치 방법과 토큰을 얻을 수 있습니다.

  • --labels app,self-hosted 지정 후 워크플로우에서 runs-on: [ self-hosted, app ] 사용
  • svc.sh 로 시스템 서비스 등록 → 재부팅 복구
# 작업 폴더 생성 및 패키지 다운로드
mkdir ~/actions-runner-app && cd ~/actions-runner-app
curl -O -L https://github.com/actions/runner/releases/download/v2.325.0/actions-runner-linux-x64-2.325.0.tar.gz
tar xzf actions-runner-linux-x64-2.325.0.tar.gz

# GitHub에서 발급받은 등록 토큰으로 러너 구성
./config.sh \
  --url    https://github.com/your-org/your-repo \
  --token  <YOUR_REGISTRATION_TOKEN> \
  --name   app-runner \
  --labels self-hosted,app

# 실행하여 상태 확인 이때 Github에 접속이 되어야 함
./run.sh 

# 서비스로 등록 → 부팅 시 자동 시작
sudo ./svc.sh install
sudo ./svc.sh start

# 서비스 상태 확인
sudo ./svc.sh status

 

SSH 키 설정 ( Action Runner 서버 -> 어플리케이션 서버 )

Action Runner 서버에서 키 생성

cd ~/.ssh
# ed25519 키 생성 (패스프레이즈 없음)
ssh-keygen -t ed25519 -f app_deploy_key -N ''

 

  • 생성결과
    • app_deploy_key ← Private Key
    • app_deploy_key.pub ← Public Key

 

어플리케이션 서버 퍼블릭 키 등록 

  • authorized_keys 는 600, .ssh 는 700
  • 키 한 줄(헤더/본문/주석) 전체가 정확히 일치해야 인증 성공
# .ssh 폴더 및 권한 설정
mkdir -p ~/.ssh && chmod 700 ~/.ssh

# 퍼블릭키 한 줄 전체를 authorized_keys로 저장
cat << 'EOF' > ~/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...rest_of_key... user명@94-server
EOF

# 파일 권한·소유권 조정
chmod 600 ~/.ssh/authorized_keys
chown 권한자:소유자 ~/.ssh ~/.ssh/authorized_keys

 

GitHub Actions 워크플로우 작성

  • SSH_PRIVATE_KEY: Actions 서버 ~/.ssh/app_deploy_key 전체
  • SSH_USER: 서버 유저명 

github secret -> actions 설정

name: Build and Deploy

on:
  push:
    branches: [ develop ]
  workflow_dispatch:

env:
  JAR_NAME:              app.jar
  APP_DIR:               /app/projects
  SERVER_IP:             xxx.xxx.xxx.xxx
  SPRING_PROFILES_ACTIVE: sandbox

jobs:
  build-and-deploy:
    runs-on:
      - self-hosted
      - app

    steps:
      # 1) 코드 체크아웃
      - uses: actions/checkout@v3

      # 2) JDK17 설치
      - uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version:  '17'

      # 3) Gradle Wrapper 실행 권한
      - run: chmod +x ./gradlew

      # 4) JAR 빌드
      - run: ./gradlew clean bootJar -x test

      # 5) SSH 키 복원
      - name: Prepare SSH key
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        run: |
          printf "%s\n" "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem

      # 6) JAR 파일 전송
      - name: Copy JAR to remote server
        env:
          SSH_USER: ${{ secrets.SSH_USER }}
        run: |
          scp -i key.pem -o StrictHostKeyChecking=no \
            build/libs/${{ env.JAR_NAME }} \
            $SSH_USER@${{ env.SERVER_IP }}:${{ env.APP_DIR }}/

      # 7) 원격서버 빌드 스크립트 실행
      - name: Run build.sh on remote server
        env:
          SSH_USER: ${{ secrets.SSH_USER }}
        run: |
          ssh -i key.pem -o StrictHostKeyChecking=no \
            $SSH_USER@${{ env.SERVER_IP }} \
            "cd ${{ env.APP_DIR }} && chmod +x build.sh && ./build.sh ${{ env.SPRING_PROFILES_ACTIVE }}"

 

위와 같이 설정이 완료 된다면 코드에서 push 하게 되면 자동으로 actions runner로 배포가 됩니다. 이제 아래는 nginx 연결에 대해 하겠습니다.

 

Docker 네트워크 & nginx 연동

네트워크 생성 

docker network create cmsapi-net

 

build.sh 실행

./build.sh sandbox

 

nginx 실행

docker run -d --name nginx --network app-net -p 80:80 \
  -v /home/etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
  -v /home/etc/nginx/conf.d:/etc/nginx/conf.d:ro \
  -v /var/log/nginx:/var/log/nginx \
  nginx:latest

 

nginx 설정

 

upstreas.conf

upstream cmsapi_backend {
  server app-sandbox:8092;
}

 

dev-app-api.conf

server {
  listen       80;
  server_name  dev-app-api.com
  location / {
    proxy_pass         http://app_backend;
    proxy_set_header   Host            $host;
    proxy_set_header   X-Real-IP       $remote_addr;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

 

DNS 서버에서 dev-app-api.com -> 호스트 아이피 설정, 방화벽 포트 80 허용

http://dev-app-api.com/

 

최종적으로 아래와 같이 동작을 하게됩니다.

실행 흐름