ようへいの日々精進XP

よかろうもん

AWS Lambda で Node.js v4.3 がサポート終了になるので, v8.10 にアップグレードする為にテストを書いて検証してみた

tl;dr

5 月の初めに AWS Lambda において, ランタイムとして Node.js v4.3 のサポートが終了になる旨のアナウンスがありました.

このアナウンスの最後に, 以下のように v6.10 又は v8.10 へのアップグレードを推奨されています.

We strongly encourage you to update all your functions to a newer available runtime version (v6.10 or v8.10) so that you continue to benefit from important security, performance, and functionality enhancements offered by more recent Node.js releases.

また, 本件については, クラスメソッドさんのブログでも取り上げられています.

dev.classmethod.jp

有難うございます.

さて

移行期間等の詳細については, 上記のクラスメソッドさんのブログが詳しいので, そちらを参考にしていただくとして, 手元の環境において Node.js v4.3 環境があるのかなと探してみたところ, CloudWatch Logs から Amazon Elasticsearch Service に転送する為に自動的に生成される Lambda ファンクションが Node.js v4.3 となっていました.

docs.aws.amazon.com

ちなみに, 2018/06/09 時点でサブスクリプションを作成した結果, Node.js のバージョンは v4.3 となりました.

$ aws lambda get-function-configuration --function-name=LogsToElasticsearch_oreno-es2
{
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "CodeSha256": "YGJUBkMfMXe6NKzVaH2yUNYy4E8TZZXI4kg/YmqnRoQ=",
    "FunctionName": "LogsToElasticsearch_oreno-es2",
    "CodeSize": 2557,
    "RevisionId": "a2e2835b-46ff-4656-9c3e-9f6e3c0646df",
    "MemorySize": 128,
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:01234567890:function:LogsToElasticsearch_oreno-es2",
    "Version": "$LATEST",
    "Role": "arn:aws:iam::01234567890:role/lambda_elasticsearch_execution",
    "Timeout": 60,
    "LastModified": "2018-06-09T11:24:28.204+0000",
    "Handler": "index.handler",
    "Runtime": "nodejs4.3",
    "Description": "CloudWatch Logs to Amazon ES streaming"
}

へー, そーなんやー.

ということで, 前述の CloudWatch Logs から Amazon Elasticsearch Service にログを転送する Lambda ファンクションを v8.10 にアップグレードすることを想定して, テストを書いてアップグレードする準備をしてみたので, その模様をハイライトでお送ります.

本編

自動的に生成された Lambda ファンクション

以下のようなコードになっています. (全体で 250 行程のですので, だいぶん端折って掲載させて頂きます.)

// v1.1.2
var https = require('https');
var zlib = require('zlib');
var crypto = require('crypto');

var endpoint = 'Amazon Elasticsearch Service のエンドポイント';

exports.handler = function(input, context) {
    // decode input from base64
    var zippedInput = new Buffer(input.awslogs.data, 'base64');

    // decompress the input
    zlib.gunzip(zippedInput, function(error, buffer) {
        if (error) { context.fail(error); return; }

        // parse the input from JSON
        var awslogsData = JSON.parse(buffer.toString('utf8'));

        // transform the input to Elasticsearch documents
        var elasticsearchBulkData = transform(awslogsData);

        // skip control messages
        if (!elasticsearchBulkData) {
            console.log('Received a control message');
            context.succeed('Control message handled successfully');
            return;
        }

        // post documents to the Amazon Elasticsearch Service
        post(elasticsearchBulkData, function(error, success, statusCode, failedItems) {
            console.log('Response: ' + JSON.stringify({ 
                "statusCode": statusCode 
            }));

            if (error) { 
                console.log('Error: ' + JSON.stringify(error, null, 2));

                if (failedItems && failedItems.length > 0) {
                    console.log("Failed Items: " +
                        JSON.stringify(failedItems, null, 2));
                }

                context.fail(JSON.stringify(error));
            } else {
                console.log('Success: ' + JSON.stringify(success));
                context.succeed('Success');
            }
        });
    });
};

function transform(payload) {
... 略 ...

このコードをローカルにコピーした上で作業を進めていきます.

テストの観点

テストには以下のような内容を期待したいと思います.

  • Lambda 関数から出ていくデータ (Elasticsearch に放り込まれるデータ) が正常に生成されること
  • Lambda 関数から出ていくデータ (Elasticsearch に放り込まれるデータ) について, バージョンによって差異が無いこと

上述の Lambda ファンクションでは, transform 関数が, その役割を担っているようですので, この関数を主にテストすることになると思います.

テストの準備

今回, テストを書くにあったって, nodenv で v4.3 と v8.10 環境を用意しました.

nodenv install 4.3.2
nodenv install 8.10.0

また, 各バージョン毎にディレクトリを作成し, カレントディレクトリに各バージョンのテスト環境を用意しました.

# Node.js v4.3 環境を用意
mkdir nodejs4
cd nodejs4
nodenv local 4.3.2
npm init -y && npm i -D mocha power-assert rewire
cd ../
# Node.js v8.10 環境を用意
mkdir nodejs8
cd nodejs8
nodenv local 8.10.0
npm init -y && npm i -D mocha power-assert rewire

尚, インストールする npm パッケージは以下の通りです.

パッケージ名 用途
mocha JavaScript のテストフレームワーク, https://mochajs.org/
power-assert アサーションライブラリ, https://github.com/power-assert-js/power-assert
rewire プライベートメソッドをテストする為に利用する, https://github.com/jhnns/rewire

一応, 動作を確認したいので, 簡単なテストを書いてみたいと思います.

以下のような sample.js を用意します.

'use strict';

exports.handler = (event, context, callback) => {
  const response = JSON.stringify({ message: 'hello lambda!', input: event });
  callback(null, response);
};

そして, テストコードは以下のようなテストを用意しました.

'use strict';
const assert = require('assert');
const lambda = require('./sample.js');

describe('Lambda Test', () => {
  let event;
  let context;

  it('event が指定されていない', () => {
      lambda.handler(event, context, (error, result) => {
        assert.equal(result, '{"message":"hello lambda!"}');
      });
  });

  it('event に sample という文字列が指定されている', () => {
      event = 'sample';
      lambda.handler(event, context, (error, result) => {
        assert.equal(result, '{"message":"hello lambda!","input":"sample"}');
      });
  });

  it('event に foo という文字列が指定されている', () => {
      event = 'foo';
      lambda.handler(event, context, (error, result) => {
        assert.notEqual(result, '{"message":"hello lambda!","input":"sample"}');
      });
  });
});

以下のようにテストを実行します.

$ node -v
v4.3.2
$ ./node_modules/mocha/bin/mocha sample.test.js


  Lambda Test
    ✓ event が指定されていない
    ✓ event に sample という文字列が指定されている
    ✓ event に foo という文字列が指定されている


  3 passing (14ms)
... 略 ...
$ node -v
v8.10.0
$ ./node_modules/mocha/bin/mocha sample.test.js


  Lambda Test
    ✓ event が指定されていない
    ✓ event に sample という文字列が指定されている
    ✓ event に foo という文字列が指定されている


  3 passing (8ms)

テスト用データの用意

手元の環境でテストを行うにあたって, 入力と出力のデータを用意する必要があります. 以下のように, 実際の Lambda ファンクションからデータをダンプして用意したいと思います.

// v1.1.2
var https = require('https');
var zlib = require('zlib');
var crypto = require('crypto');

var endpoint = 'Amazon Elasticsearch Service のエンドポイント';

exports.handler = function(input, context) {
    // 入力データをダンプする
    console.log(input);
    // decode input from base64
    var zippedInput = new Buffer(input.awslogs.data, 'base64');

    // decompress the input
    zlib.gunzip(zippedInput, function(error, buffer) {
        if (error) { context.fail(error); return; }

        // parse the input from JSON
        var awslogsData = JSON.parse(buffer.toString('utf8'));

        // transform the input to Elasticsearch documents
        var elasticsearchBulkData = transform(awslogsData);
        // 出力データ (Elasticsearch に書き込むデータ) をダンプする
        console.log(elasticsearchBulkData);
... 略 ...

実際に Apacheアクセスログを CloudWatch Logs に転送して, 上記の Lambda ファンクションには, 以下のような状態でデータが入ってきます.

{ awslogs: { data: 'H4sIAAAAAAAAAN1TUW+bMBD+K5afNq0xZwO2IU/tlmadlmpKkPbQVJUDTmKJQGbM2i7qf99BNamttP2AAQ/Wd98d9313PtGD7Tqzs8Xj0dKcfjovzu8Ws9XqfD6jZ7S9b6xHGJJEQSw1l6lEuG53c9/2R4yYoyn3doLIzjbPoVXw1hww5iZQKguVsUpuN1yIMbnrN13p3TG4trl0dbC+o/kNndWmC67srPHl/rnEXett005sR2/HwrOftgkD+URdhfXjBLTSqdJcA2QZF4mMZZLgUaUQZzF+UnKlM4ltAwhIuFKYg00Eh7qDOaAEngqdpiLhgM/ZHz+wPIeEcamZBMZ1TCb43kAWfembSADXuRB5GucxkA+QAdySNZ3PChK5YA9RaQ7Wmy6SMSefi+JbxBlfUyIAiJbIREKwu9Y/Ru1260qLsTVdtL9cXZsoYUDele3haILb1HZKFqurGdEMpuS7a6r2viPXBUkZn5LCuwpNGVKm5KJYrtBlnmZTMi8uFBNTwq6xp49flwQbYEksXkKCAUtBCfUCixGDJAXJYngDjyhWF6/wFIlK4B8vlleReL+m6K59CN6UwVaXztYVTuxEK9Q7LNJf/cM0b3/0OBWk/dvIkbq1flzNt05iEMfXDFX+Bz9RztgRypngefMY8DrkVA83ad+OZr3e00F/H/Z9N7oz5OCeh35Iwu2jT0+3T78B18fvmPUDAAA=' } }

ログ自体は JSON フォーマットになっており, 更に zip で圧縮して Base64 エンコードされています. これが入力データ (変数 input に入っているデータ) となります.

そして, Elasticsearch に書き込まれるデータは以下のようなデータとなります.

{"index":{"_index":"cwl-2018.06.09.13","_type":"apache-loggen","_id":"34087857818009912463644099750393393661789665600204177408"}}
{"date":"09/Jun/2018:22:53:30 +0900","request":"GET /item/cameras/631 HTTP/1.1","referer":"/category/office","agent":"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS122159; GTB7.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; BRI/2)","ident":"-","bytes":86,"host":"104.168.60.183","authuser":"-","status":200,"@id":"34087857818009912463644099750393393661789665600204177408","@timestamp":"2018-06-09T13:53:30.000Z","@message":"104.168.60.183 - - [09/Jun/2018:22:53:30 +0900] \"GET /item/cameras/631 HTTP/1.1\" 200 86 \"/category/office\" \"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS122159; GTB7.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; BRI/2)\"","@owner":"044703681656","@log_group":"apache-loggen","@log_stream":"i-0c7e0dae76fb12256"}

Elasticsearch の bulk API を利用する為, 上記のような 2 つの JSON が改行で連結されたフォーマットになっています. これが出力データ (transform 関数の返り値) となります.

尚, 上記で利用したログデータは apache-loggen という Gem を利用して生成したダミーデータを利用しています.

テストコード

データも用意出来たところで, 以下のようなテストコードを書きました. また, テストされる側のコード (Lambda ファンクションのコード) についても, 適当なファイル名を付けてローカルに保存しておきます. 今回は index.js とし, 以下のテストコードは index.test.js とします.

'use strict';
const assert = require('assert');
const rewire = require('rewire');
const zlib = require('zlib');
const crypto = require('crypto');

const input = { awslogs: { data: 'H4sIAAAAAAAAAN1TUW+bMBD+K5afNq0xZwO2IU/tlmadlmpKkPbQVJUDTmKJQGbM2i7qf99BNamttP2AAQ/Wd98d9313PtGD7Tqzs8Xj0dKcfjovzu8Ws9XqfD6jZ7S9b6xHGJJEQSw1l6lEuG53c9/2R4yYoyn3doLIzjbPoVXw1hww5iZQKguVsUpuN1yIMbnrN13p3TG4trl0dbC+o/kNndWmC67srPHl/rnEXett005sR2/HwrOftgkD+URdhfXjBLTSqdJcA2QZF4mMZZLgUaUQZzF+UnKlM4ltAwhIuFKYg00Eh7qDOaAEngqdpiLhgM/ZHz+wPIeEcamZBMZ1TCb43kAWfembSADXuRB5GucxkA+QAdySNZ3PChK5YA9RaQ7Wmy6SMSefi+JbxBlfUyIAiJbIREKwu9Y/Ru1260qLsTVdtL9cXZsoYUDele3haILb1HZKFqurGdEMpuS7a6r2viPXBUkZn5LCuwpNGVKm5KJYrtBlnmZTMi8uFBNTwq6xp49flwQbYEksXkKCAUtBCfUCixGDJAXJYngDjyhWF6/wFIlK4B8vlleReL+m6K59CN6UwVaXztYVTuxEK9Q7LNJf/cM0b3/0OBWk/dvIkbq1flzNt05iEMfXDFX+Bz9RztgRypngefMY8DrkVA83ad+OZr3e00F/H/Z9N7oz5OCeh35Iwu2jT0+3T78B18fvmPUDAAA=' } };
const elasticsearchBulkData = `{"index":{"_index":"cwl-2018.06.09.13","_type":"apache-loggen","_id":"34087857818009912463644099750393393661789665600204177408"}}\n{"date":"09/Jun/2018:22:53:30 +0900","request":"GET /item/cameras/631 HTTP/1.1","referer":"/category/office","agent":"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS122159; GTB7.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; BRI/2)","ident":"-","bytes":86,"host":"104.168.60.183","authuser":"-","status":200,"@id":"34087857818009912463644099750393393661789665600204177408","@timestamp":"2018-06-09T13:53:30.000Z","@message":"104.168.60.183 - - [09/Jun/2018:22:53:30 +0900] \\"GET /item/cameras/631 HTTP/1.1\\" 200 86 \\"/category/office\\" \\"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS122159; GTB7.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; BRI/2)\\"","@owner":"044703681656","@log_group":"apache-loggen","@log_stream":"i-0c7e0dae76fb12256"}\n`;

describe('Lambda function をテストする', () => {
  it('Elasticsearch に放り込むデータを生成する関数 (transform) をテストする', () => {
    let zippedInput = new Buffer(input.awslogs.data, 'base64');
    zlib.gunzip(zippedInput, function(error, buffer) {
      let awslogsData = JSON.parse(buffer.toString('utf8'));
      let lambda = rewire('./index.js'), transform = lambda.__get__('transform');
      let actual = transform(awslogsData);
      assert.deepStrictEqual(actual, elasticsearchBulkData);
    });
  });
});
  • 実際にテストしている部分というのは assert.deepStrictEqual(actual, elasticsearchBulkData); だけ
  • 他の部分は, 入力データを解凍してデコード, JSON をパースしている部分. これはテストされる側のコードから拝借している
  • assert.deepStrictEqual(actual, expected[, message]) はオブジェクトを再帰的に深い部分まで評価するアサーション
  • ちゃんと使いこなせていないけど, rewire 便利だな

テスト実行

ここまででディレクトリ構成は以下のようになっている状態です.

$ tree . -L 2
.
├── docker-compose.yml
├── dockerfiles
│   └── elasticsearch
├── nodejs4
│   ├── index.js
│   ├── index.test.js
│   ├── node_modules
│   ├── package.json
│   ├── sample.js
│   └── sample.test.js
└── nodejs8
    ├── index.js
    ├── index.test.js
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── sample.js
    └── sample.test.js

5 directories, 13 files

まず, Node.js v4.3 環境でテストをしてみます.

$ cd nodejs4
$ node -v
v4.3.2
$ ./node_modules/mocha/bin/mocha index.test.js


  Lambda function をテストする
    ✓ Elasticsearch に放り込むデータを生成する関数 (transform) をテストする


  1 passing (181ms)

つづいて, 同じコードを Node.js v8.10 環境で動かしてみます.

$ cd ../nodejs8
$ node -v
v8.10.0
$ ./node_modules/mocha/bin/mocha index.test.js


  Lambda function をテストする
    ✓ Elasticsearch に放り込むデータを生成する関数 (transform) をテストする


  1 passing (142ms)

いい感じです.

再掲となりますが, 冒頭に掲げた以下のようなテスト観点について網羅されているのではないかと思います.

  • Lambda 関数から出ていくデータ (Elasticsearch に放り込まれるデータ) が正常に生成されること
  • Lambda 関数から出ていくデータ (Elasticsearch に放り込まれるデータ) について, バージョンによって差異が無いこと

改めて, いい感じです.

以上

たった 1 行のテストでしたが, コードで検証出来たことでちょっとだけ自信を持ってアップグレード出来る気がしています. 引き続き, 異常系や Elasticsearch にデータを放り込む部分についてもテストが出来るようにしたいと考えていますが, 実際に Elasticsearch にデータを放り込むのか, モックを使うのか引き続き調査中です...

また, 今回, JavaScript のテストの書き方について勉強になりました (そもそも JavaScript をまともに書けないけど). テストフレームワークは Mocha を, アサーションライブラリとして power-assert を利用しました. これらのツールについては, 機能としてほんのちょっとしか使えていないと思うので, 引き続き勉強していきたいです.

ということで, 今後も精進したいと思います.