PHPのループで書くと約30時間かかる大量の画像アップロード処理を、Clojureのcore.asyncで1時間以下に短縮できた話。

1つのVPSで動いている既存システムのAzureへの移行を進めています。

その上で既存サーバーに入っている画像ファイルをBLOBストレージ(Azureのオブジェクトストレージ、AWSでいうS3)に移すという作業が必要になりました。

最初はバックアップから画像ファイルをループでひとつづつアップロードする処理をPHPでシンプルに書きました。

require_once 'vendor/autoload.php';

use WindowsAzure\Common\ServicesBuilder;
use MicrosoftAzure\Storage\Common\ServiceException;

// Create blob REST proxy.
$connectionString = "";
$blobRestProxy = ServicesBuilder::getInstance()->createBlobService($connectionString);

// imagesディレクトリから画像ファイルを取得
$images = getImagesFromDirectory('images');

 foreach($files as $file){
        $blob_name =  $path = "images/".$file;
        try {
            $content = fopen($path, "r");
            $blobRestProxy->createBlockBlob('imgcontainer', $blob_name, $content);
        }catch(Exception $e){
            echo "Exception Occers: ".$path;
            echo "\n";
            $code = $e->getCode();
            $error_message = $e->getMessage();
            echo $code.": ".$error_message."\n";
        }
}

最初は100枚だけで試してみたんですが、100枚をアップロードするだけで50秒ほどの時間がかかりました。 ただ画像ファイルはその時点で約23万枚ほどあり、単純計算でざっくり30時間ぐらいかかるとわかりました。

つらい。。。

30時間はつらい。。。

覚悟を決めて30時間ぶん回ししてもよかったのですが、「もし間違いがあった場合にまた30時間やるの辛い」 、「画像はいまも増えていくので再アップロードも必要になる」という理由から、なんとかもっと速く終わるようにプログラムを変えることにしました。

PHPは基本同期処理のプログラムなので、上記のやり方だとファイルアップロードの待ち時間の間処理が止まってしまいます。 その待ち時間がボトルネックだということは目に見えていたので、複数プロセス、非同期処理に変えてアップロードの待ち時間もバンバン他のファイルをアップロードするように変えれば確実に速くなります。

PHPでそのような処理を書く方法もあると思いますが、Clojureという言語のcore.asyncを使うと簡単に実装できるということを知っていたのでClojureで実装し直すことにしました。

core.asyncとは、Clojureで並行プログラミングをするときによく使われるライブラリです。 協調スレッドという軽量なスレッド(ネイティブスレッドではない)を作り、channel経由でスレッド間の通信を安全に行い非同期処理を行うプログラムを書くことができます。

golangを使ったことがある人には、goroutineと同じようなものだと考えるとわかりやすいと思います。

今回の画像アップロード処理をClojureのcore.asyncで書き直すと以下のようになりました。

(ns azure-storage-clj.core
  (:require [clojure.java.io :as io]
            [clojure.core.async :as as
             :refer [>! <! >!! <!! go chan buffer go-loop
                     close! thread alts! alts!! timeout]]
            [clojure.tools.logging :as log])
  (:import [com.microsoft.azure.storage CloudStorageAccount]))

(def connection-str "")

(def storage-account (CloudStorageAccount/parse connection-str))

(def blob-client (.createCloudBlobClient storage-account))

(def blob-container (.getContainerReference blob-client "imgcontainer"))

// 画像を置いてあるディレクトリ以下からファイルだけを取得する
(def filelist (filter #(.isFile %)
                      (file-seq (io/file "resources/images"))))

(defn image-upload [file]
  (let [f-source file
        filename (.getName file)
        blob (.getBlockBlobReference blob-container (str "images" "/" filename))]
    (.upload blob
             (new java.io.FileInputStream f-source)
             (.length f-source))))

(defn files-upload []
  (let [file-chan (chan 100) ;; バッファ100のチャネルをつくる] 
    ;; チャネルからファイルをとり、アップロードする協調スレッドを100個つくる
    (doseq [n (range 100)]
      (go-loop []
        (let [f (<! file-chan)]
          (do
            (log/debug "image-upload : " f) // ログ出力
            (image-upload f))) ;; 画像アップロード
        (recur)))
    ;; 画像ファイルのリストをひたすらチャネルに入れていく
    (for [f filelist]
      (>!! file-chan f))))

処理は以下のようなイメージです。

f:id:arakaji-yuu:20170524091829p:plain

この実装を朝の4時くらい起きて実装し、100枚、500枚と試してみて大分速くなることは確認できたので、 一気に全ファイル対象にして実行して二度寝へと向かいました(それが5時ごろ)。

7時くらいに目が覚めてPCの様子を伺うと、なんともう完了しているではありませんか。。。

ログを見ると約1時間程度で終わっていたようです。

感無量。。。。

Clojureを使うことで並行処理を簡単に実現できて、結果タスクにかかる時間も短縮できました。 やはりいろんなパラダイムの言語を知っておくとその時々の問題にあった手段を選べるのでいいですね。