自己的 Kubernetes 控制器(3)——改进和部署
我们在前面讲述了 Kubernetes 控制器的概念。简单说来控制器就是个控制回路,用来将当前状态协调到目标状态。第二篇使用 Java 实现了一个控制器。这一篇会讲讲如何部署控制器,以及如何对控制器进行改进。
集群内外
在第一篇中提到过,控制器在集群内外都能运行,只要能够完成必要的通信过程就可以。缺省情况下,官方 Kubernetes 客户端和 Fabric8 客户端都会尝试使用 ~/.kube/config
配置中存储的凭据。也就是说只要使用 kubectl
命令能访问集群,就能运行这个控制器。
交付物可以是以下几种形式:独立的 JAR,应用服务器中部署的 WebApp,甚至是一个包含很多 Class 文件的目录。这种方法的缺点是,应该把所有与所选择的方法相关的常规任务都照顾到。
另一方面,用容器化应用的方式在 Kubernetes 集群中运行会有很多好处:自动化、监控、伸缩、自愈等。如此看来,没有不容器化的道理。因此我们要给我们的控制器进行容器化。
控制器的容器化
给 Java 应用进行容器化的最直接方式就是使用 Jib 插件。这个插件在 Maven 和 Gradle 中可用,兼容于普通应用、Spring Boot 和 Micronaut 应用;它生成的镜像会分为不同的层次:最上层是业务类,下面则是依赖库。这种构建方式加快了更新镜像的构建速度:当业务更新时,只需要更换最上面的层就可以了。
Jib 配置样例:
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>1.8.0</version>
<configuration>
<from>
<image>gcr.io/distroless/java:debug</image>
</from>
<to>
<image>jvm-operator:${project.version}</image>
</to>
</configuration>
<executions>
<execution>
<phase>compile</phase>
<goals>
<goal>dockerBuild</goal>
</goals>
</execution>
</executions>
</plugin>
- 缺省镜像没有 Shell,为了方便调试,提供一个
debug
Tag - 目标镜像的标签来自于 POM
- 在
compile
阶段会运行插件。注意镜像并没有进行打包操作,因此package
阶段可以跳过 - 可用的目标包括
build
和dockerBuild
。前者无需本地 Docker,并把镜像上传到 DockerHub;后者会把镜像构建到本地 Docker 中
到了这一步,写个 Kubernetes 配置就很容易了。
deploy.yml
apiVersion: v1
kind: Pod
metadata:
namespace: jvmoperator
name: custom-operator
spec:
containers:
- name: custom-operator
image: jvm-operator:1.10
imagePullPolicy: Never
上边的代码段偷懒声明了一个简单的 Pod
。真实世界的配置会用 Deployment
。
kubectl apply -f deploy.yml
不幸的是,这个命令会失败,输出下列内容:
java.net.ProtocolException: Expected HTTP 101 response but was '403 Forbidden'
at okhttp3.internal.ws.RealWebSocket.checkResponse(RealWebSocket.java:229)
at okhttp3.internal.ws.RealWebSocket$2.onResponse(RealWebSocket.java:196)
at okhttp3.RealCall$AsyncCall.execute(RealCall.java:203)
at okhttp3.internal.NamedRunnable.run(NamedRunnable.java:32)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
鉴权
这个错误仅在集群内运行时候发生,原因是权限不足。给 Kubernetes API 发送请求是个危险行为,缺省情况下每个请求都会返回错误。因此这个容器需要有合适的授权:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
namespace: jvmoperator
name: operator-example
rules:
- apiGroups:
- ""
resources:
- pods
verbs:
- watch
- create
- delete
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: operator-service
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: operator-example
subjects:
- kind: ServiceAccount
name: operator-service
namespace: jvmoperator
roleRef:
kind: ClusterRole
name: operator-example
apiGroup: rbac.authorization.k8s.io
Kubernetes 中用 RBAC 的方式进行鉴权。这方面的主题比较复杂,想要细致学习,可以参考相关文档。
提交上述代码后,这个 Pod 就能够使用新的 ServiceAccount 运行了——只要做一点简单的修改:
apiVersion: v1
kind: Pod
metadata:
namespace: jvmoperator
name: custom-operator
spec:
serviceAccountName: operator-service
containers:
- name: custom-operator
image: jvm-operator:1.8
imagePullPolicy: Never
容器化 JVM 应用的隐患
早期版本的 JVM 会返回主机的 CPU 和内存数量,而不是容器的。JVM 尝试占用不存在的内存,会导致 OutOfMemoryError
。Kubernetes 则会杀死行为异常的 Pod。如果被杀死 Pod 是 ReplicaSet 的一部分,就会新建一个 Pod。这个过程很不利联想。JDK 10 开始这个问题已经解决了(这个特性也被融合到 JDK 8 的新版本之中)。
JVM 能够根据工作负载来调整应用程序的编译代码,这是优于静态编译的原生可执行程序的。JVM 需要大量的额外内存来实现这一点。而且 JVM 的启动时间相当长。由于自适应编译后的代码需要时间,所以在启动后的一段时间内,性能都不会符合要求。这也是为什么在 JVM 上的性能指标总是要在较长的预热时间后再进行测量的原因。最后,与原生可执行文件相比,容器的大小要大得多,因为它嵌入了 JVM 本身。
REPOSITORY TAG IMAGE ID CREATED SIZE
jvm-operator 1.8 bdaa419c75e2 50 years ago 141MB
综上所述,JVM 并非容器化应用的好对象。
克服 JVM 的限制
有两种方式能够克服上述的 JVM 问题
- 使用 Java 9 中引入的模块系统,JDK 提供了一个思路,让原生可执行文件只包含引用到的模块,抛弃其它内容。这样就见效了可执行尺寸。
- 使用 Graal VM 的 Substrate VM
Substrate VM 是一个能够将 Java 预编译成可执行镜像的框架。
Graal VM 能帮助你:
- 把应用打包成单一的 JAR
- 从 JAR 创建原生可执行文件
- 把原生可执行文件进行容器化
不幸的是,Jib 没有 GraalVM 的配置。因此需要使用多阶段 Dockerfile:
- 构建 JAR
- 从 JAR 构建 原生可执行文件
容器化
ARG VERSION=1.10 FROM zenika/alpine-maven:3 as build COPY src src COPY pom.xml pom.xml RUN mvn package FROM oracle/graalvm-ce:19.2.1 as native ARG VERSION COPY --from=build /usr/src/app/target/jvm-operator-$VERSION.jar \ /var/jvm-operator-$VERSION.jar WORKDIR /opt/graalvm RUN gu install native-image \ && native-image -jar /var/jvm-operator-$VERSION.jar \ && mv jvm-operator-$VERSION /opt/jvm-operator-$VERSION FROM scratch ARG VERSION WORKDIR /home COPY --from=native /opt/jvm-operator-$VERSION operator ENTRYPOINT ["./operator"]
- Graal VM 发行版中缺省是不包括 Substrate VM 的,因此首先要进行安装
- 在前面步骤生成的 JAR 上执行
native-image
过程 - 使用
scratch
镜像为基础。在编译过程中使用--static
选项打包,来包含所依赖的库
这样就缩减了镜像的尺寸:
REPOSITORY TAG IMAGE ID CREATED SIZE
jvm-operator 1.10 340d4d9a767e 6 weeks ago 52.7MB
Substrate VM 包含很多配置项目,为了达到上面的效果,需要这样的一组参数:
native-image.properties
Args= -J-Xmx3072m \
--static \
--allow-incomplete-classpath \
--no-fallback \
--no-server \
-H:EnableURLProtocols=https \
-H:ConfigurationFileDirectories=/var/config
应对反射
AOT 过程在反射基础上还有诸多限制。根据底层代码的编写方式不同,可能会受到更多的影响。在不同状况之中,有不同的方法来解决这个问题。这些都将在以后的帖子中介绍:现在我们先来关注一下反射。
在 Java 中,一些底层代码或多或少依赖于基于运行时的反射。不幸的是,Substrate VM 会删除它认为不需要的代码。不过,这可以通过JSON文件来配置。鉴于依赖反射的调用量,手动配置是一项艰巨的任务。
Substrate VM 提供了一个更好的选择:它提供了一个 Java 代理,可以在运行中的控制器的命令行中设置。这个代理会拦截控制器应用程序内部的每一个反射调用,并将其记录在一个专门的 reflect-config.json
文件中。
在以后的阶段,这个文件(和其他类似的文件一起)可以反馈到编译过程中,这样通过反射访问的代码就会被保留下来。一种方法是通过命令行来送入它们。另一种是将它们打包到 JAR 里面,放在一个专门的文件夹里:这允许库的提供者提供与 AOT 兼容的 JAR,应该是首选的方式。
根据具体应用的不同,可能还会需要额外的步骤。更多信息,请参考:《How to cope with incompatible code in Graal VM AOT compilation》。
结论
三篇文章,我们讲述了 Kubernetes 控制器的实现方法。开发过程中我们看到,这并不是一项艰巨的任务。在这其中提到的技术基础之上,能够实现更多更好的功能。
最后我们在 Kubernetes 集群上运行了新开发的 Java 控制器。后续我们引入 Graal VM 创建了一个原生可执行文件。虽然它使构建过程更加复杂,但使用这样的原生可执行文件消除了 JVM 平台的一些限制:它大大减少了映像大小、内存消耗以及启动时间。
完整的源码可以在 Github 上找到