使用Spring Boot和GraalVM在Knative上构建微服务 - piotr


在本文中,您将学习如何在 Knative 上运行相互通信的 Spring Boot 微服务。我还将向您展示如何使用 GraalVM 准备 Spring Boot 应用程序的本机镜像/映像。然后我们将使用 Skaffold 和 Jib Maven 插件在 Kubernetes 上运行它。
在 Knative 上,您可以运行任何类型的应用程序——不仅仅是一个函数。在这篇文章中,当我写“微服务”时,其实我在思考的是服务到服务的通信。
源代码
如果您想自己尝试一下,可以随时查看我的源代码。为此,您需要克隆我的 GitHub 存储库
作为本文中的微服务示例,我使用了两个应用程序callme-service和caller-service. 它们都公开了一个端点,该端点打印了应用程序 pod 的名称。caller-service应用程序还调用应用程序公开的端点callme-service。
在 Kubernetes 上,这两个应用程序都将部署为多个修订版的 Knative 服务。我们还将使用 Knative 路由在这些修订中分配流量。下面可见的图片说明了我们示例系统的架构。

 
1.准备Spring Boot微服务
我们有两个简单的 Spring Boot 应用程序,它们公开一个 REST 端点、健康检查和运行内存 H2 数据库。我们使用 Hibernate 和 Lombok。因此,我们需要在 Maven 中包含以下依赖项列表pom.xml。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
</dependency>

 
每次我们调用ping端点时,它都会创建一个事件并将其存储在 H2 数据库中。REST 端点返回 Kubernetes 内的 pod 和命名空间的名称以及事件的 id。该方法在我们对集群的手动测试中很有用。

@RestController
@RequestMapping("/callme")
public class CallmeController {

    @Value(
"${spring.application.name}")
    private String appName;
    @Value(
"${POD_NAME}")
    private String podName;
    @Value(
"${POD_NAMESPACE}")
    private String podNamespace;
    @Autowired
    private CallmeRepository repository;

    @GetMapping(
"/ping")
    public String ping() {
        Callme c = repository.save(new Callme(new Date(), podName));
        return appName +
"(id=" + c.getId() + "): " + podName + " in " + podNamespace;
    }

}

这是我们的模型类 - Callme。应用程序中的模型类caller-service非常相似。

@Entity
@Getter
@Setter
@NoArgsConstructor
@RequiredArgsConstructor
public class Callme {

    @Id
    @GeneratedValue
    private Integer id;
    @Temporal(TemporalType.TIMESTAMP)
    @NonNull
    private Date addDate;
    @NonNull
    private String podName;

}

另外,让我们看一下ping. CallerController稍后我们将在讨论通信和跟踪时对其进行修改。现在,重要的是要了解此方法还调用 ping 暴露的方法callme-service并返回整个响应。

@GetMapping("/ping")
public String ping() {
    Caller c = repository.save(new Caller(new Date(), podName));
    String callme = callme();
    return appName +
"(id=" + c.getId() + "): " + podName + " in " + podNamespace
            +
" is calling " + callme;
}

  
2. 使用 GraalVM 准备 Spring Boot 原生镜像
Spring Native 支持使用 GraalVM 本机编译器将 Spring 应用程序编译为本机可执行文件。有关此项目的更多详细信息,您可以参考其文档。这是我们应用程序的主要类。

@SpringBootApplication
public class CallmeApplication {

   public static void main(String args) {
      SpringApplication.run(CallmeApplication.class, args);
   }

}

Hibernate 在运行时做了很多动态的事情。因此,我们需要让 Hibernate 在构建时增强应用程序中的实体。我们需要将以下 Maven 插件添加到我们的构建中。

<plugin>
   <groupId>org.hibernate.orm.tooling</groupId>
   <artifactId>hibernate-enhance-maven-plugin</artifactId>
   <version>${hibernate.version}</version>
   <executions>
      <execution>
         <configuration>
            <failOnError>true</failOnError>
            <enableLazyInitialization>true</enableLazyInitialization>
            <enableDirtyTracking>true</enableDirtyTracking>
            <enableExtendedEnhancement>false</enableExtendedEnhancement>
         </configuration>
         <goals>
            <goal>enhance</goal>
         </goals>
      </execution>
   </executions>
</plugin>

在本文中,我使用的是 Spring Native 的最新版本——0.9.0。由于 Spring Native 正在积极开发中,后续版本之间会有较大的变化。如果您将其与其他基于早期版本的文章进行比较,我们不必禁用proxyBeansMethods、排除SpringDataWebAutoConfiguration、添加spring-context-indexer到依赖项或创建hibernate.properties。凉爽的!我也可以使用 Buildpacks 来构建原生镜像。
所以,现在我们只需要添加以下依赖项。

<dependency>
   <groupId>org.springframework.experimental</groupId>
   <artifactId>spring-native</artifactId>
   <version>0.9.0</version>
</dependency>

Spring AOT 插件执行提高本机图像兼容性和占用空间所需的提前转换。

<plugin>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-aot-maven-plugin</artifactId>
    <version>${spring.native.version}</version>
    <executions>
        <execution>
            <id>test-generate</id>
            <goals>
                <goal>test-generate</goal>
            </goals>
        </execution>
        <execution>
            <id>generate</id>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
</plugin>

 
3. 使用 Buildpacks 在 Knative 上运行原生镜像
使用 Builpacks 创建原生镜像是我们的主要选择。虽然它需要一个 Docker 守护进程,但它在每个操作系统上都能正常工作。但是,我们需要使用最新的稳定版 Spring Boot。在这种情况下,它是2.4.3。您也可以在 Maven pom.xml 中使用spring-boot-maven-plugin. 由于我们需要在 Kubernetes 上一步构建和部署应用程序,因此我更喜欢在 Skaffold 中进行配置。我们paketobuildpacks/builder:tiny用作构建器图像。还需要使用BP_BOOT_NATIVE_IMAGE环境变量启用本机构建选项。

apiVersion: skaffold/v2beta11
kind: Config
metadata:
  name: callme-service
build:
  artifacts:
  - image: piomin/callme-service
    buildpacks:
      builder: paketobuildpacks/builder:tiny
      env:
        - BP_BOOT_NATIVE_IMAGE=true
deploy:
  kubectl:
    manifests:
      - k8s/ksvc.yaml

Skaffold 配置是指我们的 KnativeService清单。这是非常不典型的,因为我们需要将 pod 和命名空间名称注入到容器中。我们还允许每个 pod 最多有 10 个并发请求。如果超过,Knative 会扩大一些正在运行的实例。

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: callme-service
spec:
  template:
    spec:
      containerConcurrency: 10
      containers:
      - name: callme
        image: piomin/callme-service
        ports:
          - containerPort: 8080
        env:
          - name: POD_NAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name
          - name: POD_NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace

默认情况下,Knative 不允许使用 KubernetesfieldRef功能。为了启用它,我们需要更新命名空间knative-features ConfigMap中的knative-serving。所需的属性名称是kubernetes.podspec-fieldref。

kind: ConfigMap
apiVersion: v1
metadata:
  annotations:
  namespace: knative-serving
  labels:
    serving.knative.dev/release: v0.16.0
data:
  kubernetes.podspec-fieldref: enabled

最后,我们可以使用以下命令在 Knative 上构建和部署 Spring Boot 微服务。

$ skaffold run

 
4. 使用 Jib 在 Knative 上运行原生镜像
与我之前关于 Knative 的文章一样,我们将使用 Skaffold 和 Jib 在 Kubernetes 上构建和运行我们的应用程序。幸运的是,Jib Maven Plugin 已经引入了对 GraalVM “native images”的支持。Jib GraalVM Native Image Extension 希望 能够完成生成“原生图像”( 目标)native-image-maven-plugin 的繁重工作 。native-image:native-image然后扩展只是简单地将二进制文件复制到容器映像中并将其设置为可执行文件。

当然,与 Java 字节码不同,本机映像不可移植,而是特定于平台的。Native Image Maven Plugin 不支持交叉编译,因此 native-image 应该构建在与运行时架构相同的操作系统上。由于我在 Ubuntu 20.10 上构建了我的应用程序的 GraalVM 映像,因此我应该使用相同的基础 Docker 映像来运行容器化微服务。在这种情况下,我选择了镜像ubuntu:20.10,如下所示。

<plugin>
   <groupId>com.google.cloud.tools</groupId>
   <artifactId>jib-maven-plugin</artifactId>
   <version>2.8.0</version>
   <dependencies>
      <dependency>
         <groupId>com.google.cloud.tools</groupId>
         <artifactId>jib-native-image-extension-maven</artifactId>
         <version>0.1.0</version>
      </dependency>
   </dependencies>
   <configuration>
      <from>
         <image>ubuntu:20.10</image>
      </from>
      <pluginExtensions>
         <pluginExtension>
            <implementation>com.google.cloud.tools.jib.maven.extension.nativeimage.JibNativeImageExtension</implementation>
         </pluginExtension>
      </pluginExtensions>
   </configuration>
</plugin>

如果你使用 Jib Maven 插件,你首先需要构建一个原生镜像。为了构建应用程序的本机映像,我们还需要包含一个native-image-maven-plugin. 你需要使用 GraalVM JDK 构建我们的应用程序。

<plugin>
   <groupId>org.graalvm.nativeimage</groupId>
   <artifactId>native-image-maven-plugin</artifactId>
   <version>21.0.0.2</version>
   <executions>
      <execution>
         <goals>
            <goal>native-image</goal>
         </goals>
         <phase>package</phase>
      </execution>
   </executions>
</plugin>

因此,本节的最后一步只是运行 Maven 构建。在我的配置中,native-image-maven-plugin需要在native-image配置文件下激活一个。

$ mvn clean package -Pnative-image

Skaffold 的配置是典型的。我们只需要启用 Jib 作为构建工具。

apiVersion: skaffold/v2beta11
kind: Config
metadata:
  name: callme-service
build:
  artifacts:
  - image: piomin/callme-service
    jib: {}
deploy:
  kubectl:
    manifests:
      - k8s/ksvc.yaml

最后,我们可以使用以下命令在 Knative 上构建和部署 Spring Boot 微服务。

$ skaffold run
 
5. Knative 上微服务之间的通信
我在 Knative 上部署了每个应用程序的两个修订版。只是为了比较,部署应用程序的第一个版本是使用 OpenJDK 编译的。只有最新版本基于 GraalVM 原生镜像。因此,我们可以比较两个版本的启动时间。
让我们看一下部署我们应用程序的两个版本后的修订列表。流量分为 60% 到最新版本,40% 到每个应用程序的先前版本。
 

在底层,Knative 创建了 KubernetesServices和多个Deployments. Deployment每个 Knative总是有一个Revision。此外,有多种服务,但始终其中一项服务是针对所有修订版的。那Service是一种ExternalName服务类型。假设您仍想在多个修订版之间拆分流量,您应该在通信中准确使用该服务。服务的名称是callme-service。但是,我们应该使用带有命名空间名称和svc.cluster.local后缀的 FQDN 名称。

我们可以使用 SpringRestTemplate来调用callme-service. 为了保证对整个请求路径的跟踪,我们需要在后续调用之间传播 Zipkin 标头。对于通信,我们将使用具有完全限定的内部域名 ( callme-service.serverless.svc.cluster.local) 的服务,如前所述。

@RestController
@RequestMapping("/caller")
public class CallerController {

   private RestTemplate restTemplate;

   CallerController(RestTemplate restTemplate) {
      this.restTemplate = restTemplate;
   }

   @Value(
"${spring.application.name}")
   private String appName;
   @Value(
"${POD_NAME}")
   private String podName;
   @Value(
"${POD_NAMESPACE}")
   private String podNamespace;
   @Autowired
   private CallerRepository repository;

   @GetMapping(
"/ping")
   public String ping(@RequestHeader HttpHeaders headers) {
      Caller c = repository.save(new Caller(new Date(), podName));
      String callme = callme(headers);
      return appName +
"(id=" + c.getId() + "): " + podName + " in " + podNamespace
                     +
" is calling " + callme;
   }

   private String callme(HttpHeaders headers) {
      MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
      Set<String> headerNames = headers.keySet();
      headerNames.forEach(it -> map.put(it, headers.get(it)));
      HttpEntity httpEntity = new HttpEntity(map);
      ResponseEntity<String> entity = restTemplate
         .exchange(
"http://callme-service.serverless.svc.cluster.local/callme/ping",
                  HttpMethod.GET, httpEntity, String.class);
      return entity.getBody();
   }

}

为了测试我们的微服务之间的通信,我们只需要caller-service通过 Knative调用Route。

GET /caller/ping让我们对调用者服务端点执行一些测试调用。我们应该使用 URL http://caller-service-serverless.apps.cluster-d556.d556.sandbox262.opentlc.com/caller/ping。

在第一次请求 caller-service 时调用最新版本的 callme-service(用 GraalVM 编译)。在第三个请求中,它与旧版本的 callme-service(使用 OpenJDK 编译)进行通信。让我们比较同一应用程序的这两个版本的启动时间。

使用 GraalVM,我们有0.3s而不是5.9s。我们还应该记住,我们的应用程序会启动一个内存中的嵌入式 H2 数据库。


 

6. 使用 Jaeger 配置跟踪
为了启用 Knative 的跟踪,我们需要更新命名空间knative-tracing ConfigMap中的knative-serving。当然,我们首先需要在我们的集群中安装 Jaeger。

apiVersion: operator.knative.dev/v1alpha1
kind: KnativeServing
metadata:
  name: knative-tracing
  namespace: knative-serving
spec:
  sample-rate: "1" 
  backend: zipkin 
  zipkin-endpoint: http:
//jaeger-collector.knative-serving.svc.cluster.local:9411/api/v2/spans 
  debug:
"false"

你也可以使用 Helm chart 来安装 Jaeger。使用此选项,您需要执行以下 Helm 命令。

$ helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
$ helm install jaeger jaegertracing/jaeger

Knative 会自动创建 Zipkin span headers。我们唯一的目标是在caller-service和callme-service应用程序之间传播 HTTP 标头。在我的配置中,Knative 向 Jaeger 发送 100% 的跟踪信息。让我们看一下GET /caller/pingKnative 微服务网格中端点的一些跟踪。


我们还可以查看每个请求的详细视图。

 
结论
在 Knative 上运行微服务时,需要考虑几件重要的事情。我专注于与通信和跟踪相关的方面。我还展示了 Spring Boot 不必在几秒钟内启动。使用 GraalVM,它可以在几毫秒内启动,因此您绝对可以将其视为无服务器框架。