laravelアプリケーションで環境変数を管理する手法 実例
エンジニアのkawataです。この記事はSEN Advent Calendar 2025の3日目の記事になります。
今回はlaravelアプリケーションにおける環境変数管理について、実際に開発で使った事例と、そこでぶつかった問題について紹介しようと思います。
laravelアプリケーションにおける環境変数
laravelの設定ファイルはconfigディレクトリに保存しますが、そのうち環境依存な値や、機微な値はenvへルパーを使って環境変数か.envで管理するのが一般的です。ここまでは公式のドキュメントに書いてあるのですが、問題は.envファイルや環境変数をどう管理するかです。特に本番環境の環境変数にはパスワードやクレデンシャルなど機微な情報が含まれることが多いため下手なところには置けません。publicなgithubのリポジトリに置くのは論外ですし、githubのソースコードそのものが流出する可能性などを考えるとprivateなgithubのリポジトリであっても直接置くのはためらわれます。そんな中でこれまで我々が採用してきた手法について紹介していきます
過去の手法1: リポジトリに暗号化して保存する
最初に使った手法は.envファイルを暗号化してリポジトリにコミットしておくというものです。デプロイ時に復号化してサーバーに配置します。修正する場合は、別途鍵を配布して手元で復号化して再度暗号化し、commitしてもらいます。見込まれた利点としては、githubで履歴管理できる、インフラの知識の薄いアプリ開発メンバーでも運用しやすいなどがありましたが、運用していくにつれ以下の欠点が出てきました。
コンフリクトの解消が難しい
gitで管理しているのは暗号化されたファイルのため、コンフリクトが発生した場合マージ先とマージ元の両方を復号化して手でマージした後再度暗号化する必要があります。これは結構面倒で、間違いやすい作業になり、実際設定したはずの環境変数がマージ時にミスによって消えてしまうトラブルが何度か発生しました・・・
権限の管理が難しい
暗号化キーの管理することで、環境変数の閲覧/編集権限を制御することになります。我々のケースでは暗号化キーを乗せたスプレッドシートを用意してそこにユーザーを追加することで権限制御してましたが、煩雑だし、一度権限を付与したら鍵を変更しないと真の意味で権限を剥奪できず、あまりセキュアではありません。
過去の手法2:secrets managerを利用してECSのタスク定義で読み込む
という背景もあり、これまでEC2で動いていたアプリケーションをECSに移行するときに同時に環境変数の管理を、AWSのマネージドサービスである、secrets managerに移行することにしました。ECSにおいてはタスク定義に読み込むべき環境変数を列挙しておいて読み込ませます。残念ながら一部ECSに移行できずEC2で動かす機能があったのですが、それらについてはデプロイ時にsecrets managerの内容を.envで書き出すことで対応しました。
secrets managerであればコンフリクトも発生しませんし、(そもそもプルリクエストという概念がないので・・・)権限の付与、剥奪もIAM経由で行えます。値を見たければマネジメントコンソールから見れて、手間もかかりません。
とはいえこれで運用していると以下の問題が出てきました。
task definitionが肥大化する
ECSのタスク定義を記載する際、secrets managerにある変数を一括で取り込むことができず、環境変数ごとにタスク定義に記載しなければなりません。環境変数の数がそれほど多くないのであればよいのですが、我々のアプリケーションでは100以上という非常に大量の環境変数を扱っており、それを全てタスク定義に記述するとタスク定義が非常に長くなって読みづらいですし、そもそもsecrets managerとタスク定義の両方に追記しないといけないため、運用が煩雑になります。また、タスク定義のJSONファイルは上限があるようで(AWSの公式ドキュメントには記載が見当たらなかったのですが、実際試すとActual length: '68206'. Max allowed length is '65536' bytes.のようなエラーが出て登録できません)、これ以上環境変数が増えるとデプロイができなくなる懸念がありました。
{
"name" : "DATABASE_HOST",
"valueFrom" : "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:app-env:DATABASE_HOST::"
},
{
"name" : "DATABASE_CONNECTION",
"valueFrom" : "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:app-env:DATABASE_CONNECTION::"
},
{
"name" : "DATABASE_NAME",
"valueFrom" : "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:app-env:DATABASE_NAME::"
},
{
"name" : "DATABASE_USERNAME",
"valueFrom" : "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:app-env:DATABASE_USERNAME::"
},
{
"name" : "DATABASE_PASSWORD",
"valueFrom" : "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:app-env:DATABASE_PASSWORD::"
},
.envのエスケープの扱いが難しい
secrets manager内では環境変数はJSONで管理されています。アプリケーションがECSであればECSが直接取り込んでくれるので問題ないですが、我々の場合一部のアプリケーションがまだEC2で動いており、それに対応するため, JSONで保存されているsecrets managerの値をデプロイ時に.envファイルに変換していました。
これを行うために簡単なgoのcliを自作して使っていたのですが、go側のdotenvとphp側のdotenvのエスケープ処理が異なるため環境変数に!や"などの文字が含まれている場合、php側でうまく復号化できなかったり、意図と異なる値で復号される問題が何度か発生しました。
.envファイルはいろいろなところで使われていますが、jsonと異なり、特に統一された仕様があるわけではなく、こういう細かい仕様は各実装が好きなように決めているという状態のようで、正しいjson->.env変換を作るのは思ったほど簡単でないのがわかってきました。
現在の手法:secrets managerの値をlaravelアプリケーションから読み込む
というわけで新たに考えたのがsecrets managerの値をlaravelアプリケーションから直接読み込む手法です。アプリケーションから一括で読み込めば、大量に環境変数がある場合でも逐一タスク定義に記載する必要はありません。secrets manager側にある状態ではJSONという明確にエスケープに関しては明確に仕様が決まった状態になっているのでjson_decodeして読み込めば.envエスケープの仕様に振り回されることもありません。
具体的な手法
まず通常laravelで.envの読み込みやconfigの読み込みがどのように行われるかですか、Kernelにおいてbootsrapperという初期化用のclassが用意されており、そこで読み込まれます。
見ての通り環境変数を読み込むLoadEnvironmentVariablesやconfigを読み込むLoadConfigurationがあります。そこで今回は新たなbootstrapperを追加することを考えます。
まず以下のようなbootstrapperを作成します
class LoadEnvironmentFromSecretManager
{
public function bootstrap(Application $app)
{
if (method_exists($app, 'configurationIsCached') && $app->configurationIsCached()) {
return;
}
$id = getenv('AWS_SECRET_MANAGER_SECRET_ID');
if ($id === false || $id === '') {
return;
}
try {
$client = new SecretsManagerClient([
'region' => 'ap-northeast-1',
]);
$result = $client->getSecretValue([
'SecretId' => $id,
]);
$secret = json_decode($result->get('SecretString'), true);
} catch (Exception $e) {
var_dump($e->getMessage());
throw $e;
}
foreach ($secret as $key => $value) {
if (getenv($key) === false) {
putenv(sprintf('%s=%s', $key, $value));
}
}
}
}
laravelのenvヘルパーはgetenvを使うのでputenvだけしておけば良いでしょう。laravelのconfigのドキュメントにあるようにconfigファイル以外で環境変数を使わず、常にconfigを参照していれば問題は起こらないはずです。putenvの中で=を書かないといけないのでエスケープが必要なのか気になりますが、putenvは=以降のものをそのまま解釈するので、特にエスケープなどは必要ないです。
注意すべき点としてはこの時点ではまだServiceProviderが動いてないのでlogやaws-sdk-php-laravelなどのlaravel向けの便利機能の多くが使えません。生phpに近い感覚で書いた方が安全でしょう。
ここでもlaravelのLogファサードは使わず、var_dumpで出力しています。
次にKernelのbootstrapperに追加してやる必要があります。
bootstrapperの中身を全部上書きしてやってもいいのですが、バージョンアップでbootstrapperが増えたりしたときに困るのでコンストラクタの中で追加してやる形式にします
class Kernel extends HttpKernel
{
public function __construct(Application $app, Router $router)
{
$index = array_search(LoadEnvironmentVariables::class, $this->bootstrappers);
if ($index !== false) {
array_splice($this->bootstrappers, $index + 1, 0, [
LoadEnvironmentFromSecretManager::class
]);
}
parent::__construct($app, $router);
}
}
注意すべき点としてはKernelはweb向けのHttp/Kernelとcli向けのConsole/Kernelの二つあり、二つともbootstrapperを追加してやる必要があります
これで環境変数としてAWS_SECRET_MANAGER_SECRET_IDだけを設定してやれば自動的に環境変数をsecrets managerから読み出してくれます。実際の本番環境ではartisan config:cacheしておけば、configのキャッシュ作成時だけsecrets managerにアクセスされ、キャッシュ作成後$app->configurationIsCached()の部分でreturnしてsecrets managerにアクセスしないのでパフォーマンスも変化しません。
最近これに移行して今の所運用上困った点はないですが、まだあまり時が経っていないので引き続き様子を見ていこうと思ってます。
最後に
我々が実際に使ってきた環境変数の管理手法とその運用上の欠点について解説しました。
laravelに限らずwebアプリケーションを運用する際、環境差異のある値や秘密にすべき値をどのように管理するかというのは悩ましいところかと思います。様々な事情により最適解は変わるでしょうが、我々の事例が何かしらの参考になれば幸いです。
さて、SEN Advent Calendar 2025の明日はいちごさんの「「1on1」という言葉が、実は少し苦手な理由。」です。お楽しみに
Discussion