📆

【GoogleCloud】Cloud Tasksの使い方を学んでみた

に公開

はじめに

Cloud Schedulerは使っていたけどCloud Tasksは使ったことがなく、
どう使えるのか分かりやすい例を作りたいと進めていったら
「Tasksのトリガーでお天気情報をSlackに通知してくれるシステム」ができたので、
その構築を例にCloud Tasksの使い方を記載していきます。

Cloud Tasksの使い道

Cloud Tasksとは?という記事は既存記事にたくさんありますので割愛しますが、ざっくり特徴を挙げると以下のような内容です。

  • 非同期処理を切り離せる
    ユーザーのWebリクエストなど、即時性が求められる処理から、時間のかかる作業を切り離し、バックグラウンドで処理できます。ユーザーへのレスポンス時間を短縮などが期待できます。
  • 確実な実行と再試行
    タスクはキューに格納され、少なくとも1回は設定したターゲットに対して実行されることが保証されています。アプリ内でリトライ処理を実装する手間が省けます。
  • 実行時間のスケジュールが可能
    タスクを特定の未来の時刻に実行するようにスケジュールできます。

例えば、

  1. ユーザーが登録フォームを送信(同期処理)。
  2. バックエンドAPIが登録処理を完了させ、すぐにユーザーに「登録完了」を返す。
  3. バックエンドAPIは、メール送信タスクをCloud Tasksキューに追加する(非同期処理)。
  4. Cloud Tasksが設定されたレートでメール送信サービス(別のWorkerやAPI)にリクエストを送り、メールを送信する。

というメール送信処理だけ非同期処理を切り離すケースに使えそうです。

Cloud Tasksの利用手順

Cloud Tasksを使うには、初回のAPI有効化はもちろんですが、最初にキューの作成が必要です。

gcloud cliなら以下で作成できます。
もちろんコンソール画面からも簡単に作成可能です。

$ gcloud tasks queues create "WEATHER" --location="asia-northeast1"
Created queue [asia-northeast1/WEATHER].

tasks01

もしキュー名を間違って作った場合、削除は簡単ですが、同名のキューは7日間は作成できなくなるそうなので、注意してください。

次に、作成したキューを利用してタスクを作成します。
例えば、以下のようにhogehoge-service(仮です)に対して{"hello":"world"}という中身でPOSTすると仮定します。

gcloud tasks create-http-task \
    --queue="WEATHER" \
    --location="asia-northeast1" \
    --url="https://hogehoge-service/update" \
    --header="Content-Type: application/json" \
    --body-content='{
        "hello": "world"
    }' \
    --method="POST"

キュー名とメソッドやURL、ヘッダーなどを指定します。
タスクは作成された瞬間、即時このURLに対してPOSTしてくれます。

ですので、ソースコード側では何かしら非同期で処理するAPIに対して、タスクさえ作成してしまえば、あとはリトライ処理含めてお任せできるわけです。

Cloud Tasksを使ってみた

実際にタスクで指定したエンドポイント先の何かが欲しいので、ここではApp Engineを呼び出し先として用意してみました。
App Engineの中身は冒頭に記載した通り、「お天気情報をSlackに通知してくれるシステム」です。

App Engineには/weather-reportというURIを用意しました。
用意したApp Engineの構築手順は本記事のメインではないため、後半に記載いたします。

さて、App Engineの用意ができたら、早速Cloud Tasks経由で呼び出してみましょう。
先述しましたWEATHERという名前で作成したキューに対して、以下のコマンドでタスクを作成してみます。

gcloud tasks create-app-engine-task \
  --queue="WEATHER" \
  --location="asia-northeast1" \
  --relative-uri="/weather-report" \
  --method="POST" \
  --schedule-time="2025-10-02T17:00:00+09:00"

App Engineに対して作成するタスクは、gcloud tasks create-app-engine-taskコマンドを使用します。--relative-uriにApp Engineで用意したURIを、--schedule-timeで日本時間の2025年10月2日17時ちょうどを指定しています。

--schedule-timeの指定がないと基本的に即時実行されますが、指定がある場合は以下のようにToDoリストとして追加されます。
そしてスケジュール通り成功するとこのリストから消え、失敗してリトライを繰り返すと再試行数や実行数が確認できます。
tasks02

ToDoリストの操作メニューからは、「タスクの実行を強制する」と「ログを表示」が選択でき、「ログを表示」でログエクスプローラのページに遷移するようになっていました。
tasks03

そして想定した動作の通り、実際にSlackへ17時ちょうどに天気情報が通知されました。
タスクがスケジュール通りに実行されてApp Engineが作動したことが分かりやすいですね。
slack01

以上がCloud Tasksの基本的な使い方でした。
他にもキュー自体の設定を以下のように細かく設定できるので、用途に応じて最大試行回数等を調整していくのが良いかと思います。
tasks04

App Engineの内容と構築手順

では今回検証で用意したApp Engineの中身も掲載しておきます。
天気情報は以下のサイトのAPIを利用しました。
こちらのAPIはユーザー登録なしに使用できるのが決め手でした。
https://weather.tsukumijima.net/

言語はGoで書いてみました。
app.yamlを用意します。

app.yaml
runtime: go121
instance_class: F1
service: default

エンドポイントは先述の通り、/weather-reportという名前で作成しました。
App Engineはデフォルトのポートが8080なので、きちんとその番号で指定しなければいけない点は注意です。(個人的にちょっとだけハマりました)

天気情報の場所はここから取得したい地域のidを見て変えていただければOKです。

SlackのWebhookURLは事前にご用意ください。
作成手順は本記事では割愛させていただきます。

main.go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
)

const (
	// 用意済みのSlack AppのWebhookURL
	slackWebhookURL = "https://hooks.slack.com/services/WEBHOOK_URL"
	// https://weather.tsukumijima.net/ の札幌市の天気予報APIURL
	weatherApiURL = "https://weather.tsukumijima.net/api/forecast/city/016010"
)

// WeatherData は天気APIから返される構造体

type WeatherData struct {
	PublicTime          string      `json:"publicTime"`
	PublicTimeFormatted string      `json:"publicTimeFormatted"`
	PublishingOffice    string      `json:"publishingOffice"`
	Title               string      `json:"title"`
	Link                string      `json:"link"`
	Description         Description `json:"description"`
	Forecasts           []Forecast  `json:"forecasts"`
	Location            Location    `json:"location"`
	Copyright           Copyright   `json:"copyright"`
}

type Description struct {
	PublicTime          string `json:"publicTime"`
	PublicTimeFormatted string `json:"publicTimeFormatted"`
	HeadlineText        string `json:"headlineText"`
	BodyText            string `json:"bodyText"`
	Text                string `json:"text"`
}

type Forecast struct {
	Date         string       `json:"date"`
	DateLabel    string       `json:"dateLabel"`
	Telop        string       `json:"telop"`
	Detail       Detail       `json:"detail"`
	Temperature  Temperature  `json:"temperature"`
	ChanceOfRain ChanceOfRain `json:"chanceOfRain"`
	Image        Image        `json:"image"`
}

type Detail struct {
	Weather string `json:"weather"`
	Wind    string `json:"wind"`
	Wave    string `json:"wave"`
}

type Temperature struct {
	Min MinMax `json:"min"`
	Max MinMax `json:"max"`
}

type MinMax struct {
	Celsius    *string `json:"celsius"`
	Fahrenheit *string `json:"fahrenheit"`
}

type ChanceOfRain struct {
	T00_06 string `json:"T00_06"`
	T06_12 string `json:"T06_12"`
	T12_18 string `json:"T12_18"`
	T18_24 string `json:"T18_24"`
}

type Image struct {
	Title  string  `json:"title"`
	URL    string  `json:"url"`
	Width  int     `json:"width"`
	Height int     `json:"height"`
	Link   *string `json:"link,omitempty"`
}

type Location struct {
	Area       string `json:"area"`
	Prefecture string `json:"prefecture"`
	District   string `json:"district"`
	City       string `json:"city"`
}

type Copyright struct {
	Title    string     `json:"title"`
	Link     string     `json:"link"`
	Image    Image      `json:"image"`
	Provider []Provider `json:"provider"`
}

type Provider struct {
	Link string `json:"link"`
	Name string `json:"name"`
	Note string `json:"note"`
}

// SlackPayload はSlackに送信するメッセージの構造体
type SlackPayload struct {
	Text string `json:"text"`
}

func main() {
	http.HandleFunc("/weather-report", handleWeatherReport)
	log.Println("Worker service started and listening on /weather-report")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleWeatherReport(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost && r.Method != http.MethodGet {
		http.Error(w, "Only POST or GET method is accepted", http.StatusMethodNotAllowed)
		return
	}

	// 1. 天気情報を取得
	weather, err := fetchWeather(r.Context())
	if err != nil {
		log.Printf("Error fetching weather: %v", err)
		http.Error(w, "Failed to fetch weather data", http.StatusInternalServerError)
		return
	}

	if len(weather.Forecasts) < 2 {
		log.Printf("No tomorrow forecast data available")
		http.Error(w, "No tomorrow forecast data", http.StatusInternalServerError)
		return
	}

	// 2. Slackに送信するためのメッセージを生成
	var tempStr string
	if weather.Forecasts[1].Temperature.Max.Celsius != nil {
		tempStr = *weather.Forecasts[1].Temperature.Max.Celsius
	} else if weather.Forecasts[1].Temperature.Min.Celsius != nil {
		tempStr = *weather.Forecasts[1].Temperature.Min.Celsius
	} else {
		tempStr = "不明"
	}
	message := fmt.Sprintf(
		"🚨 *【定時天気予報】* 🚨\n\n日付: %s (%s)\n場所: %s\n天気: *%s*\n最高気温: %s℃",
		weather.Forecasts[1].Date,
		weather.Forecasts[1].DateLabel,
		weather.Location.City,
		weather.Forecasts[1].Telop,
		tempStr,
	)

	// 3. Slackに通知
	err = postToSlack(r.Context(), message)
	if err != nil {
		log.Printf("Error posting to Slack: %v", err)
		http.Error(w, "Failed to post to Slack", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, "Weather report successfully generated and sent to Slack.")
	log.Println("Task completed successfully: Weather report sent.")
}

// fetchWeather は外部天気APIからデータを取得する関数
func fetchWeather(ctx context.Context) (WeatherData, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", weatherApiURL, nil)
	if err != nil {
		return WeatherData{}, err
	}

	client := http.Client{}
	res, err := client.Do(req)
	if err != nil {
		return WeatherData{}, err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return WeatherData{}, fmt.Errorf("weather API failed with status: %d", res.StatusCode)
	}

	var data WeatherData
	err = json.NewDecoder(res.Body).Decode(&data)
	return data, err
}

// postToSlack は指定されたメッセージをSlack Webhookに送信する関数
func postToSlack(ctx context.Context, message string) error {
	payload := SlackPayload{Text: message}
	jsonPayload, _ := json.Marshal(payload)

	req, err := http.NewRequestWithContext(ctx, "POST", slackWebhookURL, bytes.NewBuffer(jsonPayload))
	if err != nil {
		return fmt.Errorf("could not create Slack request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	client := http.Client{}
	res, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("failed to send request to Slack: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("slack webhook failed with status: %d", res.StatusCode)
	}

	return nil
}

用意ができた所でApp Engineにデプロイします。

gcloud app deploy app.yaml
...
Do you want to continue (Y/n)?  y

Beginning deployment of service [default]...
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 2 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.  

これでOKです。

おわりに

Cloud Tasksは単体では動作イメージが沸かないサービスなので、
こうして他のサービスと組み合わせると理解が深まりますね。

使ったことなかった方で本記事がお役に立てれば幸いです。
ここまで読んでいただきありがとうございました。

Discussion