CI/CD 파이프라인에서 Docker 활용
팀원이 각자 로컬에서 이미지를 빌드해 배포하면 "어떤 코드가 올라갔는지" 추적이 끊기기 쉽습니다. 문제 발생 시 롤백 기준이 모호해지고, 자격증명 노출 같은 보안 사고도 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 무료 계정으로 진행할 수 있습니다.
GHCR(GitHub Container Registry) 사용 — github.com에서 무료로 제공
gitlab.com 무료 티어에서 CI/CD 파이프라인 사용 가능
docker buildx versionmkdir -p ~/cicd-lab && cd ~/cicd-labgit init && git checkout -b mainCI/CD에서 Docker의 역할
확대
왜 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에선 됐는데"가 사라집니다. 흐름을 알면 배포가 어디서 끊겼는지 단계로 좁힐 수 있습니다.
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까지 실행됩니다.
확대
docker/build-push-action으로 이미지 빌드 및 push
GitHub Actions의 공식 Docker 액션인 docker/build-push-action은 BuildKit 기반으로 멀티플랫폼 빌드, 레지스트리 캐시, GHCR push를 지원합니다.
# .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 이미지를 함께 배포하는 것이 중요해졌습니다:
- 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.yml 전체 구성
GitLab CI는 사전 정의 변수(CI_REGISTRY_IMAGE, CI_COMMIT_SHA 등)를 자동으로 제공하여 편리하게 파이프라인을 구성할 수 있습니다.
# .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에서 레이어 캐시 활용
확대
캐시 전략별 빌드 시간 비교
레이어 캐시 없이 빌드하면 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 캐시
# 방법 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를 고려한 레이어 순서
# 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 명령을 실행하는 방법은 크게 두 가지입니다.
확대
# 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"
| 항목 | DinD | Socket 마운트 |
|---|---|---|
| 격리 수준 | 높음 (독립 데몬) | 낮음 (호스트 공유) |
| 빌드 속도 | 느림 (데몬 시작 오버헤드) | 빠름 |
| 레이어 캐시 공유 | 없음 (매번 새 데몬) | 있음 (호스트 캐시 공유) |
| 보안 위험 | --privileged 필요 | 호스트 Docker 전체 접근 가능 |
| 권장 환경 | 보안 중요 환경 | 신뢰할 수 있는 내부 CI |
기본 실습
실습용 최소 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/appcat > ~/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/workflowscat > ~/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캐시 없는 빌드와 캐시 있는 빌드의 시간 차이를 측정합니다:
# 캐시 없이 빌드 (첫 빌드 또는 --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_TOKEN의 packages: write 권한이 누락되었거나, 레포지토리 Settings > Actions > General에서 워크플로우 권한이 읽기 전용으로 설정된 경우입니다.
# 해결: 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 드라이버로 설정되어 있을 때 발생합니다.
# 현재 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나 빌드 로그에 노출되는 문제입니다.
# 위험: 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
# CI에서 BuildKit secret 전달
docker build \
--secret id=db_password,env=DATABASE_PASSWORD \
-t myapp:latest .
또한 .dockerignore에 .env, *.pem, *credentials* 등을 반드시 추가하여 빌드 컨텍스트에서 제외합니다.
GitLab CI에서 DinD 서비스 연결에 실패하는 경우입니다.
# 잘못된 설정: 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배 끌어올리는 캐시 튜닝 가이드).
실무 맥락
초기 스타트업에서는 개발자가 로컬에서 이미지를 빌드하고 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_TOKEN | GitHub Actions에서 자동 인증 |
| 이미지 태그 생성 | docker/metadata-action | 브랜치, SHA, semver 태그 자동 생성 |
| 빌드 및 Push | docker/build-push-action | BuildKit 기반 이미지 빌드 및 레지스트리 push |
| 레지스트리 캐시 | cache-from/cache-to: type=registry | 이전 빌드 레이어를 레지스트리에서 재사용 |
| GitLab 빌트인 변수 | CI_REGISTRY_IMAGE, CI_COMMIT_SHA | GitLab이 자동 제공하는 레지스트리 및 커밋 정보 |
| DinD | docker:24-dind 서비스 | CI 컨테이너 내 독립 Docker 데몬 실행 |
| 멀티 아키텍처 | platforms: linux/amd64,linux/arm64 | AMD64와 ARM64 동시 빌드 |
| 시크릿 보호 | --secret id=key,env=VAR | BuildKit 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 --platform | AMD64/ARM64 동시 빌드·push | docker 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=1 | CI 러너에서 BuildKit 강제 | DOCKER_BUILDKIT=1 docker build . |
관련 모듈로 더 깊이:
- 대규모 빌드 속도를 10배 끌어올리는 캐시 튜닝 가이드 — CI 빌드 시간을 좌우하는 registry 캐시·시크릿 마운트의 내부 동작
- 사용하지 않는 이미지 정리와 최적의 태그 아카이빙 전략 — 파이프라인이 쏟아내는 태그를 정리하고 롤백 가능하게 만드는 태그 전략
- 사내 프라이빗 레지스트리 구축과 안전한 이미지 관리 방법 — 파이프라인이 push할 사내 레지스트리를 직접 구축하고 인증하는 법
다음 모듈에서는 무중단 배포와 스케일아웃을 위한 실전 컨테이너 아키텍처 패턴(Blue-Green, Rolling, Canary)을 다룹니다.