こんにちは、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
PRでreviewerを指定する
SlackにReviewが依頼された通知が飛ぶ
コメントされたり
approveされたりしても通知される
User CRUD
Github UserとSlack Userの対応はgobotが独自に管理しています。UserのCRUDはCLI likeに行えます。
Create
Read
Update
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の設定
3 OAuth & PermissonsからBot UserOAuth Access Tokenの値を確認します。このTokenは実行時にgobotに渡してやる必要があります。
Github
PullRequestの通知を実施したいRepositoryにてWebhookを設定します。(複数Repositoryに設定して問題ありません)
Settings > Webhookから設定をおこないます。
Github設定
Payload URLに準備しておいたdomainを、pathは/github/webhook
として進めます。
Content typeにはapplication/json
を指定し、Secretはgobotに渡してやる必要があります。
Eventsは、Pull requestsとPull request reviewsを指定します。(issueは開発中のため、指定してあるだけです)
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を設定しておきます。
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
を利用しています。
type Slack struct {
*SlackOptions
Client *slack.Client
AccountResolver *AccountResolver
DuplicationChecker *DuplicationChecker
MessageHandler SlackMessageHandler
user string
userID string
rtm *slack.RTM
githubChannel *slack.Channel
}
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
の処理をみていきます
type SlackMessageHandler interface {
Handle(*SlackMessage)
}
func (s *Slack) handleMessage(msg *slack.MessageEvent) {
if msg.Msg.SubType == "bot_message" {
log.Debug("slack/ignore bot message" , zap.String("sub_type" , msg.Msg.SubType))
return
}
isDirect := strings.HasPrefix(msg.Channel, "D" )
mention := strings.Contains(msg.Text, "@" +s.userID)
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
を利用しています。
type Github struct {
Webhook *github.Webhook
Slack *app.Slack
}
var targetEvents = []github.Event{
github.PullRequestEvent,
github.PullRequestReviewEvent,
github.IssuesEvent,
}
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 :
}
}
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,
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))
}
w.WriteHeader(http.StatusOK)
}
github.Webhook.Parse
でpayloadの検証とbindingまでおこなってくれるので、必要な情報だけ抽出して、slackに通知します。
slackへの通知する際に、概要にあったようなmessageを送るためにattachmentを設定します。
type PRReviewRequestedMsg struct {
Owner string
OwnerAvatarURL string
URL string
Title string
Body string
RepoName string
RequestedReviewers []string
}
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 ,
},
},
}
}
func (s *Slack) NotifyPRReviewRequested(msg *PRReviewRequestedMsg) error {
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を使うようにしました。
このあたりの変換処理は以下のような感じでおこないました。
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 {
return fmt.Sprintf("<@%s>" , slackUserID)
}
type AccountResolver struct {
SlackClient *slack.Client
UserStore UserStore
Mu *sync.Mutex
slackUsers []slack.User
}
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 )
}
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
}
}
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 {
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
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
}
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 : |
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
- 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
これをみておかしいと思われる方もいらっしゃるかもしれません。そうです、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