GitHub Actionsを使用した肩肘張らないEKSへのPodデプロイ 〜アプリケーションリリース編〜

## はじめに

BookLiveでは2020年末にEKSを導入して、既にいくつかのアプリケーションがEKS上で本番稼働しています。
この記事では、そんなBookLiveがEKSへのデプロイをどのようにやっているか、事例を紹介したいと思います。
「既にゴリゴリK8sを運用しています」というような方には参考にならないと思いますが、これから導入しようとされている方には多少なりとも参考になるかもしれません。

### 基本的に使うもの

- GitHub Actions
- aws-cli
- kubectl

## まずはアーキテクチャ

まずはともあれアーキテクチャを紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/555899/5c8353da-94ea-7865-77fc-43a8454a06f6.png)

はい、書くまでもなかったですね（笑）。
とはいえ、多少なりとも特徴を抽出して紹介します。

- GitHub Enterprise(以下、GHE)を使用しているが、Self hosted版である
  - したがって、GitHub Actionsのrunnerインスタンス(EC2)もBookLiveのVPC内にある
  - IAMの権限はrunnerインスタンスのIAM Roleを適用するようにActionsを実装している
- GitリポジトリはK8sのマニフェストとアプリケーションコードで分けている
  - K8sマニフェスト(インフラ)とアプリケーションでリリースサイクルが違うため、分ける方がCI/CDが組みやすい
  - 逆に分けないと、gitのコミットログ・タグがどちらに属するものか分かりづらくなって、リポジトリのメンテナンス性が下がる

言うほど特徴的なものはありませんが、軽く頭に入れておいてもらえると以降の内容がわかりやすいかと思います。
また、インフラとアプリケーションでリポジトリが分かれていると書いていますが、メンテナも分かれており、前者はインフラを管理しているSREチーム、後者はブックライブサービス等を開発しているアプリケーション開発のチームに裁量があります。
僕が所属しているのはプラットフォーム開発チームで後者のチームに該当します。
したがって、今回の記事におけるデプロイとはインフラとしてのK8sリソースの更新ではなく、アプリケーションの更新を意味します。

「インフラリソースの更新」は`kubectl apply`、「アプリケーションの更新」は`kubectl rollout`という意味で使っています。

## なぜGitHub Actionsにした？

まず結論から申し上げますと、**「必要十分だったから」**という理由です。

前提としてEKSへのデプロイは「IAMの権限がある」というのが十分条件で、それさえあればkubectlでデプロイが可能です。
それに加え、やりたかったことをまとめると

- 統合環境はrelease candidate(rc)、staging(stg)、production(prod)の3つがあり、デプロイの方法を環境ごとに変えられる必要がある
  - rcとstg環境にはgitのmergeのタイミングで自動でデプロイしたい
  - git flowのアプリケーションが多く、developブランチはrc、master(main)ブランチはstgおよびprod
  - prod環境には任意のタイミングで手動でジョブを実行するような形式でデプロイしたい
- デプロイの実行ログ(誰がいつ何をしたか)を残したい

この程度のものでした。
最初はArgo CD入れるぞとか、stgへ自動デプロイ後はprodデプロイ前に承認を挟む形でSpinnakerとかでパイプラインを構築してみる？、とか意気込んでたんですが、SREチームと相談して、最初から仰々しくやらずに必要最低限の準備と運用で済むGitHub Actionsでいいっしょと帰結しました。

元々BookLiveではCIやリリースジョブにJenkinsを使用していたので、従来とほぼ同じ思想でジョブを構築できるというのも大きかったです。
Jenkinsは今でも大活躍していて、かつCI/CDはアプリケーションのメンテナに裁量があるので、今ではこの記事で紹介するやり方をJenkinsから実行していたりもします。
後ほど紹介する`rollout status`は監視ジョブとしてJenkinsで作られたものを、GitHub Actionsにも組み込んだりしてます。

補足を書いておきますが、Code PipelineはGHE Self-hostedに対応していないのでそもそも候補に入れませんでした。
GHE Self-hostedでも使おうと思えば使えますが(実際BookLiveでも使用している箇所はある)、多少周りくどいやり方をする必要があります。

## GitHub Actionsコード紹介

今回は、mainブランチへのmergeが完了後にstgとprodのEKSクラスタにデプロイするためのActionを紹介します。
多少実際のコードとは違いますが、エッセンスが伝わるように編集しています。

### [Marketplace](https://github.com/marketplace?type=actions)からの輸入

コードを紹介する前にMarketplaceから輸入したコンポーネントたちを紹介しておきます。

| Actions名 | 内容 |
|:---:|:---:|
| [Slack Notify (rtCamp)](https://github.com/marketplace/actions/slack-notify) | 決められたテンプレートに値を入力するだけでSlack通知ができる |
| [Kubectl tool installer (Azure)](https://github.com/marketplace/actions/kubectl-tool-installer) | kubectlを使えるようにダウンロードしてくれる |

### コード例 (stg)

mainブランチのリビジョンが動いたら自動で動くActionです。
このアクションでは次の内容が実行されます。
追って詳しくジョブの内容は説明します。

1. Slackにリリース開始の通知
1. コンテナイメージのビルドをした後、ECRにイメージをpushする
1. 成功・失敗に関わらずSlack通知（色でステータスを見分けられる）
1. stgクラスタに`rollout restart`を実行して、成功したらその旨をSlack通知
1. `rollout status`で待機し、4も含め成功・失敗に関わらずSlack通知
1. Slackにリリース完了の通知

```yaml
name: Release stg

on:
  push:
    branches:
    - main

jobs:
  start:
    runs-on: [ self-hosted, stg ]
    steps:
    - name: Slack notification start
      uses: rtCamp/action-slack-notify@v2
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_CHANNEL: slack_channel_name
        SLACK_USERNAME: App CI/CD
        SLACK_ICON_EMOJI: ':emoji:'
        SLACK_COLOR: ${{ job.status }}
        SLACK_MESSAGE: 'START STG RELEASE'

  build:
    needs: start
    runs-on: [ self-hosted, stg ]
    steps:
    - uses: actions/checkout@v2

    - name: Generate tag
      id: initialize
      run: |
        rev=$(git rev-parse HEAD)
        echo "::set-output name=TAG::$rev"

    - name: Build image
      run: ./build.sh -t ${{ steps.initialize.outputs.TAG }}

    - name: Push imeage
      run: |
        ./publish.sh -t ${{ steps.initialize.outputs.TAG }}

    - name: Slack notification push complete
      if: ${{ always() }}
      uses: rtCamp/action-slack-notify@v2
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_CHANNEL: slack_channel_name
        SLACK_USERNAME: App CI/CD
        SLACK_ICON_EMOJI: ':emoji:'
        SLACK_COLOR: ${{ job.status }}
        SLACK_MESSAGE: 'PUSHED STG IMAGE'

  deploy:
    needs: build
    runs-on: [ self-hosted, stg ]
    steps:
    - uses: actions/checkout@v2
    - name: Update kubeconfig
      run: aws --region ap-northeast-1 eks update-kubeconfig --name stg-cluster-name --kubeconfig ./kubeconfig

    - uses: azure/setup-kubectl@v1
    - name: Rollout
      run: |
        kubectl --kubeconfig=./kubeconfig -n app rollout restart deployment app-deployment

    - name: Slack notification applied
      if: ${{ success() }}
      uses: rtCamp/action-slack-notify@v2
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_CHANNEL: slack_channel_name
        SLACK_USERNAME: App CI/CD
        SLACK_ICON_EMOJI: ':emoji:'
        SLACK_COLOR: ${{ job.status }}
        SLACK_MESSAGE: 'APPLIED STG ROLLOUT'

    - uses: azure/setup-kubectl@v1
    - name: Check status
      run: |
        kubectl --kubeconfig=./kubeconfig -n app rollout status deployment app-deployment

    - name: Slack notification complete
      if: ${{ always() }}
      uses: rtCamp/action-slack-notify@v2
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_CHANNEL: slack_channel_name
        SLACK_USERNAME: App CI/CD
        SLACK_ICON_EMOJI: ':emoji:'
        SLACK_COLOR: ${{ job.status }}
        SLACK_MESSAGE: 'COMPLETE STG ROLLOUT'

  finish:
    needs: deploy
    runs-on: [ self-hosted, stg ]
    steps:
    - name: Slack notification finish
      uses: rtCamp/action-slack-notify@v2
      env:
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        SLACK_CHANNEL: slack_channel_name
        SLACK_USERNAME: App CI/CD
        SLACK_ICON_EMOJI: ':emoji:'
        SLACK_COLOR: ${{ job.status }}
        SLACK_MESSAGE: 'FINISHED STG RELEASE'
```

それでは各jobの説明を書いていきます。

#### start

ここで重要なのは2つあります。

一つ目は`runs-on`の設定でこの設定は全てのjobに記載する必要があります。
重要なのはprodの部分で、stg環境用のIAMロールが適用されているrunnerで実行することを指定しています。

もう一つは`${{ secrets.SLACK_WEBHOOK }}`です。
これはSlackのincoming webhookのurlをあらかじめGitHubのSecretsに登録しておいて、それを利用するようにしています。

#### build

以降のjobにも共通しますが、まずなんといっても大事なのは`needs`です。
今回のjobは全て直列に実行する必要があるので、needsに前のjob名を指定することで、そのjobが完了した後にjobを開始するよう制御できます。
逆に、指定しなければ並列で動きます。

このjobで重要なものは特にないのですが、プラクティスのひとつになればいいかなと思い、内容だけ軽く共有します。

まず、`build.sh`と`publish.sh`はBookLiveではよく使うのですが、前者はスクリプト内でdocker imageをビルドし、後者はECRへのpushを行います。
特に前者はローカルでビルドするときも使えるように実装しておくと便利です。

次に特徴的なのは

```
rev=$(git rev-parse HEAD)
echo "::set-output name=TAG::$rev"
```

と、それを引数としてスクリプトに渡す

```
-t ${{ steps.initialize.outputs.TAG }}
```

かなと思います。
ここでやりたいのは、ECRにgitのリビジョンのタグをpushするというものです。
これをやっておくことで、latestやその他のエイリアスタグがどのリビジョンを指しているのかをECRを見ることですぐ分かるようになります。
そのためにGitHub Actions内で対象コードのHEADが指しているリビジョン番号を抽出し、スクリプトにそのタグを生成するようにタグ名を指定しているというような感じです。

#### deploy

ここでようやくEKSの話です。
このjobのエッセンスは2つあります。

一つ目は`aws eks update-config`を使ってカレントディレクトリにEKSの`.kube/config`相当のファイルを生成し、それを使って`kubectl rollout`を実行するというものです。
カレントディレクトリに出力するというのが重要で、グローバルな`.kube/config`にしないことで、リポジトリ間やAction間で影響を与えることなくkubectlを実行できます。

もう一つは、`rollout status`です。
`rollout restart`はそれを実行した時点で即時にステータスが返ってくるので、`コマンドの終了 = restartの完了`ではありません。
このrestartが完了しないとPodの入れ替えは完了していないので、本当の完了時にSlack通知をするためにはPodの入れ替えを監視してあげる必要があります。
これを実現してくれるのが`rollout status`で、rollout実行中の場合、それが完了するまで待機し、成功すればステータスコード0を返してくれます。
その後にSlack通知をすることで、本当のPod入れ替え完了をただ待っておくだけで知ることができます。


#### finish

特に重要なことはなく、Slackに全てのjobが完了したことを通知しているだけです。

### コード例 (prod)

ほぼstgと一緒ですが、冒頭で紹介したやりたいことの中に、本番環境では**任意のタイミング**でリリースをしたい(デプロイをしたい)と書いていました。
これを実現してくれるのが、`workflow_dispatch`です。
`workflow_dispatch`を使うとActionを任意のタイミングで実行できるようになります。

```yaml
name: Release prod

on:
  workflow_dispatch:
```

その他大事なことは、

- prod環境用のIAMロールが適用されているrunnerでActionを実行すること
- `eks update-kubeconfig`でprod環境用のクラスタ名を指定すること

くらいです。

## 今後の展望

aws-cliの`eks update-kubeconfig`はEKSの利用を汎用的にしてくれます。
そのため、kubectlがあればコマンドラインからEKSにデプロイできるようになります。
例えば、`docker-compose run`で実行しているものを`kubectl run`に置き換えるだけで良いと考えると、K8sの利用を促進できると思いませんか？
実際、BookLiveではバッチのスケジューラにdigdag、実行環境はDockerがインストールされたEC2群、そこに対して、`docker-compose -H ${host ip} run`でバッチを実行しています。
スケジューラを移行するのは相当な手間や移行時のリスクが伴うので、スケジュール設定はそのまま、実行環境をK8sに移行してクラスタ化できると考えると非常に魅力的に感じています。

## おわりに

予告にはIAM関連の記事を書くと書いておりましたが、当初予定していた題材の準備が間に合わず、急遽今回の内容にしました。
すみません、、
どなたかひとりでも参考になったのであれば幸いです。
良いお年を。