Blog

Movable Type と WordPress の DB 接続情報を AWS Secrets Manager で管理する 一時ファイルを作らず、systemd の起動ラッパーだけで DB パスワードを渡す

連日の猛暑で、新潟もすっかり夏本番です。長岡まつり大花火大会も近づき、街の空気にも夏らしい高揚感が出てきました。こういう時期のサーバー作業は、設定変更そのものより、変更後の確認に時間を使いたいところです。
さて、Movable Type や WordPress を EC2 で運用していると、データベースの接続情報をどこに置くかが悩みどころになります。
一般的には mt-config.cgi や wp-config.php に書きますが、ファイルをバックアップしたり Git で管理したりする過程で、パスワードまで一緒に残りやすくなります。
また、データベース接続情報がそれらの設定ファイルにハードコードされている場合、ローカル環境や、開発環境・本番環境で異なるデータベース接続情報を管理するのも大変です。

今回は、データベース接続情報を AWS Secrets Manager に保存し、Amazon Linux2023 や RHEL 10 上で Movable Type と WordPress のプロセスを起動するときだけ環境変数として渡す構成を紹介します。

ポイントは、取得した値を一時ファイルにも書かないことです。systemd のラッパースクリプトで Secrets Manager から値を取得し、そのまま本来のプロセスへ引き継ぎます。

やりたいこと

今回やりたいことは次のとおりです。
  • mt-config.cgi と wp-config.php に DB パスワードを書かない
  • DB 接続情報を Git や AMI へ含めない
  • EC2 インスタンスロールで Secrets Manager を読み取る
  • 平文の接続情報をディスクへ書き出さない
  • パッケージ更新の影響を受けにくい形で systemd を拡張する
  • Movable Type と WordPress で同じ仕組みを使う
この記事では、Amazon Linux2023、RHEL 10 上で Apache HTTP Server、PHP-FPM、Movable Type を動かしている構成を前提にしています。
Movable Type は CGI または PSGI、WordPress は PHP-FPM で動かす想定です。

Movable Type で MT_CONFIG_* から設定を読み込めるのは、MT 8.3.0、MT 8.0.5、MT 7 r.5505 以降です。
それより前のバージョンでは今回の方法を利用できないため、作業前にバージョンを確認してください。

全体の構成

全体の流れは以下の感じです。
movabletype-wordpress-secrets-manager-flow.drawio.png
systemd から起動ラッパーを呼び出し、ラッパーの中で Secrets Manager へアクセスします。取得した値はシェル変数へ展開し、exec で置き換える本来のプロセスへ渡します。

/run 配下を含めて一時ファイルは作りません。パスワードが存在するのは、起動処理とアプリケーションプロセスのメモリ上だけです。

Movable Type には MT_CONFIG_*、WordPress には WP_DB_* という別の環境変数名で渡します。ラッパーは共通ですが、対象アプリケーションに不要な変数は export しないようにしています。

Secrets Manager に DB 接続情報を登録する

Movable Type と WordPress で別の DB ユーザーを使う場合は、シークレットも分けておくと IAM 権限を絞りやすくなります。
今回は次の名前で作成します。
  • Movable Type 用は prod/movabletype/mysql
  • WordPress 用は prod/wordpress/mysql

シークレットの値はどちらも同じ JSON 構造にします。

{
  "host": "database.example.ap-northeast-1.rds.amazonaws.com",
  "port": "3306",
  "user": "cms_user",
  "password": "REPLACE_ME",
  "dbname": "cms_database"
}
値の取得方法は Secrets Manager のドキュメントにまとまっています。
Terraform で管理する場合は、シークレットの入れ物だけを作り、実際の値は AWS マネジメントコンソールやシークレット投入用の安全なパイプラインから登録します。
resource "aws_secretsmanager_secret" "movabletype_mysql" {
  name                    = "prod/movabletype/mysql"
  description             = "Movable Type database connection information"
  recovery_window_in_days = 7
}

resource "aws_secretsmanager_secret" "wordpress_mysql" {
  name                    = "prod/wordpress/mysql"
  description             = "WordPress database connection information"
  recovery_window_in_days = 7
}
aws_secretsmanager_secret_version に実際のパスワードを書くと、その値は Terraform の state にも保存されます。
state の保存先やアクセス制御まで含めて設計できていない場合は、ここへ実値を入れないほうが扱いやすいです。

EC2 インスタンスロールへ権限を追加する

EC2 にはアクセスキーを配置せず、インスタンスロールを使います。必要なアクションは secretsmanager:GetSecretValue です。ポリシーの書き方は Secrets Manager の IAM ポリシー例が参考になります。
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadCmsDatabaseSecrets",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": [
        "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/movabletype/mysql-??????",
        "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/wordpress/mysql-??????"
      ]
    }
  ]
}
Secrets Manager がシークレット ARN の末尾へ付ける 6 文字のランダム文字列に一致させるため、?????? を指定しています。
* でも一致しますが、意図したシークレットより広い名前に一致する可能性があります。
シークレット作成後の完全な ARN を指定できる場合は、そちらを使うとさらに明確です。
リージョンと AWS アカウント ID は実際の環境へ置き換えてください。

Secrets Manager でカスタマーマネージド KMS キーを使っている場合は、そのキーに対する kms:Decrypt も必要です。AWS 管理キー aws/secretsmanager を使う場合は追加不要です。

AWS CLI と jq を準備する

ラッパーでは AWS CLI v2 と jq を使います。
AWS CLI v2 をインストールしたうえで、jq を追加します。
(Amazon Linux2023を使う場合はプリインストールされているので AWS CLIのインストールは不要です)
sudo dnf install -y jq
続けてシークレットを取得できるか確認します。ただし、取得結果をターミナルやログへ表示しないようにします。
aws secretsmanager get-secret-value \
  --region ap-northeast-1 \
  --secret-id prod/movabletype/mysql \
  --query SecretString \
  --output text \
  >/dev/null
終了コードが 0 であれば取得できています。

共通の起動ラッパーを作る

Movable Type と WordPress から共通で使うラッパーを /usr/local/sbin/db-env-wrapper.sh に配置します。
#!/usr/bin/env bash
set -euo pipefail

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

: "${APP_KIND:?APP_KIND is required}"
: "${AWS_REGION:?AWS_REGION is required}"
: "${DB_SECRET_ARN:?DB_SECRET_ARN is required}"

if (( $# == 0 )); then
  echo "Command is required" >&2
  exit 1
fi

secret_json="$(
  aws secretsmanager get-secret-value \
    --region "$AWS_REGION" \
    --secret-id "$DB_SECRET_ARN" \
    --query SecretString \
    --output text
)"

if ! printf '%s' "$secret_json" | jq -e '
  (.host | type == "string" and length > 0) and
  (.port | (type == "string" or type == "number") and (tostring | length > 0)) and
  (.user | type == "string" and length > 0) and
  (.password | type == "string" and length > 0) and
  (.dbname | type == "string" and length > 0)
' >/dev/null; then
  echo "Database secret has missing or invalid fields" >&2
  exit 1
fi

db_host="$(printf '%s' "$secret_json" | jq -r '.host')"
db_port="$(printf '%s' "$secret_json" | jq -r '.port')"
db_user="$(printf '%s' "$secret_json" | jq -r '.user')"
db_password="$(printf '%s' "$secret_json" | jq -r '.password')"
db_name="$(printf '%s' "$secret_json" | jq -r '.dbname')"

case "$APP_KIND" in
  movabletype)
    export MT_CONFIG_DBHOST="$db_host"
    export MT_CONFIG_DBPORT="$db_port"
    export MT_CONFIG_DBUSER="$db_user"
    export MT_CONFIG_DBPASSWORD="$db_password"
    export MT_CONFIG_DATABASE="$db_name"
    ;;
  wordpress)
    export WP_DB_HOST="${db_host}:${db_port}"
    export WP_DB_USER="$db_user"
    export WP_DB_PASSWORD="$db_password"
    export WP_DB_NAME="$db_name"
    ;;
  *)
    echo "Unsupported APP_KIND: $APP_KIND" >&2
    exit 1
    ;;
esac

unset secret_json db_host db_port db_user db_password db_name
unset APP_KIND DB_SECRET_ARN AWS_REGION

exec "$@"
配置後、root だけが編集でき、サービスの実行ユーザーは読み取りと実行だけができるようにします。スクリプト自体にシークレットの実値は保存しません。
sudo chown root:root /usr/local/sbin/db-env-wrapper.sh
sudo chmod 755 /usr/local/sbin/db-env-wrapper.sh
sudo bash -n /usr/local/sbin/db-env-wrapper.sh
httpd や php-fpm のマスタープロセスは通常 root で起動しますが、PSGI の unit では User=movabletype のように実行ユーザーを指定することがあります。
700 にすると、そのユーザーからラッパーを実行できません。
スクリプトの書き換えは root だけに制限しつつ、実行に必要な権限は残しています。

シークレットの必須項目や実行するコマンドが不足している場合は、アプリケーションを起動せずに終了します。
空のパスワードや意図しない値で DB 接続を試すより、起動時にはっきり失敗させるためです。
port は JSON の文字列と数値のどちらでも受け付けます。

ラッパー内で使い終えた APP_KIND、DB_SECRET_ARN、AWS_REGION は、exec の前に unset して本来のプロセスへ引き継がないようにしています。

Movable Type へ環境変数を渡す

対応バージョンの Movable Type は、MT_CONFIG_ で始まる環境変数を設定値として参照できます。
設定ディレクティブの一覧は環境変数リファレンスにあります。
DB 接続に使う変数は次のとおりです。
  • MT_CONFIG_DATABASE
  • MT_CONFIG_DBHOST
  • MT_CONFIG_DBPORT
  • MT_CONFIG_DBUSER
  • MT_CONFIG_DBPASSWORD

mt-config.cgi にある Database、DBHost、DBPort、DBUser、DBPassword は削除します。

環境変数へ移したあともファイル側に値を残していると、どちらが使われているのか分かりにくくなるためです。

CGI で動かす場合

Apache HTTP Server から CGI として動かしている場合は、httpd のプロセスをラッパー経由で起動します。
現在の起動コマンドは、先に確認しておきます。
systemctl cat httpd
RHEL の標準的な unit であれば、sudo systemctl edit httpd で次の drop-in を追加します。
[Service]
Environment="APP_KIND=movabletype"
Environment="AWS_REGION=ap-northeast-1"
Environment="DB_SECRET_ARN=arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/movabletype/mysql-XXXXXX"
ExecStart=
ExecStart=/usr/local/sbin/db-env-wrapper.sh /usr/sbin/httpd $OPTIONS -DFOREGROUND
ExecStart= の空行で既存の起動コマンドをいったん消し、その次の行でラッパーを追加しています。
環境によって元の ExecStart が異なる場合は、systemctl cat httpd の内容を維持したまま先頭へラッパーを加えます。

Apache から CGI へ必要な変数だけを渡すため、/etc/httpd/conf.d/movabletype-env.conf も作ります。
PassEnv MT_CONFIG_DATABASE
PassEnv MT_CONFIG_DBHOST
PassEnv MT_CONFIG_DBPORT
PassEnv MT_CONFIG_DBUSER
PassEnv MT_CONFIG_DBPASSWORD
1 台の Apache で複数のサイトを動かしている場合は、対象の VirtualHost や Directory へ配置して適用範囲を絞ります。

設定を確認して反映します。
sudo apachectl configtest
sudo systemctl daemon-reload
sudo systemctl restart httpd

PSGI で動かす場合

Starman などを専用の movabletype.service で動かしている場合は、httpd ではなく、そのサービスをラッパー経由にします。
[Service]
Environment="APP_KIND=movabletype"
Environment="AWS_REGION=ap-northeast-1"
Environment="DB_SECRET_ARN=arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/movabletype/mysql-XXXXXX"
ExecStart=
ExecStart=/usr/local/sbin/db-env-wrapper.sh /usr/local/bin/starman --workers=${WORKERS} --listen ${SOCKET} ${MT_HOME}/mt.psgi
ここも実際の unit に書かれているオプションをそのまま引き継いでください。
User=movabletype などが指定されている場合は、そのユーザーがラッパーを実行できることも確認します。
また、run-periodic-tasks を別の systemd サービスや cron から定期実行している場合は、そちらにも同じ考え方でラッパーを経由させます。

WordPress へ環境変数を渡す

WordPress では、Web サーバーの httpd ではなく、PHP を実行する PHP-FPM へ環境変数を渡します。

まず sudo systemctl edit php-fpm で drop-in を追加します。
[Service]
Environment="APP_KIND=wordpress"
Environment="AWS_REGION=ap-northeast-1"
Environment="DB_SECRET_ARN=arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/wordpress/mysql-XXXXXX"
ExecStart=
ExecStart=/usr/local/sbin/db-env-wrapper.sh /usr/sbin/php-fpm --nodaemonize
PHP-FPM の起動コマンドも環境によって異なることがあります。
変更前に systemctl cat php-fpm で確認し、元の引数を維持します。

PHP-FPM は、初期状態ではワーカープロセスへ任意の環境変数をそのまま渡しません。
clear_env を無効にするのではなく、/etc/php-fpm.d/www.conf の env[] ディレクティブで WordPress に必要な変数だけを許可します。
env[WP_DB_HOST] = $WP_DB_HOST
env[WP_DB_USER] = $WP_DB_USER
env[WP_DB_PASSWORD] = $WP_DB_PASSWORD
env[WP_DB_NAME] = $WP_DB_NAME
同じ PHP-FPM pool で複数の WordPress を動かしていると、その pool に属するすべてのワーカーへ同じ環境変数が渡ります。
サイトごとに DB 接続情報を分ける場合は、PHP-FPM の pool と systemd サービスも分けてください。

次に wp-config.php の DB 設定を環境変数参照へ変更します。
$wp_db_host = getenv( 'WP_DB_HOST' );
$wp_db_user = getenv( 'WP_DB_USER' );
$wp_db_password = getenv( 'WP_DB_PASSWORD' );
$wp_db_name = getenv( 'WP_DB_NAME' );

if (
	false === $wp_db_host || '' === $wp_db_host ||
	false === $wp_db_user || '' === $wp_db_user ||
	false === $wp_db_password || '' === $wp_db_password ||
	false === $wp_db_name || '' === $wp_db_name
) {
	throw new RuntimeException( 'WordPress database environment variables are not configured.' );
}

define( 'DB_HOST', $wp_db_host );
define( 'DB_USER', $wp_db_user );
define( 'DB_PASSWORD', $wp_db_password );
define( 'DB_NAME', $wp_db_name );
値がないときに空文字列のまま処理を続けず、設定不備として停止させています。
エラーメッセージには変数名や値を出さないようにします。

設定を確認して反映します。
sudo php-fpm -t
sudo systemctl daemon-reload
sudo systemctl restart php-fpm

WP-CLI や Movable Type のツールから使う

systemd から起動した PHP-FPM や Starman の環境変数は、SSH でログインしたシェルには引き継がれません。
WP-CLI や Movable Type のアップグレードツールを使うときも、同じラッパーを経由します。

WordPress の DB 接続確認は、WordPress を所有する OS ユーザーで実行します。
RHEL の Apache HTTP Server で使われる apache ユーザーを例にすると、次のようになります。
sudo -u apache env \
  APP_KIND=wordpress \
  AWS_REGION=ap-northeast-1 \
  DB_SECRET_ARN=arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/wordpress/mysql-XXXXXX \
  /usr/local/sbin/db-env-wrapper.sh \
  wp --path=/var/www/html db check
Movable Type のアップグレードツールも、Movable Type を実行している OS ユーザーを使います。
sudo -u movabletype env \
  APP_KIND=movabletype \
  AWS_REGION=ap-northeast-1 \
  DB_SECRET_ARN=arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:prod/movabletype/mysql-XXXXXX \
  /usr/local/sbin/db-env-wrapper.sh \
  /path/to/movabletype/tools/upgrade --name example
apache や movabletype は例なので、実際のファイル所有者やサービスの実行ユーザーへ置き換えてください。
WP-CLI へ --allow-root を付ける方法もありますが、プラグインやテーマのコードまで root 権限で動くため、ホスト OS 上では通常ユーザーでの実行を優先します。

毎回指定するのが長い場合は、アプリケーションごとに root 所有の短いラッパーを追加してもよいと思います。
シークレット ARN は識別子でありパスワードではありませんが、書き換えられない権限にしておく必要があります。

動作確認

反映後はサービスの状態とログを確認します。
sudo systemctl status httpd movabletype php-fpm
sudo journalctl -u httpd -u movabletype -u php-fpm --since "10 minutes ago"
使っていないサービスはコマンドから外してください。
ログに予期しないエラーや警告がなく、Movable Type の管理画面と WordPress の管理画面へアクセスできれば確認完了です。

環境変数を確認するために ps eww、/proc/<PID>/environ、phpinfo()、env を使うと、DB パスワードまで画面やログへ出る可能性があります。
値そのものを見るのではなく、アプリケーションから DB 接続できるかで確認します。

シークレットを更新したときの動き

この構成では、Secrets Manager から値を取得するのはプロセスの起動時だけです。
シークレットを更新しても、実行中のプロセスには自動反映されません。

パスワードをローテーションしたあとは、対象サービスを再起動します。
sudo systemctl restart httpd
sudo systemctl restart movabletype
sudo systemctl restart php-fpm
実際には、利用しているサービスだけを再起動します。
DB 側のパスワード変更とサービス再起動の間に接続エラーが起きる可能性があるため、次の流れをメンテナンス手順として決めておきます。

1. 変更前の値へ戻せる状態にしておく
2. DB 側のパスワードと Secrets Manager の値を更新する
3. 対象サービスを再起動する
4. 管理画面や CLI から DB 接続を確認する
5. 失敗した場合は DB 側と Secrets Manager の両方を変更前の値へ戻し、もう一度サービスを再起動する

Secrets Manager や AWS API へ接続できない状態で新しくサービスを起動すると、ラッパーが失敗してサービスも起動しません。
一方、すでに動いているプロセスは、再起動しない限り現在の値で動き続けます。

この構成で気をつけること

設定ファイルや一時ファイルからパスワードを外せますが、平文の値が完全になくなるわけではありません。
アプリケーションが DB へ接続する以上、実行中のプロセスメモリには接続情報が存在します。

root 権限を持つユーザーや、同じ実行ユーザーのプロセスから環境変数を参照できる場合もあります。
サーバーへログインできるユーザーを絞り、sudo、/proc、コアダンプ、監視ツールの権限もあわせて確認します。

また、ラッパーでは次の操作を避けます。
  • set -x を有効にする
  • 取得した JSON や環境変数をログへ出す
  • デバッグ目的で env や ps eww の結果を保存する
  • シークレットの実値を systemd の Environment= に書く
  • シークレットの実値をコマンドライン引数で渡す
今回の方法は、アプリケーション設定ファイルやディスクへ認証情報を残さず、IAM で取得元を制限できる点が利点です。
ただし、Secrets Manager が起動時の依存先になるため、障害時の再起動手順も含めて運用を考える必要があります。

まとめ

Movable Type と WordPress の DB 接続情報を AWS Secrets Manager へ移し、systemd の起動ラッパーから環境変数として渡すようにしました。

共通のラッパーを使いながら、Movable Type には MT_CONFIG_*、WordPress には WP_DB_* だけを渡しています。
WordPress では PHP-FPM 側をラップし、必要な環境変数だけを pool 設定で許可するのがポイントです。

systemd の drop-in を使っているため、配布元の unit ファイルを直接変更する必要もありません。
パッケージ更新に追従しやすく、設定の責務も分けやすい構成になりました。

シークレットのローテーション時にサービス再起動が必要になる点は残りますが、mt-config.cgi や wp-config.php から DB パスワードを外したい場合には、現実的な方法だと思います。

現場からは以上です。