2020年12月4日 星期五

在 JMeter 做 Mutual TLS 認證

最近要跑壓力測試時,拿之前的腳本來改,結果改完後拋出 javax.net.ssl.SSLHandshakeException: Received fatal alert: certificate_required 的錯誤訊息,研究了一段時間,才發現應該是 mTLS 方面的問題…。

在手上已經有 key、certificate 以及 CA certificate 三個 PEM 檔案的情況下,如果是用 curl 的話,指令會長的像這樣:

curl --key key.pem --cert cert.pem --cacert cacert.pem $URL

不過在 JMeter 上,則有兩種作法。一種是如果 JMeter 有打開 GUI 的話,可以從 Options → SSL Manager 來指定。不過開 GUI 一般都只會用在還在寫 script 的階段,寫完正式要跑的時候,通常還是得用 CLI 模式,因此這時需要另外一種方法,也就是自己實際產生 Client Certificate 的 keystore 檔。

Keystore for Client Certificate

要產生 Client Certificate,需要用的工具就是 keytool,指令如下:

sudo openssl pkcs12 -export -name {name} -inkey {keyPath} -in {certPath} -out {keystorePath} -password {password} -noiter -nomaciter

其中各個參數的意義如下:

  • {name}:這應該是 key 的名字吧,在我們公司的狀況,這好像是必填,但在一般狀況似乎是可以不用給這個參數。
  • {keyPath}:key 的路徑。
  • {certPath}:certificate 的路徑。
  • {keystorePath}:要產生的 keystore 要放在哪裡。
  • {password}:keystore 的密碼。這裡有特殊的格式,如果是要直接在指令上指定,則格式必須是 pass:xxxx 這樣,例如如果密碼要設定為 12345,則要給 pass:1234。但由於這樣會讓 password 可能出現在機器上(像是 ps 可能會被看到),因此如果環境不是很安全的話,建議使用 stdin 這個值來手動輸入。

執行後,keystore 就會出現在指定的位置了。

啟動 JMeter

有了 keystore 後,因為 truststore 其實本來也有了(就是 CA certificate 那個 pem 檔),因此接著只需要在啟動 JMeter 時,讓它去吃那兩個檔就好了。其中如果你的 server 的 CA 本來就是公有的 CA,那可以不用自行指定 trustStore。

/bin/jmeter.sh \
  -D javax.net.ssl.keyStore={keyStorePath} \
  -D javax.net.ssl.keyStorePasswor={keyStorePassword} \
  -D javax.net.ssl.trustStore={trustStorePath} \
  -D javax.net.ssl.trustStorePassword={trustStorePassword}
其他相關資訊
  1. JMeter 腳本裡使用的 HttpClient 實作,按照 JMeter 官方文件 [3] 的講法,是必須要用 HttpClient,否則會無法支援 KeyStore 的 Client Certification。
  2. 如果遇到的錯誤訊息是 javax.net.ssl.sslexception: closing inbound before receiving peer's close_notify 的話,原因好像有可能是 server 和 client 支援的 protocol 對不起來之類的?總之此時如果你的 JMeter 環境是跑在 Java 11 上,可以試著退回 Java 8。
  3. 不想每次執行 JMeter 時都要指定 keyStore/trustStore 的話,也可以直接把它寫在 JMeter 的 /bin/system.properties 裡。
參考資料
  1. How to Set Your JMeter Load Test to Use Client Side Certificates
  2. Mutual authentication in JMeter
  3. JMeter – Component Reference – HTTP Request
  4. Spring Boot: Jdbc javax.net.ssl.SSLException: closing inbound before receiving peer's close_notify
  5. SSLException closing inbound before receiving peer's close_notify

2020年11月11日 星期三

在 Splunk 統計 log 量

因為最近需要縮減寫到 Splunk 的 log 量,因此需要先找出從哪裡寫的 log 量最大。要想找到哪邊是寫最多 log 的地方,初步的想法,就是先以 package/class 為單位,看看哪個 package/class 的 log 最多,就會有標的可以知道我們要從哪邊下手會最有效率。

要寫 Splunk query 時,先考慮一下 log 的格式。我們的 log 是單純地用 logback 輸出成 JSON 格式,然後就讓 docker log driver 送去 Splunk,所以 log 的 JSON 內容會放在 line 欄位裡面。因此在 Splunk query 中,package/class 會被放在 line.logger 中。另外如果數字很多的時候,要看出比例的差距也不是很容易,因此可以利用 eventstats 來幫忙計算整體的占比。

index="prod" source="mylog"
  | eval length = len(_raw)/1048576
  | stats sum(length) as package_size by line.logger
  | eventstats sum(package_size) AS total
  | eval percent = round(package_size/total*100, 2)."%"
  | table line.logger, package_size, percent
  | sort - package_size

結果如下:

line.logger package_size percent
com.a.a.a 37152.74659347534 46.89%
org.zalando.logbook.Logbook 28326.010219573975 35.75%
com.b.b.b 13741.930490493774 17.34%
com.c.c.c 8.518047332763672 0.01%
com.d.d.d 6.767726898193359 0.01%

內容稍微打個碼 XD。不過這樣結果就很清楚了,整體來說都是最上面那三個 class 在寫 log,而且其中第一名佔據了將近一半的資料量….。

不過需要注意的是,這個數字並不一定完全能夠代表這些 log 在 Splunk 裡佔據的空間量,因為 len() 指令是在計算字元數(character)的,當使用不同 encoding 時,一個字元換算成 byte 的大小會不一樣。

2020年10月23日 星期五

2020年10月19日 星期一

在 Vespa 使用 Parent/Child 時的限制

Parent/Child 的關聯在實務運用上挺方便的,可以讓有重複的內容統一放在 Parent document 上,使得 Child document 只要單純繼承 Parent 就可以一併繼承那些重複的內容。不過其實它相應的限制其實也頗多,而且這個部份在 Vespa 的文件上寫得並沒有非常清楚。

難以繼承複雜的結構

近期連續遇到的狀況就是,在 Search Definition 上定義欄位時不一定會出現錯誤,但結果實際上繼承是無法生效的。舉例來說,Parent document 上如果是 Array<StructA>,其中 StructA 裡面又包含了 StructB 的狀況時,結果是部署時並不會被檢查出錯誤,但實際上 StructB 無法正常地被 Child document 繼承到。

{
    "weapons": [{
        "type": "sword",
        "material": "iron",
        "price": 300,
        "position": {
            "longitude": 0.0,
            "latitude": 0.0
        }
    }, {
        "type": "sword",
        "material": "mythril",
        "price": 9000,
        "position": {
            "longitude": 0.0,
            "latitude": 0.0
        }
    }]
}

以這個例子來說,Search Definition 可能會長這樣:

struct Weapon {
    field type type string {}
    field material type string {}
    field price type double {}
    field position type map<string, string> {}
}

field weapons type array<Weapon> {
    indexing: summary
    struct-field type { indexing: attribute }
    struct-field material { indexing: attribute }
    struct-field price { indexing: attribute }
    struct-field position.key { indexing: attribute }
    struct-field position.value { indexing: attribute }
}

在這種狀況下,deployment 的檢查都會通過,但實際上 position 這個欄位是無法正常被繼承的。真的進行 Child document 的搜尋時,會發現 position 這個欄位一直都不會出現,但除了 position 以外的其他幾個欄位則都會正常出現。同時如果對 Parent document 呼叫 Document API,還是會看到包含 position 在內的所有欄位都正常地有值。

除了這個例子以外,另外嘗試在 Parent document 有非巢狀但格式有點複雜的欄位型態 map<string, array<string>> 時,結果則是在驗證階段直接拋出錯誤訊息:

For search 'childDocument', import field 'importedField': Field 'mapWithStringArray' via reference field 'weaponReference': Is not an attribute field. Only attribute fields supported

這個部份 Vespa 已經更新了文件 [3],稍微更詳細一點地說明了可支援的型態。可以參考 Vespa 的文件 [1-2]。

踩了幾次地雷以後,簡要來說,就是不要想在 Parent document 上擺什麼複雜的結構,除非那個欄位沒有要被 Child document 繼承,否則還是都用基本型別,最複雜大概就只到 array<struct> 或者 map<string, string> 這種就好了…..。

無法使用 index

因為 Parent document 是 global document,意味著它會被放在所有 content node 的記憶體中,因此它沒有辦法使用需要用到 disk 的 index。更確切地說,是 Child document 在繼承 Parent document 的欄位時,它只能繼承到被定義為 attribute 的欄位而已,index 的欄位無法被繼承。而這個限制也同時導致了繼承下來的欄位沒辦法做 partial match,只能夠作為 filter 來使用而已。

參考資料
  1. Parent/Child
  2. import-field
  3. Clarify restrictions that apply for imported field types.

2020年7月26日 星期日

解決 switchIfEmpty() 每次都被執行的問題

紀錄一下最近發現的有點意外的 Reactor 反應。

Mono.justOrEmpty(getXXX())
    .switchIfEmpty(fallbackXXX())
    ...

本來預期的行為是,getXXX() 會回覆一個 Optional 物件,當回覆的 Optionalempty 時,才會觸發 fallbackXXX() 去呼叫外部服務。但結果在測試時發現 fallbackXXX() 每次都必然會被執行,雖然最後結果會是對的,如果 getXXX() 有回覆時,走到後面的 stream 內容會是 getXXX() 回覆的東西,但這樣就等於每次執行時都會呼叫外部服務了,而且呼叫後拿到的結果還不一定會用到。

查了好一段時間之後,發現問題好像是出在 Java 本身的 method evaluation 行為上,然後解決方法是必須把 fallbackXXX()Mono.defer() 包裝起來,以達成讓 fallbackXXX() 推遲被執行的目的。

Mono.justOrEmpty(getXXX())
    .switchIfEmpty( Mono.defer(fallbackXXX()) )
    ...
參考資料
  1. Mono switchIfEmpty() is always called

2020年7月12日 星期日

Scala 的 HTTP 伺服器:http4s

在 Ubuntu 準備 sbt 環境

雖然覺得這個步驟蠻沒意義的,不過總之我又久違地在 Windows 上開了個 Ubuntu 20.04 的 container。

在安裝 sbt 之前,需要先加入 sbt 的 repository 到 apt 的來源。

echo "deb https://dl.bintray.com/sbt/debian /" | sudo tee -a /etc/apt/sources.list.d/sbt.list
curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | sudo apt-key add
sudo apt-get update

接著就是安裝 Java 與 sbt。

sudo apt-get install -y default-jre sbt

以我目前這個時間點來說,會裝出來的版本如下:

Java: 11.0.7
sbt: 1.3.13
產生 http4s 範例專案

執行以下的初始化指令,讓 sbt 產生一個 http4s 的範例小專案。

sbt new http4s/http4s.g8 -b 0.21

這個指令其實本來在 http4s 的 Quick Start 裡是還有指定 sbt 版本的 -sbt-version 1.3.12,不過這裡就把它拿掉,讓它直接用剛裝的版本了。產生過程會問一些問題,這裡基本上我全都用預設值了。

接著就可以來看看產生出來的檔案有哪些內容了:

build.sbt

organization := "com.example"
name := "scala-test"
version := "0.0.1-SNAPSHOT"
scalaVersion := "2.12.4"

val Http4sVersion = "0.16.6a"
val Specs2Version = "4.0.2"
val LogbackVersion = "1.2.3"

libraryDependencies ++= Seq(
  "org.http4s"     %% "http4s-blaze-server"  % Http4sVersion,
  "org.http4s"     %% "http4s-circe"         % Http4sVersion,
  "org.http4s"     %% "http4s-dsl"           % Http4sVersion,
  "org.specs2"     %% "specs2-core"          % Specs2Version % "test",
  "ch.qos.logback" %  "logback-classic"      % LogbackVersion
)

HelloWorld.scala

package com.example.scalatest

import io.circe._
import org.http4s._
import org.http4s.circe._
import org.http4s.server._
import org.http4s.dsl._

object HelloWorld {
  val service = HttpService {
    case GET -> Root / "hello" / name =>
      Ok(Json.obj("message" -> Json.fromString(s"Hello, ${name}")))
  }
}

Server.scala

package com.example.scalatest

import scala.util.Properties.envOrNone
import org.http4s.server.blaze.BlazeBuilder
import org.http4s.util.ProcessApp
import scalaz.concurrent.Task
import scalaz.stream.Process

object Server extends ProcessApp {
  val port: Int = envOrNone("HTTP_PORT").fold(8080)(_.toInt)

  def process(args: List[String]): Process[Task, Nothing] = BlazeBuilder.bindHttp(port)
    .mountService(HelloWorld.service, "/")
    .serve
}

這樣就是個最簡單能跑的 Scala + http4s 的小專案了,可以透過 sbt run 啟動伺服器。執行後可以看到像是這樣的啟動訊息:

[info] Loading settings from plugins.sbt ...
[info] Loading project definition from G:\java\intellij\scala-http4s-test\project
[info] Loading settings from build.sbt ...
[info] Set current project to scala-test (in build file:/G:/java/intellij/scala-http4s-test/)
[info] Running com.example.scalatest.Server
[pool-6-thread-4] INFO  o.h.b.c.n.NIO1SocketServerGroup - Service bound to address /127.0.0.1:8080
[pool-6-thread-4] INFO  o.h.s.b.BlazeBuilder - http4s v0.16.6a on blaze v0.12.11 started at http://127.0.0.1:8080/

啟動完成後,就可以用瀏覽器連上 http://127.0.0.1:8080/ 了,例如範例的 REST API 網址會是 http://127.0.0.1:8080/hello/michael,因為輸入的 {name} 是 michael,所以就會獲得 {"message":"Hello, michael"} 的 JSON 回覆。

參考資料
  1. Installing sbt on Linux

2020年4月19日 星期日

Replications

本篇為 Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems [1] 的讀書筆記。

Replication 指的是在分散式系統中將一份資料複製並存放在多個節點上,以帶來幾個可能的好處:

  • 將資料複製到距離使用者較近的節點,以減少 latency
  • 提高 availability
  • 增加讀取的 throughput

一般討論分散式系統的 replication 時,關注的困難點都是如何處理資料的變化。因為若資料不會變化,那只要整個複製貼上就完工了,沒什麼困難點。但若資料會變化,那麼就會需要考慮多個節點間要如何控管資料的變化了。

2020年4月8日 星期三

attribute 的記憶體需求

Vespa 的欄位如果 index-type 被宣告為 attribute 的話,就會直接被放在記憶體上,因此實務上會需要估計到底需要多少記憶體才能撐住所有的資料的問題。

2020年3月29日 星期日

Reactive Programming(三):Project Reactor

本系列的文章為 Modern Java in Action / Project Reactor 的讀書筆記,因此內容可能會有點跳躍。

2020年3月18日 星期三

在 Spock Framework 測試包含 parallel stream 的標的時卡住

紀錄一下最近解了幾天的問題,程式碼大體上類似 [1] 裡面的 MyRecordProcessor 和 MyRecordProcessorTest 那樣。因為本來是要建置環境比較簡單的測試來重現問題,而原本的問題是發生在開發 Kinesis 的 RecordProcessor 中遇到的,因此 [1] 的範例也沿用了這個關係。不過實際上最後發現的原因跟 Kinesis 沒有關係就是了。

問題的徵兆是,當要測試的標的內部改寫成有 parallel 的狀況時,Spock 這邊執行測試就可能會出現測試整個卡在某個地方的狀況。卡住時會完全沒有回應,但也沒有拋出錯誤或者停止執行。這個問題好像遇到的人並不是那麼多,所以可以找到的資料也非常少。不過主要參考了 [2-3],最後確認的原因是因為測試時用了 Spy。Spy 好像存在某些限制(在 [3] 的回答中有提到,雖然他沒提到到底是什麼限制),實際上似乎導致在 parallel 的狀況時,Spy 的 dynamic proxy 似乎有時會生效、但有時會無效。所以最後解決方法就是~不要用 Spy。

參考資料
  1. spock-parallel-test
  2. Spock unit test is stuck when parallel stream is used
  3. Using thread pools in a Spock Spy'd class hangs during unit tests

2020年3月1日 星期日

Vespa 入門(一):在本機建立 containerized Vespa

因為在公司持續地在用 Vespa,所以打算寫個系列文章慢慢紀錄 Vespa 的一些使用、調校經驗。(雖然說過去寫的系列文章大多…寫到第二或第三篇就………。)

本篇會紀錄在本機啟動 Vespa 的 container 的方法,通常這麼做可以在開發階段用來測試 Vespa 的語法。可以參考 Vespa 官方文件 [1]。

環境準備

依照 [1] 的描述,Docker 環境最重要的就是需要有至少 6GB 的記憶體。而在我的環境中,我是在 Windows 10 上跑 Docker 的,因此至少要是個有支援 Hyper-V 的 Windows 版本,才能夠執行 Docker。

取得 Vespa 範例

首先,Vespa 有提供一些簡單的範例 Application,可以從以下的 Github 取得。裡面有很多資料夾,包含了數種在 Vespa 官方文件中會講到的例子。

git clone https://github.com/vespa-engine/sample-apps.git

這裡假設我把這個 repository 放在 G:\git 裡。

建立並啟動 Vespa container

要啟動 Vespa 之前,我們需要執行兩個動作。第一個是先啟動一個 Vespa 的 container,這個步驟只會準備好 Vespa 需要的執行環境而已。接著第二個是部署 Vespa Application,這個動作就像是在 RDBMS 裡執行 CREATE DATABASE 一樣,是讓 Vespa 準備好 schema 並且真的啟動伺服器。

docker run --detach --name sample-vespa --hostname vespa-container --privileged --volume g:\git\vespa-sample-apps:/vespa-sample-apps --publish 8080:8080 vespaengine/vespa
docker exec sample-vespa bash -c 'curl -s --head http://localhost:19071/ApplicationStatus'

首先如前所說,我們先以 vespaengine/vespa 的 image 啟動一個 Vespa container,這裡我們把這個 container 命名為 sample-vespa,並且把資料夾 g:\git\vespa-sample-apps 掛到 container 的 /vespa-sample-apps 路徑上,方便之後我們在這個 container 中部署與啟動 Vespa。

container 開好以後,可以透過第二行指令來確認 Vespa 的狀態。不過因為 container 雖然綁了 port,但卻還沒有啟動任何服務,因此我們要確認狀態只能利用在 container 裡執行 curl 指令來確認。如果正常的話會獲得類似這樣的回覆:

HTTP/1.1 200 OK
Date: Sun, 01 Mar 2020 11:31:41 GMT
Content-Type: application/json
Content-Length: 9232

接著就是要在裡面部署和啟動指定的 Vespa Application 了。

docker exec sample-vespa bash -c '/opt/vespa/bin/vespa-deploy prepare /vespa-sample-apps/album-recommendation-selfhosted/src/main/application/ && /opt/vespa/bin/vespa-deploy activate'
curl -s --head http://localhost:8080/ApplicationStatus

這邊我們先部署 album-recommentation-selfhosted 這個 Vespa Application。因為 Vespa 的 Search Definition 是放在 src/main/application 裡的,因此在部署的時候要指定路徑是這個資料夾。第一個指令會獲得類似這樣的反應,表示已經順利地啟用 Vespa Application。

Uploading application '/vespa-sample-apps/album-recommendation-selfhosted/src/main/application/' using http://localhost:19071/application/v2/tenant/default/session
Session 3 for tenant 'default' created.
Preparing session 3 using http://localhost:19071/application/v2/tenant/default/session/3/prepared
WARNING: Host named 'vespa-container' may not receive any config since it is not a canonical hostname. Disregard this warning when testing in a Docker container.
Session 3 for tenant 'default' prepared.
Activating session 3 using http://localhost:19071/application/v2/tenant/default/session/3/active
Session 3 for tenant 'default' activated.
Checksum:   71cf99ec40a0a08c8edbd1aa3cee03ed
Timestamp:  1583062394458
Generation: 3

再使用第二個指令確認 Application 狀態時,可以獲得:

HTTP/1.1 200 OK
Date: Sun, 01 Mar 2020 11:34:39 GMT
Content-Type: application/json
Transfer-Encoding: chunked
參考資料
  1. Vespa: Quick Start