在设计需要服务全球用户的应用时,尤其是业务范围同时覆盖中国大陆和海外市场的场景,技术团队面临的首要挑战并非功能实现,而是网络延迟与数据合规性。一个部署在欧洲的服务器,对于北美用户可能尚可接受,但对于亚洲,特别是中国用户而言,延迟将是毁灭性的。这直接引出了我们的核心问题:如何为我们的 Qwik 前端应用构建一个高性能、低延迟、类型安全且能跨越不同云平台(Azure 和阿里云)的 tRPC 后端架构。
架构决策的十字路口:单一集群 vs. 多云联邦
在真实的项目中,我们不会直接跳到最终方案。架构决策是一个权衡利弊的过程。摆在我们面前的有两种截然不同的部署模型。
方案 A: 单一全球中心化集群(Azure AKS)
这是最直接的思路。选择一个地理位置相对“中心”的区域,例如新加坡或美西,部署一个大型的 Azure Kubernetes Service (AKS) 集群。所有 tRPC 后端服务都运行于此,并通过全球 CDN(如 Cloudflare 或 Azure Front Door)将 Qwik 前端应用和 API 流量分发至全球用户。
优势:
- 运维简化: 单一 Kubernetes 集群,统一的监控、日志和部署流水线。
- 成本可控: 资源集中,更易于进行容量规划和成本优化。
- 架构简单: 无需处理跨区域、跨云的数据同步和服务发现。
劣势:
- 延迟鸿沟: 对于远离集群部署区域的用户,尤其是中国大陆用户,API 请求需要跨越重洋,网络延迟和抖动是无法接受的。即使有 CDN,也只能加速静态资源,动态 API 请求的延迟依然是硬伤。
- 数据主权与合规风险: 将所有用户数据(包括中国用户)存储在海外服务器,可能违反《网络安全法》等相关法规。
- 单点故障风险: 尽管有区域内多可用区,但整个服务依赖于单一的云区域。该区域一旦发生重大故障,全球业务将中断。
一个典型的 AKS 部署清单可能像这样,它本身并没有错,但无法解决我们的核心问题:
# deployment-aks-central.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: trpc-backend-global
labels:
app: trpc-backend
spec:
replicas: 5
selector:
matchLabels:
app: trpc-backend
template:
metadata:
labels:
app: trpc-backend
spec:
containers:
- name: server
image: my-registry/trpc-app:1.0.2
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: backend-secrets
key: db-url-global
- name: APP_REGION
value: "GLOBAL"
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "500m"
memory: "1024Mi"
这个方案的根本缺陷在于,它试图用一个物理上集中的方案去解决一个地理上分散的问题。在性能和合规性要求面前,运维的简便性必须让步。
方案 B: 多云地理分布式联邦架构
这个方案承认地理差异是首要问题。我们将部署两套独立的、但代码和镜像完全相同的 Kubernetes 集群:
- Azure AKS (法兰克福): 服务欧洲、北美及其他海外用户。
- 阿里云 ACK (上海): 专门服务中国大陆用户。
用户流量通过 GeoDNS 服务(如 NS1, Amazon Route 53 Latency-based Routing)在 DNS 解析层面就被路由到最近的集群入口。
graph TD
subgraph "用户端 (Qwik Frontend)"
User[全球用户] --> GeoDNS[GeoDNS: api.myapp.com]
end
subgraph "阿里云 (中国大陆)"
ACK[阿里云 ACK 集群]
ACK_Ingress[SLB Ingress] --> ACK_Service[tRPC Service]
ACK_Service --> Pod1[tRPC Pod 1]
ACK_Service --> Pod2[tRPC Pod 2]
end
subgraph "Azure (海外)"
AKS[Azure AKS 集群]
AKS_Ingress[Application Gateway Ingress] --> AKS_Service[tRPC Service]
AKS_Service --> Pod3[tRPC Pod 3]
AKS_Service --> Pod4[tRPC Pod 4]
end
GeoDNS -- 解析至中国 IP --> ACK_Ingress
GeoDNS -- 解析至海外 IP --> AKS_Ingress
优势:
- 极致低延迟: 用户直接访问本地区的服务器,API 响应速度得到最大保障。
- 数据合规: 中国用户的数据可以天然地存储在中国境内的数据库中,满足合规要求。
- 高可用性: 两个区域互为备份。一个区域发生故障,可以通过 DNS 手动或自动切换,保证核心业务的连续性。
劣势:
- 运维复杂性: 需要同时维护两套云平台上的 K8s 集群。CI/CD 流水线、监控告警、日志系统都需要适配双云环境。
- 基础设施即代码 (IaC) 挑战: 需要使用 Terraform 或类似工具管理两个不同 Provider 的资源,配置和状态管理更复杂。
- 成本增加: 维护两套完整的环境,资源冗余会带来更高的成本。
最终决策与理由
对于一个严肃的全球化应用而言,方案 B 是唯一正确的选择。用户体验和合规性是不可妥协的底线。运维的复杂性是工程团队需要通过自动化和标准化来解决的问题,而不是规避它的理由。我们的目标不是选择最简单的路,而是选择最正确、最能支撑业务长期发展的路。
核心实现细节
1. 统一的 tRPC 后端与容器镜像
多云部署成功的关键在于不可变基础设施的理念。我们必须保证部署到 Azure AKS 和阿里云 ACK 的是同一个 Docker 镜像。环境差异通过环境变量和 Kubernetes ConfigMap/Secret 来注入。
这是我们的 tRPC 服务核心路由结构:
// src/server/trpc/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import pino from 'pino';
// 在真实项目中,我们会使用更结构化的日志库
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
// 模拟数据库客户端
const dbClient = {
user: {
findUnique: async ({ where: { id } }: { where: { id: string }}) => {
// 模拟数据库查询
if (id === '1') {
return { id: '1', name: 'Alice', region: process.env.APP_REGION || 'UNKNOWN' };
}
return null;
}
}
};
const t = initTRPC.create();
export const appRouter = t.router({
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
logger.info({ userId: input.id }, 'Fetching user data');
try {
const user = await dbClient.user.findUnique({ where: { id: input.id } });
if (!user) {
logger.warn({ userId: input.id }, 'User not found');
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
return user;
} catch (error) {
logger.error({ err: error, userId: input.id }, 'Failed to fetch user');
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Something went wrong.' });
}
}),
});
export type AppRouter = typeof appRouter;
注意 process.env.APP_REGION,这个环境变量将是区分不同部署环境的关键。
接下来是用于构建这个服务的 Dockerfile,它必须是多阶段构建,以保证生产镜像的轻量和安全。
# ---- Base Stage ----
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
# 关键:使用 --frozen-lockfile 保证依赖一致性
RUN npm ci
# ---- Build Stage ----
FROM base AS build
WORKDIR /app
COPY . .
# 运行类型检查和构建
RUN npm run build
# ---- Production Stage ----
FROM node:18-alpine AS production
WORKDIR /app
# 只从 base 阶段拷贝生产依赖
COPY /app/node_modules ./node_modules
COPY package.json .
# 从 build 阶段拷贝编译后的代码
COPY /app/dist ./dist
# 定义健康检查
HEALTHCHECK \
CMD [ "node", "-e", "require('http').get('http://localhost:3000/healthz', (res) => process.exit(res.statusCode == 200 ? 0 : 1))" ]
# 暴露端口
EXPOSE 3000
# 启动命令
CMD ["node", "dist/server/index.js"]
这个 Dockerfile 产生的镜像是与环境无关的。我们可以把它推送到任何一个 Docker Registry(如 Azure ACR 或阿里云 ACR),供两个 Kubernetes 集群使用。
2. 针对不同云平台的 Kubernetes Manifests
尽管镜像相同,但 Kubernetes 的部署和服务清单,尤其是 Ingress 部分,会因云厂商的不同而有所差异。
Azure AKS 的部署 (deployment-aks.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: trpc-backend-eu
namespace: prod
spec:
replicas: 3
selector:
matchLabels:
app: trpc-backend
template:
metadata:
labels:
app: trpc-backend
spec:
containers:
- name: server
image: myregistry.azurecr.io/trpc-app:1.1.0 # 使用 Azure ACR
ports:
- name: http
containerPort: 3000
env:
- name: APP_REGION
value: "EU_FRANKFURT"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: backend-secrets-eu
key: db-url
# ... 其他资源和探针配置 ...
---
apiVersion: v1
kind: Service
metadata:
name: trpc-backend-svc
namespace: prod
spec:
selector:
app: trpc-backend
ports:
- protocol: TCP
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: trpc-ingress-aks
namespace: prod
annotations:
# 使用 Azure Application Gateway Ingress Controller
kubernetes.io/ingress.class: "azure/application-gateway"
appgw.ingress.kubernetes.io/ssl-redirect: "true"
appgw.ingress.kubernetes.io/backend-protocol: "http"
spec:
rules:
- host: "api.myapp.com"
http:
paths:
- path: /trpc
pathType: Prefix
backend:
service:
name: trpc-backend-svc
port:
number: 80
这里的关键是 APP_REGION 环境变量被硬编码为 "EU_FRANKFURT",并且 Ingress 使用了 Azure 特有的 azure/application-gateway 注解。
阿里云 ACK 的部署 (deployment-ack.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: trpc-backend-cn
namespace: prod
spec:
replicas: 3
selector:
matchLabels:
app: trpc-backend
template:
metadata:
labels:
app: trpc-backend
spec:
containers:
- name: server
# 使用阿里云 ACR
image: registry.cn-shanghai.aliyuncs.com/mynamespace/trpc-app:1.1.0
ports:
- name: http
containerPort: 3000
env:
- name: APP_REGION
value: "CN_SHANGHAI" # 这里的环境变量值不同
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: backend-secrets-cn
key: db-url
# ... 其他资源和探针配置 ...
---
apiVersion: v1
kind: Service
metadata:
name: trpc-backend-svc
namespace: prod
spec:
selector:
app: trpc-backend
ports:
- protocol: TCP
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: trpc-ingress-ack
namespace: prod
annotations:
# 使用阿里云 SLB Ingress
kubernetes.io/ingress.class: "alb"
alb.ingress.kubernetes.io/scheme: "internet"
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "Redirect", "RedirectConfig": {"Protocol": "HTTPS", "Port": "443", "StatusCode": "301"}}'
spec:
rules:
- host: "api.myapp.com"
http:
paths:
- path: /trpc
pathType: Prefix
backend:
service:
name: trpc-backend-svc
port:
number: 80
在 ACK 的清单中,我们使用了阿里云的 ACR 镜像地址,APP_REGION 环境变量设置为 "CN_SHANGHAI",并且 Ingress 的注解是针对阿里云 ALB (Application Load Balancer) Ingress Controller 的。
这些细微但关键的差异正是多云架构中需要精心管理的地方。使用 Helm 或 Kustomize 等工具可以帮助我们更好地管理这些环境差异,避免复制粘贴带来的错误。
3. Qwik 前端与动态 tRPC 客户端配置
前端应用本身是静态资源,可以部署在任何全球 CDN 上。挑战在于,前端代码如何知道应该连接哪个 API 后端。
最可靠的方法是让 DNS 来处理。前端代码中只硬编码一个 API 入口,例如 https://api.myapp.com/trpc。GeoDNS 服务会确保当用户浏览器解析 api.myapp.com 时,得到的是离他最近的那个集群的 Ingress IP 地址。
在 Qwik 中配置 tRPC 客户端:
// src/lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '~/server/trpc/router';
import { isServer } from '@builder.io/qwik/build';
const getBaseUrl = () => {
// 在 Vite 构建时注入环境变量
// 在真实项目中,这里不应该有条件判断,直接使用一个统一的域名
return import.meta.env.VITE_API_URL || 'https://api.myapp.com';
};
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: `${getBaseUrl()}/trpc`,
headers() {
if (isServer) {
// 如果在 SSR 期间调用,可以传递请求头
// 这对于需要在服务端转发 cookie 或认证信息的场景非常重要
return {};
}
// 客户端调用
return {};
},
}),
],
});
在 Qwik 组件中使用:
// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { trpc } from '~/lib/trpc';
// 使用 routeLoader$ 在服务端获取数据
export const useUser = routeLoader$(async () => {
// 这段代码在服务器端执行(SSR)
// 它会连接到与 Qwik 服务端渲染 (SSR) 服务器同区域的 tRPC 后端
try {
const user = await trpc.getUserById.query({ id: '1' });
return user;
} catch (error) {
// 错误处理
return null;
}
});
export default component$(() => {
const userSignal = useUser();
return (
<div>
<h1>Welcome to Qwik</h1>
{userSignal.value ? (
<p>
Hello, {userSignal.value.name}! Your data is served from region: {userSignal.value.region}.
</p>
) : (
<p>User not found.</p>
)}
</div>
);
});
当用户首次访问页面时,Qwik 的 SSR 服务器会调用 tRPC API。如果 SSR 服务器部署在 AKS 上,它会请求法兰克福的 tRPC 服务。页面加载到浏览器后,任何客户端发起的 tRPC 请求都会通过用户的 DNS 解析,智能地路由到最近的集群。最终用户会看到 region 字段显示为他所在区域的后端服务。
架构的局限性与未来演进
当前方案并非没有缺点。它完美地解决了无状态或区域隔离状态应用的地理分布问题。但如果业务需要全局共享的强一致性状态(例如,一个全局唯一的用户名注册),这个架构就会面临巨大挑战。跨区域数据库同步的延迟和复杂性会成为新的瓶颈。届时,可能需要引入支持全球分布的数据库,如 CockroachDB, TiDB, 或 Azure Cosmos DB,但这会显著增加系统的复杂度和成本。
另一个挑战是 CI/CD。为双云环境构建一个统一、可靠的自动化部署和回滚流水线,需要大量的工程投入。你需要使用像 Terraform Workspace 或 Terragrunt 这样的工具来管理不同环境的状态,CI/CD 平台(如 GitHub Actions)也需要配置多个云提供商的凭据和上下文。
未来的演进方向可能包括引入服务网格(Service Mesh)的 multi-cluster 功能,来进一步抽象化跨集群的服务发现和流量管理。但这同样是一个重型武器,需要审慎评估其带来的运维负担是否值得。这个架构是一个起点,一个在性能、合规和成本之间取得务实平衡的、经得起生产环境考验的起点。