云原生 Java:使用 Kubernetes Operator 实现基础设施自动化
优质文章,每日送达
「【只做懂你de云原生干货知识共享】」
云原生 Java:使用 Kubernetes Operator 实现基础设施自动化
Java(及其其他基于 JDK 的兄弟)是大公司中使用最广泛的编程语言。Java 开发人员专注于后端并习惯于构建复杂的分布式系统。然而,当谈到可编程基础设施挑战时,这些开发人员的潜力很少被利用。
开发人员与运维工程师合作是 DevOps 运动的核心,而 Kubernetes operator为开发人员提供了一种完美的方式来为基础设施自动化做出贡献。
在这篇博文中,将介绍:
解释用 Java 构建 Kubernetes operator的基本原理。
展示一个用于构建您自己的 Kubernetes operator的用例。
展示如何实现operator的业务逻辑。
展示如何将operator部署到 Kubernetes。
为什么是 Kubernetes operator?
Operators是一种设计模式,通过创建在 Kubernetes 上运行并嵌入管理逻辑的pod(容器)来自动化更复杂的应用程序或基础设施部分的操作。这种逻辑称为控制回路。
控制循环可以使用 Java 或任何其他可以与 Kubernetes API 通信的通用编程语言来实现。控制循环的输入是在Kubernetes API 服务器中创建的对象,它支持这些对象的自定义模式。它们被称为自定义资源。
MySQLSchema operator
基础设施自动化的一个常见场景是为应用程序提供数据库。此过程通常由开发人员向运营部门发送电子邮件来实现,这是一个成本高昂的手动过程。让我们看看如何自动化它!
在本例中,我们将配置 MySQL 数据库。MySQL 服务器中的单个数据库称为模式。我们不想为每个应用程序启动一个新的数据库服务器,而是想创建一个新的模式。
为此,我们需要做两件事:
创建一个名为 MySQLSchema 的新自定义资源定义,以允许 Kubernetes 用户创建此类型的新自定义资源。
实现将监视 Kubernetes API 以获取新 MySQLSchema 对象并在 MySQL 中创建实际模式的逻辑,或在需要时将其删除。
选择语言:Java
operator在过去几年蓬勃发展,您可以在 operatorhub.io 上找到许多流行应用程序的operator。用于实现运算符的默认工具包是基于 Golang 的operator-sdk。
但是,Golang 并不是唯一可用于构建operator的语言。由于 Operator 只是一个运行在集群上的普通 Pod,它可以用任何语言实现。由于 Java 是最流行的语言之一,因此它是实现operator的不错选择。它有一个成熟的客户端库(fabric8),用于与 Kubernetes API 服务器交互。这是拼图的关键部分,因为operator将大量使用 API——观察资源的变化并更新它们。
现在您可能想知道,为什么不随波逐流并学习一些 Go 来构建您的第一个operator?虽然这是一个完全合理的选择,但我们想提供一些关于为什么您可能想要使用 Java 的论据,假设它是您在日常工作中使用的语言。
学习如何实现一个operator比学习如何在 Golang 中编码要容易得多。虽然 Golang 被吹捧为一种简单的语言,但它仍然拥有完整的库、依赖管理、设计模式和最佳实践生态系统。
如果operator管理的应用程序是用 Java 编写的,为什么要使用完全不同的堆栈来构建operator,从而给必须维护它的整个团队带来额外的认知负担?
如果您的公司是一家 Java 商店,在没有充分理由的情况下将 Go 引入工具链可能弊大于利。
实现 MySQLSchema Operator
我们的 MySQLSchema Operator 将需要执行以下操作:
使用 Fabric8 从 API 服务器监听任何 MySQLSchema 资源的更改。
将 MySQLSchema 资源映射到一组 Java 类。
将 MySQLSchema 资源的状态与 MySQL 数据库集群中的实际状态进行协调。如果 Kubernetes 中存在名为“mydb”的资源,则Operator必须确保创建了架构。
使用新创建的数据库架构的 URL 更新自定义资源以指示成功创建架构。
使用 URL 和凭据创建ConfigMap和Secret以访问数据库。然后可以将这些映射到应用程序,该应用程序将数据库用作环境变量或卷。
MySQLSchema 资源是什么样的?它非常简单,因为我们希望它简单。我们不想给我们的用户太多的灵活性。本质上,它只是模式的名称和默认的表编码。
apiVersion: "mysql.sample.javaoperatorsdk/v1"
kind: MySQLSchema
metadata:
name: mydb
spec:
encoding: utf8
介绍 java-operator-sdk
我们希望 Java 开发人员拥有与 Golang 开发人员相同的简化体验,这就是我们创建java-operator-sdk 的原因。
在的心脏ĴAVA-operator的SDK是operator的框架。operator框架:
包装 fabric8 并将其配置为侦听指定自定义资源上的更改,从而隐藏为此所需的样板代码。
提供一个干净的接口来实现特定资源类型的协调循环。
安排要以有效方式执行的更改事件。过滤过时的事件并并行执行不相关的事件。
重试失败的协调尝试。
考虑到这一点,我们可以展示堆栈的全貌:
fabric8 客户端将处理与 Kubernetes API 的通信。operator框架处理由fabric8 接收的事件的管理并调用控制器逻辑。这是operator的核心,我们的 MySQLSchema 供应业务逻辑将存在于其中。我们的逻辑反过来将使用 mysql-connector 库与 MySQL 服务器通信。
自定义资源定义
使用工具带中的 java-operator-sdk,我们可以跳进去实现逻辑。对于那些想直接阅读代码的人,您可以在 SDK的示例中找到它。
首先,我们必须告诉 Kubernetes 我们新的自定义资源。这是使用自定义资源定义 (CRD) 完成的:
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: schemas.mysql.sample.javaoperatorsdk
spec:
group: mysql.sample.javaoperatorsdk
version: v1
scope: Namespaced
names:
plural: schemas
singular: schema
kind: MySQLSchema
validation:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
encoding:
type: string
Kubernetes 只关心要注册的名称,但您可以选择提供 OpenAPI v3 模式以确保客户端只能创建具有正确格式的自定义资源。这将在资源更新期间进行验证。
您可以使用包含上述定义的 yaml 文件使用简单的 apply 命令向 Kubernetes 注册 CRD:
kubectl apply -f crd.yaml
映射自定义资源的 Java 类
下一步是将自定义资源映射到 Java 类:
import io.fabric8.kubernetes.client.CustomResource;
public class Schema extends CustomResource {
private SchemaSpec spec;
private SchemaStatus status;
//getters and setters
}
public class SchemaSpec {
private String encoding;
//getters and setters
}
public class SchemaStatus {
private String url;
private String status;
//getters and setters
}
来自自定义资源的数据将映射到这些类的对象。Fabric8 为此在后台使用Jackson 对象映射器库。请注意,这是我们通过扩展 CustomResource 类与 fabric8 库进行交互的点。这对我们的代码来说意义不大,但对 fabric8 来说是一个要求。
实现控制回路
此时,我们已准备好实现控制循环。为此,我们必须实现 ResourceController 接口,该接口只有两个方法:
public interface ResourceController<R extends CustomResource> {
UpdateControl createOrUpdateResource(R resource, Context<R> context);
boolean deleteResource(R resource);
}
这些方法是协调逻辑存在的地方。每当 Kubernetes API 中的自定义资源对象被创建、更新或删除时,operator框架的工作就是调用这些方法。
package com.github.containersolutions.operator.sample;
import com.github.containersolutions.operator.api.Context;
import com.github.containersolutions.operator.api.Controller;
import com.github.containersolutions.operator.api.ResourceController;
import com.github.containersolutions.operator.api.UpdateControl;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Base64;
import static java.lang.String.format;
@Controller(
crdName = "schemas.mysql.sample.javaoperatorsdk",
customResourceClass = Schema.class)
public class SchemaController implements ResourceController<Schema> {
static final String USERNAME_FORMAT = "%s-user";
static final String SECRET_FORMAT = "%s-secret";
private final Logger log = LoggerFactory.getLogger(getClass());
private final KubernetesClient kubernetesClient;
public SchemaController(KubernetesClient kubernetesClient) {
this.kubernetesClient = kubernetesClient;
}
@Override
public UpdateControl<Schema> createOrUpdateResource(Schema schema, Context<Schema> context) {
try (Connection connection = getConnection()) {
if (!schemaExists(connection, schema.getMetadata().getName())) {
connection.createStatement().execute(format("CREATE SCHEMA `%1$s` DEFAULT CHARACTER SET %2$s",
schema.getMetadata().getName(),
schema.getSpec().getEncoding()));
String password = RandomStringUtils.randomAlphanumeric(16);
String userName = String.format(USERNAME_FORMAT,
schema.getMetadata().getName());
String secretName = String.format(SECRET_FORMAT,
schema.getMetadata().getName());
connection.createStatement().execute(format(
"CREATE USER '%1$s' IDENTIFIED BY '%2$s'",
userName, password));
connection.createStatement().execute(format(
"GRANT ALL ON `%1$s`.* TO '%2$s'",
schema.getMetadata().getName(), userName));
Secret credentialsSecret = new SecretBuilder()
.withNewMetadata().withName(secretName).endMetadata()
.addToData("MYSQL_USERNAME", Base64.getEncoder().encodeToString(userName.getBytes()))
.addToData("MYSQL_PASSWORD", Base64.getEncoder().encodeToString(password.getBytes()))
.build();
this.kubernetesClient.secrets()
.inNamespace(schema.getMetadata().getNamespace())
.create(credentialsSecret);
SchemaStatus status = new SchemaStatus();
status.setUrl(format("jdbc:mysql://%1$s/%2$s",
System.getenv("MYSQL_HOST"),
schema.getMetadata().getName()));
status.setUserName(userName);
status.setSecretName(secretName);
status.setStatus("CREATED");
schema.setStatus(status);
log.info("Schema {} created - updating CR status", schema.getMetadata().getName());
return UpdateControl.updateStatusSubResource(schema);
}
return UpdateControl.noUpdate();
} catch (SQLException e) {
log.error("Error while creating Schema", e);
SchemaStatus status = new SchemaStatus();
status.setUrl(null);
status.setUserName(null);
status.setSecretName(null);
status.setStatus("ERROR");
schema.setStatus(status);
return UpdateControl.updateCustomResource(schema);
}
}
@Override
public boolean deleteResource(Schema schema, Context<Schema> context) {
log.info("Execution deleteResource for: {}", schema.getMetadata().getName());
try (Connection connection = getConnection()) {
if (schemaExists(connection, schema.getMetadata().getName())) {
connection.createStatement().execute("DROP DATABASE `" + schema.getMetadata().getName() + "`");
log.info("Deleted Schema '{}'", schema.getMetadata().getName());
if (userExists(connection, schema.getStatus().getUserName())) {
connection.createStatement().execute("DROP USER '" + schema.getStatus().getUserName() + "'");
log.info("Deleted User '{}'", schema.getStatus().getUserName());
}
this.kubernetesClient.secrets()
.inNamespace(schema.getMetadata().getNamespace())
.withName(schema.getStatus().getSecretName())
.delete();
} else {
log.info("Delete event ignored for schema '{}', real schema doesn't exist",
schema.getMetadata().getName());
}
return true;
} catch (SQLException e) {
log.error("Error while trying to delete Schema", e);
return false;
}
}
private Connection getConnection() throws SQLException {
return DriverManager.getConnection(format("jdbc:mysql://%1$s:%2$s?user=%3$s&password=%4$s",
System.getenv("MYSQL_HOST"),
System.getenv("MYSQL_PORT") != null ? System.getenv("MYSQL_PORT") : "3306",
System.getenv("MYSQL_USER"),
System.getenv("MYSQL_PASSWORD")));
}
private boolean schemaExists(Connection connection, String schemaName) throws SQLException {
ResultSet resultSet = connection.createStatement().executeQuery(
format("SELECT schema_name FROM information_schema.schemata WHERE schema_name = \"%1$s\"",
schemaName));
return resultSet.first();
}
private boolean userExists(Connection connection, String userName) throws SQLException {
ResultSet resultSet = connection.createStatement().executeQuery(
format("SELECT User FROM mysql.user WHERE User='%1$s'", userName)
);
return resultSet.first();
}
}
你可以看到这个逻辑是关于使用模式自定义资源的当前版本作为输入来管理 MySQL。这就是 java-operator-sdk 的重点:让您专注于operator的业务逻辑。
当自定义资源被创建或更新时,operator将:
验证数据库架构是否已存在。
如果它在 Mysql 数据库中不存在,则创建模式。
使用数据库 url 更新自定义资源的状态字段并将状态设置为 CREATED。
创建可以使用数据库挂载到应用程序的 ConfigMap 和 Secret。
请注意,我们不会根据事件的类型(创建/更新)采取行动。我们甚至没有将这些信息作为输入,所以我们总是检查模式是否是在 Mysql 中创建的。
这是operator配置方法中的一个重要点:始终检查资源的真实状态。不要做假设。operator处理的状态很难控制并且可以被不同的进程改变,所以他们必须做很少的假设才能保持健壮。
在我们的例子中,我们检查模式是否存在,如果不存在则创建它。不需要其他操作,因为我们不支持更新现有架构。
正如你在上面的例子中看到的,资源的删除是用另一种方法处理的。这些是在后台使用终结器以特殊方式处理的,但框架的用户再次不必知道这一点。如果您有兴趣,可以在此处阅读有关框架如何使用终结器的更多信息。
在控制器类准备好后,我们只需要进行一些初始化:创建一个operator对象并向其中添加任何控制器。通常我们在一个operator中运行一个控制器,但也可以将其中的几个打包到一个 Java 虚拟机 (JVM) 进程中。
public class MySQLSchemaOperator {
private static final Logger log = LoggerFactory.getLogger(MySQLSchemaOperator.class);
public static void main(String[] args) throws IOException {
log.info("MySQL Schema Operator starting");
Config config = new ConfigBuilder().withNamespace(null).build();
Operator operator = new Operator(new DefaultKubernetesClient(config));
operator.registerControllerForAllNamespaces(new SchemaController());
new FtBasic(
new TkFork(new FkRegex("/health", "ALL GOOD!")), 8080
).start(Exit.NEVER);
}
}
在这里,我使用一个小型库来公开带有运行状况检查的 HTTP 端点。这在将 operator 部署到 Kubernetes 时很重要,因此 Kubernetes 知道如何验证它是否完全启动并运行。
部署 Operator
要构建我们的 Operator 并将其部署到 Kubernetes,我们需要执行以下步骤:
在中配置 docker-registry 属性 ~/.m2/settings.xml
构建 Docker 镜像并将其推送到远程仓库:
mvn package dockerfile:build dockerfile:push
在集群上创建 CustomResourceDefinition:
kubectl apply -f crd.yaml
创建 RBAC 对象:
kubectl apply -f rbac.yaml
部署operator:
kubectl apply -f operator-deployment.yaml
构建 Docker 镜像
SDK 目前无法帮助从您的代码构建 Docker 镜像,但该示例包含使用dockerfile-maven-plugin的全功能构建。您必须在 settings.xml 中定义docker-registry属性。我使用eu.gcr.io推送到 Google Cloud Container Registry。
<plugin>
<groupId>com.spotify</groupId>
<artifactId>dockerfile-maven-plugin</artifactId>
<version>1.4.12</version>
<configuration>
<repository>${docker-registry}/mysql-schema-operator</repository>
<tag>${project.version}</tag>
<buildArgs>
<JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
</buildArgs>
</configuration>
</plugin>
该插件需要一个实际的 Dockerfile(并安装 Docker),它将用于执行 Docker 构建。它将向 Docker 提供 JAR_FILE 参数。
FROM openjdk:12-alpine
ENTRYPOINT ["java", "-jar", "/usr/share/operator/operator.jar"]
ARG JAR_FILE
ADD target/${JAR_FILE} /usr/share/operator/operator.jar
此构建还假定maven 包生成的工件是一个可执行的 jar 文件。在示例 pom 文件中,我使用了着色器插件,但您也可以使用 Spring Boot 或许多其他方法来执行此操作。
部署资源:运行 Operator
我们的operator将在 Kubernetes 上作为正常部署进行部署。这将确保始终只有一个operator实例在运行。“重新创建”升级策略将确保在升级期间启动新版本之前先关闭旧版本。
apiVersion: v1
kind: Namespace
metadata:
name: mysql-schema-operator
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql-schema-operator
namespace: mysql-schema-operator
spec:
selector:
matchLabels:
app: mysql-schema-operator
replicas: 1 # we always run a single replica of the operator to avoid duplicate handling of events
strategy:
type: Recreate # during an upgrade the operator will shut down before the new version comes up to prevent two instances running at the same time
template:
metadata:
labels:
app: mysql-schema-operator
spec:
serviceAccount: mysql-schema-operator # specify the ServiceAccount under which's RBAC persmissions the operator will be executed under
containers:
- name: operator
image: ${DOCKER_REGISTRY}/mysql-schema-operator:${OPERATOR_VERSION}
imagePullPolicy: Always
ports:
- containerPort: 80
env:
- name: MYSQL_HOST
value: mysql.mysql # assuming the MySQL server runs in a namespace called "mysql" on Kubernetes
- name: MYSQL_USER
value: root
- name: MYSQL_PASSWORD
value: password # sample-level security
readinessProbe:
httpGet:
path: /health # when this returns 200 the operator is considered up and running
port: 8080
initialDelaySeconds: 1
timeoutSeconds: 1
livenessProbe:
httpGet:
path: /health # when this endpoint doesn't return 200 the operator is considered broken and get's restarted
port: 8080
initialDelaySeconds: 30
timeoutSeconds: 1
到 MySQL 数据库服务器的连接是使用环境变量配置的。请不要在生产中使用此设置!“mysql.mysql”主机名是指在 mysql 命名空间中的 Kubernetes 上运行的 Mysql 数据库。
如果要在 Kubernetes 上的此命名空间中运行 MySQL,请应用示例提供的 yaml 文件。请注意,这个 MySQL 只会分配临时存储,因此当 pod 被杀死时,它将丢失所有数据。
kubectl apply -f k8s/mysql.yaml
访问 Kubernetes API 的权限
大多数在 Kubernetes 上运行的应用程序不需要访问 Kubernetes API。然而,operator——根据定义——可以,因为它们使用在 API 中定义和更新的资源。处理Kubernetes RBAC(基于角色的访问控制)机制是不可避免的。
ClusterRole 资源定义了一组权限。它可以访问对 MySQLSchema 资源执行所有操作以及列出和获取 CustomResourceDefinitions。Operator 当然需要监视集群上的所有架构资源,并在状态更改时更新它们。对于CustomResourceDefinitions,operator需要在启动时获取自己的CRD来获取一些元数据。在 SDK 的未来版本中,这可能不是必需的。
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: mysql-schema-operator
rules:
- apiGroups:
- mysql.sample.javaoperatorsdk
resources:
- schemas
verbs:
- "*"
- apiGroups:
- apiextensions.k8s.io
resources:
- customresourcedefinitions
verbs:
- "get"
- "list"
这是operator将在其下执行的 ServiceAccount。它只是一个名称,将operator的运行 pod 与其对 API 服务器的权限联系起来。
apiVersion: v1
kind: ServiceAccount
metadata:
name: mysql-schema-operator
namespace: mysql-schema-operator
最后,ClusterRoleBinding 将 ServiceAccount 连接到 ClusterRole。这样,在 ServiceAccount mysql-schema-operator 下运行的任何 pod 都将采用同名的 ClusterRole 和上述权限。
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: operator-admin
subjects:
- kind: ServiceAccount
name: mysql-schema-operator
namespace: mysql-schema-operator
roleRef:
kind: ClusterRole
name: mysql-schema-operator
apiGroup: ""
下图解释了不同 RBAC 对象之间的关系:
使用 Operator 创建 MySQL Schema
部署后,您可以通过列出正在运行的 Pod 来验证 Operator 是否正常运行:
kubectl get pods -n mysql-schema-operator
您还可以使用以下命令跟踪 Operator 的日志:
kubectl logs -f -n mysql-schema-operator mysql-schema-operator-957bd9d6d-h9tqn
最后,您可以通过应用适当的 yaml 文件来创建 MySQLSchema 自定义资源。您可以编辑它以设置架构名称。
kubectl apply -f k8s/example.yaml
创建 MySQLSchema 资源后,operator应连接 MySQL 服务器并创建真正的 schema 和可以访问它的数据库用户。它应该使用访问数据库的凭据创建一个 Kubernetes Secret,您可以将其挂载到您的应用程序部署中。去这里了解更多详情。
结论
对于任何拥有多个开发团队的现代公司来说,一个灵活且完全自动化的数据库配置流程是必不可少的。在这个例子中,我们展示了如何在 Kubernetes 上使用operator模式实现这样的过程,用 Java 编写逻辑。在fabric8 和java-operator-sdk 的支持下,任何熟悉Java 编程的人都可以轻松编写Kubernetes operator。
资源
深入了解 java-operator-sdk
MySQL Schema operator示例
有关 Kubernetes operator的更多信息
更多好文推荐阅读
嘿,你在看吗?