DevOpsDocker Swarm và k3s, phần 8/8
GitOps cho 12 cửa hàng: Kustomize overlay, Flux và bước CI bắt cấu hình lệch
Cập nhật tay 12 máy k3s để ba cửa hàng vẫn chạy bản cũ cả tuần mà không ai biết. Một repo Kustomize với 12 overlay, Flux kéo về từng máy, và một bước CI so 12 bản render chỉ cho phép khác nhau ở những trường đã khai báo.
Mục lục
- 1. Vấn đề: cập nhật tay 12 máy, ba cửa hàng vẫn chạy bản cũ
- 2. Mục đích: trạng thái của 12 cửa hàng nằm trong Git, lệch thì CI chặn
- 3. Cơ sở lý thuyết: Git giữ trạng thái mong muốn, agent trên từng máy kéo về và sửa lệch
- 4. Cách giải quyết: Flux trên từng máy, 12 overlay, CI so bản render
- 5. Cách cài đặt: repo mẫu, Flux Kustomization và công cụ so render
- 6. Chứng minh: 0 vi phạm với repo đúng, 3/3 trường bị bắt khi sửa tay ch09
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Cập nhật
BanHang.CuaHangbằng tay trên 12 máy k3s mất hai buổi tối, và một tuần sau ba cửa hàng vẫn chạy bản cũ mà không ai biết. - Cách giải: Một repo Git với base, overlay vòng phát hành, component cỡ và 12 overlay cửa hàng; Flux trên từng máy kéo thư mục của mình và trả lại thay đổi tay.
- Chứng minh: Chưa dựng cụm thật; 12 overlay qua kubeconform với Kubernetes 1.36.5, bước so render báo 0 vi phạm rồi bắt đủ 3 trường sửa tay ở
ch09. - Trong .NET: Bước so render là file-based app .NET 10 dùng
YamlDotNet18.1.0, trả exit code 1 cho CI khi một trường lệch ngoài khai báo.
1. Vấn đề: cập nhật tay 12 máy, ba cửa hàng vẫn chạy bản cũ
Bản 2.1.0 của BanHang.CuaHang sửa cách chia giảm giá combo vào từng dòng hóa đơn: bản 2.0.3 làm tròn từng dòng nên tổng giảm có thể lệch 1 đồng, lỗi mà bài Chia tiền không lệch một đồng mô tả. Tối 21 và 22/9/2026, sau giờ đóng cửa, đội IT trụ sở SSH vào từng máy k3s-ch01 … k3s-ch12, sửa manifest và chạy kubectl apply.
Ngày 28/9, kế toán đối soát tuần thấy hóa đơn combo của ch05, ch09 và ch11 vẫn chia theo cách cũ. ch05 và ch11 mất mạng đúng tối cập nhật nên bị bỏ qua. ch09 đã lên 2.1.0, nhưng hôm sau kỹ thuật viên tự quay về 2.0.3 vì lỗi máy in hóa đơn, tăng memory limit lên 1Gi và đặt DongBo__ChuKyGiay=5 để theo dõi đồng bộ. Không thay đổi nào được ghi lại.
Muốn biết 12 cửa hàng đang chạy gì, hôm nay vẫn phải SSH vào 12 máy. Bản 2.2.0 sắp phát hành, và chủ hệ thống muốn chạy thử ở hai cửa hàng trước rồi mới đưa lên mười nơi còn lại.
2. Mục đích: trạng thái của 12 cửa hàng nằm trong Git, lệch thì CI chặn
- 12/12 overlay render được và qua
kubeconform -strictvới Kubernetes 1.36.5, bản Kubernetes của k3s v1.36.5+k3s1 trên kênh stable. - Công cụ so 12 bản render chỉ thấy khác nhau theo ba chiều đã khai báo: mã và tên theo cửa hàng, memory theo cỡ, image theo vòng phát hành. Repo đúng cho 0 vi phạm. Sửa tay
ch09như tuần trước cho 3/3 trường bị bắt và exit code 1. - Đưa hai cửa hàng canary lên 2.2.0 là sửa 1 dòng trong Git, và đúng 2/12 bản render đổi.
- Ngoài phạm vi: chạy Flux trên cụm thật, Secret
bhpay-api-key-v1(tạo ngoài Git), dữ liệu offline (bài 7), cách chọn probe và limit (bài 5).
3. Cơ sở lý thuyết: Git giữ trạng thái mong muốn, agent trên từng máy kéo về và sửa lệch
Bốn nguyên tắc của GitOps
OpenGitOps 1.0.0 định nghĩa GitOps bằng bốn nguyên tắc. Cách cập nhật ở mục 1 vi phạm cả bốn:
| Nguyên tắc | Nghĩa | Ở mục 1 |
|---|---|---|
| Declarative | Trạng thái mong muốn là dữ liệu, không phải chuỗi lệnh | Gõ lệnh trên từng máy |
| Versioned and Immutable | Mỗi trạng thái là một phiên bản bất biến, có lịch sử | Thay đổi ở ch09 không để lại dấu vết |
| Pulled Automatically | Agent tự kéo trạng thái mong muốn | Trụ sở đẩy; máy mất mạng thì lỡ |
| Continuously Reconciled | Agent liên tục so thật với mong muốn và sửa | Không ai so, lệch tồn tại cả tuần |
Kustomize: base, overlay và component
kustomize build <thư mục> nạp các resource mà kustomization.yaml trỏ tới, rồi chạy các transformer của tầng đó: đặt namespace, thêm nhãn, đổi tag image, áp patch. Resource có thể là thư mục chứa kustomization khác, nên các tầng xếp chồng: tầng ngoài biến đổi tiếp kết quả của tầng trong, và đầu ra là YAML thuần.
Repo xếp ba tầng. base là bộ manifest BanHang.CuaHang của bài 4, giữ nguyên. Overlay vòng vong/on-dinh và vong/canary trỏ tới base và chỉ đặt tag image. Overlay cuahang/chNN trỏ tới đúng một vòng, gắn nhãn, ghi mã và tên cửa hàng vào ConfigMap. Cỡ cửa hàng là Component (kind: Component, có từ kustomize 3.7.0): kustomization dùng lại được, overlay nào cần thì gọi, ở đây để patch memory.
flowchart TB B["base của bài 4: Deployment, Service, Ingress, ConfigMap, PVC, HPA, CronJob"] CO["Component co-nho, co-vua, co-lon: memory"] B --> OD["vong/on-dinh: tag 2.1.0"] B --> CA["vong/canary: tag 2.2.0"] OD --> C1["10 overlay cửa hàng còn lại"] CA --> C3["cuahang/ch03, cuahang/ch07"] CO -.-> C1 CO -.-> C3 C1 -->|"mỗi máy kéo thư mục của mình"| F1["Flux trên 10 máy k3s tương ứng"] C3 -->|"mỗi máy kéo thư mục của mình"| F3["Flux trên k3s-ch03, k3s-ch07"]
Vòng là overlay trung gian vì mỗi cửa hàng thuộc đúng một vòng, nên không thể vô tình nhận hai tag. Cỡ là component vì nó độc lập với vòng.
Hai chi tiết quyết định cách so ở mục 6. Base sinh ConfigMap cuahang-cauhinh bằng configMapGenerator; overlay ghi mã, tên cửa hàng bằng generator cùng tên với behavior: merge. Tên sinh ra có hậu tố hash theo nội dung, như cuahang-cauhinh-g5tkc86tdm, và kustomize sửa mọi tham chiếu theo. Biến môi trường lấy từ ConfigMap qua envFrom không tự cập nhật, theo tài liệu Kubernetes; nhờ hash, đổi cấu hình là đổi pod template, nên Deployment tự rollout.
Trường labels chỉ gắn nhãn vào metadata.labels, không vào selector. commonLabels, đã deprecated, gắn cả vào selector, mà selector của Deployment apps/v1 không đổi được sau khi tạo; chuyển ch03 từ canary sang ổn định khi đó làm apply thất bại.
Flux kéo, áp và sửa lệch
Flux chạy dưới dạng controller ngay trong cụm. source-controller lấy repo theo đối tượng GitRepository mỗi interval, mặc định 1 phút khi tạo bằng flux bootstrap. kustomize-controller build thư mục ghi trong đối tượng Kustomization rồi áp bằng server-side apply. Revision của nguồn đổi thì Kustomization được xử lý ngay, không chờ interval của nó.
Mỗi interval của Kustomization, controller build và áp lại, "correcting any existing drift" theo tài liệu Flux: kubectl edit hay kubectl set image trên máy bị trả về đúng như Git. prune: true xóa object mà revision mới không còn. wait: true chờ mọi object sẵn sàng, với Deployment là rollout xong.
Gắn lại mục 1. Máy tự kéo, nên ch05 và ch11 mất mạng chỉ nhận chậm chứ không bị bỏ qua. Thay đổi tay kiểu ch09 trên cụm bị trả lại trong một interval. Còn một lỗ hổng: thay đổi tay đi vào chính Git thì Flux áp đúng như vậy. Bước so render ở mục 4 bịt lỗ hổng này.
4. Cách giải quyết: Flux trên từng máy, 12 overlay, CI so bản render
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
SSH và kubectl apply từng máy |
Không cần công cụ | 12 lần thao tác, không lịch sử, máy mất mạng bị sót | Một hai máy thử |
Script trụ sở lặp kubectl apply qua 12 kubeconfig |
Một lệnh | Phải vào được API server từng máy; không ai sửa lệch | Mạng ổn định, ít thay đổi |
| Argo CD ở trụ sở, ApplicationSet sinh 12 Application | Giao diện chung cho 12 cửa hàng | Hub gọi API server từng máy; mất kết nối là ngừng đồng bộ | Cụm có đường ổn định về hub |
| Flux trong từng máy, kéo overlay của mình | Chỉ cần kết nối ra; tự sửa lệch | Không có giao diện; 12 bản Flux phải nâng cấp | Máy biên, mạng chập chờn |
Hai ứng viên GitOps, phiên bản ổn định tra ngày 4/10/2026:
| Tiêu chí | Flux v2.9.6 | Argo CD v3.5.3 |
|---|---|---|
| Mô hình | Mỗi cụm tự kéo | Hub gọi URL API server của cụm, lưu trong Secret; bản kéo argocd-agent còn ở 0.10.0 |
| Cài trên máy cửa hàng | 2 Deployment (source-controller, kustomize-controller); requests 50m CPU/64Mi và 100m CPU/64Mi |
Bản core: 4 workload (application-controller, repo-server, redis, applicationset-controller), không đặt requests |
| Sửa thay đổi tay trên cụm | Mặc định, mỗi interval |
Chỉ khi bật selfHeal |
| Xóa object bị bỏ khỏi Git | prune: true |
Tắt mặc định, bật bằng prune |
| Giao diện | Không kèm | Có, trừ bản core |
Số requests đọc từ flux install --export v2.9.6 và core-install.yaml v3.5.3. Requests là mức đặt chỗ khi lập lịch, không phải mức dùng thật.
Bài chọn Flux. Máy cửa hàng nằm sau NAT, đường truyền chập chờn, nên mô hình kéo khớp hơn một hub phải gọi vào 12 API server. Flux sửa lệch mà không cần bật thêm, và chỉ cần hai controller trên máy 8 GB. Cái giá là không có giao diện: trạng thái 12 máy phải gom về trụ sở bằng cách khác.
Các bước:
- Repo theo hình ở mục 3, thêm
clusters/k3s-chNNchứa file Flux của từng máy. - Mỗi máy chạy
flux bootstrap git --path=clusters/k3s-chNN --components=source-controller,kustomize-controllermột lần. - Mỗi pull request chạy CI: render 12 overlay, kubeconform, so 12 bản render.
- Phát hành theo vòng như hình dưới. Quay lại là
git revertcommit đổi tag.
flowchart TD PR["PR: vong/canary lên 2.2.0"] --> CI["CI: render 12, kubeconform, so render"] CI -->|"đỏ"| X["Không merge"] CI -->|"xanh"| M["Merge vào main"] M --> K["Flux ở ch03, ch07 kéo trong tối đa 1 phút, rollout"] K --> Q["Quan sát một ngày bán"] Q -->|"ổn"| P["PR: vong/on-dinh lên 2.2.0"] Q -->|"lỗi"| R["git revert"]
5. Cách cài đặt: repo mẫu, Flux Kustomization và công cụ so render
Công cụ, bản tra ngày 4/10/2026: kustomize v5.8.2, kubeconform v0.8.0, Flux CLI v2.9.6, .NET SDK 10.0.401 (runtime 10.0.12), gói YamlDotNet 18.1.0. Base lấy nguyên từ bài 4, gồm cả PVC, HPA 2–3 pod và CronJob sao lưu; Ingress dùng Traefik v3.7.13 đi kèm k3s v1.36.5+k3s1. Deployment không ghi replicas, nên Flux không giành trường này với HPA.
Overlay của một cửa hàng chỉ nói thuộc vòng nào, cỡ nào, là cửa hàng nào. Đây là cuahang/ch03/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../vong/canary
components:
- ../../thanh-phan/co-vua
labels:
- pairs:
banhang.local/cua-hang: ch03
configMapGenerator:
- name: cuahang-cauhinh
behavior: merge
literals:
- CuaHang__Ma=CH03
- CuaHang__Ten=BanHang Long Biên
vong/canary chỉ trỏ tới base, gắn nhãn banhang.local/vong: canary và đặt newTag: 2.2.0 trong images; transformer này đổi cả ba chỗ dùng image: container chính, init container khoi-phuc và CronJob sao lưu. vong/on-dinh giống hệt với tag 2.1.0. App đọc mã cửa hàng bằng builder.Configuration["CuaHang:Ma"], vì __ trong tên biến môi trường thành : trong cấu hình .NET.
Base của bài 4, component co-vua và vong/canary: toàn bộ file
# base/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
---
# base/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: banhang
labels:
app.kubernetes.io/part-of: banhang
---
# base/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
---
# base/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
---
# base/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
---
# base/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
---
# base/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
---
# base/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
---
# thanh-phan/co-vua/kustomization.yaml (co-nho: 384Mi, co-lon: 768Mi)
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
labels:
- pairs:
banhang.local/co: vua
patches:
- target:
kind: Deployment
name: cuahang
patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: 512Mi
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: 512Mi
---
# vong/canary/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
labels:
- pairs:
banhang.local/vong: canary
images:
- name: registry.banhang.local/banhang/cuahang
newTag: 2.2.0
Memory theo cỡ là số minh họa: 384Mi, 512Mi như base, 768Mi; bài 5 nói cách chọn limit cho heap .NET. Overlay và file Flux của 12 máy sinh bằng một script:
tao-repo.ps1: sinh component cỡ, overlay vòng, 12 overlay cửa hàng và 12 Flux Kustomization
# Windows PowerShell 5.1: lưu file UTF-8 có BOM để giữ dấu tiếng Việt.
$g = $PSScriptRoot
$utf8 = New-Object System.Text.UTF8Encoding($false)
function Ghi($path, $text) {
$full = Join-Path $g $path
New-Item -ItemType Directory -Force (Split-Path $full) | Out-Null
[IO.File]::WriteAllText($full, ($text -replace "`r`n", "`n"), $utf8)
}
# Cỡ cửa hàng: memory request = limit của container cuahang (số minh họa; base là 512Mi).
# CPU giữ như base: request 500m cho HPA, limit 1.
$co = [ordered]@{ 'nho' = '384Mi'; 'vua' = '512Mi'; 'lon' = '768Mi' }
foreach ($ten in $co.Keys) {
Ghi "thanh-phan/co-$ten/kustomization.yaml" @"
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
labels:
- pairs:
banhang.local/co: $ten
patches:
- target:
kind: Deployment
name: cuahang
patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: $($co[$ten])
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: $($co[$ten])
"@
}
# Vòng phát hành: overlay trung gian, mỗi vòng một tag. Commit đầu: cả hai 2.1.0.
$vong = [ordered]@{ 'on-dinh' = '2.1.0'; 'canary' = '2.1.0' }
foreach ($ten in $vong.Keys) {
Ghi "vong/$ten/kustomization.yaml" @"
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
labels:
- pairs:
banhang.local/vong: $ten
images:
- name: registry.banhang.local/banhang/cuahang
newTag: $($vong[$ten])
"@
}
# 12 cửa hàng: mã, tên, cỡ, vòng.
$cuaHang = @(
@('ch01', 'BanHang Cầu Giấy', 'lon', 'on-dinh'),
@('ch02', 'BanHang Hoàn Kiếm', 'lon', 'on-dinh'),
@('ch03', 'BanHang Long Biên', 'vua', 'canary'),
@('ch04', 'BanHang Hải Phòng', 'vua', 'on-dinh'),
@('ch05', 'BanHang Hải Châu', 'lon', 'on-dinh'),
@('ch06', 'BanHang Huế', 'nho', 'on-dinh'),
@('ch07', 'BanHang Nha Trang', 'vua', 'canary'),
@('ch08', 'BanHang Quận 1', 'lon', 'on-dinh'),
@('ch09', 'BanHang Thủ Đức', 'vua', 'on-dinh'),
@('ch10', 'BanHang Gò Vấp', 'vua', 'on-dinh'),
@('ch11', 'BanHang Cần Thơ', 'nho', 'on-dinh'),
@('ch12', 'BanHang Biên Hòa', 'nho', 'on-dinh')
)
foreach ($ch in $cuaHang) {
$ma, $tenCh, $c, $v = $ch
Ghi "cuahang/$ma/kustomization.yaml" @"
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../vong/$v
components:
- ../../thanh-phan/co-$c
labels:
- pairs:
banhang.local/cua-hang: $ma
configMapGenerator:
- name: cuahang-cauhinh
behavior: merge
literals:
- CuaHang__Ma=$($ma.ToUpper())
- CuaHang__Ten=$tenCh
"@
Ghi "clusters/k3s-$ma/cuahang.yaml" @"
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cuahang
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 3m
path: ./cuahang/$ma
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
"@
}
"ok"
flux bootstrap tạo GitRepository tên flux-system và một Kustomization trỏ vào clusters/k3s-ch03. Trong thư mục đó, file dưới bảo Flux của máy k3s-ch03 áp overlay cuahang/ch03:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: cuahang
namespace: flux-system
spec:
interval: 10m # mỗi 10 phút build, áp lại, trả lại thay đổi tay
retryInterval: 2m
timeout: 3m
path: ./cuahang/ch03
prune: true # object bỏ khỏi Git thì xóa trên máy
wait: true # chờ Deployment rollout xong mới báo Ready
sourceRef:
kind: GitRepository
name: flux-system
Công cụ so 12 bản render
SoCuaHang.cs trải mỗi file render thành cặp đường dẫn và giá trị lá, như Deployment/cuahang .spec.template.spec.containers[cuahang].image. Phần tử danh sách có name được gọi theo tên, không theo vị trí. Trường có hơn một giá trị giữa 12 cửa hàng phải thuộc một chiều đã khai báo, và bằng nhau trong mỗi nhóm cùng nhãn của chiều đó:
(string Mau, string Nhan)[] khaiBao =
[
(@"^ConfigMap/cuahang-cauhinh \.data\.CuaHang__(Ma|Ten)$", "banhang.local/cua-hang"),
(@"^Deployment/cuahang \.spec\.template\.spec\.containers\[cuahang\]\.resources\.", "banhang.local/co"),
(@"\]\.image$", "banhang.local/vong"), // container, initContainer, CronJob: cùng image
];
// ...với mỗi trường d có hơn một giá trị giữa 12 cửa hàng:
string? nhan = nhanBienThe.FirstOrDefault(n => d.EndsWith($" .metadata.labels.{n}"))
?? khaiBao.FirstOrDefault(k => Regex.IsMatch(d, k.Mau)).Nhan;
string loai, moTa = MoTa(giaTri, Enumerable.Range(0, ch.Length));
if (nhan is null) loai = "VI PHẠM: không khai báo";
else
{
// Cùng giá trị nhãn thì phải cùng giá trị trường (cùng cỡ thì cùng limit, cùng vòng thì cùng image).
string ten = nhan.Split('/')[1];
var lech = Enumerable.Range(0, ch.Length).GroupBy(i => nhanCua(ch[i], nhan))
.Where(g => g.Select(i => giaTri[i]).Distinct().Count() > 1).ToArray();
loai = lech.Length == 0 ? $"theo {ten}"
: $"VI PHẠM: lệch trong nhóm {string.Join(", ", lech.Select(g => $"{ten}={g.Key}"))}";
if (lech.Length > 0) moTa = string.Join("; ", lech.Select(g => $"{ten}={g.Key}: {MoTa(giaTri, g)}"));
}
Chạy dotnet run code/SoCuaHang.cs -- render; có vi phạm thì exit code 1. CI gọi nó trong kiem.ps1, chạy từ gốc repo.
SoCuaHang.cs: so 12 bản render, in trường khác nhau, báo vi phạm
#:package YamlDotNet@18.1.0
// So 12 bản render: in đúng những trường khác nhau giữa các cửa hàng,
// và báo vi phạm khi một trường khác nhau ngoài những gì repo đã khai báo.
// dotnet run SoCuaHang.cs -- <thư mục chứa ch01.yaml ... ch12.yaml>
using System.Text.RegularExpressions;
using YamlDotNet.RepresentationModel;
string thuMuc = args.Length > 0 ? args[0] : "render";
string[] nhanBienThe = ["banhang.local/cua-hang", "banhang.local/co", "banhang.local/vong"];
// Trường được phép khác nhau (regex trên "Kind/tên .đường.dẫn"), và nhãn quyết định giá trị của nó.
(string Mau, string Nhan)[] khaiBao =
[
(@"^ConfigMap/cuahang-cauhinh \.data\.CuaHang__(Ma|Ten)$", "banhang.local/cua-hang"),
(@"^Deployment/cuahang \.spec\.template\.spec\.containers\[cuahang\]\.resources\.", "banhang.local/co"),
(@"\]\.image$", "banhang.local/vong"), // container, initContainer, CronJob: cùng image
];
var ban = Directory.GetFiles(thuMuc, "*.yaml").Order()
.ToDictionary(f => Path.GetFileNameWithoutExtension(f), DocBanRender);
var ch = ban.Keys.ToArray();
var nhanCua = (string c, string nhan) =>
ban[c].GetValueOrDefault($"Deployment/cuahang .metadata.labels.{nhan}", "(không có)");
var duongDan = ban.Values.SelectMany(b => b.Keys).Distinct().Order(StringComparer.Ordinal).ToArray();
int giong = 0, theoKhaiBao = 0, viPham = 0;
var dongIn = new List<(string Khoa, string Loai, string Duong, string GiaTri)>();
foreach (var d in duongDan)
{
var giaTri = ch.Select(c => ban[c].GetValueOrDefault(d, "(không có)")).ToArray();
if (giaTri.Distinct().Count() == 1) { giong++; continue; }
string? nhan = nhanBienThe.FirstOrDefault(n => d.EndsWith($" .metadata.labels.{n}"))
?? khaiBao.FirstOrDefault(k => Regex.IsMatch(d, k.Mau)).Nhan;
string loai, moTa = MoTa(giaTri, Enumerable.Range(0, ch.Length));
if (nhan is null) loai = "VI PHẠM: không khai báo";
else
{
// Cùng giá trị nhãn thì phải cùng giá trị trường (cùng cỡ thì cùng limit, cùng vòng thì cùng image).
string ten = nhan.Split('/')[1];
var lech = Enumerable.Range(0, ch.Length).GroupBy(i => nhanCua(ch[i], nhan))
.Where(g => g.Select(i => giaTri[i]).Distinct().Count() > 1).ToArray();
loai = lech.Length == 0 ? $"theo {ten}"
: $"VI PHẠM: lệch trong nhóm {string.Join(", ", lech.Select(g => $"{ten}={g.Key}"))}";
if (lech.Length > 0) moTa = string.Join("; ", lech.Select(g => $"{ten}={g.Key}: {MoTa(giaTri, g)}"));
}
if (loai.StartsWith("VI PHẠM")) viPham++; else theoKhaiBao++;
int cach = d.IndexOf(' ');
dongIn.Add((d[(cach + 1)..] + "|" + string.Join("|", giaTri), loai, d, moTa));
}
int soTaiLieu = ban.Values.Max(b => b.Keys.Select(k => k[..k.IndexOf(' ')]).Distinct().Count());
Console.WriteLine($"{ch.Length} bản render, {soTaiLieu} tài liệu mỗi bản, {duongDan.Length} trường lá");
Console.WriteLine($"giống nhau ở cả {ch.Length}: {giong} | khác theo khai báo: {theoKhaiBao} | vi phạm: {viPham}");
// Gộp một trường lặp ở nhiều tài liệu (nhãn gắn lên mọi tài liệu) thành một dòng.
foreach (var nhom in dongIn.GroupBy(x => x.Khoa))
{
var dau = nhom.First();
string noi = nhom.Count() == 1 ? dau.Duong
: $"{nhom.Count()} tài liệu {dau.Duong[(dau.Duong.IndexOf(' ') + 1)..]}";
Console.WriteLine($"[{dau.Loai}]\n {noi}\n {dau.GiaTri}");
}
return viPham == 0 ? 0 : 1;
// Giá trị kèm danh sách cửa hàng, nhóm ít cửa hàng in trước; 12 giá trị khác nhau thì chỉ in đầu và cuối.
string MoTa(string[] giaTri, IEnumerable<int> chon)
{
if (giaTri.Distinct().Count() == ch.Length)
return $"{ch.Length} giá trị, mỗi cửa hàng một: {ch[0]}={giaTri[0]} ... {ch[^1]}={giaTri[^1]}";
// Bỏ phần đầu chung tới dấu "/" cuối: registry.banhang.local/banhang/cuahang:2.1.0 -> cuahang:2.1.0
var chung = giaTri.Aggregate((a, b) => new string(a.Zip(b).TakeWhile(p => p.First == p.Second).Select(p => p.First).ToArray()));
int cat = chung.LastIndexOf('/') + 1;
return string.Join(" | ", chon.GroupBy(i => giaTri[i][cat..]).OrderBy(g => g.Count())
.Select(g => $"{g.Key}: {string.Join(" ", g.Select(i => ch[i]))}"));
}
// Một file render -> "Kind/tên .đường.dẫn" -> giá trị lá.
static Dictionary<string, string> DocBanRender(string file)
{
var yaml = new YamlStream();
using (var r = File.OpenText(file)) yaml.Load(r);
var ra = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var doc in yaml.Documents)
{
if (doc.RootNode is not YamlMappingNode goc) continue; // tài liệu rỗng giữa hai dấu ---
var meta = (YamlMappingNode)goc["metadata"];
Phang(goc, $"{goc["kind"]}/{BoHash(meta["name"].ToString())} ", ra);
}
return ra;
}
static void Phang(YamlNode node, string duong, Dictionary<string, string> ra)
{
switch (node)
{
case YamlMappingNode m:
foreach (var (k, v) in m.Children) Phang(v, $"{duong}.{k}", ra);
break;
case YamlSequenceNode s:
for (int i = 0; i < s.Children.Count; i++)
{
// Phần tử có "name" (container, cổng, biến môi trường) gọi theo tên, không theo vị trí.
string khoa = s.Children[i] is YamlMappingNode pt
&& pt.Children.TryGetValue(new YamlScalarNode("name"), out var ten) ? ten.ToString() : i.ToString();
Phang(s.Children[i], $"{duong}[{khoa}]", ra);
}
break;
default:
ra[duong] = duong.EndsWith(".name") ? BoHash(node.ToString()) : node.ToString();
break;
}
}
// configMapGenerator thêm hậu tố hash 10 ký tự theo nội dung; nội dung đã được so riêng.
static string BoHash(string ten) => Regex.Replace(ten, "-[2456789bcdfghkmt]{10}$", "");
kiem.ps1: bước CI chạy ở mỗi pull request
# CI của repo GitOps: render 12 overlay, kiểm schema, so 12 bản render.
# Chạy từ gốc repo: .\kiem.ps1
$ErrorActionPreference = 'Stop'
$k8s = '1.36.5' # Kubernetes của k3s v1.36.5+k3s1 (kênh stable)
New-Item -ItemType Directory -Force render, .schema-cache | Out-Null
Remove-Item render\*.yaml -ErrorAction SilentlyContinue
foreach ($ch in Get-ChildItem cuahang -Directory) {
& .\tools\kustomize.exe build "cuahang/$($ch.Name)" -o "render/$($ch.Name).yaml"
if ($LASTEXITCODE) { throw "kustomize build $($ch.Name) lỗi" }
}
# -cache: schema Kubernetes tải một lần từ GitHub rồi giữ lại, CI không phụ thuộc mạng mỗi lần chạy.
& .\tools\kubeconform.exe -strict -summary -cache .schema-cache -kubernetes-version $k8s render/
if ($LASTEXITCODE) { throw 'kubeconform: manifest cửa hàng sai schema' }
# Schema của CRD Flux sinh từ flux install --export v2.9.6 bằng openapi2jsonschema.py của kubeconform.
& .\tools\kubeconform.exe -strict -summary `
-schema-location 'schema-flux/{{ .ResourceKind }}_{{ .ResourceAPIVersion }}.json' clusters/
if ($LASTEXITCODE) { throw 'kubeconform: Flux Kustomization sai schema' }
dotnet run code/SoCuaHang.cs -- render
if ($LASTEXITCODE) { throw 'SoCuaHang: có trường khác nhau ngoài khai báo' }
6. Chứng minh: 0 vi phạm với repo đúng, 3/3 trường bị bắt khi sửa tay ch09
Bài chưa dựng cụm thật: không chạy Docker, k3s hay Flux trên cụm. Bằng chứng: cấu hình đã qua công cụ kiểm, công cụ .NET đã chạy, và mô hình thời gian theo tài liệu Flux. Máy chạy: laptop Intel Core Ultra 5 125U, Windows 11, .NET 10.0.12. Repo mẫu có ba trạng thái, mỗi trạng thái một commit:
| Trạng thái | Thay đổi trong Git | Giống ở cả 12 | Khác theo khai báo | Vi phạm (exit) |
|---|---|---|---|---|
| A. 12 cửa hàng trên 2.1.0 | Commit đầu | 196 | 28 | 0 (0) |
B. ch03, ch07 lên 2.2.0 |
1 dòng ở vong/canary |
193 | 31 | 0 (0) |
C. Sửa tay ch09 trên B |
12 dòng ở cuahang/ch09 |
192 | 29 | 3 (1) |
Mỗi bản render có 8 tài liệu và 224 trường lá. Ở cả ba trạng thái, kubeconform cho 96/96 tài liệu hợp lệ, và 12 file Flux Kustomization hợp lệ với schema CRD sinh từ Flux v2.9.6.
Sửa tay ch09 vẫn hợp lệ với schema, nhưng tạo 3 trường lệch mà bước so render bắt được
Bảng số liệu
| Khác theo khai báo | Vi phạm | |
|---|---|---|
| A. 12 cửa hàng trên 2.1.0 | 28 trường | 0 trường |
| B. Canary ch03, ch07 lên 2.2.0 | 31 trường | 0 trường |
| C. Sửa tay ch09 | 29 trường | 3 trường |
Ở trạng thái C, overlay ch09 có patch đưa image về 2.0.3 và memory limit lên 1Gi, cộng literal DongBo__ChuKyGiay=5: ba thay đổi tay của tuần trước, giờ đi qua Git. Trích output của SoCuaHang:
[VI PHẠM: không khai báo]
ConfigMap/cuahang-cauhinh .data.DongBo__ChuKyGiay
5: ch09 | 30: ch01 ch02 ch03 ch04 ch05 ch06 ch07 ch08 ch10 ch11 ch12
[VI PHẠM: lệch trong nhóm vong=on-dinh]
Deployment/cuahang .spec.template.spec.containers[cuahang].image
vong=on-dinh: cuahang:2.0.3: ch09 | cuahang:2.1.0: ch01 ch02 ch04 ch05 ch06 ch08 ch10 ch11 ch12
[VI PHẠM: lệch trong nhóm co=vua]
Deployment/cuahang .spec.template.spec.containers[cuahang].resources.limits.memory
co=vua: 1Gi: ch09 | 512Mi: ch03 ch04 ch07 ch10
Ba quan sát:
- Schema không bắt được lệch. kubeconform chỉ bắt lỗi hình dạng:
limit:thay cholimits:cho ra "additional properties 'limit' not allowed",prune: yes-pleasecho ra "got string, want boolean". Memory limit hợp lệ nhưng lệch nhóm thì chỉ bước so render thấy. - Canary đúng phạm vi. Commit B đổi 1 dòng trong Git; so render trước và sau, chỉ
ch03.yamlvàch07.yamlđổi, mỗi file đúng 3 dòngimage: container chính, init container và CronJob. Số trường khác theo khai báo tăng từ 28 lên 31. - Hậu quả thấy trước khi merge. Render của
ch09đổi 7 dòng, trong đó 4 dòng là tên ConfigMap mới: hash đổi nên pod template đổi, và Deployment sẽ rollout.
Bản render của CI phải trùng với thứ Flux áp. flux build kustomization --dry-run của Flux CLI v2.9.6 build không cần cụm, theo file Kustomization của từng máy. So bằng SoCuaHang, 12/12 cửa hàng cho 224/224 trường giống bản của kustomize, sau khi bỏ hai nhãn sở hữu mà Flux thêm. Cả chuỗi kiem.ps1 chạy 5,9–12,1 giây ở ba lần đo khi schema đã nằm trong cache. Một lần chạy chưa có -cache dừng ở bước kubeconform, nhiều khả năng vì tải schema lỗi.
Giới hạn thời gian sau là mô hình suy từ tài liệu Flux, không phải số đo. Commit merge xong, máy thấy revision mới trong tối đa một interval của GitRepository, 1 phút theo mặc định của flux bootstrap, rồi áp ngay. Một kubectl edit bị trả lại trong tối đa một interval của Kustomization, 10 phút ở repo này. Thời gian kéo image và rollout cộng thêm.
Điều chưa chứng minh: Flux chạy thật trên k3s v1.36.5+k3s1, thời gian trả lại kubectl edit, hành vi khi máy mất mạng nhiều ngày, và RAM thật hai controller dùng trên máy 8 GB.
7. Kết luận
12 máy hết lệch khi trạng thái mong muốn của từng máy nằm trong Git và một agent trên chính máy đó kéo về, áp và trả lại thay đổi tay. Flux không chặn được thay đổi sai đi vào Git; một bước CI so 12 bản render theo các chiều đã khai báo làm việc đó.
Trong dự án .NET của bạn:
- Tách cấu hình theo chiều: overlay trung gian cho vòng phát hành, Component cho cỡ, overlay lá cho danh tính. Dùng
labels, không dùngcommonLabels. - Ghi mã và tên cửa hàng bằng
configMapGeneratorvớibehavior: merge, đọc bằngIConfiguration["CuaHang:Ma"]; hash của ConfigMap tự kích rollout khi cấu hình đổi. - Mỗi pull request chạy
kustomize buildcho mọi overlay,kubeconform -strict -kubernetes-versionđúng bản của k3s, rồi công cụ so render viết bằngYamlDotNet. - Bootstrap Flux với
--components=source-controller,kustomize-controller, đặtprune: truevàwait: true; đổi tag ởvong/canarytrước,vong/on-dinhsau.
Những chỗ hay hiểu sai
- "Qua kubeconform là cấu hình đúng." Schema chỉ kiểm hình dạng; ba thay đổi tay ở
ch09đều hợp lệ. - "Có GitOps thì không cần CI." Flux áp trung thành mọi thứ trong Git, kể cả commit sửa tay.
Đọc tiếp
- 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: bài trước trong series.
- Máy chủ cửa hàng chạy k3s: backend tự thay khi treo trên mini PC 8 GB: base của repo này.
- Swarm hay k3s cho trụ sở: ánh xạ stack BanHang.Api và chuyển không gián đoạn: chọn nền cho cụm trụ sở.
Nguồn
- OpenGitOps, GitOps Principles v1.0.0.
- Flux, Kustomization API:
interval, sửa drift,prune,wait, xử lý ngay khi revision của nguồn đổi. GitRepository API. flux build kustomization, flux bootstrap git. Bản phát hành v2.9.6, đọc ngày 4/10/2026. - Argo CD, Automated Sync Policy:
selfHeal,prunetắt mặc định. Installation: bản core. Declarative Setup: Clusters. ApplicationSet Cluster Generator. core-install.yaml v3.5.3. argoproj-labs/argocd-agent, bản 0.10.0. Đọc ngày 4/10/2026. - Kustomize, Components, configMapGenerator, labels. Bản phát hành kustomize/v5.8.2.
- k3s, v1.36.5+k3s1: Kubernetes v1.36.5, Traefik v3.7.13. Kênh phát hành: stable là v1.36.5+k3s1, đọc ngày 4/10/2026.
- yannh/kubeconform, v0.8.0 và openapi2jsonschema.py.
- Kubernetes, Deployments: Label selector updates, ConfigMaps: biến môi trường lấy từ ConfigMap không tự cập nhật.
- YamlDotNet 18.1.0 trên NuGet.