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. 1. Vấn đề: tiến trình treo, systemd vẫn thấy nó đang chạy
  2. 2. Mục đích: backend tự thay khi treo, cả cụm cài như một gói
  3. 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. 4. Cách giải quyết: k3s một node, backend chạy bằng Deployment
  5. 5. Cách cài đặt: một lệnh cài, một thư mục manifest
  6. 6. Chứng minh: manifest đã kiểm, app đã chạy, thời gian thay pod theo mô hình
  7. 7. Kết luận
  8. Đọc tiếp
  9. Nguồn

Đọc nhanh

  • Vấn đề: BanHang.CuaHang chạ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: MapHealthChecks tách /healthz/live và /healthz/ready trên cổng 8080; công cụ kiểm manifest viết bằng YamlDotNet bắ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/cuahang trong manifest, và luôn còn ít nhất 2 pod nhận request trong lúc đổi.
  • Manifest qua kubeconform -strict vớ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 đa timeoutSeconds. Thất bại failureThreshold lầ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ủa ch04: 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:

  1. Ghi /etc/rancher/k3s/config.yaml với tên node cố định k3s-chNN, rồi cài đúng bản bằng INSTALL_K3S_VERSION.
  2. Khai registry nội bộ trong /etc/rancher/k3s/registries.yaml nếu registry dùng chứng chỉ riêng.
  3. Áp bộ manifest: Deployment cuahang với liveness, readiness, startup probe và maxUnavailable: 0; Service cuahang; Ingress lớp traefik; HPA giữ 2 đến 3 pod; PVC và CronJob sao lưu cho bài 7.
  4. 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, cronjobYAML · 331 dòng
# ===== 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-phucC# · 263 dòng
#: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 manifestC# · 117 dòng
#: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ếtC# · 42 dòng
// 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:

Lúc nặng nhất, k3s và backend đặt chỗ khoảng 3,9 GB, còn hơn 4,2 GB cho hệ điều hành

k3s, thành phần đi kèm, workload mẫu1.596 MiB3 pod cuahang (limit)1.536 MiBPod thêm lúc rolling update512 MiBJob sao lưu (limit)256 MiBCòn lại cho hệ điều hành, page cache4.292 MiB
Máy 8 GB = 8.192 MiB. Số của k3s lấy từ trang Resource Profiling, đo trên k3s v1.26.5 có cả Prometheus và Grafana, đọc ngày 4/10/2026; các số khác là limit trong manifest, không phải số đo.
Bảng số liệu
Giá trị
k3s, thành phần đi kèm, workload mẫu1.596 MiB
3 pod cuahang (limit)1.536 MiB
Pod thêm lúc rolling update512 MiB
Job sao lưu (limit)256 MiB
Còn lại cho hệ điều hành, page cache4.292 MiB

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 tag ready) bằng MapHealthChecks; liveness trỏ live, readiness trỏ ready.
  • Nghe cổng 8080 và chạy bằng user app của image .NET 8 trở lên; đặt runAsNonRoot: true, runAsUser: 1654.
  • Ghi tên node cố định trong /etc/rancher/k3s/config.yaml và cài k3s bằng INSTALL_K3S_VERSION để 12 máy cùng một bản.
  • Trước khi áp, render bằng kubectl kustomize, chạy kubeconform -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

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Probe, giới hạn bộ nhớ và HPA trên k3s: pod cửa hàng trụ được flash sale

Flash sale 9/9 ở ch01, pod BanHang.CuaHang restart liên tục vì liveness gọi endpoint có kiểm trụ sở, heap limit ghi bằng số thập phân thành 16 GiB, và một pod không gánh nổi tải. Tách ba probe, để GC tự lấy 75% memory limit, và chạy HPA 2–3 pod; kiểm bằng app .NET chạy thật, Job Object 512 MiB và mô hình kubelet, HPA.

13 phút đọc

Trong Docker Swarm và k3s

Dữ liệu offline của cửa hàng trên k3s: PVC local-path, tên node và sao lưu bằng CronJob

SSD của máy ch06 hỏng sau gần 3 giờ mất mạng và 41 đơn bán offline mất theo; ở ch02, đổi tên máy làm pod kẹt Pending vì PV local-path gắn với tên node cũ. Cố định tên node, sao lưu SQLite mỗi 15 phút bằng API backup qua CronJob ra ổ thứ hai, và để initContainer tự khôi phục volume trống.

13 phút đọc

Trong Docker Swarm và k3s

Docker Swarm ba manager: khởi động lại từng máy mà API trụ sở vẫn bán

API trụ sở của BanHang chạy Docker Compose trên một máy, nên mỗi lần máy khởi động lại sau bản vá là bán hàng online dừng. Cụm Swarm 3 manager và 2 worker giữ quorum khi mất bất kỳ node nào, 2 manager thì không; healthcheck viết bằng .NET giữ task chưa mở cổng ngoài routing mesh.

13 phút đọc