面倒な外部コマンドを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はそのような異質な部分の置き場としてはアリなのではないかと思います。
インターン生が5日間で新システムをデプロイ!AWS Lambda+DynamoDBで広告の最適化にチャレンジ
5 月中旬から、pixiv のインターンシップに参加していた @iw_tatsu です。 5日間 (5/16 ~ 5/19, 5/30) のインターンでは、 @edvakf さんにメンターになっていただき、 ピクシブ百科事典におけるpixivコミック広告の最適化に取り組みました。 作成したコードは GitHub に置いてます。
課題: ピクシブ百科事典での pixiv コミック広告最適化
スマートフォンで見ると、 ピクシブ百科事典 の記事の最初のアコーディオンメニューには、 3 つの人気pixivコミック広告 (すべての記事で同じもの) が表示されています。
現在、この 3 つは直近で最も「いいね」を押された pixivコミックが表示されていますが、 これらを 記事ごと に最適化するのが今回の課題でした。
特に、今回はクリックをコンバージョンとして、 表示回数に対してのコンバージョン率 (CVR) が高くなるように広告の選択を最適化しています。
また、重要な制約として、 他のサービスでも利用できる汎用的なものにする というのがあり、 今回は記事のカテゴリなどのサービス特化の情報は考慮していません。 「記事ごとにクリックされる広告は異なる」というのが今回の仮説です。
取り組んだことの概要
詳しくは SlideShare にまとめていますが、 取り組んだことの概要をまとめると以下のようになります。
- 課題を CVR の高い広告を選択するバンディット問題として捉える
- システムは AWS を用いて構築
- Amazon API Gateway にリクエストを受け取る
- リクエストを AWS Lambda で処理する
ということを行っています。 データベースには、Amazon DynamoDB を用いました。
スライドの補足
Serverless Framework
スライドでは詳しく述べていませんが、 API Gateway や AWS Lambda のデプロイには、 Serverless Framework を利用しています。 作成したコードの README にも、 デプロイ方法のメモを記述しています。 これがないとコンソール上でプログラム書かないといけなくて、 バージョン管理が辛いです…。
料金体系関連
スライドではかなり省いていますが、 DynamoDB などの料金計算はやや複雑です。
特に、DynamoDB の各行の容量を直接知る方法は現状ないようで、 自分で見積もる必要があります。 今回のインターンでは、 この料金体系を考慮したテーブル設計が必要 (例: Map は Key の文字列長がサイズに影響する。 行をたくさん取る操作はお金がたくさんかかるため、 行の容量とのバランスを考えながら各行に情報を詰め込むなど) で、 結構時間をかけてしまいました (が、それも含めてとても勉強になりました)。
後、API Gateway の料金をスライドには記述し忘れていますが、 実は一番お金かかってました… ($50 程度)。
やり残したこと
基本的には、スライドや README に書いていますが、 特に考慮すべきことは、 サービスによって広告の数が違う事が考えられるので、 テーブルの設計は見直す必要があるかもということです (広告が多いとテーブルの各行の容量が大きくなって課金額が跳ね上がる)。
感想
5 日間という短い期間でしたが、 初めてのことにたくさん触れられるエンジニアとして最高の体験ができました!٩( ’ω’ )و
ほとんど書いてこなかった JavaScript を学んだり、 バンディットアルゴリズムを実際のサービスに適用してみたり、 運用費などのサービス全体を考慮した開発ができたのは 貴重な体験でした。
edvakfさん、ピクシブのみなさま、 本当にありがとうございました!
edvakfからのコメント
今回Lambdaを選んだことで、インターンの5日間という限られた時間で、ゼロから作った成果物を本番デプロイして運用するという経験ができたのは貴重でした。
料金も1週間程度で$100で済みましたし、その後のお片づけも一瞬でした。今後もインターン生などに何かを作ってもらうときに使っていけそうです。
また、コードを書くのは慣れているので比較的すぐにできても、実際に本番で使うためには価格や負荷の見積もりをしたり、見積もった上で設計段階から見なおしたり、社内の各所にかけあったりと、プログラミング以外のことが必要になってくるということを見られたのも良かったです。まさに実際の仕事を体験できたのではないでしょうか。
iw_tatsuさん、インターンお疲れ様でした!