Capstone: Dựng cluster on-prem & đưa Compose lên K8s
Sau bài này: từ docker compose up một file, bạn lên được một cụm production-ready chạy trên nhiều máy. Đây là lúc trả nốt câu nợ từ Bài 04: "on-prem không…
Đây là bài ráp nối của cả chặng. Chín bài trước bạn đã có đủ mảnh ghép:
- Kiến trúc & vì sao (Bài 01) — control plane, worker, kubeadm
- Pod & kubectl (Bài 02) — đơn vị nhỏ nhất, probe, debug
- ReplicaSet & Deployment (Bài 03) — self-healing, rolling update, scale, scheduling
- Service & Namespace (Bài 04) — địa chỉ cố định, DNS, vách ngăn, RBAC
- ConfigMap & Secret (Bài 05) — tách cấu hình khỏi code
- Lưu trữ & StatefulSet (Bài 06) — chạy app có trạng thái / database
- Job & CronJob (Bài 07) — workload chạy-rồi-xong
- Bảo mật & tài nguyên (Bài 08) — SecurityContext, Pod Security, QoS/limit
- Ingress & TLS (Bài 09) — định tuyến L7, HTTPS tự động bằng cert-manager
Giờ ráp tất cả lại thành một cụm thật. Mục tiêu kép:
- Tự tay dựng cluster on-prem 3 node (1 master + 2 worker) bằng kubeadm trên Ubuntu 22.04 — từ containerd, CNI, MetalLB, Ingress, tới Storage.
- Migrate stack LEMP từ chặng Docker (Compose) lên K8s — biến
docker-compose.ymlthành Deployment + Service + ConfigMap + Secret + PVC + Ingress.
Sau bài này: từ docker compose up một file, bạn lên được một cụm production-ready chạy trên nhiều máy. Đây là lúc trả nốt câu nợ từ Bài 04: "on-prem không có LoadBalancer thì làm sao?" — bằng MetalLB, tận tay.
⚠️ Đây là bài thực hành nặng. Đọc hết một lượt trước khi gõ. Nhiều bước phải làm trên mọi node — tôi sẽ ghi rõ phạm vi từng bước.
1. Tổng quan: vì sao chuyển từ Compose sang K8s
Hình dung cho dễ:
- Docker Compose = bạn trông một quán cà phê nhỏ: tự pha, tự phục vụ, tất cả trên một quầy (một máy).
- Kubernetes = bạn vận hành một chuỗi cà phê: tự phân công nhân viên, chi nhánh sập thì mở chi nhánh khác thay, đông khách thì tự thêm quầy.
Docker Compose Kubernetes
────────────── ──────────
Chạy trên: 1 máy Nhiều máy (cluster)
Khi lỗi: restart cơ bản self-healing (reschedule cả node)
Scale: không HPA (tự động)
Load balance: không sẵn Service (sẵn)
Update: thường downtime rolling update zero-downtime
Secret: .env (plain text) Secret object + RBAC
Hợp với: dev, app nhỏ production, quy mô lớnBản đồ chuyển đổi khái niệm (đáng dán lên tường)
Docker Compose Kubernetes Vai trò
────────────── ────────── ───────
docker-compose.yml Deployment + Service file cấu hình chính
service: Pod / Deployment 1 ứng dụng chạy
ports: "8080:80" Service (NodePort/LB) /Ingress mở cổng ra ngoài
volumes: PersistentVolume + PVC lưu dữ liệu
environment: ConfigMap / Secret biến môi trường
networks: CNI (Calico/Flannel) mạng nội bộ
depends_on: (initContainer / probe) thứ tự khởi động
restart: always mặc định trong Deployment tự khởi lại→ Bạn học Compose rồi nên cột bên trái quen hết. Cả bài này là điền cột bên phải.
2. Quy hoạch hạ tầng
Network: 192.168.200.0/24
┌──────────────────────────────────────────────┐
│ master-01 192.168.200.80 2 CPU / 4GB / 50GB Control Plane │
│ worker-01 192.168.200.81 4 CPU / 8GB / 100GB Worker │
│ worker-02 192.168.200.82 4 CPU / 8GB / 100GB Worker │
│ (tùy chọn) nfs-01 192.168.200.20 500GB Shared Storage │
└──────────────────────────────────────────────┘Tôi dùng dải IP ví dụ này; bạn thay bằng IP thật của mình. Giữ nguyên tư duy, đổi con số.
Checklist trước khi bắt đầu (mọi máy): Ubuntu 22.04 Server, cùng LAN ping được nhau, có sudo, hostname khác nhau, swap đã tắt, thời gian đồng bộ (NTP).
3. Chuẩn bị hệ thống — TRÊN TẤT CẢ CÁC NODE
⚠️ Phần 3, 4, 5 chạy trên mọi máy (master + worker). Phần 6 trở đi mới phân vai.
3.1 — Hostname
sudo hostnamectl set-hostname master-01 # trên master
sudo hostnamectl set-hostname worker-01 # trên worker 1
sudo hostnamectl set-hostname worker-02 # trên worker 23.2 — File hosts (trên mọi máy)
sudo tee -a /etc/hosts <<EOF
# Kubernetes Cluster
192.168.200.80 master-01
192.168.200.81 worker-01
192.168.200.82 worker-02
EOFGiống "danh bạ" — để các máy tìm nhau bằng tên thay vì nhớ IP.
3.3 — Tắt swap (BẮT BUỘC)
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab # tắt vĩnh viễn
free -h | grep Swap # Swap phải = 0Vì sao? Kubelet cần kiểm soát chính xác RAM mỗi container. Swap bật → khi hết RAM hệ thống lén dùng ổ cứng làm RAM (rất chậm), K8s không còn biết container thật sự cần bao nhiêu → lỗi khó lường. (Nhớ pitfall từ Bài 01.)
3.4 — Kernel modules
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilteroverlay: cho phép container xếp chồng các lớp filesystem (đúng cơ chế layer ở chặng Docker — Bài 02 Docker).br_netfilter: cho Linux "nhìn thấy" và lọc traffic qua bridge giữa các container.
3.5 — Sysctl networking
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --systembridge-nf-call-iptables = 1: iptables điều khiển được traffic qua bridge → kube-proxy mới định tuyến Service được (Bài 04).ip_forward = 1: máy chuyển tiếp gói giữa các interface → Pod máy này nói chuyện được Pod máy khác.
3.6 — Cập nhật & gói nền
sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release software-properties-common4. Cài Container Runtime (containerd) — MỌI NODE
Vì sao containerd, không phải Docker? Như đã nói ở Bài 01: từ K8s 1.24+, K8s dùng thẳng containerd — "phần ruột" chạy container của Docker.
Docker = containerd + nhiều thứ tiện cho người; K8s chỉ cần phần ruột.
4.1 — Cài
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y containerd.io4.2 — Cấu hình (bật SystemdCgroup — BẮT BUỘC)
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
# BẮT BUỘC: dùng systemd làm cgroup driver
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
sudo systemctl status containerd # phải "active (running)"Vì sao SystemdCgroup? Cgroup là cách Linux giới hạn CPU/RAM mỗi tiến trình. Kubelet và containerd phải dùng chung một người quản cgroup = systemd. Lệch driver → kubelet crash loop liên tục (pitfall Bài 01).
5. Cài kubeadm, kubelet, kubectl — MỌI NODE
- kubeadm = "thợ xây" dựng cluster · kubelet = "quản đốc" trên mỗi máy · kubectl = "điều khiển từ xa" (Bài 01).
5.1 — Repo Kubernetes (v1.30)
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | \
sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /" | \
sudo tee /etc/apt/sources.list.d/kubernetes.list5.2 — Cài & ghim version
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
# GHIM version — không cho apt tự upgrade (upgrade K8s phải có kế hoạch)
sudo apt-mark hold kubelet kubeadm kubectl
# Kiểm tra
kubeadm version
kubectl version --client
# Client Version: v1.30.x⚠️ Tài liệu gốc ở đoạn này bị dán nhầm output (
Client Version: ... / Kustomize Version: ...) vào chỗ lẽ ra là lệnh ghim. Lệnh đúng để khóa version làsudo apt-mark hold kubelet kubeadm kubectlnhư trên — phầnClient Versionchỉ là kết quả in ra khi chạykubectl version, không phải lệnh.
6. Khởi tạo Master Node — CHỈ TRÊN MASTER
6.1 — kubeadm init
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--apiserver-advertise-address=192.168.200.80 \
--control-plane-endpoint=192.168.200.80--pod-network-cidr=10.244.0.0/16: dải IP ảo dành cho Pod (như một LAN riêng cho container, tách khỏi LAN thật).--apiserver-advertise-address: IP thật của master — để worker biết kết nối đâu.--control-plane-endpoint: endpoint control plane (IP master, hoặc VIP nếu làm HA).
⚠️ Sửa lỗi tài liệu gốc: bản nháp ghi
--pod-network-cidr=10.10.20.0/16ở lệnh nhưng phần giải thích lại dùng10.244.0.0/16. Hai chỗ phải khớp nhau, và phải khớp CNI. Mình thống nhất dùng10.244.0.0/16vì đó là dải mặc định của Flannel (CNI ta cài ở bước 8). Nếu chọn Calico thì để Calico tự quản hoặc dùng192.168.0.0/16— miễn là dải này KHÔNG đè lên LAN thật của bạn (LAN ví dụ là192.168.200.0/24, nên10.244.0.0/16an toàn).
6.2 — Lưu lệnh join
Init xong, output có đoạn:
kubeadm join 192.168.200.80:6443 --token abc123.xxxx \
--discovery-token-ca-cert-hash sha256:xxxxxxxx📝 GHI LẠI lệnh
kubeadm join ...— cần ở bước 7. Lỡ mất, tạo lại trên master:kubeadm token create --print-join-command
6.3 — Cấu hình kubectl cho user
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
kubectl get nodes
# master-01 NotReady control-plane 1m v1.30.xVì sao NotReady? Chưa cài CNI (Bài 01). Như tòa nhà xây xong nhưng chưa lắp đường dây nội bộ — các phòng chưa liên lạc được. Cài CNI ở bước 8 là Ready.
7. Join Worker — TRÊN TỪNG WORKER
sudo kubeadm join 192.168.200.80:6443 --token abc123.xxxx \
--discovery-token-ca-cert-hash sha256:xxxxxxxxKiểm tra trên master:
kubectl get nodes
# master-01 NotReady control-plane 5m v1.30.x
# worker-01 NotReady <none> 1m v1.30.x
# worker-02 NotReady <none> 30s v1.30.xTất cả NotReady — bình thường, chưa có CNI. Gán nhãn cho dễ nhìn (tùy chọn):
kubectl label node worker-01 node-role.kubernetes.io/worker=worker
kubectl label node worker-02 node-role.kubernetes.io/worker=worker8. Cài CNI — mạng nội bộ cho Pod (TRÊN MASTER)
CNI = "hệ thống đường ống" cho Pod ở các máy khác nhau nói chuyện (Bài 01). Không có nó, node kẹt NotReady.
CNI Ưu điểm Hợp với
─── ─────── ───────
Flannel đơn giản, dễ cài cluster nhỏ, mới học
Calico mạnh, hỗ trợ NetworkPolicy production
Cilium hiện đại, eBPF, hiệu năng cao cluster lớnCài Flannel (khớp --pod-network-cidr=10.244.0.0/16 ở trên):
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.ymlHoặc Calico (cho production — hỗ trợ NetworkPolicy của Bài 04):
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yamlĐợi 1–2 phút rồi kiểm tra:
kubectl get nodes
# master-01 Ready control-plane 10m v1.30.x ← Ready!
# worker-01 Ready worker 5m v1.30.x
# worker-02 Ready worker 4m v1.30.x
kubectl get pods -n kube-system # mọi system Pod Running/Completed🎉 Cluster đã sống. Mọi node Ready.
9. MetalLB — LoadBalancer cho bare-metal (TRÊN MASTER)
Nhớ câu hỏi treo từ Bài 04? On-prem không có Cloud Controller Manager nên type: LoadBalancer kẹt <pending>. MetalLB lấp chỗ đó: nó lấy một dải IP từ LAN của bạn và gán cho các Service LoadBalancer.
9.1 — Cài
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.5/config/manifests/metallb-native.yaml
kubectl wait --namespace metallb-system --for=condition=ready pod \
--selector=app=metallb --timeout=90s9.2 — Cấp dải IP
cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 192.168.200.85-192.168.200.90 # ← dải IP TRỐNG trong LAN của bạn
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default-pool
EOFIPAddressPool: bạn "cho" MetalLB một dải IP trống. Mỗi ServiceLoadBalancersẽ lấy 1 IP từ đây.L2Advertisement: MetalLB "quảng bá" IP đó ra LAN bằng ARP (Layer 2) — kiểu "Này LAN, IP 192.168.200.85 ở chỗ tôi nhé!".
⚠️ Dải IP này phải chưa ai dùng trong LAN, nếu không sẽ xung đột.
10. Ingress Controller (Nginx) — TRÊN MASTER
Ingress (Bài 04) là "bảo vệ tòa nhà": request HTTP đến, đọc domain/path rồi chuyển đúng service. Nhưng Ingress chỉ là luật — cần Ingress Controller thực thi.
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.0/deploy/static/provider/baremetal/deploy.yaml
# Bản baremetal mặc định dùng NodePort — đổi sang LoadBalancer để xài MetalLB:
kubectl -n ingress-nginx patch svc ingress-nginx-controller \
-p '{"spec":{"type":"LoadBalancer"}}'
kubectl -n ingress-nginx get svc
# ingress-nginx-controller LoadBalancer 192.168.200.85 80:xxxxx,443:xxxxx→ MetalLB vừa cấp 192.168.200.85. Trỏ domain về IP này là truy cập được mọi app qua Ingress. Mạch nối đẹp: MetalLB cấp IP → Ingress nhận IP → định tuyến vào các Service.
11. Storage — lưu trữ cho Pod
Compose mount -v ./data:/app/data trên một máy. K8s thì Pod chạy ở bất kỳ node nào, nên cần lưu trữ mà mọi node truy cập được, hoặc cơ chế cấp ổ tự động.
Phương án A — Local Path (đơn giản, dev/test)
Dữ liệu nằm trên ổ của chính node chạy Pod.
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.26/deploy/local-path-storage.yaml
kubectl patch storageclass local-path \
-p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'⚠️ Pod dời sang node khác là mất dữ liệu (ổ nằm ở node cũ). Chỉ hợp dev/test.
Phương án B — NFS (production, dùng chung)
Dữ liệu trên một NFS Server riêng — mọi node đọc được.
# B.1 — Trên máy NFS (192.168.200.20)
sudo apt-get install -y nfs-kernel-server
sudo mkdir -p /srv/nfs/k8s && sudo chown nobody:nogroup /srv/nfs/k8s && sudo chmod 777 /srv/nfs/k8s
echo "/srv/nfs/k8s 192.168.200.0/24(rw,sync,no_subtree_check,no_root_squash)" | sudo tee -a /etc/exports
sudo exportfs -rav && sudo systemctl restart nfs-kernel-server
# B.2 — Trên MỌI node K8s
sudo apt-get install -y nfs-common
# B.3 — Trên master: cài provisioner qua Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-subdir-external-provisioner \
nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
--set nfs.server=192.168.200.20 \
--set nfs.path=/srv/nfs/k8s \
--set storageClass.defaultClass=trueConcept quan trọng — PV vs PVC: PersistentVolume (PV) = ổ đĩa thật (ai đó cấp). PersistentVolumeClaim (PVC) = "đơn đặt hàng" của bạn ("tôi cần 10GB"). StorageClass + provisioner ở trên tự động tạo PV để khớp PVC — bạn chỉ cần viết PVC, không phải dựng PV bằng tay.
Không phải volume nào cũng "bền"
PVC là cho dữ liệu sống lâu. Nhưng K8s còn vài loại volume khác, dùng đúng chỗ:
Loại volume Sống được bao lâu Dùng cho
─────────── ───────────────── ────────
PVC (PV) Lâu hơn Pod (bền thật) DB, upload, dữ liệu cần giữ
emptyDir Sống bằng tuổi POD (Pod chết chia file giữa các container
là mất) trong 1 Pod (sidecar — Bài 02),
thư mục tạm, cache
configMap/ Mount ConfigMap/Secret thành file cấu hình, cert, key
secret (volume) FILE trong container (nginx.conf, .pem...)
hostPath Trỏ thẳng vào ổ của NODE ⚠️ hiếm dùng — buộc chặt Pod
vào 1 node, rủi ro bảo mậtVí dụ mount một ConfigMap thành file (không phải biến env) — rất hay dùng để nhét nginx.conf:
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d
volumes:
- name: nginx-conf
configMap: { name: nginx-config } # mỗi key trong ConfigMap thành 1 file⚠️ Đừng nhầm: emptyDir KHÔNG bền — Pod bị xóa/dời là sạch. Lỡ để DB ghi vào emptyDir = mất data khi Pod tái tạo. DB phải dùng PVC.
12. MIGRATE: Docker Compose → Kubernetes
Phần quan trọng nhất. Lấy một stack quen từ chặng Docker: webapp (nginx) + database (mysql).
12.1 — Compose gốc
version: '3.8'
services:
webapp:
image: nginx:latest
ports: ["80:80"]
environment:
- APP_ENV=production
- DB_HOST=database
depends_on: [database]
restart: always
database:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=secretpass123
- MYSQL_DATABASE=myapp
volumes: [db-data:/var/lib/mysql]
restart: always
volumes:
db-data:12.2 — Sang K8s: cấu trúc thư mục
myapp-k8s/
├── namespace.yaml tách "không gian" riêng cho app
├── configmap.yaml biến môi trường thường
├── secret.yaml biến nhạy cảm (password)
├── mysql-pvc.yaml đơn đặt hàng ổ đĩa
├── mysql-deployment.yaml database
├── mysql-service.yaml cổng nội bộ cho DB
├── webapp-deployment.yaml ứng dụng web
├── webapp-service.yaml cổng nội bộ cho web
└── webapp-ingress.yaml domain/URL ra ngoàiFile 1 — namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: myappSo với Compose: Compose không có khái niệm này — mọi container chung một không gian. (Namespace = Bài 04.)
File 2 — configmap.yaml (thay environment thường)
apiVersion: v1
kind: ConfigMap
metadata:
name: webapp-config
namespace: myapp
data:
APP_ENV: "production"
DB_HOST: "mysql-service" # ← TÊN SERVICE của MySQL, thay cho container nameĐể ý
DB_HOST: mysql-service— đúng tinh thần "gọi nhau bằng tên service" của Compose, giờ là tên Service K8s (Bài 04).
File 3 — secret.yaml (thay environment nhạy cảm)
apiVersion: v1
kind: Secret
metadata:
name: mysql-secret
namespace: myapp
type: Opaque
stringData: # stringData = tự encode base64 giúp bạn
MYSQL_ROOT_PASSWORD: "secretpass123"
MYSQL_DATABASE: "myapp"So với Compose: thay cho .env (vốn plain text). ⚠️ Secret K8s mặc định chỉ là base64, KHÔNG phải mã hóa — ai đọc được etcd vẫn giải ra. Production thật nên bật encryption-at-rest cho etcd hoặc dùng Vault/Sealed Secrets. Nhưng vẫn hơn .env vì tách khỏi code, phân quyền được bằng RBAC.
Cách khác — tạo Secret bằng lệnh:
kubectl create secret generic mysql-secret \ --from-literal=MYSQL_ROOT_PASSWORD=secretpass123 \ --from-literal=MYSQL_DATABASE=myapp -n myapp
File 4 — mysql-pvc.yaml (thay volumes:)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data
namespace: myapp
spec:
accessModes: [ReadWriteOnce] # 1 node ghi tại 1 thời điểm
resources:
requests:
storage: 10Gi # "tôi cần 10GB"
# storageClassName: nfs-client # bỏ comment nếu dùng NFS (bước 11B)File 5 — mysql-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: myapp
labels: { app: mysql }
spec:
replicas: 1 # DB: 1 bản (DB thật nên StatefulSet — xem ghi chú)
selector:
matchLabels: { app: mysql }
template:
metadata:
labels: { app: mysql }
spec:
containers:
- name: mysql
image: mysql:8.0
ports: [{ containerPort: 3306 }]
envFrom:
- secretRef: { name: mysql-secret } # nạp mọi key trong Secret
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql
resources: # KHÁC Compose: giới hạn rõ
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
volumes:
- name: mysql-storage
persistentVolumeClaim:
claimName: mysql-data⚠️ Ghi chú trung thực: ví dụ dùng
Deploymentcho MySQL để bám sát tài liệu gốc và cho dễ học. Nhưng đúng chuẩn (Bài 03): database nên dùngStatefulSet— mỗi replica cần danh tính + ổ riêng ổn định. Với 1 bản học tập thì Deployment chạy được; production thật, hãy chuyển sang StatefulSet.
File 6 — mysql-service.yaml (ClusterIP nội bộ)
apiVersion: v1
kind: Service
metadata:
name: mysql-service # tên này làm "hostname" cho webapp gọi
namespace: myapp
spec:
selector: { app: mysql } # khớp label Pod — nhớ Bài 04!
ports:
- port: 3306
targetPort: 3306
type: ClusterIP # chỉ gọi từ trong clusterSo với Compose: trong Compose container tự tìm nhau qua tên database. Ở K8s, webapp gọi MySQL qua mysql-service:3306 (cùng ns) hoặc mysql-service.myapp.svc.cluster.local:3306 (FQDN).
File 7 — webapp-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
namespace: myapp
spec:
replicas: 2 # 2 bản → High Availability (Compose không có)
selector:
matchLabels: { app: webapp }
strategy: # rolling update — Bài 03
type: RollingUpdate
rollingUpdate: { maxUnavailable: 1, maxSurge: 1 }
template:
metadata:
labels: { app: webapp }
spec:
containers:
- name: webapp
image: nginx:latest
ports: [{ containerPort: 80 }]
envFrom:
- configMapRef: { name: webapp-config } # nạp ConfigMap
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
livenessProbe: # K8s tự canh app còn sống — Bài 02
httpGet: { path: /, port: 80 }
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe: # cắt traffic tới Pod chưa sẵn sàng
httpGet: { path: /, port: 80 }
initialDelaySeconds: 5
periodSeconds: 3Khác Compose rõ nhất ở đây: replicas: 2 (nhiều bản), strategy: RollingUpdate (zero-downtime), liveness/readiness (tự kiểm sức khỏe), resources (giới hạn rõ). Tất cả là kiến thức Bài 02–03 ráp lại.
File 8 — webapp-service.yaml
apiVersion: v1
kind: Service
metadata:
name: webapp-service
namespace: myapp
spec:
selector: { app: webapp }
ports: [{ port: 80, targetPort: 80 }]
type: ClusterIP # để Ingress đứng trước, không phơi trực tiếpFile 9 — webapp-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapp-ingress
namespace: myapp
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: myapp.example.com # domain của bạn (trỏ về IP MetalLB ở bước 10)
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webapp-service
port: { number: 80 }12.3 — Triển khai đúng thứ tự
kubectl apply -f namespace.yaml # 1. không gian trước
kubectl apply -f configmap.yaml -f secret.yaml
kubectl apply -f mysql-pvc.yaml
kubectl apply -f mysql-deployment.yaml -f mysql-service.yaml
kubectl -n myapp wait --for=condition=ready pod -l app=mysql --timeout=120s
kubectl apply -f webapp-deployment.yaml -f webapp-service.yaml -f webapp-ingress.yaml
# Hoặc apply cả thư mục (K8s tự xử phần lớn phụ thuộc — declarative mà):
# kubectl apply -f myapp-k8s/💡 Để ý: ta không cần
depends_onnhư Compose. K8s liên tục đối chiếu (reconciliation — Bài 01); webapp lỡ lên trước DB thì readiness fail, K8s tự thử lại tới khi DB sẵn sàng. Muốn chắc thứ tự, dùnginitContainerđợi DB (Bài 02).
12.4 — Kiểm tra
kubectl -n myapp get all # toàn cảnh
kubectl -n myapp get pods -o wide # Pod rải node nào
kubectl -n myapp logs -f deployment/webapp # log (như docker logs)
kubectl -n myapp exec -it <pod> -- /bin/bash # vào trong (như docker exec)
kubectl -n myapp get events --sort-by='.lastTimestamp' # vàng khi debug12.5 — Bảng lệnh Docker ↔ kubectl (dán lên tường)
Mục đích Docker / Compose kubectl
──────── ──────────────── ───────
Xem container/Pod docker ps kubectl get pods
Xem log docker logs <name> kubectl logs <pod>
Vào trong docker exec -it <name> bash kubectl exec -it <pod> -- bash
Triển khai docker compose up -d kubectl apply -f .
Gỡ docker compose down kubectl delete -f .
Scale compose up --scale web=3 kubectl scale deploy webapp --replicas=3
Xem "network" docker network ls kubectl get svc
Xem volume docker volume ls kubectl get pvc
Build image docker build -t app . (build ngoài, push lên registry)
Xem tài nguyên docker stats kubectl top pods13. Công cụ quản lý & Monitoring
# K9s — terminal UI, "Task Manager" cho K8s (khuyên dùng)
snap install k9s # rồi gõ: k9s
# Kubernetes Dashboard (web UI)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml
# (tạo admin ServiceAccount + ClusterRoleBinding, rồi:)
kubectl -n kubernetes-dashboard create token admin-user # lấy token
kubectl proxy # truy cập qua localhost:8001
# Lens — GUI desktop: tải tại https://k8slens.dev, import ~/.kube/config
# Prometheus + Grafana — monitoring (Bài "observability" chặng sau)
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring --create-namespace --set grafana.adminPassword=admin123Nối với chặng trước: chặng Docker đã nhắc Prometheus + Grafana + Loki. Trên K8s, chúng scrape metric Pod/node và gom log cả cluster — đây là bước "observability" tự nhiên sau khi cluster chạy.
14. Troubleshooting — bảng cấp cứu
Quy trình bất biến (từ Bài 02): describe xem Events → logs --previous xem app nói gì → get events.
# Node NotReady
sudo systemctl status kubelet && sudo journalctl -u kubelet -f
# → thường: CNI chưa cài / swap chưa tắt / containerd lỗi (restart containerd)
# Pod CrashLoopBackOff
kubectl -n <ns> logs <pod> --previous # app crash vì gì
kubectl -n <ns> describe pod <pod>
# → thường: image sai, thiếu env, port conflict, thiếu tài nguyên
# Pod Pending
kubectl -n <ns> describe pod <pod>
# → hết tài nguyên node / PVC chưa bind (sai StorageClass) / taint chặn
# Service không truy cập được (← bài học máu của Bài 04)
kubectl -n <ns> get endpoints <svc>
# → ENDPOINTS rỗng = selector không khớp label Pod
kubectl -n <ns> get pods --show-labels
# LoadBalancer kẹt <pending>
# → on-prem: chưa cài/đủ MetalLB (bước 9)
# Debug mạng nội bộ bằng Pod tạm
kubectl run debug --image=busybox -it --rm -- sh
# nslookup mysql-service.myapp.svc.cluster.local
# wget -qO- http://webapp-service.myapp.svc.cluster.localLệnh tổng quan & backup:
kubectl cluster-info
kubectl top nodes ; kubectl top pods -n <ns> # cần Metrics Server
kubectl get all -n myapp -o yaml > backup-myapp.yaml # backup manifest 1 appBackup etcd — cứu cả cluster (pitfall Bài 01, giờ làm thật):
kubectl get ... -o yaml chỉ lưu manifest từng app. Muốn cứu toàn bộ trạng thái cluster (mọi ns, secret, config) phải snapshot etcd — chạy trên master:
# Snapshot (đường dẫn cert theo chuẩn kubeadm)
sudo ETCDCTL_API=3 etcdctl snapshot save /var/backups/etcd-$(date +%F).db \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key
# Kiểm tra snapshot
sudo ETCDCTL_API=3 etcdctl snapshot status /var/backups/etcd-*.db --write-out=table⚠️ Snapshot này là bảo hiểm nhân thọ của cluster. Mất etcd mà không có nó = dựng lại từ đầu. Lên lịch cron chạy hằng ngày, copy file ra ngoài master. Khôi phục bằng
etcdctl snapshot restore(làm khi control plane đang dừng — đọc kỹ docs trước khi restore production).
Câu chuyện thực tế
Đây là đoạn kết của câu chuyện startup xuyên suốt cả chặng. Sau đêm 502 lúc 21:47 (Bài 01), tôi bỏ 4 ngày dựng cluster. Nhưng phần khó nhất không phải dựng cluster — mà là migrate.
Tôi mắc đúng cái bẫy mình cảnh báo bạn ở Bài 04: deploy stack đầu tiên, Service db báo <none> endpoints, webapp timeout. Mất 40 phút mới nhớ ra kubectl get endpoints — selector app: db lệch label app: database. Sửa một chữ, sống. Rồi type: LoadBalancer kẹt <pending> vì tôi quên on-prem cần MetalLB. Rồi MySQL Deployment 2 replica (tôi vô ý copy replicas: 2 từ webapp) khiến 2 Pod cùng ghi 1 volume → data corrupt — đúng pitfall "scale stateful như stateless" của Bài 03.
Mỗi cái sai đều là một pitfall tôi đã viết ra cho bạn. Tôi không bịa chúng — tôi tự vấp từng cái. Đó là lý do chúng nằm trong bài.
Khi mọi thứ cuối cùng xanh, tôi làm điều đã làm ở Bài 03: poweroff worker-01 trước mặt CEO. Các Pod dời sang worker-02 trong ~30 giây, myapp.example.com không nhịp lỗi nào. Onboarding dev mới giờ là: git clone repo manifest → kubectl apply -f myapp-k8s/ → vài phút có cả stack. Y hệt ROI của Compose ở chặng trước, nhưng lần này chạy trên nhiều máy và tự sống sót khi máy chết.
Bài học:
Migrate là nơi mọi pitzfall hội tụ. Selector lệch, LoadBalancer pending, stateful scale sai — bạn sẽ gặp đủ. Tin tốt: mỗi cái có một lệnh chẩn đoán, và bạn đã học hết ở Bài 02–04.
Dựng cluster là việc một lần; migrate đúng tư duy là việc cả đời. Hiểu bản đồ Compose↔K8s quan trọng hơn thuộc lệnh kubeadm.
Đừng bê y nguyên Compose lên. DB phải StatefulSet, không phải Deployment 2 bản. Secret không phải mã hóa thật.
depends_onthành probe/initContainer. Dịch ý, đừng dịch từng dòng.Test self-healing TRƯỚC khi tin. Tôi chỉ thật sự yên tâm sau khi tự
poweroffmột node và thấy app sống. Lý thuyết không đủ — phải phá rồi nhìn nó hồi.Manifest commit git = tài liệu sống. Như
docker-compose.ymlở chặng trước, thư mụcmyapp-k8s/vừa là cấu hình, vừa là tài liệu, vừa là cách onboard dev trong vài phút.
Pitfalls
Quên tắt swap / SystemdCgroup lệch → kubelet không lên. Hai lỗi cài đặt phổ biến nhất.
--pod-network-cidrkhông khớp CNI (hoặc đè lên LAN thật) → Pod không có mạng. Flannel dùng10.244.0.0/16; giữ nhất quán.Cài CNI xong vẫn
NotReady→ kiểmkubectl get pods -n kube-system, soi log Pod CNI; thường do firewall chặn port hoặc CIDR sai.type: LoadBalancerở on-prem kẹt<pending>→ thiếu MetalLB. (Bài 04 đã cảnh báo.)Dải IP MetalLB trùng IP đang dùng trong LAN → xung đột, mất mạng lung tung. Phải dùng dải trống.
Dùng Deployment + nhiều replica cho database → nhiều Pod ghi đè 1 volume → hỏng data. DB dùng StatefulSet, hoặc ít nhất 1 replica + volume riêng.
Selector Service lệch label Pod → endpoints rỗng → "Service không kết nối". Lỗi số 1 khi migrate.
Tưởng Secret là mã hóa. Mặc định chỉ base64. Bật encryption-at-rest cho etcd / dùng Vault nếu cần thật.
kubectl delete namespace myappcuốn sạch mọi thứ trong đó kể cả PVC (mất data). Cẩn thận nhưcompose down -v.Local Path storage rồi Pod dời node → mất data (ổ ở node cũ). Production dùng NFS/shared storage.
Quên readiness probe khi rolling update → "zero-downtime" thành full-downtime (Bài 03). Đặt probe.
Bê nguyên
depends_ontư duy → K8s không có; dùng initContainer/probe và để reconciliation tự xử.Mount bind code vào Pod như Compose dev → production phải build image, push registry, deploy bằng tag. Bind mount không hợp cluster.
Không backup etcd → mất cả cluster state (Bài 01). Backup định kỳ.
Tóm tắt
- Dựng cluster on-prem (kubeadm): chuẩn bị mọi node (hostname, hosts, tắt swap, kernel module, sysctl) → containerd (
SystemdCgroup=true) → kubeadm/kubelet/kubectl (ghim version) →kubeadm initmaster → join worker. - Addon để cluster "sống": CNI (Flannel/Calico — hết
NotReady), MetalLB (LoadBalancer cho bare-metal), Ingress Controller (L7), Storage (Local Path dev / NFS prod). --pod-network-cidrphải khớp CNI và không đè LAN thật (dùng10.244.0.0/16cho Flannel).- Migrate Compose→K8s theo bản đồ: service→Deployment, ports→Service/Ingress, volumes→PVC, environment→ConfigMap/Secret, networks→CNI, depends_on→probe/initContainer.
- K8s thêm thứ Compose không có: nhiều replica, rolling update, liveness/readiness, requests/limits, namespace.
- Database ≠ Deployment — dùng StatefulSet. Secret ≠ mã hóa — chỉ base64.
- Volume: PVC (bền), emptyDir (sống theo Pod — chia file giữa container), configMap/secret mount thành file, hostPath (tránh). DB phải PVC.
- Backup:
kubectl get -o yamlcho từng app; snapshot etcd cho cả cluster — lên lịch định kỳ. - Troubleshooting:
describe+logs --previous+get events; Service lỗi →get endpoints; LB pending → MetalLB;Forbidden→ RBAC (auth can-i). - Công cụ: K9s (terminal), Lens/Dashboard (GUI), Prometheus+Grafana (monitoring).
Bài sau (Bài 11 — Day-2 Operations) là bài cuối chặng, và là bài quyết định cluster bạn vừa dựng sống được 3 năm hay chết sau 12 tháng. Vì có một quả bom hẹn giờ vừa được kích hoạt ngay lúc bạn gõ kubeadm init: chứng chỉ của control plane hết hạn sau đúng 1 năm. Cùng với đó: bảo trì node mà không downtime (cordon/drain), nâng cấp K8s (kubeadm upgrade), dựng HA control plane 3 master, và khôi phục etcd — bài này mới dạy backup, chưa dạy restore.
Nếu hôm nay bạn dựng xong cụm 3 node và đưa được stack Compose lên đó — bạn đã làm được điều mà 6 tháng trước còn là docker compose up trên một con VM đơn độc. 🚀
— Minh Hưng