All checks were successful
Docker Build and Push / build-and-push (push) Successful in 2m28s
- 默认端口从 3000 改为 80 - Dockerfile 更新 EXPOSE 和健康检查端口 - 添加 ECI Spot 抢占式实例注解 - 移除 USER node 以支持监听 80 端口 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
379 lines
9.4 KiB
TypeScript
379 lines
9.4 KiB
TypeScript
/**
|
|
* K8S 部署模块 - 用于部署 Claude API 代理服务到 ECI
|
|
*/
|
|
|
|
import { config } from 'dotenv';
|
|
// 使用 override: true 确保覆盖系统环境变量
|
|
config({ override: true });
|
|
|
|
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.DEV_TLS_SECRET_NAME || 'd8d-proxy-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,
|
|
// 抢占式实例
|
|
'k8s.aliyun.com/eci-spot-instance': 'true',
|
|
// 自动创建 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,
|
|
// 抢占式实例
|
|
'k8s.aliyun.com/eci-spot-instance': 'true',
|
|
// 自动创建 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;
|
|
}
|
|
}
|