🔖

Go の Cloud Functions でファイル読み取りをするのにひと工夫必要だった

2022/08/09に公開約8,100字

概要

  • Go ランタイムの Cloud Functions では、ソースコードが serverless_function_source_code 配下にある
  • buildpacks を使えば同じ挙動を再現できる
  • buildpacks を使わずとも Cloud Function・ローカル環境ともに動くコードは書けるので、無理して使う必要はないと思います。

経緯

Go で動く Cloud Functions の処理の中で、GCS に置くほどでもないような、読み取り専用の JSON データをソースコードと一緒に管理していました。ディレクトリ構成は下記のような感じです。

$ tree
├── hoge-function
│   ├── cmd
│   │   └── main.go
│   ├── function.go   # function の本体
│   ├── go.mod
│   ├── go.sum
│   └── static
│       └── hoge.json # 読み取りたい静的な JSON ファイル

コンテナ化などはせずに functions-framework-go を使ってローカル環境を動かしています。cmd/main.go がローカルでの関数を実行するためのエントリーポイントになります。関数の処理は function.go にあります。

function.go
func ReadFile(w http.ResponseWriter, r *http.Request) {
  b, err := os.ReadFile("static/hoge.json")
  if err != nil {
    log.Println(err)
  }
  log.Println("static json content", string(b))
}

この処理をローカル環境で実行すると、当然ながら static/hoge.json の中身が出力されます。

しかし、これを Cloud Functions にデプロイして動かすと、下記のような結果になります。

cloud-logging-error

os.ReadFile を実行でエラーが返却されています...!そんなファイルはないと言われてしまいました。

原因

原因はドキュメントに記載がありました。

https://cloud.google.com/functions/docs/concepts/execution-environment#memory-file-system

注: Cloud Functions 実行環境では、関数のソースコードのルート ディレクトリは作業ディレクトリ(.)になります。ただし、Go ランタイムの場合は、関数ランタイムのルート ディレクトリが ./serverless_function_source_code にある現在の作業ディレクトリの下になります。

😅

初耳でした。Go ランタイムの Cloud Functions の実行環境では、serverless_function_source_code 配下にソースコードが置かれる仕様になっているようです。
試しに Cloud Functions 上で下記のコードを動かし、カレントディレクトリ配下のファイルパスを出力してみました。

function.go
func ReadFile(w http.ResponseWriter, r *http.Request) {
  err := filepath.Walk("./", func(path string, _ os.FileInfo, err error) error {
    if err != nil {
      return err
    }

    fmt.Printf("path: %#v\n", path)
    return nil
  })
  if err != nil {
    log.Println(err)
  }
}

実行結果は以下の通りです。カレントディレクトリは /workspace のようです。その配下に確かに serverless_function_source_code というディレクトリが存在しており、その配下にソースコードが存在していることが確認できます。

filepath.Walk()の結果

見やすくするとこんな感じです。

/
├── workspace
│   ├── .googlebuild
│   │   └── source-code.tar.gz
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── serverless_function_source_code
│       ├── cmd
│       │   └── main.go
│       ├── function.go
│       ├── go.mod
│       ├── go.sum
│       └── static
│           └── hoge.json

対策

ローカル環境でも Cloud Function の実行環境でもファイルの読み取りを行うことができるようにするにはどうすればいいか考えてみました。

前提として、Cloud Functions をローカル環境で実行するには 2 通りの方法があります。1 つは「Function Frameworks を使用する方法」もう 1 つは「Cloud Native Buildpacks を使用する方法」です。

https://cloud.google.com/functions/docs/running/overview?hl=ja#choosing_an_abstraction_layer

それぞれの方法を使った場合のソースコードへのアクセス方法を紹介します。

1. Function Frameworks を使用する場合

Function Frameworks を使っている場合は、ローカル環境と Cloud Functions の実行環境・ディレクトリ構成が異なるので、その差分を受け入れるしかありません。
読み取るディレクトリの情報を環境変数化し、その値を変えることでいずれの環境でもファイル読み取りができるようになります。

function.go
// SOURCE_DIR
// ローカル: ""
// CF環境: "serverless_function_source_code/"
b, err = os.ReadFile(os.Getenv("SOURCE_DIR") + "static/hoge.json")
if err != nil {
  log.Println(err)
}

log.Println("static json content", string(b))

環境の違いを意識したコードが生まれてしまう点がデメリットです。「ソースコードが /workspace/serverless_function_source_code にある」という仕様を知らないと、なぜこの処理が必要なのかわかりづりらいです。メリットは Function Frameworks の導入が手軽なこと・デバッグしやすいことです。後述する Buildpacks ではコードの変更の都度コンテナをビルド & 立ち上げが必要で、開発体験はイマイチなのですが、こちらは go run すればよいだけなので手軽です。

2. Cloud Native Buildpacks を使用する場合

Cloud Native Buildpacks というツールを使うと Cloud Functions の実行環境をコンテナ化できます。そのコンテナを実行すれば Cloud Functions を実行しているのとほぼ同じ環境を作ることができます。当然ディレクトリ構成も同じで /workspace/serverless_function_source_code にソースコードが置かれるので、何も考えずに serverless_function_source_code 配下のファイルを読み取ることができます。

function.go
b, err = os.ReadFile("serverless_function_source_code/static/hoge.json")
if err != nil {
  log.Println(err)
}

log.Println("static json content", string(b))

実行環境の差分を意識せずに開発ができることはメリットですが、前述の通り、ソースコードを変更するたびにコンテナをビルドし直さねばならなず、デバッグに時間がかかる点はデメリットです。また、この場合でも「ソースコードが /workspace/serverless_function_source_code にある」という仕様を知らないと謎のディレクトリを参照しているように見えてしまいます。

最終的に筆者はどうしたか

1 の Function Frameworks を使うやり方にしました。
パッと見のわかりやすさの観点では大差ないと判断したので、開発体験の良さを優先しました。

おまけ

/workspace/main.go, /workspace/go.mod の中身が気になったので覗いてみました。

├── workspace
│   ├── .googlebuild
│   │   └── source-code.tar.gz
│   ├── go.mod  # ← これ
│   ├── go.sum
│   ├── main.go # ← これ
│   └── serverless_function_source_code
│       ├── cmd
│       │   └── main.go
│       ├── function.go
│       ├── go.mod
│       ├── go.sum
│       └── static
│           └── hoge.json

function.go に下記のような処理を書き、これを Cloud Functions 上で実行してファイルの中身を出力してみました。

function.go
func ReadFile(w http.ResponseWriter, r *http.Request) {
  b, err = os.ReadFile("/workspace/go.mod")
  if err != nil {
    panic(err)
  }
  log.Println(string(b))

  b, err := os.ReadFile("/workspace/main.go")
  if err != nil {
    panic(err)
  }
  log.Println(string(b))
}

go.mod は以下の通りでした。

go.mod
module functions.local/app

go 1.16

require (
  github.com/GoogleCloudPlatform/functions-framework-go v1.5.3
  github.com/kmtym1998/gcf-playground v0.0.0
)

replace github.com/kmtym1998/gcf-playground v0.0.0 => /workspace/serverless_function_source_code

replace ディレクティブを使ってユーザがデプロイした関数の module を呼び出せるようにしていますね。何かしらの理由があってユーザが作成した module と、その処理を呼び出す module を分けたい意図があったのかな?というのが推測できます。別 module で作ることを考えるとディレクトリを切る必要があるので serverless_function_source_code のようなディレクトリが作られているのも納得できる気がします。

同階層の main.go の中身も見てみました。

main.go
// Binary main file implements an HTTP server that loads and runs user's code
// on incoming HTTP requests.
// As this file must compile statically alongside the user code, this file
// will be copied into the function image and the 'FUNCTION_TARGET' and
// 'FUNCTION_PACKAGE' strings will be replaced by the relevant function and
// package names. That edited file will then be compiled as with the user's
// function code to produce an executable app binary that launches the HTTP
// server.
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	userfunction "github.com/kmtym1998/gcf-playground"

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

func register(fn interface{}) error {
	ctx := context.Background()
	if fnHTTP, ok := fn.(func(http.ResponseWriter, *http.Request)); ok {
		if err := funcframework.RegisterHTTPFunctionContext(ctx, "/", fnHTTP); err != nil {
			return fmt.Errorf("Function failed to register: %v\n", err)
		}
	} else if fnCloudEvent, ok := fn.(func(context.Context, cloudevents.Event) error); ok {
		if err := funcframework.RegisterCloudEventFunctionContext(ctx, "/", fnCloudEvent); err != nil {
			return fmt.Errorf("Function failed to register: %v\n", err)
		}
	} else {
		if err := funcframework.RegisterEventFunctionContext(ctx, "/", fn); err != nil {
			return fmt.Errorf("Function failed to register: %v\n", err)
		}
	}
	return nil
}

func main() {
	if err := register(userfunction.ListFiles); err != nil {
		log.Fatalf("Function failed to register: %v\n", err)
	}

	// Don't invoke the function for reserved URLs.
	http.HandleFunc("/robots.txt", http.NotFound)
	http.HandleFunc("/favicon.ico", http.NotFound)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	if err := funcframework.Start(port); err != nil {
		log.Fatalf("Function failed to start: %v\n", err)
	}
}

Cloud Function の実行環境でも Function Frameworks が使われていることがわかります。
このファイルの main() 関数が最初に実行されます。ローカル環境で開発する為のエントリーポイントになる cmd/main.go は Cloud Functions 環境だと不要なのでデプロイ対象にしなくて良いのですね。


Lambda など、他の FaaS ではどんな仕様になっているのか気になりました。気が向いたら調査してみます。

GitHubで編集を提案

Discussion

ログインするとコメントできます