• 為 WordPress object cache 使用 ElastiCache Serverless IAM auth

    有什麼好處?

    傳統的redis 密碼是一個長期有效的明文密碼,寫在 wp-config.php 裡,會跟著備份、複本一起擴散。

    IAM auth token 不是每個請求都重新產生:php-fpm 會把它快取在 APCu 裡,最多 600 秒;token 是用 EC2 instance role 的臨時憑證簽出來的,主機上不存放任何長期密鑰。

    IAM 驗證防不了的事:

    • 主機被入侵:只要能在這台 EC2 上execute,就能向 IMDS 取得 role 憑證、自行簽 token,效果和竊取 wp-config.php 裡的密碼差不多。差別在 role 憑證只有數小時有效,能存取的範圍也受 policy 限制。
    • 額外負擔:多了 AWS SDK 和 IMDS 兩個依賴;每個請求多一次 AUTH。
    • Audit:elasticache:Connect(IAM AUTH)不會出現在 CloudTrail。
    • IAM 驗證可以audit「誰被授權、何時改了權限」,但無法從 CloudTrail 查到「誰在何時連線」。連線層面只能看 CloudWatch ,而且指標只有次數,沒有user 身分。

    結論:對單台主機的 WordPress 而言,IAM 驗證的實際好處是主機上不再存放長期密碼、不需手動輪換、撤銷只需改 IAM;對主機被入侵的防護,和密碼驗證差異有限。


    1. 摘要

    WordPress(Redis Object Cache plugin +phpredis)原本以 RBAC 密碼連線 ElastiCache Serverless。本次改為IAM auth連線。

    做法:由一支 PHP script 在本機用 AWS SDK for PHP 對 ElastiCache 做 SigV4 presign,產生短效 IAM token,當作密碼交給plugin 。token 有效 900 秒,快取最多 600 秒,每個 PHP 請求在快取過期時自動重新產生。php-fpm 用 APCu 快取;APCu 異常時才退回EBS 上的檔案快取,並留下檔案作為警示;CLI 不快取。


    2. 環境與資源

    | OS/PHP | Amazon Linux 2023,PHP 8.5.12(NTS,aarch64) |
    | Redis client | phpredis 6.3.0 |
    | WordPress plugin | Redis Object Cache 3.0.0(drop-in v3.0.0) |
    | Cache | ElastiCache Serverless ,Valkey 9 |
    | IAM ElastiCache user | iam-wordpress-user(on ~* +@all,Authentication Type iam) |
    | SDK | /home/www/lib/aws.phar(AWS SDK for PHP v3) |
    | token 產生器 | /home/www/lib/elasticache-iam-token.php |
    | token 快取(php-fpm) | APCu(php8.5-pecl-apcu 5.1.28,apc.enabled=On,apc.shm_size=32M) |
    | APCu 異常時的備援快取 | /home/www/lib/cache/(目錄 0700,檔案 0600)。正常情況下是空的,出現檔案即代表 php-fpm 的 APCu 不可用或寫入失敗 |
    | CLI 的 token | 不快取、不寫檔案,每次執行直接產生(APCu 在 CLI 預設關閉) |


    3. 方案選擇與依據

    3.1 為什麼「token 當密碼」在 phpredis+plugin 可行

    • ElastiCache IAM 驗證的運作方式是:把短效 token 當成 AUTH/HELLO 的密碼送出。
    • plugin 的 WP_REDIS_PASSWORD 接受 [username, password] 。
    • phpredis 6.3.0 的 redis.pconnect.echo_check_liveness 預設為 1。重用持久連線時,redis_sock_check_liveness() 會先用目前的密碼重送 AUTH,再送 ECHO。因此每個請求都會用當下的 token 重新驗證。

    3.2 ElastiCache IAM 驗證的限制(AWS 文件)

    • 需要 Valkey 7.2 以上或 Redis OSS 7.0 以上(本案為 Valkey 9)。
    • 必須啟用 in-transit encryption(TLS)。Serverless 預設啟用。
    • IAM user 的 username 和 user-id 必須相同。
    • token 有效 15 分鐘;用過期 token 重新驗證會被拒絕。
    • IAM 驗證的連線 12 小時後會自動斷開;送出帶新 token 的 AUTH/HELLO 可再延長 12 小時。
    • MULTI/EXEC 或 Lua 腳本內不支援重新驗證。
    • cache 名稱建立時會轉成小寫,產生 token 時必須用小寫。
    • Serverless 的 token 要多帶 ResourceType=ServerlessCache。

    4. 前置條件檢查

    4.1 確認 EC2 使用的 IAM 身分

    aws sts get-caller-identity
    {
        "UserId": "<ROLE_UNIQUE_ID>:<INSTANCE_ID>",
        "Account": "<ACCOUNT_ID>",
        "Arn": "arn:aws:sts::<ACCOUNT_ID>:assumed-role/EC2ConnectElastiCacheRole/<INSTANCE_ID>"
    }

    一個 instance profile 只能掛一個 role,所以 elasticache:Connect 權限要加在 EC2ConnectElastiCacheRole 上。

    4.2 確認 cache 和 user 狀態

    aws elasticache describe-serverless-caches --region ap-east-2 \
      --serverless-cache-name valkey-serverless \
      --query 'ServerlessCaches[0].[Status,UserGroupId,Endpoint.Address]'
    
    [
        "available",
        "default-valkey-group",
        "valkey-serverless.serverless.ape2.cache.amazonaws.com"
    ]
    
    aws elasticache describe-users --region ap-east-2 \
      --query 'Users[].[UserId,UserName,Engine,AccessString,Authentication.Type]'

    確認結果:

    • cache 名稱為 valkey-serverless,Valkey 9。
    • 帳號內已有其他 IAM 模式的user :iam-valkey-user、default.iam-user、wordpress-limited。

    5. 實施步驟

    步驟 1:建立 IAM 模式的 ElastiCache user

    aws elasticache create-user --region ap-east-2 \
      --user-id iam-wordpress-user --user-name iam-wordpress-user \
      --engine valkey --authentication-mode Type=iam \
      --access-string "on ~* +@all"

    步驟 2:把user 加入 serverless cache 的 user group

    aws elasticache modify-user-group --region ap-east-2 \
      --user-group-id <valkey-serverless 的 UserGroupId> \
      --user-ids-to-add iam-wordpress-user

    確認:

    aws elasticache describe-user-groups --region ap-east-2 \
      --user-group-id <UserGroupId> \
      --query 'UserGroups[0].[Status,Engine,UserIds,PendingChanges]'

    預期:Status 為 active,UserIds 包含 iam-wordpress-user,PendingChanges 為空。

    步驟 3:給 instance role 加上 elasticache:Connect 權限

    "Resource": "*",等於允許這台 EC2 以 IAM 連線帳號內所有 cache 和所有 IAM user 。改成最小權限,目前實際套用的 inline policy:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "VisualEditor0",
          "Effect": "Allow",
          "Action": "elasticache:Connect",
          "Resource": [
            "arn:aws:elasticache:ap-east-2:<ACCOUNT_ID>:user:iam-wordpress-user",
            "arn:aws:elasticache:ap-east-2:<ACCOUNT_ID>:serverlesscache:valkey-serverless"
          ]
        }
      ]
    }

    IAM 是在每次 AUTH 時才評估權限,所以 policy 改完後,下一次 AUTH 就會套用新設定。驗證新 policy 時,要用新產生的 token 測一次正向案例,再用一個不在 policy 裡的 IAM user 測一次反向案例:

    # 正向:iam-wordpress-user 應回 PONG
    REDISCLI_AUTH="$(php -r 'require "/home/www/lib/elasticache-iam-token.php"; echo ec_iam_token("valkey-serverless","iam-wordpress-user","ap-east-2");')" \
    valkey-cli -c --tls -h valkey-serverless.serverless.ape2.cache.amazonaws.com -p 6379 --user iam-wordpress-user PING
    PONG
    
    # 反向:不在 policy 裡的 iam-valkey-user 應被拒絕(前提是它也在這個 cache 的 user group 裡,否則被拒的原因是不在 group)
    REDISCLI_AUTH="$(php -r 'require "/home/www/lib/elasticache-iam-token.php"; echo ec_iam_token("valkey-serverless","iam-valkey-user","ap-east-2");')" \
    valkey-cli -c --tls -h valkey-serverless.serverless.ape2.cache.amazonaws.com -p 6379 --user iam-valkey-user PING
    AUTH failed: WRONGPASS invalid username-password pair or user is disabled.
    (error) NOAUTH Authentication required.

    確認權限(需要有 IAM 權限的 profile):

    aws iam simulate-principal-policy \
      --policy-source-arn arn:aws:iam::<ACCOUNT_ID>:role/EC2ConnectElastiCacheRole \
      --action-names elasticache:Connect \
      --resource-arns arn:aws:elasticache:ap-east-2:<ACCOUNT_ID>:serverlesscache:valkey-serverless \
                      arn:aws:elasticache:ap-east-2:<ACCOUNT_ID>:user:iam-wordpress-user \
      --query 'EvaluationResults[].[EvalResourceName,EvalDecision]'
    
    [
        [
            "arn:aws:elasticache:${Region}:${Account}:${ResourceType}:${ResourceName}",
            "allowed"
        ]
    ]

    步驟 4:安裝 AWS SDK for PHP,請放在 webroot 以外。

    sudo mkdir -p /home/www/lib && cd /home/www/lib
    sudo curl -LO https://docs.aws.amazon.com/aws-sdk-php/v3/download/aws.phar
    sudo chown -R www:www /home/www/lib

    步驟 5:建立 token generator

    /home/www/lib/elasticache-iam-token.php:

    <?php
    // ElastiCache IAM 驗證 token generator(SigV4 presign)
    // - token 有效 900 秒(AWS 規定 15 分鐘)
    // - php-fpm:正常情況快取在 APCu,最多 600 秒,且不超過 instance role 臨時憑證剩餘時間(保留 60 秒)
    // - APCu 不可用或寫入失敗時:寫 error_log,並改用 /home/www/lib/cache/ 檔案快取
    //   => 這個目錄出現檔案,就代表 php-fpm 的 APCu 出過問題
    // - CLI(APCu 預設關閉):不快取、不寫檔案,避免每次跑 wp-cli 都誤報
    // - 只有需要產生 token 時才載入 AWS SDK
    function ec_iam_token(string $cacheName, string $userId, string $region, bool $serverless = true): string
    {
        $key = 'ec_iam_token:' . $cacheName . ':' . $userId;
        $maxTtl = 600;
        $isCli = (PHP_SAPI === 'cli');
        $useApcu = !$isCli && function_exists('apcu_enabled') && apcu_enabled();
        $file = '/home/www/lib/cache/' . md5($key);
    
        // 1) 讀快取:先 APCu;APCu 沒有時再看備援檔案(正常情況下檔案不存在)
        if ($useApcu) {
            $t = apcu_fetch($key, $ok);
            if ($ok && $t !== '') {
                return $t;
            }
        }
        if (!$isCli && is_file($file) && filemtime($file) > time() - $maxTtl) {
            $t = file_get_contents($file);
            if ($t !== false && $t !== '') {
                return $t;
            }
        }
    
        // 2) 產生新 token
        try {
            require_once '/home/www/lib/aws.phar';
    
            $query = 'Action=connect&User=' . rawurlencode($userId);
            if ($serverless) {
                $query .= '&ResourceType=ServerlessCache';   // serverless 必須帶這個參數
            }
    
            $req = new \GuzzleHttp\Psr7\Request('GET', 'http://' . strtolower($cacheName) . '/?' . $query);
            $creds = \Aws\Credentials\CredentialProvider::defaultProvider()()->wait();   // EC2 instance role(IMDS)
            $signed = (new \Aws\Signature\SignatureV4('elasticache', $region))->presign($req, $creds, '+900 seconds');
            $token = preg_replace('#^http://#', '', (string) $signed->getUri());
        } catch (\Throwable $e) {
            error_log('ec_iam_token failed: ' . $e->getMessage());
            return '';   // 失敗時回傳空字串,讓plugin 走 graceful 降級,不讓整站 fatal
        }
    
        if ($isCli) {
            return $token;
        }
    
        // 3) 快取時間不超過臨時憑證剩餘時間,避免 token 提早失效卻還留在快取
        $ttl = $maxTtl;
        $exp = $creds->getExpiration();
        if ($exp) {
            $ttl = max(30, min($maxTtl, $exp - time() - 60));
        }
    
        if ($useApcu && apcu_store($key, $token, $ttl)) {
            return $token;
        }
    
        // 4) APCu 不可用或寫入失敗:記錄原因,改用檔案快取(檔案出現即為異常訊號)
        error_log(sprintf(
            'ec_iam_token: APCu %s, fallback to file cache %s',
            $useApcu ? 'store failed' : 'unavailable',
            $file
        ));
        @mkdir(dirname($file), 0700, true);
        $tmp = $file . '.' . getmypid();
        if (file_put_contents($tmp, $token, LOCK_EX) !== false) {
            chmod($tmp, 0600);
            rename($tmp, $file);
            touch($file, time() - ($maxTtl - $ttl));   // 讓檔案快取在 $ttl 秒後過期
        }
    
        return $token;
    }

    php-fpm 正常時只用 APCu;APCu 不可用或寫入失敗時,會寫 error_log 並改用 /home/www/lib/cache/ 檔案快取,所以這個目錄出現檔案,就是 APCu 出問題的訊號。CLI 不快取、不寫檔案。

    chown www:www /home/www/lib/elasticache-iam-token.php
    php -l /home/www/lib/elasticache-iam-token.php
    sudo systemctl reload php-fpm                        # 清空 APCu

    token 格式:對 http://<cache名稱>/?Action=connect&User=<userId>&ResourceType=ServerlessCache 做 SigV4 presign(服務名 elasticache,有效 900 秒),再去掉開頭的 http://。

    步驟 6:用 CLI 單獨驗證 token

    TOKEN=$(php -r 'require "/home/www/lib/elasticache-iam-token.php"; echo ec_iam_token("valkey-serverless","iam-wordpress-user","ap-east-2");')
    echo "${TOKEN:0:80}..."
    REDISCLI_AUTH="$TOKEN" valkey-cli -c --tls \
      -h valkey-serverless.serverless.ape2.cache.amazonaws.com -p 6379 \
      --user iam-wordpress-user PING
    
    valkey-serverless/?Action=connect&User=iam-wordpress-user&ResourceType=Serverles...
    PONG

    檢查 token 結構時,請mask簽章和 session token:

    echo "$TOKEN" | tr '&' '\n' | sed -E 's/(X-Amz-Security-Token=|X-Amz-Signature=).*/\1***/'

    output

    valkey-serverless/?Action=connect
    User=iam-wordpress-user
    ResourceType=ServerlessCache
    X-Amz-Security-Token=***
    X-Amz-Algorithm=AWS4-HMAC-SHA256
    X-Amz-Credential=ASIAZQ3DUCTPN6PU5GGG%2F20261023%2Fap-east-2%2Felasticache%2Faws4_request
    X-Amz-Date=20261023T063005Z
    X-Amz-SignedHeaders=host
    X-Amz-Expires=900
    X-Amz-Signature=***

    步驟 7:修改 wp-config.php

    /**wp redis object cache*/
    define( 'WP_REDIS_CLIENT', 'phpredis' );
    define( 'WP_REDIS_PERSISTENT', true );
    define( 'WP_REDIS_IGBINARY', true );
    define('WP_REDIS_CLUSTER', [
        'tls://valkey-serverless.serverless.ape2.cache.amazonaws.com:6379',
    ]);
    require_once '/home/www/lib/elasticache-iam-token.php';
    define('WP_REDIS_PASSWORD', ['iam-wordpress-user', ec_iam_token('valkey-serverless', 'iam-wordpress-user', 'ap-east-2')]);
    define('WP_REDIS_SSL_CONTEXT', [
        'verify_peer' => true,
        'verify_peer_name' => true,
    ]);

    步驟 8:啟用 APCu,檔案快取改為異常備援和警示

    背景:token generator 在沒有 APCu 時,會把 token 寫到 /home/www/lib/cache/

    OPcache 只快取 PHP 腳本編譯後的 bytecode,沒有讓程式存取任意資料的 API,不能用來存 token。要在 php-fpm 的 worker 之間共用資料,要用 APCu。

    sudo dnf install -y php8.5-pecl-apcu
    sudo systemctl restart php-fpm
    php-fpm -m | grep -i apcu
    php-fpm -i | grep -E '^apc\.(enabled|shm_size)'

    實際輸出:

    Installed:
      php8.5-pecl-apcu-5.1.28-1.amzn2023.0.1.aarch64
    apcu
    apc.enabled => On => On
    apc.shm_size => 32M => 32M

    APCu 的特性:

    • 快取存在 php-fpm master 的共享記憶體,同一個 master 底下的 worker 共用。
    • 重啟或 reload php-fpm 會清空,下一個請求會自動重新產生 token。
    • token 只有幾 KB,32M 綽綽有餘。

    實際記憶體用量:

    # pmap -x $(pgrep -o php-fpm) | awk '$2==32768'
    0000ffff732d1000   32768     136     136 rw-s- apc.9yud3J (deleted)
    # php-fpm -i | grep -E '^opcache\.memory_consumption'
    opcache.memory_consumption => 128 => 128
    • APCu 的共享記憶體區段大小 32768 KB,但 RSS 只有 136 KB。apc.shm_size 只是預留的虛擬位址空間,實際佔用的 RAM 取決於存了多少資料,所以維持 32M,不調整。
    • 同一個 php-fpm master 裡,128M 的 zero (deleted) 區段是 OPcache(opcache.memory_consumption=128),RSS 約 2.7 MB。

    6. token 更新機制

    沒有背景程式或排程在更新 token,更新是在請求中「需要時才做」:

    1. 每個 PHP 請求載入 wp-config.php 時,都會呼叫一次 ec_iam_token()。
    2. APCu 快取未過期:直接回傳快取的 token,不呼叫 AWS。
    3. APCu 快取過期(最多 600 秒,或憑證快到期時更短),或在 CLI 執行:載入 SDK,向 IMDS 取得 instance role 憑證,在本機簽出新 token;php-fpm 會寫回 APCu。
    4. phpredis 取得這個 token:
      • 新連線:用它送 AUTH。
      • 重用持久連線:liveness 檢查用它重送 AUTH〔已驗證,原始碼〕。

    token 有效 900 秒,快取最多 600 秒,所以每個請求拿到的 token 至少還有約 5 分鐘有效。每個請求都重送 AUTH,推斷也會一直延長 IAM 連線 12 小時的斷線期限。


    7. 驗證結果

    wp redis status :

    Status: Connected
    Client: PhpRedis (v6.3.0)
    Drop-in: Valid
    Ping: 1
    Errors: []
    PHP Version: 8.5.12
    Plugin Version: 3.0.0
    Redis Version: 7.2
    Global Prefix: "wp_"
    Blog Prefix: "wp_"
    Timeout: 1
    Read Timeout: 1
    Persistent: 1
    WP_REDIS_CLIENT: "phpredis"
    WP_REDIS_SSL_CONTEXT: {"verify_peer": true}
    WP_REDIS_PERSISTENT: true
    WP_REDIS_CLUSTER: ["tls://valkey-serverless.serverless.ape2.cache.amazonaws.com:6379"]
    WP_REDIS_IGBINARY: true
    WP_REDIS_PLUGIN_PATH: "/home/www/bbken.org/wp-content/plugins/redis-cache"
    WP_REDIS_PASSWORD: ["iam-wordpress-user","••••••••"]

    判讀:

    • IAM 驗證成功,使用持久連線。
    • Redis Version: 7.2: Valkey 為了相容性,把 INFO 的 redis_version 固定在 7.2;
  • PHP web runtimes (CVE-2026-109618 and CVE-2026-109619)

    有很多年沒聽過PHP 生這麼大的病,這一下就來兩個,主流的Linux 發行版都立刻更新,反而是FreeBSD 落後。

    https://www.php.net/archive/2026.php#2026-10-21-5

    這是什麼

    Linux 直接 yum/dnf/apt/zypper update 就可以了,各大發行版事先就拿到了修補程式,所以更新很快。

    FreeBSD 需要修改為最新的 latest repo 而不是預設的quarterly,如下:

    mkdir -p /usr/local/etc/pkg/repos
    cat > /usr/local/etc/pkg/repos/FreeBSD.conf <<'EOF'
    FreeBSD-ports: {
      url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest"
    }
    EOF
    pkg update -f
    pkg upgrade -y

    PfSense 發了公告 Netgate – https://www.netgate.com/blog/critical-php-security-update:

    • 只有把 GUI 或 Captive Portal 開放給不信任的網路時才有風險。GUI 預設只開在 LAN,我有開到WAN。
    • 有用 Captive Portal 的盡快更新 – 我有用。

    各版本更新後的 PHP:

    • Plus 26.07、Plus 26.03.1、CE 2.9.0 → PHP 8.5.12

    這次不是韌體更新,沒有新的小版本,所以 GUI 裡看不到,pfSense-upgrade -c 也可能顯示已經是最新。一定要從 Console 或 SSH 手動更新::

    8) Shell                             
    
    Enter an option: 8
    
    [26.07-RELEASE][admin@Home.bbken.net]/root:
    pfSense-upgrade -c && pkg upgrade -y && /etc/rc.reboot

    有用 WireGuard 的一定要加上 pkg upgrade -fy pfSense-pkg-WireGuard,而且要放在 pkg upgrade -y 之後。

    pkg upgrade -fy pfSense-pkg-WireGuard

    因為這個package 寫得比較爛,有個php_wg 是寫死的。Netgate 的說法是,要重新安裝 WireGuard 套件,它才會用到正確的 PHP 版本,不然可能起不來(php_wg 是寫死的)。論壇上也有人回報,WireGuard 指向 PHP 的連結要重新安裝才會更新。如果你是從 WireGuard 連進去更新的,過程中連線很可能會斷掉,最好先準備其他連線方式,例如 IPSec/OpenVPN。

    與此同時,還有印度人搞出來的大新聞:

  • 改用rclone 來同步靜態文件到S3 和R2

    之前從CloudFlare 改用CloudFront 主要是因為中國訪問的速度不佳,因為CloudFlare 會直接將中國人的訪問送去美國,但是CloudFront 可以選擇送去香港。

    CloudFlare 其實也可以,只不過要付更多錢。

    因此我想同時將wordpress 的靜態文件同步到S3 和R2 ,就當多一個備份。

    修改前的狀態:

    同步工具使用 aws s3 sync,腳本是 /usr/local/bin/aws-cli-sync-s3.sh。

    #!/bin/bash
    /usr/bin/inotifywait -m -r -e modify,create,delete /home/www/bbken.org/wp-conten
    t/uploads/ |
    while read path action file; do
        echo "Change detected: $action $file"
        /usr/bin/aws s3 sync /home/www/bbken.org/wp-content/uploads/ s3://bbken.org/
    wp-content/uploads/ --delete
    done

    由 systemd 服務 s3-sync.service 以 www 使用者執行。腳本用 inotifywait watch /home/www/bbken.org/wp-content/uploads/ 的 modify、create、delete 事件,每偵測到一次事件就對 s3://bbken.org/wp-content/uploads/ 完整同步一次,帶 –delete。但是,這不太通用,移植到其他雲端的時候會有一點問題

    市面上同步工具其實蠻多,但是rclone這東西歷史悠久,就他了。

    未來要擴充到其他雲端的話,添加更多的profile 就可以。aws cli 本質為 S3 設計,連 R2 要靠 –endpoint-url;rclone 原生支援數十種後端,全部共用同一套 rclone sync 指令。

    接下來我們建立並驗證 rclone 的 S3 remote:建一個名為 s3 的 remote,type 選 s3、provider 選 AWS、env_auth 設為 true,直接沿用 EC2 的 IAM role,不需填AK/SK。驗證方式是 rclone lsd s3:,成功列出帳號內所有 bucket 含 bbken.org。

    初次用預設參數空跑耗時 23.9 秒,原因是 rclone 預設比對檔案原始修改時間,該值存在物件的 X-Amz-Meta-Mtime 自訂中繼資料裡,LIST 結果不含這項,必須對每個物件各發一次 HEAD 請求,11358 個檔案就是 1.1 萬次額外請求,需要調整 rclone 參數解決效能問題。

    改用 –fast-list –update –use-server-modtime 後,改以 LIST 就能取得的 LastModified 比對,不再發 HEAD。實測結果是 aws s3 sync 空跑 24.8 秒,rclone 加上這組參數後 2.1 秒,快約 12 倍。

    這個調整對免費仔使用R2 的意義比速度更大:HEAD 在 R2 算 Class B 操作,免費額度每月 1000 萬次,若不調參數,每次同步就消耗約 1.1 萬次,雖然也不是不可以,但平均每天只有3次同步機會似乎顯得略少。

    S3 的同步很簡單,因為不需要AK/SK,接下來在 Cloudflare 建立 bucket 與憑證:建立 bucket bbken-org,產生 R2 API Token 取得 Access Key ID、Secret Access Key 與 endpoint。

    接下來建立並驗證 rclone 的 R2 remote:用 rclone config 新增名為 r2 的 remote,type 選 s3、provider 選 Cloudflare,填入金鑰、region 設 auto、endpoint 設 R2 提供的url,並加上 no_check_bucket = true。設定檔權限設為 600。

    首次完整同步 R2 並驗證,需要從EFS上同步的文件總量為 11358 個檔案、1.048 GiB,約占 R2 免費儲存額度 10 GB 的一成。

    禁用舊腳本,新增同步腳本 /usr/local/bin/rclone-sync-uploads.sh。相較舊版做了五項改動。

    #!/bin/bash
    # 監看 WordPress uploads 目錄,變動時用 rclone 同步到多個目的地
    
    SRC=/home/www/bbken.org/wp-content/uploads/
    RCLONE=/usr/bin/rclone
    DEBOUNCE=5   # 最後一次事件後等待幾秒才同步
    
    # 要擴充目的地,在這裡加一行即可
    DESTS=(
        "s3:bbken.org/wp-content/uploads"
        "r2:bbken-org/wp-content/uploads"
    )
    
    sync_all() {
        for dest in "${DESTS[@]}"; do
            if "$RCLONE" sync "$SRC" "$dest" --fast-list --update --use-server-modtime; then
                echo "Synced to $dest"
            else
                echo "ERROR: sync to $dest failed" >&2
            fi
        done
    }
    
    # 啟動時先完整同步一次,補上服務停止期間漏掉的變更
    sync_all
    
    /usr/bin/inotifywait -m -r -q \
        -e close_write,create,delete,moved_to,moved_from \
        --format '%e %w%f' "$SRC" |
    while read -r event; do
        echo "Change detected: $event"
        # 去抖動:持續讀取後續事件,直到連續 DEBOUNCE 秒沒有新事件
        while read -r -t "$DEBOUNCE" event; do
            echo "Change detected: $event"
        done
        sync_all
    done

    一是目的地改成陣列 DESTS,同時包含 s3:bbken.org/wp-content/uploads 與 r2:bbken-org/wp-content/uploads,用loop逐一同步,未來擴充只需加一行,比如要同步去GCP。

    二是加入 5 秒去抖動,用 read -t 持續吸收後續事件,直到連續 5 秒沒有新事件才同步一次,避免 WordPress 一次上傳產生多個縮圖就觸發多次完整同步。

    三是監看事件由 modify 改為 close_write,避免寫入過程重複觸發,並補上 moved_to 與 moved_from,涵蓋以改名或搬移方式進出目錄的檔案。

    四是服務啟動時先完整同步一次,補上服務停止期間漏掉的變更。

    五是各目的地分開判斷結果,單一目的地失敗會寫入日誌但不影響其他目的地。

    接下來修改 systemd 服務,變更為新的腳本,用 systemctl edit –full s3-sync.service,將 ExecStart 指向新腳本,Description 改為 Uploads Sync Service (rclone to S3 and R2),並把 After 從 network.target 改為 network-online.target 並加上對應的 Wants。接著 daemon-reload 與 restart。

    [Unit]
    Description=Uploads Sync Service (rclone to S3 and R2)
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=www
    ExecStart=/usr/local/bin/rclone-sync-uploads.sh
    Restart=always
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target

    驗證:目測沒問題,我應該沒瞎。