ハウテレビジョンブログ

『外資就活ドットコム』『Liiga』『Mond』を開発している株式会社ハウテレビジョンのブログです。

Go/Mongo/KubernetesでSlack Botを作る

こんにちは、Go2のReleaseを楽しみにしているymgytです。 この記事では、Go,Mongo,Kubernetesを使ったSlack Botの作り方について書いていきます。 Kubernetesに簡単なapplicationをdeployしてみたい方や、GoでSlack Botを作って見たい方に向けた記事です。 source codeはこちらで公開しています。

主に以下のTopicを扱います。

  • Kubernetes上に、mongo(replica set)とwebappを作成
  • Moduleを有効にしたDockerfileの作成
  • Slack RealTimeMessaging APIの利用
  • Github Webhookのhandling
  • MongoによるCRUD処理

Botの概要

作成するbotの名前はgobotとしました。 GithubのPullRequest ReviewのSlackへの通知とUserのCRUD処理を行います。

f:id:yamaguchi7073xtt:20190430151130p:plain
概要

Github PullRequest Notification

f:id:yamaguchi7073xtt:20190430142618p:plain
PRでreviewerを指定する

f:id:yamaguchi7073xtt:20190430142814p:plain
SlackにReviewが依頼された通知が飛ぶ

f:id:yamaguchi7073xtt:20190430142942p:plain
コメントされたり

f:id:yamaguchi7073xtt:20190430142905p:plain
approveされたりしても通知される

User CRUD

Github UserとSlack Userの対応はgobotが独自に管理しています。UserのCRUDはCLI likeに行えます。

f:id:yamaguchi7073xtt:20190430143532p:plain
Create

f:id:yamaguchi7073xtt:20190430143832p:plain
Read

f:id:yamaguchi7073xtt:20190430143918p:plain
Update

f:id:yamaguchi7073xtt:20190430143956p:plain
Delete

準備

Domain

GithubのWebhook URLに登録するdomainが必要です。gobotではautocert packageを利用してCertificateを取得するので、明示的なTLS設定は必要ありません。

Kubernetes

kubectlが実行できる状態にしておきます。 gobotでは、Google Kubernetes Engine(GKE)を利用しました。GCPまわりの設定についてふれると長くなってしまうので本記事では、詳しくはふれません。 gcloudコマンドをinstallしたうえで以下のコマンドにてclusterを作成しました。

gcloud components install kubectl
gcloud config set project ${PROJECT}
gcloud config set compute/zone asia-northeast1
gcloud container clusters create ${CLUSTER_NAME} --no-enable-cloud-logging --no-enable-cloud-monitoring --machine-type=f1-micro --num-nodes=1
gcloud container clusters get-credentials

Cloud Datastore

現状では、TLSを利用するために、golang.org/x/crypto/acme/autocert packageを利用しており、cache機構としてをlocal filesystemではなく、GCP Cloud Datastoreを利用しております。GCP ProjectのDatastoreを有効にして、IAM & adminからgobot用のService Accountsを取得し、Key fileをjson formatで取得してください。

Slack

SlackのRealTimeAPIを利用するために、Slack Appを登録します。

1 ここからslack appを登録

2 https://api.slack.com/apps/{ID}/bots? からBot Userの設定

f:id:yamaguchi7073xtt:20190430153031p:plain

3 OAuth & PermissonsからBot UserOAuth Access Tokenの値を確認します。このTokenは実行時にgobotに渡してやる必要があります。

Github

PullRequestの通知を実施したいRepositoryにてWebhookを設定します。(複数Repositoryに設定して問題ありません) Settings > Webhookから設定をおこないます。

f:id:yamaguchi7073xtt:20190430154202p:plain
Github設定

Payload URLに準備しておいたdomainを、pathは/github/webhook として進めます。 Content typeにはapplication/jsonを指定し、Secretはgobotに渡してやる必要があります。 Eventsは、Pull requestsとPull request reviewsを指定します。(issueは開発中のため、指定してあるだけです)

f:id:yamaguchi7073xtt:20190430154705p:plain
Webhook設定

gobot

準備の各設定をおこない、必要な設定項目を取得したので、いよいよGoのsource codeをみていきます。 ここでは、local環境の設定からdocker imageのpushまでについて述べます。 imageがpushできたら、kubernetesにdeployして完成です。

開発環境

gobotのsorceをpullしてある前提で話をすすめます。 利用するツールは以下の通りです

環境変数

.envrc_templateをcopyして.envrc fileを作成しておくと便利です。

GO111MODULE

moduleは明示的にonにしておきます。 go mod vendorコマンドでvendoringができます。

GOBIN

$(pwd)/binの値をセットしておきます。

GOBOT_VERSION

$(cat VERSION) として、Versionを定義したfileを利用します。

GOBOT_LOGGING_LEVEL

debug としておきます。

GOBOT_GCP_PROJECT_ID

Cloud Datastoreを有効にしたGCPのProject IDです

GOBOT_GCP_SERVICE_ACCOUNT_CREDENTIAL

$(cat path/to/gcp_credential.json) Servce Account上で作成した鍵fileのcontentを指定します

GOBOT_GITHUB_WEBHOOK_SECRET

Github Webhook設定で指定したSecretの内容を渡します

GOBOT_GITHUB_PR_NOTIFICATION_CHANNEL

PullRequestを通知するSlack Channelを指定します

GOBOT_SLACK_BOT_USER_OAUTH_ACCESS_TOKEN

Slack Appの設定画面から取得したBOT User用のtokenを指定します。SlackのRTM API接続時に必要になります。

GOBOT_MONGO_DSN

localではmongodb://localhost:27017を指定します。

GOBOT_MONGO_DATABASE

gobot-localを指定しておきます、なんでもよいです。

依存ツールの管理

Goで開発していると、gomockやlinterといった、go製のtoolのversionを固定したくなります。 Moduleを有効にした状態でこれを実現するには以下のようなtools.goのようなfileを作成し、build tagを設定しておきます。

// +build tools

package tools

import (
    _ "github.com/CircleCI-Public/circleci-cli"
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
    _ "github.com/google/wire/cmd/wire"
    _ "github.com/magefile/mage"
)

こうしておくとgo install github.com/google/wire/cmd/wire実行時に、go.modで指定されたversionのbinaryが${GOBIN}以下に配置されます。

Slack RTM API

開発環境も整ったので、codeをみていきます。まずは、Slack RTM APIです。 slack APIはgithub.com/nlopes/slackを利用しています。

// Slack -
type Slack struct {
    *SlackOptions
    Client             *slack.Client
    AccountResolver    *AccountResolver
    DuplicationChecker *DuplicationChecker
    MessageHandler     SlackMessageHandler

    user          string
    userID        string
    rtm           *slack.RTM
    githubChannel *slack.Channel
}

// Run -
func (s *Slack) Run(ctx context.Context) error {
    if err := s.init(); err != nil {
        return err
    }

    return s.run(ctx)
}

func (s *Slack) init() error {
    if err := s.authorize(); err != nil {
        return err
    }
    if err := s.populateChannel(); err != nil {
        return err
    }
    return nil
}

func (s *Slack) authorize() error {
    authRes, err := s.Client.AuthTest()
    if err != nil {
        return errors.Annotate(err, "authorization to slack failed. check your slack token.")
    }
    log.Info("slack authorization success", zap.Reflect("response", authRes))

    s.user = authRes.User
    s.userID = authRes.UserID
    return nil
}

func (s *Slack) populateChannel() error {
    channels, err := s.getChannels()
    if err != nil {
        return err
    }
    for i := range channels {
        if channels[i].Name == s.GithubPRNotificationChannel {
            s.githubChannel = &channels[i]
        }
    }

    if s.githubChannel == nil {
        return errors.Errorf("github pull request notification channel(%s) not found", s.GithubPRNotificationChannel)
    }
    log.Debug("github pr notification channel found",
        zap.String("channel_id", s.githubChannel.ID),
        zap.String("channel_name", s.githubChannel.NameNormalized))
    return nil
}

func (s *Slack) getChannels() ([]slack.Channel, error) {
    excludeArchive := true
    channels, err := s.Client.GetChannels(excludeArchive, slack.GetChannelsOptionExcludeMembers())
    return channels, errors.Trace(err)
}

func (s *Slack) run(ctx context.Context) error {
    s.rtm = s.Client.NewRTM()
    go s.rtm.ManageConnection()

    log.Info("listening for slack incoming messages...")

    for eventWrapper := range s.filter(s.rtm.IncomingEvents) {
        switch event := eventWrapper.Data.(type) {
        case *slack.HelloEvent:
            log.Debug("receive slack event", zap.String("type", eventWrapper.Type))
        case *slack.ConnectingEvent, *slack.ConnectedEvent:
            log.Info("receive slack event", zap.String("type", eventWrapper.Type))
        case *slack.MessageEvent:
            s.handleMessage(event)
        case *slack.RTMError:
            log.Error("receive slack event", zap.String("type", eventWrapper.Type), zap.Int("code", event.Code), zap.String("msg", event.Msg))
        default:
            log.Debug("receive unhandle slack event", zap.String("type", eventWrapper.Type), zap.Reflect("data", event))
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }

    return nil
}

func (s *Slack) filter(events <-chan slack.RTMEvent) <-chan slack.RTMEvent {
    ch := make(chan slack.RTMEvent, 10)
    go func() {
        defer close(ch)
        for event := range events {
            typ := event.Type
            if typ == "user_typing" ||
                typ == "latency_report" {
                continue
            }
            ch <- event
        }
    }()
    return ch
}

APIの認証に成功すると、Bot Userの名前とIDが取得できるので、以降はこの名前でmentionされた際に、処理を起動するようにします。 初期化時に、Githubへの通知用channelが取得できるかも確かめています。 Goらしく、SlackのEventはchannelから取得できます。gobotでは、slack.MessageEventを処理の対象にしています。ここでSlack Channel Create Eventを拾えば、Channel作成の通知処理なんかも行えそうですね。

次に、MessageEventの処理をみていきます

// SlackMessageHandler -
type SlackMessageHandler interface {
    Handle(*SlackMessage)
}

func (s *Slack) handleMessage(msg *slack.MessageEvent) {

    // bot(integration)が投稿したmessageにはsubtype == "bot_message"が設定される.
    if msg.Msg.SubType == "bot_message" {
        log.Debug("slack/ignore bot message", zap.String("sub_type", msg.Msg.SubType))
        return
    }

    // menuのApps gobotから話しかけるとChannelの先頭文字がDとして送られてくる.
    isDirect := strings.HasPrefix(msg.Channel, "D")

    mention := strings.Contains(msg.Text, "@"+s.userID)
    // @gobotがついていないければ無視する.
    if !mention {
        log.Debug("handle_message", zap.String("msg", "not being mentioned"))
        return
    }

    user, err := s.Client.GetUserInfo(msg.User)
    if err != nil {
        log.Warn("handle_message", zap.String("msg", "Client.GetUserInfo()"), zap.Error(err), zap.Reflect("event", msg))
        return
    }

    go s.MessageHandler.Handle(&SlackMessage{event: msg, user: user, client: s.Client, isDirect: isDirect})
}

Bot自身が発言したMessageも取得してしまうのですが、判別できるようになっているので、無視します。(これをしておかないとloopしてしまいます) mentionされているかや、Direct Messageで話しかけられたかどうか、発言者の情報等のコンテキストを取得して、MessageHandlerに処理を委譲します。

Github Webhook

続いて、GithubのWebhook Eventの処理をみていきます。webhook eventのbindingにはgopkg.in/go-playground/webhooks.v5/githubを利用しています。

// Github -
type Github struct {
    Webhook *github.Webhook
    Slack   *app.Slack
}

var targetEvents = []github.Event{
    github.PullRequestEvent,
    github.PullRequestReviewEvent,
    github.IssuesEvent,
}

// HandleWebhook -
func (g *Github) HandleWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    payload, err := g.Webhook.Parse(r, targetEvents...)
    if err != nil {
        log.Error("parse github event", zap.Reflect("request", r))
        return
    }
    switch payload := payload.(type) {
    case github.IssuesPayload:
        spew.Dump("issues", payload)
    case github.PullRequestPayload:
        g.handlePullRequest(w, r, &payload)
    case github.PullRequestReviewPayload:
        g.handlePullRequestReview(w, r, &payload)
    default:
    }
}

// see https://developer.github.com/v3/activity/events/types/#pullrequestevent
func (g *Github) handlePullRequest(w http.ResponseWriter, r *http.Request, pr *github.PullRequestPayload) {
    switch pr.Action {
    case "review_requested":
        g.handlePullRequestReviewRequested(w, r, pr)
    default:
        g.handlePullRequestUndefinedAction(w, r, pr)
    }
}

func (g *Github) handlePullRequestReviewRequested(w http.ResponseWriter, _ *http.Request, pr *github.PullRequestPayload) {
    log.Info("github/handle event", zap.String("event", "pullrequest"), zap.String("action", pr.Action))

    msg := &app.PRReviewRequestedMsg{
        Owner:          pr.PullRequest.User.Login,
        OwnerAvatarURL: pr.PullRequest.User.AvatarURL,
        URL:            pr.PullRequest.HTMLURL, // URLはapiのresourceを指す
        Title:          pr.PullRequest.Title,
        Body:           pr.PullRequest.Body,
        RepoName:       pr.Repository.Name,
    }

    // 複数指定されうる
    msg.RequestedReviewers = make([]string, len(pr.PullRequest.RequestedReviewers))
    for i := range pr.PullRequest.RequestedReviewers {
        msg.RequestedReviewers[i] = pr.PullRequest.RequestedReviewers[i].Login
    }

    if err := g.Slack.NotifyPRReviewRequested(msg); err != nil {
        log.Error("github", zap.String("event", "pullrequest"), zap.String("action", pr.Action), zap.Error(err))
    }

    // githubへは200を返す
    w.WriteHeader(http.StatusOK)
}

github.Webhook.Parseでpayloadの検証とbindingまでおこなってくれるので、必要な情報だけ抽出して、slackに通知します。 slackへの通知する際に、概要にあったようなmessageを送るためにattachmentを設定します。

// PRReviewRequestedMsg githubのPullRequestでReviewerを指定した際にslackに通知するための情報.
type PRReviewRequestedMsg struct {
    Owner              string // prを作成したuser name(login)
    OwnerAvatarURL     string
    URL                string   // prへのlink
    Title              string   // prのtitle
    Body               string   // prのcomment
    RepoName           string   // prが紐づくrepositoryの名前
    RequestedReviewers []string // reviewerとして指定されたuser name(login)
}

func (m *PRReviewRequestedMsg) attachment(s *Slack) slack.Attachment {
    pretext := func(reviewers []string) string {
        var mention string
        for _, reviewer := range reviewers {
            mention += s.MentionByGithubUsername(reviewer) + " "
        }
        msg := fmt.Sprintf(":point_right: %s your review is requested", mention)
        return msg
    }
    return slack.Attachment{
        Fallback:   "pull request review requested message",
        Color:      slackColorGreen,
        Pretext:    pretext(m.RequestedReviewers),
        AuthorName: m.Owner,
        AuthorIcon: m.OwnerAvatarURL,
        Title:      m.Title,
        TitleLink:  m.URL,
        Text:       m.Body,
        Footer:     "Github webhook " + footerSuffix(),
        Ts:         json.Number(fmt.Sprintf("%d", time.Now().Unix())),
        Fields: []slack.AttachmentField{
            {
                Title: "Repository",
                Value: m.RepoName,
                Short: true,
            },
        },
    }
}

// NotifyPRReviewRequested -
func (s *Slack) NotifyPRReviewRequested(msg *PRReviewRequestedMsg) error {
    // https://github.com/ymgyt/gobot/issues/7
    // when multiple reviewer are requested, multiple event emitted.
    var err error
    if ok := s.DuplicationChecker.CheckDuplicateNotification(msg.URL, (4 * time.Second)); ok {
        _, _, err = s.Client.PostMessage(s.githubChannel.ID, slack.MsgOptionAttachments(msg.attachment(s)))
    }
    return err
}

注意点としては、Reviewerが複数指定された場合、Review Request Eventが複数発火するので、素直に処理すると通知が重複して飛んでしまいます。 そこで、DuplicationCheckerを定義して、確認処理をいれています。興味がある方はsourceのほうで実装をみてみてください。

Userのmention

slackの仕様でmentionを飛ばすには、対象Userのslack上のUserIDが必要です。そのため、PRを通知するには、Github UserName -> Slack Email -> Slack UserIDの変換が必要です。 この変換のために、gobotでは独自にUserをmongoで管理しています。Github UserNameとslack UserIDのマッピングにSlack Emailを使う必要は必ずしもないのですが、slackの管理している情報で全ユーザが必ずもっていて、変化しにくいのでemailを使うようにしました。 このあたりの変換処理は以下のような感じでおこないました。

// MentionByGithubUsername githubのusernameをslackでmentionできるようにする.
func (s *Slack) MentionByGithubUsername(name string) string {
    user, err := s.AccountResolver.SlackUserFromGithubUsername(name)
    // 見つからなければそれがわかるように元の名前で返す
    if IsUserNotFound(err) {
        return fmt.Sprintf("<@%s> (could not resolve slack user by github user name)", name)
    }
    if err != nil {
        return fmt.Sprintf("<@%s> (%s)", name, err)
    }

    return Mentiorize(user.ID)
}

func Mentiorize(slackUserID string) string {
    // mentionするには <@user_id>
    return fmt.Sprintf("<@%s>", slackUserID)
}

// AccountResolver resolve user identities across multi service. ex. github <-> slack.
type AccountResolver struct {
    SlackClient *slack.Client
    UserStore   UserStore
    Mu          *sync.Mutex

    slackUsers []slack.User
}

// SlackUserFromGithubUsername -
func (ar *AccountResolver) SlackUserFromGithubUsername(githubUserName string) (slack.User, error) {
    users, err := ar.UserStore.FindUsers(context.Background(), &FindUsersInput{
        Limit:  1,
        Filter: &User{Github: GithubProfile{UserName: githubUserName}},
    })
    if err != nil {
        return slack.User{}, errors.Annotatef(err, "github username=%s", githubUserName)
    }
    user := users[0]

    return ar.SlackUserFromEmail(user.Slack.Email, false)
}

// SlackUserFromEmail -
func (ar *AccountResolver) SlackUserFromEmail(email string, updateCache bool) (slack.User, error) {
    ar.Mu.Lock()
    defer ar.Mu.Unlock()
    return ar.slackUserFromEmail(email, updateCache)
}

func (ar *AccountResolver) slackUserFromEmail(email string, updateCache bool) (slack.User, error) {
    if ar.slackUsers == nil || updateCache {
        if err := ar.updateSlackUsersCache(); err != nil {
            return slack.User{}, errors.Trace(err)
        }
    }

    for i := range ar.slackUsers {
        if ar.slackUsers[i].Profile.Email == email {
            return ar.slackUsers[i], nil
        }
    }

    // update cache then retry
    if !updateCache {
        return ar.slackUserFromEmail(email, true)
    }

    return slack.User{}, ErrUserNotFound
}

func (ar *AccountResolver) fetchSlackUsers() ([]slack.User, error) {
    return ar.SlackClient.GetUsers()
}

func (ar *AccountResolver) updateSlackUsersCache() error {
    users, err := ar.fetchSlackUsers()
    if err != nil {
        return errors.Trace(err)
    }
    ar.slackUsers = users
    return nil
}

Mongo

CLI interface

ここまででPRを通知できるようになったので、githubとslackのuserをマッピングする設定のInterfaceを作成します。 個人的な構想として、gobotはあくまで、slackとのinterfaceやwebのendpointの提供にとどめ、kubernetes上の他のserviceとgrpcでやりとりしていこうと考えております。 そこで、できるだけ拡張性があるように、CLI Likeなinterfaceにしようと考えました。 そのあたりを以下の処理で行っております。

type SlackMessage struct {
    event    *slack.MessageEvent
    user     *slack.User
    client   *slack.Client
    isDirect bool
}

type MessageHandler struct {
    CommandBuilder interface {
        Build(*SlackMessage) *cli.Command
    }
}

func (h *MessageHandler) Handle(sm *SlackMessage) {
    ctx := setSlackMessage(context.Background(), sm)
    h.CommandBuilder.Build(sm).ExecuteWithArgs(ctx, h.readArgs(sm))
}

func (h *MessageHandler) readArgs(sm *SlackMessage) []string {
    args := strings.Fields(sm.event.Msg.Text)
    normalized := make([]string, 0, len(args))
    for _, arg := range args {
        if arg == "" {
            continue
        }
        normalized = append(normalized, arg)
    }
    if len(normalized) > 0 {
        // if type "@gobot hello", we got "<@AABBCCDD> hello"
        normalized = normalized[1:]
    }
    return normalized
}

slackから@gobot ls usersのようにmention付きでコマンドが実行されるとslack上の情報をcontextにsetしたうえで、通常のcli appのような処理を開始します。 cli packageとして自作のgithub.com/ymgyt/cliを利用しています。 (cobraはspf13氏の他のpackageにかなり強く依存しており、採用しませんでした)

type CommandBuilder struct {
    UserStore UserStore

    once     sync.Once
    commands chan *cli.Command
}

func (b *CommandBuilder) Build(sm *SlackMessage) *cli.Command {
    b.once.Do(func() {
        b.commands = make(chan *cli.Command, commandBuffer)
        go b.run()
    })

    root := <-b.commands
    b.setupRecursive(root, sm)
    return root
}

func (b *CommandBuilder) run() {
    for {
        b.commands <- b.build()
    }
}

func (b *CommandBuilder) build() *cli.Command {
    rootCmd := rootCmd{}
    cmd := &cli.Command{
        Name:      "gobot",
        ShortDesc: "slack bot",
        LongDesc:  "Usage: @gobot <COMMAND> <OPTIONS> <ARGS>",
    }
    if err := cmd.Options().
        Add(&cli.BoolOpt{Var: &rootCmd.printHelp, Long: "help", Description: "print help"}).
        Err; err != nil {
        panic(err)
    }

    return cmd.
        AddCommand(NewVersionCommand()).
        AddCommand(NewUptimeCommand(b)).
        AddCommand(NewAddCommand(b)).
        AddCommand(NewLsCommand(b)).
        AddCommand(NewUpdateCommand(b)).
        AddCommand(NewDeleteCommand(b))
}

func (b *CommandBuilder) setupRecursive(cmd *cli.Command, sm *SlackMessage) {
    b.setup(cmd, sm)
    for _, sub := range cmd.SubCommands {
        b.setupRecursive(sub, sm)
    }
}

func (b *CommandBuilder) setup(cmd *cli.Command, sm *SlackMessage) {
    w := &literalWriter{w: sm}
    if cmd.Run == nil {
        cmd.Run = func(_ context.Context, cmd *cli.Command, _ []string) {
            fmt.Printf("cmd %s run\n", cmd.Name)
            cli.HelpFunc(w, cmd)
        }
    }
    cmd.Stdout, cmd.Stderr = w, w
}

type literalWriter struct {
    w io.Writer
}

func (lw *literalWriter) Write(msg []byte) (int, error) {
    var b bytes.Buffer
    b.WriteString("```\n")
    b.Write(msg)
    b.WriteString("```")
    return lw.w.Write(b.Bytes())
}

通常のcliと異なるのは、std{out,err}がslack channelへの通知なのでそこをwrapしている点です。

UserのCRUD

ようやく、UserのCRUD処理までたどり着きました。 addUserはこのような感じです

func (c *addUserCommand) runFunc(users UserStore) commandFunc {

    // TODO dupulicate check
    validateUser := func(user *User) error {
        if err := user.Validate(); err != nil {
            return errors.Annotate(err, "user validation failed")
        }
        return nil
    }

    return func(ctx context.Context, cmd *cli.Command, args []string) {
        if c.printHelp {
            cli.HelpFunc(cmd.Stdout, cmd)
            return
        }
        if len(args) < 1 {
            cli.HelpFunc(cmd.Stdout, cmd)
            return
        }
        sm := getSlackMessage(ctx)

        user, err := ReadUserFromArgs(args)
        if err != nil {
            sm.Fail(err)
            return
        }

        if err := validateUser(user); err != nil {
            sm.Fail(err)
            return
        }

        if err := users.AddUser(ctx, user); err != nil {
            sm.Fail(err)
            return
        }

        text := "user successfully added"
        sm.PostAttachment(slack.Attachment{
            Fallback:   text,
            Color:      slackColorGreen,
            Pretext:    slackEmojiOKHand + " " + text,
            AuthorName: sm.user.Profile.DisplayName,
            AuthorIcon: sm.user.Profile.Image48,
            Title:      "user profile",
            Text:       Literalize(user.Pretty()),
        })
    }
}

mongo側の処理はこのようになりました。

package store

import (
    "context"
    "time"

    "github.com/juju/errors"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "go.uber.org/zap"

    "github.com/ymgyt/gobot/app"
    "github.com/ymgyt/gobot/log"
)

const (
    userCollection = "users"
)

type Users struct {
    *Mongo
    Now func() time.Time
}

func (u *Users) AddUser(ctx context.Context, user *app.User) error {
    now := u.Now()
    user.CreatedAt = now
    user.UpdatedAt = now

    result, err := u.collection().InsertOne(ctx, user)
    if err != nil {
        return errors.Annotatef(err, "user:%v", user)
    }

    log.Debug("add user", zap.Reflect("insertOneResult", result))
    return nil
}

func (u *Users) UpdateUser(ctx context.Context, input *app.UpdateUserInput) error {
    input.User.UpdatedAt = u.Now()
    result, err := u.collection().ReplaceOne(ctx,
        input.Filter.BsonDWithoutTimestamp(),
        input.User)
    if err != nil {
        return errors.Annotatef(err, "input=%v", input)
    }
    log.Debug("update user", zap.Reflect("updateOneResult", result))
    return nil
}

func (u *Users) FindUsers(ctx context.Context, input *app.FindUsersInput) (app.Users, error) {
    opts := options.Find()
    if input.Limit > 0 {
        opts.SetLimit(input.Limit)
    }

    cur, err := u.collection().Find(ctx, input.Filter.BsonDWithoutTimestamp(), opts)
    if err != nil {
        return nil, errors.Annotatef(err, "input=%v", input)
    }
    defer cur.Close(ctx)

    var users app.Users
    for cur.Next(ctx) {
        var user app.User
        if err := cur.Decode(&user); err != nil {
            return nil, errors.Annotate(err, "failed to decode user")
        }
        if user.IsDeleted() && !input.IncludeDeleted {
            continue
        }
        // currently, mongo does not store timezone.
        user.ApplyTimeZone(app.TimeZone)
        users = append(users, &user)
    }
    if len(users) == 0 {
        return nil, app.ErrUserNotFound
    }
    return users, nil
}

func (u *Users) DeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    if input.Filter == nil && !input.All {
        return nil, errors.New("unsafe deletion process. if you want to delete all, enable the all flag")
    }
    if input.Hard {
        return u.hardDeleteUsers(ctx, input)
    }
    return u.softDeleteUsers(ctx, input)
}

func (u *Users) hardDeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    result, err := u.collection().DeleteMany(ctx, input.Filter.BsonDWithoutTimestamp())
    if err != nil {
        return nil, errors.Annotatef(err, "failed to delete user. input=%v", input)
    }

    return &app.DeleteUsersOutput{
        HardDeletedCount: result.DeletedCount,
    }, nil
}

func (u *Users) softDeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    result, err := u.collection().UpdateOne(ctx,
        input.Filter.BsonDWithoutTimestamp(),
        bson.D{
            {Key: "$set", Value: bson.D{
                {Key: "deleted_at", Value: u.Now()},
            }},
        })
    if err != nil {
        return nil, errors.Trace(err)
    }
    return &app.DeleteUsersOutput{
        SoftDeletedCount: result.ModifiedCount,
    }, nil
}

func (u *Users) collection() *mongo.Collection { return u.Mongo.Collection(userCollection) }

mongo-dirver/bsonの扱いがまだわかっていない点があり、もっとよい方法があると思っています。

Dockerfile

作成したappをdocker imageにしてpushします。Dockerfile上では、moduleをoffに指定してvendoringを利用しています。

FROM golang:1.12.4-alpine3.9 as build

WORKDIR /go/src/github.com/ymgyt/gobot

ENV GO111MODULE=off

RUN apk --no-cache add ca-certificates

COPY . ./

ARG VERSION

RUN echo $VERSION

RUN CGO_ENABLED=0 go build -o /gobot -ldflags "-X \"github.com/ymgyt/gobot/app.Version=$VERSION\""


FROM alpine:3.9

WORKDIR /root

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /gobot .

EXPOSE 80
EXPOSE 443

ENTRYPOINT ["./gobot"]

あとは、docker hubにログインした状態で、mage allを実行すればDocker Registryにimageがpushされます。

Kubernetes

pushしたimageを動かすためにKubernetesの環境を作成していきます。

Mongo

まず、Kubernetes上にMongoを作成します。

3つのResourceを作成します。

  • ConfigMap
  • Service
  • StatefulSet

ConfigMapにmongoの初期化scriptを定義します。 作成したあとはkubectl port-forward mongo-1 27018:27017(podはmasterを指定する必要があります)のようにすると、localのMongoDB Compass等のmongo clientから接続できて便利です。

ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: mongo-init
data:
  init.sh: |
    #!/bin/bash
    until ping -c 1 ${HOSTNAME}.mongo; do
      echo "waiting DNS(${HOSTNAME}.mongo)..."
      sleep 2
    done

    until /usr/bin/mongo --eval 'printjson(db.serverStatus())'; do
      echo "connecting to local mongo..."
      sleep 2
    done
    echo "connected to local."

    HOST=mongo-0.mongo:27017

    until /usr/bin/mongo --host=${HOST} --eval 'print(db.serverStatus())'; do
      echo "connecting to remote mongo..."
      sleep 2
    done
    echo "connected to remote."

    if [[ "${HOSTNAME}" != 'mongo-0' ]]; then
      until /usr/bin/mongo --host=${HOST} --eval 'printjson(rs.status())' | grep -v "no replset config has been received"; do
        echo "waiting for replication set initialization"
        sleep 2
      done

      echo "adding self to mongo-0"
      /usr/bin/mongo --host=${HOST} --eval="printjson(rs.add('${HOSTNAME}.mongo'))"
    fi

    if [[ "${HOSTNAME}" == 'mongo-0' ]]; then
      echo "initializing replica set"
      /usr/bin/mongo --eval="printjson(rs.initiate({'_id': 'rs0', 'members': [{ '_id': 0, 'host': 'mongo-0.mongo:27017'}]}))"
    fi

    echo "initialized"

    while true; do
      sleep 3600
    done

Service

apiVersion: v1
kind: Service
metadata:
  name: mongo
spec:
  ports:
  - port: 27017
    name: peer
  clusterIP: None
  selector:
    app: mongo

StatefulSet

---
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
  name: mongo
spec:
  serviceName: "mongo"
  replicas: 3
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
      - name: mongodb
        image: mongo:3.4.1
        command:
        - mongod
        - --replSet
        - rs0
        ports:
        - containerPort: 27017
          name: peer
        volumeMounts:
        - name: database
          mountPath: /data/db
        livenessProbe:
          exec:
            command:
            - /usr/bin/mongo
            - --eval
            - db.serverStatus()
          initialDelaySeconds: 10
          timeoutSeconds: 10

      # this container initializes the mongodb server, then sleeps.
      - name: init-mongo
        image: mongo:3.4.1
        command:
        - bash
        - /config/init.sh
        volumeMounts:
        - name: config
          mountPath: /config
      volumes:
        - name: config
          configMap:
            name: "mongo-init"
  volumeClaimTemplates:
  - metadata:
      name: database
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 2Gi

Deploy

Mongoを設定したので、いよいよgobotをdeployします。

3種類のResourceを作成します

  • ConfigMap
  • Service
  • Deployment

ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: gobot-ymgyt-configmap-v1.0.2
  labels:
    project: gobot
    env: ymgyt
data:
  GOBOT_LOGGING_LEVEL: debug
  # more GOBOT_* env config

これをみておかしいと思われる方もいらっしゃるかもしれません。そうです、Credential情報をConfigMapで管理しています。 KubernetesにはCredential情報を管理するResource(Secrets)があるので、ConfigMapには、Token情報は格納すべきではありません。 ConfigMapの変更を確実に反映するために、nameにversion情報をいれておいたほうがよいと思います。

Service

apiVersion: v1
kind: Service
metadata:
  name: gobot-ymgyt-loadbalancer
  labels:
    project: gobot
    env: ymgyt
spec:
  type: LoadBalancer
  loadBalancerIP: "your global IP"
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
  selector:
    app: gobot
    env: ymgyt

domainをGCP LoadBalancerにmappingするために、ひとつIPを確保しておいて、DNSに設定してあります。 AWSですと、domainにELBのdomain nameをaliasとして登録できるのですが、GCPで同じことをやる方法がわかりませんでした。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gobot-ymgyt-deploy
  labels:
    project: gobot
    env: ymgyt
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  replicas: 1
  revisionHistoryLimit: 2
  selector:
    matchLabels:
      app: gobot
      env: ymgyt
  template:
    metadata:
      labels:
        app: gobot
        env: ymgyt
    spec:
      containers:
        - name: gobot-container
          image: docker.io/ymgyt/gobot:v1.1.0
          imagePullPolicy: Always
          envFrom:
            - configMapRef:
                name: gobot-ymgyt-configmap-v1.0.2
                optional: false
          ports:
            - name: https
              protocol: TCP
              containerPort: 443
            - name: http
              protocol: TCP
              containerPort: 80
          resources:
            limits:
              memory: 100Mi
              cpu: 500m
            requests:
              memory: 10Mi
              cpu: 250m

imageにはさきほどpushしたDocker Hubを指定します。

apply

上記のfileをkubectl apply -f <resource.yaml>で適用すれば完了です。

まとめ

前回に続いて2本目の開発者ブログいかがだったでしょうか。 誤りのご指摘やご意見等あればtwitterまでいただけるとうれしいです。 本当は、mageやDIのcode生成ツールwire, golangci-lintについても書きたかったのですが、断念しました。

ハウテレビジョンでは現在、エンジニア採用を積極的に進めています。 Missionである"全人類の能力を全面開花させ、世界を変える"は、多少宗教的いろあいがあることは否めませんが、5つのValueとして掲げている

  • Challenge
  • Transparency
  • Ownership
  • Userfirst
  • Respect

は真っ当なので、大丈夫です!

特にこの記事を読んでくれる方には、是非インフラエンジニア募集にご応募していただきたいです。

一緒にGCP/AWS + Kubernetes/Container + GoでHappy Developingしましょう。

参考

https://goenning.net/2017/11/08/free-and-automated-ssl-certificates-with-go/ http://shop.oreilly.com/product/0636920043874.do