コンテナ運用しているLaravel アプリケーションで CloudFront で プライベートコンテンツを配信するための private key をどう扱うか

こんにちはみなさん

S3でプライベートコンテンツ、つまり、ログインユーザーにしかアクセスできないコンテンツを配信することは、S3へのアクセス権限を持った IAM を作っておけば、そんなに難しい話ではありません。
私は基本的にはコンテナで運用するのですが、S3へのアクセス権限を持った IAM のアクセスキーとシークレットキーを環境変数から注入してやれば、S3に期限付きでアクセスできるような署名付きURLを発行することができます。

そんなわけで、LaravelでCloudFrontからプライベートコンテンツを配信する方法と、その際に発生するprivate key の扱い方についての悩みと私の解決法をご紹介します。

あ、言うまでもありませんが、**コンテナ運用での悩み**ですので、普通のサーバで運用している場合は知らんです。

# プライベートコンテンツの配信

プライベートコンテンツとはシステムにログインしているユーザーにしか配信したくないコンテンツのことです。
たとえば、秘密の動画や画像などで、知り合いや顧客にしか公開したくないコンテンツです。

プライベートコンテンツを配信する最も単純な解決手段としては、システムを介す方法があります。
幸いなことにLaravelには storage する場所を抽象化できたりするのでログインしているユーザーに限定してコンテンツを配信することは、まあできるでしょう。

```routes/web.php
Route::middleware('auth')->group(function () {
    Route::get('contents/{name}', function ($name) {
        $content = Storage::get($name);
        return response($content, 200);
    });
});
```
しかしながら、これはわざわざシステムの内部を通って、ゴリッとデータをぶん投げているわけですから、なんとなく気持ちが悪いように思います。
なにより、動画や画像など、重たいファイルをシステム内から大量に配信すると、速度が非常に気がかりとなりますので、基本的にはCDNのようなキャッシュを使ってシステムを介さずに配信したいところです。

# CloudFrontを使った配信
## プライベートコンテンツ配信の流れ
AWSの提供するCDNであるCloudFrontはS3に配置したコンテンツをキャッシュして配信してくれるのですが、S3同様に署名付きURLを発行することで、プライベートコンテンツの配信を行うことができます。

CloudFrontの設定については面倒なので、他の記事を参照させてください。
https://dev.classmethod.jp/cloud/aws/cf-s3-deliveries-use-signurl/

大雑把に手順を書きますと

1. S3のバケットを作る
2. CloudFrontのドメインを作る
3. CloudFrontの Origin ソースを作成したS3バケットに設定する
4. CloudFrontの設定で、署名付きURLのみ許可するようにする
5. 署名付きURLを作成するためのキーペアを**ルートアカウント**で作成する
6. プライベートキー(PK)を使って署名付きURLを作成する

ってな感じです。
最も面倒なのは、ルートアカウント使わないとキーペアを発行できないことと、IAMのシークレットキーではなく、プライベートキーファイルを使わにゃならんということです。

## Laravelで署名付きURLを発行する
CloudFrontの設定も終わり、(PK)を適当に配置してやれば準備は完了です。
次はLaravelで署名付きURLを発行してやりましょう。

LaravelではAWSのSDKをいい感じに使うためのライブラリが提供されているので、まずはそいつを使いましょう。
詳細は以下のURLを参照してくださいな
https://github.com/aws/aws-sdk-php-laravel

```
composer require aws/aws-sdk-php-laravel
```
設定とかの書き方は省略します。(上のURLでことたりるので)

署名付きURLはこんな感じで作ります。

```php
$expire = 300;// 5分間だけ有効
$client = Aws::createClient('cloudfront');
$now = Carbon::now()->timestamp;
$config = [
    'url' => env('AWS_CLOUDFRONT_BASE_PATH'),
    'private_key' => resource_path('privatekey'),
    'key_pair_id' => env('AWS_CLOUDFRONT_KEY_PAIR_ID'),
];
$config['url'] .= $path;
$config['expires'] = $now + $expire;
$result = $client->getSignedUrl($config);
return (string)$result;
```
お手軽でしょ？

# プライベートキーをどうするか問題
プライベートキーをどうやって設置するかについてはいろいろ議論が出ました。
私の場合はコンテナ運用をしているので、いくつかのやり方の候補が出てきたので、ご紹介します。

## 1. ベースコンテナに入れちゃうパターン

ベースとなるコンテナを前もって作っておいて、それをもとに動作コンテナのイメージを作っちゃうパターンです。
そんなに悪い方法ではないのですが、開発環境で動作させるコンテナのイメージに更新が入った場合に、ベースコンテナの存在を忘れていると、事故る可能性があったり、かと言ってローカルのコンテナのベースイメージに使ってしまうと、みんながプライベートキーを触れてしまうので、とりあえずやめておきました。

## 2. CIから突っ込む
CIの変数に突っ込んでおいて、イメージのビルド時にその変数をechoしたりしてファイルを生成する方法。GitLab CI を使っていると、秘密の変数を作れるので、いいっちゃいいんですが、ファイルの内容がまるまる変数の中に入っているという状況が受け入れられず、ボツにしちゃいました。

## 3. 暗号化する
結局これにしちゃいました。
何をやったかというと、まあ、以下のようなコンソールコマンドを作ったわけです。

```encrypt.php

    // 暗号化
    public function handle()
    {
        $file = config('where_is_private_key');
        $string = file_get_contents($file);
        $env = config('app.env');

        $encrypted = encrypt($string);
        $out = $file . '.' . $env;
        file_put_contents($out, $encrypted);
    }
```
これは暗号化の処理で、元ファイルが`privatekey`で、対象の環境が`development`であれば`privatekey.development`が作られます。

一方復号は以下のコードになります

```decrypt.php
    // 復号
    public function handle()
    {
        $file = config('where_is_private_key');
        $encoded_file = $file . '.' . config('app.env');

        $string = file_get_contents($encoded_file);

        $decrypted = decrypt($string);

        file_put_contents($file, $decrypted);
    }
```
暗号化の逆ですな。
自身の環境名を読んで、対象の暗号化キーファイルを読み出し、復号して出力しています。

暗号化・復号の手順は以下のようになります。

1. 各環境用のAPP_KEYを一時的に自身のローカルで設定した状態で、暗号化処理を走らせて暗号化済みファイルを作る
2. 暗号化済みファイルのみリポジトリに突っ込む
3. デプロイのコンテナ起動時に、entrypointの処理が走っている最中に復号を実行する
4. アプリケーションが立ち上がるときにはプライベートキーが存在している

ってな感じです。

# まとめ
コンテナ運用は流行っているのですが、なにせ急速に発展しつつある分野でもあるわけで、十分なノウハウが溜まっていない部分も多々あります。
今回のケースも、明確にこうしたほうがいいというものが見つからなかったので、自分の考えうる限り問題なさそうな方法をとっていますが、もっといい方法がありそうな気もします。
まあ、こうやって知見をためていって、いずれはベストプラクティスが生まれていくんじゃないかと考えると、今回の試みもまあ、楽しいものではありますな。

今回はこんなところです。