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 ... !!。お楽しみに!!

Rubyがマジョリティな会社でC#を使ってAWS Lambdaの本番運用を開始した話

こんにちは!freeeでエンジニアをしている @toshi0607 です。アイコンよろしくnyanchuと呼ばれてい ます。

Microsoft PlatformというチームでC#、WPF、Xamarinなどを使ってデスクトップアプリを開発しています。

この記事はfreee develpers Advent Calendar 2017の21日目です。

デスクトップアプリから送信するログファイルの処理をAWS Lambdaを使って行うようになりました。

この記事では導入の経緯や工夫したことについて紹介します。

段階的導入

最終的にLambdaを導入することを目指しつつ、ストレージへのログファイルのアップロードを非同期化することから始まりました。

まずはアーキテクチャの変遷をご覧ください。

Phase0

f:id:s_toshi0607:20171212230919p:plain

アプリからAPIサーバに複数のログをzipで固めて送信しています。

最初zipの中身をAPIサーバで1ファイルずつAmazon S3にアップロードしてからアプリにレスポンスを返していました。

そのため、ファイル数によってはタイムアウトしてしまうことがありました。

クライアント、サーバ両面から様々なアプローチを行いましたが、zip展開処理の非同期化がもっとも効果的でした。

Phase1

f:id:s_toshi0607:20171212230949p:plain

既にRailsサーバーでの一部処理でResqueを利用していたため、当面Resqueで様子を見ることにしました。

ただ、下記の理由からよりスケーラブルなサービスとして切り出したいという思いがありました。

  • Resqueは弊社の色々な機能での非同期処理に利用されており、優先度をつけながら運用している
  • アプリからに限らずログファイル展開で活用する未来がなんとなく見えていた

Phase2

f:id:s_toshi0607:20171212231007p:plain

11月末にこのアーキテクチャで本番運用を開始しました。

メインタスクではなかったので、合間時間や開発合宿などで開発してきましたがとても楽しかったです!

zipの非同期展開処理ができるのは変わりませんが、誤解を恐れずに言うとイベント駆動でスケーラブルなアーキテクチャになりました。

S3にzipファイルをアップロードするイベントでLambdaを起動させています。

Resqueでの運用にまつわる悩みもいくつか解消できていたりします。

はい。

なぜC#(.NET Core)か?

実はインフラ周りだけではなく、サービスでLambdaをnode.jsで運用してたりします。 Real World Serverless

それでもC#で書こうとしたのには理由があります。

メンテナンス

僕が所属しているチームはMicrosoft Platformチームです。

普段C#でクライアントアプリを開発しているので、メンテナンスやレビューの観点から極めて親和性が高いと考えました。

Visual Studio

Windows

強い。AWS Toolkit for Visual StudioをインストールすればVisual Studio上でデプロイ可能。

Lambda用のプロジェクトテンプレートを使用すればローカル実行もテスト(xUnit)を介して可能です。

環境構築にサーバーレスなフレームワークを使わずよしなにやってくれます。楽です。

Mac

Windows版ほどのサポートはありませんが、Visual Studio for Macでもローカルデバッグ環境を楽に構築することができます。

  • Visual Studio for Mac(エディションは注意)
  • .NET Core 1 系(現在VS4Macインストール時に2系がインストールされる)
  • xUnit.NET 2 testing framework support(拡張機能から有効化)

を使用することで、xUnitをFunctoionのドライバとしてデバッグすることができます。

弊社ではMac使いが多いため、Mac向けのLambda環境構築や.NET Coreの出自など詳しめにドキュメントを準備しました。

いざ本番運用

.NET周りのサポートが手厚いLambdaですが、心配なことがありました。

実際本番で運用されている情報はほとんどなく、リリースしたらどんなことが起こるのだろう…?というものです。

しかし、実行環境がどうであれやることは変わらないはず。基本に忠実に次のようなテストや仕組みで準備を行いました。

リトライ

処理に失敗したときはAWS側で自動的に2回リトライしてくれます。

完全に委ねました。

通知

リトライに2回失敗したとき、エンジニアはそれを知る必要があります。

これもAWS側で準備されている仕組みですが、次の図のような構成にしました。

f:id:s_toshi0607:20171219204803p:plain

2回失敗時のDLQとしてAmazon Simple Notification Service(SNS)を選択しました。

通知先はシンプルにメールです。

Lambdaのトリガーになったイベントそのものがつぎのような形式で通知されます。

{"Records":[{"eventVersion":"2.0","eventSource":"aws:s3","awsRegion":"us-east-1","eventTime":"2017-12-22","eventName":"ObjectCreated:Put","userIdentity":{"principalId":"AWS:xxx"},"requestParameters":{"sourceIPAddress":"xx.xx.xx.xx"},"responseElements":{"x-amz-request-id":"1234567","x-amz-id-2":"xxxxx"},"s3":{"s3SchemaVersion":"1.0","configurationId":"ZipUploadedEvent","bucket":{"name":"xxxxx","ownerIdentity":{"principalId":"xxxxx"},"arn":"arn:aws:s3:::xxxxx"},"object":{"key":"xx/xx/54321-xxxx.zip","size":12345,"eTag":"abcde","sequencer":"12345"}}}]}

監視 / ログ

リトライに失敗したときには通知された内容をもとにスタックトレースが見たいです。

CloudWatch Logsには通知内のx-amz-request-idやzipのファイル名に含めたログのID(上述のログでは54321)を吐き出し、検索できるようにしました。

移行 / 切り戻し

もし大量エラーが起これば手段を変えて処理を継続する必要があります。

Lambdaの処理の有効・無効はLambdaのポータルでアップロードイベントの発生の有効・無効で制御できます。

それと同様、元々のResque処理のコードも残し、Redisへのenqueueの有効・無効も管理画面で切り替えることができるようになっています。

代替処理

代替処理でもResqueを使用します。

RedisにenqueueするためだけのRailsタスクを作成し、ログのIDとzipファイルのパスを渡せばいつでもzipの展開処理が可能です。

段階的に移行を行った際の資産を有効活用できています。

ステージング環境での検証

色々と備えたものの、本番環境に出す前に本番に近い環境でのテストはしたいものです。

本番の疑似環境にリリースし、数日間運用してみました。

その結果、次のようなエラーに未然に対処することができました。

一時ディスク容量オーバー

実行環境の/tmpは512MBまで使用することができます。

ただし、同一のコンテナは(できる限り)再利用されます。

今回は/tmp下に展開したzipファイルの中身を置いていたため、タイミングによっては容量をオーバーしてしまっていました。

実行時に/tmp下にファイルがあれば消すようにしました。

容量0のファイルのアップロードでこける

S3のAWS SDKでファイルをアップロードするとき、容量0のファイルのアップロードに失敗したため、容量のチェック処理を入れました。

CI

いくらローカルで簡単にデプロイ環境まで構築できたとしても、個人の環境に依存するのは避けたいところです。

そこでCIサービスとしてVisual Studio Team Servicesを活用しました。

2017年の8月にAWS Tools for Visual Studio Team Servicesが発表され、Lambdaについてもビルドパイプラインが数分で構築できるようになりました。

f:id:s_toshi0607:20171212231622p:plain

ビルド・デプロイの設定はたったのこれだけです。

もともと設定項目少ないのはありますが…!

まとめ

以上を経て今のところは安定稼働しています。

f:id:s_toshi0607:20171221095246p:plain

たまにS3へのアップロードでこけますが(We encountered an internal error. Please try again.)、リトライの範囲内なのでまぁよしとしています。

これまでAWSはfreeeを支える重要なインフラとして部分的に触れてきたものの、特定のサービスをしっかり触ったことはありませんでした。

これを機に他のサービスにも親しんでいけたらと思います。

また、運用は開始したものの、つぎのような課題があります。

  • 通知
    • スタックトレースをいちいち探しに行かなくても、通知されたときに直接確認したい
  • CI
    • プルリクエストやタグでビルド・テスト・リリースをトリガーしたい
  • イベントの設定状況

今回のアーキテクチャを突き詰めても直接的にユーザに価値を届けることにはなりませんが、他に活かせるところを探しつつうまいこと付き合っていきます。

通知あたりはAWS Lambda Advent Calendar 2017の23日目に書けるようにがんばります。

さいごに

ここまで読んでいただきありがとうございました!

freeeではプロダクトを一緒に成長させてくれる仲間を募集しています。

興味を持ってくださった方はぜひお願いします!

www.wantedly.com

jobs.freee.co.jp

明日はCISO(Chief Information Security Officer:最高情報セキュリティ責任者)(長い)のtosaさんです。お楽しみに!

社内での技術発表会を定期的に行うことで心がけていること

freee 株式会社 松崎 啓治です。 人事労務freeeのアプリケーション開発を担当しています🔥

この記事は、freee developers Advent Calendar 2017 20日目の記事です。

社内の技術発表会 真剣.js

真剣.jsという名前からしてJavaScriptを連想しますが、特に関係なく、技術的な発表を行う会です。 社員数が少ない時代から続いています。私がfreeeに入る前から、初期に入社した社員によって運営が切り盛りされていました。

f:id:syanbi:20171218200146j:plain
人数が少なかった頃

初期担当者が忙しくなり、切り盛り具合が大変になったため、私側で真剣.jsの名前を借り、技術勉強会を行うようになりました。

無理のない発表体制

技術発表会では、ざっくりと5分〜10分枠、15分枠を設けて、 発表したい人が居たらどのぐらいの時間枠で発表できるか選択した上で エントリーしてください、とお願いしています。

f:id:syanbi:20171219000042p:plain
社内で利用しているQiita::Teamに募集の投稿をした様子

話したい人がある程度居ないと技術発表会は会として成り立たないので、 4半期に1度のペースで技術発表会の開催を行えるように調整しています。

エンジニアの交流

2017年に入った後、エンジニアの人数も100名まで近づき、担当しているプロダクトや、得意分野、興味、家族構成、出身などかなりのバラつきが出るようになりました。 新しく入社した人と前から働いている人の技術的な興味に関するつながりはきっかけがなければ交わることがありません。

技術発表会、真剣.jsを通じて「ああいうことに技術は応用できないか」「私は運用を知っている」「もっとより良い手段を見つけている」など、 1名の技術興味、視野では追いづらいのを補完する作用があります。実際に機械学習系の発表をエンジニアが行ったところ、聴衆側のエンジニアが 自身の知識や、興味を元に質問をし、発表に出した抱え込んでいた課題の解決策を話し合っていたりしました。

開くことを定常にせず。なんとなくやっている技術発表会

開くことを義務とし、定常とし、発表者に義務を課しつつ開催し続けるスタイルにできるかもしれません。 が、それではなかなか長続きしないし、リラックスした形で発表はできないと思うし、 聴衆も聞くことが義務になるけど、興味のないことに耳は傾けづらいだろうし、レビューもしづらい。 モチベーションを維持しつつ、面白そうな発表や、質問があったらその都度気にしてもらえればいい、 そんな思いで続けています。

低コスト運用

f:id:syanbi:20171218201046j:plain

酒を発注したり、食べ物を発注したりして、コストをかけようとがんばったことも数回あったのですが、 ここ最近はやめ、場所を押さえる・発表者を集める・時間を予定する・リマインドする 以外のことには手を出さなくなりました。お酒があったり、食べ物があると人は集まりそうなのですが、 お酒があったら正しい判断しづらそうだし、発表を聞いた上で話が盛り上がったらより良い酒を飲みに行くとか、 そんな流れになったほうが良いかなという思いが強いためです。(本当は合ったほうがいいかなと最近思っては居ます)

コストが高くなると、開催する意図を細かく説明し、コストが係る理由を正当化していかなくてはなりません。 お酒・食べ物の消費も見積もらなければならないし、そうなると運営として1名ではなく複数名となるようになります。 そうすると適当な開催がしづらくなり、打ち合わせのためのミーティングが開かれ、ミーティングのためのミーティングが... となり技術発表会の開催の目的が業務の匂いが強いものになってしまいます。

なかなかそうすると紋切り型の発表が増えそうだし、一発ネタや時事ネタが流行りそうで、 交流や思わぬ発見があった、などのよくわからないおもしろイベントが発動する機会が減りそうかな〜とか そんなことを思うため、低コスト運用を心がけています。

長く、定期的に続ける

勉強会で得たノウハウや、知見共有や、そこで生まれた場や出会いなどがあるので、 もったいないので長く続けていきたい気持ちを持ってやっています。 真剣.jsをやる、という呼び込みをするとエンジニア以外の方からも真剣.jsにて発表したい、 ということで申し込みがある状態になっていて、非常にうれしいです。🙏

f:id:syanbi:20171218235539j:plain
社内への広報や宣伝に力を入れすぎなくても、自主的に集まり、技術発表が始まる

そして...

社内での技術発表会に参加したい、企画したいという方。 一度弊社まで遊びに着てください。ご案内いたします🔥 最近大阪オフィスでの採用も始まっているようですよ。

www.wantedly.com

エンジニアに限らず、ビジネス、サポートも募集しています。

jobs.freee.co.jp

次の担当は

明日、12/21のfreee Developers Advent Calendarはfreee エンジニア C#の要(かなめ)、@toshi0607 が担当します。 お楽しみに👏👏