首先说说为什么要写这篇文章。
在平时的 k8s 集群部署、日常运维、问题排查过程中,需要很清楚 k8s 的部署形态,如下面的问题:
- 节点运行时用的是什么?
- 如果节点同时部署了 docker 和 containerd,那么 kubelet 会调用谁?
- 容器网络用的是什么,是 kubelet 调用 CNI 还容器运行时调用 CNI?
- 如果部署了多个 CNI(如同时部署了calico/flannel),谁会被调用?
如果这些问题不搞清楚,对 kubelet 的理解就会出现偏差。这篇文章我们就一个个解决这些疑问。
CRI
什么是 CRI
什么是 CRI?CRI 的全称是容器运行时接口,顾名思义,就是一组接口,只要实现了这组接口,就可以作为容器运行时对外提供容器服务。下面是这组接口的定义:
scss
// RuntimeService 定义
type RuntimeService interface {
RuntimeVersioner
ContainerManager
PodSandboxManager
ContainerStatsManager
UpdateRuntimeConfig(runtimeConfig *runtimeapi.RuntimeConfig) error
// Status returns the status of the runtime.
Status() (*runtimeapi.RuntimeStatus, error)
}
// RuntimeService 中的ContainerManager
type ContainerManager interface {
CreateContainer(podSandboxID string, config *runtimeapi.ContainerConfig, sandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
StartContainer(containerID string) error
StopContainer(containerID string, timeout int64) error
RemoveContainer(containerID string) error
ListContainers(filter *runtimeapi.ContainerFilter) ([]*runtimeapi.Container, error)
ContainerStatus(containerID string) (*runtimeapi.ContainerStatus, error)
UpdateContainerResources(containerID string, resources *runtimeapi.LinuxContainerResources) error
ExecSync(containerID string, cmd []string, timeout time.Duration) (stdout []byte, stderr []byte, err error)
Exec(*runtimeapi.ExecRequest) (*runtimeapi.ExecResponse, error)
Attach(req *runtimeapi.AttachRequest) (*runtimeapi.AttachResponse, error)
ReopenContainerLog(ContainerID string) error
}
// ImageManagerService 定义
type ImageManagerService interface {
ListImages(filter *runtimeapi.ImageFilter) ([]*runtimeapi.Image, error)
ImageStatus(image *runtimeapi.ImageSpec) (*runtimeapi.Image, error)
PullImage(image *runtimeapi.ImageSpec, auth *runtimeapi.AuthConfig, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
RemoveImage(image *runtimeapi.ImageSpec) error
ImageFsInfo() ([]*runtimeapi.FilesystemUsage, error)
}
CRI 的实现一般在节点上是一个 daemon 进程,对外提供 grpc 服务,CRI 提供的服务主要分为下面两类
-
镜像类: ImageManagerService,负责容器镜像的生命周期管理 如运行容器时,kubelet 首先需要判断节点上是否存在镜像,如果不存在,那么就调用 cri 提供的 PullImage 接口,进行镜像拉取;再如,kubelet 有 ImageGC 功能,如果满足 GC 要求的话,那么 kubelet 就会调用 RemoveImage 接口进行镜像的删除。
-
**容器类:**RuntimeService,负责容器的生命周期管理。
在创建 Pod 的时候首先需要调用 CRI 的 RunPodSandbox 接口进行网络命名空间的创建和网络栈的配置,然后调用 CreateContainer 创建容器,调用 StartContainer 启动容器。
在 Pod 被 kill 的时候,Kubelet 需要调用 StopContainer 和 RemoveContainer。
为什么需要 CRI 呢?
Kubernetes早期是利用Docker作为容器运行时管理工具的,在1.6版本之前Kubernetes将Docker默认为自己的运行时工具,通过直接调用Docker的API来创建和管理容器。
在Docker项目盛行不久,CoreOS推出了rkt运行时工具,Kubernetes又添加了对rkt的支持。但随着容器技术的蓬勃发展,越来越多的运行时工具出现,提供对所有运行时工具的支持,显然是一项庞大的工程;而且直接将运行时的集成内置于Kubernetes,两者紧密结合,对Kubernetes代码本身也是一种负担,每更新一次重要的功能,Kubernetes都需要考虑对所有容器运行时的兼容适配。
所以为了解决这一问题,kubernetes 规定了一组接口,如果想要接入 kubernetes 的运行时,就必须要提供这组接口,这就是上面说的 CRI。而容器运行时则需要实现 "shim" 来将 kubernetes 的调用信息转为和自己兼容的数据结构,他看起来像下面这个图:
在 1.24 版本之前,kubelet 实现了 dockershim 将接口的调用转换为符合 docker 接口规范,在 kubelet 启动时默认会启动 dockersim 服务他的调用顺序如下:
在 1.24 版本以及之后的版本,kubelet 移除了默认使用 dockershim,而是需要用户通过参数 --container-runtime-endpoint 参数指定运行时地址,他的调用如下:
我们现在来讨论开篇的第一、二两个问题
我们分别讨论 1.24 前后的版本,因为默认运行时的配置从这个版本开始出现变化
版本 < 1.24
go
// cmd/kubelet/kubelet.go
func main() {
rand.Seed(time.Now().UnixNano())
command := app.NewKubeletCommand()
logs.InitLogs()
defer logs.FlushLogs()
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
// cmd/kubelet/app/server.go
func NewKubeletCommand() *cobra.Command {
....
kubeletFlags := options.NewKubeletFlags()
....
}
func NewKubeletFlags() *KubeletFlags {
remoteRuntimeEndpoint := ""
if runtime.GOOS == "linux" {
remoteRuntimeEndpoint = "unix:///var/run/dockershim.sock"
} else if runtime.GOOS == "windows" {
remoteRuntimeEndpoint = "npipe:////./pipe/dockershim"
}
return &KubeletFlags{
...
RemoteRuntimeEndpoint: remoteRuntimeEndpoint,
...
}
}
从上面的代码中我们看到,kubelet 设置运行时地址默认为 unix:///var/run/dockershim.sock,从这里我们也能看到,kubelet其实并不关注底层真实的运行时是怎么实现的,只要运行时实现了 CRI 那么就可被 kubelet 使用,这里 dockershim 其实并不是一个运行时,而只是一个接口转换的服务,但是 kubelet 会认为他是一个运行时。
那么 kubelet 对运行时的调用,会来到 dockershim,那么 dockershim 又是怎么找到真正底层的运行时地址的呢?
go
// cmd/kubelet/kubelet.go
func main() {
rand.Seed(time.Now().UnixNano())
command := app.NewKubeletCommand()
logs.InitLogs()
defer logs.FlushLogs()
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
// cmd/kubelet/app/server.go
func NewKubeletCommand() *cobra.Command {
....
kubeletFlags := options.NewKubeletFlags()
....
}
// 这里设置了 dockerEndpoint 的地址
func NewContainerRuntimeOptions() *config.ContainerRuntimeOptions {
...
dockerEndpoint := ""
if runtime.GOOS != "windows" {
dockerEndpoint = "unix:///var/run/docker.sock"
}
...
}
kubelet 启动时,会默认设置 docker 的地址为 unix:///var/run/docker.sock,这个地址在 dockershim 初始化启动的时候会被赋值:
go
// pkg/kubelet/kubelet.go
func PreInitRuntimeService(...) {
...
switch containerRuntime {
case kubetypes.DockerContainerRuntime:
klog.Warningf("Using dockershim is deprecated, please consider using a full-fledged CRI implementation")
if err := runDockershim(
kubeCfg,
kubeDeps,
crOptions,
runtimeCgroups,
remoteRuntimeEndpoint,
remoteImageEndpoint,
nonMasqueradeCIDR,
); err != nil {
return err
}
case kubetypes.RemoteContainerRuntime:
// No-op.
break
default:
return fmt.Errorf("unsupported CRI runtime: %q", containerRuntime)
}
...
}
func runDockershim(...) {
...
dockerClientConfig := &dockershim.ClientConfig{
// 设置 docker 的地址,就是上面设置的默认值
DockerEndpoint: kubeDeps.DockerOptions.DockerEndpoint,
RuntimeRequestTimeout: kubeDeps.DockerOptions.RuntimeRequestTimeout,
ImagePullProgressDeadline: kubeDeps.DockerOptions.ImagePullProgressDeadline,
}
// 启动 dockershim
if err := dockerServer.Start(); err != nil {
return err
}
...
return nil
}
所以从上面的代码中我们可以回答上面的两个问题:
-
在 1.24 以前: kubelet 会启动 dockershim,如果启动 kubelet 时用户没有通过参数 --container-runtime-endpoint 指定运行时的话,那么 kubelet 会把运行时设置为 dokershim,kubelet 对运行时的请求会发送到 dockersim,dockersim 把请求转换为 docker 接口后,发送到 unix:///var/run/docker.sock 指定的地址,也docker。所以,要是使用默认行为的话,那么节点必须部署 docker。
-
如果用户没有指定运行时地址,那么默认的行为就是使用 docker,部署的 containerd 将不会被使用。
版本 >= 1.24
在 >= 1.24 的版本里面,kubelet已经移除了 dockershim 的代码,不再提供默认的运行时,用户必须通过参数 --container-runtime-endpoint 指定运行时地址,否则启动会失败。
如果你还是想使用 docker + dockershim 的方式的话,那么你就得自己部署 dockershim。
CNI
CNI 全称容器网络接口,是 CNCF 的一个项目,主要用于 k8s 项目中调用容器网络实现的接口
他定义的接口如下:
scss
// github.com/containernetworking/cni/libcni/api.go
type CNI interface {
AddNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) (types.Result, error)
CheckNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error
DelNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error
GetNetworkListCachedResult(net *NetworkConfigList, rt *RuntimeConf) (types.Result, error)
GetNetworkListCachedConfig(net *NetworkConfigList, rt *RuntimeConf) ([]byte, *RuntimeConf, error)
AddNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
CheckNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
DelNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
GetNetworkCachedResult(net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
GetNetworkCachedConfig(net *NetworkConfig, rt *RuntimeConf) ([]byte, *RuntimeConf, error)
ValidateNetworkList(ctx context.Context, net *NetworkConfigList) ([]string, error)
ValidateNetwork(ctx context.Context, net *NetworkConfig) ([]string, error)
}
这组接口主要包含三个功能:
-
增加网络
如新建 Pod 的时候,创建 sandbox 的时候,容器运行时需要调用 AddNetwork 对 Pod 进行网络的配置(如添加网卡,为网卡分配 IP,添加路由等)
-
删除网络
如删除一个 Pod 的时候,需要调用 DelNetwork 对网络进行卸载,如删除网络命名空间、归还容器网络 IP 等
-
验证网络
验证容器网络插件是否支持指定的版本
这里我们需要区分容器网络接口和容器网络插件:容器网络接口就是上面定义的这组接口,它的功能是实现负责调用容器网络插件的逻辑;而容器网络插件是容器网络具体的实现,例如有 calico、flanel、cilium 等,他们有基于三层的网络方案(如 calico),有基于二层实现的网络方案(如 flannel 的 host-gw 模式),有基于 BPF 实现的网络方案(cilium),这里说的实现方案具体就是如何给 Pod 设置网卡、分配 Pod IP,以及如何实现跨主机的容器网络连通。
我们需要注意,并不是 kubelet 去调用 CNI 接口,而是由容器运行时调用 CNI,比如创建 Pod 的时候,kubelet 调用 CRI 进行 sandbox 的创建,容器运行时在创建 sandbox 的时候需要调用 CNI 进行 Pod 的网络栈的设置。
我们已经知道容器网络插件是由 CNI 的具体实现逻辑中来来调用的,那么 CNI 实现是如何调用容器网络插件的呢?我们以 CNCF 对 CNI 实现来说明(实际上,containerd 和 docker 中都是使用该实现来调用 容器网络插件的)。
如何确定使用什么网络插件
containerd 在启动时候会初始化 cni 的配置:
go
// internal/cri/config/config_unix.go
func DefaultRuntimeConfig() RuntimeConfig {
return RuntimeConfig{
CniConfig: CniConfig{
NetworkPluginBinDir: "/opt/cni/bin",
NetworkPluginConfDir: "/etc/cni/net.d",
NetworkPluginMaxConfNum: 1, // only one CNI plugin config file will be loaded
NetworkPluginSetupSerially: false,
NetworkPluginConfTemplate: "",
},
ContainerdConfig: ContainerdConfig{
DefaultRuntimeName: "runc",
Runtimes: map[string]Runtime{
"runc": {
Type: "io.containerd.runc.v2",
Options: m,
Sandboxer: string(ModePodSandbox),
},
},
},
}
}
// internal/cri/server/service_linux.go
func (c *criService) initPlatform() (err error) {
...
pluginDirs := map[string]string{
defaultNetworkPlugin: c.config.NetworkPluginConfDir,
}
for name, conf := range c.config.Runtimes {
if conf.NetworkPluginConfDir != "" {
pluginDirs[name] = conf.NetworkPluginConfDir
}
}
c.netPlugin = make(map[string]cni.CNI)
for name, dir := range pluginDirs {
max := c.config.NetworkPluginMaxConfNum
if name != defaultNetworkPlugin {
if m := c.config.Runtimes[name].NetworkPluginMaxConfNum; m != 0 {
max = m
}
}
i, err := cni.New(cni.WithMinNetworkCount(networkAttachCount),
cni.WithPluginConfDir(dir),
cni.WithPluginMaxConfNum(max),
cni.WithPluginDir([]string{c.config.NetworkPluginBinDir}))
if err != nil {
return fmt.Errorf("failed to initialize cni: %w", err)
}
c.netPlugin[name] = i
}
...
return nil
}
从上面的代码中我们可以看到,容器运行时在启动时,添加了一个默认的网络插件,也就是在 netPlugin["default"] = New(cniObj),这个容器网络插件的配置文件在:/etc/cni/net.d,网络插件的可执行文件在:/opt/cni/bin
如果 /etc/cni/net.d 下面有多个配置的话,那么会根据配置文件名的字典顺序取第一个,文件名的后缀必须是:".conf", ".conflist", ".json"其中之一,下面是一个典型的容器网络插件配置文件 /etc/cni/net.d/10-calico.conflist:
json
{
"name": "k8s-pod-network",
"cniVersion": "0.3.1",
"plugins": [
{
"type": "calico",
"log_level": "info",
"datastore_type": "kubernetes",
"nodename": "10-131-24-209",
"mtu": 1440,
"ipam": {
"type": "calico-ipam"
},
"policy": {
"type": "k8s"
},
"kubernetes": {
"kubeconfig": "/etc/cni/net.d/calico-kubeconfig"
}
},
{
"type": "portmap",
"snat": true,
"capabilities": {"portMappings": true}
},
{
"type": "bandwidth",
"capabilities": {"bandwidth": true}
}
]
}
plugins 数组里面的插件会依次被执行,其中的 type 字段实际上就是网络插件的可执行文件目录 /opt/cni/bin 下对应的可执行文件,容器运行时调用容器网络插件的时候会以 shell 命令的方式去调用:
go
// github.com/containernetworking/cni/libcni/api.go
func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
c.ensureExec()
pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
if err != nil {
return nil, err
}
if err := utils.ValidateContainerID(rt.ContainerID); err != nil {
return nil, err
}
if err := utils.ValidateNetworkName(name); err != nil {
return nil, err
}
if err := utils.ValidateInterfaceName(rt.IfName); err != nil {
return nil, err
}
newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
if err != nil {
return nil, err
}
return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}
容器网络插件方案实际上只需要实现 ADD/DEL/VERSION 这几个命令就行了,例如在 addNetwork 方法中,我们给 Pod 设置一个网络栈,调用容器网络插件时传递了 ADD 这样的参数(实际会被设置为:CNI_COMMAND=ADD 这样的环境变量),而在 /etc/cni/net.d/ 目录下插件的配置,会以标准输入的方式传递给容器网络插件,所以调用容器网络插件的时候,参数实际上是有两部分组成:
-
设置环境变量方式,有如下变量:
arduino"CNI_COMMAND="+args.Command, "CNI_CONTAINERID="+args.ContainerID, "CNI_NETNS="+args.NetNS, "CNI_ARGS="+pluginArgsStr, "CNI_IFNAME="+args.IfName, "CNI_PATH="+args.Path,
-
容器网络插件的配置参数,从 /etc/cni/net.d/ 目录文件中的读取,向标准输入传递参数给插件
容器网络插件和配置从哪来?
上面那我们说了,创建一个 Pod 的时候,容器运行时调用 CNI 的实现,CNI 实现会读取 /etc/cni/net.d 下面的配置文件, 调用 /opt/cni/bin 的容器网络插件进行 Pod 的网络配置。那么 /etc/cni/net.d,/opt/cni/bin 这两个目录下面的文件是从哪里来的呢?
一般的实现方法是(calico、flannel 都是这样实现):在集群中部署一个 daemonset,daemonset 中会有一个 initContainer,这个 initContainer 中会挂载一个 hostPath 类型的卷,把宿主机的目录 /etc/cni/net.d,/opt/cni/bin 挂载到 initContainer 内,然后把配置文件和可执行文件分别拷贝到挂载路径,这样在宿主机上就能看到配置文件和可执行文件了:
yaml
initContainers:
...
volumeMounts:
- mountPath: /var/lib/cni/networks
name: host-local-net-dir
- mountPath: /host/opt/cni/bin
name: cni-bin-dir
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount
name: calico-node-token-ksm6q
readOnly: true
volumes:
...
- hostPath:
path: /opt/cni/bin
type: ""
name: cni-bin-dir
- hostPath:
path: /etc/cni/net.d
type: ""
name: cni-net-dir
...
那么如果同时部署了多个CNI插件,容器运行时会选择哪个插件插件呢?
很清楚,我们上面已经说过了如果部署了多个插件那么 /etc/cni/net.d 目录下会存在多个配置文件,那么容器运行时只会按照文件名的字典排序顺序选择第一个作为 CNI 插件的配置文件,从配置文件中读取需要执行 /opt/cni/bin 目录下的哪些二进制。