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. 1. Vấn đề: ổ hỏng là mất đơn, đổi tên máy là pod kẹt Pending
  2. 2. Mục đích: mất tối đa 15 phút đơn, máy mới tự khôi phục
  3. 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. 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. 5. Cách cài đặt: một dòng cấu hình k3s, một CronJob, một initContainer
  6. 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. 7. Kết luận
  8. Đọc tiếp
  9. 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 ở ch06 mất 41 đơn chưa đồng bộ, đổi tên máy ở ch02 làm pod kẹt Pending.
  • 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 .db khi 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.BackupDatabase của Microsoft.Data.Sqlite 10.0.12, PRAGMA integrity_check trướ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_check và 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:

  1. Đặt node-name: k3s-ch06 trong /etc/rancher/k3s/config.yaml; đổi hostname không còn đổi tên node.
  2. Gắn ổ thứ hai, ví dụ SSD USB, vào /mnt/sao-luu, giao cho UID 1654 của user app, và tạo file đánh dấu .o-sao-luu trê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.
  3. CronJob cuahang-sao-luu mỗi 15 phút, concurrencyPolicy: Forbid: BackupDatabase ra 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ở.
  4. initContainer khoi-phuc trong 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.
  5. 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 theo MaDon, 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-phucYAML · 223 dòng
# ===== 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 fileC# · 263 dòng
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.Sqlite@10.0.12
// BanHang.CuaHang 2.1.0, bản rút gọn: dotnet run CuaHang.cs -- --urls http://127.0.0.1:8080
// Cùng một file (một image) chạy ba việc: web (mặc định), "sao-luu" (CronJob), "khoi-phuc" (initContainer).
using System.Diagnostics;
using System.Runtime;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;

if (args is ["sao-luu", ..]) return await SaoLuu.ChayAsync(args);
if (args is ["khoi-phuc", ..]) return KhoiPhuc.Chay(args);

var batDau = Stopwatch.StartNew();
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true);   // khóa BHPay từ Secret
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(15));
var cfg = builder.Configuration;
string csdl = cfg.GetConnectionString("CuaHang") ?? "Data Source=cuahang.db";
string maCuaHang = cfg["CuaHang:Ma"] ?? "CHUA-DAT";
DongBo.TruSo = new Uri(cfg["TruSo:Url"] ?? "https://api.banhang.local");
DongBo.ChuKy = TimeSpan.FromSeconds(cfg.GetValue("DongBo:ChuKyGiay", 30));
bool cauHinhCu = cfg.GetValue("CuaHang:CauHinhCu", false);   // bản 2.0.3: đồng bộ chặn lúc khởi động, trụ sở là bắt buộc
Csdl.TaoBang(csdl);

var dangDung = false;
builder.Services.AddHealthChecks()
    .AddCheck("dang-dung", () => dangDung ? HealthCheckResult.Unhealthy() : HealthCheckResult.Healthy(), tags: ["ready"])
    .AddCheck("sqlite", () => Csdl.KiemTra(csdl), tags: ["ready"])
    .AddCheck("tru-so", () => DongBo.VuaThanhCong()
        ? HealthCheckResult.Healthy()
        : new HealthCheckResult(cauHinhCu ? HealthStatus.Unhealthy : HealthStatus.Degraded, "bán offline"),
        tags: ["ready"]);
builder.Services.AddHostedService(_ => new DongBo(csdl, maCuaHang));
var app = builder.Build();

// live: không chạy check nào, chỉ cần tiến trình còn trả lời. ready: SQLite cục bộ, trạng thái trụ sở, đang dừng.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("ready"),
    ResponseWriter = (ctx, r) =>
    {
        ctx.Response.ContentType = "text/plain; charset=utf-8";
        return ctx.Response.WriteAsync($"{r.Status} " + string.Join(" ", r.Entries.Select(e => $"{e.Key}={e.Value.Status}")));
    }
});
app.MapPost("/api/don-hang/{maDon}", (string maDon, long tongTien) =>
{
    using var cn = Csdl.Mo(csdl);
    using var cmd = cn.CreateCommand();
    cmd.CommandText = "INSERT OR IGNORE INTO DonHangOffline (MaDon, NgayTao, TongTien) VALUES ($ma, $ngay, $tien)";
    cmd.Parameters.AddWithValue("$ma", maDon);
    cmd.Parameters.AddWithValue("$ngay", DateTime.UtcNow.ToString("O"));
    cmd.Parameters.AddWithValue("$tien", tongTien);
    return cmd.ExecuteNonQuery() == 1 ? Results.Text($"da-ghi {maDon}", statusCode: 201) : Results.Text($"da-co {maDon}");
});
app.MapGet("/api/thong-tin", () =>
{
    var gc = GC.GetGCMemoryInfo();
    var p = Process.GetCurrentProcess();
    return Results.Text($"""
        BanHang.CuaHang 2.1.0 cua hang {maCuaHang}, .NET {Environment.Version}, ServerGC={GCSettings.IsServerGC}
        TotalAvailableMemoryBytes={gc.TotalAvailableMemoryBytes / 1048576.0:F0} MiB, heap={GC.GetTotalMemory(false) / 1048576.0:F1} MiB
        private={p.PrivateMemorySize64 / 1048576.0:F0} MiB, working set={p.WorkingSet64 / 1048576.0:F0} MiB, don cho dong bo={Csdl.ChoDongBo(csdl)}
        """);
});
app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine($"nghe cong sau {batDau.ElapsedMilliseconds} ms"));
app.Lifetime.ApplicationStopping.Register(() => dangDung = true);   // readiness 503; preStop đã chờ trước đó

if (cauHinhCu) await DongBo.MotLanAsync(csdl, maCuaHang, soLan: 3);   // bản 2.0.3: chặn ở đây, chưa mở cổng
app.Run();
return 0;

static class Csdl
{
    public static SqliteConnection Mo(string cs)
    {
        var cn = new SqliteConnection(cs);
        cn.Open();
        return cn;
    }

    public static void TaoBang(string cs)
    {
        using var cn = Mo(cs);
        using var cmd = cn.CreateCommand();
        cmd.CommandText = """
            PRAGMA journal_mode = WAL;
            CREATE TABLE IF NOT EXISTS DonHangOffline (
                MaDon text PRIMARY KEY,
                NgayTao text NOT NULL,
                TongTien integer NOT NULL,          -- đồng
                DaDongBo integer NOT NULL DEFAULT 0
            );
            CREATE INDEX IF NOT EXISTS IX_DonHangOffline_ChoDongBo ON DonHangOffline (MaDon) WHERE DaDongBo = 0;
            """;
        cmd.ExecuteNonQuery();
    }

    public static long ChoDongBo(string cs)
    {
        using var cn = Mo(cs);
        using var cmd = cn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline WHERE DaDongBo = 0";
        return (long)cmd.ExecuteScalar()!;
    }

    public static HealthCheckResult KiemTra(string cs)
    {
        try { return HealthCheckResult.Healthy($"{ChoDongBo(cs)} đơn chờ đồng bộ"); }
        catch (Exception e) { return HealthCheckResult.Unhealthy(e.Message); }
    }

    // Mở file chỉ đọc, kiểm integrity_check và đếm đơn. Dùng cho bản sao lưu.
    public static (bool Tot, long SoDon) KiemFile(string file)
    {
        try
        {
            using var cn = new SqliteConnection($"Data Source={file};Mode=ReadOnly;Pooling=False");
            cn.Open();
            using var cmd = cn.CreateCommand();
            cmd.CommandText = "PRAGMA integrity_check";
            if ((string)cmd.ExecuteScalar()! != "ok") return (false, 0);
            cmd.CommandText = "SELECT COUNT(*) FROM DonHangOffline";
            return (true, (long)cmd.ExecuteScalar()!);
        }
        catch (SqliteException) { return (false, 0); }
    }
}

// Đẩy đơn chưa đồng bộ về BanHang.Api mỗi DongBo:ChuKyGiay giây. Lỗi mạng không làm app dừng.
sealed class DongBo(string csdl, string maCuaHang) : BackgroundService
{
    public static Uri TruSo = new("https://api.banhang.local");
    public static TimeSpan ChuKy = TimeSpan.FromSeconds(30);
    static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };
    static long _lanCuoi;   // ticks UTC của lần đồng bộ thành công gần nhất
    // Trụ sở "tốt" nếu đồng bộ thành công trong ba chu kỳ gần nhất.
    public static bool VuaThanhCong() => DateTime.UtcNow.Ticks - Interlocked.Read(ref _lanCuoi) < 3 * ChuKy.Ticks;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try { await MotLanAsync(csdl, maCuaHang, soLan: 1, ct); }
            catch (Exception e) when (!ct.IsCancellationRequested) { Console.WriteLine($"dong-bo loi: {e.Message}"); }
            await Task.Delay(ChuKy, ct);
        }
    }

    public static async Task MotLanAsync(string csdl, string maCuaHang, int soLan, CancellationToken ct = default)
    {
        for (int lan = 1; lan <= soLan; lan++)
        {
            try
            {
                using var cn = Csdl.Mo(csdl);
                using var cmd = cn.CreateCommand();
                cmd.CommandText = "SELECT MaDon || ';' || NgayTao || ';' || TongTien FROM DonHangOffline WHERE DaDongBo = 0 LIMIT 100";
                var dong = new List<string>();
                using (var r = cmd.ExecuteReader()) while (r.Read()) dong.Add(r.GetString(0));
                using var res = await Http.PostAsync(new Uri(TruSo, $"/api/cua-hang/{maCuaHang}/don-hang"),
                    new StringContent(string.Join('\n', dong)), ct);
                res.EnsureSuccessStatusCode();   // trụ sở khử trùng theo MaDon
                foreach (var d in dong)
                {
                    cmd.CommandText = "UPDATE DonHangOffline SET DaDongBo = 1 WHERE MaDon = $ma";
                    cmd.Parameters.Clear();
                    cmd.Parameters.AddWithValue("$ma", d.Split(';')[0]);
                    cmd.ExecuteNonQuery();
                }
                Interlocked.Exchange(ref _lanCuoi, DateTime.UtcNow.Ticks);
                return;
            }
            catch (Exception e) when (e is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
            {
                if (lan < soLan) await Task.Delay(TimeSpan.FromSeconds(2), ct);
            }
        }
    }
}

static class SaoLuu
{
    // sao-luu --du-lieu /data --sao-luu /sao-luu --giu 96
    public static async Task<int> ChayAsync(string[] args)
    {
        string duLieu = Tham(args, "--du-lieu", "."), noiLuu = Tham(args, "--sao-luu", "sao-luu");
        int giu = int.Parse(Tham(args, "--giu", "96"));
        if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
        {
            Console.Error.WriteLine($"o sao luu chua gan: thieu {Path.Combine(noiLuu, ".o-sao-luu")}");   // không ghi nhầm vào ổ chính
            return 2;
        }
        var sw = Stopwatch.StartNew();
        string tam = Path.Combine(noiLuu, $".dang-ghi-{Guid.NewGuid():N}.db");
        using (var nguon = new SqliteConnection($"Data Source={Path.Combine(duLieu, "cuahang.db")};Mode=ReadWrite;Pooling=False"))
        using (var dich = new SqliteConnection($"Data Source={tam};Pooling=False"))
        {
            nguon.Open();
            dich.Open();
            nguon.BackupDatabase(dich);        // sqlite3_backup: một ảnh nhất quán, app vẫn ghi được
            using var cmd = dich.CreateCommand();
            cmd.CommandText = "PRAGMA journal_mode = DELETE";   // bản sao đọc được trên mount chỉ đọc
            cmd.ExecuteNonQuery();
        }
        var (tot, soDon) = Csdl.KiemFile(tam);
        if (!tot) { File.Delete(tam); Console.Error.WriteLine("ban sao loi integrity_check"); return 3; }
        string ten = Path.Combine(noiLuu, $"cuahang-{DateTime.UtcNow:yyyyMMdd-HHmmss}.db");
        File.Move(tam, ten);
        foreach (var cu in Directory.GetFiles(noiLuu, "cuahang-*.db").Order().SkipLast(giu)) File.Delete(cu);
        Console.WriteLine($"sao-luu {Path.GetFileName(ten)}: {soDon} don, {sw.ElapsedMilliseconds} ms");
        try   // có mạng thì đẩy thêm một bản về trụ sở; mất mạng thì bản trên ổ thứ hai là đủ cho lần này
        {
            using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
            string ma = Environment.GetEnvironmentVariable("CuaHang__Ma") ?? "CHUA-DAT";
            var goc = new Uri(Environment.GetEnvironmentVariable("TruSo__Url") ?? "https://api.banhang.local");
            await using var f = File.OpenRead(ten);
            using var res = await http.PutAsync(new Uri(goc, $"/api/cua-hang/{ma}/sao-luu/{Path.GetFileName(ten)}"), new StreamContent(f));
            Console.WriteLine($"day ve tru so: {(int)res.StatusCode}");
        }
        catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
        {
            Console.WriteLine($"chua day ve tru so: {e.GetType().Name}");
        }
        return 0;
    }

    public static string Tham(string[] args, string ten, string macDinh)
    {
        int i = Array.IndexOf(args, ten);
        return i >= 0 && i + 1 < args.Length ? args[i + 1] : macDinh;
    }
}

static class KhoiPhuc
{
    // khoi-phuc --du-lieu /data --sao-luu /sao-luu: chỉ chạy khi volume còn trống
    public static int Chay(string[] args)
    {
        string duLieu = SaoLuu.Tham(args, "--du-lieu", "."), noiLuu = SaoLuu.Tham(args, "--sao-luu", "sao-luu");
        string dich = Path.Combine(duLieu, "cuahang.db");
        if (File.Exists(dich)) { Console.WriteLine("da co du lieu, bo qua"); return 0; }
        if (!File.Exists(Path.Combine(noiLuu, ".o-sao-luu")))
        {
            Console.WriteLine("CANH BAO: khong thay o sao luu, bat dau voi du lieu trong");
            return 0;
        }
        foreach (var f in Directory.GetFiles(noiLuu, "cuahang-*.db").OrderDescending())
        {
            var (tot, soDon) = Csdl.KiemFile(f);
            if (!tot) { Console.WriteLine($"bo qua {Path.GetFileName(f)}: hong"); continue; }
            File.Copy(f, dich + ".tam", overwrite: true);
            File.Move(dich + ".tam", dich);
            Console.WriteLine($"khoi-phuc tu {Path.GetFileName(f)}: {soDon} don");
            return 0;
        }
        Console.WriteLine("khong co ban sao luu, bat dau voi du lieu trong");
        return 0;
    }
}

6. Chứng minh: 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 file .db khi app đang ghi thiếu tới 295 đơn; API backup không thiếu đơn nào

Chép .db295 đơnChép .db rồi -wal0 đơnBackupDatabase0 đơn
Số đơn thiếu tối đa trong 30 lần sao lưu giữa lúc một luồng ghi liên tục. Database 20.000 đơn, .NET 10.0.12, Microsoft.Data.Sqlite 10.0.12, Windows 11.
Bảng số liệu
Giá trị
Chép .db295 đơn
Chép .db rồi -wal0 đơn
BackupDatabase0 đơn

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 imageC# · 142 dòng
#: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.BackupDatabase hoặc VACUUM INTO, không bằng cp; chạy PRAGMA integrity_check trên bản sao trước khi giữ.
  • Mở database nguồn với Mode=ReadWrite và Pooling=False trong lệnh sao lưu, để thiếu file thì báo lỗi và không giữ file mở sau khi xong.
  • Đặt node-name trong /etc/rancher/k3s/config.yaml củ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

  • "ReadWriteOnce là 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

Nguồn

Đọc tiếp

Trong Docker Swarm và k3s

Máy chủ cửa hàng chạy k3s: backend tự thay khi treo trên mini PC 8 GB

Backend ở cửa hàng ch04 treo lúc 19:40 và cửa hàng không bán được 52 phút, vì systemd chỉ chạy lại tiến trình đã thoát. k3s gói Kubernetes vào một binary dưới 100 MB với SQLite, containerd, Flannel, Traefik, ServiceLB và local-path; Deployment có liveness probe thay pod treo trong 32–67 giây theo mô hình.

14 phút đọc

Trong Docker Swarm và k3s

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

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

13 phút đọc

Trong Docker Swarm và k3s

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.

13 phút đọc