こんにちは、研究開発部 Architectグループ ML Platformチームでインターンをしていた上田です。 今回はCI/CDパイプラインで利用されていたArgoCD Image Updaterを内製ツールに移行した経緯について紹介します。
TL;DR
ArgoCD Image Updaterから内製ツールに移行しました。
ワークフローにかかっていた時間が短縮できました。
- Before:3分〜30分程度
- After:45秒〜60秒程度
一方、自分たちでImage Updaterのメンテナンスなどの面倒も見る必要が出てきました。
背景
7月からサマーインターンとして4週間(18日間)R&D Architectグループ ML Platformチームに参加をしていました。
このチームは、Circuitと呼ばれる研究開発部の研究員の作ったアプリケーションをリリースする基盤を開発運用などを行っています。
その中で私は事前のやり取りの中で1つのタスクを渡されました。
以下会話
リーダー>新しいコンテナイメージがプッシュされたときにイメージを差し替える仕組みがあるんだけど、それが遅くて体験が悪化してるから直してほしいです。
私>(なるほど、パフォーマンス改善か… )具体的な原因とかってわかってたりしますか?
リーダー>現状ArgoCD Image Updaterを使っていてそこが遅いんだけど、次のようなエラーが出ていて、それを解決するために並列数を1にしていることが原因かな…?
私>(あ、原因っぽいのがわかっているならできそうだ!)なるほど!それでどのように進めていけばいいでしょうか…?
リーダー>そこは任せます!
私>(え?)頑張ります!
詳細
研究開発部では現在、EKSインスタンス上にアプリケーションをデプロイするフローのほとんどが次のようなパイプラインで構成されています。
- 開発: 開発者はGitHub上でアルゴリズムのコードを書き、変更をコミットします。
- ビルドとプッシュ: GitHub Actionsがトリガーされ、アルゴリズムコードをビルドし、Dockerイメージを作成します。このイメージはAWS ECRにプッシュされます。
- イメージ取得とデプロイ:
- AWS EKSクラスタ内のArgoCD Image UpdaterがECRから最新のDockerイメージを取得します。
- Image Updaterは、取得したイメージを使ってArgoCDに新しいバージョンをデプロイします。
- ArgoCDは、Kubernetes APIを介して、新しいバージョンをEKSクラスタに適用します。
- バージョン管理:
- Image Updaterは、新しいバージョンをデプロイした後、
randd-circuit
リポジトリにコミットします。 - このコミットは、特定のブランチにプッシュされると、自動的にプルリクエストが作成されます。
- PRは、新しいバージョンが正しく動作することを確認するためにレビューされます。
- Image Updaterは、新しいバージョンをデプロイした後、
- マニフェスト取得と適用:
- PRがマージされると、ArgoCDは最新のmanifestファイルを取得し、EKSクラスタに適用します。
しかし、現状のパイプラインはいくつかの問題を抱えています。 その1つとして、ECRにイメージがプッシュされてからPRができるまでの時間が長く、開発者の体験を損ねていました。 また、イメージ更新のPR作成が遅れると、開発者が意図しない変更を加え、バグにつながる可能性もありました。
ArgoCD Image Updaterとは?
ここまでの話で出てきたArgoCD Image Updaterについて少し説明をします。
ArgoCD Image Updaterとは argoprj-labsで管理されているOSSで、2024/09/02現在1.2K Starを獲得しています。
GitHub - argoproj-labs/argocd-image-updater: Automatic container image update for Argo CD
このアプリケーションでは、ArgoCDによって管理されているアプリケーションを定期的にポーリングします。 対応するコンテナレジストリに新しいバージョンが見つかれば、アプリケーションのイメージタグを書き換えます。
どうしてやめたのか?
対応するコンテナレジストリに新しいバージョンがあるかどうかを確認します
と前述しましたが、Image Updaterはこの部分でgoroutineを使っていました。
しかし、2つの問題が発生していました。
1つ目にECRなど独自の認証が必要なアプリケーションでは、ログインスクリプトにより認証する必要があるということです。 この仕様がなぜ問題かというと、ArgoCD Image Updaterの仕様上ログインスクリプトはそれぞれのgoroutine内で行われていたからです。 そのため、アプリケーション数100、並列数100の場合は100回同時にログインスクリプトが実行されます。
こちらはIssueにもなっています。
これらを解決する方法として--max-concurrency
を1にすることがIssueの中で提案されており、私達も利用していました。
2つ目に関連して、--max-concurrencyを1にすると並列実行数が1になるため、すべての実行が直列に動作します。 そのため、アプリケーション数が増えれば増えるほど理論上時間が伸びます。
これらを解決するべく私はこのIssueを解決する方法を探しました。
解決策と結果
1. ArgoCD Image Updaterを治そうとした
どうしてやめたのか? の問題の部分で出た通り、--max-concurrency
を1に設定しています。並列数によって遅延が起きているのであれば根本原因を治してしまおう!と考えました。
ArgoCD Image UpdaterではImage Registryに対して4種類の認証方法が選択できます。
- 環境変数による認証:環境変数に入れたBsae64のトークンを使う方法です。
- Secretsによる認証:方法は変わらないが環境変数からSecretに格納する場所が変わった方法です。
- PullSecretsによる認証:これはおそらく各ArgoCDのApplicationに設定されているImage Pull Secretから認証情報を借りる方法です。
- Extra…自分でスクリプトなどを挿入して行う認証:これが今回利用していたケースであり、ECR ログインスクリプトなどを用いる方法です。
これらの認証はArgoCD Image Updaterが並列するプロセス・アプリケーションごとに行っています。 つまり並列数やアプリケーション数が増えると実行回数が増えます。
私は認証機能にキャッシュ機構を実装して、有効な間は認証処理を省略できるようにしました。
そして10秒ごとに非同期でキャッシュを確認し切れていた場合に認証処理を実行するようにしました。 これはArgoCD Image UpdaterのConfigMapに設定するcredExpireを基準にしています。
func BatchRunExtraCredentialJobs() { ticker := time.NewTicker(10 * time.Second) done := make(chan struct{}) go func() { for { select { case <-ticker.C: registries := registry.GetRegistryEndpoints() for _, registry := range registries { // ignore none credentials if len(strings.TrimSpace(registry.Credentials)) == 0 { continue } retry(3, func() error { credSource, err := image.ParseCredentialSource(registry.Credentials, false) if err != nil { log.Errorf("Failed to parse credential source: %s, bad credential: %s", err, registry.Credentials) return err } if err := image.RunExtraCredentialJob(credSource, registry.CredsExpire); err != nil { log.Errorf("DEBUG: Failed to run extra credential job: %s", err) return err } return nil }) } case <-done: return } } }() --- func RunExtraCredentialJob(credentialSource *CredentialSource, credExired time.Duration) error { log.Debugf("RunExtraCredentialJob exipred: %d", credExired) // check expire if _, ok := HasCredential(credentialSource.ScriptPath); ok { log.Debugf("Cache hit for %s", credentialSource.ScriptPath) return nil } log.Debugf("Cache miss for %s", credentialSource.ScriptPath) if !strings.HasPrefix(credentialSource.ScriptPath, "/") { return fmt.Errorf("path to script must be absolute, but is '%s'", credentialSource.ScriptPath) } _, err := os.Stat(credentialSource.ScriptPath) if err != nil { return fmt.Errorf("could not stat %s: %v", credentialSource.ScriptPath, err) } log.Infof("cache miss for %s, executing script", credentialSource.ScriptPath) cmd := exec.Command(credentialSource.ScriptPath) out, err := argoexec.RunCommandExt(cmd, argoexec.CmdOpts{Timeout: 10 * time.Second}) if err != nil { return fmt.Errorf("error executing %s: %v", credentialSource.ScriptPath, err) } tokens := strings.SplitN(out, ":", 2) if len(tokens) != 2 { return fmt.Errorf("invalid script output, must be single line with syntax <username>:<password>") } SetCacheCredential(credentialSource.ScriptPath, &Credential{ Username: tokens[0], Password: tokens[1], }, time.Now().Add(credExired)) return nil }
やっていることは単純で、10秒ごとにキャッシュしているクレデンシャルの期限が切れていないかを確認して更新するものです。
これを実装したのち、今までスクリプトを実行していた部分にキャッシュを利用するよう変更しました。
case CredentialSourceExt: if !strings.HasPrefix(src.ScriptPath, "/") { return nil, fmt.Errorf("path to script must be absolute, but is '%s'", src.ScriptPath) } _, err := os.Stat(src.ScriptPath) if err != nil { return nil, fmt.Errorf("could not stat %s: %v", src.ScriptPath, err) } // Consider overhead time.Sleep(100 * time.Millisecond) cred, ok := HasCredential(src.ScriptPath) log.Infof("Checking cache for %s", src.ScriptPath) // show now if we have the credential in cache for k, v := range CredentialsCache.cache { log.Infof("Cache key: %s, value: %v", k, v) } if !ok { fmt.Printf("[DEBUG] missing cache: %s\n ,cred: %v", src.ScriptPath, CredentialsCache.cache) if err := RunExtraCredentialJob(src, credExired); err != nil { return nil, err } cred, ok = HasCredential(src.ScriptPath) if !ok { return nil, fmt.Errorf("error running extra credential job") } }
その結果スクリプトの都度実行がなくなり、タイムアウトのエラーは消えました。
並列数を上げて検証した結果…
しかし、並列数を上げてみたところ速度は改善しませんでした。
並列数を上げたとしてもリソースの使用率は向上せず、アプリケーション数が多い環境では顕著に遅くなってしまいました。
2. 内製ツールで作ってしまおう!
- 我々のユースケースでうまく動くようにArgoCD Image Updater自体を修正するには変更箇所が大きくなりすぎている
- Pull型で都度走査するのではなく、イメージのPushイベントをトリガーとしたシステムのほうがスケールしやすいのではないか
という意見があり、自分でもその方が効率良いなぁと思い自力でImage Updaterを自作することにしました。
自作するに当たり私は3通りの設計をしました。
- Argo Events、Argo Workflowsを使う方法
- GitHub Actionsで完結させる方法
- Webhookのエンドポイントを持つImage Updaterを作ってAWS SNSから叩く方法
設計の検討
設計1のArgo Events, Argo Workflowsを使う方法では没にしました。 ImageUpdaterの為に追加でArgo EventsとArgo Wrokflowsを同時に検証する必要があり、期間内に導入するには不確実性が高いと判断したためです。
設計2のGitHub Actionsで完結させる方法も没にしました。
- 各アプリケーションが格納されているリポジトリに対してWorkflowを書く必要があったこと
- manifestが格納されているリポジトリに対してそれらのリポジトリが書き込みを行える権限を付与する必要があること
という2点から今後もアプリケーションが増えたときに大変になりそうだ!と思ったためです。
上記の理由から設計3のWebhookのエンドポイントを持つImage Updaterを作ってAWS SNSから叩く方法を当初採用しました。
しかし問題が発生しました。
SNSからEKS内にデプロイしたアプリケーションに対してリクエストを叩けなかったということです。 これはALBのアクセスポリシーを厳格にしていることが原因だったのでSNSだけ許可をすればいいだろうと考えていたのですが、ここでも問題がありました。
上記ドキュメントの通り決まったIPがないので許可は現実的でありません。
そのため、SNSで叩く設計は断念しました。
恐らく、ここまで読んでる方ならLambdaを使えばいいじゃないか!と思うのではないでしょうか。 わかります。私もそう思います。 しかし、CircuitにはCI/CDや運用に便利な仕組みが揃っているため、中長期的にはこちらのほうが良いと判断しました。
そのため作戦を少し変更し、SQSにイベントデータを送信し、アプリケーションがSQSをポーリングする設計にしました。
仕様
- ECRのPushイベントをEventBridgeでSQSに流す
- EKSアプリケーション上にデプロイした自作Image UpdaterがSQSをポーリング
- イベントがあったら変更の合ったImage URIから一意にリポジトリを特定して、kustomization.yamlを修正
- GitHubにコミットする
という簡単な仕様で作りました。
今までArgoCD Image UpdaterがApplication SetなどのAnnotationでやっていたことをこのアプリケーションではConfigMapsで扱うようにしました。*1
- githubRepository: https://github.com/AAA/BBB/services/$1/$2/$3/overlays/stg registryURI: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/hoge/$1/$2/$3 allowImageTag: - regexp:^[0-9a-f]{7,40}$ denyImageTag: - latest env: staging - githubRepository: https://github.com/AAA/AAAAAA/services/$1/$2/$3/overlays/stg registryURI: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/hoge/fuga/$1/$2:$3.* denyImageTag: - latest env: staging
幸いチーム内のManifestのディレクトリ構成はしっかりとしたパターンがあったため、簡単なconfigで済みました。
結果…
検証していた環境では最短でも2〜3分弱かかっていたArgoCD Image Updaterのワークフローが45〜60秒
程度に短縮されました。
簡単な負荷テストをした
速くなったのはわかったが、処理する量によって時間がかかっていたら元も子もありません!ということで、イメージを同時に27ほどpushしてテストをしました。
その結果。。。
1つあたりおおよそ1分弱くらいでPRが上がってきており、パフォーマンスの低下は見られませんでした! 🎉🎉🎉
リソースの使用率をDataDogで確認したところ、しっかりとリソースを使い、使い終わったら解放までしているのでかなり優秀だなぁと思いました。
スケーラビリティの確認
このアプリケーションの設計上、replicaの数を増やせばより効率的にリソースを扱えるはず!ということで、replicaの数を3にしてもう一度イメージを同時に27ほどpushしてテストをしました。
結果は次の通り、大体どのpodも負荷が1/3になっている!ということでしっかりスケールもすることが確認できました! 🎉🎉🎉 また、PRも特に異常はなく正常に27個上がってきました!
まとめ
ArgoCD Image Updaterの修正から始まったこのタスクですが、結果的に内製のシステムを作りました。 Dockerイメージをビルドするワークフローを見届け終わったと思ったらPRが出ている程度には高速化され、ユーザ体験の向上ができました。 4週間という期間の中で紆余曲折ありましたが、個人的には成果と言える着地ができたと感じています。 一方で、テストカバレッジやシステムの柔軟性などまだ課題はあります。
期間内で設計レビューやペアプロと技術面で支えてくださったチームの方々には感謝しかありません! ありがとうございました。
求人
ML Platformチームは一緒に働く方を募集しています。
*1:CRDを作りたいわけじゃなかった。KubeAPIを叩きたくなかった。