DevOpsDocker Swarm và k3s, phần 7/8
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.
Mục lục
- 1. Vấn đề: ổ hỏng là mất đơn, đổi tên máy là pod kẹt Pending
- 2. Mục đích: mất tối đa 15 phút đơn, máy mới tự khôi phục
- 3. Cơ sở lý thuyết: PV local-path gắn với node, file SQLite đang mở không chép được bằng cp
- 4. Cách giải quyết: tên node cố định, sao lưu ra ổ thứ hai, khôi phục khi volume trống
- 5. Cách cài đặt: một dòng cấu hình k3s, một CronJob, một initContainer
- 6. Chứng minh: ba cách chép SQLite, lệnh sao lưu và khôi phục chạy thật
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Dữ liệu offline nằm trên volume local-path gắn với một ổ và một tên node: ổ hỏng ở
ch06mất 41 đơn chưa đồng bộ, đổi tên máy ởch02làm pod kẹtPending. - Cách giải: Cố định tên node trong cấu hình k3s, CronJob 15 phút sao lưu SQLite bằng API backup ra ổ thứ hai rồi đẩy về trụ sở, initContainer khôi phục khi volume trống.
- Chứng minh: Chưa dựng cụm thật; chép file
.dbkhi app đang mở thiếu 65/65 đơn của ngày, API backup thiếu 0 đơn ở 30/30 lần sao lưu giữa lúc đang ghi. - Trong .NET:
SqliteConnection.BackupDatabasecủaMicrosoft.Data.Sqlite10.0.12,PRAGMA integrity_checktrước khi giữ bản sao, và cùng một image chạy web,sao-luu,khoi-phuc.
1. Vấn đề: ổ hỏng là mất đơn, đổi tên máy là pod kẹt Pending
Thứ Bảy 26/9/2026, ch06 Huế mất Internet từ 13:10. BanHang.CuaHang bán tiếp như thiết kế: ghi đơn vào SQLite trên PVC cuahang-data và chờ đồng bộ về trụ sở. 16:05, SSD của máy k3s-ch06 báo lỗi I/O rồi không khởi động lại được. Kỹ thuật viên mang máy dự phòng tới, cài k3s, áp manifest; 17:40 pod Ready với một database trống. 41 đơn bán offline, khoảng 62 triệu đồng, không còn trong hệ thống, và cửa hàng nhập lại từ hóa đơn giấy.
Ba tuần trước đó, ch02 Hoàn Kiếm thay mainboard. SSD giữ nguyên, nhưng hệ điều hành cài lại với hostname k3s-ch02-moi. k3s khởi động trên dữ liệu cũ và đăng ký một node mới; node k3s-ch02 thành NotReady. Pod mới của cuahang nằm Pending 40 phút với sự kiện node(s) didn't match PersistentVolume's node affinity, tới khi IT đặt lại tên máy. Diễn biến của hai sự cố là minh họa.
Hai sự cố chung một gốc: dữ liệu của cửa hàng nằm trong một thư mục, trên đúng một ổ, gắn với đúng một tên node.
2. Mục đích: mất tối đa 15 phút đơn, máy mới tự khôi phục
- Ổ dữ liệu hỏng, kể cả lúc mất mạng: mất tối đa 15 phút đơn. Với nhịp 14 đơn mỗi giờ của chiều 26/9 ở
ch06, đó là khoảng 4 đơn thay vì 41. - Bản sao lưu nhất quán: 30/30 bản qua
PRAGMA integrity_checkvà không thiếu đơn nào đã commit trước lúc bắt đầu sao lưu, kể cả khi app đang ghi. - Máy thay thế: pod tự khôi phục từ bản sao lưu mới nhất khi volume trống, không thêm lệnh tay ngoài cài k3s và áp manifest.
- Đổi hostname không đổi tên node, nên pod không kẹt
Pending. - Ngoài phạm vi: lưu trữ nhân bản qua nhiều node, mã hóa bản sao lưu.
3. Cơ sở lý thuyết: PV local-path gắn với node, file SQLite đang mở không chép được bằng cp
PVC, PV và local-path của k3s
PersistentVolumeClaim (PVC) là yêu cầu "cho tôi 2Gi, đọc ghi"; PersistentVolume (PV) là volume thật đáp ứng yêu cầu đó. k3s v1.36.5+k3s1 kèm local-path-provisioner v0.0.37 và StorageClass mặc định local-path. Manifest của k3s đặt cho nó ba thuộc tính quyết định bài này:
| Thuộc tính | Giá trị | Hệ quả |
|---|---|---|
volumeBindingMode |
WaitForFirstConsumer |
PV chỉ được tạo sau khi pod đã có node, trên đúng node đó |
defaultVolumeType |
local |
PV kiểu local, mang nodeAffinity kubernetes.io/hostname In [tên node] |
reclaimPolicy |
Delete |
Xóa PVC thì script teardown chạy rm -rf thư mục dữ liệu |
Dữ liệu nằm dưới /var/lib/rancher/k3s/storage của node. Tài liệu k3s ghi pod bị gắn cố định với node giữ volume; README của provisioner ghi thêm rằng dung lượng xin trong PVC không được áp. Chế độ ReadWriteOnce nghĩa là một node, không phải một pod: tài liệu Kubernetes ghi nhiều pod trên cùng node vẫn đọc ghi được. Vì vậy 2–3 pod của HPA (bài 5) và pod của Job sao lưu cùng mount cuahang-data.
Vì sao kẹt Pending, vì sao mất
Pod dùng PVC đã gắn PV thì scheduler chỉ xét node khớp nodeAffinity của PV. Ở ch02, PV ghi k3s-ch02, node đang chạy tên k3s-ch02-moi, nên plugin VolumeBinding loại node đó với lý do node(s) didn't match PersistentVolume's node affinity. Dữ liệu vẫn nguyên trên ổ; chỉ có tên là sai.
flowchart TB
P["Pod cuahang"] --> C["PVC cuahang-data"]
C --> V["PV kiểu local<br/>nodeAffinity: hostname = k3s-ch02"]
V -->|"khớp"| N1["Node k3s-ch02<br/>NotReady"]
V -.->|"không khớp: pod Pending"| N2["Node k3s-ch02-moi<br/>Ready"]
N1 --> M["Cùng một máy, cùng SSD"]
N2 --> M
M --- D[("Thư mục dữ liệu còn nguyên<br/>/var/lib/rancher/k3s/storage/pvc-...")]
Sửa PV cho khớp tên mới cũng không được: spec.nodeAffinity của PV chỉ đổi được khi bật feature gate MutablePVNodeAffinity, vẫn là alpha và tắt mặc định ở Kubernetes 1.36. k3s lấy tên node từ hostname, trừ khi đặt node-name; tài liệu của k3s yêu cầu hai node không trùng tên, và chỉ cách đặt tên cố định bằng --node-name.
Ở ch06, ổ hỏng mang theo cả thư mục volume lẫn datastore SQLite của k3s trong /var/lib/rancher/k3s/server. Máy mới là một cụm mới, PVC mới, PV trống. Tài liệu Kubernetes ghi đúng giới hạn này: volume local phụ thuộc vào node bên dưới và có thể mất dữ liệu theo độ bền của ổ.
Chép file SQLite đang mở
App ghi SQLite ở chế độ WAL. Mỗi commit được nối vào cuahang.db-wal; checkpoint chép các page đó vào file chính khi WAL đạt khoảng 1.000 page, hoặc khi kết nối cuối cùng đóng. Microsoft.Data.Sqlite giữ kết nối trong pool, nên khi app còn chạy, kết nối không đóng và WAL chỉ gộp theo ngưỡng. Mỗi đơn sửa vài page: bảng, khóa chính, chỉ mục. Trong phép thử ở mục 6, file chính thiếu tới 295 đơn; ở 65 đơn một ngày, đó là đơn của hơn bốn ngày.
Vì vậy cp cuahang.db khi app đang chạy cho một file hợp lệ nhưng thiếu mọi đơn từ lần checkpoint cuối. Chép cả .db lẫn -wal giữa lúc đang ghi cũng không an toàn: trang "How To Corrupt An SQLite Database File" ghi bản chép có thể chứa nửa cũ nửa mới và hỏng. Cách an toàn trên database đang chạy là API backup, VACUUM INTO hoặc sqlite3_rsync. SqliteConnection.BackupDatabase gọi API backup và chép một ảnh nhất quán trong một bước.
4. Cách giải quyết: tên node cố định, sao lưu ra ổ thứ hai, khôi phục khi volume trống
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
emptyDir |
Không cần cấu hình | Mất khi pod bị xóa hay dời | Bộ đệm tạm |
hostPath |
Đơn giản | Không có vòng đời, không gắn node, ghi lẫn với hệ thống | Đọc file của máy |
| PVC local-path, như hiện tại | Sống qua restart và cập nhật | Gắn một ổ, một tên node | Dữ liệu dựng lại được |
| Lưu trữ nhân bản như Longhorn | Bản sao trên node khác | Một node thì không có node khác giữ bản sao | Cụm từ 3 node |
| local-path, tên node cố định, CronJob sao lưu, initContainer khôi phục | Mất tối đa một chu kỳ; máy mới tự khôi phục | Cần ổ thứ hai; mất tới 15 phút đơn | Máy biên một node |
Bài chọn dòng cuối. Các bước:
- Đặt
node-name: k3s-ch06trong/etc/rancher/k3s/config.yaml; đổi hostname không còn đổi tên node. - Gắn ổ thứ hai, ví dụ SSD USB, vào
/mnt/sao-luu, giao cho UID 1654 của userapp, và tạo file đánh dấu.o-sao-luutrên chính ổ đó. Ổ chưa gắn thì thư mục rỗng, không có file đánh dấu, và lệnh sao lưu từ chối ghi vào ổ chính. - CronJob
cuahang-sao-luumỗi 15 phút,concurrencyPolicy: Forbid:BackupDatabasera file tạm,integrity_check, đổi tên, giữ 96 bản (24 giờ), rồi thử đẩy bản mới nhất về trụ sở. - initContainer
khoi-phuctrong Deployment: volume trống thì chép bản sao lưu mới nhất còn tốt vào trước khi app chạy; volume có dữ liệu thì bỏ qua. - Hỏng máy: cắm ổ sao lưu sang máy dự phòng, cài k3s với cùng
node-name, áp manifest. Đơn đã đồng bộ được gửi lại sẽ bị trụ sở khử trùng theoMaDon, như idempotency key.
Mạng còn thì bản mới nhất nằm ở trụ sở và trên ổ thứ hai; mất mạng thì ổ thứ hai giữ đơn của tối đa 15 phút trước.
5. Cách cài đặt: một dòng cấu hình k3s, một CronJob, một initContainer
Phiên bản: k3s v1.36.5+k3s1 (Kubernetes 1.36.5), local-path-provisioner v0.0.37; .NET SDK 10.0.401, runtime 10.0.12, Microsoft.Data.Sqlite 10.0.12. Trên máy cửa hàng, chưa chạy trên máy thật trong bài này:
printf 'node-name: k3s-ch06\n' | sudo tee /etc/rancher/k3s/config.yaml
echo 'LABEL=SAOLUU /mnt/sao-luu ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstab
sudo mkdir -p /mnt/sao-luu && sudo mount /mnt/sao-luu
sudo touch /mnt/sao-luu/.o-sao-luu && sudo chown -R 1654:1654 /mnt/sao-luu
Tài liệu Kubernetes ghi rằng muốn đọc ghi hostPath mà không chạy bằng root thì phải sửa quyền trên máy, nên lệnh cuối giao thư mục cho UID 1654. Phần chính của CronJob và initContainer:
# CronJob cuahang-sao-luu
schedule: "*/15 * * * *"
timeZone: Asia/Ho_Chi_Minh
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
# ... pod mang nhãn app.kubernetes.io/name: cuahang-sao-luu, khác nhãn mà Service cuahang chọn
# args: ["sao-luu", "--du-lieu", "/data", "--sao-luu", "/sao-luu", "--giu", "96"]
# volumes: PVC cuahang-data tại /data, hostPath /mnt/sao-luu (type: Directory) tại /sao-luu
# Deployment cuahang, trước container chính
initContainers:
- name: khoi-phuc
image: registry.banhang.local/banhang/cuahang:2.1.0
args: ["khoi-phuc", "--du-lieu", "/data", "--sao-luu", "/sao-luu"]
volumeMounts:
- { name: data, mountPath: /data }
- { name: sao-luu, mountPath: /sao-luu, readOnly: true }
Lệnh sao-luu trong app dùng API backup, chuyển bản sao sang journal thường để đọc được trên mount chỉ đọc, và kiểm trước khi giữ:
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); // integrity_check + COUNT(*), hỏng thì xóa và trả mã 3
Mode=ReadWrite thay cho mặc định ReadWriteCreate: thiếu file thì báo lỗi, không lặng lẽ tạo database rỗng. Toàn bộ manifest và app ở các khối dưới.
pvc.yaml, cronjob-sao-luu.yaml và deployment.yaml có initContainer khoi-phuc
# ===== 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
# ===== 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
# ===== 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
CuaHang.cs: backend rút gọn, lệnh sao-luu và khoi-phuc nằm ở cuối file
#: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: ba cách chép SQLite, lệnh sao lưu và khôi phục chạy thật
Bài chưa dựng cụm thật: không chạy k3s, CronJob hay initContainer trên cụm. Bằng chứng gồm manifest đã qua công cụ kiểm, các lệnh .NET đã chạy trên máy, và cơ chế Pending đọc từ mã nguồn. Máy chạy: laptop Intel Core Ultra 5 125U, Windows 11, .NET 10.0.12, SQLite đi kèm Microsoft.Data.Sqlite 10.0.12.
Manifest
Bộ base render bằng kubectl kustomize qua kubeconform v0.8.0, -strict, schema Kubernetes 1.36.5: 8/8 hợp lệ, gồm PVC và CronJob. KiemManifest.cs của bài 4 báo 0 lỗi; khi chép nhầm nhãn app.kubernetes.io/name: cuahang vào pod của CronJob, nó bắt lỗi "selector chọn cả pod của CronJob cuahang-sao-luu", còn kubeconform vẫn cho hợp lệ.
Ba cách chép một database đang mở
SaoLuuThu.cs dựng database 20.000 đơn đã checkpoint, rồi so ba cách: chép file .db như cp, chép .db rồi -wal, và BackupDatabase. Mỗi bản sao được mở lại, chạy integrity_check và đếm đơn.
Phần 1, app vừa ghi 65 đơn của một ngày bán và còn giữ kết nối, không ai đang ghi:
| Cách | integrity_check |
Số đơn | Thiếu |
|---|---|---|---|
Chép .db |
ok | 20.000 | 65 |
Chép .db rồi -wal |
ok | 20.065 | 0 |
BackupDatabase |
ok | 20.065 | 0 |
Phần 2, một luồng ghi liên tục từng đơn, 1.660 đơn trong lúc thử; mỗi cách sao lưu 30 lần, so với số đơn đã commit ngay trước khi bắt đầu chép:
| Cách | Bản hỏng | Thiếu trung bình | Thiếu tối đa | Thời gian trung bình |
|---|---|---|---|---|
Chép .db |
0/30 | 129 đơn | 295 đơn | 4 ms |
Chép .db rồi -wal |
0/30 | 0 | 0 | 13 ms |
BackupDatabase |
0/30 | 0 | 0 | 51 ms |
Chép .db rồi -wal không hỏng lần nào trong 30 lần, nhưng 30 lần không chứng minh nó an toàn: hai file được chép ở hai thời điểm, và tài liệu SQLite xếp việc chép giữa lúc có giao dịch vào nhóm có thể cho bản hỏng. BackupDatabase chậm hơn 47 ms mỗi lần với database 2 MB, không đáng kể với chu kỳ 15 phút.
Lệnh của image: sao lưu, mất ổ, khôi phục
Phần 3 chạy đúng hai lệnh mà CronJob và initContainer gọi, với trụ sở không với tới:
sao-luu (chua gan o) exit 2, 294 ms: o sao luu chua gan: thieu o-sao-luu\.o-sao-luu
sao-luu exit 0, 2850 ms: sao-luu cuahang-20261004-160808.db: 20065 don, 349 ms | chua day ve tru so: HttpRequestException
ban sao luu: 2028 KiB
khoi-phuc (volume trong) exit 0, 288 ms: khoi-phuc tu cuahang-20261004-160808.db: 20065 don
khoi-phuc (da co du lieu) exit 0, 242 ms: da co du lieu, bo qua
volume moi: 20065 don
Thiếu file đánh dấu thì không sao lưu và trả mã 2, Job thất bại và hiện trong lịch sử của CronJob. Sao lưu 20.065 đơn mất 349 ms trong lệnh; phần còn lại của 2,85 giây là lần thử đẩy về trụ sở thất bại. Khôi phục vào volume trống mất 288 ms và đủ 20.065 đơn; chạy lại trên volume đã có dữ liệu thì bỏ qua, nên initContainer an toàn ở mọi lần pod khởi động.
SaoLuuThu.cs: ba cách chép, 30 lần giữa lúc ghi, rồi chạy lệnh sao-luu và khoi-phuc của image
#:package Microsoft.Data.Sqlite@10.0.12
// SaoLuuThu.cs: sao lưu file SQLite đang mở bằng ba cách, đếm đơn thiếu và kiểm integrity_check.
// dotnet run SaoLuuThu.cs (cùng thư mục với CuaHang.cs; phần 3 gọi lệnh sao-luu/khoi-phuc của CuaHang)
using System.Diagnostics;
using Microsoft.Data.Sqlite;
string goc = Directory.CreateTempSubdirectory("saoluu-").FullName;
const string Ddl = """
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS DonHangOffline (
MaDon text PRIMARY KEY, NgayTao text NOT NULL, TongTien integer NOT NULL, DaDongBo integer NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS IX_DonHangOffline_ChoDongBo ON DonHangOffline (MaDon) WHERE DaDongBo = 0;
""";
long soMa = 0;
// Phần 1: app đang mở kết nối (pool), ghi 65 đơn của một ngày bán, rồi sao lưu khi không ai ghi.
string db1 = TaoCsdl("p1", 20_000);
using (var app = Mo(db1)) { for (int i = 0; i < 65; i++) Ghi(app); } // kết nối trả về pool, vẫn mở
Console.WriteLine("Phan 1: 20.000 don cu da checkpoint, them 65 don, app con mo ket noi");
foreach (var cach in new[] { "chep .db", "chep .db + -wal", "BackupDatabase" })
{
var (tot, so) = Kiem(SaoLuu(cach, db1, Path.Combine(goc, $"p1-{cach.Replace(' ', '_').Replace('+', 'v')}.db")));
Console.WriteLine($" {cach,-18} integrity={(tot ? "ok" : "HONG")} {so} don, thieu {20_065 - so}");
}
// Phần 2: một luồng ghi liên tục từng đơn một; mỗi cách sao lưu 30 lần giữa lúc đang ghi.
string db2 = TaoCsdl("p2", 20_000);
long daCommit = 20_000;
using var dung = new CancellationTokenSource();
var ghi = Task.Run(() =>
{
using var cn = Mo(db2);
while (!dung.IsCancellationRequested) { Ghi(cn); Interlocked.Increment(ref daCommit); Thread.Sleep(2); }
});
Console.WriteLine("\nPhan 2: sao luu trong luc mot luong ghi lien tuc, 30 lan moi cach");
foreach (var cach in new[] { "chep .db", "chep .db + -wal", "BackupDatabase" })
{
int hong = 0; long thieuMax = 0, thieuTong = 0; var ms = new List<double>();
for (int lan = 0; lan < 30; lan++)
{
Thread.Sleep(200);
long truoc = Interlocked.Read(ref daCommit); // đơn đã commit trước khi bắt đầu sao lưu
var sw = Stopwatch.StartNew();
string ban = SaoLuu(cach, db2, Path.Combine(goc, $"p2-{lan}-{cach.Length}.db"));
ms.Add(sw.Elapsed.TotalMilliseconds);
var (tot, so) = Kiem(ban);
if (!tot) { hong++; continue; }
long thieu = Math.Max(0, truoc - so);
thieuMax = Math.Max(thieuMax, thieu); thieuTong += thieu;
}
Console.WriteLine($" {cach,-18} hong {hong}/30, thieu TB {thieuTong / (30.0 - hong):F0} don, toi da {thieuMax}, thoi gian TB {ms.Average():F0} ms");
}
dung.Cancel(); await ghi;
Console.WriteLine($" luong ghi da commit {daCommit - 20_000} don trong phan 2");
// Phần 3: chạy đúng lệnh của image: sao-luu, mất ổ dữ liệu, khoi-phuc vào volume trống.
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 duLieu = Path.Combine(goc, "data"), noiLuu = Path.Combine(goc, "o-sao-luu"), duLieuMoi = Path.Combine(goc, "data-may-moi");
Directory.CreateDirectory(duLieu); Directory.CreateDirectory(noiLuu); Directory.CreateDirectory(duLieuMoi);
SqliteConnection.ClearAllPools(); // đóng hẳn: -wal được gộp vào file chính
File.Copy(db1, Path.Combine(duLieu, "cuahang.db"));
Console.WriteLine("\nPhan 3: lenh cua image");
Chay("sao-luu (chua gan o)", "sao-luu", "--du-lieu", "data", "--sao-luu", "o-sao-luu");
File.WriteAllText(Path.Combine(noiLuu, ".o-sao-luu"), "o thu hai");
Chay("sao-luu", "sao-luu", "--du-lieu", "data", "--sao-luu", "o-sao-luu", "--giu", "96");
Console.WriteLine($" ban sao luu: {new FileInfo(Directory.GetFiles(noiLuu, "cuahang-*.db").Single()).Length / 1024} KiB");
Chay("khoi-phuc (volume trong)", "khoi-phuc", "--du-lieu", "data-may-moi", "--sao-luu", "o-sao-luu");
Chay("khoi-phuc (da co du lieu)", "khoi-phuc", "--du-lieu", "data-may-moi", "--sao-luu", "o-sao-luu");
Console.WriteLine($" volume moi: {Kiem(Path.Combine(duLieuMoi, "cuahang.db")).SoDon} don");
return 0;
void Chay(string ten, params string[] a)
{
var psi = new ProcessStartInfo(exe, a) { RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = goc };
psi.Environment["TruSo__Url"] = "http://127.0.0.1:9"; // trụ sở không với tới: như lúc mất mạng
var sw = Stopwatch.StartNew();
using var p = Process.Start(psi)!;
string o = (p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd()).Trim().Replace(Environment.NewLine, " | ");
p.WaitForExit();
Console.WriteLine($" {ten,-26} exit {p.ExitCode}, {sw.ElapsedMilliseconds} ms: {o}");
}
string TaoCsdl(string ten, int soDon)
{
string f = Path.Combine(goc, ten + ".db");
using var cn = Mo(f);
using (var cmd = cn.CreateCommand()) { cmd.CommandText = Ddl; cmd.ExecuteNonQuery(); }
using (var tx = cn.BeginTransaction()) { for (int i = 0; i < soDon; i++) Ghi(cn, tx); tx.Commit(); }
using (var cmd = cn.CreateCommand()) { cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; cmd.ExecuteNonQuery(); }
return f;
}
SqliteConnection Mo(string f) { var cn = new SqliteConnection($"Data Source={f}"); cn.Open(); return cn; }
void Ghi(SqliteConnection cn, SqliteTransaction? tx = null)
{
using var cmd = cn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = "INSERT INTO DonHangOffline (MaDon, NgayTao, TongTien) VALUES ($ma, $ngay, 1500000)";
cmd.Parameters.AddWithValue("$ma", $"CH06-{Interlocked.Increment(ref soMa):D8}");
cmd.Parameters.AddWithValue("$ngay", DateTime.UtcNow.ToString("O"));
cmd.ExecuteNonQuery();
}
// Ba cách: "cp" file chính; "cp" file chính rồi file -wal; API sao lưu trực tuyến của SQLite.
string SaoLuu(string cach, string nguon, string dich)
{
if (cach == "BackupDatabase")
{
using var a = new SqliteConnection($"Data Source={nguon};Mode=ReadWrite;Pooling=False");
using var b = new SqliteConnection($"Data Source={dich};Pooling=False");
a.Open(); b.Open();
a.BackupDatabase(b);
return dich;
}
Chep(nguon, dich);
if (cach.Contains("-wal") && File.Exists(nguon + "-wal")) Chep(nguon + "-wal", dich + "-wal");
return dich;
}
static void Chep(string tu, string den) // như cp: đọc file đang được tiến trình khác ghi
{
using var r = new FileStream(tu, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var w = File.Create(den);
r.CopyTo(w);
}
static (bool Tot, long SoDon) Kiem(string f)
{
try
{
using var cn = new SqliteConnection($"Data Source={f};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); }
}
Mất bao nhiêu đơn, kẹt bao lâu
Với CronJob 15 phút, đơn có thể mất khi ổ dữ liệu hỏng là đơn của tối đa 15 phút cuối. Ở nhịp 41 đơn trong gần 3 giờ của ch06, khoảng 14 đơn mỗi giờ, đó là khoảng 4 đơn thay vì 41. Đây là phép tính theo lịch CronJob, không phải số đo. Với node-name cố định, đổi hostname không tạo node mới, nên điều kiện của lý do node(s) didn't match PersistentVolume's node affinity không xảy ra; kết luận này dựa trên mã nguồn local-path-provisioner v0.0.37, nơi PV được gắn kubernetes.io/hostname của node, và plugin VolumeBinding của Kubernetes 1.36.
Điều chưa chứng minh: chưa chạy CronJob, initContainer, hostPath trên k3s thật; chưa thử ổ USB rút ra giữa lúc ghi; chưa đo bản sao lưu đẩy về trụ sở. Nếu cả ổ dữ liệu và ổ sao lưu cùng hỏng lúc mất mạng, đơn của lần mất mạng đó vẫn mất.
7. Kết luận
Volume local-path giữ dữ liệu qua restart và cập nhật, nhưng nó là một thư mục trên một ổ, gắn với một tên node. Cố định tên node bỏ được lỗi Pending; sao lưu nhất quán ra ổ khác cộng khôi phục tự động biến hỏng ổ thành mất tối đa một chu kỳ.
Trong dự án .NET của bạn:
- Sao lưu SQLite đang chạy bằng
SqliteConnection.BackupDatabasehoặcVACUUM INTO, không bằngcp; chạyPRAGMA integrity_checktrên bản sao trước khi giữ. - Mở database nguồn với
Mode=ReadWritevàPooling=Falsetrong lệnh sao lưu, để thiếu file thì báo lỗi và không giữ file mở sau khi xong. - Đặt
node-nametrong/etc/rancher/k3s/config.yamlcủa mọi máy một node, và kiểm file đánh dấu trước khi ghi vào ổ gắn ngoài. - Cho pod của CronJob một nhãn khác nhãn Service chọn, và đặt
concurrencyPolicy: Forbid.
Những chỗ hay hiểu sai
- "
ReadWriteOncelà một pod." Nó là một node; một pod làReadWriteOncePod. - "PVC là dữ liệu bền." Với local-path, nó bền đúng bằng một ổ, và xóa PVC là xóa thư mục.
- "Chép file database đang mở là bản sao lưu." Ở chế độ WAL, file chính có thể thiếu mọi đơn từ lần checkpoint cuối.
- "Sửa nodeAffinity của PV là xong." Trường đó không đổi được nếu chưa bật
MutablePVNodeAffinity.
Đọc tiếp
- Swarm hay k3s: bài trước trong series, trụ sở có nên chuyển sang k3s nhiều server.
- GitOps cho 12 cửa hàng: bài sau, đưa manifest tới 12 máy bằng overlay Kustomize và Flux.
- Máy chủ cửa hàng chạy k3s: k3s một node, local-path và bộ manifest gốc.
- Probe, giới hạn bộ nhớ và HPA trên k3s: ba pod cùng ghi volume này.
- Idempotency key: gửi lại đơn đã đồng bộ mà không ghi hai lần.
Nguồn
- k3s, Volumes and Storage: local-path,
/var/lib/rancher/k3s/storage, pod gắn với node. Requirements: tên node không trùng,--node-name. Configuration Options. Manifest local-storage.yaml của v1.36.5+k3s1. Đọc ngày 4/10/2026. - rancher/local-path-provisioner v0.0.37, README: dung lượng không được áp,
nodeAffinityKey. provisioner.go: PV gắnkubernetes.io/hostname. - Kubernetes 1.36, Persistent Volumes: access mode, node affinity,
MutablePVNodeAffinity. Volumes:local, quyền củahostPath. CronJob:concurrencyPolicy,startingDeadlineSeconds,timeZone. Mã nguồn binder.go của plugin VolumeBinding. - SQLite, Write-Ahead Logging: checkpoint ở 1.000 page. How To Corrupt An SQLite Database File: sao lưu giữa giao dịch. Online Backup API.
- Microsoft Learn, Backup with Microsoft.Data.Sqlite, Connection strings:
Mode,Pooling.