PulumiにおけるServerlessアプリケーション開発

本記事は リクルートライフスタイル Advent Calendar 2019 21日目の記事です。

こんにちは!先月のKubeConのセッションを消化しきれないままの年越しを危惧している小谷野(@bandwagondagon)です。

みなさんのチームでは、インフラリソースのプロビジョニングには何かツールを使っていますでしょうか? 弊社ではTerraform Enterpriseの導入事例で取り上げているようにTerraformを利用しているチームが多いです。

TeraformはHCLによってインフラコードを記述するプロビジョニングツールです。このHashiCorp社製言語のHCLは、開発者によって作成・変更がしやすく、さらにJSON互換でマシンフレンドリーという設計思想に基づいて開発されています。

一方、最近はAWS CDK がGAになったり、PulumiのVer1.0がリリースされたりなど、TypeScriptやGoなどのプログラミング言語で、インフラのコードを実装するアプローチのプロビジョニングツールの開発も盛んになっているように感じます。

そんな中、個人的にTerraformとAWS CDK・Pulumiの機能や開発体験を比較する機会があったのですが、Pulumiは小規模なSeverlessアプリケーション開発において特に相性がよいのでは、感じることがあったので、本記事で紹介したいと思います。

Pulumiとは?

Pulumi社が開発しているOSSで、JavaScript, TypeScript, Python, Go, .NET系言語などの言語を用いてインフラのコードを記述できるプロビジョニングツールです。 Terraform同様にマルチクラウドをサポートしており、各クラウドのプロパイダーや、TerraformのプロパイダーをPulumiのプロパイダーへ変換するようなツールも公開されています。

基本機能については公式のドキュメントをご参照下さい。

Pulumiはクラウド版での機能が提供されており、無料版・有料版のプランがあります。基本的にはSaaSの利用を推奨しているようですが、ローカルでの利用も可能です。

さて、Pulumi社のFounderであるJoe Duffy氏のブログ記事や、Pulumi社が公開しているWhite Paperを見ると、Pulumiが生まれた背景や、ツールとしての特徴や思想を知ることができます。White Paperでは、Pulumiフレームワークの思想として

  • Multi-Language Runtime
  • Cloud Object Model
  • Multi-Technology Scope
  • Multi-Cloud Scope

を掲げているのですが、その中でも個人的に注目した

  • Cloud Object Model

に関連して、まずはPulumiの仕組みや説明します。

Cloud Object ModelとPulumiの仕組み

Cloud Object ModelはPulumiの中心的な概念のひとつであり、任意のプログラミング言語を用いてクラウドのリソースを宣言的に作成することを可能にします。この仕組みを理解するためには、まずPulumiがどのようにコードを元にインフラリソースをプロビジョニングしているかを知る必要があります。

PulumiはCLI上の pulumi up コマンドによってプロビジョニングするのですが、その際の処理の流れは以下の通りです。

  1. コードをパースしてリソース間の依存関係を解決し、実現したいリソースの状態を表現する有向非巡回グラフ(DAG)を形成
  2. 前回リソースを更新した際に保存したDAGをStateファイルから形成
  3. 新しいDAGと前回のDAGを比較して差分を検出し、差を埋めるためにどんなCRUDを実行するべきかを計算
  4. 計算結果を元に各Providerを使用して新たなリソースの状態をプロビジョニング
  5. 新しいDAGをStateファイルとして保存

公式より引用

パースされる元のプログラム言語が異なることを除けば、処理の流れ自体はTerraformと大差なく、Terraformに慣れているという方にとっては馴染み深いものではないでしょうか。 このように、Pulumiではプログラミング言語によってインフラコードを記述するものの、あくまでそのままコードが実行されるわけではなく、Pulumiエンジン上で解釈・変換された上で実行されています。

次に、Cloud Object Modelによる宣言的なリソースの記述を、AWS SDKとの比較から見てみましょう。

以下はPulumiでAWS DynamoDBのテーブルを作成するコードの例です。

1
2
3
4
// DynamoDBのテーブルリソースに紐づくCloud Objectを作成
let table = new aws.dynamodb.Table("customer_list", {
  // テーブル定義
});

一方こちらはAWS SDKを用いてDynamoDBのテーブルを作成する例です。

1
2
3
4
5
6
7
8
9
10
11
12
13
// DynamoDBに接続するためのService Objectを取得
let dynamodb = new AWS.DynamoDB({apiVersion: '2012-08-10'});
// テーブル定義
let params = {
  TableName: 'customer_list',
  // (他のパラメーターは省略)
};
// 手続き的にテーブルを作成
dynamodb.createTable(params, function(err, data) {
  // 成功時・エラー時のコールバックを定義
});

これらのコードをそれぞれ実行したときの挙動を表にまとめました。

DynamoDBの customer_list テーブルの状態 Pulumiの挙動 AWS SDKの挙動
まだ存在していない Create Create
既に存在していて、パラメーターに変化がある Update Createしようとするがエラー
既に存在していて、パラメーターに変化がない 何もしない Createしようとするがエラー

AWS SDKは、基本的にはAWSの各サービスから提供されているAPIのCRUDを抽象化したものです。 Createは冪等な処理ではないので、上記のコードを継続的に何度も実行するとしたら、既にテーブルが存在するかどうかによって処理を分岐させるようなコードを自分で追加しなければなりません。

一方、PulumiはCloud Object Modelによって、CRUDのどのアクションをすべきかのロジックも aws.dynamodb.Table オブジェクトとそれに紐づくPulumiエンジン側に内包しています。したがって開発者は、クラウドから提供されるCURD操作のためのAPIをあまり意識することがなく、インフラリソースをコード化することができます。

SDKとPulumiのより踏み込んだ違いに関してはこちらをご参照下さい。

※ここではPulumiのコード例を取り上げましたが、AWS SDK・AWS CDK間でも同様の比較ができます。

PulumiにおけるServerlessアプリケーション開発

では実際にCloud Object Modelを用いてServerlessアプリケーション開発している例をいくつか見てみましょう。

ここでは、AWS Lambdaを用いたServerlessアプリケーション開発の例を2つ紹介します。 まずは、公式サイトにある、API Gatewayを用いてAWS Lambdaを公開エンドポイントから叩けるようにするコードの例です。

大まかなコードの流れと実装は以下の通りです。

  1. DynamoDBのテーブル定義・作成
  2. awsx.apigateway.API を用いてAPI GatewayやAWS Lambdaなどの関連リソースを作成
  3. eventHandler としてLambdaで実行するアプリケーションのコードを記述
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
// 1. DynamoDBのテーブル定義・作成
let counterTable = new aws.dynamodb.Table("counterTable", {
    attributes: [{ name: "id", type: "S" }],
    hashKey: "id",
    readCapacity: 5,
    writeCapacity: 5,
});
// 2. awsx.apigateway.APIを用いてAPI GatewayやAWS Lambdaなどの関連リソースを作成
let endpoint = new awsx.apigateway.API("hello-world", {
    routes: [{
        path: "/{route+}",
        method: "GET",
// 3. Lambdaで実行するアプリケーションのコードを記述
        eventHandler: (req, res) => {
            let route = event.pathParameters!["route"];
            let client = new aws.sdk.DynamoDB.DocumentClient();
            let tableData = await client.get({
                TableName: counterTable.name.get(),
                Key: { id: route },
                ConsistentRead: true,
            }).promise();
            let value = tableData.Item;
            let count = (value && value.count) || 0;
            await client.put({
                TableName: counterTable.name.get(),
                Item: { id: route, count: ++count },
            }).promise();
            return {
                statusCode: 200,
                body: JSON.stringify({ route, count }),
            };
        },
    }],
});

このコードからわかるPulumiの特徴に

  • AWS Lambda上で実行するアプリケーションのコードと、インフラを定義するコードを一緒に記述できる (eventHandlerとして定義した関数がAWS Lambda用に変換された上でZip化されてAWS Lambdaにアップロードされる)
  • インフラコード内で宣言しているDynamoDBのテーブル名をアプリケーションコード内で参照できる(ビルド時に定数として置き換えられる)

というものがあります。これらは

  • アプリケーションコードとインフラコードが同じプログラミング言語で記述できる
  • プロビジョニング時に、アプリケーションコードやインフラコードが直接実行されるわけではなく、Pulumiのエンジンによって変換された上で実行される

というPulumiの特徴をフルに活用した実装方法ではないでしょうか。

次に、TypeScriptを用いたS3とAWS Lambdaの例です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
// S3バケットを作成
const tpsReports = new aws.s3.Bucket("tpsReports");
const tpsZips = new aws.s3.Bucket("tpsZips");
// tpsReportsバケットにオブジェクトが作成されたときに、AWS Lambdaを立ち上げる。
// 2番目の引数として渡されたasync関数をアプリケーションコードとしてLambda上で実行する
tpsReports.onObjectCreated("zipTpsReports", async (e) => {
//  tpsReportsから取得したデータをzip化してtpsZipsに保存するアプリケーション
    const admZip = require("adm-zip");
    const s3 = new aws.sdk.S3();
    for (const rec of e.Records || []) {
        const zip = new admZip();
        const [ buck, key ] = [ rec.s3.bucket.name, rec.s3.object.key ];
        console.log(`Zipping ${buck}/${key} into ${tpsZips.bucket.get()}/${key}.zip`);
        const data = await s3.getObject({ Bucket: buck, Key: key }).promise();
        zip.addFile(key, data.Body);
        await s3.putObject({
            Bucket: tpsZips.bucket.get(),
            Key: `${key}.zip`,
            Body: zip.toBuffer(),
        }).promise();
    }
});

tpsReports.onObjectCreated メソッドにアプリケーションコードとしてasync関数を渡すことで、S3の tpsReports バケットにオブジェクトが作成された際に、AWS Lambdaにどのような処理をさせるかを一気に定義できます。

AWS LambdaなどのFaaSを利用して小規模なServerlessアプリケーションを作成する際、アプリケーション内で何かしらのStateを扱ったり、Eventと紐付けたりしようとすると、Serverlessアプリケーションを実行する部分以外のインフラのリソースや設定も多く必要になることがあります。

私はプロトタイピング的にちょっとしたSeverlessアプリケーションをデプロイするよくあるのですが、AWS Lambdaなどを使うとSeverless環境セットアップ部分の作業が非常に簡単だと感じる反面、それ以外の周辺環境のセットアップの作業量が相対的に多くなってしまい、やや煩雑に感じてしまうことがありました。

そんな状況においては、このアプリケーションコードとインフラコードを同じコンテキストで同時に記述する方法によって、操作すべき言語やツールがの数が少なくなるという面で、より開発スピードを上げることができるのではないでしょうか。

一方、記述の自由度がTerraformなどと比較して高いため、開発者間での実装方針にばらつきを出やすくなったりはするなど、どのようにメンテナブルなコードを維持していくかの課題はより大きくなりやすいのではないかと感じます。

まとめ

本記事では、Pulumiの仕組みと、小規模なサーバーレスアプリケーション開発の例を紹介しました。また、ここでは取り上げられませんでしたが、Pulumiは他にも、Kubernetesに関連したリソースの管理ができたりPolicy as Codeを実現したりと、Multi-Technology Scopeなソフトウェアです。開発スピードも早く、続々と新機能もBlogでアナウンスされているので、気になる方はぜひチェックしてみて下さい!