EKSへのデプロイを楽にするためにGitHub Appsを導入しました

こんにちは。
マネーフォワードでインフラエンジニアとして働いているkappy(@kappyhappy)です。
今回は、Amazon EKSへのデプロイを効率化するために導入したGitHub Appsのdeploy-botを紹介します。

なぜdeploy-botが必要だったのか

デプロイに使用しているコンポーネント

deploy-bot導入の経緯の説明の前に、デプロイのために使用しているコンポーネントとその用途を説明します。

  • AWS ECR
    • 作成したDocker Imageを保存します
  • AWS EKS
    • AWSマネージドなKubernetesを実行します
  • GitHub
    • ソースコードを保存します
  • CircleCI
    • GitHubでPRのマージをトリガーにECRにDocker Imageをpushします
  • ArgoCD
    • GitHubのリポジトリ更新をトリガーに、更新されたKubernetes Manifestsのapplyを行います
    • Kubernetes Manifestsの作成にはKustomizeを使用しています

導入の背景

2020年7月現在、弊社ではいくつかの本番サービスをAmazon EKS上で稼働させています。また我々は、複数サービスをEKSクラスタで稼働させる為に、いくつかの理由から1つのクラスタ上に複数のサービスを同居させるマルチテナント構成を採用しました。マルチテナント構成におけるマニフェスト管理の運用として、マニフェストファイルを単一のGitHubリポジトリに集約し、サービス単位でディレクトリを作成しています。

上記の構成を取る為に「アプリケーションコードを管理するリポジトリ(以下Application Repository)」と「Kubernetesのマニフェストを管理するリポジトリ(以下、Kubernetes Manifests Repository)」を分割しました。
そのような環境でアプリケーションをデプロイするために、デプロイフローを以下のように定義しました。

  1. Application Repositoryに対してPRを作成&アプルーブ&マージ
  2. CircleCIによるビルドがトリガーされ、ECRにDockerイメージをpush
  3. Kubernetes Manifests Repositoryに対して(デプロイするDockerイメージを変更する)PRを作成&アプルーブ&マージ

デプロイフローのイメージです

step1およびstep3の段階で開発者がPRを作成しアプルーブするというフローになっており、デプロイの度にPRのアプルーブ&マージを2回実施する必要があります。
EKSでの運用開始直後は、このフローを手作業で実施していました。
しかし、一ヶ月ほど経過したあとに行った開発チームへのヒアリングの中で「Application RepositoryにPRがマージされたらTEST環境に対しては自動でデプロイができるようになって欲しい」という要望をいただきました。

そこで、上記の要望を実現するためにdeploy-botを作成することにしました。

なぜGitHub Appsを選んだのか

deploy-botの開発にあたって設計を検討する中で、大きくGitHub ActionsとGitHub Appsという2つの選択肢がありました。
他にもOAuth AppsやPersonal Access Tokenを使用したdeploy-bot作成も可能性としてはありましたが、ユーザーに紐づいた権限付与を避けたかったため、選択肢から外しました。
https://developer.github.com/apps/about-apps/

検討の結果、Application RepositoryをトリガにしてKubernetes Manifests RepositoryにPRの自動作成するGitHub Actionsは難しい、という点からGihub Appsを作成することになりました

検討マトリクス

項目 GitHub Apps GitHub Actions
URL https://developer.github.com/apps/about-apps/ https://help.github.com/ja/actions
概要 Webhookを利用し、外部に用意するアプリケーションがリポジトリに対して処理を行う リポジトリに設定を記載し、GitHubの用意している仮想環境上で、リポジトリに対してジョブを実行する
PR自動作成
GitHub Appsで作成したアプリケーションによって実行可能
×
Application Repositoryに定義されたGitHub Actionsから、Kubernetes Manifests Repositoryのファイルを変更したPRを作成するのは難しい
PR自動アプルーブ
GitHub Appsで作成したアプリケーションによって実行可能

auto-approve-actionというGitHub Actionsも存在し、実行可能
PR自動マージ
GitHub Appsで作成したアプリケーションによって実行可能

automergeというGitHub Actionsも存在し、実行可能

GitHub Appsの構成と処理内容

deploy-botを動かす為のインフラは、運用負荷及びコスト面を考慮し、API Gateway + Lambda + StepFunctionsを採用しました。
構成は以下のとおりです。

設計のポイント

create-botとapprove-botに分けて作成したことが重要な点です。
最初は1つのbotとして作成を進めていましたが、PRを自分で作成して自分でアプルーブすることができなかったため、2つに分けています。

処理フロー

それぞれのbotの処理フローは以下になります。 

create-bot

  1. PRを作成すべきか確認
  2. commit hashが最新の値と同じか確認
  3. Kubernetes Manifests Repositoryをgit clone
  4. Kubernetes Manifests Repositoryのファイルを更新して使用するDockerイメージを変更する
  5. git commit & git push –force
  6. PRが既に存在するか確認し、存在しない場合はPRを作成
  7. slack通知

approve-bot

  1. 受け取ったPayloadの情報が正しいか確認
  2. PRの作成者がcreate-botであることを確認
  3. 変更されているファイルが適切であることを確認する
  4. PRのCIが終わっていることを確認する
  5. PRのアプルーブ&マージ
  6. slack通知

実際の処理

全ての処理のコードを記載して説明すると長くなるので、重要そうな箇所を説明します。

create-bot

PRを作成すべきか確認

PR作成条件は、送られてくる情報の中でもAction、Application Repositoryの名前、トリガーになったブランチもしくはタグを用いて判断しています。
AWS Lambda に記載するコードは Go の AWS Lambda 関数ハンドラー に沿った書き方にする必要があります。

最初に、GitHubから(正確にはCircleCIから)送られてくる情報を受け取るためのstructを用意しました。
TagとPullRequestのどちらをトリガーにしても動作するように作成したので、以下のような項目になっています。
いくつかの項目は毎回取得できるわけではなく、例えばPullRequestの情報はGitHubでPRが作成された時やPRがマージされた時に取得可能な情報です。例えばTagを追加する、というトリガーではPullRequestにはデータが入らないため、データを取得しようと試してもエラーにはなりませんが何も情報は得られません。

type githubPayload struct {
    Action      string `json:"action"`
    PullRequest struct {
        Base struct {
            Branch string `json:"ref"`
        } `json:"base"`
        MergeCommitSHA string `json:"merge_commit_sha"`
        Merged         bool   `json:"merged"`
    } `json:"pull_request"`
    Repository struct {
        Name string `json:"name"`
    } `json:"repository"`
    Ref        string `json:"ref"`
    HeadCommit struct {
        SHA string `json:"id"`
    } `json:"head_commit"`
}

上記のstructを用いて、AWS Lambda用のmain関数は以下のようになっています。

func main() {
    lambda.Start(execute)
}

func execute(ctx context.Context, gp githubPayload) (string, error) {
}

リポジトリ毎にTESTとPRODに適用したいブランチやタグが異なるので、PR作成条件を定義しています。
example1はブランチで適用する環境を変えたい例、example2はタグで適用する環境を変えたい例です。

func setCreatePRConditions() map[string][]map[string]string {
    return map[string][]map[string]string{
        "example1": {
            {
                "env":  "test",
                "base": "develop",
                "tag":  "",
            },
            {
                "env":  "prod",
                "base": "master",
                "tag":  "",
            },
        },
        "example2": {
            {
                "env":  "test",
                "base": "master",
                "tag":  "",
            },
            {
                "env":  "prod",
                "base": "master",
                "tag":  "release-",
            },
        },
    }
}

受け取ったApplication Repositoryの情報がPR作成条件の中に入っているかを確認した後、
受け取った情報とPR作成条件を組み合わせてPRを作成するべきか判別します。
トリガーがPullRequestなのかTagなのかによって処理が変わりますが、それぞれブランチ名とタグ名がPR作成条件と合致しているかを確認しています。

func checkIfPullRequestShouldBeCreated(gp githubPayload, createPRConditions map[string][]map[string]string) error {
    githubTag := ""
    // gp.Ref == "" means event type is not push but pull_request
    if gp.Ref == "" {
        if gp.Action == "closed" {
            fmt.Println("PR will be created")
        } else {
            return fmt.Errorf("This event is not the trigger to create PR")
        }
    } else {
        ref := strings.Split(gp.Ref, "/")
        if len(ref) != 3 {
            return fmt.Errorf("This event is not the trigger to create PR")
        }
        if ref[1] == "tags" {
            githubTag = ref[2]
        } else {
            return fmt.Errorf("This event is not the trigger to create PR")
        }
    }

    shouldCreatePR := false
    for _, createPRCondition := range createPRConditions[gp.Repository.Name] {
        if createPRCondition["tag"] == "" {
            if createPRCondition["base"] == gp.PullRequest.Base.Branch {
                shouldCreatePR = true
                break
            }
        } else {
            var tagRegexp = regexp.MustCompile(createPRCondition["tag"])
            matches := tagRegexp.FindStringSubmatch(githubTag)
            if len(matches) > 0 {
                shouldCreatePR = true
                break
            }
        }
    }

    if !shouldCreatePR {
        return fmt.Errorf("This payload is not the trigger to create PR")
    }

    return nil
}

ここまでの処理が終わるとPRの作成処理に移ります。

Kubernetes Manifests Repositoryをgit clone

PRを作成するために、git clone/add/commit/pushと実施していくのですが、
git cloneの前に、GitとGitHubの処理をどちらもGitHub AppsのTokenを使って行うために、GitHub用のClientと、Git用のTokenを作成します。

func createGithubAppsTokenAndClient() (string, *github.Client, error) {
    tr := http.DefaultTransport

    itr, err := ghinstallation.New(tr, appID, installationID, []byte(githubAppsPrivateKey))
    if err != nil {
        fmt.Println("ghinstallation new failed")
        fmt.Println(err)
        return "", nil, err
    }

    token, err := itr.Token(context.Background())
    if err != nil {
        fmt.Println("Failed to create new github token")
        fmt.Println(err)
        return "", nil, err
    }

    return token, github.NewClient(&http.Client{Transport: itr}), nil
}

準備が整ったらgit cloneを行います。
気にすべき点は2つで、AWS Lambdaが /tmp ディレクトリしか書き込みができないことと、作成したgit tokenを使用するとusernameの値は何でも構わない、という点です。

func cloneManifestRepository(token string) (string, error) {
    // Lambda allows only /tmp to write
    dir, err := ioutil.TempDir("/tmp", kubernetesManifestsRepositoryName)
    if err != nil {
        return "", fmt.Errorf("Failed to create temp dir")
    }

    url := "https://github.com/moneyforward/" + kubernetesManifestsRepositoryName + ".git"
    if _, err := git.PlainClone(dir, false, &git.CloneOptions{
        URL:           url,
        ReferenceName: "refs/heads/master",
        Depth:         1, // Shallow Clone
        Auth: &githttp.BasicAuth{
            Username: "dummy",
            Password: token,
        },
        Progress: nil,
    }); err != nil {
        return "", fmt.Errorf("Failed to clone repository")
    }

    return dir, nil
}

cloneが済んだので、ファイルの更新処理を行います

Kubernetes Manifests Repositoryのファイルを更新して使用するDockerイメージを変更する

ArgoCDはKustomizeを使用しているので、Kubernetes Manifests Repositoryで使用するDockerイメージをkustomizeを使用して変更します

func updateImageTag(kustomizationYamlPath, gp githubPayload) error {
    kustomizationYamlDirPath := filepath.Dir(kustomizationYamlPath)
    originalDir, err := os.Getwd()
    if err != nil {
        return err
    }
    defer os.Chdir(originalDir)

    if err := os.Chdir(kustomizationYamlDirPath); err != nil {
        return err
    }

    newImage := ""
    if gp.Ref == "" {
        newImage = gp.PullRequest.MergeCommitSHA
    } else {
        newImage = gp.HeadCommit.SHA
    }

    return exec.Command(originalDir+"/kustomize", "edit", "set", "image", gp.Repository.Name+"="+newImage).Run()
}

なお、AWS Lambdaの処理中にkustomizeを使用するために、 AWS Lambdaにアップロードするzipファイルはgolangのバイナリファイルとkustomizeを含めて作成しています。

GOOS=linux go build -o main .
zip function.zip main kustomize

この後はgit commit/pushしてPRを作成し、結果をSlack通知に送ってcreate-botの処理は終了です。

approve-bot

PRの作成者がcreate-botであることを確認

approve-botが使用しているstructは以下のとおりです。
PR番号やPR編集者の情報を取得しています。

type githubPayload struct {
    Action      string `json:"action"`
    Number      int    `json:"number"`
    PullRequest struct {
        Head struct {
            Branch string `json:"ref"`
        } `json:"head"`
        Base struct {
            Branch string `json:"ref"`
        } `json:"base"`
    } `json:"pull_request"`
    Repository struct {
        Name string `json:"name"`
    } `json:"repository"`
    Sender struct {
        Login string `json:"login"`
    } `json:"sender"`
}

PR編集者がcreate-botかどうかを判別します。
これによって誰かが間違えてcreate-botと同じブランチ名でPRを作成してしまってもapprove-botはマージしなくなります。

    if gp.Sender.Login != "pr-create-bot[bot]" {
        return "", fmt.Errorf("PR sender is invliad.")
    }

この後、変更されているファイルが正しいか確認してから、CI終了を待つ処理に移ります。

PRのCIが終わっていることを確認する

https://godoc.org/github.com/google/go-github/github#PullRequest.GetMergeable
PullRequestのgetMergeableを使うと、例えCIが回っている状態でも True が返ってきたため、getMergeableStateで値が clean になっていることを確認しています。

func checkPRStatus(ctx context.Context, client *github.Client, pr *github.PullRequest, gp GithubPayload) error {
    if pr.GetMergeable() == false {
        return fmt.Errorf("PR is not mergeable")
    }

    // Wait until CI check complete within waitMaxSecond
    maxAttempt := waitMaxSecond / waitInterval
    for i := 0; i < maxAttempt; i++ {
        pr, _, err := client.PullRequests.Get(ctx, "moneyforward", gp.Repository.Name, gp.Number)
        if err != nil {
            return err
        }
        if pr.GetMergeableState() == "clean" {
            return nil
        }
        time.Sleep(time.Second * time.Duration(waitInterval))
    }

    return fmt.Errorf("PR mergeable state is not clean")
}

CIの確認が終わったのでPRのアプルーブとマージを行います

PRのアプルーブ&マージ

CODEOWNERにGitHub Appsが登録できなかったため、approveの処理のみ、Personal Access Tokenを用いてクライアントを作成して、アプルーブします。

func approvePR(ctx context.Context, gp githubPayload) error {
    client, err := createGithubClientWithPersonalAccessToken(ctx)
    if err != nil {
        return err
    }

    prr := &github.PullRequestReviewRequest{
        Event: github.String("APPROVE"),
    }

    _, _, err = client.PullRequests.CreateReview(ctx, "moneyforward", gp.Repository.Name, gp.Number, prr)
    return err
}

func createGithubClientWithPersonalAccessToken(ctx context.Context) (*github.Client, error) {
    personalAccessToken, err := getSSMParameterValue(ssmPersonalAccessToken)
    if err != nil {
        return nil, err
    }

    ts := oauth2.StaticTokenSource(
        &oauth2.Token{AccessToken: personalAccessToken},
    )
    tc := oauth2.NewClient(ctx, ts)

    return github.NewClient(tc), nil
}

アプルーブが終わったら、マージしてSlackに通知してpr-approve-botの処理は完了です。

deploy-bot作成中の問題点

作成している最中に問題になったこととを以下に記載します。

  1. CODEOWNERSにGitHub Appsを登録することができなかった
  2. API Gatewayは29秒の制約があり、botの処理完了が間に合わなかった
  3. go-gitのpushにforceオプションがなかった

CODEOWNERSにGitHub Appsを登録することができなかった

まず、内容をあまりわかっていない人がアプルーブしてしまうことを防ぐためにCODEOWNERSを使用しているのですが、
特定のファイルに対してのCODEOWNERにapprove-botを追加しようとしたところ、現時点ではGitHhub Appsは追加できないとのことでした。
対策として、避けたかったのですがやむなくPersonal Access Tokenを使うことにしました。

API Gatewayは29秒の制約があり、botの処理完了が間に合わなかった

create-botとapprove-botの処理がAPI Gatewayの制約である29秒以内に完了しなくなったのは原因があります。
create-botは処理中で前回マージされた時からの差分を確認し、その時の変更に含まれるApplication RepositoryのPRをリストにして、Kubernetes Manifests Repositoryに作成するPRに記載しています。しかし、大量のPRが含まれている場合、処理が29秒以内に完了できませんでした。
approve-botの場合は、PRのマージを行う条件としてCIのテストが終わっている必要があるのですが、CIのテストが29秒以内に終わらない場合がありました。
結果として、create-bot、approve-botともにAPI Gateway+Lambdaの構成では不十分だということがわかりました。

対策としてAPI GatewayとLambdaの間にStepFucntionsを挟むことで最長でLambdaの制約である15分は動かせるようにしました。

create-botが作成するPRのイメージ

go-gitのpushにforceオプションがなかった

go-gitにpushのforceオプションがないと、デプロイのトリガとなるApplication Repositoryのブランチに立て続けにPRがマージされた際に、既存のPRに簡単にpushすることができず、PRを削除して再作成しなければなりません。
CloseされたPRが何回も生成されるのは嫌だったこと、git pushにタイミングの問題で失敗した後にリトライする処理は書きたくなかったことからgit pushにforceオプションがあると良いなと考えました。

対策として、OSS貢献のチャンス!ということでPRを出しました。
https://github.com/go-git/go-git/pull/71
無事PRはマージされていて、go-gitのv5.1.0からはgo-gitを使ってpushする時にもforceオプションが使用可能です。

実際にGitHub Appsを作ってみての感想

いくつか良かったことがありました

  1. デプロイフローを効率化することで、Developer Experienceを上げることができました。
  2. サーバレスでツールを開発する経験が少なかったので、勉強になりました。
  3. AWS関連のリソースはTerraformで作ったので、GitHub AppsでなくてもAPI Gateway+Lambdaの構成であればサクッと作れるようになりました。
  4. 普段お世話になってばかりのOSSにコントリビュートできました。
  5. 仲間から喜びの声をもらえました。

おわりに

マネーフォワードでは、Kubernetesを使用したサービスの安定化や、業務の効率化を促進するエンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket