kubelet 이 원하는 커널 파라미터

Kubelet 은 실행하는 머신의 커널 파라미터가 특정 값이 제대로 설정되어있지 않다면 동작하지 않고 오류가 발생하게 됩니다. 이 때 kubelet에 --protect-kernel-defaults=false 라는 실행 인자를 념겨주면 정상 동작을 하게 됩니다. 이런 단순한 오류 해결을 넘어서 실제로 kubelet 은 어떤 커널 파라미터가 세팅되어있기를 원하는지, 그리고 어떤 코드에서 그런 내용을 볼 수 있는지 등에 대해서 다루는 글입니다.

kubelet 이 원하는 커널 파라미터
💡
이 글에서는 Packer 를 통해 가상머신에서 구동할 K8s 클러스터에 대한 골든 이미지를 만드는 작업, 그리고 실제 클러스터를 프로비저닝 하고 테스트 하는 과정에서 겪은 kubelet과 관련된 문제를 다룹니다.

최근 packer 를 이용하여 golden image 를 생성해서 Kubernetes 클러스터를 생성하는 작업을, 별다른 프로비저닝 툴의 도움 없이 (kubeadm 조차도 쓰지 않고)진행하고 있습니다.

packer 에서 사용할 hcl 파일의 일부. 생각보다 간단하게 이미지에 대한 명세 작성이 가능했다!

이 작업을 하는 이유는 Virtual Machine 들로 이루어진 쿠버네티스 클러스터를 프로비저닝/운영 하기 위함이고. 전반적인 모습은 아마도 EKS 와 매우 비슷한 모습일겁니다.

EKS 는 각 쿠버네티스 컴포넌트(바이너리)가 사전에 설치된 쿠버네티스용 ami 기반으로 가상머신(ec2)를 생성하고, 그것들을 클러스터에 join 시키며, 컨트롤 플레인은 AWS가 managed 로 처리해주는 컨셉입니다.

여기서 Virtual Machine 기반의 쿠버네티스 클러스터를 만들어보고 테스트하는 과정에서, 가상머신을 한번 껐다가 켰더니 kubelet 이 아래와 같은 에러 로그를 발생시키고 있었습니다.

💡
현재 테스트하는 환경은 M1 Mac(ARM64) + VMWare Fusion 을 사용한 가상머신(ARM64) 환경입니다.
💡
kubelet 은 systemd 로 보통 실행되기때문에, 로그를 보기 위해서는 journalctl -xeu kubelet -f 와 같은 명령어를 수행하면 됩니다.
kubelet이 예상하는 커널 파라미터 값과 달라서 kubelet 실행이 실패한다. ($ journalctl -xeu kubelet -f)

May 02 03:05:49 control1 kubelet[1295]: E0502 03:05:49.409568 1295
kubelet.go:1542] "Failed to start ContainerManager" err="[invalid kernel flag: kernel/panic, expected value: 10, actual value: 0, invalid kernel flag: vm/overcommit_memory, expected value: 1, actual value: 0]"

위 에러 로그를 해석해보자면 kubelet 이 컨테이너 매니저를 실행시키는데 실패했고, 그 이유는 kernal/panic 커널 파라미터와 vm/overcommit_memory 커널 파라미터가 kubelet 이 “예상” 한 값과 다르기 때문에 kubelet 의 실행이 실패한것입니다.

우선 잘 알려진 해결 방법은 kubelet 을 실행 할 때 --protect-kernel-defaults=false 실행 인자를 추가해주는 방법이 소개되고 있습니다. 사실 --protect-kernel-defaults=true 가 기본값이기 때문에 위 에러 로그가 발생한것이라고 다시 해석이 가능한데요.

이 실행 인자는 kubelet 이 권장/원하는 커널 파라미터와 실제 커널 파라미터 설정값이 차이가 있을 경우 kubelet 이 오버라이드 할것이냐 말것이냐를 지정하는 옵션으로. 말 그대로 커널 파라미터 설정값을 보호 하겠냐, 보호하지 않겠냐의 의미로 해석하시면 됩니다.

따라서 --protect-kernel-defaults=false 인자를 kubelet 에 실행 인자로 주는 경우 커널 파라미터가 변경되면서 잘 동작하게 됩니다.

kubelet 의 실행 인자 추가/변경하는 방법

💡
kubelet systemd 파일은 kubeadm 으로 프로비저닝했는지, 직접 프로비저닝 했는지 등에 따라서 systemd service 파일의 형태가 다소 다릅니다. service 파일에 직접 인자를 넣어줄수도 있고, kubeadm 으로 배포한 경우 systemd drop in 파일에서 해당 인자를 넣어줄수도 있습니다. 각자의 쿠버네티스 환경에 맞게 적용해야 합니다.

Kubelet systemd 서비스 파일을 직접 만들어서 클러스터 프로비저닝 한 경우

우선 kubelet 에 대한 systemd drop-in 파일이 없는 구조에서, 환경변수를 사용하지 않고 kubelet 실행인자를 직접 service 파일에 지정한 경우에는 아래와 같습니다.

[Install]
WantedBy=multi-user.target
[Unit]
Description=Kubernetes Kubelet Server
Documentation=https://github.com/GoogleCloudPlatform/kubernetes
After=containerd.service
Wants=containerd.service
[Service]
ExecStart=/usr/local/bin/kubelet \
  --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubeconfig \
  --config=/etc/kubernetes/kubelet-config.yaml \
  --kubeconfig=/etc/kubernetes/kubelet.kubeconfig \
  --register-node=true \
  --v=2 \
  --protect-kernel-defaults=false
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target

kubelet.service

위와 같이 실행 인자에 직접 protect-kernel-defaults=false 를 넣어주면 됩니다.

Kubeadm 을 통해 프로비저닝한 클러스터

kubeadm 을 통해 프로비저닝한 클러스터의 kubelet systemd service
💡
kubelet 을 운영체제의 패키지 매니저로 설치했을때의 모습입니다. 패키지 매니저를 사용하지 않고 직접 바이너리를 다운로드 받아서 kubeadm 으로 클러스터를 프로비저닝한경우 이 상황과 완전히 일치하지 않을 수 있습니다.

만약 systemctl status kubelet 을 했을 때 drop-in 파일이 있는경우 해당 파일을 확인합니다.

위 케이스의 경우 kubeadm 을 통해 프로비저닝한 클러스터이고. /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf 파일에 지정된 systemd 파일의 선언 블록들이 오버라이드 됩니다. 따라서 해당 파일을 확인해봅니다.

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/sysconfig/kubelet
ExecStart=
ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS

10-kubeadm.conf

위 항목에 여러 환경변수를 지정하는데. $KUBELET_EXTRA_ARGS 의 경우/etc/sysconfig/kubelet 파일을 통해 로드하고. 해당 파일을 열어보면

KUBELET_EXTRA_ARGS=

/etc/sysconfig/kubelet

위와 같이 KUBELET_EXTRA_ARGS 를 볼수 있습니다. 여기서

KUBELET_EXTRA_ARGS="--protect-kernel-defaults=false"

/etc/sysconfig/kubelet

를 넣어주면 됩니다.

Kubespray 를 통해 프로비저닝한 클러스터

kubespray 를 통해 프로비저닝한 클러스터의 systemctl status kubelet 결과

kubespray 를 통해 프로비저닝한 클러스터는 단일 systemd service 파일로 구성되어있습니다.

[Unit]
Description=Kubernetes Kubelet Server
Documentation=https://github.com/GoogleCloudPlatform/kubernetes
After=containerd.service
Wants=containerd.service
[Service]
EnvironmentFile=-/etc/kubernetes/kubelet.env
ExecStart=/usr/local/bin/kubelet \
                $KUBE_LOGTOSTDERR \
                $KUBE_LOG_LEVEL \
                $KUBELET_API_SERVER \
                $KUBELET_ADDRESS \
                $KUBELET_PORT \
                $KUBELET_HOSTNAME \
                $KUBELET_ARGS \
                $DOCKER_SOCKET \
                $KUBELET_NETWORK_PLUGIN \
                $KUBELET_VOLUME_PLUGIN \
                $KUBELET_CLOUDPROVIDER
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target

kubelet.service

환경변수를 /etd/kubernetes/kubelet.env 를 통해 로드합니다.

해당 파일을 열어보면

KUBE_LOG_LEVEL="--v=2"
KUBELET_ADDRESS="--node-ip=192.168.207.3"
KUBELET_HOSTNAME="--hostname-override=node1"
KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \
--config=/etc/kubernetes/kubelet-config.yaml \
--kubeconfig=/etc/kubernetes/kubelet.conf \
--container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \
--runtime-cgroups=/system.slice/containerd.service \
 "
KUBELET_CLOUDPROVIDER=""
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

/etd/kubernetes/kubelet.env

위와 같이 되어있기 때문에, 아래와 같이 수정해서 저장하면 된다.

KUBE_LOG_LEVEL="--v=2"
KUBELET_ADDRESS="--node-ip=192.168.207.3"
KUBELET_HOSTNAME="--hostname-override=node1"
KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \
--config=/etc/kubernetes/kubelet-config.yaml \
--kubeconfig=/etc/kubernetes/kubelet.conf \
--container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \
--runtime-cgroups=/system.slice/containerd.service \
--protect-kernel-defaults=false \
 "
KUBELET_CLOUDPROVIDER=""
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

/etd/kubernetes/kubelet.env KUBELET_ARGS 부분의 맨 아래쪽에 --protect-kernel-defaults=false 추가됨

kubelet 이 어떤 커널 파라미터들이 어떻게 설정되있기를 예상하나요?

위 트러블 슈팅을 하면서 궁금했던건, kubelet 은 그럼 어떤 커널 파라미터가 어떻게 설정되있기를 원하느냐가 궁금했습니다.

단순히 kubelet 이 커널 파라미터를 오버라이드 하도록 두는것보다, 애초에 VM 의 커널 설정을 그렇게 하는것이 좋겠다고 생각했습니다.

거기다가 kubelet 이 해당 커널 파라미터를 원하는데에는 kubernetes 클러스터를 안정적으로 운영하기 위함이라는 추측이 있었고, 커널 파라미터가 어떻게 설정되있기를 바라는지 아는것 또한 충분히 의미가 있을 것이라고 생각했습니다.

kubernetes/pkg/kubelet/cm/container_manager_linux.go at 82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9 · kubernetes/kubernetes
Production-Grade Container Scheduling and Management - kubernetes/kubernetes
// setupKernelTunables validates kernel tunable flags are set as expected
// depending upon the specified option, it will either warn, error, or modify the kernel tunable flags
func setupKernelTunables(option KernelTunableBehavior) error {
	desiredState := map[string]int{
		utilsysctl.VMOvercommitMemory: utilsysctl.VMOvercommitMemoryAlways,
		utilsysctl.VMPanicOnOOM:       utilsysctl.VMPanicOnOOMInvokeOOMKiller,
		utilsysctl.KernelPanic:        utilsysctl.KernelPanicRebootTimeout,
		utilsysctl.KernelPanicOnOops:  utilsysctl.KernelPanicOnOopsAlways,
		utilsysctl.RootMaxKeys:        utilsysctl.RootMaxKeysSetting,
		utilsysctl.RootMaxBytes:       utilsysctl.RootMaxBytesSetting,
	}

	sysctl := utilsysctl.New()

	errList := []error{}
	for flag, expectedValue := range desiredState {
		val, err := sysctl.GetSysctl(flag)
		if err != nil {
			errList = append(errList, err)
			continue
		}
		if val == expectedValue {
			continue
		}

		switch option {
		case KernelTunableError:
			errList = append(errList, fmt.Errorf("invalid kernel flag: %v, expected value: %v, actual value: %v", flag, expectedValue, val))
		case KernelTunableWarn:
			klog.V(2).InfoS("Invalid kernel flag", "flag", flag, "expectedValue", expectedValue, "actualValue", val)
		case KernelTunableModify:
			klog.V(2).InfoS("Updating kernel flag", "flag", flag, "expectedValue", expectedValue, "actualValue", val)
			err = sysctl.SetSysctl(flag, expectedValue)
			if err != nil {
				if inuserns.RunningInUserNS() {
					if utilfeature.DefaultFeatureGate.Enabled(kubefeatures.KubeletInUserNamespace) {
						klog.V(2).InfoS("Updating kernel flag failed (running in UserNS, ignoring)", "flag", flag, "err", err)
						continue
					}
					klog.ErrorS(err, "Updating kernel flag failed (Hint: enable KubeletInUserNamespace feature flag to ignore the error)", "flag", flag)
				}
				errList = append(errList, err)
			}
		}
	}
	return utilerrors.NewAggregate(errList)
}

kubelet 의 linux 컨테이너 매니저 코드인 pkg/kubelet/cm/container_manager_linux.go 코드를 확인해보면

VMOvercommitMemory , VMPanicOnOOM 과 같은 값을 볼 수 있는데 이것은 utilsysctl 쪽에 아래와 같이 정의되어있습니다.

kubernetes/staging/src/k8s.io/component-helpers/node/util/sysctl/sysctl.go at 82cd82aa15aeb5e5ac1201c3b800b8134eb51cb9 · kubernetes/kubernetes
Production-Grade Container Scheduling and Management - kubernetes/kubernetes
const (
	sysctlBase = "/proc/sys"
	// VMOvercommitMemory refers to the sysctl variable responsible for defining
	// the memory over-commit policy used by kernel.
	VMOvercommitMemory = "vm/overcommit_memory"
	// VMPanicOnOOM refers to the sysctl variable responsible for defining
	// the OOM behavior used by kernel.
	VMPanicOnOOM = "vm/panic_on_oom"
	// KernelPanic refers to the sysctl variable responsible for defining
	// the timeout after a panic for the kernel to reboot.
	KernelPanic = "kernel/panic"
	// KernelPanicOnOops refers to the sysctl variable responsible for defining
	// the kernel behavior when an oops or BUG is encountered.
	KernelPanicOnOops = "kernel/panic_on_oops"
	// RootMaxKeys refers to the sysctl variable responsible for defining
	// the maximum number of keys that the root user (UID 0 in the root user namespace) may own.
	RootMaxKeys = "kernel/keys/root_maxkeys"
	// RootMaxBytes refers to the sysctl variable responsible for defining
	// the maximum number of bytes of data that the root user (UID 0 in the root user namespace)
	// can hold in the payloads of the keys owned by root.
	RootMaxBytes = "kernel/keys/root_maxbytes"
	// VMOvercommitMemoryAlways represents that kernel performs no memory over-commit handling.
	VMOvercommitMemoryAlways = 1
	// VMPanicOnOOMInvokeOOMKiller represents that kernel calls the oom_killer function when OOM occurs.
	VMPanicOnOOMInvokeOOMKiller = 0
	// KernelPanicOnOopsAlways represents that kernel panics on kernel oops.
	KernelPanicOnOopsAlways = 1
	// KernelPanicRebootTimeout is the timeout seconds after a panic for the kernel to reboot.
	KernelPanicRebootTimeout = 10
	// RootMaxKeysSetting is the maximum number of keys that the root user (UID 0 in the root user namespace) may own.
	// Needed since docker creates a new key per container.
	RootMaxKeysSetting = 1000000
	// RootMaxBytesSetting is the maximum number of bytes of data that the root user (UID 0 in the root user namespace)
	// can hold in the payloads of the keys owned by root.
	// Allocate 25 bytes per key * number of MaxKeys.
	RootMaxBytesSetting = RootMaxKeysSetting * 25
)

위 코드의 내용들을 정리해보면 kubelet 이 검증( --protect-kernel-defaults=false 인자가 들어온다면 수정)하는 커널 파라미터는 아래와 같았습니다.

vm.overcommit_memory = 1
vm.panic_on_oom = 0
kernel.panic = 10
kernel.panic_on_oops = 1
kernel.keys.root_maxkeys = 1000000
kernel.keys.root_maxbytes = 25000000 # root_maxkeys * 25

제가 작업하는 환경에서는 packer 를 통해 쿠버네티스 노드(컨트롤 플레인 노드, 워커노드 모두)의 이미지를 만들기 때문에, 이미지를 빌드할 때에 아래와 같은 ansible task 를 추가하여서 반영했습니다.

- name: kubelet이 원하는 커널 파라미터 변경
  ansible.posix.sysctl:
    sysctl_file: /etc/sysctl.d/k8s.conf
    name: "{{ item.name }}"
    value: "{{ item.value }}"
    state: present
    reload: yes
  with_items:
    - { name: kernel.keys.root_maxbytes, value: 25000000 }
    - { name: kernel.keys.root_maxkeys, value: 1000000 }
    - { name: kernel.panic, value: 10 }
    - { name: kernel.panic_on_oops, value: 1 }
    - { name: vm.overcommit_memory, value: 1 }
    - { name: vm.panic_on_oom, value: 0 }

ansible task

실제로 kubespray 또한 비슷한 작업을 하는데요.

kubespray/roles/kubernetes/preinstall/tasks/0080-system-configurations.yml at e01355834b461cf6e5578088ac377be2b6f70e6a · kubernetes-sigs/kubespray
Deploy a Production Ready Kubernetes Cluster. Contribute to kubernetes-sigs/kubespray development by creating an account on GitHub.

위 코드를 통해 볼 수 있습니다.

다만, task 이름이 명확하지 않아서 관련한 이슈/PR 까지도 남겼습니다.

Change a task name in preinstall tasks (in 0080-system-configurations.yml ) · Issue #11170 · kubernetes-sigs/kubespray
What would you like to be added I think that change the task name Ensure kube-bench parameters are set in kubernetes/preinstall/tasks/0080-system-configurations.yml into Ensure kubelet expected par…
Change a task name in preinstall/0080-system-configurations.yml by kimsehwan96 · Pull Request #11171 · kubernetes-sigs/kubespray
What type of PR is this? /kind documentation What this PR does / why we need it: These kernel parameters are the expected values for kubelet to operate(not the kube-bench), so it would be good to c…

Wrapping up

이렇게 해서 가상머신이 부팅 된 이후에 발생하는 kubelet 에러의 원인이 무엇인지 확인했고, 더 나아가서 kubelet 코드 내부에서 이것들을 어떻게 처리하고있고, 어떤 커널 파라미터들을 expect 하고 있는지 확인 할 수 있었습니다.

--protect-kernel-defaults=false 옵션을 kubelet 실행인자에 주면 위 에러는 쉽게 해결됩니다. 해당 커널 파라미터들을 kubelet 이 덮어씌우기 때문이죠.

위와 같은 방법으로 쉽게 해결되는 문제이지만, 근본적으로 kubelet 이 예상하는(expect)커널 파라미터에 무엇이 있으며, 어떤 값으로 설정되어야하는지. 더 나아가서는 그 커널 파라미터, 값들이 어떤 의미를 갖는지까지 알아보는것이 좋다고 생각합니다.