[WordPress] 從 GCP 搬到本機 KIND:Multisite 搭配 Cloudflare Tunnel
原本這兩套 WordPress Multisite 都放在 GCP;這次我把它們搬到自己的 Mac 上跑 KIND。網站搬完之後才發現,這件事不只是把 WordPress 和資料庫跑起來,Multisite、備份檔、網域和 HTTPS 都要一起對好。這篇就把這次的做法和踩到的坑記錄下來~
先補充一下,KIND 是 Kubernetes IN Docker 的縮寫:它會把 Kubernetes 節點當成容器在本機啟動,讓我不用另外租 VM,也能在 Mac 上測試 Kubernetes 的 Deployment、Service 和 PVC。這次 WordPress 實際跑在 KIND 節點裡;Cloudflare Tunnel 再把外部流量送進來。它很適合開發和測試,但本機 cluster 不是高可用服務,Mac、KIND 和 tunnel connector 都要維持運作,網站才能連線。

先盤點原本的網站
我原本有兩套 WordPress Multisite,這次也保留成兩個獨立的安裝:
wordpress1:ephrain.net、travel.ephrain.networdpress2:另一套獨立的 Multisite(網域資訊不列出)
為什麼不合併成一套?因為兩邊原本就是不同的 Multisite 網路,UpdraftPlus 備份與還原也是分開管理。Multisite 的資料庫備份會包含整個網路的網站,還原到另一套網路可能會覆蓋它的資料,所以保留兩套最單純。
每套 WordPress 都有自己的 PVC
在 Kubernetes 裡,我為每套 WordPress 各自部署 WordPress、MariaDB,並分開建立資料庫 PVC 和 WordPress 檔案 PVC。資料庫放 MariaDB 的 PVC;WordPress 的 PVC 掛載在 /var/www/html,Multisite 上傳的圖片也在這個 volume 裡。
這樣 Pod 重建時資料還在,不過 KIND 的 standard StorageClass 是 node-local storage,而且 reclaim policy 是 Delete。刪掉 KIND cluster 或 Mac 硬碟故障,PVC 裡的資料也可能一起不見,所以 PVC 不是備份。

實際使用的 YAML 範例
下面是這次 manifests 的核心內容。根目錄用 Kustomize 組合兩套 stack 和 Tunnel;敏感密碼不放進 YAML,而是透過 Secret 注入。
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- wordpress1
- wordpress2
- tunnel.yaml
---
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: wordpress
以 wordpress1 為例,DB 和 WordPress 檔案各有一個 PVC。wordpress2 的名稱和 Secret 換成 wordpress2;兩套不共用資料庫或檔案 volume。
# wordpress1/stack.yaml:兩個獨立的 8Gi PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress1-db-data
namespace: wordpress
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources:
requests:
storage: 8Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress1-files
namespace: wordpress
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources:
requests:
storage: 8Gi
MariaDB 用 StatefulSet,資料目錄掛到 wordpress1-db-data;密碼則從 wordpress1-secrets 讀取。root 密碼也要放 Secret,不要寫在 manifests 裡。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: wordpress1-db
namespace: wordpress
spec:
serviceName: wordpress1-db
replicas: 1
selector:
matchLabels:
app: wordpress1-db
template:
metadata:
labels:
app: wordpress1-db
spec:
containers:
- name: mariadb
image: mariadb:11.4.8
env:
- name: MARIADB_DATABASE
value: wordpress
- name: MARIADB_USER
value: wordpress
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress1-secrets
key: db-password
- name: MARIADB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress1-secrets
key: db-root-password
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumes:
- name: data
persistentVolumeClaim:
claimName: wordpress1-db-data
WordPress 本身用 Deployment 和 ClusterIP Service。Multisite 的 domain 常數、HTTPS proxy 判斷和檔案 volume 都放在同一個 Pod template;.htaccess 則由 ConfigMap 掛載。
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress1
namespace: wordpress
spec:
replicas: 1
selector:
matchLabels:
app: wordpress1
template:
metadata:
labels:
app: wordpress1
spec:
containers:
- name: wordpress
image: wordpress:6.8.3-php8.3-apache
env:
- name: WORDPRESS_DB_HOST
value: wordpress1-db:3306
- name: WORDPRESS_DB_NAME
value: wordpress
- name: WORDPRESS_DB_USER
value: wordpress
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress1-secrets
key: db-password
- name: WORDPRESS_CONFIG_EXTRA
value: |
define('WP_ALLOW_MULTISITE', true);
define('MULTISITE', true);
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'ephrain.net');
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) { $_SERVER['HTTPS'] = 'on'; }
volumeMounts:
- name: files
mountPath: /var/www/html
- name: htaccess
mountPath: /var/www/html/.htaccess
subPath: .htaccess
readOnly: true
volumes:
- name: files
persistentVolumeClaim:
claimName: wordpress1-files
- name: htaccess
configMap:
name: wordpress1-htaccess
wordpress2 的結構相同,只要把資源名稱、labels、PVC、Secret 和 DB Service 換成 wordpress2,並把 DOMAIN_CURRENT_SITE 改成第二套網路的主網域。不要把兩套 Multisite 的資料庫或 PVC 指到同一個名稱。
Cloudflare Tunnel 是另一個 Deployment,token 從 cloudflared-token Secret 讀取;不需要把 token 放在公開設定檔:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: wordpress
spec:
replicas: 1
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:2025.11.1
args: ["tunnel", "--no-autoupdate", "run", "--token", "$(TUNNEL_TOKEN)"]
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token
key: token
先在 KIND 裡部署,不要急著改公開 DNS
先在本機建立 namespace、準備 Kubernetes Secrets,再套用 manifests:
kubectl apply -f namespace.yaml
python3 create-secrets.py
kubectl apply -k .
kubectl -n wordpress get pods,pvc,svc
kubectl --context kind-kind-cluster -n wordpress rollout status deploy/wordpress1
kubectl --context kind-kind-cluster -n wordpress rollout status deploy/wordpress2
Secret 裡放兩套 MariaDB 的密碼和 Cloudflare Tunnel token,不要把密碼寫進公開的 Git repository。等 WordPress、MariaDB Pod 都 Ready,PVC 也都 Bound 之後,再用 port-forward 從瀏覽器做本機測試。這次我先用 18081 和 18082 兩個 port 分別測兩套網站。
Cloudflare Tunnel 的公開路由怎麼設定?
這次用的是 remote-managed Tunnel。Cloudflare Dashboard 裡到 Zero Trust → Networks → Tunnels → 選這個 Tunnel → Routes → Add route → Published application。每個 route 填公開 hostname,再把 Service URL 指向 Kubernetes 內部的 Service DNS;不要填 Mac 的 localhost 或 node IP:
- ephrain.net 與 travel.ephrain.net →
http://wordpress1.wordpress.svc.cluster.local:80 - 第二套網路的主網域與子網域 →
http://wordpress2.wordpress.svc.cluster.local:80
Published route 會嘗試自動建立 DNS CNAME。如果 hostname 已有指向舊主機的 A/AAAA 記錄,自動建立可能失敗;先檢查該筆網站記錄,再改成指向 <tunnel-uuid>.cfargotunnel.com 的 CNAME,並啟用 Cloudflare Proxy。不要動 MX、TXT 或其他不相關記錄。DNS 變更會立刻把公開流量導到 KIND。
kubectl --context kind-kind-cluster -n wordpress get pods -l app=cloudflared
kubectl --context kind-kind-cluster -n wordpress logs deploy/cloudflared --tail=30
curl -sSI https://ephrain.net | grep -Ei 'HTTP/|server:|cf-ray:'
curl -sSI https://travel.ephrain.net | grep -Ei 'HTTP/|server:|cf-ray:'
切換前先確認 Cloudflare Edge 憑證為 Active,且涵蓋正式主機名稱。訪客端的 HTTPS 由 Cloudflare Edge 憑證處理,Tunnel 再把流量送到 KIND 裡的 HTTP Service;WordPress 設定則根據 X-Forwarded-Proto 判斷原始請求是 HTTPS。這次 Cloudflare 已經有舊的 A 記錄,因此 Tunnel 路由建立後,DNS 沒辦法自動建立 CNAME;我檢查每個主機名稱後,把網站用的記錄改成指向 tunnel 的 proxied 記錄,其他 DNS 記錄則保留不動。
切換 DNS 會讓公開訪客立刻開始連到 KIND,所以我等網站在本機和還原資料都檢查過後,才做最後的 cutover。完成後再逐一確認 HTTPS 首頁、子站和圖片都能正常開啟。
Sub-domain Multisite 的設定
Multisite 網路建立後,wp-config.php 要明確設定成子網域模式。以 ephrain.net 為例,正式網域的設定大致如下;另一套則改成它自己的主網域:
define('MULTISITE', true);
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'ephrain.net');
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
$_SERVER['HTTPS'] = 'on';
}
本機測試時,我用兩個 port-forward(18081 和 18082)分別測試兩套網路;正式上線前則要把 port 從 DOMAIN_CURRENT_SITE、wp_site、wp_blogs 和每個子站的 home/siteurl 移除。Apache 的 .htaccess 也要放 SubDomain 版本的 rewrite rules,不能直接照抄 Subdirectory 版本。
UpdraftPlus 還原時,我遇到的兩個坑
第一個是備份檔要配對。理想狀況是 database、plugins、themes、others 和 uploads 都來自同一個備份時間及識別碼。這次 wordpress1 的資料庫是 2026-09-08,plugins/themes/others 是 2026-09-06,uploads 又是另外的 ZIP;我照手邊檔案還原後,首頁和代表性圖片都正常,但較新的媒體仍有可能不在這份舊 uploads 裡。wordpress2 則是 database/plugins/themes/others 一組,再把 uploads ZIP 補進去。
第二個是權限。把備份用 kubectl cp 複製到 wp-content/updraft 後,目錄可能是 root 或 Mac 使用者擁有,UpdraftPlus 就會說「備份目錄存在但不可寫入」。修正成 Apache 使用者即可:
kubectl -n wordpress exec deploy/wordpress1 -- \
chown -R www-data:www-data /var/www/html/wp-content/updraft
uploads ZIP 要先檢查裡面是不是以 uploads/ 開頭、有沒有不安全路徑,並確認 PVC 空間足夠;解壓後放在 wp-content/uploads,Multisite 的子站圖片會在 uploads/sites/<blog_id>。最後記得把 ownership 設回 www-data。
把本機網址換回正式 HTTPS 網址
還原到本機時,我讓 UpdraftPlus 把來源網址改成 port-forward 的目的網址。要正式對外前,再用 WP-CLI 做一次序列化安全的反向取代。先 dry-run 看影響範圍:
wp search-replace 'http://ephrain.net:18081' 'https://ephrain.net' \
--network --all-tables-with-prefix --precise --skip-columns=guid --dry-run
每一個子網域也要各跑一組舊網址→新網址;確認 dry-run 的替換數量和表格後,拿掉 --dry-run 再執行。這次 WP-CLI 對少數未載入的序列化物件有警告,所以我另外確認主要網路/子站 domain、home/siteurl,以及最後實際公開頁面,而不是只看指令成功就算完成。
最後記錄一下
這次搬完之後,兩套 Multisite 都在 KIND 裡跑,Cloudflare Tunnel 負責把網域導進來。這個方式不用開家裡的 inbound port,不過網站能不能連線還是取決於 Mac、KIND cluster、網路和 tunnel connector 有沒有在運作。重要資料還是要定期備份到 KIND 以外的地方~