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…

13 tháng 7, 2026 · 30 phút đọc

Đâ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:

  1. 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.
  2. Migrate stack LEMP từ chặng Docker (Compose) lên K8s — biến docker-compose.yml thà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
            ──────────────          ──────────
Chy trên:  1 máy                   Nhiu máy (cluster)
Khi li:    restart cơ bn          self-healing (reschedule cả node)
Scale:      không                   HPA (tự động)
Load balance: không sn             Service (sẵn)
Update:     thường downtime         rolling update zero-downtime
Secret:     .env (plain text)       Secret object + RBAC
Hp vi:    dev, app nhproduction, quy mô ln

Bả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 2

3.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
EOF

Giố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 = 0

Vì 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_netfilter
  • overlay: 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 --system
  • bridge-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-common

4. 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.io

4.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.list

5.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 kubectl như trên — phần Client Version chỉ là kết quả in ra khi chạy kubectl 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ùng 10.244.0.0/16. Hai chỗ phải khớp nhau, và phải khớp CNI. Mình thống nhất dùng 10.244.0.0/16 vì đó 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ùng 192.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ên 10.244.0.0/16 an 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.x

Vì 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:xxxxxxxx

Kiể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.x

Tấ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=worker

8. 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ớn

Cà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.yml

Hoặ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=90s

9.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
EOF
  • IPAddressPool: bạn "cho" MetalLB một dải IP trống. Mỗi Service LoadBalancer sẽ 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=true

Concept 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        Trthẳng vào ổ của NODE       ⚠️ hiếm dùng — buộc chặt Pod
                                                  vào 1 node, ri ro bảo mật

Ví 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ài

File 1 — namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: myapp

So 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 Deployment cho 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ùng StatefulSet — 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 cluster

So 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: 3

Khá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ếp

File 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_on như 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ùng initContainer đợ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 debug

12.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>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 pods

13. 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=admin123

Nố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.local

Lệ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 app

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

  1. 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.

  2. 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.

  3. Đừ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_on thành probe/initContainer. Dịch ý, đừng dịch từng dòng.

  4. Test self-healing TRƯỚC khi tin. Tôi chỉ thật sự yên tâm sau khi tự poweroff một node và thấy app sống. Lý thuyết không đủ — phải phá rồi nhìn nó hồi.

  5. Manifest commit git = tài liệu sống. Như docker-compose.yml ở chặng trước, thư mục myapp-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

  1. Quên tắt swap / SystemdCgroup lệch → kubelet không lên. Hai lỗi cài đặt phổ biến nhất.

  2. --pod-network-cidr không khớp CNI (hoặc đè lên LAN thật) → Pod không có mạng. Flannel dùng 10.244.0.0/16; giữ nhất quán.

  3. Cài CNI xong vẫn NotReady → kiểm kubectl get pods -n kube-system, soi log Pod CNI; thường do firewall chặn port hoặc CIDR sai.

  4. type: LoadBalancer ở on-prem kẹt <pending> → thiếu MetalLB. (Bài 04 đã cảnh báo.)

  5. 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.

  6. 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.

  7. Selector Service lệch label Pod → endpoints rỗng → "Service không kết nối". Lỗi số 1 khi migrate.

  8. 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.

  9. kubectl delete namespace myapp cuốn sạch mọi thứ trong đó kể cả PVC (mất data). Cẩn thận như compose down -v.

  10. Local Path storage rồi Pod dời node → mất data (ổ ở node cũ). Production dùng NFS/shared storage.

  11. Quên readiness probe khi rolling update → "zero-downtime" thành full-downtime (Bài 03). Đặt probe.

  12. Bê nguyên depends_on tư duy → K8s không có; dùng initContainer/probe và để reconciliation tự xử.

  13. 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.

  14. 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 init master → 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-cidr phải khớp CNI và không đè LAN thật (dùng 10.244.0.0/16 cho 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 yaml cho 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

← Bài trước
Ingress & TLS: một cổng vào, HTTPS tự động
Bài tiếp theo →
Day-2 Operations: giữ cluster sống được nhiều năm
Zalo tư vấn