ちょっと利用したくなったのでKotlin 1.4のキャッチアップも兼ねて作りました。
環境は
- Kotlin 1.4.21
- dukat 0.5.8-rc.3
- Node 12
です
こんな感じで任意のURLのキャプチャがSlackに投稿されるものです。

前回触った記事はこちら
sakebook.hatenablog.com
kotlin 1.4でどう変わった
KotlinのJSの実装は package_imported/
というフォルダに展開されていました。しかしプロジェクト の package.json
にその参照はされていませんでした。なので、解決のためにはnpm registryから取得してきていました。
しかし、1.4から package.json
に、ローカルpathが参照されるようになりました(file:/XXX)。なので、以前のように外部から無理やり依存関係を解決しなくても良くなりました。
ただしこれはこれでCloud Functiionsへのデプロイ時に一部修正が必要です(後述)。
1.4以前
{
"main": "kotlin/kotlin-js-cloud-function-linebot.js",
"devDependencies": {
"source-map-support": "0.5.16"
},
"dependencies": {
"kotlin": "1.3.72",
"kotlinx-serialization-kotlinx-serialization-runtime": "0.20.0",
"@jetbrains/kotlin-extensions": "^1.0.1-pre.91",
"axios": "^0.19.2"
},
"peerDependencies": {},
"optionalDependencies": {},
"bundledDependencies": [],
"name": "kotlin-js-cloud-function-linebot",
"version": "1.0.0-SNAPSHOT"
}
1.4以後
{
"main": "kotlin/kotlin-js-headless-chrome.js",
"devDependencies": {
"dukat": "0.5.8-rc.3",
"source-map-support": "0.5.19"
},
"dependencies": {
"axios": "0.21.0",
"puppeteer": "5.5.0",
"form-data": "3.0.0",
"@google-cloud/secret-manager": "3.2.2",
"kotlin": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin/1.4.21",
"kotlinx-coroutines-core": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-coroutines-core/1.3.9",
"kotlinx-atomicfu": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-atomicfu/0.14.4",
"kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
"17def1782b5ee417-kotlinx-nodejs-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
"kotlin-test-js-runner": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test-js-runner/1.4.21",
"kotlin-test": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test/1.4.21"
},
"peerDependencies": {},
"optionalDependencies": {},
"bundledDependencies": [],
"name": "kotlin-js-headless-chrome",
"version": "0.0.1"
}
dukatはKotlin/JSのpluginに統合され、利用しやすくなりました。具体的には、次のように引数で指定してあげることで generateExternalsIntegrated
タスクが実行され、ソースコードを生成してくれます。
implementation(npm("axios", "0.21.0", generateExternals = true))
なので package.json
のdevDependenciesに含まれているわけです。
また、CoroutineやSerializationも同様に利用しやすくなっています。
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC")
採用したのはPuppeteer
Cloud FunctionsでPuppeteerが利用可能になったのは知っていたので、同様にHeadless Browserを扱えるPlaywrightを利用しようとしたのですが、結論うまくいきませんでした。
github.com
github.com
デプロイ時にchromiumを含めると、ファイルサイズの制約でデプロイに失敗します。なので、デプロイ後のmain関数実行時にchromiumをDLする流れを取っているPuppeteerを採用しました。
関数実行時にchromiumをDLする流れ
Playwrightでも環境変数を指定してあげれば同じ流れになるはずなのですがうまく行っていません。issueは上げたのでどこかのバージョンで解決されると思います。
ちなみにどうしてもPlaywrightを使いたい場合、playwright-aws-lambdaを利用すれば可能です。こちらはchromiumを含んでいますが、圧縮することでファイルサイズの制約を回避しています。
github.com
Cloud Functionsへのデプロイ
Cloud Functionsへのデプロイは、今回はCloud Buildで行いました。
Kotlin/JSをCloud Functionsで動かすには次の3パターンの方法が考えられます。
- すべて込みのjsファイルを作る
- package-lock.json込みであげて依存関係を解決する
- package.json込みでdeploy時に実行してもらう
すべて込みのjsファイルを作る
こちらはひとつのjsファイルにすべての実装を詰め込んだものを用意して動かすパターンです。あまり筋が良くないので却下しました。
package-lock.json込みであげて依存関係を解決する
こちらは node_modules/
をデプロイ時のソースコードに含むものです。前述の通り、chromiumを含むことになるのでファイルサイズの問題で却下しました。
package.json込みでdeploy時に実行してもらう
なので消去法でこちらの方法でデプロイします。
Cloud Functionsデプロイのために、タスクを定義します。
tasks {
val packaging by creating(Copy::class) {
val directory = "functions"
from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/", "build/js/packages/${project.name}/package.json")
exclude("**/packages/", "node_modules/", "node_modules.state", "yarn.lock")
into("$directory/")
rename { it.replace("${project.name}.js", "index.js") }
doLast {
val jsonFile = file("$directory/package.json")
val texts = jsonFile.readLines()
.map { it.replace("kotlin/${project.name}.js", "index.js") }
.map { it.replace("file:${project.projectDir.absolutePath}/build/js/", "") }
jsonFile.writeText(texts.joinToString("\n"))
}
}
}
Kotlin関連の実装はローカルの絶対pathを参照しているので、このままだと環境によって差分が出てしまいます。それを埋めるために、相対pathを参照するようにし、デプロイ時に必要なものだけをまとめるディレクトリを生成しています。
これによってfunctionsフォルダが生成され、次の構成になります。
├── functions
│ ├── index.js
│ ├── package.json
│ └── packages_imported
│ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy
│ │ └── 0.0.7
│ │ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js
│ │ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js.map
│ │ └── package.json
│ ├── kotlin
│ │ └── 1.4.21
│ │ ├── kotlin.js
│ │ ├── kotlin.js.map
│ │ └── package.json
│ ├── kotlin-test
│ │ └── 1.4.21
│ │ ├── kotlin-test.js
│ │ ├── kotlin-test.js.map
│ │ └── package.json
│ ├── kotlin-test-js-runner
│ │ └── 1.4.21
│ │ ├── karma-debug-framework.js
│ │ ├── karma-debug-runner.js
│ │ ├── karma-kotlin-reporter.js
│ │ ├── kotlin-test-karma-runner.js
│ │ ├── kotlin-test-karma-runner.js.map
│ │ ├── kotlin-test-nodejs-runner.js
│ │ ├── kotlin-test-nodejs-runner.js.map
│ │ ├── mocha-kotlin-reporter.js
│ │ ├── package.json
│ │ ├── tc-log-appender.js
│ │ └── tc-log-error-webpack.js
│ ├── kotlinx-atomicfu
│ │ └── 0.14.4
│ │ ├── kotlinx-atomicfu.js
│ │ ├── kotlinx-atomicfu.js.map
│ │ └── package.json
│ ├── kotlinx-coroutines-core
│ │ └── 1.3.9
│ │ ├── kotlinx-coroutines-core.js
│ │ ├── kotlinx-coroutines-core.js.map
│ │ └── package.json
│ └── kotlinx-serialization-kotlinx-serialization-core-jsLegacy
│ └── 1.0.0-RC
│ ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js
│ ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js.map
│ └── package.json
functionsディレクトリには、ローカルで参照するKotlin関連の実装と、Cloud Functionsの規則に合わせたindex.jsというデプロイしたい関数の実装ファイルと、package.jsonがあります。
package.jsonは次のようになります。
{
"main": "index.js",
"devDependencies": {
"dukat": "0.5.8-rc.3"
},
"dependencies": {
"axios": "0.21.0",
"puppeteer": "5.5.0",
"form-data": "3.0.0",
"@google-cloud/secret-manager": "3.2.2",
"kotlin": "packages_imported/kotlin/1.4.21",
"kotlinx-coroutines-core": "packages_imported/kotlinx-coroutines-core/1.3.9",
"kotlinx-atomicfu": "packages_imported/kotlinx-atomicfu/0.14.4",
"kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC",
"17def1782b5ee417-kotlinx-nodejs-jsLegacy": "packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7",
"kotlin-test-js-runner": "packages_imported/kotlin-test-js-runner/1.4.21",
"kotlin-test": "packages_imported/kotlin-test/1.4.21"
},
"peerDependencies": {},
"optionalDependencies": {},
"bundledDependencies": [],
"name": "kotlin-js-headless-chrome",
"version": "0.0.1"
}
main
が変わり、絶対pathだった部分が相対pathになっています。
デプロイについてですが、cloudbuild.yamlは次のようになっています。
steps:
- name: gradle:6.3-jdk8
entrypoint: gradle
args: ['cKJ', 'packaging']
- name: 'gcr.io/cloud-builders/gcloud'
dir: 'functions/'
args:
- functions
- deploy
- capture
- --source=.
- --region=asia-northeast1
- --trigger-http
- --runtime=nodejs12
- --allow-unauthenticated
- --memory
- 512MB
- --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
- --update-env-vars
- PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token
Gradleが実行可能なDockerコンテナでjsファイルの生成とデプロイの準備をし、gcloudコマンドが利用可能なDockerコンテナでCloud Functionsのデプロイコマンドを実行します。
不要なファイルをアップロードしないように dir
で作業ディレクトリを移動しています。
Secret Managerの利用
Cloud Functionsで環境変数に機密性の高い値を置くのは不適切とされています。
cloud.google.com
YAML ファイル、デプロイ スクリプト、ソース管理に重要な認証情報は保存しないでください。
前回は環境変数に置いてしまっていたので、今回はSecret Managerで管理する方法を取りました。
cloud.google.com
dukatでそのまま生成はできなかったので、次のようなファイルを用意してKotlinから利用しました。
@JsModule("@google-cloud/secret-manager")
external object SecretManager {
class SecretManagerServiceClient {
fun accessSecretVersion(json: kotlin.js.Json = definedExternally): Promise<Array<AccessSecretVersionResponse>>
}
}
@Serializable
external class AccessSecretVersionResponse {
@SerialName("name")
val name: String
val payload: Data
}
@JsName("data")
external class Data {
val data: Buffer
}
external class Buffer {
fun toString(str: String): String
}
fun Array<AccessSecretVersionResponse>.value(): String {
return this[0].payload.data.toString("utf8")
}
利用するには
Secret Manager のシークレット アクセサー
のロール
- PROJECT_ID
- 保存しているSecretのKey名
が必要になります。Cloud Buildでは次の部分がそれに当たります。
- --service-account=secret-manager@able-source-341.iam.gserviceaccount.com
- --update-env-vars
- PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token
Cloud Functionsのランタイムサービスアカウントは、デフォルトだと PROJECT_ID@appspot.gserviceaccount.com
となっています。なので、そのサービスアカウントにSecret Manager のシークレット アクセサー
のロールを与えるか、Secret Manager のシークレット アクセサー
のロールを持ったサービスアカウントを別途用意する必要があります。今回は後者のほうで実現しています。
cloud.google.com
ロールの設定
少し複雑ですが、
- Cloud Buildを実行するメンバー
- Cloud Functionsを呼び出すメンバー
- Cloud Functionsを実行するメンバー
はそれぞれ別です。
Cloud Buildを実行するメンバーはデフォルトだと PROJECT_NUMBER@cloudbuild.gserviceaccount.com
というサービスアカウントです。Cloud Functionsのリソースにアクセスするので Cloud Functions 開発者
のロールを追加しておく必要があります。
cloud.google.com
Cloud Functionsを呼び出すメンバーには、Cloud Functions 起動元
のロールが必要です。publicにしたい場合は allUsers
メンバーにCloud Functions 起動元
のロールを追加してあげると良いです。
今回は定期実行させたかったので、 Cloud Schedulerから呼び出すことにしました。
cloud.google.com
AuthヘッダーにOIDCトークンを選択し、Cloud Functions 起動元
のロールをつけたサービスアカウントを設定することで安全に呼び出しています。
Cloud Functionsを実行するメンバーは、 前項で設定しているサービスアカウントです。
注意が必要なのは、Cloud Functionsを呼び出すメンバーと実行するメンバーが異なるということです。呼び出すメンバーにSecret Manager のシークレット アクセサー
のロールは不要です。逆に持っていたとしても、実行するメンバーにそのロールが与えられていなければ読み取りエラーになります。
ファイルの保存
今回はSlackに投稿するに当たり、一旦キャプチャした画像をファイルとして保存してから投稿するようにしました。
Cloud Functionsでは、/tmp
ディレクトリ以外は書き込み権限がありません。ローカルでも同様に確認できるようにするため、os.tmpdir
で保存先を確認するようにしています。
cloud.google.com
form-dataでファイルを投稿する
ハマってしまったのですが、Node.jsからファイルを送る場合form-data形式で送る必要があります。Kotlin/JSでもFormDataは用意されてるのですが、こちらはブラウザでの利用を想定されているものでNode.jsから動かした場合正しく機能しません。
form-dataを依存関係に追加して利用する必要があります。
www.npmjs.com
Kotlinから利用するに当たり、最低限次のように定義しました。
@JsModule("form-data")
external class FormData {
fun getBoundary(): String
fun append(string: String, dynamic: dynamic)
}
まとめ
ローカルで動かすだけなら比較的すんなりいくのですが、Cloud Functionsで動かそうとすると考えることがとても増えて苦しみました。
IAM周りの話や、Headless Chromeを扱うようなときは参考にできると思います。
リポジトリはこちらです。
github.com
参考
品質とパフォーマンスに注力した Kotlin 1.4 をリリースしました | JetBrains Blog
Specifying dependencies in Node.js | Cloud Functions Documentation
Quotas and limits | Firebase
https://rominirani.com/using-puppeteer-in-google-cloud-functions-809a14856e14
playwright/installation.md at master · microsoft/playwright · GitHub
Secret Managerを使ってみる@Cloud Functions編 | apps-gcp.com
Slack
javascript - Google Clould Functions deploy: EROFS: read-only file system - Stack Overflow
Node.js上からmultipart/form-data形式でHTTPリクエストをする - Qiita