博客

Kubernetes 上的依赖管理

如何使用 Renovate CLI 和 Argo 工作流简化 Kubernetes 上的依赖管理。

亲身体验 Elasticsearch:在 Elasticsearch Labs 仓库中查看示例笔记本,开始免费云试用,或者立即在本地计算机上体验 Elastic。

这就是我们如何使用 Kubernetes、Argo 工作流、Argo Events 和 Renovate CLI 构建自托管依赖管理平台,以实现自动化更新、快速解决常见漏洞和暴露 (CVE),并高效地在数千个存储库中传播新包版本的方法。

Elastic 的依赖管理

在 Elastic,我们必须管理数百甚至数千个存储库,包括私有和公共存储库。当发现关键 CVE 时,我们需要立即找到答案并采取行动:哪些存储库存在漏洞?我们能多快把它们修补好?除了安全性,生产力问题也随之而来:我们如何才能在不花费太多时间进行手动操作的情况下,迅速将新软件包版本的发布信息传播到所有依赖它的存储库?

寻找依赖管理方法的最初原因是需要建立一个具有自动更新功能的安全基础,以减少 CVE。仔细考虑有关依赖管理的解决方案后,我们首先着手构建一个自托管的基础设施。我们使用自己的 Kubernetes 集群来运行 Mend Renovate 社区自托管服务。我们的想法是能够提供一个依赖管理平台,让我们的用户能够以自助服务的方式访问该平台。

最初的实验取得了成功,因此越来越多的团队开始使用我们的平台,并将其应用于日常存储库的生命周期管理中,用于更新和 CVE 补丁修复。这种情况发生得太快,以至于我们很快就达到了自托管安装的上限。

图 1:Elastic 依赖管理的高级概述。

挑战:我们如何在拥有大量存储库的大型组织中扩展依赖管理平台?

我们的依赖管理平台一次只能处理一个存储库,由于我们拥有大量存储库,这种顺序处理模型已无法跟上需求。我们已经确定,问题在于我们的依赖管理工具的单个实例无法处理我们庞大且不断增长的存储库列表这一概念。存储库在队列中等待,有时长达数小时。我们超过 50% 的存储库甚至没有每天进行处理。这意味着超过 50% 的存储库扫描间隔时间超过 24 小时。

图 2:每天至少处理一次的存储库数量(使用 Nano Banana 制作)。

大型存储库由于代码库规模庞大且有多个开放 PR,因此会造成更大的瓶颈。GitHub webhook 事件打乱了顺序。由于扫描时间无法预测,自动合并变得不可靠。我们曾向用户承诺扫描频率,但未能兑现。

决定内部构建:满足 Elastic 独特的扩展和安全需求

虽然我们考虑了商业选项,包括 Mend 的 Renovate 自托管企业版,但在 Elastic 内部,我们有几个关键计划正在加速推进。

我们决定构建一个内部平台,这一决定源于我们认识到,只有深度定制的解决方案才能满足 Elastic 不可妥协的特殊要求:

  1. 投资我们的内部开发者平台:当时,我们已经开始大力投资内部开发者平台。我们正在讨论和设计每项服务都能融入其中的方法。这意味着我们希望为我们的依赖管理平台测试我们自己的规则和做法。除此之外,新的指南即将出台,我们希望在此之前设计好平台。

  2. 本地集成和工作流程定制:我们需要与内部工具和内部流程直接集成。例如,我们希望通过服务目录(后台)将配置集中为代码。我们对后台的使用有特殊需求,希望我们的平台能与之兼容。因此,尽管可以将 Renovate 自托管 API 与我们的后台自动化结合使用,但这并不能完全覆盖我们的内部流程。

  3. 针对 Elastic 的深度防御安全:我们严格的安全合规要求为我们的生态系统量身定制安全机制。我们正在努力强化对“非人类身份”的使用。这种访问权限的强化方式意味着,如果工具不支持 GitHub 内部的这种实现方式,那么非标准的身份验证方法将无法使用现成的工具。我们的工作流包括实施父子工作流密钥加密模式,并使用临时的一次性 GitHub 令牌。在我们复杂的多云环境中,内部构建是嵌入这些独特的安全层并最大限度减少攻击面的唯一实用方法。

解决方案:用于依赖管理的工作流编排

我们的解决方案源于这样一个事实,即我们希望在已使用的依赖管理工具的基础上进行构建,而不是将其替换掉并寻找其他方案。它已显示出其潜力,其灵活性对于满足我们整个组织的不同需求非常重要。我们考虑了不同的解决方案,而帮助我们做出决定的是我们必须承担的重大且有时特殊的需求。我们决定构建一个可靠且具有可扩展性的依赖管理平台,在这个Platform上,每个存储库都将单独处理,消除瓶颈,为未来发展奠定基础。

我们在设计该平台时遵循了三个核心原则:

1. 并行处理

每个存储库都有其专属的依赖管理处理环境。不再有排队的情况。我们的并发性仅受我们消耗的资源数量限制。我们还应用了智能分布式调度,以避免受到 GitHub 的速率限制。

2. 可自助服务

我们使用服务目录(后台)自动载入和管理任何新的存储库。我们使用自己的资源定义,让最终用户可以选择存储库的处理频率、计划分配多少资源,以及出于任何原因选择关闭或重新开启处理。随着用户需求的变化以及他们对新安装方式日益熟练,我们计划通过这种方式增加更多选项。

3. 缩小了机密范围和命名空间隔离

为了提高安全性,我们在每次工作流开始时为依赖管理 Pod 提供临时生成的 GitHub 令牌。此外,我们还将工作负载隔离在特定的命名空间中,以便仅向它们提供必要的机密。我们使用 Kubernetes RBAC 控制每个依赖管理工作流可以访问哪些机密。我们还使用加密技术将 GitHub 令牌从父工作流传播到子工作流。

我们使用 Kubernetes 重建了平台,并借助 Kubernetes 的强大功能,Argo 工作流为我们的流程逻辑提供支持,同时 Renovate CLI 已设置好,用于一次扫描和处理一个存储库。

图 3:新的依赖管理工作流程的高级概述。

亮点:我们正以一种创新的方式使用经过实战验证的开源项目,为所有这些项目提供新的工作示例,同时为我们的团队提高开发速度并减少 CVE。

依赖管理架构:四个微服务

该平台由四个定制组件构成:

图4:有关组件连接方式的高级概述。

工作流 Operator (Go/Kubebuilder)

Kubernetes Operator 通过三个自定义资源定义 (CRD) 管理工作流生命周期:

  • RepoConfig CRD:存储库配置的单一事实来源。

这就是在 Operator 中定义 RepoConfig 的方式:

// RepoConfig is the Schema for the repoconfigs API
type RepoConfig struct {
	metav1.TypeMeta `json:",inline"`

	// metadata is a standard object metadata
	// +optional
	metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`

	// spec defines the desired state of RepoConfig
	// +required
	Spec RepoConfigSpec `json:"spec"`

	// status defines the observed state of RepoConfig
	// +optional
	Status RepoConfigStatus `json:"status,omitempty,omitzero"`
}

这就是 RepoConfig 实例的样子:

apiVersion: workflows.elastic.co/v1
kind: RepoConfig
metadata:
  generation: 3
  name: elastic-test-repo
  namespace: dependency-management-operator
spec:
  owner: group:my-team
  renovate:
    config:
      resourceGroup: SMALL
      runFrequency: 4h
    enabled: true
  repository: elastic/test-repo
  • 父级 CRD:管理用于计划扫描的 CronWorkflow。

在父控制器的协调循环内部,我们确保创建并保持工作流设置的最新状态,甚至在必要时将其删除。

首先,它会获取一些全局配置的工作流设置:

func (r *ParentReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, parent *workflowsv1.Parent) error {
	logger := logf.FromContext(ctx)
	logger.Info("Reconcile SubResources for Parent", "name", req.NamespacedName)
	wfSet := workflowsettings.WorkflowSettings{
		RunFrequency:   parent.Spec.RunFrequency,
		ResourceGroups: "parent",
	}

它确保互斥 configmap 是最新的,以防止类似的工作流同时运行:

	cfMngr := resources.NewConfigMapManager(r.Client, r.Scheme, r.OperatorConfig.ParentNamespace)
	err := cfMngr.CreateOrUpdateSyncMutexConfigmap(ctx, fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), strings.TrimPrefix(parent.Spec.Repository, "elastic/"), r.OperatorConfig.SemaphoreConcurrencyLimit)

然后创建工作流管理器,该结构将创建或更新 CronWorkflows 和工作流模板:

	wfMngr := resources.NewArgoWorkflowManager(r.Client,
		r.Scheme,
		curateResourceName(
			strings.ReplaceAll(parent.Spec.Repository, "/", "-"),
		),
		parent.Namespace,
		"parent-workflow",
		false).
		WithOrganization(r.OperatorConfig.GitHubOrg).
		WithRepoName(parent.Spec.Repository).
		Init(true, true).
		WithPrefix(r.OperatorConfig.ResourcesPrefix).
		WithWfTemplateName(r.OperatorConfig.ParentWorkflowTemplate).
		WithResources(wfSet.GetResourceCategory()).
		WithSchedule(wfSet.GetCronSchedule()).
		WithImagePullSecrets([]corev1.LocalObjectReference{{
			Name: r.OperatorConfig.WorkflowImagePullSecrets,
		}}).
		AddArgument(true, true, "extra_cli_args").
		SetArgument(true, false, "extra_cli_args", "none").
		AddTemplate(resources.NewParentDAGTemplateInstance()).
		AddTemplate(resources.NewWorkflowsTemplateInstance("check-child-workflows", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddTemplate(resources.NewWorkflowsTemplateInstance("security", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddTemplate(resources.NewWorkflowsTemplateInstance("submit-child-workflow", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector))
	wfMngr.OverWriteCommand("submit-child-workflow", r.OperatorConfig.ChildNamespace)
	wfMngr.OverwriteWfTemplateName("parent-wftmpl")
	wfMngr.AddSynchronization(fmt.Sprintf("%s%s", r.OperatorConfig.ResourcesPrefix, r.OperatorConfig.SyncMutexCfgMapName), "{{workflow.parameters.repo_name}}")
	err = wfMngr.CreateOrUpdateCronWorkflow(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update cron workflow: %w", err)
	}
	err = wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update workflow template: %w", err)
	}
	return nil
  • 子 CRD:使用每个存储库的资源管理 WorkflowTemplate。

子控制器与父控制器有类似的协调职责,但这次它负责子命名空间中将由父工作流触发的工作流模板。

func (r *ChildReconciler) reconcileSubResources(ctx context.Context, req ctrl.Request, child *workflowsv1.Child) error {
	logger := logf.FromContext(ctx)
	logger.Info("Reconcile SubResources for Child", "name", req.NamespacedName)
	wfSet := workflowsettings.WorkflowSettings{
		ResourceGroups: child.Spec.ResourceCategory,
	}
	wfMngr := resources.NewArgoWorkflowManager(r.Client,
		r.Scheme,
		curateResourceName(
			strings.ReplaceAll(child.Spec.Repository, "/", "-"),
		),
		child.Namespace,
		"runner",
		true).
		Init(false, true). // only manage workflow template
		WithPrefix(r.OperatorConfig.ResourcesPrefix).
		WithSuffix("-child-wftmpl").
		WithRepoName(child.Spec.Repository).
		WithOrganization(r.OperatorConfig.GitHubOrg).
		WithResources(wfSet.GetResourceCategory()). // will override resources of presets if set
		WithImagePullSecrets([]corev1.LocalObjectReference{{
			Name: r.OperatorConfig.WorkflowImagePullSecrets,
		}}).
		AddTemplate(resources.NewWorkflowsTemplateInstance("runner", r.OperatorConfig.WorkflowImagePullPolicy, r.OperatorConfig.WorkflowNodeSelector)).
		AddArgument(false, true, "repo_full_name").
		AddArgument(false, true, "repo_name").
		AddArgument(false, true, "encrypted_token").
		AddArgument(false, true, "extra_cli_args")
	wfMngr.OverWriteCommand("runner", r.OperatorConfig.ChildNamespace)
	err := wfMngr.CreateOrUpdateWorkflowTemplate(ctx)
	if err != nil {
		return fmt.Errorf("failed to create or update workflow template: %w", err)
	}
	return nil
}

多控制器模式提供了明确的分隔:RepoConfig 控制器处理加入/退出,父控制器管理调度,子控制器处理执行模板。

GitHub 事件网关 (Go)

一个安全的 Webhook 代理,用于接收 GitHub 的 Webhook,验证签名,按组织/存储库进行筛选,并将其路由到 Argo Events。我们构建了 10 个不同的传感器,分别对依赖仪表板交互、PR 事件和软件包更新做出响应。

此网关可通过以下方式与 GitHub 应用集成:

  • 验证传入的 GitHub Webhook 签名以确保安全。

  • 将有效事件转发给 Argo Events EventSource,并附上所有相关标头和身份验证。

  • 我们还在 EventSource 上配置了一个 authSecret,并在转发的请求中将其作为 Bearer 标头提供。

  • 提供日志记录、指标和重试逻辑。

它对每个 GitHub 事件请求执行各种验证。

它确保某些 HTTP 属性存在:

// ValidateRequestMethod checks if the request method is POST.
func ValidateRequestMethod(r *http.Request) error {
	if r.Method != http.MethodPost {
		return fmt.Errorf("method not allowed, only POST is accepted")
	}
	return nil
}

// ValidateRequiredHeaders checks for required GitHub headers.
func ValidateRequiredHeaders(r *http.Request) error {
	eventType := r.Header.Get("X-GitHub-Event")
	deliveryID := r.Header.Get("X-GitHub-Delivery")
	signature := r.Header.Get("X-Hub-Signature-256")
	if eventType == "" || deliveryID == "" || signature == "" {
		return fmt.Errorf("missing required GitHub headers")
	}
	return nil
}

// ValidateUserAgent checks that the User-Agent header starts with GitHub-Hookshot/
func ValidateUserAgent(r *http.Request) error {
	userAgent := r.Header.Get("User-Agent")
	if !strings.HasPrefix(userAgent, "GitHub-Hookshot/") {
		return fmt.Errorf("invalid User-Agent")
	}
	return nil
}

同时,它还会验证每个请求的签名及其组织。

// ValidateSignature verifies the GitHub webhook signature.
func ValidateSignature(r *http.Request, secret string) ([]byte, error) {
	payload, err := GitHub.ValidatePayload(r, []byte(secret))
	if err != nil {
		return nil, fmt.Errorf("invalid GitHub signature: %w", err)
	}
	return payload, nil
}

// ValidateAllowedOwner checks if the organization login is in the allowed organizations list.
func ValidateAllowedOwner(payload []byte, allowedGitHubOrganizations []string) (string, error) {
	var orgLogin string
	var payloadMap map[string]any
	if err := json.Unmarshal(payload, &payloadMap); err == nil {
		if orgObj, ok := payloadMap["organization"].(map[string]any); ok {
			if login, ok := orgObj["login"].(string); ok {
				orgLogin = login
			} else if name, ok := orgObj["name"].(string); ok {
				orgLogin = name
			}
		}
	}
	if !slices.Contains(allowedGitHubOrganizations, orgLogin) {
		return orgLogin, fmt.Errorf("organization login not allowed")
	}
	return orgLogin, nil
}

最后,它会根据事件类型路由到 Argo Events:

	// Map eventType to Argo `EventSource` path
	var endpoint string
	switch eventType {
	case "push":
		endpoint = "/push"
	case "issues":
		endpoint = "/issues"
	case "pull_request":
		endpoint = "/pull-requests"
	default:
		slog.Info("Ignoring unhandled event type", "event_type", eventType, "delivery_id", deliveryID)
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
		return
	}
	forwardURL := h.config.ArgoEventSourceForwardURL + endpoint

在 Argo Events 方面,有 10 个传感器在监视 Argo Events EventBus 上的新事件。

apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
  name: {{ .Values.sensors.packageUpdateOnDefaultBranch.name }}
  namespace: {{ .Release.Namespace }}
spec:
  eventBusName: {{ .Values.eventBus.name }}

然后,脚本会应用每个传感器的逻辑:

script: |
          local e = event
          if not e or not e.body or not e.body.repository then
            return false
          end

          -- e.g., "refs/heads/main"
          local ref = e.body.ref
          local default_branch = e.body.repository.default_branch
          if not ref or not default_branch then
            return false
          end

          local expected = "refs/heads/" .. default_branch
          if ref ~= expected then
            return false
          end

        {{- if .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}
          patterns = { {{- range $i, $f := .Values.sensors.packageUpdateOnDefaultBranch.packageFiles }}{{ if $i }}, {{ end }}"{{ $f }}"{{- end }} }
        {{- end }}

          local function anyMatch(path)
            if type(path) ~= "string" then return false end
            for _, pat in ipairs(patterns) do
              -- match filename at repo root, or anywhere under subdirs
              if path:match(pat) or path:match(".+/" .. pat) then
                return true
              end
            end
            return false
          end

          local function filesContainPackage(paths)
            if type(paths) ~= "table" then return false end
            for _, p in ipairs(paths) do
              if anyMatch(p) then return true end
            end
            return false
          end

          -- Inspect all commits (GitHub includes added/modified/removed lists)
          local commits = e.body.commits
          if type(commits) ~= "table" then
            -- Fallback: some payloads include only head_commit
            commits = {}
            if type(e.body.head_commit) == "table" then
              table.insert(commits, e.body.head_commit)
            end
          end

          for _, c in ipairs(commits) do
            if filesContainPackage(c.added) or filesContainPackage(c.modified) or filesContainPackage(c.removed) then
              return true
            end
          end

          return false

后台同步器 (Go)

此过程将轮询我们的服务目录(后台)以获取存储库真实资源实体,将其转换为 RepoConfig CRD,并使平台与配置更改保持同步。更改将在三分钟内生效。

repoMap := make(map[string]map[string]interface{})
			for i := range entities {
				entity := &entities[i]
				if entity.Spec.Type != "GitHub-repository" {
					continue
				}

				implRaw, err := json.Marshal(entity.Spec.Implementation)
				if err != nil {
					logger.Error("Failed to marshal implementation", "error", err)
					continue
				}

				var implMap map[string]interface{}
				err = json.Unmarshal(implRaw, &implMap)
				if err != nil {
					logger.Error("Failed to unmarshal implementation map", "error", err)
					continue
				}
				var repoName string
				if specMap, ok := implMap["spec"].(map[string]interface{}); ok {
					if repo, ok := specMap["repository"].(string); ok {
						repoName = repo
					}
				}
				if repoName == "" {
					continue
				}

				var workflowsRaw []byte
				if v, ok := implMap["spec"].(map[string]interface{}); ok {
					if r, ok := v["renovate"]; ok {
						workflowsRaw, _ = json.Marshal(r)
					} else {
						workflowsRaw = []byte(`{}`)
					}
				} else {
					workflowsRaw = []byte(`{}`)
				}

				var workflowsWithDefaults schema.WorkflowsMetadata
				err = json.Unmarshal(workflowsRaw, &rworkflowsWithDefaults)
				if err != nil {
					logger.Error("Failed to unmarshal workflows config", "error", err)
					continue
				}

				workflowsMap := map[string]interface{}{
					"enabled":        workflowsWithDefaults.Enabled,
					"require_pr":     workflowsWithDefaults.RequirePr,
					"resource_group": string(workflowsWithDefaults.ResourceGroup),
					"run_frequency":  string(workflowsWithDefaults.RunFrequency),
				}
				repoMap[repoName] = map[string]interface{}{
					"renovate": workflowsMap,
					"owner":    entity.Spec.Owner,
				}
			}
			logger.Info("Fetched GitHub Repository data from Backstage", "repository_count", len(repoMap), "status_code", resp.StatusCode)

最后,它将数据写入 RepoConfig 实例。

工作流基础(混合:JavaScript、Go、Helm)

基础层包含 Helm 图表、JavaScript 配置、带有加密支持的适用于 Renovate CLI 的 Go 封装器,以及适用于 Alpine 软件包的自定义 APK 索引器。

图 5:基础组件的高级视图(使用 Nano Banana 制作)。

自助服务配置

团队通过后台声明式配置其存储库:

spec:
  renovate:
    enabled: true
    config:
      resourceGroup: LARGE      # SMALL | MEDIUM | LARGE  
      runFrequency: "0 */4 * * *"  # Every 4 hours

资源组根据存储库大小分配 CPU 和内存:

  • 小型:500m CPU,1Gi 内存。

  • 中型:1000m CPU,2Gi 内存。

  • 大型:2000m CPU,4Gi 内存。

配置受版本控制、可审计并自动应用。

父子模式

执行模型采用父子工作流模式:

  • 父工作流:按计划运行的轻量级 CronWorkflow。加密机密,确定是否应运行扫描,将配置传递给子项。

  • 子工作流:运行 Renovate CLI 的临时 Pod。动态分配资源,在隔离环境中解密机密,完成后终止。

这种分离提供了安全性(在父级加密机密)、资源优化(父级使用最少资源)以及可扩展性(子级并行运行)。

结果

性能转换

  • 之前:每次处理一个存储库,有些存储库可能一天甚至更长时间都无法得到处理,每天扫描量不足 1000 次。

  • 之后:超过 100 次并发扫描,通常每天 8,000 次扫描,最多可达 10000 次记录的扫描,仅受我们愿意投入的资源数量以及处理 GitHub 速率限制的方式的限制。

成本效率

尽管听起来有点奇怪,但每天运行 8000 个 Pod 可以比让一个长期运行的 Pod 试图达到同样的结果花费少得多,而且效果相同。

在之前的设置中,我们运行的是单个实例,在状态良好的情况下,每天能执行 500 到 600 次扫描。同时,由于不同类型的存储库将在同一个 Pod 上执行,我们需要根据最大的存储库来调整 Pod 的大小。这种尺寸比我们目前提供的超大型产品要大得多,我们的 Pod 使用 8 个 CPU 和 16G 内存。

为满足当前的每日输出,单个 Pod 需要运行 12 天。因此,将单个 Pod 运行 12 天的成本与每天运行 8,000 个“中等”大小 Pod 的成本进行比较,我们的新设计在相同的扫描输出下要高效得多:

指标

场景 A(工作流)

场景 B(长时间运行的单个 pod)

设置

8,000 个 pod(1 个 vCPU / 2GB)

1 个 pod(8 个 vCPU / 16 GB)*

持续时间

每次 10 分钟

连续 12 天

总工作时间

1,333 计算小时

288 个计算小时

总成本

$65.83

$113.75

不过,我们应考虑到,我们的工作负载默认设置为“小型”,绝大多数工作负载在 0.5 CPU 和 1G RAM 的情况下成功运行,只有少数需要更改为中型或大型。让我们看看,如果 60% 的工作负载运行在“小型”级别,30% 运行在“中型”级别,10% 运行在“大型”级别会发生什么情况,这更接近实际情况。

指标

场景 A(混合群)

场景 B(长时间运行)

战略

8,000 个 Pod(混合尺寸)

1 个 pod(8 个 vCPU / 16 GB)*

持续时间

每次 10 分钟

连续 12 天

总成本

$52.66

$113.75

保存

61.09 美元(便宜 54%)

—

我们可以看到,在相同的输出下,我们目前的配置成本效益要高得多。

增强安全

  • 临时 GitHub 令牌(暴露时间为几分钟而不是几天)。

  • 通过基于角色的访问控制 (RBAC) 边界实现命名空间隔离。

  • 父工作流中的机密数据静态加密。

  • 移除了直接访问金库的权限。

可预测的性能

有了有保障的扫描频率,我们终于可以设定服务水平目标 (SLO)。自动合并功能运行可靠。团队信任平台能够兑现承诺。

关键架构决策

以下是一些塑造平台外观的里程碑式设计决策。

  • 为何采用父子工作流?

我们采用这种模式来实施深度防御策略。通过将高价值证书(例如 GitHub 应用密钥)限制在专用且锁定的命名空间,我们使用基于角色的访问控制 (RBAC) 来确保临时执行 Pod 无法随意访问敏感数据。最近的供应链漏洞(例如“Shai Hulud”持续集成/持续交付 [CI/CD] 攻击)表明,将执行动态脚本的运行时环境与凭据存储空间隔离开至关重要。

同时,这种解耦还实现了细粒度的资源优化。“父”工作流充当轻量级编排器,占用资源极少,而“子”工作流则处理计算密集型依赖扫描。这种分离简化了生命周期管理,使我们能够对每一层应用不同的协调逻辑,让用户能够控制执行参数(子级),同时保留对调度和安全基础设施的管理控制(父级)。

  • 为什么采用可自助服务?

消除团队在存储库配置方面的瓶颈是一项关键要求。我们的使命是构建一个可扩展的自助服务平台,能够支持各种用例。我们认识到,鉴于存储库的庞大数量,为每项配置更改充当“守门员”的做法是不可持续的。相反,我们采取了一种赋能的理念:提供“轨道”(基础设施和保障措施),同时赋予用户驾驶“列车”(执行和自定义)的权力。我们相信,这种向团队自主权的转变,能让用户根据自己的具体运营需求来定制系统,从而显著提高了生产率。

  • 为什么使用 Kubernetes Operator 模式?

如上所述,一个基本的设计原则是确保平台可以完全自助服务。我们需要一种自动机制来捕捉用户意图(例如切换扫描、调整调度频率或调整运行时资源限制),并立即将这些更改传播到底层工作流中。考虑到未来的需求,该系统还需要易于扩展。

为了实现这一目标,我们开发了自定义依赖管理 Kubernetes Operator。通过使用 CRD 作为配置接口,我们建立了一个原生 Kubernetes 协调循环。此 Operator 会持续监控用户定义的期望状态,并自动编排对工作流基础设施进行必要的更新。这确保了事件驱动的无缝操作,平台逻辑可以在后台处理所有复杂性。

  • 为什么要设计 GitHub 事件网关?

采用事件驱动型架构 (EDA) 对平台的响应速度至关重要。尽管 CronWorkflows 提供了可靠的基线计划,但我们还需要具备灵活性来处理临时执行,例如用户通过仪表板手动触发扫描。为了实现这一目标,我们需要一个专用的摄取网关来验证有效负载的完整性并智能地路由请求。

我们评估了现有解决方案,包括 Argo 的原生 GitHub EventSource,但发现在运营开销和严格的 GitHub API 配额(例如,每个存储库的 Webhook 限制)方面存在重大风险。因此,我们构建了一个自定义网关,使我们的基础设施不受这些限制的影响。

至关重要的是,此网关在我们的迁移过程中充当了战略流量控制点。它充当了一个开关,使我们能够从传统系统向新的基础设施执行渐进式、细粒度的部署(流量切换)。这确保了数千个存储库的导入过程是受控且无风险的,而非“大爆炸”式切换。

经验教训

我们学到的一些经验教训与 Elastic 源代码密切相关:

  1. 客户至上:平台为用户而构建。因此,将用户需求放在首位非常重要。这将平台塑造成高效设计的基础设施和应用程序,从而减少与用户的摩擦,简化平台的扩展,并使其易于采用。

  2. 空间与时间:有时,最顺畅的道路也会通向变幻莫测的沙漠。我们最初尝试优化现有的顺序处理模型,但这并未解决我们的问题;事实上,它只是引入了更多复杂性和未解决的问题。重新构建并行处理平台的大胆决定需要大量的前期工作。然而,它最终为可持续的平台增长铺平了道路,并几乎消除了繁琐的日常管理工作。

  3. 视情况而定:平台无法孤立运作;其成功取决于它与更广泛的生态系统的整合程度。在我们的案例中,与后台的集成至关重要,因为它是无缝服务导入的真实来源。同样,连接到 Artifactory 使我们能够高效地管理私有包更新,而且这些重要的集成远不止于此。

  4. 进步,简单即完美:在整个实施过程中,我们不断对最初的假设进行压力测试,并在新障碍出现时进行调整。我们没有被完美主义所束缚,而是采取迭代的方法,逐一解决挑战,并根据实际情况调整迁移策略。

未来发展

该平台的交付使我们能够开展更有意义的工作,这将有助于我们改善平台的用户体验和效率。一些示例包括:

  • 增加并规范自动合并的采用

自动合并功能通过消除繁琐的手动任务,显著加快了团队的工作进度。然而,我们需要确保设立严格的防护措施,确保这种速度提升不以牺牲安全性为代价。

  • 改善围绕最终用户体验的可观测性

我们路线图的一个重要优先事项是增强可观测性,不仅是在平台层面,而且特别是从最终用户的角度。虽然捕获基础设施指标很简单,但要理解实际的用户体验需要更深入的见解。我们正在努力定义以用户为中心的核心关键性能指标 (KPI),以便我们的遥测技术能够在问题升级为用户投诉之前检测到摩擦点和性能问题。

  • 消除障碍以促进更广泛的应用

展望未来,我们的首要任务是找出并消除任何阻碍平台采用的障碍。无论这需要开发新的集成还是部署特定的功能集,我们都致力于数据驱动的规划。我们已成功构建了一个专为扩展而设计的平台;现在我们的重点转向最大限度地发挥其潜力。

了解全貌

依赖管理工作流项目展示了一个更广泛的原则:当您需要将开源工具扩展到其默认部署模型之外时,原生的 Kubernetes 模式提供了前进的道路。

通过拥抱:

  • 用于配置的 CRD。

  • 适用于生命周期管理的 Operator。

  • 用于响应的事件驱动架构

  • 用于部署的 GitOps。

我们构建了可独立于所管理的存储库数量进行扩展的编排。无论管理的是 100 个还是 1,000 个存储库,扫描单个存储库的性能都相同。

公布关键的 CVE 时,我们现在能在几分钟内给出答案,而不是几小时。这就是瓶颈和竞争优势的区别。

致谢

该平台建立在优秀的开源工具之上:

  • Kubebuilder:用于启动 Kubernetes Operator 的开源框架,这些 Operator 可引导和编排工作流。[1][2]

  • 后台:构建服务目录所基于的开源框架,也是我们获取事实依据的来源。[1][2]

  • Argo 工作流和 Argo 事件:用于编排复杂流程并基于事件添加动态处理的开源套件。[1][2][3][4]

  • Renovate CLI:处理存储库的开源依赖管理工具。[1][2]

*尽管我们的工作负载不一定在 AWS 上运行,而是在完整的 Kubernetes 集群上运行,但我们还是以 AWS Fargate 的定价模型作为单个 Pod 成本的参考。

这些内容对您有多大帮助?

相关内容

Kibana 仪表板 API:为每种面板类型提供稳定的 API 规范,正式发布前已经过 50 多个团队测试

Kibana 仪表板 API:为每种面板类型提供稳定的 API 规范,正式发布前已经过 50 多个团队测试

Teresa Alvarez Soler
为 Elastic Cloud Serverless 和 Elasticsearch 引入统一的 API 密钥。

为 Elastic Cloud Serverless 和 Elasticsearch 引入统一的 API 密钥。

Alex Chalkias
使用 Elastic 工作流监测 Kibana 仪表板的浏览情况

使用 Elastic 工作流监测 Kibana 仪表板的浏览情况

Gustavo Llermaly
介绍 Kibana 中的 Elasticsearch 查询规则用户界面

介绍 Kibana 中的 Elasticsearch 查询规则用户界面

Jhon Guzmán
使用 Mastra 和 Elasticsearch 构建具有语义召回功能的知识代理

使用 Mastra 和 Elasticsearch 构建具有语义召回功能的知识代理

JD Armada

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。