未来の開発体験を作る技術基盤チーム | Wantedly Engineer Blog
こんにちは、Wantedly DX Squad の 大坪です。 ...
https://www.wantedly.com/companies/wantedly/post_articles/242144
Photo by Sam Moqadam on Unsplash
こんにちは 👋
DX Squad でインターンをしている山本です。
今回はインターンの課題として取り組んだ Custom Controller を作ってデプロイ開始と終了の検知を実現した話 についてまとめようと思います 👨💻
DX は "Developer eXperience" の略称です 🙋♂️ 以下を目標に動いているチームです。
詳しくは、DX Squad のリーダー 兼 メンターである大坪さんの記事をご覧ください!
まずは今回のインターンで自分が取り組んだ課題について詳しく説明します。
タイトルにもある通り、デプロイ開始と終了の検知を実現するという課題に取り組みました。
なお、ここでいうデプロイとは、Kubernetes上で Rolling Update が始まってから終わるまでのことを指しています。したがって、docker image の build などは含みません。
さて、この課題のモチベーションは以下のようにいくつかありました。
では、それぞれについて詳しく述べていきます。
Wantedly では主に Kubernetes でサービスを運用しており、現状 Wantedly では 1 つのクラスタの上で 100個以上ものマイクロサービスが動いています。
それぞれのデプロイにかかる時間は感覚的には把握しているものの、正確に数値としてのデータはありませんでした。
そのため、デプロイを高速化する際の指標としてデプロイにかかる時間を正確に数値として欲しいというモチベーションがありました。
デプロイした後に何か問題が起きた場合、その問題のデプロイの開始・終了との関係を正確に分析することは難しいです。
例えば「パフォーマンスが劣化した」・「Thundering Herdが起こっている」などのエラーにはならない問題は、デプロイとの関係を把握するのが特に難しく、そういった分析をしやすくするためにデプロイの開始・終了のタイミングを正確に知りたいというモチベーションがありました。
まず、事前知識として kube fork について説明します。
マイクロサービスの開発では、様々な言語で書かれたアプリケーションが相互に通信を行っています。そのうちの一部を変更したいと思った時、docker-compose などを用いて関係するサービスをすべて手元で実行するのは現実的ではありません。
そこで、DX Squad では kubectl のラッパーである kube(詳しくはこちら) という内製の CLI ツールに fork と remote fork というコマンドを実装しています。
詳しい仕組みについてはリンク先の動画をご覧いただきたいのですが、このコマンドにより手元では変更対象のマイクロサービスをプロセスとして立てるだけで良くなり、自分専用のクラスタを持っているような開発体験が得られます。
上記で述べた2つのコマンドのうちの remote fork の仕組みを用いて、同じく DX Squad でインターンをしている大森くんが Pull Request Preview というものを作っています。
これは、上記のように Vercel などの PaaS ではよくあるのですが、プルリクエストでの変更が反映されたアプリケーションがデプロイされ、そのアプリケーションのリンクが発行されるというもので、これにより動作確認が簡単になったり見た目の修正の確認がワンクリックで出来るようになる素晴らしい仕組みです。
この Pull Request Preview のすごいところは、先述した remote fork の仕組みを使うことで、変更の必要があるマイクロサービスだけ別インスタンスとして Deployment を用意し、それ以外のマイクロサービスは Copy On Write で Preview 用の環境を作っているところです。
remote fork の裏側については上記の記事にわかりやすい説明があるのでぜひご覧ください。
簡単に説明しますと、Preview 用の Deployment とそこにアクセスするための Service が apply されたり VirtualService の設定がされたりするようになっています。
さて、ここまで長々と書きましたが、ここからが本題です。
この Pull Request Preview ですが、現状では上記のように表示されるようになっています。
現状では、deployment が作成されたらすぐにコメントされるようになっており、"Your preview will be ready in a sec!" の文言からもわかるように、Preview 用の Pod が立ち上がる前にコメントされるようになっています。
これだと、Pod が立ち上がる前に Developer 達が Preview を見ようとした際はリンクを押してもエラー画面が表示されてしまいます。Pull Request Preview を使用している Developer からするといつになったらそれが治るのか、そもそも Pod が立っていないことが原因なのか自分の変更が原因で問題が生じているのかわからなくなり、開発体験が悪くなってしまいます。
そこで、デプロイを検知する仕組みを作ることで、 Preview 用の Pod が立ち上がったことを検知し、その後に Preview コメントをするようにすることで上記の問題を解決したいというモチベーションがありました。
この要件で重要なのは、「検知したデプロイをリアルタイムで Pull Request Preview に教える必要がある」ということです。Preview 用の Pod が立ち上がってから数時間後にコメントが来るのでは意味がないので...
タイトルがネタバレになってしまっていますが、Custom Controller を作ることでデプロイの検知を実現しました。その名も "deploy-status-notifier" です。
既存の OSS を使用したり、改良する方法もありましたが要件にバッチリはまるものが見つからなかったため自作することにしました。
上述の課題を解決するために考えることは主に以下の3つでした。
それぞれについて詳しく説明していきます。
デプロイの状態として、開始・終了の他に中断やキャンセルなどが考えられますが、これらについてどこまで検知することを目指すのかについてまず考えました。
結論としては、デプロイの開始と終了だけの検知を目指すことにしました。これは、終了を漏れなく取得できれば、中断やキャンセルは「終了の記録がない状態で次のデプロイ開始が記録されている時」と同義でありわざわざステータスとして用意する必要がないということ、まずは最も簡易な方法で出来るだけ開発のハードルを下げたい、などといった理由からです。
次にもっとも頭を悩ませた、デプロイの開始と終了の定義についてです。
開始と終了について考える前に、そもそもここでいうデプロイとは何なのでしょうか。「新しい docker image の pod が作られ始めてから、replicaset 内の pod がすべて available になるまでのこと」と定義してみました。
すると、デプロイの開始と終了の定義は以下のようにできそうです。
デプロイ開始: Replicaset が作成された瞬間
デプロイ終了: replicaset 内の pod がすべて available になった瞬間
したがって、実装としてはひとつの Reconcile で開始と終了を判別するのではなく、replicaset の CREATE イベントだけを検知する replicaset_create_controller.go と replicaset の UPDATE イベントだけを検知する replicaset_update_controller.go に分けることで判別を楽にしました。
実は、当初 1 で検知対象を考える際に、中断やキャンセルも一緒に考えようとしていたため、デプロイ終了の例として 「Deployment の Status が ProgressDeadlineExceeded になってデプロイが中断される場合」などについても考えなくてはいけなくなって、Custom Controller の Reconcile の中でそれぞれのケースの判断する必要があり苦戦していました。。。
まず、deploy-status-notifier の責務は以下の通り大きく二つに分けることができます。
検知方法については 2 と被る部分も多いですが、以下のように取得することにしました。
デプロイ開始:
デプロイ終了:
updatedReplicas == replicas == availableReplicas == desiredReplicas && observedGeneration >= generation
を満たすときupdatedReplicas == 0 && replicas == availableReplicas == desiredReplicas && observedGeneration >= generation && revision == oldRsRevision
を満たすときデプロイ開始の取得については自明なので、 ここからはデプロイ終了の取得について具体的な実装も踏まえて詳しく説明します。
Custom Controller の実装をしたことがない・そもそも Custom Controller ってなに?という人は、つくって学ぶKubebuilder をやってみるとこれ以降の説明が理解しやすくなると思います!
まず、Kubernetes の Rolling Update 時の挙動として、以下の図のように「最後に新しい Pod が作成されてデプロイが完了する場合」と「最後に古い Pod が消えてデプロイが完了する場合」の 2 パターンがあります。
これは maxUnavailable
や maxSurge
といった Rolling Update の設定値と replica数によって変わるのですが、その部分の詳しい話は他の記事に任せます。
さて、それでは上記で挙げた 2 パターンのそれぞれについて説明していきます。
(図の引用元: https://tech-lab.sios.jp/archives/18553)
この場合、新たな replicaset に変更が加わることにより、デプロイにおける最後の Reconcile が Custom Controller で発火されます。
したがって、その Reconcile 時をデプロイ終了として検知すれば良いことになります。
上にもチラッと挙げましたが Reconcile 内でこのパターンを判別するための具体的な実装は以下のようになります。
if updatedReplicas == desiredReplicas &&
replicas == desiredReplicas &&
availableReplicas == desiredReplicas &&
observedGeneration >= generation {
// デプロイ終了を通知
notify := notifier.NewNotifier(rs, dp)
err = notify.Complete(deployCompleteTime)
if err != nil {
return ctrl.Result{}, err
}
}
ここで、それぞれの変数が表す内容は以下の通りです。
generation と observedGeneration については若干話が逸れてしまうので割愛します。
つまり、Rolling Update におけるこのパターンでは、「対象の Deployment に属する Replicaset のうち、最新の Replicaset に属する Pod が全て立ち上がっておりそれ以外の Replicaset は replica数が 0 」である場合にデプロイ終了とみなすようにしています。
実はこの条件は Kubernetes 標準の Deployment Controller のロジックを参考にしています。
興味のある方は本家のコードも追ってみてください!
(図の引用元: https://tech-lab.sios.jp/archives/18553)
次にこの場合では、古い replicaset に変更が加わることにより、デプロイにおける最後の Reconcile が Custom Controller で発火されます。
したがって、先ほどと同様、その Reconcile 時をデプロイ終了として検知すれば良いことになります。
具体的な実装は以下の通りです。
if updatedReplicas == 0 &&
replicas == desiredReplicas &&
availableReplicas == desiredReplicas &&
observedGeneration >= generation &&
revision == oldRsRevision {
// complete を通知
notify := notifier.NewNotifier(rs, dp)
err = notify.Complete(deployCompleteTime)
if err != nil {
return ctrl.Result{}, err
}
}
の部分が変わっています。基本的な条件は先ほどのパターンと似ていますが、 updatedReplicas == 0
と revision == oldRsRevision
の部分が変わっています。
これは updatedReplicas
の取得のために Kubernetes 本家の util メソッドを利用しているせいで、古い replicaset の image を最新として updatedReplicas
を取得してしまうので、この rolling update のパターンでは updatedReplicas == 0
としています。
また、revision == oldRsRevision
の条件は、Reconcile が古い replicaset に変更が加わったことによるものであるかを判別するために入れています。
revision
は Reconcile の元となる replicaset の revision のことで、annotation から取ってきています。
また、oldRsRevision
は Reconcile の元となる replicaset から owner である Deployment を辿り、その Deployment に属する全ての replicaset を List で取ってきて、それぞれの annotation をもとに最新の revisoin を取得し、 最新の revision - 1 をすることで rolling update における古い replicaset の revision を取得しています。
おそらく何を言っているかわからないと思うので実装を載せておきます。自分でも何を言っているかわかりません。
// 対象の replicaset を取得
var replicaset appsv1.ReplicaSet
err = r.Get(ctx, req.NamespacedName, &replicaset)
if err != nil {
log.Error(err, "unable to get replicaset", "name", req.NamespacedName)
return ctrl.Result{}, err
}
rs := replicaset.DeepCopy()
revision, err := strconv.Atoi(rs.Annotations["deployment.kubernetes.io/revision"])
if err != nil {
log.Error(err, "couldn't convert number of replicaset revision: %v\\n")
return ctrl.Result{}, err
}
// 対象の Replicaset の 親Deployment の情報を取得
if len(replicaset.ObjectMeta.OwnerReferences) <= 0 {
return ctrl.Result{}, err
}
ownerDeploymentName := replicaset.ObjectMeta.OwnerReferences[0].Name
ownerDeploymentNamespace := req.NamespacedName.Namespace
ownerDeploymentNamespacedName := types.NamespacedName{Namespace: ownerDeploymentNamespace, Name: ownerDeploymentName}
// 対象の Replicaset の 親Deployment を取得
var ownerDeployment appsv1.Deployment
err = r.Get(ctx, ownerDeploymentNamespacedName, &ownerDeployment)
if err != nil {
log.Error(err, "unable to get owner deployment", "name", ownerDeploymentNamespacedName)
return ctrl.Result{}, err
}
dp := ownerDeployment.DeepCopy()
// 対象の Replicaset の 親Deployment に属する ReplicasetのList を取得
var rsList appsv1.ReplicaSetList
err = r.List(ctx, &rsList, client.MatchingFields(map[string]string{ownerDeploymentNameField: ownerDeploymentName}), client.InNamespace(ownerDeploymentNamespace))
if err != nil {
log.Error(err, "unable to get replicaset list belongs to same owner deployment", "name", req.NamespacedName)
return ctrl.Result{}, err
}
// 終了条件に必要な情報の準備
allReplicas := []*appsv1.ReplicaSet{}
rsRevisionList := []int{}
for _, v := range rsList.Items {
rsRevision, err := strconv.Atoi(v.Annotations["deployment.kubernetes.io/revision"])
if err != nil {
log.Error(err, "couldn't convert number of replicaset revisions: %v\\n")
return ctrl.Result{}, err
}
allReplicas = append(allReplicas, v.DeepCopy())
rsRevisionList = append(rsRevisionList, rsRevision)
}
oldRsRevision := Max(rsRevisionList) - 1
updatedReplicas := util.GetActualReplicaCountForReplicaSets([]*appsv1.ReplicaSet{rs})
replicas := util.GetActualReplicaCountForReplicaSets(allReplicas)
availableReplicas := util.GetAvailableReplicaCountForReplicaSets(allReplicas)
observedGeneration := ownerDeployment.Status.ObservedGeneration
desiredReplicas := *(ownerDeployment.Spec.Replicas)
generation := ownerDeployment.Generation
つまりは先ほどと同様に、このパターンでは、「対象の Deployment に属する Replicaset のうち、最新の Replicaset に属する Pod が全て立ち上がっておりそれ以外の Replicaset は replica数が 0 」であるという条件を元にアレンジして、古い Replicaset での Reconcile においてそれを判別できるようにしているということです。
Custom Controller を実装したことがある人にとっては当然かもしれませんが、Reconcile は思っている以上にいっぱい発火されていて、それぞれがなぜ発火されたのかを正確に把握しそれらを判別するということはかなり難しいです。ですので、Reconcile 自体を制御しようとするのではなく、Reconcile がたくさん発火される中でどういう条件を満たした時に処理を行なって欲しいのかを考えないといけません。
ステートレスかつ冪等性を持った実装にするのは中々骨が折れました。
次に通知方法についてですが、「デプロイの開始・終了時刻を記録しておいて後で分析に使いたい」というニーズと、「デプロイの開始・終了をリアルタイムに伝えたい」というニーズがあったので、それぞれ BigQuery, Github Status API に通知することにしました。
これらを選んだ理由は、この後の "実現できたこと" で述べようと思います。
ここまでで "deploy-status-notifier" により実現できることが増えたのでまとめます。
デプロイの開始時刻・終了時刻を BigQuery に通知することで、以下の画像のようにそれらを保存できるようになりました!
なぜデプロイを検知したいのかというモチベーションの部分でも書きましたが、「デプロイを高速化する際の指標としてデプロイにかかる時間が欲しい」といったニーズはこれで満たせるようになりました。
ちなみになぜ BigQuery に通知するようにしたかというと、さまざまな Wantedly の内製ツールが整っているおかげで BigQuery に対する通知部分の実装がとても簡単だったからです。
Preview 用の Pod が立ち上がってから Preview のコメントがされるようになりました!
前述の通り、Pod が立ち上がる前に Developer 達が Preview を見ようすることが無くなったことで、「Pull Request Preview を使用している Developer からするといつになったらそれが治るのか、そもそも Pod が立っていないことが原因なのか自分の変更が原因なのかわからなくなってしまい、開発体験が悪くなってしまう」という問題が解決されました。ヤッター。
ちなみにどのように deploy-status-notifier と pull request preview が連動しているかと言いますと、Wantedly には github-webhook-handler というマイクロサービスがいて、こいつが Github の全 Webhook を受け取っています。
なので、こいつを利用して、以下の流れで Preview コメントがされるようになっています。
すごくややこしいですがこんな感じです。
ここで鋭い方はお気づきかと思いますが、「一度 Github Status にデプロイ終了を通知してるんだからわざわざ pull request preview が Preview コメントして大丈夫なのかどうか確認する必要はないのでは?」と思われるかもしれません。
実は Wantedly では pull request preview により作られる Deployment が一つとは限らないので、一度デプロイ終了が通知されたからといって、別の Deployment がデプロイ途中の可能性があります。したがって、都度 pull request preview が実際に確認しに行く仕様となっています。
ただし、DX squad 内で、「インターフェースの切り方として pull request preview が Preview コメントするのではなく github-webhook-handler にやらせるべきではないか」といった話が上がっており上記のフローは若干変わるかもしれません。
ちなみにこれは同じインターンの大森くんと相談して決めた仕様です。インターンが主導権を持ってゴリゴリ進められるのはとてもやりがいがありましたし、何より同世代のエンジニアと相談しながら進めていくのは楽しかったです!
ここまで deploy-status-notifier のプラスな部分だけを述べてきましたが、まだまだ課題が残っているのでまとめようと思います。
早速 deploy-status-notifier の存在意義が根底から覆りそうになっていますが、意図せずデプロイ終了として検知されてしまうパターンが以下の通り二つあります。
それぞれ詳しく説明します。
まず、HPA による Scale についてですが、Wantedly では HPA(Horizontal Pod Autoscaler) を導入しており、replicaset の replica 数が変わることがよくあります。
HPA により replicaset のspec.replicasが書き換えられると replicaset の UPDATE イベントによって、deploy-status-notifier の replicaset_update_controller の Reconcile が発火されます。
先に述べた通り、デプロイの開始は replicaset の CREATE イベントをもとに取っていて、これは「replicaset が新しく作られるような変更が deployment に加えられること」つまり、「DeploymentのPodテンプレート(この場合.spec.template)が変更された場合」をデプロイの開始と捉えていることを表しています。
したがって、HPA による Scale ではデプロイの開始は検知・通知されません。
デプロイの終了についてですが、Reconcile の処理をステートレスに保とうと思うと、本当のデプロイ終了と HPA の Scale は区別できないと考えています。
これは HPA の Scale であると判別するための条件が、「HPA により replicaset のspec.replicas
が書き換えられる前と後で image が同じ」かつ「replicaset のspec.replicasが変化している」ことであり、デプロイ終了と検知されてしまう Reconcile の中で replicaset の Reconcile 前の状態を知る必要があるためです。
この問題を解決するためには Redis のキャッシュを使うなどの方法が考えられますが、BigQuery に関しては集計した記録を見て人間が判断すればいいですし、Github Status に関しては status: succeess が上塗りされるだけで特に問題はないので、とりあえず今回のインターンでは対応しないことにしました。
ただ、今後 Slack への通知を追加するなど拡張していくことを考えると、要件としてデプロイ終了が何度も通知されることを解消しないといけない時は必ず来ると思っています。
次に キャッシュの再同期による Recocile 発火についてですが、これは Custom Controller の仕様によるものです。
つくって学ぶKubebuilderから引用させていただきますと、それは以下の仕様のことです。
Kubernetes上ではいくつものコントローラーが動いているため、それぞれが毎回APIサーバーにアクセスしてリソースの取得をおこなうと、APIサーバーやそのバックエンドにいるetcdの負荷が高まってしまうという問題があります。
そこで、controller-runtimeの提供するクライアントはキャッシュ機構を備えています。 このクライアントは Get() や List() でリソースを取得すると、同一namespace内の同じKindのリソースをすべて取得してインメモリにキャッシュします。そして対象のリソースをWatchし、APIサーバー上でリソースの変更が発生した場合にキャッシュの更新をおこないます。
このキャッシュの再同期(以下、resync と呼びます)時に Reconcile が発火されるのですが、この際も HPA による Scale と同様にステートレスに判別する方法が無く、Redis などを使う必要があると考えています。
ちなみにこの resync はデフォルトで 10時間に一回行われるようになっているため、検知された時刻を見れば大体判断できるのでこれも HPA による Scale と同様に今回のインターンでは対応しないことにしました。
こちらも Custom Controller 特有の「コントローラーの起動時に Reconcile が発火される」という仕様によるものです。
これは暫定的に、Replicaset の creationTimeStamp が Reconcile の瞬間の時刻より5秒以上前である場合は通知を Skip することで対処しています。
現状これで問題なく動いていますが、意図しない挙動をする場合が出る可能性はあるので厳密で恒久的な対応が必要だと考えています。
deploy-status-notifier によってデプロイの開始と終了が検知・通知できるようになったことで、Pull Request Preview が改善されたりと Developer の方々の開発体験向上に少し貢献出来たと思います 🎉
これから改善していけば、最初にあげた用途以外(例えば、HPA の Scale にかかる時間の計測など)にも役立てることが出来ると思うのでこれからの OSS 化に期待したいです!🙆♂️
また、この課題以外に kube や pull request preview の機能の修正・拡充などもできたりと、Wantedly の技術基盤に触れられる、とても楽しいインターンでした!
メンターの大坪さんや、インターンの大森くん、DX Squad や Infra Squad の皆さん、開発者の皆さん、さまざまなサポートをしていただきありがとうございました!!💪
技術的なこと以外にも、今回のインターンで学んだ仕事に対する考え方や姿勢、進め方などについてもブログにまとめましたので、これから Wantedly のインターンを考えている方々もそうでない方もぜひ一読ください!🙇