LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

マッハバイトをChatOpsでリリースしたい

インフラストラクチャグループの春日です。本記事では開発体験を改善させる目的で マッハバイトSlack からリリースできるようにした話をさせていただきます。

背景

マッハバイトは2021年現在、オンプレ環境のVM上で稼動しています。 将来的にはクラウドへ移行することを念頭に置いて日々開発を進めています。

現状ではレガシーなDB設計やモノリスコードがまだ残っていて、直近ですぐにモダンな環境に刷新するには難しい点も多いです。 そんな中でマッハバイトを構成する各種アプリケーションは日に何回もリリースが行われています。

そこで日々のリリース作業に焦点を置いた改善を試みました。

課題

手動によるコマンド実行

デプロイ処理は基本的に Capistrano を利用しており、リリース時の実行手順は以下でした。

  1. オペレーションサーバーと呼ばれるサーバーにSSHで入る
  2. git コマンドで最新のソースコードを得る
  3. 手動で cap コマンドを実行する

慣れればどうということはないですが、地味に面倒な作業の典型と言えます。

オペレーションサーバー自体の運用

オペレーションサーバーにはデプロイ対象となる全てのリポジトリを網羅した環境を整えておく必要があります。 プロビジョニングツールを使用して構築してはいるものの、できればオペレーションサーバー自体の運用からも解放されたいと常々思っていました。

リリースの告知と報告と記録

  • 社内ドキュメント資料に年月日と氏名とリポジトリ名とリリース内容を記録
  • リリース前にSlackで内容を告知
  • オペレーションサーバーに入ってコマンド実行
  • 終わったらSlackで報告

リリースごとに作業者が上記を行っていました。 情報的にはどれもgitに含まれている内容ばかりで人間が手動でやる必要はなさそうです。

ChatOpsの検討

機械に任せられる仕事は積極的に自動化していくのが本来あるべき姿です。 リリース作業においては何かしらのサービスと連携させたり、リリース手順をスクリプト化するなどの方法が考えられます。

そもそもCapistrano以外のデプロイ手段を模索する手もありましたが、現在のシステム構成を大幅に変更する選択肢は今回は見送りました。 クラウド移行中の過渡期というのもあり、近い将来に捨てることになるシステムを時間を掛けて構築するメリットは薄いと判断したからです。

そこで耳にするのは○○Opsという単語です。 以下のようにチャットからbotに指示を出してリリースを行うChatOpsはよくある話だと思います。

誰がどんなオペレーションをしているのか、その作業結果はどうなったのか、の情報がSlack上に集約されることにメリットがあると感じます。 昨今は誰がどこで働いているかを把握しにくい時代になっており、チャットでのコミュニケーションの重要度が増しています。

他にもChatOps以外だとデプロイツール、リリース管理ツールの導入も検討していました。 例えば Shopify 社の Shipit というツールで Rails Engine としてつくられています。 UIもわかりやすく、GitHubとの連携もしっかりしていて良さそうでした。

しかしチームに相談したところ、見なければならないツールが増えるよりもコミュニケーションが集中しているSlack上でリリースできた方が情報共有がスムーズでは、という結論に至りました。 Slackから1コマンドでリリースの告知・作業・報告が完結するやり方は筋が良さそうです。作業者や日時も判別ができ、履歴も辿ることができます。

ChatOpsの実現

ここからは実際にマッハバイト用のChatOpsの仕組みを構築するにあたって組み合わせたコンポーネントを紹介していきます。 やりたいのはオンプレ環境のVM上で動くアプリケーションに対するCapistranoデプロイ自動化の仕組みを構築することです。 ですが、この仕組みのオンプレ部分は将来的にクラウド移行が完了すると捨てることになります。

構成図

まずは全体の構成図です。

ChatOps全体構成図
ChatOps全体構成図
Slack上でbotにメンション付きでデプロイコマンドを投稿するとキューイングされ、ワーカーがデプロイコンテナを起動して処理が終了すると、最後に結果がSlackに通知される流れです。

運用が必要なオペレーションサーバーを使わずにCapistranoデプロイを実行するにはコンテナ化が第一候補に上がります。 コンテナを操作するのに適したオーケストレーターは今では Kubernetes がデファクトスタンダードです。 今回はデプロイ対象がオンプレなのでオンプレ側にKubernetesを構築して使うことにしました。

Kubernetes
Kubernetes
もちろん全体の処理をオンプレKubernetes側で完結させることも可能です。 しかしマッハバイトは将来的にクラウドへ移行するため、以下の目的でクラウドのマネージドサービスと組み合わせることにしました。

  • 今の内からクラウドの知見を貯めていく
  • bot本体がクラウドにいれば各種マネージドサービスと連携したChatOpsが今後しやすくなる

それでは各部品を紹介していきます。

bot本体の開発

項目 内容
動作環境 AWS ECS Fargate
開発言語 Go
責務 Slackメッセージの送受信、AWS SQS へのenqueue

チャット上のメッセージ送受信および任されたタスクの処理やキューイングを行うChatBotです。ChatBot用のフレームワークは何個か存在します。

しかし今回は上記のようなフレームワークに頼らずにGo言語で実装しました。理由は以下です。

  • slack-go というライブラリの存在
  • シングルバイナリとコンテナとの相性の良さ
  • 型があってコンパイルができる言語の安心感

機能としては WebSocket 通信を用いた Real Time Messaging API を使っています。 WebSocket通信だと一度bot側からSlack APIに接続してしまえば、それだけで双方向通信ができてしまう手軽さがあります。 Slackアプリとしては旧タイプになってしまいますが、WebSocket版のbotは依然として扱い易いです。 ライブラリがgoroutineでコネクションを常に監視しており、切れたら再接続してくれます。 なお、本記事を書いている間にWebSocket通信を使った新しいAPIの Socket Mode が発表されました。 SlackのGoライブラリも 爆速で対応 していたので、近いうちに移行予定です。

リリースは本番アプリケーションに変更を加えるクリティカルな作業のため、デプロイコマンドが使える部屋や人を制限する仕組みも実装しています。 ライブラリドキュメント を見ればわかりますが、痒い所に手が届く機能の実装が可能です。

AWS SQSへのenqueueは以下の情報をJSON化して渡しています。

  • デプロイ対象リポジトリ名
  • デプロイ対象環境 (本番、ステージングなど)
  • デプロイ対象ハッシュ値 (ブランチ、タグ、コミットハッシュのどれか)

Kubernetesカスタムコントローラーの開発

項目 内容
動作環境 オンプレKubernetes (Deployment)
開発言語 Go
責務 AWS SQSからのdequeue、デプロイジョブの生成と掃除

Capistranoデプロイタスクを実行するコンテナはKubernetesだとJobリソースとして扱えそうです。 そのJobリソースを作成したり削除したりするにはKubernetesのAPIを叩く必要があります。 AWS SQSから定期的にdequeueし続け、かつJobリソースを管理することを考えると aws-clikubectl を利用したシェルスクリプトに頼る方法では骨が折れます。 そこでKubernetesカスタムコントローラーを開発することにしました。

Kubernetesは「期待する状態」と「実際の状態」との差分を常に監視し、差異が発生したら「期待する状態」に合わせるためのReconciliation Loopと呼ばれる処理が走っています。 この仕組みは非常に強力で、マニフェストで宣言的に「期待する状態」を定義できるインターフェースと相俟って魅力的です。 この仕組みに乗らない手はありません。

kubectlなどでapplyしたリソース定義は etcd に保存されます。 etcdはコントローラーから直接触ることはできず、KubernetesのAPIからしか扱えません。 よってKubernetesのコントローラーはKubernetesのAPIを叩いてリソースを操作します。 ただしループ処理になるため、毎回APIを叩くと負荷が高くなってしまいます。 そこで基本的に参照系の処理は内部キャッシュから取得することになります。

ここらへんの登場人物の理解には以下の資料に助けられました。

あと以下のセッションも参考になりました。


Writing Kube Controllers for Everyone - Maciej Szulik, Red Hat (Beginner Skill Level)

上記を見返しながら公式サンプルコントローラーをベースに、まずは試作品を実装していきました。

試作品はAWS SQSからdequeueしたメッセージをコンテナの引数(args)に指定してJobリソースを生成するように実装しています。 例としてキューのメッセージから「秒数」情報を取得し、スリープするだけのJobリソースを生成するマニフェストは こちら です。

ところでKubernetesのリソースには親子関係が存在します。 カスタムコントローラーはDeploymentリソースで動かす想定です。 試作品の aws-sqs-worker-job-controllerCRDAWSSQSWorkerJob カスタムリソースを扱います。 それらの親子関係は以下です。

Pod ReplicaSet Daployment
Pod Job AWSSQSWorkerJob

メタデータの OwnerReferences を指定すればKubernetesが親子関係を認識し、親が削除されると子も消えるようになります。 Garbage Collection

この試作品をベースにCapistranoデプロイができるJobリソースを扱えるようにカスタマイズしていきました。

  • 入力
    • カスタムリソース定義(デプロイ対象リポジトリごとに必要)
      • AWS SQSのキューURL(デプロイ対象リポジトリごとにキューを用意)
      • Capistranoデプロイコンテナ情報
    • AWS SQSからdequeueしたメッセージ
      • デプロイ対象リポジトリ名
      • デプロイ対象環境(staging, productionなど)
      • デプロイ対象リビジョン(git tagやbranch名など)
  • 出力
    • CapistranoデプロイJobリソース定義の生成(掃除もやる)
      • Capistranoデプロイコンテナの環境変数とargsの指定で以下の形をつくる
        • REVISION=v1.0.0 cap production deploy
      • Capistranoはデプロイ環境をコマンド引数で渡せますが、対象リビジョンは他の形で指定する必要があります

以下のGoコードはJobリソースのマニフェスト相当のオブジェクトを実際に生成している処理です。 カスタムリソース宣言情報とdequeueしたメッセージを引数にJobリソースの構造体データを返しています。

func getJobTemplate(obj *customapi.CapDeployJob, msg string) (*batch.Job, error) {
    var one int32 = 1
    var zero int32 = 0
    kind := customapi.SchemeGroupVersion.WithKind("CapDeployJob")

    job := &batch.Job{
        ObjectMeta: meta.ObjectMeta{
            Name:            fmt.Sprintf("%s-%d", obj.Name, time.Now().Unix()),
            OwnerReferences: []meta.OwnerReference{*meta.NewControllerRef(obj, kind)},
        },
        Spec: batch.JobSpec{
            Parallelism:  &one,
            Completions:  &one,
            BackoffLimit: &zero,
        },
    }

    obj.Spec.Template.DeepCopyInto(&job.Spec.Template)
    if len(job.Spec.Template.Spec.Containers) == 0 {
        return nil, fmt.Errorf("failed to copy custom resource data, make sure the OpenAPI schema in your CRD manifest")
    }

    params, err := decodeMessageAsDeployParams(msg)
    if err != nil {
        return nil, fmt.Errorf("failed to create custom resource tepmlate")
    }

    job.Spec.Template.Spec.RestartPolicy = "Never"
    job.Spec.Template.Spec.Containers[0].Args = []string{"cap", params.Environment, "deploy"}

    for i := range job.Spec.Template.Spec.Containers[0].Env {
        if strings.HasSuffix(job.Spec.Template.Spec.Containers[0].Env[i].Name, "_DEPLOY_HASH") {
            job.Spec.Template.Spec.Containers[0].Env[i].Value = params.Hash
            break
        }
    }

    return job, nil
}

上記で生成したマニフェストテンプレートオブジェクトを以下のように引数で渡して、リソースを生成するAPIを叩けます。

client.BatchV1().Jobs(namespace).Create(
    context.TODO(),
    job,  // 生成したマニフェストテンプレートオブジェクト
    meta.CreateOptions{},
)

なおKubernetesカスタムコントローラーの実装用にいくつかのフレームワークが存在します。

しかし今回は余計な学習コストを避けたかったためシンプルに sample-controller をベースに実装していくことにしました。 今後カスタムコントローラーを量産する場合は上記のようなフレームワークを利用した方が保守性が高くなるかと思います。

Capistranoデプロイコンテナの開発

項目 内容
動作環境 オンプレKubernetes (Job)
開発言語 --
責務 cap コマンドの実行およびIncoming Webhookを利用したSlackへの通知

Capistranoデプロイ処理を実行するコンテナでKubernetesのJobリソースとして扱います。 マニフェストにはJobリソースではなくカスタムリソースとして宣言し、その情報を元にカスタムコントローラーがJobリソースとして生成します。 デプロイ対象リポジトリごとに使用しているライブラリのバージョンやデプロイ設定が異なるため、リポジトリごとにコンテナ化する必要があります。

CapistranoはSSHを利用して資源を配布します。なのでコンテナにSSHキーをマウントする必要があります。 また、GitHubのプライベートリポジトリを使用している関係でデプロイサーバー上でgit cloneを成功させるためのSSH Agent Forwardingもしないといけません。

volumes:
  - name: sample-ssh-volume
    secret:
      # kubectl create secret generic --save-config sample-ssh-key --from-file=private_key=/path/to/.ssh/private_key
      secretName: sample-ssh-key
      defaultMode: 256 # 0400
      items:
        - key: private_key
          path: private_key
volumeMounts:
  - name: sample-ssh-volume
    mountPath: "/root/.ssh"
    readOnly: true

Slackへの通知には以下のCapistranoプラグインを利用しています。

capistrano-slackify

以上を踏まえてDockerfileとentrypoint.shの中身は以下です。(バージョン表記は伏字)

FROM ruby:**********

RUN apt-get update\
  && apt-get install -y --no-install-recommends\
    git=**********\
    openssh-client=**********\
    curl=**********\
  && rm -rf /var/lib/apt/lists/*

ENV RUBYOPT -EUTF-8
ENV LANG C.UTF-8

RUN gem install\
  'capistrano:x.x.x'\
  'capistrano-rbenv:x.x.x'\
  'capistrano-ndenv:x.x.x'\
  'capistrano-rails:x.x.x'\
  'sshkit-sudo:x.x.x'\
  'capistrano-slackify:x.x.x'\
  --no-document

WORKDIR /opt/app

COPY Capfile /opt/app/
COPY config/deploy.rb /opt/app/config/
COPY config/deploy /opt/app/config/deploy

COPY .ruby-version /opt/app/

COPY docker/deploy/entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["cap", "-T"]
#!/bin/bash

set -eu

eval $(ssh-agent)

key=$HOME/.ssh/private_key
if [[ -e ${key} && -r ${key} ]]; then
  ssh-add ${key}
fi

exec "$@"

AWS ECRにログインしてKubernetes Secretを更新するコンテナの開発

項目 内容
動作環境 オンプレKubernetes (CronJob)
開発言語 Go
責務 ECRへログインし imagePullSecrets で指定する Secret を更新する

AWS ECRのプライベートリポジトリからコンテナイメージをpullする際にマニフェスト上で imagePullSecrets を指定します。 このSecretはkubectlを使うと以下のように生成できます。

SERVER=https://${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
PASSWORD=$(aws ecr get-login --region ${AWS_REGION} --registry-ids ${AWS_ACCOUNT_ID} | cut -d ' ' -f 6)
kubectl delete secret sample-secret || true
kubectl create secret docker-registry sample-secret \
  --docker-server=${SERVER} \
  --docker-username=AWS \
  --docker-password=${PASSWORD} \
  --docker-email=foo@example.com

しかし一定時間が経過するとログインが無効になってしまいます。 そこでAWS ECRにログインし、その認証情報を元にSecretを更新するタスクを実行するコンテナを開発しました。

aws-ecr-login-secret-updater

CronJobリソースとして使う想定です。 動作的にはECRにログインして認証トークンを取得後にKubernetesのAPIを叩いてSecretの削除と作成を行います。 初回実行などでSecretが存在しない状態でも削除工程の失敗は無視されるようにつくっています。 AWS IAM やKubernetes ServiceAccounts の権限設定をちゃんとしていれば動きます。

もちろん AWS EKS で動かす場合はこのような対応は不要だと思います。

ChatOpsの経過

2020年12月頃から試験的に一部のリポジトリのアプリケーションをSlackからリリースする運用を始めました。 まだ当初の課題を全て解決できた訳ではないですが、今のところ感触は良さそうです。

業務時間外の障害対応などに重宝しそう

稼動中のサービスで障害が発生した場合に、不具合を修正してリリースするまでに緊張が走ります。 そういう時こそ落ちついて作業するべきですが、手順書を見ながらサーバーにSSHで入ってコマンドを叩く対応は手が震えます。 まだChatOpsのデプロイに対応していないリポジトリで業務時間後に障害が発生したことがあり、その対応を迫られた際に一度ChatOpsを経験するともう元には戻れそうにないなと個人的に痛感しました。

deployコマンドの使い勝手に改善の余地がありそう

ChatBotのdeployコマンドはコマンドライン風のインターフェースを選択しました。

@chat-bot deploy -r foo -e bar -h baz

オプション指定形式を選択した理由は引数の順番を覚えなくて済むからです。 コマンドのパース処理もGoの flag パッケージを使用しています。 しかし最初の頃は複数あるオプションが覚えにくく、コピペが多発して間違った指定のまま投稿してしまうなどのミスも見られました。 Slackには Interactive Messages 機能があります。 テキストベースからリッチなUIに変更して打ち間違いを防止するなど、まだまだ改善の余地がありそうです。

コピペ投稿すると不要なバイトコードが紛れ込んでしまう

deployコマンドのコピペ投稿でChatBotの反応がバグってしまう問題が多発しました。 原因を調査してみるとSlack上でコピペ投稿した場合に 0xa0 のような印字不可能なバイトコードが紛れ込み、コマンドのパース処理に失敗していました。 以下のように半角スペースに変換して無害化させたところ解消しました。

func sanitizeNotPrintableChars(str string) string {
    runes := make([]rune, 0, utf8.RuneCountInString(str))
    for _, r := range str {
        if (0x20 <= r && r <= 0x7e) || (0xa1 <= r && r <= 0xdf) || (0xff < r) {
            runes = append(runes, r)
        } else {
            runes = append(runes, ' ')
        }
    }
    return string(runes)
}

GitOpsも体験できた

オンプレKubernetesで動かしているリソースのマニフェストは Kustomize でビルドして ArgoCD でapplyしています。 今回、触り程度ではありますがGitを唯一の情報源とした GitOps の知見も得ることができました。 チャットからの投稿か、Gitの操作か、の違いはあれど人手を介したオペレーションを極力排除していく点ではChatOpsもGitOpsも似ていると感じました。

最後に

以上、ChatOpsを導入してマッハバイトをSlackからリリースできるようにしたお話でした。 今回の開発体験向上施策は、あくまでプロジェクト全体における副次的な改善に過ぎません。 ビジネスインパクトを考えると、機能追加に強く構成変更に柔軟になるようにアプリケーション側をつくり変えていくことの方が重要であると考えます。 インフラストラクチャグループとしては引き続きクラウド移行へ向けた改善も進めていきたいと思います。