Spring Session + Hazelcast | Client/Server 部署 - 單個 Application 部署連通測試

話說在前面 #

這篇實作奠基於前篇文章規劃,會記錄下使用 Spring Session 和 Hazelcast 達到 Session 同步的流程紀錄。

部署方式 #

重提一下我選擇的是 Client/Server 的部署方式,Hazelcast 還有另外幾種部署方式,可以參考官方文件或網路上的介紹,這邊不再贅述。

測試目標 #

在這個階段我的測試目標為 Spring Application 接得通 Hazelcast,會較多著墨在設定試錯的過程,這篇我只起了一顆 Spring Application Pod,下一步才會是以 Deployment 來做測試。

為求方便與瞭解原理,Client 端測試既然都要以 Spring Boot 起的應用程式,配置就使用 Java API Configuration 方式;Server 端我整個發懶,官方包好的 image 也沒有再用 Dockerfile 再包一層,直接在起 Pod 的時候使用環境變數傳入所需的設定。

環境 #

資料來源 #

參考資料是沒這麼多的,因為大部分教學文件不是 Hazelcast in K8S 就是 Hazelcast Embedded With Spring Session,而我們要做的事情剛好是兩邊都拿一點。

來源主要是官方的這幾個文件:

接著還有一個很重要的資料來源,是 Hazelcast Discovery Plugin for Kubernetes 這個官方已經宣布過時的 git repository,這邊很清楚的寫到服務發現的機制,幫助我很多。
而現在已經被整合到上面的 Hazelcast for Kubernetes


測試準備 #

minikube start #

起好 minikube,我一口氣起 4GB:

minikube start --memory=4096

Docker with minikube env #

這個步驟主要是讓我們的實驗環境排除「你的 localhost 不是你的 localhost」的狀況:

我們預備的應用程式會先容器化,接著以 Pod 的形式起在 minikube 的 K8S 叢集當中,此時 K8S 的 Node 完全是另外一台主機,並不是我們的實體本機,所以它會拉不到實體本機 localhost 的 image list,必須在 build image 之前先將 Docker 切到 minikube 的環境中。

其實非常簡單,在 terminal 當中下以下指令就可以了:

eval $(minikube docker-env)

將著在同個 terminal session 就可以安心起 Pod,切換回來則使用 minikube docker-env --unset

另外還需注意:


開始動手做 #

準備 Spring Boot Application (Client) #

Dependencies #

我的目標是使用 Spring Session,並且確定 Session 真的有灌到 Hazelcast 當中,因此除了 Spring Boot 基本的依賴以外,還需要依賴這些包(以下以 Maven 管理 pom.xml 為例):

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-hazelcast</artifactId>
</dependency>
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
<version>${hazelcast.version}</version>
</dependency>

Spring Boot 及其對應的 Hazelcast 版本如前述

Controller #

為了測試 Session 的正常性,準備了一隻 Controller:

package com.cherry.hazelcast.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@RestController
@RequestMapping("session")
public class SessionController {

@Autowired
private HttpServletRequest request;

@GetMapping("/getSessionIfExist")
public String getSessionIfExist() {
HttpSession session = request.getSession(false);
return session == null ? null : session.getId();
}

@GetMapping("/getSession")
public String getSession() {
return request.getSession().getId();
}
}

Configuration #

這部分才是重頭戲!程式碼下方,會開始描述配置的緣由。

在此之前還是得先重述一下:

這邊我圖方便(可以點進去看 Java Method 都做了啥XD),使用 Java API Configuration 方式配置,這些內容完全是可以拉出去以 XML 或 YAML 來配置,在外部使用檔案配置讀入,應屬於比較好的方案,下回也會嘗試這麼做。

package com.cherry.hazelcast.configuration;

import com.hazelcast.client.HazelcastClient;
import com.hazelcast.client.config.ClientConfig;
import com.hazelcast.config.SSLConfig;
import com.hazelcast.core.HazelcastInstance;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.MapSession;
import org.springframework.session.Session;
import org.springframework.session.hazelcast.Hazelcast4SessionUpdateEntryProcessor;
import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance;
import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;

@Configuration
@EnableHazelcastHttpSession
public class SessionConfiguration {

@Value("${hazelcast.service.namespace}")
private String serviceNamespace;

@Value("${hazelcast.service.name}")
private String serviceName;

@Value("${hazelcast.service.port}")
private int servicePort;

@Bean
@SpringSessionHazelcastInstance
public HazelcastInstance hazelcastInstance() {
ClientConfig config = new ClientConfig();

config.getNetworkConfig().setConnectionTimeout(50000);
config.getNetworkConfig().setSSLConfig(new SSLConfig().setEnabled(false));

config.getUserCodeDeploymentConfig()
.setEnabled(true)
.addClass(Session.class)
.addClass(MapSession.class)
.addClass(Hazelcast4SessionUpdateEntryProcessor.class);

config.getNetworkConfig().getKubernetesConfig()
.setEnabled(true)
.setProperty("namespace", serviceNamespace)
// .setProperty("service-port", Integer.toString(servicePort))
.setProperty("service-name", serviceName);

return HazelcastClient.newHazelcastClient(config);
}

}

服務發現 #

先講講我於 application.properties 自訂的各項參數,這邊需要跟 K8S 中部署的 Service 名稱、Namespace、Port 等等完全一致,不可以亂寫:

hazelcast.service.name=hz-hazelcast
hazelcast.service.namespace=default
hazelcast.service.port=5701

另外 config.setProperty("service-port", Integer.toString(servicePort)) 被我註解,是因為 Hazelcast 預設會監聽的就是 5701 Port,所以完全不設定也無所謂。

這邊是使用到 Kubernetes API 來做服務發現,而不是使用 DNS Lookup 的服務發現機制,我認為是比較直觀的。

超時與 SSL 設置(暫時擺爛) #

Connection Timeout 時間的設定是為了將時間拖長,因為在測試期間我發生過幾次 Pod IP 被加入黑名單的狀況,網路上查到一些資料說和這件事有關,果然一拖長就沒再發生過了。測試環境沒有 SSL,也是關掉了事。

驗證設置(暫時擺爛) #

應該有不少眼尖的人,發現到我並沒有設定連線驗證,也沒有指定 Hazelcast 叢集名稱,這還是跟我的目標有關,我想要先確定 Spring Session 和 Hazelcast Server in Kubernetes 是真的能連接上的,不想在此時被驗證干擾。

而且 Hazelcast Server 端也必須要設定開啟 Client 驗證並設定驗證方式如帳號密碼,才會同意 Client 做登入動作;不過就像我前面所說,現階段我還沒打算再包一次 Dockerfile,所以這部分先被我輕輕的略過了XD。

在此還是以註解方式奉上 Client 端 Java API Configuration 相關設定:

// config.setClusterName(clusterName);
// config.getSecurityConfig()
// .setUsernamePasswordIdentityConfig("dev", "dev-pass");

至於為什麼和叢集名稱有關呢?這就很有意思了!

我去看過 ClientConfig.class 這隻檔案,clusterName 居然是有預設值的:

一查證才發現,ClientConfig 中的預設 clusterName 為 dev,而在此狀況下,帳號密碼驗證會直接關閉

為了關閉掉相關驗證,不設定叢集讓它維持 dev 的預設值,能讓我們連上 Hazelcast Server 的測試腳步更快速。

User Code Deployment 設置 #

這部分非常重要,且是我試到一半出錯才發現居然還要補設定這段。當時我遇到了 ClassNotFoundException 的錯誤導致 Map 序列化失敗,神奇的是我的 Dependencies 完全沒少,然後這些解答也都沒什麼參考價值。

通過幾番查找我發現我需要的答案就在官方文件當中,Create a Hazelcast Instance Bean 這段說明了原因,我直接將官方的說明 copy and paste:

// If spring-session packages do not present in Hazelcast member's classpath,
// these classes need to be deployed over the client. This is required since
// Hazelcast updates sessions via entry processors.
clientConfig.getUserCodeDeploymentConfig().setEnabled(true).addClass(Session.class)
.addClass(MapSession.class).addClass(Hazelcast4SessionUpdateEntryProcessor.class);

如果 spring-session package 不存在於 Hazelcast 成員的 classpath 中,那麼這些 class 需要在 Client 端部署。這是因為 Hazelcast 通過 entry processor 更新 Session 時需要這些 class。

哪些 class 呢?

成員指的其實是 Server 端,因為那邊是單純的 Hazelcast jar image,並沒有對 Spring Session 依賴,吃不到這些 Class 是非常正常的呀!

接著我們就要來對 Server 端做一些設定,讓它反過來吃 Client 端設定的這些 Class,完整的起 Pod 指令會在下個步驟中說明。

其實這件事情超乎想像的簡單,只需要加上 HZ_USERCODEDEPLOYMENT_ENABLED=true 這個參數即可。

至於怎麼知道有這招的,其實是來自錯誤訊息,官方 git repo 的程式碼錯誤訊息也可一窺端倪。錯誤截圖放在下面:

(所以說這篇文章真的是試錯血淚史)

啟動 Hazelcast in K8S 提供服務 (Server) #

此時我們要準備啟動 Hazelcast Sever 囉!加上前段所說缺少的設定部分,一起來將 Pod 服務起起來吧!

這部分做完之後,我們才會回頭去包 Spring Application 的 image,並將它起在 K8S 當中。
先起 Hazelcast Server,再起 Spring Application,才能讓這個 Server 為我們的應用程式服務。

以下的內容主要是官方文件的步驟,目的應該也是為了讓使用者學習如何快速上手,所以設定上是蠻簡陋的,真的需要部署的話有些地方可以再將權限再小化。

預備好 Kubernetes API 所需要的 RBAC #

Server 端在這次的實驗中我們只會起一個 Pod,但其實他們是可以透過相同的 Group Name 組成一個叢集的。
至於它們是怎麼發現彼此的呢?以及 Client 端是如何發現它們的呢?就需要靠 Kubernetes API 來做這件事了。

首先,我們必須配置一組 RBAC 提供給 Hazelcast Cluster 使用:

kubectl apply -f https://raw.githubusercontent.com/hazelcast/hazelcast-kubernetes/master/rbac.yaml

再讓我們一窺這個 RBAC 設定的內容,我們準備好一個 ClusterRole 和一組與其綁定的 ClusterRoleBinding,綁定的 subject 是 default namespace 底下 default 的 Service Account。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: hazelcast-cluster-role
rules:
- apiGroups:
- ""
resources:
- endpoints
- pods
- nodes
- services
verbs:
- get
- list

---

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: hazelcast-cluster-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: hazelcast-cluster-role
subjects:
- kind: ServiceAccount
name: default
namespace: default

起 Hazelcast Server,並加上必要參數 #

下面的指令當中 --env HZ_USERCODEDEPLOYMENT_ENABLED=true 代表前面提到的 Server 端需要開啟的 User Code Deployment 設定,並貼上 role=hazelcas 的 label。

我們先起一個 Pod 即可,當然你想起很多顆也是 OK 的,只要改改 Pod name 就可以了。

kubectl run hz-hazelcast-0 --image=hazelcast/hazelcast:5.1.5 -l "role=hazelcast" --env HZ_USERCODEDEPLOYMENT_ENABLED=true

透過 Service 暴露 Hazelcast Server Pod #

我們將服務以 ClusterIP 型態的 Service 暴露出去,--tcp=5701 則會讓這個 Service 的 Target port (Container port)、Service port 以 TCP 協定的 5701 為埠號。

接著再為這個 Service 選定帶有 role=hazelcast 標籤的 Pod 為負載平衡的目標,也就是我們剛剛起的 Hazelcast Pod。

kubectl create service clusterip hz-hazelcast --tcp=5701 -o yaml --dry-run=client | kubectl set selector --local -f - "role=hazelcast" -o yaml | kubectl create -f -

此時可能需要短暫等待 Hazelcast 起服務,我們接著來打包並啟動我們的 Spring Boot Application。

啟動 Spring Boot Application (Client) #

整個試驗階段最簡單的部分可能就在這裡,讓我們將剛剛準備好的 Spring Boot 應用程式容器化並啟動。

Dockerization 設定準備 #

為了辨識,我將應用程式開在 9999 port。

Dockerfile #

FROM openjdk:8-jre-alpine
EXPOSE 9999

ADD target/hazelcast-*.jar test-hazelcast.jar

ENTRYPOINT ["java", "-jar", "test-hazelcast.jar"]

Spring Boot Application Server Port #

Spring Boot application.properties 也要將 server port 設定在 9999。

# 前 hazelcast 參數設定略
server.port=9999

Maven Build #

mvn package -f pom.xml

Docker Build #

docker build 之前,別忘了 terminal 要先將 Docker 切到 minikube 的環境設定

test-hazelcast 可以替換成任意的 image 名稱,要加上 tag version 也是可以的,我這邊是有點懶:

docker build -t test-hazelcast .

Kubernetes Run #

在 run pod 之前記得檢查一下 kubeconfig context 是不是在 minikube 的 cluster。

--image-pull-policy=Never 前面也有提到,也別忘記了:

kubectl run --image=test-hazelcast test-hazelcast --image-pull-policy=Never

部署完成,開始測試 #

接下來是 Controller 的測試部分,提供測法參考:

  1. 進入 getSessionIfExist API,會發現一片空白,因為不存在。
  2. 進入 getSession API,取得 session,Rest API 會給我們當前 session id。
  3. 重整或再進入 getSessionIfExist API,取得同一個 session id。
  4. 重整或再進入 getSession API,取得同一個 session id。
  5. 在不同瀏覽器重複以上流程,發生情況類似,但會取得一組新的 session id。

至此可以發現 session 機制正常,即 Spring Session 是可以串到 Hazelcast 去的,但有個很重要的部分還沒測試:這次只有起單顆 Pod,我們怎麼知道 Pod 之間的 Session 有同步呢?

下一篇文章將會來測試這個部分!


其他 Ref #

🙏🙏🙏

感謝你的閱讀 💖!
歡迎將本篇文章 分享 📋 出去,也歡迎到 我的 LinkedIn 聊聊。

Published