Rails on EBの最強アセット展開術

Elastic Beanstalkで作った環境でRailsを運用していたのですが、そのままだといろいろ不都合な事態が発生してしまうことに気づきました。そこで、アセットだけS3に展開する環境を作ることとしました。

# そのままの設定で生じる問題点
もちろん、そのままの設定でも表示自体は正常に行われます。ただし、不都合な点が生じることになります。

まず、ローカルでアセットを生成する場合は`tmp/`ディレクトリの中に一時ファイルが作られて、元ファイルが変わらなければ一時ファイルを再利用して生成するので、一部のファイルだけ更新した場合は比較的短時間で終了します。一方、Elastic Beanstalkの場合、デプロイごとにディレクトリが作り直されて一時ファイルが残らないので[^1]、毎回最初からのプリコンパイルとなって、時間がかかってしまします。

さらに、デプロイ作業はサーバごとに進みますので、複数サーバで運用する場合にも各サーバでプリコンパイルが走ってしまい、処理能力のムダ使いとなってしまいます。

もっと言えば、静的ファイルを配信するだけならS3を使ったほうが効率的なので、本来ならEC2の資源を使うまでもありません。

# `assets_sync`というgem
ちょうどこのような環境に対応するためのgemとして、`asset_sync`というものがあります（[GitHub](https://github.com/AssetSync/asset_sync)）。これは、プリコンパイルで生成したアセットを、別なクラウドサーバに送信しておくというものです。もちろんS3にも対応していますので、EB環境であれば比較的容易に導入できます。

まず、`Gemfile`に`gem 'asset_sync'`を追加して`bundle install`を行い、そして`rails g asset_sync:install --provider=AWS`として初期化ファイルのひな形を生成します（全部環境変数で設定する方法もありますが、ほかの場所で`fog`を使うと干渉して厄介だし、アセット用のバケツは別途で用意したほうがいいと思いますので、設定ファイルの構築をおすすめします）。

設定ファイルができあがったら、何点か書き換えないといけない点があります。

* `asset_sync`は本番環境時、そしてそこへデプロイするファイルのプリコンパイル時以外は不要ですので、それ以外の状況では`Gemfile`の書き方によって読み込ませないという選択もありです。そうすると、`AssetSync`が見つからなくなるので、外側の`if`文が必要となります。
* アセットは専用のバケツに入れたほうがいいので、`config.fog_directory`は適宜設定しましょう。あと、東京リージョンの場合はその設定も必要です（`ap-northeast-1`）。
* AWSのキーやシークレットは別な環境変数でも構いませんが、原則**ソースには書かない**ほうがいいです。`dotenv-rails`を使って、ローカルでは`.env`ファイル、EB上ではEBから環境変数を設定、というのが適当でしょう。

```rb:config/initializers/asset_sync.rb
if defined?(AssetSync)
  AssetSync.configure do |config|
    config.fog_provider = 'AWS'
    config.fog_directory = 'バケツの名前'
    config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID']
    config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']

    # Don't delete files from the store
    # config.existing_remote_files = 'keep'
    #
    # Increase upload performance by configuring your region
    config.fog_region = 'ap-northeast-1'
    #
    # Automatically replace files with their equivalent gzip compressed version
    # config.gzip_compression = true
    #
    # Use the Rails generated 'manifest.yml' file to produce the list of files to
    # upload instead of searching the assets directory.
    # config.manifest = true
    #
    # Fail silently.  Useful for environments such as Heroku
    # config.fail_silently = true
  end
end
```

そして、環境設定ファイルの方で

```rb:config/environments/produciton.rb
# 東京リージョンの場合
config.action_controller.asset_host = 'https://s3-ap-northeast-1.amazonaws.com/バケツ名/'
```
とすれば、S3から参照できるようになります。とはいえ、これだけではハッシュ付きリソースのハッシュを判別できず、ロードに失敗します。

プリコンパイル時に、`public/assets/`直下に、マニフェストとなるJSONファイルが生成しています。これをGitリポジトリに追加してコミットしましょう。なお、それ以外のファイルは不要なので、以下のように`.gitignore`へ設定しておくのがいいかもしれません。

```text:.gitignore
/public/assets/
!/public/assets/*.json
```

# もうひと手間加えて
このままの設定にしていると、JavaScriptやCSSは、そのまま配信されます。gzip圧縮をかけることで、

* エンドユーザーのダウンロード時間が節約できる
* サーバ側でも、Amazonの転送量課金を節約できる

と一石二鳥です。一度設定すればずっと節約できるので、設定しておきましょう。

## 設定だけでは動かない
ちょうど`AssetSync`の設定に`config.gzip_compression = true`という設定があるのですが、これだけでは**何も変化しません**。上のコメントにも書いてありますが、このオプションの動作は「クラウドにアップすべきファイルのgzip版があったときに、本来のファイルの代わりにアップロードする」という意味合いです。自分でgzipファイルを用意しなければ動きません。

もっとも、gzip圧縮したファイルを用意するのはそこまで手間ではありません。

```BASH:asset_gzip.sh
#!/bin/bash

# 現実問題として、スペース入りのアセットを用意することはほぼ考えられないので、
# そういうイレギュラーな場合は考えていません

for FILE in $( find public/assets -type f \( -name '*.css' -or -name '*.js' \) )
do
    gzip -c $FILE > $FILE.gz
done
```

そして、`rake assets:sync`とすると、アセットの同期だけしてくれます。ただし、さらに注意点があって、gzip前のファイルを転送していると、すでにファイルがあると判断して圧縮バージョンはサーバに送られません[^2]。そこで、`config.run_on_precompile = false`として`assets:precompile`時の送信を止めた上で、以下のような手順でデプロイすることになります。

1. `rake assets:precompile`
2. gzipファイルの生成
3. `rake assets:sync`
4. マニフェストファイルのコミットへの追加
5. `eb deploy`

手動でするには重量級の手順となってしまいますので、何かしら自動運用させましょう。


[^1]: `tmp/`もろともコミットするという、筋の悪い解決策は置いておきましょう。
[^2]: `config.gzip_compression = true`として圧縮ファイルも用意したのに送られないと思ったら、これが原因だったことがありました。