feat: 添加 K8S 部署功能
- 新增 src/k8s-client.ts: K8S 客户端封装 - 新增 src/deploy.ts: 部署/删除/状态查询逻辑 - 新增 scripts/deploy.ts: 命令行部署工具 - 更新 .env.example: 添加 K8S 配置项 - 安装 @kubernetes/client-node@1.3.0 依赖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
373
src/deploy.ts
Normal file
373
src/deploy.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* K8S 部署模块 - 用于部署 Claude API 代理服务到 ECI
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
import { K8sClient, V1Deployment, V1Ingress, V1IngressRule, V1ServicePort } from './k8s-client';
|
||||
|
||||
// 环境变量
|
||||
const K8S_API_URL = process.env.K8S_API_URL || '';
|
||||
const K8S_API_TOKEN = process.env.K8S_API_TOKEN || '';
|
||||
const DEV_HOST = process.env.DEV_HOST || 'dev.d8d.fun';
|
||||
const TLS_SECRET_NAME = process.env.TLS_SECRET_NAME || 'd8d-tls';
|
||||
|
||||
// 专用命名空间
|
||||
const NAMESPACE = 'd8d-proxy';
|
||||
|
||||
// 初始化 K8S 客户端
|
||||
export const k8sClient = new K8sClient(K8S_API_URL, K8S_API_TOKEN);
|
||||
|
||||
export interface DeployProxyParams {
|
||||
/** 容器组名称(唯一标识) */
|
||||
name: string;
|
||||
/** Docker 镜像 */
|
||||
image: string;
|
||||
/** 副本数 */
|
||||
replicas?: number;
|
||||
/** 容器端口 */
|
||||
port?: number;
|
||||
/** ECI 实例规格 */
|
||||
instanceType?: string;
|
||||
/** 是否自动创建 EIP */
|
||||
autoEip?: boolean;
|
||||
/** EIP 带宽 (Mbps) */
|
||||
eipBandwidth?: number;
|
||||
/** 环境变量 */
|
||||
env?: Record<string, string>;
|
||||
/** 标签 */
|
||||
labels?: Record<string, string>;
|
||||
/** 域名后缀 */
|
||||
hostSuffix?: string;
|
||||
}
|
||||
|
||||
export interface DeployProxyResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
hosts?: string[];
|
||||
deploymentName?: string;
|
||||
serviceName?: string;
|
||||
ingressName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署 Claude API 代理服务
|
||||
*/
|
||||
export async function deployProxy(params: DeployProxyParams): Promise<DeployProxyResult> {
|
||||
const {
|
||||
name,
|
||||
image,
|
||||
replicas = 1,
|
||||
port = 3000,
|
||||
instanceType = 'ecs.t5-lc2m1.nano',
|
||||
autoEip = true,
|
||||
eipBandwidth = 5,
|
||||
env = {},
|
||||
labels = {},
|
||||
hostSuffix = DEV_HOST,
|
||||
} = params;
|
||||
|
||||
const deploymentName = name;
|
||||
const serviceName = `service-${name}`;
|
||||
const ingressName = `${name}-ingress`;
|
||||
|
||||
// 清理域名后缀
|
||||
const cleanHostSuffix = hostSuffix.replace(/^\*\./, '').replace(/^\.+/, '');
|
||||
|
||||
// 构建域名
|
||||
const host = `${name}-${port}.${cleanHostSuffix}`;
|
||||
|
||||
try {
|
||||
// 1. 创建 Deployment
|
||||
const deployment: V1Deployment = {
|
||||
metadata: {
|
||||
name: deploymentName,
|
||||
labels: {
|
||||
app: name,
|
||||
'proxy-container': 'true',
|
||||
...labels,
|
||||
},
|
||||
annotations: {
|
||||
// ECI 实例规格
|
||||
'k8s.aliyun.com/eci-use-specs': instanceType,
|
||||
// 自动创建 EIP
|
||||
...(autoEip && {
|
||||
'k8s.aliyun.com/eci-open-enable-eip': 'true',
|
||||
'k8s.aliyun.com/eci-eip-bandwidth': String(eipBandwidth),
|
||||
'k8s.aliyun.com/eci-eip-internet-charge-type': 'PayByBandwidth',
|
||||
}),
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
replicas,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: name,
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: name,
|
||||
'proxy-container': 'true',
|
||||
...labels,
|
||||
},
|
||||
annotations: {
|
||||
// ECI 实例规格
|
||||
'k8s.aliyun.com/eci-use-specs': instanceType,
|
||||
// 自动创建 EIP
|
||||
...(autoEip && {
|
||||
'k8s.aliyun.com/eci-open-enable-eip': 'true',
|
||||
'k8s.aliyun.com/eci-eip-bandwidth': String(eipBandwidth),
|
||||
'k8s.aliyun.com/eci-eip-internet-charge-type': 'PayByBandwidth',
|
||||
}),
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
automountServiceAccountToken: false,
|
||||
enableServiceLinks: false,
|
||||
nodeSelector: {
|
||||
type: 'virtual-kubelet',
|
||||
},
|
||||
containers: [
|
||||
{
|
||||
name: name,
|
||||
image: image,
|
||||
ports: [
|
||||
{
|
||||
containerPort: port,
|
||||
protocol: 'TCP',
|
||||
},
|
||||
],
|
||||
env: Object.entries(env).map(([key, value]) => ({
|
||||
name: key,
|
||||
value,
|
||||
})),
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: '250m',
|
||||
memory: '256Mi',
|
||||
},
|
||||
},
|
||||
readinessProbe: {
|
||||
httpGet: {
|
||||
path: '/health',
|
||||
port: port,
|
||||
},
|
||||
initialDelaySeconds: 5,
|
||||
periodSeconds: 10,
|
||||
},
|
||||
livenessProbe: {
|
||||
httpGet: {
|
||||
path: '/health',
|
||||
port: port,
|
||||
},
|
||||
initialDelaySeconds: 15,
|
||||
periodSeconds: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
tolerations: [
|
||||
{
|
||||
key: 'alibabacloud.com/eci',
|
||||
operator: 'Exists',
|
||||
effect: 'NoSchedule',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await k8sClient.createDeployment({
|
||||
json: {
|
||||
namespace: NAMESPACE,
|
||||
deployment,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Deployment ${deploymentName} 创建成功`);
|
||||
|
||||
// 2. 创建 Service
|
||||
const ports: V1ServicePort[] = [
|
||||
{
|
||||
port: port,
|
||||
targetPort: port,
|
||||
name: 'http',
|
||||
},
|
||||
];
|
||||
|
||||
await k8sClient.createService({
|
||||
json: {
|
||||
namespace: NAMESPACE,
|
||||
service: {
|
||||
metadata: {
|
||||
name: serviceName,
|
||||
labels: {
|
||||
app: name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
type: 'ClusterIP',
|
||||
selector: {
|
||||
app: name,
|
||||
},
|
||||
ports,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Service ${serviceName} 创建成功`);
|
||||
|
||||
// 3. 创建 Ingress
|
||||
const ingressRule: V1IngressRule = {
|
||||
host,
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: '/',
|
||||
pathType: 'Prefix',
|
||||
backend: {
|
||||
service: {
|
||||
name: serviceName,
|
||||
port: {
|
||||
number: port,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const ingress: V1Ingress = {
|
||||
metadata: {
|
||||
name: ingressName,
|
||||
annotations: {
|
||||
'nginx.ingress.kubernetes.io/proxy-body-size': '50m',
|
||||
'nginx.ingress.kubernetes.io/proxy-read-timeout': '300',
|
||||
'nginx.ingress.kubernetes.io/proxy-send-timeout': '300',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
tls: [
|
||||
{
|
||||
hosts: [host],
|
||||
secretName: TLS_SECRET_NAME,
|
||||
},
|
||||
],
|
||||
rules: [ingressRule],
|
||||
},
|
||||
};
|
||||
|
||||
await k8sClient.createIngress({
|
||||
json: {
|
||||
namespace: NAMESPACE,
|
||||
ingress,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Ingress ${ingressName} 创建成功`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `代理服务 ${name} 部署成功`,
|
||||
hosts: [`https://${host}`],
|
||||
deploymentName,
|
||||
serviceName,
|
||||
ingressName,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`❌ 部署失败: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `部署失败: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理服务
|
||||
*/
|
||||
export async function deleteProxy(name: string): Promise<{ success: boolean; message: string }> {
|
||||
const ingressName = `${name}-ingress`;
|
||||
const serviceName = `service-${name}`;
|
||||
|
||||
try {
|
||||
// 1. 删除 Ingress
|
||||
await k8sClient.deleteIngress({
|
||||
param: {
|
||||
namespace: NAMESPACE,
|
||||
ingressName,
|
||||
},
|
||||
}).catch(err => console.warn(`删除 Ingress 失败: ${err.message}`));
|
||||
|
||||
// 2. 删除 Service
|
||||
await k8sClient.deleteService({
|
||||
param: {
|
||||
namespace: NAMESPACE,
|
||||
serviceName,
|
||||
},
|
||||
}).catch(err => console.warn(`删除 Service 失败: ${err.message}`));
|
||||
|
||||
// 3. 删除 Deployment
|
||||
await k8sClient.deleteDeployment({
|
||||
param: {
|
||||
namespace: NAMESPACE,
|
||||
name,
|
||||
},
|
||||
}).catch(err => console.warn(`删除 Deployment 失败: ${err.message}`));
|
||||
|
||||
console.log(`✅ 代理服务 ${name} 删除成功`);
|
||||
return { success: true, message: `代理服务 ${name} 删除成功` };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, message: `删除失败: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理服务状态
|
||||
*/
|
||||
export async function getProxyStatus(name: string): Promise<{
|
||||
exists: boolean;
|
||||
status?: string;
|
||||
hosts?: string[];
|
||||
}> {
|
||||
try {
|
||||
const deployment = await k8sClient.getDeployment({
|
||||
param: {
|
||||
namespace: NAMESPACE,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
const readyReplicas = deployment.status?.readyReplicas || 0;
|
||||
const replicas = deployment.spec?.replicas || 1;
|
||||
|
||||
// 获取 Ingress 域名
|
||||
const ingressResult = await k8sClient.getIngress({
|
||||
param: {
|
||||
namespace: NAMESPACE,
|
||||
ingressName: `${name}-ingress`,
|
||||
},
|
||||
});
|
||||
|
||||
const hosts = ingressResult.ingress?.spec?.rules?.map(r => `https://${r.host}`) || [];
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
status: readyReplicas >= replicas ? 'Running' : 'Pending',
|
||||
hosts,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorCode = (error as { code?: number })?.code;
|
||||
if (errorCode === 404) {
|
||||
return { exists: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user