infra
Platform

모듈 맵

[Docker] 빌드 자동화와 이미지 태그 배포 파이프라인 구축

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 26 / 27

[Docker] 빌드 자동화와 이미지 태그 배포 파이프라인 구축

GitHub Actions와 GitLab CI에서 이미지 빌드, 테스트, 레지스트리 push를 자동화합니다

CI/CD 파이프라인에서 Docker 활용

🚨INCIDENT ALERT
HIGH

팀원이 각자 로컬에서 이미지를 빌드해 배포하면 "어떤 코드가 올라갔는지" 추적이 끊기기 쉽습니다. 문제 발생 시 롤백 기준이 모호해지고, 자격증명 노출 같은 보안 사고도 CI 로그에서 자주 발생합니다. CI/CD에서 Docker를 표준화하면 빌드 재현성, 태그 추적성, 배포 자동화를 한 번에 확보할 수 있습니다. 이 모듈은 GitHub Actions/GitLab CI에서 바로 적용 가능한 안전한 파이프라인 패턴을 다룹니다.

"이미지는 개발자 로컬에서 빌드한다"는 관행은 팀이 커질수록 문제가 됩니다. 빌드 결과가 개발자 환경마다 달라지고, 누가 언제 무엇을 배포했는지 추적이 어려워집니다. CI/CD 파이프라인에 Docker 빌드를 통합하면 모든 이미지가 동일한 환경에서 만들어지고, git 커밋과 이미지가 1:1로 연결되며, 사람의 손을 거치지 않고 자동으로 레지스트리에 push됩니다.


이번 챕터에서 배울 것

GitHub Actions와 GitLab CI를 중심으로 실무에서 바로 사용할 수 있는 Docker 파이프라인 패턴을 다룹니다. 빌드 캐시 전략과 보안 주의사항까지 포함합니다.

  • 1CI/CD에서 Docker로 빌드를 표준화하고 테스트 환경 일관성을 확보할 수 있다
  • 2GitHub Actions(docker/build-push-action)로 이미지를 빌드하고 GHCR에 push할 수 있다
  • 3GitLab CI .gitlab-ci.yml로 빌드, 테스트, push 단계 파이프라인을 구성할 수 있다
  • 4git SHA, 브랜치명, semver를 혼합한 이미지 태그 전략을 운용할 수 있다
  • 5레이어 캐시(cache-from/cache-to with registry)를 CI에서 활용할 수 있다
  • 6Docker-in-Docker(DinD)와 Docker socket 마운트 방식을 비교해 선택할 수 있다
실습 환경 준비

GitHub Actions 실습은 github.com 계정만 있으면 됩니다. GitLab CI 실습은 선택 사항이며 gitlab.com 무료 계정으로 진행할 수 있습니다.

GitHub 계정 및 레포지토리 준비

GHCR(GitHub Container Registry) 사용 — github.com에서 무료로 제공

GitLab 계정 및 프로젝트 준비 (선택)

gitlab.com 무료 티어에서 CI/CD 파이프라인 사용 가능

로컬 Docker BuildKit 확인
docker buildx version
실습용 샘플 앱 디렉토리 생성
mkdir -p ~/cicd-lab && cd ~/cicd-lab
git 레포지토리 초기화
git init && git checkout -b main
💡개념

CI/CD에서 Docker의 역할

CI/CD에서 Docker의 역할 — 코드 push를 트리거로 CI가 이미지를 빌드·테스트·태깅해 레지스트리에 push하고, CD가 그 이미지를 환경에 배포. 같은 이미지가 빌드→테스트→프로덕션을 거치므로 "내 PC에선 됐는데" 문제 없이 환경 일관성을 보장확대

왜 CI에서 이미지를 빌드해야 하는가

로컬 빌드의 문제점은 재현 불가능성입니다. 개발자 A의 맥북, 개발자 B의 리눅스 워크스테이션, 그리고 CI 서버에서 같은 Dockerfile로 빌드하더라도 Node.js 버전, 캐시 상태, 운영체제 차이로 인해 미묘하게 다른 이미지가 만들어질 수 있습니다.

CI/CD 파이프라인에 빌드를 통합하면 다음이 보장됩니다:

  • 빌드 표준화: 항상 동일한 환경(CI Runner)에서 빌드
  • git 연동: 모든 이미지가 특정 커밋과 연결되어 추적 가능
  • 자동화: PR 머지 시 자동 빌드 및 배포
  • 보안 스캔 통합: 빌드 후 자동으로 취약점 스캔 실행

이미지 태그 전략

태그는 이미지의 버전을 식별하는 핵심 정보입니다. 잘못된 태그 전략은 롤백을 불가능하게 만듭니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_26 && cd /tmp/docker/part5/exam_26

# 안티패턴: latest만 사용
docker push myapp:latest  # 이전 버전으로 돌아갈 방법 없음

# 권장 패턴 1: git SHA 기반 (추적 가능성 최대)
docker push myapp:a1b2c3d4

# 권장 패턴 2: 브랜치 + SHA 조합
docker push myapp:main-a1b2c3d4
docker push myapp:main-latest  # 브랜치의 최신 이미지

# 권장 패턴 3: semver (릴리스 이미지)
docker push myapp:1.2.3
docker push myapp:1.2
docker push myapp:1

실무에서는 **SHA 태그(추적용) + 브랜치 태그(편의용)**를 동시에 push하는 방식이 가장 일반적입니다.

💡개념

커밋 하나가 프로덕션 이미지로 배포되기까지 — 도커 이미지 파이프라인 7단계

git push 한 번. 잠시 뒤 서버에는 방금 그 커밋으로 만든 새 컨테이너가 돌고 있습니다. 이 사이에 파이프라인은 push 트리거 → 빌드 → 테스트·스캔 → 태그 → 레지스트리 push → 배포 대상 pull → 재시작·헬스체크를 순서대로 실행합니다. 일반 CI/CD와 다른 점은, 단계 사이를 흐르는 것이 코드가 아니라 하나의 도커 이미지라는 것입니다 — 빌드에서 만든 그 이미지가 태그로 식별되어 테스트·프로덕션까지 그대로 전달되므로 "내 PC에선 됐는데"가 사라집니다. 흐름을 알면 배포가 어디서 끊겼는지 단계로 좁힐 수 있습니다.

TEXT
git push (main / v1.2.3 태그)
   │
   ① 트리거           (on: push — 워크플로우/파이프라인 기동)
   │
   ② 빌드             (docker build — 레이어 캐시 재사용, 이미지 생성)
   │
   ③ 테스트·스캔      (그 이미지로 단위테스트 + trivy 취약점 스캔)
   │
   ④ 태그 부여        (git SHA=불변 추적, 브랜치/semver=사람 식별)
   │
   ⑤ 레지스트리 push  (ghcr.io/org/app:sha-a1b2c3d 업로드)
   │
   ⑥ 배포 대상 pull   (서버·쿠버네티스가 그 태그를 내려받음)
   │
   ⑦ 재시작·헬스체크  (새 이미지로 컨테이너 교체 → /health 통과)
   ▼
같은 이미지가 빌드→테스트→프로덕션을 관통

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일막히면 증상
① 트리거on: push·태그 조건에 맞으면 러너가 깨끗한 환경에서 잡을 시작브랜치·경로 필터 불일치 → 파이프라인이 아예 안 돎(실행 로그 없음)
② 빌드Dockerfile로 이미지 빌드. 변경 없는 의존성 레이어는 캐시에서 재사용문법·의존성 오류 → failed to solve. 캐시 미스로 매번 npm ci부터 = 느림
③ 테스트·스캔방금 빌드한 이미지를 그대로 실행해 테스트, trivy로 CVE 스캔테스트 실패·치명 취약점 → 게이트에서 중단(레지스트리에 안 올라감)
④ 태그커밋 해시로 불변 태그, 브랜치·버전으로 편의 태그를 동시에 부여latest만 쓰면 어떤 커밋인지 역추적·롤백 불가
⑤ push태그별로 레지스트리에 레이어 업로드(변경 레이어만)인증·권한 부족 → denied·unauthorized. packages: write 누락이 흔함
⑥ pull배포 대상이 그 태그를 pull. K8s면 imagePullPolicy·imagePullSecret 적용태그 오타·비공개 레지스트리 인증 실패 → ImagePullBackOff·ErrImagePull
⑦ 재시작·헬스새 이미지로 컨테이너를 교체하고 헬스체크로 정상 기동을 확인런타임 env·포트 문제로 기동 직후 크래시 → 빌드 성공이 실행 성공을 보장하지 않음

배포 성공은 일곱 단계가 모두 통과했다는 뜻이고, 실패하는 지점이 단계마다 다릅니다 — 파이프라인이 안 돌면 ①(트리거 조건), failed to solve면 ②(빌드), 레지스트리에 이미지가 없으면 ③④⑤(테스트 게이트·태그·인증), 서버가 못 받으면 ⑥(pull 인증·태그), 받았는데 죽으면 ⑦(런타임)입니다. 특히 흔한 착각은 ②빌드 성공을 배포 성공으로 오해하는 것 — ③에 이미지 실행 스모크 테스트를, ⑦에 헬스체크를 게이트로 넣어야 "빌드는 됐는데 배포 후 죽는" 사고를 막습니다. git SHA 태그(④)를 항상 붙이면 어느 단계에서 멈춰도 '무엇이 배포 대상이었는지'를 역추적할 수 있습니다.

💡개념

GitHub Actions 워크플로우 구성

main 브랜치에 push할 때마다 이미지를 자동으로 빌드해서 레지스트리에 올리고 싶습니다. 매번 로컬에서 docker build, docker push를 수동으로 실행하는 것은 실수가 생기고, 누가 빌드했는지 추적도 안 됩니다. GitHub Actions에 워크플로우 파일 하나를 추가하면 코드 push 시점에 자동으로 빌드, 태그, push까지 실행됩니다.

GitHub Actions 워크플로우 — main 브랜치 push를 트리거로 워크플로우가 체크아웃→docker build→태그→레지스트리 push까지 자동 실행. 수동 build·push의 실수와 추적 불가 문제를 없애고, 누가 언제 빌드했는지 실행 로그로 남김확대

docker/build-push-action으로 이미지 빌드 및 push

GitHub Actions의 공식 Docker 액션인 docker/build-push-action은 BuildKit 기반으로 멀티플랫폼 빌드, 레지스트리 캐시, GHCR push를 지원합니다.

YAML
# .github/workflows/docker-build.yml
name: Docker 이미지 빌드 및 Push

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}  # owner/repo 형태

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write  # GHCR에 push하려면 packages 권한 필요

    steps:
      - name: 코드 체크아웃
        uses: actions/checkout@v4

      - name: Docker Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: GHCR 로그인
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}  # 자동 제공되는 토큰

      - name: 이미지 메타데이터 추출 (태그 및 레이블 자동 생성)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix={{branch}}-
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - name: 빌드 및 Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}  # PR은 빌드만, push는 배포
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

metadata-action이 생성하는 태그 예시

위 워크플로우에서 main 브랜치에 커밋 a1b2c3d를 push하면:

ghcr.io/myorg/myapp:main
ghcr.io/myorg/myapp:main-a1b2c3d

v1.2.3 태그를 달면:

ghcr.io/myorg/myapp:1.2.3
ghcr.io/myorg/myapp:1.2
ghcr.io/myorg/myapp:1

멀티 아키텍처 빌드 (AMD64 + ARM64)

Apple Silicon 맥 사용자가 늘면서 ARM64 이미지를 함께 배포하는 것이 중요해졌습니다:

YAML
      - name: QEMU 설정 (크로스 플랫폼 빌드용)
        uses: docker/setup-qemu-action@v3

      - name: Docker Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: 멀티 아키텍처 빌드 및 Push
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
💡개념

GitLab CI 파이프라인 구성

회사가 GitLab을 씁니다. GitHub Actions 예시는 많은데 .gitlab-ci.yml로 Docker 이미지를 빌드하고 GitLab Container Registry에 push하는 방법은 구조가 다릅니다. GitLab CI는 자체 레지스트리와 CI 변수가 미리 연결되어 있어서, 인증 토큰 설정 없이도 GitLab이 제공하는 사전 정의 변수만 쓰면 됩니다.

GitLab CI 파이프라인 — .gitlab-ci.yml로 이미지를 빌드해 GitLab Container Registry에 push하는 구조. GitLab은 자체 레지스트리와 CI 변수(CI_REGISTRY·CI_REGISTRY_IMAGE 등)가 미리 연결돼 있어 별도 인증 토큰 설정 없이 사전 정의 변수만으로 push 가능확대

.gitlab-ci.yml 전체 구성

GitLab CI는 사전 정의 변수(CI_REGISTRY_IMAGE, CI_COMMIT_SHA 등)를 자동으로 제공하여 편리하게 파이프라인을 구성할 수 있습니다.

YAML
# .gitlab-ci.yml
stages:
  - build
  - test
  - push
  - deploy

variables:
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  IMAGE_BRANCH: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

# 빌드 스테이지: 이미지 빌드 및 임시 저장
build-image:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    # 레지스트리 캐시에서 이전 레이어 가져오기
    - docker pull $IMAGE_BRANCH || true
    - docker build
        --cache-from $IMAGE_BRANCH
        --tag $IMAGE_TAG
        --tag $IMAGE_BRANCH
        --label "git-commit=$CI_COMMIT_SHA"
        --label "git-branch=$CI_COMMIT_REF_NAME"
        .
    # 테스트용 임시 태그로 push
    - docker push $IMAGE_TAG
    - docker push $IMAGE_BRANCH
  only:
    - branches
    - merge_requests

# 테스트 스테이지: 빌드된 이미지로 테스트 실행
test-unit:
  stage: test
  image: $IMAGE_TAG
  script:
    - npm test
  dependencies:
    - build-image

test-integration:
  stage: test
  image: $IMAGE_TAG
  services:
    - postgres:15-alpine
    - redis:7-alpine
  variables:
    DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/testdb"
    REDIS_URL: "redis://redis:6379"
    POSTGRES_PASSWORD: "postgres"
    POSTGRES_DB: "testdb"
  script:
    - npm run test:integration
  dependencies:
    - build-image

# push 스테이지: main 브랜치 머지 시 프로덕션 태그 push
push-production:
  stage: push
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker pull $IMAGE_TAG
    - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

# semver 태그 릴리스 (v* 태그 push 시 자동 실행)
push-release:
  stage: push
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker pull $IMAGE_TAG
    - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
  only:
    - /^v\d+\.\d+\.\d+$/  # v1.2.3 형식 태그에만 실행
  except:
    - branches
💡개념

CI에서 레이어 캐시 활용

레이어 캐시 전략 — CI는 매번 새 러너에서 빌드해 캐시가 없으면 의존성 설치에 23분 소요. 의존성 파일(package.json)을 소스보다 먼저 COPY해 레이어를 분리하고 registry/gha 캐시를 재사용하면, 코드만 바뀐 빌드를 1020초로 단축확대

캐시 전략별 빌드 시간 비교

레이어 캐시 없이 빌드하면 Node.js 의존성 설치에만 23분이 소요될 수 있습니다. 캐시를 활용하면 코드만 변경된 경우 1020초로 단축됩니다.

캐시 없음:
  FROM node:20-alpine      → 다운로드 30초
  COPY package*.json ./    → 1초
  RUN npm ci               → 2분 30초  ← 매번 재실행
  COPY . .                 → 1초
  RUN npm run build        → 30초
  총: ~3분 30초

레지스트리 캐시 사용:
  FROM node:20-alpine      → 캐시 HIT
  COPY package*.json ./    → 캐시 HIT (package.json 변경 없음)
  RUN npm ci               → 캐시 HIT ← 이 레이어 재사용!
  COPY . .                 → 캐시 MISS (코드 변경)
  RUN npm run build        → 30초
  총: ~35초

BuildKit inline 캐시 vs registry 캐시

YAML
# 방법 1: inline 캐시 (이미지에 캐시 메타데이터 포함)
# 장점: 별도 캐시 이미지 불필요
# 단점: 프로덕션 이미지 크기 증가, mode=max 미지원
- name: 빌드 (inline 캐시)
  uses: docker/build-push-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/org/app:latest
    cache-to: type=inline

# 방법 2: registry 캐시 (별도 캐시 태그 사용) — 권장
# 장점: 프로덕션 이미지와 분리, mode=max로 모든 중간 레이어 캐싱
# 단점: 별도 캐시 이미지 태그 관리 필요
- name: 빌드 (registry 캐시)
  uses: docker/build-push-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/org/app:buildcache
    cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

# 방법 3: GitHub Actions 캐시 (actions/cache 연동)
- name: 빌드 (gha 캐시)
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Dockerfile 캐시 최적화 — CI를 고려한 레이어 순서

Dockerfile
# CI 최적화 Dockerfile
FROM node:20-alpine AS deps

WORKDIR /app

# 1단계: 의존성 파일만 먼저 복사 (자주 변경 안 됨)
COPY package.json package-lock.json ./

# 2단계: 의존성 설치 (package.json 변경 시에만 레이어 무효화)
RUN npm ci --only=production

# --
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

# 3단계: 소스코드 복사 (자주 변경됨 — 최대한 뒤에 배치)
COPY . .
RUN npm run build

# --
FROM node:20-alpine AS runtime

WORKDIR /app

# 프로덕션 의존성만 복사
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/server.js"]
💡개념

DinD vs Docker Socket 마운트

CI 파이프라인을 컨테이너에서 실행하고 있는데, 파이프라인 안에서 docker build를 쓰려니 "Cannot connect to the Docker daemon" 오류가 납니다. 컨테이너 안에서 Docker를 쓰는 방법이 두 가지 있는데, 어떤 걸 써야 하는지 그리고 각각의 보안 리스크가 무엇인지를 모르면 판단이 어렵습니다. 잘못 선택하면 호스트 서버 전체가 노출될 수 있습니다.

두 가지 접근 방식 비교

CI Runner 컨테이너 안에서 Docker 명령을 실행하는 방법은 크게 두 가지입니다.

CI에서 Docker 실행 — 방법1 DinD(CI Runner 안에 독립 docker:dind 데몬, 완전 격리·--privileged 필요), 방법2 Socket 마운트(호스트 docker.sock 공유, 빠르지만 보안 위험)확대

YAML
# GitLab CI에서 DinD 설정 (권장)
build-dind:
  image: docker:24-cli
  services:
    - name: docker:24-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: "1"
  script:
    - docker build -t myapp:latest .

# Docker socket 마운트 (빠르지만 보안 위험 있음)
# gitlab-runner config.toml에서 설정:
# [[runners.docker.volumes]]
# "/var/run/docker.sock:/var/run/docker.sock"
항목DinDSocket 마운트
격리 수준높음 (독립 데몬)낮음 (호스트 공유)
빌드 속도느림 (데몬 시작 오버헤드)빠름
레이어 캐시 공유없음 (매번 새 데몬)있음 (호스트 캐시 공유)
보안 위험--privileged 필요호스트 Docker 전체 접근 가능
권장 환경보안 중요 환경신뢰할 수 있는 내부 CI
CI/CD Docker 판단 — 태그·캐시·실행방식을 어떻게 고르나
이미지 태그 전략latest 단독 금지 — 롤백·추적 불가. git SHA(불변)로 배포 식별 + 시맨틱 버전(v1.2.3)으로 사람 식별 + 환경 태그. '무엇이 배포됐는지'를 SHA로 항상 역추적 가능하게
CI 빌드가 매번 느리다레이어 캐시 미활용 — registry cache(--cache-from/to) 또는 BuildKit inline 캐시. 의존성 설치를 소스 복사 앞 레이어로 분리. 'COPY package.json → install → COPY 나머지' 순서가 핵심
DinD vs Docker Socket 마운트보안 중요·격리 필요면 DinD(또는 Kaniko/Buildah, 데몬리스). 신뢰된 내부 CI는 socket 마운트가 빠름. 'socket 마운트=호스트 도커 장악 위험'이라 공개 러너엔 금물
캐시 무효화가 자꾸 일어난다자주 바뀌는 것(소스)을 안 바뀌는 것(의존성) 뒤에 둬서. 빌드 인자·타임스탬프·잦은 파일이 상위 레이어에 있으면 이후 전부 재빌드. '변하는 건 아래로'
멀티 아키텍처(ARM+AMD)가 필요buildx로 --platform linux/amd64,linux/arm64. Apple Silicon 개발 + x86 서버 배포 환경에서 필수. 단 빌드 시간·복잡도 증가하니 실제 대상 아키텍처만
빌드는 성공인데 배포 후 실패CI에서 이미지 스캔(trivy)·스모크 테스트를 게이트로. 'push 전에 docker run으로 기동 확인'. 빌드 성공≠동작 — 실행 검증을 파이프라인에 넣어라

기본 실습

1샘플 Node.js 앱과 Dockerfile 작성

실습용 최소 Node.js 앱을 만듭니다.

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_5 && cd /tmp/docker/part4/exam_5

# CI/CD 파이프라인 실습용 기본 구조 생성
mkdir -p app .github/workflows

# 기본 Dockerfile 생성
cat > app/Dockerfile << 'EOF'
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
EOF

# .dockerignore 생성
cat > app/.dockerignore << 'EOF'
node_modules
npm-debug.log
.git
.github
*.test.js
EOF

이제 실습을 진행합니다.

로컬 터미널
# package.json 생성
cat > ~/cicd-lab/app/package.json << 'EOF'
{
  "name": "cicd-demo",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "test": "echo 'Tests passed' && exit 0"
  }
}
EOF

# 서버 코드 생성
cat > ~/cicd-lab/app/server.js << 'EOF'
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    status: 'ok',
    version: process.env.APP_VERSION || '1.0.0',
    commit: process.env.GIT_COMMIT || 'unknown'
  }));
});
server.listen(3000, () => console.log('서버 시작: http://localhost:3000'));
EOF

# Dockerfile 생성
cat > ~/cicd-lab/app/Dockerfile << 'EOF'
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ARG GIT_COMMIT=unknown
ARG APP_VERSION=dev
ENV GIT_COMMIT=${GIT_COMMIT}
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000
CMD ["node", "server.js"]
EOF

git SHA를 빌드 인수로 전달하여 이미지에 커밋 정보를 포함시킵니다:

로컬 터미널
cd ~/cicd-lab/app
git init && git add -A && git commit -m "초기 커밋"
GIT_SHA=$(git rev-parse --short HEAD)
docker build \
  --build-arg GIT_COMMIT=${GIT_SHA} \
  --build-arg APP_VERSION=1.0.0 \
  -t cicd-demo:${GIT_SHA} \
  -t cicd-demo:latest \
  .
mkdir -p ~/cicd-lab/app && cd ~/cicd-lab/app
2GitHub Actions 워크플로우 파일 생성
로컬 터미널
cat > ~/cicd-lab/app/.github/workflows/docker-build.yml << 'EOF'
name: Docker 빌드 및 GHCR Push

on:
  push:
    branches: [main]
    tags: ['v*.*.*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Docker Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: GHCR 로그인
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 이미지 메타데이터 추출
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=sha-
            type=semver,pattern={{version}}

      - name: 빌드 및 Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            GIT_COMMIT=${{ github.sha }}
            APP_VERSION=${{ github.ref_name }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
EOF

echo "워크플로우 파일 생성 완료"
cat ~/cicd-lab/app/.github/workflows/docker-build.yml
mkdir -p ~/cicd-lab/app/.github/workflows
3GitLab CI 파이프라인 파일 생성
로컬 터미널
cat > ~/cicd-lab/app/.gitlab-ci.yml << 'EOF'
stages:
  - build
  - test
  - release

variables:
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_SHA: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  IMAGE_BRANCH: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

build:
  stage: build
  image: docker:24-cli
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker pull $IMAGE_BRANCH || true
    - docker build
        --cache-from $IMAGE_BRANCH
        --tag $IMAGE_SHA
        --tag $IMAGE_BRANCH
        --build-arg GIT_COMMIT=$CI_COMMIT_SHA
        --build-arg APP_VERSION=$CI_COMMIT_TAG
        .
    - docker push $IMAGE_SHA
    - docker push $IMAGE_BRANCH

test:
  stage: test
  image: $IMAGE_SHA
  script:
    - npm test
  needs: [build]

release:
  stage: release
  image: docker:24-cli
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker pull $IMAGE_SHA
    - docker tag $IMAGE_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
  only:
    - /^v\d+\.\d+\.\d+$/
  except:
    - branches
EOF

echo "GitLab CI 파일 생성 완료"
cd ~/cicd-lab/app
4빌드 캐시 효과 측정

캐시 없는 빌드와 캐시 있는 빌드의 시간 차이를 측정합니다:

로컬 터미널
# 캐시 없이 빌드 (첫 빌드 또는 --no-cache)
time docker build --no-cache -t cicd-demo:nocache .

# 코드만 변경 후 캐시 있는 빌드
echo "// 코드 변경" >> server.js
time docker build -t cicd-demo:withcache .

# 레이어 캐시 상태 확인
docker history cicd-demo:withcache

# 빌드 캐시 목록 확인
docker buildx du

의존성 파일(package.json)이 변경되지 않으면 npm ci 레이어가 캐시에서 재사용되어 빌드 시간이 크게 단축됩니다.

cd ~/cicd-lab/app
🔍실행 후 확인할 것
  • docker inspect cicd-demo:${GIT_SHA} 실행 후 Config.Labels 또는 Config.Env에서 GIT_COMMIT 값이 실제 커밋 해시와 일치하는지 먼저 확인 — 값이 "unknown"이면 --build-arg 전달이 누락된 것
  • 빌드 소요 시간 기준: package.json이 변경되지 않은 재빌드는 30초 미만이어야 캐시가 효과적으로 작동하는 것 — 첫 빌드(캐시 없음)와 두 번째 빌드(코드만 변경) 시간 차이가 2분 이상이면 Dockerfile 레이어 순서 재검토 필요
  • docker history cicd-demo:latest --no-trunc 출력에서 GIT_COMMIT 값이 보이는지 확인 — ARG로 전달한 시크릿이 history에 노출되면 보안 위험. 일반 버전 정보(APP_VERSION)는 노출되어도 무방하지만 API 키·패스워드는 반드시 --secret 마운트 방식으로

트러블슈팅

GHCR 또는 Docker Hub 로그인이 실패하는 오류입니다. CI 환경에서 자주 발생합니다.

GitHub Actions 원인: GITHUB_TOKENpackages: write 권한이 누락되었거나, 레포지토리 Settings > Actions > General에서 워크플로우 권한이 읽기 전용으로 설정된 경우입니다.

YAML
# 해결: jobs 레벨에서 permissions 명시
jobs:
  build:
    permissions:
      contents: read
      packages: write  # 이 줄이 반드시 필요

GitLab CI 원인: CI_REGISTRY_USER, CI_REGISTRY_PASSWORD 변수가 제공되지 않는 경우는 GitLab CI의 container registry 기능이 비활성화되었을 때입니다. Project Settings > General > Visibility에서 Container Registry가 활성화되어 있는지 확인합니다.

멀티 아키텍처 빌드 시 buildx 드라이버가 기본 docker 드라이버로 설정되어 있을 때 발생합니다.

Docker
# 현재 buildx 드라이버 확인
docker buildx ls

# docker-container 드라이버로 새 빌더 생성
docker buildx create --name mybuilder --driver docker-container --use

# 빌더 부트스트랩 (BuildKit 컨테이너 시작)
docker buildx inspect --bootstrap

# 이제 멀티 플랫폼 빌드 가능
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest .

GitHub Actions에서는 docker/setup-buildx-action@v3을 사용하면 자동으로 올바른 드라이버가 설정됩니다.

Dockerfile ARG로 받은 시크릿이 docker history나 빌드 로그에 노출되는 문제입니다.

Dockerfile
# 위험: ARG 값이 이미지 레이어에 노출될 수 있음
ARG DATABASE_PASSWORD
RUN echo $DATABASE_PASSWORD  # 절대 금지!

# 안전: BuildKit --secret 마운트 사용 (파일 시스템에만 일시적으로 존재)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=db_password \
    DB_PASS=$(cat /run/secrets/db_password) && \
    ./setup-db.sh
Docker
# CI에서 BuildKit secret 전달
docker build \
  --secret id=db_password,env=DATABASE_PASSWORD \
  -t myapp:latest .

또한 .dockerignore.env, *.pem, *credentials* 등을 반드시 추가하여 빌드 컨텍스트에서 제외합니다.

GitLab CI에서 DinD 서비스 연결에 실패하는 경우입니다.

YAML
# 잘못된 설정: TLS 없는 DinD에 TLS 연결 시도
variables:
  DOCKER_HOST: tcp://docker:2376  # TLS 포트
  DOCKER_TLS_CERTDIR: "/certs"

# 올바른 설정 1: TLS 사용 (권장)
variables:
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_CERT_PATH: "/certs/client"
  DOCKER_TLS_VERIFY: "1"
services:
  - name: docker:24-dind
    variables:
      DOCKER_TLS_CERTDIR: "/certs"

# 올바른 설정 2: TLS 없이 (개발 환경)
variables:
  DOCKER_HOST: tcp://docker:2375
  DOCKER_TLS_CERTDIR: ""
services:
  - docker:24-dind --insecure-registry registry.example.com

심화 — 캐시가 있는데도 CI가 매번 처음부터 빌드할 때

💡개념

심화: BuildKit 캐시 백엔드의 내부 동작 — 왜 CI에서 cache-from이 안 먹나

cache-from·cache-to를 넣었는데도 CI 빌드가 매번 npm ci부터 다시 도는 일이 흔합니다. 캐시 옵션을 켜는 것과 캐시가 실제로 재사용되는 것은 다른 이야기라, BuildKit이 레이어 캐시를 어떻게 다루는지 한 겹 더 들여다봐야 합니다.

  • 캐시는 내용으로 식별됩니다: BuildKit은 각 레이어를 명령과 입력(COPY 대상 파일 등)의 해시로 식별합니다. cache-from은 원격에 저장된 캐시 매니페스트를 받아와 지금 빌드하려는 레이어의 키와 맞춰보고, 일치하면 재빌드 없이 가져옵니다. 그래서 의존성 파일을 소스보다 먼저 COPY해 키를 안정시키는 것이 캐시 히트의 전제입니다.
  • 빌더 드라이버가 export를 좌우합니다: 기본 docker 빌더 드라이버는 이미지 스토어에 직접 붙어 있어 type=registry 캐시의 mode=max export를 지원하지 않습니다. docker-container 드라이버 빌더(GitHub Actions의 setup-buildx-action이 만들어 주는 빌더)에서 빌드해야 모든 중간 레이어가 레지스트리로 나갑니다. docker buildx ls로 현재 드라이버를 확인하세요.
  • gha 캐시는 scope로 격리됩니다: type=gha는 브랜치 단위 scope로 나뉘어, PR 빌드가 main 브랜치가 쌓아 둔 캐시를 그대로 보지 못할 수 있습니다. 캐시가 브랜치마다 새로 시작되는 것처럼 보이면 scope 격리를 의심합니다.
  • 첫 빌드는 원래 미스입니다: 캐시가 비어 있는 최초 1회는 당연히 전부 빌드합니다. 캐시 효과는 두 번째 빌드부터 측정해야 합니다.

정리하면 캐시가 안 먹을 땐 옵션 문자열이 아니라 빌더 드라이버·scope·레이어 순서를 봐야 합니다.

상황: 워크플로우에 cache-from: type=registry와 cache-to: ...,mode=max를 넣었는데도, 매 빌드가 의존성 설치부터 전부 다시 돌아 3분씩 걸립니다. 로그 어디에도 CACHED 표시가 없습니다.

원인: 빌드를 기본 docker 빌더 드라이버로 돌리고 있었습니다. 이 드라이버는 registry 캐시의 mode=max export를 지원하지 않아, cache-to가 실제로 레지스트리에 저장되지 않습니다. 저장이 안 되니 다음 빌드의 cache-from도 가져올 것이 없어 항상 전부 미스가 됩니다.

진단: docker buildx ls로 현재 빌더의 드라이버가 docker인지 docker-container인지 확인합니다. 빌드 로그에 캐시 매니페스트를 importing/exporting하는 줄이 있는지, 레지스트리에 buildcache 태그가 실제로 push됐는지도 봅니다. 이 줄이 없으면 export 자체가 일어나지 않은 것입니다.

해결: docker/setup-buildx-action으로 docker-container 드라이버 빌더를 만들어 그 위에서 빌드하고, cache-to에 mode=max를 유지합니다. 그러면 두 번째 빌드부터 변경 없는 레이어가 재사용됩니다. gha 캐시를 쓴다면 브랜치 scope가 갈리지 않는지 확인하고, 의존성 파일을 소스보다 먼저 COPY해 캐시 키가 소스 변경마다 깨지지 않도록 Dockerfile 레이어 순서도 함께 점검합니다(대규모 빌드 속도를 10배 끌어올리는 캐시 튜닝 가이드).


실무 맥락

💼
실무 맥락팀 내 '이미지는 로컬에서 빌드해 Slack으로 공유' 관행을 CI/CD로 전환하는 과정
현업 패턴

초기 스타트업에서는 개발자가 로컬에서 이미지를 빌드하고 Docker Hub에 push한 뒤 동료에게 태그를 알려주는 방식이 흔합니다. 팀이 3~4명일 때는 작동하지만 규모가 커지면 다음 문제가 생깁니다.

문제 1: "내 맥에서는 됐는데" — 개발자 A가 Node.js 18로 빌드했지만 개발자 B는 Node.js 20이 설치되어 있어 다른 이미지가 만들어집니다. CI Runner는 항상 동일한 환경을 보장합니다.

문제 2: 배포 추적 불가 — "어제 배포한 버전이 뭐야?"라는 질문에 답할 수 없습니다. git SHA 태그를 사용하면 docker inspect myapp:a1b2c3d로 정확히 어떤 코드가 배포되었는지 확인할 수 있습니다.

전환 순서: 먼저 기존 로컬 빌드와 동일한 CI 파이프라인을 만들어 병행 운영합니다. CI 빌드 결과가 안정적임을 확인한 후 로컬 빌드를 금지하는 팀 규칙을 도입합니다. 마지막으로 레지스트리 접근 권한을 CI 서비스 계정에만 부여하여 개발자가 직접 push할 수 없도록 합니다.

캐시 전략 도입: 처음에는 캐시 없이 시작해도 됩니다. 빌드 시간이 3분을 넘기 시작하면 레지스트리 캐시를 도입합니다. Dockerfile을 캐시 친화적으로 리팩터링(의존성 파일 먼저 COPY)하는 것만으로도 50% 이상 시간을 단축할 수 있습니다.


핵심 요약

개념명령/설정설명
GHCR 로그인docker/login-action + GITHUB_TOKENGitHub Actions에서 자동 인증
이미지 태그 생성docker/metadata-action브랜치, SHA, semver 태그 자동 생성
빌드 및 Pushdocker/build-push-actionBuildKit 기반 이미지 빌드 및 레지스트리 push
레지스트리 캐시cache-from/cache-to: type=registry이전 빌드 레이어를 레지스트리에서 재사용
GitLab 빌트인 변수CI_REGISTRY_IMAGE, CI_COMMIT_SHAGitLab이 자동 제공하는 레지스트리 및 커밋 정보
DinDdocker:24-dind 서비스CI 컨테이너 내 독립 Docker 데몬 실행
멀티 아키텍처platforms: linux/amd64,linux/arm64AMD64와 ARM64 동시 빌드
시크릿 보호--secret id=key,env=VARBuildKit secret 마운트로 레이어에 노출 방지

명령어·단축키 빠른 참조

파이프라인이 CI 안에서 실제로 실행하는 docker CLI 명령을 모았습니다(위 표는 GitHub Actions/GitLab 설정, 아래 표는 러너에서 치는 명령).

명령어/단축키용도자주 쓰는 예
docker login레지스트리 인증(CI에선 토큰 stdin)echo $TOKEN | docker login ghcr.io -u USER --password-stdin
docker build파이프라인에서 이미지 빌드docker build -t myapp:$GIT_SHA .
docker tag하나의 이미지에 여러 태그 부여docker tag myapp:$GIT_SHA myapp:main-latest
docker push레지스트리에 이미지 업로드docker push myapp:1.2.3 (불변 태그 우선)
docker pull배포 대상에서 이미지 내려받기docker pull myapp:main-a1b2c3d4
docker buildx ls빌더 인스턴스·지원 플랫폼 목록docker buildx ls
docker buildx create멀티아키텍처용 빌더 생성docker buildx create --name mybuilder --driver docker-container --use
docker buildx inspect --bootstrap빌더 기동 및 플랫폼 확인docker buildx inspect --bootstrap
docker buildx build --platformAMD64/ARM64 동시 빌드·pushdocker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --push .
docker build --secret빌드 시크릿을 레이어에 남기지 않기docker build --secret id=db_password,env=DB_PASSWORD .
docker history캐시 재사용·레이어 확인docker history cicd-demo:withcache
docker buildx du빌드 캐시 디스크 사용량docker buildx du
DOCKER_BUILDKIT=1CI 러너에서 BuildKit 강제DOCKER_BUILDKIT=1 docker build .

관련 모듈로 더 깊이:

다음 모듈에서는 무중단 배포와 스케일아웃을 위한 실전 컨테이너 아키텍처 패턴(Blue-Green, Rolling, Canary)을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

GitHub Actions에서 docker/build-push-action을 사용할 때 레지스트리 캐시를 재사용하려면 어떤 옵션을 설정해야 하는가?

Q2

Docker-in-Docker(DinD) 방식과 Docker socket 마운트 방식의 가장 큰 차이점은?

Q3

CI/CD에서 이미지 태그 전략으로 가장 권장되는 방식은?

Q4

GitLab CI 파이프라인에서 빌드한 이미지를 프로젝트의 Container Registry에 push하려 합니다. 이미지 태그를 하드코딩하지 않고 레지스트리 주소를 자동으로 참조하려면 어떤 변수를 사용해야 하나요?

Q5

CI 환경에서 Docker 빌드 시 secrets(API 키, 비밀번호)를 안전하게 다루는 방법으로 올바른 것은?

Q6

CI 러너는 매번 깨끗한 환경에서 시작해 로컬 빌드 캐시가 없어 Docker 빌드가 느리다. 레지스트리/원격 레이어 캐시를 재사용하면 빨라지는 이유는?

Q7

[심화] CI 러너에서 cache-from·cache-to에 type=registry를 지정했는데도 mode=max 캐시가 레지스트리에 export되지 않는다. 가장 흔한 근본 원인은?

Q8

[심화] CI 빌드 로그에 CACHED가 하나도 없고 npm ci가 매번 처음부터 실행된다. cache-from과 cache-to는 설정돼 있다. 가장 먼저 확인할 것은?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요