# 自己的 Kubernetes 控制器（3）——改进和部署

我们在前面讲述了 Kubernetes 控制器的概念。简单说来控制器就是个控制回路，用来将当前状态协调到目标状态。第二篇使用 Java 实现了一个控制器。这一篇会讲讲如何部署控制器，以及如何对控制器进行改进。

## 集群内外

在第一篇中提到过，控制器在集群内外都能运行，只要能够完成必要的通信过程就可以。缺省情况下，官方 Kubernetes 客户端和 Fabric8 客户端都会尝试使用 `~/.kube/config` 配置中存储的凭据。也就是说只要使用 `kubectl` 命令能访问集群，就能运行这个控制器。

交付物可以是以下几种形式：独立的 JAR，应用服务器中部署的 WebApp，甚至是一个包含很多 Class 文件的目录。这种方法的缺点是，应该把所有与所选择的方法相关的常规任务都照顾到。

另一方面，用容器化应用的方式在 Kubernetes 集群中运行会有很多好处：自动化、监控、伸缩、自愈等。如此看来，没有不容器化的道理。因此我们要给我们的控制器进行容器化。

## 控制器的容器化

给 Java 应用进行容器化的最直接方式就是使用 [Jib 插件](https://github.com/GoogleContainerTools/jib)。这个插件在 Maven 和 Gradle 中可用，兼容于普通应用、Spring Boot 和 Micronaut 应用；它生成的镜像会分为不同的层次：最上层是业务类，下面则是依赖库。这种构建方式加快了更新镜像的构建速度：当业务更新时，只需要更换最上面的层就可以了。

Jib 配置样例：

~~~xml
<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

~~~yaml
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`

不幸的是，这个命令会失败，输出下列内容：

~~~text
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 发送请求是个危险行为，缺省情况下每个请求都会返回错误。因此这个容器需要有合适的授权：

~~~yaml
---
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 运行了——只要做一点简单的修改：

~~~yaml
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 本身。

~~~plaintext
REPOSITORY            TAG          IMAGE ID            CREATED             SIZE
jvm-operator          1.8          bdaa419c75e2        50 years ago        141MB
~~~

综上所述，JVM 并非容器化应用的好对象。

## 克服 JVM 的限制

有两种方式能够克服上述的 JVM 问题

1. 使用 Java 9 中引入的模块系统，JDK 提供了一个思路，让原生可执行文件只包含引用到的模块，抛弃其它内容。这样就见效了可执行尺寸。
1. 使用 [Graal VM](https://www.graalvm.org/) 的 Substrate VM

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

Graal VM 能帮助你：

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

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

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

~~~dockerfile
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` 选项打包，来包含所依赖的库

这样就缩减了镜像的尺寸：

~~~text
REPOSITORY            TAG          IMAGE ID            CREATED             SIZE
jvm-operator          1.10         340d4d9a767e        6 weeks ago         52.7MB
~~~

Substrate VM 包含很多配置项目，为了达到上面的效果，需要这样的一组参数：

> native-image.properties

~~~plaintext
Args=  -J-Xmx3072m \
       --static \
       --allow-incomplete-classpath \
       --no-fallback \
       --no-server \
       -H:EnableURLProtocols=https \
       -H:ConfigurationFileDirectories=/var/config
~~~

## 应对反射

AOT 过程在反射基础上还有[诸多限制](https://github.com/oracle/graal/blob/master/substratevm/LIMITATIONS.md)。根据底层代码的编写方式不同，可能会受到更多的影响。在不同状况之中，有不同的方法来解决这个问题。这些都将在以后的帖子中介绍：现在我们先来关注一下反射。

在 Java 中，一些底层代码或多或少依赖于基于运行时的反射。不幸的是，Substrate VM 会删除它认为不需要的代码。不过，这可以通过JSON文件来配置。鉴于依赖反射的调用量，手动配置是一项艰巨的任务。

Substrate VM 提供了一个更好的选择：它提供了一个 Java 代理，可以在运行中的控制器的命令行中设置。这个代理会拦截控制器应用程序内部的每一个反射调用，并将其记录在一个专门的 `reflect-config.json` 文件中。

在以后的阶段，这个文件（和其他类似的文件一起）可以反馈到编译过程中，这样通过反射访问的代码就会被保留下来。一种方法是通过命令行来送入它们。另一种是将它们打包到 JAR 里面，放在一个专门的文件夹里：这允许库的提供者提供与 AOT 兼容的 JAR，应该是首选的方式。

根据具体应用的不同，可能还会需要额外的步骤。更多信息，请参考：[《How to cope with incompatible code in Graal VM AOT compilation》](https://blog.frankel.ch/coping-incompatible-code-graalvm-compilation/)。

## 结论

三篇文章，我们讲述了 Kubernetes 控制器的实现方法。开发过程中我们看到，这并不是一项艰巨的任务。在这其中提到的技术基础之上，能够实现更多更好的功能。

最后我们在 Kubernetes 集群上运行了新开发的 Java 控制器。后续我们引入 Graal VM 创建了一个原生可执行文件。虽然它使构建过程更加复杂，但使用这样的原生可执行文件消除了 JVM 平台的一些限制：它大大减少了映像大小、内存消耗以及启动时间。

完整的源码可以在 [Github](https://github.com/nfrankel/jvm-controller) 上找到
