JavaSpringBoot

为什么要用Istio?

目前,对于Java技术栈来说,构建微服务的最佳选择是Spring Boot而Spring Boot一般搭配目前落地案例很多的微服务框架Spring Cloud来使用。
Spring Cloud看似很完美,但是在实际上手开发后,很容易就会发现Spring Cloud存在以下比较严重的问题:

  • 服务治理相关的逻辑存在于Spring Cloud Netflix等SDK中,与业务代码紧密耦合。
  • SDK对业务代码侵入太大,SDK发生升级且无法向下兼容时,业务代码必须做出改变以适配SDK的升级——即使业务逻辑并没有发生任何变化。
  • 各种组件令人眼花缭乱,质量也参差不齐,学习成本太高,且组件之间代码很难完全复用,仅仅为了实现治理逻辑而学习SDK也并不是很好的选择。
  • 绑定于Java技术栈,虽然可以接入其他语言但要手动实现服务治理相关的逻辑,不符合微服务“可以用多种语言进行开发”的原则。
  • Spring Cloud仅仅是一个开发框架,没有实现微服务所必须的服务调度、资源分配等功能,这些需求要借助Kubernetes等平台来完成。但Spring Cloud与Kubernetes功能上有重合,且部分功能也存在冲突,二者很难完美配合。

替代Spring Cloud的选择有没有呢?有!它就是Istio。
Istio彻底把治理逻辑从业务代码中剥离出来,成为了独立的进程(Sidecar)。部署时两者部署在一起,在一个Pod里共同运行,业务代码完全感知不到Sidecar的存在。这就实现了治理逻辑对业务代码的零侵入——实际上不仅是代码没有侵入,在运行时两者也没有任何的耦合。这使得不同的微服务完全可以使用不同语言、不同技术栈来开发,也不用担心服务治理问题,可以说这是一种很优雅的解决方案了。
所以,“为什么要使用Istio”这个问题也就迎刃而解了——因为Istio解决了传统微服务诸如业务逻辑与服务治理逻辑耦合、不能很好地实现跨语言等痛点,而且非常容易使用。只要会用Kubernetes,学习Istio的使用一点都不困难。

为什么要使用gRPC作为通信框架?

在微服务架构中,服务之间的通信是一个比较大的问题,一般采用RPC或者RESTful API来实现。
Spring Boot可以使用RestTemplate调用远程服务,但这种方式不直观,代码也比较复杂,进行跨语言通信也是个比较大的问题;而gRPC相比Dubbo等常见的Java RPC框架更加轻量,使用起来也很方便,代码可读性高,并且与Istio和Kubernetes可以很好地进行整合,在Protobuf和HTTP2的加持下性能也还不错,所以这次选择了gRPC来解决Spring Boot微服务间通信的问题。并且,虽然gRPC没有服务发现、负载均衡等能力,但是Istio在这方面就非常强大,两者形成了完美的互补关系。
由于考虑到各种grpc-spring-boot-starter可能会对Spring Boot与Istio的整合产生不可知的副作用,所以这一次没有用任何的grpc-spring-boot-starter,而是直接手写了gRPC与Spring Boot的整合。不想借助第三方框架整合gRPC和Spring Boot的可以简单参考一下实现。

编写业务代码

首先使用Spring Initializr建立父级项目spring-boot-istio,并引入gRPC的依赖。pom文件如下:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <modules>
  6. <module>spring-boot-istio-api</module>
  7. <module>spring-boot-istio-server</module>
  8. <module>spring-boot-istio-client</module>
  9. </modules>
  10. <parent>
  11. <groupId>org.springframework.boot</groupId>
  12. <artifactId>spring-boot-starter-parent</artifactId>
  13. <version>2.2.6.RELEASE</version>
  14. <relativePath/> <!-- lookup parent from repository -->
  15. </parent>
  16. <groupId>site.wendev</groupId>
  17. <artifactId>spring-boot-istio</artifactId>
  18. <version>0.0.1-SNAPSHOT</version>
  19. <name>spring-boot-istio</name>
  20. <description>Demo project for Spring Boot With Istio.</description>
  21. <packaging>pom</packaging>
  22. <properties>
  23. <java.version>1.8</java.version>
  24. </properties>
  25. <dependencyManagement>
  26. <dependencies>
  27. <dependency>
  28. <groupId>io.grpc</groupId>
  29. <artifactId>grpc-all</artifactId>
  30. <version>1.28.1</version>
  31. </dependency>
  32. </dependencies>
  33. </dependencyManagement>
  34. </project>

然后建立公共依赖模块spring-boot-istio-api,pom文件如下,主要就是gRPC的一些依赖:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0"
  3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  5. <parent>
  6. <artifactId>spring-boot-istio</artifactId>
  7. <groupId>site.wendev</groupId>
  8. <version>0.0.1-SNAPSHOT</version>
  9. </parent>
  10. <modelVersion>4.0.0</modelVersion>
  11. <artifactId>spring-boot-istio-api</artifactId>
  12. <dependencies>
  13. <dependency>
  14. <groupId>io.grpc</groupId>
  15. <artifactId>grpc-all</artifactId>
  16. </dependency>
  17. <dependency>
  18. <groupId>javax.annotation</groupId>
  19. <artifactId>javax.annotation-api</artifactId>
  20. <version>1.3.2</version>
  21. </dependency>
  22. </dependencies>
  23. <build>
  24. <extensions>
  25. <extension>
  26. <groupId>kr.motd.maven</groupId>
  27. <artifactId>os-maven-plugin</artifactId>
  28. <version>1.6.2</version>
  29. </extension>
  30. </extensions>
  31. <plugins>
  32. <plugin>
  33. <groupId>org.xolstice.maven.plugins</groupId>
  34. <artifactId>protobuf-maven-plugin</artifactId>
  35. <version>0.6.1</version>
  36. <configuration>
  37. <protocArtifact>com.google.protobuf:protoc:3.11.3:exe:${os.detected.classifier}</protocArtifact>
  38. <pluginId>grpc-java</pluginId>
  39. <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.28.1:exe:${os.detected.classifier}</pluginArtifact>
  40. <protocExecutable>/Users/jiangwen/tools/protoc-3.11.3/bin/protoc</protocExecutable>
  41. </configuration>
  42. <executions>
  43. <execution>
  44. <goals>
  45. <goal>compile</goal>
  46. <goal>compile-custom</goal>
  47. </goals>
  48. </execution>
  49. </executions>
  50. </plugin>
  51. </plugins>
  52. </build>
  53. </project>

建立src/main/proto文件夹,在此文件夹下建立hello.proto,定义服务间的接口如下:

  1. syntax = "proto3";
  2. option java_package = "site.wendev.spring.boot.istio.api";
  3. option java_outer_classname = "HelloWorldService";
  4. package helloworld;
  5. service HelloWorld {
  6. rpc SayHello (HelloRequest) returns (HelloResponse) {}
  7. }
  8. message HelloRequest {
  9. string name = 1;
  10. }
  11. message HelloResponse {
  12. string message = 1;
  13. }

很简单,就是发送一个name返回一个带name的message。
然后生成服务端和客户端的代码,并且放到java文件夹下。这部分内容可以参考gRPC的官方文档。
有了API模块之后,就可以编写服务提供者(服务端)和服务消费者(客户端)了。这里重点看一下如何整合gRPC和Spring Boot。

服务端

业务代码非常简单:

  1. /**
  2. * 服务端业务逻辑实现
  3. */
  4. @Slf4j
  5. @Component
  6. public class HelloServiceImpl extends HelloWorldGrpc.HelloWorldImplBase {
  7. @Override
  8. public void sayHello(HelloWorldService.HelloRequest request,
  9. StreamObserver<HelloWorldService.HelloResponse> responseObserver) {
  10. // 根据请求对象建立响应对象,返回响应信息
  11. HelloWorldService.HelloResponse response = HelloWorldService.HelloResponse
  12. .newBuilder()
  13. .setMessage(String.format("Hello, %s. This message comes from gRPC.", request.getName()))
  14. .build();
  15. responseObserver.onNext(response);
  16. responseObserver.onCompleted();
  17. log.info("Client Message Received:[{}]", request.getName());
  18. }
  19. }

光有业务代码还不行,还需要在应用启动时把gRPC Server也给一起启动起来。首先写一下Server端的启动、关闭等逻辑:

  1. /**
  2. * gRPC Server的配置——启动、关闭等
  3. * 需要使用<code>@Component</code>注解注册为一个Spring Bean
  4. */
  5. @Slf4j
  6. @Component
  7. public class GrpcServerConfiguration {
  8. @Autowired
  9. HelloServiceImpl service;
  10. /** 注入配置文件中的端口信息 */
  11. @Value("${grpc.server-port}")
  12. private int port;
  13. private Server server;
  14. public void start() throws IOException {
  15. // 构建服务端
  16. log.info("Starting gRPC on port {}.", port);
  17. server = ServerBuilder.forPort(port).addService(service).build().start();
  18. log.info("gRPC server started, listening on {}.", port);
  19. // 添加服务端关闭的逻辑
  20. Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  21. log.info("Shutting down gRPC server.");
  22. GrpcServerConfiguration.this.stop();
  23. log.info("gRPC server shut down successfully.");
  24. }));
  25. }
  26. private void stop() {
  27. if (server != null) {
  28. // 关闭服务端
  29. server.shutdown();
  30. }
  31. }
  32. public void block() throws InterruptedException {
  33. if (server != null) {
  34. // 服务端启动后直到应用关闭都处于阻塞状态,方便接收请求
  35. server.awaitTermination();
  36. }
  37. }
  38. }

定义好gRPC的启动、停止等逻辑后,就可以使用CommandLineRunner把它加入到Spring Boot的启动中去了:

  1. /**
  2. * 加入gRPC Server的启动、停止等逻辑到Spring Boot的生命周期中
  3. */
  4. @Component
  5. public class GrpcCommandLineRunner implements CommandLineRunner {
  6. @Autowired
  7. GrpcServerConfiguration configuration;
  8. @Override
  9. public void run(String... args) throws Exception {
  10. configuration.start();
  11. configuration.block();
  12. }
  13. }

之所以要把gRPC的逻辑注册成Spring Bean,就是因为在这里要获取到它的实例并进行相应的操作。
这样,在启动Spring Boot时,由于CommandLineRunner的存在,gRPC服务端也就可以一同启动了。

客户端

业务代码同样非常简单:

  1. /**
  2. * 客户端业务逻辑实现
  3. */
  4. @RestController
  5. @Slf4j
  6. public class HelloController {
  7. @Autowired
  8. GrpcClientConfiguration configuration;
  9. @GetMapping("/hello")
  10. public String hello(@RequestParam(name = "name", defaultValue = "JiangWen", required = false) String name) {
  11. // 构建一个请求
  12. HelloWorldService.HelloRequest request = HelloWorldService.HelloRequest
  13. .newBuilder()
  14. .setName(name)
  15. .build();
  16. // 使用stub发送请求至服务端
  17. HelloWorldService.HelloResponse response = configuration.getStub().sayHello(request);
  18. log.info("Server response received: [{}]", response.getMessage());
  19. return response.getMessage();
  20. }
  21. }

在启动客户端时,需要打开gRPC的客户端,并获取到channel和stub以进行RPC通信,来看看gRPC客户端的实现逻辑:

  1. /**
  2. * gRPC Client的配置——启动、建立channel、获取stub、关闭等
  3. * 需要注册为Spring Bean
  4. */
  5. @Slf4j
  6. @Component
  7. public class GrpcClientConfiguration {
  8. /** gRPC Server的地址 */
  9. @Value("${server-host}")
  10. private String host;
  11. /** gRPC Server的端口 */
  12. @Value("${server-port}")
  13. private int port;
  14. private ManagedChannel channel;
  15. private HelloWorldGrpc.HelloWorldBlockingStub stub;
  16. public void start() {
  17. // 开启channel
  18. channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
  19. // 通过channel获取到服务端的stub
  20. stub = HelloWorldGrpc.newBlockingStub(channel);
  21. log.info("gRPC client started, server address: {}:{}", host, port);
  22. }
  23. public void shutdown() throws InterruptedException {
  24. // 调用shutdown方法后等待1秒关闭channel
  25. channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
  26. log.info("gRPC client shut down successfully.");
  27. }
  28. public HelloWorldGrpc.HelloWorldBlockingStub getStub() {
  29. return this.stub;
  30. }
  31. }

比服务端要简单一些。
最后,仍然需要一个CommandLineRunner把这些启动逻辑加入到Spring Boot的启动过程中:

  1. /**
  2. * 加入gRPC Client的启动、停止等逻辑到Spring Boot生命周期中
  3. */
  4. @Component
  5. @Slf4j
  6. public class GrpcClientCommandLineRunner implements CommandLineRunner {
  7. @Autowired
  8. GrpcClientConfiguration configuration;
  9. @Override
  10. public void run(String... args) {
  11. // 开启gRPC客户端
  12. configuration.start();
  13. // 添加客户端关闭的逻辑
  14. Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  15. try {
  16. configuration.shutdown();
  17. } catch (InterruptedException e) {
  18. e.printStackTrace();
  19. }
  20. }));
  21. }
  22. }

编写Dockerfile

业务代码跑通之后,就可以制作Docker镜像,准备部署到Istio中去了。
在开始编写Dockerfile之前,先改动一下客户端的配置文件:

  1. server:
  2. port: 19090
  3. spring:
  4. application:
  5. name: spring-boot-istio-client
  6. server-host: ${server-host}
  7. server-port: ${server-port}

接下来编写Dockerfile:
服务端:

  1. FROM openjdk:8u121-jdk
  2. RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
  3. && echo 'Asia/Shanghai' >/etc/timezone
  4. ADD /target/spring-boot-istio-server-0.0.1-SNAPSHOT.jar /
  5. ENV SERVER_PORT="18080"
  6. ENTRYPOINT java -jar /spring-boot-istio-server-0.0.1-SNAPSHOT.jar

可以看到这里添加了启动参数,配合前面的配置,当这个镜像部署到Kubernetes集群时,就可以在Kubernetes的配合之下通过服务名找到服务端了。
同时,服务端和客户端的pom文件中添加:

  1. <build>
  2. <plugins>
  3. <plugin>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-maven-plugin</artifactId>
  6. <configuration>
  7. <executable>true</executable>
  8. </configuration>
  9. </plugin>
  10. <plugin>
  11. <groupId>com.spotify</groupId>
  12. <artifactId>dockerfile-maven-plugin</artifactId>
  13. <version>1.4.13</version>
  14. <dependencies>
  15. <dependency>
  16. <groupId>javax.activation</groupId>
  17. <artifactId>activation</artifactId>
  18. <version>1.1</version>
  19. </dependency>
  20. </dependencies>
  21. <executions>
  22. <execution>
  23. <id>default</id>
  24. <goals>
  25. <goal>build</goal>
  26. <goal>push</goal>
  27. </goals>
  28. </execution>
  29. </executions>
  30. <configuration>
  31. <repository>wendev-docker.pkg.coding.net/develop/docker/${project.artifactId}
  32. </repository>
  33. <tag>${project.version}</tag>
  34. <buildArgs>
  35. <JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
  36. </buildArgs>
  37. </configuration>
  38. </plugin>
  39. </plugins>
  40. </build>

这样执行mvn clean package时就可以同时把docker镜像构建出来了。

编写部署文件

有了镜像之后,就可以写部署文件了:
服务端:

  1. apiVersion: v1
  2. kind: Service
  3. metadata:
  4. name: spring-boot-istio-server
  5. spec:
  6. type: ClusterIP
  7. ports:
  8. - name: http
  9. port: 18080
  10. targetPort: 18080
  11. - name: grpc
  12. port: 18888
  13. targetPort: 18888
  14. selector:
  15. app: spring-boot-istio-server
  16. ---
  17. apiVersion: apps/v1
  18. kind: Deployment
  19. metadata:
  20. name: spring-boot-istio-server
  21. spec:
  22. replicas: 1
  23. selector:
  24. matchLabels:
  25. app: spring-boot-istio-server
  26. template:
  27. metadata:
  28. labels:
  29. app: spring-boot-istio-server
  30. spec:
  31. containers:
  32. - name: spring-boot-istio-server
  33. image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-server:0.0.1-SNAPSHOT
  34. imagePullPolicy: Always
  35. tty: true
  36. ports:
  37. - name: http
  38. protocol: TCP
  39. containerPort: 18080
  40. - name: grpc
  41. protocol: TCP
  42. containerPort: 18888

主要是暴露服务端的端口:18080和gRPC Server的端口18888,以便可以从Pod外部访问服务端。
客户端:

  1. apiVersion: v1
  2. kind: Service
  3. metadata:
  4. name: spring-boot-istio-client
  5. spec:
  6. type: ClusterIP
  7. ports:
  8. - name: http
  9. port: 19090
  10. targetPort: 19090
  11. selector:
  12. app: spring-boot-istio-client
  13. ---
  14. apiVersion: apps/v1
  15. kind: Deployment
  16. metadata:
  17. name: spring-boot-istio-client
  18. spec:
  19. replicas: 1
  20. selector:
  21. matchLabels:
  22. app: spring-boot-istio-client
  23. template:
  24. metadata:
  25. labels:
  26. app: spring-boot-istio-client
  27. spec:
  28. containers:
  29. - name: spring-boot-istio-client
  30. image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-client:0.0.1-SNAPSHOT
  31. imagePullPolicy: Always
  32. tty: true
  33. ports:
  34. - name: http
  35. protocol: TCP
  36. containerPort: 19090

主要是暴露客户端的端口19090,以便访问客户端并调用服务端。
如果想先试试把它们部署到k8s可不可以正常访问,可以这样配置Ingress:

  1. apiVersion: networking.k8s.io/v1beta1
  2. kind: Ingress
  3. metadata:
  4. name: nginx-web
  5. annotations:
  6. kubernetes.io/ingress.class: "nginx"
  7. nginx.ingress.kubernetes.io/use-reges: "true"
  8. nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
  9. nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
  10. nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
  11. nginx.ingress.kubernetes.io/proxy-body-size: "10m"
  12. nginx.ingress.kubernetes.io/rewrite-target: /
  13. spec:
  14. rules:
  15. - host: dev.wendev.site
  16. http:
  17. paths:
  18. - path: /
  19. backend:
  20. serviceName: spring-boot-istio-client
  21. servicePort: 19090

Istio的网关配置文件与k8s不大一样:

  1. apiVersion: networking.istio.io/v1alpha3
  2. kind: Gateway
  3. metadata:
  4. name: spring-boot-istio-gateway
  5. spec:
  6. selector:
  7. istio: ingressgateway
  8. servers:
  9. - port:
  10. number: 80
  11. name: http
  12. protocol: HTTP
  13. hosts:
  14. - "*"
  15. ---
  16. apiVersion: networking.istio.io/v1alpha3
  17. kind: VirtualService
  18. metadata:
  19. name: spring-boot-istio
  20. spec:
  21. hosts:
  22. - "*"
  23. gateways:
  24. - spring-boot-istio-gateway
  25. http:
  26. - match:
  27. - uri:
  28. exact: /hello
  29. route:
  30. - destination:
  31. host: spring-boot-istio-client
  32. port:
  33. number: 19090

主要就是暴露/hello这个路径,并且指定对应的服务和端口。

部署应用到Istio

首先搭建k8s集群并且安装istio。使用的k8s版本是1.16.0,Istio版本是最新的1.6.0-alpha.1,使用istioctl命令安装Istio。建议跑通官方的bookinfo示例之后再来部署本项目。
注:以下命令都是在开启了自动注入Sidecar的前提下运行的
在虚拟机中运行的k8s,所以istio-ingressgateway没有外部ip:

  1. $ kubectl get svc istio-ingressgateway -n istio-system
  2. NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
  3. istio-ingressgateway NodePort 10.97.158.232 <none> 15020:30388/TCP,80:31690/TCP,443:31493/TCP,15029:32182/TCP,15030:31724/TCP,15031:30887/TCP,15032:30369/TCP,31400:31122/TCP,15443:31545/TCP 26h

所以,需要设置IP和端口,以NodePort的方式访问gateway:

  1. export INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].nodePort}')
  2. export SECURE_INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}')
  3. export INGRESS_HOST=127.0.0.1
  4. export GATEWAY_URL=$INGRESS_HOST:$INGRESS_PORT

必须要等到两个pod全部变为Running而且Ready变为2/2才算部署完成。
接下来就可以通过

  1. curl -s http://${GATEWAY_URL}/hello

访问到服务了。如果成功返回了Hello, JiangWen. This message comes from gRPC.的结果,没有出错则说明部署完成。