朱曄和你聊Spring系列S1E11:小測Spring Cloud Kubernetes @ 阿里雲K8S

  • 2019 年 10 月 3 日
  • 筆記

有關Spring Cloud Kubernates(以下簡稱SCK)詳見https://github.com/spring-cloud/spring-cloud-kubernetes,在本文中我們主要測試三個功能:

  • 使用Kubernates服務發現配合Spring Cloud Ribbon做服務調用
  • 讀取Kubernates的ConfigMap配置並且支援修改後動態刷新
  • Spring Boot Actuator對Kubernates Pod資訊的感知

編寫測試程式

首先,我們來創建pom文件,注意幾點:

  • Spring Boot版本不能太高
  • 引入了 Spring Boot Web以及Actuator兩個模組,我們開發一個Web項目進行測試
  • 引入了 Spring Cloud的Ribbon模組,我們需要測試一下服務調用
  • 引入了spring-cloud-starter-kubernetes-all依賴,我們的主要測試對象
  • 額外引入了docker-maven-plugin插件用於幫助我們構建鏡像
  • 設置了finalName

文件如下:

<?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>      <parent>          <groupId>org.springframework.boot</groupId>          <artifactId>spring-boot-starter-parent</artifactId>          <version>2.0.9.RELEASE</version>          <relativePath/>      </parent>      <groupId>me.josephzhu</groupId>      <artifactId>springcloudk8sdemo</artifactId>      <version>0.0.1-SNAPSHOT</version>      <name>springcloudk8sdemo</name>        <properties>          <java.version>11</java.version>      </properties>        <dependencies>          <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.cloud</groupId>              <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>          </dependency>          <dependency>              <groupId>org.springframework.cloud</groupId>              <artifactId>spring-cloud-starter-kubernetes-all</artifactId>              <version>1.0.3.RELEASE</version>          </dependency>            <dependency>              <groupId>org.projectlombok</groupId>              <artifactId>lombok</artifactId>              <optional>true</optional>          </dependency>          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-test</artifactId>              <scope>test</scope>          </dependency>      </dependencies>        <build>          <finalName>k8sdemo</finalName>          <plugins>              <plugin>                  <groupId>org.springframework.boot</groupId>                  <artifactId>spring-boot-maven-plugin</artifactId>              </plugin>              <plugin>                  <groupId>com.spotify</groupId>                  <artifactId>docker-maven-plugin</artifactId>                  <version>1.0.0</version>                  <configuration>                      <imageName>zhuye/${project.artifactId}</imageName>                      <dockerDirectory>src/main/docker</dockerDirectory>                      <resources>                          <resource>                              <targetPath>/</targetPath>                              <directory>${project.build.directory}</directory>                              <include>${project.build.finalName}.jar</include>                          </resource>                      </resources>                  </configuration>              </plugin>          </plugins>      </build>        <dependencyManagement>          <dependencies>              <dependency>                  <groupId>org.springframework.cloud</groupId>                  <artifactId>spring-cloud-dependencies</artifactId>                  <version>Finchley.SR4</version>                  <type>pom</type>                  <scope>import</scope>              </dependency>          </dependencies>      </dependencyManagement>    </project>

接下去在srcmaindocker目錄下創建Dockerfile文件:

FROM openjdk:11-jdk-slim  VOLUME /tmp  ADD k8sdemo.jar app.jar  ENTRYPOINT exec java $JAVA_OPTS -jar /app.jar

值得注意的是,JVM參數我們希望從環境變數注入。

來看看程式碼,我們首先定義一個配置類:

package me.josephzhu.springcloudk8sdemo;    import lombok.Data;  import org.springframework.boot.context.properties.ConfigurationProperties;  import org.springframework.context.annotation.Configuration;    @Configuration  @ConfigurationProperties(prefix = "bean")  @Data  public class TestConfig {      private String message;      private String serviceName;  }

有了SCK的幫助,配置可以從ConfigMap載入,之後我們會看到ConfigMap的配置方式。下面我們定義一個控制器扮演服務端的角色:

package me.josephzhu.springcloudk8sdemo;    import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.cloud.client.discovery.DiscoveryClient;  import org.springframework.web.bind.annotation.GetMapping;  import org.springframework.web.bind.annotation.RestController;    import java.net.InetAddress;  import java.net.UnknownHostException;  import java.util.List;    @RestController  public class TestServer {        @Autowired      private DiscoveryClient discoveryClient;        @GetMapping("servers")      public List<String> servers() {          return discoveryClient.getServices();      }        @GetMapping      public String ip() throws UnknownHostException {          return InetAddress.getLocalHost().getHostAddress();      }  }  

可以看到這裡定義了兩個介面:

  • servers 用於返回服務發現找到的所有服務(K8S的服務)
  • 根路徑返回了當前節點的IP地址

接下去定義另一個控制器扮演客戶端的角色:

package me.josephzhu.springcloudk8sdemo;    import lombok.extern.slf4j.Slf4j;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.web.bind.annotation.GetMapping;  import org.springframework.web.bind.annotation.RestController;  import org.springframework.web.client.RestTemplate;    import java.net.InetAddress;  import java.net.UnknownHostException;    @RestController  @Slf4j  public class TestClient {        @Autowired      private RestTemplate restTemplate;      @Autowired      private TestConfig testConfig;        @GetMapping("client")      public String client() throws UnknownHostException {          String ip = InetAddress.getLocalHost().getHostAddress();          String response = restTemplate.getForObject("http://"+testConfig.getServiceName()+"/", String.class);          return String.format("%s -> %s", ip, response);      }  }  

這裡就一個介面client介面,訪問後通過RestTemplate來訪問服務端根路徑的介面,然後輸出了客戶端和服務端的IP地址。

然後我們定義一個全局的異常處理器,在出錯的時候我們直接看到是什麼錯:

package me.josephzhu.springcloudk8sdemo;    import lombok.extern.slf4j.Slf4j;  import org.springframework.web.bind.annotation.ExceptionHandler;  import org.springframework.web.bind.annotation.RestControllerAdvice;    @RestControllerAdvice  @Slf4j  public class GlobalAdvice {        @ExceptionHandler(Exception.class)      public String exception(Exception ex){          log.error("error:", ex);          return ex.toString();      }  }  

最後我們定義啟動程式:

package me.josephzhu.springcloudk8sdemo;    import lombok.extern.slf4j.Slf4j;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.boot.SpringApplication;  import org.springframework.boot.autoconfigure.SpringBootApplication;  import org.springframework.cloud.client.discovery.EnableDiscoveryClient;  import org.springframework.cloud.client.loadbalancer.LoadBalanced;  import org.springframework.cloud.netflix.ribbon.RibbonClient;  import org.springframework.context.annotation.Bean;  import org.springframework.scheduling.annotation.EnableScheduling;  import org.springframework.scheduling.annotation.Scheduled;  import org.springframework.web.client.RestTemplate;    import java.lang.management.ManagementFactory;  import java.util.stream.Collectors;    @SpringBootApplication  @EnableDiscoveryClient  @EnableScheduling  @Slf4j  @RibbonClient(name = "k8sdemo")  public class Springcloudk8sdemoApplication {        public static void main(String[] args) {          log.info("jvm:{}",                  ManagementFactory.getRuntimeMXBean().getInputArguments().stream().collect(Collectors.joining(" ")));          SpringApplication.run(Springcloudk8sdemoApplication.class, args);      }        @Autowired      private TestConfig testConfig;        @Scheduled(fixedDelay = 5000)      public void hello() {          log.info("config:{}", testConfig);      }        @LoadBalanced      @Bean      RestTemplate restTemplate() {          return new RestTemplate();      }  }  

在這個啟動程式中我們做了幾件事情:

  • 定義了一個定時器,5秒一次輸出配置(隨後用於觀察ConfigMap配置動態刷新)
  • 定義了RestTemplate和Ribbon配合使用
  • 在啟動的時候輸出下JVM參數,以便證明JVM參數(通過環境變數)注入成功

配置文件方面,首先是application.yaml:

spring:    application:      name: k8sdemo    cloud:      kubernetes:        reload:          enabled: true        config:          sources:            - name: ${spring.application.name}

幹了三件事情:

  • 定義應用程式名稱
  • 指定ConfigMap名稱為應用程式名,也就是k8sdemo
  • 啟用ConfigMap配置自動刷新(見下圖,默認是event方式)

image_1dm8j8hhpa1jsi1brb1gb81nh8m.png-374.9kB

再定義一個bootstrap.yaml用於打開actuator的一些端點:

management:    endpoint:      restart:        enabled: true      health:        enabled: true      info:        enabled: true

整個程式碼源碼參見 https://github.com/JosephZhu1983/SpringCloudK8S

配置阿里雲K8S集群

集群購買過程我就略去了,這些選項都可以勾上,Ingress特別記得需要,我們之後要在公網上進行測試。

image_1dm8jqua649k1hp811vb1nu31osr9.png-84.1kB

差不多30秒就有了一個K8S集群,這鬼東西要自己從頭搭建一套高可用的沒一天搞不下來,這裡可以看到我買了一個3節點的託管版K8S,所謂託管版也就是K8S的管理節點我們直接用阿里雲自己的,只需要買工作節點,省錢省心。

image_1dm8jgiu51v7mkkd14ks1fdt18vo1g.png-59.5kB

買好後記得配置下kubeconfig,這樣才能通過kubectl訪問集群。

image_1dm8jfosa1kfcg2a5cd1oi5p0013.png-291.9kB

注意下,阿里雲給出的配置別一股腦直接複製覆蓋了原來的配置(比如你可能還有本地集群),也別直接粘貼到文件的最後,文件是有格式的,你需要把cluster、context和user三個配置分別複製到對應的地方。

構建鏡像

我們知道在K8S部署程式不像虛擬機,唯一的交付是鏡像,因此我們需要把鏡像上傳到阿里雲。
首先,本地構建鏡像:

mvn package docker:build -DskipTests

完成後查看鏡像:
image_1dm8k69r61uiu1efvc711eeq118u13.png-77.4kB

然後在阿里雲開通鏡像服務,創建自己的倉庫:

image_1dm8k8bgh1q3c1g7g1hogp319701g.png-128.4kB

根據裡面的說明,給鏡像打上標籤後推送鏡像到倉庫:

docker login --username=【你的帳號】 registry.cn-shanghai.aliyuncs.com  docker tag 80026bb476ce registry.cn-shanghai.aliyuncs.com/zhuyedocker/test:v6  docker push registry.cn-shanghai.aliyuncs.com/zhuyedocker/test:v6

完成後在鏡像倉庫查看鏡像:
image_1dm8kd82h1jld133i12685oo1t1t.png-87.2kB

部署應用

通過鏡像創建無狀態應用:
image_1dm8kfhvkolh1opdj79hoc1p2g2a.png-54.2kB

創建的時候注意下面幾點:

  • 選擇正確的鏡像和Tag
  • 我這裡給予一個應用1C CPU 1.4G記憶體的配置
  • 埠和應用一致,設置為8080
  • 通過環境變數注入額外的JVM參數:-server -XX:+UseContainerSupport -XX:MaxRAMPercentage=50.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0 -XX:MaxMetaspaceSize=256M -XX:ThreadStackSize=256 -XX:+DisableExplicitGC -XX:+AlwaysPreTouch

這裡我配置了JVM動態根據容器的資源限制來設置堆記憶體大小(此特性在部分版本的JDK8上支援,在9以後都支援),這比直接設置死Xms和Xmx好很多(設置死的話不方便進行擴容),這裡我設置了50%,不建議設置更高(比如如果是2GB的記憶體限制,給堆設置為1.5GB顯然是不合適的),畢竟Java進程所使用的記憶體除了堆之外還有堆外、執行緒棧(執行緒數*ThreadStackSize)、元數據區等,而且容器本身也有開銷。

我這裡展示的是編輯介面,創建介面略有不同但是類似:
image_1dm8khth11mvcob91jcv1rotark2n.png-103.6kB

創建應用的時候你可以把Service和Ingress一併創建。

image_1dm8lkjpr16mqihdlvgvc7e5134.png-12kB

完成後可以進入應用詳情看到2個節點狀態都是運行中:

image_1dm8lmgfpcoj0e10jk1mm31f0q3h.png-95.2kB

測試應用啟動情況

來到Ingress介面可以看到我們的公網Ingress記錄,可以直接點擊訪問:

image_1dm8ls39f12f2lra1cbq1v751ft74o.png-136.8kB

根節點輸出的是IP,在之前的截圖中我們可以看到服務運行在1.13和0.137兩個IP上:
image_1dm8lqnph1tv31uqgpnv11p31ee4b.png-22.1kB

多刷新幾次瀏覽器可以看到負載均衡的效果。

訪問services可以查看到所有K8S的服務:
image_1dm8m0cmu36uqof7fs1ptso75v.png-27.8kB

訪問actuator/info可以看到有關K8S的詳情(感謝SCK),顯然我們程式碼里獲取到的IIP是PodIP:
image_1dm8lvlj347vg7d1e42b1613om5i.png-65.6kB

測試讀取K8S配置

接下去我們來到配置項來配置ConfigMap:
image_1dm8m52su1o111dod1edci5i6sg6c.png-181kB
這裡配置項的名稱需要和配置文件中的對應起來,也就是k8sdemo。然後配置項的Key需要和程式碼中的對應:
image_1dm8m7jjbm0o166c11v41pg0fug6p.png-35.6kB

我們來看看應用的日誌:
image_1dm8m9be315988n4vo11og6avi76.png-24.1kB
的確正確獲取到了配置,我們修改下配置項bean.message為9999,隨後再來看看日誌:
image_1dm8md75p151ei939ud10l31fns7j.png-346.5kB
可以看到程式發現了配置的變更,刷新了上下文,然後獲取到了最新的配置。

測試通過K8S服務發現進行服務調用:

訪問client介面可以看到1.13正常從0.137獲取到了數據:
image_1dm8mfnon1nprljq1gel1pdt1o7n80.png-25.5kB
多刷新幾次:
image_1dm8mhs2r1r9l1lfj1q0p4g8bqb8d.png-25.1kB

我們訪問到應用的負載均衡是由Ingress實現的,應用訪問服務端的負載均衡是由Ribbon實現的。

查看JVM記憶體情況

還記得嗎,我們在創建應用的時候給的記憶體是1.4GB,然後我們設置了JVM使用50%的記憶體(初始和最大都是50%),現在我們來看看是不是這樣。

首先來看看pod的情況:

image_1dm8mm02iuqj1pom1due1l441odm8q.png-72.3kB

然後執行如下命令在Pod內運行jinfo

kubectl exec k8sdemo-7b44d9fbff-c4jkf -- jinfo 1

可以看到如下結果,初始和最大堆是700M左右,說明參數起作用了:
image_1dm8mpup4nb91van1c2bse12nl97.png-290.1kB

小結

本文我們簡單展示了一下Spring Cloud Kubernetes的使用,以及如何通過阿里雲的K8S集群來部署我們的微服務,我們看到:

  • 如何通過SCK來讀取ConfigMap的配置,支援動態刷新
  • 如何通過SCK來使用K8S的服務發現進行服務調用
  • JVM記憶體參數設置問題
  • 如何把鏡像推到阿里雲並且在阿里雲的K8S跑起來我們的鏡像

有關K8S和基於Spring Boot/Spring Cloud的微服務結合使用,有幾點需要注意:

  • Spring Cloud 有自己的服務註冊中心,比如Eureka。如果你希望統一使用K8S做服務發現,那麼可以使用Spring Cloud Kubernetes。如果你希望使用Eureka作為服務發現,那麼服務之間調用都建議通過Feign或Ribbon調用,而不是使用K8S的Service域名或Ingress調用,兩套服務發現體系混用的話比較混亂而且有協同性問題。
  • 在K8S而不是VM中部署應用,最主要的區別是不能認為服務的IP是固定的,因為Pod隨時可能重新調度,對於某些框架,需要依賴有狀態的應用IP,比如XXL Job這可能是一個問題,需要改造。
  • Pod的生命周期和VM不同,考慮各種日誌和OOM Dump的收集和保留問題。
  • 應用無故重啟,考慮健康檢測、資源不足等問題,在K8S部署應用需要觀察應用的重啟問題,合理設置reques和limit配置以及JVM參數(比如-XX:+UseContainerSupport -XX:MaxRAMPercentage=50.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0),審查健康檢測的配置是否合理。