メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

Kotlin/JSでHeadless Chromeを使って画面をキャプチャしてSlackに投稿する

ちょっと利用したくなったのでKotlin 1.4のキャッチアップも兼ねて作りました。

環境は

  • Kotlin 1.4.21
  • dukat 0.5.8-rc.3
  • Node 12

です

こんな感じで任意のURLのキャプチャがSlackに投稿されるものです。

f:id:sakebook:20201220231715p:plain

前回触った記事はこちら

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デプロイのために、タスクを定義します。

  • build.gradle.kts
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から利用しました。

  • SecretManager.kt
@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から利用するに当たり、最低限次のように定義しました。

  • FormData.kt
@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