Từ Docker sang Kubernetes (K3s) & Thiết lập CI/CD Zero-Downtime
Hướng dẫn chi tiết cách di chuyển ứng dụng Java Spring Boot và MySQL từ Docker Compose sang cụm Kubernetes (K3s) trên VPS Ubuntu. Tích hợp Ingress, SSL Let's Encrypt và CI/CD GitHub Actions cấu hình Zero-Downtime. Ở bài trước mình có viết bài Từ Manual đến DevOps: Hướng dẫn CI/CD tự động Deploy với GitHub Actions & Docker .
Kubernetes (k8s) dùng để quản lý và vận hành container ở quy mô lớn.
Tác dụng chính:
• Tự động triển khai app container
• Tự heal (pod chết thì tự tạo lại)
• Scale lên/xuống theo tải
• Rolling update / rollback an toàn khi release
• Cân bằng tải & service discovery
• Quản lý cấu hình/secret tập trung
• Chạy ổn định multi-node, giảm downtime
Hiểu đơn giản:
Docker là đóng gói/chạy container trên 1 máy, còn k8s là “hệ điều hành điều phối” nhiều container trên nhiều máy một cách tự động.
Nếu app nhỏ, 1 VPS thì thường docker compose là đủ.
Khi cần HA, scale, CI/CD bài bản thì k8s rất đáng dùng.
Việc nâng cấp hệ thống từ Docker Compose lên Kubernetes (K8s) là một bước tiến lớn giúp ứng dụng chịu tải tốt hơn và quản lý linh hoạt hơn. Tuy nhiên, quá trình này thường gây bối rối cho người mới.
Trong bài viết này, mình sẽ hướng dẫn các bạn từng bước thực chiến: từ việc dựng cụm K3s trên VPS Ubuntu, di chuyển dữ liệu MySQL, cấu hình Ingress HTTPS, cho đến việc tự động hóa quá trình deploy bằng GitHub Actions mà không làm rớt mạng bất kỳ giây nào (Zero-Downtime).
1. Chuẩn Bị Môi Trường & Cài Đặt K3s
Để chạy môi trường Production gọn nhẹ nhưng vẫn mạnh mẽ, chúng ta sẽ sử dụng K3s – một bản phân phối K8s siêu nhẹ cực kỳ phù hợp cho VPS. (Khuyến nghị cấu hình VPS tối thiểu 2 Core, 4GB RAM. Trong bài này mình dùng bản 8 Core - 8GB RAM).
Bước 1 : Đăng nhập SSH vào VPS Ubuntu và chạy lệnh cài đặt thần thánh sau:
curl -sfL https://get.k3s.io | sh -
Kiểm tra xem Node đã sẵn sàng chưa:
kubectl get nodes
Khi Node báo Ready, nền móng K8s của bạn đã hoàn tất!

Bước 2: Đưa file Backup SQL lên VPS mới
Chuyển file quyenlt_backup.sql và thư mục uploads chứa hình ảnh của mã nguồn lên con VPS mới này để chuẩn bị import.
Bạn mở tab Terminal trên máy Mac (môi trường Local) và chạy lệnh đẩy file:

2. Dựng Database và Đổ dữ liệu vào K8s.
Trong Docker Compose, bạn gộp mọi thứ vào 1 file. Nhưng K8s có tính tổ chức cao hơn, nó chia Database của bạn thành 3 "mảnh ghép" riêng biệt để dễ quản lý:
-
PVC (PersistentVolumeClaim): Cái ổ cứng ảo để lưu dữ liệu (thay cho
volumescủa Docker). Kể cả khi container bị xóa, dữ liệu ở đây vẫn còn nguyên. -
Deployment: Cỗ máy chạy MySQL.
-
Service: Địa chỉ mạng nội bộ để lát nữa web
com.quyenltcó thể gọi vào.
Chúng ta sẽ gộp 3 mảnh ghép này vào một file duy nhất cho dễ quản lý nhé.
Bước 1: Tạo file cấu hình MySQL cho K8s
Trên VPS mới, bạn tạo một file tên là mysql.yaml:
nano mysql.yaml
Copy và dán toàn bộ nội dung sau vào (nhớ để ý tôi đã bọc mật khẩu "****1008" trong ngoặc kép để tránh "vết xe đổ" của Docker Compose nhé 😂):
# 1. Tạo ổ cứng ảo (PVC) dung lượng 5GB
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
# 2. Triển khai Container MySQL (Deployment)
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysqldb
spec:
selector:
matchLabels:
app: mysqldb
strategy:
type: Recreate
template:
metadata:
labels:
app: mysqldb
spec:
containers:
- name: mysqldb
image: mysql:8.0.28
args:
- "--default-authentication-plugin=mysql_native_password"
env:
- name: MYSQL_ROOT_PASSWORD
value: "****1008" # Đã bọc ngoặc kép an toàn!
- name: MYSQL_DATABASE
value: "quyenlt"
ports:
- containerPort: 3306
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-pvc
---
# 3. Mở cổng mạng nội bộ (Service)
apiVersion: v1
kind: Service
metadata:
name: mysqldb # Tên này rất quan trọng, Webapp sẽ gọi Database qua tên này
spec:
selector:
app: mysqldb
ports:
- port: 3306
Lưu file lại (Bấm Ctrl+O -> Enter -> Ctrl+X).
Bước 2: Kích hoạt file cấu hình
Hãy ra lệnh cho K8s xây dựng bộ Database này bằng lệnh:
kubectl apply -f mysql.yaml

Bạn chờ khoảng 30 giây để K8s kéo image MySQL về, sau đó kiểm tra xem nó đã chạy xanh chưa bằng lệnh:
kubectl get pods
(Khi nào cột STATUS hiện Running là thành công).

Bước 3: Import (Phục hồi) dữ liệu vào K8s
Trong K8s, lệnh để "nhồi" file SQL vào container hơi khác Docker một chút, nhưng nguyên lý thì y hệt.
Đầu tiên, bạn cần lấy tên chính xác của Pod MySQL đang chạy (nó sẽ có dạng mysqldb-xxxxxxxx-yyyy). Bạn lấy tên đó ở lệnh kubectl get pods vừa gõ xong.
Sau đó, chạy lệnh thần thánh này để bơm dữ liệu từ file quyenlt_backup.sql thẳng vào K8s:
cat quyenlt_backup.sql | kubectl exec -i <TÊN_POD_MYSQL> -- mysql -uroot -p"****1008" quyenlt
(Nhớ thay <TÊN_POD_MYSQL> bằng tên thật, ví dụ: cat quyenlt_backup.sql | kubectl exec -i mysqldb-6f8d... -- mysql -uroot -p"****1008" quyenlt)
Lệnh này chạy xong sẽ không báo gì cả (đặc thù của Linux, không báo gì tức là thành công 100%).
Nếu gõ kubectl get pods thấy MySQL chạy Running và đổ data xong !
3: Đưa ứng dụng Spring Boot (quyenlt.com) lên sóng.
Trong Docker, bạn dùng volumes: - ./uploads:/app/uploads để giữ lại ảnh. Trong K8s trên một máy chủ (Single-node), chúng ta sẽ dùng khái niệm tương đương gọi là hostPath để nối thư mục /root/quyenlt-com/uploads thẳng vào trong Pod.
Bước 1: Viết cấu hình cho Webapp
Bạn tạo file webapp.yaml ngay trong thư mục dự án:
nano webapp.yaml
Copy và dán nội dung này vào. Tôi đã chuyển đổi nguyên vẹn các biến môi trường từ file Docker Compose cũ của bạn sang chuẩn của K8s:
# 1. Triển khai Container Spring Boot
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
spec:
replicas: 1 # Chạy 1 bản sao trước để test
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
containers:
- name: webapp
image: tobi1008/com.quyenlt-webapp:latest # Image của bạn trên Docker Hub
ports:
- containerPort: 8080
env:
# Gọi thẳng tên Service 'mysqldb' mà chúng ta đã tạo lúc nãy
- name: SPRING_DATASOURCE_URL
value: "jdbc:mysql://mysqldb:3306/quyenlt?useSSL=false&allowPublicKeyRetrieval=true"
- name: SPRING_DATASOURCE_USERNAME
value: "root"
- name: SPRING_DATASOURCE_PASSWORD
value: "03051008"
- name: SPRING_JPA_HIBERNATE_DDL_AUTO
value: "update"
- name: SPRING_WEB_RESOURCES_STATIC_LOCATIONS
value: "classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/,file:/app/"
volumeMounts:
- name: uploads-volume
mountPath: /app/uploads # Nơi code Java lưu ảnh
volumes:
- name: uploads-volume
hostPath:
path: /root/quyenlt-com/uploads # Thư mục thực tế trên VPS mới
type: DirectoryOrCreate
---
# 2. Mở cổng truy cập (Service NodePort)
apiVersion: v1
kind: Service
metadata:
name: webapp-service
spec:
type: NodePort
selector:
app: webapp
ports:
- port: 8080 # Cổng của Service
targetPort: 8080 # Cổng bên trong Container Java
nodePort: 30080 # Đục lỗ cổng 30080 ra ngoài Internet để test
Lưu lại (Bấm Ctrl+O -> Enter -> Ctrl+X).
Bước 2: Kích hoạt ứng dụng
Bạn gõ lệnh sau để K8s bắt đầu kéo Image từ Docker Hub về và chạy:
kubectl apply -f webapp.yaml
Bước 3: Theo dõi quá trình khởi động
Vì ứng dụng Java thường mất vài giây để khởi động (kết nối Database, nạp thư viện), bạn hãy dùng lệnh này để xem trạng thái Pod:
kubectl get pods -w
(Chữ -w nghĩa là watch - nó sẽ tự động cập nhật màn hình khi trạng thái thay đổi. Khi nào thấy webapp-... chuyển sang Running thì bấm Ctrl+C để thoát theo dõi).
Nếu muốn chắc ăn hơn, bạn có thể xem log của Spring Boot y như lúc dùng Docker:
kubectl logs -l app=webapp -f
(Thấy dòng Started Application in... là thành công! Bấm Ctrl+C để thoát).

Tận hưởng thành quả đầu tiên
Ngay lúc này, bạn hãy mở trình duyệt web trên máy tính Mac và gõ: http://<IP_VPS_MỚI>:30080
Bạn có nhìn thấy giao diện quen thuộc của website quyenlt.com cùng với toàn bộ dữ liệu bài viết cũ hiện lên không?

4. Cấu hình để webapp hoạt động với tên miền
Lúc trước ở VPS cũ, bạn dùng Nginx Proxy Manager để đứng mũi chịu sào, hứng traffic từ tên miền và đẩy vào container. Trong thế giới Kubernetes, khái niệm đó được gọi là Ingress (Người gác cổng).
Rất may mắn là khi bạn cài K3s trên VPS mới, nó đã tích hợp sẵn một Ingress Controller siêu xịn tên là Traefik. Việc của chúng ta bây giờ chỉ là viết một cái "luật" (Rule) để bảo Traefik: "Nếu có ai gõ quyenlt.com, hãy dẫn họ vào Service của webapp".
Dưới đây là 3 bước để thiết lập:
Bước 1: Trỏ tên miền về IP của VPS mới
Trước khi cấu hình K8s, bạn cần đăng nhập vào nơi quản lý tên miền của bạn (như Cloudflare, Mắt Bão, Hostinger...) và sửa lại bản ghi DNS:
-
Loại (Type): A
-
Tên (Name):
@(hoặcquyenlt.com) -
Giá trị (Value/IP): Nhập địa chỉ IP của VPS mới.
(Lưu ý: Tùy nhà cung cấp mạng mà việc cập nhật DNS có thể mất từ vài phút đến vài tiếng, nhưng thường Cloudflare thì nhận ngay).
Bước 2: Tạo file cấu hình Ingress
Trên VPS mới (đang ở thư mục ~/quyenlt-com), bạn tạo file ingress.yaml:
nano ingress.yaml
Dán nội dung sau vào:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapp-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
spec:
rules:
- host: quyenlt.com # <-- Tên miền chuẩn xác đây rồi
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp-service
port:
number: 8080
- host: www.quyenlt.com # <-- Bổ sung thêm www cho chắc cú
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp-service
port:
number: 8080
Bước 3: Kích hoạt Ingress
Gõ lệnh này để báo cho K8s biết luật định tuyến mới:
kubectl apply -f ingress.yaml
Để kiểm tra xem Ingress đã nhận tên miền chưa, bạn gõ:
kubectl get ingress
Chỉ cần bạn đã trỏ IP của tên miền quyenlt.com (và www.quyenlt.com) về con VPS mới này trên trang quản lý DNS, thì chưa đầy 1 phút sau, bạn có thể gõ http://quyenlt.com trên trình duyệt và tận hưởng thành quả rồi!
5. Cấu hình và cài đặt SSL cho tên miền
Trong thế giới Docker/VPS truyền thống, bạn hay dùng lệnh certbot hoặc Nginx Proxy Manager để xin chứng chỉ. Nhưng trong Kubernetes, chúng ta có một "người quản gia" tự động làm việc này tên là cert-manager. Bạn chỉ cần ra lệnh 1 lần, nó sẽ tự động xin chứng chỉ từ Let's Encrypt và tự động gia hạn trước khi hết hạn. Mãi mãi không cần đụng tay vào nữa!
Dưới đây là 3 bước cực kỳ chuẩn chỉ để cấp SSL cho tên miền quyenlt.com.
(⚠️ Lưu ý cực kỳ quan trọng trước khi làm: Bạn phải chắc chắn 100% là tên miền quyenlt.com đã trỏ về địa chỉ IP của VPS mới rồi nhé. Nếu chưa trỏ, Let's Encrypt sẽ từ chối cấp chứng chỉ).
Bước 1: Cài đặt "người quản gia" cert-manager
Bạn đứng tại VPS mới và chạy thẳng câu lệnh này để K8s tự động tải và cài đặt cert-manager:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml
Bạn chờ khoảng 30 giây đến 1 phút, rồi gõ lệnh sau để xem quản gia đã thức dậy chưa:
kubectl get pods -n cert-manager
(Khi nào thấy 3 Pod hiện lên và báo Running là xong bước 1).

Bước 2: Tạo tài khoản Let's Encrypt (ClusterIssuer)
K8s cần biết email của bạn để Let's Encrypt gửi thông báo (nếu có lỗi). Bạn tạo một file tên là issuer.yaml trong thư mục ~/quyenlt-com:
nano issuer.yaml
Dán nội dung này vào (nhớ thay email của bạn vào chỗ email: ... nhé):
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
# URL của Let's Encrypt
server: https://acme-v02.api.letsencrypt.org/directory
# Thay bằng email thật của bạn
email: [email protected]
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik
Lưu file lại (Ctrl+O -> Enter -> Ctrl+X) và kích hoạt:
kubectl apply -f issuer.yaml
Bước 3: Gắn chứng chỉ vào tên miền (Cập nhật Ingress)
Bây giờ là lúc báo cho K8s biết: "Hãy lấy chứng chỉ gắn vào webapp cho tôi".
Bạn mở lại file ingress.yaml:
nano ingress.yaml
Và sửa nội dung lại thành thế này (chỉ thêm 1 dòng ở annotations và khối tls ở spec):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapp-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
# Dòng này gọi quản gia cert-manager vào làm việc
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- quyenlt.com
- www.quyenlt.com
# Tên két sắt chứa chứng chỉ SSL (K8s sẽ tự tạo)
secretName: quyenlt-tls
rules:
- host: quyenlt.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp-service
port:
number: 8080
- host: www.quyenlt.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp-service
port:
number: 8080
Lưu lại và áp dụng sự thay đổi:
kubectl apply -f ingress.yaml
Chờ phép màu xảy ra...
Ngay sau khi bạn chạy lệnh trên, cert-manager sẽ bí mật chạy ra ngoài internet xin chứng chỉ từ Let's Encrypt. Quá trình này mất khoảng 30 giây - 1 phút.
Bạn có thể theo dõi tiến độ bằng lệnh này:
kubectl get certificate
Khi nào bạn thấy cột READY chuyển từ False sang True thì xin chúc mừng!

Bạn mở trình duyệt ẩn danh, gõ https://quyenlt.com để tận hưởng thành quả ổ khóa xanh nhé!
6. Nối lại đường ống CI/CD bằng GitHub Actions.
Bản chất của việc này rất đơn giản: Nhà bếp (GitHub) vẫn nấu món ăn đó, Shipper (Docker Hub) vẫn giao hàng, nhưng thay vì gọi quản gia cũ là Docker Compose ra nhận hàng, chúng ta sẽ gọi người gác cổng mới là Kubernetes (K3s).
Bạn chỉ cần làm 2 việc cực kỳ gọn gàng trên kho chứa GitHub của dự án com.quyenlt:
Việc 1: Cập nhật lại IP và Mật khẩu của VPS mới
Vì chúng ta đã chuyển nhà sang con VPS cấu hình 8 Core này, bạn phải báo cho GitHub biết địa chỉ mới.
-
Vào repository chứa code trên GitHub.
-
Chuyển đến tab Settings -> Secrets and variables -> Actions.
-
Bấm vào biểu tượng cây bút để sửa lại biến
HOST(nhập IP của VPS mới) và biếnPASSWORDhoặcSSH_KEY(nhập mật khẩu/key của VPS mới).
Nếu quên bạn có thể xem lại bài hướng dẫn tại phần Bước 2: Tạo chìa khóa để GitHub vào được VPS (SSH Key) và Lần 5: Key SSH (Cái này quan trọng nhất)
Việc 2: Sửa lệnh Deploy trong file Workflow
Bạn mở file cấu hình CI/CD của bạn (thường nằm ở .github/workflows/deploy.yml hoặc main.yml).
-
Phần Build Java và Push Docker Image: Bạn giữ nguyên 100%, không sửa một chữ nào cả.
-
Phần Deploy qua SSH: Thay vì dùng lệnh
docker compose pullvàdocker compose up -dnhư ngày xưa, bạn sửa lại lệnh thực thi (script) thànhkubectl rollout restart deployment webapp.
Bạn mở file .github/workflows/deploy.yml trên máy Mac, xóa hết nội dung cũ và dán toàn bộ đoạn code này vào:
name: Deploy Java App
on:
push:
branches: [ "main" ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# 1. Lấy code từ GitHub xuống
- name: Checkout code
uses: actions/checkout@v4
# 2. Cài Java để build code
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
# 3. Build ra file .jar (tạo ra thư mục target/)
- name: Build with Maven
run: mvn clean package -DskipTests
# 4. Đăng nhập Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 5. Đóng gói Image và đẩy lên Docker Hub
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/com.quyenlt-webapp:latest
# 6. Ra lệnh cho Kubernetes trên VPS cập nhật phiên bản mới (Zero-downtime)
- name: Deploy to K8s VPS
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_KEY }}
port: 22 # LƯU Ý: Sửa thành 2210 nếu VPS mới của bạn đã đổi cổng SSH
script: |
echo "Bắt đầu cập nhật ứng dụng trên Kubernetes..."
# Lệnh thần thánh: Khởi động lại Deployment webapp để kéo Image mới nhất về
kubectl rollout restart deployment webapp
# Dừng lại 5 giây để K8s kịp tạo Pod mới
sleep 5
# In ra trạng thái các Pod để bạn dễ kiểm tra trên log của GitHub Actions
kubectl get pods
⚠️ Lời nhắc cuối trước khi Push:
Đừng quên vào trang GitHub của dự án com.quyenlt ➡️ Settings ➡️ Secrets and variables ➡️ Actions để cập nhật lại 2 biến này nhé (nếu không GitHub sẽ deploy nhầm sang con VPS cũ đấy):
-
VPS_HOST: Thay bằng IP của VPS mới. -
VPS_KEY: Thay bằng SSH Private Key của VPS mới.
Sau khi thay xong Secrets, bạn cứ việc git add ., git commit và git push đoạn code này lên nhánh main.
Sự kỳ diệu của lệnh rollout restart: Khác với Docker Compose phải tắt web cũ đi rồi mới bật web mới lên (gây ra vài giây downtime), Kubernetes cực kỳ thông minh. Khi nhận lệnh này, nó sẽ:
-
Tạo một Pod mới chạy code mới.
-
Chờ cho đến khi Pod mới thực sự
Runningvà sẵn sàng nhận khách. -
Từ từ chuyển luồng truy cập sang Pod mới.
-
Cuối cùng mới khai tử Pod cũ. 👉 Website
quyenlt.comcủa bạn sẽ được cập nhật mà không rớt mạng một giây nào (Zero-Downtime Deployment)!
Bạn hãy sửa file, commit và push lên GitHub để kích hoạt đường ống CI/CD này chạy thử xem sao nhé. Cứ mở tab Actions trên GitHub theo dõi, nếu nó báo "tick xanh" thành công thì báo lại cho tôi, chúng ta sẽ test thử một thay đổi nhỏ trên web!

7. Tổng kết
Quá trình chuyển đổi từ Docker Compose sang Kubernetes có thể tốn chút thời gian làm quen với các khái niệm như Pod, PVC hay Ingress. Nhưng bù lại, bạn sở hữu một hệ thống mạnh mẽ, tự động hóa cao và cực kỳ chuyên nghiệp.
Nếu gặp bất kỳ khó khăn nào trong quá trình thực hành, hãy để lại bình luận phía dưới nhé!