“이제와서 시작하는 Docker 마스터하기” CI/CD(지속적 통합/지속적 배포)는 현대 소프트웨어 개발의 핵심입니다. Docker를 CI/CD 파이프라인에 통합하면 일관된 빌드와 배포가 가능해집니다. 이번 편에서는 주요 CI/CD 플랫폼에서 Docker를 활용하는 방법을 알아보겠습니다.
CI/CD와 Docker의 시너지
Docker가 CI/CD에 주는 이점
- 환경 일관성: 개발, 테스트, 프로덕션 환경 통일
- 재현 가능한 빌드: 동일한 이미지로 어디서나 같은 결과
- 빠른 배포: 이미지 기반 배포로 속도 향상
- 롤백 용이: 이전 버전 이미지로 즉시 롤백
CI/CD 파이프라인 플로우
flowchart LR
subgraph 개발
Dev[개발자] --> Code[Code Push]
Code --> SCM[Git Repository]
end
subgraph CI[지속적 통합]
SCM --> Build[Docker Build]
Build --> Test[테스트 실행]
Test --> Scan[보안 스캔]
Scan --> Push[Registry Push]
end
subgraph CD[지속적 배포]
Push --> Stage[Staging 배포]
Stage --> Verify[검증]
Verify --> Prod[Production 배포]
Prod --> Monitor[모니터링]
end
Test -->|실패| Dev
Scan -->|취약점| Dev
Verify -->|문제| Stage
Monitor -->|알림| Dev
GitHub Actions
처음에는 GitHub Actions 하나만 골라 테스트 → 이미지 빌드·게시 → 서버 배포의 연결을 따라가세요. 뒤의 GitLab, Jenkins, CircleCI는 같은 목적을 다른 도구로 구현하는 대안입니다. 모든 설정을 한 프로젝트에 동시에 적용하는 순서가 아닙니다.
1. 기본 워크플로우
파일 안의 테스트 명령, Docker 빌드 경로, SSH 대상 서버와 secrets는 프로젝트에 맞게 준비해야 합니다. 특히 마지막 원격 명령은 기존 myapp 컨테이너를 멈추고 새로 띄우는 예제입니다. 아래 Blue-Green 예제와 구분하여 읽고, 먼저 각 job이 어떤 산출물을 다음 단계로 넘기는지 확인하세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
| # .github/workflows/docker.yml
name: Docker CI/CD
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
target: test
load: true
tags: ${{ env.IMAGE_NAME }}:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run tests
run: |
docker run --rm ${{ env.IMAGE_NAME }}:test npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v0.1.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 80:3000 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
|
2. 멀티 스테이지 Dockerfile (CI/CD용)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| # 테스트 스테이지
FROM node:24-alpine AS test
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run lint
CMD ["npm", "test"]
# 빌드 스테이지
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 프로덕션 스테이지
FROM node:24-alpine AS production
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package*.json ./
USER nodejs
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
|
GitLab CI/CD
1. .gitlab-ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
| stages:
- test
- build
- deploy
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
before_script:
- docker info
test:
stage: test
image: docker:latest
services:
- docker:dind
script:
- docker build --target test -t test-image .
- docker run --rm test-image
only:
- merge_requests
- branches
build:
stage: build
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build -t $IMAGE_TAG .
- docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
- docker push $IMAGE_TAG
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
- develop
deploy-staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- |
curl -X POST \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"image\": \"$IMAGE_TAG\"}" \
https://staging.example.com/deploy
environment:
name: staging
url: https://staging.example.com
only:
- develop
deploy-production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
script:
- |
ssh $DEPLOY_USER@$DEPLOY_HOST << EOF
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker pull $IMAGE_TAG
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 80:3000 \
-e NODE_ENV=production \
$IMAGE_TAG
EOF
environment:
name: production
url: https://example.com
when: manual
only:
- main
|
Jenkins Pipeline
1. Jenkinsfile
Jenkins를 선택한 경우: 빌드·테스트·게시·정리 파이프라인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
| pipeline {
agent any
environment {
DOCKER_REGISTRY = 'registry.example.com'
DOCKER_IMAGE = 'myapp'
DOCKER_CREDENTIALS = 'docker-registry-credentials'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Test') {
steps {
script {
docker.build("${DOCKER_IMAGE}:test", "--target test .")
docker.image("${DOCKER_IMAGE}:test").inside {
sh 'npm test'
}
}
}
}
stage('Build') {
steps {
script {
dockerImage = docker.build("${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${env.BUILD_NUMBER}")
}
}
}
stage('Push') {
when {
branch 'main'
}
steps {
script {
docker.withRegistry("https://${DOCKER_REGISTRY}", DOCKER_CREDENTIALS) {
dockerImage.push()
dockerImage.push('latest')
}
}
}
}
stage('Deploy to Staging') {
when {
branch 'develop'
}
steps {
sshagent(['staging-ssh-key']) {
sh '''
ssh user@staging.example.com << EOF
docker pull ${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${BUILD_NUMBER}
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 80:3000 \
${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${BUILD_NUMBER}
EOF
'''
}
}
}
stage('Deploy to Production') {
when {
branch 'main'
}
input {
message "Deploy to production?"
ok "Deploy"
}
steps {
sshagent(['production-ssh-key']) {
sh '''
ssh user@production.example.com << EOF
docker pull ${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${BUILD_NUMBER}
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 80:3000 \
-e NODE_ENV=production \
${DOCKER_REGISTRY}/${DOCKER_IMAGE}:${BUILD_NUMBER}
EOF
'''
}
}
}
}
post {
always {
cleanWs()
sh 'docker system prune -f'
}
}
}
|
2. Jenkins Docker 설정
1
2
3
4
5
6
7
8
9
10
| // Jenkins 설정 스크립트
pipeline {
agent {
docker {
image 'docker:dind'
args '--privileged -v /var/run/docker.sock:/var/run/docker.sock'
}
}
// ...
}
|
CircleCI
1. .circleci/config.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| version: 2.1
orbs:
docker: circleci/docker@2.2.0
jobs:
test:
executor: docker/docker
steps:
- setup_remote_docker
- checkout
- docker/check
- run:
name: Build test image
command: |
docker build --target test -t myapp:test .
- run:
name: Run tests
command: |
docker run --rm myapp:test
build-and-push:
executor: docker/docker
steps:
- setup_remote_docker
- checkout
- docker/check
- docker/build:
image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME
tag: ${CIRCLE_SHA1:0:7},latest
- docker/push:
image: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME
tag: ${CIRCLE_SHA1:0:7},latest
deploy:
docker:
- image: cimg/base:stable
steps:
- add_ssh_keys:
fingerprints:
- "SO:ME:FI:NG:ER:PR:IN:T"
- run:
name: Deploy to server
command: |
ssh -o StrictHostKeyChecking=no user@server.com << EOF
docker pull $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME:latest
docker stop myapp || true
docker rm myapp || true
docker run -d \
--name myapp \
--restart unless-stopped \
-p 80:3000 \
$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME:latest
EOF
workflows:
version: 2
test-build-deploy:
jobs:
- test
- build-and-push:
requires:
- test
filters:
branches:
only:
- main
- develop
- deploy:
requires:
- build-and-push
filters:
branches:
only: main
|
ArgoCD와 GitOps
1. Kubernetes 배포 매니페스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| # k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: ghcr.io/username/myapp:latest
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
|
2. ArgoCD Application
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/username/myapp-k8s-config
targetRevision: HEAD
path: k8s
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
|
보안을 위한 CI/CD
1. 이미지 스캔 통합
1
2
3
4
5
6
7
8
9
10
11
12
13
| # GitHub Actions 예시
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
|
2. 시크릿 관리
1
2
3
4
5
6
7
8
9
10
11
12
| # GitHub Actions에서 시크릿 사용
- name: Build with secrets
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
secrets: |
"npm_token=${{ secrets.NPM_TOKEN }}"
"api_key=${{ secrets.API_KEY }}"
build-args: |
BUILDKIT_INLINE_CACHE=1
|
배포 전략
배포 전략 비교
| 전략 | 특징 | 장점 | 단점 | 적합한 경우 |
| Blue-Green | 두 환경 교체 | 빠른 롤백 | 리소스 2배 필요 | 빠른 롤백이 필수인 경우 |
| 카나리 | 점진적 배포 | 위험 최소화 | 복잡한 설정 | 새 기능 테스트 |
| 롤링 업데이트 | 순차적 교체 | 간단한 구현 | 일시적 비일관성 | 일반적인 업데이트 |
| A/B 테스팅 | 트래픽 분할 | 실험 가능 | 복잡한 모니터링 | 기능 비교 테스트 |
1. Blue-Green 배포
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| #!/bin/bash
# blue-green-deploy.sh
NEW_COLOR="green"
OLD_COLOR="blue"
if docker ps | grep -q "myapp-green"; then
NEW_COLOR="blue"
OLD_COLOR="green"
fi
echo "Deploying to $NEW_COLOR environment"
# 새 버전 배포
docker run -d \
--name myapp-$NEW_COLOR \
--network myapp-network \
-e COLOR=$NEW_COLOR \
$IMAGE_TAG
# 헬스체크
sleep 10
if ! curl -f http://localhost:8080/health; then
echo "Health check failed"
docker stop myapp-$NEW_COLOR
docker rm myapp-$NEW_COLOR
exit 1
fi
# 트래픽 전환
docker exec nginx sed -i "s/myapp-$OLD_COLOR/myapp-$NEW_COLOR/g" /etc/nginx/nginx.conf
docker exec nginx nginx -s reload
# 이전 버전 제거
sleep 30
docker stop myapp-$OLD_COLOR
docker rm myapp-$OLD_COLOR
|
2. 카나리 배포
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| # Kubernetes에서 Flagger 사용
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: myapp
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
progressDeadlineSeconds: 60
service:
port: 80
targetPort: 3000
analysis:
interval: 1m
threshold: 5
maxWeight: 50
stepWeight: 10
metrics:
- name: request-success-rate
thresholdRange:
min: 99
interval: 1m
- name: request-duration
thresholdRange:
max: 500
interval: 30s
|
모니터링과 알림
파이프라인의 성공 여부는 테스트 통과, 레지스트리의 이미지 게시, 대상 서버의 실행 상태를 따로 확인하세요. 배포 명령이 끝난 뒤에는 실제 서비스 주소에서 응답을 받아야 합니다. 연습에서는 어떤 이미지 태그를 빌드했고 어떤 태그를 서버가 실행하는지 한 줄로 기록해보세요.
1. 배포 알림
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Slack 알림 예시
- name: Notify deployment
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: |
Deployment ${{ job.status }}
Repository: ${{ github.repository }}
Branch: ${{ github.ref }}
Commit: ${{ github.sha }}
Author: ${{ github.actor }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
if: always()
|
2. 배포 대시보드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # Grafana 대시보드 설정
apiVersion: v1
kind: ConfigMap
metadata:
name: deployment-dashboard
data:
dashboard.json: |
{
"dashboard": {
"title": "CI/CD Metrics",
"panels": [
{
"title": "Deployment Frequency",
"targets": [{
"expr": "rate(deployments_total[5m])"
}]
},
{
"title": "Build Success Rate",
"targets": [{
"expr": "sum(rate(builds_total{status=\"success\"}[5m])) / sum(rate(builds_total[5m]))"
}]
}
]
}
}
|
베스트 프랙티스
CI/CD 톴 비교
| 톴 | 특징 | Docker 지원 | 설정 복잡도 | 가격 |
| GitHub Actions | GitHub 통합 | ★★★★★ | 낮음 | 무료/종량제 |
| GitLab CI | GitLab 통합 | ★★★★★ | 중간 | 무료/기업 |
| Jenkins | 오픈소스 | ★★★★☆ | 높음 | 무료 |
| CircleCI | 클라우드 | ★★★★★ | 낮음 | 무료/종량제 |
| Azure DevOps | MS 통합 | ★★★★☆ | 중간 | 무료/기업 |
| Travis CI | 간단한 설정 | ★★★★☆ | 낮음 | 종량제 |
1. 태그 전략
1
2
3
4
5
6
7
8
| # 시맨틱 버저닝
git tag v1.2.3
git push origin v1.2.3
# 자동 태깅
VERSION=$(cat VERSION)
git tag -a v$VERSION -m "Release version $VERSION"
git push origin v$VERSION
|
2. 캐시 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
| # BuildKit 캐시
- name: Build with cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
빌드 성능 최적화 결과
gantt
title Docker 빌드 시간 비교
dateFormat mm:ss
axisFormat %M:%S
section 기본 빌드
레이어 다운로드 :02:30, 150s
소스 복사 :00:20, 20s
빌드 실행 :01:00, 60s
section 캐시 사용
캐시 확인 :00:05, 5s
변경된 레이어만 :00:30, 30s
빌드 실행 :00:15, 15s
section 멀티스테이지 + 캐시
병렬 빌드 :00:05, 5s
변경 감지 :00:02, 2s
최종 조립 :00:08, 8s
마무리
Docker와 CI/CD의 결합은 현대적인 소프트웨어 배포의 핵심입니다. 자동화된 테스트, 빌드, 배포를 통해 더 빠르고 안정적인 릴리스가 가능해집니다. 다음 편에서는 Docker Swarm을 통한 오케스트레이션을 알아보겠습니다.
다음 편 예고
- Docker Swarm 아키텍처
- 서비스와 스택 관리
- 롤링 업데이트
- 고가용성 구성
컨테이너 오케스트레이션의 세계로! 🎯
📚 Docker 마스터하기 시리즈
🐳 기초편 (입문자용 - 5편)
- Docker란 무엇인가?
- Docker 설치 및 환경 설정
- 첫 번째 컨테이너 실행하기
- Docker 이미지 이해하기
- Dockerfile 작성하기
💼 실전편 (중급자용 - 6편)
- Docker 네트워크 기초
- Docker 볼륨과 데이터 관리
- Docker Compose 입문
- 멀티 컨테이너 애플리케이션
- Docker Hub 활용하기
- Docker 보안 베스트 프랙티스
🚀 고급편 (전문가용 - 9편)
- Docker 로그와 모니터링
- Docker로 Node.js 애플리케이션 배포
- Docker로 Python 애플리케이션 배포
- Docker로 데이터베이스 운영
- Docker 이미지 최적화
- Docker와 CI/CD ← 현재 글
- Docker Swarm 기초
- 문제 해결과 트러블슈팅
- Docker 생태계와 미래