有一類作業顯然不滿足這樣的條件,這就是“離線業務”,或者叫作 Batch Job(計算業務)。
這 種業務在計算完成后就直接退出了,而此時如果你依然用 Deployment 來管理這種業務的話,就會 發現 Pod 會在計算結束后退出,然后被 Deployment Controller 不斷地重啟;而像“滾動更 新”這樣的編排功能,更無從談起了。 所以,早在 Borg 項目中,Google 就已經對作業進行了分類處理,提出了 LRS(Long Running Service)和 Batch Jobs 兩種作業形態,對它們進行“分別管理”和“混合調度”。
不過,在 2015 年 Borg 論文剛剛發布的時候,Kubernetes 項目並不支持對 Batch Job 的管理。直 到 v1.4 版本之后,社區才逐步設計出了一個用來描述離線業務的 API 對象,它的名字就是:Job。
Job API 對象的定義非常簡單,我來舉個例子,如下所示:
job.yaml
apiVersion: batch/v1 kind: Job metadata: name: pi spec: template: spec: containers: - name: pi image: resouer/ubuntu-bc command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "] restartPolicy: Never backoffLimit: 4
此時,相信你對 Kubernetes 的 API 對象已經不再陌生了。在這個 Job 的 YAML 文件里,你肯定一 眼就會看到一位“老熟人”:Pod 模板,即 spec.template 字段。
在這個 Pod 模板中,我定義了一個 Ubuntu 鏡像的容器(准確地說,是一個安裝了 bc 命令的 Ubuntu 鏡像),它運行的程序是:
echo "scale=10000; 4*a(1)" | bc -l
其中,bc 命令是 Linux 里的“計算器”;-l 表示,我現在要使用標准數學庫;而 a(1),則是調用數 學庫中的 arctangent 函數,計算 atan(1)。這是什么意思呢?
中學知識告訴我們:tan(π/4) = 1。所以,4*atan(1)正好就是π,也就是 3.1415926…。
所以,這其實就是一個計算π值的容器。而通過 scale=10000,我指定了輸出的小數點后的位數是 10000。
在我的計算機上,這個計算大概用時 1 分 54 秒。 但是,跟其他控制器不同的是,Job 對象並不要求你定義一個 spec.selector 來描述要控制哪些 Pod。具體原因,我馬上會講解到。 現在,我們就可以創建這個 Job 了:
kubectl create -f job.yaml
在成功創建后,我們來查看一下這個 Job 對象,如下所示:
$ kubectl describe jobs/pi Name: pi Namespace: default Selector: controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495 Labels: controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495 job-name=pi Annotations: <none> Parallelism: 1 Completions: 1 .. Pods Statuses: 0 Running / 1 Succeeded / 0 Failed Pod Template: Labels: controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495 job-name=pi Containers: ... Volumes: <none> Events: FirstSeen LastSeen Count From SubobjectPath Type Reason Message --------- -------- ----- ---- ------------- -------- ------ ------- 1m 1m 1 {job-controller } Normal SuccessfulCreate Created pod: pi-rq5rl
可以看到,這個 Job 對象在創建后,它的 Pod 模板,被自動加上了一個 controller-uid=< 一個隨 機字符串 > 這樣的 Label。
而這個 Job 對象本身,則被自動加上了這個 Label 對應的 Selector,從 而 保證了 Job 與它所管理的 Pod 之間的匹配關系。
而 Job Controller 之所以要使用這種攜帶了 UID 的 Label,就是為了避免不同 Job 對象所管理的 Pod 發生重合。需要注意的是,這種自動生成的 Label 對用戶來說並不友好,所以不太適合推廣到 Deployment 等長作業編排對象上。 接下來,我們可以看到這個 Job 創建的 Pod 進入了 Running 狀態,這意味着它正在計算 Pi 的值。
kubectl get pods NAME READY STATUS RESTARTS AGE pi-rq5rl 1/1 Running 0 10s
而幾分鍾后計算結束,這個 Pod 就會進入 Completed 狀態:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-rq5rl 0/1 Completed 0 4m
這也是我們需要在 Pod 模板中定義 restartPolicy=Never 的原因:離線計算的 Pod 永遠都不應該 被重啟,否則它們會再重新計算一遍。
此時,我們通過 kubectl logs 查看一下這個 Pod 的日志,就可以看到計算得到的 Pi 值已經被打印 了出來:
kubectl logs pi-rq5rl 3.141592653589793238462643383279...
這時候,你一定會想到這樣一個問題,如果這個離線作業失敗了要怎么辦?
比如,我們在這個例子中定義了 restartPolicy=Never,那么離線作業失敗后 Job Controller 就 會不斷地嘗試創建一個新 Pod,如下所示:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-55h89 0/1 ContainerCreating 0 2s pi-tqbcz 0/1 Error 0 5s
可以看到,這時候會不斷地有新 Pod 被創建出來。 當然,這個嘗試肯定不能無限進行下去。
所以,我們就在 Job 對象的 spec.backoffLimit 字段里定 義了重試次數為 4(即,backoffLimit=4),而這個字段的默認值是 6。 需要注意的是,Job Controller 重新創建 Pod 的間隔是呈指數增加的,即下一次重新創建 Pod 的 動作會分別發生在 10 s、20 s、40 s …后。 而如果你定義的 restartPolicy=OnFailure,那么離線作業失敗后,Job Controller 就不會去嘗試 創建新的 Pod。
但是,它會不斷地嘗試重啟 Pod 里的容器。這也正好對應了 restartPolicy 的含義 (你也可以借此機會再回顧一下第 15 篇文章《深入解析 Pod 對象(二):使用進階》中的相關內 容)。 如前所述,當一個 Job 的 Pod 運行結束后,它會進入 Completed 狀態。但是,如果這個 Pod 因 為某種原因一直不肯結束呢? 在 Job 的 API 對象里,有一個 spec.activeDeadlineSeconds 字段可以設置最長運行時間,比如:
spec: backoffLimit: 5 activeDeadlineSeconds: 100
一旦運行超過了 100 s,這個 Job 的所有 Pod 都會被終止。並且,你可以在 Pod 的狀態里看到終 止的原因是 reason: DeadlineExceeded。
以上,就是一個 Job API 對象最主要的概念和用法了。不過,離線業務之所以被稱為 Batch Job, 當然是因為它們可以以“Batch”,也就是並行的方式去運行。
接下來,我就來為你講解一下Job Controller 對並行作業的控制方法。 在 Job 對象中,負責並行控制的參數有兩個: 1. spec.parallelism,它定義的是一個 Job 在任意時間最多可以啟動多少個 Pod 同時運行; 2. spec.completions,它定義的是 Job 至少要完成的 Pod 數目,即 Job 的最小完成數。
這兩個參數聽起來有點兒抽象,所以我准備了一個例子來幫助你理解。 現在,我在之前計算 Pi 值的 Job 里,添加這兩個參數
apiVersion: batch/v1 kind: Job metadata: name: pi spec: parallelism: 2 completions: 4 template: spec: containers: - name: pi image: resouer/ubuntu-bc command: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "] restartPolicy: Never backoffLimit: 4
這樣,我們就指定了這個 Job 最大的並行數是 2,而最小的完成數是 4。 接下來,我們來創建這個 Job 對象:
kubectl create -f job.yaml
可以看到,這個 Job 其實也維護了兩個狀態字段,即 DESIRED 和 SUCCESSFUL,如下所示:
kubectl get job NAME DESIRED SUCCESSFUL AGE pi 4 0 3s
其中,DESIRED 的值,正是 completions 定義的最小完成數。 然后,我們可以看到,這個 Job 首先創建了兩個並行運行的 Pod 來計算 Pi:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-5mt88 1/1 Running 0 6s pi-gmcq5 1/1 Running 0 6s
而在 40 s 后,這兩個 Pod 相繼完成計算。 這時我們可以看到,每當有一個 Pod 完成計算進入 Completed 狀態時,就會有一個新的 Pod 被自 動創建出來,並且快速地從 Pending 狀態進入到 ContainerCreating 狀態:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-gmcq5 0/1 Completed 0 40s pi-84ww8 0/1 Pending 0 0s pi-5mt88 0/1 Completed 0 41s pi-62rbt 0/1 Pending 0 0s $ kubectl get pods NAME READY STATUS RESTARTS AGE pi-gmcq5 0/1 Completed 0 40s pi-84ww8 0/1 ContainerCreating 0 0s pi-5mt88 0/1 Completed 0 41s pi-62rbt 0/1 ContainerCreating 0 0s
緊接着,Job Controller 第二次創建出來的兩個並行的 Pod 也進入了 Running 狀態:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-5mt88 0/1 Completed 0 54s pi-62rbt 1/1 Running 0 13s pi-84ww8 1/1 Running 0 14s pi-gmcq5 0/1 Completed 0 54s
最終,后面創建的這兩個 Pod 也完成了計算,進入了 Completed 狀態。 這時,由於所有的 Pod 均已經成功退出,這個 Job 也就執行完了,所以你會看到它的 SUCCESSFUL 字段的值變成了 4:
kubectl get pods NAME READY STATUS RESTARTS AGE pi-5mt88 0/1 Completed 0 5m pi-62rbt 0/1 Completed 0 4m pi-84ww8 0/1 Completed 0 4m pi-gmcq5 0/1 Completed 0 5m $ kubectl get job NAME DESIRED SUCCESSFUL AGE pi 4 4 5m
通過上述 Job 的 DESIRED 和 SUCCESSFUL 字段的關系,我們就可以很容易地理解Job Controller 的工作原理了。
首先,Job Controller 控制的對象,直接就是 Pod。 其次,Job Controller 在控制循環中進行的調諧(Reconcile)操作,是根據實際在 Running 狀態 Pod 的數目、已經成功退出的 Pod 的數目,以及 parallelism、completions 參數的值共同計算出 在這個周期里,應該創建或者刪除的 Pod 數目,然后調用 Kubernetes API 來執行這個操作。 以創建 Pod 為例。在上面計算 Pi 值的這個例子中,當 Job 一開始創建出來時,實際處於 Running 狀態的 Pod 數目 =0,已經成功退出的 Pod 數目 =0,而用戶定義的 completions,也就是最終用 戶需要的 Pod 數目 =4。
所以,在這個時刻,需要創建的 Pod 數目 = 最終需要的 Pod 數目 - 實際在 Running 狀態 Pod 數 目 - 已經成功退出的 Pod 數目 = 4 - 0 - 0= 4。也就是說,Job Controller 需要創建 4 個 Pod 來 糾正這個不一致狀態。 可是,我們又定義了這個 Job 的 parallelism=2。也就是說,我們規定了每次並發創建的 Pod 個數 不能超過 2 個。
所以,Job Controller 會對前面的計算結果做一個修正,修正后的期望創建的 Pod 數目應該是:2 個。
這時候,Job Controller 就會並發地向 kube-apiserver 發起兩個創建 Pod 的請求。 類似地,如果在這次調諧周期里,Job Controller 發現實際在 Running 狀態的 Pod 數目,比 parallelism 還大,那么它就會刪除一些 Pod,使兩者相等。 綜上所述,Job Controller 實際上控制了,作業執行的並行度,以及總共需要完成的任務數這兩個 重要參數。而在實際使用時,你需要根據作業的特性,來決定並行度(parallelism)和任務數 (completions)的合理取值。
接下來,我再和你分享三種常用的、使用 Job 對象的方法。
外部管理器 +Job 模板
第一種用法,也是最簡單粗暴的用法:外部管理器 +Job 模板。 這種模式的特定用法是:把 Job 的 YAML 文件定義為一個“模板”,然后用一個外部工具控制這 些“模板”來生成 Job。這時,Job 的定義方式如下所示:
apiVersion: batch/v1 kind: Job metadata: name: process-item-$ITEM labels: jobgroup: jobexample spec: template: metadata: name: jobexample labels: jobgroup: jobexample spec: containers: - name: c image: busybox command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"] restartPolicy: Never
可以看到,我們在這個 Job 的 YAML 里,定義了 $ITEM 這樣的“變量”。
所以,在控制這種 Job 時,我們只要注意如下兩個方面即可:
- 1. 創建 Job 時,替換掉 $ITEM 這樣的變量;
- 2. 所有來自於同一個模板的 Job,都有一個 jobgroup: jobexample 標簽,也就是說這一組 Job 使 用這樣一個相同的標識。
而做到第一點非常簡單。比如,你可以通過這樣一句 shell 把 $ITEM 替換掉:
mkdir ./jobs for i in apple banana cherry do cat job-tmpl.yaml | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml done
這樣,一組來自於同一個模板的不同 Job 的 yaml 就生成了。接下來,你就可以通過一句 kubectl create 指令創建這些 Job 了
kubectl create -f ./jobs kubectl get pods -l jobgroup=jobexample NAME READY STATUS RESTARTS AGE process-item-apple-kixwv 0/1 Completed 0 4m process-item-banana-wrsf7 0/1 Completed 0 4m process-item-cherry-dnfu9 0/1 Completed 0 4m
這個模式看起來雖然很“傻”,但卻是 Kubernetes 社區里使用 Job 的一個很普遍的模式。
原因很簡單:大多數用戶在需要管理 Batch Job 的時候,都已經有了一套自己的方案,需要做的往 往就是集成工作。這時候,Kubernetes 項目對這些方案來說最有價值的,就是 Job 這個 API 對 象。所以,你只需要編寫一個外部工具(等同於我們這里的 for 循環)來管理這些 Job 即可。 這種模式最典型的應用,就是 TensorFlow 社區的 KubeFlow 項目。 很容易理解,在這種模式下使用 Job 對象,completions 和 parallelism 這兩個字段都應該使用默 認值 1,而不應該由我們自行設置。而作業 Pod 的並行控制,應該完全交由外部工具來進行管理 (比如,KubeFlow)。
第二種用法:擁有固定任務數目的並行 Job
這種模式下,我只關心最后是否有指定數目(spec.completions)個任務成功退出。至於執行時的 並行度是多少,我並不關心。 比如,我們這個計算 Pi 值的例子,就是這樣一個典型的、擁有固定任務數目(completions=4)的 應用場景。 它的 parallelism 值是 2;或者,你可以干脆不指定 parallelism,直接使用默認的並行 度(即:1)。 此外,你還可以使用一個工作隊列(Work Queue)進行任務分發。這時,Job 的 YAML 文件定義 如下所示:
apiVersion: batch/v1 kind: Job metadata: name: job-wq-1 spec: completions: 8 parallelism: 2 template: metadata: name: job-wq-1 spec: containers: - name: c image: myrepo/job-wq-1 env: - name: BROKER_URL value: amqp://guest:guest@rabbitmq-service:5672 - name: QUEUE value: job1 restartPolicy: OnFailure
我們可以看到,它的 completions 的值是:8,這意味着我們總共要處理的任務數目是 8 個。也就 是說,總共會有 8 個任務會被逐一放入工作隊列里(你可以運行一個外部小程序作為生產者,來提 交任務)。
在這個實例中,我選擇充當工作隊列的是一個運行在 Kubernetes 里的 RabbitMQ。
所以,我們需 要在 Pod 模板里定義 BROKER_URL,來作為消費者。 所以,一旦你用 kubectl create 創建了這個 Job,它就會以並發度為 2 的方式,每兩個 Pod 一 組,創建出 8 個 Pod。每個 Pod 都會去連接 BROKER_URL,從 RabbitMQ 里讀取任務,然后各 自進行處理。這個 Pod 里的執行邏輯,我們可以用這樣一段偽代碼來表示:
/* job-wq-1 的偽代碼 */ queue := newQueue($BROKER_URL, $QUEUE) task := queue.Pop() process(task) exit
可以看到,每個 Pod 只需要將任務信息讀取出來,處理完成,然后退出即可。
而作為用戶,我只關 心最終一共有 8 個計算任務啟動並且退出,只要這個目標達到,我就認為整個 Job 處理完成了。
所 以說,這種用法,對應的就是“任務總數固定”的場景。
指定並行度
第三種用法,也是很常用的一個用法:指定並行度(parallelism),但不設置固定的 completions 的值。 此時,你就必須自己想辦法,來決定什么時候啟動新 Pod,什么時候 Job 才算執行完成。在這種情 況下,任務的總數是未知的,所以你不僅需要一個工作隊列來負責任務分發,還需要能夠判斷工作 隊列已經為空(即:所有的工作已經結束了)。 這時候,Job 的定義基本上沒變化,只不過是不再需要定義 completions 的值了而已:
apiVersion: batch/v1 kind: Job metadata: name: job-wq-2 spec: parallelism: 2 template: metadata: name: job-wq-2 spec: containers: - name: c image: gcr.io/myproject/job-wq-2 env: - name: BROKER_URL value: amqp://guest:guest@rabbitmq-service:5672 - name: QUEUE value: job2 restartPolicy: OnFailure
而對應的 Pod 的邏輯會稍微復雜一些,我可以用這樣一段偽代碼來描述:
/* job-wq-2 的偽代碼 */ for !queue.IsEmpty($BROKER_URL, $QUEUE) { task := queue.Pop() process(task) } print("Queue empty, exiting") exit
由於任務數目的總數不固定,所以每一個 Pod 必須能夠知道,自己什么時候可以退出。
比如,在這 個例子中,我簡單地以“隊列為空”,作為任務全部完成的標志。所以說,這種用法,對應的 是“任務總數不固定”的場景。 不過,在實際的應用中,你需要處理的條件往往會非常復雜。比如,任務完成后的輸出、每個任務 Pod 之間是不是有資源的競爭和協同等等。 所以,在今天這篇文章中,我就不再展開 Job 的用法了。
因為,在實際場景里,要么干脆就用第一 種用法來自己管理作業;要么,這些任務 Pod 之間的關系就不那么“單純”,甚至還是“有狀態應 用”(比如,任務的輸入 / 輸出是在持久化數據卷里)。在這種情況下,我在后面要重點講解的 Operator,加上 Job 對象一起,可能才能更好的滿足實際離線任務的編排需求。 最后,我再來和你分享一個非常有用的 Job 對象,叫作:CronJob。 顧名思義,CronJob 描述的,正是定時任務。它的 API 對象,如下所示:
apiVersion: batch/v1beta1 kind: CronJob metadata: name: hello spec: schedule: "*/1 * * * *" jobTemplate: spec: template: spec: containers: - name: hello image: busybox args: - /bin/sh - -c - date; echo Hello from the Kubernetes cluster restartPolicy: OnFailure
在這個 YAML 文件中,最重要的關鍵詞就是jobTemplate。看到它,你一定恍然大悟,原來 CronJob 是一個 Job 對象的控制器(Controller)! 沒錯,CronJob 與 Job 的關系,正如同 Deployment 與 Pod 的關系一樣。
CronJob 是一個專門用 來管理 Job 對象的控制器。只不過,它創建和刪除 Job 的依據,是 schedule 字段定義的、一個標 准的Unix Cron格式的表達式。 比如,"*/1 * * * *"。 這個 Cron 表達式里 */1 中的 * 表示從 0 開始,/ 表示“每”,1 表示偏移量。所以,它的意思就 是:從 0 開始,每 1 個時間單位執行一次。
那么,時間單位又是什么呢? Cron 表達式中的五個部分分別代表:分鍾、小時、日、月、星期。 所以,上面這句 Cron 表達式的意思是:從當前開始,每分鍾執行一次。 而這里要執行的內容,就是 jobTemplate 定義的 Job 了。 所以,這個 CronJob 對象在創建 1 分鍾后,就會有一個 Job 產生了,如下所示:
kubectl create -f ./cronjob.yaml cronjob "hello" created # 一分鍾后 kubectl get jobs NAME DESIRED SUCCESSFUL AGE hello-4111706356 1 1 2s
此時,CronJob 對象會記錄下這次 Job 執行的時間:
kubectl get cronjob hello NAME SCHEDULE SUSPEND ACTIVE LAST-SCHEDULE hello */1 * * * * False 0 Thu, 6 Sep 2018 14:34:00 -070
需要注意的是,由於定時任務的特殊性,很可能某個 Job 還沒有執行完,另外一個新 Job 就產生 了。這時候,你可以通過 spec.concurrencyPolicy 字段來定義具體的處理策略。
比如:
- 1. concurrencyPolicy=Allow,這也是默認情況,這意味着這些 Job 可以同時存在;
- 2. concurrencyPolicy=Forbid,這意味着不會創建新的 Pod,該創建周期被跳過;
- 3. concurrencyPolicy=Replace,這意味着新產生的 Job 會替換舊的、沒有執行完的 Job。
而如果某一次 Job 創建失敗,這次創建就會被標記為“miss”。當在指定的時間窗口內,miss 的數 目達到 100 時,那么 CronJob 會停止再創建這個 Job。
這個時間窗口,可以由 spec.startingDeadlineSeconds 字段指定。比如 startingDeadlineSeconds=200,意味着在過去 200 s 里,如果 miss 的數目達到了 100 次,那么 這個 Job 就不會被創建執行了。 總結 在今天這篇文章中,我主要和你分享了 Job 這個離線業務的編排方法,講解了 completions 和 parallelism 字段的含義,以及 Job Controller 的執行原理。
緊接着,我通過實例和你分享了 Job 對象三種常見的使用方法。但是,根據我在社區和生產環境中 的經驗,大多數情況下用戶還是更傾向於自己控制 Job 對象。所以,相比於這些固定的“模式”, 掌握 Job 的 API 對象,和它各個字段的准確含義會更加重要。