在这里插入图片描述

👋 大家好,欢迎来到我的技术博客!
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕SkyWalking这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!


SkyWalking - 构建多租户 SaaS APM 平台:基于 namespace 隔离

在现代云原生和微服务架构盛行的时代,应用性能监控(APM)已成为保障系统稳定性和可观测性的关键组件。Apache SkyWalking 作为一款开源的、功能强大的 APM 系统,因其对分布式追踪、服务拓扑、指标监控等能力的全面支持,被广泛应用于各类企业级场景。然而,当我们将 SkyWalking 应用于 SaaS(Software as a Service)平台时,一个核心挑战浮出水面:如何安全、高效地实现多租户隔离?

多租户 SaaS 平台的核心诉求是“一平台多客户”,即多个客户(租户)共享同一套基础设施,但彼此的数据、配置和视图必须严格隔离,互不干扰。这不仅关乎数据安全与隐私合规,也直接影响用户体验和平台可扩展性。

本文将深入探讨如何基于 namespace(命名空间)机制,构建一个支持多租户的 SkyWalking SaaS APM 平台。我们将从理论基础、架构设计、配置实践、代码集成到运维考量,全方位剖析这一方案,并提供可落地的 Java 代码示例,帮助你构建一个既强大又安全的多租户监控体系。🚀

什么是多租户与 Namespace 隔离?

在深入技术细节之前,让我们先厘清两个核心概念。

多租户(Multi-tenancy)

多租户是一种软件架构模式,其中单个软件实例可以为多个租户(通常是不同的组织或客户)提供服务。每个租户的数据和配置在逻辑上是隔离的,尽管它们可能物理上共享相同的服务器、数据库或网络资源。这种模式极大地降低了 SaaS 提供商的运营成本,并简化了版本更新和维护。

在 APM 的上下文中,多租户意味着:

  • 租户 A 只能看到自己应用的拓扑、追踪和指标。
  • 租户 B 无法访问或感知租户 A 的任何监控数据。
  • 每个租户可以拥有独立的告警规则、仪表盘甚至自定义的采样策略。

Namespace(命名空间)隔离

Namespace 是一种通用的逻辑隔离机制,广泛应用于 Kubernetes、数据库、消息队列等系统中。其核心思想是为不同的实体(如租户)分配一个唯一的标识符(即 namespace),所有属于该实体的资源都必须带上这个标识符。系统在处理请求时,会根据这个标识符来过滤和路由数据,从而实现隔离。

在 SkyWalking 中,serviceservice instance 是核心的监控对象。通过引入 namespace 概念,我们可以将这些对象进行逻辑分组。例如,租户 A 的服务可以命名为 tenant-a::order-service,而租户 B 的同名服务则为 tenant-b::order-service。SkyWalking OAP(Observability Analysis Platform)后端在存储和查询时,会将 namespace 作为关键的上下文信息,确保数据不会交叉。

为什么选择 Namespace 而不是其他方式?
相比于为每个租户部署一套独立的 SkyWalking 实例(物理隔离),Namespace 方案具有显著的成本优势和管理便利性。它避免了资源的重复浪费,同时简化了平台的统一升级和维护。虽然物理隔离提供了最强的安全边界,但对于大多数 SaaS 场景而言,逻辑隔离(Namespace)在安全性、成本和复杂性之间取得了最佳平衡。

SkyWalking 的多租户支持现状

Apache SkyWalking 项目本身对多租户的支持是一个渐进的过程。早期版本主要面向单租户场景,但随着社区需求的增长,官方逐渐引入了相关特性。

核心机制:serviceservice instance 的命名

SkyWalking 最核心的隔离单元是 service。在默认情况下,服务的名称由探针(Agent)自动上报,通常基于应用的名称(如 Spring Boot 应用的 spring.application.name)。为了实现多租户,最直接的方式就是在服务名前加上租户标识。

例如:

  • 租户 acme-corp 的用户服务:acme-corp_user-service
  • 租户 globex-industries 的用户服务:globex-industries_user-service

这种方式简单有效,OAP 后端天然就能将它们视为两个完全不同的服务,从而在 UI 上、数据库中实现隔离。

官方支持:namespace 字段

从 SkyWalking 8.x 版本开始,项目引入了更正式的 namespace 概念。在 OAP 的配置文件 application.yml 中,你可以看到相关的配置项。更重要的是,在数据模型层面,ServiceServiceInstance 等实体都支持一个 namespace 字段。

这意味着,你不再需要将租户 ID “硬编码”到服务名中,而是可以通过一个独立的字段来传递租户上下文。这使得数据模型更加清晰,也便于未来实现更复杂的多租户策略(如跨租户聚合分析)。

当前限制与社区发展

尽管有了 namespace 字段,但截至本文撰写时,SkyWalking 的 Web UI(RocketBot)对多租户的原生支持仍然有限。UI 本身并不知道“租户”的存在,它只是忠实地展示 OAP 后端返回的数据。因此,完整的多租户 SaaS 平台通常需要你在 SkyWalking 之上构建一层自己的业务逻辑层,负责:

  1. 租户认证与授权:识别当前用户属于哪个租户。
  2. 上下文注入:在查询 OAP 时,自动附加 namespace 过滤条件。
  3. 定制化 UI:为不同租户提供个性化的仪表盘和视图。

好消息是,SkyWalking 的后端 API(GraphQL)非常强大和灵活,为你构建这层业务逻辑提供了坚实的基础。

架构设计:构建你的多租户 SaaS 平台

一个健壮的多租户 SaaS APM 平台,其架构应该清晰地分离关注点。下图展示了一个典型的分层架构:

1. 带namespace的探针
1. 带namespace的探针
2. GraphQL查询
携带租户ID
3. 重写查询
注入namespace
4. 返回租户数据
5. 返回给前端
6. 验证租户身份

租户A的应用

SkyWalking OAP

租户B的应用

SaaS 平台前端

SaaS 业务网关/服务

认证中心

让我们分解每一层:

1. 数据采集层(Agent Layer)

这是整个监控链路的起点。你需要确保每个租户的应用在启动时,其 SkyWalking Agent 能够正确地注入租户的 namespace

  • 对于 Java 应用:可以通过 JVM 参数或环境变量动态设置。
  • 对于其他语言:SkyWalking 为 Go、Node.js、Python 等提供了相应的探针,同样支持配置 namespace

关键点在于,namespace 的值必须与租户在你的 SaaS 平台中的唯一标识符一致。这通常是在应用部署时由你的 CI/CD 流程或 PaaS 平台注入的。

2. 数据处理与存储层(OAP Layer)

这是 SkyWalking 的核心。OAP 负责接收来自 Agent 的遥测数据(Trace, Metrics, Logs),进行分析、聚合,并最终持久化到后端存储(如 Elasticsearch, MySQL, TiDB 等)。

在这个多租户场景中,OAP 的角色是“无状态”的数据处理器。它只关心数据流中的 namespace 字段,并据此进行数据的隔离存储。你不需要为每个租户运行单独的 OAP 实例,一个集群即可服务所有租户。

3. 业务逻辑层(SaaS Gateway/Service)

这是你构建 SaaS 平台的关键。它位于你的前端和 SkyWalking OAP 之间,扮演着“智能代理”的角色。

  • 认证与授权:当用户登录你的 SaaS 平台时,你的认证服务会颁发一个包含租户 ID 的 JWT Token。业务网关在收到前端的任何查询请求时,首先验证此 Token,并从中提取出 tenantId
  • 查询重写:网关接收到前端的 GraphQL 查询后,会解析该查询,并在所有涉及服务、实例、端点等实体的地方,自动注入 namespace: "tenantId" 的过滤条件。
  • 结果返回:将重写后的查询发送给 OAP,获取结果后,再原样返回给前端。

通过这一层,你的前端可以像使用单租户 SkyWalking 一样进行开发,而无需关心底层的多租户逻辑。所有的隔离魔法都在网关中完成。

4. 用户交互层(SaaS Frontend)

这是租户用户直接交互的界面。它可以是基于 SkyWalking RocketBot UI 的深度定制,也可以是一个完全自研的前端。

无论哪种方式,前端只需要与你的 SaaS 业务网关 通信,而不是直接与 SkyWalking OAP 对话。这保证了安全性和灵活性。

实战:Java 应用集成与配置

理论是灰色的,实践之树常青。现在,让我们通过具体的 Java 代码和配置,看看如何将一个多租户 Java 应用接入 SkyWalking。

步骤 1:准备 SkyWalking Agent

首先,你需要下载 SkyWalking Java Agent。假设你已经将其解压到 /opt/skywalking-agent

步骤 2:在应用中动态设置 Namespace

最优雅的方式是通过环境变量或系统属性来传递 namespace。这样,你的应用代码无需硬编码任何租户信息,完全由部署环境决定。

方法 A:通过 JVM 参数

在启动你的 Java 应用时,添加如下 JVM 参数:

-javaagent:/opt/skywalking-agent/skywalking-agent.jar
-Dskywalking.agent.namespace=${TENANT_ID}
-Dskywalking.service_name=${APP_NAME}

这里,${TENANT_ID}${APP_NAME} 是由你的部署脚本(如 Dockerfile、Kubernetes Deployment)注入的环境变量。

方法 B:通过 agent.config 文件

你也可以直接修改 skywalking-agent/config/agent.config 文件:

# agent.config
agent.namespace=${SW_AGENT_NAMESPACE:default-namespace}
agent.service_name=${SW_AGENT_NAME:Your_ApplicationName}

然后在启动时通过环境变量覆盖:

export SW_AGENT_NAMESPACE=acme-corp
export SW_AGENT_NAME=user-service
java -javaagent:/opt/skywalking-agent/skywalking-agent.jar -jar your-app.jar

步骤 3:Spring Boot 应用示例

假设你有一个标准的 Spring Boot 应用。为了让 namespace 的设置更加灵活,你可以在应用启动时读取一个自定义的配置。

// TenantContext.java
package com.yourcompany.tenant;

import org.springframework.stereotype.Component;

@Component
public class TenantContext {
    // 从环境变量或配置文件中读取租户ID
    private static final String TENANT_ID = System.getenv("TENANT_ID") != null 
        ? System.getenv("TENANT_ID") 
        : "default";

    public static String getTenantId() {
        return TENANT_ID;
    }
}

虽然上面的代码在运行时读取了 TENANT_ID,但请注意,SkyWalking Agent 是在 JVM 启动的极早期就初始化的,远早于 Spring 容器的启动。因此,你不能依赖 Spring 的 @Value@ConfigurationProperties 来设置 Agent 的 namespace

所以,强烈推荐使用方法 A 或 B,即在 JVM 启动参数或 agent.config 中直接设置。TenantContext 类在这里的作用更多是为了在你的业务代码中也能方便地获取租户上下文,以实现应用内部的多租户逻辑(如数据库行级隔离)。

步骤 4:验证数据上报

启动应用后,打开 SkyWalking UI(假设你已部署好 OAP 和 UI)。你应该能在服务列表中看到类似 acme-corp::user-service 的服务(具体格式取决于 OAP 的配置和版本)。

点击进入该服务,你应该只能看到属于 acme-corp 租户的追踪、拓扑和指标。如果一切正常,说明数据采集层的隔离已经成功。

构建 SaaS 业务网关:查询重写

现在,我们解决了数据上报的隔离问题。接下来,我们需要解决数据查询的隔离问题。这就是 SaaS 业务网关的用武之地。

我们将使用 Spring Boot 构建一个简单的网关服务。

1. 项目依赖

首先,添加必要的依赖到 pom.xml

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Boot WebFlux (可选,用于异步非阻塞) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    
    <!-- GraphQL Client -->
    <dependency>
        <groupId>com.graphql-java</groupId>
        <artifactId>graphql-java</artifactId>
        <version>20.0</version>
    </dependency>
    
    <!-- JWT for authentication -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

2. 配置文件

application.yml:

server:
  port: 8080

skywalking:
  oap:
    url: http://skywalking-oap:12800/graphql # OAP 的 GraphQL 端点

jwt:
  secret: your-super-secret-key-for-jwt-signing

3. 认证拦截器

创建一个拦截器,用于从请求头中提取 JWT Token 并验证,从中解析出 tenantId

// JwtAuthenticationInterceptor.java
package com.yourcompany.gateway.interceptor;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class JwtAuthenticationInterceptor implements HandlerInterceptor {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }

        String token = authHeader.substring(7); // Remove "Bearer "
        try {
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(jwtSecret.getBytes())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
            
            // 假设 JWT payload 中包含 tenantId
            String tenantId = claims.get("tenantId", String.class);
            if (tenantId == null || tenantId.isEmpty()) {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                return false;
            }
            
            // 将 tenantId 存入请求属性,供后续处理器使用
            request.setAttribute("tenantId", tenantId);
            return true;
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
    }
}

注册拦截器:

// WebConfig.java
package com.yourcompany.gateway.config;

import com.yourcompany.gateway.interceptor.JwtAuthenticationInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtAuthenticationInterceptor jwtAuthenticationInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtAuthenticationInterceptor)
                .addPathPatterns("/graphql/**"); // 拦截所有 GraphQL 请求
    }
}

4. GraphQL 查询重写核心逻辑

这是最关键的一步。我们需要一个工具类,能够解析传入的 GraphQL 查询字符串,并在适当的位置注入 namespace 参数。

由于直接操作 GraphQL AST(抽象语法树)比较复杂,一个更实用的方法是利用字符串替换,但这要求前端查询遵循一定的规范。

约定:前端在查询服务、实例等相关实体时,必须在查询变量(variables)中提供一个占位符,例如 __NAMESPACE_PLACEHOLDER__

网关的职责就是将这个占位符替换为真实的 tenantId

// GraphqlQueryRewriter.java
package com.yourcompany.gateway.util;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Component;

@Component
public class GraphqlQueryRewriter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 重写 GraphQL 查询,注入 namespace
     * @param originalQuery 原始查询字符串
     * @param originalVariables 原始查询变量 JSON 字符串
     * @param tenantId 租户ID
     * @return 重写后的查询对象
     */
    public RewrittenQuery rewrite(String originalQuery, String originalVariables, String tenantId) {
        // 1. 重写 variables
        String rewrittenVariables = injectNamespaceIntoVariables(originalVariables, tenantId);
        
        // 2. (可选)如果查询体中也有硬编码的 namespace,也可以在这里重写
        // 对于大多数情况,只重写 variables 就足够了
        
        return new RewrittenQuery(originalQuery, rewrittenVariables);
    }

    private String injectNamespaceIntoVariables(String variablesJson, String tenantId) {
        try {
            if (variablesJson == null || variablesJson.isEmpty()) {
                ObjectNode newVars = objectMapper.createObjectNode();
                newVars.put("namespace", tenantId);
                return newVars.toString();
            }

            JsonNode variables = objectMapper.readTree(variablesJson);
            if (variables.isObject()) {
                ((ObjectNode) variables).put("namespace", tenantId);
                return variables.toString();
            } else {
                throw new IllegalArgumentException("Variables must be a JSON object");
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to inject namespace into variables", e);
        }
    }

    public static class RewrittenQuery {
        private final String query;
        private final String variables;

        public RewrittenQuery(String query, String variables) {
            this.query = query;
            this.variables = variables;
        }

        // getters...
        public String getQuery() { return query; }
        public String getVariables() { return variables; }
    }
}

5. GraphQL 代理控制器

最后,创建一个控制器,接收前端的 GraphQL 请求,经过认证和重写后,转发给 SkyWalking OAP。

// GraphqlProxyController.java
package com.yourcompany.gateway.controller;

import com.yourcompany.gateway.util.GraphqlQueryRewriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@RestController
@RequestMapping("/graphql")
public class GraphqlProxyController {

    @Value("${skywalking.oap.url}")
    private String skywalkingOapUrl;

    @Autowired
    private GraphqlQueryRewriter queryRewriter;

    @Autowired
    private RestTemplate restTemplate;

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<String> proxyGraphqlRequest(
            @RequestBody Map<String, Object> requestBody,
            HttpServletRequest request) {

        // 从拦截器中获取 tenantId
        String tenantId = (String) request.getAttribute("tenantId");

        // 提取原始查询和变量
        String originalQuery = (String) requestBody.get("query");
        String originalVariables = objectMapper.writeValueAsString(requestBody.get("variables"));

        // 重写查询
        GraphqlQueryRewriter.RewrittenQuery rewritten = 
            queryRewriter.rewrite(originalQuery, originalVariables, tenantId);

        // 构建新的请求体
        MultiValueMap<String, String> newBody = new LinkedMultiValueMap<>();
        newBody.add("query", rewritten.getQuery());
        newBody.add("variables", rewritten.getVariables());

        // 转发请求到 SkyWalking OAP
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(newBody, headers);

        ResponseEntity<String> oapResponse = restTemplate.exchange(
                skywalkingOapUrl,
                HttpMethod.POST,
                entity,
                String.class
        );

        return ResponseEntity.status(oapResponse.getStatusCode())
                .body(oapResponse.getBody());
    }
}

6. 前端查询示例

现在,前端可以像这样发起查询:

// 前端 JavaScript 代码
const query = `
  query getService($serviceId: ID!, $duration: Duration!) {
    service: getService(id: $serviceId) {
      name
      // ... other fields
    }
    // ... other queries
  }
`;

const variables = {
  serviceId: "some-service-id",
  duration: { start: "2023-10-01", end: "2023-10-02" },
  // 注意:不需要显式提供 namespace!
  // 网关会自动注入
};

fetch('/graphql', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer <your-jwt-token>',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ query, variables })
})
.then(response => response.json())
.then(data => console.log(data));

通过这种方式,前端开发者完全无需关心多租户的实现细节,极大地提升了开发体验。

数据存储与性能考量

多租户架构在带来便利的同时,也对底层存储提出了更高的要求。让我们看看在使用 SkyWalking 时需要注意什么。

存储模型

SkyWalking 支持多种后端存储,最常用的是 Elasticsearch。在多租户场景下,理解其数据模型至关重要。

SkyWalking 在 ES 中会创建一系列索引,例如:

  • service_inventory-*
  • service_instance_inventory-*
  • segment-* (用于存储 Trace)

在启用了 namespace 后,service_inventory 文档的结构大致如下:

{
  "name": "user-service",
  "namespace": "acme-corp",
  "layer": "GENERAL",
  // ... other fields
}

关键点在于,namespace 是文档的一个普通字段。这意味着:

  • 查询隔离:当你查询 namespace: "acme-corp" 时,ES 只会返回匹配的文档。
  • 无物理隔离:所有租户的数据都混合存储在同一个索引中。

性能与扩展性

这种混合存储模型在中小规模租户数量下表现良好。ES 强大的倒排索引能够高效地处理 namespace 字段的过滤。

然而,当租户数量激增(成千上万)或单个租户的数据量极大时,可能会遇到以下挑战:

  1. “噪声”问题:一个超大租户的查询可能会因为数据量过大而影响整个 ES 集群的性能,间接影响其他小租户。
  2. 资源争抢:所有租户共享相同的 ES 资源(CPU、内存、I/O)。

应对策略

  1. 索引生命周期管理 (ILM):为不同租户或不同数据类型设置不同的保留策略。例如,免费租户的数据保留7天,付费租户保留30天。
  2. 冷热数据分离:将近期的热数据放在高性能 SSD 节点上,历史冷数据迁移到低成本存储。
  3. 分片策略优化:合理设置索引的分片数。过多的分片会增加集群开销,过少则会影响查询并发能力。
  4. 租户分级:对于 VIP 租户,可以考虑为其分配独立的 ES 索引,甚至独立的 OAP 集群,实现物理隔离。这是一种混合模式,兼顾了成本和性能。

SkyWalking 社区也在积极探索更高级的多租户存储方案,例如通过插件机制支持按租户路由到不同的存储后端。

安全性与合规性

在 SaaS 平台中,安全是生命线。多租户隔离不仅是功能需求,更是安全合规的基石。

1. 数据泄露风险

最大的风险莫过于租户 A 的数据被租户 B 看到。我们的 Namespace + 业务网关方案通过双重保障来规避此风险:

  • 写入时隔离:Agent 上报的数据天然带有 namespace
  • 读取时强制过滤:所有查询都必须经过网关,网关会强制注入 namespace 过滤条件。

即使某个租户尝试构造恶意的 GraphQL 查询(例如,试图移除 namespace 过滤),也会被网关拦截并重写。

2. 认证与授权

  • 强认证:使用 JWT、OAuth 2.0 等标准协议进行用户认证。
  • 细粒度授权:除了租户级别的隔离,还应考虑在租户内部实现基于角色的访问控制(RBAC)。例如,租户管理员可以看到所有服务,而普通开发者只能看到自己负责的服务。这需要在你的 SaaS 业务网关中进一步扩展。

3. 审计日志

记录所有敏感操作(如查询、配置修改)的日志,包括操作者、租户、时间、操作内容等。这对于安全审计和故障排查至关重要。

4. 合规性

根据你服务的地区和行业,可能需要遵守 GDPR、HIPAA、等保等法规。确保你的数据存储、传输和处理流程符合相关要求。例如,GDPR 要求能够响应用户的“被遗忘权”,你需要有能力彻底删除某个租户的所有监控数据。

高级话题:跨租户分析与计费

一个成熟的 SaaS 平台,除了基本的隔离,还需要提供增值服务。

跨租户分析

作为平台运营商,你可能需要全局视角来监控平台健康状况、分析整体性能趋势或进行容量规划。这需要一种机制,能够安全地跨越租户边界进行聚合查询。

SkyWalking 的 GraphQL API 本身不支持跨租户查询(因为 namespace 是必选的过滤条件)。要实现这一点,你可以在 OAP 层面开发一个自定义的 Query Plugin

这个插件可以:

  • 识别特殊的、高权限的查询请求(例如,来自 platform-admin 的请求)。
  • 在执行查询时,忽略 namespace 过滤,或者聚合所有 namespace 的数据。
  • 对返回的数据进行脱敏处理,移除敏感的租户标识。

这需要深入 SkyWalking 的插件开发机制,但为平台运营提供了强大的能力。

计费与配额

SaaS 平台通常按用量计费。你需要能够精确计量每个租户消耗的资源,例如:

  • 上报的 Trace 数量
  • 存储的数据量
  • 查询的次数

SkyWalking OAP 本身不直接提供计费数据。你可以通过以下方式实现:

  1. 日志分析:分析 OAP 的访问日志,统计每个 namespace 的请求量。
  2. 自定义 Meter:在 OAP 中开发一个插件,为每个 namespace 的关键指标(如 trace_count)打上标签,并暴露为 Prometheus metrics。然后,你的计费系统可以定期抓取这些 metrics。
  3. 存储层计量:直接查询后端存储(如 ES),统计每个 namespace 占用的索引大小。

一旦有了用量数据,就可以轻松实现配额限制(如免费版每月100万 Trace)和账单生成。

总结与展望

构建一个基于 SkyWalking 的多租户 SaaS APM 平台,是一项充满挑战但也极具价值的工作。通过 Namespace 隔离这一核心机制,结合 自定义的 SaaS 业务网关,我们能够以相对较低的成本,实现安全、高效、可扩展的多租户监控服务。

回顾我们的方案:

  • 数据采集:通过 Agent 的 namespace 配置,确保源头隔离。
  • 数据查询:通过业务网关重写 GraphQL 查询,确保读取隔离。
  • 架构清晰:分层设计,各司其职,易于维护和扩展。

当然,这只是一个起点。随着业务的发展,你可能需要面对更复杂的场景,如混合云监控、边缘计算节点的接入、AI 驱动的异常检测等。幸运的是,SkyWalking 作为一个活跃的开源项目,其生态和能力正在不断进化。

如果你正计划或正在进行类似的工作,希望本文能为你提供有价值的参考。记住,多租户不仅仅是技术问题,更是产品和商业模式的体现。在追求技术完美的同时,也要时刻关注租户的真实需求和体验。🌟

最后,如果你想深入了解 SkyWalking 的内部机制,官方文档是最好的起点。此外,CNCF(Cloud Native Computing Foundation)网站上也有很多关于可观测性和多租户架构的最佳实践分享,值得一看。

Happy Coding and Monitoring! 👨‍💻


🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐