1. 从一次流水线卡顿说起:为什么需要单独聊 Tekton Trigger
去年帮一个团队排查持续集成流水线的问题,现象很典型:代码推送到仓库之后,流水线有时候几秒钟就跑起来了,有时候要等十几秒甚至更久,偶尔还会出现同一个提交触发了两次构建的情况。翻日志发现,问题不在 Task 本身,也不在 Pipeline 编排,而是卡在了"事件进来之后、PipelineRun 被创建之前"这一段。这段逻辑,正是 Tekton Trigger 负责的领域。
很多人上手 Tekton 的路径是这样的:先学 Task,再学 Pipeline,然后跑通一个 PipelineRun,觉得已经掌握了。等到真正要接 Git 仓库的 webhook、要区分不同分支走不同流水线、要从事件里提取参数的时候,才发现 Trigger 这一层绕不过去。而 Trigger 恰恰是 Tekton 里概念密度最高、最容易配错的一环——EventListener、TriggerBinding、TriggerTemplate、Interceptor、TriggerGroup,光名字就能劝退一批人。
这篇内容就是想把 Tekton Trigger 这一层讲透。我会从它到底解决什么问题讲起,把每个核心资源对象的职责边界拆开,然后给出一套可以直接抄的配置,最后重点讲几个我在实际项目里踩过的坑。适合已经跑通过 Tekton Pipeline、准备把流水线接入真实事件源的开发者,也适合正在做云原生 CI/CD 选型、想搞清楚 Tekton 事件驱动模型的技术负责人。读完之后,你应该能独立设计一套从代码提交到流水线触发的完整链路,并且知道每个参数为什么这么填。
2. Trigger 到底在流水线里扮演什么角色
2.1 没有 Trigger 的时候,PipelineRun 是怎么被创建的
先退一步看。Tekton 最核心的执行单元是 Task,Task 组合成 Pipeline,Pipeline 的一次执行叫 PipelineRun。PipelineRun 是一个 Kubernetes 自定义资源,也就是说,你完全可以用最原始的方式创建它:
apiVersion: tekton.dev/v1 kind: PipelineRun metadata: generateName: build-run- spec: pipelineRef: name: build-and-deploy params: - name: repo-url value: "https://example.com/team/app.git" - name: revision value: "main"kubectl apply一下,流水线就跑起来了。在本地做验证、写 Demo 的时候,这种方式完全够用。但一旦进入真实研发流程,问题就来了:谁来创建这个 PipelineRun?每次提交的代码仓库地址、分支、commit SHA 都不一样,这些参数从哪来?如果靠人手动改 YAML 再 apply,那这套 CI/CD 基本没有落地价值。
所以本质上,Trigger 要解决的是一个"翻译"问题:把外部系统发来的事件(比如 Git 仓库的 webhook 请求),翻译成一个或多个 Kubernetes 资源(主要是 PipelineRun 和 TaskRun)。这个翻译过程需要三样东西——事件长什么样、我要从事件里拿哪些字段、拿到之后组装成什么资源。Tekton 用三个资源对象分别对应这三件事,这就是 TriggerBinding、TriggerTemplate 和 EventListener 的由来。
2.2 事件驱动模型里的四个关键角色
把 Trigger 的组件摊开看,其实是四个角色在配合:
| 组件 | 职责 | 类比 |
|---|---|---|
| EventListener | 对外暴露一个 HTTP 端点,接收事件 | 前台接待 |
| Interceptor | 在事件进入处理流程前做校验、过滤、转换 | 安检+翻译 |
| TriggerBinding | 从事件 payload 里提取字段,变成参数 | 信息登记表 |
| TriggerTemplate | 用参数渲染出要创建的资源 | 施工图纸 |
一次完整的事件处理流程是这样的:外部系统把请求打到 EventListener 暴露的地址,EventListener 先把请求交给配置好的 Interceptor 链做处理,Interceptor 可以校验签名、过滤分支、甚至改写 payload;处理完之后,EventListener 把 payload 交给 TriggerBinding 提取参数;参数再传给 TriggerTemplate,由它渲染出 PipelineRun 或 TaskRun 的 YAML;最后 EventListener 用集群内的权限把这些资源创建出来。
理解这条链路之后,很多配置上的困惑就迎刃而解了。比如"为什么我的参数取不到值",大概率是 TriggerBinding 里的 JSONPath 写错了;"为什么事件进来了但没触发流水线",可能是 Interceptor 的 CEL 表达式把事件过滤掉了;"为什么创建出来的 PipelineRun 名字重复",那是 TriggerTemplate 里没用generateName而是写死了name。
2.3 Trigger 和 Pipeline 的边界在哪里
这里有个容易混淆的点:Trigger 只负责"创建资源",不负责"执行资源"。PipelineRun 一旦被创建出来,后面的事情就交给 Tekton Pipeline 的控制器了,Trigger 不再参与。这意味着两件事。
第一,Trigger 的调试和 Pipeline 的调试是分开的。如果 PipelineRun 根本没被创建出来,你去翻 Task 的日志是没用的,得去看 EventListener 的 Pod 日志。反过来,如果 PipelineRun 创建出来了但执行失败,那跟 Trigger 就没关系了。
第二,Trigger 的权限模型和 Pipeline 是独立的。EventListener 需要以某个 ServiceAccount 的身份运行,这个 SA 必须有创建 PipelineRun、TaskRun 的 RBAC 权限。我见过不止一次,配置全对但就是创建不出资源,最后发现是 SA 少了create权限。这个坑后面会详细讲。
3. 把 EventListener 拆开看:它到底做了什么
3.1 EventListener 的本质是一个 Deployment
很多人以为 EventListener 是个抽象的控制器,其实不是。当你创建一个 EventListener 资源,Tekton Triggers 的控制器会为它创建一个 Deployment 和一个 Service。这个 Deployment 里跑的是一个叫eventlistener的容器,它内部起了一个 HTTP 服务器,监听 8080 端口。Service 把流量转发到这个 Pod 上。
理解这一点非常关键,因为它决定了你的排错方式。事件没触发,第一反应应该是去看 EventListener 对应的 Pod 日志:
kubectl get pods -l eventlistener=my-listener kubectl logs -f <pod-name>日志里会打印每个进来的请求、Interceptor 的处理结果、参数提取情况、资源创建结果。这是排查 Trigger 问题最直接的手段,比猜配置快得多。
3.2 ServiceAccount 与 RBAC:最容易被忽略的前置条件
EventListener 创建 PipelineRun 用的是它自己的 ServiceAccount,不是你的账号,也不是 Tekton 控制器的账号。所以你必须显式地给这个 SA 授权。一个最小可用的 RBAC 配置长这样:
apiVersion: v1 kind: ServiceAccount metadata: name: trigger-sa --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: trigger-role rules: - apiGroups: ["tekton.dev"] resources: ["pipelineruns", "taskruns"] verbs: ["create", "get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: trigger-rolebinding subjects: - kind: ServiceAccount name: trigger-sa roleRef: kind: Role name: trigger-role apiGroup: rbac.authorization.k8s.io然后在 EventListener 的 spec 里通过serviceAccountName引用它。这里有个细节:如果你用的是Role而不是ClusterRole,那 RoleBinding 必须和 PipelineRun 创建在同一个 namespace 里。跨 namespace 创建资源需要 ClusterRole,但生产环境一般不建议这么做,权限太大。
提示:授权之后如果还是创建失败,检查一下 EventListener 的 Pod 是否真的用了你指定的 SA。有时候 EventListener 资源更新了但 Pod 没重建,用的还是旧的 SA,删掉 Pod 让它重建即可。
3.3 暴露 EventListener 的几种方式
EventListener 的 Service 默认是 ClusterIP,集群外访问不到。要让 Git 仓库的 webhook 能打进来,得把它暴露出去。常见做法有三种。
第一种是用 Ingress。这是最标准的做法,配一个域名,把路径指到 EventListener 的 Service 上。好处是能复用现有的入口网关、证书、鉴权体系。
第二种是用 NodePort。适合测试环境,简单粗暴,但端口管理麻烦,不推荐生产用。
第三种是通过内网穿透工具做临时调试。本地开发时想接真实 webhook,可以临时用这种方式,但仅限调试,别用在正式环境。
不管用哪种方式,都要注意 EventListener 的spec.resources.kubernetesResource.serviceType字段,它决定了生成的 Service 类型。默认是 ClusterIP,需要 NodePort 的话在这里改。
4. TriggerBinding 与 TriggerTemplate:参数是怎么流动的
4.1 TriggerBinding 里的 JSONPath 提取
TriggerBinding 的工作是从事件 payload 里抽字段。它用的是 JSONPath 语法,配置形式是$(body.xxx)。举个 Git 推送事件的例子,payload 大概长这样:
{ "ref": "refs/heads/main", "repository": { "url": "https://example.com/team/app.git", "name": "app" }, "head_commit": { "id": "a1b2c3d4" } }对应的 TriggerBinding 这样写:
apiVersion: triggers.tekton.dev/v1beta1 kind: TriggerBinding metadata: name: git-push-binding spec: params: - name: git-repo-url value: $(body.repository.url) - name: git-revision value: $(body.head_commit.id) - name: git-ref value: $(body.ref)这里有几个实操要点。第一,JSONPath 取不到值的时候不会报错,而是变成空字符串,所以参数为空往往意味着路径写错了,而不是事件没进来。第二,嵌套字段用点号连接,数组用[index],比如$(body.commits[0].id)。第三,$(body)和$(header)是两个不同的命名空间,前者是请求体,后者是 HTTP 头,别混用。
4.2 TriggerTemplate 如何渲染出 PipelineRun
TriggerTemplate 拿到参数之后,用 Go template 语法渲染资源。它的结构是spec.resourcetemplates,里面是一个资源列表,每个元素都是一段完整的 YAML。看个例子:
apiVersion: triggers.tekton.dev/v1beta1 kind: TriggerTemplate metadata: name: build-template spec: params: - name: git-repo-url description: 仓库地址 - name: git-revision description: 提交版本 resourcetemplates: - apiVersion: tekton.dev/v1 kind: PipelineRun metadata: generateName: build-run- spec: pipelineRef: name: build-and-deploy params: - name: repo-url value: $(tt.params.git-repo-url) - name: revision value: $(tt.params.git-revision) workspaces: - name: shared-data volumeClaimTemplate: spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 1Gi注意参数引用前缀是$(tt.params.xxx),tt就是 TriggerTemplate 的缩写。这个前缀和 TriggerBinding 里的$(body.xxx)不一样,写错了就取不到值。
metadata里用generateName而不是name,这是 Tekton 的惯例。因为每次事件都会创建一个新资源,用固定名字第二次就会冲突。generateName会让 Kubernetes 自动加随机后缀,保证唯一性。
4.3 一个 Trigger 如何绑定 Binding 和 Template
TriggerBinding 和 TriggerTemplate 本身是独立的资源,它们通过 Trigger 这个对象组合起来。Trigger 又归属于 EventListener 的spec.triggers列表。完整结构是这样:
apiVersion: triggers.tekton.dev/v1beta1 kind: EventListener metadata: name: git-listener spec: serviceAccountName: trigger-sa triggers: - name: push-trigger bindings: - ref: git-push-binding template: ref: build-template一个 EventListener 可以挂多个 Trigger,每个 Trigger 有自己的 bindings 和 template。这就实现了"不同事件走不同流水线"——比如 push 事件走构建流水线,tag 事件走发布流水线,靠多个 Trigger 分别配置即可。
5. Interceptor:事件进入流水线前的最后一道闸
5.1 为什么需要 Interceptor
如果只有 Binding 和 Template,任何打到 EventListener 的请求都会触发流水线。这显然不行——Git 仓库会发各种事件,ping 事件、issue 事件、PR 事件,你只想让 push 事件触发构建。更别说还有安全性问题:任何人都能往这个地址发请求,如果不校验来源,等于把流水线的触发权公开了。
Interceptor 就是解决这些问题的。它在 Binding 之前介入,可以做三件事:过滤(不满足条件的事件直接丢弃)、校验(验证请求合法性)、转换(改写 payload 或添加字段)。Tekton 内置了几种 Interceptor,也支持自定义。
5.2 内置 Interceptor 的实战用法
最常用的内置 Interceptor 是cel、git和github。cel用通用表达式做过滤,最灵活。比如只处理 main 分支的 push:
interceptors: - name: filter-main ref: name: cel params: - name: filter value: "body.ref == 'refs/heads/main'" - name: overlays value: - key: branch expression: "body.ref.split('/')[2]"这段配置做了两件事:filter表达式为 false 时事件被丢弃;overlays往 payload 里注入了一个新字段branch,值是main。后面 Binding 就可以直接用$(body.branch)取到分支名,不用再写复杂的 split 逻辑。
gitInterceptor 专门处理 Git 类事件,能自动识别事件类型。githubInterceptor 则针对特定代码托管平台的事件格式做了适配,还能校验 webhook 签名。签名校验这块很重要,它保证请求确实来自你的仓库而不是伪造的。
5.3 Interceptor 链的执行顺序
一个 Trigger 可以配多个 Interceptor,它们按顺序执行,前一个的输出是后一个的输入。这个特性很有用。比如先校验签名,再过滤分支,最后做字段转换:
interceptors: - ref: name: github params: - name: secretRef value: secretName: webhook-secret secretKey: token - ref: name: cel params: - name: filter value: "body.ref.startsWith('refs/heads/')"顺序不能乱。如果先过滤再校验签名,那未通过校验的请求也会走一遍过滤逻辑,虽然最终会被拦,但浪费资源,而且日志会混乱。校验类的一定放最前面。
注意:Interceptor 里任何一个环节返回失败,整个事件处理就终止,后面的 Interceptor 和 Binding 都不会执行。所以如果发现事件"静默消失",优先检查 Interceptor 的过滤条件是不是太严了。
6. 一套可直接落地的完整配置
6.1 从 Secret 到 EventListener 的全套 YAML
把前面讲的拼起来,给一套完整可用的配置。假设场景是:Git 仓库 push 到任意分支时,触发一条构建流水线,并把分支名和 commit SHA 传进去。
先建 webhook 密钥:
kubectl create secret generic webhook-secret \ --from-literal=token=your-webhook-token然后是 TriggerBinding:
apiVersion: triggers.tekton.dev/v1beta1 kind: TriggerBinding metadata: name: push-binding spec: params: - name: repo-url value: $(body.repository.url) - name: branch value: $(body.ref) - name: commit-sha value: $(body.head_commit.id)TriggerTemplate:
apiVersion: triggers.tekton.dev/v1beta1 kind: TriggerTemplate metadata: name: push-template spec: params: - name: repo-url - name: branch - name: commit-sha resourcetemplates: - apiVersion: tekton.dev/v1 kind: PipelineRun metadata: generateName: push-build- spec: pipelineRef: name: build-pipeline params: - name: repo-url value: $(tt.params.repo-url) - name: branch value: $(tt.params.branch) - name: commit-sha value: $(tt.params.commit-sha)EventListener:
apiVersion: triggers.tekton.dev/v1beta1 kind: EventListener metadata: name: push-listener spec: serviceAccountName: trigger-sa triggers: - name: push-trigger interceptors: - ref: name: github params: - name: secretRef value: secretName: webhook-secret secretKey: token - ref: name: cel params: - name: filter value: "body.ref.startsWith('refs/heads/')" bindings: - ref: push-binding template: ref: push-template6.2 验证链路是否打通
配置 apply 之后,别急着去仓库配 webhook,先在集群内自测一遍。找到 EventListener 的 Service:
kubectl get svc -l eventlistener=push-listener然后用一个临时 Pod 发个模拟请求:
kubectl run curl-test --rm -it --image=curlimages/curl -- \ curl -X POST http://<service-name>:8080 \ -H "Content-Type: application/json" \ -d '{"ref":"refs/heads/main","repository":{"url":"https://example.com/team/app.git"},"head_commit":{"id":"abc123"}}'发完之后立刻看两样东西:EventListener 的 Pod 日志,以及有没有新的 PipelineRun 被创建:
kubectl get pipelinerun如果 PipelineRun 出现了,说明链路通了,再去仓库那边配 webhook 地址。如果没出现,日志里一定有线索——是签名校验失败,还是过滤条件不满足,还是参数提取为空。
6.3 参数传递的调试技巧
参数传递出问题是最常见的。我的经验是,在 TriggerTemplate 里临时加一个"回显"步骤,把收到的参数打印出来。比如在 PipelineRun 的 params 里加一个 debug 参数,或者在 Pipeline 的第一个 Task 里 echo 一下。这样能快速定位是 Binding 没取到值,还是 Template 没引用对。
另一个技巧是善用 Interceptor 的 overlays。与其在 Binding 里写复杂的 JSONPath,不如在 Interceptor 里用 CEL 表达式把需要的字段整理好,Binding 直接取。这样逻辑更清晰,也更好调试。
7. 那些年我踩过的 Trigger 坑
7.1 事件重复触发:webhook 重试与幂等
有一次线上出现同一个 commit 触发了三次构建。查下来是 Git 仓库的 webhook 有重试机制——第一次请求超时了(其实 EventListener 处理成功了,只是响应慢),仓库就重试了两次。而我们的 Trigger 没有任何幂等控制,来一次就创建一个 PipelineRun。
解决办法有两个方向。一是在 Interceptor 里做去重,用 CEL 表达式检查某个标识(比如 commit SHA)最近是否处理过,但这需要外部状态存储,比较重。二是让 PipelineRun 本身幂等,比如在 Pipeline 里加一个判断,如果这个 commit 已经构建过就跳过。实际项目里我倾向于后者,因为状态管理更简单。
还有个更简单的缓解手段:调大 EventListener 的超时时间,减少仓库侧的重试概率。但治标不治本,幂等还是得做。
7.2 参数为空:JSONPath 写错的排查过程
前面提过,JSONPath 取不到值不报错,只返回空字符串。这个特性坑过我不止一次。有一次配了个新仓库的 webhook,流水线能触发,但 repo-url 参数是空的,导致 clone 步骤失败。
排查过程是这样的:先看 EventListener 日志,确认事件进来了;然后在日志里找到原始 payload,发现这个仓库的事件格式和之前的不一样——仓库地址在body.project.git_http_url而不是body.repository.url。不同代码托管平台的事件结构差异很大,甚至同一平台不同事件类型也不一样。
所以我的建议是:配 Binding 之前,先把真实的事件 payload 抓下来看一眼。可以在 EventListener 日志里找,也可以临时配一个只做日志输出的 Interceptor。别凭记忆写 JSONPath。
7.3 权限不足:SA 配置的隐蔽问题
这个坑最隐蔽。配置全对,日志里也没有明显报错,就是创建不出 PipelineRun。最后在 EventListener 日志的末尾发现一行pipelineruns.tekton.dev is forbidden,才知道是 RBAC 问题。
问题出在 RoleBinding 的 namespace 上。Role 和 RoleBinding 是 namespace 级别的,如果 EventListener 的 SA 在 namespace A,而 PipelineRun 要创建在 namespace B,那 namespace A 里的 RoleBinding 是管不到 namespace B 的。要么把两者放同一个 namespace,要么用 ClusterRole。
还有个更隐蔽的情况:EventListener 资源更新了serviceAccountName,但对应的 Pod 没有重建,还在用旧 SA。Tekton 的控制器在某些版本里不会自动滚动更新 EventListener 的 Pod,需要手动删 Pod 触发重建。这个行为在不同版本间有差异,升级的时候要留意。
7.4 Interceptor 过滤太严导致事件静默丢失
有次配了个 CEL 过滤,想只处理特定分支,写的是body.ref == 'refs/heads/main'。结果测试的时候发现所有事件都没反应。查了半天,发现测试用的仓库默认分支叫master不叫main。过滤条件本身没错,是假设错了。
这类问题的教训是:过滤条件尽量写宽松一点,把精确匹配放到 Pipeline 内部去判断。比如 Interceptor 只过滤"是不是 push 事件",具体分支在 Pipeline 里用条件判断。这样即使分支命名和预期不符,至少事件能进来,日志里能看到,不至于静默丢失。
另外,CEL 表达式里的字符串比较是大小写敏感的,refs/heads/Main和refs/heads/main不相等。如果团队分支命名不规范,这里很容易出问题。
8. 关于 Trigger 的一点个人体会
用了一段时间 Tekton Trigger 之后,我最大的感受是:它的设计哲学是"显式优于隐式"。每个环节都要你明确配置,参数从哪来、怎么转换、创建什么资源,全都写在 YAML 里。这带来的代价是配置量大、上手门槛高,但好处是整条链路完全透明,出问题的时候有迹可循。
我的建议是,刚开始别追求一步到位。先把最简单的链路跑通——一个 EventListener、一个 Binding、一个 Template,能触发一条 Pipeline 就行。跑通之后再逐步加 Interceptor 做过滤和校验,加多个 Trigger 做分支路由。每加一层都验证一遍,别一次性堆一大堆配置然后一起调,那样出问题根本不知道是哪一层的事。
还有个小技巧:把 EventListener 的日志级别调高,或者在开发阶段临时加一个"全量日志"的 Interceptor,把每个进来的请求原样打印出来。等链路稳定了再关掉。这个习惯帮我省了很多排查时间。Trigger 这一层,看得见比猜得着重要得多。