スマートニュース株式会社の尾形 (@nobu666) です。インフラ専任エンジニアが一人もいない弊社ですので、自分もインフラエンジニアと名乗らずに、飲酒系エンジニアとか言っておこうと思っております。

さて、今回は軽めのネタをご紹介させていただこうと思います。弊社では全面的に AWS を採用しており、2015年6月に Lambda が Asia Pacific (Tokyo) のリージョンで利用可能になりましたので早速使ってみました。AWS Lambda の詳細については、製品ページをご覧ください。

やりたいこと

弊社ではCDNとして Amazon CloudFrontAkamai Download Delivery を併用しています。その中でも、ニュース記事のサムネイル画像なんかは Amazon S3 を Origin にして画像の配信を行っています。あまりアグレッシブに Cache してしまうと画像の差し替えがあった時に困るのと、そもそも Cache Invalidate を管理画面から手動でやらなくてはならないため面倒です。Lambda が使えるようになったので、だったら S3 にファイルが上がった時点で勝手に Cache Invalidate するようにできれば、アグレッシブな Cache を行ってもいいし、人間が行う作業も減るし一石二鳥というわけです。

やってみる

入門ガイドが用意されているので、基本的にそれに従って準備を進めます。

IAMロールの設定

以下のような感じで logs / s3 / lambda / cloudfront それぞれの権限をつけてやります。 Resource 部分の* は必要に応じて絞るなりなんなりしてください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudfront:ListDistributions",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

コーディングする

今のところサポートされているのは Node.jsJava の2つだけです。今回は Node.js でサクっと書いていきます。

var request = require('request');
var HashMap = require('hashmap');
var aws = require('aws-sdk');
var akamai = require('akamai');
var Q = require('q');

var s3 = new aws.S3({ apiVersion: '2006-03-01' });
var kms = new aws.KMS();

function makeMap() {
    var map = new HashMap();
    map
        .set('<your-bucket-name1>', { name: '<your-domain1>', is_akamai: true })
        .set('<your-bucket-name2>', { name: '<your-domain2>', is_akamai: false })
        ;

    return map;
}

function invalidateAkamai(params, success, error) {
    console.log('invalidating Akamai');
    var password;
    kms.decrypt({
        CiphertextBlob: new Buffer('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'base64')
    }, function(err, data) {
        if (err) {
            context.fail(err);
        } else {
            password = data.Plaintext.toString();
            var invalidate_url = 'http://' + map.get(params.Bucket).name + '/' + params.Key;
            akamai.invalidate.production.url('<your-akamai-username>', password, [invalidate_url]).then(function(resp) {
                success();
            }).catch(function(err) {
                error();
            });
        }
    });
}

function invalidateCloudFront(params, success, error) {
    console.log('invalidating CloudFront');
    var cloudfront = new aws.CloudFront();
    cloudfront.listDistributions({}, function (err, data) {
        var promises = [];
        if (err) {
            error();
            return;
        }

        data.Items.map(function (distribution) {
            var deferred = Q.defer();
            var exists = false;

            distribution.Origins.Items.map(function (origin) {
                if (exists) return;

                if (origin.DomainName.indexOf(params.Bucket) === 0) {
                    exists = true;
                    var name = distribution.DomainName;
                    if (distribution.Aliases.Quantity > 0) {
                        name = distribution.Aliases.Items[0];
                    }
                    var invalidation_param = {
                        DistributionId : distribution.Id,
                        InvalidationBatch : {
                            CallerReference : '' + new Date().getTime(),
                            Paths : {
                                Quantity : 1,
                                Items : [ '/' + params.Key ]
                            }
                        }
                    };

                    cloudfront.createInvalidation(invalidation_param, function (err, data) {
                        if (err) {
                            deferred.reject();
                            return;
                        }
                        deferred.resolve();
                    });
                }
            });
            if (!exists) {
                deferred.resolve();
            }
            promises.push(deferred.promise);
        });
        Q.all(promises).then(function() {
            success();
        });
    });
}

function is_akamai(params) {
    if (map.get(params.Bucket).is_akamai) {
        return true;
    } else {
        return false;
    }
}

function invalidate(params, success, error) {
    if (is_akamai(params)) {
        invalidateAkamai(params, success, error);
    } else {
        invalidateCloudFront(params, success, error);
    }
}

var map = makeMap();

exports.handler = function(event, context) {
    var params = {
        Bucket: event.Records[0].s3.bucket.name,
        Key: event.Records[0].s3.object.key
    };
    var invalidate_url = 'http://' + map.get(params.Bucket).name + '/' + params.Key;

    request.head(invalidate_url, function(err, resp) {
        var headEtag;
        if (!resp.headers.hasOwnProperty('etag')) {
            headEtag = "";
        } else {
            headEtag = resp.headers.etag;
        }
        if ('"' + event.Records[0].s3.object.eTag + '"' === headEtag) {
            console.log('skip invalidate');
            context.succeed('skip invalidate');
        } else {
            invalidate(params, function() {
                context.succeed(invalidate_url + ': invalidate request created');
            }, function() {
                console.log(err.toString());
                context.fail(err.toString());
            });
        }
    });
};

若干長ったらしいですが、要点をまとめると

  • S3 の バケット名とドメイン名、 invalidate する先が Akamai なのか CloudFront なのかの情報を mapping する
    • これは後述する ETag を取りたいために使っています。問答無用で invalidate する場合は不要です
  • 対象となる URL に対して HEAD リクエストを送って ETag を取得し、S3 に置かれたファイルの ETag と比較します。なぜこんなことをするかというと、 S3 の Event Notifier はファイルの新規作成と更新の区別が付かないからです
    • 弊社のバケットはガンガンファイルが更新されてしまうため、何も考えずに invalidate していると大変なことになってしまうという事情もあります
  • Akamai の API は ユーザ名とパスワードを渡さないと叩けないのですが、パスワードを生でハードコードしたくないため AWS Key Management Service を利用して暗号化したものを Base64 エンコードして使っています

Lambda Function の作成

コンソールから Lambda Function を作成します。

0f11501a-f0b9-edaf-c208-cc09fbad9434

blueprint を選べるようになっているので、 s3-get-object を選択して event を発生させるバケットを選択したり Event Type を選択したりします。今回は Event Typeは PUT を使います。

Name / Runtime / Role(さっき作ったIAM Role) / Memory / Timeout を適当に設定します。 Handler はそのままで大丈夫です。コード本体ですが、これはあとでアップロードするのでひとまずそのままで大丈夫です。

コードのアップロード

修正するたびにコンソール開いてアップロードするのも面倒なので、ターミナルでコードを書いたその流れでひと通りのことができるようにしてしまいます。

package.json を以下のように書きます。

{
    "name": "<your-function-name>",
    "version": "0.0.1",
    "private": true,
    "engines": { "node": "0.12.7" },
    "main": "index.js",
    "dependencies": {
    },
    "devDependencies": {
        "aws-sdk": "*",
        "hashmap": "*",
        "request": "*",
        "akamai": "*",
        "q": "*"
    },
    "scripts": {
        "init": "node ./script/init.js",
        "build": "npm run init && node ./script/build.js",
        "publish": "npm run init && npm run build && node ./script/publish.js"
    }
}

npm run init で Lambda Function の設定用 JSON を生成、 npm run build でコードを zip、 npm run publish でコードのアップロードと設定の反映を行います。他にも lint するとか test するとかできると思いますが今回は割愛します。というか AWS Lambdaの関数をnpmでパッケージ管理という記事でまとめられていますので、そちらをご参照ください。

あとは npm install してから、 npm run publish すればオシマイです。もっと頑張るなら Circle CI とか Travis CI とかで、上記コマンドが動くようにしておけば git push するだけでコードが反映されるように出来ます。

注意点

1つの Lambda Function で、複数の Event をハンドリングすることができます。つまり上記の作業を済ませてしまえば、あとはコンソールの `Event sources タブから CDN の Origin として使っている S3 のバケットを追加していけばいいです。

ただ落とし穴がありまして、バケット名が .com で終わっている場合、コンソールから情報を見ることができません。

e61cd5db-b516-20e2-506b-5b5109e49a88

修正作業中らしいのでそのうち治ると思いますが、一時的に特定のバケットに対してこの機能を止めたい、というときに通常フローだと上記の画面で Enabled の部分をクリックすれば Disabled にすることができます。ですが .com なバケットの場合、 State 列が空っぽになっているためこの操作を行うことができません。

aws lambda get-policy コマンドで設定済みのバケットについての情報が取れてきますので、無効化したい部分を削除して put-policy し、該当バケットの Notification も削除してやることで無効にできます。

おまけ - hubot による Cache Invalidate

弊社では Slack をチャットツールとして使っており、そこに地球くんが住んでいます。 invalidate じゃなくて remove したいとか、複数の URL をまとめて処理したいとか、様々な理由から手動でやりたいということもあり、チャットからもできるようになっています。

4ab5c42f-a8fc-99eb-2643-243880d3a130

地球くんかわいいよ地球くん

まとめ

AWS では魅力的な新サービスがどんどんでてくるので、いままで自力で頑張らなければできなかったことが、ちょっとした労力で簡単にできるようになっています。今回は Cache Invalidate の話でしたが、他にも Lambda でできることは沢山ありそうです(実際それ以外の用途でも利用しています)。

これ以外にももっとよくできること、自動化したいこと、などが沢山あります。一緒により良いものを作ってくれる仲間を絶賛募集中です!

http://about.smartnews.com/ja/careers/