為 WordPress object cache 使用 ElastiCache Serverless IAM auth

Written by

in

有什麼好處?

傳統的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;

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *