Kubernetes 官方 java 客户端之七:patch 操作,深入浅出 Java
本文内容
这是篇万字长文,所以一开始就要明确本文的核心内容:开发一个 SpringBoot 应用并部署在 kubernetes 环境,这个应用通过 kubernetes 的 java 客户端向 API Server 发请求,请求内容包括:创建名为 test123 的 deployment、对这个 deployment 进行 patch 操作,如下图:
接下来先了解一些 kubernetes 的 patch 相关的基本知识;
关于 patch
是对各种资源的增删改查是 kubernetes 的基本操作;
对于修改操作,分为 Replace 和 Patch 两种;
Replace 好理解,就是用指定资源替换现有资源,replace 有个特点,就是 optimistic lock 约束(类似与转账操作,先读再计算再写入);
Patch 用来对资源做局部更新,没有 optimistic lock 约束,总是最后的请求会生效,因此如果您只打算修改局部信息,例如某个属性,只要指定属性去做 patch 即可(如果用 Replace,就只能先取得整个资源,在本地修改指定属性,再用 Replace 整体替换);
更详细的信息请参考下图,来自官方文档,地址:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/
patch 的四种类型
kubernetes 的 patch 一共有四种:
json patch:在请求中指定操作类型,例如:add、replace,再指定 json 内容进行操作 z,请参考:https://tools.ietf.org/html/rfc6902
merge patch:合并操作,可以提交整个资源的信息,与现有信息进行合并后生效,也可以提交部分信息用于替换,请参考:https://tools.ietf.org/html/rfc7386
strategic merge patch:json patch 和 merge patch 都遵守 rfc 标准,但是 strategic merge patch 却是 kubernetes 独有的,官方中文文档中称为策略性合并,也是 merge 的一种,但是真正执行时 kubernetes 会做合并还是替换是和具体的资源定义相关的(具体策略由 Kubernetes 源代码中字段标记中的 patchStrategy 键的值指定),以 Pod 的 Container 为例,下面是其源码,红框中显示其 Container 节点的 patchStrategy 属性是 merge,也就是说如果您提交了一份 strategic merge patch,里面的内容是关于 Pod 的 Container 的,那么原有的 Container 不会被替换,而是合并(例如以前只有 nginx,提交的 strategic merge patch 是 redis,那么最终 pod 下会有两个 container:nginx 和 redis):
通过源码查看资源的 patchStrategy 属性是很麻烦的事情,因此也可以通过 Kubernetes API 文档来查看,如下图,地址是:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podspec-v1-core
第四种是 apply patch:主要是指 kubernetes 1.14 版本开始的 server-side apply,由 APIServer 做 diff 和 merge 操作,很多原本易碎的现象都得到了解决(例如 controller 和 kubectl 都在更新),另外要格外注意的是:1.14 版本默认是不开启 server-side apply 特性的,具体的开启操作在下面会详细讲解;
以上是对 kubernetes 四种 patch 的简介,讲得很浅,如果您想深入了解每种 patch,建议参阅官方资料,接下来咱们聚焦 java 客户端对这些 patch 能力的实现;
源码下载
如果您不想编码,可以在 GitHub 下载所有源码,地址和链接信息如下表所示(https://github.com/zq2599/blog_demos):
| 名称 | 链接 | 备注 |
| :-- | :-- | :-- |
| 项目主页 | https://github.com/zq2599/blog_demos | 该项目在 GitHub 上的主页 |
| git 仓库地址(https) | https://github.com/zq2599/blog_demos.git | 该项目源码的仓库地址,https 协议 |
| git 仓库地址(ssh) | git@github.com:zq2599/blog_demos.git | 该项目源码的仓库地址,ssh 协议 |
这个 git 项目中有多个文件夹,本章的应用在 kubernetesclient 文件夹下,如下图红框所示:
实战步骤概述
接下来会创建一个 springboot 工程(该工程是 kubernetesclient 的子工程),针对四种 patch 咱们都有对应的操作;
每种 patch 都会准备对应的 json 文件,提前将这些文件的内容保存在字符串变量中,在程序里用 kubernetes 客户端的 patch 专用 API,将此 json 字符串发送出去,流程简图如下:
编码完成后,就来动手验证功能,具体操作如下:
部署名为 patch 的 deployment,这里面是咱们编码的 SpringBoot 工程,提供多个 web 接口;
浏览器访问/patch/deploy 接口,就会创建名为 test123 的 deployment,这个 deployment 里面是个 nginx,接下来的 patch 操作都是针对这个名为 test123 的 deployment;
浏览器访问 test123 的 nginx 服务,确保部署成功了;
浏览器访问/patch/json 接口,该接口会修改 test123 的一个属性:terminationGracePeriodSeconds
浏览器访问/patch/fullmerge 接口,该接口会提交全量 merge 请求,修改内容很少,仅增加了一个 label 属性;
接下来是对比 merge patch 和 strategic merge patch 区别,分别访问/patch/partmerge 和/patch/strategic 这两个接口,其实它们操作的是同一段 patch 内容(一个新的 container),结果 merge patch 会替换原有的 continer,而 strategic merge patch 不会动原有的 container,而是新增 container,导致 test123 这个 deployment 下面的 pod 从一个变为两个;
最后是 apply yaml patch,访问接口/patch/apply,会将 nginx 容器的标签从 1.18.0 改为 1.19.1,咱们只要在浏览器访问 test123 里面的 nginx 服务就能确定是否修改生效了;
准备工作
准备工作包括创建工程、编写辅助功能代码、初始化代码等:
打开《Kubernetes官方java客户端之一:准备 》一文创建的项目 kubernetesclient,新增名为 patch 的子工程,pom.xml 内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bolingcavalry</groupId>
<artifactId>kubernetesclient</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<groupId>com.bolingcavalry</groupId>
<artifactId>patch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>patch</name>
<description>patch demo</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.kubernetes</groupId>
<artifactId>client-java</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.0.RELEASE</version>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
</project>
编写一个辅助类 ClassPathResourceReader.java,作用是读取 json 文件的内容作为字符串返回:
package com.bolingcavalry.patch;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.stream.Collectors;
import org.springframework.core.io.ClassPathResource;
public class ClassPathResourceReader {
private final String path;
private String content;
public ClassPathResourceReader(String path) {
this.path = path;
}
public String getContent() {
if (content == null) {
try {
ClassPathResource resource = new ClassPathResource(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream()));
content = reader.lines().collect(Collectors.joining("\n"));
reader.close();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
return content;
}
}
接下来新建本篇文章的核心类 PatchExample.java,首先这个类中有 main 方法,整个应用从这里启动:
public static void main(String[] args) {
SpringApplication.run(PatchExample.class, args);
}
接下来有两个常量定义,分别是 kubernetes 环境里用来测试的 deployment 名称,以及 namespace 名称:
static String DEPLOYMENT_NAME = "test123";
static String NAMESPACE = "default";
然后定义几个字符串变量,执行 patch 操作时用到的 json 内容都保存到这些字符串变量中:
static String deployStr, jsonStr, mergeStr, strategicStr, applyYamlStr;
在 resources 文件夹中放入 json 文件,稍后的初始化代码会将这些文件读取到字符串变量中,如下图,这些 json 文件的内容稍后会详细说明:
编写初始化代码(通过 PostConstruct 注解实现),主要是客户端配置,还有将 json 文件的内容读出来,保存到刚刚准备的字符串变量中:
@PostConstruct
private void init() throws IOException {
// 设置 api 配置
ApiClient client = Config.defaultClient();
Configuration.setDefaultApiClient(client);
// 设置超时时间
Configuration.getDefaultApiClient().setConnectTimeout(30000);
// 部署用的 JSON 字符串
deployStr = new ClassPathResourceReader("deploy.json").getContent();
// json patch 用的 JSON 字符串
jsonStr = new ClassPathResourceReader("json.json").getContent();
// merge patch 用的 JSON 字符串,和部署的 JSON 相比:replicas 从 1 变成 2,增加一个名为 from 的 label,值为 merge
mergeStr = new ClassPathResourceReader("merge.json").getContent();
// strategic merge patch 用的 JSON 字符串
strategicStr = new ClassPathResourceReader("strategic.json").getContent();
// server side apply 用的 JSON 字符串
applyYamlStr = new ClassPathResourceReader("applyYaml.json").getContent();
}
以上就是准备工作;
创建服务
首先要开发一个部署的接口,通过调用此接口可以在 kubernetes 环境部署一个 deployment:
部署服务的 path 是/patch/deploy,代码如下,可见部署 deployment 的代码分为三步:创建 api 实例、用字符串创建 body 实例、把 body 传给 api 即可:
/**
通用 patch 方法
@param patchFormat patch 类型,一共有四种
@param deploymentName deployment 的名称
@param namespace namespace 名称
@param jsonStr patch 的 json 内容
@param fieldManager server side apply 用到
@param force server side apply 要设置为 true
@return patch 结果对象转成的字符串
@throws Exception
*/
private String patch(String patchFormat, String deploymentName, String namespace, String jsonStr, String fieldManager, Boolean force) throws Exception {
// 创建 api 对象,指定格式是 patchFormat
ApiClient patchClient = ClientBuilder
.standard()
.setOverridePatchFormat(patchFormat)
.build();
log.info("start deploy : " + patchFormat);
// 开启 debug 便于调试,生产环境慎用!!!
patchClient.setDebugging(true);
// 创建 deployment
ExtensionsV1beta1Deployment deployment = new ExtensionsV1beta1Api(patchClient)
.patchNamespacedDeployment(
deploymentName,
namespace,
new V1Patch(jsonStr),
null,
null,
fieldManager,
force
);
log.info("end deploy : " + patchFormat);
return new GsonBuilder().setPrettyPrinting().create().toJson(deployment);
}
body 实例用到的 json 字符串来自 deploy.json 文件,内容如下,很简单,只有 nginx 的 1.18.0 版本的 pod:
{
"kind":"Deployment",
"apiVersion":"extensions/v1beta1",
"metadata":{
"name":"test123",
"labels":{
"run":"test123"
}
},
"spec":{
"replicas":1,
"selector":{
"matchLabels":{
"run":"test123"
}
},
"template":{
"metadata":{
"creationTimestamp":null,
"labels":{
"run":"test123"
}
},
"spec":{
"terminationGracePeriodSeconds":30,
"containers":[
{
"name":"test123",
"image":"nginx:1.18.0",
"ports":[
{
"containerPort":80
}
],
"resources":{
}
}
]
}
},
"strategy":{
}
},
"status":{
}
}
如此一来,web 浏览器只要访问/patch/deploy 就能创建 deployment 了;
发起 patch 请求的通用方法
通过 kubernetes 的客户端发起不同的 patch 请求,其大致步骤都是相同的,只是参数有所不同,我这里做了个私有方法,发起几种 patch 请求的操作都调用此方法实现(只是入参不同而已),可见都是先建好 ApiClient 实例,将 patch 类型传入,再创建 V1Patch 实例,将 patch 字符串传入,最后执行 ExtensionsV1beta1Api 实例的 patchNamespacedDeployment 方法即可发送 patch 请求:
/**
通用 patch 方法
@param patchFormat patch 类型,一共有四种
@param deploymentName deployment 的名称
@param namespace namespace 名称
@param jsonStr patch 的 json 内容
@param fieldManager server side apply 用到
@param force server side apply 要设置为 true
@return patch 结果对象转成的字符串
@throws Exception
*/
private String patch(String patchFormat, String deploymentName, String namespace, String jsonStr, String fieldManager, Boolean force) throws Exception {
// 创建 api 对象,指定格式是 patchFormat
ApiClient patchClient = ClientBuilder
.standard()
.setOverridePatchFormat(patchFormat)
.build();
log.info("start deploy : " + patchFormat);
// 开启 debug 便于调试,生产环境慎用!!!
patchClient.setDebugging(true);
// 创建 deployment
ExtensionsV1beta1Deployment deployment = new ExtensionsV1beta1Api(patchClient)
.patchNamespacedDeployment(
deploymentName,
namespace,
new V1Patch(jsonStr),
null,
null,
fieldManager,
force
);
log.info("end deploy : " + patchFormat);
return new GsonBuilder().setPrettyPrinting().create().toJson(deployment);
}
上述代码中,有一行代码要格外重视,就是 patchClient.setDebugging(true)这段,执行了这一行,在 log 日志中就会将 http 的请求和响应全部打印出来,是我们调试的利器,但是日志内容过多,生产环境请慎用;
上述 patch 方法有六个入参,其实除了 patch 类型和 patch 内容,其他参数都可以固定下来,于是再做个简化版的 patch 方法:
/**
通用 patch 方法,fieldManager 和 force 都默认为空
@param patchFormat patch 类型,一共有四种
@param jsonStr patch 的 json 内容
@return patch 结果对象转成的字符串
@throws Exception
*/
private String patch(String patchFormat, String jsonStr) throws Exception {
return patch(patchFormat, DEPLOYMENT_NAME, NAMESPACE, jsonStr, null, null);
}
入参 patchFormat 的值是四种 patch 类型的定义,在 V1Patch.java 中,其值如下所示:
接下来可以轻松的开发各种类型 patch 的代码了;
执行 json patch
首先来看 json patch 要提交的内容,即 json.json 文件的内容,这些内容在应用启动时被保存到变量 jsonStr,如下所示,非常简单,修改了 terminationGracePeriodSeconds 属性的值,原来是 30,这个属性在停止 pod 的时候用到,是等待 pod 的主进程的最长时间:
[
{
"op":"replace",
"path":"/spec/template/spec/terminationGracePeriodSeconds",
"value":27
}
]
接下来就是 web 接口的代码,可见非常简单,仅需调用前面准备好的 patch 方法:
/**
JSON patch 格式的关系
@return
@throws Exception
*/
@RequestMapping(value = "/patch/json", method = RequestMethod.GET)
public String json() throws Exception {
return patch(V1Patch.PATCH_FORMAT_JSON_PATCH, jsonStr);
}
merge patch(全量)
先尝试全量的 merge patch,也就是准备好完整的 deployment 内容,修改其中一部分后进行提交,下图是 json 文件 merge.json 的内容,其内容前面的 deploy.json 相比,仅增加了红框处的内容,即增加了一个 label:
代码依然很简单:
@RequestMapping(value = "/patch/fullmerge", method = RequestMethod.GET)
public String fullmerge() throws Exception {
return patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, mergeStr);
}
merge patch(增量)
前面曾提到 merge patch 和 strategic merge patch 的区别:merge patch 提交一个 container 时做的是替换,而 strategic merge patch 提交一个 container 时做的是合并,为了展示这两种 patch 的不同,这里我们就用同一个 json 内容,分别执行 merge patch 和 strategic merge patch,看看结果有什么区别,这是最直观的学习方法;
这个 json 对应的文件是 strategic.json,内容如下:
{
"spec":{
"template":{
"spec":{
"containers":[
{
"name":"test456",
"image":"tomcat:7.0.105-jdk8"
}
]
}
}
}
}
增量 merge 的代码如下:
@RequestMapping(value = "/patch/partmerge", method = RequestMethod.GET)
public String partmerge() throws Exception {
return patch(V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, strategicStr);
}
strategic merge patch
strategic merge patch 用的 json 内容和前面的增量 merge patch 是同一个,代码如下:
@RequestMapping(value = "/patch/strategic", method = RequestMethod.GET)
public String strategic() throws Exception {
return patch(V1Patch.PATCH_FORMAT_STRATEGIC_MERGE_PATCH, strategicStr);
}
apply yaml patch
apply yaml patch 与其他 patch 略有不同,调用 ExtensionsV1beta1Api 的 patchNamespacedDeployment 方法发请求时,fieldManager 和 force 字段不能像之前那样为空了:
@RequestMapping(value = "/patch/apply", method = RequestMethod.GET)
public String apply() throws Exception {
return patch(V1Patch.PATCH_FORMAT_APPLY_YAML, DEPLOYMENT_NAME, NAMESPACE, applyYamlStr, "example-field-manager", true);
}
上面的代码中,如果 force 字段不等于 true,可能会导致 patch 失败,在官方文档也有说明,如下图红框:
apply yaml patch 的 json 字符串来自文件 applyYaml.json,其内容是从 deploy.json 直接复制的,然后改了下图两个红框中的内容,红框 1 修改了 nginx 的版本号,用来验证 patch 是否生效(原有版本是 1.18),红框 2 是 kubernetes1.16 之前的一个问题,protocol 字段必填,否则会报错,问题详情请参考:https://github.com/kubernetes-sigs/structured-merge-diff/issues/130
以上就是所有代码和 patch 的内容了,接下来部署到 kubernetes 环境实战吧
制作镜像并且部署
在 patch 工程目录下执行以下命令编译构建:
mvn clean package -U -DskipTests
在 patch 工程目录下创建 Dockerfile 文件,内容如下:
指定基础镜像,这是分阶段构建的前期阶段
FROM openjdk:8u212-jdk-stretch as builder
执行工作目录
WORKDIR application
配置参数
ARG JAR_FILE=target/*.jar
将编译构建得到的 jar 文件复制到镜像空间中
COPY ${JAR_FILE} application.jar
通过工具 spring-boot-jarmode-layertools 从 application.jar 中提取拆分后的构建结果
RUN java -Djarmode=layertools -jar application.jar extract
评论