最新文章

AI Coding 时代 Code Review 的最终形态

背景

最近半年 Coding Agent 发展很快。SWE-Agent(普林斯顿大学开源的软件工程 Agent)、OpenHands(前身 OpenDevin,开源的自主编程 Agent 平台)、Devin(Cognition AI 推出的 AI 软件工程师)、Claude Code、Codex 这类工具,已经能在几分钟内产出一个可以跑测试的补丁。

这带来了一个很实际的问题:当一个团队每天面对的不是 10 个 PR,而是 1000 个 Agent 提交时,人类 Reviewer 就成了整个系统里最慢的一环。LGTM(Looks Good To Me)从一个判断行为退化成一个盖章行为。

在我看来,这里真正发生变化的不是“Code Review 怎么做得更快”,而是问题本身被重写了。我们都知道,传统 Code Review 建立在一个隐含前提上:代码是稀缺的、写代码本身是瓶颈,所以人有时间逐行看。这个前提在 AI Coding 时代正在崩塌。当代码被大规模、高速度地生成出来,核心矛盾就从 Generate Code 转移到了 Establish Trust(建立可信性)。

那么 Code Review 的未来到底是什么形态?这篇文章简单聊聊我的看法。

为什么说 Verification 比 Coding 更难

在讨论最终形态之前,先看看今天大家在做什么。

目前几乎所有 Coding Agent 的默认闭环都是同一个:

Issue
  |
Coding Agent
  |
生成 Patch
  |
Run Test
  |
失败则继续修改

这里的 verifier(验证者)是 test,也就是“测试通过 = 改动正确”。这个闭环在 benchmark 场景里跑得通,但在真实工程里大概存在这么几个问题:

  • 测试覆盖不全,很多路径根本没有对应用例。
  • 隐藏的 bug、并发问题、内存泄漏、性能回退、安全漏洞,普通单测几乎不可能捕获。
  • benchmark 本身的测试设计也可能有问题——需求描述不完整、断言过严或过松,都会导致 tests pass 并不能可靠地等价于 correctness(正确性)。

OpenAI 最近对 SWE-bench Pro 的审计发现大约 30% 的任务本身是坏的(过严测试、描述不全、低覆盖测试、误导性 prompt),这从另一个角度说明了一件事:在 Agent 时代,验证问题本身比生成问题更难。评测标准里潜藏的噪声,甚至可能让一个“变强的模型”看起来没有进步。

因此,Code Review 需要解决的核心问题从来都不是“更快地看 diff”,而是“用什么方法建立可信性”。这也直接决定了未来的形态不会是一个更聪明的 Reviewer 模型,而是一个由多层验证能力组成的系统。

几个正在发生的方向

业界还没有一个统一的名字(我倾向于叫它 Verification Agent),但多个研究方向已经在向同一个终点收敛。

Test-driven Verifier

这是当下最成熟,也最容易被误解为“已经解决”的方向。它把 Coding Agent 与测试框架绑成一个闭环:改代码、跑测试、失败再改,直到全部通过。前面提到的 SWE-Agent、OpenHands、Devin、Claude Code、Codex,本质都在做这件事。

优点是工程简单、可以做强化学习。缺点也很明确:它把“代码是否正确”等价于“测试是否通过”,把验证问题外包给了测试设计者。当测试不完整时,它会以极高的效率生产出一堆“测试通过但不解决问题”的补丁。

Multi-agent Review

第二类做法是把 Reviewer 也变成 Agent,让不同角色互相 challenge:

Coder Agent
  |
Reviewer Agent
  |
Critic Agent
  |
Fix Agent

Reddit 上有人做过一个非严格的对比实验,在 SWE-bench Verified 的 100 个实例上,单 Agent(Claude Opus 4.5)解决率是 80%,加上一个 Reviewer Agent(GPT-5.2)之后提升到 90%,代价是平均耗时从 3.5 分钟增加到 7.8 分钟,大约 2.2 倍。这个实验样本量不大,不能直接推广,但它揭示了一个更基本的现象:Review 是可以带来独立信息量的,只要 Reviewer 不再和 Coder 共享同一个上下文与偏见。

当然,如果 Reviewer 只是用另一个 LLM 重新读一遍 diff,它仍然停留在“感觉合理”的层面。要往前走,Review 需要能调用工具、能形成证据,而不仅仅是重新表达一次自然语言判断。

Specification Verification

第三个方向是我个人觉得潜在价值最大、但目前几乎没有人做好的。

今天大部分验证做的是 Code -> Pass Test。真正应该做的是 Spec -> Implementation -> Equivalent?,也就是验证实现和规格说明是不是一致。举个例子,一个 PR 的需求可能是:

  • 必须 backward compatible(向后兼容)。
  • P99 延迟必须小于 200ms。
  • 单实例内存必须小于 4GB。

Verification Agent 要做的不是跑一遍单测,而是自动证明:

  • 公共 API 没有破坏性变更。
  • Benchmark 结果满足 P99 阈值。
  • 内存 profile 满足上限。
  • 所有历史测试通过。
  • 新功能测试通过。

这里被 review 的对象已经不再是代码本身,而是 Spec 是否被实现。这一步之所以难,是因为它要求需求本身是可验证的——这大概也意味着未来工程流程里,Spec 会重新变成一等公民。

Formal Verification 与 Explain + Verify

第四个方向来自更“硬核”的一侧。

近期有一篇论文叫 “The Prover Is the Judge”,尝试用 Ada / SPARK(一种面向高可靠系统的编程语言与验证工具集)和 GNATprove(SPARK 的形式化证明工具)让 AI 生成安全关键代码,再由证明器验证大量证明义务,形成“AI 写代码,证明器判决”的闭环。论文里报告 GNATprove 自动完成了 49,280 条证明义务,覆盖了密码学、TLS 1.3、IKEv2、X.509 等组件,但作者也坦率地指出:证明器能证明什么,取决于规格和验证器的表达能力,对于某些缺陷仍然需要 known-answer tests(已知答案测试)、互操作验证或人工审查。

与之并行的是 Explain + Verify 这条思路,代表性的评测是 ExplainBench(ASE 2026)。它的观察很朴素:一个 Agent 说“我改好了”并不足够,我们真正想知道的是“为什么这样改、影响哪些模块、为什么不会 break”。ExplainBench 的结果给出一个重要判断:解释质量与 Coding 能力是两个独立维度,一个补丁可能正确但解释错误,反之亦然;而且 Agent 经常错误地宣称补丁正确。论文里实现了一个 explanation audit agent(解释审计代理),通过额外运行测试来验证和修正解释,确实改善了所有被评估 Agent 的解释质量。

六层 Verification Stack

把上面这些方向叠加起来,我倾向于认为 AI Coding 时代的 Code Review 最终会长成一个分层的 Verification Stack,而不是任何单一模型或单一工具。下面简单介绍一下每一层。

Layer 1:Syntax

最底层,也是最容易被忽视的一层。编译、lint、格式化、类型检查在这一层完成。它保证“代码至少是一个合法的字符串”,任何上层验证都建立在这个前提之上。AI 在这一层的价值不大,现有工具已经足够。

Layer 2:Static Analysis

第二层负责结构性缺陷:死锁、竞态、资源泄漏、空指针、注入类漏洞等等。今天已经有 CodeQL(GitHub 的语义代码分析引擎)、Semgrep(轻量级静态分析工具)、Infer(Facebook 开源的静态分析器)等成熟工具。未来 LLM 的角色大概是统一调用与解释:根据改动特征选择合适的规则集,把工具产出翻译成可读的、能被 Verification Agent 引用的证据。

Layer 3:Behavioral Verification

第三层不再看代码,而是运行代码:unit test(单元测试)、integration test(集成测试)、e2e(端到端测试)、fuzz(模糊测试)、mutation test(变异测试,通过故意修改代码来检验测试用例的质量)。AI 在这一层最有价值的动作是自动补测试——针对 diff 与调用图,生成有针对性的用例,特别是补齐 mutation score(变异得分)较低的路径。它是 Test-driven Verifier 的自然升级。

Layer 4:Repository-level Reasoning

第四层是 LLM 相对于传统工具真正拉开差距的地方。它要回答的是仓库级问题:

  • 这个 PR 影响哪些模块?
  • 有没有引入重复实现?
  • 有没有违反既定架构?
  • 有没有造成 API drift(API 漂移)?

这一层需要基于代码图(调用图、依赖图、模块图)做推理,是 diff-only Reviewer 天然做不到的。它决定了 Verification Agent 能不能看到“小 PR 引发系统性风险”的场景。

Layer 5:Spec Verification

第五层是我认为价值最大、也最难的一层。它把需求变成一等公民:

Spec
  |
读取代码
  |
生成验证计划
  |
执行验证
  |
证明是否满足

在这里被 review 的不再是 code,而是 intent(意图)。这一层的成熟度大概直接决定了未来能不能把 Coding Agent 真正规模化地交给系统而不是个人。

Layer 6:Runtime Verification

最后一层很多人会漏掉:不是所有问题都可以在合入前发现。内存泄漏、突发流量、少见的竞态、上线后的延迟回退,本质上都是运行时问题。未来的 Verification Agent 会延伸到 merge 之后:持续观察 metrics、logs、trace、crash、canary(金丝雀发布),判断某个指标漂移是不是某个 PR 引起,必要时触发自动回滚。

也就是说,Verification 不是一个 merge 前的门禁,而是覆盖整个软件生命周期的能力。

未来流程

如果把这些层组合起来,未来的流程大概会更像这样:

Spec
  |
Coding Agent
  |
Verification Agent
  ├── Static
  ├── Dynamic
  ├── Tests
  ├── Security
  ├── Performance
  ├── Spec Matching
  ├── Explanation Audit
  └── Runtime Prediction
  |
Risk Score
  |
Human(仅高风险)

对比今天的 Human -> Read Diff -> Approve,可以看到两个主要差别:

  • Human Review 不消失,但被显式地留在高风险位置。绝大多数改动由 Verification Agent 直接通过或直接拒绝,人只在系统“拿不准”的时候进入。
  • Verification 的输出从“同意 / 拒绝”变成“一个带有置信度的风险评分”,可以被后续系统消费。

这两个变化共同推翻了 Code Review 的旧范式:Reviewer 不再是一个决定权在自己手上的角色,而是整个可信性系统里的一环。

Evidence-based Verification

如果只让我押一个具体的研究方向,我会选一个几乎还没有名字的方向:Evidence-based Verification Agent(基于证据的验证代理)。

今天的 Reviewer(无论人还是 AI)给出的通常只是一个结论:

LGTM

未来的 Verification Agent 应该给出的是一整条证据链:

Claim:
  这个 PR 不会 break API。

Evidence:
  - AST Analysis:公共接口签名未变。
  - 142 Regression Tests:全部通过。
  - Mutation Score:93%,覆盖新增分支。
  - Call Graph:无新增循环依赖。
  - Canary Prediction:预测风险 0.03。

Confidence:
  98.7%

这个方向和近几年 AI 领域强调的 reasoning trace(推理轨迹)有相似的气味,但对象完全不同:reasoning trace 是模型推理过程的可解释性,而 Evidence Chain(证据链)是软件可信性的可解释性。前者服务于“模型说了什么”,后者服务于“我们应不应该相信这次改动”。

顺着这个方向再往下想,有三个问题在我看来最值得投入:

  • Spec-to-Evidence Verification:如何把自然语言需求自动转化为可验证的证据计划,而不仅仅是生成一些测试。
  • Repository-scale Verification Graph:把整个仓库建模成依赖图、调用图与架构图,验证一次变更的系统级影响,而不是只看 PR diff。
  • Evidence Aggregation:把静态分析、动态测试、LLM 推理、运行时监控、形式化验证等信号融合成一个可解释、可校准的风险评分,而不是简单的 Approve / Reject。

如果说 Coding Agent 的核心动作是 Generation(生成),那么 Verification Agent 的核心动作就应该是 Evidence(证据)。Code Review 这个名字大概率会保留下来,但它背后的东西会被彻底换掉——不是“怎么看代码”,而是“我们凭什么相信一段代码”。这大概也是 AI Coding 时代最值得做的一件事。

参考

分类:ai标签:#ai-coding #code-review #verification-agent #agent #software-engineering

一次网络延迟高的问题排查

问题背景

客户的机房部署了多套不同架构的 k8s 集群:Intel CPU + redhat OS,海光 CPU + 麒麟 OS。其中 Intel CPU + redhat OS 的 k8s 已经稳定运行了很久。海光 CPU + 麒麟 OS 是后来上线的,主要是为了满足信创的要求。但是在实际使用中发现,海光 CPU + 麒麟 OS 的 k8s 集群中,运行的服务在压测时会偶现服务请求 redis 超时的异常。

问题定位

因为同样的服务,在非信创的机器上可以正常运行,因此排除服务本身的问题。主要排查硬件+操作系统+ k8s CNI 这几个环节。这里之所以要排查 CNI 的问题,主要还是考虑这个 CNI 是专门为这个客户开发的,用来实现 Underlay 的网络,以及一系列的特殊网络需求。 整个网络的链路如图所示: network1

抓包分析

首先是在 Pod 内用 tcpdump 进行长时间的抓包,保存的本地文件。然后进行压测来复现问题。根据日志找到出问题的时间点,用 wireshark 自带的命令对抓包文件按时间切割。

# 每 10s 保存一个分片。
editcap -i 10 tcpdump.cap pod1111

用 wireshark 打开对应时间片段的抓包文件进行分析。这里因为安全要求不方便放上异常包的截图。大概描述一下问题现象:分析的是 tcp 包,表现为异常时间点 redis 回包存在大量重传,且重传的包几乎都在同一时刻到达 Pod 内。 因此下一步要排查 tcp 包的延迟发生链路上的什么位置。因此选择同时在 redis 虚拟机,物理机网卡 eth0,pod 内网卡抓包。然后用同样的方式进行问题复现。 因为有了多个位置点的抓包数据,根据 tcp 的 seq 号就可以分析同一个数据包从 redis 虚拟机发出来的时间,以及到达物理机 eth0 以及 Pod 内的时间。然后根据时间就可以找到延迟点在哪。 分析后发现延迟在 redis→物理机这条链路上。redis 出来的包因为延迟到达了物理机,因此也延迟到达 Pod 内。redis 所在的虚拟机发包后因为一直没有收到回报,因此会触发 tcp 的重传机制。但是重传的包也出现了延迟。最终原始包和重传包在某一刻同时进入了物理机。 因此怀疑是交换机上出现了延迟,但是如果是交换机的问题,那么接入该交换机的其他机器也一定会出问题才对。但是现象仅局限于信创服务器上。所以也和麒麟的供应商沟通了,他们怀疑是一个已知的 cgroup 问题导致的,出现在 4.19 前的内核里。升级内核后果然问题就解决了。

问题分析

上面说的 cgroup 问题,详细来说是 kubelet 中的 cadvisor 在采集 node 的内存信息时,会读取 /sys/fs/cgroup/memory/memory.numa_stat 信息。但是因为内核的实现会导致这个读取信息的系统调用很慢。慢的原因有两点:

  1. cgroup 是通过 cgroup 伪文件系统来管理的,可以通过删除伪文件系统中的文件目录来删除相应的 cgroup。但是内核中代表 cgroup 的结构体会仍然存在,直到所有对它的引用被释放。只有当被删除的 memory cgroup 中的页都被回收掉,相应的引用都被释放,该 memory cgroup 才会被彻底删除。系统中所有的 memory cgroup 数量可以通过 cat /proc/cgroups 来查看。而内存页的回收时间与内核的回收机制有关,如果当中有一些页一直活跃的被使用,就可能永远不会被回收。
  2. cadvisor 读取 /sys/fs/cgroup/memory/memory.numa_stat 信息时,其实是一个系统调用。这个调用的实现也存在性能问题,它会遍历所有的子 cgroup 层级,累加 memory 的使用信息求和,得到总的 memory 使用情况。

因此,在一台一直运行的服务器上,memory cgroup 可能会达到 1w+。cadvisor 在获取 memory cgroup 时可能耗费 1s 以上的时间。在这段时间内,CPU 没法被调度给其他地方使用。 那为什么会导致网络的延迟呢?linux 网络数据包的接收,在之前的文章 linux 网络数据包接收流程(一)中整理过。数据包到达网卡后,依赖硬中断(3)+软中断(6)来触发 CPU 对数据包进行处理。 network2 并且现在的网卡很多都是多队列的,每条队列和某个 CPU 进行绑定,由该 CPU 进行处理。因此如果这个软中断发生在 cadvisor 统计 memory cgroup,进行系统调用时,软中断的处理就可能因此而延迟。如果这个过程持续 1s+,那么引起的现象可能就是对端 tcp 出现重传。如果这个过程持续 2s+,那么因为服务本身读取 redis 的超时时间设置为 2s,就可能出现超时了。

解决方案

长期解决方案就是升级内核。在更高的版本内核中,对 cgroup memory 的计算进行了优化,这里不再会遍历所有的子 memory cgroup 进行统计了。因为本身 cgroup 就已经维护了该信息,直接读取并返回就行了。内核相关修复:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?h=v6.2-rc7&id=dd8657b6c1cb5e65b13445b4a038736e81cf80ea 短期解决方案是周期执行这条命令:echo 3 > /proc/sys/vm/drop_caches。这会触发内核清理 PageCache, dentries 和 inodes 缓存。这里如果是 echo 1,则只清理 PageCache。echo 2,只清理 dentries 和 inodes 缓存。

分类:linux

runc 的输入输出

概述

runc 是一个基于 OCI 规范实现的程序。在基于 containerd 的容器技术栈上,runc 是一个非常重要但又容易忽略的组件。当执行 docker, nerdctl, ctr 等命令时,其实底层都要调用 runc 去运行容器的进程。不过这篇文章仅会涉及到 runc 的输入输出。 因为在日常的开发中,大多数开发者接触到的都是 docker,因此下面会以 docker 为例,结合底层的 runc 来说明容器进程的输入输出。

标准输入输出

在 linux 上,所有的进程都会打开三个文件描述符:

  • 0: stdin 标准输入
  • 1: stdout 标准输出
  • 2: stderr 标准错误输出

在容器技术上,同样离不开这三个基本概念。在使用 docker run/exec 时,其实就是在 linux cgroup 和 namespace 下运行了一个普通的进程而已。所以这样的进程同样会有输入输出,当我们使用 docker run nginx 的时候,nginx 的日志就会打印到终端上,其实就是将 nginx 进程的标准输入输出通过各种手段输出到终端上了。

-i 参数

docker 提供了 -i 参数,来运行交互式的命令,文档上的说明是:

-i,  --interactive                    Keep STDIN open even if not attached

也就是说即使没有使用 docker attach,也会保持 STDIN 打开。这样我们在终端的 STDIN 就会定向到容器内 sh 进程的 STDIN,这样就可以在终端上输入命令行了。 比如 docker run -i nginx sh。这里就是使用 nginx 镜像启动容器,并运行了 sh 命令,然后终端的 bash 的 STDIN 定向到容器内的 sh。如下图所示: /uploads/wp/2023/03/Snipaste_2023-03-08_13-01-18.png 不过我们也可以发现,这里的输出格式似乎和在终端里 ls 的输出不同。这里就要引入一个新的知识点:tty 与 pty 以及我们的 -t 参数。

-t 参数

官方文档上的说明是:

-t, --tty                            Allocate a pseudo-TTY

tty 得名于早期的电传打字机(Teletype),随着技术的发展,tty 的概念变得更宽泛,它不再指打字机这样的物理设备,很多时候指的是 linux 内核中的 tty 驱动。当我们使用无 GUI 的 linux 时,比如 server 版本的 ubuntu,我们使用 ctrl+alt+f1~f6就可以在 tty1~6 之间进行切换。这里的 tty 就是由 linux 内核模拟出来的。 /uploads/wp/2023/03/Snipaste_2023-03-08_13-18-38.png 不过在 GUI 场景中,我们打开的终端并不是使用 tty,而是 pseudo-TTY(下文称之为 pty)。pty 也称为伪终端,也就是说它并不是真实的 tty 设备。引入了 pty 之后,终端的数量就不会再有限制了。我们可以打开任意多的 GUI 终端程序,每打开一个 GUI 终端,就会在 /dev/pts/ 下生成一个数字的文件,这个文件就是 pty slave,另外全局还有共用的一个 pty master,位于 /dev/ptmx。

  1. 通过 GUI 终端,我们可以输入字符,输入的字符会发到 pty master 上,pty master 文件描述符,发送到对应的 pty slave。
  2. pty slave 将输入发送给终端上的 shell 程序,比如 bash。
  3. bash 上执行的任何命令,都会将输出发送给 bash,bash 再发送给 pty slave,之后再经过 tty driver, pty master 到 gui 终端上显示。

pty 的工作原理如下图: /uploads/wp/2023/03/Snipaste_2023-03-08_13-28-22.png 这时候我们在回到 -t 参数,这里其实就是为容器内的 sh 进程分配了一个 pty slave。当 sh 感知到自己被分配了 pty slave 后,它会采用不同的工作模式。比如会在终端上输出提示符,对输出的格式进行调整,像 bash 之类的还是加上颜色等等。 Untitled

# 下面这段程序会输出很多行,也说明了在 sh 里执行 ls 时,原本的输出是很多行的。
root@5f96c3475e20:/# ls | while read line; do echo $line;done
bin
boot
dev
docker-entrypoint.d
docker-entrypoint.sh
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

回到 runc

上述提到 stdio,pty 等概念,很好的解决了运行容器时的输入输出的处理。而这些能力也是 runc 本身就提供的,docker 是在其之上进行了封装。 runc 的输入输出处理有四种模式,由 -d(detach),-t 进行组合得到。

terminal && detached

/uploads/wp/2023/03/Snipaste_2023-03-08_13-44-19.png

  1. 上层的管控程序,比如 runc-shim 会创建 unix socket
  2. runc 运行在容器内,接管容器的 stdio。然后通过 unix socket 将 pty master 和 fd 发送给上层管控程序。
  3. 将自己的 stdio 通过 pty slave 输入输出。

passthrough && detached

/uploads/wp/2023/03/Snipaste_2023-03-08_14-02-40.png

passthrough && foreground

/uploads/wp/2023/03/Snipaste_2023-03-08_14-03-28.png

terminal && foreground

/uploads/wp/2023/03/Snipaste_2023-03-08_14-03-22.png

参考资料

http://www.wowotech.net/tty_framework/tty_concept.html http://www.wowotech.net/tty_framework/tty_architecture.html http://www.wowotech.net/tty_framework/application_view.html https://blog.51cto.com/u_14592069/5824829 https://dev.to/napicella/linux-terminals-tty-pty-and-shell-192e https://github.com/opencontainers/runc/blob/main/docs/terminals.md

分类:容器技术

kubelet PLEG 的实现与优化

概述

PLEG 全称是 Pod Lifecycle Event Generator,用来为 kubelet 生成 container runtime 的 pod 生命周期事件,这样 kubelet 就可以根据 pod 的 spec 和 status 对比,来执行对应的控制逻辑。 在 1.1 及之前的 kubelet 中是没有 PLEG 的实现的。kubelet 会为每个 pod 单独启动一个 worker,这个 worker 负责向 container runtime 查询该 pod 对应的 sandbox 和 container 的状态,并进行状态同步逻辑的执行。这种 one worker per pod 的 polling 模型给 kubelet 带来了较大的性能损耗。即使这个 pod 没有任何的状态变化,也要不停的对 container runtime 进行主动查询。 因此在 1.2 中,kubelet 引入了 PLEG,将所有 container runtime 上 sandbox 和 container 的状态变化事件统一到 PLEG 这个单独的组件中,实现了 one worker all pods。这种实现相比于 one worker per pod 已经带来了较大的性能提升,详细实现会在后文进行介绍。但是默认情况下,仍然需要每秒一次的主动向 container runtime 查询,在 node 负载很高的情况下,依然会有一定的性能问题,比较常见的情况是导致 node not ready,错误原因是 PLEG is not healthy。 在 1.26 中,kubelet 引入了 Evented PLEG,为了和之前的 PLEG 实现区别,之前的 PLEG 称为 Generic PLEG。当然,Evented PLEG 并不是为了取代 Generic PLEG,而是和 Generic PLEG 配合,降低 Generic PLEG 的 polling 频率,从而提高性能的同时,也能保证实时性。

Generic PLEG

Generic PLEG 定时(默认1s)向 runtime 进行查询,这个过程称为 relist,这里会调用 cri 的 ListPodSandboxListContainers接口。runtime 返回所有的数据之后,PLEG 会根据 sandbox 和 container 上的数据,对应的 Pod 上,并更新到缓存中。同时,组装成事件向 PLEG Channel 发送。 /uploads/wp/2023/02/Snipaste_2023-02-27_16-10-20.png kubelet 会在 pod sync loop 中监听 PLEG Channel,从而针对状态变化执行相应的逻辑,来尽量保证 pod spec 和 status 的一致。

Evented PLEG

引入 Evented PLEG 后,对 Generic PLEG 做了些许调整,主要是 relist 的周期和阈值,以及对缓存的更新策略。

  • relist 的同步周期由 1s 增加到 300s。同步阈值从 3min 增加到 10min。
  • 缓存更新时,updateTime 不再是取本地的时间,而是 runtime 返回的时间。

除此之外,Generic PLEG 会和之前一样运行,这样也保证了及时 Evented PLEG 丢失了一些 状态变更的 event,也可以由 Generic PLEG 兜底。 Evented PLEG 会调用 runtime 的 GetContainerEvents 来监听 runtime 中的事件,然后生成 pod 的 event,并发送到 PLEG Channel 中供 kubelet pod sync loop 消费。 如果 Evented 不能按照预期工作(比如 runtime 不支持 GetContainerEvents),还会降级到 Generic PLEG。降级逻辑是:

  • 停止自己。
  • 停止已有的 Generic PLEG。
  • 更新 Generic PLEG 的 relist 周期和阈值为 1s, 3min。
  • 启动新的 Generic PLEG。

/uploads/wp/2023/02/Snipaste_2023-02-27_16-58-56.png /uploads/wp/2023/02/Snipaste_2023-02-27_16-10-20-1.png 因为 Evented PLEG 和 Generic PLEG 会同时更新缓存,所以在更新时还会对比当前值和缓存值的时间戳,保证当前值是更新的状态,才会更新到缓存中。

参考文章

分类:k8s标签:#pleg

Traceroute 的实现原理

traceroute 是一个很常用的工具,用来检查当前设备到目的 IP 地址的路径以及每个中间设备产生的延迟。如下图所示:

traceroute to baidu.com (110.242.68.66), 64 hops max, 52 byte packets
 1  10.43.244.2 (10.43.244.2)  4.132 ms  2.294 ms  2.683 ms
 2  10.41.0.217 (10.41.0.217)  3.178 ms  1.846 ms  1.686 ms
 3  10.40.0.54 (10.40.0.54)  2.554 ms  2.033 ms  2.174 ms
 4  10.42.0.69 (10.42.0.69)  4.058 ms  2.905 ms  2.957 ms
 5  10.42.0.54 (10.42.0.54)  3.304 ms  3.058 ms *
 6  10.42.0.20 (10.42.0.20)  3.205 ms  3.199 ms  3.086 ms
 7  14.17.22.130 (14.17.22.130)  4.497 ms  3.424 ms  3.195 ms
 8  10.162.89.97 (10.162.89.97)  10.903 ms  4.797 ms  4.136 ms
 9  10.200.52.57 (10.200.52.57)  5.684 ms
    10.200.52.65 (10.200.52.65)  5.288 ms
    10.200.52.73 (10.200.52.73)  7.327 ms
10  * * *
11  * * *

在 mac/linux/windows 下都有类似的工具。因为在 https://github.com/joyme123/gnt 中实现了 traceroute 的能力,这里记录一下。 traceroute 的实现中,利用了一些基本的网络协议的特性:

  • IP (网络层)数据包在网络中传输时,可以通过 TTL 字段来控制这个数据包的生命周期。每经过一个三层设备的转发,这个 TTL 都会减少1,当 TTL 变为 0 时,三层设备就会丢弃这个数据包而不是继续转发它。这样的设计可以避免网络链路形成环时,数据包会被无限的转发。
  • 中间的三层设备在丢弃数据包时,会使用 ICMP 协议,向数据包的源发送方(通过 src ip)发送 ICMP 包,来告知数据包因为 TTL 为 0 而被丢弃。当然有的三层设备不会发送这个 ICMP 消息,所以 traceroute 时部分中间环节会显示 *。并且这种 ICMP 包的 payload 都会包含源数据包的二层和三层 header。
  • traceroute 支持多种协议: TCP、UDP 和 ICMP。当 TCP, UDP 的包到达目标设备后,如果 TCP, UDP 的目的端口不能访问,那么目标设备也会通过 ICMP 消息向源设备告知该端口不可达。如果是使用 ICMP echo request,那么目标设备会返回 ICMP echo reply 向源设备告知收到了 echo request。

有了以上的网络协议特性,traceroute 的实现可行性就有了。以使用 TCP 协议为例,具体的步骤如下:

  1. traceroute 构造 TCP 数据,源端口根据当前进程 ID 生成,目的端口选择一个不太可能使用的端口,比如 33434。
    1. 源端口根据当前进程 ID 生成,这样收到中间网络设备的 ICMP 包时,就可以通过 payload 中携带的源数据包信息判断出这个 ICMP 是响应哪个进程的。
    2. 目的端口选择一个不常用端口,是防止对目的端的 TCP 服务产生影响。并且这个目的端口每次请求都会加1。
  2. traceroute 构建 IP 头,TTL 一开始设置为 1。这样第一个中间设备收到后,就会丢弃这个包,并返回 ICMP 包了。后续 TTL 逐渐加 1,就可以探测到每一个中间设备了。
  3. 当 traceroute 收到 ICMP 包时,先根据源端口判断这个包属不属于当前进程,再根据目的端口判断这个包是第几个发出的。比如目的端口是 33436,那么已知起始目的端口是 33034 的情况下,就知道这个包是第三个发出的。这样根据第三个包发出的时间,就知道延迟情况了。
    1. 如果这个 ICMP 包是 ttl exceeded,说明中间网络设备返回的。
    2. 如果这个 ICMP 包是 destination unreachable,说明是目的设备返回的。
分类:网络

Ping 与 ICMP 协议

概述

ping 命令是一个非常常用的网络工具,通过 ICMP 协议来探测本地到远端地址之间网络的连通性,以及延迟,稳定等性能指标。但是大多数人其实对 ping 命令的实现了解的并不会太多,因为我们日常的开发工作中,很少会和 ICMP 协议打交道。因为最近在开发 https://github.com/joyme123/gnt,目标是通过单个二进制文件,实现大多数的网络工具的能力。所以接触了一些 ping 命令的实现,这里做一些简单的记录和分享。

ping 命令基于 ICMP 协议的实现

ICMP 协议本身这里不多做介绍,网络上有很多很好很详细的资料。比如维基百科上的这篇介绍:Internet_Control_Message_Protocol。 ICMP 和 TCP/UDP 这样的协议有这很大的区别,像 TCP/UDP 这种传输层的协议,都是进程级别的,即可以通过 Port 对应到一个或多个进程。因此在运行使用 TCP/UDP 协议的应用时不需要特殊的权限。而 ICMP 则不一样,它是操作系统级别的,因此早期的 linux 上,如果应用要发送 ICMP 包,则必须通过 socket(AF_INET, SOCK_RAW, int protocol) 这种方式来实现,而调用 SOCK_RAW 则需要 root 权限,或者通过 linux network capability。 因为这种特殊性,后来的 linux 又提供了 socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) 这种方式去发送 ICMP,这里的 SOCK_DGRAM 是 UDP 协议使用的。不过容易误解的是,并不是说用 UDP 协议去包装或实现了 ICMP 的能力,使用这种方式发送出去的仍然是 ICMP 数据包,不过不再需要 root 权限或者特殊的 network capability 设置了。通常称这种方式为 Unprivileged ICMP。linux 同样也提供了 sysctl 的配置去限制这一能力的使用

# 999~59999 指定了允许的用户组 ID 范围,如果所有用户组都不允许可以设置为 1 0
net.ipv4.ping_group_range = 999 59999

不过需要注意的是,通过 SOCK_DGRAM 只能发送这几种 ICMP 请求:ICMP_ECHO, ICMP_TSTAMP or ICMP_MASKREQ. 解决了如何发送 icmp 包的问题,就可以考虑实现几个主要的 ping 命令特性了。

丢包检测

每个 ICMP 包都有一个 sequence 字段,发送的时候可以指定这个 sequence 的值,目的端响应的时候会把这个 sequence 值设置成一样的,表示响应的是哪一个请求包。这样我们就可以知道每个发送出去的 ICMP 的响应包了,那么没有响应的 sequence 就是被丢弃的包。通过这种方式就可以检测出网络中使用存在丢包现象。

延迟检测

ICMP 支持 echo request 和 reply,即通过 ICMP 协议包装的 payload 发送出去,目的端会原样返回。所以我们可以通过在 request 的时候写入发送时的时间,然后收到回包时取出这个时间就能知道延迟了。 关于写入的时间格式,实现方式上可以随意。但是建议使用 unix time,精确到微秒。然后保留 4 字节的秒+4字节的微秒,写入到 payload 的开头。这样的实现方式和 linux ping 的实现一致,像 wireshark 这种抓包工具就可以识别出来了。

分类:网络

k8s 1.24 ServiceAccount Token 的行为变化

起因

有一个 CNI 组件以 DaemonSet 的方式运行在所有的 node 上,这个 CNI Pod 会将自己的 Service Account Token 转换成 kubeconfig 并存储到主机的目录下。当 kubelet 调用 cni 插件时,cni 插件会使用这个 kubeconfig 去获取集群 Pod 的一些信息。 在 k8s 1.24 上出现了问题,当 CNI Pod 重启后,使用生成的 kubeconfig 就会返回 Unauthorized 的错误,即这个 token 已经过不了 APIServer 的认证了。

原因

k8s 1.24 上,ServiceAccount(下文缩写为 SA) 的 token 生成逻辑已经发生了变化,不再会自动为 SA 生成 token 并保存到 secret 中,Pod 中使用 token 时也不会再挂载这个 secret。当 Pod 使用 SA 时,默认行为如下:

  1. Pod 创建出来后,在 admission 阶段,有一个 serviceaccount admission 会为 Pod 挂载 token,路径同样还是在 /var/run/secrets/kubernetes.io/serviceaccount 下。但是 volume 字段不再是通过 secret,而是通过 projected。
projected:
  defaultMode: 420
  sources:
    # source 类型是 serviceAccountToken
  - 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
  1. Pod 调度到 Node 上后,kubelet 中的 projected volume mounter 会根据 volumesMount 中的 volume 类型,为 Pod 挂载对应的文件。当发现存在 ServiceAccountToken 类型的 projected source 时,就会调用 apiserver 的 TokenRequest 接口,为当前 Pod 请求临时的 Token。并且这个 token 的有效期只有 3607s。kubelet 会自动刷新这个 token 来保证它不会过期。

    case source.ServiceAccountToken != nil:
                tp := source.ServiceAccountToken
    
                // When FsGroup is set, we depend on SetVolumeOwnership to
                // change from 0600 to 0640.
                mode := *s.source.DefaultMode
                if mounterArgs.FsUser != nil || mounterArgs.FsGroup != nil {
                    mode = 0600
                }
    
                var auds []string
                if len(tp.Audience) != 0 {
                    auds = []string{tp.Audience}
                }
                tr, err := s.plugin.getServiceAccountToken(s.pod.Namespace, s.pod.Spec.ServiceAccountName, &authenticationv1.TokenRequest{
                    Spec: authenticationv1.TokenRequestSpec{
                        Audiences:         auds,
                        ExpirationSeconds: tp.ExpirationSeconds,
                        BoundObjectRef: &authenticationv1.BoundObjectReference{
                            APIVersion: "v1",
                            Kind:       "Pod",
                            Name:       s.pod.Name,
                            UID:        s.pod.UID,
                        },
                    },
                })
                if err != nil {
                    errlist = append(errlist, err)
                    continue
                }
                payload[tp.Path] = volumeutil.FileProjection{
                    Data:   []byte(tr.Status.Token),
                    Mode:   mode,
                    FsUser: mounterArgs.FsUser,
                }

    这样带来的好处就是 service account 默认不再会有永久性 token,而是每个 Pod 有一个临时的 token,这个 token 默认有效期是 3607s,由 kubelet 自动刷新。并且当 Pod 删除后,该 token 也会自动失效。这在安全性上带来了很大的提升。

    解决

    为了和之前组件的行为保持一致,需要保证这个 token 是永久有效的。最简单的解决办法就是手动创建 service account 的 token secret。例如:

    apiVersion: v1
    kind: Secret
    # 表示这个 secret 类型
    type: kubernetes.io/service-account-token
    metadata:
      name: mycontroller
      namespace: kube-system
      annotations:
        # service account 名称
        kubernetes.io/service-account.name: "mycontroller"

    k8s 的 tokens-controller 在 watch 到该 secret 时,会发现 ca, namespace, token 字段均为空,因此会自动为该 secret 填充这些字段。这样我们就获得了永久性的 token,并使用该 token 生成 kubeconfig 了。

    func (e *TokensController) secretUpdateNeeded(secret *v1.Secret) (bool, bool, bool) {
        caData := secret.Data[v1.ServiceAccountRootCAKey]
        needsCA := len(e.rootCA) > 0 && !bytes.Equal(caData, e.rootCA)
    
        needsNamespace := len(secret.Data[v1.ServiceAccountNamespaceKey]) == 0
    
        tokenData := secret.Data[v1.ServiceAccountTokenKey]
        needsToken := len(tokenData) == 0
    
        return needsCA, needsNamespace, needsToken
    }

Token 是如何做身份认证的

service account token 在不同版本下的行为不同,那么 token 本身又是如何做身份认证的呢? token 是一个符合 JWT 规范的字符串。 对于永久性 token 来说,其中保存了 service account 的信息。

{
  "iss": "kubernetes/serviceaccount",
  "kubernetes.io/serviceaccount/namespace": "kube-system",
  "kubernetes.io/serviceaccount/secret.name": "mycontroller",
  "kubernetes.io/serviceaccount/service-account.name": "mycontroller",
  "kubernetes.io/serviceaccount/service-account.uid": "2f0ab840-064c-4168-b9b2-932c361e13d6",
  "sub": "system:serviceaccount:kube-system:mycontroller"
}

apiserver 在获取到这个 token 后,根据 JWT 的规范对内容进行完整性校验。校验通过后就根据 token 中 service account 进行认证鉴权了。 对于临时性(pod) token 来说,内容就稍有不同了。

{
  "aud": [
    "https://kubernetes.default.svc.cluster.local"
  ],
  "exp": 1705344168,
  "iat": 1673808168,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "kubernetes.io": {
    "namespace": "kube-system",
    "pod": {
      "name": "mycontroller-lr99n",
      "uid": "f8a3c6c7-c41c-4a33-9329-f40d208a03e6"
    },
    "serviceaccount": {
      "name": "mycontroller",
      "uid": "2f0ab840-064c-4168-b9b2-932c361e13d6"
    },
    "warnafter": 1673811775
  },
  "nbf": 1673808168,
  "sub": "system:serviceaccount:kube-system:mycontroller"
}

可以看到 token 中除了 service account 的信息,还有 pod 的信息。这样 token 的有效期是由 pod 的生命周期以及 nbf, exp 来确定了。nbf 代表 Not valid before,exp 代表 Expiration time,都是使用 unix time 来保存的。并且在 pod 删除后,token 就自动失效了。同时鉴权还是使用 service account 进行。

分类:k8s标签:#k8s #service account

OpenFlow 的流表匹配规则

OpenFlow 里有一个重要的概念—流表(FlowTable),通过 Flow Table,我们可以制定交换机处理流量的行为。因此,理解流表匹配规则,是理解 OpenFlow 的重要一环。 OpenvSwitch(下文称为OVS) 是一个开源的软件交换机的实现,同时也是支持 OpenFlow 的,因此下文也会通过 OVS 来说明流表是如何匹配的。

流表匹配规则

流表项

在一个 OpenFlow 的网络中,每一个支持 OpenFlow 的交换机都必须包含至少 1 个流表(table 0),这个流表里会包含 0 或多个流表项。这些流表项描述了流量的匹配规则,计数器以及如果针对这些流量做出动作。 一个流表项通常由以下元素组成

字段 描述
Match Fileds 用来匹配数据包
Priority 匹配流表项的优先级。如果一个数据包被多个流表项匹配到,则会根据优先级进行选择
Counters 当数据包被匹配成功时会更新该字段,主要用来流量统计
Instructions 用来修改 Actions 或 流水线处理
Timeouts 流表项的超时时间
Cookie 一些不透明的数据值。控制器可以使用它来过滤流量统计,流量修改和流量删除。在处理数据包时不使用这些数据值

流水线处理

流表在处理时,就像流水线一下,每个 table 都是一个处理阶段。在每一个 table 里,数据包都可能被:

  • 丢弃。
  • 转发给下一个 table
  • 发送给 controller

/uploads/wp/2023/01/openflow-flow.webp 如上图所示,数据包通过 port 进入交换机后,首先会匹配 table 0 中的流表项。如果流表项匹配到了,则会根据该流表项设定的 actions 进行操作。如果未匹配到,则会被丢弃。 数据包在不同的 table 中流转时,只能按照升序进行,也就是说可以从 table 0 → table10,但是不能从 table10 → table0。并且需要注意的是,在流表之间跳转需要使用 goto_table 或 resubmit 语句来将数据包 copy 一份转发到其他的流表中处理。如果没有指定跳转动作,是不会继续在其他 table 中进行匹配的。 /uploads/wp/2023/01/openflow-flow-det.webp

OVS 操作演示

为了加深对 OpenFlow 的理解,可以使用 OVS 提供的一个例子进行练习。这个例子中,使用 OVS 的流表实现了交换机二层 Mac 地址的学习,以及 VLAN 的支持。

环境准备

和 ovs 官网不同的是,这里我使用 mininet 快速创建一个实验环境。

sudo mn --topo=single,4 --mac --controller=none

这里创建了一个名为 s1 的交换机和 4 台连接到交换机上的主机 h1, h2, h3, h4,通过 port1, port2, port3, port4 连接。port1 trunk 所有的 vlan,port2 access vlan 20, port3, port3 access vlan 30。 /uploads/wp/2023/01/ovs.drawio-1.png

table 0: 准入控制

table 0 是数据包进入交换机的起始位置。我们在这一步去禁止某些数据包的进入。比如:以多播源地址的数据包是非法的,所以我们可以在这里丢弃掉。

sh ovs-ofctl add-flow s1 "table=0, dl_src=01:00:00:00:00:00/01:00:00:00:00:00, actions=drop"

交换机也不应该转发 IEEE 802.1D Spanning Tree Protocol(STP) 数据包,所以我们在可以通过流表项来丢弃掉。

sh ovs-ofctl add-flow s1 "table=0, dl_dst=01:80:c2:00:00:00/ff:ff:ff:ff:ff:f0, actions=drop"

对于其他合法的数据包,我们都提交到流水线的下一阶段(table1) 中去处理。这里我们使用最低的优先级来做兜底

sh ovs-ofctl add-flow s1 "table=0, priority=0, actions=resubmit(,1)"

测试 table 0

这里因为有一些数据包不方便构造去做实际的测试,所以使用 ofproto/trace 去模拟数据库的匹配。 例子1

sh ovs-appctl ofproto/trace s1 in_port=1,dl_dst=01:80:c2:00:00:05

会出现以下输出

Flow: in_port=1,vlan_tci=0x0000,dl_src=00:00:00:00:00:00,dl_dst=01:80:c2:00:00:05,dl_type=0x0000

bridge("s1")
------------
 0. dl_dst=01:80:c2:00:00:00/ff:ff:ff:ff:ff:f0, priority 32768
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=1,dl_src=00:00:00:00:00:00/01:00:00:00:00:00,dl_dst=01:80:c2:00:00:00/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

可以看到以下关键信息:

  1. 数据包匹配到了 dl_dst=01:80:c2:00:00:00/ff:ff:ff:ff:ff:f0, priority 32768 ,根据 action 需要被 drop 掉
  2. Final flow 为 unchanged ,说明数据包本身没有被修改。

例子2

sh ovs-appctl ofproto/trace s1 in_port=1,dl_dst=01:80:c2:00:00:10

出现以下输出

Flow: in_port=1,vlan_tci=0x0000,dl_src=00:00:00:00:00:00,dl_dst=01:80:c2:00:00:10,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. No match.
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=1,dl_src=00:00:00:00:00:00/01:00:00:00:00:00,dl_dst=01:80:c2:00:00:10/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

可以看出数据包被 resubmit 到 table 1 了,但是因为 table 1 中没有任何流表项,所以被 drop 了。

table 1: VLAN 进入数据包处理

进入 table 1 的数据包,都是在 table 0 中被校验有效的数据包。table 1 被设计用来校验数据包的 VLAN,这个校验是基于数据包进入交换机经过的 Port 配置。同时我们也会在这些进入 access port 的数据包 header 里添加 VLAN tag,来保证后续的处理都可以基于这些 VLAN tag 进行。 首先我们添加一个默认 drop 的规则

sh ovs-ofctl add-flow s1 "table=1, priority=0, actions=drop"

对于 trunk port 1,可以接收任意数据包,无论数据包是否有 VLAN header 或者这个 VLAN tag 是多少。所以可以添加一个流表项,将进入 port1 的所有数据包 resubmit 到 table 2 中。

sh ovs-ofctl add-flow s1 "table=1, priority=99, in_port=1, actions=resubmit(,2)"

在 access port 上,只接收没有 VLAN header 的数据包,然后对数据包打上 VLAN tag,然后再提交到下一阶段(table2) 去处理。

sh ovs-ofctl add-flow s1 "table=1, priority=99, in_port=2, vlan_tci=0, actions=mod_vlan_vid:20, resubmit(,2)"
sh ovs-ofctl add-flow s1 "table=1, priority=99, in_port=3, vlan_tci=0, actions=mod_vlan_vid:30, resubmit(,2)"
sh ovs-ofctl add-flow s1 "table=1, priority=99, in_port=4, vlan_tci=0, actions=mod_vlan_vid:30, resubmit(,2)"

测试 table 1

例子1:数据包进入 trunk port 数据包进入 trunk port 1

sh ovs-appctl ofproto/trace s1 in_port=1,vlan_tci=5

得到以下输出,数据包在 table 0 中 resubmit 到 table 1,再到 table 2 后没有规则,被默认丢弃

Flow: in_port=1,vlan_tci=0x0005,vlan_tci1=0x0000,dl_src=00:00:00:00:00:00,dl_dst=00:00:00:00:00:00,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=1, priority 99
    resubmit(,2)
 2. No match.
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=1,dl_src=00:00:00:00:00:00/01:00:00:00:00:00,dl_dst=00:00:00:00:00:00/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

例子2:有效的数据包进入 access port 一个没有 802.1Q header 的数据包进入 port 2

sh ovs-appctl ofproto/trace s1 in_port=2

得到以下输出,数据包在 table 0 中 resubmit 到 table 1,再到 table 2 后没有规则,被默认丢弃i

Flow: in_port=2,vlan_tci=0x0000,dl_src=00:00:00:00:00:00,dl_dst=00:00:00:00:00:00,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=2,vlan_tci=0x0000, priority 99
    mod_vlan_vid:20
    resubmit(,2)
 2. No match.
    drop

Final flow: in_port=2,dl_vlan=20,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=00:00:00:00:00:00,dl_dst=00:00:00:00:00:00,dl_type=0x0000
Megaflow: recirc_id=0,eth,in_port=2,dl_src=00:00:00:00:00:00/01:00:00:00:00:00,dl_dst=00:00:00:00:00:00/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

例子3: 无效的数据包进入 access port 一个有 802.1Q header 的数据包进入 port 2

sh ovs-appctl ofproto/trace s1 in_port=2,vlan_tci=5

得到以下输出,在 table 1 中被 drop 了。

Flow: in_port=2,vlan_tci=0x0005,vlan_tci1=0x0000,dl_src=00:00:00:00:00:00,dl_dst=00:00:00:00:00:00,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. priority 0
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=2,vlan_tci=0x0005,dl_src=00:00:00:00:00:00/01:00:00:00:00:00,dl_dst=00:00:00:00:00:00/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

table 2: 进入 port 后学习 MAC+VLAN

table 2允许我们实现的交换机学习数据包的 source mac。只需要一个流表项

sh ovs-ofctl add-flow s1 "table=2 actions=learn(table=10, NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[], load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15]),resubmit(,3)"

learn 这个 action 会基于正在处理的数据包,动态的修改流表。对于 learn 的几个字段说明如下:

table=10

    Modify flow table 10.  This will be the MAC learning table.

NXM_OF_VLAN_TCI[0..11]

    Make the flow that we add to flow table 10 match the same VLAN
    ID that the packet we're currently processing contains.  This
    effectively scopes the MAC learning entry to a single VLAN,
    which is the ordinary behavior for a VLAN-aware switch.

NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[]

    Make the flow that we add to flow table 10 match, as Ethernet
    destination, the Ethernet source address of the packet we're
    currently processing.

load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15]

    Whereas the preceding parts specify fields for the new flow to
    match, this specifies an action for the flow to take when it
    matches.  The action is for the flow to load the ingress port
    number of the current packet into register 0 (a special field
    that is an Open vSwitch extension to OpenFlow).

测试 table 2

例子1

sh ovs-appctl ofproto/trace s1 in_port=1,vlan_tci=20,dl_src=50:00:00:00:00:01 -generate

得到以下输出。上面的命令中使用了 -generate,是为了让数据包真实的在 OVS 中生效,不指定的话,OVS 不会真实的生成 table 10 中的流表项。

Flow: in_port=1,vlan_tci=0x0014,vlan_tci1=0x0000,dl_src=50:00:00:00:00:01,dl_dst=00:00:00:00:00:00,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=1, priority 99
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     -> table=10 vlan_tci=0x0014/0x0fff,dl_dst=50:00:00:00:00:01 priority=32768 actions=load:0x1->NXM_NX_REG0[0..15]
    resubmit(,3)
 3. No match.
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=1,vlan_tci=0x0014/0x1fff,dl_src=50:00:00:00:00:01,dl_dst=00:00:00:00:00:00/ff:ff:ff:ff:ff:f0,dl_type=0x0000
Datapath actions: drop

在 table 10 中确认是否有新的流表项生成

sh ovs-ofctl dump-flows s1 table=10

cookie=0x0, duration=142.170s, table=10, n_packets=0, n_bytes=0, vlan_tci=0x0014/0x0fff,dl_dst=50:00:00:00:00:01 actions=load:0x1->NXM_NX_REG0[0..15]

例子2

sh ovs-appctl ofproto/trace s1 in_port=2,dl_src=50:00:00:00:00:01 -generate

cookie=0x0, duration=193.493s, table=10, n_packets=0, n_bytes=0, vlan_tci=0x0014/0x0fff,dl_dst=50:00:00:00:00:01 actions=load:0x2->NXM_NX_REG0[0..15]

可以看到,在例子 1 中 table 10 中,在 port 1 上学习到了 mac 地址 50:00:00:00:00:01,现在 port 2 中也出现了该 mac 地址,所以这个 mac 地址更新到了 port 2 上。

table 3: 查找目标 Port

这个 table 实现了如何通过 MAC 和 VLAN 查找到目标的 output port。通过以下流表项来实现查找

sh ovs-ofctl add-flow s1 "table=3 priority=50 actions=resubmit(,10), resubmit(,4)"

这个流表项首先将数据包提交到 table 10 中。table 10 中存储了学习到的 mac 地址。如果这个 mac 地址没有被学习过,则 table 10 中不会被匹配。那么就会被 resubmit 到 table 4 中继续处理。

测试 table 3

下面的命令会让 OVS 学习到 port 1上 VLAN 20 的 mac 地址 f0:00:00:00:00:01

sh ovs-appctl ofproto/trace s1 in_port=1,dl_vlan=20,dl_src=f0:00:00:00:00:01,dl_dst=90:00:00:00:00:01 -generate

得到以下输出,数据包在 table 10 中没有匹配到,所以被 resubmit 到 table 4.

Flow: in_port=1,dl_vlan=20,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=f0:00:00:00:00:01,dl_dst=90:00:00:00:00:01,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=1, priority 99
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     -> table=10 vlan_tci=0x0014/0x0fff,dl_dst=f0:00:00:00:00:01 priority=32768 actions=load:0x1->NXM_NX_REG0[0..15]
    resubmit(,3)
 3. priority 50
    resubmit(,10)
    10. No match.
            drop
    resubmit(,4)
 4. No match.
    drop

Final flow: unchanged
Megaflow: recirc_id=0,eth,in_port=1,dl_vlan=20,dl_src=f0:00:00:00:00:01,dl_dst=90:00:00:00:00:01,dl_type=0x0000
Datapath actions: drop

可以通过以下两种方式验证 port 1 上学习到的 mac 地址: 方法一:

sh ovs-ofctl dump-flows s1 table=10

cookie=0x0, duration=107.451s, table=10, n_packets=0, n_bytes=0, vlan_tci=0x0014/0x0fff,dl_dst=f0:00:00:00:00:01 actions=load:0x1->NXM_NX_REG0[0..15]

方法二:

sh ovs-appctl ofproto/trace s1 in_port=2,dl_src=90:00:00:00:00:01,dl_dst=f0:00:00:00:00:01 -generate

Flow: in_port=2,vlan_tci=0x0000,dl_src=90:00:00:00:00:01,dl_dst=f0:00:00:00:00:01,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=2,vlan_tci=0x0000, priority 99
    mod_vlan_vid:20
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     -> table=10 vlan_tci=0x0014/0x0fff,dl_dst=90:00:00:00:00:01 priority=32768 actions=load:0x2->NXM_NX_REG0[0..15]
    resubmit(,3)
 3. priority 50
    resubmit(,10)
    10. vlan_tci=0x0014/0x0fff,dl_dst=f0:00:00:00:00:01, priority 32768
            load:0x1->NXM_NX_REG0[0..15]
    resubmit(,4)
 4. No match.
    drop

Final flow: reg0=0x1,in_port=2,dl_vlan=20,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=90:00:00:00:00:01,dl_dst=f0:00:00:00:00:01,dl_type=0x0000
Megaflow: recirc_id=0,eth,in_port=2,dl_src=90:00:00:00:00:01,dl_dst=f0:00:00:00:00:01,dl_type=0x0000
Datapath actions: drop

可以看到在 table 10 中匹配到了流表项,学习到了 load:0x1->NXM_NX_REG0[0..15]

table 4: 数据包输出处理

在 table 4 中,我们知道 register 0 包含了需要的 output port,如果该 output port 是 0,则说明需要将数据包 flood。我们也知道数据包的 VLAN 在它的 802.1Q header 上。

sh ovs-ofctl add-flow s1 "table=4 reg0=1 actions=1"

对于要 output 的 port,还需要把 VLAN header 移除掉。

sh ovs-ofctl add-flow s1 "table=4 reg0=2 actions=strip_vlan,2"
sh ovs-ofctl add-flow s1 "table=4 reg0=3 actions=strip_vlan,3"
sh ovs-ofctl add-flow s1 "table=4 reg0=4 actions=strip_vlan,4"

flood 广播或多播包

sh ovs-ofctl add-flow s1 "table=4 reg0=0 priority=99 dl_vlan=20 actions=1,strip_vlan,2"
sh ovs-ofctl add-flow s1 "table=4 reg0=0 priority=99 dl_vlan=30 actions=1,strip_vlan,3,4"
sh ovs-ofctl add-flow s1 "table=4 reg0=0 priority=50            actions=1"

测试 table 4

例子1:广播,多播以及未知目的地址 测试在 port 1 上进入广播包,VLAN 是 30

sh ovs-appctl ofproto/trace s1 in_port=1,dl_dst=ff:ff:ff:ff:ff:ff,dl_vlan=30

Flow: in_port=1,dl_vlan=30,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=00:00:00:00:00:00,dl_dst=ff:ff:ff:ff:ff:ff,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=1, priority 99
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     >> suppressing side effects, so learn action ignored
    resubmit(,3)
 3. priority 50
    resubmit(,10)
    10. No match.
            drop
    resubmit(,4)
 4. reg0=0,dl_vlan=30, priority 99
    output:1
     >> skipping output to input port
    strip_vlan
    output:3
    output:4

Final flow: in_port=1,vlan_tci=0x0000,dl_src=00:00:00:00:00:00,dl_dst=ff:ff:ff:ff:ff:ff,dl_type=0x0000
Megaflow: recirc_id=0,eth,in_port=1,dl_vlan=30,dl_vlan_pcp=0,dl_src=00:00:00:00:00:00,dl_dst=ff:ff:ff:ff:ff:ff,dl_type=0x0000
Datapath actions: pop_vlan,4,3

可以看到 Datapath actions: pop_vlan,4,3,最终数据包被移除 vlan,从 port3,4出去了。 而下面的广播包都会被 drop,因为 VLAN 必须属于 input port

sh ovs-appctl ofproto/trace s1 in_port=1,dl_dst=ff:ff:ff:ff:ff:ff
sh ovs-appctl ofproto/trace s1 in_port=1,dl_dst=ff:ff:ff:ff:ff:ff,dl_vlan=55

例子2: MAC 学习 VLAN 30, port 1 学习 MAC 地址:

sh ovs-appctl ofproto/trace s1 in_port=1,dl_vlan=30,dl_src=10:00:00:00:00:01,dl_dst=20:00:00:00:00:01 -generate

Flow: in_port=1,dl_vlan=30,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=10:00:00:00:00:01,dl_dst=20:00:00:00:00:01,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=1, priority 99
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     -> table=10 vlan_tci=0x001e/0x0fff,dl_dst=10:00:00:00:00:01 priority=32768 actions=load:0x1->NXM_NX_REG0[0..15]
    resubmit(,3)
 3. priority 50
    resubmit(,10)
    10. No match.
            drop
    resubmit(,4)
 4. reg0=0,dl_vlan=30, priority 99
    output:1
     >> skipping output to input port
    strip_vlan
    output:3
    output:4

Final flow: in_port=1,vlan_tci=0x0000,dl_src=10:00:00:00:00:01,dl_dst=20:00:00:00:00:01,dl_type=0x0000
Megaflow: recirc_id=0,eth,in_port=1,dl_vlan=30,dl_vlan_pcp=0,dl_src=10:00:00:00:00:01,dl_dst=20:00:00:00:00:01,dl_type=0x0000
Datapath actions: pop_vlan,4,3

因为目的地址是未知的,所以数据包被 flood 到 port3,port4 上。然后我们再次测试 MAC 地址是否学习到了。

sh ovs-appctl ofproto/trace s1 in_port=4,dl_src=20:00:00:00:00:01,dl_dst=10:00:00:00:00:01 -generate

Flow: in_port=4,vlan_tci=0x0000,dl_src=20:00:00:00:00:01,dl_dst=10:00:00:00:00:01,dl_type=0x0000

bridge("s1")
------------
 0. priority 0
    resubmit(,1)
 1. in_port=4,vlan_tci=0x0000, priority 99
    mod_vlan_vid:30
    resubmit(,2)
 2. priority 32768
    learn(table=10,NXM_OF_VLAN_TCI[0..11],NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],load:NXM_OF_IN_PORT[]->NXM_NX_REG0[0..15])
     -> table=10 vlan_tci=0x001e/0x0fff,dl_dst=20:00:00:00:00:01 priority=32768 actions=load:0x4->NXM_NX_REG0[0..15]
    resubmit(,3)
 3. priority 50
    resubmit(,10)
    10. vlan_tci=0x001e/0x0fff,dl_dst=10:00:00:00:00:01, priority 32768
            load:0x1->NXM_NX_REG0[0..15]
    resubmit(,4)
 4. reg0=0x1, priority 32768
    output:1

Final flow: reg0=0x1,in_port=4,dl_vlan=30,dl_vlan_pcp=0,vlan_tci1=0x0000,dl_src=20:00:00:00:00:01,dl_dst=10:00:00:00:00:01,dl_type=0x0000
Megaflow: recirc_id=0,eth,in_port=4,dl_src=20:00:00:00:00:01,dl_dst=10:00:00:00:00:01,dl_type=0x0000
Datapath actions: push_vlan(vid=30,pcp=0),2
分类:网络标签:#ovs

linux 网络问题排查手册

这里主要记录一些在实际排查网络问题过程中,觉得非常好用的工具或方法。大多数和 k8s 的网络问题相关

1. iptables 处理规则流程图

出处:https://www.zsythink.net/archives/1199 /uploads/wp/2022/12/screenshot-20221210-113850.png

2. 使用 xtables-monitor 追踪数据包在 iptables 规则中的流向

这种场景常用在宿主机上有比较复杂的 iptables 规则,导致在排查问题时比较难发现。比如 k8s 中 pod 访问 svc ip 不通时。 首先在数据包必经的地方添加 TRACE,比如 raw 表的 PREROUTING 就是个好地方。

iptables -t raw -A PREROUTING -p tcp --dport 5432 -j TRACE

添加 TRACE 之后,就可以追踪该数据包后续的流向了。通过 trace 的信息,可以看到数据库的流入流出网卡,mark 值,匹配的 iptables 规则等等。非常容易帮我们判断出问题

xtables-monitor --trace

3. 使用 conntrack 查看连接的生命周期

conntrack 是 linux 网络协议栈提供的能力,顾名思义,conntrack 提供的是连接追踪的能力。这里的连接并不是 tcp 的连接,而且传输层的 5 元组(源IP,源端口,目的IP,目的端口,协议)来唯一确定的连接。像 NAT 之类的功能,都是基于 conntrack 之上的。

# 比如这条命令就可以看 10.233.101.170 这个 pod 访问 svc ip 建立的连接请。
conntrack -p tcp -s 10.233.101.170 -L -o ktimestamp

4. no route to host 怎么解决

这个问题相信大多数人都遇到过,之所以要单独写在这里,是因为遇到过一些小众场景非常难排查。 大多数情况下,这个问题可以通过检查下面几项:

  1. 检查机器上的路由表,是否有通往目的 IP 的路由。
  2. 确认网关配置是否正确,网关路由是否正常。
  3. 抓包检查,这里我们可以选择抓 ARP 包。如:tcpdump -i any arp and host xx.xxx.xx.xx -ne。这种方式看似没什么用,但是非常好用,特别是在网络配置非常复杂(多网卡,多网络平面的 k8s 集群)时。有的时候很难准确判断出出去的数据包命中了什么样的路由。这种情况下,我们可以通过 arp 请求的网关 IP,来判断出路由是哪条,且网关是否正常连通。

5. 路由判断理解是否正确

通过第一点中提供的图,我们可以发现,路由判断只在两个地方发生:

  1. PREROUTING 之后
  2. OUTPUT 之前

也就是说,当数据包通过了这两个地方之后,路由就已经确定了。在这之后做任何数据包的修改都不会再影响路由判断。 实际场景:使用 ip rule 可以创建一些策略路由,比如来自于 10.100.0.0/24 的数据包,mark 值为 0x06 时走什么路由策略。当在 POSTROUTING 链上做 SNAT,set mark 的时候,是不会影响路由的。

6. iptables 与 LVS

/uploads/wp/2022/12/nf-lvs.png 正常情况下,LVS nat 模式的流量不能做 SNAT,因为不会经过 POSTROUTING 链。需要增加下的配置来让 LVS 数据包经过 POSTROUTING

net.ipv4.vs.conntrack=1

iptables 在 PREROUTING 阶段做的 mark,在 LVS NAT 后仍然有效。

7. 容器网络不通解决思路

这里的容器网络仅指 docker/nerdctl 部署的容器网络,k8s 情况会更复杂,也要结合 CNI 去看,这里不讨论。 实际使用中,常见的使用方式是部署容器时使用端口(以 443 为例)映射,将宿主机的 port 映射到容器的 port。一般的底层实现都是使用 iptables DNAT,将发到 443 端口的流量 DNAT 到容器的 ip:port。这样再根据本机的路由,将流量通过 docker0/nerdctl1 这样的网桥进行转发即可。 根据以上的背景,一般我们可以检查下面几个地方:

  1. 宿主机的 ip_forward 是否打开:sysctl -a | grep ip_forward。需要确认值为 1,这时候宿主机才会将不属于自己的流量进行 FORWARD。
  2. 宿主机的 bridge-nf-call-iptables 是否打开:sysctl -a | grep bridge。需要确认值为 1,这时候 bridge 设备上的流量就会经过 iptables conntrack。
  3. 检查 DNAT 规则。docker/nerdctl 一般都会在 iptables NAT 表上写这些规则。iptables -t nat -L PREROUTING
  4. 检查 FILTER 表。实际使用中遇到过在宿主机上同时使用 docker 和 nerdctl 的用户,docker 默认将 FILTER 表 FORWARD 链改为 DROP Policy,也就是不匹配 FORWARD 下规则的流量都会丢弃。而 nerdctl 则是默认 ACCEPT。这样 nerdctl 启动的容器网络就会不通。
分类:网络

通过 metrics-server 获取的 NodeMetrics 为何会不准确

背景描述

在使用 metrics-server 的 NodeMetrics 获取 node 的 CPU 使用量时,会稍微大于 node 的 CPU 核心数,导致计算剩余可用的 CPU 时出现了负数。此时 node 的 cpu 是 100% 满载,但是理论上无论如何也不会超过 CPU 总量。

问题分析

通过 metrics-server 获取 NodeMetrics 的链路如下:metrics-server → kubelet 10250 端口→ cadvisor。所以问题的本质还是在于 cadvisor 如何统计 CPU 使用量。 通过分析 cadvisor 的代码可以知道,cadvisor 会读取 cgroup 根目录的 cpuacct.usage 中的值,来获取当前累积使用的 CPU 时间。如下图代码所示。 cadvisor1 cpuacct.usage 的描述如下:

  • reports the total CPU time (in nanoseconds) consumed by all tasks in this cgroup (including tasks lower in the hierarchy).

cadvisor 会定期(每秒)对 node 进行采样,保存到 CpuUsage 中。 cadvisor2 当 metrics-server 需要获取当前的 CPU 使用量时,cadvisor 会统计最近 60s 内(即60个采样数据)的累积 CPU 使用时间,并除以总采样时间(ns),得到 CPU 使用量。 cadvisor3 因为 CPU 使用量的时间单位是精确到纳秒(ns)的,因此难免在计算上会有一定的误差,所以当 CPU 满载时出现统计出来的数据会稍大于 CPU 核心数也是正常情况了。

分类:k8s