asset_syncの設定を見直してデプロイ時間を7分半削減した話

はじめまして、Progateの小笠原です。本記事は Progate AdventCalendar 4日目の記事です。 普段はSREチームでProgateの開発効率化を始め基盤運用、トラブル対応などサービスの安定化にも幅広く取り組んでいます。

本稿ではProgateのCI/CDを改善する中で得たasset_syncにwebpackを乗せる際の注意点とその効果について知見を共有します。

事の発端

「Progateのデプロイが遅いよね」という話が以前から開発者の間で上がっておりその原因を調べるところから今回の話は始まります。

Web版Progateサービス(以下Progate)はCIサービス(主にCircleCI)を使って検証環境と本番環境の2環境へのデプロイを行っているのですが、9月時点でCircleCIのデプロイワークフローの実行時間がそれぞれ約18分程度と非常に時間がかかっていました。

Progateは1日に数回デプロイが行われるため開発時のストレスにもなっており、また有事の際にパッチをデプロイするのに時間がかかりサービスの信頼性を損ねる要因にもなっていました。

事象の調査

CircleCIのジョブ履歴を見ると一番無駄が多そうでかつボトルネックになっていたのが静的アセットの生成とアップロードに関するジョブ(run rails assets)で、合計で約9分かかっていました。

このジョブではRailsのassetのprecompileを行い、その後にasset_syncを用いてprecompileされたassetとwebpackで別途バンドルしたファイル(以下バンドルファイル)を配信用ストレージ(S3バケット)にアップロードします。要は ASSET_SYNC=true bundle exec rails assets:precompile assets:clean のようなコマンドを実行するジョブです。

ログを詳しく見たところwebpackのバンドルファイルが大量にS3にアップロードされており、それだけで約7分半の時間を浪費していることがわかりました。

f:id:shotaogasawara:20201130181423p:plain
大量のファイルをアップロードするCirclCI

Progateではレッスンの演習画面を始めとする多くの画面でReactを用いて開発していますが、構成も複雑になっておりその分JavaScript(以下JS)や画像ファイルの数も多くなっています。

f:id:shotaogasawara:20201130180946p:plain
nodeコースのレッスン演習画面

またバンドル時にファイル分割を行う都合上、ストレージにアップロードされるファイル総数はおよそ300個にのぼります。

このような背景もあり、アップロード処理に不備や無駄があるとその分大きくデプロイ時間が圧迫されることになるのは容易に想像が付きました。

原因の推測

当初はJSのモジュールバンドラ(webpack)の設定不備を疑い、以下のような仮説を立てました。

  • webpackの生成するファイル名(のハッシュ値)が意図せず変わっているのではないか
  • webpackでは画像のgzipファイルを作成していないためasset_syncが正しく動いていないのではないか

しかし、実際にログの中身を精査して以下の事実を得ました。

  • webpackが生成するファイル名はコードに変更が無い限り変わらない
  • バンドルファイルはJSか画像かに限らずすべて毎回アップロードされる
  • Rails側で管理するassetは変更があったもののみアップロードされる

この時点でwebpackの設定不備に関する仮説を棄却して、asset_syncとの連携部分に問題があることを疑い始めました。

バージョン

これからの話は以下のバージョンを想定して進みます。なお、念の為Railsとwebpackのバージョンも載せておきますが主に参照するのはasset_syncとfog-awsのコードです。

asset_sync → v2.12.1, fog-aws → v3.4.0 (Rails → v5.2.3, webpack → v4.11.1)

当時のwebpackとasset_syncの設定

関係部分に絞っていますが本番環境へのデプロイ用に以下のような設定を入れていました。

【webpack】

// webpack.js
module.exports = {
  output: {
    path: path.resolve(__dirname, 'public/packs'),
  }
}

バンドルしたファイル(以下バンドルファイル)を public/packs 以下に展開する設定です。特に問題はなさそうです。

【asset_sync】

# asset_sync.rb
AssetSync.configure do |config|
  config.add_local_file_paths do
    # Add files to be uploaded
    Dir.chdir(Rails.root.join('public')) do
      Dir[File.join('packs', '**', '**')]
    end
  end
end

バンドルファイルをアップロード対象に含めるために add_local_file_paths を使っています。こちらもasset_syncのREADMEを見ても使い方に問題があるようには見えません。

asset_pipelineのアセットと同様、webpackが生成したバンドルファイルに関しても変更の入ったファイルだけが都度アップロードされることを期待しますが、しかしこの設定ではどうやら不十分のようです。

これ以上はコードを追って詳細を調べるほかなさそうです。

asset_syncのアップロード処理を深堀り

asset_syncのアップロード処理を見ていきます。

今回見たいのはリモートストレージへのアップロード対象を決めるロジックです。コードをたどると、具体的には以下のupload_filesメソッドが今回見るべきメソッドだとわかりました。

open code

def upload_files
  # fixes: https://github.com/rumblelabs/asset_sync/issues/19
  local_files_to_upload = local_files - ignored_files - remote_files + always_upload_files
  local_files_to_upload = (local_files_to_upload + get_non_fingerprinted(local_files_to_upload)).uniq
  # Only files.
  local_files_to_upload = local_files_to_upload.select { |f| File.file? "#{path}/#{f}" }

  if self.config.concurrent_uploads
    jobs = Queue.new
    local_files_to_upload.each { |f| jobs.push(f) }
    jobs.close

    num_threads = [self.config.concurrent_uploads_max_threads, local_files_to_upload.length].min
    # Upload new files
    workers = Array.new(num_threads) do
      Thread.new do
        while f = jobs.pop
          upload_file(f)
        end
      end
    end
    workers.map(&:join)
  else
    # Upload new files
    local_files_to_upload.each do |f|
      upload_file f
    end
  end

  if self.config.cdn_distribution_id && files_to_invalidate.any?
    log "Invalidating Files"
    cdn ||= Fog::CDN.new(self.config.fog_options.except(:region))
    data = cdn.post_invalidation(self.config.cdn_distribution_id, files_to_invalidate)
    log "Invalidation id: #{data.body["Id"]}"
  end

  update_remote_file_list_cache(local_files_to_upload)
end

local_files_to_upload にアップロード対象を入れ、それに対して upload_file メソッドを呼び出すという流れです。したがって重要なのは以下で定義される local_files_to_upload です。

local_files_to_upload = local_files - ignored_files - remote_files + always_upload_files

ここで ignored_filesalways_upload_files は今回の問題に直接関係しないので説明を割愛し、local_filesremote_files に関してそれぞれ中身を調べていきます。

local_files メソッド

local_filesget_local_files メソッドで取得されるものと add_local_file_paths メソッドで追加されるもので構成されます。

def local_files
  @local_files ||=
    (get_local_files + config.additional_local_file_paths).uniq
end

Progateのプロジェクトで実際に local_files を確認したところ、以下のようなファイルパスの配列が返っていました。 (注意)数が多いので一部のみピックアップ。実際のハッシュ値は冗長なので <hash> と表現

["assets/business/top/features_dashboard_image-<hash>.png", ... , "assets/business/top/feature_slide-<hash>.jpg" , "packs/progate_admin-user_page_infos.bundle-<hash>.js", ... , "packs/about-careers_apply.bundle-<hash>.js"]

packsで始まるものは add_local_files で取得したwebpackのバンドルファイルのファイルパスです。

一方、assetsで始まるものは get_local_files で取得したファイルパスです。manifest.yml を利用する場合はmanifest.ymlに記載のあるファイルパスが入り、そうでない場合は publicディレクトリで assets/**/** でglob検索した結果が入ります。

Progateではmanifest.ymlを利用していないため public/assets 以下が検索に引っかかり、これらのファイルパスが入っています。

local_files はアップロード候補となるファイルパスの配列で、アップロード対象に追加したいバンドルファイルも含まれているため期待通りの挙動です。

remote_files メソッド

次にremote_filesですが、これは名前からリモートストレージに存在する既存ファイルを表すものと期待できます。

これをアップロード候補の local_files から引くことで既にリモートに存在するファイルを再度アップロードしないように防いでいると推測できます。

実際にProgateのプロジェクトで remote_files を確認したところ以下のような配列が入っていました。 (注意)数が多いので一部のみピックアップ

["assets/business/top/features_dashboard_image-<hash>.png", ... , "assets/business/top/feature_slide-<hash>.jpg"]

ここで、本来あってほしいpacksで始まるファイルパスが配列に含まれていないことに気づきます。これは期待と異なり違和感があります。

もう少し原因を深堀り

remote_filesbucket.files で取得したオブジェクトから key を取り出して生成した配列を返すメソッドです。名前からしてS3のオブジェクトキーだと推測できますが、もう少し実態を追いかけてみます。

def get_remote_files
  raise BucketNotFound.new("#{self.config.fog_provider} Bucket: #{self.config.fog_directory} not found.") unless bucket
  # fixes: https://github.com/rumblelabs/asset_sync/issues/16
  #        (work-around for https://github.com/fog/fog/issues/596)
  files = []
  bucket.files.each { |f| files << f.key }
  return files
end

bucketは以下で定義されています。

def bucket
  # fixes: https://github.com/rumblelabs/asset_sync/issues/18
  @bucket ||= connection.directories.get(self.config.fog_directory, :prefix => self.config.assets_prefix)
end

ここからは fog-aws の話に入り詳細な話になってしまうので先に結論を言うとここで get メソッドで第2引数に指定した prefix オプションにより、取得されるS3バケットのオブジェクトキーに制限がかかります。

具体的には config.assets_prefix (Progateの場合はRailsのデフォルト値の assets)で始まるオブジェクトキーしか取得されなくなり、それが原因で packs から始まるバンドルファイルのパスが remote_files の検索対象から外れます。S3には local_files_to_upload で得られたファイルパスをキーとしてオブジェクトを保存するため、packs から始まるファイルパスが remote_files に含まれることは永遠にありません。

結果的にasset_sync実行時にリモートに同名のファイルがあったとしても packs から始まるバンドルファイルに関しては毎回アップロードされるようになっていた、というのが真相です。

bucketの中身の深堀り

細かい話に入りますが先程のbucketメソッドの中のconnectionにはFog::Storageクラスのインスタンスが入り、そこからFog::Storage::Directoriesクラスのgetメソッドが呼ばれます。

さらにその中でFog::AWS::Storage::Realクラスの get_bucketメソッドが呼ばれ、その際クエリオプションとしてprefixにbucketメソッドで渡した self.config.assets_prefix が渡されます。

コメントを見るとこのprefixに応じて取得できるオブジェクトキーが制限されることがわかります。

対策方法

対策はいくつかアプローチがありそうですが、一番お手軽で他への影響が少ないのはJSファイルの置き場所を public/packs から public/assets/packs のように頭にassetsが付いた場所に変更する、というものです。具体的には以下のような設定に変えればバンドルファイルに関してもリモートとの比較が正しくなされるようになるはずです。

【webpack】

// webpack.js
module.exports = {
  output: {
    path: path.resolve(__dirname, 'public/assets/packs'),
  }
}

【asset_sync】

# asset_sync.rb
AssetSync.configure do |config|
  # public/assets以下にファイルを出力するとupload対象に含めてくれるためadd_local_file_pathsオプションは不要
  # config.add_local_file_paths do
  #   # Add files to be uploaded
  #   Dir.chdir(Rails.root.join('public')) do
  #     Dir[File.join('packs', '**', '**')]
  #   end
  # end
end

対策方法の懸念点

今回はRails6で標準となったwebpackerでデフォルトに指定されている出力先を参考にして public/assets/packs としていますが、public/assets 以下はRailsのasset_pipelineが管轄するディレクトリのため本来命名には注意が必要です。

また、manifest.ymlを利用する場合は add_local_file_paths の設定が必要になるのでご注意ください。

改善結果

上記の対策を行うことでバンドルファイルのアップロード時間は7分半から数十秒程度まで削減でき、デプロイのストレスを大きく減らすことができました。

また、この他にもassets precompileのキャッシュを利用した高速化も行いprecompileとcleanおよびasset_syncの処理をすべて含めて最速約50秒に抑えることができました。

まとめ

webpack等のアセットをasset_syncでアップロードする際、設定を正しく行うことで不要なアップロード処理をスキップしてデプロイを高速化できることがわかりました。

今後の話

asset_syncは高機能で素晴らしいのですがRails向けに設計されてるためRailsの規約に関する理解が多少なりとも必要になりメンテコストを上げているように感じました。例えばnode.jsで高機能なアップローダがあればそちらに乗り換えを検討したほうが良いかもしれません。もしこれがおすすめ、というものがある方がいればぜひ教えてください。

さて、次回は oguchy さんによる投稿です。お楽しみに!