リモート開発メインのソフトウェア開発企業のエンジニアブログです

systemd で運用しているサービスに AWS Secrets Manager からクレデンシャル情報を環境変数で渡す

表題の通り、今回は systemd で運用している Apache HTTP Server に AWS Secrets Manager で管理している機密情報を環境変数で渡す設定を行う機会があったので共有します。

免責事項 (Disclaimer)

一応セキュリティに絡む話題なので、書かれた内容を実践する際は自己責任でお願いいたします。

背景

今関わっているプロジェクトの中に、古い PHP フレームワークで作られた web アプリを Apache HTTP Server + mod_php で運用しているものがあります。将来的には Laravel への移行も計画されていますが、その前に潰せるリスクは潰しておこうということになりました。その1つがコードベースにハードコーディングされている機密情報を削除する、というものです。一度、状況を箇条書きで整理します。

  • 言語: 比較的古いバージョンの PHP + かなり古いフレームワーク(具体的な製品名は言及しません)
  • インフラ: EC2 (イメージ自体は割と新めです)上の Apache HTTP Server + mod_php を systemd で運用
  • コード管理: GitHub
  • 今回対応する機密情報: API キーとかそういう類のものです
Moba Pro

対応方針

最初に、Laravel に移行する背景から .env をインフラ側の管理下でサーバーに配置し、アプリはそこから機密情報を動的に取得することでコードベースからハードコーディングされた内容を削除するという方針を考えました。しかし、今回また別の話になりますが EC2 から ECS への実行環境の移行も計画されていて、AWS Secrets Manager や SSM Parameter Store に保管された値を環境変数に埋め込む方式の方が ECS と親和性が高いと思ったので今回はその方法で行くことにしました。

具体的な運用方針としては、

  • 機密情報は Secrets Manager の Secret にまとめて保管。JSON の key-value をそのまま環境変数として扱う
  • systemd が httpd.service を(再)起動する前に上記を取得し、 EnvironmentFile ディレクティブを使ってサービスプロセスに環境変数を注入
  • アプリからは getenv 関数を使って機密情報を読み取る

といったものになります。

尚、以降の設定例では、ap-northeast-1 上の my-secret という名前の Secret に保管することを前提とします。

IAM Role の設定

EC2 の Instance Profile に設定する Role に以下のポリシーを追加しておきます:

{
	"Sid": "AllowReadOnlyOwnAppsecretValue",
	"Effect": "Allow",
	"Action": "secretsmanager:GetSecretValue",
	"Resource": [
		"arn:aws:secretsmanager:ap-northeast-1:<AWS_ACCOUNT_ID>:secret:my-secret-*"
	]
}

systemd への設定

Secret 取得専用のサービスを作る

httpd.service の起動の前段に動く、Secret から値を取得するサービスを作ります。まずは簡単な bash (Python コードを含む)を /usr/local/bin/fetch-appsecret.sh に作ります:

#!/usr/bin/env bash
# fetch-appsecret.sh
#
# Fetch a JSON secret from AWS Secrets Manager and write it as a
# shell-compatible env file (KEY='value' per line).
#
# Usage:
#   fetch-appsecret.sh <secret-id> <output-file> [region]
#
# Arguments:
#   secret-id    AWS Secrets Manager secret ID or ARN
#   output-file  Path to write the env file (created with mode 600)
#   region       AWS region (default: ap-northeast-1)
#
# Example:
#   fetch-appsecret.sh myapp/prod/db /etc/myapp/db.env
#   source /etc/myapp/db.env

set -euo pipefail

SECRET_ID="${1:?secret id is required}"
OUT="${2:?output file is required}"
REGION="${3:-ap-northeast-1}"
RETRIES=5

umask 077
mkdir -p "$(dirname "$OUT")"

# Retry to cover IMDS not being ready yet or transient API errors
for i in $(seq 1 "$RETRIES"); do
  [[ $i -gt 1 ]] && sleep 3
  SECRET_JSON="$(
    aws secretsmanager get-secret-value \
      --secret-id "$SECRET_ID" \
      --region "$REGION" \
      --query 'SecretString' \
      --output text 2>&1
  )" && break
  echo "Failed to fetch secret ($i/$RETRIES): $SECRET_JSON" >&2
done

if [[ $i -eq $RETRIES ]] && ! echo "$SECRET_JSON" | python3 -c "import json,sys; json.load(sys.stdin)" &>/dev/null; then
  echo "ERROR: Failed to fetch secret after $RETRIES attempts" >&2
  exit 1
fi

echo "$SECRET_JSON" |
python3 -c "
import json, re, shlex, sys
data = json.load(sys.stdin)
assert isinstance(data, dict), 'SecretString must be a JSON object'
for k, v in data.items():
    print(f\"{re.sub(r'[^A-Za-z0-9_]', '_', str(k).upper())}={shlex.quote(str(v))}\")
" > "$OUT"

echo "Secret written successfully: $SECRET_ID -> $OUT" >&2

例として、以下のような形式で実行します:

fetch-appsecret.sh my-secret /etc/sysconfig/httpd-appsecret ap-northeast-1

この例では、my-secret から JSON 情報を取得し、env ファイル (KEY=VALUE) 形式に変換し、 /etc/sysconfig/httpd-appsecret に配置します。値は shlex.quote でエスケープされます。また、配置されるファイルは実行ユーザー(通常は root)のみ読み取り可能なパーミッション設定になります。

このスクリプトをサービス化します。 /etc/systemd/system/fetch-httpd-appsecret.service に以下の内容を配置します:

[Unit]
Description=Fetch app secret from Secrets Manager for httpd
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/fetch-appsecret.sh my-secret /etc/sysconfig/httpd-appsecret

ExecStart は先ほどの例の通りです。Type=oneshot にしていますが、注意点としては RemainAfterExit=yes を指定しないことです。これを指定してしまうと、このスクリプト自体は実行後即座に正常に終了するんですが、 systemd がこのサービスを active のまま扱うため、systemctl start で再実行されなくなってしまうからです(後の httpd.service との連携に支障)。

また、インスタンス起動直後にこのサービスが起動する際 Instance Metadata Service (IMDS) が利用できない(= IAM 認証等ができない)ケースがあったので、Wants=network-online.targetAfter=network-online.target を設定した上で、スクリプトで念の為リトライするようにしています。

最後に、サービスを開始して問題なくファイルが配置されることを確認します:

systemctl daemon-reload
systemctl start fetch-httpd-appsecret.service

httpd.service の drop-in を作成

httpd.service の挙動を拡張するための drop-in を作成します。次のコマンドで作成・編集できます:

SYSTEMD_EDITOR=vim systemctl edit httpd

中身は次の通り:

[Unit]
Requires=fetch-httpd-appsecret.service
After=fetch-httpd-appsecret.service

[Service]
EnvironmentFile=/etc/sysconfig/httpd-appsecret

これにより、httpd.service の起動前に fetch-httpd-appsecret.service の実行が事前に正常終了することが保証されます。失敗した場合は httpd.service 自体も起動しません。その後、 EnvironmentFile ディレクティブで生成された env ファイルをサーバープロセスに注入します。こうすることで EC2 インスタンス起動時と、 httpd.service を restart する時に必ず fetch-httpd-appsecret.service が実行されます。
ちなみに、この時 fetch-httpd-appsecret.serviceRemainAfterExit=yes になってると、 systemctl restart httpd.service の時に再実行されません(先述の支障)。

以下のコマンドで設定に問題がないかチェックします:

# 設定が入ってるかチェック
systemctl cat httpd.service

# 構文チェック
systemd-analyze verify httpd.service

設定の反映

drop-in の反映を済ませ、 httpd.service をリスタートします:

systemctl daemon-reload
systemctl restart httpd.service

特に問題がなければ httpd が起動し、journalctl でログを確認するとこんな感じになっていると思われます:

journalctl -u httpd.service -u fetch-httpd-appsecret.service --no-pager -n 20
systemd[1]: Starting Fetch app secret from Secrets Manager for httpd...
fetch-appsecret.sh[*]: Secret written successfully: my-secret -> /etc/sysconfig/httpd-appsecret
systemd[1]: Started Fetch app secret from Secrets Manager for httpd.
systemd[1]: Starting The Apache HTTP Server...
systemd[1]: Started The Apache HTTP Server.

これでアプリからも getenv で環境変数経由で機密情報を読み取れるようになっているはずです。また、Secret の内容を書き換えて systemctl restart httpd.service を実行すると、getenv で取得できる内容も更新されます。

おわりに

今回は systemd の機能を使って Apache HTTP Server + mod_php の環境に、Secrets Manager で管理している機密情報を環境変数で渡す方法を紹介しました。普段はコンテナを使っていて systemd を触る機会があまりなかったので、久々に色々と勉強になりました。尚、この手法はおそらく php-fpm を使っている場合でも機能すると思います(その場合は php-fpm に同様の設定をしてください)。

最後に、一度 GitHub に push した機密情報はセキュリティ上の懸念があるので、コードベースから削除した後は必ずローテーションを忘れずに。

← 前の投稿

Claude Code も Codex も大半の人は$20のプランで良い

次の投稿 →

Tauri 2 on iOS: A simple fix for WKWebView safe-area inset issues

コメントを残す