亚马逊AWS官方博客

深入浅出的谈谈Amazon EKS的身份认证处理

Amazon EKS 作为托管型的 Kubernetes 集群服务大大降低了使用k8s技术的门槛, 有效的帮助用户简化了k8s集群的运维。但是这个便捷有时候也会被认为是过度的简化,导致用户在遇到使用问题时缺乏深度的理解和判断。EKS 最重要的简化便在于将Control Plane 彻底托管,用户不再需要自己去运维管理 api server, etcd, scheduler 等,而k8s集群的身份认证处理作为control plane 的一部分自然也被归并进了被AWS托管的功能,用户在使用 Amazon EKS 的过程中可能也已经发现了,与自建k8s多种多样的身份认证方式相比,EKS托管集群在身份认证上并不是那么灵活,并且通过集成AWS  IAM 服务,形成了AWS自己的特色。我这篇博客的目的也就在于将这个身份认证功能解剖出来,帮助用户有效的理解EKS身份认证的逻辑,希望能对之后使用EKS过程中出现的身份认证方面的问题提供一个参考。

Amazon EKS的身份认证流程

EKS作为基于开源k8s的一种distro,身份认证方式实际上并没有跳脱开源k8s的技术栈,主要采用了三种身份认证策略:

1. Service account tokens
2. OpenID connect tokens
3. Webhook token authentication

这三种策略在k8s官网文档中有较为详细的介绍,有兴趣的读者可以去详细读一读。如果读者对这三种身份认证策略不熟悉的话,也没有关系,我们会在后面解释EKS的身份认证时大致介绍一下。而这三种策略又大体可以分为两类:

  • Service account tokens一般是被运行在集群Pod上的应用使用的身份认证
  • 另两种策略则主要是针对于运行于集群外部的客户端或者用户的身份认证

本篇博客在接下来的篇幅里将会针对这两类的策略详细叙述其实现的方式,而在叙述过程当中会使用相应的一些命令行指令作技术验证,在此也鼓励读者可以利用自己的测试环境进行同样的验证以加深理解,所需的测试环境是:

  1. 一个eks托管集群(eks 1.21或以上版本)
  2. 能够连接托管集群的kubectl客户端
  3. aws cli (2.5.2或以上版本)
  4. curl 或者postman工具
  5. jq工具,用于解析json数据
  6. 一个可用于测试的OIDC身份池(可选)

集群内部pod应用的身份认证

无论是Amazon EKS还是开源的k8s对于集群内pod应用的身份认证都是采用的service account的方式,对于service account的概念本身在这里就不做过多赘述,抽象来说,可以把service account看做是由k8s的control plane分发给pod的专属的security token,而api server就是通过查看请求中包含的这个security token来识别身份信息的。如果我们查看一下EKS托管api server的默认flag配置(可以参考这里),可以看到如下几个与service account相关的配置:

根据这些flags,我们可以得到两个重要信息,第一是Amazon EKS有一个默认的OIDC认证的token分发服务,可用来产生service account 的token,第二是这个token 的有效期最长是24小时。接下来,我们来理解一下这个service account的token究竟是怎么产生的。首先,service account是k8s原生的一个资源,我们可以通过以下指令产生:

kubectl create serviceaccount demo
kubectl describe serviceaccount demo

如上图所示,这个service account有一个属性叫做‘Mountable secrets’,实际上这个就是一个k8s的secret资源,用于存储OIDC服务分发给这个service account的token。我们可以通过执行以下指令来查看这个token:

kubectl get secrets $(kubectl get serviceaccounts/demo -o jsonpath='{.secrets[0].name}') \
    -o jsonpath='{.data.token}' | base64 --decode

而这个指令的结果会是一长串的JWT的token,对这个JWT的token进行解码会看到类似于下图的结构:

我们得到的这个token是完全可以直接拿来使用的,比如说我们可以尝试使用上面这个token来直接以HTTP请求的方式与api server进行交流:

export api_endpoint=<API SERVER ENDPOINT>
curl -k --location --request GET ‘https://$api_endpoint/apis/apps/v1/deployments’ \
--header ‘Authorization: Bearer <JWT TOKEN>‘

如果读者并不知道EKS集群的API SERVER ENDPOINT,则可以通过以下指令获取:

kubectl config view -o jsonpath='{.clusters[0]}' | jq

如果你没有安装‘jq’指令,也可以去掉后面的‘jq’指令。

如上截图所示,‘server’属性后的字段就是API SERVER ENDPOINT。

我们在尝试使用之前提到的curl指令之后,应该会得到以下的response:

{
    "kind": "Status",
    "apiVersion": "v1",
    "metadata": {},
    "status": "Failure",
    "message": "deployments.apps is forbidden: User \"system:serviceaccount:default:demo\" cannot list resource \"deployments\" in API group \"apps\" at the cluster scope",
    "reason": "Forbidden",
    "details": {
        "group": "apps",
        "kind": "deployments"
    },
    "code": 403
}

从返回可以看到API serve拒绝了这个请求,是因为我们没有授予这个service account token任何权限. 但这已经表明我们成功的在集群外使用了service account token与api server进行了交互,并且api server也识别出了这个token的身份。

现在我们可以给这个service account授予一个强大的‘cluster-admin’权限:

kubectl create clusterrolebinding demo-cluster-rule --clusterrole=cluster-admin --serviceaccount=default:demo

现在我们再次运行上面的那个‘curl’指令,就会得到如下信息。(由于我的集群里跑了一些deployment,所以会有一长串的信息,每个人执行得到的内容会与自己的k8s里运行的workload相关)

需要注意的是,我们通过上述指令获得的service account token并非是会一直有效的,它的有效期只有1小时。

到目前为止模拟的是直接向api server发出http请求得到的token,但是在pod里面的应用却并不需要用这种方式。当我们将service account分配给一个pod的时候,实际上k8s会有一个admission controller来自动给pod添加三个projected volumes,而这些volumes里面会包含有service account token的内容,接下来,我们可以在测试环境下看一看这个过程。我们先执行以下指令来将刚刚创建的service account分发给一个Pod:

kubectl run nginx --image=nginx --restart=Never --overrides='{ "spec": { "serviceAccount": "demo" }  }'
kubectl get pod nginx -w

待这个pod建立成功以后,我们可以来查看一下这个pod资源的volume状况:

kubectl get pod nginx -o jsonpath='{.spec.volumes}' | jq

会得到类似于下图的结果:

[
  {
    "name": "kube-api-access-pt6wl",
    "projected": {
      "defaultMode": 420,
      "sources": [
        {
          "serviceAccountToken": {
            "expirationSeconds": 3607,
            "path": "token"
          }
        },
        {
          "configMap": {
            "items": [
              {
                "key": "ca.crt",
                "path": "ca.crt"
              }
            ],
            "name": "kube-root-ca.crt"
          }
        },
        {
          "downwardAPI": {
            "items": [
              {
                "fieldRef": {
                  "apiVersion": "v1",
                  "fieldPath": "metadata.namespace"
                },
                "path": "namespace"
              }
            ]
          }
        }
      ]
    }
  }
]

你使用的K8S版本低于1.21,如果,执行同样的指令得到的结果与上图完全不同,在1.21及更新的版本中,这种service account token被叫做Bound Service Account,比起之前的service account token,这种实际上更具安全性。由上图可以看到,一共三个projected volumes,其中serviceAccountToken是真正包含有token内容的volume,可以看到它有一个‘expirationSeconds’的属性,上图显示是3607,代表着这个volume包含的内容会在1个小时的时间范围过期,每隔一个小时,这个pod会自动更新这个token来维持有效性,第二个configmap的volume包含有CA的证书内容,最后一个downwardAPI里面则是包含有这个service account所属的namespace信息。这三个projected volume都会被自动mount在如下文件路径下

/var/run/secrets/kubernetes.io/serviceaccount

所以运行在pod内的应用,如果需要与api server进行交互,那么就可以直接从以上文件路径下读取有效的token,然后再发出指令,如此api server就能有效的识别指令的身份信息。这种通过这三个projected volume来控制token有效性的方式比起之前的k8s版本的service account token要安全了不少。

集群外部user的身份认证

Service account只是针对pod应用的,对于集群外部的用户或者应用,如果直接使用service account token来做身份认证显然是非常不安全的,其次也非常不方便进行管理。所以对于外部user,EKS会采用另外两种身份认证策略,webhook身份认证和OIDC身份认证。

Webhook 身份认证策略

首先我们先来看看webhook身份认证策略,这个策略也是最常用的eks身份认证策略,并且集成了成熟的AWS IAM 服务,来让用户不再需要单独管理维护一套k8s本身的身份认证系统,而AWS云平台本身的身份认证信息(AWS IAM)就可以直接作为k8s集群本身的身份认证信息来使用。熟悉Amazon EKS的用户应该都会使用过一条AWS CLI的指令:

aws eks update-kubeconfig --region region-code --name cluster-name

这条指令生成的kubeconfig文件实际上就是指导kubectl客户端去通过webhook身份认证策略与api server进行交互的。

那么在我们深入探索这个webhook身份认证策略是如何实现的之前,我们还是先看一下在api server的flags配置里,有哪些与webhook认证直接相关的选项:

我们在这些flags配置上可以看到,webhook的配置文件是放在了api server所在的node的‘/etc/kubernetes/authenticator/apiserver-webhook-kubeconfig.yaml’文件路径下,因为api server是托管的,所以我们也无法直接查看这个配置文件的内容,但是根据EKS的官方文档,在托管的control plane里面包含有一个叫做aws-iam-authenticator的开源 组件,具体可以查看这个repo,我们可以看下这段代码

var webhookKubeconfigTemplate = template.Must(
	template.New("webhook.kubeconfig").Option("missingkey=error").Parse(`
clusters:
  - name: aws-iam-authenticator
    cluster:
      certificate-authority-data: {{.CertificateAuthorityBase64}}
      server: {{.ServerURL}}
# user refers to the API server client
users:
  - name: apiserver
current-context: webhook
contexts:
- name: webhook
  context:
    cluster: aws-iam-authenticator
    user: apiserver
`))
 

这是一个标准的webhook配置结构,而这个webhook身份认证的逻辑如下图所示

Figure 1 Source: https://blog.lightspin.io/exploiting-eks-authentication-vulnerability-in-aws-iam-authenticator

这个逻辑总结起来其实就三个步骤:

  1. user需要使用一个利用IAM服务产生的token发送请求至api server
  2. api server根据webhook选项的配置,将这个请求的authorization token首先转发给IAM Authenticator server
  3. IAM Authenticator Server会把这个token传给AWS STS服务进行身份核实,并传回身份信息认证结果

这三个步骤解释了服务端的流程,但有两个重要的问题还未回答:

  1. 第一步里面user该如何获取IAM 服务产生的token
  2. IAM authenticator server是如何将IAM的身份转化为k8s内的身份并赋予相应权限的

首先我们来回答第一个问题,user可以直接通过Amazon EKS的service endpoint来获得token,不过这个user必须是要有相应的IAM Policy的支持才能够去向这个service endpoint请求token。AWS CLI提供了一个便捷的指令来获取这个token:

aws eks get-token --cluster-name &lt;CLUSTER NAME&gt; | jq ‘.status.token’ -r

这个token是可以直接作为bearer token放在api request 里面使用的,比如说使用以下指令来获取deployments信息:

curl --location --request GET ‘https://<API SERVER ENDPOINT>/apis/apps/v1/deployments’ \
--header ‘Authorization: Bearer <TOKEN>‘

但这里有一个需要注意的问题,具有使用EKS GET-TOKEN的IAM权限的user并不意味着能够去获取k8s内资源的权限。如果我们刚才执行的‘curl’指令成功获得了那些deployments信息,没有被access deny,那么多半是因为我们执行‘aws eks get-token’指令获取token时所用的IAM user(或者federated user)实际上正是建立这个EKS集群时所用的user,因为EKS默认会授予建立集群的user在k8s集群内的最高管理员权限。那么对于其他其他的IAM user,又该如何让他们的身份得到EKS集群内的认证和授权呢?这个就直接引出了我们需要解答的第二个问题,IAM Authenticator server也需要有一种机制能够将AWS IAM user转化成k8s内的user并授予相应的角色权限(RBAC)。AWS的官方文档对于这个机制有详细的说明。文档里提到了一个存在于kube-system命名空间下的叫做aws-auth的configmap的资源,在IAM Authenticator server的源代码里有如下的一段代码

func (ms *MapStore) startLoadConfigMap(stopCh <-chan struct{}) {
	go func() {
		for {
			select {
			case <-stopCh:
				return
			default:
				watcher, err := ms.configMap.Watch(context.TODO(), metav1.ListOptions{
					Watch:         true,
					FieldSelector: fields.OneTermEqualSelector("metadata.name", "aws-auth").String(),
				})
				if err != nil {
					logrus.Errorf("Unable to re-establish watch: %v, sleeping for 5 seconds.", err)
					metrics.Get().ConfigMapWatchFailures.Inc()
					time.Sleep(5 * time.Second)
					continue
				}
 
				for r := range watcher.ResultChan() {
					switch r.Type {
					case watch.Error:
						logrus.WithFields(logrus.Fields{"error": r}).Error("recieved a watch error")
					case watch.Deleted:
						logrus.Info("Resetting configmap on delete")
						userMappings := make([]config.UserMapping, 0)
						roleMappings := make([]config.RoleMapping, 0)
						awsAccounts := make([]string, 0)
						ms.saveMap(userMappings, roleMappings, awsAccounts)
					case watch.Added, watch.Modified:
						switch cm := r.Object.(type) {
						case *core_v1.ConfigMap:
							if cm.Name != "aws-auth" {
								break
							}
							logrus.Info("Received aws-auth watch event")
							userMappings, roleMappings, awsAccounts, err := ParseMap(cm.Data)
							if err != nil {
								logrus.Errorf("There was an error parsing the config maps.  Only saving data that was good, %+v", err)
							}
							ms.saveMap(userMappings, roleMappings, awsAccounts)
							if err != nil {
								logrus.Error(err)
							}
						}
 
					}
				}
				logrus.Error("Watch channel closed.")
			}
		}
	}()
}

这段代码实际的作用就两个:

  1. 监测aws-auth这个configmap资源的变化
  2. 将aws-auth这个configmap里面的内容parse出来

由此我们知道IAM Authenticator server便是根据这个configmap里面的信息来将IAM user给转化成k8s内部的user,并授予相应的身份和权限。现在让我们一起查看一下这个叫做aws-auth的configmap的内容。执行以下指令:

kubectl edit configmap aws-auth -n kube-system

从上面的截图我们可以看到一些有趣的信息,我们可以忽略metadata的部分,而在data部分,我们会看见一个默认写好的mapRoles,如果我们仔细看一下‘rolearn’后面的值,然后再去EC2的控制台里看一下EKS集群的worker node的instance profile,会发现这正是那个节点的instance role,默认已有的这个mapRoles就是用来告诉IAM Authenticator server,worker node的role是有k8s集群内身份的,并且给予它‘system:node’的身份权限。之所以需要默认有这一个身份权限,是因为worker node里面安装的kubelet也需要使用instance profile里面的instance role来获得k8s里面的身份权限,然后与api server进行沟通,比如发送Node信息去etcd,或是从api server接收指令。

这个aws-auth的configmap结构里面,除了mapRoles以外,我们还可以增加mapUsers与mapGroups,分别对应着IAM user和IAM group的转化。具体的操作读者可以根据AWS官方文档来进行,这里我提供一个写好的mapUsers结构作参考:

到这里我们可以总结一下,当我们需要使一个IAM user, group或者role,获得EKS托管集群内部权限时,需要满足两个条件:

  1. 要有相应的EKS GET-TOKEN这个action的IAM 权限来获取针对相应cluster的token
  2. 需要在aws-auth里面加入相应的mapping来指导IAM Authenticator server做相应的aws iam身份到k8s内身份的转化

我们在实际运维场景中比较常见的情况就是我们可能并不直接使用IAM USER来授权aws云平台资源,而是通过AD-Federated user来授权aws权限,那如果我们需要给予某一个AD user相应的EKS集群内的权限,就需要首先给这个AD user所使用的IAM role加上EKS GET-TOKEN的权限,然后再去aws-auth里面增加mapRoles的定义。

OIDC身份认证

除开集成了AWS IAM的webhook身份认证,EKS还提供了另外一种集成OIDC协议身份池的身份认证。需要注意的是前面一节介绍的webhook认证方式是默认开启的,即使使用OIDC的认证方式,webhook的认证也会持续可用。

这种OIDC身份认证方式是开源k8s原生的,与AWS IAM完全无关。这种身份认证方式实际上就是让k8s集群将用户池管理完全托管给外部的OIDC兼容的身份池,当k8s需要给来自于外部身份池的user授予权限时,可以直接使用rolebinding的方式,将k8s内部的role权限授予这个外部的user。我们会在接下来的章节里通过实际演示来详细描述这个实现过程。

Figure 2 Source: https://docs.banyansecurity.io/docs/feature-guides/infrastructure/k8s-api/oidc-auth/

上图便是一个完整的OIDC认证的抽象流程图,其逻辑是非常直白的,在从OIDC的身份池里获取了相应的identity token以后,便可以使用这个token作为authorization的header向api server发送请求。

如果需要在EKS上使用OIDC的身份认证,需要进行配置,首先得有一个OIDC兼容的身份池,在这里我会使用Keycloak来建立一个OIDC的身份池,读者可以使用任何OIDC兼容的身份池来做测试。

无论读者使用的是哪种OIDC的身份池,在配置EKS OIDC之前,我们需要从OIDC身份池中获取一些必要的信息。以keycloak为例,我们需要的第一个信息便是OIDC Issuer url(如下图所示),EKS的control plane将会利用这个url去验证请求里面的Identity token的身份信息,所以这个issuer url必须是对公网开放访问的。

然后我们还需要为EKS集群配置一个OIDC的client,这里需要设置一个独有的client id,这个client id在后面配置EKS OIDC认证时也会用到。

我们现在可以暂时先离开keycloak的设置界面,回到EKS OIDC的配置,执行一下指令

aws eks associate-identity-provider-config --cluster-name <CLUSTER NAME> --oidc identityProviderConfigName=test_oidc,issuerUrl=<ISSUER URL>,clientId=<CLIENT ID>,groupsClaim=group,groupsPrefix=gid:

请替换进自己的CLUSTER NAME, ISSUER URL和CLIENT ID。

然后等待一段时间直到OIDC配置更改完成,可用如下指令查看:

aws eks describe-identity-provider-config --cluster-name test_oidc --identity-provider-config type=oidc,name=test_oidc

当返回结果如下图红框部分,显示为ACTIVE时,就说明EKS OIDC的身份认证配置成功了。

我们确认这个配置成功以后就可以测试一下我们的keycloak身份池里面的user是否能够被EKS所认可。我们可以回到keycloak里面建立一个新的user,并给它设置一个密码:

在创建好这个user以后,我们先用curl的方式直接获取这个user的identity token:

curl -k --location --request POST 'https://<KEYCLOAK DOMAIN>/auth/realms/<REALM NAME>/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=testuser' \
--data-urlencode 'password=<PASSWORD>' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_secret=<CLIENT_SECRET>' \
--data-urlencode 'scope=profile openid' \
--data-urlencode 'client_id=<CLIENT ID>' | jq '.id_token' -r

这个只是针对于keycloak的请求格式,读者可以用任意自己的方式获得这个user的identity token。在得到这个token后,我们可以尝试使用,例如:

curl --location --request GET '<API SERVER URL>/api/v1/namespaces/default/deployments' -k \
--header 'Authorization: Bearer <IDENTITY TOKEN>'

这个指令会去请求default命名空间内deployments的信息,然后我们应该会得到类似于下面的response:

虽然返回的是一个403,但是根据信息显示,此时的api server已经认可了keycloak 的OIDC身份池里面的user身份,我们已经成功配置了EKS的外部OIDC身份认证。如果我们更深一步想要真正的授予k8s集群内权限给外部OIDC的身份池内user的话,我们就需要做一个rolebinding的步骤。我们这个时候可以回过头来看一看前面配置EKS OIDC认证所用的指令:

这里其实有至关重要的两个属性配置我们前面并没有提及,就是截图中红框的部分。这里的两个属性,一个是groupsClaim,还有一个是groupsPrefix。其中groupsClaim代表着当EKS control plane去解析identity token时,应该从哪个字段去提取group命名,而之后EKS正是会以这个group命名为主体进行授权;另一个groupsPrefix则是用于统一在这个group命名加一个前缀,通过这个前缀我们可以方便管理k8s内不同groups时,区分来自于外部OIDC用户的groups。如果读者在这里还是有些困惑于这两个概念的话,也没有关系,我们接下来的操作步骤本身就会展现这两个属性的用处。

那么为了给OIDC用户进行授权,我们需要建一个role,在这里我们建一个clusterrole,可将以下yaml存为’clusterrole-read-deployments.yaml’

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: read-deployments
rules:
- apiGroups:
  - ""
  resources:
  - deployments 
  verbs:
  - 'get'
  - 'watch'
  - 'list'

然后执行‘kubectl apply -f clusterrole-read-deployments.yaml’创建一个cluster role。接下来我们用同样的方式建一个clusterRoleBinding:

kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: read-deployments-role-binding
  namespace: default
subjects:
- kind: Group
  name: "gid:/testgroup"
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: read-deployments
  apiGroup: rbac.authorization.k8s.io

注意在以上yaml代码中我们所使用的Group的名字叫做“gid:/testgroup”,是以‘gid’为前缀的一个group命名。现在我们只需要去keycloak中真正建立这个叫做testgroup的组,并把我们的testuser加到这个testgroup组里面去,我们就完成了授权。

如上图所示,我们现在已经成功的将testuser加到了testgroup里面去,但是对于keycloak来说,我们还需要手动将这个group名字加入到identity token的字段中去,所以还有一步操作就是进入到keycloak的client界面建一个新的mapper:

这里可以看到Mapper Type是”Group Membership”而Token Claim Name则是“group”(和前面配置EKS OIDC时所配置的groupClaim属性保持一致)。在我们建好了这个Mapper之后,整个授权就确实的大功告成了,如果我们此时再次执行

curl --location --request GET '<API SERVER URL>/api/v1/namespaces/default/deployments' -k \
--header 'Authorization: Bearer <IDENTITY TOKEN>'

我们会得到default命名空间内deployments的信息。而之后我们在keycloak的身份池内创建新的user时,只要把这个新的user加入到‘testgroup’的group内,就会获得EKS内同样的权限。

对于大多数用户来说,使用curl的并不是与api server交互的常规方式,一般来说会更加习惯于使用kubectl来进行交互,那么在使用OIDC认证方式之后,kubectl的配置文件kubeconfig就需要进行相应的更改来集成OIDC的认证方式,手动嵌入identity token到kubeconfig中显然是麻烦且容易出错的,这种情况,我们可以选择使用一个kubectl的plugin来简化这个步骤,比如说kube-login,其逻辑如下图所示,在这里因为篇幅原因就不再过多陈述了。

Figure 3 Source: https://github.com/int128/kubelogin

EKS身份认证小结

本篇博客着重介绍了EKS的身份认证机制,对主要的三种身份认证方式做了整体的介绍,我们的目的主要是帮助读者从使用的角度来理解这三种身份认证,所以其实略过了很多理论上面的细节。对于pod内部的应用,如果需要k8s集群内权限,推荐使用service account来做认证,特别是在kubernetes 1.21以后的版本,新增了bound service account的特点,大大增强了service account token的安全性。

而对于外部用户的认证来说,情况则更为灵活一些,默认使用的webhook身份认证机制集成了AWS IAM服务,提供了高安全性的认证方式。这种默认使用的身份认证是无法,也不应该关闭的,因为EKS的worker node运转的kubelet默认也是使用这种认证方式与api server进行交互的。对于已经在使用IAM federated identity来管理AWS资源权限的企业用户,这种webhook认证机制提供了最大便利就是,已经在使用的federated identity可以同样用于提供EKS集群内的身份认证机制。

而OIDC的身份认证方式则是一种开源k8s原生的认证方式,对于希望将自建k8s集群迁移上EKS的用户,如果他们原本的集群认证使用的就是OIDC的方式,那么EKS也同样提供相同的机制,方便用户减少因为迁移上云带来的逻辑更改。

本篇作者

钟威

AWS解决方案架构师,负责跨国企业级客户基于AWS的技术架构设计、咨询和设计优化工作。在加入AWS之前曾就职于Continental AG和Vitesco Technologies汽车企业,积累了丰富的基础设施搭建和CICD pipeline的实践经验。