為什麼用這開場? 跟要講的內容有啥關係? 其實…沒有….只是剛剛看完Continental第一集, 又覺得基哥講這句很帥!!!

“What do you need?”

“Small and smaller”

容器化技術玩多了後, 可能會有人跟你說, 容器的映像檔越小越好, 小到一個極致是最完美的, 所以曾經(現在還有嗎?)有一度, 以alpine基底的映像檔很流行, 但到底要小到多小才夠? 而且建置這個, 就有點像調酒一樣, 放入了基酒(Base image)後, 你還會在上面一層層往上疊加東西, 而且你要加的東西, OS的套件管理又會幫你加一大堆依賴套件(Dependencies), 當你疊了一堆有的沒的之後, 就算你基酒再純粹, 出來的東西還是會很混濁(很肥)

所以, 大小是有關係的嗎? 大部分的人知道要"小", 但不是每個人都想過, 為何要小? 要把它做的小小的, 不外乎幾個原因:

  1. 傳輸成本: 尤其現在大多流行用Kubernetes管理容器, 當節點(node)失效時, 容器常常需要在節點中搬移, 大的映像需要更多的傳輸頻寬跟時間讓節點從container registry下載下來, 以致於會需要花更多的時間來重建容器, 拉長系統回復的時間
  2. 安全性: 一個映像中裝越多不同的套件, 碰上套件的安全漏洞機率越高, 另外如果安裝了shell就給了人可以去執行一些程式的機會(甚至很多映像其實是以root權限在執行), 如果裡面又有了package manager, 就又可以進去任意安裝軟體, 甚至如果裡面包了一些敏感的設定檔, 資料, server certificate, 那就更增加敏感資料給別人拿走的機率
  3. 可維護性: 這跟2是有關的, 當你套件越多, 碰到安全漏洞需要patch的頻率越高, 尤其如果是base image, 很多應用程式的映像都仰賴於你, 當你更新時, 他們勢必也要一起更新到最新版本

所以我的看法是, 要追求的應該不是"minimal", 而是"optimal", 只包入自己所需要的就好, 不需要的東西通通塞進去不是一件好事

那, 我們需要的是怎樣的image? 需要怎樣的base image? 我覺得這要拆兩部份來看 – BuildRuntime, 大部分的程式語言, 在建置(Build)時, 需要的東西總是比之後執行的時候來得多, 像java在單元測試時需要一些額外的jar檔, 這些在執行階段是不需要的(也不需要javac), nodejs也是有一些dev only的套件在執行時期是不需要的, go在建置後,那個單一執行檔也就夠了, 很多東西都不需要跟著一起被包入container image之中, 但大部分的人其實不太知道要用Multi-stage build, 把BuildRuntime 給分開, 一旦分開了, runtime所需要的基底(base image)就可以使用很精簡的版本, 而build time則可以用比較完整的程式建置環境, 所以關鍵點會在於 Multi-stage build 的使用

Distroless

Distroless是一組由Google所維護的base images, 旨在提供一些不包含像是shell和package manager 這類的不必要的東西的映像給執行階段(Runtime)使用, 以增進容器安全性, 它是基於debian建置而來的, 在基於debian 11和debian 12兩種基礎上, 並提供static, base, cc, java, python, nodejs相關runtime的image

那怎樣利用這一系列的image? 這是拿來當作base image來使用的, 而且就是拿來當runtime base image來使用的, 先以go當例子:

FROM golang:1.21 as build

WORKDIR /go/src/app
COPY . .

RUN go mod download
RUN go vet -v
RUN go test -v

RUN CGO_ENABLED=0 go build -o /go/bin/app

FROM gcr.io/distroless/static-debian11

COPY --from=build /go/bin/app /
CMD ["/app"]

這例子很清楚的就是一個multi-stage build, 用golang:1.21當作build image, 而用static distroless作為base image, 因為go建置出來的是一個static binary, 不需要有其他依賴, 所以用這最小的版本就足夠了

那再看看java:

FROM openjdk:11-jdk-slim-bullseye AS build-env
COPY . /app/examples
WORKDIR /app
RUN javac examples/*.java
RUN jar cfe main.jar examples.HelloJava examples/*.class 

FROM gcr.io/distroless/java11-debian11
COPY --from=build-env /app /app
WORKDIR /app
CMD ["main.jar"]

他就必須要用到distroless/java11-debian了,因為這版才有java runtime (JVM), 另外, 既然是Google出品, 可以搭配Bazel用也一點不意外, 這邊可以看範例

說到大小, distroless的映像最小的 gcr.io/distroless/static-debian12 只有大約2MB, 用dive把它拆解來看, 其實裡面也沒啥東西, 光一個zoneinfo就佔掉1.7MB:

相對於alpine感覺好像的確小很多

但其實仔細看一下, 這大小是有點不太公平, gcr.io/distroless/static-debian12不像 alpine內包了 busybox, apk, musl libc, 對於可以static compile的語言像是go, rust, 用static其實就夠了, 但有蠻多還是要libc的, 所以要比應該也是要用gcr.io/distroless/base-debian12 這個包入libc6的版本來比

不意外的, 光glibc就吃掉大部分了, 相較之下alpine還是比較小, 可見, 小不是它的重點, 如果要的是安全, 不包額外的套件, non-root, no shell & package manager才是這類的base image的賣點之一

那談到安全, 我們也來跟alpine來做個相比好了, 這邊用grype這套弱掃工具來掃描各自的最新版本(latest):

首先來個alpine的:

完全都沒有, 好棒棒! 至少在這最基本的版本還蠻乾淨的, 那接下來就distroless static:

這也沒有, 不過如果真掃得出來就神奇了啦…因為這包幾乎完全沒有東西呀, 那接下來看distroless base:

哇~~ GG, High…won’t fix…果然是libc6, 那其他基於這個的就不用太看下去了, 不過, 這樣比並不見得公平, 那只是我現在掃有掃到這些, 隨時都有可能會有新的漏洞, 也會有新的修復, 真正要比可能就是更新這些漏洞修復到底多快, 可能比較實在

有沒其他的缺點? 由於基於debian, 所以只能用debian套件, 安全性更新應該就相依於debian了, 另外因為沒package manager的關係(連deb都沒喔), 除了他提供的幾個image外, 你如果想在上面加別的套件, 舉個例, 如果你用到了libffmpeg, 你要怎弄出一個image是有含有ffmpeg的? 目前應該只能透過Bazel, 有興趣的話, 可以參考JAVA image的BUILD, 不過Bazel會有點入門的門檻就是

不過其實如果是像go這種static build的, 用gcr.io/distroless/static-debian12反而應該不會是最佳的, 我們用它的範例做一個版本來分析一下:

大小是4.2MB, 大概就是多加上build出來的檔案1.8MB而已, 很小, 沒多餘的東西, 其實蠻好的呀, 不過你如果把Dockerfile改成:

FROM golang:1.18 as build

WORKDIR /go/src/app
COPY . .

RUN go mod download
RUN go vet -v
RUN go test -v

RUN CGO_ENABLED=0 go build -o /go/bin/app

FROM scratch

COPY --from=build /go/bin/app /
CMD ["/app"]

再來看看結果:

怎回事?只有1.8MB, 對, 只有app本身那1.8MB, 什麼其他東西都沒有, 這應該是更簡潔的, 因為用了scratch, 就是一個完全空的映像, 這樣其實就能跑了(其實容器下就是Linux呀), 所以像是go, 應該用scratch會比distroless來得好, 不過其實這範例還少了點東西, 還是需要包入zone info跟ca, 這樣時區才不會錯, ssl連線也才可以正常, 不過這應該還不到2MB才對

UBI Micro and Buildah

Google搞了個distroless, Linux發行商們怎會吞得下這口氣呢? RedHat的做法就是UBI Micro這個distroless的image

RedHat這做法有點不一樣是, 他只丟一個相當於Google的distroless base, 裡面沒套件管理員, 可以算是一種distroless, 如果要安裝套件, 則靠buildah和yum, 這點倒是有點有趣, 來個範例看看怎來建置一個java image好了:

首先我們要把ubi micro給掛載到目錄去, 所以我們要透過buildah unshare進入到root模式, buildah from的作用跟Dockerfile裡的from的作用類似, 就是我們要以某個image當做基底來建置, 這範例就是ubi micro, 然後我們透過buildah mount把這個新的image給掛載到一個目錄去

接下來就簡單了, 基本上你要放啥東西到這個image, 就只要把檔案放到那目錄下就好了, 所以就算裡面沒包裹套件管理, 那我們其實只要用 yum install --installroot $micromout 就可以把套件裝到目錄不用在裝套件管理員到image內了

做完之後, 我們要記得 buildah umountbuildah commit $microcontainer java-headless-11, 這樣我們就可以有一個叫java-headless-11的新image

但, 怎那麼肥? 有沒搞錯, 將近600MB, 一般大部分的java image了不起也只有3xxMB, gcr.io/distroless/java17-debian12更是只有228MB, 這也就是這方法的缺點, yum會幫你管好依賴, 但其實很多東西也不用到完全, 像這個例子, 裡面光locale就有225MB, 這扣掉後也是頂多3xx MB, 去研究了一下, Google distroless的java也是沒包全部的local, 因此還是可以再瘦, 但就不在這邊討論, 因此, 的確, 使用套件管理來裝, 有些不必要的依賴可能也就混入了

不過, buildah提供了一個Dockerfile以外建置container image一個不錯的方法, 比起Google Distroless用Bazel應該會好上手很多

Ubuntu Chisel

這做法我還沒很深入去看, 可以參考: Chiselled Ubuntu: the perfect present for your containerised and cloud applications, 或是下一段影片

GitHub: https://github.com/canonical/chisel 原來似乎就是從package著手在取出自己要的, 結合scratch, 不過我還沒搞懂它切割縫合的做法, 這邊就先不多做解釋

其他的Distroless

像是Microsoft也有Marinara, 它是以Microsoft CBL-Mariner 2.0為基礎去製作Distroless image, 以它做出的 mcr.microsoft.com/openjdk/jdk:17-distroless, 我掃不到有啥安全漏洞, 蠻優秀的, 也只有三百多妹嘎

Buildpack

我之前也有介紹過Buildpacks, 雖然這跟這話題好像關係不大, 不過, 它其實也有一個paketobuildpacks/builder-jammy-tiny的builder可以讓你build出比較小的image, 使用方法如下

pack build myimage --builder paketobuildpacks/builder-jammy-tiny --path .

如果應用程式如果是寫好後建置成container image, 不太會需要裝額外的套件的話, 找一個適當的build image來建置程式, 然後基於一個適當的runtime來建置成image, 這樣一個簡單的multi-stage Dockerfile就可以做到了, 但用這方式的話, base image更版的話就要再去更新Dockerfile, 其實也是有點不方便, 如果把這整個封裝在buildpacks內, 應該也是不錯的做法, 這樣如果有需要更新base image的話, 用pack來rebuild應該就簡單多了, 應該找時間來研究一下怎建buildpack

在近幾年, 微服務(Micro service)架構大部分的人應該不陌生了, 不管是面試, 實戰, 應該都已經聽到快爛了, 不過, 這篇來講講一個基於NATS的做法

首先, 先來了解一下NATS到底是啥東西?簡單來說, 它是一個輕量(Container image只有小小的18MB), 高效, 且安全的訊息佇列(Message Queue), 就基本的Pub/Sub用法來說, 它也的確像是這樣, 很容易就會把它跟Kafka, RabbitMQ等等歸為同一類, 那, 如果要談用Message Queue做微服務的溝通核心, 那有啥好講的? 不就是像是發佈訂閱(Pub Sub), 做成非同步架構, 那有啥好講的?

在微服務架構下, 要完成一件事, 各微服務之間的溝通是非常吃重的, 一般來說比較直覺的方式就是制定介面(API)來當作各微服務間溝通的協議, 微服務之間透過呼叫API的方式來與另一個服務做溝通, 不管是透過REST API或是透過gRPC, 這都屬於同步(Synchronized)的溝通方式, 也就是任一次呼叫在一定時間內都會預期有回覆(或錯誤)

再另一種方式就是利用Message Queue做成非同步的做法, 也就是呼叫方把訊息發佈到Message Queue內, 再由另一方訂閱方把訊息收去處理, 因為每次呼叫並不會需要預期有回應的結果, 呼叫方把訊息發佈後, 就不理了, 所以也就不會造成程式的阻塞, 適合需要處理很久的操作, 缺點就是呼叫方不容易拿到執行結果

如果只是要講後者, 那這篇講到這邊差不多就可以下課了(那我還寫幹嘛), 其實NATS的目標應該不僅止于Message queue, 由網站上寫的有關NATS的相關內容, 可以知道它目標是作分散式應用程式的中樞神經系統, 所以其實除了非同步的方式外, 也可以識做成同步架構

Request-Reply

前面有說到, 微服務間的溝通方式, 其中一種就是一個微服務透過API呼叫另一個微服務, 而這個API可以預期的狀況是:

  1. 成功並取得結果
  2. 失敗並取得錯誤相關訊息
  3. 在等待一段時間後(timeout), 呼叫失敗

NATS也提供機制讓你達成這樣的結果, 雖然NATS的基本就是Pub Sub, 但還是提供了Request/Reply的做法

不多囉唆, 先看一下程式:

    nc, err := nats.Connect(*urls, opts...)
	if err != nil {
		log.Fatal(err)
	}
	defer nc.Close()
	subj, payload := args[0], []byte(args[1])

	msg, err := nc.Request(subj, payload, 2*time.Second)
	if err != nil {
		if nc.LastError() != nil {
			log.Fatalf("%v for request", nc.LastError())
		}
		log.Fatalf("%v for request", err)
	}

上頭這隻程式是一個 “requester”, 他把請求送到一個NATS subject, 並且等待並接收回傳訊息, 其實看起來就跟一個publisher沒啥兩樣, 差別就是他會卡在那邊等待回應(或timeout)

    //Responder
    nc, err := nats.Connect(*urls, opts...)
	if err != nil {
		log.Fatal(err)
	}

	subj, reply, i := args[0], args[1], 0

	nc.QueueSubscribe(subj, *queueName, func(msg *nats.Msg) {
		i++
		printMsg(msg, i)
		msg.Respond([]byte(reply))
	})
	nc.Flush()

上面則是相對於 “requester”“responder” , 其實跟個subscriber差不多, 就是把訊息接回來處理,多一個回傳的動作(msg.Respond([]byte(reply)))而已, 從抽象角度來看, 跟我們直接拿REST API實作有點類似:

但實際上, 他的做法比較是這樣的:

好像不太意外, 但這樣有啥好處, 我不就直接寫rest不就好了? 我們先來看一下負載平衡的做法好了:

在這做法下, NATS其實就擔當起load balancer這角色了, 其實, 不知道你有沒注意到, 他也兼顧了service discovery的角色, 傳統你呼叫一個API service, 你必須先知道他的endpoint, 但在這邊你只要知道subject就好了, 因為responder是在監聽著那個subject, 因此, 還可以變形成這樣:

就可以簡單的實現到跨區呼叫或故障轉移(failover)

NATS Service API

這應該是一個美麗(?)的未來, 不久前看到這段影片, 其實也真的就不久, 三月放出來的影片, 離現在也沒多久

剛開始看到覺得, 頗酷的呀, 感覺就是在原本request/reply機制上再加上更多像是monitor和tracing的機制, 並讓它變得更像RPC call

但為了寫這篇時, 做實驗後發現, 他講的東西目前也都還沒push到main trunk去的樣子, 像是schema, 說有支援typescript也還沒, 還有nats service相關的指令也都還沒有, main裡面還沒找到相關的source code

所以這篇就沒打算寫太多了, 免得未來差異太大, 相關細節還是可以去看那段影片

先簡單來看一下程式會長成怎樣:

// GreeterServer is the server API for Greeter service.
type GreeterServer interface {

	// Sends a greeting
	SayHello(in *HelloRequest) *HelloReply
}

func RegisterGreeterServer(conn *nats_go.Conn, subject string, greeter GreeterServer) error {
	srv, err := micro.AddService(conn, micro.Config{
		Name:    "greeter",
		Version: "1.0.0",
	})
	if err != nil {
		return err
	}
	grp := srv.AddGroup(subject)
	grp.AddEndpoint("sayhello", micro.HandlerFunc(func(r micro.Request) {
		req := &HelloRequest{}
		proto.Unmarshal(r.Data(), req)
		resp := greeter.SayHello(req)
		data, _ := proto.Marshal(resp)
		r.Respond(data)
	}))
	return nil
}

type GreeterClient struct {
	subject string
	timeout time.Duration
	conn    *nats_go.Conn
}

func NewGreeterClient(conn *nats_go.Conn, subject string, timeout time.Duration) *GreeterClient {
	return &GreeterClient{subject, timeout, conn}
}

func (c *GreeterClient) SayHello(in *HelloRequest) (*HelloReply, error) {
	data, _ := proto.Marshal(in)
	msg, err := c.conn.Request(c.subject+".sayhello", data, c.timeout)
	if err != nil {
		return nil, err
	}
	reply := new(HelloReply)
	proto.Unmarshal(msg.Data, reply)
	return reply, nil
}

這段client/server的程式就跟request/reply的感覺差不多, 只是多了一些東西

其實我也試著想結合grpc跟這機制, 因此寫個小工具叫NPC, 所以以上的程式其實是由底下這個定義產生的:

syntax = "proto3";
option go_package = "nnrpc/pb";

// The greeting service definition.
// - version: 1.0.0
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

(這邊就不談怎寫protoc的plugin了)

這篇主要是要來講K8S上的liveness probe/readiness probe/startup probe, 這應該是比較不會被注意到的題目, 大家可能會想, 不過就是health check嘛, 有啥難的? 

不過其實在K8S上面其實不是只有單單health check這麼簡單, 它上面有liveness probe, readiness probe, startup probe, 每一種都有它的不同作用, 如果沒特別注意各自特性, 其實也是有可能會碰到災難的, 首先來看看, 怎麼使用這三種不同的"探針"(翻成探針好像怪怪的, 但我想不到比較好的說法)

這個定義是針對Pod (Deployment …), 所以你的YAML可能會像這樣

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: liveness-http
spec:
  containers:
  - name: liveness
    image: registry.k8s.io/liveness
    args:
    - /server
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
        httpHeaders:
        - name: Custom-Header
          value: Awesome
      periodSeconds: 3
    startupProbe:
      httpGet:
        path: /healthz
        port: liveness-port
      failureThreshold: 5
      initialDelaySeconds: 30
      periodSeconds: 10 
    readinessProbe:
      httpGet:
        path: /healthz
        port: liveness-port
      failureThreshold: 30
      periodSeconds: 10   

先說一下, 上面並不是一個好寫法, 三種Probe都用了同一個endpoint, 先不多做解釋, 看完應該就比較會清楚啥不好

不管哪一種, 都有三種檢查機制可以用:

就是選擇適合來確認你服務的健康狀態的來使用就好了(廢話)

那這三種有啥分別?

  • livenessProbe 用來確認你的服務是不是還在"執行", 如果不是, pod就會被砍掉, 然後會依據restartPolicy的設定是不是要重啟你的pod
  • readinessProbe 活著(liveness)和準備好(readiness)差別在哪? 這邊的readiness指的是你的容器(container), 是不是已經"準備好提供服務"了, 如果是, 才會把請求(request)轉送到你的容器
  • startupProbe 特性其實接近livenessProbe, 但是是用在容器剛啟動時, 用來確認容器是否正常啟動, 如果檢查失敗(時間超過 initialDelaySeconds[一開始等多久] + failureThreshold[失敗幾次] × periodSeconds[間隔多久重試])一樣是砍掉pod, 然後會依據restartPolicy的設定是不是要重啟你的pod

詳細點來說,

啥時用livenessProbe?

俗語說的好, “重開機治百病"(哪來的俗語呀?), 簡單的說, 如果你的容器卡住不動了, 怎麼搖都懷疑人生, 沒反應了, 需要透過重開, 重新投胎, 才能(有機會)恢復正常, 那就是livenessProbe可以發揮的地方了

這邊說的是卡住不動沒回應這類的, 所以像是你的程式碰到dead lock, 無窮迴圈而無法正常回應都算, 但程式結束, 不正常離開(像Java的uncaught RuntimeException?) 其實不用等到livenessProbe打失敗, 就會照restartPolicy來處理, 所以可以知道, 當發現有Pod重啟的情形, 應該就不外乎是自然死, 意外死, 還有就是因為livenessProbe打施打失敗被謀殺了

那….

該不該在livenessProbe去檢查相關的服務或資料庫有沒活著?

不該!(張惠妹/周杰倫), 這還蠻常見的狀況, 就想說, 這health check 嘛, 我資料庫連不上, 當然就不健康囉, 所以就回傳了Failure了, Spring boot acuator的health endpoint也是會幫你檢查相關依賴的資源的狀況列入檢查(不過它有為Kubernetes有對應的做法啦, 後面再說)

這樣會導致啥狀況? 你明明服務還好好的, 然後只是資料庫連不上, 結果你的Pod就被砍了(好無辜), 然後只要資料庫還沒修好, 就一直復活一直死(好悲哀)

所以liveness的probe應該簡單到只是確認這個容器有沒被卡死, 資料庫連不上只是不能服務, 資料庫修好了還是可以繼續服務呀

老是因為打livenessProbe時timeout被砍, 那我是不是盡量把timeoutSeconds盡量設的越大越好?

其實這檢查的目的只是要確認容器有沒被卡死, 所以livenessProbe應該盡可能越簡單越好, 不太適合去做一些需要大量運算或是複雜的事, 因為那可能會因為你的pod或node的繁忙程度去影響到它執行時間長短差距很大, 那如果因為這樣去調高timeout時間也不太合理, 因為也很難確定要多久才能確定它"真的被卡住了”, 再加上你可能會因為頻繁做這些複雜運算(因為每periodSecond會被探測一次)影響系統效能(像是不要在livenessProbe實作內call DB query)

依據你livenessProbe正常會回應的時間再多給點應該就足夠了, 設非常大的話, 搞不好容器真的卡住了, 但卻反應慢了

我可不可以不要設livenessProbe

為何不可? 前面一直說, 它只是用來偵測程式是不是被"卡住了", 如果沒被卡死的風險, 不需要靠重啟手段來回復的話, 那不用設是可以呀

那啥時用readinessProbe呢?

readinessProbe跟livenessProbe的差異在於會不會出人(Pod)命, readiness為success的話, 上游(Service)才會把請求(request)送來給我處理, 不然的話, 就會收不到(又是廢話), 所以從這邊就可以看出前面那題的答案了, liveness和readniess的檢查邏輯應該會不一樣的(所以不太適合同一個endpoint搞定)

那要不要去檢查相關的服務或資料庫有沒活著?

可以, 也建議, 因為如果後面的服務或資料庫死了, 表示請求送進來也會處理失敗, 那不如先把它擋在外面等到服務正常了

startupProbe呢?

這個通常用在啟動會很久的容器, 為了怕太早打livenessProbe, readinessProbe導致高失敗率(因為啟動很久, 太早打一定都失敗的), 所以用這個probe來確認容器成功後才真的去實施那兩個探測

容器啟動很久其實不是一個很好的practice, 所以這個其實也是萬不得以才在用, 如果啟動時間不長的話, 為probe設定initialDelaySeconds 就已經很足夠了

誰去打這些Probe的?

一個比較錯誤的想像是, Kubernetes在某個地方, control plane或那裡有個服務去打所有的probe, 其實不是, 這樣它會累死

其實是由每個node的kubelet來負責, 當被加入一個Pod時, kubelet就會為這些probe每個都起一個go routine來根據設定的規則做檢查(感覺這設計沒太好, 會起不少go routine)

可以參考kubelet的實做細節

這樣其實比較合理啦, 每個node的kubelet就顧自己家後院就好

Spring boot acuator的Kubernetes Probes

Spring boot acuator預設的health endpoint是/actuator/health, 但這其實不好一體適用於liveness和readiness

Spring boot要用/actuator/health/liveness在livenessProbe而/actuator/health/readiness在readinessProbe, 可以參考這篇….

架設Kafka一個稍稍討厭的點就是先架好Zookeeper, 在Kafka 2.8 (2021/4發布)之後, 支援了以自家的KRaft實現的Quorum controller, 這就可以不用再依賴zookeeper了, Confluent這篇文章有簡單的介紹一下Quorum controller是怎運作的

在我前面這篇有提到, 如何用docker跑無zookeeper的Kafka:

docker run -it --name kafka-zkless -p 9092:9092 -e LOG_DIR=/tmp/logs quay.io/strimzi/kafka:latest-kafka-2.8.1-amd64 /bin/sh -c 'export CLUSTER_ID=$(bin/kafka-storage.sh random-uuid) && bin/kafka-storage.sh format -t $CLUSTER_ID -c config/kraft/server.properties && bin/kafka-server-start.sh config/kraft/server.properties'

那如果要架設在K8S上, 可以怎麼做呢? 原本的Kafka需要依賴zookeeper, 加上Kafka的eco system其實蠻多東西的, 一般也不會光只用Kafka本身而已, Kafka Bridge, Kafka connect, schema registry, 進階一點就Kafka stream, KSQL, 規模大一點還需要用上Cruise control, Mirror Maker, 所以用Operator來架設可能會比單純寫manifest, helm chart來的好用, 而比較常見(有名的?)Kafka Operator大致上有這三個(就我知道的啦):

  1. Confluent Operator, 由Confluent這家公司發布的, 由於Confluent這家公司的背景(https://docs.confluent.io/5.5.1/installation/operator/index.html), Kafka雖是Open source但就是他們家的產品, 所以這個也算是官方出品的Operator, 但這個功能上比較起來稍弱, 而且並沒啥更新, 當然也就還沒看到KRaft相關的支援
  2. KOperator, 萬歲雲(Bonzai Cloud)出品, 由於Bonzai Cloud目前是Cisco的, 所以這個也可以算大公司出品(?), 我自己是還沒用過, 但看架構, 預設就會架起Cruise control跟Prometheus, 感覺架構上考量是比較完整的, 另外就是也考量到部屬到Istio mesh的部分, 用Envoy來做external LB, 以及用等等, 另外一個值得一提的是Kafka這種Stateful application, 它卻並不是採用Statefulset來部署(它的文件有提到All Kafka on Kubernetes operators use StatefulSet to create a Kafka Cluster, 但事實是後來Strimzi也採用一樣的策略了), 但一樣的, 也還沒有支援KRaft
  3. Strimzi Operator, 這應該算蠻廣泛被利用的一個Operator, 支援豐富, 更新迅速, 也是可以支援Cruise control (不一定要開), 基本該支援的, 應該也都差不多了, 而且從0.29就支援了KRaft, 不過這個Operator基本消費的記憶體就需要到300MB了

總和以上, 看起來如果要在K8S上玩KRaft的話, Strimzi是一個比較適合的選擇

安裝Strimzi operator

用以下指令安裝:

kubectl create namespace kafka
kubectl create -f 'https://strimzi.io/install/latest?namespace=kafka' -n kafka

這除了會建立strimzi-cluster-operator這個Deployment, 也會建立相關的ClusterRoles, ClusterRoleBindings, 和相關的CRD, 所以要先確定你有權限建立這些(尤其是Cluster level的), 其實相當簡單

另外, 用以下的manifest就會幫你建立好一個Kafka Cluster

apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
  name: my-cluster
spec:
  kafka:
    version: 3.3.1
    replicas: 3
    listeners:
      - name: plain
        port: 9092
        type: internal
        tls: false
      - name: tls
        port: 9093
        type: internal
        tls: true
    config:
      offsets.topic.replication.factor: 3
      transaction.state.log.replication.factor: 3
      transaction.state.log.min.isr: 2
      default.replication.factor: 3
      min.insync.replicas: 2
      inter.broker.protocol.version: "3.3"
    storage:
      type: ephemeral
  zookeeper:
    replicas: 3
    storage:
      type: ephemeral

這會建立一個replica數量為3的Kafka cluster,以及對應的Zookeeper, 這邊的Storage type為ephemeral, 這表示它會用emptyDir當Volume, 如果你有相對應的PVC, 也可以把這替換掉

這邊還是會幫你建立出zookeeper, 那如何能擺脫zookeeper呢?

打開實驗性功能

截至這邊文章寫的時間的版本(0.32), KRaft還是一個實驗性功能, 要以環境變數打開, 如下:

kubectl set env deployments/strimzi-cluster-operator STRIMZI_FEATURE_GATES=+UseKRaft -n kafka

strimzi是靠STRIMZI_FEATURE_GATES來當作feature toggle, 在0.32只有一個實驗性功能的開關, 那就是UseKRaft, 上面那行指令就可以把這功能打開

用上面一模一樣的Manfest(Zookeeper那段要留著, 雖然沒用, 但在這版本還是必須), 就可以開出一個不依賴zookeeper的kafka cluster了, 以下是整個操作過程:

asciicast

這邊你可能會發現, 我是用一個strimzipotset的資源來確認是否Kafka有沒正確被開成功, 你如果再去看Replica set, Stateful set, 你會發現找不到Kafka相關的, Strimzi其實就是靠自己的controller來管理Kafka的pods

你也可以用 kubectl get kafkas -n kafka來確認kafka這namespace下的kafka cluster的狀況

這個Manifest其實我拿掉了EntityOperator的部分, 是因為KRaft功能目前還沒支援TopicOperator, 沒拿掉會報錯

是該拋棄Zookeeper了嗎?

KRaft相對很新, 以三大有名的Kafka Operator來說, 目前也只有Strimzi有支援, 而且才剛開始, 實務上來說, 以功能, 穩定性或許應該還不是時候在production廣泛使用, 真要用, 還是多測試一下再說吧, 短時間還是跟zookeeper做做好朋友

做了些實驗, 紀錄一下, 剛好寫這篇時巴西也被幹掉了, 也記錄一下 XD

這個問題其實要滿足以下條件才可能發生:

  • MongoDB版本在 4.4.14, 5.3.0, 5.0.7, 4.2.20 之前, 這算是一個Mongodb在2022一月才fix的一個bug, 所以比這幾版舊是有可能的
  • MongoDB instance在K8S上有設memory limit (且這limit要小於host node memory的一半?)
  • K8S所在的Host OS 的cgroup版本為V2, 可以參考這文件, Ubuntu 21.10, Fedora 31之後都開啟V2了, 不過如果你用的是WSL2, 由於WSL2的Kenel還是V1, 是試不出這問題的 (我是找了台Fedora來試)

問題是甚麼?

查這問題的起因當然是碰到Mongodb被OOM Kill, 後來發現好像這也算蠻常踩到的坑, 只是好像沒人寫出完整可能性

碰到被OOM Kill第一個會思考的是, 他為何要那麼多記憶體?要給他多少才夠? 另外一個是, 由於是發生在container, 跑在K8S上, 一個疑問是, 那MongoDB是否會遵守設給他的resource limit? 還是他會當node所有記憶體都是他可用的?

有沒有哪裡可疑的?

有哪些東西會吃記憶體? 連線會, index會, 但其實其中一個比較可疑的是給Wired Tiger的cache, 根據這份文件, Wired Tiger的cache會用掉

  • 50% 的(總記憶體 - 1GB) 或是
  • 256MB

也就是至少256MB, 然後如果你有64G記憶體, 他就會用掉最多 (64-1)/2, 到這聽起來好像沒啥問題, 只用一半還不至於有撐爆的問題, 會不會是其他的地方?

但另一個問題是, 直接裝在單機沒問題, 如果是跑在K8S上的容器, Memory limit我們是給在K8S上, MongoDB到底會以memory limit當總記憶體大小還是以整個node全部可用的記憶體計算?

其實根據文件的補充說明, 它是有考慮到的, 它會以hostInfo.system.memLimitMB 來計算

In some instances, such as when running in a container, the database can have memory constraints that are lower than the total system memory. In such instances, this memory limit, rather than the total system memory, is used as the maximum RAM available.

而它這資訊是透過cgroup去抓的, K8S也是用cgroup做資源管理的, 所以這值會等於你設定的limit

我第一次在WSL下測試, 把memory limit設為2Gi, hostInfo.system.memLimitMB 也的確是這個值(用mongo client下db.hostInfo()即可查詢)

那看來應該沒問題呀, 問題在哪?

後來查到一個bug : https://jira.mongodb.org/browse/SERVER-60412, 原來cgroup v1, v2抓這些資訊的位置是不同的, 所以導致舊版的會有抓不正確的狀況

看到這就來做個實驗, 找了台有開啟v2的fedora (with podman), 跑了k3s, 在這k3s上分別跑了4.4.13, 4.4.15兩個版本去做測試, memory limit都設為2Gi, 用db.hostInfo()查詢memLimitMB得到下面結果:

4.4.13

4.4.15

Bingo! 4.4.13果然抓到的memLimitMB是整個node的記憶體大小而非limit, 這樣如果node的記憶體大小遠大於limit, Wired Tiger cache是有可能用超過limit的

當然, 這只是其中一種可能性, 不見得一定都是這情形, 但碰到這類狀況, 這的確是可以考慮查的一個方向