面倒な外部コマンドをAWS Lambda化して運用から解放される
最近は社内でChainerやTensorFlowのハンズオンをしている@edvakfです。
今日は機械学習ではなく、AWS Lambdaの話です。
pixivのPDF生成機能
pixiv小説には自分の投稿した小説を印刷可能な縦書きPDFに変換する機能があります。
小説を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で行うようにしました。
CiecleCIにpushすると、masterブランチであればpixiv-publishing-lambda-productionという名前で、それ以外のブランチであればpixiv-publishing-lambda-developmentという名前でデプロイされます。複数人が同時に頻繁に更新する性質のプロジェクトではないので、ブランチごとにデプロイ先を別々にするまではせずに、productionとdevelopmentのみで十分と判断しました。
上の記事にある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に置いておけば、テストが走った後にブラウザで開いて出力を確認することができて便利です。
ローカル実行
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はそのような異質な部分の置き場としてはアリなのではないかと思います。