ボクココ

個人開発に関するテックブログ

Heroku と Amazon Lambdaを連携して、バックグラウンドジョブを実現した

気にはなってたAmazon Lambda をようやく使えたのでシェア。

Heroku はご存知の通り、Web とは別のWorker プロセスを立ち上げようとすると、その分プランに応じて倍増する。バックグラウンドジョブがそこまでないシーンで、常にWorkerプロセスを立ち上げっぱなしにするのは非効率。必要な時に必要な時だけバックグラウンドジョブを実行できる環境がないのか、考えていた。そこで登場するのが Amazon Lambdaだった、って訳だ。Amazon Lambda の魅力は何と言っても便利なだけでなく、1 か月あたり 100 万の要求と最大 3.2M 秒のコンピューティング時間が無料である、という点にある。素晴らしい。

今回やりたいこと

CSVなどのデータをアップロードして、それらを一括でHeroku Postgres の DBにデータを入れたい。それらの処理は時間がかかるので、バックグラウンドで実行し、終了時にメールを送るようにする。

連携イメージ

Amazon Lambdaの使い方としてはS3のアップロード時のイベントをトリガとして、LambdaのNode.js側からHeroku Postgresに接続し、データを挿入する。ややトリッキーなやり方だ。以下に概要図を示す。ここで重要なポイントは、 Heroku の Webサーバーを一切介さずに処理を終了させている ところだ。

f:id:cevid_cpp:20150828212125p:plain

このシステム開発でのポイント

以下、気になる点だけ読んでもらえれば。ブログ記事分けようと思ったけど、一気に書く。

  • Amazon LambdaやAmazon Cognito でのIAMユーザー、グループ、ロール等の管理
  • Amazon Lambdaの開発、デバッグ及びデプロイ環境
  • Node.js でのasyncを用いた開発スタイル
  • Heroku PostgreにCSVなどの大量データを一括インポートする

上記について体験したことをまとめておく。

Amazon LambdaやAmazon Cognito でのIAMユーザー、グループ、ロール等の管理

これ、初めて AWS触る人にとっては色々面倒なことをしなければならない。でもこれをやらなければならないのは最初だけなので、なんとか辛抱して頑張って設定してほしい。

まず図の1だ。さくっとAWScsvjsonをアップロードする、と書いてあるが、これだけでも裏側のAWSの設定を色々やってあげる必要がある。これに関しては、以前書いた以下の記事を参照していただきたい。ちなみにPaperclipやCarrierwaveを利用してHerokuのRailsサーバーからファイルをS3に上げることはできるが、Herokuはこれを推奨しておらず、以下のようにJavaScriptから直接上げるやり方を推奨している。

サーバーレスでJavaScript だけで画像ファイルをアップロードする方法 | selfree

これでまずは指定バケットにファイルをアップロードすることができた。

それとは別に、Amazon Lambdaを利用する場合はこの実行権限などを設定する必要がある。また、Amazon Lambdaを利用する場合はAWS CLIを利用しないと開発効率が上がらないので、これも同時に入れてあげる必要がある。それに向けて、グループを追加してその中で追加したユーザーでAWS CLIを利用することがセキュリティに大事である。これに関しても書いてるだけで大量になってくるので、詳細はAmazon Lambdaのドキュメント(英語) の、16ページを参照していただきたい。 Amazon Lambdaを初めて触る方は、それ以降のサンプルの実行も合わせて読み進めることで理解が深まるだろう。

Amazon Lambdaの開発、デバッグ及びデプロイ環境

AWS CLIさえ動かせる環境になったら、後はスイスイ開発できる。

AWS CLIで仮のイベントを発生させたり、AWS CLIからNode.jsのソースコードを直接アップロードできるようになる。今回はソースのzip化 -> ソースのアップロード -> Lambdaファンクションの実行 を一つのコマンドでできるように、シェルを組んで開発した。

ローカルでコードを実行する必要なんてない。だって 1 か月あたり 100 万の要求と最大 3.2M 秒のコンピューティング時間が無料 なんだから。

以下はデプロイシェルのサンプル。 lambdaディレクトリ内に index.js とnode_modulesがある想定。カレントディレクトリはlambdaディレクトリがある。

cd lambda
zip -r ../zipfile .
cd ..
aws lambda update-function-code --function-name sample --zip-file fileb:///path/to/zipfile
rm -f zipfile.zip

# invoke function
aws lambda invoke --payload file://input.json --invocation-type RequestResponse --function-name sample --region ap-northeast-1 --log-type Tail   outputfile.txt
rm outputfile.txt

最後アウトプットファイルを削除している。ここにもログファイルの内容を出力してくれるんだけど、 Amazon LambdaはAmazon Cloud Watchに自動的にログを出してくれるので、基本そちらを見ながら開発した。つまり、このシェルを実行した後、Cloud Watchで実行結果を確認する、という流れを取った。これだけでだいぶ開発効率が上がった。

Error: Cannot find module 'index' 、というエラーでちょっとハマった。これはzipファイルを固めた時、その解凍した直下にそのjsファイルがないとこの問題が発生する。zip化する時に要注意。

Node.js でのasyncを用いた開発スタイル

先ほど紹介したAmazon Lambdaのドキュメントを読んでみると、S3のイメージリサイズの所で、Asyncを利用したサンプルが紹介されている。これを利用すると、Node.jsにありがちなコールバック地獄から抜け出すことができる。特に今回は PostgreSQLのNode.jsドライバーである node-postgres を利用するが、これはクエリを投げるたびにコールバックを返すので、asyncを使わないととんでもないことになる。ぜひ使おう。

ここで、ちょっと癖っ毛のあるasyncのwaterfallの使い方について注意点だけ書いておく。基本的な構文は以下の通り。

async.waterfall([
    function first(callback) {
        getSomething(options, function (err, result) {
          if (err) {
            callback(new Error("failed getting something:" + err.message));
          } else {
            callback(null, result);
          }
        });
    },
    function second(result, callback) {
        // ...
    }
], function (err, result) {
   if (err) { console.error(err);}
   console.log("Fin.");
});

最初の関数にcallbackという次に渡すための関数がデフォルトで入る。次の処理に移る時、first内でこのcallbackを呼んであげる。そしたら次のsecondに処理が移る。ここで重要なのが、 callbackの第一引数はerrオブジェクトが入るという点。つまり、問題なく次に進みたい時は、callbackの第一引数はnullをセットしてあげなければならない!これを知らずに普通に第一引数にデータ入れると、なぜか処理がぶっ飛んでいきなりFin.と表示されてしまう。ただ、ほとんどのライブラリのコールバック呼び出しの第一引数はerrが入る慣習?っぽいので自分で呼ぶ時だけ要注意なのかも。

また、次のsecondに引数を渡したい時は callbackの第二引数以降に渡すデータは次の関数の第一引数以降に変わる。今回のソースだとresultの所。これは引数何個でも同じ。secondの最後の引数にcallbackを追加してあげて、second内でまたcallbackを呼んであげるようにする。

以上の点にさえ気をつければ問題なく開発できるかと思う。

Heroku PostgreにCSVなどの大量データを一括インポートする

まず、Amazon LambdaからHeroku Postgresに直接接続することは可能。Heroku Postgresの URL postgres://user:pass@ec35232-52528.compute-1.amazonaws.com:5432/database?ssl=trueを接続してあげる。ここで、?ssl=trueを末尾につけないと接続できないので注意。

それ以降はnode-postgresのクエリをそのまま発行してあげればOK。実行結果のコールバックの第一引数はerr、第二引数にresultということで、特に気にせずasyncのコールバックを渡せる。以下のように。

client.query("select * from users;", callback);

次のwaterfall の関数で (result, callback) の引数でresultを取ってこれる。規約に従ってれば、asyncは便利!

さて、一つずつデータを挿入するなら、シンプルにinsert文を入れればいいだけだが、掲題のように、CSVファイルで一括インポート、という時はちょっとテクニックがいる。今回の想定として、シンプルなマスターデータではなく、インクリメンタルなIDや外部キーなどのテーブルに入れたいという場合を想定する。その場合、以下のような手順を踏む。

  1. CSVファイルをlambda内の/tmp内に保存する。
  2. CSVファイルと同じ構成のダミーテーブルを作成する。
  3. INSERT INTO users ... SELECT FROM dummy_users WHERE ... の構文でデータを挿入
  4. ダミーテーブルを削除
  5. /tmpのファイルを削除

このやり方は、以下の記事を参考にさせていただいている。

PostgreSQL の COPY コマンドと SQL だけで様々なデータをインポートする – hoge256 blog

/tmp内に移動させたcsvファイルを消さなければならないことに注意!次実行する時、同じ名前でファイルがあった場合、lambdaが別ユーザーで実行した時にpermission deniedとなってしまうことがあるからだ。ここら辺はLambdaがリソース使いまわしている点があるのでよくわからない挙動をすることがある。

Heroku Postgresはadmin権限で実行することができないため、csvファイルを直接COPY FROMを実行させることは不可能だった。ということで、標準入力でファイルを読み取ってそれをデータとして渡してあげる必要がある。それを実現するのに、node-pg-copy-streamsを利用した。waterfall 内で以下のような感じになる。ここの書き方にハマった。。

          function copyTable(result, callback) {
            console.log("Copy CSV to table.");
            var stream = client.query(copyFrom("COPY " + tempTableName +" FROM STDIN USING DELIMITERS ',' CSV;"));
            var fileStream = fs.createReadStream("/tmp/data.csv");
            fileStream.on('error', function() {
              callback("Open Stream Error");
            });
            fileStream.pipe(stream).on('finish', function() {
              callback();
            }).on('error', function(err) {
              callback(err);
            });
          }

まとめ

本来ならもっと一つ一つを丁寧に説明すべきかとは思うが、この記事で最も言いたかったことは、Heroku と Lambdaを連携してバックグラウンドジョブを実行させることは可能で、このやり方はなかなかお勧めできる、ということだ。

EC2は高い。だからWebサーバーはHerokuを使う。が、Herokuはバックグラウンドジョブをする場合には余計に課金が発生する。だからAmazon Lambdaでそれらの処理を任せる。これは両方の良い所を取った、素晴らしい構成だと思う。

この構成を考えていた人にとって有益な記事となれば幸いだ。