DevOpsDocker Swarm và k3s, phần 4/8
Máy chủ cửa hàng chạy k3s: backend tự thay khi treo trên mini PC 8 GB
Backend ở cửa hàng ch04 treo lúc 19:40 và cửa hàng không bán được 52 phút, vì systemd chỉ chạy lại tiến trình đã thoát. k3s gói Kubernetes vào một binary dưới 100 MB với SQLite, containerd, Flannel, Traefik, ServiceLB và local-path; Deployment có liveness probe thay pod treo trong 32–67 giây theo mô hình.
Mục lục
- 1. Vấn đề: tiến trình treo, systemd vẫn thấy nó đang chạy
- 2. Mục đích: backend tự thay khi treo, cả cụm cài như một gói
- 3. Cơ sở lý thuyết: Kubernetes là các vòng điều khiển, k3s gói chúng vào một tiến trình
- 4. Cách giải quyết: k3s một node, backend chạy bằng Deployment
- 5. Cách cài đặt: một lệnh cài, một thư mục manifest
- 6. Chứng minh: manifest đã kiểm, app đã chạy, thời gian thay pod theo mô hình
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề:
BanHang.CuaHangchạy bằng systemd trên mini PC 8 GB; tiến trình treo thì không ai chạy lại, và cập nhật 12 cửa hàng là 12 lần SSH. - Cách giải: Cài k3s một node, một binary gồm control plane, SQLite, containerd, Flannel, Traefik, ServiceLB và local-path, rồi chạy backend bằng Deployment, Service và Ingress.
- Chứng minh: Chưa dựng cụm thật; theo quy tắc probe của Kubernetes và thời gian mở cổng đo được, pod treo được thay sau 32–67 giây thay vì 52 phút.
- Trong .NET:
MapHealthCheckstách/healthz/livevà/healthz/readytrên cổng 8080; công cụ kiểm manifest viết bằngYamlDotNetbắt 4/4 lỗi tham chiếu mà kubeconform để lọt.
1. Vấn đề: tiến trình treo, systemd vẫn thấy nó đang chạy
Mỗi cửa hàng của BanHang có một máy mini PC 8 GB RAM, 4 nhân, chạy Ubuntu. Trên đó chạy BanHang.CuaHang, backend bán tại quầy: nhận đơn từ máy POS, ghi vào SQLite trên máy, đồng bộ về BanHang.Api ở trụ sở, và vẫn bán khi mất mạng. Đến tháng 8/2026, nó là một service systemd với Restart=on-failure.
19:40 ngày 18/8/2026, backend ở ch04 Hải Phòng ngừng trả lời. Máy POS chờ hết thời gian, nhưng tiến trình vẫn còn trong bảng tiến trình. systemd chỉ chạy lại tiến trình đã thoát, nên nó không làm gì. Nhân viên gọi IT; 20:32 IT SSH vào và khởi động lại service. Cửa hàng không bán được 52 phút giữa giờ đông khách buổi tối. Diễn biến này là minh họa.
Cập nhật cũng làm bằng tay: đổi bản cho 12 cửa hàng là 12 lần SSH, chép file, khởi động lại service sau giờ đóng cửa. Muốn quay lại bản cũ thì chép lại file cũ. Trụ sở đã chạy BanHang.Api bằng container trên Docker Swarm (bài 1) và muốn cửa hàng cũng vậy. Đội IT thử dựng Kubernetes bằng kubeadm trên một máy cửa hàng và dừng lại: ngoài control plane còn phải tự chọn, cài và nâng cấp container runtime, mạng pod, ingress controller, load balancer, storage, trên 12 máy không có người IT tại chỗ.
2. Mục đích: backend tự thay khi treo, cả cụm cài như một gói
- Pod treo bị bỏ khỏi Service trong dưới 15 giây và được thay trong dưới 70 giây mà không cần người, tính theo quy tắc probe của Kubernetes cộng thời gian mở cổng đo được.
- Cài bằng một lệnh, một binary. k3s và mọi thành phần đi kèm, cộng tối đa 4 pod backend lúc cập nhật và một Job sao lưu, đặt chỗ dưới một nửa 8 GB RAM.
- Cập nhật là đổi tag image
registry.banhang.local/banhang/cuahangtrong manifest, và luôn còn ít nhất 2 pod nhận request trong lúc đổi. - Manifest qua
kubeconform -strictvới Kubernetes 1.36.5 và 0 lỗi tham chiếu chéo giữa Deployment, Service, Ingress, HPA. - Ngoài phạm vi: cách đặt probe và limit (bài 5), dữ liệu trên volume (bài 7), cập nhật 12 nơi cùng lúc (bài 8).
3. Cơ sở lý thuyết: Kubernetes là các vòng điều khiển, k3s gói chúng vào một tiến trình
Ai giữ backend chạy
Kubernetes giữ hệ thống ở trạng thái đã khai báo bằng các vòng điều khiển. Deployment khai báo "luôn có N pod từ template này". Controller tạo ReplicaSet, ReplicaSet tạo pod, scheduler chọn node, kubelet trên node bảo container runtime chạy container rồi theo dõi nó.
Kubelet làm hai việc mà systemd ở ch04 không làm:
- Chạy lại container đã thoát. Theo tài liệu Pod Lifecycle của Kubernetes 1.36, lần đầu chạy lại ngay; các lần sau chờ 10, 20, 40 giây, gấp đôi tới tối đa 300 giây. Container chạy ổn 10 phút thì bộ đếm về đầu.
- Giết container không còn trả lời. Liveness probe gọi HTTP theo chu kỳ
periodSeconds, mỗi lần chờ tối đatimeoutSeconds. Thất bạifailureThresholdlần liên tiếp thì kubelet dừng container và chạy lại. Tài liệu Kubernetes nêu đúng trường hợp củach04: liveness dùng để bắt deadlock.
Readiness probe trả lời câu khác: pod có nhận request được không. Pod không ready bị bỏ khỏi endpoint của Service nhưng không bị giết. Triển khai không gián đoạn dùng readiness để rút pod khỏi load balancer trước khi dừng; bài 5 giải thích cách đặt từng probe.
Thứ kubeadm để lại cho bạn, thứ k3s gói sẵn
kubeadm dựng control plane: API server, scheduler, controller-manager, etcd. Tài liệu kubeadm yêu cầu cài container runtime trước, và ghi "You must deploy a CNI based Pod network add-on". Kubernetes gốc cho tạo Service kiểu LoadBalancer nhưng không kèm bộ cài đặt nào; trên máy không có cloud, Service đó nằm pending mãi.
k3s là một bản phân phối Kubernetes đầy đủ, đóng trong một binary dưới 100 MB. Một tiến trình k3s server chạy cả control plane và kubelet; phần còn lại đi kèm. Bản ổn định ngày 4/10/2026 là k3s v1.36.5+k3s1, phát hành 30/9/2026, mang Kubernetes v1.36.5:
| Phần việc | kubeadm | k3s v1.36.5+k3s1 |
|---|---|---|
| Lưu trạng thái cụm | etcd | SQLite 3.53.4 qua Kine khi một server; etcd nhúng hoặc database ngoài khi nhiều server |
| Chạy container | Tự cài | containerd v2.3.4 |
| Mạng pod | Tự cài CNI | Flannel v0.28.4, VXLAN |
| Ingress | Tự cài | Traefik v3.7.13 |
Service LoadBalancer |
Không có | ServiceLB |
| Volume trên ổ máy | Tự cài | local-path-provisioner v0.0.37 |
| Số đo CPU, RAM cho HPA | Tự cài | metrics-server v0.9.0 |
SQLite thay etcd khi chỉ có một máy
Kine dịch API etcd mà API server gọi sang câu lệnh SQL, nên k3s lưu trạng thái cụm vào SQLite. Tài liệu k3s ghi SQLite không dùng được cho cụm nhiều server; máy cửa hàng chỉ có một server nên đây không phải giới hạn. Bảng đo tài nguyên của k3s, trên máy 4 nhân 8 GB với k3s v1.26.5, mọi thành phần đi kèm, Prometheus, Grafana và một Deployment nginx mẫu, cho thấy khác biệt chính nằm ở ổ đĩa:
| Datastore | RAM của server có workload | IOPS | Độ trễ ổ cần có |
|---|---|---|---|
| Kine/SQLite | 1596 M | 10 | dưới 10 ms |
| etcd nhúng | 1606 M | 50 | dưới 5 ms |
etcd cần gấp 5 lần IOPS và ổ nhanh gấp đôi. Trên SSD giá rẻ của mini PC, SQLite là lựa chọn ít rủi ro hơn.
Request đi từ máy POS tới pod
Router của cửa hàng phân giải cuahang.banhang.local về IP của máy k3s-ch04. ServiceLB tạo một DaemonSet trong kube-system cho mỗi Service LoadBalancer; pod của DaemonSet giữ hostPort 80 và 443 của máy và chuyển vào Service của Traefik. Traefik đọc Ingress, chuyển tới Service cuahang, và Service chia request cho các pod đang ready.
flowchart TB
POS["Máy POS trong cửa hàng"] -->|"cuahang.banhang.local:80"| LB["Pod ServiceLB, giữ hostPort 80 của k3s-ch04"]
LB --> T["Traefik v3.7.13"]
T -->|"Ingress cuahang"| S["Service cuahang :80"]
S --> P1["Pod cuahang :8080"]
S --> P2["Pod cuahang :8080"]
API["k3s server: API server, scheduler, controller"] --- DB[("SQLite qua Kine")]
API --> KL["k3s server: kubelet, containerd"]
KL -.->|"probe live, ready"| P1
KL -.->|"probe live, ready"| P2
Ba ô có chữ "k3s server" thuộc cùng một tiến trình k3s; containerd chạy thành tiến trình con do k3s khởi động.
Container nghe cổng 8080 vì từ .NET 8, image ASP.NET Core đổi cổng mặc định từ 80 sang 8080. Cũng từ .NET 8, image có sẵn user không phải root tên app, UID 1654, để manifest chọn bằng runAsUser.
4. Cách giải quyết: k3s một node, backend chạy bằng Deployment
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
systemd, Restart=on-failure (đang dùng) |
Không thêm phần mềm | Không bắt tiến trình treo; cập nhật và quay lại bằng tay | Một máy, hiếm khi đổi bản |
Docker Compose, restart: unless-stopped |
Cùng image với trụ sở | Restart policy chỉ chạy lại container đã thoát; không có rolling update | Thử nghiệm |
| Kubernetes dựng bằng kubeadm | Kubernetes gốc | Tự chọn và nâng cấp runtime, CNI, ingress, load balancer, storage; etcd ghi nhiều | Có đội vận hành cụm |
| k3s một node | Một binary, đủ thành phần, SQLite | Không chịu được hỏng máy; dữ liệu nằm trên ổ máy | Máy biên như cửa hàng |
Bài chọn k3s một node. Hỏng cả máy vẫn là điểm yếu của một node; bài 7 xử lý phần dữ liệu. Các bước:
- Ghi
/etc/rancher/k3s/config.yamlvới tên node cố địnhk3s-chNN, rồi cài đúng bản bằngINSTALL_K3S_VERSION. - Khai registry nội bộ trong
/etc/rancher/k3s/registries.yamlnếu registry dùng chứng chỉ riêng. - Áp bộ manifest: Deployment
cuahangvới liveness, readiness, startup probe vàmaxUnavailable: 0; Servicecuahang; Ingress lớptraefik; HPA giữ 2 đến 3 pod; PVC và CronJob sao lưu cho bài 7. - Kiểm manifest offline trước khi áp: kubeconform cho schema, một công cụ C# cho tham chiếu chéo.
Deployment không ghi spec.replicas: HPA giữ số pod. Rolling update với maxSurge: 1, maxUnavailable: 0 tạo pod mới, chờ nó ready, rồi mới dừng pod cũ, nên luôn còn ít nhất 2 pod nhận request.
5. Cách cài đặt: một lệnh cài, một thư mục manifest
Phiên bản: k3s v1.36.5+k3s1 trên Ubuntu; .NET SDK 10.0.401, runtime 10.0.12; gói Microsoft.Data.Sqlite 10.0.12 và YamlDotNet 18.1.0. Lệnh trên máy cửa hàng, chưa chạy trên máy thật trong bài này:
sudo mkdir -p /etc/rancher/k3s
printf 'node-name: k3s-ch04\nwrite-kubeconfig-mode: "0640"\n' | sudo tee /etc/rancher/k3s/config.yaml
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.36.5+k3s1 sh -
sudo k3s kubectl apply -k ./base
sudo k3s kubectl create secret generic bhpay-api-key-v1 -n banhang --from-file=api-key=./bhpay-api-key.txt
Secret tạo ngoài Git; file khóa trong ví dụ chứa giá trị giả. Lệnh apply -k tạo namespace banhang trước; pod chờ ở ContainerCreating tới khi Secret có rồi tự chạy. Phần lõi của Deployment:
spec:
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
spec:
containers:
- name: cuahang
image: registry.banhang.local/banhang/cuahang:2.1.0
ports:
- { name: http, containerPort: 8080 }
envFrom:
- configMapRef: { name: cuahang-cauhinh }
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: "1", memory: 512Mi }
startupProbe: { httpGet: { path: /healthz/live, port: http }, periodSeconds: 5, failureThreshold: 24 }
livenessProbe: { httpGet: { path: /healthz/live, port: http }, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3 }
readinessProbe: { httpGet: { path: /healthz/ready, port: http }, periodSeconds: 5, timeoutSeconds: 2, failureThreshold: 2 }
Trong app, liveness không chạy check nào: chỉ cần tiến trình còn trả lời HTTP. Readiness chạy các check gắn tag ready, gồm SQLite trên máy và trạng thái đồng bộ với trụ sở:
// live: không chạy check nào, chỉ cần tiến trình còn trả lời.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
// ready: SQLite cục bộ, trạng thái trụ sở (mất mạng chỉ là Degraded), đang dừng.
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
});
Khi tiến trình treo kiểu ch04, cả hai endpoint cùng không trả lời: readiness bỏ pod khỏi Service trước, liveness giết nó sau. Toàn bộ manifest và app ở hai khối dưới; app chạy bằng dotnet run CuaHang.cs -- --urls http://127.0.0.1:8080.
Bộ manifest base: kustomization.yaml, namespace, pvc, deployment, service, ingress, hpa, cronjob
# ===== kustomization.yaml
# Base của BanHang.CuaHang cho k3s v1.36.5+k3s1 (Kubernetes 1.36.5).
# Overlay của từng cửa hàng thường chỉ đổi:
# - ConfigMap cuahang-cauhinh: configMapGenerator với behavior: merge, khóa CuaHang__Ma và CuaHang__Ten;
# - tag image registry.banhang.local/banhang/cuahang (mặc định 2.1.0) bằng images/newTag.
# Secret bhpay-api-key-v1 (khóa api-key) không nằm trong Git: tạo riêng trên từng cụm.
# Kiểm: kubectl kustomize . > render.yaml, rồi kubeconform -strict -kubernetes-version 1.36.5 render.yaml.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: banhang
resources:
- namespace.yaml
- pvc.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- hpa.yaml
- cronjob-sao-luu.yaml
# Tên sinh ra có hậu tố hash theo nội dung: đổi cấu hình là đổi pod template, Deployment tự rollout.
configMapGenerator:
- name: cuahang-cauhinh
options:
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
literals:
- CuaHang__Ma=CHUA-DAT
- CuaHang__Ten=CHUA-DAT
- TruSo__Url=https://api.banhang.local
- DongBo__ChuKyGiay=30
- ConnectionStrings__CuaHang=Data Source=/data/cuahang.db
# ===== namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: banhang
labels:
app.kubernetes.io/part-of: banhang
# ===== pvc.yaml
# Dữ liệu offline của cửa hàng (SQLite). local-path của k3s tạo PV kiểu local,
# gắn nodeAffinity kubernetes.io/hostname vào đúng node lúc tạo.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cuahang-data
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 2Gi
# ===== deployment.yaml
# BanHang.CuaHang: backend bán tại quầy, bán được khi mất mạng, đồng bộ về trụ sở.
# Không ghi spec.replicas: HPA (hpa.yaml) giữ số pod trong khoảng 2-3.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: cuahang
template:
metadata:
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
terminationGracePeriodSeconds: 30 # > preStop 5 s + ShutdownTimeout 15 s
securityContext:
runAsNonRoot: true
runAsUser: 1654 # user "app" của image .NET từ bản 8
runAsGroup: 1654
fsGroup: 1654
seccompProfile:
type: RuntimeDefault
initContainers:
# Volume trống (máy mới, PVC mới) thì chép bản sao lưu mới nhất vào trước khi app chạy.
- name: khoi-phuc
image: registry.banhang.local/banhang/cuahang:2.1.0
args: ["khoi-phuc", "--du-lieu", "/data", "--sao-luu", "/sao-luu"]
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: sao-luu
mountPath: /sao-luu
readOnly: true
- name: tmp
mountPath: /tmp
containers:
- name: cuahang
image: registry.banhang.local/banhang/cuahang:2.1.0
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "1"
memory: 512Mi # GC tự đặt heap hard limit 75% = 384 MiB
startupProbe: # tối đa 24 x 5 s = 120 s để khởi động
httpGet:
path: /healthz/live
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
livenessProbe: # không kiểm phụ thuộc nào
httpGet:
path: /healthz/live
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe: # SQLite cục bộ; mất kết nối trụ sở chỉ là Degraded
httpGet:
path: /healthz/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
sleep:
seconds: 5
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: bhpay-api-key
mountPath: /run/secrets
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: cuahang-data
- name: sao-luu # ổ thứ hai của máy, gắn sẵn tại /mnt/sao-luu
hostPath:
path: /mnt/sao-luu
type: Directory
- name: bhpay-api-key # Secret tạo ngoài Git, xem kustomization.yaml
secret:
secretName: bhpay-api-key-v1
items:
- key: api-key
path: BHPay__ApiKey
- name: tmp
emptyDir:
sizeLimit: 64Mi
# ===== service.yaml
apiVersion: v1
kind: Service
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: cuahang
ports:
- name: http
port: 80
targetPort: http
# ===== ingress.yaml
# Traefik đi kèm k3s nhận cổng 80/443 của máy qua ServiceLB.
# Router của mỗi cửa hàng phân giải cuahang.banhang.local về máy k3s-chNN trong LAN.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
ingressClassName: traefik
rules:
- host: cuahang.banhang.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cuahang
port:
name: http
# ===== hpa.yaml
# HPA giữ 2 đến 3 pod, nhắm 70% CPU request (70% của 500m = 350m mỗi pod).
# 3 pod x limit 1 CPU chừa 1 nhân của máy mini PC 4 nhân cho k3s, Traefik và hệ điều hành.
# Deployment không ghi spec.replicas để HPA và GitOps không giành nhau trường này.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cuahang
namespace: banhang
labels:
app.kubernetes.io/name: cuahang
app.kubernetes.io/part-of: banhang
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cuahang
minReplicas: 2
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300
# ===== cronjob-sao-luu.yaml
# Sao lưu SQLite mỗi 15 phút sang ổ thứ hai (/mnt/sao-luu), giữ 96 bản (24 giờ),
# rồi thử đẩy bản mới nhất về trụ sở. Nhãn khác "cuahang" để Service không chọn pod của Job.
apiVersion: batch/v1
kind: CronJob
metadata:
name: cuahang-sao-luu
namespace: banhang
labels:
app.kubernetes.io/name: cuahang-sao-luu
app.kubernetes.io/part-of: banhang
spec:
schedule: "*/15 * * * *"
timeZone: Asia/Ho_Chi_Minh
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 600
template:
metadata:
labels:
app.kubernetes.io/name: cuahang-sao-luu
app.kubernetes.io/part-of: banhang
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
seccompProfile:
type: RuntimeDefault
containers:
- name: sao-luu
image: registry.banhang.local/banhang/cuahang:2.1.0
args: ["sao-luu", "--du-lieu", "/data", "--sao-luu", "/sao-luu", "--giu", "96"]
envFrom:
- configMapRef:
name: cuahang-cauhinh
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data # cùng PVC với Deployment: RWO cho phép vì cùng node
mountPath: /data
- name: sao-luu
mountPath: /sao-luu
- name: tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: cuahang-data
- name: sao-luu
hostPath:
path: /mnt/sao-luu
type: Directory
- name: tmp
emptyDir:
sizeLimit: 64Mi
CuaHang.cs: backend rút gọn, health check, đồng bộ nền, lệnh sao-luu và khoi-phuc
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.Sqlite@10.0.12
// BanHang.CuaHang 2.1.0, bản rút gọn: dotnet run CuaHang.cs -- --urls http://127.0.0.1:8080
// Cùng một file (một image) chạy ba việc: web (mặc định), "sao-luu" (CronJob), "khoi-phuc" (initContainer).
using System.Diagnostics;
using System.Runtime;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;
if (args is ["sao-luu", ..]) return await SaoLuu.ChayAsync(args);
if (args is ["khoi-phuc", ..]) return KhoiPhuc.Chay(args);
var batDau = Stopwatch.StartNew();
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); // khóa BHPay từ Secret
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(15));
var cfg = builder.Configuration;
string csdl = cfg.GetConnectionString("CuaHang") ?? "Data Source=cuahang.db";
string maCuaHang = cfg["CuaHang:Ma"] ?? "CHUA-DAT";
DongBo.TruSo = new Uri(cfg["TruSo:Url"] ?? "https://api.banhang.local");
DongBo.ChuKy = TimeSpan.FromSeconds(cfg.GetValue("DongBo:ChuKyGiay", 30));
bool cauHinhCu = cfg.GetValue("CuaHang:CauHinhCu", false); // bản 2.0.3: đồng bộ chặn lúc khởi động, trụ sở là bắt buộc
Csdl.TaoBang(csdl);
var dangDung = false;
builder.Services.AddHealthChecks()
.AddCheck("dang-dung", () => dangDung ? HealthCheckResult.Unhealthy() : HealthCheckResult.Healthy(), tags: ["ready"])
.AddCheck("sqlite", () => Csdl.KiemTra(csdl), tags: ["ready"])
.AddCheck("tru-so", () => DongBo.VuaThanhCong()
? HealthCheckResult.Healthy()
: new HealthCheckResult(cauHinhCu ? HealthStatus.Unhealthy : HealthStatus.Degraded, "bán offline"),
tags: ["ready"]);
builder.Services.AddHostedService(_ => new DongBo(csdl, maCuaHang));
var app = builder.Build();
// live: không chạy check nào, chỉ cần tiến trình còn trả lời. ready: SQLite cục bộ, trạng thái trụ sở, đang dừng.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResponseWriter = (ctx, r) =>
{
ctx.Response.ContentType = "text/plain; charset=utf-8";
return ctx.Response.WriteAsync($"{r.Status} " + string.Join(" ", r.Entries.Select(e => $"{e.Key}={e.Value.Status}")));
}
});
app.MapPost("/api/don-hang/{maDon}", (string maDon, long tongTien) =>
{
using var cn = Csdl.Mo(csdl);
using var cmd = cn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO DonHangOffline (MaDon, NgayTao, TongTien) VALUES ($ma, $ngay, $tien)";
cmd.Parameters.AddWithValue("$ma", maDon);
cmd.Parameters.AddWithValue("$ngay", DateTime.UtcNow.ToString("O"));
cmd.Parameters.AddWithValue("$tien", tongTien);
return cmd.ExecuteNonQuery() == 1 ? Results.Text($"da-ghi {maDon}", statusCode: 201) : Results.Text($"da-co {maDon}");
});
app.MapGet("/api/thong-tin", () =>
{
var gc = GC.GetGCMemoryInfo();
var p = Process.GetCurrentProcess();
return Results.Text($"""
BanHang.CuaHang 2.1.0 cua hang {maCuaHang}, .NET {Environment.Version}, ServerGC={GCSettings.IsServerGC}
TotalAvailableMemoryBytes={gc.TotalAvailableMemoryBytes / 1048576.0:F0} MiB, heap={GC.GetTotalMemory(false) / 1048576.0:F1} MiB
private={p.PrivateMemorySize64 / 1048576.0:F0} MiB, working set={p.WorkingSet64 / 1048576.0:F0} MiB, don cho dong bo={Csdl.ChoDongBo(csdl)}
""");
});
app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine($"nghe cong sau {batDau.ElapsedMilliseconds} ms"));
app.Lifetime.ApplicationStopping.Register(() => dangDung = true); // readiness 503; preStop đã chờ trước đó
if (cauHinhCu) await DongBo.MotLanAsync(csdl, maCuaHang, soLan: 3); // bản 2.0.3: chặn ở đây, chưa mở cổng
app.Run();
return 0;
static class Csdl
{
public static SqliteConnection Mo(string cs)
{
var cn = new SqliteConnection(cs);
cn.Open();
return cn;
}
public static void TaoBang(string cs)
{
using var cn = Mo(cs);
using var cmd = cn.CreateCommand();
cmd.CommandText = """
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS DonHangOffline (
MaDon text PRIMARY KEY,
NgayTao text NOT NULL,
TongTien integer NOT NULL, -- đồng
DaDongBo integer NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS IX_DonHangOffline_ChoDongBo ON DonHangOffline (MaDon) WHERE DaDongBo = 0;
""";
cmd.ExecuteNonQuery();
}
public static long ChoDongBo(string cs)
{
using var cn = Mo(cs);
using var cmd = cn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline WHERE DaDongBo = 0";
return (long)cmd.ExecuteScalar()!;
}
public static HealthCheckResult KiemTra(string cs)
{
try { return HealthCheckResult.Healthy($"{ChoDongBo(cs)} đơn chờ đồng bộ"); }
catch (Exception e) { return HealthCheckResult.Unhealthy(e.Message); }
}
// Mở file chỉ đọc, kiểm integrity_check và đếm đơn. Dùng cho bản sao lưu.
public static (bool Tot, long SoDon) KiemFile(string file)
{
try
{
using var cn = new SqliteConnection($"Data Source={file};Mode=ReadOnly;Pooling=False");
cn.Open();
using var cmd = cn.CreateCommand();
cmd.CommandText = "PRAGMA integrity_check";
if ((string)cmd.ExecuteScalar()! != "ok") return (false, 0);
cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline";
return (true, (long)cmd.ExecuteScalar()!);
}
catch (SqliteException) { return (false, 0); }
}
}
// Đẩy đơn chưa đồng bộ về BanHang.Api mỗi DongBo:ChuKyGiay giây. Lỗi mạng không làm app dừng.
sealed class DongBo(string csdl, string maCuaHang) : BackgroundService
{
public static Uri TruSo = new("https://api.banhang.local");
public static TimeSpan ChuKy = TimeSpan.FromSeconds(30);
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };
static long _lanCuoi; // ticks UTC của lần đồng bộ thành công gần nhất
// Trụ sở "tốt" nếu đồng bộ thành công trong ba chu kỳ gần nhất.
public static bool VuaThanhCong() => DateTime.UtcNow.Ticks - Interlocked.Read(ref _lanCuoi) < 3 * ChuKy.Ticks;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await MotLanAsync(csdl, maCuaHang, soLan: 1, ct); }
catch (Exception e) when (!ct.IsCancellationRequested) { Console.WriteLine($"dong-bo loi: {e.Message}"); }
await Task.Delay(ChuKy, ct);
}
}
public static async Task MotLanAsync(string csdl, string maCuaHang, int soLan, CancellationToken ct = default)
{
for (int lan = 1; lan <= soLan; lan++)
{
try
{
using var cn = Csdl.Mo(csdl);
using var cmd = cn.CreateCommand();
cmd.CommandText = "SELECT MaDon || ';' || NgayTao || ';' || TongTien FROM DonHangOffline WHERE DaDongBo = 0 LIMIT 100";
var dong = new List<string>();
using (var r = cmd.ExecuteReader()) while (r.Read()) dong.Add(r.GetString(0));
using var res = await Http.PostAsync(new Uri(TruSo, $"/api/cua-hang/{maCuaHang}/don-hang"),
new StringContent(string.Join('\n', dong)), ct);
res.EnsureSuccessStatusCode(); // trụ sở khử trùng theo MaDon
foreach (var d in dong)
{
cmd.CommandText = "UPDATE DonHangOffline SET DaDongBo = 1 WHERE MaDon = $ma";
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("$ma", d.Split(';')[0]);
cmd.ExecuteNonQuery();
}
Interlocked.Exchange(ref _lanCuoi, DateTime.UtcNow.Ticks);
return;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
if (lan < soLan) await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
}
}
static class SaoLuu
{
// sao-luu --du-lieu /data --sao-luu /sao-luu --giu 96
public static async Task<int> ChayAsync(string[] args)
{
string duLieu = Tham(args, "--du-lieu", "."), noiLuu = Tham(args, "--sao-luu", "sao-luu");
int giu = int.Parse(Tham(args, "--giu", "96"));
if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
{
Console.Error.WriteLine($"o sao luu chua gan: thieu {Path.Combine(noiLuu, ".o-sao-luu")}"); // không ghi nhầm vào ổ chính
return 2;
}
var sw = Stopwatch.StartNew();
string tam = Path.Combine(noiLuu, $".dang-ghi-{Guid.NewGuid():N}.db");
using (var nguon = new SqliteConnection($"Data Source={Path.Combine(duLieu, "cuahang.db")};Mode=ReadWrite;Pooling=False"))
using (var dich = new SqliteConnection($"Data Source={tam};Pooling=False"))
{
nguon.Open();
dich.Open();
nguon.BackupDatabase(dich); // sqlite3_backup: một ảnh nhất quán, app vẫn ghi được
using var cmd = dich.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode = DELETE"; // bản sao đọc được trên mount chỉ đọc
cmd.ExecuteNonQuery();
}
var (tot, soDon) = Csdl.KiemFile(tam);
if (!tot) { File.Delete(tam); Console.Error.WriteLine("ban sao loi integrity_check"); return 3; }
string ten = Path.Combine(noiLuu, $"cuahang-{DateTime.UtcNow:yyyyMMdd-HHmmss}.db");
File.Move(tam, ten);
foreach (var cu in Directory.GetFiles(noiLuu, "cuahang-*.db").Order().SkipLast(giu)) File.Delete(cu);
Console.WriteLine($"sao-luu {Path.GetFileName(ten)}: {soDon} don, {sw.ElapsedMilliseconds} ms");
try // có mạng thì đẩy thêm một bản về trụ sở; mất mạng thì bản trên ổ thứ hai là đủ cho lần này
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
string ma = Environment.GetEnvironmentVariable("CuaHang__Ma") ?? "CHUA-DAT";
var goc = new Uri(Environment.GetEnvironmentVariable("TruSo__Url") ?? "https://api.banhang.local");
await using var f = File.OpenRead(ten);
using var res = await http.PutAsync(new Uri(goc, $"/api/cua-hang/{ma}/sao-luu/{Path.GetFileName(ten)}"), new StreamContent(f));
Console.WriteLine($"day ve tru so: {(int)res.StatusCode}");
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
Console.WriteLine($"chua day ve tru so: {e.GetType().Name}");
}
return 0;
}
public static string Tham(string[] args, string ten, string macDinh)
{
int i = Array.IndexOf(args, ten);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : macDinh;
}
}
static class KhoiPhuc
{
// khoi-phuc --du-lieu /data --sao-luu /sao-luu: chỉ chạy khi volume còn trống
public static int Chay(string[] args)
{
string duLieu = SaoLuu.Tham(args, "--du-lieu", "."), noiLuu = SaoLuu.Tham(args, "--sao-luu", "sao-luu");
string dich = Path.Combine(duLieu, "cuahang.db");
if (File.Exists(dich)) { Console.WriteLine("da co du lieu, bo qua"); return 0; }
if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
{
Console.WriteLine("CANH BAO: khong thay o sao luu, bat dau voi du lieu trong");
return 0;
}
foreach (var f in Directory.GetFiles(noiLuu, "cuahang-*.db").OrderDescending())
{
var (tot, soDon) = Csdl.KiemFile(f);
if (!tot) { Console.WriteLine($"bo qua {Path.GetFileName(f)}: hong"); continue; }
File.Copy(f, dich + ".tam", overwrite: true);
File.Move(dich + ".tam", dich);
Console.WriteLine($"khoi-phuc tu {Path.GetFileName(f)}: {soDon} don");
return 0;
}
Console.WriteLine("khong co ban sao luu, bat dau voi du lieu trong");
return 0;
}
}
6. Chứng minh: manifest đã kiểm, app đã chạy, thời gian thay pod theo mô hình
Bài chưa dựng cụm thật: không chạy Docker, k3s hay container nào. Bằng chứng gồm ba loại: manifest đã qua công cụ kiểm, app .NET đã chạy trên máy, và thời gian thay pod tính theo quy tắc trong tài liệu Kubernetes. Máy chạy: laptop Intel Core Ultra 5 125U, Windows 11, .NET 10.0.12.
Manifest: schema hợp lệ chưa đủ
kubectl kustomize (kubectl v1.32.2, kustomize v5.5.0, đi kèm Docker Desktop, chạy offline) render base thành 8 tài nguyên. kubeconform v0.8.0, -strict, schema Kubernetes 1.36.5 tải trước về máy, cho 8/8 hợp lệ. KiemManifest.cs báo 0 lỗi và 1 cảnh báo: Secret bhpay-api-key-v1 không nằm trong bộ manifest, đúng như thiết kế.
Để thử công cụ, bản hong chèn bốn lỗi hay gặp vào base. kubeconform vẫn cho 8/8 hợp lệ vì mỗi lỗi đều đúng schema; lỗi nằm ở chỗ hai tài nguyên trỏ vào nhau:
| Lỗi chèn | kubeconform | KiemManifest.cs |
|---|---|---|
Ingress gọi cổng web, Service chỉ có cổng http |
Hợp lệ | Bắt |
| readinessProbe gọi cổng 80, thói quen từ trước .NET 8 | Hợp lệ | Bắt |
Pod của CronJob sao lưu mang nhãn app.kubernetes.io/name: cuahang |
Hợp lệ | Bắt |
HPA trỏ Deployment/cua-hang |
Hợp lệ | Bắt |
LOI hong.yaml Deployment cuahang/cuahang: readinessProbe gọi cổng 80, container không mở cổng đó
CANH BAO hong.yaml Deployment cuahang: Secret bhpay-api-key-v1 không nằm trong bộ manifest, phải tạo riêng trên cụm
LOI hong.yaml Service cuahang: selector chọn cả pod của CronJob cuahang-sao-luu, request sẽ rơi vào pod sao lưu
LOI hong.yaml Ingress cuahang: Service cuahang không có cổng web
LOI hong.yaml HorizontalPodAutoscaler cuahang: không có Deployment/cua-hang
8 tài nguyên, 4 lỗi, 1 cảnh báo
Lỗi thứ ba khó thấy nhất: Service chọn pod theo nhãn, nên pod sao lưu không mở cổng 8080 vẫn được đưa vào endpoint, và một phần request của máy POS rơi vào đó.
KiemManifest.cs: kiểm tham chiếu chéo giữa các manifest
#:package YamlDotNet@18.1.0
// KiemManifest.cs: kiểm tham chiếu chéo giữa các manifest, phần kubeconform không xem.
// dotnet run KiemManifest.cs -- <thư mục manifest>
using YamlDotNet.RepresentationModel;
string thuMuc = args.Length > 0 ? args[0] : ".";
var docs = new List<(string File, YamlMappingNode N)>();
foreach (var f in Directory.GetFiles(thuMuc, "*.yaml").Order())
{
if (Path.GetFileName(f) == "kustomization.yaml") continue;
var ys = new YamlStream();
using (var r = File.OpenText(f)) ys.Load(r);
docs.AddRange(ys.Documents.Select(d => d.RootNode).OfType<YamlMappingNode>().Select(n => (Path.GetFileName(f), n)));
}
int loi = 0, canhBao = 0;
void Loi(string f, string s) { loi++; Console.WriteLine($"LOI {f,-22} {s}"); }
void CanhBao(string f, string s) { canhBao++; Console.WriteLine($"CANH BAO {f,-22} {s}"); }
string Ten(YamlMappingNode n) => $"{Y.S(n, "kind")} {Y.S(n, "metadata.name")}";
IEnumerable<(string File, YamlMappingNode N)> Loai(string kind) => docs.Where(d => Y.S(d.N, "kind") == kind);
// Pod template của từng workload: Deployment, Job, CronJob.
var podTemplates = docs.Select(d => (d.File, d.N, T: Y.S(d.N, "kind") switch
{
"Deployment" or "StatefulSet" or "DaemonSet" or "Job" => Y.M(d.N, "spec.template"),
"CronJob" => Y.M(d.N, "spec.jobTemplate.spec.template"),
_ => null,
})).Where(x => x.T != null).ToList();
var tenCoSan = docs.Select(d => $"{Y.S(d.N, "kind")}/{Y.S(d.N, "metadata.name")}").ToHashSet();
foreach (var (f, n, t) in podTemplates)
{
var nhan = Y.Map(t!, "metadata.labels");
if (Y.S(n, "kind") == "Deployment" && !Y.Map(n, "spec.selector.matchLabels").All(kv => nhan.GetValueOrDefault(kv.Key) == kv.Value))
Loi(f, $"{Ten(n)}: selector không khớp nhãn của pod template");
foreach (var c in Y.L(t!, "spec.containers").Concat(Y.L(t!, "spec.initContainers")))
{
string anh = Y.S(c, "image") ?? "";
if (!anh.Contains(':') || anh.EndsWith(":latest")) Loi(f, $"{Ten(n)}/{Y.S(c, "name")}: image '{anh}' không ghi tag cố định");
foreach (var k in new[] { "resources.requests.cpu", "resources.requests.memory", "resources.limits.memory" })
if (Y.S(c, k) == null) Loi(f, $"{Ten(n)}/{Y.S(c, "name")}: thiếu {k}");
var cong = Y.L(c, "ports").Select(p => (Ten: Y.S(p, "name"), So: Y.S(p, "containerPort"))).ToList();
foreach (var probe in new[] { "startupProbe", "livenessProbe", "readinessProbe" })
if (Y.S(c, $"{probe}.httpGet.port") is { } pp && !cong.Any(x => x.Ten == pp || x.So == pp))
Loi(f, $"{Ten(n)}/{Y.S(c, "name")}: {probe} gọi cổng {pp}, container không mở cổng đó");
if (Y.S(c, "livenessProbe.httpGet.path") is { } lp && lp == Y.S(c, "readinessProbe.httpGet.path"))
CanhBao(f, $"{Ten(n)}/{Y.S(c, "name")}: liveness và readiness cùng gọi {lp}; phụ thuộc hỏng là kubelet restart");
foreach (var e in Y.L(c, "env"))
if (Y.S(e, "name") is "DOTNET_GCHeapHardLimit" or "DOTNET_GCHeapHardLimitPercent" && Y.S(e, "value") is { } v && !v.StartsWith("0x"))
Loi(f, $"{Ten(n)}/{Y.S(c, "name")}: {Y.S(e, "name")}={v} được runtime đọc là hex 0x{v}");
foreach (var r in Y.L(c, "envFrom").Select(e => Y.S(e, "configMapRef.name")).OfType<string>())
if (!tenCoSan.Contains($"ConfigMap/{r}")) Loi(f, $"{Ten(n)}: thiếu ConfigMap {r}");
}
foreach (var v in Y.L(t!, "spec.volumes"))
{
if (Y.S(v, "persistentVolumeClaim.claimName") is { } pvc && !tenCoSan.Contains($"PersistentVolumeClaim/{pvc}"))
Loi(f, $"{Ten(n)}: thiếu PersistentVolumeClaim {pvc}");
if (Y.S(v, "secret.secretName") is { } sec && !tenCoSan.Contains($"Secret/{sec}"))
CanhBao(f, $"{Ten(n)}: Secret {sec} không nằm trong bộ manifest, phải tạo riêng trên cụm");
}
}
foreach (var (f, svc) in Loai("Service"))
{
var chon = Y.Map(svc, "spec.selector");
bool Khop(YamlMappingNode t) { var nhan = Y.Map(t, "metadata.labels"); return chon.All(kv => nhan.GetValueOrDefault(kv.Key) == kv.Value); }
var trung = podTemplates.Where(p => Khop(p.T!)).ToList();
if (!trung.Any(p => Y.S(p.N, "kind") == "Deployment")) Loi(f, $"{Ten(svc)}: selector không chọn pod nào của Deployment");
foreach (var p in trung.Where(p => Y.S(p.N, "kind") is "Job" or "CronJob"))
Loi(f, $"{Ten(svc)}: selector chọn cả pod của {Ten(p.N)}, request sẽ rơi vào pod sao lưu");
foreach (var port in Y.L(svc, "spec.ports"))
{
string tp = Y.S(port, "targetPort") ?? Y.S(port, "port")!;
foreach (var p in trung.Where(p => Y.S(p.N, "kind") == "Deployment"))
if (!Y.L(p.T!, "spec.containers").SelectMany(c => Y.L(c, "ports")).Any(x => Y.S(x, "name") == tp || Y.S(x, "containerPort") == tp))
Loi(f, $"{Ten(svc)}: targetPort {tp} không có trong container của {Ten(p.N)}");
}
}
foreach (var (f, ing) in Loai("Ingress"))
foreach (var path in Y.L(ing, "spec.rules").SelectMany(r => Y.L(r, "http.paths")))
{
string ten = Y.S(path, "backend.service.name") ?? "";
var svc = Loai("Service").Select(d => d.N).FirstOrDefault(s => Y.S(s, "metadata.name") == ten);
if (svc == null) { Loi(f, $"{Ten(ing)}: không có Service {ten}"); continue; }
string? tenCong = Y.S(path, "backend.service.port.name"), soCong = Y.S(path, "backend.service.port.number");
if (!Y.L(svc, "spec.ports").Any(p => (tenCong != null && Y.S(p, "name") == tenCong) || (soCong != null && Y.S(p, "port") == soCong)))
Loi(f, $"{Ten(ing)}: Service {ten} không có cổng {tenCong ?? soCong}");
}
foreach (var (f, hpa) in Loai("HorizontalPodAutoscaler"))
{
string dich = $"{Y.S(hpa, "spec.scaleTargetRef.kind")}/{Y.S(hpa, "spec.scaleTargetRef.name")}";
var d = docs.FirstOrDefault(x => $"{Y.S(x.N, "kind")}/{Y.S(x.N, "metadata.name")}" == dich);
if (d.N == null) Loi(f, $"{Ten(hpa)}: không có {dich}");
else if (Y.S(d.N, "spec.replicas") != null) CanhBao(d.File, $"{dich} ghi spec.replicas trong khi {Ten(hpa)} quản lý số pod");
}
Console.WriteLine($"{docs.Count} tài nguyên, {loi} lỗi, {canhBao} cảnh báo");
return loi > 0 ? 1 : 0;
static class Y
{
static YamlNode? Node(YamlNode? n, string duong)
{
foreach (var k in duong.Split('.'))
{
if (n is not YamlMappingNode m || !m.Children.TryGetValue(new YamlScalarNode(k), out n)) return null;
}
return n;
}
public static string? S(YamlNode? n, string duong) => (Node(n, duong) as YamlScalarNode)?.Value;
public static YamlMappingNode? M(YamlNode? n, string duong) => Node(n, duong) as YamlMappingNode;
public static IEnumerable<YamlMappingNode> L(YamlNode? n, string duong) =>
(Node(n, duong) as YamlSequenceNode)?.Children.OfType<YamlMappingNode>() ?? [];
public static Dictionary<string, string?> Map(YamlNode? n, string duong) =>
M(n, duong)?.Children.ToDictionary(kv => ((YamlScalarNode)kv.Key).Value!, kv => (kv.Value as YamlScalarNode)?.Value) ?? [];
}
App: mở cổng dưới 1 giây, 63 MiB sau 2.000 đơn
ChayThu.cs chạy CuaHang như trong container: cổng 8080, trụ sở không với tới. Nó đo thời gian từ lúc khởi tiến trình tới khi /healthz/ready trả 200, ghi 2.000 đơn với 4 client song song, rồi giết tiến trình và chạy lại hai lần trên cùng file SQLite, như kubelet chạy lại container:
| Lần chạy | Mở cổng (app tự đo) | Tới ready | Ghi chú |
|---|---|---|---|
| 1, file mới | 244 ms | 571 ms | Degraded: mất trụ sở, vẫn bán |
| 2, sau khi bị giết | 348 ms | 715 ms | 2.000 đơn còn nguyên |
| 3, sau khi bị giết | 331 ms | 734 ms |
Sau 2.000 đơn, app báo private 27 MiB, working set 63 MiB, Server GC, 2.000 đơn chờ đồng bộ. Đó là số trên Windows, chưa đo trong container Linux.
ChayThu.cs: đo thời gian tới ready, bộ nhớ, chạy lại sau khi bị giết
// ChayThu.cs: chạy CuaHang như trong container (cổng 8080, mất mạng về trụ sở),
// đo thời gian tới ready, bộ nhớ sau 2.000 đơn, rồi giết và chạy lại hai lần trên cùng file dữ liệu.
// dotnet run ChayThu.cs (cùng thư mục với CuaHang.cs)
using System.Diagnostics;
Process.Start("dotnet", "build CuaHang.cs -o out-cuahang -v q").WaitForExit();
string exe = Path.GetFullPath(Path.Combine("out-cuahang", OperatingSystem.IsWindows() ? "CuaHang.exe" : "CuaHang"));
string thuMuc = Directory.CreateTempSubdirectory("cuahang-").FullName;
// ConnectTimeout ngắn: trên Windows, kết nối tới cổng chưa ai nghe mất khoảng 2 s mới bị từ chối.
var http = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(100) })
{ BaseAddress = new("http://127.0.0.1:8080"), Timeout = TimeSpan.FromSeconds(30) };
if (await Ready()) throw new InvalidOperationException("cổng 8080 đang có tiến trình khác");
for (int lan = 1; lan <= 3; lan++) // lần 2, 3: như kubelet chạy lại container trên cùng volume
{
var psi = new ProcessStartInfo(exe, ["--urls", "http://127.0.0.1:8080", "--CuaHang:Ma", "CH04"])
{ RedirectStandardOutput = true };
psi.Environment["ConnectionStrings__CuaHang"] = $"Data Source={Path.Combine(thuMuc, "cuahang.db")}";
psi.Environment["TruSo__Url"] = "http://127.0.0.1:9"; // không với tới trụ sở
var sw = Stopwatch.StartNew();
using var app = Process.Start(psi)!;
try
{
while (!await Ready()) await Task.Delay(20);
Console.WriteLine($"lan {lan}: ready sau {sw.ElapsedMilliseconds} ms ({app.StandardOutput.ReadLine()}), " +
await http.GetStringAsync("/healthz/ready"));
if (lan > 1) continue;
await Parallel.ForAsync(0, 2000, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (i, ct) =>
{
using var r = await http.PostAsync($"/api/don-hang/CH04-{i:D6}?tongTien={1_000_000 + i * 1000}", null, ct);
r.EnsureSuccessStatusCode();
});
Console.WriteLine(await http.GetStringAsync("/api/thong-tin"));
}
finally { app.Kill(); app.WaitForExit(); } // container chết đột ngột
}
async Task<bool> Ready()
{
try { using var r = await http.GetAsync("/healthz/ready"); return r.IsSuccessStatusCode; }
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }
}
Mô hình: pod treo được thay sau 32–67 giây
Ghép quy tắc probe của Kubernetes với cấu hình ở mục 5 và thời gian tới ready đo được, cho trường hợp tiến trình treo như ch04:
| Bước | Thời gian | Lý do |
|---|---|---|
| Readiness thất bại 2 lần, pod rời Service | 7–12 s | Lần thử đầu rơi trong 5 s sau khi treo, mỗi lần chờ tối đa 2 s |
| Liveness thất bại 3 lần, kubelet quyết định giết | 22–32 s | Lần thử đầu trong 10 s, thêm hai chu kỳ 10 s, chờ 2 s |
| Dừng container | 5–30 s | preStop 5 s; tiến trình treo không thoát thì bị SIGKILL ở hạn 30 s |
| Chạy lại, startup probe qua, ready | khoảng 5 s | Lần đầu chạy lại ngay; app mở cổng sau 0,7 s, probe kế tiếp sau 5 s |
| Từ lúc treo tới khi có pod mới ready | 32–67 s | So với 52 phút ở mục 1 |
Với 2 pod, cửa hàng không dừng bán: sau 7–12 giây, request mới chỉ đi vào pod còn lại. Mô hình chưa tính thời gian containerd tạo container và thời gian Traefik nhận thay đổi endpoint.
Ngân sách RAM của máy 8 GB
Cộng mức RAM của k3s theo tài liệu với limit trong manifest, lúc nặng nhất là rolling update khi HPA đang ở 3 pod:
3.900 MiB đặt chỗ dưới một nửa 8.192 MiB. Working set đo được của app, 63 MiB, cho thấy limit 512 MiB còn dư; bài 5 giải thích vì sao vẫn chọn mức đó.
Điều chưa chứng minh: chưa cài k3s, nên thời gian cài, RAM thật của k3s v1.36.5 trên mini PC, hành vi của ServiceLB và Traefik đều chưa đo. Thời gian mở cổng đo trên laptop Windows, chưa đo trên mini PC với limit 1 CPU.
7. Kết luận
Trên một máy cửa hàng, k3s cho đúng thứ systemd thiếu: một vòng điều khiển thay pod treo theo health check, và cập nhật bằng cách đổi một dòng manifest. Cái giá là học Kubernetes, không phải vận hành từng thành phần của nó.
Trong dự án .NET của bạn:
- Tách
/healthz/live(không check nào,Predicate = _ => false) và/healthz/ready(check gắn tagready) bằngMapHealthChecks; liveness trỏ live, readiness trỏ ready. - Nghe cổng 8080 và chạy bằng user
appcủa image .NET 8 trở lên; đặtrunAsNonRoot: true,runAsUser: 1654. - Ghi tên node cố định trong
/etc/rancher/k3s/config.yamlvà cài k3s bằngINSTALL_K3S_VERSIONđể 12 máy cùng một bản. - Trước khi áp, render bằng
kubectl kustomize, chạykubeconform -strict -kubernetes-version 1.36.5, rồi một bước kiểm tham chiếu chéo nhưKiemManifest.cs.
Những chỗ hay hiểu sai
- "k3s là Kubernetes bị cắt bớt API." Nó là bản phân phối Kubernetes đầy đủ; phần khác là cách đóng gói và thành phần đi kèm.
- "SQLite không dùng được thật." Nó là datastore mặc định cho một server; chỉ cụm nhiều server mới cần etcd hoặc database ngoài.
- "Restart policy là đủ để tự hồi phục." Tiến trình treo không thoát; chỉ liveness probe làm kubelet giết nó.
- "Qua kubeconform là manifest đúng." Schema không biết Service trỏ vào pod nào.
Đọc tiếp
- Secret và cấu hình trên Swarm: bài trước trong series, chuỗi kết nối và khóa BHPay trong
/run/secrets. - Probe, giới hạn bộ nhớ và HPA trên k3s: bài sau, vì sao liveness trỏ sai làm pod restart liên tục và limit không khớp heap .NET.
- Swarm hay k3s: trụ sở có nên chuyển sang k3s nhiều server.
- Dữ liệu cửa hàng khi pod dời node: PVC local-path và sao lưu bằng CronJob.
- GitOps cho 12 cửa hàng: 12 overlay Kustomize và Flux trên từng máy cửa hàng.
- Triển khai không gián đoạn: readiness,
ApplicationStoppingvàpreStop.
Nguồn
- k3s, Introduction: bản phân phối Kubernetes đầy đủ, binary dưới 100 MB, thành phần đi kèm. Requirements, Cluster Datastore, Managing Packaged Components, Networking Services: Traefik, ServiceLB. Resource Profiling. Environment Variables:
INSTALL_K3S_VERSION. Configuration Options, Private Registry. Đọc ngày 4/10/2026. - k3s, v1.36.5+k3s1: bảng phiên bản thành phần. Kênh phát hành: stable là v1.36.5+k3s1, đọc ngày 4/10/2026. Manifest traefik.yaml cùng bản.
- Kubernetes 1.36, Pod Lifecycle: restart, backoff, probe. Configure Liveness, Readiness and Startup Probes. Deployments. Installing kubeadm, Creating a cluster with kubeadm.
- Docker, Start containers automatically: restart policy.
- Microsoft Learn, Default ASP.NET Core port changed from 80 to 8080, New non-root 'app' user in Linux images. dotnet/dotnet-docker, Dockerfile runtime-deps 10.0:
APP_UID=1654. Health checks in ASP.NET Core. - yannh/kubeconform, v0.8.0; schema kubernetes-json-schema v1.36.5-standalone-strict.