Istio初體驗:使用Spring Boot+gRPC構建微服務並部署

語言: CN / TW / HK

大家好,搞微服務也有好幾年時間,從16年開始就一直關注微服務,到現在一直在使用的還是SpringCloud原生那套,雖然後來出現了SpringCloud Alibaba,但由於前面的所有系統框架都已定,就沒有在變化,而在微服務的實施過程,為了降運維的服務度,先後使用了jenkins,docker, kubernetes等,就還是沒有使用過Istio。

基於這個原因,一直想嘗試下開發基於Istio的Service Mesh的應用。正好最近受夠了Spring Cloud的“折磨”,對Kubernetes也可以熟練使用了,而且網上幾乎沒有Spring Boot微服務部署到Istio的案例,我就開始考慮用Spring Boot寫個微服務的Demo並且部署到Istio。專案本身不復雜,就是傳送一個字串並且返回一個字串的最簡單的Demo。

1.  為什麼要用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的使用一點都不困難。

1.1.  為什麼要使用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的可以簡單參考一下我的實現。

1.2. 編寫業務程式碼

首先使用Spring Initializr建立父級專案spring-boot-istio,並引入gRPC的依賴。pom檔案如下:

<?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" 
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <modules>
        <module>spring-boot-istio-api</module>
        <module>spring-boot-istio-server</module>
        <module>spring-boot-istio-client</module>
    </modules>
    <parent>
        <groupId>org.springframework.boot</groupId> 
       <artifactId>spring-boot-starter-parent</artifactId> 
       <version>2.2.6.RELEASE</version>
       <relativePath/>
     </parent>
    <groupId>site.wendev</groupId>
    <artifactId>spring-boot-istio</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-istio</name>
    <description>Demo project for Spring Boot With Istio.</description>
    <packaging>pom</packaging>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>io.grpc</groupId>
                <artifactId>grpc-all</artifactId>
                <version>1.28.1</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

然後建立公共依賴模組spring-boot-istio-api,pom檔案如下,主要就是gRPC的一些依賴:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-boot-istio</artifactId>
        <groupId>site.wendev</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>spring-boot-istio-api</artifactId>
    <dependencies>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-all</artifactId>
        </dependency>
        <dependency>
            <groupId>javax.annotation</groupId>
            <artifactId>javax.annotation-api</artifactId>
            <version>1.3.2</version> 
       </dependency>
    </dependencies> 
   <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.6.2</version>
            </extension>
        </extensions>
        <plugins>
            <plugin> 
               <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.11.3:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.28.1:exe:${os.detected.classifier}</pluginArtifact>
                    <protocExecutable>/Users/jiangwen/tools/protoc-3.11.3/bin/protoc</protocExecutable>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

建立src/main/proto資料夾,在此資料夾下建立hello.proto,定義服務間的介面如下:

syntax = "proto3";
option java_package = "site.wendev.spring.boot.istio.api";
option java_outer_classname = "HelloWorldService";
package helloworld;

service HelloWorld {
    rpc SayHello (HelloRequest)
 returns (HelloResponse) {}
}

message HelloRequest {
    string name = 1;
}
message HelloResponse {
    string message = 1;
}

很簡單,就是傳送一個name返回一個帶name的message。

然後生成服務端和客戶端的程式碼,並且放到java資料夾下。這部分內容可以參考gRPC的官方文件。

有了API模組之後,就可以編寫服務提供者(服務端)和服務消費者(客戶端)了。這裡我們重點看一下如何整合gRPC和Spring Boot。

1) 服務端

業務程式碼非常簡單:

/**
 * 服務端業務邏輯實現
 * 
 * @author 江文
 * @date 2020/4/12 2:49 下午
 */
@Slf4j
@Component
public class HelloServiceImpl extends HelloWorldGrpc.HelloWorldImplBase {
    @Override
    public void sayHello(HelloWorldService.HelloRequest request,
                         StreamObserver<HelloWorldService.HelloResponse> responseObserver) {
        // 根據請求物件建立響應物件,返回響應資訊
        HelloWorldService.HelloResponse response = HelloWorldService.HelloResponse
                .newBuilder()
                .setMessage(String.format("Hello, %s. This message comes from gRPC.", request.getName()))
                .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
        log.info("Client Message Received:[{}]", request.getName());
    }
}

光有業務程式碼還不行,我們還需要在應用啟動時把gRPC Server也給一起啟動起來。首先寫一下Server端的啟動、關閉等邏輯:

/**
 * gRPC Server的配置——啟動、關閉等
 * 需要使用<code>@Component</code>註解註冊為一個Spring Bean
 * 
 * @author 江文
 * @date 2020/4/12 2:56 下午
 */
@Slf4j
@Componentpublic class GrpcServerConfiguration {
    @Autowired
    HelloServiceImpl service;
    /** 注入配置檔案中的埠資訊 */ 
   @Value("${grpc.server-port}")
    private int port;
    private Server server;
    public void start() throws IOException {
        // 構建服務端
        log.info("Starting gRPC on port {}.", port);
        server = ServerBuilder.forPort(port).addService(service).build().start();
        log.info("gRPC server started, listening on {}.", port);
        // 新增服務端關閉的邏輯
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            log.info("Shutting down gRPC server.");
            GrpcServerConfiguration.this.stop();
            log.info("gRPC server shut down successfully.");
        }));
    }
    private void stop() {
        if (server != null) {
            // 關閉服務端
            server.shutdown();
        }
    }
    public void block() throws InterruptedException {
        if (server != null) {
            // 服務端啟動後直到應用關閉都處於阻塞狀態,方便接收請求 
           server.awaitTermination();
        }
    }
}

定義好gRPC的啟動、停止等邏輯後,就可以使用CommandLineRunner把它加入到Spring Boot的啟動中去了:

/** 
 * 加入gRPC Server的啟動、停止等邏輯到Spring Boot的生命週期中
 *
 * @author 江文
 * @date 2020/4/12 3:10 下午
 */
@Component
public class GrpcCommandLineRunner implements CommandLineRunner {
    @Autowired
    GrpcServerConfiguration configuration;

    @Override
    public void run(String... args) throws Exception {
        configuration.start();
        configuration.block();
    }
}

之所以要把gRPC的邏輯註冊成Spring Bean,就是因為在這裡要獲取到它的例項並進行相應的操作。

這樣,在啟動Spring Boot時,由於CommandLineRunner的存在,gRPC服務端也就可以一同啟動了。

2) 客戶端

業務程式碼同樣非常簡單:

/**
 * 客戶端業務邏輯實現
 *
 * @author 江文
 * @date 2020/4/12 3:26 下午
 */
@RestController
@Slf4j
public class HelloController {
    @Autowired
    GrpcClientConfiguration configuration;
    @GetMapping("/hello")
    public String hello(@RequestParam(name = "name", defaultValue = "JiangWen", required = false) String name) {
        // 構建一個請求        HelloWorldService.HelloRequest request = HelloWorldService.HelloRequest
                .newBuilder()
                .setName(name)
                .build();        // 使用stub傳送請求至服務端
        HelloWorldService.HelloResponse response = configuration.getStub().sayHello(request);
        log.info("Server response received: [{}]", response.getMessage());
        return response.getMessage();
    }
}

在啟動客戶端時,我們需要開啟gRPC的客戶端,並獲取到channel和stub以進行RPC通訊,來看看gRPC客戶端的實現邏輯:

/**
 * gRPC Client的配置——啟動、建立channel、獲取stub、關閉等
 * 需要註冊為Spring Bean
 *
 * @author 江文
 * @date 2020/4/12 3:27 下午
 */
@Slf4j
@Component
public class GrpcClientConfiguration {
    /** gRPC Server的地址 */
    @Value("${server-host}")
    private String host;
    /** gRPC Server的埠 */
    @Value("${server-port}")
    private int port;
    private ManagedChannel channel;
    private HelloWorldGrpc.HelloWorldBlockingStub stub;
    public void start() {
        // 開啟channel
        channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
        // 通過channel獲取到服務端的stub
        stub = HelloWorldGrpc.newBlockingStub(channel);
        log.info("gRPC client started, server address: {}:{}", host, port);
    }
    public void shutdown() throws InterruptedException {
        // 呼叫shutdown方法後等待1秒關閉channel
        channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
        log.info("gRPC client shut down successfully.");
    }
    public HelloWorldGrpc.HelloWorldBlockingStub getStub() {
        return this.stub;
    }
}

比服務端要簡單一些。

最後,仍然需要一個CommandLineRunner把這些啟動邏輯加入到Spring Boot的啟動過程中:

/**
 * 加入gRPC Client的啟動、停止等邏輯到Spring Boot生命週期中
 *
 * @author 江文
 * @date 2020/4/12 3:36 下午
 */
@Component
@Slf4j
public class GrpcClientCommandLineRunner implements CommandLineRunner {
    @Autowired
    GrpcClientConfiguration configuration;

    @Override
    public void run(String... args) {
        // 開啟gRPC客戶端
        configuration.start();
                // 新增客戶端關閉的邏輯
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                configuration.shutdown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }));
    }
}

1.3、 編寫Dockerfile

業務程式碼跑通之後,就可以製作Docker映象,準備部署到Istio中去了。

在開始編寫Dockerfile之前,先改動一下客戶端的配置檔案:

server:
  port: 19090
spring:
  application:
      name: spring-boot-istio-clientserver-host: ${server-host}server-port: ${server-port}

接下來編寫Dockerfile:

1) 服務端:

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

主要是規定服務端應用的埠為18080,並且在容器啟動時讓服務端也一起啟動。

2) 客戶端:

FROM openjdk:8u121-jdk
RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
  && echo 'Asia/Shanghai' >/etc/timezoneADD /target/spring-boot-istio-client-0.0.1-SNAPSHOT.jar /ENV GRPC_SERVER_HOST="spring-boot-istio-server"ENV GRPC_SERVER_PORT="18888"ENTRYPOINT java -jar /spring-boot-istio-client-0.0.1-SNAPSHOT.jar \ --server-host=$GRPC_SERVER_HOST \ --server-port=$GRPC_SERVER_PORT

可以看到這裡添加了啟動引數,配合前面的配置,當這個映象部署到Kubernetes叢集時,就可以在Kubernetes的配合之下通過服務名找到服務端了。

同時,服務端和客戶端的pom檔案中新增:

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <executable>true</executable>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.spotify</groupId> 
               <artifactId>dockerfile-maven-plugin</artifactId>
                <version>1.4.13</version>
                <dependencies>
                    <dependency>
                        <groupId>javax.activation</groupId>
                        <artifactId>activation</artifactId>
                        <version>1.1</version>
                    </dependency>
                </dependencies>
                <executions>
                    <execution>
                        <id>default</id> 
                       <goals>
                            <goal>build</goal>
                            <goal>push</goal>
                       </goals>
                    </execution>
                </executions>
                <configuration>
                    <repository>wendev-docker.pkg.coding.net/develop/docker/${project.artifactId}</repository>
                    <tag>${project.version}</tag>
                    <buildArgs>
                        <JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
                    </buildArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>

這樣執行mvn clean package時就可以同時把docker映象構建出來了。

2. 編寫部署檔案

有了映象之後,就可以寫部署檔案了:

1) 服務端:

apiVersion: v1
kind:
 Servicemetadata:
  name: spring-boot-istio-server
spec:
  type: ClusterIP
  ports:
    - name: http 
      port: 18080
      targetPort: 18080
    - name: grpc
      port: 18888
      targetPort: 18888
  selector:
    app: spring-boot-istio-server

---apiVersion: apps/v1
kind:
 Deploymentmetadata:
  name: spring-boot-istio-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-boot-istio-server
  template:
    metadata:
      labels:
        app: spring-boot-istio-server
    spec:
      containers:
        - name: spring-boot-istio-server
          image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-server:0.0.1-SNAPSHOT
          imagePullPolicy: Always
          tty: true
          ports:
            - name: http
              protocol: TCP
              containerPort: 18080 
            - name: grpc
              protocol: TCP
              containerPort: 18888

主要是暴露服務端的埠:18080和gRPC Server的埠18888,以便可以從Pod外部訪問服務端。

2) 客戶端:

apiVersion: v1
kind:
 Servicemetadata:
  name:
 spring-boot-istio-client
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 19090
      targetPort: 19090
  selector:
    app: spring-boot-istio-client

---apiVersion: apps/v1
kind:
 Deploymentmetadata:
  name: spring-boot-istio-client
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-boot-istio-client
  template:
    metadata:
      labels:
        app: spring-boot-istio-client
    spec:
      containers:
        - name: spring-boot-istio-client
          image: wendev-docker.pkg.coding.net/develop/docker/spring-boot-istio-client:0.0.1-SNAPSHOT
          imagePullPolicy: Always
          tty: true
          ports:
            - name: http 
             protocol: TCP
              containerPort: 19090

主要是暴露客戶端的埠19090,以便訪問客戶端並呼叫服務端。

如果想先試試把它們部署到k8s可不可以正常訪問,可以這樣配置Ingress:

apiVersion: networking.k8s.io/v1beta1
kind:
 Ingressmetadata:
  name: nginx-web
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/use-reges: "true"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: dev.wendev.site
      http:
        paths:
          - path: /
            backend:
              serviceName: spring-boot-istio-client
              servicePort: 19090

Istio的閘道器配置檔案與k8s不大一樣:

apiVersion: networking.istio.io/v1alpha3
kind:
 Gatewaymetadata:
  name: spring-boot-istio-gateway
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 80
        name: http
        protocol: HTTP
      hosts:
        - "*"

---apiVersion: networking.istio.io/v1alpha3
kind: Virtual
Servicemetadata:
  name: spring-boot-istio
spec:
  hosts:
    - "*"
  gateways:
    - spring-boot-istio-gateway
  http:
    - match:
        - uri:
            exact: /hello
      route:
        - destination:
            host: spring-boot-istio-client
            port:
              number: 19090

主要就是暴露/hello這個路徑,並且指定對應的服務和埠。

3. 部署應用到Istio

首先搭建k8s叢集並且安裝istio。我使用的k8s版本是1.16.0,Istio版本是最新的1.6.0-alpha.1,使用istioctl命令安裝Istio。建議跑通官方的bookinfo示例之後再來部署本專案。

注:以下命令都是在開啟了自動注入Sidecar的前提下執行的

我是在虛擬機器中執行的k8s,所以istio-ingressgateway沒有外部ip:

$ kubectl get svc istio-ingressgateway -n istio-system
NAME                   TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)

AGEistio-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:

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

這樣就可以了。

接下來部署服務:

$ kubectl apply -f spring-boot-istio-server.yml
$ kubectl apply -f spring-boot-istio-client.yml
$ kubectl apply -f istio-gateway.yml

必須要等到兩個pod全部變為Running而且Ready變為2/2才算部署完成。

接下來就可以通過

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

訪問到服務了。如果成功返回了Hello, JiangWen. This message comes from gRPC.的結果,沒有出錯則說明部署完成。

GitHub地址: https://github.com/WenDev/spring-boot-istio-demo