在現代的網頁開發中,JavaScript 驅動的動態網站變得越來越普遍,這對使用傳統 HTML 解析的爬蟲工具帶來了挑戰。傳統的方法不再適用,因為網頁的內容必須在執行 JavaScript 後才會生成。解析這種網站需要更多前端的知識。

chromedp 是一個用 Go 語言編寫的工具包,它通過 Chrome 的 DevTools 協議進行無頭瀏覽器(Headless Browser)自動化,使開發者能夠程式化地控制 Chrome 瀏覽器,方便地爬取和解析動態生成的內容。本文將介紹如何使用 chromedp 來建立一個簡單的網路爬蟲。

基本原理

chromedp 主要通過與 Chrome 瀏覽器的 DevTools 協議通信來實現其功能。這使得開發者可以模擬使用者操作,例如導航到網頁、點擊按鈕、填寫表單以及提取動態載入的內容。這些操作在無頭模式(Headless mode)下進行,瀏覽器界面不可見,從而提高效能和資源利用效率。透過這種方式,chromedp 可以處理傳統 HTML 解析工具無法處理的情況,特別是在處理動態生成的內容時。

簡單範例程式碼

以下是一個使用 chromedp 爬取網站資料的簡單範例,這個範例展示了如何導航到一個網站、選擇一些元素、提交表單並提取所需的數據:

package main

import (
 "context"
 "fmt"
 "log"
 "time"

 "github.com/chromedp/chromedp"
)

func main() {
 // 建立 context
 ctx, cancel := chromedp.NewContext(context.Background())
 defer cancel()

 // 分配瀏覽器
 ctx, cancel = chromedp.NewExecAllocator(ctx, chromedp.DefaultExecAllocatorOptions[:]...)
 defer cancel()

 // 建立具有timeout 30秒的 context
 ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
 defer cancel()

 // 執行任務
 var res string
 err := chromedp.Run(ctx,
  chromedp.Navigate("https://example.com"),
  chromedp.WaitVisible(`body`, chromedp.ByQuery),
  chromedp.SendKeys(`input[name="q"]`, "chromedp", chromedp.ByQuery),
  chromedp.Click(`input[type="submit"]`, chromedp.ByQuery),
  chromedp.WaitVisible(`#result-stats`, chromedp.ByQuery),
  chromedp.Text(`#result-stats`, &res, chromedp.ByQuery),
 )

 if err != nil {
  log.Fatal(err)
 }

 fmt.Println("Search Result Stats:", res)
}

在這個範例中,我們建立了一個 chromedp 任務,它會導航到 example.com,等待頁面載入完成後,在搜尋框中輸入 “chromedp”,點擊提交按鈕,然後等待搜尋結果統計資訊的元素可見,並提取該資訊。這是一個基本範例,展示了如何使用 chromedp 進行基本的網頁互動和數據提取。你可以根據需要擴展此範例以實現更複雜的爬蟲功能。

接下來我們用一個更實用的案例來實作,以下範例展示如何使用 chromedp 爬取中華職棒(CPBL)的賽程表,此網站由 Vue.js 實作。

解析文件

中華職棒賽程網站的網址是 https://cpbl.com.tw/schedule。首先,我們用檢視網頁原始碼(View page source)來看看,可以發現裡面找不到任何賽程資料。另外,我們還能發現這段程式碼:

var app = new Vue({
            el: "#Center",
            mixins: [mixin],

以及另一段用來取得賽程資訊的程式:

$.ajax({
    url: '/schedule/getgamedatas',
    type: 'POST',
    data: filterData,
    headers: {
        RequestVerificationToken: 'PzmpuUOvS4z2zH_QhwgFQYTzVC82b0n2QH30wEOJ12kOWA6zeq0Yn7_6d2v_o-ZTWuNPe3HjrqsMqAHp9sL0F5KB4KM1:5jgubJ0tGDTK3cLm2JU7_bCw9JqLOG8j8yeNiWDhR4nnTACLXerDqmzB5chZv-iqY8m1ep6IirI3hAwRCPfNTU6jO_E1'
    },
    success: function(result) {
        if (result.Success) {
            _this.gameDatas = JSON.parse(result.GameDatas);
            _this.getGames();
        }
    },
    error: function(res) {
        console.log(res);
    },
    complete: function () {
        $("body").unblock()
    }
});

很明顯這是一個用 Vue.js 寫的網頁。我們當然可以試著去打它的 API,但看到那串 Token,可能做了某些保護,使用 chromedp 的方式可能更簡單。

那怎麼開始解析呢?用 ChatGPT 或許是一個好方法,打開 Chrome 的開發人員工具,在 Elements 那邊可以看到已經是最終的網頁結果,試著把它存成一個檔案並詢問 ChatGPT:

資料結構也可以順便請它設計一下:

這當然只能當作一開始的參考,後面也可以請它幫你直接寫程式。不過,我試了一下,它寫出來的只能當範例,不能產出正確的結果,但拿來作為基礎修改其實也不錯用。

先定義一下需求,我們需要寫一個函數,可以輸入年月和比賽種類來取得賽程資訊。

依照這些資訊,先來寫一個比較粗略的版本來實驗一下:

type Game struct {
 No       int    `json:"no"`
 Year     int    `json:"year"`
 Month    int    `json:"month"`
 Day      int    `json:"day"`
 Home     string `json:"home"`
 Away     string `json:"away"`
 Ballpark string `json:"ballpark"`
}

func getGameNodes(nodes *[]*cdp.Node) chromedp.Action {
 return chromedp.ActionFunc(func(ctx context.Context) error {
  ctxWithTimeout, cancel := context.WithTimeout(ctx, 900*time.Millisecond)
  defer cancel()

  chromedp.Nodes("div.game", nodes).Do(ctxWithTimeout)
  for _, n := range *nodes {
   dom.RequestChildNodes(n.NodeID).WithDepth(6).Do(ctxWithTimeout)
  }

  return nil
 })
}

func selectMonth(month string) chromedp.QueryAction {
 return chromedp.SetValue("div.item.month select", month, chromedp.ByQueryAll)
}

func selectYear(year string) chromedp.QueryAction {
 return chromedp.SetValue("div.item.year select", year, chromedp.ByQueryAll)
}

func selectGameType(gtype string) chromedp.Action {
 return chromedp.SetValue("div.item.game_type select", gtype, chromedp.ByQueryAll)
}

func fetchGamesByMonth(ctx context.Context, year string, month string) ([]Game, error) {
 chromedp.Run(ctx, selectMonth(month),
  chromedp.WaitVisible("div.ScheduleGroup"),
  chromedp.Sleep(800*time.Millisecond),
 )

 var nodes []*cdp.Node
 var mn string

 chromedp.Run(ctx,
  chromedp.Text(".date_selected .date", &mn),
  getGameNodes(&nodes),
 )

 var games []Game = make([]Game, len(nodes))
 for i, node := range nodes {
  games[i].No, _ = strconv.Atoi(strings.Trim(node.Children[0].Children[0].Children[0].Children[1].Children[0].NodeValue, " "))
  games[i].Ballpark = node.Children[0].Children[0].Children[0].Children[0].Children[0].NodeValue
  games[i].Year, _ = strconv.Atoi(year)
  monthInt, _ := strconv.Atoi(month)
  games[i].Month = monthInt + 1
  dataDate := node.Parent.Children[0].AttributeValue("data-date")
  day, _ := strconv.Atoi(dataDate)
  games[i].Day = day
  games[i].Away = node.Children[0].Children[0].Children[1].Children[0].Children[0].AttributeValue("title")
  games[i].Home = node.Children[0].Children[0].Children[1].Children[2].Children[0].AttributeValue("title")
 }

 return games, nil
}

這個版本程式碼很粗略但可用,主要使用 NodeValue 和 AttributeValue 來取值。這種方法的問題在於,這些 chromedp 呼叫每一個都需要與 Chrome 通信,而這是通過 Chrome DevTools Protocol 來實現的。Chrome DevTools Protocol 使用 WebSocket 進行通信,這樣頻繁來回不僅效率低,穩定性也較差。

下面這個範例是從 ChatGPT 學來的方法再優化的:

// FetchSchedule fetches the schedule from CPBL website based on the year, month, and game type
func FetchSchedule(year int, month int, gameType string) ([]GameSchedule, error) {
 ctx, cancel := chromedp.NewContext(context.Background())
 defer cancel()

 var schedules []GameSchedule

 // Define the URL
 url := "https://cpbl.com.tw/schedule"

 // Run chromedp tasks
 err := chromedp.Run(ctx,
  chromedp.Navigate(url),
  chromedp.WaitReady(`.ScheduleTableList`), // Wait for year select to be ready
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.filters.kindCode = '%s'", gameType), nil),
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.calendar.year = %d", year), nil),
  chromedp.WaitReady(`.ScheduleTableList`), // Wait for year select to be ready
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.calendar.month = %d", month-1), nil),
  chromedp.Evaluate(`document.querySelector('#Center').__vue__.getGameDatas()`, nil), // Wait for table to be visible
  chromedp.Sleep(2*time.Second), // Wait for table to load
  chromedp.Evaluate(`
   (() => {
    let schedules = [];
    document.querySelectorAll('.ScheduleTable tbody .date').forEach(dateDiv => {
     let date = dateDiv.innerText.trim();
     let parent = dateDiv.parentNode;
     parent.querySelectorAll('.game').forEach(gameDiv => {
      let location = gameDiv.querySelector('.place') ? gameDiv.querySelector('.place').innerText.trim() : '';
      let game_no = gameDiv.querySelector('.game_no') ? gameDiv.querySelector('.game_no').innerText.trim() : '';
      let away_team = gameDiv.querySelector('.team.away span') ? gameDiv.querySelector('.team.away span').title.trim() : '';
      let home_team = gameDiv.querySelector('.team.home span') ? gameDiv.querySelector('.team.home span').title.trim() : '';
      let score = gameDiv.querySelector('.score') ? gameDiv.querySelector('.score').innerText.trim() : '';
      let remark = gameDiv.querySelector('.remark .note div') ? gameDiv.querySelector('.remark .note div').innerText.trim() : '';
      schedules.push({ date, location, game_no, away_team, home_team, score, remark });
     });
    });
    return schedules;
   })()
  `, &schedules),
 )

 if err != nil {
  return nil, err
 }

 return schedules, nil
}

這個版本大量使用 chromedp.Evaluate 來內嵌 JavaScript 程式碼直接在網頁執行。這樣可讀性更好,且避免了頻繁與 Chrome 通信。這種方法更高效且穩定。

為什麼用這開場? 跟要講的內容有啥關係? 其實…沒有….只是剛剛看完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做做好朋友