Operator Kubernetes để quản trị hosting WordPress nhiều site (multi-tenant) trên một cụm dùng chung.
Mỗi "host" WordPress được khai báo bằng một Custom Resource WordPressSite; operator tự động dựng
Deployment + Service + Ingress + database + user riêng, dùng chung một volume nhưng tách thư mục con.
┌──────────────┐ REST/JWT ┌─────────────────┐ watch/reconcile ┌──────────────────────┐
│ React UI │ ───────────────▶ │ API server (Go) │ ─── create CR ───▶ │ Operator (Go) │
│ antd + TS │ ◀─────────────── │ go-chi + auth │ │ controller-runtime │
└──────────────┘ └─────────────────┘ └───────────┬──────────┘
│ reconcile
┌────────────────────────────────────────────┼───────────────┐
▼ ▼ ▼ ▼ ▼
Deployment Service Ingress Secret MySQL DB+User
(subPath/site) (domain→svc) (db pass+salts) (per-site)
│
▼
┌──────────────────┐
│ Shared RWX PVC │ /var/www/html ← subPath = <tên site>
│ wordpress-shared │ ├── blog-acme/
└──────────────────┘ └── shop-foo/ (mỗi site 1 folder con)
| Thư mục | Vai trò |
|---|---|
api/v1alpha1/ |
Định nghĩa CRD WordPressSite (Go types + deepcopy) |
internal/controller/ |
Vòng reconcile: dựng tài nguyên + provision DB |
internal/resources/ |
Builder cho Deployment / Service / Ingress / Secret + đặt tên |
internal/mysql/ |
Tạo database + user least-privilege cho mỗi site |
internal/apiserver/ |
REST API + auth JWT cho UI |
internal/metrics/ |
Theo dõi CPU/RAM (prod: metrics.k8s.io; dev: tổng hợp) |
internal/sqlite/ |
Provisioner SQLite cho dev mock (thay MySQL) |
cmd/operator/ |
Entrypoint controller manager |
cmd/apiserver/ |
Entrypoint REST API |
ui/ |
React + TypeScript + Ant Design |
deploy/ |
Toàn bộ manifest YAML (cài theo thứ tự số) |
config/samples/ |
Ví dụ WordPressSite |
Một CR khai báo toàn bộ một host. Operator coi nó là nguồn sự thật và tự đồng bộ mọi thứ phía dưới.
Xem các trường trong api/v1alpha1/wordpresssite_types.go và CRD
deploy/01-crd.yaml.
Tất cả site mount cùng một PVC ReadWriteMany (wordpress-shared) nhưng mỗi pod mount tại
subPath = <tên site>. Nhờ vậy chia sẻ một volume lớn mà file mỗi host vẫn cô lập trong thư mục riêng.
deploy/04-shared-storage.yaml tạo sẵn một PV hostPath RWX và bind
PVC vào đó (storageClassName: "") → chạy được ngay trên kind/minikube/1-node (các provisioner mặc
định như local-path KHÔNG hỗ trợ RWX nên PVC sẽ kẹt Pending).
⚠️ hostPath chỉ đúng cho 1 node. Multi-node production: thay PV này bằng RWX thật (NFS/CephFS/Longhorn-RWX/EFS/Azure Files/Filestore) hoặc StorageClass RWX động. Nếu PVC cũ đãPendingvới class khác (vdlocal-path), phải xoá rồi tạo lại vìstorageClassNamebất biến:kubectl -n wordpress-sites delete pvc wordpress-sharedrồi apply lại.
Khi tạo site, operator dùng quyền admin MySQL để:
- Tạo database
wp_<site>(utf8mb4). - Tạo user riêng
wpu_<site>với mật khẩu ngẫu nhiên. GRANT ALLchỉ trên đúng database đó → một site bị lộ không truy cập được DB của site khác.
Mật khẩu + WordPress salts được lưu trong Secret *-wp và inject vào container qua env
(WORDPRESS_DB_*, các WORDPRESS_*_KEY/SALT). Reconcile là idempotent: mật khẩu được giữ nguyên qua
các vòng lặp (không xoay vòng credential đang chạy).
spec.domain (+ aliases) sinh ra Ingress trỏ về Service của site, kèm TLS qua cert-manager khi
spec.tls.enabled. Có thể tùy biến bằng spec.ingressClassName và spec.ingressAnnotations.
spec.env,spec.phpConfig(→WORDPRESS_CONFIG_EXTRA, code wp-config.php),spec.resources,spec.replicas,spec.image(mặc địnhwordpress:latest),spec.ingressAnnotations.spec.phpIni— nội dung php.ini (memory_limit, upload_max_filesize, max_execution_time…). Operator tạo ConfigMap và mount vào/usr/local/etc/php/conf.d/zz-wpmgr.ini; khi edit php.ini, pod tự rollout (annotation hash đổi) nên PHP nạp lại cấu hình ngay. Cài qua form lúc tạo host hoặc sửa tay trong tab YAML của trang chi tiết. Để trống → dùng php.ini mặc định:file_uploads = On memory_limit = 256M upload_max_filesize = 500M post_max_size = 500M max_execution_time = 300 extension=mysqli
- Trang chi tiết host (
/sites/<name>) hiển thị thông tin + YAML đã deploy, cho phép sửa tay WordPressSite rồi Lưu — operator reconcile lại. Tab "Manifests đã deploy" xem read-only Deployment/Service/Ingress. - Editor có kiểm tra YAML phía client (parse js-yaml) — báo lỗi cú pháp và chặn Lưu khi YAML sai.
- Tạm dừng / Kích hoạt host ngay trên trang chi tiết (
spec.suspend→ operator scale Deployment về 0, phaseSuspended). - API:
GET /api/v1/sites/{name}/yaml(source CR + rendered manifests),PUT …/yaml(sửa tay toàn bộ spec),PUT /api/v1/sites/{name}(sửa nhanh các trường thường dùng, giữ nguyên env/resources…),POST /api/v1/sites/{name}/suspend·…/resume,POST /api/v1/sites/preview(xem trước khi tạo).
GET /api/v1/metrics trả về CPU & RAM cấp cụm — đã dùng / capacity / allocatable / còn trống —
kèm usage từng host. UI hiển thị thẻ tài nguyên (CPU, RAM với thanh % + "còn trống") ở đầu trang
quản trị và cột CPU/RAM theo từng host trong bảng (poll mỗi 8s).
- Production: đọc
metrics.k8s.io(cần metrics-server) + capacity từ Node. Thiếu metrics-server thì vẫn báo capacity, usage = 0 và cờmetricsAvailable=false(UI hiện badge cảnh báo). - Dev mock:
internal/metricstổng hợp số liệu từ các site (used tăng/available giảm khi tạo thêm host).
API bảo vệ bằng JWT (HS256). Đăng nhập tại POST /api/v1/login; danh tính admin lấy từ env
(ADMIN_USERNAME, ADMIN_PASSWORD hoặc ADMIN_PASSWORD_HASH bcrypt). Mọi route /api/v1/* còn lại
yêu cầu Authorization: Bearer <token>.
⚠️ Đổi mọi giá trịchange-me-*trongdeploy/03-mysql.yamlvàdeploy/06-apiserver.yamltrước khi dùng thật.
Sau khi image đã có trên registry (xem hướng dẫn GitHub Actions):
# Từ GitHub Release (install.yaml đã trỏ sẵn image GHCR của tag đó):
kubectl apply -f https://github.com/<owner>/<repo>/releases/latest/download/install.yaml
# Hoặc từ file trong repo:
make install # = ./hack/gen-install.sh && kubectl apply -f install.yamlinstall.yaml gói toàn bộ control plane theo đúng thứ tự phụ thuộc (Namespaces → CRD → RBAC → MySQL →
PVC dùng chung → Operator → API → UI). Sinh lại bất cứ lúc nào: make install.yaml.
Trỏ sang image GHCR: IMAGE_REGISTRY=ghcr.io/<owner> IMAGE_TAG=latest ./hack/gen-install.sh.
Chạy toàn bộ API + UI quản trị offline trên máy: dùng in-memory fake cluster (mock K8s API) và
SQLite thay cho MySQL. Bật bằng DEV_MODE=true.
make dev-api # API mock trên :8090 (SQLite tại .dev/sqlite, k8s in-memory)
make dev-ui # UI trên :5173, proxy /api -> :8090
# đăng nhập: admin / adminKhi tạo host ở chế độ này, API server tự chạy reconcile in-process: tạo file SQLite cho từng site
(wp_<site>.db), dựng Deployment/Service/Ingress/Secret trong cluster ảo, set status.phase=Ready. Nhờ
vậy UI hoạt động đầy đủ (tạo/sửa/xóa/preview YAML) mà không cần cụm thật.
| Production | Dev mock (DEV_MODE=true) |
|
|---|---|---|
| Database | MySQL (internal/mysql) |
SQLite (internal/sqlite) |
| Kubernetes | cụm thật (operator riêng) | fake client in-memory, reconcile in-process |
| Owned objects | Server-Side Apply | client-side create/update |
# 1. Build image
make docker # operator + apiserver + ui
# (kind) nạp image vào cụm, ví dụ:
# kind load docker-image wordpress-manager/operator:latest
# kind load docker-image wordpress-manager/apiserver:latest
# kind load docker-image wordpress-manager/ui:latest
# 2. Sửa storageClassName trong deploy/04-shared-storage.yaml (cần RWX) và
# ingress host trong deploy/07-ui.yaml.
# 3. Triển khai theo thứ tự
make deploy # = kubectl apply -f deploy/
# 4. Tạo một host
kubectl apply -f config/samples/wordpresssite-sample.yaml
kubectl get wordpresssites -n wordpress-sitesYêu cầu cụm: một Ingress controller (mặc định nginx) và, nếu bật TLS, cert-manager.
make run-operator # chạy controller ngoài cụm
make run-api # chạy REST API (cần JWT_SECRET, ADMIN_PASSWORD)
make ui-dev # Vite dev server, proxy /api → :8090- Đảm bảo finalizer → đảm bảo Secret (sinh mật khẩu/salts nếu chưa có).
EnsureDatabasetrên MySQL (idempotent): database + user + grant theo phạm vi.- Apply Deployment + Service + Ingress (set ownerRef để GC tự dọn khi xóa site).
- Cập nhật
.status(phase, url, databaseName/User). - Khi xóa: tùy
DROP_DATA_ON_DELETEmà drop DB; tài nguyên con bị GC qua ownerRef.
📘 Hướng dẫn từng bước (đẩy code, đặt package public, cài 1 lệnh, xử lý sự cố): docs/GITHUB_ACTIONS.md.
| Workflow | Khi nào chạy | Việc làm |
|---|---|---|
.github/workflows/ci.yml |
push/PR vào main |
go build/vet/test -race + UI npm test + npm run build |
.github/workflows/docker-publish.yml |
chỉ khi tag v* (+ dispatch) |
build 3 image (matrix) multi-arch, push lên GHCR |
.github/workflows/release.yml |
tag v* |
sinh install.yaml (trỏ image GHCR) + tạo GitHub Release đính kèm |
.github/workflows/e2e.yml |
thủ công / lịch tuần | full luồng trên kind: cài → tạo host → assert DB/Ingress/API |
go test ./... # unit: resources builders + REST API (mock cluster fake client)
cd ui && npm test # unit: UI quản trị (Vitest + Testing Library, mock api/client)
./hack/e2e.sh # full luồng end-to-end trên kind (xem bên dưới)hack/e2e.sh dựng một kind cluster tạm rồi chạy đúng luồng thật: build 3 image → load →
cài install.yaml (MySQL + operator + API + UI) → cài ingress-nginx → tạo một WordPressSite →
kiểm chứng: CR Ready, Secret/Service/Ingress được sinh, database wp_* + user wpu_* tạo trong
MySQL (grant đúng phạm vi), pod WordPress phục vụ qua Ingress, và REST API login/create/list hoạt động.
Giữ cluster để soi: KEEP=1 ./hack/e2e.sh.
Image xuất bản (multi-arch amd64+arm64) lên GitHub Container Registry:
ghcr.io/<owner>/wordpress-manager-operator
ghcr.io/<owner>/wordpress-manager-apiserver
ghcr.io/<owner>/wordpress-manager-ui
Tag tự sinh: tên branch, pr-<n>, sha-xxxxxxx, SemVer (khi push tag v1.2.3), và latest ở branch mặc định.
Không cần secret thêm — workflow dùng GITHUB_TOKEN sẵn có (cần bật Read and write cho Actions, hoặc
để mặc định với permissions: packages: write).
Sau khi push, trỏ manifest về image GHCR (thay <owner>):
sed -i '' "s#wordpress-manager/operator:latest#ghcr.io/<owner>/wordpress-manager-operator:latest#" deploy/05-operator.yaml
sed -i '' "s#wordpress-manager/apiserver:latest#ghcr.io/<owner>/wordpress-manager-apiserver:latest#" deploy/06-apiserver.yaml
sed -i '' "s#wordpress-manager/ui:latest#ghcr.io/<owner>/wordpress-manager-ui:latest#" deploy/07-ui.yamlNếu package GHCR để private, tạo
imagePullSecretvà gắn vào các ServiceAccount tương ứng.
- Thay admin MySQL
rootbằng user quản trị riêng; cân nhắc TLS tới MySQL. - Cung cấp
ADMIN_PASSWORD_HASH(bcrypt) thay vì mật khẩu thô; đặtJWT_SECRETđủ dài & ngẫu nhiên. - Siết
CORS_ORIGINSvề đúng origin của UI. - Cân nhắc
NetworkPolicyđể chỉ operator/site được nói chuyện với MySQL.
## License
[MIT](LICENSE) © 2026 NortonBen