Bài 01: Tự động build và deploy ứng dụng Docker với Jenkins CI/CD

  • Đăng bởi JP
  • 26/10/2026

Ở Bài 06: Triển khai ứng dụng thực tế với Docker, chúng ta đã đưa được một stack App + PostgreSQL + Redis lên server. Nhưng mỗi lần có code mới, bạn vẫn phải tự tay làm một loạt việc: build image, gắn tag, push lên registry, SSH vào server, sửa .env, rồi pull và up.

Làm vài lần thì không sao. Làm mỗi ngày, với nhiều người cùng commit, thì sớm muộn cũng có lúc quên một bước, deploy nhầm tag, hoặc đẩy lên production một bản chưa ai chạy thử.

Bài viết này sẽ tự động hóa toàn bộ quy trình đó với Jenkins: chỉ cần git push, phần còn lại để máy làm.


CI/CD Là Gì Và Vì Sao Cần?

  • CI (Continuous Integration): mỗi lần có code mới, hệ thống tự động build và chạy test để phát hiện lỗi sớm, ngay trên nhánh của người viết.
  • CD (Continuous Delivery/Deployment): code đã qua test sẽ được tự động đóng gói và triển khai lên server.

So sánh với cách làm ở Bài 06:

Deploy thủ công Deploy bằng pipeline
Các bước Nhớ trong đầu hoặc trong một file ghi chú Viết thành code (Jenkinsfile), commit cùng source
Test trước khi deploy Tùy người, dễ bỏ qua Bắt buộc, test lỗi thì dừng
Tag image Tự đặt, dễ trùng hoặc nhầm Tự động theo commit SHA
Ai được deploy Ai có quyền SSH vào server Chỉ Jenkins, chỉ từ nhánh main
Rollback SSH vào sửa tay Bấm một nút, nhập tag cũ
Lịch sử Không có Mỗi lần build có log, thời gian, người trigger

Pipeline chúng ta sẽ xây dựng:

git push ──▶ GitHub ──webhook──▶ Jenkins
                                   │
         ┌─────────────────────────┴─────────────────────────┐
         │ Checkout ▶ Build image ▶ Test ▶ Push ▶ Deploy     │
         │                                (chỉ nhánh main)   │
         └─────────────────────────┬─────────────────────────┘
                                   │ ssh
                                   ▼
                       Server: docker compose pull && up -d

Bước 1: Cài Jenkins Bằng Docker

Nên cài Jenkins trên một máy riêng, không phải server production. Jenkins sẽ build image và chạy test, việc này tốn CPU/RAM và không nên tranh tài nguyên với ứng dụng thật.

Image jenkins/jenkins chính thức không có Docker CLI, nên ta build một image mới dựa trên nó. Tạo thư mục ~/jenkins với file Dockerfile:

FROM jenkins/jenkins:lts-jdk21

USER root
# Cài Docker CLI + Compose plugin (chỉ CLI, không cài Docker daemon) và SSH client
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl openssh-client \
 && install -m 0755 -d /etc/apt/keyrings \
 && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
 && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
    > /etc/apt/sources.list.d/docker.list \
 && apt-get update \
 && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
 && rm -rf /var/lib/apt/lists/*
USER jenkins

RUN jenkins-plugin-cli --plugins \
    workflow-aggregator git github-branch-source pipeline-graph-view \
    credentials-binding ssh-agent timestamper slack

Các plugin được cài sẵn:

Plugin Dùng để
workflow-aggregator Bộ plugin Pipeline, đọc và chạy Jenkinsfile
git, github-branch-source Lấy code từ GitHub, tạo Multibranch Pipeline, nhận webhook
pipeline-graph-view Hiển thị các stage dạng sơ đồ, xem log theo từng bước
credentials-binding Đưa mật khẩu/token vào biến môi trường một cách an toàn
ssh-agent Dùng SSH key đã lưu trong Jenkins để SSH vào server
timestamper Thêm thời gian vào từng dòng log
slack Gửi thông báo kết quả build vào Slack

Tiếp theo là docker-compose.yml:

services:
  jenkins:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - jenkins_home:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
    group_add:
      - "${DOCKER_GID}"
    restart: unless-stopped

volumes:
  jenkins_home:
  • jenkins_home: named volume chứa toàn bộ cấu hình, job, lịch sử build. Đây là thứ cần backup (Bài 04).
  • /var/run/docker.sock: cho Docker CLI trong container nói chuyện với Docker daemon của máy host. Nhờ vậy Jenkins chạy được docker build, docker compose.
  • group_add: user jenkins trong container cần thuộc group sở hữu file socket thì mới được dùng nó.

Khởi động:

cd ~/jenkins
echo "DOCKER_GID=$(stat -c %g /var/run/docker.sock)" > .env
docker compose up -d --build

# Lấy mật khẩu admin ban đầu
docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

Mở http://<ip-may-jenkins>:8080, dán mật khẩu vừa lấy, chọn Install suggested plugins rồi tạo tài khoản admin.

Cảnh báo bảo mật: mount docker.sock vào container đồng nghĩa với việc trao quyền root trên máy host cho Jenkins. Bất kỳ ai chạy được pipeline (hoặc sửa được Jenkinsfile) đều có thể dùng Docker để đọc, ghi mọi file trên máy đó. Vì vậy:

  • Không mở Jenkins trực tiếp ra internet. Đặt sau reverse proxy có HTTPS, hoặc chỉ truy cập qua VPN.
  • Tắt đăng ký tài khoản, dùng mật khẩu mạnh, phân quyền chặt.
  • Không tự động build Pull Request từ fork của người lạ.
  • Không cài Jenkins trên server production.

Với hệ thống lớn hơn, nên tách việc build sang các agent riêng, hoặc dùng các công cụ build không cần Docker daemon (Kaniko, BuildKit rootless).


Bước 2: Các Khái Niệm Chính

Khái niệm Ý nghĩa
Job Một “công việc” trong Jenkins. Có nhiều loại: Freestyle, Pipeline, Multibranch Pipeline…
Pipeline Chuỗi các bước từ code đến deploy, mô tả bằng code
Jenkinsfile File chứa pipeline, đặt ở thư mục gốc của repo, được review và commit như code bình thường
Declarative Pipeline Cú pháp pipeline { ... } có cấu trúc rõ ràng, dễ đọc. Bài này dùng cú pháp này
Agent Máy (hoặc container) thực thi pipeline. agent any nghĩa là chạy ở bất kỳ máy nào đang rảnh
Stage Một giai đoạn lớn: Build, Test, Deploy… Hiển thị thành từng ô trên giao diện
Step Một lệnh cụ thể trong stage: sh, echo, checkout…
Credentials Kho lưu mật khẩu, token, SSH key. Pipeline chỉ tham chiếu bằng ID, không bao giờ chứa giá trị thật

Bước 3: Chuẩn Bị Credentials Và Server

Credentials trong Jenkins

Vào Manage Jenkins → Credentials → System → Global credentials → Add Credentials và tạo 2 mục:

  1. registry, loại Username with password: username là tài khoản GitHub, password là Personal Access Token có quyền write:packages (dùng để push image lên GHCR).
  2. deploy-ssh, loại SSH Username with private key: username là deploy, private key là key dành riêng cho Jenkins.

Tạo SSH key riêng cho Jenkins (đừng dùng lại key cá nhân của bạn):

ssh-keygen -t ed25519 -C "jenkins-deploy" -f jenkins_deploy -N ""
# jenkins_deploy      → dán vào credential "deploy-ssh"
# jenkins_deploy.pub  → thêm vào ~/.ssh/authorized_keys của user deploy trên server

Trên server production

Chuẩn bị giống Bước 5 của Bài 06, với một user riêng tên deploy:

sudo adduser deploy
sudo usermod -aG docker deploy      # để user deploy chạy được lệnh docker
sudo su - deploy

mkdir -p ~/myapp ~/.ssh && cd ~/myapp
# copy docker-compose.yml và .env của Bài 06 vào đây
# dán nội dung jenkins_deploy.pub vào ~/.ssh/authorized_keys

docker login ghcr.io -u <username>  # chỉ cần nếu image là private

Thành viên group docker cũng tương đương root. Hãy coi private key của deploy như mật khẩu root của server.


Bước 4: Viết Jenkinsfile

Trong repo myapp của Bài 06, thêm 2 file vào thư mục gốc.

docker-compose.ci.yml, dùng riêng cho bước test:

services:
  app:
    ports: !reset []

File này gỡ bỏ phần ports: "3000:3000" của service app khi chạy test. Nhờ vậy nhiều build có thể chạy cùng lúc mà không tranh nhau port 3000 trên máy Jenkins. Cú pháp !reset cần Docker Compose 2.24 trở lên.

Jenkinsfile:

pipeline {
    agent any

    parameters {
        string(name: 'ROLLBACK_TAG', defaultValue: '', description: 'Tag cũ cần deploy lại (ví dụ a1b2c3d). Bỏ trống để build bình thường.')
    }

    environment {
        REGISTRY    = 'ghcr.io'
        IMAGE       = 'ghcr.io/<username>/myapp'
        DEPLOY_HOST = 'deploy@<server-ip>'
        DEPLOY_DIR  = '/home/deploy/myapp'
    }

    options {
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        timeout(time: 30, unit: 'MINUTES')
        timestamps()
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                script {
                    env.IMAGE_TAG = params.ROLLBACK_TAG ?: sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
                    env.CI_PROJECT = env.BUILD_TAG.toLowerCase().replaceAll(/[^a-z0-9_-]/, '-')
                }
                echo "Image: ${IMAGE}:${IMAGE_TAG}"
            }
        }

        stage('Build image') {
            when { expression { !params.ROLLBACK_TAG } }
            steps {
                sh 'docker build -t "$IMAGE:$IMAGE_TAG" .'
            }
        }

        stage('Test') {
            when { expression { !params.ROLLBACK_TAG } }
            environment {
                APP_IMAGE            = "${env.IMAGE}:${env.IMAGE_TAG}"
                COMPOSE_PROJECT_NAME = "${env.CI_PROJECT}"
                COMPOSE_FILE         = 'docker-compose.yml:docker-compose.ci.yml'
                POSTGRES_USER        = 'ci'
                POSTGRES_PASSWORD    = 'ci'
                POSTGRES_DB          = 'ci'
            }
            steps {
                sh 'docker compose up -d --no-build --wait'
                sh 'docker compose exec -T app wget -qO- http://localhost:3000/'
            }
            post {
                always {
                    sh 'docker compose down -v'
                }
            }
        }

        stage('Push') {
            when {
                branch 'main'
                expression { !params.ROLLBACK_TAG }
            }
            steps {
                withCredentials([usernamePassword(credentialsId: 'registry', usernameVariable: 'REG_USER', passwordVariable: 'REG_TOKEN')]) {
                    sh '''
                        echo "$REG_TOKEN" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
                        docker push "$IMAGE:$IMAGE_TAG"
                        docker logout "$REGISTRY"
                    '''
                }
            }
        }

        stage('Deploy') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh']) {
                    sh '''
                        ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_HOST" "
                            set -e
                            cd $DEPLOY_DIR
                            sed -i '/^#* *APP_IMAGE=/d' .env
                            echo 'APP_IMAGE=$IMAGE:$IMAGE_TAG' >> .env
                            docker compose pull app
                            docker compose up -d --no-build --wait app
                        "
                    '''
                }
            }
        }
    }

    post {
        success {
            slackSend(color: 'good', message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} thành công, image `${env.IMAGE_TAG}` (<${env.BUILD_URL}|Xem build>)")
        }
        failure {
            slackSend(color: 'danger', message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} thất bại (<${env.BUILD_URL}console|Xem log>)")
        }
        always {
            sh 'docker image rm "$IMAGE:$IMAGE_TAG" || true'
        }
    }
}

Nhớ thay <username> và <server-ip> bằng giá trị thật. Đi qua từng phần:

environment và options

  • Các giá trị không bí mật (tên image, địa chỉ server) để thẳng trong environment. Các giá trị bí mật không bao giờ xuất hiện trong file này.
  • disableConcurrentBuilds(): trên cùng một nhánh, build sau phải chờ build trước xong. Tránh cảnh hai lần deploy chạy chồng lên nhau.
  • timeout: build bị treo quá 30 phút sẽ tự hủy, không chiếm executor mãi mãi.

Stage Checkout

  • IMAGE_TAG là commit SHA rút gọn (ví dụ 8f381aa). Nhìn tag là biết ngay image được build từ commit nào, đúng lời khuyên “đừng chỉ dùng latest” ở Bài 06.
  • CI_PROJECT là tên project Compose riêng cho từng build, tạo từ BUILD_TAG (ví dụ jenkins-myapp-feature-2flogin-3). Tên nhánh như feature/login chứa ký tự / không hợp lệ, nên ta thay chúng bằng dấu -.

Stage Build image: build đúng Dockerfile của Bài 06. Lần build sau nhanh hơn nhiều nhờ cache layer.

Stage Test: đây là một smoke test, chạy cả stack thật bằng chính image vừa build:

  • --wait chờ đến khi mọi container đều healthy. Nếu HEALTHCHECK trong Dockerfile báo lỗi, lệnh này thất bại và pipeline dừng ngay, không có gì được push hay deploy.
  • Sau đó gọi thử endpoint / từ bên trong container app để chắc chắn ứng dụng đọc/ghi được PostgreSQL và Redis.
  • post { always { ... down -v } }: dù test pass hay fail, stack test và volume của nó đều được dọn sạch.
  • Database test dùng mật khẩu ci cố định vì nó chỉ sống vài chục giây và không mở port ra ngoài.

Nếu dự án có unit test (npm test), hãy chạy chúng trước smoke test, ví dụ thêm một stage docker run --rm "$IMAGE:$IMAGE_TAG" npm test (với điều kiện image có devDependencies), hoặc thêm một stage test riêng trong Dockerfile.

Stage Push

  • when { branch 'main' }: chỉ nhánh main mới được push. Các nhánh feature chỉ build và test.
  • withCredentials đưa username/token vào biến môi trường chỉ trong khối này. Jenkins tự che giá trị thành **** nếu chúng lỡ xuất hiện trong log.
  • Lệnh sh dùng nháy đơn '''...''', để shell (không phải Groovy) đọc biến $REG_TOKEN. Nếu dùng nháy kép, Groovy sẽ chèn token thẳng vào câu lệnh và Jenkins sẽ cảnh báo rò rỉ bí mật.

Stage Deploy

  • sshagent nạp SSH key từ credential deploy-ssh vào một ssh-agent tạm thời, chỉ tồn tại trong khối lệnh này. Key không nằm trong workspace hay trong log.
  • Trên server, lệnh sed xóa dòng APP_IMAGE cũ (kể cả dòng đang bị comment như trong file .env mẫu của Bài 06), sau đó ghi tag mới vào. Ghi vào .env thay vì chỉ truyền biến tạm thời giúp server nhớ phiên bản đang chạy: khi server reboot hay ai đó chạy docker compose up -d, đúng image đó sẽ được dùng.
  • docker compose up -d --no-build --wait app chỉ tạo lại container app, database và Redis vẫn chạy bình thường. --wait khiến stage báo lỗi nếu bản mới không healthy.
  • Các biến $IMAGE, $IMAGE_TAG, $DEPLOY_DIR nằm trong chuỗi nháy kép nên được thay giá trị ngay trên máy Jenkins trước khi gửi sang server.

Khối post cuối cùng: gửi kết quả vào Slack, và xóa image trên máy Jenkins để ổ cứng không đầy dần theo số lần build.


Bước 5: Tạo Multibranch Pipeline

Vào New Item, đặt tên myapp, chọn Multibranch Pipeline:

  • Branch Sources → Add source → GitHub: chọn credential để đọc repo (một Personal Access Token có quyền repo nếu repo là private), nhập URL của repo.
  • Build Configuration: giữ mặc định by Jenkinsfile, đường dẫn Jenkinsfile.
  • Lưu lại. Jenkins sẽ quét repo và tạo một job con cho mỗi nhánh có Jenkinsfile.
Multibranch Pipeline tự tạo job cho từng nhánh: main được deploy, nhánh feature/broken bị chặn ở bước test

Multibranch Pipeline tự tạo job cho từng nhánh: main được deploy, nhánh feature/broken bị chặn ở bước test

Vì sao nên dùng Multibranch thay cho Pipeline thường?

  • Mỗi nhánh, mỗi Pull Request đều được build và test tự động. Lỗi bị phát hiện trước khi merge vào main.
  • Điều kiện when { branch 'main' } chỉ hoạt động trong Multibranch Pipeline, vì chỉ ở đây Jenkins mới biết build đang chạy cho nhánh nào.

Mô hình này khớp với Gitflow cho dự án thực tế: nhánh feature chỉ chạy CI, còn khi merge vào main thì CD mới chạy. Nếu có môi trường staging, bạn có thể thêm một stage Deploy staging với when { branch 'develop' }, trỏ tới một server khác.


Bước 6: Trigger Tự Động Bằng GitHub Webhook

Để Jenkins build ngay khi có git push, vào repo trên GitHub: Settings → Webhooks → Add webhook:

  • Payload URL: https://jenkins.example.com/github-webhook/ (bắt buộc có dấu / ở cuối)
  • Content type: application/json
  • Events: chọn Pushes và Pull requests

GitHub phải gọi được tới Jenkins qua internet. Nếu Jenkins nằm trong mạng nội bộ hoặc sau VPN (như khuyến nghị ở Bước 1), hãy dùng cách thay thế: trong cấu hình job, mục Scan Multibranch Pipeline Triggers, bật Periodically if not otherwise run với chu kỳ 2–5 phút. Jenkins sẽ tự quét repo, chậm hơn webhook một chút nhưng không cần mở cổng ra ngoài.

Kết quả sau khi merge một thay đổi vào main:

Stage Deploy: chỉ container app được tạo lại, db và redis vẫn chạy

Stage Deploy: chỉ container app được tạo lại, db và redis vẫn chạy

Còn khi code lỗi, healthcheck thất bại và pipeline dừng ở bước Test, Push và Deploy đều bị bỏ qua:

Smoke test thất bại: stack test vẫn được dọn sạch, không có gì lên production

Smoke test thất bại: stack test vẫn được dọn sạch, không có gì lên production


Bước 7: Thông Báo Kết Quả Vào Slack

Pipeline đã có sẵn slackSend trong khối post. Để nó gửi được tin nhắn:

  1. Trên api.slack.com/apps, tạo một Slack app, cấp quyền chat:write rồi cài vào workspace. Slack sẽ cấp một Bot User OAuth Token (bắt đầu bằng xoxb-). Mời app vào channel nhận thông báo, ví dụ #deploy.
  2. Trong Jenkins, tạo credential loại Secret text chứa token đó.
  3. Vào Manage Jenkins → System → Slack: điền Workspace, chọn credential vừa tạo, tick Custom slack app bot user, điền Default channel, rồi bấm Test Connection.

Nếu chưa cấu hình, slackSend chỉ ghi một dòng lỗi vào log và không làm build thất bại, nên bạn có thể giữ nguyên Jenkinsfile và cấu hình sau.

Nếu đã làm theo bài Hướng dẫn tích hợp Slack và GitHub, bạn có thể cho cả hai đổ về cùng một channel: GitHub báo có commit/PR mới, Jenkins báo build và deploy thành công hay thất bại. Cả team nhìn một chỗ là biết code đang ở đâu.


Bước 8: Rollback Khi Bản Mới Gặp Sự Cố

Vì mỗi lần build main đều push một image có tag riêng lên registry, việc rollback chỉ là deploy lại một tag cũ. Không cần build lại gì cả.

  1. Tìm tag cần quay về: mở một build cũ đã chạy tốt, dòng Image: ghcr.io/<username>/myapp:8f381aa ở stage Checkout cho biết tag. Bạn cũng có thể xem danh sách tag trên trang Packages của GitHub.
  2. Vào job myapp → main, chọn Build with Parameters, điền ROLLBACK_TAG = 8f381aa, bấm Build.
  3. Pipeline bỏ qua Build, Test, Push và chạy thẳng stage Deploy với tag đó.

Lưu ý:

  • Tham số ROLLBACK_TAG chỉ xuất hiện sau lần build đầu tiên của nhánh, vì Jenkins cần chạy Jenkinsfile một lần mới biết pipeline có tham số gì.
  • Rollback chỉ là biện pháp chữa cháy. Lần push tiếp theo vào main sẽ lại deploy code mới nhất. Hãy git revert commit lỗi để sửa tận gốc.
  • Nếu bản lỗi đã chạy migration làm thay đổi cấu trúc database, chỉ quay lại image cũ là chưa đủ. Hãy thiết kế migration theo hướng tương thích ngược, và backup database trước mỗi lần deploy lớn (xem phần backup ở Bài 06).

Tổng Kết

Sau bài này, quy trình deploy của chúng ta trở thành:

  1. Developer chỉ cần git push. Mọi nhánh đều được build và chạy smoke test tự động.
  2. Chỉ code đã qua test trên nhánh main mới lên production, với image gắn tag theo commit SHA.
  3. Bí mật nằm trong Jenkins Credentials, không nằm trong code hay trong log.
  4. Rollback bằng một nút bấm, kết quả mỗi lần build được báo về Slack.

Toàn bộ pipeline trong bài đã được chạy thử trên Jenkins 2.580 và Docker Compose v5 với ứng dụng mẫu của Bài 06: deploy nhánh main, chặn nhánh có healthcheck lỗi, chạy song song hai nhánh và rollback về tag cũ.

Mô hình một server + Docker Compose này phù hợp với phần lớn dự án vừa và nhỏ. Khi ứng dụng cần chạy trên nhiều server, tự co giãn theo tải và deploy không gián đoạn, đó là lúc tìm hiểu đến Kubernetes, chủ đề của một bài viết sắp tới.


Toàn bộ series DevOps · Series Docker


Trân trọng,
JP

Thảo luận trên tinh thần học hỏi