1. 引言

本教程将介绍 Kubernetes Operator 的概念,以及如何使用 Java Operator SDK 实现它们。我们将通过实现一个简化版的 OWASP Dependency-Track 应用部署 Operator 来演示这个过程。

2. 什么是 Kubernetes Operator?

在 Kubernetes 术语中,Operator 是一个软件组件(通常部署在集群中),用于管理一组资源的生命周期。它扩展了原生控制器集合(如 ReplicaSet 和 Job 控制器),将复杂或相互关联的组件作为单个管理单元进行管理。

Operator 的常见应用场景包括:

  • ✅ 在集群中部署应用时强制执行最佳实践
  • ✅ 跟踪并恢复意外删除/修改的应用资源
  • ✅ 自动化应用相关的维护任务(如定期备份和清理)
  • ✅ 自动化集群外资源配置(例如存储桶和证书)
  • ✅ 改善开发者与 Kubernetes 的交互体验
  • ✅ 通过仅管理应用级资源(而非 Pod 和 Deployment 等底层资源)提升整体安全性
  • ✅ 将应用特定资源(即自定义资源定义)暴露为 Kubernetes 资源

最后一个场景特别有价值——它允许解决方案提供商利用现有的 Kubernetes 资源管理实践来管理应用特定资源。主要优势是采用该应用的用户可以直接使用现有基础设施即代码工具

要了解各类 Operator,可以访问 OperatorHub.io 网站。那里有流行数据库、API 管理器、开发工具等的 Operator。

3. Operator 与 CRD

自定义资源定义(CRD)是 Kubernetes 的扩展机制,允许我们在集群中存储结构化数据。与平台上几乎所有内容一样,CRD 定义本身也是一种资源。

这个元定义描述了 CRD 实例的作用域(基于命名空间或全局)以及用于验证 CRD 实例的架构。注册后,用户可以像创建原生资源一样创建 CRD 实例。集群管理员还可以将 CRD 纳入角色定义,仅授权给特定用户和应用。

但请注意,仅注册 CRD 本身并不意味着 Kubernetes 会以任何方式使用它。对 Kubernetes 而言,CRD 实例只是其内部数据库中的一个条目。由于标准原生控制器都不知道如何处理它,什么都不会发生。

这就是 Operator 的控制器部分发挥作用的地方。一旦部署,它会监听与相应自定义资源相关的事件并做出响应

这里的"响应"动作是关键。该术语受控制理论启发,可总结为以下流程图:

控制循环

4. 实现 Operator

创建 Operator 需要完成以下主要任务:

  • 定义要通过 Operator 管理的目标资源模型
  • 创建捕获部署这些资源所需参数的 CRD
  • 创建监听集群中与已注册 CRD 相关事件的控制器

本教程中,我们将为 OWASP 的旗舰项目 Dependency-Track 实现 Operator。该应用允许用户跟踪组织内使用的库中的漏洞,帮助安全专业人士评估和解决问题。

Dependency-Track 的 Docker 发行版包含两个组件:API 服务和前端服务,各自使用独立镜像。在 Kubernetes 集群中部署时,通常将每个组件包装在 Deployment 中以管理运行这些镜像的 Pod。

但这还不够,完整解决方案还需要额外资源:

此外,我们还需要正确设置存活/就绪探针、资源限制等细节——这些普通用户本不该关心的问题。

让我们看看如何用 Operator 简化这个过程。

5. 定义模型

我们的 Operator 将专注于运行 Dependency-Track 系统所需的最小资源集。幸运的是,提供的镜像有合理的默认值,我们只需一个信息:访问应用的外部 URL

目前我们暂不考虑数据库和存储设置,但掌握基础后添加这些特性会很简单。

不过我们会保留一些自定义空间,特别是允许用户覆盖部署使用的镜像和版本(因为它们在持续演进)。

下图展示了包含所有组件的 Dependency-Track 安装架构:

Dependency-Track 安装架构

必需的模型参数包括:

  • 资源创建的 Kubernetes 命名空间
  • 用于安装和派生组件名称的名称
  • Ingress 资源使用的主机名
  • 可选的 Ingress 额外注解(某些云服务商如 AWS 需要这些注解才能正常工作)

6. 控制器项目设置

下一步本应手动定义 CRD 架构,但由于使用 Java Operator SDK,这部分会自动处理。我们直接进入控制器项目本身。

从标准 Spring Boot 3 WebFlux 应用开始,添加必要依赖:

<dependency>
    <groupId>io.javaoperatorsdk</groupId>
    <artifactId>operator-framework-spring-boot-starter</artifactId>
    <version>5.4.0</version>
</dependency>

<dependency>
    <groupId>io.javaoperatorsdk</groupId>
    <artifactId>operator-framework-spring-boot-starter-test</artifactId>
    <version>5.4.0</version>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j2-impl</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>io.fabric8</groupId>
    <artifactId>crd-generator-apt</artifactId>
    <version>6.9.2</version>
    <scope>provided</scope>
</dependency>

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>1.77</version>
</dependency>

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcpkix-jdk18on</artifactId>
    <version>1.77</version>
</dependency>

最新版本可在 Maven Central 获取:

前两个依赖分别用于实现和测试 Operator。crd-generator-apt 是注解处理器,从带注解的类生成 CRD 定义。最后,bouncycastle 库用于支持现代加密标准。

注意测试启动器中排除了 log4j 依赖——因为它与 logback 冲突。

7. 实现主资源

主资源类表示用户将部署到集群的 CRD。它使用 @Group@Version 注解标识,使 CRD 注解处理器能在编译时生成相应的 CRD 定义:

@Group("com.baeldung")
@Version("v1")
public class DeptrackResource extends CustomResource<DeptrackSpec, DeptrackStatus> implements Namespaced {
    @JsonIgnore
    public String getFrontendServiceName() {
        return this.getMetadata().getName() + "-" + DeptrackFrontendServiceResource.COMPONENT;
    }

    @JsonIgnore
    public String getApiServerServiceName() {
        return this.getMetadata().getName() + "-" + DeptrackApiServerServiceResource.COMPONENT;
    }
}

这里我们利用 SDK 的 CustomResource 类实现 DeptrackResource。除了基类,还使用了 Namespaced 标记接口,告知注解处理器我们的 CRD 实例将部署到 Kubernetes 命名空间。

我们添加了两个辅助方法,稍后用于派生前端和 API 服务的名称。这里需要 @JsonIgnore 注解,避免在 Kubernetes API 调用中序列化/反序列化 CRD 实例时出现问题。

8. 规范与状态类

CustomResource 类需要两个模板参数:

  • 规范类:包含模型支持的参数
  • 状态类:包含系统动态状态信息

我们的规范类参数很少,所以相当简单:

public class DeptrackSpec {
    private String apiServerImage = "dependencytrack/apiserver";
    private String apiServerVersion = "";

    private String frontendImage = "dependencytrack/frontend";
    private String frontendVersion = "";

    private String ingressHostname;
    private Map<String, String> ingressAnnotations;

    // ... getters/setters omitted
}

状态类则直接扩展 ObservedGenerationAwareStatus

public class DeptrackStatus extends ObservedGenerationAwareStatus {
}

使用这种方式,SDK 会在每次更新时自动递增 observedGeneration 状态字段。这是控制器跟踪资源变更的常见实践

9. 协调器

接下来需要创建 Reconciler 类,负责管理 Dependency-Track 系统的整体状态。我们的类必须实现该接口,以资源类作为参数:

@ControllerConfiguration(dependents = {
    @Dependent(name = DeptrackApiServerDeploymentResource.COMPONENT, type = DeptrackApiServerDeploymentResource.class),
    @Dependent(name = DeptrackFrontendDeploymentResource.COMPONENT, type = DeptrackFrontendDeploymentResource.class),
    @Dependent(name = DeptrackApiServerServiceResource.COMPONENT, type = DeptrackApiServerServiceResource.class),
    @Dependent(name = DeptrackFrontendServiceResource.COMPONENT, type = DeptrackFrontendServiceResource.class),
    @Dependent(type = DeptrackIngressResource.class)
})
@Component
public class DeptrackOperatorReconciler implements Reconciler<DeptrackResource> {
    @Override
    public UpdateControl<DeptrackResource> reconcile(DeptrackResource resource, Context<DeptrackResource> context) throws Exception {
        return UpdateControl.noUpdate();
    }
}

关键在于 @ControllerConfiguration 注解。dependents 属性列出了生命周期将与主资源关联的各个资源

对于 Deployment 和 Service,除了资源 type 外还需指定 name 属性以区分它们。而 Ingress 只需一个,所以不需要名称。

注意我们还添加了 @Component 注解——这是为了让 Operator 的自动配置逻辑检测到协调器并将其注册到内部注册表。

10. 依赖资源类

对于每个因 CRD 部署而需要在集群中创建的资源,我们需要实现 KubernetesDependentResource 类。这些类必须使用 @KubernetesDependent 注解,负责响应主资源变更来管理这些资源的生命周期

SDK 提供的 CRUDKubernetesDependentResource 工具类极大简化了此任务。只需覆盖 desired() 方法,返回依赖资源期望状态的描述:

@KubernetesDependent(resourceDiscriminator = DeptrackApiServerDeploymentResource.Discriminator.class)
public class DeptrackApiServerDeploymentResource extends CRUDKubernetesDependentResource<Deployment, DeptrackResource> {
    public static final String COMPONENT = "api-server";
    private Deployment template;

    public DeptrackApiServerDeploymentResource() {
        super(Deployment.class);
        this.template = BuilderHelper.loadTemplate(Deployment.class, "templates/api-server-deployment.yaml");
    }

    @Override
    protected Deployment desired(DeptrackResource primary, Context<DeptrackResource> context) {
        ObjectMeta meta = fromPrimary(primary, COMPONENT)
          .build();

        return new DeploymentBuilder(template)
          .withMetadata(meta)
          .withSpec(buildSpec(primary, meta))
          .build();
    }

    private DeploymentSpec buildSpec(DeptrackResource primary, ObjectMeta primaryMeta) {
        return new DeploymentSpecBuilder()
          .withSelector(buildSelector(primaryMeta.getLabels()))
          .withReplicas(1)
          .withTemplate(buildPodTemplate(primary,primaryMeta))
          .build();
    }

    private LabelSelector buildSelector(Map<String, String> labels) {
        return new LabelSelectorBuilder()
          .addToMatchLabels(labels)
          .build();
    }

    private PodTemplateSpec buildPodTemplate(DeptrackResource primary, ObjectMeta primaryMeta) {
        return new PodTemplateSpecBuilder()
          .withMetadata(primaryMeta)
          .withSpec(buildPodSpec(primary))
          .build();
    }

    private PodSpec buildPodSpec(DeptrackResource primary) {
        String imageVersion = StringUtils.hasText(primary.getSpec().getApiServerVersion()) ?
          ":" + primary.getSpec().getApiServerVersion().trim() : "";

        String imageName = StringUtils.hasText(primary.getSpec().getApiServerImage()) ?
          primary.getSpec().getApiServerImage().trim() : Constants.DEFAULT_API_SERVER_IMAGE;

        return new PodSpecBuilder(template.getSpec().getTemplate().getSpec())
          .editContainer(0)
            .withImage(imageName + imageVersion)
            .and()
          .build();
    }
}

这里我们使用可用的构建器类创建 Deployment。数据部分来自方法传入的主资源元数据和初始化时读取的模板。这种方式让我们能使用现有经过验证的 Deployment 作为模板,只修改真正需要的部分

最后需要指定 Discriminator 类——Operator 引擎用它来处理来自多个同类事件源时定位正确的资源类。这里我们使用基于框架提供的 ResourceIDMatcherDiscriminator 工具类的实现:

class Discriminator extends ResourceIDMatcherDiscriminator<Deployment, DeptrackResource> {
     public Discriminator() {
         super(COMPONENT, (p) -> new ResourceID(
           p.getMetadata().getName() + "-" + COMPONENT,
           p.getMetadata().getNamespace()));
     }
}

工具类需要事件源名称和映射函数。后者接收主资源实例,返回关联组件的资源标识符(命名空间+名称)。

所有资源类共享相同的基本结构,这里不再重复展示。建议查看源码了解每个资源的构建方式。

11. 本地测试

由于控制器只是常规 Spring 应用,我们可以使用标准测试框架创建单元测试和集成测试。

Java Operator SDK 还提供了便捷的 Kubernetes 模拟实现,简化测试用例。在测试类中使用 @EnableMockOperator 配合标准 @SpringBootTest

@SpringBootTest
@EnableMockOperator(crdPaths = "classpath:META-INF/fabric8/deptrackresources.com.baeldung-v1.yml")
class ApplicationUnitTest {
    @Autowired
    KubernetesClient client;
    @Test
    void whenContextLoaded_thenCrdRegistered() {
        assertThat(
          client
            .apiextensions()
            .v1()
            .customResourceDefinitions()
            .withName("deptrackresources.com.baeldung")
            .get())
          .isNotNull();
    }
}

crdPath 属性指定注解处理器创建 CRD 定义 YAML 文件的位置。测试初始化时,模拟 Kubernetes 服务会自动注册它,使我们能创建 CRD 实例并验证预期资源是否正确创建

SDK 的测试基础设施还配置了 Kubernetes 客户端,可用于模拟部署并检查资源是否正确创建。注意无需运行真实的 Kubernetes 集群

12. 打包与部署

要打包控制器项目,可以使用 Dockerfile,或者更好的方式——使用 Spring Boot 的 build-image 目标推荐后者,因为它确保镜像遵循安全性和分层组织的最佳实践

将镜像发布到本地或远程仓库后,需创建 YAML 清单将控制器部署到现有集群。

该清单包含管理控制器本身的部署和支持资源:

  • CRD 定义
  • 控制器所在的命名空间
  • 列出控制器使用的所有 API 的集群角色
  • 服务账户
  • 将角色与账户关联的集群角色绑定

完整清单 可在我们的 GitHub 仓库中找到。

13. CRD 部署测试

完成教程前,让我们创建一个简单的 Dependency-Track CRD 清单并部署它。我们将使用专用命名空间("test")并暴露应用。

测试中使用本地 Kubernetes(IP 172.31.42.16),因此主机名设为 deptrack.172.31.42.16.nip.io。**NIP.IO 是一个 DNS 服务,将形如 *.1.2.3.4.nip.io 的主机名解析到 IP 1.2.3.4,无需配置 DNS 记录**。

查看部署清单:

apiVersion: com.baeldung/v1
kind: DeptrackResource
metadata:
  namespace: test
  name: deptrack1
  labels:
    project: tutorials
  annotations:
    author: Baeldung

spec:
  ingressHostname: deptrack.172.31.42.16.nip.io

现在用 kubectl 部署:

$ kubectl apply -f k8s/test-resource.yaml
deptrackresource.com.baeldung/deptrack1 created

查看控制器日志,会发现它响应了 CRD 创建并生成了依赖资源:

$ kubectl get --namespace test deployments
NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
deptrack1-api-server   0/1     1            0           62s
deptrack1-frontend     1/1     1            1           62s

$ kubectl get --namespace test services 
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
deptrack1-frontend-service ClusterIP 10.43.122.76 <none> 8080/TCP 2m17s

$ kubectl get --namespace test ingresses
NAME CLASS HOSTS ADDRESS PORTS AGE
deptrack1-ingress traefik deptrack.172.31.42.16.nip.io 172.31.42.16 80 2m53s

如预期,test 命名空间现在有两个 Deployment、两个 Service 和一个 Ingress。浏览器访问 https://deptrack.172.31.42.16.nip.io 会看到应用登录页——证明解决方案已正确部署。

完成测试,删除 CRD:

$ kubectl delete --namespace test deptrackresource/deptrack1
deptrackresource.com.baeldung "deptrack1" deleted

由于 Kubernetes 知道哪些资源与 CRD 关联,它们也会被删除:

$ kubectl get --namespace test deployments
No resources found in test namespace.

14. 总结

本教程展示了如何使用 Java Operator SDK 实现基础 Kubernetes Operator。虽然需要一些样板代码,但实现过程相当直接。

SDK 处理了大部分状态协调的繁重工作,开发者只需专注于定义处理复杂部署的最佳方式。

一如既往,所有代码可在 GitHub 获取。


原始标题:Create Kubernetes Operators with the Java Operator SDK