利用TDD与Cilium为Laravel微服务构建API感知的零信任网络策略


一个正在从单体架构向微服务迁移的Laravel项目中,最棘手的挑战之一并非业务逻辑的拆分,而是如何在服务间建立既安全又可观测的通信边界。当TransactionService需要调用UserService时,我们面临一个根本性的安全问题:如何确保它只能调用GET /api/v1/users/{id}/balance这个端点,而不能触碰任何其他API,甚至不能对UserService的Pod进行端口扫描?

传统的做法通常在两个层面寻找答案:应用层与网络层。

方案A,应用层认证授权。我们可以为服务间通信引入OAuth 2.0的客户端凭证模式,TransactionService携带一个具有特定scope(例如read:user_balance)的JWT访问UserServiceUserService的中间件负责校验Token及其scope

  • 优点: 这是应用开发人员最熟悉的领域,控制粒度可以非常精细,与业务逻辑结合紧密。
  • 缺点:
    1. 侵入性强: 每个服务都需要集成JWT校验的逻辑,增加了代码的复杂性和维护成本。
    2. 性能开销: 每次请求都涉及加密解密和签名验证。
    3. 安全盲区: 如果TransactionService本身被攻陷,攻击者虽然无法调用其他业务API,但仍能对UserService的IP进行网络探测,寻找其他潜在漏洞。它只解决了“能不能做”,没解决“能不能通”的问题。

方案B,基础设施层使用服务网格(Service Mesh),如Istio。通过在每个Pod中注入一个Sidecar代理(如Envoy),所有流量都被代理拦截。我们可以在Istio的AuthorizationPolicy中定义L7规则,精确到HTTP方法和路径。

  • 优点: 与应用代码解耦,提供了强大的流量管理、mTLS加密和深度可观测性。
    • 缺点:
      1. 资源消耗: Sidecar模式会显著增加内存和CPU的开销,尤其是在大规模部署时。
      2. 运维复杂性: 引入了一整套新的控制平面和数据平面组件,学习曲线陡峭,排障难度大。对于我们当前仅需解决核心访问控制的目标而言,这套方案显得过于重型。

这就引出了我们最终的选择:将测试驱动开发(TDD)的理念延伸至基础设施安全,并利用Cilium提供的基于eBPF的内核级网络策略来实现。这个方案的核心思想是:开发人员通过编写测试用例来定义服务间的预期通信行为,而Cilium则在内核层面将这些预期转化为强制性的、不可绕过的L7网络安全策略。

这种方法的优势在于它结合了方案A的精确性和方案B的非侵入性,同时避免了它们的主要缺点。它没有Sidecar的资源开销,策略执行在内核中,效率极高。更重要的是,它将安全策略的定义与应用的开发测试流程无缝集成,实现了真正的DevSecOps。

核心实现:TDD驱动的安全策略落地

我们的场景包含两个Laravel服务:user-servicetransaction-service。它们都运行在同一个Kubernetes命名空间financial-services中。

步骤一:使用TDD定义transaction-service的外部依赖

transaction-service中,我们需要一个功能来创建交易,而这需要先获取用户的余额。我们从一个测试用例开始。

tests/Feature/TransactionCreationTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
use App\Services\UserServiceClient;
use App\Exceptions\UserBalanceFetchException;
use App\Exceptions\InsufficientBalanceException;
use \Illuminate\Http\Client\RequestException;

class TransactionCreationTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     * 正常情况下,当用户服务返回足够余额时,交易应成功创建
     */
    public function it_creates_transaction_when_user_service_provides_sufficient_balance()
    {
        // 模拟 UserService 的成功响应
        // 这里的关键是定义了期望的外部API端点和响应结构
        Http::fake([
            'user-service.financial-services.svc.cluster.local/api/v1/users/1/balance' => Http::response(['balance' => 1000.00], 200),
            // 捕获所有其他未被模拟的请求
            '*' => Http::response('Not Found', 404) 
        ]);

        $response = $this->postJson('/api/v1/transactions', [
            'user_id' => 1,
            'amount' => 500.00,
            'description' => 'Test transaction'
        ]);

        $response->assertStatus(201)
                 ->assertJsonPath('data.status', 'completed');
        
        $this->assertDatabaseHas('transactions', [
            'user_id' => 1,
            'amount' => 500.00
        ]);
    }

    /**
     * @test
     * 当用户服务返回余额不足时,应抛出特定异常并返回422错误
     */
    public function it_fails_with_422_if_balance_is_insufficient()
    {
        Http::fake([
            'user-service.financial-services.svc.cluster.local/api/v1/users/1/balance' => Http::response(['balance' => 400.00], 200),
            '*' => Http::response('Not Found', 404)
        ]);

        $response = $this->postJson('/api/v1/transactions', [
            'user_id' => 1,
            'amount' => 500.00,
            'description' => 'Test transaction'
        ]);

        $response->assertStatus(422) // Unprocessable Entity
                 ->assertJsonPath('message', 'Insufficient balance for the transaction.');
    }

    /**
     * @test
     * 当无法连接到用户服务时,应返回503服务不可用错误
     */
    public function it_returns_503_when_user_service_is_unreachable()
    {
        // 模拟网络错误,这将由我们的网络策略在真实环境中触发
        Http::fake(function ($request) {
            // 通过抛出RequestException来模拟网络层面的连接失败
            throw new RequestException($request);
        });

        $response = $this->postJson('/api/v1/transactions', [
            'user_id' => 1,
            'amount' => 500.00,
            'description' => 'Test transaction'
        ]);

        $response->assertStatus(503) // Service Unavailable
                 ->assertJsonPath('message', 'Failed to communicate with User Service.');
    }
}

这个测试文件不仅验证了业务逻辑,更重要的是,它显式地声明了transaction-serviceuser-service的唯一依赖:GET user-service.financial-services.svc.cluster.local/api/v1/users/{id}/balance。任何偏离此契约的行为都应被视为异常。

接下来是实现代码,让测试通过。

app/Services/UserServiceClient.php:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Exceptions\UserBalanceFetchException;
use \Illuminate\Http\Client\RequestException;

class UserServiceClient
{
    protected string $baseUrl;

    public function __construct()
    {
        // 在真实环境中,服务地址通过环境变量配置,并指向K8s Service DNS
        $this->baseUrl = config('services.user_service.base_url');
    }

    /**
     * 获取用户余额
     *
     * @param int $userId
     * @return float
     * @throws UserBalanceFetchException
     */
    public function getBalance(int $userId): float
    {
        $url = "{$this->baseUrl}/api/v1/users/{$userId}/balance";

        try {
            $response = Http::timeout(3) // 设置3秒超时,防止网络问题导致长时间阻塞
                            ->retry(2, 100) // 简单重试机制,间隔100ms
                            ->get($url);

            $response->throw(); // 如果HTTP状态码为4xx或5xx,则抛出异常

            $data = $response->json();
            
            if (!isset($data['balance'])) {
                Log::error('Invalid response structure from User Service', ['response' => $data]);
                throw new UserBalanceFetchException('Invalid response from User Service.');
            }

            return (float) $data['balance'];

        } catch (RequestException $e) {
            // 捕获所有连接、超时等HTTP客户端异常
            Log::critical('Failed to connect to User Service', [
                'url' => $url,
                'error' => $e->getMessage(),
            ]);
            throw new UserBalanceFetchException('Failed to communicate with User Service.', 0, $e);
        }
    }
}

此时,运行./vendor/bin/phpunit,所有测试都应该通过。我们已经通过TDD的方式,在代码层面定义并验证了服务间的通信契约。

步骤二:部署服务并建立默认拒绝的网络策略

现在,我们将这两个服务部署到Kubernetes集群中。假设我们已经为每个服务构建了Docker镜像,并创建了对应的Deployment和Service。

deployment.yaml (片段):

# user-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
  namespace: financial-services
  labels:
    app: user-service
spec:
  # ...
  template:
    metadata:
      labels:
        app: user-service # 关键标签,用于策略选择
    # ...

---
# user-service-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: user-service
  namespace: financial-services
spec:
  selector:
    app: user-service
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8000

# transaction-service-deployment.yaml 和 service.yaml 类似

在应用这些资源后,transaction-service默认可以访问user-service。现在,我们应用一个全局的默认拒绝策略,锁死financial-services命名空间内的所有通信。

default-deny-policy.yaml:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "default-deny-all"
  namespace: financial-services
spec:
  endpointSelector: {} # 选择命名空间中的所有Pod
  ingress: [] # 空的ingress列表,拒绝所有入站流量
  egress: []  # 空的egress列表,拒绝所有出站流量

执行kubectl apply -f default-deny-policy.yaml。此时,如果我们尝试在transaction-service的Pod中执行curl http://user-service,请求将会超时。我们的单元测试如果在一个可以连接到集群的CI/CD环境中运行,也会因为网络不通而失败。这正是我们想要的零信任起点。

步骤三:编写并应用API感知的Cilium网络策略

这是最关键的一步。我们将TDD测试中定义的通信契约,翻译成一个CiliumNetworkPolicy

transaction-to-user-policy.yaml:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "allow-transaction-to-user-balance-api"
  namespace: financial-services
spec:
  # 策略应用到 transaction-service Pod
  endpointSelector:
    matchLabels:
      app: transaction-service
  
  # 定义出站规则 (Egress)
  egress:
  - toEndpoints:
    - matchLabels:
        app: user-service # 目标是 user-service Pod
    
    # 这里的 toPorts 定义了L4和L7规则
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP
      
      # rules 定义了L7层的HTTP策略
      rules:
        http:
        - method: "GET"
          # 使用正则表达式匹配路径,允许任意用户ID
          path: "/api/v1/users/[0-9]+/balance"
          headers:
            - "User-Agent: GuzzleHttp/7" # 可选:增加header匹配,进一步增强安全性

  # 我们还需要允许DNS查询,以便服务可以通过名称解析
  - toEndpoints:
    - matchLabels:
        "k8s:io.kubernetes.pod.namespace": kube-system
        "k8s:k8s-app": kube-dns
    toPorts:
    - ports:
      - port: "53"
        protocol: UDP
      rules:
        dns:
        - matchPattern: "*.financial-services.svc.cluster.local"
        - matchPattern: "user-service.financial-services.svc.cluster.local"

这个策略的每一部分都至关重要:

  1. endpointSelector: 确定此策略应用于哪个源Pod(transaction-service)。
  2. egress.toEndpoints: 定义了允许访问的目标Pod(user-service)。
  3. toPorts.ports: L4规则,允许访问目标Pod的80端口。
  4. toPorts.rules.http: 核心所在。这是Cilium的L7策略,它指示eBPF程序在内核中解析HTTP流量,仅当methodGETpath匹配/api/v1/users/[0-9]+/balance模式时才放行。
  5. DNS规则: 这是一个常见的坑。在默认拒绝的环境下,必须显式允许Pod对Kubernetes内部DNS服务(kube-dns)的访问,否则服务发现会失败。

应用此策略:kubectl apply -f transaction-to-user-policy.yaml

现在,再次进入transaction-service的Pod中进行测试:

  • curl http://user-service/api/v1/users/1/balance -> 应该成功返回余额。
  • curl http://user-service/api/v1/users/1 -> 请求将被Cilium在内核层面直接丢弃,客户端表现为连接超时或被重置。
  • curl http://google.com -> 请求同样会被默认拒绝策略拦截。

步骤四:使用Hubble进行可观测性验证

Cilium自带的可观测性工具Hubble,能够让我们直观地看到策略的执行效果。

# 开启Hubble端口转发
cilium hubble port-forward&

# 使用Hubble CLI观察流量
# --from: 源Pod标签, --to: 目标Pod标签, -n: 命名空间
# 观察最近的流量,包括被拒绝的
hubble observe --from app=transaction-service --to app=user-service -n financial-services --last 10

当一个被拒绝的请求发生时,Hubble的输出会清晰地标示出policy-verdict: DENIEDdrop-reason: Policy denied at Egress

sequenceDiagram
    participant TS as transaction-service (Pod)
    participant Kernel as Linux Kernel (eBPF)
    participant US as user-service (Pod)

    TS->>Kernel: HTTP GET /api/v1/users/1/balance
    note right of Kernel: eBPF程序检查流量
源: app=transaction-service
目的: app=user-service
协议: TCP/80
Method: GET
Path: /api/v1/users/1/balance
**策略匹配 -> ALLOW** Kernel->>US: Forward Packet US-->>Kernel: HTTP 200 OK Kernel-->>TS: Forward Response TS->>Kernel: HTTP GET /api/v1/users/1 note right of Kernel: eBPF程序检查流量
源: app=transaction-service
目的: app=user-service
协议: TCP/80
Method: GET
Path: /api/v1/users/1
**策略不匹配 -> DROP** Kernel-->>TS: Packet Dropped (or TCP RST)

这个流程图展示了eBPF在内核中作为决策点的关键作用。所有网络包在离开transaction-service的网络命名空间时,都会被Cilium加载的eBPF程序拦截并检查,符合策略的才会被转发。

架构的扩展性与局限性

这种将TDD与Cilium L7策略结合的模式,为构建零信任环境下的微服务提供了一个轻量级且高效的方案。在真实项目中,这个模式可以进一步扩展:

  • 策略即代码 (Policy as Code): 将Cilium的YAML策略文件与应用代码放在同一个Git仓库中,通过CI/CD流水线进行自动化部署。对通信契约的任何变更(例如,transaction-service需要调用一个新的UserService端点),都必须先修改或增加一个Feature Test,然后同步更新CiliumNetworkPolicy文件,确保测试、代码和安全策略三者同步演进。
  • gRPC支持: Cilium同样支持对gRPC流量的L7解析,可以根据servicemethod来制定策略,这对于使用gRPC进行服务间通信的场景同样适用。

然而,这个方案也存在其适用边界和需要注意的细节:

  1. 协议限制: Cilium的L7策略目前主要支持HTTP、gRPC、Kafka等常见应用层协议。对于自定义的TCP/UDP协议,它只能回退到L3/L4策略,无法进行应用层内容的解析。
  2. HTTPS流量: 如果服务间通信启用了TLS(mTLS),Cilium将无法解析加密的流量内容,L7策略会失效。在这种情况下,需要依赖服务网格(如Istio)来处理TLS终止和L7策略,或者接受只在L4层面进行隔离。在受信任的集群内部网络,有时会选择在网络层面强制隔离,而在应用层面使用普通HTTP通信,以简化部署并获得L7策略的能力,这是一种架构上的权衡。
  3. 策略复杂度管理: 随着服务数量和交互的增多,手动编写和管理大量的CiliumNetworkPolicy文件会变得困难。需要引入Helm、Kustomize或专门的GitOps工具来模板化和管理这些策略。

最终,我们选择的并非一个单一的技术工具,而是一套融合了开发、测试与安全运维的工作流。它将安全责任左移,让开发者在定义功能的同时,也清晰地定义了服务的安全边界,并通过底层强大的eBPF技术,将这个边界以最小的性能损耗、最强的隔离性在生产环境中强制执行。


  目录