pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

Slackを一句BOTで風流に

おはようございます。プログラマーのhakatashiです。

普段はpixivコミックpixivノベルの開発を手伝っています。が、今回はそれとは全く関係ないSlackの話をします。

一句BOTとは

みなさんSlackは使っているでしょうか。普段から業務にプライベートにと幅広くSlackを使っていると、メンバーの何気ない一言に“一句”を感じることがあります。

f:id:hakatashi:20160705180600p:plain

風流ですね。

pixivにはこのような日常に潜む和の心を大切にする風雅なエンジニアが多いので、平安貴族よろしく日常会話や業務連絡に5・7・5の形の川柳を混ぜて会話します。とても優雅ですね。

ですが、上の画像のような完全に日常に溶け込んだ野生のステルス一句は、誰にも気づかれずにログの彼方へ流れていってしまうことも多いようです。そこで、Slackのメッセージから自動で一句を検出してReactionをつけるBOT、slack-ikkuを(1時間で)作りました。

github.com

f:id:hakatashi:20160705170036p:plain

このBOTを社内Slackに導入したことにより、人間がメッセージを読んで一句判定をする必要がなくなり、社員の負担が軽減され、QoLが上昇、業務成績は右肩上がりとなり、社内から笑顔の絶えない明るい職場となりました。みなさんもslack-ikkuを社内に導入して優雅な一句ライフをエンジョイしましょう。

使用した技術

というのはもちろん冗談ですが、これで終わるのも情けないので、今回のBOT制作に使用した技術を書き連ねていきます。

言語(処理系): Node.js

筆者はJavaScript大好きマンなので迷わずNode.jsを選択しました。

形態素解析: kuromojin

与えられた文章が5・7・5に分割できるかを判定するためには、文章の読みと単語の境界を解析しなくてはいけません。これを行う形態素解析器にはChaSenMeCabなどの実装が存在しますが、今回はkuromojinを使用しました。

kuromojinは、形態素解析器kuromojiの純JavaScript実装であるkuromoji.jsを平易なAPIでラップしたライブラリです。このライブラリを使用すると、以下のように書くだけで文字列を形態素に分割することができます。

const tokenize = require('kuromojin').tokenize;
 
tokenize('「スラック」と「一句」で韻を踏んでいる').then((tokens) => {
    console.log(tokens);
});

kuromojinはtextlintの日本語プラグインを作る際にもよく用いられるライブラリです。覚えておいて損はないでしょう。

一句判定

形態素解析した文章が一句であるか判定するには、与えられた形態素を適当なスパンで分割して5音・7音・5音の三句に分けられるかどうかを判定すればよいでしょう。ですが、それだけの条件だとあまり和の心を感じない文章も一句判定されてしまい、一部の風流人に怒られてしまいます。

f:id:hakatashi:20160705172052p:plain

これはよくありません。このような一句は文節の途中で句切れが入ってしまっているため、あまりよい一句とは言えません。このような文章を除外するために、形態素列から文節の切れ目を適当に検出します。この手法は私が過去執筆した機械学習で石川啄木の未完の短歌を完成させるに書いた方法を応用したものなので、詳しくはそちらを参照してください。

Unicode正規化: unorm

一句の文化がSlackに根付くにつれて、だんだんと過激派の一句も登場します。

f:id:hakatashi:20160705183634p:plain

もはや風流を通り越してシュールですが、このような一句にもUnicode正規化を行うことで対応できます。

const unorm = require('unorm');

unorm.nfkc('㌠ ㌢㍍ ㌕'); //=> 'サンチーム センチメートル キログラム'

正規化で多少の表記ゆれにも対応できるので、事前処理としてこのような処理を入れておいて損はないでしょう。ライブラリはNFKCを正しく実装しているならなんでも良いですが、一番DL数の多いunormを使用しました。

機能テスト: mocha, mockery, nock, mock-socket

正直これだけのアプリケーションなのでテスト書かなくてもよいと思ったのですが、仮にも業務用Slackで動かすものだというのと、HTTP通信のモックを書いてみたかったのがあったので、簡単に機能テストを書きました。Travis-CIで動いています。

  • mocha: テストフレームワーク。
  • mockery: npmモジュールをモックするライブラリ。今回は設定ファイルとWebSocketをモックするのに使った。同名のPHPライブラリとは別物だと思われる。
  • nock: HTTP通信をモックするライブラリ。Slack API への通信をモックした。
  • mock-socket: WebSocket通信をモックするライブラリ。Slack Real Time Messaging への通信をモックした。

軽い気持ちで書き始めたら想像以上にたくさんのモジュールをモックする必要があって大変でした。

メンテナンス: GreenKeeper

こんな一発芸的なBOTは作ったあと誰にもメンテされないのは目に見えているので、あとの管理作業は機械に任せてしまいましょう。テストを書いたあと、GreenKeeperを導入しました。

GreenKeeperはリポジトリ上のpackage.jsonを監視し、依存しているライブラリにアップデートがあったら自動でテストを走らせて、ビルドが壊れていないかどうか確かめてくれます。npmの依存モジュールのバージョン解決はデフォルトでメジャーバージョンの最新に追従するようになっているので、新しいコードが浸透しやすい代わりにこれがビルドが壊す原因にもなっています。Node.jsアプリケーションを作ったら、ちゃんとテストを書いてこういったサービスを導入することを検討したほうがいいかもしれません。

また、GreenKeeperは依存バージョンにpackage.jsonでカバーされてないアップデートがあった場合に、package.jsonを更新するプルリクエストを自動で発行してくれます。

f:id:hakatashi:20160705175639p:plain

プルリクを受け取ったら、テストが通っているのを確認してGitHub上のマージボタンを押すだけでpackage.jsonの更新が完了します。スマホからでも余裕です。テストが落ちてたら不健全なのでコードを修正しましょう。

次回予告

slack-ikkuの開発により社内Slackに一句文化を根付かせることに成功したhakatashi。実装が落ち着いたと思ったのも束の間、pixivのリードエンジニアから新たな難題が……。

f:id:hakatashi:20160705184956p:plain

果たしてhakatashiはこの無茶ぶり難問に立ち向かうことができるのか。次回へ続く?

おわりに

pixivでは和の心を大切にするクリエイティブで風流なエンジニアを募集しています。一緒にSlackで一句バトルを繰り広げましょう。アルバイトインターンもあるんだよ。

面倒な外部コマンドをAWS Lambda化して運用から解放される

最近は社内でChainerやTensorFlowのハンズオンをしている@edvakfです。

今日は機械学習ではなく、AWS Lambdaの話です。

pixivのPDF生成機能

pixiv小説には自分の投稿した小説を印刷可能な縦書きPDFに変換する機能があります。

inside.pixiv.net

f:id:edvakf:20160610220805j:plain

小説をPDF化する部分は最初インターン生が作ったものが元になっていて、C++で書かれています。そのプログラムに渡すデータを用意する部分はというと、これまたインターン生が作ったpixiv-novel-parserと、小説本文を組版に最適な形式に自動変換するhakatashi/osekkaiいうNode.jsのプログラムを使って生成しています。

サービスにC++のコードを導入するのって勇気がいりますよね? もし入力ファイルによって任意のコードが実行できる脆弱性があったりすると大問題です。そのため、このプログラム(pixiv-publishingと呼ばれています)はjailingを使って実行していました。

pixivのメイン言語はPHPですが、pixiv-publishingを動かすための一式をすべて一つのComposerプロジェクトにして呼び出していました。Node.jsの処理系一式、JSで書かれた変換スクリプト、pixiv-publishingのコンパイル済み実行ファイル、これらの処理をPHPから呼び出すためのクラス、フォントファイル一覧や画像ファイル、合わせて400MBのComposerプロジェクトです…!

この面倒なシステムをどうしようかと思ってDockerを試したり色々やってみたのですが、先週ぐらいにふと思い立ってLambda化してみたら意外とイケたので、そのまま本番環境を移行してみました。

今回はそのあたりのノウハウを書いてみます。

Lambdaと外部コマンド呼び出し

LambdaのハンドラはNode.jsで書きますが、実行されるのは普通のAmazon Linuxっぽい環境です。なので、外部プログラムを同梱すれば普通に実行することができます。

const child_process = require('child_process');

const pixivPublishingCmd = path.join(__dirname, 'bin/pixiv-publishing');

child_process.execFile(pixivPublishingCmd, args,
    {encoding: 'buffer', maxBuffer: 1024 * 1024},
    function(error, stdout, stderr) {
    ...

バイナリ通信は不可

色々調べましたが、Lambdaはバイナリデータを返す方法はなさそうです。なので、生成されたPDFはをBase64エンコードして返しています。

expors.handler = function(event, context, callback) {

...

   callback(null, {base64: stdout.toString('base64')}); // stdoutはNodeJSのBuffer
}

実行コマンドはスタティックリンク化

pixiv-publishingはlibicuに依存していましたが、Lambdaにインストールすることができないので、スタティックリンクしてシングルバイナリ化しました。これによって実行ファイルのサイズが33MBになってしまいました。zipに圧縮すると13MBです。

Lambdaはzipにした状態で50MBまで、展開した状態で250MBまでという制限があります。フォントをそのままzipに同梱しては50MBを超えてしまいます。

フォントを最適化

最初はフォントをS3に置いてLambdaが起動された時にダウンロードしていたのですが、やはり待ち時間がストレスになるので、フォントをサブセット化して容量を削減しました。

フォントのサブセット化は基本的にはこちらで紹介されている方法と同じでしたが、50MBに収まればいいので、第四水準までの漢字の他にUnicodeの中からある程度の領域を含めてサブセット化しました。

CircleCIでデプロイ

デプロイはこちらの記事を参考にgulpとnode-aws-lambdaで行うようにしました。

dev.classmethod.jp

CiecleCIにpushすると、masterブランチであればpixiv-publishing-lambda-productionという名前で、それ以外のブランチであればpixiv-publishing-lambda-developmentという名前でデプロイされます。複数人が同時に頻繁に更新する性質のプロジェクトではないので、ブランチごとにデプロイ先を別々にするまではせずに、productionとdevelopmentのみで十分と判断しました。

f:id:edvakf:20160613212534j:plain

上の記事にあるlambda-config.jsというファイルではこのようにprofile等を指定しました。

module.exports = {
  profile: 'pixiv-publishing-lambda-deploy',
  region: 'ap-northeast-1',
  handler: 'handler.handler',
  role: 'arn:aws:iam::XXXXXXXXXXXXX:role/pixiv-publishing-lambda',
  functionName: 'pixiv-publishing-lambda-' + process.env.PUBLISHING_ENV,
...

profileは~/.aws/credentialsの設定が使われるので、CircleCIではこのようなコマンドであらかじめ~/.aws/credentialsを作っておきます。

echo -e "[pixiv-publishing-lambda-deploy]\naws_access_key_id = $AWS_ACCESS_KEY_ID\naws_secret_access_key = $AWS_SECRET_ACCESS_KEY" > ~/.aws/credentials

テスト

CircleCIでテストしたらそのままLambdaを呼び出してテストします。

const fs = require('fs');
const event = require('./event').event;

const AWS = require('aws-sdk');

const credentials = new AWS.SharedIniFileCredentials({profile: 'pixiv-publishing-lambda-deploy'});
AWS.config.credentials = credentials;

const lambda = new AWS.Lambda({
  region: 'ap-northeast-1'
});

const params = {
  FunctionName: 'pixiv-publishing-lambda-' + process.env.PUBLISHING_ENV,
  Payload: JSON.stringify(event)
};

lambda.invoke(params, function(err, result) {
  if (err) {
    console.error(err, err.stack);
    process.exit(1);
  } else {
    console.log(result.StatusCode);
    const payload = JSON.parse(result.Payload);
    if (payload.base64) {
      const data = new Buffer(payload.base64, 'base64');
      fs.writeFileSync(__dirname + '/pdf/result.pdf', data);
      console.log('written test/pdf/result.pdf');
    } else {
      console.log(payload);
      process.exit(1);
    }
  }
});

こうして得られたPDFをCircleCIのArtifactに置いておけば、テストが走った後にブラウザで開いて出力を確認することができて便利です。

f:id:edvakf:20160610232502j:plain

ローカル実行

Lambdaの関数は単なるNode.jsでexportsされた関数でしかないので、ローカルで実行することもできます。(もしLambda環境に依存した処理をしたい場合はそれなりに頑張る必要がありますが…)

const fs = require('fs');
const event = require('./event').event;
const handler = require('../handler').handler;

handler(event, {}, function(err, payload) {
  if (err) {
    console.error(err, err.stack);
    process.exit(1);
  } else {
    if (payload.base64) {
      const data = new Buffer(payload.base64, 'base64');
      fs.writeFileSync(__dirname + '/pdf/result.pdf', data);
      console.log('written test/pdf/result-local.pdf');
    } else {
      console.log(payload);
      process.exit(1);
    }
  }
});

当然ですがローカル実行するときは外部プログラムはローカルで動くようにビルドされている必要があります。

権限まわり

今回はAWSのIAMでユーザーを2つとロールを1つ作成しました。

まずLambdaをデプロイできるユーザー。

            "Action": [
                "lambda:GetFunction",
                "lambda:CreateFunction",
                "lambda:InvokeFunction",
                "lambda:PublishVersion",
                "lambda:UpdateFunctionCode",
                "lambda:UpdateFunctionConfiguration"
            ],
            "Resource": [
                "arn:aws:lambda:リージョン:ユーザーID:function:pixiv-publishing-lambda-development",
                "arn:aws:lambda:リージョン:ユーザーID:function:pixiv-publishing-lambda-production"
            ]
...
            "Action": [
                "iam:PassRole"
            ],
            "Resource": [
                "arn:aws:iam::XXXXXXXXX:role/ロール名"
            ]

次にLambdaを実行するロール。作成時にAWSサービスロールでAWS Lambdaを選択して、次の権限を足しました。もしC++のプログラムに脆弱性があって任意のコードが実行されたとしても、影響はこの範囲にとどまるはずです。

            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"

最後にpixivからLambdaを実行するユーザー。

            "Action": [
                "lambda:GetFunction",
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "arn:aws:lambda:リージョン:ユーザーID:function:pixiv-publishing-lambda-development",
                "arn:aws:lambda:リージョン:ユーザーID:function:pixiv-publishing-lambda-production"
            ]

まとめ

システムの中で「異質な部分」とどう付き合っていくかは大きい課題です。それがきちんと枯れて価値を産んでいる場合は、単純に終了するわけにもいかず運用や引き継ぎコストが積み上がっていきます。AWS Lambdaはそのような異質な部分の置き場としてはアリなのではないかと思います。