スクエニ ITエンジニア ブログ

GoでCloud Functionsの関数を作る

Cloud Functions

GCPが提供しているFaaS(Function as a Service)にCloud Functionsがあります。
これは、Googleが管理するインフラのもと、ユーザが登録したソースコード(関数)を実行してくれるサービスで、2022年9月現在、以下の言語をランタイムとしてサポートしています。1

  • Node.js
  • Python
  • Go
  • Java
  • .NET Core
  • Ruby
  • PHP

私のチームではGoを使ってFunctionを実装しているのですが、この記事ではその際のローカル開発環境について紹介します。 以降、本稿ではソースコード中の関数は「関数」、GCP上のCloud Functionsにおけるリソースとしての関数は「Function」と表現します。

ローカル開発環境

ローカルでの開発(Cloud Functionsのドキュメント)より、

関数をローカルで実行するには、Function Frameworks または Cloud Native Buildpacks を使用します。

とのことなので、今回はFunction Frameworksを使います。Go言語におけるFunction Frameworkのリポジトリは https://github.com/GoogleCloudPlatform/functions-framework-go です。

2022年9月現在、Cloud FunctionsにおけるGoの推奨バージョンは1.16です。 Go 1.16以降ではデフォルトではModuleモードがデフォルトに、依存関係の追加には go install より go get が推奨されるようになった 2 ので、以下の手順でモジュールを作成、及び依存関係の追加を行います。

$ go mod init example.com/gcp_sample_func
$ go get github.com/GoogleCloudPlatform/functions-framework-go/funcframework
$ grep functions-framework-go go.mod
require github.com/GoogleCloudPlatform/functions-framework-go v1.6.1

Hello World

Goランタイムの場合、HTTPリクエストによるトリガーをサポートしているので、HTTP GETに対して単に"Hello World" のみ返すFunctionを実装してみます。

次のディレクトリ構造で実際にHello Worldを返す関数の実装である hello.go と、ローカル実行用のエントリーポイント cmd/main.go を作ります。

.
├── cmd
│   └── main.go
├── hello.go
├── go.mod
└── go.sum

Functions Frameworkによって、実装は単にHTTPリクエストとレスポンスを表す構造体を引数に持つ関数として実装できるので、以下のような実装になります。

hello.go の内容はパッケージ名、変数名など細部は異なりますがGCPドキュメント記載のサンプルコード と同様のものです。

package gcp_sample_func

import (
	"io"
	"net/http"
)

func HelloWorld(writer http.ResponseWriter, request *http.Request) {
	io.WriteString(writer, "Hello World")
}

この関数 HelloWorld をFunctions Frameworkから使うように cmd/main.go で指定することで、ローカル環境で関数を試すことが可能になります。

package main

import (
	"context"
	"log"

	"example.com/gcp_sample_func"

	"github.com/GoogleCloudPlatform/functions-framework-go/funcframework"
)

func main() {

	ctx := context.Background()

	hello := gcp_sample_func.HelloWorld

	// 実行する関数の登録
	if err := funcframework.RegisterHTTPFunctionContext(ctx, "/", hello); err != nil {
		log.Fatalf("funcframework.RegisterHTTPFunctionContext: %v\n", err)
	}

	port := "8080"
	if err := funcframework.Start(port); err != nil {
		log.Fatalf("funcframework.Start: %v\n", err)
	}
}

実行してみます。

$ go run cmd/main.go

別のシェルでHTTPのリクエストをしてみます。

$ curl http://localhost:8080
Hello World

よさそうですね!

デプロイ

実際にGCP上にもデプロイしてみましょう。
※ us-central1 に特段理由はないです

$ gcloud functions deploy sample_func --entry-point HelloWorld --runtime go116 --trigger-http --region us-central1

以下(実際のログより抜粋)から、Cloud Buildでビルド、デプロイされ、 https://us-central1-<project name>.cloudfunctions.net/sample_func というエンドポイントが用意されていることが分かります。

Deploying function (may take a while - up to 2 minutes)...⠛
For Cloud Build Logs, visit: https://console.cloud.google.com/cloud-build/builds;region=us-central1/<build id>?project=<project id>
Deploying function (may take a while - up to 2 minutes)...done.
entryPoint: HelloWorld
httpsTrigger:
  securityLevel: SECURE_ALWAYS
  url: https://us-central1-<project name>.cloudfunctions.net/sample_func

デプロイできました。では、実際に呼び出してみましょう。HTTPトリガーなので、curl などのコマンドで簡単に呼び出せるはずです。

$ curl https://us-central1-${PROJECT_NAME}.cloudfunctions.net/sample_func

<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/sample_func</code> from this server.</h2>
<h2></h2>
</body></html>

403 Forbiddenが返ってきました。これはドキュメント より、 デフォルトではFunctionのエンドポイントはroles/cloudfunctions.invoker ロールを持つGCPのユーザアカウント、またはサービスアカウントによる認証が必要なためです。認証に使うトークンは gcloud auth print-identity-token で得られるので、これをリクエストに付与して再実行してみます。

$ curl https://us-central1-${PROJECT_NAME}.cloudfunctions.net/sample_func -H "Authorization: bearer $(gcloud auth print-identity-token)"
Hello World

ローカルとGCP上で同じレスポンスをすることが確認できました!

関数の登録を宣言的にする

ところで、私のチームでCloud Functionsを使い始めたときは上記の実装方法だったのですが、執筆時点でのfunctions-framework-go(v1.6.1)ではREADME にあるように、以下のように別の記法で cmd/main.go を実装できます。

package main

import (
	"log"

	// 追加
	_ "example.com/gcp_sample_func"

	"github.com/GoogleCloudPlatform/functions-framework-go/funcframework"
)

func main() {
	// funcframework.RegisterHTTPFunctionContext()の削除に伴い、contextも不要に

	port := "8080"
	if err := funcframework.Start(port); err != nil {
		log.Fatalf("funcframework.Start: %v\n", err)
	}
}
package gcp_sample_func

import (
	"io"
	"net/http"

	// 追加
	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

// 追加
func init() {
	functions.HTTP("hello", helloWorld)
}

func helloWorld(writer http.ResponseWriter, request *http.Request) {
	io.WriteString(writer, "Hello World")
}

これまでとの違いは、 cmd/main.go 内で funcframework.RegisterHTTPFunctionContext() の呼び出しをやめ、hello.go 内で functions.HTTP() を呼び出すようにしたことです。

Goではパッケージの初期化処理を init() 関数に記述できるので、cmd/main.go から example.com/gcp_sample_func を参照することで init() 関数が自動的に呼ばれるようになります。これにより、 functions.HTTP() によって関数の登録が行われます。

ローカル環境での関数の実行方法は以下のように変わります。 FUNCTION_TARGET で指定するのは functions.HTTP() の第1引数で、関数名ではないことに注意が必要です。

$ FUNCTION_TARGET=hello go run cmd/main.go
$ curl http://localhost:8080
Hello World

よく見ると、これまでは登録する関数が cmd/main.go にハードコーディングされていましたが、この記法では実行時に使う関数を指定できるようになっています。 この仕様の良し悪しはFunctionの運用次第ですが、例えば1つのリポジトリで複数のFunctionを管理したい場合にメリットがあります。

例えば、単純にリクエストのクエリパラメータを返す、エコーサーバのようなものを追加することを考えます。 これは HelloWorld と同じファイルで管理してもよいのですが、機能が別なので別ファイル echo.go とし、以下のようなファイル構造を採用してみます。

.
├── cmd
│   └── main.go
├── echo.go // 追加
├── go.mod
├── go.sum
└── hello.go
package gcp_sample_func

import (
	"io"
	"net/http"

	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

func echo(writer http.ResponseWriter, request *http.Request) {
	// echo=xxxxで渡されたクエリパラメータのxxxxをそのまま返す
	params := request.URL.Query()
	echo := params.Get("echo")
	io.WriteString(writer, echo)
}

func init() {
	functions.HTTP("echo", echo) // hello.goのhelloWorldと別名で登録
}
$ FUNCTION_TARGET=echo go run cmd/main.go
$ curl http://localhost:8080?echo=hello
hello
$ curl http://localhost:8080?echo=echo
echo

同じ cmd/main.go のまま、別の関数を実行できました!

GCPにデプロイするコマンドにも1点だけ変更があります。 --entry-point で渡す値は、FUNCION_TARGET で指定する値を採用するようにします。

$ gcloud functions deploy sample_func --entry-point hello --runtime go116 --trigger-http --region us-central1

まとめ

GCPのCloud FunctionsをGoで実装して、ローカルで実行、及びGCPにデプロイする方法をまとめました。

Functions Frameworkでは、ローカル実行用の数行のエントリーポイントを用意するのみでGCP上にデプロイする関数と同じ関数をローカルで手軽に実行可能です。 また、私のチームでは1つのリポジトリで複数のFunctionを管理しているため、ローカルで実行する際にもより簡単に関数を切り替えられる点も良い点だと感じています。


  1. 言語のバージョンなど、詳細はこちらから確認できます。 ↩︎

  2. https://go.dev/doc/go1.16 ↩︎

この記事を書いた人

記事一覧
SQUARE ENIXでは一緒に働く仲間を募集しています!
興味をお持ちいただけたら、ぜひ採用情報ページもご覧下さい!