GitHub Actions로 Docker 이미지 자동 빌드 및 배포 파이프라인 구축하기

들어가며

회사에서 GitLab에서 GitHub로 이전하면서 CI/CD 파이프라인을 새로 구축해야 했습니다. 특히 Docker 컨테이너로 서비스를 운영하고 있어 이미지 빌드와 배포 프로세스의 자동화가 필요했습니다.

이 글에서는 기존 수동 배포 방식의 문제점을 해결하기 위해 GitHub Actions를 도입하고, 요금과 운영 효율성을 고려해 셀프호스팅 러너와 레지스트리를 구성한 경험을 공유합니다.

문제 상황: 수동 배포의 한계

기존 배포 프로세스

이전에는 개발자가 직접 Docker 이미지를 빌드하고 레지스트리 서버에 업로드하는 수동 방식을 사용했습니다:

  1. 개발자가 로컬에서 docker build 실행
  2. 빌드된 이미지를 docker save로 tar 파일로 추출
  3. scp로 레지스트리 서버에 전송
  4. 레지스트리 서버에서 docker load로 이미지 로드
  5. 버전 태그를 수동으로 관리

문제점

1. 인적 오류 가능성

  • 버전 태그를 잘못 입력하거나 중복 사용
  • 빌드 환경 차이로 인한 불일치
  • 배포 절차를 놓치는 경우

2. 확장성 문제

  • 서비스가 늘어날수록 배포 작업이 기하급수적으로 증가
  • 각 서비스마다 배포 담당자가 달라 일관성 부족
  • 배포 시간과 리소스 낭비

3. 형상 관리 어려움

  • 어떤 버전이 언제 배포되었는지 추적 어려움
  • 롤백이 필요할 때 이전 버전을 찾기 어려움
  • Git 커밋과 배포 버전 간 연결이 약함

4. 개발 생산성 저하

  • 배포에 시간을 많이 소비
  • 반복적인 작업으로 인한 피로도 증가
  • 실제 개발에 집중하기 어려움

해결 전략 수립

1. GitHub Actions 도입 결정

GitHub로 이전하면서 CI/CD 자동화를 위해 GitHub Actions를 선택했습니다. 주요 이유:

  • GitHub 통합: 코드 저장소와 CI/CD가 같은 플랫폼에 있어 관리가 용이
  • 무료 제공량: 공개 리포지토리는 무료, 비공개도 일정량 무료 제공
  • 풍부한 액션 생태계: Docker 빌드, 배포 등 다양한 액션 제공
  • 워크플로우 유연성: YAML로 복잡한 파이프라인 구성 가능

2. 레지스트리 선택: GHCR vs 셀프호스팅

처음에는 GitHub Container Registry(GHCR)를 사용하려고 했습니다.

GHCR의 장점:

  • GitHub과 완전 통합
  • 별도 인프라 관리 불필요
  • GitHub Actions에서 인증 자동 처리

하지만 요금 문제로 셀프호스팅 선택:

GitHub Packages(GHCR 포함)는 다음과 같은 요금 정책을 가집니다:

  • 공개 리포지토리: 무료
  • 비공개 리포지토리:
    • 저장소: 500MB 무료, 초과 시 GB당 요금
    • 데이터 전송: 1GB/월 무료, 초과 시 요금

여러 서비스를 운영하고 이미지 크기가 큰 경우 비용이 빠르게 증가할 수 있어, 기존에 구축된 팀 도커 레지스트리 서버를 활용하기로 결정했습니다.

셀프호스팅 레지스트리의 장점:

  • 요금 부담 없음 (인프라 비용만)
  • 내부 네트워크에서 빠른 전송 속도
  • 보안 정책에 맞춘 접근 제어 가능
  • 기존 인프라 활용

3. GitHub Actions Runner: 셀프호스팅 선택

GitHub Actions는 두 가지 러너 옵션을 제공합니다:

GitHub 호스팅 러너:

  • GitHub에서 제공하는 가상 머신
  • 설정 없이 바로 사용 가능
  • 비공개 리포지토리는 사용량에 따라 요금 부과

셀프호스팅 러너:

  • 사용자가 직접 관리하는 서버에 러너 설치
  • GitHub Actions 이용 요금 무료 (인프라 비용만)
  • 빌드 환경을 직접 제어 가능

셀프호스팅 러너를 선택한 이유:

  1. 비용 절감: 여러 서비스를 빌드할 때 GitHub 호스팅 러너 요금이 누적됨
  2. 빌드 속도: 내부 네트워크에서 레지스트리로 빠르게 푸시 가능
  3. 환경 제어: 필요한 도구와 설정을 미리 구성 가능
  4. 보안: 내부 네트워크에서만 동작하도록 구성 가능

GitHub Actions 워크플로우 구현

기본 구조

워크플로우는 다음과 같은 단계로 구성됩니다:

  1. 트리거: 특정 브랜치에 코드 푸시 시 실행
  2. 버전 생성: 기존 태그를 기반으로 자동 버전 증가
  3. Docker 이미지 빌드: Dockerfile을 사용해 이미지 생성
  4. Git 태그 생성: 버전 태그 자동 생성
  5. 레지스트리 배포: 팀 레지스트리 서버로 이미지 푸시

워크플로우 YAML 파일

name: Build Docker Image

on:
  push:
    branches:
      - develop  # develop 브랜치에 푸시 시 실행

concurrency:
  group: build-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # 동시 실행 시 이전 작업 취소

env:
  IMAGE_NAME: my-service
  DOCKER_BUILDKIT: 1  # BuildKit 사용으로 빌드 속도 향상

permissions:
  contents: write  # 태그 생성 및 릴리스 생성을 위해 필요

jobs:
  build:
    runs-on: self-hosted  # 셀프호스팅 러너 사용
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 모든 태그 정보를 가져오기 위해 필요

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

      - name: Generate version (auto increment)
        id: version
        run: |
          # 최신 태그 찾기 (v0.0.* 형식)
          LATEST_TAG=$(git tag -l "v0.0.*" | sort -V | tail -n1)
          
          if [ -z "$LATEST_TAG" ]; then
            # 태그가 없으면 0.0.1로 시작
            VERSION="0.0.1"
          else
            # 마지막 숫자 추출 후 1 증가 (호환성을 위해 sed 사용)
            LAST_NUM=$(echo $LATEST_TAG | sed 's/.*\.//')
            NEXT_NUM=$((LAST_NUM + 1))
            VERSION="0.0.${NEXT_NUM}"
          fi
          
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "Determined version: $VERSION"

      - name: Build Docker image
        working-directory: apps/my-service  # 서비스별 디렉토리
        env:
          IMAGE_TAG: ${{ steps.version.outputs.version }}
        run: |
          echo "Building image ${IMAGE_NAME}:${IMAGE_TAG}"
          
          docker buildx build --platform linux/amd64 --load \
            --build-arg BUILD_HASH=${{ github.sha }} \
            --build-arg USE_SLIM=true \
            -t ${IMAGE_NAME}:${IMAGE_TAG} \
            -t ${IMAGE_NAME}:latest \
            .
          
          # 빌드 성공 확인
          if ! docker images ${IMAGE_NAME}:${IMAGE_TAG} | grep -q ${IMAGE_TAG}; then
            echo "ERROR: Docker image build failed!"
            exit 1
          fi
          
          echo "Docker image built successfully"
          docker images ${IMAGE_NAME}:${IMAGE_TAG}

      - name: Create Git tag
        if: github.event_name == 'push'
        run: |
          TAG=v${{ steps.version.outputs.version }}
          git fetch --tags
          
          # 태그가 이미 존재하는지 확인
          if git rev-parse "$TAG" >/dev/null 2>&1; then
            echo "Tag $TAG already exists, skipping tag creation."
          else
            git config user.name "GitHub Actions"
            git config user.email "actions@github.com"
            git tag "$TAG"
            git push origin "$TAG"
            echo "Created and pushed tag: $TAG"
          fi

      - name: Push to team registry
        if: github.event_name == 'push'
        env:
          IMAGE_TAG: ${{ steps.version.outputs.version }}
        run: |
          # 팀 레지스트리 서버에 이미지 푸시
          docker tag ${IMAGE_NAME}:${IMAGE_TAG} \
            registry.example.com/${IMAGE_NAME}:${IMAGE_TAG}
          docker tag ${IMAGE_NAME}:${IMAGE_TAG} \
            registry.example.com/${IMAGE_NAME}:latest
          
          # 보안을 위해 --password-stdin 사용
          echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin registry.example.com
          
          docker push registry.example.com/${IMAGE_NAME}:${IMAGE_TAG}
          docker push registry.example.com/${IMAGE_NAME}:latest
          
          echo "Image pushed to team registry"

주요 기능 설명

1. 버전 자동 증가 로직

LATEST_TAG=$(git tag -l "v0.0.*" | sort -V | tail -n1)
if [ -z "$LATEST_TAG" ]; then
  VERSION="0.0.1"
else
  # 호환성을 위해 sed 사용 (grep -P는 GNU grep 전용)
  LAST_NUM=$(echo $LATEST_TAG | sed 's/.*\.//')
  NEXT_NUM=$((LAST_NUM + 1))
  VERSION="0.0.${NEXT_NUM}"
fi
  • 기존 태그 중 v0.0.* 패턴을 찾아 최신 버전 확인
  • 마지막 숫자를 추출해 1 증가 (sed를 사용해 호환성 확보)
  • 태그가 없으면 0.0.1로 시작

버전 관리 전략:

  • v0.0.1, v0.0.2, ... 형식으로 패치 버전 자동 증가
  • 필요시 v0.1.0, v1.0.0 등으로 수동 태그 생성 가능
  • Git 태그와 Docker 이미지 태그를 일치시켜 형상 관리 용이

참고: 이 로직은 v0.0.* 패턴만 처리합니다. v0.1.0 이상의 버전으로 전환하려면 수동으로 태그를 생성하거나 로직을 확장해야 합니다.

2. Concurrency 제어

concurrency:
  group: build-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • 같은 브랜치에서 여러 커밋이 빠르게 푸시되면 이전 빌드를 취소
  • 불필요한 빌드 방지 및 리소스 절약

3. 레지스트리 자동 푸시

  • 빌드된 이미지를 자동으로 팀 레지스트리 서버에 푸시
  • 버전 태그와 latest 태그를 모두 푸시하여 형상 관리 용이

셀프호스팅 러너 설정

러너 설치

# GitHub Actions 러너 다운로드 및 설치
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz

# 러너 등록 (리포지토리 레벨 또는 조직 레벨)
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO --token YOUR_TOKEN

# 러너 서비스로 등록 (자동 시작)
sudo ./svc.sh install
sudo ./svc.sh start

러너 환경 준비

셀프호스팅 러너 서버에 다음을 설치해야 합니다:

  • Docker 및 Docker Buildx
  • Git
  • 기타 빌드에 필요한 도구

보안 고려사항

  • 러너는 내부 네트워크에서만 접근 가능하도록 설정
  • Secrets는 GitHub에 저장하고 러너로 전달
  • 레지스트리 인증 정보는 GitHub Secrets로 관리
  • Docker 로그인 시 --password-stdin 사용 (비밀번호가 프로세스 목록에 노출되지 않도록)

레지스트리 배포 전략

빌드된 이미지는 자동으로 팀 레지스트리 서버에 푸시됩니다:

  • 빌드 후 자동으로 레지스트리 서버에 푸시
  • 운영 서버에서 레지스트리에서 pull하여 배포
  • 장점: 자동화된 배포 파이프라인 구성 가능
  • 단점: 레지스트리 서버 관리 필요

레지스트리 푸시 예시

- name: Push to team registry
  env:
    IMAGE_NAME: my-service
    IMAGE_TAG: ${{ steps.version.outputs.version }}
  run: |
    # 보안을 위해 --password-stdin 사용 (비밀번호가 프로세스 목록에 노출되지 않음)
    echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin registry.example.com
    
    docker tag ${IMAGE_NAME}:${IMAGE_TAG} \
      registry.example.com/${IMAGE_NAME}:${IMAGE_TAG}
    
    docker push registry.example.com/${IMAGE_NAME}:${IMAGE_TAG}

개선 효과

Before (수동 배포)

  • 배포 시간: 서비스당 약 10-15분
  • 인적 오류: 버전 태그 실수, 빌드 환경 차이
  • 형상 관리: 어려움
  • 확장성: 서비스 증가 시 배포 부담 급증

After (자동화)

  • 배포 시간: 코드 푸시 후 자동 실행 (개발자 개입 최소화)
  • 인적 오류: 버전 자동 생성으로 실수 방지
  • 형상 관리: Git 태그와 이미지 태그 자동 일치
  • 확장성: 서비스 추가 시 워크플로우 파일만 복사/수정

정량적 개선

  • 배포 시간: 90% 감소 (10-15분 → 1-2분, 대부분 자동)
  • 배포 실수: 거의 제로 (자동화로 인적 오류 제거)
  • 개발 생산성: 배포 작업 시간을 개발에 집중 가능

추가 고려사항

1. 멀티 서비스 관리

여러 서비스를 운영하는 경우:

  • 각 서비스마다 워크플로우 파일 생성
  • 공통 부분은 재사용 가능한 액션이나 템플릿으로 분리
  • Monorepo 구조라면 매트릭스 전략 사용 고려

2. 브랜치 전략

on:
  push:
    branches:
      - develop    # 개발 환경 배포
      - staging   # 스테이징 환경 배포
      - main       # 프로덕션 환경 배포
  • 브랜치별로 다른 레지스트리나 태그 전략 사용 가능
  • 환경별 이미지 분리로 롤백 용이

3. 빌드 캐시 활용

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3
  with:
    driver-opts: |
      image=moby/buildkit:latest
      network=host
  • Docker BuildKit 캐시로 빌드 속도 향상
  • 레이어 캐시 활용

4. 보안 강화

  • 이미지 스캔 도구 통합 (Trivy, Snyk 등)
  • 취약점이 발견되면 빌드 실패 처리
  • 최소 권한 원칙으로 Secrets 관리

5. 모니터링 및 알림

  • 빌드 실패 시 Slack/이메일 알림
  • 빌드 시간 추적 및 최적화
  • 레지스트리 사용량 모니터링

트러블슈팅

문제 1: 버전 충돌

증상: 같은 버전 태그가 이미 존재하는 경우

해결: 태그 생성 전 존재 여부 확인 로직 추가 (위 예시에 포함)

문제 2: 빌드 시간이 오래 걸림

해결:

  • Docker BuildKit 사용
  • 멀티스테이지 빌드로 이미지 크기 최적화
  • 빌드 캐시 활용
  • 불필요한 의존성 제거

문제 3: 러너 서버 부하

해결:

  • 여러 러너 인스턴스 구성
  • 러너 라벨로 작업 분산
  • 빌드 큐 관리

마무리

GitHub Actions를 활용한 Docker 이미지 자동 빌드 및 배포 파이프라인을 구축하면서 다음과 같은 성과를 얻었습니다:

  1. 자동화로 인한 생산성 향상: 개발자가 배포에 신경 쓸 필요 없이 코드에만 집중
  2. 형상 관리 개선: Git 태그와 이미지 태그의 일관성 확보
  3. 비용 최적화: 셀프호스팅 러너와 레지스트리로 운영 비용 절감
  4. 확장성: 서비스가 늘어나도 워크플로우만 추가하면 됨

이 파이프라인은 지속적으로 개선해 나가고 있으며, 보안 스캔, 자동 테스트, 배포 자동화 등 추가 기능을 고려하고 있습니다.


참고 자료: