こんにちは、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処理を行います。
Github PullRequest Notification
User CRUD
Github UserとSlack Userの対応はgobotが独自に管理しています。UserのCRUDはCLI likeに行えます。
準備
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の設定
3 OAuth & PermissonsからBot UserOAuth Access Tokenの値を確認します。このTokenは実行時にgobotに渡してやる必要があります。
Github
PullRequestの通知を実施したいRepositoryにてWebhookを設定します。(複数Repositoryに設定して問題ありません) Settings > Webhookから設定をおこないます。
Payload URLに準備しておいたdomainを、pathは/github/webhook
として進めます。
Content typeにはapplication/json
を指定し、Secretはgobotに渡してやる必要があります。
Eventsは、Pull requestsとPull request reviewsを指定します。(issueは開発中のため、指定してあるだけです)
gobot
準備の各設定をおこない、必要な設定項目を取得したので、いよいよGoのsource codeをみていきます。 ここでは、local環境の設定からdocker imageのpushまでについて述べます。 imageがpushできたら、kubernetesにdeployして完成です。
開発環境
gobotのsorceをpullしてある前提で話をすすめます。 利用するツールは以下の通りです
- go1.12
- docker
- docker-compose
- direnv(optional)
- mage
- golangci-lint(optional)
- wire
環境変数
.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