Webpackのビルド時間を短くするための取り組み

メリークリスマス。freeeでエンジニアをやってます @yo_waka です。 この記事は freee Developers Advent Calendar 2017 の25日目です。

Webpack でビルドしてますか?僕は今日もビルドしています。
弊社ではフロントエンドのビルドに Webpack を用いているのですが、サービスの規模が大きくなるとともにビルド時間が長くなってきて困ってきました。

会計freee というサービスのフロントエンドの規模的にはこのような感じです。

  • JSコード行数: 275421行
  • Webpack エントリポイント数: 108

そこそこですね。 煩悩の数だけJSのエントリポイントがあります。
エントリポイントが多い理由は、元々は Rails の標準である Sprockets のみでビルドしていたものを少しずつ移行していったためです。
元々SPAで作られているアプリケーションではないのと、機能依存の処理もかなり大きいため1ファイルにするデメリットも多いため、機能の数だけエントリポイントが存在しています。

こちらが現在のビルド時間です。 f:id:yo_waka:20171225110354p:plain

以前から HappyPack という並列実行ライブラリを使っていました。
HappyPack は loader ごとに並列で実行してくれるのですが、前述の通り babel-loader のトランスパイル対象のJSファイルが多いこと、処理するエントリポイントが多いことが問題になってきたので効果が少なくなっていました。 (多量の loader を使っている環境では高速化の恩恵にあずかれるかもしれません)

どうにかならないかなと Webpack のドキュメントを見ていたら、thread-loaderとcache-loaderを使ってみるとよいとの記述を見つけて試してみたのがこちらの記事になります。

webpack loader の仕組み

Webpack は Loader API という仕組みを提供しており、これに則って loader は作られています。
基本的に loader はコールバックを実行する関数を提供するのみです。

以下は全てのファイルの先頭に「Powered by freee」というコメントを追加するだけの単純な loader です。
content にはビルド対象となるファイルコンテンツが渡ってきます。

module.exports = function(content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
}

function someAsyncOperation(content, callback) {
  var comment = '/** Powered by freee. */¥n';
  callback(null, comment + content);
}

この loader を loaders ディレクトリに置いたとして、webpack.config.js の resolveLoader に指定してあげれば使えるようになります。

module.exports = {
  resolveLoader: {
    modules: [
      'node_modules',
      path.resolve(__dirname, 'loaders')
    ]
  }
}

変わった仕組みとして、Pitching loader という仕組みがあります。
これは「pitch」という関数を loader に生やすと、loader の実行前にpitch関数のみがloaderとは逆の順番に実行されるというものです。
通常はuseディレクティブに指定された loader は最後から最初に向かって実行されますが、pitch関数は最初から最後に向かって実行されます。
また、pitch関数で return されると、以降のpitch関数のチェーンおよび loader の実行がスキップされます。

以下は公式ドキュメントにも記載されている処理フローです。

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

pitch関数を実装することでこんな感じにできます。

|- a-loader `pitch`
  |- b-loader `pitch` returns a module
|- a-loader normal execution

以下の loader の処理をスキップしつつ独自にloaderを実行するようなインターセプタを作ることが可能です。
thread-loader はこれを利用して並列に動くワーカにloaderの処理を実行させて高速化を図っています。

thread-loaderの内部実装

以上を踏まえて、thread-loader の実装を見て行きます。

import { getPool } from './workerPools';

function pitch() {
  const options = loaderUtils.getOptions(this) || {};
  const workerPool = getPool(options);
  const callback = this.async();
  workerPool.run({
    loaders: this.loaders.slice(this.loaderIndex + 1).map((l) => {
      return {
        loader: l.path,
        options: l.options,
        ident: l.ident,
      };
    }),

    /** 中略 */
  }, (err, r) => {
    if (r) {
      r.fileDependencies.forEach(d => this.addDependency(d));
      r.contextDependencies.forEach(d => this.addContextDependency(d));
    }
    if (err) {
      callback(err);
      return;
    }
    callback(null, ...r.result);
  });
}

function warmup(options, requires) {
  const workerPool = getPool(options);
  workerPool.warmup(requires);
}

export { pitch, warmup };

pitch関数が実装されていますね。
worker に渡ってくる this.loaders というのは webpack.config.js の useディレクティブで指定された loader 配列です。
各 loader に渡されたオプションをworkerに渡し直しています。

warmup という関数をビルド前に呼ぶことでworkerプロセスを事前に起動することができるようです。
コードを見ると、worker が作られる際に child_process#spawn が呼ばれ、pipe で入出力を繋げられるようになっていました。
README にも書かれていますが、子プロセスの起動オーバーヘッドは数百msになるため、warmup を使った方がよさそうです。

class PoolWorker {
  constructor(options, onJobDone) {
    /** 中略 */

    this.worker = childProcess.spawn(process.execPath, [].concat(options.nodeArgs || []).concat(workerPath, options.parallelJobs), {
      stdio: ['ignore', 1, 2, 'pipe', 'pipe'],
    });
    const [, , , readPipe, writePipe] = this.worker.stdio;
    this.readPipe = readPipe;
    this.writePipe = writePipe;
    this.readNextMessage();
  }

worker ではオプションで渡される parallelJobs(デフォルトは200)の数に従って、useディレクティブに指定された loader (thread-loader 以降に指定されたもの)の処理を並列実行します。 (内部では asyncパッケージの 'async/queue' が利用されています)

cache-loaderの内部実装

cache-loader は1ファイルのシンプルな実装になっています。
ここでもpitch関数が実装されていて、キャッシュヒットすると以降の処理をスキップしてキャッシュされた結果を返す、ここでキャッシュヒットしなかった場合は通常フローで loader 関数が実行され、結果をキャッシュ保存する、というようになっています。

function pitch(remainingRequest, prevRequest, dataInput) {
  /** 中略 */

  const callback = this.async();
  readFn(data.cacheKey, (readErr, cacheData) => {
    if (readErr) {
      callback();
      return;
    }
    if (cacheData.remainingRequest !== remainingRequest) {
      // in case of a hash conflict
      callback();
      return;
    }
    async.each(cacheData.dependencies.concat(cacheData.contextDependencies), (dep, eachCallback) => {
      fs.stat(dep.path, (statErr, stats) => {
        if (statErr) {
          eachCallback(statErr);
          return;
        }
        if (stats.mtime.getTime() !== dep.mtime) {
          eachCallback(true);
          return;
        }
        eachCallback();
      });
    }, (err) => {
      if (err) {
        data.startTime = Date.now();
        callback();
        return;
      }
      cacheData.dependencies.forEach(dep => this.addDependency(dep.path));
      cacheData.contextDependencies.forEach(dep => this.addContextDependency(dep.path));
      callback(null, ...cacheData.result);
    });
  });
}

pitch関数が何をやっているのかを理解すると、loader の実装が追えるようになって楽しいですね!
全然話は変わりますが loader は asyncパッケージがふんだんに使われていてそっちのAPIを見るのに時間がかかってしまいました。。。

結果

thread-loader はファイル単位で並列loader実行してくれること、cache-loader はそれをキャッシュして2回目以降は loader 処理をスキップしてくれることから、freee が抱えているビルドの問題を解消してくれると思われ、導入していく気持ちが高まりました。

というわけで結果です。 f:id:yo_waka:20171225123921p:plain

2回目以降のビルドについて58%ほど高速化されました。

webpack.config.js はこのようにしました。

const threadLoader = require('thread-loader');

const jsWorkerOptions = {
  workers: require('os').cpus().length - 1,
  workerParallelJobs: 50,
  poolTimeout: 2000,
  poolParallelJobs: 50,
  name: 'js-pool'
};
threadLoader.warmup(jsWorkerOptions, [ 'babel-loader', 'babel-preset-es2015', 'eslint-loader' ]);

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          { loader: 'cache-loader' },
          { loader: 'thread-loader', options: jsWorkerOptions },
          { loader: 'babel-loader?cacheDirectory=true' },
          { loader: 'eslint-loader' }
        ],
        exclude: [ /third_party/, /node_modules/, /jst/ ]
      },
  ...
}

webpack-dev-server と組み合わせると、それなりに早くはなったものの、まだ快適かといわれるとギャップはあります。 cache-loader について、ファイルごとのキャッシュになっているため、エントリポイント単位のキャッシュにできないか試してみたところ、今度はI/Oがボトルネックになってしまい、あまり早くならず。
この辺りは実装を工夫すれば解決できそうな気はするので、もう少しやってみて公開したいと思っています。

まとめ1

Webpack は難しい。

まとめ2

freee のサービスは会計、人事労務並びに請求や支払いなど複数の業務ドメインが抱える課題を解決することを目指しています。
誰でも使えるUIを目指すと、可能な項目や作業が増えていくので、それをシンプルにするために複雑なUI実装が必要になり、結果としてフロントエンドがボリューミーになっていきます。
よって、JSのビルド時間は何もしないと増えていくので、ビルド周りの改善は都度都度行なっています。

大規模なフロントエンドを快適に実装するための基盤を作りつつ、ユーザーさんに価値を届けたい方、ぜひ一緒にやりましょう。

jobs.freee.co.jp

@ymrl からスタートした freee Developers Advent Calendar 2017 ですが、今年はこれでおしまいです。
来年もまた、freee の開発チームが取り組んだ新しいチャレンジについてお伝えできればと思います。

CTOが1ヶ月間の育休をとってみた話

こんにちは、freee CTOの横路(@yokoji)です。

この記事は freee Developers Advent Calendar 2017 の24日目です。

私事ながらこの11月にはじめてのこどもが生まれまして、この1ヶ月間は育休をとっているので、今回はまさにいま育休をとって感じていることを書こうと思います。

f:id:yokoji:20171223152032j:plain

育休について

日本では育児介護休業法が定められていて、企業で働く両親が協力して育児休業を取得できる仕組みのガイドラインがあります。

政府主導で男性の育児参加への取り組みも進められており、育休取得率は徐々に上がってきていますが、それでも直近で3.16%と、2020年までに13%という目標にはまだまだ届きそうにないのが現状のようです。

(ちなみに、フィンランドでは男性の育休取得率が8割を超えているそうです)

なぜ育休をとろうと思ったのか

ある調査によると、育休を取らない理由の上位に「職場に育休を取りづらい雰囲気がある」というのがありますが、freeeでは育休を取得することがもはや普通になっていて、社長をはじめ、パパママともに多くのメンバーが育休をとっているという環境があります。

freeeにはすでに育休をとりやすい雰囲気があるという前提ではありますが、わたしの場合、育休をとる決め手となったのは

  • 妻が働いていてすぐに復職予定のため、今後の生活基盤を早めに構築して慣れておきたかったこと
  • こどもの成長は最初の1ヶ月がとんでもなく早いと聞いていたので、その時期をぜひ一緒に過ごしておきたいと思ったこと

ということでした。また、妻からは

  • 育児の協力体制づくりが遅れて産後クライシスが起こることを避けたい

というコメントもありました。

1ヶ月間も会社を休んだことがない自分にとって、いない間の仕事についてや復帰後のキャッチアップコストの心配など、たしかに慣れないことではありましたが、仕事の棚卸のよいタイミングだと思い、はじめは1, 2週間くらいと思っていたところを、あえて1ヶ月間の育休をとることにしました。

育休中の仕事はどうしていたのか

育休前は、育児と並行して少し仕事もしようと思っていましたが、実際は最低限必要なミーティングだけハングアウトで参加し、その他はほとんど仕事をしませんでした。 仕事をしなくても安心して過ごせたのは、頼もしいメンバーたちに支えられていたおかげです。

f:id:yokoji:20171223143515p:plain

また、育休中は大量インプット期間にしようと思い読みたい本リストを作っていましたが、育児をしていると意外とまとまった時間はなくて、半分も消化できませんでした。

個人的には、最初から育児も仕事も全力で両立!というよりは、期間を決めていちど育児にとにかくフォーカスしてみたほうが中長期的には効率がいいかもと思い始めています。

現代においてもお産にはリスクがつきものということを実感したのもありますし、産後の妻は心身ともに想像以上に衰弱しているので、育休中は割り切って母子のサポートを第一に考えるのがよいという感覚です。

育休をとってみてどうだったか

慣れないことだらけで、アクシデントだらけの1ヶ月でした。妻が産後2週間で突然大量出血して生死をさまよい(今でも1万人に1人くらいは出産で死のリスクに直面するようです)1週間入院したり、どんな状況でもこどもは2, 3時間おきにミルクをねだるし、おむつを替えた瞬間におしっこうんちするし、なんで泣いてるのか分からないしで、夫婦でてんやわんやして様々なトラブルを踏み抜きましたが、おかげさまで育児への理解と仕事との両立の自信がつきました。1ヶ月を終えてみて、個人的に育休のなかでやってよかったことを振り返ってみます。

いちど一人で全部やってみること

はじめは意図していませんでしたが、出産から2週間後に妻が救急車で運ばれ1週間入院したため、現場責任者として育児その他全てを取り仕切る機会がありました。 ひととおり一人で育児をやりきることで、育児にオーナーシップと自信がつき、妻からの信頼も得られたように感じています。

CEOの佐々木が実践していた、夫婦で時期をずらして育休をとるというのもよさそうです。

f:id:yokoji:20171223152113j:plain

育休中は、仕事を忘れて育児にフォーカスすること

育児も仕事も全力で!みたいなスーパーマンをはじめから目指すのは、万人にはハードルが高いと実感しています。

ライフイベントに集中する時期があって、仕事に集中する時期があって、というのも全然ありだと思っていて、わたしの場合は期間を決めて 集中してひととおりの育児経験をすることで、自信がついて仕事とうまく両立していくイメージがわきました。

育児生活の中でやらないことを決めること

育児には生活の非効率が正当化されてしまう魔力があると感じました。圧倒的にかわいいので、「子供のためなら」とつい全力で育児や家事などの世話をしたくなってしまいますが、今後の仕事との両立を考えると全てに手間暇をかけるには時間がなさすぎます。育休中に、「本当に手をかけたいところはどこか」を夫婦で決めて、それ以外は文明の利器や制度をつかってうまく仕組み化していくとよさそうです。

具体例としては、

  • 食事は無理して全部つくらない。Uber Eats、区の家事補助や調理代行、ベビーシッターなどを使うことを躊躇わない
  • 洗濯は全自動洗濯乾燥機に任せて、衣類の痛みは気にしない
  • 掃除はルンバに任せて、細かいところは大掃除にまとめてやる。水回りは定期的にアウトソースする
  • 母乳だけにこだわらず、ミルクも使って授乳の負担をフレキシブルに分担できるようにする

といったことです。freeeではキッズラインと提携していて、会社がベビーシッターにかかるお金の半額を補助しているので、活用しない手はありません。

また、スマートスピーカーの真価は「ながら」作業であることを育児中に実感しました。こどもを抱っこしたままタイマー・アラームを設定できたり、テレビのON/OFFや音楽の再生が出来たり、最高でした。

復帰後の働きかた

仕事の内容を大きく変えることはしませんが、こどもをお風呂に入れたいので、基本的には早めに出社して18時すぎには時間厳守で帰る予定です。

freeeでは、家族の状況にあわせて出退勤の時間を調整することはすでにみんな普通にやっていることなので、特に違和感はありません。

まとめ

今回1ヶ月間の育休を取得したことで、自分の仕事の棚卸のよいきっかけにもなり、夫婦の信頼関係も含めた今後の生活基盤を早めに構築できたことがよかったです。自分に合った生活リズムを早めに見つけるために、みなさんもぜひ育休とってみてください。

育休をとるならおすすめしたいのは、

  • 育休中の仕事は他メンバーに任せて、母子サポートを最優先すること
  • 育休中に、ひとりで一通りの育児を全部経験する機会をもつこと
  • 手をかけたいところを明確にして、それ以外はテクノロジーや制度を活用して仕組み化していくこと

ということでした。

その他、freeeでは社内SNS上で育児ノウハウを交換できる部屋や、家族連れの定期的なリアル懇親イベントがあり、先輩パパ・ママにいつでも相談するのもとても心強いです。

f:id:yokoji:20171224133743j:plain

そして、自分の子はとてもかわいいです。自分の子なら泣いていてもかわいいですし、見ていて退屈しません。育休中はぜひこどもとの時間を楽しんでください!

f:id:yokoji:20171223151933j:plain

さて、明日はいよいよ最終日です。先輩パパ兼開発本部長の@yo_wakaが、アドベントカレンダードリブンでコードを書くぞ!と宣言していたので、とても楽しみです。

TensorFlow Servingで機械学習モデルをプロダクション環境で運用する

こんにちは、freee株式会社でエンジニアをやっている米川(@yonekawa)です。最先端のテクノロジーを使って新しいソリューションを生み出していくことをミッションにした、CTW (Change The World) という役職で働いています。

この記事はfreee Developers Advent Calendar 2017の23日目です。

機械学習ではアルゴリズムや大規模データ処理が注目されがちですが、学習したモデルをどうやってサービスで運用するかも悩ましい問題です。実験やアルゴリズムの検証では強力なツールが揃っているPythonがよく使われるので、そのままPythonでAPI作るケースが多いと思います。しかしプロダクション環境で運用するとなると開発しやすさ以外にも、大量リクエスト時のパフォーマンスやデプロイ、モデルの精度評価やA/Bテストなどさまざまな課題があります。

またfreeeでは、WebサービスはRuby on Rails、バックエンドサーバーはGo + gRPCという構成でマイクロサービス基盤が構築されているため、できればこれらの資産を流用して開発することで生産性や品質を高めたい気持ちがありました。

こういった背景があって、より良い機械学習プロダクトのプロダクション運用ができる方法が無いか考えてみることにしました。 そして、いくつかの選択肢を検討する中でTensorFlow Servingが良さそうだったのでご紹介します。

f:id:yonekawa:20171222150905p:plain
Tensorflow Servingによる機械学習モデルの運用イメージ

TensorFlow Servingとは

TensorFlow ServingはTensorFlowで構築した機械学習モデルをプロダクション環境で運用することを目的に設計されたモジュールです。 以下のような特徴があります。

  • C++で書かれた堅牢で安定した高パフォーマンスなRPCサーバー
  • gRPCを使って機械学習モデルによる推論を呼び出せるインタフェース
  • 新しいモデルを読み込んだり複数のモデルを並行運用したりが簡単にできる
  • モデルの読み込みなど一部の実装がプラガブルになっていて用途に応じて書き換えられる

このように、冒頭で触れた機械学習モデルの運用における課題を解決する機能が一通り揃っていることがわかります。 gRPCで推論リクエストを送れることで言語を問わないところと、モデルのバージョン管理や並行運用がしやすいところがメリットになると思います。 そしてGoogleでも実際に使われていることからパフォーマンスや安定性もある程度は期待できます。(まだベンチマークは取っていないのですが、Googleによると推論の時間やネットワークを除いて100,000クエリ/秒くらいは捌けるらしいです)

TensorFlow Servingを使った機械学習モデルの配信は以下のような流れになります。

  1. 学習済みモデルをエクスポートしてモデルサーバーを起動する
  2. クライアントからgRPCでモデルサーバーに推論リクエストを送る
  3. モデルのバージョンを追加してモデルサーバーに反映する

順を追って解説していきます。

準備: モデルサーバーのインストール

モデルサーバーはソースからビルドすることもできますが、apt-getで簡単にインストールすることができます。

$ echo "deb [arch=amd64] http://storage.googleapis.com/tensorflow-serving-apt stable tensorflow-model-server tensorflow-model-server-universal" | sudo tee /etc/apt/sources.list.d/tensorflow-serving.list
$ curl https://storage.googleapis.com/tensorflow-serving-apt/tensorflow-serving.release.pub.gpg | sudo apt-key add -
$ sudo apt-get update && sudo apt-get install tensorflow-model-server

こちらのDockerfileを使うと最小構成でtensorflow-model-serverが動作する環境を準備できます。

$ docker build --pull -t tensorflow-model-server -f Dockerfile .

1. 学習済みモデルをエクスポートしてモデルサーバーを起動する

モデルサーバーで読み込むための学習済みモデルを、SavedModelとしてローカルファイルにエクスポートします。例えばこういうMNISTのモデルがあったとします。

import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data

sess = tf.InteractiveSession()

images = tf.placeholder('float', shape=[None, 28, 28, 1], name='images')
x = tf.reshape(images, [-1, 784])
y_ = tf.placeholder('float', shape=[None, 10])
w = tf.Variable(tf.zeros([784, 10]))
b = tf.Variable(tf.zeros([10]))

sess.run(tf.global_variables_initializer())
scores = tf.nn.softmax(tf.matmul(x, w) + b, name='scores')
cross_entropy = -tf.reduce_sum(y_ * tf.log(scores))
train_step = tf.train.GradientDescentOptimizer(0.01).minimize(cross_entropy)

mnist = input_data.read_data_sets('MNIST_data', one_hot=True)
for _ in range(FLAGS.training_iteration):
    batch = mnist.train.next_batch(50)
    train_step.run(feed_dict={x: batch[0], y_: batch[1]})

この学習済みのセッションをtensorflow.saved_model.builder.SavedModelBuilderを使ってSavedModelに変換します。

prediction_signature = (
    tf.saved_model.signature_def_utils.build_signature_def(
        inputs={'images': tf.saved_model.utils.build_tensor_info(images)},
        outputs={'scores': tf.saved_model.utils.build_tensor_info(scores)},
        method_name=tf.saved_model.signature_constants.PREDICT_METHOD_NAME))

version = 1
export_path = 'models/mnist/{}'.format(str(version))
builder = tf.saved_model.builder.SavedModelBuilder(export_path)
builder.add_meta_graph_and_variables(
    sess, [tf.saved_model.tag_constants.SERVING],
    signature_def_map={
        'predict_images': prediction_signature
    },
    legacy_init_op=tf.group(tf.tables_initializer(), name='legacy_init_op'))

builder.save(as_text=False)

コードの全体はこちらです。モデルが出力できたらモデルサーバーを起動します。起動時のオプションでモデルの名前と配信するディレクトリを指定します。

$ ls models/mnist
1
$ tensorflow_model_server --port=9000 --model_name=mnist --model_base_path=models/mnist

Kerasを使っている場合

TensorFlow 1.4においてTensorFlowがKerasを統合し、KerasのモデルをEstimatorに変換する機能が追加されました。Estimatorはexport_savedmodelでSavedModelとして保存できるので、KarasでもTensorFlow Servingによるモデル配信を活用できます。

import tensorflow as tf
from tensorflow.python import keras
from tensorflow.python.estimator.export import export

model = keras.applications.vgg16.VGG16(weights='imagenet')
model.compile(optimizer=keras.optimizers.SGD(lr=.01, momentum=.9),
              loss='binary_crossentropy',
              metrics=['accuracy'])
estimator = tf.keras.estimator.model_to_estimator(keras_model=model)
feature_spec = {'input_1': model.input}
serving_input_fn = export.build_raw_serving_input_receiver_fn(feature_spec)
estimator.export_savedmodel(export_path_base, serving_input_fn)

しかし、こういったバグがあったようで記事を書いている時点で最新のTensorFlowではまだうまく動作しませんでした。 上記Pull Requestの内容をパッチで当てるとうまく動作することは確認できたのでアップデートが待たれるところです。

2. gRPCを使ってモデルサーバーに推論リクエストを送る

TensorFlow ServingはgRPCでエンドポイントを提供しています。Protocol Bufferのインタフェースに従えばGoやRailsから推論を利用できます(もちろん画像データを数値化するなどの前処理が必要になったりはするのですが...)

TensorFlowのリポジトリに配置されているProtocol Bufferのファイルからクライアントコードを自動生成できます。 例えばGoのクライアントコードを生成するには以下のようにします。

$ git clone --recursive https://github.com/tensorflow/serving.git
$ protoc -I=serving -I serving/tensorflow --go_out=plugins=grpc:$GOPATH/src serving/tensorflow_serving/apis/*.proto
$ protoc -I=serving/tensorflow --go_out=plugins=grpc:$GOPATH/src serving/tensorflow/tensorflow/core/framework/*.proto
$ protoc -I=serving/tensorflow --go_out=plugins=grpc:$GOPATH/src serving/tensorflow/tensorflow/core/protobuf/{saver,meta_graph}.proto
$ protoc -I=serving/tensorflow --go_out=plugins=grpc:$GOPATH/src serving/tensorflow/tensorflow/core/example/*.proto

$GOPATH/src以下にtensorflowとtensorflow_serving用の自動生成コードが出力されます。 以下のようなコードでgRPCでPredictionServiceを呼び出すことで、モデルサーバーにリクエストを送ることができます。

import (
    protobuf "github.com/golang/protobuf/ptypes/wrappers"
    pb "tensorflow_serving/apis"
    "google.golang.org/grpc"
)

img, err := os.Open("path/to/image")
if err != nil {
    panic(err)
}
defer img.Close()
p, err := png.Decode(img)
if err != nil {
    panic(err)
}
inputTensorValues := make([]float32, 28*28)
for i := 0; i < 28; i++ {
    for j := 0; j < 28; j++ {
        r, _, _, _ := p.At(i, j).RGBA()
        inputTensorValues[i+(j*28)] = float32(r) / 255
    }
}

request := &pb.PredictRequest{
    ModelSpec: &pb.ModelSpec{
        Name:          "mnist",
        SignatureName: "predict_images",
    },
    Inputs: map[string]*tfcoreframework.TensorProto{
        "images": &tfcoreframework.TensorProto{
            Dtype: tfcoreframework.DataType_DT_FLOAT,
            TensorShape: &tfcoreframework.TensorShapeProto{
                Dim: []*tfcoreframework.TensorShapeProto_Dim{
                    &tfcoreframework.TensorShapeProto_Dim{
                        Size: int64(1),
                    },
                    &tfcoreframework.TensorShapeProto_Dim{
                        Size: int64(28),
                    },
                    &tfcoreframework.TensorShapeProto_Dim{
                        Size: int64(28),
                    },
                    &tfcoreframework.TensorShapeProto_Dim{
                        Size: int64(1),
                    },
                },
            },
            FloatVal: inputTensorValues,
        },
    },
}
conn, err := grpc.Dial(*servingAddress, grpc.WithInsecure())
if err != nil {
    panic(err)
}
defer conn.Close()

client := pb.NewPredictionServiceClient(conn)
resp, err := client.Predict(context.Background(), request)
if err != nil {
    panic(err)
}

for i, s := range resp.Outputs["scores"].FloatVal {
    if s == 1.0 {
        fmt.Print(i)
        break
    }
}

コードの全体はこちらです。注意点として上記コードにある通り、TensorShapeProtoは1次元の値しか取れなくなっているため、入力が多次元の場合には1次元に変換する必要があります。本来の構造の情報はDimで指定することでPredictionServiceが正しい形式で取り扱ってくれます。

実際に実行してみると以下のような結果が返ってくると思います(テスト用にmyleott/mnist_pngを使わせていただきました)。ちゃんと推論が動いていますね!

$ go run serving_client/mnist/client.go --serving-address localhost:9000 mnist_png/testing/2/1174.png
2

3. モデルのバージョンを追加してモデルサーバーに反映する

データの増加やアルゴリズムの見直しによって精度が向上した最新の学習モデルに更新したい、というユースケースはよくあります。 TensorFlow Servingのモデルサーバーはローカルファイルシステムを監視しており、新しいモデルが配置されたら自動で読み込んでくれます。 なので --model_base_path に指定されたディレクトリにバージョン番号のディレクトリを作ってモデルを配置するだけで、自動で新しいモデルを使えるようになります。

例として以下のように、学習回数を極端に少なくしたバージョン1を作ってみます。

$ python export/tensorflow_mnist --model_version=1 --training_iteration=1 /tmp/models/mnist
$ ls /tmp/modesl/mnist
1
$ tensorflow_model_server --port=9000 --model_name=mnist --model_base_path=models/mnist
$ go run serving_client/mnist/client.go --serving-address localhost:9000 mnist_png/testing/2/1174.png
8

学習が足りていないため精度が悪く間違った推測をしています。では次に1000回学習させたモデルをバージョン2として配置してみます。サーバーを再起動する必要はありません。

$ python export/tensorflow_mnist --model_version=2 --training_iteration=1000 /tmp/models/mnist
$ ls /tmp/modesl/mnist
1  2
$ go run serving_client/mnist/client.go --serving-address localhost:9000 mnist_png/testing/2/1174.png
2

このようにモデルを配置するだけで起動中のモデルサーバーが自動で最新のモデルを読み込んでくれました。

モデルのバージョンポリシーを変更する

モデルサーバーはデフォルトだと指定されたディレクトリから最新のモデルだけを読み込むため、新しいモデルを配置すると古いモデルは配信されなくなります。そうではなく新旧2つのモデルを並行運用したいとか、入力の仕様を変えた2つのモデルをエンドポイントを変えて運用したいなどの時は、モデルのバージョンポリシーを変更することで対応できます。

バージョンポリシーには以下の3つがあります。

  • Latest: 常に最新のモデルを配信する(デフォルト)
  • All: すべてのモデルを配信する
  • Specific: 特定のバージョンのモデルのみ配信する

モデルサーバーのバージョンポリシーを変更するには設定ファイルを作り、--model_config_fileで読み込む必要があります。 例えばディレクトリ以下にある全てのモデルを配信し続けたい場合は以下のような設定ファイルを作ります(中身はテキスト形式のProtocol Bufferです)。

model_config_list: {
  config: {
    name: "mnist",
    base_path: "/tmp/models/mnist",
    model_platform: "tensorflow",
    model_version_policy: { all: {} },
  },
}
$ ls /tmp/models/mnist
1  2
$ tensorflow_model_server --port=9000 --model_config_file=./misc/model.conf

PredictRequestModelSpecVersionを指定することで、任意のバージョンを指定して呼び出すことができます。

import (
    protobuf "github.com/golang/protobuf/ptypes/wrappers"
    pb "tensorflow_serving/apis"
)

request := &pb.PredictRequest{
    ModelSpec: &pb.ModelSpec{
        Name:          "mnist",
        SignatureName: "predict_images",
        Version: &protobuf.Int64Value{ Value: int64(1) },
    },
    Inputs: map[string]*tfcoreframework.TensorProto{
        ....
    },
}

この設定ファイルは入力の仕様を変えた2つのモデルをエンドポイントを変えて運用したい場合にも使えます。configフィールドは繰り返し指定ができるので、名前を変えて2つ定義すれば別のモデルとして呼び出せます。

model_config_list: {
  config: {
    name: "mnist",
    base_path: "/tmp/models/mnist",
    model_platform: "tensorflow",
  },
  config: {
    name: "mnist2",
    base_path: "/tmp/models/mnist2",
    model_platform: "tensorflow",
  },
}

ModelSpecNameを指定すると、呼び出すモデルをリクエスト側で変更できます。

import (
    protobuf "github.com/golang/protobuf/ptypes/wrappers"
    pb "tensorflow_serving/apis"
)

request := &pb.PredictRequest{
    ModelSpec: &pb.ModelSpec{
        Name:          "mnist2",
        SignatureName: "predict_images"
    },
    Inputs: map[string]*tfcoreframework.TensorProto{
        ....
    },
}

このように3つのバージョンポリシーと設定ファイルを駆使すれば、プロダクション環境における学習モデルの更新やデプロイのユースケースの多くは網羅できるのではないかと思います。

まとめ

今回使ったサンプルコードなどはすべてこちらのリポジトリに置いてありますのでご自由にお使いください。

TensorFlow Servingが嬉しいのは、開発や検証には便利なライブラリを活用できるPythonを使い、プロダクション環境ではパフォーマンスや運用コストを考慮してシステム設計するというフローが手軽に実現できるところです。gRPCで通信できるのでマイクロサービスの構成要素として自然に組み込めますし、もちろんKubernetesに載せることも可能です。学習モデルの更新は要件に依存しますが、バージョンポリシーによってさまざまなユースケースに対応できます。分類や回帰などの一般的なタスクの範囲を超えるモデルの場合は(C++を書くことで)プラガブルにカスタマイズすることも可能です。

もっと楽をしたい場合はフルマネージドなクラウドサービスを検討してもいいと思います。 Azure ML StudioはGUIで簡単に学習処理を記述できますし、Amazon SageMakerならJupyter Notebookで学習処理を書いたモデルをシームレスにプロダクションに公開することができます。(GCPは詳しくないですがCloud Machine Learningというのがあるようです)

機械学習の技術は日進月歩で進化していますが、それをプロダクトに活用するエンジニアリングはまだまだ試行錯誤が必要だと感じています。 freeeでは、機械学習を前提にしたプロダクトの基盤を作りユーザーに高速に価値を届けていく腕力のあるエンジニアを募集しています。 一緒に世界を変えるプロダクトを創りましょう。

www.wantedly.com

jobs.freee.co.jp

明日はいよいよ弊社CTOであるよこじ氏が満を持して登場します。ご期待ください。

gophish を使ったお手軽な標的型攻撃メール訓練

freee株式会社 土佐です。今年の7月からCISOとして、全社セキュリティ・プロダクトセキュリティを担当しております。

この記事は、 freee developers Advent Calendar 2017 の 22日目の記事になります。

標的型攻撃メール訓練やってますか?

f:id:teppei-studio:20171222011938j:plain

IPAが毎年発表している 情報セキュリティ10大脅威 で、2014年 から 4年連続で1位となっているのが「標的型攻撃による情報流出」です。

当社でも、たまたま素晴らしく注意深い社員が受信したことで被害を未然に防ぐことができましたが、実際にかなり巧妙な標的型攻撃メールを受信しました。

もちろん様々にシステム的な防御策をとってはいますが、如何せん人を狙った攻撃である以上、人が強くならないといけないのが、この攻撃の厄介なところです。

ということで、多くの企業も実践済みだとは思いますが、当社でも標的型攻撃メール訓練を実施しました。

この訓練は、取引先からの仕事のメールを装った攻撃メールを実際に従業員宛に送付し、その従業員の行動として適切な行動がとれるよう、従業員に対する啓発を行うものです。

gophish とは

標的型攻撃メール訓練の実施には、様々なセキュリティベンダーからソリューションとして提供されています。しかし、この記事 で gophish の存在を知り、まずは gophish を活用して内製で実施してみたら、コストメリットあるのではないかと考えました。

gophish というのは、フィッシングメールや、標的型攻撃メールの訓練を簡単に行えるオープンソースのツールキットです。

結論から言ってしまうと、非常に構築も簡単だし、使い勝手もよく、素晴らしいものでした。

この記事では gophish の紹介と使う上でわずかながらつまづいたポイントについて、そして、標的型攻撃メール訓練をやるに当たって、特に気をつけたことについて共有させていただきたいと思います。

gophish の環境構築

gophish の環境構築は非常に簡単です。 gophish は go言語で書かれていますので、 go言語の環境設定ができていることを前提に説明します。

まずは go get でソースコードを取得して

go get github.com/gophish/gophish

そのディレクトリに移動して

cd $GO_HOME/src/github.com/gophish/gophish

ビルドして、

go build

起動する。

sudo ./gophish

すると、もう画面を開いてログインをすることができます

f:id:teppei-studio:20171221134053p:plain
ログイン画面

デフォルトのID/PWは以下の通りです。

id: admin / pw: gophish

これに加えて、今回は AWS EC2 にインスタンスを立てて、そこに環境構築したので、SecurityGroupの設定を適宜修正しています。

それに合わせて、httpsのURLになる管理画面のポートを修正するために config.json を修正しています。

{
        "admin_server" : {
                "listen_url" : "0.0.0.0:443",
                "use_tls" : true,
                "cert_path" : "gophish_admin.crt",
                "key_path" : "gophish_admin.key"
        },
        "phish_server" : {
                "listen_url" : "0.0.0.0:80",
                "use_tls" : false,
                "cert_path" : "example.crt",
                "key_path": "example.key"
        },
        "db_name" : "sqlite3",
        "db_path" : "gophish.db",
        "migrations_prefix" : "db/db_"
}

また、インスタンス起動後、サービスとして上がってきてもらいたいので、以下のように /etc/init.d/gophish を作成して chkconfig で rc登録しました

#!/bin/bash
# /etc/init.d/gophish
# initialization file for stop/start of gophish application server

processName=Gophish
process=gophish
appDirectory=/home/ec2-user/go/src/github.com/gophish/gophish
logfile=/var/log/gophish/gophish.log
errfile=/var/log/gophish/gophish.error

start() {
    echo 'Starting '${processName}'...'
    cd ${appDirectory}
    nohup ./$process >>$logfile 2>>$errfile &
    sleep 1
}

stop() {
    echo 'Stopping '${processName}'...'
    pid=$(/usr/sbin/pidof ${process})
    kill ${pid}
    sleep 1
}

status() {
    pid=$(/usr/sbin/pidof ${process})
    if [[ "$pid" != "" ]]; then
        echo ${processName}' is running...'
    else
        echo ${processName}' is not running...'
    fi
}

case $1 in
    start|stop|status) "$1" ;;
esac

はい。本当に簡単ですね。素晴らしい。

gophish の使い方

送信先の設定

「User & Group」のメニューから送信先をグループに追加していきます。

f:id:teppei-studio:20171222121256p:plain

送信するメールのテンプレートを設定

「Email Templates」のメニューから送信するメールのテンプレートを設定します。このテンプレートでは、既存のメールをインポートして、実在するメールに限りなく似せることを簡単にする機能もあります。

また、{{.FirstName}} のような記法で動的に値を設定させることもできます。この辺りの記法は、マニュアルサイトにまとまってます。

「+Add Files」からメールに添付するファイルを指定することもできます。

f:id:teppei-studio:20171222115504p:plain
Email Templates

フィッシングサイトの登録

「Landing Pages」のメニューで、メール上のリンクで開かせるページを登録することができます。ここも、「Import Site」のボタンから既存のページを取り込んで、簡単に本物そっくりのフィッシングサイトを登録することができます。主にログインページを登録して、パスワードを盗み取るフィッシングサイトを作ることを想定しているようです。

また「Capture Submitted Data」にチェックを入れると、ログイン画面で訓練対象者が実際に入力した値を取得することができます。ただ、その下の「Capture Passwords」のチェックを入れない限り、パスワードを取得することはありません。訓練と言えども従業員のパスワードを取得するのはやばいですからね。

さらに「Redirect To」 には、ログインボタンが押下された後に遷移するサイトのURLが指定できます。ここに本物のログイン画面のURLを指定すると、訓練対象者はたまたまパスワードの入力などが間違ってしまっただけに見えて、完全に騙すことができるでしょう。やばいですね。

f:id:teppei-studio:20171222115510p:plain
Landing Pages

送信元メールアドレスの設定

「Sending Profile」のメニューからは、訓練メールの送信元アドレスを設定することができます。この画面には「Send Test Mail」というボタンがあって、ここから設定された送信元メールアドレスから実際にメールを飛ばしてみて、設定が正しいか確認することもできます。便利。

f:id:teppei-studio:20171222115515p:plain
Sending Profiles

訓練の設定

「Campaigns」のメニューから、訓練設定を登録することができます。上述で設定した各種登録を組み合わせて、訓練設定の登録を行います。

「URL」の入力欄は、フィッシングサイトのURLを登録します。

f:id:teppei-studio:20171222115856p:plain

実際にやってみた

さて、キャンペーンを稼働させると、こちらのようなメールが届きます。

f:id:teppei-studio:20171222120114p:plain

これは、当社のプロダクトである 会計freee から送信される スマート請求書 のメールとそっくりに作ったメールになります。

スマート請求書 とは、freeeのユーザ企業同士でfreeeの請求書機能を使って請求書を送受信した場合に、受け取った請求書の経理データ入力までの作業をワンクリックで処理できる、非常に便利な機能です。

実際の訓練では、送信元企業名には、日本でもっとも多い企業名を使いました。

f:id:teppei-studio:20171221172744p:plain

メールにあるボタンリンクを開くと、会計freee のログイン画面が表示されます。本物のログインページを取り込んで作ったものなので、メールアドレスの形式チェック処理なども本物と同じように動くので、訓練対象者をかなり騙すことができてしまいます。

今回の訓練では、このページのURLが直IPアドレスのままになるようにしたので、URLを見ておかしいと気づくことができるようになっていました。

そして、ログインボタンを押してしまうと、種明かしとばかりにこのようなDocが開くことになります。

f:id:teppei-studio:20171221172802p:plain

最大の難関は Gmail

非常に簡単に構築できて、使い勝手のいい gophish ですが、一点非常にハマった問題がありました。

それは、Gmail のフィルタ機能をどう掻い潜るかです。当社はグループウェアやOffice製品代替、メールサービスとして、G Suite を使っています。 G Suite に置けるメール機能である Gmail は、不正メールの検知機能が非常に高性能です。当社の CSIRT では、警視庁のTwitterアカウントが、最近拡散中の標的型攻撃メールの情報を公開して警戒を促しているのを受けて、そこで報告されている件名のメールを受信している従業員がいないかどうか都度調査しています。それを見ると、ほぼほぼ全て Gmail の方で不正なメールを検知して、自動的に reject してくれています。高度な不正メール検知機能をどう掻い潜るかが一番難しいポイントでした。

色々試した結果、訓練用にドメインを取得して、Office365のアカウントを作成して、それを gophish に登録することで実現できました。

本当はやりたくなかった標的型メール訓練

実は、私、標的型攻撃メール訓練をやるのがずっと嫌でした。やりたくありませんでした。

というのも、前職で同じような訓練をやらされて、メールを開いてしまった経験があるからです。

そのメールのタイトルは「昇給通知」というものでした。

あれ〜?昇給の季節じゃないはずなんだけどなぁ、俺ってなんか特別なのかなぁ、とか、

この送信者の人の名前、こんな名前の人が人事にいたようないなかったような、、、

などなど、おかしいなおかしいなと思いながら開けました。

でも、昇給通知なんて言われたら、開けちゃうのが人情ってもんですよね!?

そして訓練と知って、なんだかとっても会社に裏切られた気分でした。 (自分が悪いのに)

みんなにそんな思いをさせる立場に私がなるなんて、絶対嫌だったんです。

しかし、いくつかの記事などで、「開封率に捕らわれるな」という論調に触れて、確かに開封率より大事にすべき本質があるはずだ、それをちゃんとみんなにコミュニケーションすれば、誤解なくやれるのではと考えました。

一番大事にしないといけないのは開封率ではなく、連絡率

今回実施した訓練でもっとも大事にした指標は連絡率です。

メールを開こうが開くまいが、何かおかしいと思った時に CSIRT に連絡をもらうことが大事なのです。仮にメールに気づいて開かなかったとしても、他の誰かが開いていてマルウェアに感染しているかもしれません。それをCSIRTは調査することができますし、しないといけないです。

訓練を実施するにあたり、全社ミーティングなどでそれを事前に周知しましたし、前述の偽ログイン画面でログイン操作をしてしまった後に表示するドキュメントでも、そのことを説明しました。また、開いた開かなかったに関わらず、連絡をくれた人には感謝の言葉を忘れませんでしたし、訓練の総括として、連絡率に対する評価を社内に共有しました。

今の所、これといった不満の声は届いていませんし、連絡率も8割を超える結果となったので、悪くないアプローチだったのかなと思います。

最後に

いかがでしたでしょうか。

標的型攻撃は人が狙われるものなので、人を強くすることが大事です。 gophish を使って、お手軽に訓練をやってみてはいかがでしょう。

freee では Hack Evertything の精神で、セキュリティ向上に取り組む仲間を募集しています。興味のある方は、ぜひお気軽に話を聞きにお越しください。

jobs.freee.co.jp

さて、いよいよアドベントカレンダーも大詰め。明日からは freee が誇る 最強豪腕エンジニア達の記事が三日続きます。明日はその一番手、米川さん です。孤高のスーパーエンジニアに授けられたポジションは、 Change The World ... !!。お楽しみに!!