[도커 30강] 27강. CI에서 Docker 빌드 테스트 파이프라인

[도커 30강] 27강. CI에서 Docker 빌드 테스트 파이프라인

도커를 팀에서 제대로 쓰기 시작하면, 로컬에서 docker build가 한 번 성공하는 것만으로는 부족하다는 사실을 금방 체감하게 됩니다. 실제로는 커밋이 들어올 때마다 동일한 방식으로 빌드되고, 테스트가 실행되고, 실패 시 배포가 자동으로 멈춰야 안정성이 확보됩니다. 즉, 도커 실무의 핵심은 "이미지 빌드 명령" 자체보다 CI 파이프라인으로 품질을 반복 가능하게 만드는 설계에 있습니다.

이번 강의에서는 GitHub Actions를 기준으로 Docker 빌드/테스트 파이프라인을 구성하는 방법을 설명합니다. 단순 YAML 예시를 넘어서, 캐시 전략, 태그 정책, 테스트 분리, 실패 지점 가시화까지 포함해 "작은 팀도 바로 적용 가능한 형태"로 정리하겠습니다.


핵심 개념

  • CI 파이프라인의 목표는 "자동화"가 아니라 일관성입니다. 누가 커밋하든 같은 기준으로 빌드/테스트가 수행되어야 합니다.
  • Docker CI는 보통 3단계로 나눕니다: 정적 검사(린트/유효성) → 이미지 빌드 → 컨테이너 기반 테스트.
  • 이미지 태그는 latest 하나로 끝내면 안 됩니다. **불변 태그(커밋 SHA, 버전 태그)**를 함께 운영해야 추적과 롤백이 쉬워집니다.
  • 캐시는 빌드 시간을 크게 줄이지만, 무조건 빠른 것이 정답은 아닙니다. 재현 가능성을 해치지 않는 범위에서 캐시를 써야 합니다.
  • 테스트 실패는 "개발자 실수"가 아니라 파이프라인이 제 역할을 한 것입니다. 실패 로그를 빠르게 읽고 수정 가능하도록 출력 구조를 설계해야 합니다.

기본 사용

예제 1) 최소 Docker CI 뼈대 만들기

mkdir -p .github/workflows
cat > .github/workflows/docker-ci.yml <<'YAML'
name: docker-ci
on:
  pull_request:
  push:
    branches: [ "main" ]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Build image
        run: |
          docker build -t myapp:ci .

      - name: Run container smoke test
        run: |
          docker run --rm myapp:ci python -V
YAML

git add .github/workflows/docker-ci.yml
git status

설명:

  • 먼저 "작게 동작하는 파이프라인"을 만든 뒤 점진적으로 확장하는 것이 안전합니다.
  • python -V 같은 아주 작은 스모크 테스트라도 추가하면, 이미지가 실행 가능한지 빠르게 검증할 수 있습니다.
  • 이 단계의 목표는 속도가 아니라 "PR마다 자동 실행" 흐름을 고정하는 것입니다.

예제 2) 캐시와 불변 태그를 포함한 빌드 단계 확장

cat > .github/workflows/docker-ci.yml <<'YAML'
name: docker-ci
on:
  pull_request:
  push:
    branches: [ "main" ]

env:
  IMAGE_NAME: ghcr.io/acme/myapp

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Login GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract tags
        id: meta
        run: |
          SHA_TAG=${GITHUB_SHA::12}
          echo "sha_tag=$SHA_TAG" >> $GITHUB_OUTPUT

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: |
            ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha_tag }}
            ${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
YAML

설명:

  • cache-from/cache-to를 쓰면 동일 레이어 재사용으로 빌드 시간을 줄일 수 있습니다.
  • latest를 유지하더라도 반드시 SHA 태그를 함께 발행해야 롤백 근거가 생깁니다.
  • PR에서는 push=false, main에서는 push=true처럼 분기해 레지스트리 오염을 줄이는 패턴이 실무에서 자주 쓰입니다.

예제 3) 컨테이너 기반 테스트를 별도 잡으로 분리

cat > docker-compose.ci.yml <<'YAML'
services:
  app:
    image: myapp:ci
    environment:
      APP_ENV: test
      DB_HOST: db
    depends_on:
      db:
        condition: service_healthy
    command: ["pytest", "-q", "tests"]

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app_test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app_test"]
      interval: 5s
      timeout: 3s
      retries: 10
YAML

cat > .github/workflows/docker-ci.yml <<'YAML'
name: docker-ci
on: [pull_request, push]

jobs:
  unit-and-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:ci .
      - name: Run integration tests in compose
        run: |
          docker compose -f docker-compose.ci.yml up --abort-on-container-exit --exit-code-from app
      - name: Cleanup
        if: always()
        run: docker compose -f docker-compose.ci.yml down -v
YAML

설명:

  • 애플리케이션과 DB를 함께 띄워 통합 테스트하면 "로컬은 됐는데 CI는 안 됨" 문제를 조기에 잡습니다.
  • --exit-code-from app를 사용하면 테스트 컨테이너의 종료코드로 잡 결과가 결정되어 실패 판단이 명확합니다.
  • if: always() 정리는 실패 상황에서도 리소스를 회수해 다음 러너 작업을 안정화합니다.

예제 4) 품질 게이트 스크립트로 실패 지점 명확화

mkdir -p scripts
cat > scripts/ci_gate.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail

echo "[1] Dockerfile lint (hadolint가 있다면 실행)"
if command -v hadolint >/dev/null 2>&1; then
  hadolint Dockerfile
else
  echo "skip: hadolint not installed"
fi

echo "[2] Build"
docker build -t myapp:ci .

echo "[3] Unit test"
docker run --rm myapp:ci pytest -q tests/unit

echo "[4] Integration test"
docker compose -f docker-compose.ci.yml up --abort-on-container-exit --exit-code-from app
docker compose -f docker-compose.ci.yml down -v

echo "OK: all gates passed"
BASH

chmod +x scripts/ci_gate.sh
bash scripts/ci_gate.sh

설명:

  • CI 로직을 YAML에만 길게 넣으면 가독성이 떨어집니다. 핵심 검증은 스크립트로 분리하면 유지보수가 쉬워집니다.
  • 단계별 번호를 출력하면 실패 지점을 팀원이 즉시 파악할 수 있어 MTTR(복구시간)이 줄어듭니다.
  • 장기적으로는 이 게이트 스크립트를 로컬 pre-push 훅에서도 재사용하면 "CI에서만 깨지는" 상황을 줄일 수 있습니다.

자주 하는 실수

실수 1) main과 PR에서 같은 강도로 레지스트리 push

  • 원인: 파이프라인을 급하게 만들면서 조건 분기를 생략함.
  • 해결: PR은 빌드/테스트만, main만 push/배포 트리거가 되도록 분리하세요.

실수 2) latest 태그만 사용

  • 원인: 태그 관리가 번거롭다는 이유로 단일 태그에 의존함.
  • 해결: SHA 기반 불변 태그를 기본으로 하고, latest는 편의 태그로만 유지하세요.

실수 3) 테스트를 호스트 환경에서 직접 실행

  • 원인: 도커를 쓰면서도 CI 테스트는 pip install 방식으로 섞어 씀.
  • 해결: 가능한 테스트를 컨테이너에서 실행해 런타임 일관성을 맞추세요.

실수 4) 실패 로그가 장황하고 핵심 원인이 안 보임

  • 원인: 모든 명령을 한 번에 실행해 어디서 실패했는지 모호함.
  • 해결: 단계별 출력, 명확한 종료코드, 핵심 에러 메시지(원인+해결 힌트)를 남기세요.

실무 패턴

실무에서 가장 안정적인 Docker CI 패턴은 "파이프라인 단순화 + 규칙 고정"입니다. 즉, 기술적으로 화려한 구성보다 실패했을 때 누구나 원인을 재현할 수 있는 구성이 중요합니다. 예를 들어 빌드 단계에서는 Dockerfile lint와 이미지 생성까지만 책임지고, 테스트 단계는 컨테이너 실행 검증에 집중시키며, 배포 단계는 main 브랜치와 태그 이벤트에서만 열어두는 식으로 역할을 분리합니다.

또 하나 중요한 패턴은 "캐시를 쓰되, 캐시를 신뢰하지 않는" 태도입니다. 캐시는 성능 도구이지 정합성 도구가 아닙니다. 주기적으로 --no-cache 빌드를 돌려 캐시 오염 문제를 점검하고, 의존성 잠금 파일(poetry.lock, package-lock.json, requirements.txt) 변경 여부에 따라 캐시 무효화를 명시하는 습관이 필요합니다.

팀 협업 관점에서는 CI 실패를 개인 실수로 몰지 않는 문화가 중요합니다. CI는 실패를 통해 위험을 알려주는 안전장치이므로, "왜 실패했는지"가 빠르게 공유될수록 팀 품질이 올라갑니다. 그래서 PR 템플릿에 "CI 실패 시 확인 순서"를 넣거나, 자주 깨지는 테스트를 별도 문서로 관리하는 것도 효과적입니다.

마지막으로, 파이프라인 결과를 배포 기록과 연결하세요. 어떤 커밋 SHA가 어떤 이미지 태그로 빌드됐고, 어떤 테스트를 통과했는지 남겨야 장애 발생 시 롤백/원인 분석이 빨라집니다. 이 추적성이 쌓일수록 도커 기반 운영은 예측 가능해지고, 배포 공포가 줄어듭니다.

오늘의 결론

한 줄 요약: Docker CI의 핵심은 자동 빌드 자체가 아니라, 불변 태그와 컨테이너 기반 테스트로 품질을 반복 가능하게 고정하는 것이다.

연습문제

  1. 현재 저장소에 docker-compose.ci.yml을 만들고 앱+DB 통합 테스트가 CI에서 실행되도록 구성해보세요.
  2. 이미지 태그를 latest + SHA 동시 발행으로 바꾸고, 배포 스크립트는 SHA 태그만 사용하도록 수정해보세요.
  3. CI 실패 로그를 읽기 쉽게 개선하기 위해 단계 번호 출력과 실패 시 정리(down -v)를 스크립트에 넣어보세요.

이전 강의 정답

26강 연습문제 해설:

  • 하드 체크 5개는 보통 필수 환경변수, latest 금지, 헬스체크 존재, 메모리 제한, 재시작 정책으로 시작하면 실전 장애를 크게 줄일 수 있습니다.
  • CI에 반영할 때는 체크 실패 시 즉시 exit 1로 종료하고, 이후 배포 잡이 실행되지 않게 needs 의존 관계를 걸어야 합니다.
  • 스모크 테스트 자동화의 핵심은 "임시 스택 기동 → 핵심 엔드포인트 확인 → 실패 시 정리 후 종료" 3단계입니다. 특히 실패 시에도 down -v가 실행되도록 if: always() 또는 trap을 넣는 구성이 정답입니다.

실습 환경/재현 정보

  • OS: Ubuntu 22.04+ (GitHub Actions runner 기준), macOS/Linux 로컬 재현 가능
  • Docker 버전: Docker Engine 24+/25+, Docker Compose v2
  • 실행 순서:
    1. Dockerfile과 테스트 코드 준비
    2. docker-compose.ci.yml로 통합 테스트 환경 정의
    3. .github/workflows/docker-ci.yml 작성
    4. PR 생성 후 자동 빌드/테스트 확인
    5. main 머지 시 이미지 push 조건 동작 확인
  • 재현 체크:
    • PR에서 이미지 빌드/테스트가 자동 실행되는가?
    • 테스트 실패 시 파이프라인이 명확히 실패하는가?
    • main에서만 레지스트리 push가 실행되는가?
    • SHA 태그 기반으로 특정 빌드를 다시 실행/롤백할 수 있는가?
    • 실패 후 리소스 정리(down -v)가 누락되지 않는가?