在kubernetes的實際生產實踐中,經常會看到pod內的容器因為內存使用超限被內核kill掉,使用kubectl命令查看pod,可以看到容器的退出原因是OOMKilled,退出碼是137。
文章導讀
cgroup簡介與使用
linux epoll原理分析
containerd代碼解析
kubelet代碼解析
使用event_control監聽oom事件
經過前面幾篇文章的鋪墊與遞進,本篇終于可以進入正題,已以下兩條主線進行分析。
- 容器的退出原因OOMKilled是如何經由containerd更新到kubelet,并最終更新到Pod的status中。
- 退出碼為何是137。
本文的分析思路是由下至上,先分析內核的oom是如何觸發的,再分析進程被kill掉后,退出碼是何時賦值的,最后再分析containerd-shim,containerd,kubelet是如何處理進程的oom狀態和退出碼。
場景再現
本次實驗基于4.19內核的centos的系統,kubernetes版本為1.23.1,kubelet的運行時配置為containerd,containerd版本為1.4,cgroup使用v1版本。
首先,編輯一個Pod的yaml文件,配置Pod的restartPolicy為Nerver,內存限額設置為50M,容器鏡像為mem_alloc:v1。該容器啟動后會不斷申請并使用內存,直到內存超限,被內核kill掉。
[root@localhost oom]# cat pod_oom.yaml
apiVersion: v1
kind: Pod
metadata:
name: lugl-oom-test
spec:
restartPolicy: Never
containers:
- name: oom-test
image: docker.io/registry/mem_alloc:v1
imagePullPolicy: IfNotPresent
resources:
limits:
memory: "50Mi"
使用kubectl create -f pod_oom.yaml創建該pod,過一會兒使用kubectl查看pod的狀態(刪除掉不相關的字段信息)。可以看到容器的退出原因(reason)為OOMKilled,退出碼(exitCode)為137。其中還有個字段lastState可以記錄上一次的容器的狀態信息,這里為了簡化分析,忽略該字段。
[root@localhost oom]# kubectl get pods lugl-oom-test -o yaml
apiVersion: v1
kind: Pod
spec:
containers:
- image: docker.io/registry/mem_alloc:v1
imagePullPolicy: IfNotPresent
name: oom-test
resources:
limits:
memory: 50Mi
requests:
memory: 50Mi
restartPolicy: Never
status:
conditions:
- lastProbeTime: null
lastTransitionTime: "2022-02-20T02:10:30Z"
status: "True"
type: Initialized
containerStatuses:
- containerID: containerd://388f01cfb6b8ba2817ff85ef8e72c654f866200d96123a623a73e0304e4934cf
image: docker.io/registry/mem_alloc:v1
imageID: sha256:3bd194272e76fa429b1c5df19d91bcf47eacc1ed07e3afe378ec3d7b49524ef0
lastState: {}
name: oom-test
ready: false
restartCount: 0
started: false
state:
terminated:
containerID: containerd://388f01cfb6b8ba2817ff85ef8e72c654f866200d96123a623a73e0304e4934cf
exitCode: 137
finishedAt: "2022-02-20T02:10:42Z"
reason: OOMKilled
startedAt: "2022-02-20T02:10:31Z"
oom機制和信號處理
(1) 本環境中的PAGESIZE為4K,mem_alloc程序在運行中,會申請內存并使用,所以會不斷觸發缺頁異常,內核在每次申請內存頁時都會去比較該cgroup進程的內存使用量 是否超過了該cgroup內設置的閾值(memory.limit_in_bytes),如果超過閾值,并且在該cgroup內也沒法通過內存回收釋放足夠的內存,則內核會在該cgroup內選擇一個進程kill掉(oom_badness)。
(2)當觸發oom時,內核會向進程發送SIGKILL信號,這里的發送信號是擬人化的寫法,方便人去理解,但實際的內核發送信號僅僅是對進程數據結構(task_struct)中的信號相關字段的修改,如將SIGKILL這個sig添加到該進程的信號處理隊列中,這樣就算內核完成了信號的發送。
SIGKILL(對應信號值9)和SIGSTOP(對應信號值19)是兩個特權信號,即用戶不可以注冊這兩個信號的信號處理函數,由內核的默認函數進行處理。 SIGTERM(對應信號值15)是可以被注冊信號處理函數的。kill命令不加參數默認就是向進程發送SIGTERM信號。kubelet停止pod時,也是先發送SIGTERM信號,經過terminationGracePeriodSeconds時間后,如果pod沒有退出再發送SIGKILL信號。所以容器內的進程最好是注冊SIGTERM對應的信號處理函數,在進程退出的時候做一些資源清理的操作,減少異常的發生,如關閉遠端連接。容器內的init進程退出時,內核會向該init進程命名空間下的其他進程發送SIGKILL信號,更完善的做法是init進程攔截SIGTERM信號后,將SIGTERM轉發給子進程,這樣init和init的子進程都可以實現優雅退出,如docker的 --init參數就是使用一個專用的init進程接管用戶的程序。
下面就是mem_alloc進程因為oom被kil掉的堆棧信息。
# 設置以下關注函數
[root@node-135 tracing]# cat set_ftrace_filter
__send_signal
do_send_sig_info
__oom_kill_process
out_of_memory
mem_cgroup_out_of_memory
try_charge
# cat trace_pipe
=> __send_signal -- 向進程發送SIGKILL信號
=> do_send_sig_info
=> __oom_kill_process -- kill掉一些進程
=> oom_kill_process
=> out_of_memory -- 內存不足
=> mem_cgroup_out_of_memory
=> try_charge
=> mem_cgroup_try_charge -- cgroup內的內存是否充足
=> mem_cgroup_try_charge_delay
=> __handle_mm_fault
=> handle_mm_fault
=> __do_page_fault
=> do_page_fault
=> page_fault -- 缺頁異常
(3)內核修改了進程的task_struct,表明該進程有信號需要處理。用戶注冊的信號處理函數在用戶態,此時還在內核中執行異常處理流程,那么信號處理函數的執行時機是什么呢? 在返回到用戶態的時候是一個執行時機。信號處理的實現比較復雜,具體細節分析可以參見極客時間《趣談linux操作系統》中的信號處理章節,詳細介紹了信號處理函數執行的時機,以及執行完信號處理函數又是如何返回到正常的執行流程中的整個過程。
以下堆棧信息展示了mem_alloc程序的退出過程。
# 以下的函數堆棧是一個SIGKILL信號的處理
=> __send_signal -- 向父進程發送SIGCHILD信號
=> do_notify_parent
=> do_exit -- 在這個函數里會設置進程的退出碼
=> do_group_exit -- 這個函數的參數只有一個退出碼
=> get_signal -- 獲取需要處理的信號
=> do_signal
=> exit_to_usermode_loop -- 返回到用戶態
=> prepare_exit_to_usermode
=> swapgs_restore_regs_and_return_to_usermode
(4)正常的程序在退出時是調用exit系統調用,exit是調用do_exit函數,在(3)的堆棧中可以看到處理SIGKILL也會調用do_exit,區別在于正常的退出,錯誤碼是經過了處理的,而在處理SIGKILL信號時,是直接將信號的值賦值給了進程的退出碼(task_struct.exit_code)。
SYSCALL_DEFINE1(exit, int, error_code)
{
do_exit((error_code&0xff)<<8);
}
(5)使用$?查看程序的退出碼,這也上面的分析不一致,錯誤碼應該是9,還不是137,問題在哪里呢?
[root@localhost test]# echo 104857600 > memory.limit_in_bytes
[root@localhost test]# echo $$ > cgroup.procs
[root@localhost test]# /root/training/memory/oom/mem-alloc/mem_alloc 40000
Allocating,set to 40000 Mbytes
Killed
[root@localhost test]# echo $?
137
(6)重復(5)中的步驟,使用ftrace查看函數do_exit的參數值。從下面的trace_pipe中確實看到mem_alloc的退出碼確實是9,而不是137。這里直接說結論,在下面會再次驗證,bash進程是mem_alloc的父進程,在處理mem_alloc進程的返回值時,如果是9,說明是被SIGKILL掉的,所以將9加上一個偏移量128,結果就是137,這樣做可能是為了與linux系統標準的錯誤碼做一個區分吧。
# 設置kprobe
[root@localhost tracing]# echo 'p:myprobe do_exit exit_code=%di' > kprobe_events
[root@localhost tracing]# echo 1 > events/kprobes/myprobe/enable
# 設置過濾條件
[root@localhost tracing]# echo exit_code==9 > events/kprobes/myprobe/filter
[root@localhost tracing]# /root/training/memory/oom/mem-alloc/mem_alloc 40000
Allocating,set to 40000 Mbytes
Killed
#查看結果
[root@localhost tracing]# cat trace_pipe
mem_alloc-17077 [001] .... 875.147095: myprobe: (do_exit+0x0/0xc60) exit_code=0x9
containerd-shim 進程的處理
containerd-shim監聽cgroup內進程的oom事件與使用event_control監聽oom事件中的一致,這里不再贅述。
(1) 在Run函數中會監聽eventfd,如果epoll_wait返回,則說明該cgroup內有進程觸發了oom。
func (e *epoller) Run(ctx context.Context) {
var events [128]unix.EpollEvent
for {
select {
default:
n, err := unix.EpollWait(e.fd, events[:], -1)
for i := 0; i < n; i++ {
e.process(ctx, uintptr(events[i].Fd))
}
}
}
}
(2) process函數中會通過GRPC消息將TaskOOMEvent轉發到containerd中。
func (e *epoller) process(ctx context.Context, fd uintptr) {
if err := e.publisher.Publish(ctx, runtime.TaskOOMEventTopic, &eventstypes.TaskOOM{
ContainerID: i.id,
});
}
(3)在進程退出時,內核會向其父進程(containerd-shim)發送SIGCHILD信號。handleSignals會處理SIGCHILD信號,調用Reap,進而使用系統調用wait4拿到進程的退出碼。
// runtime/v2/shim/shim_unix.go
func handleSignals(ctx context.Context, logger *logrus.Entry, signals chan os.Signal) error {
for {
select {
case s := <-signals:
switch s {
case unix.SIGCHLD:
if err := reaper.Reap(); err != nil {
logger.WithError(err).Error("reap exit status")
}
}
}
}
(4)在containerd-shim啟動的時候,會啟動goroutine監聽容器進程的退出,checkProcess中會將容器id,進程id,退出碼,退出時間等信息通過GRPC消息轉發到containerd中。
func New(ctx context.Context, id string, publisher shim.Publisher, shutdown func()) (shim.Shim, error) {
if cgroups.Mode() == cgroups.Unified {
ep, err = oomv2.New(publisher)
} else {
ep, err = oomv1.New(publisher)
}
go ep.Run(ctx)
go s.processExits()
go s.forward(ctx, publisher)
}
func (s *service) checkProcesses(e runcC.Exit) {
for _, container := range s.containers {
for _, p := range container.All() {
p.SetExited(e.Status)
s.sendL(&eventstypes.TaskExit{
ContainerID: container.ID,
ID: p.ID(),
Pid: uint32(e.Pid),
ExitStatus: uint32(e.Status),
ExitedAt: p.ExitedAt(),
})
}
}
}
(5)回收子進程會調用reap函數,在exitStatus中,會判斷進程是否是被信號中斷的,如果是的話,則加上一個偏移量128,這里就驗證了上面說的結論,137 = 9 + 128。那么可以推斷bash中 $?也是同樣的處理。
func reap(wait bool) (exits []exit, err error) {
for {
pid, err := unix.Wait4(-1, &ws, flag, &rus)
exits = append(exits, exit{
Pid: pid,
Status: exitStatus(ws),
})
}
}
// sys/reaper/reaper_unix.go
const exitSignalOffset = 128
func exitStatus(status unix.WaitStatus) int {
if status.Signaled() {
return exitSignalOffset + int(status.Signal())
}
return status.ExitStatus()
}
// unix/syscall_linux.go
const (
mask = 0x7F
core = 0x80
exited = 0x00
stopped = 0x7F
shift = 8
)
func (w WaitStatus) Signaled() bool {
return w&mask != stopped && w&mask != exited
}
containerd的處理
(1)在handleEvent中會處理containerd-shim轉發過來的容器的事件信息,如果是TaskOOM,則調用UpdateSync更新容器的退出原因。
(2)如果是進程退出,則調用handleContainerExit,再調用UpdateSync更新容器的退出碼。
// handleEvent handles a containerd event.
func (em *eventMonitor) handleEvent(any interface{}) error {
switch e := any.(type) {
case *eventtypes.TaskExit:
logrus.Infof("TaskExit event %+v", e)
cntr, err := em.c.containerStore.Get(e.ID)
handleContainerExit(ctx, e, cntr);
case *eventtypes.TaskOOM:
logrus.Infof("TaskOOM event %+v", e)
// For TaskOOM, we only care which container it belongs to.
cntr, err := em.c.containerStore.Get(e.ContainerID)
err = cntr.Status.UpdateSync(func(status containerstore.Status) (containerstore.Status, error) {
status.Reason = oomExitReason
return status, nil
})
return nil
}
func handleContainerExit(ctx context.Context, e *eventtypes.TaskExit, cntr containerstore.Container) error {
err = cntr.Status.UpdateSync(func(status containerstore.Status) (containerstore.Status, error) {
if status.FinishedAt == 0 {
status.Pid = 0
status.FinishedAt = e.ExitedAt.UnixNano()
status.ExitCode = int32(e.ExitStatus)
}
return status, nil
})
}
(3)UpdateSync是containerd中用來更新容器存儲狀態的函數。
func (s *statusStorage) UpdateSync(u UpdateFunc) error {
newStatus, err := u(s.status)
data, err := newStatus.encode()
continuity.AtomicWriteFile(s.path, data, 0600)
s.status = newStatus
}
(4)containerd的處理就算完成了,接下來就等kubelet來獲取容器的狀態了。
kubelet的處理
kubelet的細節分析參見 kubelet代碼解析
(1)relist函數會定期執行,對比pod狀態的變化。
(2)通過computeEvent、updateEvents和generateEvents對比內存中的pod的狀態和從runtime接口獲取的實時的pod的狀態差異,從而可以推斷出pod內的容器發生了狀態變化。
func (g *GenericPLEG) Start() {
go wait.Until(g.relist, g.relistPeriod, wait.NeverStop)
}
func (g *GenericPLEG) relist() {
klog.V(5).InfoS("GenericPLEG: Relisting")
for pid := range g.podRecords {
oldPod := g.podRecords.getOld(pid)
pod := g.podRecords.getCurrent(pid)
// Get all containers in the old and the new pod.
allContainers := getContainersFromPods(oldPod, pod)
for _, container := range allContainers {
events := computeEvents(oldPod, pod, &container.ID)
for _, e := range events {
updateEvents(eventsByPodID, e)
}
}
}
}
(3)如因oom產生一個類型為ContainerDied的PodLifecycleEvent事件
func generateEvents(podID types.UID, cid string, oldState, newState plegContainerState) []*PodLifecycleEvent {
klog.V(4).InfoS("GenericPLEG", "podUID", podID, "containerID", cid, "oldState", oldState, "newState", newState)
switch newState {
case plegContainerRunning:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerStarted, Data: cid}}
case plegContainerExited:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerDied, Data: cid}}
case plegContainerUnknown:
return []*PodLifecycleEvent{{ID: podID, Type: ContainerChanged, Data: cid}}
}
(4)在syncLoopIteration中會處理該pleg事件,繼而調用Podworker的sync方法。
func (kl *Kubelet) syncLoopIteration(configCh <-chan kubetypes.PodUpdate, handler SyncHandler,
syncCh <-chan time.Time, housekeepingCh <-chan time.Time, plegCh <-chan *pleg.PodLifecycleEvent) bool {
select {
case e := <-plegCh:
handler.HandlePodSyncs([]*v1.Pod{pod})
}
(5)generateAPIPodStatus會將容器的狀態轉換為 v1.PodStatus定義中的字段。再調用statusManager的SetPodStatus方法更新pod的狀態信息。
func (kl *Kubelet) syncPod(ctx context.Context, updateType kubetypes.SyncPodType, pod, mirrorPod *v1.Pod, podStatus *kubecontainer.PodStatus) error {
klog.V(4).InfoS("syncPod enter", "pod", klog.KObj(pod), "podUID", pod.UID)
// Generate final API pod status with pod and status manager status
apiPodStatus := kl.generateAPIPodStatus(pod, podStatus)
kl.statusManager.SetPodStatus(pod, apiPodStatus)
// Call the container runtime's SyncPod callback
result := kl.containerRuntime.SyncPod(pod, podStatus, pullSecrets, kl.backOff)
}
(6)根據狀態做不同的賦值,如ContainerStateExited,則要更新容器的退出碼,原因等。
func (kl *Kubelet) convertToAPIContainerStatuses(pod *v1.Pod, podStatus *kubecontainer.PodStatus, previousStatus []v1.ContainerStatus, containers []v1.Container, hasInitContainers, isInitContainer bool) []v1.ContainerStatus {
switch {
case cs.State == kubecontainer.ContainerStateRunning:
status.State.Running = &v1.ContainerStateRunning{StartedAt: metav1.NewTime(cs.StartedAt)}
case cs.State == kubecontainer.ContainerStateCreated:
fallthrough
case cs.State == kubecontainer.ContainerStateExited:
status.State.Terminated = &v1.ContainerStateTerminated{
ExitCode: int32(cs.ExitCode),
Reason: cs.Reason,
Message: cs.Message,
StartedAt: metav1.NewTime(cs.StartedAt),
FinishedAt: metav1.NewTime(cs.FinishedAt),
ContainerID: cid,
}
}
(7)getPhase也是一個比較重要的函數,這里會決定pod的狀態,PodSpec中的restartPolicy是在這里生效的。
func getPhase(spec *v1.PodSpec, info []v1.ContainerStatus) v1.PodPhase {
for _, container := range spec.Containers {
containerStatus, ok := podutil.GetContainerStatus(info, container.Name)
if !ok {
unknown++
continue
}
switch {
case containerStatus.State.Running != nil:
running++
case containerStatus.State.Terminated != nil:
stopped++
if containerStatus.State.Terminated.ExitCode == 0 {
succeeded++
}
}
switch {
case running > 0 && unknown == 0:
return v1.PodRunning
case running == 0 && stopped > 0 && unknown == 0:
if spec.RestartPolicy == v1.RestartPolicyAlways {
// All containers are in the process of restarting
return v1.PodRunning
}
if stopped == succeeded {
return v1.PodSucceeded
}
if spec.RestartPolicy == v1.RestartPolicyNever {
return v1.PodFailed
}
return v1.PodRunning
}
}
(8)status_manager中的syncPod方法會將新的pod狀態通過patch方法更新到apiserver。
func (m *manager) syncPod(uid types.UID, status versionedPodStatus) {
pod, err := m.kubeClient.CoreV1().Pods(status.podNamespace).Get(context.TODO(), status.podName, metav1.GetOptions{})
newPod, patchBytes, unchanged, err := statusutil.PatchPodStatus(m.kubeClient, pod.Namespace, pod.Name, pod.UID, *oldStatus, mergePodStatus(*oldStatus, status.status))
klog.V(3).InfoS("Patch status for pod", "pod", klog.KObj(pod), "patch", string(patchBytes))
}
mem_alloc代碼
參考極客時間 《容器高手實戰課》
[root@localhost mem-alloc]# cat mem_alloc.c
#include <stdio.h>
#include <malloc.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#define BLOCK_SIZE (10*1024*1024)
int main(int argc, char **argv)
{
int thr, i;
char *p1;
if (argc != 2) {
printf("Usage: mem_alloc <num (MB)>\n");
exit(0);
}
thr = atoi(argv[1]);
printf("Allocating," "set to %d Mbytes\n", thr);
sleep(10);
for (i = 0; i < thr; i++) {
p1 = malloc(BLOCK_SIZE);
memset(p1, 0x00, BLOCK_SIZE);
}
sleep(600);
return 0;
}
[root@localhost oom]# cat Dockerfile
FROM nginx:latest
COPY ./mem-alloc/mem_alloc /
CMD ["/mem_alloc", "2000"]
[root@localhost oom]# cat Makefile
all: image
mem_alloc: mem-alloc/mem_alloc.c
gcc -o mem-alloc/mem_alloc mem-alloc/mem_alloc.c
image: mem_alloc
docker build -t registry/mem_alloc:v1 .
clean:
rm mem-alloc/mem_alloc -f
docker stop mem_alloc;docker rm mem_alloc;docker rmi registry/mem_alloc:v1
總結
本文研究的都是確定性的問題,好像沒有什么意義,話說回來,如果沒有學習這些確定性問題的積累,又如何去應對不確定的問題呢?