AWS
S3
laravel
CloudFront
webpack

webpackでビルド時に指定ディレクトリ配下を丸ごとS3にアップロードする

状況

  • パフォーマンス向上のため、画像など一部静的ファイルをCloudFront経由でS3から読み込むようにしている
  • cssやjsファイルはコンパイルなど一手間あるのでアプリケーションサーバの静的ファイル置き場(publicディレクトリ配下)から読み込んでいた
  • コンパイル含むビルドはwebpackを使用している(さらに言うとLaravelのmixというwebpackのラッパー)

やりたいこと

  • パフォーマンス向上のため、cssやjsファイルを含むアプリケーションサーバの静的ファイル置き場から読み込んでいるものを全てCloudFront経由でS3から読み込むようにしたい

やったこと

webpack-s3-pluginというプラグインで本番環境時のみS3にアップロードするようにする

webpack-contrib/s3-plugin-webpack: Uploads files to s3 after complete

webpack.mix.js
const { mix } = require('laravel-mix');
const S3Plugin = require('webpack-s3-plugin');

// S3アップロードのコマンド以外のコマンドで実行する処理
if (!process.env.UPLOAD_S3) {
 // sass、jsのコンパイルなどの処理
}

// 本番環境かつS3アップロードのコマンドでのみ実行する処理
if (mix.inProduction() && process.env.UPLOAD_S3) {

  // webpackのカスタム設定
  mix.webpackConfig({
    plugins: [
      // public/asv配下をs3にアップロードする 
      new S3Plugin({
        // s3Options are required
        s3Options: {
          accessKeyId: process.env.MIX_AWS_ACCESS_KEY_ID,
          secretAccessKey: process.env.MIX_AWS_SECRET_ACCESS_KEY,
          region: 'ap-northeast-1',
        },
        s3UploadOptions: {
          Bucket: process.env.MIX_S3_BUCKET,
          CacheControl: 'max-age=864000', // 10日のブラウザキャッシュ
        },
        // s3のどのルート直下パスに置くか
        basePath: 'site1',
        // リポジトリ内の下記ディレクトリを丸ごとアップロードする
        directory: 'public',
        // アップロード時にCloudFrontのインバリデーションを行う
        cloudfrontInvalidateOptions: {
          DistributionId: process.env.MIX_CLOUDFRONT_DISTRIBUTION_ID,
          Items: ["/site1/*"]
        }
      })
    ]
  });
}

開発環境やステージング環境では取り回しの聞くアプリケーションサーバからの読み込みの方が便利なのでそのままにし、本番環境でのビルド時のみS3にアップロードするようにしています。
ステージング環境で上記作業を行うようにすると、ブランチ運用している場合にS3のアップロード先が別ブランチの作業で上書きされかねないので注意が必要です。
また、CloudFrontからの読み込みはキャッシュに気をつけないとデプロイしたのに変更がされないという事故にも繋がりかねないので、アップロードしたディレクトリのオブジェクトキャッシュを全てインバリデーションするようにしています。

以下、関連して行ったことです。

sass、jsのコンパイルなどとS3へのアップロードはnpm runコマンドを分ける

なぜこうするかというと、S3へのアップロードはsass、jsのコンパイルなどと非同期で行われるからです。
つまり、コンパイル後のcss、jsをS3にアップロードしてほしいのですが、非同期で行われるためコンパイル前にアップロードされる可能性があるので、npm runコマンド自体を分け、同期的に処理が行われるようにしたということです。

例えば、下記のようにpackage.jsonを書きます。

package.json
"scripts": {
  "production": "npm run production-build && npm run production-upload-s3",
  "production-build": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
  "production-upload-s3": "cross-env NODE_ENV=production UPLOAD_S3=true node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},

production-buildは通常のsass、jsのコンパイルなどを実行するコマンド、production-upload-s3はS3へのアップロードのみを行うコマンドです。
production-upload-s3にはUPLOAD_S3=trueで環境変数UPLOAD_S3を渡し、webpack.mix.jsなどでprocess.env.UPLOAD_S3として受け取って、それによってproduction-upload-s3実行時のみS3へのアップロードのみを行うようにします。
逆に、process.env.UPLOAD_S3がないときにsass、jsのコンパイルなどを実行すると設定すれば、ひとつの設定ファイルでそれぞれのコマンドの実行を排他的にすることができます。

それぞれのコマンドで処理が分かれるようにすれば、productionコマンド時にnpm run production-build && npm run production-upload-s3として、同期的に処理が走るようにできます。

ファイル読み込み関数を本番環境時とそれ以外の環境で読み込む場所を変更する

一例ですが、自分はLaravelのasset関数をオーバーライドして対応しています。

public function asset($path, $secure = null, $withQuery = true)
{
    // 本番ではCloudFrontを見る
    if (app()->environment('production')) {
        $path = trim($path, '/');
        $url = config('site.cloudFrontUrl') . '/site1/' . $path;

    // ローカル、ステージングの場合はpublicを見る
    } else {
        $root = $this->route('top');
        $root = rtrim($root, '/');
        $url = $root . '/' . $path;
    }

    if ($withQuery) {
        $url = $this->revision ? $url .'?'. $this->revision : $url;
    }

    return $url;
}

CloudFrontのBehaviorsのCompress Objects AutomaticallyをYesにする

cssやjsなどのgzip圧縮を有効にするオプションです。
おそらくCloudFront側のメモリを使用するから初期値Noになってそう?
pngやjpgの画像はYesでもgzip圧縮できないのでそのままにしていましたが、cssやjsなどのテキストファイルは圧縮可能なのでYesに変更。
なお、拡張子ごとなどに設定を変えたい場合はBehaviorを作成してパスパターンで分岐させます。

css、jsの連結をやめて複数ファイルに分ける

CloudFrontへのリクエストはHTTP2に対応しており、同一ドメインへの同時リクエストの制限がなくなるので、HTTP1.1で行っていたリクエスト数を減らすためのファイル連結を行う必要がなくなります。
むしろページごとに使用するcss、jsのコンポーネントがある程度異なるような大きさのプロジェクトでは、いくつかのファイルに分けて不要なコンポーネントは読み込ませないようにした方がパフォーマンスが上がります。
キャッシュのヒット率がなるべく上がるように意識してファイルを分割しました。