Go+Block Kit で作る ChatOps「猫 Ops」 #Zaim
Zaim のインフラチームの尾形です。
今回は、今さら感はありますが ChatOps の導入をご紹介したいと思います。
元々 Zaim ではデプロイを Jenkins の UI からポチポチしていました。しかし前所属では Ruboty を利用した ChatOps を活用していたため、Jenkins にログインしてポチポチーという作業が中々に耐え難く、導入を決めました。
ChatOps とは?
チャットでシステム運用をオペレーションすること、と巷では言われているかと思います。他にもロールバックやマイグレーションなど、さまざまな操作をチャットから実行させる場合もあります。
今回 Zaim では「Slack から Zaim の各アプリケーションをデプロイする」というオペレーションができるようになることをゴールとしました。
導入した結果
導入した結果、チームメンバーはまずまず喜んでくれたようです。
猫 Ops などと言っているのは Slack Bot の名前が zaim-cat だからですね。ちなみにこの可愛い猫さんはチームメンバーが飼っている猫さんで、Slack に投下されていたので、お借りしました。
ChatOps を導入することで、誰が何をいつデプロイしたかがわかり易くなり、Slack 上で完結するので利用するツールを減らせました。メデタシメデタシ...なのですが、今回 ChatOps を作る際に少し苦労をしたので、その辺をお話ししたいと思います。
技術選定
まず候補として、最初に利用実績のある Ruboty の導入を考えましたが、Slack の Interactive Message を利用する術がなく、残念ながら候補から外れました。
Ruboty の作者である r7kamura さんのブログ チャットボットフレームワーク Ruboty を振り返る - r7kamura - を読んでそう判断したので、本当はあるものの見つけられていないだけかもしれません。
また、同時期に Zaim のサーバーサイドをリプレイスするという話が上がっていました。そして、諸々の観点から開発には Go 言語を採用することになりました。
今まで Go を触ったことない私としては練習がてら Go を使おうと、メルカリさんのブログ Golang で Slack Interactive Message を使った Bot を書く - Mercari Engineering Blog を参考にしながら作ってみることにしました。
苦労した点
サンプルコードが用意されていて、かつ日本語による説明もあるので最初はサクサク構築が進みました。そしてある程度、動くようになった時点で、公式サイトを眺めていたら以下の文言を見つけてしまいました。
These docs describe an outmoded approach to composing messages and interactive experiences. Learn how to more effectively communicate and help folks get work done by our new documentation and transitioning to "blocks".
日本語訳はこちら。
これらの文書はメッセージと対話型の経験を構成するための時代遅れのアプローチを説明しています。より効果的にコミュニケーションを取り、人々が私たちの新しいドキュメントと「ブロック」への移行によって仕事を成し遂げるのを助ける方法を学びましょう。
by Google 翻訳
時代遅れ...
これから導入するというのに、時代遅れのモノを入れるなんて耐え難い...
ということで Slack 社の言う通り、 Introducing Block Kit | Slack に乗り換えることにしました。
「Block Kit を Go で実装してみた」というブログを見つけることは叶いませんでしたが、他言語で実装してみたブログはあったため、問題ないと判断し、実装を進めました。
Interactive Message で利用していたライブラリ GitHub - nlopes/slack: Slack API in Go では幸いにも Block Kit の対応が始まっていたものの、当時(2019/05)はまだリリースされていない状態で、全体的にサンプルが少なくライブラリのコードを読みながら実装しました。
Go 初心者になんてことをさせるんだ…と泣きながら実装を進めましたが、さすが Go 言語、ライブラリが読みやすいです。さらに GitHub のスターも 2,000 を超えるだけあって Issue や PR も活発で同じような問題にぶつかってる方々が多く、大変参考になりました。
とりあえず Block kit message templating · Issue #476 · nlopes/slack · GitHub と slack/examples/blocks at master · nlopes/slack · GitHub を読めば、かなり楽に実装できると思います。
ただ、OSS あるあるですが、使いたい機能のうちの一つが残念ながら未実装でした。PR は出ていたので、マージされるのを待つだけで良かったのは不幸中の幸い...。
Golang で作成する Slack Block Kit Bot
サンプルコードはこちらで公開しております。
GitHub の Branch 一覧を取得したり Jenkins のジョブを実行したり、AWS Secrets Manager から各種トークンを取得するなど本質に関係ないところが入っていますが、メインコードだけ説明していきます。
また Block Kit 以外のコード、例えば Slack App に登録する方法や Interactive Handler の説明などメルカリさんのブログで既に説明されていることは省略します。
まずは Slack RTM でイベントを受けた場合に Block Kit を表示する方法を見ていきます。
func (s *SlackListener) handleMessageEvent(ev *slack.MessageEvent) error {
// Only response mention to bot. Ignore else.
log.Print(ev.Msg.Text)
if !strings.HasPrefix(ev.Msg.Text, fmt.Sprintf("<@%s> ", s.botID)) {
return nil
}
if regexp.MustCompile(`deploy staging`).MatchString(ev.Msg.Text) {
msgOpt := SelectDeployTarget("staging")
s.client.PostMessage(ev.Msg.Channel, msgOpt)
return nil
}
if regexp.MustCompile(`deploy production`).MatchString(ev.Msg.Text) {
msgOpt := SelectDeployTarget("production")
s.client.PostMessage(ev.Msg.Channel, msgOpt)
return nil
}
return nil
}
RTM でイベントを受けた場合は client.PostMessage で後述する slack.MsgOption を渡してあげるだけで Block Kit が表示されます。
func SelectDeployTarget(phase string) slack.MsgOption {
headerText := slack.NewTextBlockObject("mrkdwn", ":jenkins:", false, false)
headerSection := slack.NewSectionBlock(headerText, nil, nil)
apiSection := createDeployButtonSection("API", API, phase)
authSection := createDeployButtonSection("Auth", Auth, phase)
closeAction := CloseButtonAction()
return slack.MsgOptionBlocks(
headerSection,
apiSection,
authSection,
closeAction,
)
}
func createDeployButtonSection(summary string, target Project, phase string) *slack.SectionBlock {
txt := slack.NewTextBlockObject("mrkdwn", "*"+summary+"*", false, false)
btnTxt := slack.NewTextBlockObject("plain_text", "Deploy", false, false)
btn := slack.NewButtonBlockElement("", fmt.Sprintf("deploy_select_%s_%s", target, phase), btnTxt)
section := slack.NewSectionBlock(txt, nil, slack.NewAccessory(btn))
return section
}
func CloseButtonAction() *slack.ActionBlock {
closeBtnTxt := slack.NewTextBlockObject("plain_text", "Close", false, false)
closeBtn := slack.NewButtonBlockElement("", "close", closeBtnTxt)
section := slack.NewActionBlock("", closeBtn)
return section
}
slack.MsgOptionBlocks に各種 Block を渡すと slack.MsgOption として返してくれます。slack.NewButtonBlockElement の第 3 引数が BlockAction の Value となり、この後の InteractionHandler で Value の中身を見て処理を振り分けます。
func (h interactionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Parse input from request
if err := r.ParseForm(); err != nil {
// getSlackError is a helper to quickly render errors back to slack
responseBytes := getSlackError("Server Error", "An unknown error occurred", "unkown")
w.Write(responseBytes) // not display message on slack
return
}
interactionRequest := slack.InteractionCallback{}
json.Unmarshal([]byte(r.PostForm.Get("payload")), &interactionRequest)
// Get the action from the request, it'll always be the first one provided in my case
var actionValue string
switch interactionRequest.ActionCallback.BlockActions[0].Type {
case "button":
actionValue = interactionRequest.ActionCallback.BlockActions[0].Value
case "static_select":
actionValue = interactionRequest.ActionCallback.BlockActions[0].SelectedOption.Value
}
userID := interactionRequest.User.ID
// Handle close action
if strings.Contains(actionValue, "close") {
// Found this on stack overflow, unsure if this exists in the package
closeStr := fmt.Sprintf(`{
'response_type': 'in_channel',
'text': 'closed by <@%s>',
'replace_original': true,
'delete_original': true
}`, userID)
// Post close json back to response URL to close the message
http.Post(interactionRequest.ResponseURL, "application/json", bytes.NewBuffer([]byte(closeStr)))
return
}
log.Printf("[INFO] Action Value: %s", actionValue)
switch {
case strings.HasPrefix(actionValue, "deploy_select_"):
h.DeployInteraction(w, interactionRequest)
case strings.HasPrefix(actionValue, "select_branch_value_"):
h.DeployCheckInteraction(w, interactionRequest)
case strings.HasPrefix(actionValue, "ok_select_branch_value_"):
h.SelectBranchInteraction(w, interactionRequest)
default:
log.Print("[ERROR] An unknown error occurred")
responseBytes := getSlackError("Server Error", "An unknown error occurred", userID)
http.Post(interactionRequest.ResponseURL, "application/json", bytes.NewBuffer([]byte(responseBytes)))
}
}
Block Kit Builder でいう Section with Select で発行される Action が通常の Action と異なるため、BlockAction の Type をチェックして処理を分岐させています。
func (h interactionHandler) DeployInteraction(w http.ResponseWriter, interactionRequest slack.InteractionCallback) {
actionValue := interactionRequest.ActionCallback.BlockActions[0].Value
userID := interactionRequest.User.ID
if !IsDeveploers(userID) {
log.Print("[ERROR] Forbidden Error")
responseBytes := getSlackError("Forbidden Error", "Please contact admin.", userID)
http.Post(interactionRequest.ResponseURL, "application/json", bytes.NewBuffer(responseBytes))
return
}
arr := strings.Split(actionValue, "_")
if len(arr) != 4 {
log.Print("[ERROR] Internal Server Error")
responseBytes := getSlackError("Internal Server Error", "Please contact admin.", userID)
http.Post(interactionRequest.ResponseURL, "application/json", bytes.NewBuffer(responseBytes))
return
}
responseData := h.selectBranchList(Project(arr[2]), arr[3])
responseData.ReplaceOriginal = true
responseBytes, _ := json.Marshal(responseData)
http.Post(interactionRequest.ResponseURL, "application/json", bytes.NewBuffer(responseBytes))
return
}
func (h interactionHandler) selectBranchList(z Project, phase string) slack.Message {
repo := z.GitHubRepository()
github := CreateGitHubInstance(h.githubBotUserToken)
arr, err := github.ListBranch(repo)
if err != nil {
log.Print("Failed to list branch" + err.Error())
}
var opts []*slack.OptionBlockObject
for i, v := range arr {
txt := slack.NewTextBlockObject("plain_text", v, false, false)
opt := slack.NewOptionBlockObject(fmt.Sprintf("select_branch_value_%s_%s_%d", z, phase, i), txt)
opts = append(opts, opt)
}
txt := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s* branch list", repo), false, false)
availableOption := slack.NewOptionsSelectBlockElement("static_select", nil, "", opts...)
section := slack.NewSectionBlock(txt, nil, slack.NewAccessory(availableOption))
closeAction := CloseButtonAction()
return slack.NewBlockMessage(
section,
closeAction,
)
}
Interaction Handler で受け取って Slack 側へ応答する場合は slack.Message をhttp.Post で ResponseURL へ送ります。RTM で使った slack.MsgOption とは異なるので注意です。responseData.ReplaceOriginal を true にすると Action 元のメッセージを Post したメッセージで置き換えてくれます。Post する内容は Block Kit となるので、再度アクションを要求するようなこともできます。
おわりに
引き続き ChatOps の充実とともにインフラを改善していきます!
サーバサイドの Go 言語によるリプレイスや、インフラの改善に興味のあるエンジニアを募集しています。というより助けてください。
この記事が気に入ったらサポートをしてみませんか?