DevOpsDocker Swarm và k3s, phần 6/8
Swarm hay k3s cho trụ sở: ánh xạ stack BanHang.Api và chuyển không gián đoạn
kompose dịch stack BanHang.Api thành manifest qua kubeconform, nhưng app không khởi động và mọi thiết lập an toàn của ba bài Swarm rơi mất. Bảng tiêu chí có nguồn, ánh xạ 43 khóa của stack có công cụ C# kiểm, và một kế hoạch chuyển từng máy mà hai cụm cùng phục vụ sau load balancer.
Mục lục
- 1. Vấn đề: kompose dịch xong, kubeconform qua, app không chạy
- 2. Mục đích: quyết định có nguồn, ánh xạ không sót, chuyển không dừng
- 3. Cơ sở lý thuyết: cùng lõi Raft, khác ở những việc Swarm làm ngầm
- 4. Cách giải quyết: chuyển khi chấp nhận tự khai báo, ánh xạ có công cụ, chuyển từng máy
- 5. Cách cài đặt: k3s ba server, manifest sửa tay, công cụ đối chiếu
- 6. Chứng minh: 11 dòng sai và thiếu ở bản kompose, 0 ở bản sửa tay
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Manifest do kompose 1.38.0 dịch từ stack Swarm qua kubeconform, nhưng sai 6 chỗ và thiếu 5, trong đó secret gắn sai tên file làm
BanHang.Apikhông khởi động. - Cách giải: Chuyển trụ sở sang k3s ba server khi chấp nhận tự khai báo bốn việc Swarm làm sẵn, ánh xạ từng khóa bằng tay có công cụ đối chiếu, rồi chuyển từng máy với hai cụm cùng sau load balancer.
- Chứng minh: Chưa dựng cụm thật; 43/43 khóa của stack có luật, manifest sửa tay 0 sai 0 thiếu và qua kubeconform, app .NET thật đọc được secret theo bố cục mới.
- Trong .NET:
KeyPerFilecần file/run/secrets/ConnectionStrings__BanHang, Downward API điềnBanHang__Node, vàSoSwarmK3s.csviết bằngYamlDotNetchặn ánh xạ sót trong CI.
1. Vấn đề: kompose dịch xong, kubeconform qua, app không chạy
Sau bài 8, 12 cửa hàng chạy k3s và nhận bản mới qua Flux. Trụ sở vẫn chạy BanHang.Api trên cụm Swarm năm máy của bài 1: hai nền, hai bộ công cụ kiểm cấu hình, hai cách triển khai. Ngày 02/10, một kỹ sư thử gom về một nền (diễn biến minh họa, output ở mục 6 là thật): chạy kompose 1.38.0 trên banhang-api.stack.yml, được một Deployment và một Service, và kubeconform báo 2/2 tài liệu hợp lệ.
Đọc kỹ thì khác. Secret được gắn thành file /run/secrets/banhang-sql-v1, trong khi KeyPerFile của bài 3 cần file ConnectionStrings__BanHang: chạy thử với bố cục đó, API thoát ngay vì thiếu chuỗi kết nối. BanHang__Node mang nguyên chuỗi {{.Node.Hostname}}. Service là ClusterIP, nên load balancer phía trước không gọi vào được.
Sửa riêng mấy chỗ đó cũng chưa đủ. Manifest không có readiness, không có preStop, không có gì thay cho delay: 90s và failure_action: rollback. Đó là đúng các lỗi ba bài Swarm đã sửa: request vào task chưa mở cổng, lệnh bị cắt lúc dừng, bản treo lên cả 4 replica. Câu hỏi của bài: trụ sở có nên chuyển sang k3s, và nếu chuyển thì giữ đủ thiết lập và không dừng bán hàng thế nào.
2. Mục đích: quyết định có nguồn, ánh xạ không sót, chuyển không dừng
- Bảng tiêu chí Swarm và k3s, mỗi dòng ghi nguồn; dòng chỉ dựa trên tài liệu, chưa đo, ghi rõ.
- 43/43 khóa lá trong kết quả
docker stack configcó luật ánh xạ. Manifest sửa tay: 0 dòng sai, 0 dòng thiếu; bản của kompose để so. - Manifest qua
kubeconform -strictvới Kubernetes 1.36.5, vàBanHang.Apithật đọc được secret theo bố cục manifest gắn vào. - Kế hoạch chuyển: ở mọi bước, ít nhất một cụm còn chịu được một máy hỏng, và load balancer không phải đổi danh sách máy.
- Ngoài phạm vi: dựng cụm thật (bài này chưa dựng), độ sẵn sàng của
sql01, công cụ rollout tự động như Argo Rollouts hay Flagger.
3. Cơ sở lý thuyết: cùng lõi Raft, khác ở những việc Swarm làm ngầm
Lõi giống nhau
Manager của Swarm và etcd nhúng của k3s cùng đồng thuận bằng Raft: N máy cần ⌊N/2⌋ + 1 máy để ghi, nên 3 máy chịu được 1 máy hỏng, như bảng quorum ở bài 1. Tài liệu k3s yêu cầu số server lẻ khi dùng etcd nhúng; SQLite qua Kine chỉ dùng được với một server (bài 4). Server của k3s mặc định cũng chạy workload, như manager của Swarm. Chuyển nền không làm cụm chịu lỗi tốt hơn.
Năm việc Swarm làm sẵn, Kubernetes bắt khai báo
| Việc | Swarm | Kubernetes |
|---|---|---|
| Kiểm sức khỏe | Một healthcheck vừa chặn task chưa mở cổng khỏi mesh, vừa giết task treo | Startup, liveness, readiness là ba probe riêng |
| Dừng task | Gỡ khỏi mesh, chờ 2 s, rồi SIGTERM | Gỡ endpoint và chạy preStop song song; SIGTERM sau preStop |
| Bản mới lỗi | failure_action: rollback tự quay lại |
Chỉ báo ProgressDeadlineExceeded; quay lại là việc của công cụ khác |
| Trải replica | Scheduler chọn node ít task của service nhất | Mặc định maxSkew: 3, ScheduleAnyway: không bảo đảm mỗi node một pod |
| Lưu secret | Raft log mã hóa sẵn; tên có _, target là tên file |
Mã hóa khi lưu chỉ khi bật --secrets-encryption; tên chỉ chữ thường, số, -, . |
Dòng thứ hai quyết định cách dịch RutChoGiay: "0". Trên Swarm, 0 đúng vì Swarm đã chờ 2 giây trước SIGTERM. Trên Kubernetes, SIGTERM có thể tới khi endpoint chưa được gỡ (Triển khai không gián đoạn), nên preStop phải làm bước chờ. Grace period tính từ trước preStop, nên stop_grace_period 20 giây trở thành terminationGracePeriodSeconds 30 giây: lớn hơn preStop 5 giây cộng ShutdownTimeout 15 giây cộng 1 giây của Kestrel.
sequenceDiagram participant SW as Swarm participant K as Kubernetes participant N as Tiến trình .NET Note over SW,N: Swarm: tuần tự SW->>SW: gỡ task khỏi routing mesh SW->>N: chờ 2 s, rồi SIGTERM Note over K,N: Kubernetes: song song K->>K: gỡ endpoint khỏi Service K->>N: cùng lúc chạy preStop sleep 5 s K->>N: preStop xong mới SIGTERM, grace 30 s tính từ đầu
Dòng thứ ba quyết định cách dịch delay, monitor và rollback. Với maxSurge: 1, maxUnavailable: 0, Deployment chỉ gỡ pod cũ khi pod mới "available", tức ready liên tục trong minReadySeconds. Đặt minReadySeconds: 90 thì một bản treo sau 20 giây không bao giờ available: rollout dừng ở pod mới đầu tiên, 4 pod cũ vẫn phục vụ. Theo tài liệu Deployment, Kubernetes không làm gì thêm ngoài báo trạng thái khi quá progressDeadlineSeconds; quay lại là git revert để Flux áp bản cũ.
kompose dịch theo chữ, không theo tác dụng: start_period thành initialDelaySeconds của liveness, secret thành file mang tên secret, chuỗi mẫu {{...}} giữ nguyên. Vì vậy manifest hợp lệ với schema mà sai về hành vi.
4. Cách giải quyết: chuyển khi chấp nhận tự khai báo, ánh xạ có công cụ, chuyển từng máy
Có nên chuyển
| Tiêu chí | Swarm | k3s ba server | Nguồn |
|---|---|---|---|
| Chịu một máy hỏng | 3 manager | 3 server etcd nhúng | Bài 1; tài liệu k3s, chưa đo |
| Bản lỗi tự quay lại | Có | Không; chặn bằng minReadySeconds, quay lại bằng Git |
Bài 2; tài liệu Kubernetes, chưa đo |
| Tách sống và sẵn sàng | Không | Có | Bài 1, bài 5 |
| Thêm pod theo tải | Không | HPA | Bài 5 |
| Triển khai từ Git, kiểm manifest | docker stack deploy, KiemStack.cs riêng |
Flux, kubeconform, cùng chuỗi CI với 12 cửa hàng | Bài 2, bài 8 |
| RAM của control plane | Tài liệu không có số | 1606 M cho server etcd kèm workload, k3s v1.26.5 | Bài 4, trang Resource Profiling; chưa đo |
Bài chọn chuyển, vì ba dòng giữa: readiness tách khỏi liveness, HPA, và một chuỗi Git, Flux, kubeconform cho cả 13 cụm. Cái giá là bốn việc ở mục 3 phải viết ra thành manifest và cờ của k3s, và rollback tự động thành một bước git revert. Đội không muốn đổi thì Swarm vẫn đủ cho yêu cầu chịu lỗi của bài 1.
Chuyển thế nào
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Dừng Swarm, cài k3s trên năm máy | Đơn giản | Dừng bán hàng online suốt lúc cài | Không dùng |
| Dựng k3s trên năm máy mới, đổi load balancer | Hai cụm đều đủ ba nút điều khiển | Gấp đôi số máy trong lúc chuyển | Có sẵn máy |
| Chuyển từng máy, hai cụm cùng sau load balancer | Không cần máy mới | Có lúc một cụm không chịu được lỗi | Năm máy hiện có |
Bài chọn cách thứ ba. Load balancer của bài 1 trỏ vào cổng 8080 của cả năm máy. Máy thuộc Swarm trả lời qua routing mesh, máy thuộc k3s qua ServiceLB, một DaemonSet giữ hostPort 8080 trên mọi node. Load balancer kiểm /healthz/ready như ở Triển khai không gián đoạn thì máy đang đổi nền tự rớt khỏi pool rồi tự vào lại, nên danh sách máy không đổi. Cột chịu lỗi tính bằng ⌊(N − 1)/2⌋ trên số manager hoặc server:
| Bước | Việc | Swarm: manager, chịu lỗi | k3s: server, chịu lỗi | Ai phục vụ |
|---|---|---|---|---|
| 0 | Hiện tại | 3, chịu 1 | Chưa có | Swarm |
| 1 | sw-wkr-1 rời Swarm, cài server đầu tiên --cluster-init |
3, chịu 1 | 1, chịu 0 | Swarm |
| 2 | sw-wkr-2 thành server thứ hai; tạo Secret, áp manifest |
3, chịu 1 | 2, chịu 0 | Cả hai |
| 3 | sw-mgr-3 demote, rời Swarm, thành server thứ ba |
2, chịu 0 | 3, chịu 1 | Cả hai |
| 4 | sw-mgr-2, sw-mgr-1 rời Swarm, thành agent |
Hết | 3, chịu 1 | k3s |
Ở mọi bước, một cụm còn chịu được một máy hỏng. Swarm mất quorum thì task đang chạy vẫn phục vụ, chỉ không ai dời chúng (bài 1); với k3s, bài này chưa kiểm hành vi tương ứng. Mỗi máy rời Swarm theo đúng vòng drain của bài 1. Bước 3 và 4 nên làm liền nhau, vì Swarm chỉ còn hai manager.
5. Cách cài đặt: k3s ba server, manifest sửa tay, công cụ đối chiếu
Phiên bản: k3s v1.36.5+k3s1 (Kubernetes v1.36.5), Docker Engine 29.8.2 như bài 1, kompose 1.38.0, kustomize v5.8.2, kubeconform v0.8.0, .NET 10.0.12, YamlDotNet 18.1.0. Lệnh trên máy, chưa chạy trong bài; token và giá trị secret là giả:
# sw-wkr-1, sau khi drain và docker swarm leave
printf 'node-name: sw-wkr-1\ncluster-init: true\nsecrets-encryption: true\ntoken: GIA-token-tru-so\n' | sudo tee /etc/rancher/k3s/config.yaml
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.36.5+k3s1 sh -s - server
# sw-wkr-2, sw-mgr-3: server; sw-mgr-2, sw-mgr-1: "agent --server https://sw-wkr-1:6443"
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.36.5+k3s1 K3S_TOKEN=GIA-token-tru-so sh -s - server --server https://sw-wkr-1:6443 --secrets-encryption
sudo k3s kubectl create secret generic banhang-sql-v1 -n banhang --from-file=connection-string=./banhang-sql.txt
sudo k3s kubectl create secret generic bhpay-api-key-v1 -n banhang --from-file=api-key=./bhpay-api-key.txt
Theo tài liệu k3s, --secrets-encryption thuộc nhóm cờ phải giống nhau trên mọi server. Phần manifest khác Swarm nhiều nhất:
spec:
replicas: 4
minReadySeconds: 90 # delay, monitor 90 s
strategy: { rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } } # parallelism 1, start-first
template:
spec:
terminationGracePeriodSeconds: 30
topologySpreadConstraints:
- { maxSkew: 1, topologyKey: kubernetes.io/hostname, whenUnsatisfiable: DoNotSchedule,
labelSelector: { matchLabels: { app.kubernetes.io/name: banhang-api } } }
containers:
- name: api
env:
- name: BanHang__Node # {{.Node.Hostname}}
valueFrom: { fieldRef: { fieldPath: spec.nodeName } }
startupProbe: { httpGet: { path: /healthz/live, port: http }, periodSeconds: 1, timeoutSeconds: 2, failureThreshold: 60 }
livenessProbe: { httpGet: { path: /healthz/live, port: http }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 }
readinessProbe: { httpGet: { path: /healthz/ready, port: http }, periodSeconds: 5, timeoutSeconds: 2, failureThreshold: 2 }
lifecycle: { preStop: { sleep: { seconds: 5 } } }
volumeMounts: [{ name: bi-mat, mountPath: /run/secrets, readOnly: true }]
volumes:
- name: bi-mat # hai Secret chung một thư mục
projected: { sources: [
{ secret: { name: banhang-sql-v1, items: [{ key: connection-string, path: ConnectionStrings__BanHang }] } },
{ secret: { name: bhpay-api-key-v1, items: [{ key: api-key, path: BHPay__ApiKey }] } } ] }
Hai Secret phải nằm chung /run/secrets như trên Swarm, mà hai volume không gắn được cùng một đường dẫn, nên dùng volume projected. path của từng item là tên khóa cấu hình, nên AddKeyPerFile("/run/secrets") và GetConnectionString("BanHang") giữ nguyên. Toàn bộ manifest:
tru-so/: namespace, deployment, service, pdb, kustomization
# ===== kustomization.yaml
# Secret banhang-sql-v1 (khóa connection-string) và bhpay-api-key-v1 (khóa api-key) tạo ngoài Git.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- pdb.yaml
# ===== namespace.yaml
---
apiVersion: v1
kind: Namespace
metadata:
name: banhang # tên stack của Swarm
# ===== deployment.yaml
---
# BanHang.Api trên k3s HA của trụ sở. Ánh xạ từ banhang-api.stack.yml (service api của stack banhang).
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: banhang
labels:
app.kubernetes.io/name: banhang-api
spec:
replicas: 4 # deploy.replicas
minReadySeconds: 90 # update_config.delay và monitor 90 s
progressDeadlineSeconds: 600 # quá hạn chỉ báo ProgressDeadlineExceeded, không tự rollback
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # parallelism 1, order start-first
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: banhang-api
template:
metadata:
labels:
app.kubernetes.io/name: banhang-api
spec:
automountServiceAccountToken: false # API không gọi Kubernetes API
terminationGracePeriodSeconds: 30 # > preStop 5 s + ShutdownTimeout 15 s + 1 s
topologySpreadConstraints: # Swarm tự trải 4 replica trên 5 node; Kubernetes phải khai báo
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: banhang-api
containers:
- name: api
image: registry.banhang.local/banhang/api:1.5.0
ports:
- name: http
containerPort: 8080
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: BanHang__Node # Swarm: {{.Node.Hostname}}
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: BanHang__Task # Swarm: {{.Task.Name}}
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: RutChoGiay # preStop chờ thay cho 2 s Swarm chờ trước SIGTERM
value: "0"
resources:
requests:
cpu: 500m # reservations.cpus 0.5
memory: 512Mi # reservations.memory 512M
limits:
cpu: "2"
memory: 1Gi # limits.memory 1024M
startupProbe: # start_period 60 s, start_interval 1 s
httpGet:
path: /healthz/live
port: http
periodSeconds: 1
timeoutSeconds: 2
failureThreshold: 60
livenessProbe: # healthcheck: interval 5 s, timeout 3 s, retries 3
httpGet:
path: /healthz/live
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
readinessProbe: # Swarm không có; như bài 4
httpGet:
path: /healthz/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
sleep:
seconds: 5
volumeMounts:
- name: bi-mat
mountPath: /run/secrets
readOnly: true
volumes:
- name: bi-mat # hai Secret chung một thư mục như /run/secrets của Swarm
projected:
sources:
- secret:
name: banhang-sql-v1
items:
- key: connection-string
path: ConnectionStrings__BanHang
- secret:
name: bhpay-api-key-v1
items:
- key: api-key
path: BHPay__ApiKey
# ===== service.yaml
---
# ServiceLB của k3s giữ cổng 8080 trên mọi node, như routing mesh của Swarm.
apiVersion: v1
kind: Service
metadata:
name: api
namespace: banhang
labels:
app.kubernetes.io/name: banhang-api
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: banhang-api
ports:
- name: http
protocol: TCP
port: 8080
targetPort: http
# ===== pdb.yaml
---
# kubectl drain khi vá máy (docker node update --availability drain ở bài 1): giữ ít nhất 3/4 replica.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api
namespace: banhang
spec:
minAvailable: 3
selector:
matchLabels:
app.kubernetes.io/name: banhang-api
SoSwarmK3s.cs trải kết quả docker stack config thành 43 khóa lá, như services.api.deploy.update_config.monitor. Mỗi luật nhận một nhóm khóa bằng regex và đọc chỗ tương ứng trong manifest; khóa không luật nào nhận là CHƯA ÁNH XẠ. Ba luật cuối kiểm việc Swarm làm ngầm. Mã thoát là số dòng sai, thiếu và chưa ánh xạ. Một luật:
(@"^services\.api\.deploy\.update_config\.(delay|monitor)$", "delay, monitor", () =>
{
double can = Math.Max(double.Parse(Giay(S("deploy.update_config.delay"))), double.Parse(Giay(S("deploy.update_config.monitor"))));
string? m = V(dep, "spec", "minReadySeconds");
return m is null ? ("THIẾU", "không có minReadySeconds: bản treo lên cả 4 pod")
: So(m, 0) >= can ? ("đổi dạng", $"minReadySeconds {m}") : ("SAI", $"minReadySeconds {m} < {can}");
}),
Chạy dotnet run SoSwarmK3s.cs -- banhang-api.render.yml tru-so trong CI cùng kubeconform.
SoSwarmK3s.cs: so 43 khóa của stack Swarm với manifest Kubernetes
#:package YamlDotNet@18.1.0
// So từng thiết lập của stack Swarm (kết quả docker stack config) với manifest Kubernetes.
// Mỗi khóa lá của service phải được một luật nhận; luật nói manifest giữ thiết lập đó ra sao.
// dotnet run SoSwarmK3s.cs -- banhang-api.render.yml <manifest.yaml | thư mục manifest>
using System.Globalization;
using System.Text.RegularExpressions;
using YamlDotNet.RepresentationModel;
var swarm = new Dictionary<string, string>();
Phang(DocYaml(args[0])[0], "", swarm);
foreach (var k in swarm.Keys.Where(k => !k.StartsWith("services.") && !k.StartsWith("secrets.")).ToList()) swarm.Remove(k); // bỏ "version"
var k8s = (Directory.Exists(args[1]) ? Directory.GetFiles(args[1], "*.yaml") : [args[1]]).SelectMany(DocYaml)
.OfType<YamlMappingNode>().ToList();
var dep = k8s.FirstOrDefault(d => V(d, "kind") == "Deployment");
var pod = N(dep, "spec", "template", "spec");
var c = (N(pod, "containers") as YamlSequenceNode)?.Children.FirstOrDefault();
var svc = k8s.FirstOrDefault(d => V(d, "kind") == "Service");
var pdb = k8s.FirstOrDefault(d => V(d, "kind") == "PodDisruptionBudget");
string S(string p) => swarm.GetValueOrDefault("services.api." + p, "");
const double ShutdownTimeout = 15, KestrelSauAbort = 1, GossipSwarm = 2; // BanHang.Api.cs, bài 2
// Trạng thái: khớp | đổi dạng (trường khác, cùng tác dụng) | thêm mới (Swarm làm ngầm) |
// thay bằng quy trình | không tương đương | SAI | THIẾU
var luat = new List<(string Mau, string Ten, Func<(string, string)> Kiem)>
{
(@"^services\.api\.image$", "image", () => Bang(V(c, "image"), S("image"))),
(@"^services\.api\.deploy\.(mode|replicas)$", "replicas", () => Bang(V(dep, "spec", "replicas"), S("deploy.replicas"))),
(@"^services\.api\.deploy\.resources\.limits\.cpus$", "limits.cpus", () => Cpu(V(c, "resources", "limits", "cpu"), S("deploy.resources.limits.cpus"))),
(@"^services\.api\.deploy\.resources\.limits\.memory$", "limits.memory", () => Ram(V(c, "resources", "limits", "memory"), S("deploy.resources.limits.memory"))),
(@"^services\.api\.deploy\.resources\.reservations\.cpus$", "reservations.cpus", () => Cpu(V(c, "resources", "requests", "cpu"), S("deploy.resources.reservations.cpus"))),
(@"^services\.api\.deploy\.resources\.reservations\.memory$", "reservations.memory", () => Ram(V(c, "resources", "requests", "memory"), S("deploy.resources.reservations.memory"))),
(@"^services\.api\.deploy\.restart_policy\.condition$", "restart_policy.condition", () =>
V(pod, "restartPolicy") is null or "Always" ? ("khớp", "restartPolicy Always") : ("SAI", V(pod, "restartPolicy")!)),
(@"^services\.api\.deploy\.restart_policy\.delay$", "restart_policy.delay", () =>
("không tương đương", "kubelet tự lùi 10 s, 20 s, 40 s… tối đa 300 s")),
(@"^services\.api\.environment\.ASPNETCORE_ENVIRONMENT$", "env ASPNETCORE_ENVIRONMENT", () => Bang(V(Env("ASPNETCORE_ENVIRONMENT"), "value"), S("environment.ASPNETCORE_ENVIRONMENT"))),
(@"^services\.api\.environment\.BanHang__Node$", "env {{.Node.Hostname}}", () => TuPod("BanHang__Node", "spec.nodeName")),
(@"^services\.api\.environment\.BanHang__Task$", "env {{.Task.Name}}", () => TuPod("BanHang__Task", "metadata.name")),
(@"^services\.api\.environment\.RutChoGiay$", "rút trước SIGTERM", () =>
{
double rut = double.Parse(V(Env("RutChoGiay"), "value") ?? "0", CultureInfo.InvariantCulture), cho = PreStop();
string k = $"RutChoGiay {rut} + preStop {cho} s";
return rut + cho >= GossipSwarm ? ("đổi dạng", k) : ("SAI", k + $": SIGTERM tới cùng lúc gỡ endpoint, Swarm chờ {GossipSwarm} s");
}),
(@"^services\.api\.ports\[0\]\.target$", "ports.target", () => Bang(V(N(c, "ports") is YamlSequenceNode p ? p.Children[0] : null, "containerPort"), S("ports[0].target"))),
(@"^services\.api\.ports\[0\]\.(published|mode|protocol)$", "routing mesh :8080", () =>
svc is null ? ("THIẾU", "không có Service")
: V(svc, "spec", "type") == "LoadBalancer" && V(N(svc, "spec", "ports") is YamlSequenceNode sp ? sp.Children[0] : null, "port") == S("ports[0].published")
? ("đổi dạng", "Service LoadBalancer :8080, ServiceLB trên mọi node")
: ("SAI", $"Service {V(svc, "spec", "type") ?? "ClusterIP"}: không nhận request từ ngoài cụm")),
(@"^services\.api\.healthcheck\.test\[\d\]$", "healthcheck.test", () =>
{
var lv = N(c, "livenessProbe");
string url = S("healthcheck.test[2]"), duong = new Uri(url).AbsolutePath;
if (V(lv, "httpGet", "path") == duong) return ("đổi dạng", $"livenessProbe httpGet {duong}");
if (N(lv, "exec", "command") is YamlSequenceNode cmd && cmd.Children.Any(x => x.ToString() == url)) return ("khớp", "livenessProbe exec KiemTra");
return ("THIẾU", "không có livenessProbe");
}),
(@"^services\.api\.healthcheck\.interval$", "healthcheck.interval", () => Bang(V(c, "livenessProbe", "periodSeconds"), Giay(S("healthcheck.interval")))),
(@"^services\.api\.healthcheck\.timeout$", "healthcheck.timeout", () => Bang(V(c, "livenessProbe", "timeoutSeconds"), Giay(S("healthcheck.timeout")))),
(@"^services\.api\.healthcheck\.retries$", "healthcheck.retries", () => Bang(V(c, "livenessProbe", "failureThreshold"), S("healthcheck.retries"))),
(@"^services\.api\.healthcheck\.(start_period|start_interval)$", "start_period, start_interval", () =>
{
double han = double.Parse(Giay(S("healthcheck.start_period"))), buoc = double.Parse(Giay(S("healthcheck.start_interval")));
var st = N(c, "startupProbe");
if (st is not null)
{
double p = So(V(st, "periodSeconds"), 10), f = So(V(st, "failureThreshold"), 3);
return p == buoc && p * f >= han ? ("đổi dạng", $"startupProbe {p} s × {f}") : ("SAI", $"startupProbe {p} s × {f}");
}
string? tre = V(c, "livenessProbe", "initialDelaySeconds");
return tre is null ? ("THIẾU", "không có startupProbe") : ("SAI", $"liveness initialDelaySeconds {tre}: kiểm muộn {tre} s, không có startupProbe");
}),
(@"^services\.api\.stop_grace_period$", "stop_grace_period", () =>
{
double grace = So(V(pod, "terminationGracePeriodSeconds"), 30), cho = PreStop(), can = cho + ShutdownTimeout + KestrelSauAbort;
string k = $"terminationGracePeriodSeconds {grace}" + (cho > 0 ? $", tính từ trước preStop {cho} s" : "");
return grace > can ? (cho > 0 ? "đổi dạng" : "khớp", k) : ("SAI", k + $": cần hơn {can} s");
}),
(@"^services\.api\.deploy\.update_config\.(parallelism|order)$", "parallelism, start-first", () =>
{
var ru = N(dep, "spec", "strategy", "rollingUpdate");
string k = $"maxSurge {V(ru, "maxSurge") ?? "25%"}, maxUnavailable {V(ru, "maxUnavailable") ?? "25%"}";
return V(ru, "maxSurge") == S("deploy.update_config.parallelism") && V(ru, "maxUnavailable") == "0" ? ("đổi dạng", k) : ("SAI", k);
}),
(@"^services\.api\.deploy\.update_config\.(delay|monitor)$", "delay, monitor", () =>
{
double can = Math.Max(double.Parse(Giay(S("deploy.update_config.delay"))), double.Parse(Giay(S("deploy.update_config.monitor"))));
string? m = V(dep, "spec", "minReadySeconds");
return m is null ? ("THIẾU", "không có minReadySeconds: bản treo lên cả 4 pod")
: So(m, 0) >= can ? ("đổi dạng", $"minReadySeconds {m}") : ("SAI", $"minReadySeconds {m} < {can}");
}),
(@"^services\.api\.deploy\.update_config\.failure_action$", "failure_action: rollback", () =>
V(dep, "spec", "progressDeadlineSeconds") is { } h
? ("thay bằng quy trình", $"progressDeadlineSeconds {h} chỉ báo lỗi; quay lại bằng git revert")
: ("THIẾU", "Deployment không tự rollback, không có hạn báo lỗi")),
(@"^services\.api\.deploy\.rollback_config\.", "rollback_config", () =>
("không tương đương", "rollout undo và git revert dùng lại strategy của Deployment")),
(@"^services\.api\.secrets\[\d\]\.(source|target)$", "secrets", () => BiMat()),
(@"^secrets\.", "secret external", () =>
k8s.Any(d => V(d, "kind") == "Secret") ? ("SAI", "Secret nằm trong manifest") : ("khớp", "Secret tạo ngoài Git")),
};
// Swarm làm ngầm, không có khóa trong stack nhưng bài 1 dựa vào.
var ngam = new List<(string Ten, Func<(string, string)> Kiem)>
{
("trải 4 replica trên 5 node", () => N(pod, "topologySpreadConstraints") is YamlSequenceNode t
&& t.Children.Any(x => V(x, "maxSkew") == "1" && V(x, "topologyKey") == "kubernetes.io/hostname" && V(x, "whenUnsatisfiable") == "DoNotSchedule")
? ("thêm mới", "topologySpreadConstraints maxSkew 1") : ("THIẾU", "mặc định maxSkew 3, ScheduleAnyway")),
("vào mesh khi healthcheck đạt", () => N(c, "readinessProbe") is not null
? ("thêm mới", $"readinessProbe {V(c, "readinessProbe", "httpGet", "path")}") : ("THIẾU", "pod nhận request trước khi mở cổng")),
("drain giữ 3/4 replica", () => pdb is not null ? ("thêm mới", $"PodDisruptionBudget minAvailable {V(pdb, "spec", "minAvailable")}") : ("THIẾU", "không có PodDisruptionBudget")),
};
var ketQua = new List<(string TrangThai, string Ten, string Swarm, string K8s)>();
var daNhan = new HashSet<string>();
foreach (var (mau, ten, kiem) in luat)
{
var khoa = swarm.Keys.Where(k => Regex.IsMatch(k, mau)).ToList();
if (khoa.Count == 0) continue;
daNhan.UnionWith(khoa);
var (tt, kv) = kiem();
ketQua.Add((tt, ten, string.Join(", ", khoa.Select(k => swarm[k]).Distinct()), kv));
}
foreach (var (ten, kiem) in ngam) { var (tt, kv) = kiem(); ketQua.Add((tt, ten, "(ngầm)", kv)); }
var chuaNhan = swarm.Keys.Where(k => !daNhan.Contains(k)).ToList();
foreach (var r in ketQua) Console.WriteLine($"{r.TrangThai,-20}{r.Ten,-30}{Cat(r.Swarm, 28),-30}{r.K8s}");
foreach (var k in chuaNhan) Console.WriteLine($"{"CHƯA ÁNH XẠ",-20}{k}");
Console.WriteLine($"{swarm.Count} khóa lá trong stack, {daNhan.Count} được luật nhận, {ketQua.Count} dòng so: " +
string.Join(", ", ketQua.GroupBy(r => r.TrangThai).OrderBy(g => g.Key).Select(g => $"{g.Key} {g.Count()}")));
int loi = ketQua.Count(r => r.TrangThai is "SAI" or "THIẾU") + chuaNhan.Count;
return loi;
// ---- đọc YAML ----
static List<YamlNode> DocYaml(string f) { var y = new YamlStream(); using var r = File.OpenText(f); y.Load(r); return y.Documents.Select(d => d.RootNode).ToList(); }
static void Phang(YamlNode n, string duong, Dictionary<string, string> ra)
{
if (n is YamlMappingNode m) foreach (var (k, v) in m.Children) Phang(v, duong == "" ? k.ToString() : $"{duong}.{k}", ra);
else if (n is YamlSequenceNode s) for (int i = 0; i < s.Children.Count; i++) Phang(s.Children[i], $"{duong}[{i}]", ra);
else ra[duong] = n.ToString();
}
static YamlNode? N(YamlNode? n, params string[] duong)
{
foreach (var d in duong)
if (n is YamlMappingNode m && m.Children.TryGetValue(new YamlScalarNode(d), out var con)) n = con; else return null;
return n;
}
static string? V(YamlNode? n, params string[] duong) => N(n, duong) is YamlScalarNode s ? s.Value : null;
YamlNode? Env(string ten) => (N(c, "env") as YamlSequenceNode)?.Children.FirstOrDefault(e => V(e, "name") == ten);
double PreStop() => So(V(c, "lifecycle", "preStop", "sleep", "seconds"), 0);
// ---- so giá trị ----
static (string, string) Bang(string? k, string s) => k is null ? ("THIẾU", "-") : k == s ? ("khớp", k) : ("SAI", k);
(string, string) TuPod(string ten, string truong)
{
var e = Env(ten);
if (V(e, "valueFrom", "fieldRef", "fieldPath") == truong) return ("đổi dạng", $"fieldRef {truong}");
return e is null ? ("THIẾU", "-") : ("SAI", $"chuỗi \"{V(e, "value")}\": Kubernetes không thay mẫu của Swarm");
}
static (string, string) Cpu(string? k, string s) => k is null ? ("THIẾU", "-")
: SoCpu(k) == double.Parse(s, CultureInfo.InvariantCulture) ? (k == s ? "khớp" : "đổi dạng", k) : ("SAI", k);
static (string, string) Ram(string? k, string s) => k is null ? ("THIẾU", "-")
: Byte(k) == long.Parse(s) ? (k == s ? "khớp" : "đổi dạng", k) : ("SAI", k);
(string, string) BiMat()
{
var loi = new List<string>(); var dung = new List<string>();
foreach (int i in new[] { 0, 1 })
{
string nguon = S($"secrets[{i}].source"), dich = S($"secrets[{i}].target"), ten = nguon.Replace('_', '-');
string? tep = TepBiMat(ten);
if (tep == $"/run/secrets/{dich}") dung.Add($"{ten} -> {tep}"); else loi.Add($"{ten} -> {tep ?? "không gắn"}");
}
return loi.Count == 0 ? ("đổi dạng", string.Join("; ", dung)) : ("SAI", string.Join("; ", loi) + ": KeyPerFile không thấy khóa");
}
// Đường dẫn file mà Secret `ten` hiện ra trong container, theo volumeMounts, items và subPath.
string? TepBiMat(string ten)
{
foreach (var vol in (N(pod, "volumes") as YamlSequenceNode)?.Children ?? [])
{
var nguon = N(vol, "secret") is { } s1 ? new[] { s1 } : (N(vol, "projected", "sources") as YamlSequenceNode)?.Children.Select(x => N(x, "secret")).OfType<YamlNode>().ToArray() ?? [];
foreach (var s in nguon)
{
if ((V(s, "secretName") ?? V(s, "name")) != ten) continue;
string item = (N(s, "items") as YamlSequenceNode)?.Children.Select(x => V(x, "path")).FirstOrDefault() ?? ten;
var gan = (N(c, "volumeMounts") as YamlSequenceNode)?.Children.FirstOrDefault(m => V(m, "name") == V(vol, "name"));
if (gan is null) return null;
return V(gan, "subPath") is not null ? V(gan, "mountPath") : $"{V(gan, "mountPath")!.TrimEnd('/')}/{item}";
}
}
return null;
}
static double So(string? s, double macDinh) => s is null ? macDinh : double.Parse(s, CultureInfo.InvariantCulture);
static double SoCpu(string s) => s.EndsWith('m') ? double.Parse(s[..^1], CultureInfo.InvariantCulture) / 1000 : double.Parse(s, CultureInfo.InvariantCulture);
static long Byte(string s)
{
var m = Regex.Match(s, @"^(\d+)(Ki|Mi|Gi|k|M|G)?$");
long x = long.Parse(m.Groups[1].Value);
return x * m.Groups[2].Value switch { "Ki" => 1L << 10, "Mi" => 1L << 20, "Gi" => 1L << 30, "k" => 1000, "M" => 1_000_000, "G" => 1_000_000_000, _ => 1 };
}
static string Giay(string s) => Regex.Matches(s, @"([\d.]+)(h|ms|m|s)").Sum(m =>
double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) * m.Groups[2].Value switch { "h" => 3600, "m" => 60, "ms" => 0.001, _ => 1 })
.ToString(CultureInfo.InvariantCulture);
static string Cat(string s, int n) => s.Length <= n ? s : s[..(n - 1)] + "…";
ThuBoCuc.cs: BanHang.Api thật với bố cục secret của kompose và của manifest sửa tay
#:package Microsoft.Data.SqlClient@7.1.1
#:property PublishAot=false
// ThuBoCuc.cs: chạy BanHang.Api thật với hai bố cục /run/secrets và hai cách điền BanHang__Node.
// - kompose 1.38.0: file mang tên Secret, env giữ nguyên chuỗi mẫu của Swarm.
// - manifest sửa tay: file mang tên khóa cấu hình, env do kubelet điền từ spec.nodeName.
// dotnet run ThuBoCuc.cs (BanHang.Api.cs ở ./app; tạo rồi xóa database Kumeo_G2 trên LocalDB). Giá trị bí mật là giả.
using System.Diagnostics;
using Microsoft.Data.SqlClient;
const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;Encrypt=False";
const string ChuoiSql = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_G2;Integrated Security=true;Encrypt=False";
const string KhoaGia = "bhpay_test_sk_GIA_v1_7Qm2Xc9Lp4";
Process.Start("dotnet", "build app/BanHang.Api.cs -o out/api -v q").WaitForExit();
string exe = Path.GetFullPath(Path.Combine("out/api", OperatingSystem.IsWindows() ? "BanHang.Api.exe" : "BanHang.Api"));
await Sql("IF DB_ID('Kumeo_G2') IS NULL CREATE DATABASE Kumeo_G2");
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
// Bố cục file trong /run/secrets, đúng như hai manifest gắn Secret vào container.
string ThuMuc(string ten, params (string tep, string giaTri)[] tep)
{
var d = Path.GetFullPath(Path.Combine("bimat", ten));
if (Directory.Exists(d)) Directory.Delete(d, true);
Directory.CreateDirectory(d);
foreach (var (t, v) in tep) File.WriteAllText(Path.Combine(d, t), v); // như kubelet: không thêm xuống dòng
return d;
}
var boCuc = new (string ten, string thuMuc, string node)[]
{
("kompose 1.38.0", ThuMuc("kompose", ("banhang-sql-v1", ChuoiSql), ("bhpay-api-key-v1", KhoaGia)), "{{.Node.Hostname}}"),
("kompose, sửa riêng secret", ThuMuc("kompose-sua-secret", ("ConnectionStrings__BanHang", ChuoiSql), ("BHPay__ApiKey", KhoaGia)), "{{.Node.Hostname}}"),
("sửa tay", ThuMuc("sua-tay", ("ConnectionStrings__BanHang", ChuoiSql), ("BHPay__ApiKey", KhoaGia)), "sw-wkr-1"),
};
int cong = 5281;
foreach (var (ten, thuMuc, node) in boCuc)
{
string url = $"http://127.0.0.1:{cong++}";
var psi = new ProcessStartInfo(exe, ["--urls", url, "--ThuMucBiMat", thuMuc, "--SqlChoKhoiDongGiay", "5"])
{ RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true };
psi.Environment["BanHang__Node"] = node; // kompose: chuỗi mẫu; sửa tay: kubelet điền tên node
var sw = Stopwatch.StartNew();
var p = Process.Start(psi)!;
var loi = new List<string>();
p.OutputDataReceived += (_, _) => { };
p.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (loi) loi.Add(e.Data); };
p.BeginOutputReadLine(); p.BeginErrorReadLine();
while (!p.HasExited && await Ma(url + "/healthz/ready") != 200) await Task.Delay(50);
Console.WriteLine($"{ten}: {string.Join(", ", Directory.GetFiles(thuMuc).Select(Path.GetFileName))}");
if (p.HasExited)
{
await Task.Delay(200);
string dong = loi.FirstOrDefault(d => d.Contains("Exception")) ?? "";
Console.WriteLine($" không khởi động được, thoát mã {p.ExitCode}: {dong.Split("Exception: ")[^1].Trim()}");
continue;
}
var may = System.Text.Json.JsonDocument.Parse(await http.GetStringAsync(url + "/api/may"));
Console.WriteLine($" ready 200 sau {sw.Elapsed.TotalSeconds:F1} s; /api/may trả node = {may.RootElement.GetProperty("node")}");
p.StandardInput.WriteLine("term"); await p.WaitForExitAsync();
}
SqlConnection.ClearAllPools();
await Sql("ALTER DATABASE Kumeo_G2 SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_G2");
Console.WriteLine($".NET {Environment.Version}, Kumeo_G2 đã xóa");
async Task<int> Ma(string u) { try { using var r = await http.GetAsync(u); return (int)r.StatusCode; } catch { return 0; } }
static async Task Sql(string lenh) { await using var cn = new SqlConnection(Master); await cn.OpenAsync(); await new SqlCommand(lenh, cn).ExecuteNonQueryAsync(); }
6. Chứng minh: 11 dòng sai và thiếu ở bản kompose, 0 ở bản sửa tay
Bài chưa dựng cụm thật: không chạy Docker, k3s hay Swarm. Bằng chứng gồm cấu hình đã qua công cụ kiểm, .NET chạy thật trên laptop Intel Core Ultra 5 125U, Windows 11, và bảng chịu lỗi ở mục 4 tính theo công thức quorum của bài 1.
kubeconform v0.8.0, -strict, Kubernetes 1.36.5: bản kompose 2/2 tài liệu hợp lệ, bản sửa tay 4/4; kubernetes-validate 1.36.0 cũng cho 4/4. SoSwarmK3s.cs trên cùng 43 khóa, 29 dòng so (26 luật và 3 việc ngầm):
| Trạng thái | kompose 1.38.0 | Sửa tay |
|---|---|---|
| Khớp | 14 | 10 |
| Đổi dạng, cùng tác dụng | 2 | 13 |
| Thêm mới, Swarm làm ngầm | 0 | 3 |
| Thay bằng quy trình, không tương đương | 2 | 3 |
| Sai | 6 | 0 |
| Thiếu | 5 | 0 |
Bản kompose có 11/29 dòng sai hoặc thiếu; bản sửa tay giữ đủ, phần lớn bằng trường khác tên
Bảng số liệu
| Khớp | Đổi dạng hoặc thêm mới | Không tương đương | Sai | Thiếu | Tổng | |
|---|---|---|---|---|---|---|
| kompose 1.38.0 | 14 dòng | 2 dòng | 2 dòng | 6 dòng | 5 dòng | 29 dòng |
| Sửa tay | 10 dòng | 16 dòng | 3 dòng | 0 dòng | 0 dòng | 29 dòng |
Các dòng sai của bản kompose, bỏ cột giá trị Swarm và rút gọn lời giải thích:
SAI env {{.Node.Hostname}} chuỗi mẫu giữ nguyên, Kubernetes không thay
SAI rút trước SIGTERM RutChoGiay 0 + preStop 0 s
SAI routing mesh :8080 Service ClusterIP, không nhận request từ ngoài
SAI start_period, start_interval liveness initialDelaySeconds 60, không startupProbe
SAI secrets /run/secrets/banhang-sql-v1: KeyPerFile không thấy khóa
Dòng thứ sáu là {{.Task.Name}}, cùng lỗi với dòng đầu. Năm dòng thiếu là minReadySeconds, hạn báo lỗi rollout, topologySpreadConstraints, readiness và PodDisruptionBudget cho lúc drain. Ba dòng không tương đương ở bản sửa tay là restart_policy.delay (kubelet tự lùi 10, 20, 40 giây, tối đa 300 giây), rollback_config, và failure_action thay bằng progressDeadlineSeconds cộng git revert.
ThuBoCuc.cs chạy bản build của BanHang.Api.cs chung ba bài Swarm, với SQL Server 2019 LocalDB, database Kumeo_G2 tạo rồi xóa, .NET 10.0.12:
kompose 1.38.0: banhang-sql-v1, bhpay-api-key-v1
không khởi động được, thoát mã -532462766: Thiếu ConnectionStrings:BanHang (secret ConnectionStrings__BanHang)
kompose, sửa riêng secret: BHPay__ApiKey, ConnectionStrings__BanHang
ready 200 sau 1.4 s; /api/may trả node = {{.Node.Hostname}}
sửa tay: BHPay__ApiKey, ConnectionStrings__BanHang
ready 200 sau 1.3 s; /api/may trả node = sw-wkr-1
Với bố cục của kompose, app dừng ở dòng throw của bài 3; trên Kubernetes pod sẽ vào vòng restart. Sửa riêng secret thì app chạy, nhưng mọi pod báo cùng một tên node vô nghĩa. Ở bản sửa tay, biến môi trường đứng thay cho giá trị kubelet điền từ spec.nodeName.
Điều chưa chứng minh: chưa cài k3s, nên hành vi ServiceLB khi một máy rời cụm, thời gian rollout với minReadySeconds 90, và việc pod cũ còn phục vụ khi bản mới treo là suy từ tài liệu Kubernetes, chưa đo. Kế hoạch năm bước chưa chạy thử; mã thoát -532462766 là của Windows, trên Linux tiến trình thoát theo cách khác.
7. Kết luận
Chuyển Swarm sang k3s không làm cụm chịu lỗi tốt hơn; nó đổi rollback tự động lấy readiness, HPA và một chuỗi GitOps chung với 12 cửa hàng. Chuyển được an toàn khi mọi khóa của stack có một luật nói rõ thiết lập đó sống ở đâu trong manifest.
Trong dự án .NET của bạn:
- Đặt
pathcủa Secret bằng tên khóa cấu hình và gom các Secret bằng volumeprojectedvào/run/secrets, đểAddKeyPerFilekhông đổi. - Thay
{{.Node.Hostname}}bằng Downward APIspec.nodeName; thayRutChoGiaycủa Swarm bằngpreStopsleep và tăngterminationGracePeriodSecondstương ứng. - Dịch
delayvàmonitorthànhminReadySeconds, đặtprogressDeadlineSeconds, và để Flux ápgit revertkhi rollout báo lỗi. - Chạy kubeconform và một công cụ đối chiếu như
SoSwarmK3s.cstrong CI; mã thoát khác 0 thì không merge.
Những chỗ hay hiểu sai
- "kompose ra manifest hợp lệ là xong." Hợp lệ với schema, nhưng 11/29 dòng sai hoặc thiếu.
- "k3s HA chịu lỗi tốt hơn Swarm." Cả hai là Raft trên 3 máy, chịu được 1 máy hỏng.
- "Kubernetes tự rollback như Swarm." Deployment chỉ báo
ProgressDeadlineExceeded.
Đọc tiếp
- Probe, giới hạn bộ nhớ và HPA trên k3s: pod cửa hàng trụ được flash sale: bài trước trong series.
- 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 sau trong series.
- Docker Swarm ba manager: khởi động lại từng máy mà API trụ sở vẫn bán: stack chung và quorum.
- Rolling update trên Swarm: lên 1.5.0 không cắt lệnh, bản lỗi tự rollback:
delay,monitor,rollback. - GitOps cho 12 cửa hàng: Kustomize overlay, Flux và bước CI bắt cấu hình lệch: Flux và
git revert.
Nguồn
- k3s, đọc ngày 4/10/2026: High Availability Embedded etcd (số server lẻ,
--cluster-init, đổi SQLite sang etcd), High Availability External DB (server chạy workload mặc định), Secrets Encryption, Networking Services: ServiceLB, Requirements (cổng 2379–2380, 6443), v1.36.5+k3s1. - Kubernetes: Deployments (
minReadySeconds,progressDeadlineSeconds, không tự rollback), Pod Topology Spread Constraints (ràng buộc mặc định), Secrets (tên DNS subdomain), Projected volumes, Downward API, Pod Lifecycle: Termination of Pods, Disruptions. - kompose: v1.38.0. docker/go-units: size.go (
RAMInBytesdùng hệ 1024). - yannh/kubeconform v0.8.0; kubernetes-validate 1.36.0; YamlDotNet 18.1.0.