[도커 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의 핵심은 자동 빌드 자체가 아니라, 불변 태그와 컨테이너 기반 테스트로 품질을 반복 가능하게 고정하는 것이다.
연습문제
- 현재 저장소에
docker-compose.ci.yml을 만들고 앱+DB 통합 테스트가 CI에서 실행되도록 구성해보세요. - 이미지 태그를
latest+SHA동시 발행으로 바꾸고, 배포 스크립트는 SHA 태그만 사용하도록 수정해보세요. - 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
- 실행 순서:
- Dockerfile과 테스트 코드 준비
docker-compose.ci.yml로 통합 테스트 환경 정의.github/workflows/docker-ci.yml작성- PR 생성 후 자동 빌드/테스트 확인
- main 머지 시 이미지 push 조건 동작 확인
- 재현 체크:
- PR에서 이미지 빌드/테스트가 자동 실행되는가?
- 테스트 실패 시 파이프라인이 명확히 실패하는가?
- main에서만 레지스트리 push가 실행되는가?
- SHA 태그 기반으로 특정 빌드를 다시 실행/롤백할 수 있는가?
- 실패 후 리소스 정리(
down -v)가 누락되지 않는가?