Prow Manual-Trigger 深度解析:架构、工作流程与实战 📚 前言 本文深入剖析 Kubernetes test-infra 仓库中的 prow/cmd/manual-trigger 项目,详细讲解其技术架构、工作流程和使用方法。Manual-Trigger 是一个 HTTP 服务,允许用户手动触发 Prow CI/CD 任务,而无需依赖 GitHub webhook 事件。
一、Test-Infra 仓库整体概述 1.1 Test-Infra 是什么? test-infra 是 Kubernetes 官方的测试基础设施仓库 ,包含了 Kubernetes 项目的所有 CI/CD 工具和配置文件。它是一个大型的测试自动化框架,主要用于:
CI 任务管理 : 自动化测试、构建、部署流程
代码审查 : PR 自动化测试和合并管理
测试结果展示 : 历史测试数据和失败分析
资源管理 : GCP 项目池、GitHub API 代理等
1.2 核心组件 - Prow Prow 是这个仓库的核心,它是一个基于 Kubernetes 的 CI/CD 系统,专为 GitHub 设计。Prow 的主要组件包括:
组件
功能
Hook
监听 GitHub webhook 事件
Plank
调度和管理 ProwJob
Deck
Web UI,展示任务状态
Tide
自动合并 PR
Crier
上报任务结果到 GitHub
Manual-Trigger
手动触发任务(本文重点)
二、Manual-Trigger 项目深度剖析 2.1 项目定位 Manual-Trigger 是一个 HTTP 服务 ,允许用户手动触发 Prow 任务 ,而无需通过 GitHub 事件(如 push、PR)。这在以下场景非常有用:
✅ 手动测试 CI 任务 ✅ 对特定代码版本运行测试 ✅ 运维和维护操作 ✅ 使用自定义参数触发任务
2.2 技术架构 2.2.1 技术栈 1 2 3 4 5 6 7 编程语言: Go 1.21 框架: 标准库 net/http Kubernetes: 通过 client-go 与 K8s API 交互 依赖: - Prow API (prowjobs/v1) - Kubernetes Core API (corev1) - Prow 配置管理 (config.Agent)
2.2.2 架构图 graph TB
A[用户/客户端] -->|HTTP POST| B[Manual-Trigger 服务]
B -->|读取配置| C[Config Agent]
B -->|创建 ProwJob| D[ProwJob Client]
D -->|提交 CRD| E[Kubernetes API Server]
E -->|存储| F[ProwJob Custom Resource]
F -->|监听| G[Prow Plank]
G -->|创建 Pod| H[Kubernetes Pod]
H -->|执行任务| I[CI/CD 任务]
B -->|返回结果| A
核心组件说明 :
HTTP Handler : 接收和解析用户请求
Config Agent : 读取 Prow 配置文件,查找任务定义
ProwJob Client : Kubernetes 客户端,用于创建 ProwJob CRD
Prow Plank : 监听 ProwJob 创建事件,调度 Pod 执行
三、工作流程详解 3.1 核心工作流 (Step by Step) 步骤 1: 接收 HTTP 请求 用户发送 POST 请求到 /manual-trigger 端点:
1 2 3 4 5 6 7 8 9 10 curl -X POST "http://manual-trigger.tessprow/manual-trigger" \ -H "Content-Type: application/json" \ -d '{ "org": "tess", "repo": "tessops", "base_ref": "master", "prowtype": "postsubmit", "prowjob": "sddz-e2e-k8s-1.32", "user": "fesu" }'
参数说明 :
参数
说明
必需
示例
org
GitHub 组织名
✅
tess
repo
仓库名
✅
tessops
base_ref
基础分支
✅
master
prowtype
任务类型
✅
postsubmit
prowjob
任务名称
✅
sddz-e2e-k8s-1.32
pullrequest
PR 号(presubmit 必需)
条件
123
user
自定义用户名
❌
fesu
步骤 2: 请求解析与验证 handleManualTrigger 函数处理请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 if req.Org == "" || req.Repo == "" || req.BaseRef == "" || req.ProwType == "" || req.ProwJob == "" { } if req.ProwType == "presubmit" && req.PullRequest <= 0 { }
步骤 3: 查找任务配置 从 Prow 配置中查找对应的任务定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 cfg := s.configAgent.Config() if presubmits, ok := cfg.PresubmitsStatic[req.Org+"/" +req.Repo]; ok { for _, p := range presubmits { if p.Name == req.ProwJob { prowJob, err = s.createProwJobFromPresubmit(p, req) break } } } if prowJob == nil { if postsubmits, ok := cfg.PostsubmitsStatic[req.Org+"/" +req.Repo]; ok { } }
步骤 4: 创建 ProwJob 规范 根据任务类型创建 ProwJob 对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 func (s *server) createProwJobFromPresubmit(presubmit config.Presubmit, req triggerRequest) (*prowapi.ProwJob, error ) { refs := prowapi.Refs{ Org: req.Org, Repo: req.Repo, BaseRef: req.BaseRef, RepoLink: fmt.Sprintf("https://github.corp.ebay.com/%s/%s" , req.Org, req.Repo), } if req.ProwType == "presubmit" && req.PullRequest > 0 { refs.Pulls = []prowapi.Pull{ { Number: req.PullRequest, Link: fmt.Sprintf("https://github.corp.ebay.com/%s/%s/pull/%d" , req.Org, req.Repo, req.PullRequest), }, } } spec := pjutil.PresubmitSpec(presubmit, refs) if req.ProwType == "postsubmit" { spec.Type = prowapi.PostsubmitJob } pj := pjutil.NewProwJob(spec, labels, annotations) if req.User != "" { s.addAuthorEnvToProwJob(&pj, req.User) } return &pj, nil }
步骤 5: 提交到 Kubernetes 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 if !s.dryRun { ctx := context.Background() _, err = s.prowJobClient.Create(ctx, prowJob, metav1.CreateOptions{}) if err != nil { } ctx, cancel := context.WithTimeout(context.Background(), 15 *time.Second) defer cancel() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): goto buildResponse case <-ticker.C: latestProwJob, _ := s.prowJobClient.Get(context.Background(), prowJob.Name, metav1.GetOptions{}) if latestProwJob.Status.BuildID != "" { prowJob = latestProwJob goto buildResponse } } } }
步骤 6: 返回响应 1 2 3 4 5 6 7 8 9 10 11 12 statusLink := fmt.Sprintf("https://%s/prowjob?prowjob=%s" , baseURL, prowJob.Name) logLink := fmt.Sprintf("https://%s/log?job=%s&id=%s" , baseURL, req.ProwJob, prowJob.Status.BuildID) { "success" : true , "message" : "ProwJob created successfully" , "job_name" : "715c1a56-f815-11f0-b860-8acb6d93d6ad" , "status_link" : "https://prow.example.com/prowjob?prowjob=..." , "log_link" : "https://prow.example.com/log?job=...&id=..." }
步骤 7: Prow 执行任务 一旦 ProwJob CRD 被创建:
Plank 组件监听到新的 ProwJob
Plank 根据 spec.PodSpec 创建 Kubernetes Pod
Pod 执行测试/构建任务
Plank 更新 ProwJob 的 status 字段
Deck 在 Web UI 上展示任务状态
Crier 可以将结果上报到外部系统
3.2 核心数据结构 ProwJob CRD 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 type ProwJob struct { metav1.TypeMeta metav1.ObjectMeta Spec ProwJobSpec Status ProwJobStatus } type ProwJobSpec struct { Type ProwJobType Agent ProwJobAgent Cluster string Job string Refs *Refs PodSpec *corev1.PodSpec } type Refs struct { Org string Repo string BaseRef string BaseSHA string Pulls []Pull } type ProwJobStatus struct { State ProwJobState BuildID string StartTime metav1.Time CompletionTime *metav1.Time URL string }
ProwJob 生命周期状态 :
stateDiagram-v2
[*] --> Triggered: ProwJob 创建
Triggered --> Pending: Pod 开始运行
Pending --> Success: 任务成功 (exit 0)
Pending --> Failure: 任务失败 (exit non-zero)
Pending --> Aborted: 被提前终止
Pending --> Error: 调度失败
Success --> [*]
Failure --> [*]
Aborted --> [*]
Error --> [*]
四、关键特性详解 4.1 灵活的类型转换 Manual-Trigger 支持将 presubmit 任务作为 postsubmit 运行 :
1 2 3 4 5 6 if req.ProwType == "postsubmit" { spec := pjutil.PresubmitSpec(presubmit, refs) spec.Type = prowapi.PostsubmitJob }
使用场景 : 想在某个分支上运行测试,但不想创建 PR
4.2 自定义环境变量注入 通过 user 参数可以注入 AUTHOR 环境变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func (s *server) addAuthorEnvToProwJob(pj *prowapi.ProwJob, user string ) { if pj.Spec.PodSpec != nil && pj.Spec.PodSpec.Containers != nil { for i := range pj.Spec.PodSpec.Containers { authorEnv := corev1.EnvVar{ Name: "AUTHOR" , Value: user, } found := false for j := range pj.Spec.PodSpec.Containers[i].Env { if pj.Spec.PodSpec.Containers[i].Env[j].Name == "AUTHOR" { pj.Spec.PodSpec.Containers[i].Env[j].Value = user found = true break } } if !found { pj.Spec.PodSpec.Containers[i].Env = append (pj.Spec.PodSpec.Containers[i].Env, authorEnv) } } } }
使用场景 : 任务脚本可以根据 AUTHOR 环境变量执行不同逻辑
4.3 BuildID 等待机制 为了提供完整的日志链接,服务会等待 BuildID 生成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ctx, cancel := context.WithTimeout(context.Background(), 15 *time.Second) defer cancel()ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop()for { select { case <-ctx.Done(): goto buildResponse case <-ticker.C: latestProwJob, err := s.prowJobClient.Get(context.Background(), prowJob.Name, metav1.GetOptions{}) if latestProwJob.Status.BuildID != "" { goto buildResponse } } }
五、部署架构 5.1 Kubernetes 部署清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 apiVersion: v1 kind: ServiceAccount metadata: name: manual-trigger namespace: tessprow --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: manual-trigger namespace: tessprow rules: - apiGroups: - prow.k8s.io resources: - prowjobs verbs: - create - get - list --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: manual-trigger namespace: tessprow roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: manual-trigger subjects: - kind: ServiceAccount name: manual-trigger namespace: tessprow --- apiVersion: apps/v1 kind: Deployment metadata: name: manual-trigger namespace: tessprow labels: app: manual-trigger spec: replicas: 1 selector: matchLabels: app: manual-trigger template: metadata: labels: app: manual-trigger spec: serviceAccountName: manual-trigger containers: - name: manual-trigger image: hub.tess.io/prowimages/manual-trigger:latest imagePullPolicy: Always args: - --config-path=/etc/config/config.yaml - --dry-run=false - --port=8080 - --namespace=tessprow ports: - name: http containerPort: 8080 - name: metrics containerPort: 9090 livenessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 5 periodSeconds: 5 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi volumeMounts: - name: config mountPath: /etc/config readOnly: true volumes: - name: config configMap: name: config --- apiVersion: v1 kind: Service metadata: name: manual-trigger namespace: tessprow labels: app: manual-trigger spec: type: ClusterIP ports: - name: http port: 80 targetPort: 8080 protocol: TCP - name: metrics port: 9090 targetPort: 9090 protocol: TCP selector: app: manual-trigger
5.2 容器镜像构建 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 FROM golang:1.21 AS builderWORKDIR /workspace COPY . . WORKDIR /workspace/prow/cmd/manual-trigger RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manual-trigger . FROM hub.tess.io/tess/ubuntu-22.04 :hardenedCOPY --from=builder /workspace/prow/cmd/manual-trigger/manual-trigger /usr/local/bin/manual-trigger RUN useradd -r -u 1000 -g root prow && \ chmod +x /usr/local/bin/manual-trigger USER 1000 :0 ENTRYPOINT ["/usr/local/bin/manual-trigger" ]
构建命令 :
1 2 docker build -t hub.tess.io/fesu/manual-trigger:latest \ -f prow/cmd/manual-trigger/Dockerfile .
六、使用示例 6.1 基础使用 1 2 3 4 5 6 7 8 9 10 11 curl -X POST "http://manual-trigger.tessprow/manual-trigger" \ -H "Content-Type: application/json" \ -d '{ "org": "tess", "repo": "tessops", "base_ref": "master", "prowtype": "postsubmit", "prowjob": "sddz-e2e-k8s-1.32", "user": "admin" }'
响应示例 :
1 2 3 4 5 6 7 { "success" : true , "message" : "ProwJob created successfully" , "job_name" : "715c1a56-f815-11f0-b860-8acb6d93d6ad" , "status_link" : "https://prow.tessprow/prowjob?prowjob=715c1a56-f815-11f0-b860-8acb6d93d6ad" , "log_link" : "https://prow.tessprow/log?job=sddz-e2e-k8s-1.32&id=20260328-100000" }
6.2 触发 presubmit 任务 1 2 3 4 5 6 7 8 9 10 11 curl -X POST "http://manual-trigger.tessprow/manual-trigger" \ -H "Content-Type: application/json" \ -d '{ "org": "tess", "repo": "tessops", "base_ref": "master", "prowtype": "presubmit", "prowjob": "sddz-e2e-k8s-1.32", "pullrequest": 123 }'
6.3 将 presubmit 任务作为 postsubmit 运行 1 2 3 4 5 6 7 8 9 curl -X POST "http://manual-trigger.tessprow/manual-trigger" \ -d '{ "org": "tess", "repo": "tessops", "base_ref": "feature-branch", "prowtype": "postsubmit", "prowjob": "sddz-e2e-k8s-1.32" }'
6.4 Shell 脚本封装 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 #!/bin/bash MANUAL_TRIGGER_URL="http://manual-trigger.tessprow/manual-trigger" trigger_job () { local org=$1 local repo=$2 local branch=$3 local job=$4 local type =${5:-postsubmit} local pr =$6 local user=${7:-""} local data="{\"org\":\"$org \",\"repo\":\"$repo \",\"base_ref\":\"$branch \",\"prowtype\":\"$type \",\"prowjob\":\"$job \"}" if [ ! -z "$pr " ]; then data=$(echo $data | sed "s/}$/,\"pullrequest\":$pr }/" ) fi if [ ! -z "$user " ]; then data=$(echo $data | sed "s/}$/,\"user\":\"$user \"}/" ) fi echo "📤 触发任务: $data " response=$(curl -s -X POST $MANUAL_TRIGGER_URL \ -H "Content-Type: application/json" \ -d "$data " ) echo "$response " | jq . log_link=$(echo "$response " | jq -r '.log_link' ) if [ "$log_link " != "null" ] && [ ! -z "$log_link " ]; then echo "📊 日志链接: $log_link " fi } trigger_job "tess" "tessops" "master" "sddz-e2e-k8s-1.32" "postsubmit" "" "admin"
七、与 Prow 生态的集成 7.1 Prow 配置文件 Manual-Trigger 依赖 Prow 配置文件来查找任务定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 presubmits: tess/tessops: - name: sddz-e2e-k8s-1.32 always_run: true decorate: true spec: containers: - image: gcr.io/k8s-testimages/kubekins-e2e:latest command: - runner.sh args: - make - test env: - name: AUTHOR value: ""
7.2 与其他 Prow 组件交互 graph LR
A[Manual-Trigger] -->|创建 ProwJob| B[Kubernetes API]
B --> C[Plank 调度器]
B --> D[Deck Web UI]
B --> E[Crier 通知器]
C -->|创建 Pod| F[执行任务]
D -->|展示状态| G[用户界面]
E -->|上报结果| H[GitHub/Slack]
八、安全与监控 8.1 RBAC 权限 最小权限原则 :
权限
说明
create
只能创建 ProwJob
get
只能查询 ProwJob 状态
list
只能列出 ProwJob
❌ delete
不能删除 ProwJob
❌ update
不能更新 ProwJob
8.2 监控指标 服务暴露 Prometheus metrics (端口 9090):
1 2 metrics.ExposeMetrics("manual-trigger" , configAgent.Config().PushGateway, o.instrumentationOptions.MetricsPort)
可监控的指标 :
HTTP 请求计数
请求延迟
错误率
ProwJob 创建成功/失败次数
Prometheus 查询示例 :
1 2 3 4 5 6 7 8 9 # 每秒请求数 rate(http_requests_total{job="manual-trigger"}[5m]) # 错误率 rate(http_requests_total{job="manual-trigger",code=~"4.."}[5m]) / rate(http_requests_total{job="manual-trigger"}[5m]) # P95 延迟 histogram_quantile(0.95, http_request_duration_seconds_bucket{job="manual-trigger"})
8.3 运行安全
✅ 非 root 用户 : UID 1000
✅ 只读配置 : ConfigMap 挂载为 readOnly
✅ 健康检查 : liveness 和 readiness probe
✅ 资源限制 : CPU 和内存限制
九、故障排查 9.1 常见错误 错误 1: Job not found in config 1 2 3 4 { "success" : false , "message" : "Job sddz-e2e-k8s-1.32 not found in config for tess/tessops" }
原因 :
任务名称拼写错误
任务未在 Prow 配置中定义
org/repo 组合错误
解决方法 :
1 2 3 4 5 kubectl get configmap config -n tessprow -o yaml | grep "sddz-e2e-k8s-1.32" cat config/jobs/tess/tessops/tessops-presubmits.yaml | grep "name:"
错误 2: Pull request number is required 1 2 3 4 { "success" : false , "message" : "Pull request number is required for presubmit jobs. Please provide 'pullrequest' parameter." }
原因 : presubmit 任务必须提供 PR 号
解决方法 : 添加 pullrequest 参数或改用 prowtype=postsubmit
9.2 调试技巧 查看 ProwJob 状态 1 2 3 4 5 6 7 8 kubectl get prowjobs -n tessprow --sort-by=.metadata.creationTimestamp | tail -10 kubectl get prowjob <job_name> -n tessprow -o yaml kubectl logs -n tessprow <pod_name>
查看服务日志 1 2 kubectl logs -n tessprow -l app=manual-trigger --tail =100 -f
十、ProwJob 的 PodSpec 生成与 Pod 执行详解 10.1 PodSpec 的来源与生成 核心问题:PodSpec 从哪里来? 很多人疑惑:Manual-Trigger 创建 ProwJob 时,spec.PodSpec 是如何生成的?答案很简单:它不生成,而是直接复制 。
完整的数据流 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 ┌─────────────────────────────────────────────────────────────────┐ │ 1. Prow 配置文件 (YAML) │ ├─────────────────────────────────────────────────────────────────┤ │ presubmits: │ │ tess/tessops: │ │ - name: sddz-e2e-k8s-1.32 │ │ spec: │ │ containers: │ │ - image: gcr.io/k8s-testimages/kubekins-e2e:latest │ │ command: [runner.sh] │ │ args: [make, test] │ └─────────────────────────────────────────────────────────────────┘ │ │ YAML 反序列化 ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 2. config.JobBase.Spec (*v1.PodSpec) │ ├─────────────────────────────────────────────────────────────────┤ │ type JobBase struct { │ │ Name string │ │ Spec *v1.PodSpec `json:"spec,omitempty"` ← 直接存储! │ │ } │ └─────────────────────────────────────────────────────────────────┘ │ │ pjutil.PresubmitSpec() ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 3. prowapi.ProwJobSpec │ ├─────────────────────────────────────────────────────────────────┤ │ func specFromJobBase(jb config.JobBase) prowapi.ProwJobSpec { │ │ return prowapi.ProwJobSpec{ │ │ PodSpec: jb.Spec, ← 直接赋值,零转换! │ │ } │ │ } │ └─────────────────────────────────────────────────────────────────┘
关键代码路径 :
文件 : prow/config/jobs.go:89-140
1 2 3 4 5 6 7 8 9 10 type JobBase struct { Name string Agent string Cluster string Spec *v1.PodSpec `json:"spec,omitempty"` }
文件 : prow/pjutil/pjutil.go:192-218
1 2 3 4 5 6 7 8 9 10 11 func specFromJobBase (jb config.JobBase) prowapi.ProwJobSpec { return prowapi.ProwJobSpec{ Job: jb.Name, Agent: prowapi.ProwJobAgent(jb.Agent), PodSpec: jb.Spec, } }
10.2 Decoration 机制:Pod 中真正运行的内容 什么是 Decoration? Decoration 是 Prow 的核心机制,它将用户定义的简单容器包装 成完整的 CI/CD 任务。
配置文件中的 decorate: true :
1 2 3 4 5 6 7 8 9 presubmits: tess/tessops: - name: integration-test decorate: true spec: containers: - image: golang:1.21 command: ["runner.sh" ] args: ["make" , "test" ]
Decoration 添加的组件
组件
类型
作用
clonerefs
InitContainer
克隆代码到 /home/prow/go
initupload
InitContainer
创建 started.json,标记任务开始
place-entrypoint
InitContainer
复制 entrypoint 二进制到 /tools
entrypoint wrapper
修改主容器
包装用户命令,提供超时、日志收集
sidecar
Sidecar Container
上传日志和结果到 GCS
10.3 完整的 Pod 结构对比 没有 Decoration 的 Pod 1 2 3 4 5 6 7 8 9 spec: containers: - name: test image: golang:1.21 command: ["runner.sh" ] args: ["make" , "test" ]
有 Decoration 的 Pod 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 spec: initContainers: - name: clonerefs image: gcr.io/k8s-prow/clonerefs:latest command: ["/clonerefs" ] volumeMounts: - name: code mountPath: /home/prow/go - name: logs mountPath: /logs - name: initupload image: gcr.io/k8s-prow/initupload:latest command: ["/initupload" ] args: - --bucket=gs://my-bucket volumeMounts: - name: logs mountPath: /logs - name: place-entrypoint image: gcr.io/k8s-prow/entrypoint:latest command: ["/entrypoint" ] args: - --copy-mode=true - --copy-dst=/tools/entrypoint volumeMounts: - name: tools mountPath: /tools containers: - name: test image: golang:1.21 command: ["/tools/entrypoint" ] args: - --timeout=120m - --grace-period=15s - --artifact-dir=/logs/artifacts - --marker-file=/logs/marker - -- - runner.sh - make - test volumeMounts: - name: code mountPath: /home/prow/go - name: logs mountPath: /logs - name: tools mountPath: /tools env: - name: ARTIFACTS value: /logs/artifacts - name: GOPATH value: /home/prow/go - name: sidecar image: gcr.io/k8s-prow/sidecar:latest command: ["/sidecar" ] args: - --bucket=gs://my-bucket - --wait-for-marker-file=/logs/marker volumeMounts: - name: logs mountPath: /logs volumes: - name: code emptyDir: {} - name: logs emptyDir: {} - name: tools emptyDir: {}
10.4 执行时序图 sequenceDiagram
participant K8s as Kubernetes
participant Clone as clonerefs
participant Init as initupload
participant Place as place-entrypoint
participant Test as test (Main)
participant Side as sidecar
Note over K8s: Pod 创建
K8s->>Clone: 启动 clonerefs
Clone->>Clone: 克隆代码到 /home/prow/go
Clone->>Clone: 检出 PR 分支
Clone->>Clone: 写入 clone.json
Clone-->>K8s: 完成 (exit 0)
K8s->>Init: 启动 initupload
Init->>Init: 创建 started.json
Init->>Init: 上传到 GCS
Init-->>K8s: 完成 (exit 0)
K8s->>Place: 启动 place-entrypoint
Place->>Place: 复制 /entrypoint → /tools/entrypoint
Place-->>K8s: 完成 (exit 0)
Note over K8s: 所有 InitContainers 完成
par 并行启动
K8s->>Test: 启动测试容器
K8s->>Side: 启动 sidecar
end
Test->>Test: /tools/entrypoint 包装原始命令
Test->>Test: 执行: runner.sh make test
Test->>Test: 收集输出到 /logs/artifacts
Test->>Test: 写入 /logs/marker
Test-->>K8s: 退出 (exit 0/1)
Side->>Side: 等待 /logs/marker
Side->>Side: 读取 /logs 目录
Side->>Side: 上传日志到 GCS
Side->>Side: 创建 finished.json
Side-->>K8s: 完成 (exit 0)
10.5 实际执行示例 配置文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 presubmits: tess/tessops: - name: unit-test decorate: true spec: containers: - image: golang:1.21 command: - /bin/bash args: - -c - | cd /home/prow/go/src/github.corp.ebay.com/tess/tessops go test ./... cp coverage.out ${ARTIFACTS}/
Pod 内实际执行流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 $ /clonerefs --log =/logs/clone.json Cloning tess/tessops... Fetching refs/pull/456/head... Merging into master... Clone successful. $ /initupload --bucket=gs://my-bucket ... Creating started.json... Uploading to gs://my-bucket/pr-logs/pull/456/unit-test/123/started.json... Done. $ /entrypoint --copy-mode=true --copy-dst=/tools/entrypoint Copying /entrypoint to /tools/entrypoint... Done. $ /tools/entrypoint \ --timeout =120m \ --artifact-dir=/logs/artifacts \ --marker-file=/logs/marker \ -- \ /bin/bash -c "cd /home/prow/go/src/... && go test ./... && cp coverage.out ${ARTIFACTS} /" $ cd /home/prow/go/src/github.corp.ebay.com/tess/tessops $ go test ./... ok github.corp.ebay.com/tess/tessops/pkg/foo 0.123s ok github.corp.ebay.com/tess/tessops/pkg/bar 0.456s $ cp coverage.out /logs/artifacts/ $ echo "0" > /logs/marker $ exit 0 $ /sidecar --wait-for-marker-file=/logs/marker ... Waiting for marker file... Marker file found, exit code: 0 Uploading /logs/build-log.txt... Uploading /logs/artifacts/coverage.out... Creating finished.json... Upload complete. $ exit 0
GCS 存储结构 1 2 3 4 5 6 7 8 9 10 11 12 13 gs://my-bucket/ └── pr-logs/ └── pull/ └── tess_tessops/ └── 456/ └── unit-test/ └── 123/ # BuildID ├── started.json # initupload 创建 ├── finished.json # sidecar 创建 ├── build-log.txt # sidecar 上传 ├── clone.json # clonerefs 创建 └── artifacts/ # 用户代码产生 └── coverage.out
10.6 关键环境变量 Prow 自动注入的环境变量:
环境变量
值
作用
ARTIFACTS
/logs/artifacts
用户存放测试产物的目录
GOPATH
/home/prow/go
Go 代码路径
JOB_SPEC
JSON 字符串
任务的完整元数据
PULL_NUMBER
456
PR 号码
PULL_BASE_REF
master
基础分支
PULL_BASE_SHA
abc123
基础 commit SHA
PULL_PULL_SHA
def789
PR commit SHA
REPO_OWNER
tess
仓库所有者
REPO_NAME
tessops
仓库名称
使用示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 echo "Running tests for PR #${PULL_NUMBER} " echo "Base: ${PULL_BASE_REF} @${PULL_BASE_SHA} " echo "Head: ${PULL_PULL_SHA} " go test ./... cp coverage.out ${ARTIFACTS} /echo "Test report saved to ${ARTIFACTS} /coverage.out"
10.7 总结:Pod 中跑的内容 由谁定义?
组件
由谁定义
定义位置
容器镜像
用户
spec.containers[].image
执行命令
用户
spec.containers[].command + args
环境变量
用户 + Prow
用户定义的 + Prow 自动添加
资源限制
用户
spec.containers[].resources
InitContainers
Prow
自动添加(如果 decorate: true)
Sidecar
Prow
自动添加(如果 decorate: true)
entrypoint 包装
Prow
自动修改用户 command
Volumes
Prow
自动添加(code, logs, tools)
执行流程
InitContainers 顺序执行 :
clonerefs → 克隆代码
initupload → 初始化上传
place-entrypoint → 放置入口点
主容器和 Sidecar 并行运行 :
主容器:执行用户命令(被 entrypoint 包装)
Sidecar:等待任务完成,上传日志
结果上传到 GCS :
日志文件
用户产生的 artifacts
元数据文件(started.json, finished.json)
核心结论
PodSpec 来源 : 直接从配置文件中的 spec: 字段复制,没有任何生成或转换
Pod 中运行的内容 : 用户定义的命令 + Prow 自动添加的辅助容器
Decoration 的作用 : 自动包装用户命令,提供代码克隆、日志收集、结果上传等 CI/CD 基础设施
十一、总结 11.1 Manual-Trigger 的核心价值
价值
说明
灵活性
绕过 GitHub webhook,手动控制 CI 任务
可测试性
在不创建 PR 的情况下测试任务
可运维性
用于维护、热修复、紧急部署
可扩展性
支持自定义参数和环境变量
11.2 技术亮点 ✅ 简洁的架构 : 单一 HTTP 服务,职责清晰 ✅ Kubernetes 原生 : 使用 CRD,与 Prow 生态无缝集成 ✅ 类型安全 : Go 强类型,减少运行时错误 ✅ 优雅的错误处理 : 详细的错误信息,易于调试 ✅ 等待机制 : BuildID 轮询,提供完整的用户体验
11.3 适用场景
场景
说明
手动测试
开发新 CI 任务时,手动触发测试
紧急修复
需要立即在特定分支运行测试
批量触发
脚本批量触发多个任务
定制化执行
使用自定义环境变量运行任务
无 PR 测试
在分支上运行 presubmit 类型的任务
11.4 扩展阅读
参考资料
Kubernetes test-infra 仓库
Prow 官方文档
Manual-Trigger 源码
ProwJob API 定义
作者 : Tashen日期 : 2026-03-28标签 : #Kubernetes #Prow #CI/CD #Go #DevOps
💡 提示 : 如果你在使用 Manual-Trigger 过程中遇到问题,欢迎在评论区讨论!