Cloud SchedulerでGoのCloud Functionsを定期的に実行する

GCPのCloud Scheduler + Cloud Functionsを使って定期的に何かをする、というのを試してみましたので纏めておきます。

やりたいこと

今回の課題は「毎日更新される特定のURLにあるHTMLをダウンロードして、どこかに溜めておきたい」、というものでした。人間系でやるのも良いですが、毎日欠かさず..となるとなかなか面倒です。

特定のURLにアクセスしてダウンロードし、指定のストレージに格納する、というちょっとした処理であれば、Cloud Functionsあたりでさっさと書けてしまいそうです。なのでGCPでゴニョゴニョして、やってみることにしました。

構成

GCPのサービスのうち、

  • Cloud Scheduler
  • PubSub
  • Cloud Functions

を使って実現することができました。図に描くと↓のようなイメージになります。(Cloud Functionsの中で、Cloud Storageへアクセスしますがここでは省略しています。)

cloudscheduler-pubsub-gcf

やったこと

上記の構成で期待通り動作するまでにやったことを纏めていきます。 ちなみに、Googleのチュートリアルもあります。

Pub/Sub を使用して Cloud ファンクションをトリガーする

PubSub

まず、Cloud SchedulerとCloud Functionsの仲介役となるPubSubにTopicを作成します。 PubSubのページに行き、「トピックを作成する」をクリックし、トピックIDを入力すれば完成です。

PubSubにTopicを作成する

Cloud Scheduler

次にCloud Schedulerで新しいJobを作成します。 Cloud Schedulerは、Cloud Functionsを定期的に実行する際の肝になるサービスです。いわゆるcronのようなものです。

GCPの管理画面から「ジョブを作成」をクリックすると、図のような画面が表示されます。 Cloud Schedulerのジョブを作成する

この画面で、

  • 名前
  • 説明
  • 頻度
  • タイムゾーン
  • ターゲット

を入力します。

名前

ジョブの名前を決めて、入力します。

説明

これは必須ではないので、必要であれば入力します。

頻度

ジョブを実行する頻度をunixやlinuxのcronの頻度を指定する要領で入力します。今回は、毎日午前6時に実行したいので、 0 6 * * * と指定しました。

タイムゾーン

ジョブを実行するタイムゾーンを選択します。今回は、日本のタイムゾーンで実行したいので、「日本標準時(JST)」を選択しました。

ターゲット

ターゲットは、このジョブが実行する対象を、

  • HTTP
  • Pub/Sub
  • App Engine HTTP

から選択します。今回は、もちろん「Pub/Sub」を選択しました。「Pub/Sub」を選択すると、追加で、

  • トピック
  • ペイロード

の入力を求められます。「トピック」には、先程作成したトピックIDを入力し、ペイロードにはトピックに投げ込むデータを入力します。今回は、トピックへのデータは使用しないため、適当な文字列(Hello, World!)を入力しておきました。

これらを入力して、「作成」ボタンをクリックするとジョブが作られます。

Cloud Functions

最後に実際の処理を行う関数を作成します。今回は、Go言語で作成しました。 初めてだったので、GCPの管理画面からの作成ではなく、PCで関数を実装し、gcloudコマンドでデプロイする、という方法を取りました。

作成した関数はおおよそ次のような実装となりました。 ちなみに、Goのバージョンは、1.13.14を使いました。

package some_package

import (
	"context"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"

	"cloud.google.com/go/pubsub"
	"cloud.google.com/go/storage"
	"github.com/slack-go/slack"
)

const (
	downloadURL = "ダウンロードしたいページのURL"
	bucketName  = "バケット名"
)

// Download あるURL上のHTMLをダウンロードする。
func Download(ctx context.Context, m *pubsub.Message) error {
	res, err := http.Get(dailyReportURL)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	byteData, _ := ioutil.ReadAll(res.Body)

	// GCSへアップロードする
	gcs, err := storage.NewClient(ctx)
	if err != nil {
		return err
	}

	objName := uploadObjectName()
	obj := gcs.Bucket(bucketName).Object(objName)
	writer := obj.NewWriter(ctx)
	writer.ContentType = "text/html"
	if _, err := writer.Write(byteData); err != nil {
		return err
	}
	if err := writer.Close(); err != nil {
		return err
	}

  return nil
}

// uploadObjectName GCS上のオブジェクト名を返す
func uploadObjectName() string {
	return "何かしらのオブジェクト名"
}

この関数を、go.modgo.sumとともに、適当なディレクトリに格納し、gcloudコマンドでGCPにデプロイします。 コマンドは、↓のような感じです。

gcloud functions deploy 名前 --region=リージョン --entry-point Goの関数名 --runtime go113 --trigger-topic=PubSubのトピックID

「名前」には、Cloud Functions上でのこの関数の名前を指定します。

「リージョン」には、関数をデプロイするリージョンを指定します。実際にはasia-northeast1を指定しました。

「Goの関数名」には、定期的に実行するGoの関数名を指定します。上記のコードでいうところの Downloadになります。

「PubSubのトピックID」には、先程作成したPubSubのトピックIDを指定します。

デプロイは2分ほどかかりますが、正常にデプロイできると、GCPの管理画面にも反映され、実際に実行することができます。実行はCloud Schedulerの画面で「今すぐ実行」ボタンをクリックして実行することもできますし、ジョブの頻度の設定にしたがい、時間になると自動で実行されます。

ハマったこと

Cloud Functionsには2種類ある

Cloud Functions の作成 | Google Cloud Functions に関するドキュメントをちゃんと読んでいればハマることはなかったのですが、Cloud Functionsには、

  • HTTP関数
  • バックグラウンド関数

の2種類あり、Goで関数を書く場合、エントリーポイントとなる関数のシグネチャーが異なります。今回のようにPubSub経由でトリガーされる関数は、バックグラウンド関数として実装する必要があり、関数のシグネチャーは、次のようになります。

func EntryPoint(ctx context.Context, m PubSubMessage) error

これに気づかず、当初HTTP関数のシグネチャーで実装していたため、時間を溶かしてしまいました。

Cloud FunctionsのタイムゾーンはUTC?

上記の関数で、GCSのバケットにアップロードするオブジェクト名を、日付をもとに決定するような実装をしていたのですが、なかなか期待通りに動いてくれませんでした。 どうやらCloud FunctionsはデフォルトではタイムゾーンがUTCとして動作するようです。(GCFのデフォルトのタイムゾーンがUTCである、と記載されたGoogle公式のドキュメントは見つけられていません...)

GCPのCloud Functionsでタイムゾーンを日本時間に設定する - 動かざることバグの如しの記事を参考にさせて頂いて、上記の関数に環境変数TZ=Asia/Tokyoを設定してみたところ、期待どおりに動作しました。

まとめ

とりあえず今回の課題であった「毎日更新される特定のURLにあるHTMLをダウンロードして、どこかに溜めておきたい」は、「Cloud Schedule + PubSub + Cloud Functionsで毎日特定のURLにあるHTMLをダウンロードして、GCSのあるバケットに溜める」という形で実現することができました。

正直、Cloud Functionsの適切なエラー処理方法や、ログ出力の方法など、細かい部分はまだ精査できていないので、今後引き続き改善していきたいと思います。