💸

AWS 請求通知 BOT を AWS Lambda + AWS SDK + Go で作る ~ 実装編 ~

2022/07/31に公開約19,600字

AWS 請求通知 BOT を AWS Lambda + AWS SDK + Go で作る ~ 実装編 ~

あらまし

世の中に AWS 請求額が設定ミスで多額の請求が来てしまったという事故が多々見受けられる、その対策として、現在の請求額を通知する BOT を作るのも有効な対策手段の一つである。
前回 AWS Cost Explorer の SDK に関して調査を行った。

https://zenn.dev/_kazuya/articles/aeabc23cd5df2a

今回はその続編として、実際に請求額通知 BOT の実装を行なった。

構成

まずは全体をイメージするために構成図を見ていこう。

使用している技術スタック、サービス

  • Go
    • aws-sdk-go
    • aws-lambda-go
    • slack-go
    • go-chart
  • AWS Lambda
  • AWS SAM
  • AWS SSM Parameter Store
  • AWS CloudWatch Logs
  • AWS Cost Explorer
  • AWS EventBridge
  • AWS S3
  • open exchange rates API
  • Slack API

Lambda の構成について

  • MainFunction
  • GetCostFunction
  • NotifyFunction
  • PutRateFunction

SSM Parameter Store のパラメータについて

1, /openexchangerates/JPY_RATE

  • 1USD ベースの日本円

2, /openexchangerates/APP_ID

  • open exchange rates の API を叩くために必要な APP_ID

3, /slack/APITOKEN

  • Slack の APITOKEN

4, /slack/CHANNEL

  • 通知したい Slack チャンネルの名称

EventBridge の設定について

  • 毎日日本時間午前 9:00 に MainFunction を実行
  • 毎日日本時間午前 6:00 に PutRateFunction を実行

SAM で Lambda を立てる

ディレクトリ構造

tree
.
├── Makefile
├── README.md
├── go.mod
├── go.sum
├── invoke-obj
│   ├── getcost
│   │   └── main.go
│   ├── main
│   │   └── main.go
│   ├── notify
│   │   └── main.go
│   └── putrate
│       └── main.go
├── pkg
│   ├── aws
│   │   └── sdk
│   │       └── ce
│   │           └── costandusage
│   │               ├── calc
│   │               │   └── sum.go
│   │               ├── getcost
│   │               │   └── getcost.go
│   │               ├── granularity
│   │               │   └── elem.go
│   │               ├── group
│   │               │   └── elem.go
│   │               ├── metric
│   │               │   └── elem.go
│   │               └── term
│   │                   ├── elem.go
│   │                   └── term.go
│   └── openexchangerates
│       └── getrates.go
├── renew.yaml
└── template.yaml

SAM や Lambda については以下を参照せよ。

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html

GetCostFunction を実装しよう

まず、AWS SDK 接続用のセッションを作成する。

https://docs.aws.amazon.com/sdk-for-go/api/aws/session/
main.go
const Region = "ap-northeaset-1"
sess := session.Must(session.NewSessionWithOptions(session.Options{
    Config: aws.Config{
        Region: aws.String(Region),
    },
    SharedConfigState: session.SharedConfigEnable,
}))

今回は、 session の オプションとして Config にデフォルトリージョンを設定する。
また、 SharedConfigState を設定することによって、環境変数(AWS_SDK_LOAD_CONFIG)が設定されているかのようにセッションが作成されるので、ローカル環境でテストする際に役立つ。

terminal
$ sam local invoke GetCostFunction

aws.String に疑問を持たれるかもしれないが、これは単に渡した文字列のポインタを返すだけの便利関数である。

convert_types.go
// String returns a pointer to the string value passed in.
func String(v string) *string {
	return &v
}

https://github.com/aws/aws-sdk-go/blob/main/aws/convert_types.go

次に、AWS Cost Explorer をサービスとして宣言する。
宣言時に先程バインドした sess を引数として渡す。

main.go
svce := costexplorer.New(sess)

今度は、指定した期間の金額を AWS Cost Explorer から取得する。
あらかじめ、 pkg にて実装しておいた、実行した月の初日から実行日の前日までの期間を startend にバインドする。
また、 getcost からパラメータを c にバインドしておく。
GetCost 関数を c から呼び出し、先程宣言した svce と、 startend を渡してコストを取得する。

main.go
// The start date is inclusive, but the end date is exclusive.
start, end, _, err := term.CreateThisMonthRange(jst)
c := getcost.Params{}
cost, err := c.GetCost(svce, start, end)

CreateThisMonthRange 関数に関しては次のように実装した。

pkg/aws/sdk/ce/costandusage/term/term.go
package term

import (
	"errors"
	"time"

	"github.com/aws/aws-sdk-go/aws"
)

func beginningOfMonth(day time.Time) time.Time {
	return time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location())
}

func CreateThisMonthRange(zone *time.Location) (*string, *string, string, error) {
	now := time.Now().UTC().In(zone)
	start := beginningOfMonth(now)
	end := now

	if start.Unix() > end.Unix() {
		return nil, nil, "", errors.New("error: Time Range")
	}

	s := start.Format(Format.String())
	e := end.Format(Format.String())

	// The start date is inclusive, but the end date is exclusive.
	// https://docs.aws.amazon.com/ja_jp/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html
	p := end.AddDate(0, 0, -1).Format(Format.String())
	return aws.String(s), aws.String(e), p, nil
}

GetCost 関数に関しては次のように実装した。

pkg/aws/sdk/ce/costandusage/getcost/getcost.go
func (c Params) GetCost(svc costexploreriface.CostExplorerAPI, start *string, end *string) (*costexplorer.GetCostAndUsageOutput, error) {
	c.Granularity = granularity.Monthly.String()

	c.Metrics = []*string{
		metric.UnblendedCost.String(),
	}

	c.Term = &costexplorer.DateInterval{
		Start: start,
		End:   end,
	}

	service := costexplorer.GroupDefinition{
		Key:  group.Service.Key(),
		Type: group.Dimention.Type(),
	}
	c.Groups = append(c.Groups, &service)

	input := costexplorer.GetCostAndUsageInput{
		Granularity: c.Granularity,
		TimePeriod:  c.Term,
		Metrics:     c.Metrics,
		GroupBy:     c.Groups,
	}

	result, err := svc.GetCostAndUsage(&input)
	if err != nil {
		return nil, err
	}
	return result, nil
}

また、GetCost をはじめとした、各種関数内に展開されるリテラルに関しては、あらかじめ elem.go に分離してパラメータを宣言した。

実装例として、granularity/elem.go を以下に提示する。

pkg/aws/sdk/ce/costandusage/granularity/elem.go
package granularity

import "github.com/aws/aws-sdk-go/aws"

type CE int

const (
	Daily CE = iota
	Monthly
	Hourly
)

func (ce CE) String() *string {
	switch ce {
	case Daily:
		return aws.String("DAILY")
	case Monthly:
		return aws.String("MONTHLY")
	case Hourly:
		return aws.String("HOURLY")
	default:
		return nil
	}
}

次に、AWS SSM Parameter Store をサービスとして宣言する。
sess を引数として渡す。

main.go
svc := ssm.New(sess)

後述するが、あらかじめ、PutRateFunction にて open exchange rates API で取得した 1USD ベースの日本円を AWS SSM Parameter Store から取得する。
このパラメータは暗号化の必要がないため、プレーンテキストで処理する。
この場合 WithDecryptionfalse に設定する。

main.go
rawjpy, err := svc.GetParameter(&ssm.GetParameterInput{
    Name:           aws.String("/openexchangerates/JPY_RATE"),
    WithDecryption: aws.Bool(false),
})

また、SSM Parameter Store から取得したパラメータは String 型で返ってくるので、Float 型にコンバートが必要。

main.go
jpy, err := strconv.ParseFloat(*rawjpy.Parameter.Value, 64)

最後に Lambda からのレスポンスとして、値を返却する。

main.go
return Response{
        Message: Succcess,
        Cost:    cost,
        Rate:    jpy,
    },
    nil

PutRateFunction を実装しよう

AWS SDK の通信用にセッションを sess にバインドする。
上述したので割愛する。

次に、あらかじめ実装しておいた、 PutOpenexchangeratesJpy 関数を用いて AWS SSM Parameter Store に 1USD ベースの JPY をパラメータとしてストアする。

main.go
// Put Base of USD JPY Rate to ssm parameter store with OpenExchangeRates API
if err := openexchangerates.PutOpenexchangeratesJpy(sess); err != nil {
    return Response{
            Message: ErrPutOpenExchangeRates,
        },
        err
}

PutOpenexchangeratesJpy 関数に関しては次のように実装した。

pkg/openexchangerates/getrates.go
func PutOpenexchangeratesJpy(sess *session.Session) error {
	// Base URL: see https://docs.openexchangerates.org
	base := "https://openexchangerates.org/api/latest.json?app_id=%s"
	// Application id: https://docs.openexchangerates.org/docs/authentication
	// Using ssm parametor store: https://ap-northeast-1.console.aws.amazon.com/systems-manager/parameters
	svc := ssm.New(sess)
	app_id, err := svc.GetParameter(&ssm.GetParameterInput{
		Name:           aws.String("/openexchangerates/APP_ID"),
		WithDecryption: aws.Bool(true),
	})
	if err != nil {
		return err
	}
	// Create Request url
	url := fmt.Sprintf(base, *app_id.Parameter.Value)

	// Start Request
	resp, err := http.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	source, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	// Parse json
	desc := Schema{}
	json.Unmarshal(source, &desc)

	// Cache rates in ssm parametor store
	cache := strconv.FormatFloat(desc.Rates.JPY, 'f', 2, 64)
	svc.PutParameter(&ssm.PutParameterInput{
		Name:      aws.String("/openexchangerates/JPY_RATE"),
		Value:     aws.String(cache),
		Overwrite: aws.Bool(true),
	})
	if err != nil {
		return err
	}
	return nil
}

解説すると、まず、AWS SSM Parameter Store から open exchange rates API へのアクセスキーを取得する。

レート取得には open exchange rates を使用している。

あらかじめ、アクセスキーを取得して、 AWS SSM Parameter Store に格納しておく必要がある。

  • open exchange rates URL にアクセス
  • アカウントを取得
  • Integration -> App IDs にアクセス
  • Generate New App ID をクリック
  • 「App ID」が API key なので、こちらをコピー
  • API key を SSM Parameter Store に設定して完了

アクセスキーに関しては、セキュアストリングを使用しているので、復号が必要となる。
そのため、WithDecryptiontrue にする必要がある。

次に、アクセスキーを含んだアクセス URL を作成するため、fmt パッケージの Sprintf 関数を使って url へバインドしている。
この url を使って http get リクエストを発行する。
レスポンスを読み込んで Json を構造体 Schema へ Unmarshal する。

Schema
type Schema struct {
	Rates Rate `json:"rates"`
}

type Rate struct {
	JPY float64 `json:"JPY"`
}

最後に取得できたレートを AWS SSM Parameter Store に上書きして完了。

NotifyFunction を実装しよう

今回、通知先を Slack にしたいので、 Slack API を利用する。

Slack API の手続き方法

  • Slack アプリを作成
  • 左サイドバーの「OAuth & 権限」タブをクリック
  • 「ボットトークンのスコープ」にあるスコープを少なくとも 1 つ選択したら、「OAuth スコープを追加する」をクリック
  • 左サイドバーの「App Home」タブをクリックして、追加したボットユーザーと設定を確認
  • 取得したトークンを AWS SSM Parameter Store に設定
  • 通知したいチャンネル名を AWS SSM Parameter Store に設定

各種パラメータを AWS SSM Parameter Store に格納できたら次は、Lambda を立てる。

まず、上述した Lambda 関数から NotifyFunction に値を渡す必要があるが、その際、値取得状況によって通知可能かどうかをチェックする必要がある。

event から値を取得できるので、受け取りたい値を、構造体 Event として定義する。

main.go
type Event struct {
	Cost *costexplorer.GetCostAndUsageOutput
	Rate float64
}

次に、AWS Cost Explorer から取得した値と、 open exchange rates から値を取得できていない場合は nil 及び ゼロ値 なため、その値の場合はエラーを返す。

main.go
func handler(event Event) (Response, error) {
	// Check event value
	if event.Cost == nil || event.Rate == 0 {
		return Response{
				Message: ErrEvent,
			},
			nil
	}
~ ommit

また、通知時に表示用の期間を改めて計算する。

main.go
start, _, endexclusivedate, err := term.CreateThisMonthRange(jst)

次に、通知用のテキストを作っていく。

main.go
// Create Notify Contents
var notifyText string
var list []chart.Value
for _, group := range event.Cost.ResultsByTime[0].Groups {
    label := *group.Keys[0]
    amount, err := strconv.ParseFloat(*group.Metrics[*metric.UnblendedCost.String()].Amount, 64)
    if err != nil {
        return Response{
                Message: ErrParseAmount,
            },
            err
    }
    // Create Text
    text := fmt.Sprintf("・ %s: %.2f 円\n", label, amount*event.Rate)
    notifyText += text
    // Create GraphList
    // Avoid strings longer than 12 characters that cannot be drawn correctly with go-chart
    if len(label) > 10 && !strings.Contains(label, " ") {
        label = label[:11] + " " + label[11:]
    }
    list = append(list, chart.Value{
        Value: amount * event.Rate,
        Label: label,
    })
}
// Create Title
total := calc.Sum(event.Cost, event.Rate)
notifyTitle := fmt.Sprintf("%s 〜 %s の請求額(1 USD = %.2f JPYで計算)\n%s", *start, endexclusivedate, event.Rate, total)

ここで、 calc.Sum に関してだが、 総額を AWS Cost Explorer から取得するためには、リクエストを 2 回行わなければならないため、 Lambda 関数内で計算することにした。

pkg/aws/sdk/ce/costandusage/calc/sum.go
func Sum(cost *costexplorer.GetCostAndUsageOutput, jpy float64) (total string) {
	var sum float64
	for _, c := range cost.ResultsByTime[0].Groups {
		amount, _ := strconv.ParseFloat(*c.Metrics["UnblendedCost"].Amount, 64)
		sum += amount
	}
	total = fmt.Sprintf("▷ Total: %.2f 円", sum*jpy)
	return total
}

さて、通知部分を書いていこう。

まず、 AWS SSM Parameter Store から、 Slack API に必要なアクセス TOKEN と CHANNEL 名を取得する。

  • Slack APITOKEN
main.go
// Post Message
// Create SSM session
svc := ssm.New(sess)
// Get Paramater from ssm
SLACKAPITOKEN, err := svc.GetParameter(&ssm.GetParameterInput{
    Name:           aws.String("/slack/APITOKEN"),
    WithDecryption: aws.Bool(true),
})
  • Slack CHANNEL 名
main.go
// Get Paramater from ssm
SLACKCHANNEL, err := svc.GetParameter(&ssm.GetParameterInput{
    Name:           aws.String("/slack/CHANNEL"),
    WithDecryption: aws.Bool(false),
})

次に、Slack の Session を作成する。

main.go
// Creat session of Slack
api := slack.New(*SLACKAPITOKEN.Parameter.Value)

Slack API の Attachement 構造体に通知したいコンテンツをアタッチする。

main.go
attachment := slack.Attachment{
    Pretext: notifyTitle,
    Text:    notifyText,
}

通知を行う。

main.go
_, _, err = api.PostMessage(
    *SLACKCHANNEL.Parameter.Value,
    slack.MsgOptionAttachments(attachment),
    slack.MsgOptionAsUser(true),
)

おまけ

金額の通知をテキストで行なったが、せっかくなのでグラフも作って通知してみたい。

AWS CloudWatch などを使えば、グラフを取得できたりするが、今回は JPY に変換したデータを使って、自前でグラフを用意する。

グラフ生成には、 go-chart パッケージを利用した。

まず、グラフ生成の際に、描画可能な上限、下限を定める。

AWS のサービス数はおおよそ 200 程度なので、利用サービス 1 ~ 200 件を描画できる範囲とする。

main.go
// Create Graph
if len(list) >= 200 || len(list) <= 1 {
    return Response{
        Message: ErrPost,
    }, errors.New("Can't plot graph")
}

次にグラフのレイアウトを決める。
今回は棒グラフとして、描画する。

ここら辺は好みの問題になると思うので、参考程度に実装例を以下に提示する。

main.go
baseHeight, baseWidth := 100, 100
if len(list) <= 5 {
    baseHeight, baseWidth = 200, 200
}
graph := chart.BarChart{
    Title: "AWS Price per Service",
    Background: chart.Style{
        Padding: chart.Box{
            Top: 50,
        },
    },
    Height:   len(list) * baseHeight / 2,
    Width:    len(list) * baseWidth,
    BarWidth: 90,
    Bars:     list,
}

レイアウトを決めたら、グラフをレンダリングする。

main.go
buf := bytes.NewBuffer([]byte{})
err = graph.Render(chart.PNG, buf)

最後に、再度 Slack API を用いてレンダリングしたグラフを送信して完了。

main.go
// Post Graph
_, err = api.UploadFile(
    slack.FileUploadParameters{
        Reader:   buf,
        Filename: "awscost-" + endexclusivedate + ".png",
        Channels: []string{*SLACKCHANNEL.Parameter.Value},
    })

MainFunction を実装しよう

それぞれ機能ごとに Lambda を分離したので、 MainFunction という Lambda を立てて、ワークロード用の関数を立てる。
今回は、 Lambda を立てるが、StepFunctions などを利用しても良いだろう。

まず、それぞれ機能ごとの Lambda を呼び出す際のリクエスト、レスポンス用の構造体を定義する。

main.go
type getCostItemsResponse struct {
	Message string                              `json:"message"`
	Cost    *costexplorer.GetCostAndUsageOutput `json:"cost"`
	Rate    float64                             `json:"rate"`
}

type getNotifyItemsRequest struct {
	Cost *costexplorer.GetCostAndUsageOutput
	Rate float64
}

type getNotifyItemsResponse struct {
	Message string `json:"message"`
}

流れとしては、AWS Cost Explorer 、 open exchange rates を利用して取得したデータを GetCostFunction 関数から受け取る。
次に、NotifyFunction に受け取った値を渡す。
最後に メッセージの送信の結果を受け取る。

Lambda 関数内から Lambda 関数を呼び出すには、aws-lambda-go パッケージを利用する。

まず、セッションを作成し、サービスを宣言する。

main.go
svcl := lambda.New(sess)
  • GetCostFunction を Invoke する。
main.go
// Invoke getcostfunction
result, err := svcl.Invoke(
    &lambda.InvokeInput{
        FunctionName: aws.String("GetCostFunction"),
    },
)
var getcostresp getCostItemsResponse
err = json.Unmarshal(result.Payload, &getcostresp)

まず、Invoke したい Lambda 関数名を InvokeInput 構造体の FunctionName に設定。
受け取った Payload を先程用意した getCostItemsResponse 構造体へ Unmarshal する。

  • NotifyFunction を Invoke する。
main.go
// Invoke notifyfunction
request := getNotifyItemsRequest{
    Cost: getcostresp.Cost,
    Rate: getcostresp.Rate,
}
payload, _ := json.Marshal(request)
result, err = svcl.Invoke(
    &lambda.InvokeInput{
        FunctionName: aws.String("NotifyFunction"),
        Payload:      payload,
    },
)
var notifyresp getNotifyItemsResponse
err = json.Unmarshal(result.Payload, &notifyresp)

リクエストに渡したい値を Payload にセットして、Lambda 関数を Invoke する。
同様に、Invoke したい Lambda 関数名を InvokeInput 構造体の FunctionName に設定。
受け取った Payload を先程用意した getCostItemsResponse 構造体へ Unmarshal する。

以上で通知 BOT の実装は完了。

sam local invoke コマンド等でテストできる。

※注意点

  • 今回は解説の際の見通しを良くするために エラー処理を省いて解説している部分があるが、きちんとエラー処理を書くこと。

Lambda 関数をデプロイ

各 Lambda 関数の実装ができたら、次にデプロイだ。

template.yaml を記述しよう。

今回は AWS SAM を使うので、 Transform を設定すること。

次にそれぞれの関数のタイムアウトを適当に 20 sec に設定。
今回は割愛するが、それぞれの Lambda 関数内に適切なタイムアウトも設定すること。

template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Globals:
  Function:
    Timeout: 20

次に、Resorces を定義する。

template.yaml
Resources:

まず、 MainFunction の IamRole を定義する。
MainFunction 内で呼び出す Lambda 関数は  GetCostFunction と  NotifyFunction なので、Resorce にそれぞれの関数の ARN を指定する。

template.yaml
  MainFunctionIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "MainFunctionLambdaPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "lambda:InvokeFunction"
                Resource:
                  - !GetAtt GetCostFunction.Arn
                  - !GetAtt NotifyFunction.Arn

また、AWS CloudWatch logs にログを書き出したいため、追加で WriteLimitedAccessToCloudWatch Policy を定義しておく。

template.yaml
        - PolicyName: "WriteLimitedAccessToCloudWatch"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - arn:aws:logs:ap-northeast-1:<ommit>:log-group:*

この IAM ロールを作成するには GetCostFunction と NotifyFunction の ARN が存在しないと適切に作成できないので、 DependsOn の設定を忘れないように。

template.yaml
    DependsOn:
      - GetCostFunction
      - NotifyFunction

次に、Lambda 本体である、 MainFunction を定義する。

template.yaml
  MainFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: MainFunction
      CodeUri: invoke-obj/main
      Handler: app.lambda_handler
      Runtime: go1.x
      Architectures:
        - x86_64
      Role: !GetAtt MainFunctionIamRole.Arn
      Events:
        Main:
          Type: Schedule
          Properties:
            Schedule: cron(0 0 ? * * *) #09:00JST,Everyday.
  MainFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${MainFunction}
      RetentionInDays: 30

プロパティに関数名と、コードの場所、今回は Go で実装しているため、ランタイムに go1.x を指定。

Role には、先程定義した MainFunctionIamRole の ARN を指定する。

Events には、今回、毎日日本時間 AM 9:00 に通知を行うため、 Schedule 部分に Cron 式を記述する。

また、LogGroup を定義して、関数ごとにログを分ける。
RetentionInDays を設定して、一ヶ月のみログを保存するライフサイクルを記述しておこう。

以上の要領で、残りの GetCostFunction 、 PutRateFunction 、 NotifyFunction を定義しよう。

今回は比較的手軽に使用できる AWS CLI を使ってデプロイする。

まず、template.yaml に文法的な間違いがないか validate を使って確認する。

Makefile
.PHONY: valid
valid:
	sam validate -t template.yaml

次に、sam build でビルド済みの実行ファイルを S3 にアップロードする。
また、CodeUri を S3 に書き換える必要があるため、別途 template ファイルを出力してデプロイ時にそちらを使用する。

Makefile
.PHONY: package
package:
	sam package --output-template-file renew.yaml --s3-bucket <hoge>

最後にデプロイを行う。今回は IAM Role を作成する必要があるため、 CAPABILITY_IAM を指定する。

Makefile

.PHONY: deploy
deploy: package
	sam deploy --template-file renew.yaml --stack-name <hoge> --capabilities CAPABILITY_IAM --region ap-northeast-1

以上でデプロイが完了。

  • 通知の様子

まとめ

今回は Lambda 関数を実装してデプロイまで行った。

AWS を利用したサービス設計時、ある程度正しい見積もりができないと大きな損害を生む可能性があるので、信用を失うことになりかねない。
日々 BOT 等を活用し、関係者に通知を送ることによって責任を分散させていければ良いなと思う。

次回は、CI/CD 編を書く予定。

GitHubで編集を提案

Discussion

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