如何在容器中執行多條指令並能優雅退出
本文主要圍繞k8s command展開討論。(deployment.spec.template.spec.containers[n].command)
主要聊聊平臺在接入使用者業務時,如何保證滿足業務基本需求情況下增強平臺易用性。
最初是由bash啟動程序引起的業務程序無法接收sigterm優雅退出問題。解決過程中逐漸迴歸為如何在k8s command定義多條指令
@
目錄原生K8S-Command規範
填寫格式
field | type | comment |
---|---|---|
container.command | []string | 對應Dockerfile中Entrypoint指令欄位 |
container.args | []string | 對應Dockerfile中Cmd欄位 |
生效規則:
填寫command
時,command[0]
為首啟動命令執行檔案,command[1:] 及 args[:]
均為啟動引數。
未填寫command
時,args[0]
為首啟動命令執行檔案,args[1:]
為啟動引數。
例項(pod)生命週期
建立前
生產環境中我們一般不會單獨建立pod,而是利用kube-controller-manager的元件deployment、daemonSet等API來管控例項,其控制迴圈功能可自動部署、自動恢復,將任務狀態永遠調整向期望狀態。
例如
- 使用者宣告deployment.spec(期望例項模板) 及 replicas(例項數)交給k8s;
- 在deploymentController部分的控制邏輯中,將生成ReplicasSet;
- ReplicasSetController監聽資源處理,生成Pod;
- Pod被kube-scheduler監聽處理,為其分配合適的node;
- kubelet(此元件安裝在slave node上)監聽到pod繫結資訊,在node上例項化pod資訊。
建立
- 建立sanbox容器
- 拉取映象並建立init容器
- 建立普通容器 (拉取映象,建立容器,啟動首啟動程序,執行postStart)
當init容器執行完成退出後,啟動所有普通容器。根據liveness
readiness
配置情況探測並確定容器是否ready。所有容器ready時pod狀態更新為Ready。
建立普通容器
code位於pkg/kubelet/kuberuntime/kuberuntime_cotainer.go
的 startContainer
函式
// Step 1: pull the image.
// Step 2: create the container.
// Step 3: start the container.
err = m.runtimeService.StartContainer(containerID)
// Step 4: execute the post start hook.
if container.Lifecycle != nil && container.Lifecycle.PostStart != nil {
kubeContainerID := kubecontainer.ContainerID{
Type: m.runtimeName,
ID: containerID,
}
msg, handlerErr := m.runner.Run(kubeContainerID, pod, container, container.Lifecycle.PostStart)
注意這裡step3,4: 先StartContainer(啟動首啟動程序-即上面的command、args資訊);然後在向容器傳送postStart指令,注意此處postStart。
(這裡著重看postStart 是由於 有用postStart來實現容器內自定義多程序的想法)
runner.Run()呼叫處為
func (hr *HandlerRunner) Run(containerID kubecontainer.ContainerID, pod *v1.Pod, container *v1.Container, handler *v1.Handler) (string, error) {}
Run()中將呼叫RunInContainer
func (m *kubeGenericRuntimeManager) RunInContainer(id kubecontainer.ContainerID, cmd []string, timeout time.Duration) ([]byte, error) {
stdout, stderr, err := m.runtimeService.ExecSync(id.ID, cmd, timeout)
return append(stdout, stderr...), err
}
ExecSync
函式為
func (r *RemoteRuntimeService) ExecSync(containerID string, cmd []string, timeout time.Duration) (stdout []byte, stderr []byte, err error) {
...
resp, err := r.runtimeClient.ExecSync(ctx, req)
if resp.ExitCode != 0 {
err = utilexec.CodeExitError{
Err: fmt.Errorf("command '%s' exited with %d: %s", strings.Join(cmd, " "), resp.ExitCode, resp.Stderr),
Code: int(resp.ExitCode),
}
}
}
return resp.Stdout, resp.Stderr, err
以上可得
- 容器首啟動命令 與 postStart 先後發起,但非同步執行。
- postStart 命令呼叫介面建立與執行容器session並執行指令。 - 容器必須為執行態,postStart才能執行成功。
- postStart本身同步執行,等待到
exitCode=0
後才退出建立容器函式,之後容器才可進行running和Ready判斷。
建立後
容器正常啟動後,使用docker exec contaienrID bash
進入容器後,使用ps
命令,一般有兩個特殊程序:
- 1號程序 為容器首啟動程序,其餘程序基本都是首啟動程序的子孫程序。
- 0號程序 為1號程序的父程序,也為
docker exec....
攜帶指令的父程序(即從外部向running容器內發起的指令)。
整個程序檢視與所在宿主機隔離。
簡單瞭解下容器pidNamespace隔離
容器呼叫最終是建立一個特殊程序,如下
//此處只放本篇要聊的巨集,實際涉及隔離的巨集很多
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
如上,呼叫clone
函式並傳遞CLONE_NEWPID
巨集,詳見 clone() 。
clone函式是作為建立程序的系統呼叫,所以呼叫此函式實際上也是建立一個程序,加了CLONE_NEWPID
後此程序擁有獨立的程序檢視,且在檢視內PID=1
退出
發起pod退出指令後,pod DeleteTimestap被置位,進入Terminating態。
kubelet呼叫容器執行時發起刪除容器請求。containerd-shim
將向容器首程序傳送SIGTERM
訊號,等待10s(預設可改)後傳送SIGKILL
訊號。中間的等待時間給使用者提供了優雅退出(graceful stop)機制。應用內可捕獲SIGTERM
後執行一些清理資源操作。
這裡有兩個問題需要注意:
- 全程只看到給1號程序傳送訊號,但實際上現象是容器退出後相關程序會全部消失
查閱資料後,瞭解到由於PID=1
程序的特殊性,1號程序退出後,由其而生的PID-namespace
被銷燬,核心將向該namespace下所有子程序傳送SIGKILL
訊號。
注意這裡 子程序們是直接被kill的,不存在優雅結束的機會。 - 程序被kill後,如何被回收
dockerDaemon
發起建立容器請求,由containerd
接收並建立containerd-shim
,containerd-shim
即上面提到的0號程序。所以實際的建立容器、容器內執行指令等都是此程序在做。 同時,containerd-shim
具有回收殭屍程序的功能,容器1號程序退出後,核心清理其下子孫程序,這些子孫程序被containerd-shim
收養並清理。
注意:如果1號程序不被Kill,那麼其下程序如果有殭屍程序,是無法被處理的。所以使用者開發的容器首程序要注意回收退出程序。
所有容器清理後,pod刪除。
(pod刪除過程也包含preStop的執行等,本篇暫時把重點放在容器上)
初版設計
如上,正常使用中容器首啟動程序應為單條指令,然後程序可接收SIGTERM
訊號優雅退出。
但在使用中,現有並不滿足使用者使用習慣
- 形為
cd /home/work/bin && npm run start
的指令,包含多條指令並順序執行。 - 需要在容器啟動crond程序
crond && /home/work/hello.py
,多條指令但不必順序執行。
為提高易用性,我們後臺通過bash -c
統一包裹命令,使用者在終端測試OK的命令可以直接交給平臺。
暴露問題及原因
使用者反映,每次發版過程中,pod會在Terminating狀態停留很久。而且配置在程序內的SIGTERM
處理並未生效(不是preStop)。
(這裡由於deployment滾動更新時,舊版本可刪除pod會被立刻置位DeleteTimestamp,所以退出慢並不影響更新速度。)
原因在於bash程序。 bash程序會接收SIGTERM
訊號,但並不會傳遞訊號給業務程序,直到等待超時時間後收到SIGKILL
訊號而退出。這裡說明下,普通bash程序收到SIGTERM
會退出,可能是由於容器首啟動程序執行預設開啟tty,這裡不確定,有清楚的同學借一步說話。
利用postStart
例項(pod)生命週期 的 建立 部分有提到postStart為外部在容器內發起的程序,可用來在容器啟動後向容器內發起,deploymentYaml配置如下:
command:
/home/work/hello.py
lifecycle:
postStart:
exec:
command:
- /bin/bash
- -c
- crond
如上,容器內多程序可實現。但需注意postStart
不可為前臺程序,並且必須在啟動超時時間內執行完成並正常退出,否則將影響pod的正常啟動。
但是postStart方式僅可在 業務程序與postStart程序不必順序執行時使用,依舊無法解形如 cd /home/work/bin && npm run start
的指令執行問題,由此引入init程序。
引入Init程序
docker原生提供init開關,可自定義是否引入init程序。在指定init後,將init程式碼嵌入容器中,並作為首啟動程序,特點如下:
- 作為容器1號程序,並建立使用者定義的業務程序
- 預設將訊號傳遞給子程序,也支援更多傳遞方式
- 監聽子程序退出並回收
- 跟隨最初建立的業務程序的退出而退出
如果使用init
的預設功能,程序退出行為為:
正常情況下刪除容器,init程序收到SIGTERM
訊號後,會向子程序傳遞此訊號。並等待程序退出後退出,從而容器退出,容器空間清理。
問題及解決
但是init啟動業務命令的規則k8s啟動一致,正常僅支援一條指令。如果要支援普通的shell指令,還是要用bash -c包裹。此時問題轉化為:
- init傳遞
SIGTERM
訊號給bash而不是業務程序。 - 非1號程序的bash收到SIGTERM會立即退出進而引起init退出,init退出即容器退出。
解決
- init 可配置
TINI_KILL_PROCESS_GROUP
,配置後,SIGTREM
訊號將傳遞給子程序所在程序組的所有程序(即由bash而生的程序可收到訊號)。 - bash 通過
-i
引數可開啟互動模式,開啟後bash收到sigterm不作為。
如上,容器開啟init,設定環境變數TINI_KILL_PROCESS_GROUP
,並使用bash -ic $command
格式啟動業務程序,即可使容器首程序命令執行更加自由,並不會影響訊號接收。
例如開啟init時,啟動命令["bash", "-ic", "cd . && sleep 10d"]
,此時程序檢視為:
正常啟動時,init作為1號程序,bash程序作為1號子程序,業務程序又作為bash程序的子程序
容器正常退出時,init收到SIGTERM
訊號,傳遞訊號給其子程序(6號)所在程序組的所有程序(6和16),bash處於互動模式忽略訊號不作為, 業務容器接受SIGTERM
訊號,處理後退出,bash緊隨業務程序退出。
容器異常退出時,業務程序(16)異常退出,bash緊隨業務程序退出。 init程序接受到子程序(6號bash)退出訊號SIGCHILD,退出容器。
k8s支援init
走到上一步,基本算解決了使用者易用性並保證業務正常接收訊號。但k8s目前還未提供init
開關引數。這裡提供兩種方案:
全域性使用
可在 /etc/docker/daemon.json
檔案中新增:
{
"init": true,
}
並在啟動容器時新增TINI_KILL_PROCESS_GROUP
環境變數。 即k8s建立的所有容器都將開啟init
。
開關模式
需要修改K8s程式碼,最終決定使用container.Env來設定init開關,原因:
annotation和label均為pod級別,而pod下支援多個容器,全域性設定不夠靈活。故寫入環境變數,作為container級別的配置。
(理想狀態是將 init 作為pod.spec.containers[n].init欄位交由使用者配置)
注意: (如果有同學想用label或annotation做init標記,需要注意程式碼修改比env多一些,因為在構造容器config時,label和annotation不會繼承pod的,而env是會完整複製pod內定義的)
程式碼修改比較簡單,在pkg/kubelet/dockershim/docker_container.go
檔案中新增
init := false
for i, _ := range config.Envs {
if config.Envs[i].Key == "CONTAINER_S_INIT" {
init = true
}
}
createConfig := dockertypes.ContainerCreateConfig{
... // 一些容器引數的設定
HostConfig: &dockercontainer.HostConfig{
...
Init: &init,
},
}
END
有執行多條指令的需求的使用者可使用bash -ic
包裹業務指令,並在容器的Env中新增:
CONTAINER_S_INIT = true
TINI_KILL_PROCESS_GROUP = true
如此:
- bash所帶指令正常啟動
- pod退出時業務程序可處理
SIGTERM
後很快完成容器退出