この記事はなに?
ベーシック Advent Calendar 2016 20日目です。
機会があって100万枚近い画像を3時間弱でアップロードできるアップローダを作成しました。今回はアップローダを作成する際の手法をご紹介したいと思います!
そもそも何故やることになったのか
僕の携わっているサービスが全面リニューアルをおこなっているのですが、要件の中に、MongoDBからMySQLに乗り換えるというものがありました。その際に大量にある画像を下記理由から再アップロードする必要が出てきました。
- MongoDBからMySQLへの変更に伴い、データの持ち方が大きく変わったので、画像パスを再登録する必要があった。
- 画像を保存しているディレクトリ構造がいけてなく、S3上での検索が困難だったので整えたかった。
また、画像のアップロード作業をする際は、サービスを一時停止、また万が一の為にやり直しをするバッファが欲しかったので、あまり時間を掛ける事が出来ませんでした。なので今回は3時間以内を目標にアップローダの開発を行ないました。
前提
単位
今回出てくる単位としてユーザ、マテリアル、画像という単位があります。
ユーザに対してマテリアルが複数紐付き、マテリアルに対して画像が3枚紐付いています。
画像のサイズ
1枚約2MB程度を想定しています。
環境
環境 | バージョン | スペック |
---|---|---|
EC2 | c4.xlarge | |
S3 | ||
Ruby | 2.3.1 | |
Rails | 5.0.0.1 | |
capistrano | 3.4.1 | |
sidekiq | 4.1.2 | |
ElastiCache(Redis) | 3.2.4 | cache.m3.medium |
RDS(MySQL) | 5.6.27 | db.m3.large |
MongoDB | 3.0.8 | |
CarrierWave | 0.10.0 |
今回は上記環境をベースにアップローダを作成しました。
基本の構成
処理の流れ
バッチをスタートさせ、MongoDBに保存してあるパスを元にバケットAから画像を取得します。
※ S3に保存してる画像を取得するとファイル名がかわってしまうので、一度ローカルに保存してからリネーム、再取得をするメソッドを作成しました。(ここはもって良い方法がありそう…)
def open_image
# バケットAのDOMAINを設定
DOMAIN = 'https://baqet-a.s3.amazonaws.com/'
DOMAIN.freeze
# S3に上がっている画像名を取得
image_name = File.basename("#{DOMAIN}hogehoge")
# S3から画像を取得(ファイル名が変わってしまっている)
open_image = open(image_path)
new_image_path = "/tmp/#{image_name}"
# 保存した画像をリネーム
File.rename(open_image, new_image_path)
# リネームした画像を再取得
open(new_image_path)
end
あとは必要なモデルに上記で作成したファイルを保存すれば自動的にCarrierWaveがS3に画像をアップロードしてくれます!(簡単・便利!)
CarrierWaveの詳しい説明に関しては下記が非常に参考になりました!有難うございます!
Carrierwave + Rails 4.2.5 画像アップローダー
結果
ユーザ100人
マテリアル数 455
処理時間: 10分
う、めっちゃ遅い…計算してみると
(1000000枚 / (455 * 3枚) * 10分) / 60分 = 122時間
このままだと5日以上かかってしまいます…
とてもじゃないですが、5日も待てません。そこで、アップロード処理を非同期にすることで、高速化を図りました。
sidekiqを使った非同期処理
上記のままだと余りにも時間がかかってしまうので、次にsidekiqを使った非同期処理を試しました。
sidekiqに関しては下記URLに詳しく書かれています。
sidekiqの使い方
今回はスレッド数を下記のように20にし、ユーザ単位でジョブを生成するようにしました。
:pidfile: ./tmp/pids/sidekiq.pid
:logfile: ./log/sidekiq.log
:concurrency: 20 #スレッド数
:queues:
- [default, 5]
問題① MySQL接続時にエラー発生
sidekiqを使って、非同期処理にしたところ下記のようなエラーがでました。
ActiveRecord::ConnectionTimeoutError - could not obtain a database connection within 5 seconds. The max pool size is currently 5; consider increasing it
どうやら、コネクション出来る最大数が決まっていたようです。
詳しくは下記サイトに書いてありました!有難うございます!
ActiveRecordのconnection_poolで怒られる
今回はsidekiqのスレッド数を20にしたのでpool数は少し多めの30に設定しました。
default: &default
adapter: mysql2
encoding: utf8
pool: 30
socket: /var/lib/mysql/mysql.sock
これで上記のエラーは出なくなりました。
問題② MongoDB接続時にエラー発生
こちらも同様に接続エラーが発生しました。
どうやらスロークエリがあったのが原因だったので、indexを張り高速化することでエラーを解消しました。
これでsidekiqを使った非同期処理の準備が整いました!
結果
ユーザ100人
マテリアル数 455
処理時間: 4分
sidekiqを入れたことで10分から4分に短縮することが出来ました。
(1000000枚 / (455 * 3枚) *4分) / 60分 = 49時間
約2日まで縮める事が出来ました。
ですが、まだまだ遅い!!
そこでsidekiq専用サーバを別に作成して、より非同期処理できる環境を用意することにしました。
sidekiq専用サーバを建てる
次にsidekiq専用サーバを複数建てて、より多くのタスクを非同期で捌けるようにしました。
複数のsidekiqを一つのRedisを監視する構成にすれば、よしなに各sidekiqサーバがタスクを分担してくれたので、ここは特に難しいところもなく、環境を作ることが出来ました。
下記サイトが非常に参考になりました!(有難うございます!)
Resque、SidekiqからSucker PunchまでActiveJobのバックエンドについてひと通り調べてみた
構成
sidekiqサーバを効率良く増やすために
sidekiqサーバを1台づつ作ると非常に時間がかかってしまうので、まずは1台Capstranoでデプロイ出来るまでの環境を作成しました。
あとは、AWSの管理画面からそのサーバのAMIを作成して、ポチポチサーバを複製します。
サーバを複製したら、capistarnoの設定ファイルに先ほど複製したサーバを追加してデプロイすれば準備完了です。
結果
ユーザ1000人
マテリアル数 15800
処理時間: 30分
うーんかなり高速化しましたが
(1000000枚 / (15800 * 3枚) * 26分) / 60分 = 9.1時間
まだ半日近くかかってしまっています…
原因・対策
ユーザ単位でジョブを投げていたので大量のマテリアルが紐付いているユーザを担当することになったsidekiqサーバに処理が集中してしまい、うまく処理を分散することが出来ていませんでした。なので、ユーザ単位ではなくマテリアル単位でジョブを作成する仕様に変更しました。
ジョブの粒度をユーザからマテリアル単位に変更
ジョブの粒度をユーザからマテリアル単位に変更しました。その際に先ほど設定したMySQLのpool数が足りなくなるので、pool数を250に増やしました。
default: &default
adapter: mysql2
encoding: utf8
pool: 250
socket: /var/lib/mysql/mysql.sock
結果
ユーザ1000人
マテリアル数 15800
処理時間: 8分
粒度をユーザからマテリアル単位に変えたことで3倍ほど高速化しました!
これを本番を想定した画像枚数にしてみると
((1000000枚 / 15800 * 3枚) * 8分) / 60分 = 2.7時間!!
これで100万枚近い画像を3時間弱でアップロードすることが出来るようになりました!!
まとめ
- sidekiqを使って非同期処理にする。その際はDBのコネクションエラーに注意する。
- それでも間に合わない場合はsidekiqサーバを複数台用意して更に処理を分散してあげる。
- 効率的に非同期処理をさせるために、なるべく粒度は細かい単位でジョブを作る。
終わりに
無事に1週間かかる処理を3時間に短縮することが出来ました!更に処理を短縮したい場合は、sidekiqのスレッド数・サーバ台数増やせばまだまだ高速化出来そうです!