포스트

[이제와서 시작하는 Docker 마스터하기 - 고급편 #6] Docker와 CI/CD

[이제와서 시작하는 Docker 마스터하기 - 고급편 #6] Docker와 CI/CD

“이제와서 시작하는 Docker 마스터하기” CI/CD(지속적 통합/지속적 배포)는 현대 소프트웨어 개발의 핵심입니다. Docker를 CI/CD 파이프라인에 통합하면 일관된 빌드와 배포가 가능해집니다. 이번 편에서는 주요 CI/CD 플랫폼에서 Docker를 활용하는 방법을 알아보겠습니다.

CI/CD와 Docker의 시너지

Docker가 CI/CD에 주는 이점

  1. 환경 일관성: 개발, 테스트, 프로덕션 환경 통일
  2. 재현 가능한 빌드: 동일한 이미지로 어디서나 같은 결과
  3. 빠른 배포: 이미지 기반 배포로 속도 향상
  4. 롤백 용이: 이전 버전 이미지로 즉시 롤백

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

1. 기본 워크플로우

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: $

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Build test image
        uses: docker/build-push-action@v4
        with:
          context: .
          target: test
          load: true
          tags: $:test
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Run tests
        run: |
          docker run --rm $: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@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Log in to Container Registry
        uses: docker/login-action@v2
        with:
          registry: $
          username: $
          password: $

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: $/$
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern=
            type=semver,pattern=.
            type=sha,prefix=-

      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: $
          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: $
          username: $
          key: $
          script: |
            docker pull $/$:main
            docker stop myapp || true
            docker rm myapp || true
            docker run -d \
              --name myapp \
              --restart unless-stopped \
              -p 80:3000 \
              $/$: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:18-alpine AS test
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run lint
CMD ["npm", "test"]

# 빌드 스테이지
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 프로덕션 스테이지
FROM node:18-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

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: $/$:$
    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@v4
  with:
    context: .
    push: true
    tags: $
    secrets: |
      "npm_token=$"
      "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: $
    text: |
      Deployment $
      Repository: $
      Branch: $
      Commit: $
      Author: $
  env:
    SLACK_WEBHOOK_URL: $
  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@v4
  with:
    context: .
    push: true
    tags: $
    cache-from: |
      type=gha
      type=registry,ref=$/$:buildcache
    cache-to: |
      type=gha,mode=max
      type=registry,ref=$/$: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편)

  1. Docker란 무엇인가?
  2. Docker 설치 및 환경 설정
  3. 첫 번째 컨테이너 실행하기
  4. Docker 이미지 이해하기
  5. Dockerfile 작성하기

💼 실전편 (중급자용 - 6편)

  1. Docker 네트워크 기초
  2. Docker 볼륨과 데이터 관리
  3. Docker Compose 입문
  4. 멀티 컨테이너 애플리케이션
  5. Docker Hub 활용하기
  6. Docker 보안 베스트 프랙티스

🚀 고급편 (전문가용 - 9편)

  1. Docker 로그와 모니터링
  2. Docker로 Node.js 애플리케이션 배포
  3. Docker로 Python 애플리케이션 배포
  4. Docker로 데이터베이스 운영
  5. Docker 이미지 최적화
  6. Docker와 CI/CD ← 현재 글
  7. Docker Swarm 기초
  8. 문제 해결과 트러블슈팅
  9. Docker 생태계와 미래
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.