1. 程式人生 > 其它 >如何在容器中執行多條指令並能優雅退出

如何在容器中執行多條指令並能優雅退出

本文主要圍繞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來管控例項,其控制迴圈功能可自動部署、自動恢復,將任務狀態永遠調整向期望狀態。
例如

  1. 使用者宣告deployment.spec(期望例項模板) 及 replicas(例項數)交給k8s;
  2. 在deploymentController部分的控制邏輯中,將生成ReplicasSet;
  3. ReplicasSetController監聽資源處理,生成Pod;
  4. Pod被kube-scheduler監聽處理,為其分配合適的node;
  5. kubelet(此元件安裝在slave node上)監聽到pod繫結資訊,在node上例項化pod資訊。

建立

  1. 建立sanbox容器
  2. 拉取映象並建立init容器
  3. 建立普通容器 (拉取映象,建立容器,啟動首啟動程序,執行postStart)
    當init容器執行完成退出後,啟動所有普通容器。根據liveness
    readiness配置情況探測並確定容器是否ready。所有容器ready時pod狀態更新為Ready。

建立普通容器
code位於pkg/kubelet/kuberuntime/kuberuntime_cotainer.gostartContainer函式

// 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	

以上可得

  1. 容器首啟動命令 與 postStart 先後發起,但非同步執行。
  2. postStart 命令呼叫介面建立與執行容器session並執行指令。 - 容器必須為執行態,postStart才能執行成功。
  3. 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. 全程只看到給1號程序傳送訊號,但實際上現象是容器退出後相關程序會全部消失
    查閱資料後,瞭解到由於PID=1程序的特殊性,1號程序退出後,由其而生的PID-namespace被銷燬,核心將向該namespace下所有子程序傳送SIGKILL訊號。
    注意這裡 子程序們是直接被kill的,不存在優雅結束的機會。
  2. 程序被kill後,如何被回收
    dockerDaemon發起建立容器請求,由containerd接收並建立containerd-shimcontainerd-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包裹。此時問題轉化為:

  1. init傳遞SIGTERM訊號給bash而不是業務程序。
  2. 非1號程序的bash收到SIGTERM會立即退出進而引起init退出,init退出即容器退出。

解決

  1. init 可配置 TINI_KILL_PROCESS_GROUP ,配置後,SIGTREM訊號將傳遞給子程序所在程序組的所有程序(即由bash而生的程序可收到訊號)。
  2. 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後很快完成容器退出