Micro-service 範例分享 — online keyboard firmware compiler service

李松錡
19 min readJan 28, 2020

--

大家知道其實我是一直有在開發一把曲面的人體工學鍵盤 ExDactyl,而最近我想要辦一個活動,是免費借出這把鍵盤 (最多收押金跟運費,然後還鍵盤的時候退押金),只要幫我寫一篇使用心得就可以了。

更新,借用活動已經開始囉! 請看這篇文章

關於出借活動的公告,可以先按讚追蹤我的鍵盤粉專 ErgoKB,就可以獲得第一手資訊。

但因為這把鍵盤還有一些 pin 腳定義還沒有完全確定,所以目前我還沒辦法發 PR 到 QMK configurator ,讓借的人可以使用他們的介面來設定自己喜歡的鍵位。但由於 ExDactyl 其實就是曲面的 Ergodox,因此暫時性的我先借用 Ergodox-ez 的 ORYX configurator,從介面上設定好之後,選擇 Download source 下來,然後有了 source 我就可以把他轉換成 ExDactyl 使用的韌體。

成果

今年的春假我很多預計要做的事情都沒做,都在做這個專案。最後的成果是,我有一個 upload 的 API,user 上傳檔案以後,可以在 0.6s 的時間內 compile 完回傳。而最終版本的詳細狀況,則是一個 Kubernets Server:

最前面有一個 gateway 會去做第一階段的解壓縮,然後把鍵位設定往後丟給 compiler,並且把結果回回去給 user。

做這個小專案的時候,中間有幾個階段:

  • 第一個階段,我其實是做了一個 docker image 讓別人可以跑一個 docker container 起來,然後這個 container 的 entry point 就會自己跑完 compile 並輸出韌體
  • 第二個階段,我只有做一個很單純的 HTTP server,收到 zip 以後就把它解壓縮,然後叫起一個 docker container 來 compiler
  • 第三個階段,優化第一階階段的成果,盡量減少一些 copy。並且因為意識到了叫起一個 container 這個過程其實很花時間,所以嘗試預先 create container,但不讓他們開始 run
  • 最後一個階段,我發現 docker 的 create 其實不怎麼花時間,最花時間的其實是在 start container。所以在幾經思考之後,我直接套了 Kubernetes 來完成這個 service。

那就讓我們先來了解一下這個問題的限制在哪裡,為什麼我要用 container 的形式來進行吧。

第一階段

要想了解為什麼我要用 container 的形式,就要先從這個韌體怎麼來的說起。首先我是使用 QMK 這個開源的韌體,他是一個非常完整且功能豐富的鍵盤韌體,但因為是使用 AVR controller,所以不可避免的,還是使用了 c 來作為撰寫的語言。那為了要能夠得到 firmware,所以我們要 compile 這些 c 的 source code。

但是有玩過 embedded system 的人都知道,要 compile 成該 controller 的 firmware 其實很麻煩 (我的前提條件是你不是裝一個 Keil 之類的 IDE,然後裡面點一點下載之類的),通常是要下載一個能 compile 該 controller 的 toolchain,像是 avr-gcc 等等的 compiler,然後還會有一堆有的沒有的 dependencies 要處理。另外 toolchain 不是下載下來放著就好,還要把很多的 path 都連好連對才行;此外 QMK 還是使用 makefile 來執行編譯的順序,因此你的電腦還要裝可以執行 makefile 的程式,像是 ubuntu 就要裝 build tools 等等,在 Mac 上用 brew 安裝的話你會發現你的電腦可能就卡在那邊半個小時,因為目前 brew 安裝的 avr-gcc 是拉 source 下來 compile 的。

基於以上種種理由,所以我就想到如果要給別人用,最好的方式就是我自己 build 一個 docker image,裡面含有所有 compile 需要的 dependencies。既然我都做到這邊了,那我乾脆就寫一個 script,順便讓這個 container 的時候同時執行 make ,去把 firmware 產出來。這時候我其實有一個簡單的 script 是採用 mount volume 的方式,你只要把下載下來的 zip 解開,找到裡面有一個 keymap.c 的檔案之後,把這個檔案跟這隻 script 放在同一個位置,然後執行這個 script 的話,他就會把當前這個 directory mount 進裡面一個特定位置,然後 compile 完之後把 firmware 放到這個共用 volume 裡面,然後 user 就可以在原來的資料夾裡面得到這個 firmware。

#!/bin/bash 

docker run --mount type=bind,source="$(pwd)",target=/root/qmk_firmware/keyboards/ex_dactyl_v2/keymaps/custom/ lschyi/qmk_compiler:20200110

不過後來我想到我其實忘了把 container 清掉,而且也不用 mount 進去其實也可以,只要透過先 create docker container,把 keymap.c copy 進去,再 start container,最後清掉也是可以的,所以 script 應該會變成這樣:

#!/bin/bashdocker create --name=compiler lschyi/qmk_compiler:20200110
docker cp keymap.c compiler:/root/qmk_firmware/keyboards/ex_dactyl_v2/keymaps/custom/keymap.c
docker start compiler
docker cp compiler:/root/qmk_firmware/ex_dactyl_v2_custom.hex .
docker rm compiler

第二階段

即便我已經弄出一隻 script 跟一個 docker image,user 還是有一個要懂得安裝 docker 的巨大的門檻,所以我就在想要怎麼做才能對使用者更友善,更容易把鍵盤借出去推銷。那想來想去,只有我幫他們 compile 一途了。當然如果你看過 make 跑的流程,你會發現 有很多 common 的檔案是不用重新 compile 的,他可以直接把 .o 檔 link 起來就行,我這邊如果建立一個 online compile 的 service 的話,也可以只完成最後一小段,也就是 compile 剛剛說到的 keymap.c,然後我再做最後的 linking 其實就完成了。

說起來是如此的簡單,但做起來其實有很多問題。

如果我直接寫一個接收上傳的 server ,這並不是什麼大問題,但問題在 compile。我不想重新造一個輪子,自己寫一個 compiler,然後來客製化裡面的行為,所以最好的方法就是我把收到的 keymap.c 存起來,然後在電腦裡面跑 make 就是了。但是這又會有一個問題,compile 的時候其實會有一堆 .o 檔產生,為了避免如果有多個人同時上傳 keymap.c,造成我的 server 同一時間下了多個 compile 的指令然後就出現資源搶奪造成的 bug,所以變成我必須要限制同一時間只能 compile 一份 code,而且 compile 完以後,我還要馬上把這個成品重新命名,這樣才能確保在接下一個 compile 指令的時候,不會因為檔案名字是一樣的,就把前一個 compile 的成果覆寫掉,所以變成我還要自己管理檔案。除此之外,也可以想見等我把檔案傳輸回去完畢以後,我還要自己把檔案清掉;再者,我還要防範有人傳了奇怪的東西進我的 compile 主機,make 跑下去可能主機就暫時死機了。

在以上諸多的考量以後,最後我想到最好的解法就是開一個新的環境來跑 compile,這樣如果 compile 失敗的話,我還可以限縮壞掉的區域,不至於讓整個 server 掛掉。那這麼一講,最適合的,也還是跑一個 docker container 啦!我前面都已經做了一個 image 了,裡面該要有的 compile dependencies 通通都有,要創造一個新的 compile 環境就是 docker create 一個 container,壞掉就壞掉吧,反正整個主機還是好的,整個 container 扔掉就是了,所以在我找到 docker 的 SDK 以後,我就寫了一個簡單的 HTTP server,他做的事情非常單純,就是接收 HTML file upload 的表單,然後把 Zip 解開,找到 keymap.c 的檔案之後,就透過 docker SDK 去叫起一個 container,然後塞 keymap.c 進去,再讓 container 跑起來,並且在跑完之後把 firmware copy 回來,清掉 container 並且把 firmware 送回去給 user。

這過程聽起來很簡單,但其實做起來也是有一些坑的。像是 docker SDK 裡面提供的 copy method 要求的是 tar file,所以本來我想得很美,想說 HTTP request 來,我就把內容直接倒給 docker container 就好了,中間能省一點 copy,但因為要包成 tar,所以我還是要先讀進當前 process 的 memory,然後包成 tar 之後再送;再者,我在 copy 回 host 的時候,有遇到很莫名的 context cancel 的問題,找了半天之後,才發現是 docker SDK client 上面的 copy 使用的 context,在 cancel 以後會連帶把 copy 回來的 io.ReadCloser 一起關掉,然後我就莫名其妙的不能把 firmware 寫進 response 的 body 裡面。然後這樣一聽,你有沒有覺得有點怪怪的? docker SDK client 怎麼好像不是用 low level 的方式在操作 docker engine? 沒錯,在我翻了翻文件之後,發現原來 docker SDK 其實也是叫一個 HTTP client 去操作。哪泥! 難怪我東西要包成 tar,然後他才會透過 HTTP protocol 把東西送給 docker engine,然後 docker engine 自己會去解那個 tar,然後把東西塞進 container 裡面。相同的,我如果要 copy 東西的話,也是 docker engine 自己先把東西包成 tar,然後送回來給我,我自己再解 tar 以後,才能寫回去 HTTP response。

既然寫了就寫了,那就這樣吧。總之,我還是把第二個版本做出來了,好處是我真的做到很乾淨的每次 compile 都是一個新的環境,如果有惡意的或有問題的 keymap 傳給我,我的 process 並不會直接死掉,這樣至少我不用擔心重啟的問題,而且我還能做到開啟多個 container,一次跑好幾個 compile 耶! 這大概是我最開心的事了。

第三階段

話是這麼說,但人都是貪心的。如果你注意看,你會看到其實我都有寫 test

這算是進公司後才養成的習慣,以前就是不寫 test case 的那種佛系開發者,然後遇到 API 看不太懂的時候,要嘛就是寫一個小的 main program 跑看看,不然就是一直用 main program 一直 try and error 的去嘗試,然後後來發現其實可以寫完一個小的 function,或者不確定的 API 用法之後,就寫一個 test case 跑跑看,如此一來,開發就更加踏實了。而且因為不是用 try and error 的形式,所以開發速度其實也有加快。

但如果你注意看,你會到他其實有標示說每個 test 花的時間是多少。我們看到 TestCopyFirmware 那行,那邊其實就是跑完完整個 copmile,然後把 firmware copy 回來,un tar 結果後所花的時間。整個過程差不多花 2s,我不是跑 benchmark test,所以還不是跑多次統計的結果,而且這還是在一台 i7–3770 配 8G ram 的主機上跑的結果,怎麼看,一次 compile 加上傳輸的時間,就要花個 3s 吧,聽起來就很不舒服。所以我就開始看還有什麼可以省掉的時間,因此,我除了把 un tar 的部分優化,省掉一點 copy 之後,再看看有沒有什麼能預先做的部分,可以減少整個 compile 需要的時間。

其中一個可以預先做的部分,就是先 create 好一些 container,這樣需要的時候就直接 copy keymap,start container 就行了。而且為了避免開太多的 docker container 把系統資源吃掉 (我本來是想說在 docker engine 上面加設定,這樣沒辦法 create 新的 container 的時候系統會回 503 service unavailable),所以我乾脆做成系統固定就是有四個 docker client,每個 client 自己會去維護。然後我其實心裏也在想別的 compiler 作法,所以我也把 compiler 的部分改成 interface,這些 code 在這個 branch 上。這個 branch 就沒有做得太認真,因為當時是試驗性質的做做看。這麼做下來之後,大概可以省掉 … 0.15s。聽起來挺雞肋的,但那時腦袋有點秀逗了,竟然沒看到最花時間的其實是 start container (1.48s),所以真正 compile 花的時間,其實大概就 0.5 ~ 0.6s 而已。這也促成我下一版的改進。

第四階段

那前面我終於意識到,其實最花時間的是把一個 container 從 create 狀態到 start 起來,所以我就開始在想,那我該怎麼減少這段時間呢?

有一個顯而意見的方式,就是我不要一個 file upload 才 create 一個 container,而是我預先就讓 container 一直都是 running 的狀態,變成我透過某個事件讓他執行一次 compile,compile 完以後維持待命狀態,不要馬上關掉,而是繼續待命,等待下一次的 compile 指令。反正 keymap 會被我刷新,然後跑 make 的時候自然也只會去 compile 更新的部分而已。最一開始我想到的作法,是從前面那個 pool 的 branch 去想的,前面我又已經把 compiler 做成 interface 了,那我就加新的 compiler implementation 不就行了嗎?

乍聽之下好像很簡單,但其實細想,會有很多問題。第一個是我有什麼方法可以讓這個 container 一直維持 runnin,而不是跑完 entrypoint 就死掉了呢? 這好像聽起來就是我去覆寫那個 entrypoint,然後讓他跑一個 bash 的無窮迴圈,不斷 sleep 就好了,但是我要怎麼下 compile 的指令呢? 於是我又回去翻 docker SDK,然後,就沒有然後了。恩,不愧是 client API,確實只能做到 docker command line 能做到的部分功能,而且也維持 docker 本來的理念,就是一個 container 應該只做一件事情,像我這種要求的,docker 大概這輩子從來沒見過。開玩笑的,應該是像我這種要求,應該是要讓這個 container 就是一個簡單的 server。

那再來,我要用什麼去溝通呢?前面已經說過,我發現 docker SDK 其實也只是 HTTP client,去跟 docker engine 溝通,然後 engine 再把東西 copy 出來給我。這麼一想,那我直接在 container 上面開一個 HTTP server (或 TCP 之類使用 network stack 溝通的 server),都行,反正效能再差也不會比把那個要用 tar 把東西包起來給我,我自己再解開來得差。所以這麼一想,用 network stack 溝通應該是沒什麼問題了,但,如果要用 network stack 溝通,大問題才真的來了。因為 docker 每個 container 都是在自己的 name space 裡面的,我最外面那隻接收 zip 的程式,是沒有辦法直接跟不同的 name space network stack 溝通的。那我用 docker port binding 呢? 這樣變成我要自己去找哪些 port 是空的,這在我剛剛把一個 compiler 做成一個 object 來說好像勉強還行,但找 port 這件事聽起來就很麻煩。再來還有一個問題,那如果這個 docker container 不小心掛掉,那我是不是又要想辦法把他叫起來?

綜合最後上面這些想法,你不覺得這玩意跟某個東西很像嗎? 沒錯,就是 Kubernetes。Kubernetes 不但會自己幫你 restart container,然後內部的 networking 都幫你連好好的。我只要把 compiler 做成一個 micro-service,給他們一個 service definition,還會自己幫我做 load balancing,我覺得 compiler 不夠的時候,直接去指定 auto scaling,這樣我最前面那隻吃 zip 的程式也根本不用改任何設定,不需要改要有多少個 compiler。而這樣一想的話,就變成我最後那張圖的架構:

最前面的 gateway 就是原來那隻會吃 zip 的程式,然後我就在其中一個 commit 切出去做了新的 compiler implementation,那我這邊就很簡單的採用 HTTP protocol 來處理,所以這個 compiler 的 implementation,其實也只是叫了一個 HTTP client 來運作,那下面每一個小的 compiler micro-service 也只要做成 HTTP server 即可。值得一提的是,我一開始還想說走一般的 HTTP file upload 方式,也就是要上傳一個 browser 會送的 HTML form,但看了一下實在太麻煩了,所以我就不管三七二十一,直接把 keymap 完整內容寫進 Body 裡面,那我在 micro-service 上面也不用想太多,反正把 request 的 Body 從頭開始讀乾淨,就會是一個完整的 keymap.c 檔案。

這樣想一想之後,我就決定用 Kubernetes 的方式來解決。這邊我安裝的是 microk8s,這是一個適合只有單台主機運行的小型版 kubernets。於是要開始 deploy definition 的部分,我在 gateway 這邊只要設定原來吃 zip 的程式是一個 deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
labels:
app: gateway
spec:
replicas: 1
selector:
matchLabels:
app: gateway
template:
metadata:
labels:
app: gateway
spec:
containers:
- name: gateway
image: lschyi/compiler_gateway:20200128
env:
- name: TYPE
value: "STATIONARY"
- name: ENDPOINT
value: "http://compiler-service:8080/"
ports:
- containerPort: 8080

這樣這個 gateway 如果因為奇怪的原因 panic 死掉了,Kubernets 也會幫我叫起來。然後再來要 deploy 後面的 compiler,這邊我就直接連 service definition 都定義好 deploy 上去

apiVersion: apps/v1
kind: Deployment
metadata:
name: compiler-service
labels:
app: compiler-service
spec:
replicas: 4
selector:
matchLabels:
app: compiler-service
template:
metadata:
labels:
app: compiler-service
spec:
containers:
- name: complier
image: lschyi/compiler_service:20200128
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: compiler-service
spec:
selector:
app: compiler-service
ports:
- protocol: TCP
port: 8080
targetPort: 8080

這邊我現在比較保守,就根據我自己 CPU 的數量來設定 replicas。那最後一件事,就是我要讓外部可以存取我的 gateway,所以 gateway 的 service definition 比較不一樣

apiVersion: v1
kind: Service
metadata:
name: gateway
spec:
selector:
app: gateway
ports:
- protocol: TCP
nodePort: 30000
port: 8080
targetPort: 8080
type: LoadBalancer

我要指定他是 load balancer,除此之外,我指定了 nodePort 是 30000。這樣設定完之後,只要有人存取這台 server 的 30000 port,就會被導到 gateway 8080 port 上,就可以完成原來的 compile 運作。

值得一提的是,因為現在有 Kubernetes 幫我管理 container (pod),所以我故意把 compiler 的 micro-service 寫得比較死一點,如果是一些我不是很肯定的錯誤,我就直接讓這個 process panic,整個 pod error,讓 Kubernetes 去重生一個新的 compiler micro-service pod 出來。

結語

其實我在第四階段的時候,本來差點懶惰癌發作,想說這東西可能要寫個三四天,沒想到最後半天就寫完了。然後整個專案做下來,覺得這一年多一點點在公司上班,真的成長不少。從以前根本搞不懂什麼是 Kubernets,寫 code 不懂不愛寫 test case,到現在這些事都會乖乖做,好好做,還對整個 linux 系統有更深入的了解,算是非常的欣慰啦,也希望大家看完這篇文章能有點收獲,如果看得霧煞煞也沒關係,至少你看到了一個 Kubernetes 的應用 case,未來有機會可能會比較好上手。

--

--