调试 Kubernetes 里的服务有多烦?本地改一行代码,要走完 mvn packagedocker buildkind loadkubectl rollout restart 这一套,少则一两分钟,多则好几分钟。而且这还只是单服务,如果服务依赖同命名空间里的 MySQL、Redis 或者其他微服务,kubectl port-forward 也救不了你——端口转发不能同时暴露多个服务的网络环境,更没法让你的本地进程「假装」是 Pod 本身。

mirrord 解决的正是这个问题。这篇文章通过一个完整的 Demo 项目,带你从零走完「本地进程通过 mirrord 接入 k8s 集群调试」的完整流程。


问题:开发者与 K8s 之间的摩擦#

常见的几种调试方案,各有明显局限:

方案 痛点
kubectl port-forward 只能转发单个端口,进程本身感知不到集群的服务发现和环境变量
Skaffold / Tilt 热重载 仍然需要 build → load → rollout,只是自动化了步骤,没有消除延迟
在 Pod 内直接 exec 调试体验差,IDE 断点无法接入
远程 JVM debug + port-forward 可以断点,但进程还是跑在集群里,不是本地环境

理想状态是:本地用 IDE 正常启动应用,但进程的网络、环境变量、文件系统全都来自集群。 这就是 mirrord 的设计目标。


mirrord 是什么?它怎么工作?#

mirrord 是 MetalBear 开源的一个开发者工具(GitHub),核心思路是:

在本地进程旁边注入一个 agent,把进程的系统调用重定向到集群 Pod 上运行的 mirrord-agent 容器。

具体来说,mirrord 会拦截本地进程的:

  • 网络出站调用:进程发出的 TCP/UDP 连接透过 agent 走到集群内网,所以本地代码可以直接用 mysql.mirrord-demo.svc.cluster.local:3306 这类集群 DNS。
  • 网络入站流量:集群 Pod 收到的请求可以被镜像(mirror)或劫持(steal)到本地进程。
  • 环境变量:注入目标 Pod 的环境变量,本地进程和 Pod 看到的配置完全一致。
  • 文件系统(可选):本地读取某些路径时,实际读的是 Pod 里的文件。

整个过程不需要修改应用代码,也不需要重新打镜像。集群里的 Pod 继续运行,只是 mirrord 在它旁边悄悄塞了一个 agent sidecar(用后自动清理)。

本地 Java 进程
   │  (系统调用被 mirrord 拦截)
   │
mirrord-agent (本地侧)
   │  (加密隧道)
   ▼
mirrord-agent (Pod 旁的 sidecar)
   │
   ├──► 集群内网 DNS / MySQL / 其他服务
   └──► Pod 的入站流量

Demo 项目架构#

演示项目在 meirongdev/mirrord-demo,组成如下:

本地开发机
┌────────────────────────────────────────────────────┐
│                                                    │
│  make run-local                                    │
│  ┌──────────────────────────────┐                  │
│  │  本地 Spring Boot :8080      │◄── curl :8080    │
│  │                              │                  │
│  │  mirrord agent               │                  │
│  └──────────┬───────────────────┘                  │
│             │ 拦截 DB 连接 + steal 指定请求         │
└─────────────┼──────────────────────────────────────┘
              │
kind 集群     │
┌─────────────▼──────────────────────────────────────┐
│  namespace: mirrord-demo                           │
│                                                    │
│  ┌──────────────────┐    ┌────────────────────┐    │
│  │ mirrord-demo Pod │───►│ MySQL Pod :3306     │    │
│  │ NodePort 30080   │    └────────────────────┘    │
│  └─────────┬────────┘                              │
└────────────┼───────────────────────────────────────┘
             │ kind port mapping
curl :18080  ▼
127.0.0.1:18080

应用本身非常简单:一个 /api/messages/current 的 GET/POST 接口,消息存在 MySQL 里,响应里带一个 handledBy 字段,用来区分请求是被集群 Pod 还是本地进程处理的。

// GET /api/messages/current
{
  "message": "hello from cluster",
  "handledBy": "cluster"    // 或 "local"
}

快速上手:五个命令跑通全流程#

前置依赖:Docker、kind、kubectl、Java 21、Maven、mirrord

# 安装 mirrord(macOS)
brew install metalbear-co/mirrord/mirrord

# 克隆项目
git clone https://github.com/meirongdev/mirrord-demo.git
cd mirrord-demo

# 一键部署:建 kind 集群 → 编译 → 打镜像 → 部署 MySQL + App
make deploy

# 启动本地进程,接入集群(新终端窗口)
make run-local

make run-local 启动后,你会在日志里看到 Spring Boot 正常启动,而且它连上了集群里的 MySQL,不需要本地安装任何数据库。


集群侧需要准备什么?#

这个 Demo 看起来像“本地连集群”,但集群侧其实不需要做很多魔改。最小前提是:

  1. 能访问目标集群:本地 kubectl config current-context 指向目标集群,并且当前身份有权限访问 mirrord-demo namespace。
  2. 目标工作负载已经存在:这里的 target 是 deployment/mirrord-demo
  3. 依赖服务已经在集群里可用:这里是 mysql.mirrord-demo.svc.cluster.local:3306
  4. 请求要走真实集群网络路径:这个 Demo 用 kind 的宿主机端口映射 18080 -> 30080,再配合应用 Service 的 NodePort 30080,让流量按正常路径进入 Pod。
  5. 允许 mirrord 创建 agent:执行 mirrord exec 时,mirrord 会利用当前 kubeconfig 在目标 Pod 一侧拉起 mirrord-agent。所以至少需要允许它访问 Kubernetes API,并在目标 namespace 完成这一步。

也就是说,这个项目不需要你先在应用里接 SDK,也不需要额外改 Spring Boot 代码。只要集群里已经有目标 Deployment 和依赖服务,mirrord 就能把本地进程接进来。


make run-local 背后到底执行了什么?#

表面上你执行的是:

make run-local

实际展开后,脚本运行的是:

mirrord exec -f .mirrord/mirrord.json -- \
  java \
  -Ddemo.app-instance=local \
  -Dspring.datasource.url='jdbc:mysql://mysql.mirrord-demo.svc.cluster.local:3306/mirrord_demo?createDatabaseIfNotExist=true&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true' \
  -Dspring.datasource.username=demo \
  -Dspring.datasource.password=demo \
  -Dspring.sql.init.mode=never \
  -jar target/mirrord-demo-0.0.1-SNAPSHOT.jar

这几点很关键:

  • mirrord exec 不是去远端启动 JVM,而是在本地启动你的 Java 进程
  • -f .mirrord/mirrord.json 指定目标 workload 和流量规则。
  • -- 后面仍然是原始本地命令,所以 IDE、断点、热重启这些本地体验都保留着。

核心配置解析:.mirrord/mirrord.json#

{
  "target": {
    "namespace": "mirrord-demo",
    "path": "deployment/mirrord-demo"
  },
  "feature": {
    "network": {
      "incoming": {
        "mode": "steal",
        "http_filter": {
          "header_filter": "^x-mirrord-mode: steal$"
        }
      }
    }
  }
}

逐字段解释:

target#

"target": {
  "namespace": "mirrord-demo",
  "path": "deployment/mirrord-demo"
}

告诉 mirrord 要「附着」在哪个工作负载上。mirrord 会在这个 Deployment 的 Pod 旁边注入 agent,本地进程的出站网络流量和入站流量都经过这个 Pod。

path 支持 deployment/<name>pod/<name>rollout/<name> 等格式。

feature.network.incoming.mode#

mirrord 有两种处理入站流量的模式:

模式 行为
mirror 复制一份流量发给本地,Pod 同时处理原始请求(不影响线上)
steal 把匹配的流量完全劫持到本地,Pod 不再处理这些请求

Demo 使用 steal 模式,但配合了 http_filter 只劫持特定请求,其他流量继续由 Pod 处理。这是生产环境友好型配置的雏形。

http_filter.header_filter#

"header_filter": "^x-mirrord-mode: steal$"

正则表达式,匹配 HTTP 头。只有带 x-mirrord-mode: steal 这个 header 的请求才会被劫持到本地。普通请求继续走 Pod。

为什么用 header filter?

在多人共享集群或预发布环境中,header filter 让你可以在不影响其他人的情况下调试特定请求。流量通过 header 路由,而不是全量接管。

.mirrord/mirrord.json 要不要提交到 Git?#

这个 Demo 里,答案是

原因很简单:mirrord.json 不是某个人的本机临时状态,而是这个项目的共享运行配置,它定义了:

  • target 是哪个 Deployment
  • 入站流量是 steal 还是 mirror
  • 哪些请求会被 header filter 路由到本地

如果不提交,别人拉下仓库后就无法直接执行文档里的:

mirrord exec -f .mirrord/mirrord.json -- ...

更合理的做法是:

  • 提交:像 /.mirrord/mirrord.json 这种团队共享配置
  • 忽略:本机私有、临时、可能带凭据或环境差异的其他 mirrord 文件

steal vs mirror:怎么选?#

                 mirror 模式
请求 ──────────►  Pod (原始处理)
          └─────► 本地进程 (副本,不影响响应)

                 steal 模式(全量)
请求 ──────────►  本地进程 (Pod 不处理)

                 steal + http_filter 模式
带 header 请求 ──► 本地进程
普通请求 ──────►  Pod

mirror 适合:只想观察请求 / 响应,不需要本地进程的返回值影响调用方。比如新功能上线前在 staging 上用镜像流量跑本地代码做压测对比。

steal + filter 适合:需要让本地进程实际处理请求,比如开发新接口逻辑、调试请求处理 bug。加上 filter 可以做到精准路由,不打扰其他请求。


完整调试演练#

假设已经 make deploy 完成,再开一个终端 make run-local

1. 确认本地进程在接管请求#

curl http://127.0.0.1:8080/api/messages/current
# {"message":"hello from cluster","handledBy":"local"}

handledBy: local 说明这个请求被本地进程处理了。数据库里读出来的内容(hello from cluster)是集群 Pod 之前写进去的——因为本地进程用的是同一个 MySQL。

2. 本地写入,集群可见#

# 通过本地进程写入
curl -X POST \
  -H 'Content-Type: application/json' \
  -d '{"message":"written by local dev"}' \
  http://127.0.0.1:8080/api/messages/current

# 通过集群 NodePort 读取(绕过 mirrord,直接打集群 Pod)
curl http://127.0.0.1:18080/api/messages/current
# {"message":"written by local dev","handledBy":"cluster"}

本地进程写的数据,集群 Pod 立刻能读到。因为 mirrord 让本地进程的 DB 连接打到了集群内的 MySQL,共享同一份数据,没有任何数据同步问题。

3. steal header 请求测试#

# 走集群 NodePort,但带 steal header
curl -H 'x-mirrord-mode: steal' http://127.0.0.1:18080/api/messages/current
# {"message":"written by local dev","handledBy":"local"}

请求从 18080(集群 NodePort)进来,经过 mirrord 的 http_filter 匹配到 header,被劫持到本地进程处理。handledBy: local 证明了流量劫持成功。


kind 的网络特殊性:为什么不用 port-forward?#

这是 Demo 里一个容易踩的坑,值得单独说清楚。

kubectl port-forward 的工作机制:它通过 Kubernetes API Server 开一条隧道,直接连接到 Pod 端口,完全绕过了 Service → iptables → Pod 这条网络路径。

mirrord 的流量拦截点:mirrord 的入站流量拦截是在 Pod 的网络命名空间层面工作的,它 hook 的是从 Service 转发过来的真实流量。port-forward 的流量不经过这条路径,所以 mirrord 的 steal/mirror 对它不起作用。

Demo 的解法:使用 kind 的 extraPortMappings + NodePort Service:

# kind-config.yaml
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30080
        hostPort: 18080
        protocol: TCP
# k8s/app-deployment.yaml (Service 部分)
spec:
  type: NodePort
  ports:
    - port: 8080
      nodePort: 30080

18080 → kind 容器 30080 → NodePort → iptables → Pod。这条路径上的流量,mirrord 可以正常拦截。


官方文档和这个 Demo 的对应关系#

如果你想把“这个 Demo 为什么能工作”彻底串起来,建议对照看这三篇官方文档:

对应关系如下:

  1. Introduction
    官方定义了 mirrord 的核心价值:让本地进程运行在云环境上下文里。在这个 Demo 里,这体现在本地 JVM 可以直接访问集群内 MySQL、沿用 Pod 的网络环境。

  2. Architecture
    这篇解释了 mirrord-climirrord-layermirrord-agent 的分工:CLI 负责启动,layer 注入本地进程并 hook 底层系统调用,agent 在集群侧代理目标 Pod 的上下文。也正因为这样,mirrord exec -- java ... 启动的依然是本地进程,但出站网络已经像 Pod 一样工作。

  3. Network Traffic
    这篇解释了 mirrorstealhttp_filter.header_filter。本 Demo 用的就是 steal + header filter:只有带 x-mirrord-mode: steal 的请求才会被导向本地进程,普通请求继续由集群 Pod 处理。

这三篇文档连起来看,再回头看 kind + NodePort + header filter 的配置,基本就能完整理解这个 Demo 的工作机制。


应用到真实项目#

从 Demo 扩展到真实项目,mirrord 的配置需要做哪些调整?

1. target 精确到容器

如果 Pod 有多个容器,可以指定:

"target": {
  "namespace": "my-namespace",
  "path": "deployment/my-service",
  "container": "app"
}

2. 环境变量注入

默认 mirrord 会注入 Pod 的环境变量。如果想覆盖某些值(比如把日志级别改成 DEBUG),在本地启动时加 JVM 参数即可,mirrord 不会覆盖显式设置的值。

3. 出站网络:调用其他微服务

不需要额外配置。本地进程发出的 TCP 连接会经过 mirrord 隧道走集群内网,所以 http://order-service.production.svc.cluster.local 这类服务发现 URL 可以直接用。

4. 多人共享集群

使用 steal + http_filter,每个开发者用不同的 header 值:

"http_filter": {
  "header_filter": "^x-dev: alice$"
}

调用方在请求头里加 x-dev: alice,流量就只会路由到 Alice 的本地进程。

5. IDE 集成

mirrord 有 IntelliJ IDEA 和 VS Code 插件,可以在 IDE 里直接启动带 mirrord 的运行配置,不需要命令行。IntelliJ 插件支持选择目标 Pod、配置 feature,和 Run Configuration 无缝集成,断点调试体验和本地开发完全一致。


总结#

传统方案(build → deploy) mirrord
验证一次代码改动 2–5 分钟 秒级(本地 JVM 热重启)
访问集群内服务 port-forward(有限) 集群内网完全可用
本地 IDE 断点 需要 remote debug + port-forward 直接 Run/Debug
对集群影响 重新部署(影响其他人) agent sidecar(可选 filter 隔离)
需要修改应用代码

mirrord 最大的价值是缩短了代码改动到验证的循环。本地开发环境该有的工具(IDE、断点、热重启)全部可用,但应用运行时的上下文(网络、配置、数据)来自集群。

对于微服务密集、依赖多、集群是唯一完整运行环境的项目,mirrord 是目前体验最接近「本地开发但访问真实上下文」的方案。


Demo 项目地址github.com/meirongdev/mirrord-demo

跑一遍 make deploy && make validate,整个流程端到端跑通,看完日志基本就能理解 mirrord 的工作方式。