在AWS EKS上构建异构推理服务:PHP前端、Java中台与Python模型的集成架构决策


定义问题:在遗留系统中集成现代AI能力

一个稳定运行多年的PHP内容管理系统,承载着核心业务。现在的需求是在不进行颠覆性重构的前提下,为其赋予基于机器学习模型的智能推荐能力。模型由Python栈(scikit-learn, PyTorch等)构建,计算密集,且需要独立伸缩。直接将Python运行时嵌入PHP,或者在PHP应用服务器上通过shell_exec调用Python脚本,这些方案因其脆弱性、性能瓶颈和运维噩梦,在架构评审阶段就被直接否决。

核心挑战在于:如何在保证遗留PHP系统稳定性的同时,引入一个高性能、可独立伸靡、可观测的AI推理服务,并建立一套统一的、自动化的部署流程。这是一个典型的异构系统集成问题,考验的是架构层面的权衡能力,而非单一技术的深度。

方案A:API网关直连模型服务

一个直接的思路是服务化。将Python模型封装成一个独立的RESTful API服务,PHP系统通过HTTP调用它。

graph TD
    subgraph "Legacy System"
        PHP_App[PHP Application]
    end

    subgraph "Inference Service"
        Python_Model[Python Model Service]
    end

    PHP_App -->|HTTP Request| API_Gateway[API Gateway]
    API_Gateway -->|Route Traffic| Python_Model

优势分析:

  1. 强解耦: 模型服务的生命周期、技术栈、伸缩策略与PHP系统完全分离。Python团队可以独立迭代模型和部署,无需与PHP团队协调。
  2. 独立伸缩: 在AWS EKS上,可以为模型服务配置独立的Horizontal Pod Autoscaler (HPA),根据CPU或自定义指标(如QPS)自动增减Pod数量,有效应对推理负载的波动。

劣势分析:

  1. 业务逻辑泄露: 原始请求可能需要复杂的预处理(数据清洗、特征提取),响应也可能需要后处理(结果格式化、与业务数据融合)。如果将这些逻辑放在PHP端,会加重遗留系统的负担;如果放在Python模型服务里,则违反了单一职责原则,模型服务会变得臃肿,混杂了大量非推理相关的业务逻辑。
  2. “智能”管道的缺失: 这是一个“哑管道”。它缺乏对调用的精细化控制。例如:
    • 熔断与降级: 当模型服务出现故障或高延迟时,无法优雅地降级,可能导致PHP前端请求连锁超时,拖垮整个系统。
    • 请求缓存: 对于某些输入相同、结果不变的推理请求,缺乏有效的缓存层来降低成本和延迟。
    • 认证与授权: 需要在API网关或模型服务本身实现复杂的认证逻辑,以防未授权的调用。
    • 请求路由与A/B测试: 难以实现复杂的流量路由策略,例如将10%的流量引导至新版模型进行A/B测试。

在真实项目中,这些“管道”能力至关重要。方案A将这些复杂性推给了调用方(PHP)或被调用方(Python),两者都不是理想的承担者。

最终选择:引入Java编排层作为智能中台

为了解决方案A的缺陷,我们引入一个中间服务层,专门负责处理系统间的交互、业务逻辑编排和韧性保障。考虑到团队技术栈、生态成熟度和对高并发、稳定性的要求,我们选择基于Java(Spring Boot WebFlux)构建这个编排服务。

最终架构如下:

graph TD
    subgraph "Legacy System"
        PHP_App[PHP Application]
    end

    subgraph "AWS EKS Cluster"
        subgraph "Orchestration Layer"
            Java_Service[Java Orchestration Service]
        end
        subgraph "Inference Layer"
            Python_Service[Python Model Service]
        end
    end
    
    PHP_App -->|1. Business Request| Java_Service
    Java_Service -->|2. Pre-process & Call| Python_Service
    Python_Service -->|3. Inference Result| Java_Service
    Java_Service -->|4. Post-process & Respond| PHP_App

    subgraph "CI/CD"
        GitHub[GitHub Repo] -- triggers --> GitHub_Actions[GitHub Actions]
        GitHub_Actions -- deploys --> Java_Service
        GitHub_Actions -- deploys --> Python_Service
    end

决策理由:

  1. 职责分离:
    • PHP: 维持其作为前端业务入口的角色,仅负责发起一个定义清晰的业务请求。
    • Java编排服务: 作为系统的“大脑”。它处理认证、请求校验、数据转换、调用模型服务、结果处理、缓存、熔断、日志记录等所有“脏活累活”。Spring Boot生态提供了开箱即用的解决方案(如Resilience4j, Spring Cache),极大地降低了实现复杂度。
    • Python模型服务: 回归其核心职责——执行数学计算。它接收干净、格式化的数据,返回原始的推理结果。这使得模型开发和优化可以更专注。
  2. 性能与韧性: Java的JVM在长时间运行和高并发场景下的性能是公认的。使用Spring WebFlux进行异步非阻塞IO调用,可以在等待模型服务响应时释放线程,极大地提高编排服务的吞吐量。结合Resilience4j等库,可以轻松实现健壮的断路器和重试机制。
  3. 统一运维与可观测性: Java和Python服务都部署在同一个EKS集群中。这使得我们可以利用统一的工具链(Prometheus, Grafana, Fluentd, OpenTelemetry)进行监控、日志收集和分布式链路追踪,从而获得对整个调用链路的端到端可见性。
  4. 技术栈优势互补: 我们利用了PHP在Web生态中的成熟度,Java在构建稳定、高并发后端服务方面的优势,以及Python在数据科学生态中的统治地位。每种技术都在其最擅长的领域发挥作用。

核心实现概览

1. Python模型服务 (FastAPI)

这是一个纯粹的计算服务。我们使用FastAPI因为它性能高,并且自带基于Pydantic的数据校验和文档生成。

model_server/main.py

import numpy as np
import joblib
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, conlist
from typing import List

# 在生产环境中,模型应该在启动时加载一次
# 这里用一个joblib保存的简单模型作为示例
# from sklearn.linear_model import LinearRegression
# X_train = np.array([[1], [2], [3], [4]])
# y_train = np.array([2, 4, 6, 8])
# model = LinearRegression().fit(X_train, y_train)
# joblib.dump(model, 'model.joblib')
try:
    model = joblib.load('model.joblib')
except FileNotFoundError:
    # 这是一个兜底,防止服务在没有模型文件时崩溃
    # 在真实的CI/CD流程中,模型文件应与代码一同打包进镜像
    print("Warning: model.joblib not found. Using a dummy model.")
    model = None

app = FastAPI(
    title="Inference Service",
    description="A simple service to expose a scikit-learn model.",
    version="1.0.0",
)

# 使用Pydantic定义输入数据结构,自动进行类型检查和校验
class InferenceRequest(BaseModel):
    features: List[conlist(float, min_items=1, max_items=10)] # 示例:接受一个包含多个特征列表的列表

class InferenceResponse(BaseModel):
    predictions: List[float]

@app.post("/predict", response_model=InferenceResponse)
async def predict(request: InferenceRequest):
    """
    接收特征数据并返回模型预测结果
    """
    if model is None:
        raise HTTPException(status_code=503, detail="Model is not loaded.")
    
    try:
        # FastAPI/Pydantic已经处理了大部分输入验证
        # 核心逻辑:调用模型进行预测
        predictions = model.predict(request.features).tolist()
        return InferenceResponse(predictions=predictions)
    except Exception as e:
        # 捕获预测过程中可能发生的任何异常
        # 日志记录应该在这里完成
        raise HTTPException(status_code=500, detail=f"Internal server error during prediction: {str(e)}")

@app.get("/health")
def health_check():
    """
    Kubernetes Liveness & Readiness Probe endpoint
    """
    # 真实项目中可以检查模型是否加载成功,或依赖的数据库连接是否正常
    return {"status": "ok"}

model_server/Dockerfile

FROM python:3.9-slim

WORKDIR /app

# 先拷贝依赖文件并安装,可以利用Docker的层缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 拷贝模型文件和应用代码
COPY model.joblib .
COPY main.py .

# 暴露端口
EXPOSE 8000

# 生产环境使用gunicorn等多进程worker启动
CMD ["gunicorn", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "main:app"]

k8s/model-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: model-inference-service
  labels:
    app: model-inference
spec:
  replicas: 2
  selector:
    matchLabels:
      app: model-inference
  template:
    metadata:
      labels:
        app: model-inference
    spec:
      containers:
      - name: model-server
        image: <YOUR_ECR_REPO>/model-inference-service:latest # CI/CD会替换这个tag
        ports:
        - containerPort: 8000
        # 资源请求和限制是保证K8s集群稳定性的关键
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "1"
            memory: "1Gi"
        # 探针配置,让K8s知道你的服务何时是健康的
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 15
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: model-inference-svc
spec:
  selector:
    app: model-inference
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8000
  type: ClusterIP # 只在集群内部暴露

2. Java编排服务 (Spring Boot)

这个服务是架构的核心,它需要健壮、可观测且具备容错能力。

pom.xml (关键依赖)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-spring-boot2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

application.yaml (配置)

server:
  port: 8080

# 模型服务的内部DNS名称
model-service:
  url: http://model-inference-svc/predict # K8s service DNS

# Resilience4j熔断器配置
resilience4j.circuitbreaker:
  instances:
    modelService:
      registerHealthIndicator: true
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 20 # 最近20次调用
      failureRateThreshold: 50 # 失败率超过50%则打开断路器
      waitDurationInOpenState: 10s # 断路器打开后等待10秒进入半开状态
      permittedNumberOfCallsInHalfOpenState: 5 # 半开状态下允许5次请求尝试
      automaticTransitionFromOpenToHalfOpenEnabled: true

OrchestrationController.javaInferenceClient.java

// package ...;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RestController;
// ...其他imports

@RestController
public class OrchestrationController {

    private final InferenceClient inferenceClient;

    public OrchestrationController(InferenceClient inferenceClient) {
        this.inferenceClient = inferenceClient;
    }

    @PostMapping("/api/recommend")
    public Mono<RecommendationResponse> getRecommendations(@RequestBody RecommendationRequest request) {
        // 1. 认证、授权逻辑 (省略)
        
        // 2. 请求校验和数据预处理
        // e.g., 从数据库获取用户画像,构建特征向量
        InferenceRequest inferenceRequest = buildFeaturesFrom(request);

        // 3. 调用模型服务,包含熔断和降级逻辑
        return inferenceClient.getPrediction(inferenceRequest)
                .map(this::processInferenceResult) // 4. 结果后处理
                .doOnError(e -> log.error("Error in recommendation flow", e));
    }
    
    // ... 私有辅助方法 ...
}


@Service
public class InferenceClient {
    
    private static final Logger log = LoggerFactory.getLogger(InferenceClient.class);
    private final WebClient webClient;

    public InferenceClient(WebClient.Builder webClientBuilder, @Value("${model-service.url}") String modelServiceUrl) {
        this.webClient = webClientBuilder.baseUrl(modelServiceUrl).build();
    }
    
    @CircuitBreaker(name = "modelService", fallbackMethod = "fallbackPrediction")
    public Mono<InferenceResponse> getPrediction(InferenceRequest request) {
        return webClient.post()
                .body(BodyInserters.fromValue(request))
                .retrieve()
                .bodyToMono(InferenceResponse.class)
                .timeout(Duration.ofSeconds(2)); // 设置超时
    }

    /**
     * 熔断器的降级方法。
     * 它的方法签名必须与受保护的方法匹配,并额外接收一个Throwable参数。
     */
    public Mono<InferenceResponse> fallbackPrediction(InferenceRequest request, Throwable t) {
        log.warn("Fallback triggered for model service. Reason: {}", t.getMessage());
        // 降级逻辑:可以返回一个默认的、通用的推荐列表,或者空列表
        // 保证即使AI服务不可用,主业务流程也不会中断
        return Mono.just(new InferenceResponse(Collections.emptyList())); 
    }
}

3. 统一的CI/CD流程 (GitHub Actions)

一个仓库管理所有服务(monorepo)可以简化CI/CD。我们使用路径触发来避免不必要的构建。

.github/workflows/deploy.yml

name: Deploy Services to EKS

on:
  push:
    branches:
      - main
    paths:
      - 'java-orchestrator/**'
      - 'model-server/**'

jobs:
  build-and-deploy-java:
    if: startsWith(github.event.head_commit.message, 'feat(java)') || contains(join(github.event.commits.*.message), '[java]')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push image to ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: java-orchestration-service
          IMAGE_TAG: ${{ github.sha }}
        run: |
          cd java-orchestrator
          # 使用Maven或Gradle构建JAR包,然后构建Docker镜像
          # ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

      - name: Update Kubernetes deployment
        run: |
          # 配置kubectl连接到EKS集群
          aws eks update-kubeconfig --name <YOUR_EKS_CLUSTER_NAME> --region us-east-1
          # 使用kubectl set image命令来触发滚动更新
          kubectl set image deployment/java-orchestrator-deployment java-orchestrator-container=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

  build-and-deploy-python:
    if: startsWith(github.event.head_commit.message, 'feat(python)') || contains(join(github.event.commits.*.message), '[python]')
    runs-on: ubuntu-latest
    # ... 步骤与Java作业类似,只是路径和镜像名称不同 ...
    steps:
      # ... AWS & ECR Login ...
      - name: Build, tag, and push image to ECR
        env:
          # ... env vars ...
        run: |
          cd model-server
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      
      - name: Update Kubernetes deployment
        run: |
          aws eks update-kubeconfig --name <YOUR_EKS_CLUSTER_NAME> --region us-east-1
          kubectl set image deployment/model-inference-service model-server=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

架构的扩展性与局限性

扩展路径:

  1. 异步化: 对于非实时或耗时较长的推理任务,可以在Java和Python服务之间引入消息队列(如AWS SQS)。PHP发起请求,Java服务将任务投递到队列后立即返回一个任务ID,PHP可以通过该ID轮询结果。这进一步解耦了系统,提高了吞吐量。
  2. 服务网格: 当服务数量增多,调用关系变得复杂时,可以引入Istio或Linkerd。服务网格可以提供透明的mTLS、更精细的流量控制(如金丝雀发布)、开箱即用的遥测数据,而无需在应用代码中实现。
  3. 模型A/B测试: 利用Kubernetes Service或服务网格的流量切分能力,可以轻松部署一个新版本的模型服务(model-inference-service-v2),并将一小部分流量导入其中,通过比较业务指标来验证新模型的效果。

当前局限性:

  1. 运维复杂度: 引入了新的服务和技术栈,对运维团队提出了更高的要求。需要熟悉Kubernetes、JVM和Python运行时的监控与调优。
  2. 网络延迟: 增加了一次网络跳跃(PHP -> Java -> Python)。虽然在EKS集群内部的延迟通常很低(亚毫秒级),但在极端性能敏感的场景下,这仍然是一个需要考量的因素。服务间的调用必须设计为高效的,避免传输不必要的数据。
  3. 分布式事务: 当前架构不涉及复杂的跨服务写操作。如果未来的需求需要保证多个服务操作的原子性,那么将面临分布式事务的挑战,可能需要引入Saga等模式,进一步增加系统复杂性。

  目录