Bài này hướng dẫn dựng từ đầu một cụm Kubernetes 1.33 có control plane chịu lỗi trên bốn máy Ubuntu 24.04, chạy một website tin tức WordPress và đưa ra Internet qua Cloudflare Tunnel mà không cần mở cổng trên router. Phần cuối thêm giao diện quản trị Rancher, chỉ truy cập được qua Tailscale. Mọi lệnh đều đã được chạy thật trên cụm bốn node. Cuối bài có các phép thử độ sẵn sàng để bạn tự kiểm chứng, kèm số liệu đo được.
Bước 0: chuẩn bị và quy hoạch địa chỉ
Phần cứng tối thiểu: 4 máy (máy thật hoặc VM) Ubuntu 24.04, mỗi máy từ 4 vCPU, 8 GB RAM và 50 GB đĩa trở lên, nằm chung một mạng LAN, có Internet, và có user được sudo. Tắt swap nếu đang bật (swapoff -a và bỏ dòng swap trong /etc/fstab).
Quy hoạch địa chỉ. Bài dùng các giá trị dưới đây; hãy thay bằng giá trị trong mạng của bạn ở mọi lệnh. Dải VIP và LoadBalancer phải là IP chưa dùng và nằm ngoài dải DHCP của router.
| Mục | Giá trị ví dụ |
|---|---|
| node1, node2, node3 (control plane + chạy ứng dụng) | 192.168.10.11, .12, .13 |
| node4 (worker) | 192.168.10.14 |
| Card mạng LAN trên mỗi máy | ens33 (xem bằng ip -br a) |
| VIP của API server | 192.168.10.10 |
| Dải IP LoadBalancer (MetalLB) | 192.168.10.20 – 192.168.10.29 |
| IP của Ingress (Traefik) | 192.168.10.20 |
| Pod CIDR / Service CIDR | 10.244.0.0/16 / 10.96.0.0/12 (không được trùng LAN) |
| Tên miền website | tintuc.example.com (quản lý trên Cloudflare) |
Trừ khi có ghi chú khác, mọi lệnh chạy bằng root (sudo -i).
Bước 1: chuẩn bị cả 4 node
Chạy nguyên khối lệnh dưới đây trên từng máy. Nó nạp kernel module, bật chuyển tiếp gói tin, cài containerd (dùng cgroup systemd), cài kubeadm/kubelet/kubectl 1.33, cài các gói Longhorn cần (open-iscsi, nfs-common), và tắt multipathd vì dịch vụ này chiếm các thiết bị khối của Longhorn.
IFACE=ens33 # doi theo card LAN cua ban
printf 'overlay\nbr_netfilter\n' > /etc/modules-load.d/k8s.conf
modprobe overlay; modprobe br_netfilter
cat > /etc/sysctl.d/60-k8s.conf <<'EOF'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 524288
EOF
sysctl --system >/dev/null
systemctl disable --now multipathd.socket multipathd.service 2>/dev/null
apt-get update
apt-get install -y containerd nfs-common open-iscsi apt-transport-https ca-certificates curl gpg jq
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl restart containerd; systemctl enable containerd iscsid
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | 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.33/deb/ /' > /etc/apt/sources.list.d/kubernetes.list
apt-get update && apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl
# kubelet luon dung IP LAN (quan trong neu may co them VPN: tailscale, wireguard...)
echo "KUBELET_EXTRA_ARGS=--node-ip=$(ip -4 -o addr show $IFACE | awk '{print $4}' | cut -d/ -f1)" > /etc/default/kubelet
systemctl enable kubelet
Kiểm tra: kubeadm version -o short in ra v1.33.x, và grep SystemdCgroup /etc/containerd/config.toml in ra true.
Bước 2: khởi tạo control plane đầu tiên (node1) với VIP
kube-vip chạy dưới dạng static pod trên mỗi control plane. Nó giữ VIP 192.168.10.10 và chuyển VIP sang node khác khi node đang giữ bị hỏng. Trên node1, sinh manifest kube-vip:
KVVER=$(curl -fsSL https://api.github.com/repos/kube-vip/kube-vip/releases/latest | jq -r .tag_name)
ctr -n k8s.io image pull ghcr.io/kube-vip/kube-vip:$KVVER
mkdir -p /etc/kubernetes/manifests
ctr -n k8s.io run --rm --net-host ghcr.io/kube-vip/kube-vip:$KVVER vip /kube-vip manifest pod \
--interface ens33 --address 192.168.10.10 --controlplane --arp --leaderElection \
> /etc/kubernetes/kube-vip.yaml
# Tu K8s 1.29, luc kubeadm init admin.conf chua co quyen -> node DAU TIEN tam dung super-admin.conf
sed 's#path: /etc/kubernetes/admin.conf#path: /etc/kubernetes/super-admin.conf#' \
/etc/kubernetes/kube-vip.yaml > /etc/kubernetes/manifests/kube-vip.yaml
Tạo file cấu hình kubeadm /root/kubeadm-init.yaml:
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.10.11
bindPort: 6443
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.33.13 # dung dung ban da cai: kubeadm version -o short
clusterName: k8s-lab
controlPlaneEndpoint: 192.168.10.10:6443
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
apiServer:
certSANs: ["192.168.10.10", "192.168.10.11", "192.168.10.12", "192.168.10.13"]
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
serverTLSBootstrap: true # kubelet xin chung chi serving do CA cua cum ky (dung o buoc 8)
Khởi tạo, rồi trả kube-vip về dùng admin.conf:
kubeadm init --config /root/kubeadm-init.yaml --upload-certs | tee /root/kubeadm-init.log
cp /etc/kubernetes/kube-vip.yaml /etc/kubernetes/manifests/kube-vip.yaml
mkdir -p ~/.kube && cp /etc/kubernetes/admin.conf ~/.kube/config
ip -br a show ens33 # phai thay ca 192.168.10.11 va VIP 192.168.10.10
kubectl get nodes # node1 NotReady: binh thuong, chua co CNI
Cuối output của kubeadm init có hai lệnh kubeadm join: một lệnh cho control plane (có --control-plane --certificate-key) và một lệnh cho worker. Lưu cả hai lại. Token hết hạn sau 24 giờ và certificate key sau 2 giờ; nếu hết hạn, sinh lại bằng:
kubeadm token create --print-join-command # lenh join worker
kubeadm init phase upload-certs --upload-certs | tail -1 # certificate-key moi cho control plane
Bước 3: join node2, node3 (control plane) và node4 (worker)
Trên node2, rồi mới đến node3 (lần lượt từng máy vì etcd thêm thành viên tuần tự), chạy lệnh join cho control plane lấy từ bước 2, sau đó mới đặt manifest kube-vip. Nếu đặt trước, bước kiểm tra lúc join sẽ báo lỗi vì thư mục manifests/ không trống.
kubeadm join 192.168.10.10:6443 --token <...> --discovery-token-ca-cert-hash sha256:<...> \
--control-plane --certificate-key <...>
# chep /etc/kubernetes/kube-vip.yaml tu node1 sang (ban dung admin.conf), roi:
cp kube-vip.yaml /etc/kubernetes/manifests/kube-vip.yaml
mkdir -p ~/.kube && cp /etc/kubernetes/admin.conf ~/.kube/config
Các dòng cảnh báo can only promote a learner member which is in sync with leader trong lúc join là bình thường: etcd đang đồng bộ thành viên mới. Trên node4, chạy lệnh join cho worker (lệnh không có --control-plane).
Bốn node đều chạy ứng dụng, nên bỏ taint trên các control plane. Từ bước này trở đi, các lệnh kubectl/helm đều chạy trên node1.
kubectl taint nodes node1 node2 node3 node-role.kubernetes.io/control-plane:NoSchedule-
kubectl -n kube-system get pods -l component=etcd # 3 etcd Running
Bước 4: mạng pod với Calico
Từ Calico 3.30, CRD của operator nằm trong file riêng và phải cài trước. Không dùng pod CIDR mặc định 192.168.0.0/16 của Calico, vì nó trùng với hầu hết các mạng LAN.
CV=$(curl -fsSL https://api.github.com/repos/projectcalico/calico/releases/latest | jq -r .tag_name)
kubectl apply --server-side -f https://raw.githubusercontent.com/projectcalico/calico/$CV/manifests/operator-crds.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/projectcalico/calico/$CV/manifests/tigera-operator.yaml
kubectl wait --for condition=established crd/installations.operator.tigera.io --timeout=60s
cat <<'EOF' | kubectl apply -f -
apiVersion: operator.tigera.io/v1
kind: Installation
metadata: {name: default}
spec:
calicoNetwork:
nodeAddressAutodetectionV4: {interface: ens33}
ipPools:
- {name: default-ipv4-ippool, cidr: 10.244.0.0/16, blockSize: 26,
encapsulation: VXLANCrossSubnet, natOutgoing: Enabled, nodeSelector: all()}
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata: {name: default}
spec: {}
EOF
kubectl get nodes -w # cho ca 4 node Ready (1-2 phut), Ctrl+C de thoat
Bước 5: Helm và MetalLB
Trên máy chủ thường không có cloud provider, nên service kiểu LoadBalancer sẽ mãi ở trạng thái <pending>. MetalLB lấp chỗ trống này bằng cách cấp IP thật trong LAN và quảng bá IP đó bằng ARP.
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm repo add metallb https://metallb.github.io/metallb
helm repo add traefik https://traefik.github.io/charts
helm repo add longhorn https://charts.longhorn.io
helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update
helm install metallb metallb/metallb -n metallb-system --create-namespace --wait
cat <<'EOF' | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata: {name: lan-pool, namespace: metallb-system}
spec: {addresses: ["192.168.10.20-192.168.10.29"]}
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata: {name: lan-l2, namespace: metallb-system}
spec: {ipAddressPools: ["lan-pool"], interfaces: ["ens33"]}
EOF
Bước 6: Ingress controller Traefik
Dự án ingress-nginx đã ngừng phát triển từ tháng 3/2026, vì vậy bài dùng Traefik. Traefik hỗ trợ cả Ingress lẫn Gateway API. Tạo file traefik-values.yaml:
deployment:
replicas: 2
service:
annotations:
metallb.io/loadBalancerIPs: 192.168.10.20
spec:
externalTrafficPolicy: Local
ingressClass:
enabled: true
isDefaultClass: true
providers:
kubernetesIngress:
publishedService: {enabled: true}
affinity: # 2 ban Traefik nam tren 2 node khac nhau
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels: {app.kubernetes.io/name: traefik}
topologyKey: kubernetes.io/hostname
podDisruptionBudget: {enabled: true, minAvailable: 1}
ports:
web:
forwardedHeaders:
trustedIPs: ["10.244.0.0/16"] # tin X-Forwarded-Proto tu cloudflared (chay trong pod) -> tranh redirect vong
helm install traefik traefik/traefik -n traefik --create-namespace -f traefik-values.yaml --wait
kubectl -n traefik get svc traefik # EXTERNAL-IP = 192.168.10.20
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.10.20/ # 404 = Traefik da nhan request
Bước 7: lưu trữ phân tán Longhorn
Longhorn biến đĩa của các node thành volume có nhiều bản sao. Khi một node chết, pod được tạo lại ở node khác và vẫn có dữ liệu của mình.
helm install longhorn longhorn/longhorn -n longhorn-system --create-namespace \
--set persistence.defaultClass=true \
--set persistence.defaultClassReplicaCount=3 \
--set defaultSettings.defaultReplicaCount=3 --wait --timeout 10m
kubectl -n longhorn-system get nodes.longhorn.io # cho ca 4 node READY=True
# Mac dinh, pod StatefulSet tren node chet KHONG BAO GIO duoc tao lai -> bat chinh sach nay
kubectl -n longhorn-system patch settings.longhorn.io node-down-pod-deletion-policy \
--type=merge -p '{"value":"delete-both-statefulset-and-deployment-pod"}'
Bước 8: metrics-server (có kiểm tra TLS)
HPA cần metrics-server. Nhiều hướng dẫn thêm cờ --kubelet-insecure-tls, tức là tắt việc kiểm tra chứng chỉ của kubelet. Không cần làm vậy. Ở bước 2 ta đã bật serverTLSBootstrap, nên mỗi kubelet đã xin một chứng chỉ serving. Việc còn lại là duyệt các yêu cầu đó, và chỉ duyệt khi người xin đúng là node của cụm:
# node2-4 da join voi cau hinh nay; tren node1 can khoi dong lai kubelet 1 lan de gui CSR
systemctl restart kubelet; sleep 10
kubectl get csr -o json | jq -r '.items[]
| select(.spec.signerName=="kubernetes.io/kubelet-serving" and (.status.conditions|not))
| select(.spec.username|test("^system:node:node[1-4]$")) | .metadata.name' \
| xargs -r kubectl certificate approve
helm install metrics-server metrics-server/metrics-server -n kube-system \
--set 'args={--kubelet-preferred-address-types=InternalIP}' --wait
kubectl top nodes # co so lieu CPU/RAM la dat
Chứng chỉ này tự gia hạn khoảng mỗi năm và sinh ra CSR mới; bạn phải duyệt lại, hoặc cài kubelet-csr-approver để tự duyệt theo quy tắc. Nếu không duyệt, kubectl top và HPA sẽ ngừng hoạt động.
Bước 9: triển khai MariaDB và WordPress
Tạo namespace và mật khẩu ngẫu nhiên:
kubectl create namespace tintuc
kubectl -n tintuc create secret generic tintuc-db \
--from-literal=password=$(openssl rand -hex 16) --from-literal=root-password=$(openssl rand -hex 16)
kubectl -n tintuc create secret generic tintuc-admin --from-literal=password=$(openssl rand -base64 18 | tr -d '/+=')
mkdir -p ~/tintuc && cd ~/tintuc
10-mariadb.yaml: database dạng StatefulSet, dữ liệu nằm trên volume Longhorn. tolerationSeconds: 30 khiến pod rời node chết sau 30 giây thay vì 300 giây mặc định.
apiVersion: v1
kind: Service
metadata: {name: mariadb, namespace: tintuc}
spec:
clusterIP: None
selector: {app: mariadb}
ports: [{name: mysql, port: 3306}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: mariadb, namespace: tintuc}
spec:
serviceName: mariadb
replicas: 1
selector: {matchLabels: {app: mariadb}}
template:
metadata: {labels: {app: mariadb}}
spec:
terminationGracePeriodSeconds: 60
tolerations: # node chet -> doi pod sau 30s thay vi 300s mac dinh
- {key: node.kubernetes.io/not-ready, operator: Exists, effect: NoExecute, tolerationSeconds: 30}
- {key: node.kubernetes.io/unreachable, operator: Exists, effect: NoExecute, tolerationSeconds: 30}
containers:
- name: mariadb
image: mariadb:11.4
args: ["--innodb-buffer-pool-size=512M", "--max-connections=300"]
env:
- {name: MARIADB_DATABASE, value: wordpress}
- {name: MARIADB_USER, value: wordpress}
- {name: MARIADB_PASSWORD, valueFrom: {secretKeyRef: {name: tintuc-db, key: password}}}
- {name: MARIADB_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: tintuc-db, key: root-password}}}
ports: [{containerPort: 3306, name: mysql}]
volumeMounts: [{name: data, mountPath: /var/lib/mysql}]
resources:
requests: {cpu: 250m, memory: 768Mi}
limits: {memory: 1Gi}
readinessProbe:
exec: {command: ["healthcheck.sh", "--connect", "--innodb_initialized"]}
periodSeconds: 5
livenessProbe:
exec: {command: ["healthcheck.sh", "--connect"]}
initialDelaySeconds: 60
periodSeconds: 10
volumeClaimTemplates:
- metadata: {name: data}
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources: {requests: {storage: 10Gi}}
20-wordpress.yaml: WordPress, service, PDB, Ingress, và middleware tự thử lại request. Thư mục wp-content (ảnh upload, theme, plugin) nằm trên volume RWX để mọi pod dùng chung. Bắt đầu với 1 pod: lần chạy đầu, image WordPress tự chép mã nguồn vào volume, và nhiều pod cùng chép sẽ ghi chồng lên nhau.
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: wp-content, namespace: tintuc}
spec:
accessModes: [ReadWriteMany] # 3+ pod cung doc/ghi wp-content (anh upload, theme, plugin)
storageClassName: longhorn
resources: {requests: {storage: 10Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: wordpress, namespace: tintuc}
spec:
replicas: 1 # cai dat xong WordPress moi tang len (buoc 11)
strategy: {type: RollingUpdate, rollingUpdate: {maxUnavailable: 0, maxSurge: 1}}
selector: {matchLabels: {app: wordpress}}
template:
metadata: {labels: {app: wordpress}}
spec:
tolerations: # node chet -> doi pod sau 30s thay vi 300s mac dinh
- {key: node.kubernetes.io/not-ready, operator: Exists, effect: NoExecute, tolerationSeconds: 30}
- {key: node.kubernetes.io/unreachable, operator: Exists, effect: NoExecute, tolerationSeconds: 30}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector: {matchLabels: {app: wordpress}}
containers:
- name: wordpress
image: wordpress:6-php8.3-apache
env:
- {name: WORDPRESS_DB_HOST, value: mariadb-0.mariadb}
- {name: WORDPRESS_DB_NAME, value: wordpress}
- {name: WORDPRESS_DB_USER, value: wordpress}
- {name: WORDPRESS_DB_PASSWORD, valueFrom: {secretKeyRef: {name: tintuc-db, key: password}}}
- name: WORDPRESS_CONFIG_EXTRA
value: |
define('WP_HOME', 'https://tintuc.example.com');
define('WP_SITEURL', 'https://tintuc.example.com');
define('DISALLOW_FILE_EDIT', true);
ports: [{containerPort: 80, name: http}]
volumeMounts: [{name: wp-content, mountPath: /var/www/html/wp-content}]
resources:
requests: {cpu: 200m, memory: 256Mi}
limits: {memory: 512Mi}
readinessProbe:
httpGet: {path: /wp-includes/images/blank.gif, port: 80}
periodSeconds: 5
livenessProbe:
httpGet: {path: /wp-includes/images/blank.gif, port: 80}
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: wp-content
persistentVolumeClaim: {claimName: wp-content}
---
apiVersion: v1
kind: Service
metadata: {name: wordpress, namespace: tintuc}
spec:
selector: {app: wordpress}
ports: [{name: http, port: 80, targetPort: 80}]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: wordpress, namespace: tintuc}
spec:
minAvailable: 2
selector: {matchLabels: {app: wordpress}}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress
namespace: tintuc
annotations:
traefik.ingress.kubernetes.io/router.middlewares: tintuc-retry@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: tintuc.example.com
http:
paths:
- {path: /, pathType: Prefix, backend: {service: {name: wordpress, port: {number: 80}}}}
---
# Pod vua chet nhung endpoint chua kip go -> Traefik thu lai sang pod khac
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: {name: retry, namespace: tintuc}
spec:
retry: {attempts: 3, initialInterval: 100ms}
kubectl apply -f 10-mariadb.yaml -f 20-wordpress.yaml
kubectl -n tintuc rollout status statefulset/mariadb
kubectl -n tintuc rollout status deploy/wordpress
kubectl -n tintuc get pods,pvc # 2 PVC Bound (RWO + RWX)
Bước 10: cài đặt WordPress và nạp nội dung mẫu
Dùng wp-cli ngay trong pod WordPress:
POD=$(kubectl -n tintuc get pod -l app=wordpress -o jsonpath='{.items[0].metadata.name}')
PW=$(kubectl -n tintuc get secret tintuc-admin -o jsonpath='{.data.password}' | base64 -d)
kubectl -n tintuc exec -i $POD -- env ADMIN_PW="$PW" bash <<'EOF'
cd /var/www/html
curl -fsSL -o /tmp/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
WP="php /tmp/wp --allow-root"
$WP core install --url=https://tintuc.example.com --title="Tin Tức K8s" \
--admin_user=admin --admin_password="$ADMIN_PW" [email protected] --skip-email
$WP language core install vi --activate
$WP option update timezone_string Asia/Ho_Chi_Minh
$WP rewrite structure '/%category%/%postname%/'
for c in "Thời sự:thoi-su" "Công nghệ:cong-nghe" "Kinh tế:kinh-te" "Thể thao:the-thao"; do
$WP term create category "${c%%:*}" --slug="${c##*:}"; done
$WP post create --post_status=publish --post_category=cong-nghe \
--post_title="Bài viết đầu tiên trên Kubernetes" --post_content="Trang tin chạy trên cụm 4 node."
rm -f /tmp/wp
EOF
Kiểm tra từ trong LAN (giả lập header mà Cloudflare sẽ gửi):
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: tintuc.example.com' \
-H 'X-Forwarded-Proto: https' http://192.168.10.20/ # 200
Mật khẩu admin xem bằng kubectl -n tintuc get secret tintuc-admin -o jsonpath='{.data.password}' | base64 -d.
Bước 11: bật tự co giãn (HPA)
WordPress đã cài xong, giờ cho HPA quản lý số pod, từ 3 đến 10. Tạo 25-hpa.yaml:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: wordpress, namespace: tintuc}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: wordpress}
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}}
kubectl apply -f 25-hpa.yaml
kubectl -n tintuc get hpa # REPLICAS len 3 sau it giay
kubectl -n tintuc get pods -o wide -l app=wordpress # 3 pod nam tren 3 node khac nhau
Bước 12: đưa ra Internet qua Cloudflare Tunnel
cloudflared chạy trong cụm và mở kết nối ra Cloudflare, nên router không cần mở cổng hay NAT, và máy chủ cũng không cần IP tĩnh. TLS được xử lý tại Cloudflare.
- Vào Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel, chọn loại Cloudflared và đặt tên, ví dụ
k8s-tintuc. - Ở bước cài connector, chỉ copy token (chuỗi dài sau
--token). Không cần chạy lệnh cài đặt trên máy. - Tab Public Hostname: hostname
tintuc.example.com, ServiceHTTP, URLtraefik.traefik.svc.cluster.local:80. Cloudflare tự tạo bản ghi DNS CNAME.
Nạp token vào cụm dưới dạng Secret, sau đó chạy cloudflared với 30-cloudflared.yaml (2 bản sao nằm trên 2 node khác nhau):
kubectl create namespace cloudflared
kubectl -n cloudflared create secret generic tunnel-token --from-literal=token='<TOKEN_VUA_COPY>'
history -d $((HISTCMD-1)) 2>/dev/null # xoa dong chua token khoi lich su shell
# Cloudflare Tunnel: ket noi ra ngoai tu trong cum -> khong can mo cong tren router.
# Cau hinh route (hostname -> service) quan ly tren Cloudflare; o day chi can token.
apiVersion: apps/v1
kind: Deployment
metadata: {name: cloudflared, namespace: cloudflared}
spec:
replicas: 2
selector: {matchLabels: {app: cloudflared}}
template:
metadata: {labels: {app: cloudflared}}
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector: {matchLabels: {app: cloudflared}}
topologyKey: kubernetes.io/hostname
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args: ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:2000", "run"]
env:
- {name: TUNNEL_TOKEN, valueFrom: {secretKeyRef: {name: tunnel-token, key: token}}}
livenessProbe:
httpGet: {path: /ready, port: 2000}
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {memory: 256Mi}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: cloudflared, namespace: cloudflared}
spec:
minAvailable: 1
selector: {matchLabels: {app: cloudflared}}
kubectl apply -f 30-cloudflared.yaml
kubectl -n cloudflared get pods -o wide # 2 pod Running, khac node
curl -I https://tintuc.example.com/ # HTTP/2 200, co header cf-ray
Bước 13: kiểm tra toàn cụm
kubectl get nodes # 4 Ready
kubectl get pods -A | grep -vE 'Running|Completed' # khong con pod loi
kubectl -n longhorn-system get volumes.longhorn.io # ROBUSTNESS = healthy
kubectl -n kube-system get lease plndr-cp-lock -o jsonpath='{.spec.holderIdentity}' # node dang giu VIP
Bước 14: tự kiểm chứng độ sẵn sàng
Mở một cửa sổ riêng trên máy không thuộc cụm và chạy bộ đo. Để nó chạy trong suốt mỗi phép thử:
while true; do echo "$(date +%T) $(curl -s -o /dev/null -m 3 -w %{http_code} https://tintuc.example.com/)"; sleep 1; done | tee probe.log
| Phép thử | Lệnh | Kết quả đo trên cụm mẫu |
|---|---|---|
| Cập nhật phiên bản | kubectl -n tintuc set image deploy/wordpress wordpress=wordpress:6.8-php8.3-apache |
Hoàn tất trong 134 s, 240/240 request trả 200 |
| Tải cao | kubectl -n tintuc run loadgen --image=httpd:2.4-alpine --restart=Never --command -- ab -t 180 -c 60 -H 'Host: tintuc.example.com' -H 'X-Forwarded-Proto: https' http://wordpress.tintuc.svc/, rồi theo dõi kubectl -n tintuc get hpa -w |
3→10 pod trong 53 s; 18.239 request, 0 lỗi; khoảng 5 phút sau khi hết tải thu về 3 pod |
| Bảo trì node | kubectl drain <node chạy mariadb-0> --ignore-daemonsets --delete-emptydir-data, rồi kubectl uncordon |
DB chuyển node, gián đoạn 41 s |
| Mất node đột ngột | Trên node chạy mariadb-0: ip link set ens33 down (khôi phục bằng console hoặc hẹn giờ systemd-run --on-active=300 ip link set ens33 up) |
VIP chuyển sau 23 s; website lỗi khoảng 206 s cho tới khi DB chạy lại ở node khác; không mất dữ liệu |
Kết quả cho thấy lớp web đã chịu lỗi tốt, còn MariaDB chạy một bản là điểm lỗi đơn. Muốn loại bỏ gián đoạn khi mất node DB, bước tiếp theo là dùng MariaDB Galera 3 node (qua mariadb-operator) và thêm cache trang.
Bước 15: giao diện quản trị Rancher, truy cập qua Tailscale
Rancher cho bạn quản trị cụm qua trình duyệt: xem và sửa workload, xem log, vào shell của pod, cài ứng dụng từ Helm chart, quản lý người dùng và phân quyền, bật giám sát bằng Prometheus/Grafana. Vì Rancher có toàn quyền trên cụm, ta không mở nó ra Internet mà chỉ cho vào qua Tailscale, vẫn với chứng chỉ Let’s Encrypt hợp lệ.
Cơ chế: tên miền rancher.example.com trỏ về IP Tailscale (dải 100.64.0.0/10, không định tuyến trên Internet) của node1 và node2. Trên hai node này, tailscale serve chuyển nguyên kết nối TCP 443 (và 80) sang Traefik, không giải mã TLS. Chứng chỉ được xin bằng DNS-01, nên Let’s Encrypt không cần truy cập được vào máy chủ. Bên trong cụm, CoreDNS trỏ tên này thẳng vào Traefik, để các agent của Rancher không phụ thuộc vào Tailscale.
Cần có: Tailscale đã cài và đăng nhập trên node1, node2 và trên máy tính của bạn (curl -fsSL https://tailscale.com/install.sh | sh && tailscale up). Một API token Cloudflare chỉ có quyền Zone → DNS → Edit cho zone example.com. Kiểm tra phiên bản Kubernetes nằm trong dải Rancher hỗ trợ: helm show chart rancher-stable/rancher | grep kubeVersion; bản 2.15 hỗ trợ dưới 1.37.
15.1. cert-manager và chứng chỉ Let’s Encrypt (DNS-01)
helm repo add jetstack https://charts.jetstack.io
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
helm repo update
helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace \
--set crds.enabled=true \
--set 'extraArgs={--dns01-recursive-nameservers-only,--dns01-recursive-nameservers=1.1.1.1:53\,8.8.8.8:53}' --wait
kubectl -n cert-manager create secret generic cloudflare-api-token --from-literal=api-token='<CLOUDFLARE_TOKEN>'
Tạo file issuer.yaml:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: {name: letsencrypt-dns}
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef: {name: letsencrypt-dns-account}
solvers:
- dns01:
cloudflare:
apiTokenSecretRef: {name: cloudflare-api-token, key: api-token}
selector:
dnsZones: ["example.com"]
---
apiVersion: v1
kind: Namespace
metadata: {name: cattle-system}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: {name: tls-rancher-ingress, namespace: cattle-system}
spec:
secretName: tls-rancher-ingress # ten secret ma chart Rancher doc khi ingress.tls.source=secret
dnsNames: ["rancher.example.com"]
issuerRef: {name: letsencrypt-dns, kind: ClusterIssuer}
kubectl apply -f issuer.yaml
kubectl -n cattle-system wait --for=condition=Ready certificate/tls-rancher-ingress --timeout=5m # thuong 1-2 phut
15.2. Bản ghi DNS. Xem IP Tailscale của node1 và node2 bằng tailscale ip -4. Trên Cloudflare, tạo hai bản ghi A rancher trỏ về hai IP đó, để ở chế độ DNS only (mây xám, không proxy). Có hai bản ghi thì khi một node tắt, trình duyệt tự dùng IP còn lại.
15.3. CoreDNS: bên trong cụm trỏ tên Rancher vào Traefik
kubectl -n kube-system get cm coredns -o yaml > coredns.cm.bak
kubectl -n kube-system get cm coredns -o json | jq '.data.Corefile |= sub(" kubernetes cluster.local";
" rewrite name exact rancher.example.com traefik.traefik.svc.cluster.local answer auto\n kubernetes cluster.local")' \
| kubectl apply -f -
kubectl -n kube-system rollout restart deploy/coredns
kubectl run dnstest --rm -i --restart=Never --image=busybox:1.36 -- nslookup rancher.example.com
# phai tra ve ClusterIP cua service traefik (kubectl -n traefik get svc traefik)
15.4. Cài Rancher
openssl rand -base64 18 | tr -d '/+=' > ~/rancher-bootstrap-password; chmod 600 ~/rancher-bootstrap-password
helm install rancher rancher-stable/rancher -n cattle-system \
--set hostname=rancher.example.com \
--set bootstrapPassword="$(cat ~/rancher-bootstrap-password)" \
--set ingress.tls.source=secret \
--set ingress.ingressClassName=traefik \
--set replicas=3 --wait --timeout 15m
kubectl -n cattle-system rollout status deploy/rancher
curl -s -o /dev/null -w '%{http_code}\n' --resolve rancher.example.com:443:192.168.10.20 https://rancher.example.com/ping # 200
Sau vài phút, Rancher tự cài thêm rancher-webhook và fleet (namespace cattle-fleet-*). Kiểm tra bằng kubectl get pods -A | grep -E 'cattle|fleet'; mọi pod phải ở trạng thái Running hoặc Completed. Ở trạng thái ổn định, Rancher dùng khoảng 2–2,5 GB RAM.
15.5. Mở qua Tailscale. Chạy trên node1 và node2. Cấu hình được lưu lại qua reboot.
tailscale serve --bg --tcp 443 tcp://192.168.10.20:443
tailscale serve --bg --tcp 80 tcp://192.168.10.20:80 # go http:// cung vao duoc (Rancher tu chuyen sang https)
tailscale serve status # |-- tcp://<ten-may>.<tailnet>.ts.net:443 (tailnet only)
15.6. Đăng nhập lần đầu. Trên một máy đang kết nối Tailscale, mở https://rancher.example.com, nhập mật khẩu trong ~/rancher-bootstrap-password, đặt mật khẩu mới cho admin, giữ Server URL mặc định, rồi xoá file mật khẩu khởi tạo. Nên làm bước này ngay: chừng nào chưa làm, ai trong tailnet có mật khẩu khởi tạo đều có thể nhận quyền admin.
Một số thao tác hữu ích trong Rancher:
- Log, shell của pod: Workloads → Pods → menu ⋮ → View Logs / Execute Shell.
- kubectl ngay trên trình duyệt: biểu tượng >_ ở góc trên bên phải.
- Giám sát: Apps → Charts → Monitoring → Install, cần thêm khoảng 2–3 GB RAM.
- Giao diện Longhorn, không phải mở thêm cổng:
https://rancher.example.com/k8s/clusters/local/api/v1/namespaces/longhorn-system/services/http:longhorn-frontend:80/proxy/ - Cấp quyền chỉ xem cho người khác: tạo user với quyền Standard User, đưa namespace vào một Project, thêm user vào Project với role Read-only. Người đó cũng phải được mời vào tailnet.
Xử lý sự cố thường gặp
| Hiện tượng | Nguyên nhân và cách xử lý |
|---|---|
kubeadm init treo ở bước chờ API, VIP không xuất hiện |
kube-vip trên node đầu đang dùng admin.conf. Đổi sang super-admin.conf như ở bước 2 |
kubeadm join báo /etc/kubernetes/manifests is not empty |
Đã đặt manifest kube-vip trước khi join. Xoá đi, join xong mới đặt lại |
no matches for kind "Installation" |
Chưa cài operator-crds.yaml của Calico (từ bản 3.30) |
Apply failed with 1 conflict ... kubectl-create |
Trộn lẫn kubectl create và apply --server-side; thêm --force-conflicts |
| Pod mạng lỗi, pod không ping được nhau, hoặc node dùng nhầm IP VPN | Pod CIDR trùng LAN, hoặc Calico/kubelet chọn sai card mạng. Kiểm tra --node-ip và nodeAddressAutodetectionV4 |
kubectl top báo lỗi x509 hoặc không có dữ liệu |
CSR kubelet-serving chưa được duyệt: kubectl get csr |
| Website lặp chuyển hướng (ERR_TOO_MANY_REDIRECTS) | Traefik chưa tin X-Forwarded-Proto từ dải pod: thiếu forwardedHeaders.trustedIPs |
| Ngay sau khi apply, Ingress trả 503 vài giây | Traefik chưa kịp nạp Ingress và endpoint mới; tự hết |
Node chết nhưng mariadb-0 kẹt ở Terminating vô thời hạn |
Chưa bật node-down-pod-deletion-policy của Longhorn (bước 7) |
| Volume Longhorn không gắn được, báo thiết bị bận | multipathd đang chiếm thiết bị; tắt nó như ở bước 1 |
Certificate Rancher đứng ở READY False |
kubectl -n cattle-system describe challenge. Thường do token Cloudflare thiếu quyền DNS Edit, sai dnsZones, hoặc resolver trong LAN không tra được bản ghi TXT: dùng cờ --dns01-recursive-nameservers-only như ở bước 15.1 |
Cụm local trong Rancher không lên Active, log agent báo không kết nối được máy chủ |
Pod đang phân giải rancher.example.com ra IP Tailscale. Kiểm tra lại rewrite CoreDNS ở bước 15.3 |
| Trình duyệt không mở được Rancher | Máy chưa kết nối Tailscale, tailscale serve status trống trên node1/node2, hoặc bạn gõ http:// mà chưa chuyển tiếp cổng 80 (bước 15.5). Ngoài tailnet thì không mở được là đúng thiết kế |
| Rancher trả 404 qua Traefik | Thiếu --set ingress.ingressClassName=traefik, hoặc truy cập bằng tên khác với hostname đã khai báo |
Khi không dùng nữa, gỡ cụm bằng kubeadm reset -f trên từng node, xoá /etc/cni/net.d và /var/lib/longhorn, rồi xoá tunnel cùng các bản ghi DNS trên Cloudflare. Nếu có Rancher, chạy thêm tailscale serve --tcp=443 off và tailscale serve --tcp=80 off trên node1 và node2.
