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로 이미지 빌드 및 GHCR push (docker/build-push-action)
- 3GitLab CI .gitlab-ci.yml 파이프라인 구성 — 빌드, 테스트, push 단계
- 4이미지 태그 전략 — git SHA, 브랜치명, semver 혼합 운용
- 5레이어 캐시를 CI에서 활용 (cache-from/cache-to with registry)
- 6Docker-in-Docker(DinD) vs 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하는 방식이 가장 일반적입니다.
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 명령을 실행하는 방법은 크게 두 가지입니다.
┌─────────────────────────────────────────────────────┐
│ 방법 1: Docker-in-Docker (DinD) │
│ │
│ CI Runner 컨테이너 │
│ ┌─────────────────────────────┐ │
│ │ docker:dind 서비스 │ │
│ │ (독립 Docker 데몬 실행) │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ 빌드된 이미지/컨테이너 │ │ │
│ │ └──────────────────────┘ │ │
│ └─────────────────────────────┘ │
│ 특징: 완전 격리, --privileged 필요 │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 방법 2: Docker Socket 마운트 │
│ │
│ 호스트 │
│ /var/run/docker.sock ←─────────────────────┐ │
│ CI Runner 컨테이너 │ │
│ 호스트 Docker 데몬 ┌───────────────────┘ │
│ ┌──────────────────┐ │ -v /var/run/docker.sock │
│ │ 모든 컨테이너 │◄───┘ :/var/run/docker.sock │
│ └──────────────────┘ │
│ 특징: 빠름, 호스트 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- 커밋 SHA 기반 태그로 이미지와 소스 버전을 1:1 추적할 수 있는가?
- CI에서 로그인 권한/시크릿 주입이 실패 없이 동작하고 로그 노출이 없는가?
- cache-from/cache-to 설정으로 재빌드 시간이 실제로 단축됐는가?
트러블슈팅
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
실무 맥락
초기 스타트업에서는 개발자가 로컬에서 이미지를 빌드하고 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 마운트로 레이어에 노출 방지 |