[WordPress] 從 GCP 搬到本機 KIND:Multisite 搭配 Cloudflare Tunnel

[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 都要維持運作,網站才能連線。

從 GCP 備份兩套 WordPress Multisite,還原到 KIND、驗證後,再切換 Cloudflare Tunnel
先還原與驗證,再切換公開 DNS;PVC 不是備份。

先盤點原本的網站

我原本有兩套 WordPress Multisite,這次也保留成兩個獨立的安裝:

  • wordpress1:ephrain.net、travel.ephrain.net
  • wordpress2:另一套獨立的 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 不是備份。

Cloudflare Edge 和 Tunnel 將請求送入 KIND;兩套 WordPress Multisite 各自有 WordPress files PVC、MariaDB StatefulSet 與 database PVC
一個 Tunnel,兩套隔離的 Multisite stack;資料庫和上傳檔案各自使用 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 以外的地方~

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料。