K8S Cron Job 記憶體不足

Robert Yen
9 min readMay 25, 2023

--

Photo by Liam Briese on Unsplash

情境

Batch job 在系統設計中是一種很常見的一種解決方案,用來解決大量或者批次的工作。在 K8S 的環境中,我們會採用 CronJob 的物件型態宣告預期工作的執行方式。

系統設計

方法一

在這個故事中,原先是採用 light-weight cron job 的方式:也就是說,對於 cron job 來說,它本身只是輕量級的 trigger point,時間到了,它就透過一個 rest API call 的方式將此任務派送到遠方的 API server,由這 API server 接續處理大量的工作。這樣 trigger 型態的 cron job 是一種 fire and forget 的方式,不會等待遠方是否完成,或者遭遇異常錯誤。

方法二

當有新的人加入後,有不同的想法與提議。新的作法改變了原先 light-weight cron job 的設計理念,希望將 cron job 所要執行 heavy task 放置於一個專屬的 process,與原先的 API server 區隔開來,好處是可以避免影響線上 API server 的效能。

當初在程式架構有做適當的分層,並遵守 clean architecture 的一些規範,因此不管是採用方法一或者方法二對於程式碼的撰寫與測試都沒有太大的差別。方法一是在 cron job 端撰寫一個簡單的 rest API call,透過 HTTP request 向遠端 web server 請求執行一項服務。原先分層架構中,外層 (Input port) 是採用 controller 的方式呼叫內部核心的商業邏輯。在方法二中,直接在 script 裡面呼叫 controller 即可完成相同任務。這也顯示一個好的架構,不管是提供遠端的 HTTP request 或者是 job request,對於業務邏輯都不會產生任何影響,業務邏輯仍然保持在核心與圓的中心,不受外層呼叫端的影響。

比較

優點:

cron job 本身是 light weight

可以透過呼叫 API endpoint 執行 cron job 的任務

缺點:

無法得知執行結果

heavy cron job 跟一般的 http request 混雜,有可能因為 cron job 的執行影響 web server 的效能

實作細節

Cron job:

apiVersion: batch/v1
kind: CronJob
metadata:
name: my-heavy-job
spec:
schedule: "30 18 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 60
template:
spec:
containers:
- name: my-heavy-job
image: image.example.com/my-heavy-job
resources:
requests:
cpu: 1G
memory: 512Mi
limits:
memory: 512Mi
command:
- sh
- -c
- npm run runMyJob

package.json

{
"scripts": {
"runMyJob": "ts-node -r tsconfig-paths/register script/myJob.ts",
}
}

問題

我們發現在測試環境上,這個 job 並沒有成功的開啟,以下是蒐集到的 error log:

此外,我們在本地的開發環境都可以正確地執行無誤,因此無法在本地開發端重現這個問題。

解決方法

從上面的 log 看起來,process 並沒有辦法在測試環境上運行。

由於是 memory 的問題,我們還是嘗試在本地端執行此程式,以下的截圖是透過 Mac Activity Monitor 所產生的 dashboard:

理論上應該只有一個 node process 不是嗎?為何會有兩個 processes,這很詭異。另外,引起我們特別注意是其中一個 node process 居然使用了 1.36 GB 的記憶體,出乎我們的意外。

我們注意到我們呼叫 node.js 的方式似乎有問題,我們將 ts-node 改變成 node:

{
"scripts": {
"runMyJob": "node -r tsconfig-paths/register script/myJob.ts",
}
}

下面是透過 node 執行產生的記憶體大小:

結論一

我們在 cron job 宣告只有512 MB的記憶體,但實際透過 ts-node 跑起來的 process 需要超過1 GB 的記憶體,只要把執行命令修改成 node 即可解決記憶體大小的問題。

問題二

正當我們以為一切都完成時,過了一陣子再回來觀察,我們發現 job 還是沒有完成。檢視 job 最後的 status 我們可以發現,job 執行時間超時被強迫停止執行。

我們回過頭檢視我們的 yaml,我們發現了一件有趣的宣告:

activeDeadlineSeconds: 60

以下是從文件節錄出來:

Another way to terminate a Job is by setting an active deadline. Do this by setting the .spec.activeDeadlineSeconds field of the Job to a number of seconds. The activeDeadlineSeconds applies to the duration of the job, no matter how many Pods are created. Once a Job reaches activeDeadlineSeconds, all of its running Pods are terminated and the Job status will become type: Failed with reason: DeadlineExceeded.

結論二

當我們將執行時間延長到合適的時間後,cron job 就可以正確無誤地執行完了。

Lesson Learned

  • 很多時候在本地開發端執行沒有問題,但是到了測試環境會發現一些無法預期的結果。為了確保結果是正確的,需要在 CI pipeline 的各環境(Beta / Staging / Production)確認結果符合預期。
  • 在 production code 不應該使用 ts-node,它消耗非常大量的記憶體。
  • 透過 node 執行可以減少一個 process。
  • 官網或者 best practices 可以作為一個不錯的技術參考。

補充

透過 node command 的方式而不是透過 yarn 或者 npm 的方式執行 node.js 可以減少一個額外的 process,以下為原文的說明:

When creating an image, you can bypass the package.json's start command and bake it directly into the image itself. First off this reduces the number of processes running inside of your container. Secondly it causes exit signals such as SIGTERM and SIGINT to be received by the Node.js process instead of npm swallowing them.

CMD ["node","index.js"]

https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#cmd

-max_old_space_size 可以用來限制 process 的 heap size,在本地端測試時我們透過這個參數限制 process 的 heap size。

kubectl create job — from=cronjob/{cron-job-name} manual — namespace={name-space}

如果採用 alpine 的 container image 時,ps 的指令很多參數皆無法支援 (BusyBox)

如何在本地端重現問題

在 debug 時,如果能夠找到重現問題的方法,才能有效率地找出解決方法。透過環境變數的設定 export NODE_OPTIONS= — max-old-space-size=512,然後執行 yarn runMyJob 的方式,我們可以在本地端重現問題,接下來就可以開始解謎了。

Reference

原文出處: https://www.notion.so/robert-yen/K8S-Cron-Job-29eaaa79b4a54c8a819490e84e195746

--

--