作者:来自 Elastic Nikos Fotiou

如何使用 Renovate CLI 和 Argo Workflows 在 Kubernetes 上简化依赖管理。
动手体验 Elasticsearch:深入探索 Elasticsearch Labs 仓库中的示例 notebook,开始免费的云试用,或者现在就在你的本地机器上试用 Elastic。
这就是我们如何使用 Kubernetes、 Argo Workflows、 Argo Events 和 Renovate CLI 构建一个自托管的依赖管理平台,以自动化更新、快速应对 常见漏洞与暴露( CVEs ),并高效地在数千个代码仓库中传播新的软件包版本。
Elastic 的依赖管理
在 Elastic,我们需要管理数百甚至数千个代码仓库,包括私有和公共仓库。当发现一个关键的 CVE 时,我们需要立刻得到答案并采取行动:哪些代码仓库存在漏洞?我们能多快完成修复?除了安全性之外,还会出现生产力方面的问题:如何在不花费大量人工时间的情况下,快速将一个新软件包版本的发布传播到所有依赖它的代码仓库中?
最初推动我们寻找依赖管理方案的原因,是需要通过自动化更新来建立一个安全的基础,以减少 CVEs。在认真评估依赖管理解决方案之后,我们首先开始构建一个自托管的基础设施。我们使用自己的 Kubernetes 集群来运行 Mend Renovate Community Self-Hosted。这个想法是提供一个依赖管理平台,让用户可以以自助的方式访问和使用。
最初的实验取得了成功,越来越多的团队开始接入我们的平台,并在他们日常的代码仓库生命周期中使用它来进行更新和 CVE 修复。这个过程发展得非常快,以至于我们很快就达到了自托管部署的容量上限。
图 1: Elastic 的依赖管理整体架构概览。
挑战:如何在拥有大量仓库的大型组织中扩展依赖管理平台?
我们的依赖管理平台一次只能处理一个仓库,顺序处理模型由于我们拥有大量仓库而无法跟上需求。我们已经发现问题在于这样一个假设:单个依赖管理工具实例可以处理我们庞大且不断增长的仓库列表。仓库在队列中等待,有时要等上好几个小时。超过 50% 的仓库甚至无法做到每天被处理。这意味着超过 50% 的仓库在两次扫描之间需要等待超过 24 小时。
图 2:每天至少处理一次的仓库数量(使用 Nano Banana 制作)。
大型仓库由于代码库规模大且同时存在多个打开的 PR,会造成更大的瓶颈。 GitHub webhook 事件会打乱处理顺序。由于扫描时间不可预测, automerge 变得不可靠。我们曾向用户承诺扫描频率,但已经无法兑现。
构建内部平台的决定:满足 Elastic 独特的规模与安全需求
虽然我们评估过商业方案,包括 Mend 的 Renovate Self-Hosted Enterprise Self-Hosted 版本,但在 Elastic 内部,有几个关键计划正在同步推进。
我们决定构建内部平台,是因为我们认识到,只有深度定制的解决方案才能满足 Elastic 具体且不可妥协的需求:
- 投资内部开发者平台:当时,我们已经开始大力投入内部开发者平台,并在讨论和设计各个服务如何融入其中。这意味着我们希望在依赖管理平台中实践我们自己的规则和流程。同时,新的指导方针正在引入,我们希望在变化发生之前就提前设计好平台。
- 原生集成与工作流定制:我们需要与内部工具和内部流程进行直接集成。例如,我们希望通过 Service Catalog( Backstage )将配置集中为 code。我们在 Backstage 的使用上有特定需求,希望平台能够与之兼容。虽然可以结合 Renovate Self-Hosted API 和 Backstage 自动化,但这无法完全覆盖我们的内部流程。
- Elastic 特有的纵深防御安全 :我们严格的安全合规要求定制化的安全机制,以适配我们的生态系统。我们正在强化 "非人类身份" 的使用方式。这种访问加固方式意味着,非标准的 GitHub 认证方法无法与不支持该内部实现的现成工具配合使用。我们的工作流包括父子工作流的 secret 加密模式,以及临时的一次性 GitHub token。只有构建内部平台,才能嵌入这些独特的安全层,并在复杂的多云环境中尽量减少攻击面。
解决方案:用于依赖管理的工作流编排
我们的解决方案基于一个事实:我们希望在已经使用的依赖管理工具之上构建,而不是替换它并寻找其他方案。它已经展现出潜力,其灵活性对于整个组织中不同的需求非常重要。我们评估了多种方案,最终促使我们下定决心的是我们需要覆盖的大量且有时相当特殊的需求。我们决定构建一个可靠且可扩展的依赖管理平台,让每个仓库都能独立处理,消除瓶颈,并为未来增长做好准备。
我们在设计平台时遵循了三个核心原则:
-
并行处理
每个仓库都有自己独立的依赖管理处理环境。不再有排队。并发能力只受我们投入资源数量的限制。我们还应用了智能的分布式调度,以避免触发 GitHub 的速率限制。
-
自助式
我们使用 Service Catalog( Backstage )来自动接入和管理任何新的仓库。我们通过自定义的资源定义,让最终用户可以选择仓库的处理频率、为调度分配多少资源,以及是否因任何原因关闭或重新开启处理。随着用户需求的演进以及他们对新安装方式的熟悉,我们计划逐步增加更多选项。
-
缩小 secret 范围并进行命名空间隔离
为了提升安全性,我们在每个工作流开始时为依赖管理 pod 生成一次性的 GitHub token。除此之外,我们将工作负载隔离在特定的命名空间中,只向其提供必要的 secret。我们使用 Kubernetes RBAC 来控制每个依赖管理工作流可以访问哪些 secret。同时,我们还使用加密机制,将 GitHub token 从父工作流安全地传递到子工作流。
我们使用 Kubernetes 重建了平台,并充分利用 Kubernetes 的能力,由 Argo Workflows 驱动流程逻辑,而 Renovate CLI 被配置为一次只扫描和处理一个仓库。
图 3:新依赖管理工作流流程的高层概览。
亮点:我们以原创方式使用经过实战检验的开源项目,为所有这些项目提供新的工作示例,同时提升开发速度并巩固团队的 CVE 减少效果。
依赖管理架构:四个微服务
该平台由四个自定义构建的组件组成:
图 4:组件如何互相连接的高级概览。
Workflows Operator (Go/Kubebuilder)
一个 Kubernetes operator,通过三个自定义资源定义(CRD)管理 workflow 生命周期:
- RepoConfig CRD:仓库配置的单一真实来源。
这是 operator 中 RepoConfig 的定义方式:
go
``
1. // RepoConfig is the Schema for the repoconfigs API
2. type RepoConfig struct {
3. metav1.TypeMeta `json:",inline"`
5. // metadata is a standard object metadata
6. // +optional
7. metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`
9. // spec defines the desired state of RepoConfig
10. // +required
11. Spec RepoConfigSpec `json:"spec"`
13. // status defines the observed state of RepoConfig
14. // +optional
15. Status RepoConfigStatus `json:"status,omitempty,omitzero"`
16. }
``AI写代码
这是一个 RepoConfig 实例的示例:
markdown
`
1. apiVersion: workflows.elastic.co/v1
2. kind: RepoConfig
3. metadata:
4. generation: 3
5. name: elastic-test-repo
6. namespace: dependency-management-operator
7. spec:
8. owner: group:my-team
9. renovate:
10. config:
11. resourceGroup: SMALL
12. runFrequency: 4h
13. enabled: true
14. repository: elastic/test-repo
`AI写代码
- Parent CRD:管理用于定期扫描的 CronWorkflows。
在 parent 控制器的 reconciliation 循环中,我们确保工作流设置被创建并保持最新,必要时甚至可以删除。
首先,它会获取一些全局配置的工作流设置:
markdown
`
1. func (r *ParentReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, parent *workflowsv1.Parent) error {
2. logger := logf.FromContext(ctx)
3. logger.Info("Reconcile SubResources for Parent", "name", req.NamespacedName)
4. wfSet := workflowsettings.WorkflowSettings{
5. RunFrequency: parent.Spec.RunFrequency,
6. ResourceGroups: "parent",
7. }
`AI写代码
它确保 mutex configmap 保持最新状态,以防止相似的 workflows 同时运行:
css
`1. cfMngr := resources.NewConfigMapManager(r.Client, r.Scheme, r.OperatorConfig.ParentNamespace)
2. err := cfMngr.CreateOrUpdateSyncMutexConfigmap(ctx, fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), strings.TrimPrefix(parent.Spec.Repository, "elastic/"), r.OperatorConfig.SemaphoreConcurrencyLimit)`AI写代码
然后它创建一个 Workflow Manager,这是一个 struct,用于创建或更新 CronWorkflows 和 Workflow Templates:
scss
`1. wfMngr := resources.NewArgoWorkflowManager(r.Client,
2. r.Scheme,
3. curateResourceName(
4. strings.ReplaceAll(parent.Spec.Repository, "/", "-"),
5. ),
6. parent.Namespace,
7. "parent-workflow",
8. false).
9. WithOrganization(r.OperatorConfig.GitHubOrg).
10. WithRepoName(parent.Spec.Repository).
11. Init(true, true).
12. WithPrefix(r.OperatorConfig.ResourcesPrefix).
13. WithWfTemplateName(r.OperatorConfig.ParentWorkflowTemplate).
14. WithResources(wfSet.GetResourceCategory()).
15. WithSchedule(wfSet.GetCronSchedule()).
16. WithImagePullSecrets([]corev1.LocalObjectReference{{
17. Name: r.OperatorConfig.WorkflowImagePullSecrets,
18. }}).
19. AddArgument(true, true, "extra_cli_args").
20. SetArgument(true, false, "extra_cli_args", "none").
21. AddTemplate(resources.NewParentDAGTemplateInstance()).
22. AddTemplate(resources.NewWorkflowsTemplateInstance("check-child-workflows", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
23. AddTemplate(resources.NewWorkflowsTemplateInstance("security", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
24. AddTemplate(resources.NewWorkflowsTemplateInstance("submit-child-workflow", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector))
25. wfMngr.OverWriteCommand("submit-child-workflow", r.OperatorConfig.ChildNamespace)
26. wfMngr.OverwriteWfTemplateName("parent-wftmpl")
27. wfMngr.AddSynchronization(fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), "{{workflow.parameters.repo_name}}")
28. err = wfMngr.CreateOrUpdateCronWorkflow(ctx)
29. if err != nil {
30. return fmt.Errorf("failed to create or update cron workflow: %w", err)
31. }
32. err = wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
33. if err != nil {
34. return fmt.Errorf("failed to create or update workflow template: %w", err)
35. }
36. return nil`AI写代码
- Child CRD:管理每个仓库资源的 WorkflowTemplates。
Child controller 的协调职责与 parent 类似,但这次它负责子命名空间中的 workflow templates,这些 templates 会由 parent workflows 触发。
scss
`
1. func (r *ChildReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, child *workflowsv1.Child) error {
2. logger := logf.FromContext(ctx)
3. logger.Info("Reconcile SubResources for Child", "name", req.NamespacedName)
4. wfSet := workflowsettings.WorkflowSettings{
5. ResourceGroups: child.Spec.ResourceCategory,
6. }
7. wfMngr := resources.NewArgoWorkflowManager(r.Client,
8. r.Scheme,
9. curateResourceName(
10. strings.ReplaceAll(child.Spec.Repository, "/", "-"),
11. ),
12. child.Namespace,
13. "runner",
14. true).
15. Init(false, true). // only manage workflow template
16. WithPrefix(r.OperatorConfig.ResourcesPrefix).
17. WithSuffix("-child-wftmpl").
18. WithRepoName(child.Spec.Repository).
19. WithOrganization(r.OperatorConfig.GitHubOrg).
20. WithResources(wfSet.GetResourceCategory()). // will override resources of presets if set
21. WithImagePullSecrets([]corev1.LocalObjectReference{{
22. Name: r.OperatorConfig.WorkflowImagePullSecrets,
23. }}).
24. AddTemplate(resources.NewWorkflowsTemplateInstance("runner", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
25. AddArgument(false, true, "repo_full_name").
26. AddArgument(false, true, "repo_name").
27. AddArgument(false, true, "encrypted_token").
28. AddArgument(false, true, "extra_cli_args")
29. wfMngr.OverWriteCommand("runner", r.OperatorConfig.ChildNamespace)
30. err := wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
31. if err != nil {
32. return fmt.Errorf("failed to create or update workflow template: %w", err)
33. }
34. return nil
35. }
`AI写代码

多控制器模式提供了清晰的职责分离:RepoConfig Controller 负责 onboarding/offboarding,Parent Controller 管理调度,Child Controller 处理执行模板。
GitHub Events Gateway (Go)
一个安全的 webhook 代理,接收 GitHub webhook,验证签名,按组织/仓库过滤,并路由到 Argo Events。我们构建了 10 个独立的 sensor,用于响应依赖仪表板交互、PR 事件和包更新。
这个 gateway 通过以下方式实现与 GitHub Apps 的集成:
-
验证传入的 GitHub webhook 签名以确保安全。
-
将有效事件转发到 Argo Events 的 EventSource,同时保留所有相关 headers 和认证信息。
-
我们还在 EventSource 上配置了 authSecret,并在转发请求中以 Bearer header 提供该 secret。
-
提供日志记录、指标监控和重试逻辑。
-
对每个 GitHub Event 请求执行各种验证。
它确保某些 HTTP 属性存在:
swift
`
1. // ValidateRequestMethod checks if the request method is POST.
2. func ValidateRequestMethod(r *http.Request) error {
3. if r.Method != http.MethodPost {
4. return fmt.Errorf("method not allowed, only POST is accepted")
5. }
6. return nil
7. }
9. // ValidateRequiredHeaders checks for required GitHub headers.
10. func ValidateRequiredHeaders(r *http.Request) error {
11. eventType := r.Header.Get("X-GitHub-Event")
12. deliveryID := r.Header.Get("X-GitHub-Delivery")
13. signature := r.Header.Get("X-Hub-Signature-256")
14. if eventType == "" || deliveryID == "" || signature == "" {
15. return fmt.Errorf("missing required GitHub headers")
16. }
17. return nil
18. }
20. // ValidateUserAgent checks that the User-Agent header starts with GitHub-Hookshot/
21. func ValidateUserAgent(r *http.Request) error {
22. userAgent := r.Header.Get("User-Agent")
23. if !strings.HasPrefix(userAgent, "GitHub-Hookshot/") {
24. return fmt.Errorf("invalid User-Agent")
25. }
26. return nil
27. }
`AI写代码
同时,它还会验证每个请求的签名及其 organization:
csharp
`
1. // ValidateSignature verifies the GitHub webhook signature.
2. func ValidateSignature(r *http.Request, secret string) ([]byte, error) {
3. payload, err := GitHub.ValidatePayload(r, []byte(secret))
4. if err != nil {
5. return nil, fmt.Errorf("invalid GitHub signature: %w", err)
6. }
7. return payload, nil
8. }
10. // ValidateAllowedOwner checks if the organization login is in the allowed organizations list.
11. func ValidateAllowedOwner(payload []byte, allowedGitHubOrganizations []string) (string, error) {
12. var orgLogin string
13. var payloadMap map[string]any
14. if err := json.Unmarshal(payload, &payloadMap); err == nil {
15. if orgObj, ok := payloadMap["organization"].(map[string]any); ok {
16. if login, ok := orgObj["login"].(string); ok {
17. orgLogin = login
18. } else if name, ok := orgObj["name"].(string); ok {
19. orgLogin = name
20. }
21. }
22. }
23. if !slices.Contains(allowedGitHubOrganizations, orgLogin) {
24. return orgLogin, fmt.Errorf("organization login not allowed")
25. }
26. return orgLogin, nil
27. }
`AI写代码
最后,它会根据 event type 路由到 Argo Events:
go
``1. // Map eventType to Argo `EventSource` path
2. var endpoint string
3. switch eventType {
4. case "push":
5. endpoint = "/push"
6. case "issues":
7. endpoint = "/issues"
8. case "pull_request":
9. endpoint = "/pull-requests"
10. default:
11. slog.Info("Ignoring unhandled event type", "event_type", eventType, "delivery_id", deliveryID)
12. w.WriteHeader(http.StatusOK)
13. _, _ = w.Write([]byte("ok"))
14. return
15. }
16. forwardURL := h.config.ArgoEventSourceForwardURL + endpoint``AI写代码
在 Argo Events 这边,10 个 sensors 监视 Argo Events EventBus 以获取新事件:
markdown
`
1. apiVersion: argoproj.io/v1alpha1
2. kind: Sensor
3. metadata:
4. name: {{ .Values.sensors.packageUpdateOnDefaultBranch.name }}
5. namespace: {{ .Release.Namespace }}
6. spec:
7. eventBusName: {{ .Values.eventBus.name }}
`AI写代码
然后脚本应用每个 sensor 的逻辑:
lua
`
1. script: |
2. local e = event
3. if not e or not e.body or not e.body.repository then
4. return false
5. end
7. -- e.g., "refs/heads/main"
8. local ref = e.body.ref
9. local default_branch = e.body.repository.default_branch
10. if not ref or not default_branch then
11. return false
12. end
14. local expected = "refs/heads/" .. default_branch
15. if ref ~= expected then
16. return false
17. end
19. {{- if .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}
20. patterns = { {{- range $i, $f := .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}{{ if $i }}, {{ end }}"{{ $f }}"{{- end }} }
21. {{- end }}
23. local function anyMatch(path)
24. if type(path) ~= "string" then return false end
25. for _, pat in ipairs(patterns) do
26. -- match filename at repo root, or anywhere under subdirs
27. if path:match(pat) or path:match(".+/" .. pat) then
28. return true
29. end
30. end
31. return false
32. end
34. local function filesContainPackage(paths)
35. if type(paths) ~= "table" then return false end
36. for _, p in ipairs(paths) do
37. if anyMatch(p) then return true end
38. end
39. return false
40. end
42. -- Inspect all commits (GitHub includes added/modified/removed lists)
43. local commits = e.body.commits
44. if type(commits) ~= "table" then
45. -- Fallback: some payloads include only head_commit
46. commits = {}
47. if type(e.body.head_commit) == "table" then
48. table.insert(commits, e.body.head_commit)
49. end
50. end
52. for _, c in ipairs(commits) do
53. if filesContainPackage(c.added) or filesContainPackage(c.modified) or filesContainPackage(c.removed) then
54. return true
55. end
56. end
58. return false
`AI写代码收起代码块
Backstage Syncer (Go)
它轮询我们的 Service Catalog (Backstage) 获取 Repository Real Resource Entities,将它们转换为 RepoConfig CRDs,并保持平台与配置更改同步。更改会在三分钟内生效。
go
``
1. repoMap := make(map[string]map[string]interface{})
2. for i := range entities {
3. entity := &entities[i]
4. if entity.Spec.Type != "GitHub-repository" {
5. continue
6. }
8. implRaw, err := json.Marshal(entity.Spec.Implementation)
9. if err != nil {
10. logger.Error("Failed to marshal implementation", "error", err)
11. continue
12. }
14. var implMap map[string]interface{}
15. err = json.Unmarshal(implRaw, &implMap)
16. if err != nil {
17. logger.Error("Failed to unmarshal implementation map", "error", err)
18. continue
19. }
20. var repoName string
21. if specMap, ok := implMap["spec"].(map[string]interface{}); ok {
22. if repo, ok := specMap["repository"].(string); ok {
23. repoName = repo
24. }
25. }
26. if repoName == "" {
27. continue
28. }
30. var workflowsRaw []byte
31. if v, ok := implMap["spec"].(map[string]interface{}); ok {
32. if r, ok := v["renovate"]; ok {
33. workflowsRaw, _ = json.Marshal(r)
34. } else {
35. workflowsRaw = []byte(`{}`)
36. }
37. } else {
38. workflowsRaw = []byte(`{}`)
39. }
41. var workflowsWithDefaults schema.WorkflowsMetadata
42. err = json.Unmarshal(workflowsRaw, &rworkflowsWithDefaults)
43. if err != nil {
44. logger.Error("Failed to unmarshal workflows config", "error", err)
45. continue
46. }
48. workflowsMap := map[string]interface{}{
49. "enabled": workflowsWithDefaults.Enabled,
50. "require_pr": workflowsWithDefaults.RequirePr,
51. "resource_group": string(workflowsWithDefaults.ResourceGroup),
52. "run_frequency": string(workflowsWithDefaults.RunFrequency),
53. }
54. repoMap[repoName] = map[string]interface{}{
55. "renovate": workflowsMap,
56. "owner": entity.Spec.Owner,
57. }
58. }
59. logger.Info("Fetched GitHub Repository data from Backstage", "repository_count", len(repoMap), "status_code", resp.StatusCode)
``AI写代码收起代码块
最后,它将这些数据写入 RepoConfig 实例。
Workflows 基础层 (混合:JavaScript, Go, Helm)
基础层包含 Helm charts、JavaScript 配置、带加密支持的 Renovate CLI Go 包装器,以及用于 Alpine 包的自定义 APK Indexer。
图 5:基础组件的高层视图(使用 Nano Banana 制作)。
自助式配置
团队通过 Backstage 以声明式方式配置他们的仓库:
yaml
`
1. spec:
2. renovate:
3. enabled: true
4. config:
5. resourceGroup: LARGE # SMALL | MEDIUM | LARGE
6. runFrequency: "0 */4 * * *" # Every 4 hours
`AI写代码
资源组根据仓库大小分配 CPU 和内存:
- SMALL: 500m CPU, 1Gi 内存。
- MEDIUM: 1000m CPU, 2Gi 内存。
- LARGE: 2000m CPU, 4Gi 内存。
配置受版本控制,可审计,并自动应用。
父子模式
执行模型使用父子 workflow 模式:
- 父 workflow: 轻量级 CronWorkflow 按计划运行。加密 secrets,决定是否运行扫描,将配置传递给子 workflow。
- 子 workflow: 临时 pod 运行 Renovate CLI。动态分配资源,在隔离环境中解密 secrets,完成后终止。
这种分离提供了安全性(父级加密 secrets)、资源优化(父级使用最少资源)和可扩展性(子级并行运行)。
结果
性能变革
- 之前: 一次处理一个仓库,有些仓库可能一天或更长时间都未处理,每天扫描次数不到 1,000 次。
- 之后: 100+ 并发扫描,通常每天 8,000 次,最高可记录 10,000 次扫描,唯一限制是我们愿意投入的资源量以及如何处理 GitHub 速率限制。
成本效率
听起来可能奇怪,但每天运行 8,000 个 pod,比运行一个长时间运行的 pod 达到相同结果要便宜得多。
在之前的设置中,我们运行单实例,在状态良好的情况下,每天可执行 500--600 次扫描。同时,由于不同类型的仓库会在同一个 pod 上执行,我们需要根据最大仓库大小来配置 pod。这种配置比我们目前的超大规格要大很多,为 pod 配置了 8 个 CPU 和 16G 内存。
要达到当前的每日输出,单个 pod 需要运行 12 天。因此,将单个 pod 运行 12 天的成本与每天运行 8,000 个"MEDIUM"规格 pod 的成本相比,我们的新设计在相同扫描输出下效率更高。
| 指标 | 场景 A (Workflows) | 场景 B (长时间运行的单 pod) |
|---|---|---|
| 配置 | 8,000 个 pod (1 vCPU / 2GB) | 1 个 pod (8 vCPU / 16GB)* |
| 持续时间 | 每个 10 分钟 | 连续 12 天 |
| 总计算时间 | 1,333 计算小时 | 288 计算小时 |
| 总成本 | $65.83 | $113.75 |
但是,让我们考虑到我们的工作负载默认设置为 "SMALL",绝大多数工作负载可以在 0.5 CPU 和 1G RAM 下成功运行,只有少数需要改为 MEDIUM 或 LARGE。让我们来看一下,如果 60% 的工作负载运行在 "SMALL",30% 在 "MEDIUM",10% 在 "LARGE",这更接近实际情况,会发生什么。
| 指标 | 场景 A (混合集群) | 场景 B (长时间运行的单 pod) |
|---|---|---|
| 策略 | 8,000 个 pod (混合大小) | 1 个 pod (8 vCPU / 16GB)* |
| 持续时间 | 每个 10 分钟 | 连续 12 天 |
| 总成本 | $52.66 | $113.75 |
| 节省 | $61.09 (便宜 54%) | --- |
我们可以看到,对于相同的产出,我们当前的设置在成本上要高效得多。
增强安全性
-
短暂的 GitHub token(暴露时间为几分钟,而不是几天)。
-
使用基于角色的访问控制 (RBAC) 的命名空间隔离。
-
父工作流中密钥静态加密。
-
移除了直接访问 vault 的权限。
可预测的性能
-
由于扫描频率有保证,我们终于可以设定服务等级目标 (SLOs)。
-
自动合并 (Automerge) 可靠工作。
-
团队信任平台能够兑现承诺。
关键架构决策
以下是塑造平台外观的一些里程碑式设计决策。
- 为什么选择父子工作流?
我们采用这一模式以执行纵深防御策略。通过将高价值凭证(如 GitHub App secrets)限制在专用的受控命名空间中,我们使用 RBAC 确保短暂执行的 pod 无法随意访问敏感数据。最近的供应链漏洞(例如 "Shai Hulud" 持续集成/持续交付 [CI/CD] 攻击)显示了将执行动态脚本的运行时环境与凭证存储隔离的关键性。
同时,这种解耦允许精细的资源优化。"父"工作流作为轻量级编排器,占用极少资源,而"子"工作流处理计算密集型的依赖扫描。这种分离简化了生命周期管理,使我们能够为每一层应用不同的调节逻辑,用户可以控制执行参数(子),而管理员保留调度和安全基础设施的控制(父)。
- 为什么要自助式?
消除团队作为仓库配置瓶颈是关键要求。我们的目标是构建可扩展的自助平台,支持多样化用例。考虑到仓库数量庞大,为每个配置变更都充当"守门人"是不可持续的。我们采用了赋能哲学:提供"轨道"(基础设施和护栏),让用户驱动"列车"(执行和自定义)。我们相信,这种向团队自主权的转变显著提升了生产力,使用户能够根据特定运营需求定制系统。
- 为什么使用 Kubernetes Operator 模式?
如前所述,基础设计原则是确保平台完全自助式。我们需要一种自动机制来捕获用户意图(如切换扫描、调整调度频率或调整运行时资源限制)并即时将更改传播到底层工作流。为满足未来需求,系统还必须易于扩展。
为此,我们开发了自定义的依赖管理 Kubernetes Operator。通过使用 CRD 作为配置接口,我们建立了 Kubernetes 原生的调节循环。该 Operator 持续监控用户定义的期望状态,并自动协调工作流基础设施的必要更新。确保事件驱动、无缝操作,平台逻辑在后台处理所有复杂性。
- 为什么设计 GitHub Events Gateway?
采用事件驱动架构 (EDA) 对平台响应能力至关重要。虽然 CronWorkflows 提供了可靠的基础调度,但我们需要处理临时执行的敏捷性,例如用户通过仪表盘手动触发扫描。为此,我们需要专用的接入网关来验证负载完整性并智能路由请求。
我们评估了现有解决方案,包括 Argo 的原生 GitHub EventSource,但发现操作开销大且受 GitHub API 限额严格限制(例如每个仓库的 webhook 限制)。因此,我们构建了自定义网关,将基础设施与这些限制解耦。
关键是,该网关在迁移期间充当战略流量控制点。它作为开关,使我们能够从旧系统到新基础设施进行渐进、精细的流量切换。这确保了数千个仓库的上线过程是可控、无风险的,而不是一次性"大爆炸"切换。
经验教训
我们总结的一些经验与 Elastic 源代码紧密相关:
- 以客户为先:平台是为用户构建的。因此,将用户需求作为第一优先级非常重要。这塑造了高效设计的基础设施和应用程序,减少了用户摩擦,简化了平台扩展并便于采用。
- 空间与时间:有时最小阻力路径会导致动荡。我们最初尝试优化现有的顺序处理模型,但未能解决问题;实际上,它只引入了更多复杂性和悬而未决的事项。大胆决定使用并行处理重新架构平台需要大量前期投入。然而,这最终为平台的可持续增长铺平了道路,并几乎消除了繁琐的日常管理工作。
- IT,依赖关系:平台不能孤立运行;其成功取决于与更广泛生态系统的整合程度。在我们的案例中,与 Backstage 的集成至关重要,因为它是无缝服务上线的真实来源。同样,连接到 Artifactory 使我们能够高效管理私有包更新,而必需集成的清单远不止这些。
- 进步,SIMPLE 完美:在整个实施过程中,我们不断对初始假设进行压力测试,并根据出现的新障碍进行调整。我们没有被完美主义束缚,而是采取迭代方法,一次解决一个挑战,并调整迁移策略以适应实际条件。
接下来
平台的交付使我们能够从事更有意义的工作,从而提升平台的 UX 和效率。一些示例包括:
- 增加并规范 auto-merge 的采用
auto-merge 功能通过消除繁琐的手动任务显著加快团队速度。然而,我们需要确保严格的保护措施到位,以保证这种速度提升不会以安全为代价。
- 改善终端用户体验的可观测性
我们的路线图中的关键优先事项是增强可观测性,不仅在平台层面,还特别关注终端用户视角。虽然捕获基础设施指标比较直接,但理解实际用户体验需要更深入的洞察。我们正在定义以用户为中心的核心关键绩效指标 (KPIs),以便我们的遥测可以在问题升级为用户投诉前,检测摩擦点和性能问题。
- 消除阻碍以促进更广泛采用
展望未来,我们的优先事项是识别并消除任何阻碍平台采用的障碍。无论这是否需要开发新集成或部署特定功能集,我们都致力于数据驱动的规划。我们已经成功构建了可扩展平台;现在的重点是最大化其潜力。
更大的视角
依赖管理工作流项目展示了一个更广泛的原则:当你需要将开源工具扩展到其默认部署模型之外时,Kubernetes 原生模式提供了前进的路径。
通过采用:
- CRDs 进行配置。
- Operators 进行生命周期管理。
- 事件驱动架构提高响应能力。
- GitOps 进行部署。
我们构建了独立于管理仓库数量的编排。扫描单个仓库的性能无论是管理 100 个还是 1,000 个仓库都相同。
当发布关键 CVE 时,我们现在能在几分钟内得到答案,而不是几小时。这就是瓶颈与竞争优势的区别。
致谢
该平台构建在优秀的开源工具之上:
-
Kubebuilder:我们用于启动 Kubernetes Operators 并引导和编排工作流的开源框架。 [1][2]
-
Backstage:我们基于此构建 Service Catalog 并将其作为真实来源的开源框架。 [1][2]
-
Argo Workflows 和 Argo Events:用于编排复杂流程并基于事件添加动态处理的开源套件。 [1][2][3][4]
-
Renovate CLI:处理我们仓库的开源依赖管理工具。 [1][2]
-
AWS Fargate 定价模型被用作单个 pod 成本的参考,尽管我们的工作负载不一定运行在 AWS 上,而是运行在完整的 Kubernetes 集群中。