前回は、PulumiでGoogle Cloud FunctionsにTypeScriptの関数をデプロイする方法という投稿をしました。そこでは、PulumiではインフラもGoogle Cloud Functionsの実装もTypeScriptで書け、かつ、インフラと関数を一体化して1つのファイルで管理できることを説明しました。
PulumiはかなりTypeScriptフレンドリーなDevOpsが実現できるわけですが、ひとつ残念な点があります。それは、Pulumiの構成ファイルに直書きした関数はES2015(ES6)としてコンパイルされデプロイされるという点です。
この投稿では、PulumiでTypeScript製のCloud Functionsをデプロイする際、その関数のtargetをES2019にする方法を紹介します。
Pulumiがデプロイする関数はES2015にコンパイルされるのが玉にキズ
Pulumiの売りはCloud Functionsの実装をインフラ構成に混ぜて、シームレスに書けるところでした。それはそれで便利なのですが、PulumiがトランスパイルするTypeScriptコードは、古めのJavaScriptであるES2015になってしまいます。
そのため、次のようなES2015より後に導入された構文、例えばawait
(ES2017)などを使った関数は、
import * as gcp from "@pulumi/gcp";
// デプロイしたい関数
const helloWorld: gcp.cloudfunctions.HttpCallback = async (req, res) => {
const message = await Promise.resolve("Hello World!"); // await(ES2017の構文を使用)
res.send(message);
};
// 上の関数のデプロイに関するインフラの設定
const helloWorldFunction = new gcp.cloudfunctions.HttpCallbackFunction(
"helloWorldFunction",
{ runtime: "nodejs12", callback: helloWorld }
);
そのシンタックスを使わないコードにトランスパイルされていまいます:
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
const helloWorld = (req, res) => __awaiter(void 0, void 0, void 0, function* () {
const message = yield Promise.resolve("Hello World!"); // await(ES2017の構文を使用)
res.send(message);
});
もちろん、ES2015でもCloud Functionsでは問題なく実行されるのですが、今日現在のCloud FunctionsはNode.js 12に対応していてES2019までの構文が使えるはずなので、デプロイされるコードもES2019としてコンパイルしてほしいところです。そのほうが、await
などがNode.jsのネイティブなパフォーマンスで実行されたり、可読性が上がることでデバッグがしやすかったりとメリットがあるはずです。
tsconfig.jsonのtargetを変えても意味がない
Pulumiを初期化すると、プロジェクトディレクトリにtsconfig.jsonを作ってくれます。その設定のデフォルトはtarget
がes2016
になっています。一見すると、これをes2019
にすれば、デプロイされるCloud Functionsもそれに従いそうです。
実際は、tsconfig.jsonのtarget設定を変更しても、それはCloud Functionsのトランスパイルには影響しません。Pulumiはes2015をtargetにしてCloud Functionsをトランスパイルしてしまいます。これは仕様です。現にPulumiのトランスパイル処理では、target=es2015がハードコーディングされていて、設定からこれを変える手立てはありません。このハードコーディング問題はIssueにもなっていますが、解決はされていません:
Pulumi overrides tsconfig.json target · Issue #2673 · pulumi/pulumi
以上が、PulumiでTypeScript製の関数をデプロイするにあたって受ける制約の話になります。
PulumiでデプロイされるTypeScript製Cloud FunctionsをES2019にする小技
では、PulumiでTS製の関数をデプロイする際、そのコンパイルターゲットをES2019にするにはどうしたらいいか見て行きましょう。
Pulumiが提供するAPIのgcp.cloudfunctions.HttpCallbackFunctionは、関数の実装をそのまま渡せて便利ですが、まずこの使用は諦める必要があります。
その代わりに、関数をZIPに固めてアップロードする方法を採用します。「えっ?ZIPで固めてからデプロイ?コマンド一発でインフラも関数もデプロイできるのがPulumiの良さだったのに、ひと手間増えるの?めんどくさそう!」そう思った皆さん、ちょっと待ってください。ここで紹介する方法は、思ったほど面倒ではありません。ZIPで固めると言いましたが、自分でZIPを作る必要もありませんし、デプロイも今まで通りpulumi up
だけでできます。なので心配しないで下さい。ではどのようにやるのか順を追って見ていきましょう。
手順1: 関数を別パッケージにする
まず、PulumiとCloud Functionsの実装がくっついていると、どうしてもES2019にはできないので、ファイルを分けます。関数の実装は、ディレクトリを掘ってそこのindex.tsに移動します:
.
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── helloWorld
│ └── index.ts ← helloWorld関数をここに移動
├── index.ts
├── package.json
└── tsconfig.json
移動した関数の中身は、Pulumiを使わずにCloud Functionsの関数を実装したときのようになります:
import * as express from "express";
export const helloWorld = async (
req: express.Request,
res: express.Response
) => {
const message = await Promise.resolve("Hello World!"); // await(ES2017)
res.send(message);
};
次に、helloWorldディレクトリを単なるサブディレクトリではなく、NPMパッケージにしていきます。具体的には、helloWorldディレクトリに移動して、yarn init
してpackage.jsonを作ります。
cd helloWorld
yarn init
手順2: TypeScriptを入れてtargetをES2019にする
パッケージの準備ができたら、helloWorldパッケージにTypeScriptとexpressの型定義をインストールします:
yarn add -D typescript @types/express
TSをインストールしたら、TypeScript Compilerを設定を作り、targetをES2019にします:
npx tsc --init
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
ここまでの手順で、helloWorldディレクトリには以下の4つのファイルとnode_modules
ディレクトリができているはずです:
helloWorld
├── node_modules
├── index.ts
├── package.json
├── tsconfig.json
└── yarn.lock
手順3: GCPデプロイ時にトランスパイルされるようにする
続いて、GCPにデプロイ時に、TypeScriptからJavaScriptへコンパイルされるように、package.jsonの設定を加えて行きます。GCPはpackage.jsonのscripts
フィールドにgcp-build
があると、関数をデプロイしたときにそのコマンドを実行してくれます。これを利用して、デプロイ時トランスパイルを実現します:
{
/* ...中略... */
"scripts": {
"gcp-build": "tsc"
}
}
もちろん、ローカルでtsc
したりWebPack
でバンドルしてからデプロイする方法も考えられますが、pulumi up
コマンド一発でデプロイする体験を崩さないようにするには、Google Cloudにビルドをおまかせするのが手っ取り早いです。
手順4: デプロイされるZIPファイルの内容を決める
あとは、このhelloWorldパッケージをZIP化してデプロイするようにすればいいのですが、どのファイルをZIPに含めるかは、npm pack
のアルゴリズムに任せるのが最も手軽です。npm pack
のアルゴリズムでは、package.jsonは含めるが、node_modulesは除外するなどといった、パッケージをリリースするのに特化した条件が組み込まれています。これは普通NPMのレジストリにパッケージを登録するためのものですが、Google Cloud Functionsに関数をデプロイするシーンでも流用できる条件になります。自前でZIPファイルの内容を決めるロジックを実装するよりは、npm pack
のアルゴリズムに任せたほうが断然楽です。
ということで、npm pack
が活用できるよう、package.jsonの設定を加えていきます。ここでは、デプロイに必要なのにnpm pack
が取りこぼしてしまうファイルを追加していく手順になります。package.jsonは次のようになります:
{
/* ...中略... */
"files": [
"**/*.ts",
"tsconfig.json",
"yarn.lock"
]
}
npm pack
はデフォルトでは、yarn.lockなどを除外してしまうので、files
フィールドでZIPに含まれるように調整しました。files
フィールドを定義すると、npm packはpackage.jsonなどを除き、そこに書かれているファイルだけをZIPに含めるようになるので、加えてtsファイルやtsconfig.jsonも連ねておく必要があります。ちなみに、含まれるファイル一覧はnpm pack --dry-run
で確認できます。
以上で、helloWorldパッケージで必要な設定は終わりです。
一応念の為、index.tsがちゃんとコンパイルできるか確認しておきます:
npx tsc --noEmit
手順5: Pulumi側のインフラ構成を直す
続いて、Pulumi側のインフラ構成ファイルを直して行きます。helloWorldディレクトリから抜けて、カレントディレクトリはPulumi.yamlがあるディレクトリに移動しておいてください。
まず、npm pack
のアルゴリズムをPulumi側で使いたいので、そのパッケージである[npm-packlist]をインストールします:
npm install npm-packlist @types/npm-packlist
次に、index.tsを直しますが、その先に完成形を示しておこうと思います。各部分の説明はその後にします。
import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";
import packlist from "npm-packlist";
// Cloud Functions APIを有効化する
const cloudFunctionsService = new gcp.projects.Service(
"cloudFunctionsService",
{
disableDependentServices: true,
service: "cloudfunctions.googleapis.com",
}
);
// helloWorldパッケージのZIPの内容を定義する
const assetArchive = new pulumi.asset.AssetArchive(
packlist({ path: "./helloWorld" }).then((files) => {
const map: pulumi.asset.AssetMap = {};
for (const file of files) {
map["./" + file] = new pulumi.asset.FileAsset("./helloWorld/" + file);
}
return map;
})
);
// Cloud Functionsの関数のZIP置き場をStorageに作る
const cloudfunctionsBucket = new gcp.storage.Bucket("cloudfunctions");
// helloWorldパッケージのZIPをStorageにアップロードする
const helloWorldZip = new gcp.storage.BucketObject("helloWorld", {
bucket: cloudfunctionsBucket.name,
name: "helloWorld.zip",
source: assetArchive,
});
// デプロイする関数のインフラ構成定義
const helloWorldFunction = new gcp.cloudfunctions.Function(
"helloWorldFunction",
{
name: "helloWorld",
runtime: "nodejs12",
sourceArchiveBucket: cloudfunctionsBucket.name,
sourceArchiveObject: helloWorldZip.name,
triggerHttp: true,
entryPoint: "helloWorld",
},
{ dependsOn: cloudFunctionsService }
);
// helloWorld関数を誰でも呼び出せるようにする設定
const helloWorldInvoker = new gcp.cloudfunctions.FunctionIamMember(
"helloWorldInvoker",
{
cloudFunction: helloWorldFunction.name,
role: "roles/cloudfunctions.invoker",
member: "allUsers",
}
);
// デプロイ後に表示する情報
export const functionUrl = helloWorldFunction.httpsTriggerUrl;
ご覧のとおり、gcp.cloudfunctions.HttpCallbackFunction
を使っていたときと比べると、たった3行で書けていたものが、格段に記述量が増えました。これは、それだけHttpCallbackFunction
クラスが高レベルなものであり、隠蔽していた定義がこれだけあったということです。量は多いですが、ひとつひとつは難しくないので、各設定の内容を見ていきましょう。
まず、helloWorldパッケージのZIPの内容を決める部分です:
// helloWorldパッケージのZIPの内容を定義する
const assetArchive = new pulumi.asset.AssetArchive(
packlist({ path: "./helloWorld" }).then((files) => {
const map: pulumi.asset.AssetMap = {};
for (const file of files) {
map["./" + file] = new pulumi.asset.FileAsset("./helloWorld/" + file);
}
return map;
})
);
ZIPの内容は、helloWorldパッケージのpackage.jsonで設定したものが効いてきますので、ここで改めて何らかのリストを作る必要はありません。npm pack
のアルゴリズムを使えさえすれば良いので、ここではそのpacklist
関数を使い、helloWorldディレクトリのファイルリストを取って来ます。そして、ファイルリストをPulumiが扱えるように、AssetMap型に変形するのがここの主たる処理です。
こうして作ったZIPは、Google Storageにアップロードされる必要があります。それを行うのが次の部分です:
// Cloud Functionsの関数のZIP置き場をStorageに作る
const cloudfunctionsBucket = new gcp.storage.Bucket("cloudfunctions");
// helloWorldパッケージのZIPをStorageにアップロードする
const helloWorldZip = new gcp.storage.BucketObject("helloWorld", {
bucket: cloudfunctionsBucket.name,
name: "helloWorld.zip",
source: assetArchive,
});
この部分は、HttpCallbackFunction
クラスを使っていたときは隠蔽されていたので気にする必要がありませんでしたが、今回はそれが使えないので、明示的に定義する必要が出てきた部分です。
続いて、Cloud Functionsの設定です:
// デプロイする関数のインフラ構成定義
const helloWorldFunction = new gcp.cloudfunctions.Function(
"helloWorldFunction",
{
name: "helloWorld", // これはURLになる部分なので自由に決めていい
runtime: "nodejs12",
sourceArchiveBucket: cloudfunctionsBucket.name,
sourceArchiveObject: helloWorldZip.name,
triggerHttp: true,
entryPoint: "helloWorld", // helloWorld/index.tsでexport constした変数
},
{ dependsOn: cloudFunctionsService }
);
ここでは、先程決めたアップロード先の情報を設定したり、呼び出す関数の変数名を指定したりします。
最後に、Cloud Functionsは誰が呼び出せるかを決めるところです:
// helloWorld関数を誰でも呼び出せるようにする設定
const helloWorldInvoker = new gcp.cloudfunctions.FunctionIamMember(
"helloWorldInvoker",
{
cloudFunction: helloWorldFunction.name,
role: "roles/cloudfunctions.invoker",
member: "allUsers", // 誰でも呼び出しOK
}
);
以上がPulumi側のインフラ構成の定義になります。
この構成ファイルを使って、pulumi up
でデプロイすると、デプロイされた関数はES2019をtargetにコンパイルされたものになります。