自己的 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 阶段可以跳过
  • 可用的目标包括 builddockerBuild。前者无需本地 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 问题

  1. 使用 Java 9 中引入的模块系统,JDK 提供了一个思路,让原生可执行文件只包含引用到的模块,抛弃其它内容。这样就见效了可执行尺寸。
  2. 使用 Graal VM 的 Substrate VM

Substrate VM 是一个能够将 Java 预编译成可执行镜像的框架。

Graal VM 能帮助你:

  • 把应用打包成单一的 JAR
  • 从 JAR 创建原生可执行文件
  • 把原生可执行文件进行容器化

不幸的是,Jib 没有 GraalVM 的配置。因此需要使用多阶段 Dockerfile:

  1. 构建 JAR
  2. 从 JAR 构建 原生可执行文件
  3. 容器化

    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 上找到

Avatar
崔秀龙

简单,是大师的责任;我们凡夫俗子,能做到清楚就很不容易了。

comments powered by Disqus
下一页
上一页

相关