一个棘手的现实是,多数技术决策并非发生在绿地项目上。我们面对的往往是一个运行多年、承载核心业务的单体应用,它背后是一套关系范式固化的MySQL数据库,以及一套用了近十年的Jenkins Freestyle任务构成的部署流程。我们的任务,就是在这个“带病”的身体上,嫁接新的、更具活力的器官。
问题的起点是一个用户中心模块。它最初设计简单,一张users
表和一张user_profiles
表就能满足需求。但随着业务发展,需要存储的用户标签、行为轨迹、设备信息等非结构化数据越来越多,通过不断给MySQL加字段、建关联表的方式已经让整个Schema变得臃肿不堪,每次查询的JOIN操作都成了性能瓶颈。新的产品需求,比如实时个性化推荐,更是对现有架构发出了最后通牒。
初步构想是采用“绞杀者模式”(Strangler Fig Pattern),将新的功能、特别是数据模型差异巨大的部分,作为微服务剥离出来。技术栈的选择上,团队倾向于新组合:使用tRPC构建类型安全的API,后端存储则选用Cassandra,它的分布式、无主架构和灵活的列族模型,天然适合存储用户画像这类写入密集、高可用的数据。
然而,最大的挑战并非构建新服务,而在于如何让新旧系统安全、平滑地共存并逐步交接。直接进行双写?数据一致性如何保证?一次性切换流量?无异于一场豪赌。我们需要一个强大的流量控制平面,它必须能做到:
- 流量镜像 (Mirroring): 在不影响老系统的前提下,将生产流量复制一份到新服务,用于影子测试和性能验证。
- 按权重路由 (Weighted Routing): 实现精细化的灰度发布,例如先切1%的流量,观察稳定后再逐步增加比例。
- 统一可观测性: 无侵入地为新旧两个系统提供统一的Metrics, Tracing, Logging,否则在混合架构下排查问题将是一场噩梦。
这正是Istio的用武之地。它以Sidecar代理的方式接管服务间所有流量,为我们提供了梦寐以求的控制力。而CI/CD方面,我们决定继续使用现有的Jenkins,但将其改造为支持声明式Pipeline,用以自动化构建、部署以及Istio规则的下发。这是一个在现实约束下,融合了新旧技术的折中方案。
第一阶段:新旧服务并行部署
我们首先需要让新旧两个服务在同一个Kubernetes集群里跑起来。
遗留服务:legacy-user-service
这是一个典型的Node.js Express应用,连接着外部的MySQL数据库。为了让它能被Istio管理,我们只需要确保它的Deployment包含必要的label,以便Sidecar注入。
legacy-user-service/deployment.yaml
:
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-user-service
labels:
app: legacy-user-service
spec:
replicas: 3
selector:
matchLabels:
app: legacy-user-service
template:
metadata:
labels:
app: legacy-user-service
# Istio sidecar injector会识别这个label
version: v1
spec:
containers:
- name: app
image: my-registry/legacy-user-service:1.0.0
ports:
- containerPort: 3000
env:
- name: MYSQL_HOST
value: "mysql.external.svc.cluster.local" # 假设MySQL在集群外部
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: mysql-credentials
key: user
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-credentials
key: password
---
apiVersion: v1
kind: Service
metadata:
name: user-service
labels:
app: user-service
spec:
selector:
app: legacy-user-service # Service的selector暂时只指向老服务
ports:
- name: http
port: 80
targetPort: 3000
这里的关键点在于Service
的名称是user-service
,它代表了“用户服务”这个抽象概念。目前,它的流量全部由legacy-user-service
承载。
新服务:trpc-profile-service
这是我们使用tRPC和Cassandra构建的新服务。tRPC的后端实现相当直观。
trpc-profile-service/src/server.ts
:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { z } from 'zod';
import { cassandraClient } from './database'; // Cassandra客户端封装
import { publicProcedure, router } from './trpc';
import { logger } from './logger'; // 生产级日志
const appRouter = router({
getUserProfile: publicProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ input }) => {
try {
// 在真实项目中,这里会有更复杂的查询和数据转换逻辑
const query = 'SELECT user_id, tags, last_active FROM user_profiles WHERE user_id = ?';
const result = await cassandraClient.execute(query, [input.userId], { prepare: true });
if (result.rowLength === 0) {
logger.warn({ userId: input.userId }, 'User profile not found in Cassandra');
return null;
}
const profile = result.first();
return {
userId: profile.user_id,
tags: profile.tags, // set<text> in Cassandra
lastActive: profile.last_active, // timestamp
};
} catch (error) {
logger.error({ err: error, userId: input.userId }, 'Failed to fetch profile from Cassandra');
// tRPC会自动处理错误,返回给客户端一个标准的RPCError
throw new Error('Database query failed');
}
}),
// 更多其他的tRPC procedures...
});
export type AppRouter = typeof appRouter;
const server = createHTTPServer({
router: appRouter,
// 在生产环境中,需要配置更详细的错误处理和上下文创建
createContext() {
return {};
},
});
server.listen(4000);
logger.info('tRPC profile service started on port 4000');
新服务的Deployment YAML与老服务类似,但它有自己的label和版本号。
trpc-profile-service/deployment.yaml
:
apiVersion: apps/v1
kind: Deployment
metadata:
name: trpc-profile-service
labels:
app: trpc-profile-service
spec:
replicas: 2
selector:
matchLabels:
app: trpc-profile-service
template:
metadata:
labels:
app: trpc-profile-service
# 标记为v2版本,用于Istio的路由识别
version: v2
spec:
containers:
- name: app
image: my-registry/trpc-profile-service:1.0.0
ports:
- containerPort: 4000
env:
- name: CASSANDRA_CONTACT_POINTS
value: "cassandra.datastore.svc.cluster.local"
# ... 其他环境变量和配置
此刻,我们有两个独立的服务,但user-service
这个Kubernetes Service只知道老服务。外界的流量还完全接触不到新服务。
第二阶段:用Istio接管流量并实现镜像
现在是引入Istio魔法的时刻。首先,确保namespace开启了Istio sidecar自动注入。然后,我们创建Gateway
和VirtualService
来管理入口流量。
istio/gateway.yaml
:
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: user-gateway
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "user.api.example.com"
接下来是核心的VirtualService
。我们将所有到user-service
的流量路由到v1
版本(遗留服务),同时,把100%的GET请求镜像到v2
版本(tRPC服务)。
istio/user-virtualservice-mirror.yaml
:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service-vs
spec:
hosts:
- "user.api.example.com" # 从Gateway进入的流量
- user-service # 集群内部服务间的流量
gateways:
- user-gateway
http:
- match:
- uri:
prefix: "/api/user"
route:
- destination:
host: user-service
subset: v1
weight: 100
# 关键部分:流量镜像
mirror:
host: user-service
subset: v2
# 镜像流量占主流量的百分比
mirrorPercentage:
value: 100.0
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: user-service-dr
spec:
host: user-service
subsets:
- name: v1
labels:
version: v1 # 对应 legacy-user-service 的 pod label
- name: v2
labels:
version: v2 # 对应 trpc-profile-service 的 pod label
这里的坑在于,我们的新老服务Deployment名称不同 (legacy-user-service
vs trpc-profile-service
),但VirtualService
和DestinationRule
需要作用于同一个抽象服务user-service
上。这要求我们修改user-service
的Kubernetes Service定义,使其selector
能够同时选中新旧服务的Pods。但这样做会引入负载均衡的混乱。
正确的做法是为新服务创建一个独立的Service
,例如trpc-profile-svc
,然后在VirtualService
中直接引用这两个不同的host。
修正后的VirtualService
和DestinationRule
:
# 创建一个指向新服务的Kubernetes Service
apiVersion: v1
kind: Service
metadata:
name: trpc-profile-svc
spec:
selector:
app: trpc-profile-service
ports:
- name: http
port: 80
targetPort: 4000
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service-vs
spec:
hosts:
- "user.api.example.com"
- user-service
gateways:
- user-gateway
http:
- match:
- uri:
# 假设只有获取用户信息的API需要迁移
prefix: "/api/user/profile"
route:
- destination:
host: user-service # 这是指向老服务的K8s Service
weight: 100
mirror:
host: trpc-profile-svc # 直接指向新服务的K8s Service
mirrorPercentage:
value: 100.0
# 其他的 /api/user/* 路由仍然全部指向老服务
- route:
- destination:
host: user-service
weight: 100
应用这套配置后,所有对 /api/user/profile
的请求依然由老服务响应,但Istio的Sidecar会异步地将请求复制一份,发往我们的trpc-profile-service
。此刻,我们可以通过观察trpc-profile-service
的日志和监控指标(Istio自动采集),来验证它在真实负载下的表现是否符合预期,而不用担心任何对生产环境的影响。
第三阶段:数据同步与渐进式流量切换
镜像测试稳定后,就进入了最危险的阶段:切换真实流量。在此之前,必须解决数据同步问题。一个常见的错误是直接在新服务中实现对老数据库MySQL的读写,这会造成新旧逻辑的深度耦合,违背了微服务改造的初衷。
我们采用了一个折中的“双写”方案:改造遗留服务的代码,在它更新MySQL的同时,也调用新trpc-profile-service
的写入接口,向Cassandra写入数据。
legacy-user-service/updateProfile.js
(伪代码):
async function updateUserProfile(userId, profileData) {
const mysql_conn = await mysql.createConnection(...);
const transaction = await mysql_conn.beginTransaction();
try {
// 1. 写入主数据库 MySQL
await transaction.query('UPDATE user_profiles SET ... WHERE user_id = ?', [userId]);
// 2. 异步调用新服务写入 Cassandra
// 使用服务发现,调用集群内的trpc-profile-svc
// 这里需要健壮的重试和失败降级逻辑
axios.post('http://trpc-profile-svc/rpc/updateUserProfile', { userId, ...profileData })
.catch(err => {
// 关键:写入新库失败不能影响主流程
// 记录失败日志,后续通过补偿任务修复数据
legacyLogger.error('Failed to dual-write to trpc-profile-service', err);
});
await transaction.commit();
return { success: true };
} catch (error) {
await transaction.rollback();
throw error;
}
}
这个方案的优点是实现简单,但缺点是存在数据不一致的风险。在我们的场景中,短期的不一致是可以接受的。对于金融等要求强一致的场景,则必须使用基于消息队列的最终一致性方案或分布式事务。
数据同步启动后,我们可以修改VirtualService
,开始切分流量。
istio/user-virtualservice-weighted.yaml
:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service-vs
spec:
hosts:
- "user.api.example.com"
- user-service
gateways:
- user-gateway
http:
- match:
- uri:
prefix: "/api/user/profile"
route:
# 90%流量到老服务
- destination:
host: user-service
weight: 90
# 10%流量到新服务
- destination:
host: trpc-profile-svc
weight: 10
# 其他API路由保持不变
- route:
- destination:
host: user-service
weight: 100
我们将10%的读请求切换到了新服务。通过Istio的可视化工具(如Kiali)和监控仪表盘(Grafana),我们可以清晰地看到两个服务的流量分布、请求成功率和延迟。一旦确认新服务运行稳定,就可以通过修改weight
值,逐步将流量从90/10调整到50/50,再到10/90,最终实现100%切换。
第四阶段:Jenkins Pipeline自动化发布
手动修改并应用YAML文件来进行灰度发布,不仅效率低下,还容易出错。我们需要将这个过程自动化,集成到Jenkins Pipeline中。
Jenkinsfile
:
pipeline {
agent {
kubernetes {
cloud 'kubernetes'
yamlFile 'jenkins/pod-template.yaml'
}
}
parameters {
string(name: 'IMAGE_TAG', defaultValue: "1.0.${env.BUILD_NUMBER}", description: 'Docker image tag')
// 关键参数:用于控制灰度发布的流量权重
string(name: 'NEW_SERVICE_WEIGHT', defaultValue: '0', description: 'Traffic weight for the new tRPC service (0-100)')
}
environment {
// 定义Istio VirtualService模板文件路径
VS_TEMPLATE_PATH = 'istio/user-virtualservice-template.yaml'
// 定义最终生成的VirtualService文件路径
VS_OUTPUT_PATH = 'istio/generated-vs.yaml'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build and Push Image') {
steps {
container('golang') {
sh 'go test ./...'
}
container('docker') {
script {
def dockerImage = "my-registry/trpc-profile-service:${params.IMAGE_TAG}"
sh "docker build -t ${dockerImage} ."
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) {
sh "docker login my-registry -u ${DOCKER_USER} -p ${DOCKER_PASS}"
sh "docker push ${dockerImage}"
}
}
}
}
}
stage('Deploy Application') {
steps {
container('kubectl') {
// 更新Kubernetes Deployment的镜像版本
// 在真实项目中,会使用kustomize或helm进行更复杂的管理
sh "sed -i 's|image: my-registry/trpc-profile-service:.*|image: my-registry/trpc-profile-service:${params.IMAGE_TAG}|g' trpc-profile-service/deployment.yaml"
sh "kubectl apply -f trpc-profile-service/deployment.yaml"
sh "kubectl rollout status deployment/trpc-profile-service"
}
}
}
stage('Update Traffic Routing') {
steps {
container('kubectl') {
script {
// 使用envsubst动态生成Istio VirtualService配置
def oldServiceWeight = 100 - params.NEW_SERVICE_WEIGHT.toInteger()
sh """
export NEW_WEIGHT=${params.NEW_SERVICE_WEIGHT}
export OLD_WEIGHT=${oldServiceWeight}
envsubst < ${VS_TEMPLATE_PATH} > ${VS_OUTPUT_PATH}
"""
echo "Applying Istio VirtualService with weights: old=${oldServiceWeight}, new=${params.NEW_SERVICE_WEIGHT}"
sh "kubectl apply -f ${VS_OUTPUT_PATH}"
}
}
}
}
}
}
配套的user-virtualservice-template.yaml
模板文件:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service-vs
spec:
# ... (hosts, gateways等部分省略)
http:
- match:
- uri:
prefix: "/api/user/profile"
route:
- destination:
host: user-service
weight: ${OLD_WEIGHT} # Jenkins会替换这个变量
- destination:
host: trpc-profile-svc
weight: ${NEW_WEIGHT} # Jenkins会替换这个变量
# ...
现在,SRE或开发人员可以通过触发一个带参数的Jenkins Job,来精确控制新服务的上线和流量切换过程。例如,第一次部署时NEW_SERVICE_WEIGHT
设为0,只部署不切流量。验证无误后,再次运行Job,将权重设为10,就完成了10%的灰度发布。整个过程有记录、可回滚、风险可控。
最终架构
经过上述步骤,我们构建了一个可平滑演进的混合架构。
graph TD subgraph Kubernetes Cluster with Istio Mesh Ingress[istio-ingressgateway] -->|user.api.example.com| VS(VirtualService: user-service-vs); subgraph "Traffic for /api/user/profile" VS -- weight:${OLD_WEIGHT} --> LegacySvc[Service: user-service]; VS -- weight:${NEW_WEIGHT} --> TrpcSvc[Service: trpc-profile-svc]; end subgraph "Traffic for other /api/user/*" VS -- 100% --> LegacySvc end LegacySvc --> LegacyPod1[Pod: legacy-user-service-v1]; LegacySvc --> LegacyPod2[Pod: legacy-user-service-v1]; TrpcSvc --> TrpcPod1[Pod: trpc-profile-service-v2]; TrpcSvc --> TrpcPod2[Pod: trpc-profile-service-v2]; LegacyPod1 -- "Dual Write" --> TrpcSvc; LegacyPod2 -- "Dual Write" --> TrpcSvc; TrpcPod1 --> Cassandra[Cassandra Cluster]; TrpcPod2 --> Cassandra[Cassandra Cluster]; end subgraph External Systems Jenkins[Jenkins] -- "kubectl apply" --> VS; Jenkins -- "build & push" --> DockerRegistry[Docker Registry]; LegacyPod1 --> MySQL[(MySQL DB)]; LegacyPod2 --> MySQL; end User[Client] --> Ingress; style VS fill:#f9f,stroke:#333,stroke-width:2px
这个架构允许我们以最小的风险,将功能从臃肿的单体中逐步迁移出来。tRPC和Cassandra为新业务提供了现代化的开发体验和强大的数据处理能力,而Istio则扮演了至关重要的交通指挥官角色,确保整个迁移过程的平稳与透明。
局限与展望
当前方案并非银弹。我们采用的应用层双写方案,其数据一致性保障较弱,是特定场景下的权衡。在对一致性要求更高的系统中,必须引入基于CDC(Change Data Capture)的工具如Debezium,将MySQL的binlog实时同步到消息队列,再由新服务消费,从而实现更可靠的异步数据同步。
其次,虽然我们用Jenkins Pipeline实现了自动化,但它本质上仍是“推”模式(Push-based)。每一次变更都需要CI/CD系统主动执行操作。更云原生的做法是转向GitOps,使用ArgoCD或Flux。将Istio配置和Kubernetes清单都存放在Git仓库中,由GitOps控制器自动拉取和同步集群状态,这将提供更强的声明式保证和更清晰的变更审计。
最后,当所有依赖/api/user/profile
的流量都切换到新服务后,“绞杀”并未结束。我们需要清理遗留代码、移除双写逻辑,并最终下线MySQL中不再需要的表。这个过程同样需要细致的规划和验证,直至旧的器官被完全替代,系统完成一次真正意义上的新陈代谢。