GCP検証環境で不要なリソースを自動停止する

こんにちは、オールアバウト SRE所属の @ishii1648 です。

この記事は、[All About Group（株式会社オールアバウト） Advent Calendar 2020](https://qiita.com/advent-calendar/2020/allabout) 25日目の記事です。

## 概要

クラウドの検証環境における悩みの種の一つとして、テスト用に構築したリソースを停止し忘れてコストがかさむ事が挙げられると思います。一昔前なら夜の9時くらいになったらGCE（AWSならEC2）のリソースを自動停止するスクリプトを書いて事足りたと思いますが、今やGCE以外にも止め忘れる可能性があるリソースは数多くあります。

オールアバウトではGKEを採用しているため、検証環境で構築したGKEのインスタンスグループを停止し忘れるという事が度々ありました。

上記の現状を踏まえ、停止忘れを効果的に防止するのであればGCE以外のリソース（GKE、Cloud SQL、Memory Store）も自動停止できる統合的な仕組みを開発する必要があると考え今回紹介するスクリプトを実装しました。

## 利用したGCPサービス

- Cloud Scheduler
- Cloud Pub/Sub
- Cloud Functions

## ざくっと設計

実装するにあたって設計方針として以下を考えました。

- リソースの種類毎に実装するコード量は少なくする
- 実際に停止したインスタンスをSlack通知する
- 検証環境でしか動作しないよう環境名はハードコードする
- 構造化ロギングを採用する

Cloud Functionsで出力されたログはCloud Logging上で確認できるのですが、この時Cloud Loggingのフォーマットに沿ってログが構造化されているとseverityが色で判別できるようになったり、特定の値でフィルターできるようになるので構造化ロギングを採用することにしました。

## 実装内容

ここからは実際のコードを例に説明していきます。会社のプライベートリポジトリで管理しているので、全てのコードを晒すことができず恐縮なのですが、重要な箇所だけピックアップしました。コードをじっくり読むというよりは全体感をざっくり掴むイメージで眺めて貰う感じになるかと思います。ちなみにGoで書いています。

以下は各種リソースへの操作の実装を抽象化した部分になります。各種リソースの停止用ファイルは*_patroller.goという名称で作成しているのですが、それぞれで定義すべきメソッドをinterfaceで宣言し、クライアントコードからNewGCPResourcePatroller()がコールされたタイミングで全インスタンスを生成します。

```
var factories = make(map[string]func(ctx context.Context, projectID string) (Patroller, error))

// API call interval
const CallInterval = 50 * time.Millisecond

type Patroller interface {
	Scan() error
	TerminateOrDestroy(*sync.WaitGroup, interface{})
	PrintReport() (slack.WebhookMessage, error)
	GetScanedResults() []interface{}
	GetTerminatedResults() []interface{}
	GetResourceType() string
}

type patroller struct {
	resourceType string
	results      map[stateType][]interface{}
	projectID    string
	ctx          context.Context
}

type GCPResourcePatroller struct {
	Patrollers map[string]Patroller
}

func registerPatroller(patroller string, factory func(ctx context.Context, projectID string) (Patroller, error)) {
	factories[patroller] = factory
}

func NewGCPResourcePatroller(ctx context.Context, projectID string) (*GCPResourcePatroller, error) {
	patrollers := make(map[string]Patroller)
	for key, factory := range factories {
		patroller, err := factory(ctx, projectID)
		if err != nil {
			return nil, err
		}
		patrollers[key] = patroller
	}
	return &GCPResourcePatroller{Patrollers: patrollers}, nil
}
```

以下がクライアントコード部分になります。NewGCPResourcePatroller()をコールすると、全リソースのインスタンスが配列で返却されるため、ループで各インスタンスを取り出して操作を実行しています。

interfaceでメソッドを定義しているため、リソースの違いを意識する事が無いポリモーフィズムな実装になっています。

また、操作の流れとしては[1] 停止対象のリソース一覧を洗い出し、[2] 停止APIをコールし、[3] Slack通知すべきメッセージを作成してから[4] 通知しています。

1. patroller.Scan()
2. o.terminateOrDestroyWithDryrun(patroller)
3. patroller.PrintReport()
4. slack.PostWebhook(o.SlackWebhook, &report)

```
func NewOperator(ctx context.Context, projectID string, webhook string, dryrun bool, debug bool) *Operator {
	log.SetLogger(debug)

	c, err := patroller.NewGCPResourcePatroller(ctx, projectID)
	if err != nil {
		log.Error("", err)
		os.Exit(1)
	}

	return &Operator{
		Patrollers:   c.Patrollers,
		SlackWebhook: webhook,
		DryRun:       dryrun,
	}
}

func (o *Operator) Run() {
	for _, patroller := range o.Patrollers {
		patroller.Scan()

		o.terminateOrDestroyWithDryrun(patroller)

		if len(patroller.GetTerminatedResults()) == 0 {
			log.Info(patroller.GetResourceType(), "no terminated resources. skip to send slack message")
			continue
		}

		report, err := patroller.PrintReport()
		if err != nil {
			log.Error(patroller.GetResourceType(), err)
			continue
		}

		if err := slack.PostWebhook(o.SlackWebhook, &report); err != nil {
			log.Error(patroller.GetResourceType(), err)
			continue
		}
		log.Debug(patroller.GetResourceType(), "success to send slack message")
	}
}
```

最後にリソースの実装です。ここではGKEのインスタンスグループを停止するための実装部分になります。ファイル名としてはgke_patroller.goになります。

重要なのはinit()の中身になります。Goにおいてinit()はパッケージ読み込み時に最初にコールされるメソッドなので、ここに各リソースの初期化用の処理を書くことで、上述したNewGCPResourcePatroller(ctx context.Context, projectID string)コール時にインスタンスを返却できるようになります。

その下は実際にGKEのインスタンスグループを取得する処理と停止する処理になります。特筆すべき点はありませんが、GKE周りのAPIの実装例として記載させて頂きました。

```
func init() {
	registerPatroller("GKE", newGKEPatroller)
}
...

func (p *GKEPatroller) Scan() error {
	// get all clusters list
	clusters, err := container.NewProjectsLocationsClustersService(p.containerService).List("projects/" + p.projectID + "/locations/-").Do()
	if err != nil {
		log.Error(p.GetResourceType(), err)
		return err
	}

	for _, cluster := range clusters.Clusters {
		var instanceGroups []*GKEInstanceGroup

		for _, nodePool := range cluster.NodePools {
			instanceGroupUrlList := strings.Split(nodePool.InstanceGroupUrls[0], "/")
			instanceGroupName := instanceGroupUrlList[len(instanceGroupUrlList)-1]
			instanceGroupZone := instanceGroupUrlList[len(instanceGroupUrlList)-3]

			resp, err := p.computeService.InstanceGroups.Get(p.projectID, instanceGroupZone, instanceGroupName).Context(p.ctx).Do()
			if err != nil {
				log.Error(p.GetResourceType(), err)
				return err
			}

			if resp.Size > 0 {
				instanceGroups = append(instanceGroups, &GKEInstanceGroup{name: instanceGroupName, zone: instanceGroupZone})
			}
		}

		if len(instanceGroups) > 0 {
			c := &GKECluster{
				name:           cluster.Name,
				labels:         cluster.ResourceLabels,
				location:       cluster.Location,
				instanceGroups: instanceGroups,
			}
			p.results[scaned] = append(p.results[scaned], c)
			log.Info(p.GetResourceType(), "found running node "+c.name)
		}
	}

	return nil
}

func (p *GKEPatroller) TerminateOrDestroy(wg *sync.WaitGroup, scanedResult interface{}) {
	defer wg.Done()

	cluster := scanedResult.(*GKECluster)

	ms := compute.NewInstanceGroupManagersService(p.computeService)
	for _, instanceGroup := range cluster.instanceGroups {
		if _, err := ms.Resize(p.projectID, instanceGroup.zone, instanceGroup.name, 0).Do(); err != nil {
			log.Error(p.GetResourceType(), err)
			return
		}
		log.Info(p.GetResourceType(), instanceGroup.name+" called stop command")
	}

	p.results[terminated] = append(p.results[terminated], cluster)
}
```

以上が非常にざっくりとでしたがコードの解説になります。かなり部分的なコードのみをお見せしているので分かりづらい点が多々あったかと思いますが、何となくでも実装のイメージを掴んで頂けたら幸いです。

## まとめ

この仕組みを作ったことでGKEを停止し忘れて、翌日気づくという残念な事が起こらなくなったのは我ながら有り難かったです。

また今回始めてCloud Functionsを使って実装したのですが、デプロイまで含めて思ったより簡単にできました。AWSで同じようなことをやる場合はCloud Watch EventsとLambdaを組み合わせて実装することになるかと思いますが、その場合とやることはほとんど同じです。なのでAWSの経験がある方なら困るポイントはほとんど無いかと思います。

一方で反省点として、安易にCloud Functionsを採用してしまった事は良くなかったと考えています。今回のケースでは指定時間に起動できれば良いのでCloud Functions、Cloud Runの2つが採用候補だったのですが、Lambdaと似ているという理由でCloud Functionsを採用しました。しかしローカルでのテストのしやすさという点ではCloud Runのほうに分があり、それ以外のデメリットも特に見当たらないため今から考えるとCloud Runを選択すべきだったと思っています。

この辺りのCloud FunctionsとCloud Runの両方が使えるケースでどちらを使うべきか、という話しは以下の記事で詳しく書かれていますので興味のある方はご覧ください。
https://medium.com/google-cloud/cloud-run-and-cloud-function-what-i-use-and-why-12bb5d3798e1

以上、「GCP検証環境で不要なリソースを自動停止する」でした。