Argo CD が Kubernetes の廃止予定APIを呼ぶ原因を探る

Kubernetes v1.22 では結構多くのAPIが廃止1され、慎重なアップグレードが求められる。 その補助として、最近出たGKEの新機能 Deprecation Insights を使うと便利だ。 v1.21 等のGKE clusterで廃止予定のAPIが使われていないかチェックしてくれる。

あるGKEクラスターで確認してみると、Argo CD controller から2種類の廃止予定APIが呼ばれていることが判明した。

  • /apis/extensions/v1beta1/ingresses
  • /apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions

古い Ingress API の呼び出し

これに関してはArgo CDのissueに詳しい解説があった。

  • GKE では延命措置(?)として v1.22 でも廃止されるはずの Ingress API が残されている
  • よって Argo CD が管理対象の GKE cluster に対して API resourceの一覧を取得すると古い Ingress API もチェックしてしまう

つまり GKE 側が古い API resource を返さないようにすれば Argo CD も状態を取得しないはずで、Argo CDで管理しているアプリ内に古いタイプの Ingress がなければそのまま v1.22 にアップグレードして問題ない2。(ただし、廃止予定APIの警告機能のせいで自動アップグレードは止められるので、手動アップグレードが必要になる3。)

古い CRD API の呼び出し

2つ目の /apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions が厄介だった。 これはArgo CDのissuesを探しても見つからず、Argo CDのどの機能が呼び出しているのか最初わからなかった。

Deprecation Insights はAPI呼び出し元の User-Agent は教えてくれるが、一体 Argo CDのどの箇所から呼ばれたのかは流石にわからない。それならいっそArgo CDにパッチを当てて、k8s APIの呼び出しをすべてフックしてStack Traceを出力してしまえば呼び出し箇所がわかるのでは!?と考え、 Argo CD内の共通のclient-go REST API Client設定に http.RoundTripper を差し込んで customresource をURLに含む場合のみStack Trace (Caller)を出すことにした。

使っていた Argo CD は v2.0.5 だったので、Argo CDのソースをcloneし、v2.0.5 のタグをcheckoutしたあとに以下のパッチを適用した。

diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go
index 31fad8a57..e3d3ae583 100644
--- a/pkg/apis/application/v1alpha1/types.go
+++ b/pkg/apis/application/v1alpha1/types.go
@@ -3,6 +3,7 @@ package v1alpha1
 import (
    "encoding/json"
    "fmt"
+   "k8s.io/klog/v2"
    math "math"
    "net"
    "net/http"
@@ -11,6 +12,7 @@ import (
    "path/filepath"
    "reflect"
    "regexp"
+   "runtime"
    "sort"
    "strconv"
    "strings"
@@ -2850,10 +2852,34 @@ func (proj AppProject) IsDestinationPermitted(dst ApplicationDestination) bool {
    return false
 }
 
+type customRT struct {
+   base http.RoundTripper
+}
+
+func (c *customRT) RoundTrip(req *http.Request) (*http.Response, error) {
+   if strings.Contains(req.URL.Path, "customresource") {
+       var callers []string
+       for i := 0; i < 50; i++ {
+           _, file, line, ok := runtime.Caller(i)
+           if ok {
+               callers = append(callers, fmt.Sprintf("%s:%d", file, line))
+           } else {
+               break
+           }
+       }
+       j, _ := json.Marshal(callers)
+       klog.Warningf("============== %s %s caller: %s", req.Method, req.URL, string(j))
+   }
+   return c.base.RoundTrip(req)
+}
+
 // SetK8SConfigDefaults sets Kubernetes REST config default settings
 func SetK8SConfigDefaults(config *rest.Config) error {
    config.QPS = common.K8sClientConfigQPS
    config.Burst = common.K8sClientConfigBurst
+   config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper {
+       return &customRT{base: rt}
+   }
    tlsConfig, err := rest.TLSConfigFor(config)
    if err != nil {
        return err

改造したArgo CDをKubernetes内で動作させるには改造済Argo CDのビルドとコンテナイメージが必要となる。

公式リポジトリ内に Dockerfile も用意されていたのだが、手元のMacではすんなり動かず、今回は argocd-application-controller のバイナリさえ差し替えればよかったので無理やり自前で Dockerfile を用意して、次のように kubectl patch でイメージだけを別物に差し替えるというHACKなやり方をした。

FROM golang:1.17-alpine as builder
WORKDIR /go/src/argo-cd
COPY ./argo-cd/go.* ./
RUN --mount=type=cache,target=/go/pkg/mod set -eux && go mod download
COPY ./argo-cd .
RUN --mount=type=cache,target=/root/.cache/go-build set -eux && CGO_ENABLED=0 go build -o /bin/argocd-application-controller ./cmd/main.go

FROM ubuntu:20.04
ENV USER=argocd
USER 999
WORKDIR /go/src/argo-cd
COPY ./argo-cd/assets ./assets
COPY --from=builder /bin/argocd-application-controller /usr/local/bin/argocd-application-controller
IMAGE="argocd-patched:$(date +%s)"
docker build -t "${IMAGE}" -f ./Dockerfile .
minikube image load "${IMAGE}"
kubectl -n argocd patch deploy argocd-application-controller -p "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"application-controller\",\"image\":\"${IMAGE}\"}]}}}}"

このやり方は非常に無理やり感があるが、過去にAgonesやExternal SecretsのControllerをデバッグした際にも役立っており、意外といろいろな場所で使えるのでは…と思っている。

改造版Argo CDを適用後、CRD を管理下に含むアプリケーション をSyncしてみると次のログが得られた。

argocd-application-controller-f8bbf44db-c6gsr application-controller W0804 17:33:46.409455       1 types.go:2871] ============== GET https://10.96.0.1:443/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/sealedsecrets.bitnami.com caller: ["/go/src/argo-cd/pkg/apis/application/v1alpha1/types.go:2863","/go/pkg/mod/k8s.io/client-go@v0.20.4/transport/round_trippers.go:287","/go/src/argo-cd/pkg/apis/application/v1alpha1/types.go:2873","/go/pkg/mod/github.com/argoproj/pkg@v0.9.1/kubeclientmetrics/metric.go:127","/go/pkg/mod/k8s.io/client-go@v0.20.4/transport/round_trippers.go:298","/go/pkg/mod/k8s.io/client-go@v0.20.4/transport/round_trippers.go:160","/usr/local/go/src/net/http/client.go:252","/usr/local/go/src/net/http/client.go:176","/usr/local/go/src/net/http/client.go:725","/usr/local/go/src/net/http/client.go:593","/go/pkg/mod/k8s.io/client-go@v0.20.4/rest/request.go:891","/go/pkg/mod/k8s.io/client-go@v0.20.4/rest/request.go:964","/go/pkg/mod/k8s.io/apiextensions-apiserver@v0.20.4/pkg/client/clientset/clientset/typed/apiextensions/v1beta1/customresourcedefinition.go:72","/go/src/gitops-engine/pkg/sync/sync_context.go:815","/go/pkg/mod/k8s.io/apimachinery@v0.20.4/pkg/util/wait/wait.go:211","/go/pkg/mod/k8s.io/apimachinery@v0.20.4/pkg/util/wait/wait.go:445","/go/pkg/mod/k8s.io/apimachinery@v0.20.4/pkg/util/wait/wait.go:441","/go/src/gitops-engine/pkg/sync/sync_context.go:814","/go/src/gitops-engine/pkg/sync/sync_context.go:869","/go/src/gitops-engine/pkg/sync/sync_context.go:1113","/go/src/gitops-engine/pkg/sync/sync_context.go:1198","/usr/local/go/src/runtime/asm_arm64.s:1133"]

少し見づらいが、 /go/src/gitops-engine/pkg/sync/sync_context.go:815 という箇所がヒットした。 ここの ensureCRDReady という関数で apiextensions/v1beta1/customresourcedefinitions を呼んでいることがわかる。

crd, err := sc.extensionsclientset.ApiextensionsV1beta1().CustomResourceDefinitions().Get(context.TODO(), name, metav1.GetOptions{})

この ensureCRDReady 関数の歴史をたどってみると、Argo CDが利用している gitops-engine のPull Request にたどり着いた。この修正で古いAPIを呼ぶのをやめ新しい v1 を呼ぶようになったらしい。

その後 Argo CD v2.3 で上記のgitops-engineの修正が取り込まれたようだ。 よって、Argo CDを最新化すると直りそうだとわかった!

fix: Upgrade gitops-engine to fix compatibility with v1 CRDs by terrytangyuan · Pull Request #8515 · argoproj/argo-cd · GitHub

さらに言ってしまうと この修正が入る Argo CD v2.3 以前では ensureCRDReady でエラーが出てもスルーされるようになっている。 なので実はAPIが急に呼べなくなっても障害にはならなさそうだ。該当箇所のコメントを見ると Method is best effort と書かれているし、PollImmediate の結果も捨てられている。

// ensureCRDReady waits until specified CRD is ready (established condition is true). Method is best effort - it does not fail even if CRD is not ready without timeout.
func (sc *syncContext) ensureCRDReady(name string) {
    _ = wait.PollImmediate(time.Duration(100)*time.Millisecond, crdReadinessTimeout, func() (bool, error) {

つまり、Argo CDのバージョンを上げないまま Kubernetes v1.22 に上げても実稼働には問題ないとみて良さそうだ。

まとめ

GKEのDeprecation Insightsは便利だが、Argo CDくらいの大規模なソフトウェアになると、なぜ、どこで古いAPIを呼んでいるのかは一見ではわからない。

しかし、Argo CDはOSSなのでやろうと思えば改造して動作確認が可能である。 また、 http.RoundTripper や実行時のスタック情報を持つGoの利点を生かすことで、むりやり感はあるものの呼び出し箇所を特定できた。