5日かかる計算だった100万近くある画像のアップロードを3時間弱にした話

# この記事はなに？
[ベーシック Advent Calendar 2016](http://qiita.com/advent-calendar/2016/basicinc) 20日目です。

機会があって100万枚近い画像を3時間弱でアップロードできるアップローダを作成しました。今回はアップローダを作成する際の手法をご紹介したいと思います！

# そもそも何故やることになったのか
僕の携わっているサービスが全面リニューアルをおこなっているのですが、要件の中に、**MongoDBからMySQL**に乗り換えるというものがありました。その際に大量にある画像を**下記理由**から再アップロードする必要が出てきました。

- MongoDBからMySQLへの変更に伴い、データの持ち方が大きく変わったので、画像パスを再登録する必要があった。
- 画像を保存しているディレクトリ構造がいけてなく、S3上での検索が困難だったので整えたかった。

また、画像のアップロード作業をする際は、サービスを一時停止、また万が一の為にやり直しをするバッファが欲しかったので、あまり時間を掛ける事が出来ませんでした。なので今回は**3時間以内**を目標にアップローダの開発を行ないました。
# 前提
## 単位
今回出てくる単位としてユーザ、マテリアル、画像という単位があります。
ユーザに対してマテリアルが複数紐付き、マテリアルに対して画像が3枚紐付いています。
![image3.png](https://qiita-image-store.s3.amazonaws.com/0/49347/dc35b20b-dda9-5d82-ea06-e34881ae9d00.png)

## 画像のサイズ
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|

今回は上記環境をベースにアップローダを作成しました。

# 基本の構成
まずは単純な下記構成で画像のアップロードを試みました。
![image1.png](https://qiita-image-store.s3.amazonaws.com/0/49347/4222110b-8e06-aff2-3f26-7a7ebe384b76.png)

## 処理の流れ
バッチをスタートさせ、MongoDBに保存してあるパスを元にバケットAから画像を取得します。
※ S3に保存してる画像を取得するとファイル名がかわってしまうので、一度ローカルに保存してからリネーム、再取得をするメソッドを作成しました。(ここはもって良い方法がありそう…)

```rb:S3から取得した画像を一旦保存してリネームするメソッド
def open_image
  # バケットAのDOMAINを設定
  DOMAIN = 'https://baqet-a.s3.amazonaws.com/'
  DOMAIN.freeze

  # S３に上がっている画像名を取得
  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 画像アップローダー](http://morizyun.github.io/blog/carrierwave-image-uploader-rails/)


## 結果
ユーザ100人
マテリアル数 455
処理時間: 10分

う、めっちゃ遅い…計算してみると
(1000000枚 / (455 * 3枚) * 10分) / 60分 = 122時間
このままだと5日以上かかってしまいます…

とてもじゃないですが、5日も待てません。そこで、アップロード処理を非同期にすることで、高速化を図りました。

# sidekiqを使った非同期処理
上記のままだと余りにも時間がかかってしまうので、次にsidekiqを使った非同期処理を試しました。
sidekiqに関しては下記URLに詳しく書かれています。
[sidekiqの使い方](http://qiita.com/nysalor/items/94ecd53c2141d1c27d1f)

今回はスレッド数を下記のように**20**にし、ユーザ単位でジョブを生成するようにしました。

```yaml:config/sidekiq.yml
:pidfile: ./tmp/pids/sidekiq.pid
:logfile: ./log/sidekiq.log
:concurrency: 20 #スレッド数
:queues:
  - [default, 5]
```


### 問題① MySQL接続時にエラー発生
sidekiqを使って、非同期処理にしたところ下記のようなエラーがでました。

```bash:error_log
ActiveRecord::ConnectionTimeoutError - could not obtain a database connection within 5 seconds. The max pool size is currently 5; consider increasing it
```
どうやら、コネクション出来る最大数が決まっていたようです。
詳しくは下記サイトに書いてありました！有難うございます！
[ActiveRecordのconnection_poolで怒られる](http://h3poteto.hatenablog.com/entry/2015/03/31/013717)


今回はsidekiqのスレッド数を20にしたのでpool数は少し多めの30に設定しました。

```yaml:database.yml
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時間

約２日まで縮める事が出来ました。
ですが、まだまだ遅い！！
そこでsidekiq専用サーバを別に作成して、より非同期処理できる環境を用意することにしました。

# sidekiq専用サーバを建てる
次にsidekiq専用サーバを複数建てて、より多くのタスクを非同期で捌けるようにしました。
複数のsidekiqを一つのRedisを監視する構成にすれば、よしなに各sidekiqサーバがタスクを分担してくれたので、ここは特に難しいところもなく、環境を作ることが出来ました。
下記サイトが非常に参考になりました！（有難うございます！）
[Resque、SidekiqからSucker PunchまでActiveJobのバックエンドについてひと通り調べてみた](http://blog.chopschips.net/blog/2015/02/26/active-job/)

## 構成
![image2.png](https://qiita-image-store.s3.amazonaws.com/0/49347/805d1441-4e35-ce2d-c9fd-d2ebded90688.png)

## sidekiqサーバを効率良く増やすために
sidekiqサーバを1台づつ作ると非常に時間がかかってしまうので、まずは1台Capstranoでデプロイ出来るまでの環境を作成しました。
あとは、AWSの管理画面からそのサーバのAMIを作成して、ポチポチサーバを複製します。
サーバを複製したら、capistarnoの設定ファイルに先ほど複製したサーバを追加してデプロイすれば準備完了です。


## 結果
ユーザ1000人
マテリアル数 15800
**処理時間: 30分**
うーんかなり高速化しましたが
(1000000枚 / (15800 * 3枚) * 26分) / 60分 = **9.1時間**
まだ半日近くかかってしまっています…

## 原因・対策
ユーザ単位でジョブを投げていたので大量のマテリアルが紐付いているユーザを担当することになったsidekiqサーバに処理が集中してしまい、うまく処理を分散することが出来ていませんでした。なので、ユーザ単位ではなくマテリアル単位でジョブを作成する仕様に変更しました。

# ジョブの粒度をユーザからマテリアル単位に変更
ジョブの粒度をユーザからマテリアル単位に変更しました。その際に先ほど設定したMySQLのpool数が足りなくなるので、pool数を**250**に増やしました。

```yaml:config/database.yml
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サーバを複数台用意して更に処理を分散してあげる。
- 効率的に非同期処理をさせるために、なるべく粒度は細かい単位でジョブを作る。

# 終わりに
無事に１週間かかる処理を3時間に短縮することが出来ました！更に処理を短縮したい場合は、sidekiqのスレッド数・サーバ台数増やせばまだまだ高速化出来そうです！