在容器里跑K3s:Dind与K3s结合实战指南 1. 为什么要在容器里再跑一个 Kubernetes第一次听到“在容器里跑 K3s”这个想法很多人的反应是这不是套娃吗容器本身就是被编排的对象现在反过来在容器里跑一个编排系统图什么我最初也是这个疑问直到在一个 CI/CD 流水线项目里被现实狠狠教育了一顿。当时的需求是这样的每次代码合并到主分支流水线需要拉起一套完整的 Kubernetes 环境把待测的 Operator 部署上去跑完集成测试后销毁。如果用传统的做法——在云上开一台虚拟机、装 K3s、跑测试、再销毁——整个流程至少需要三到五分钟其中大部分时间花在等虚拟机启动和系统初始化上。而如果能把 K3s 直接跑在容器里借助容器镜像的秒级启动能力整个环境的准备时间可以压缩到十几秒。这就是DindDocker in Docker配合K3s的核心价值把一整套 Kubernetes 控制平面和工作节点封装进一个容器镜像实现真正的“开箱即用”。你不需要关心底层是物理机还是虚拟机不需要手动配置 containerd 或 iptables只需要一条docker run命令几十秒后就能拿到一个可用的kubectl上下文。这篇文章适合三类人一是正在搭建 CI/CD 流水线、需要频繁创建临时 Kubernetes 环境的工程师二是想在本机快速体验 K3s 但不想污染宿主系统的开发者三是对容器嵌套技术感兴趣、想理解其底层原理的技术爱好者。我会从实际踩坑的角度出发把 Dind 与 K3s 结合时的关键配置、常见故障和排查思路讲清楚让你少走弯路。注意本文讨论的 Dind 方案仅适用于开发和测试场景。生产环境中的 Kubernetes 集群应当运行在专用节点上不建议采用容器嵌套的方式。2. Dind 与 K3s 结合的核心机制拆解2.1 Dind 到底解决了什么问题Docker in Docker 的本质是在一个容器内部运行一个完整的 Docker 守护进程。这个守护进程拥有自己独立的镜像存储、网络栈和容器运行时与宿主机的 Docker 完全隔离。实现方式通常有两种一种是使用docker:dind镜像它内部已经配置好了 dockerd 的启动脚本另一种是在普通容器中手动安装 Docker 并启动守护进程。为什么 K3s 需要 Dind因为 K3s 默认使用 containerd 作为容器运行时而 containerd 需要直接操作宿主机的内核功能包括 cgroups、namespaces、iptables 等。如果 K3s 直接跑在普通容器里它会尝试调用这些内核接口但普通容器默认没有足够的权限。Dind 通过--privileged模式赋予容器几乎与宿主机同等的内核访问能力让内部的 Docker 守护进程和 K3s 都能正常工作。这里有一个容易混淆的点K3s 本身并不依赖 Docker。K3s 内置了 containerd可以直接管理容器。那为什么还要 Dind原因在于当 K3s 跑在容器里时它需要一种方式来创建和管理 Pod 中的容器。如果使用内置的 containerd它需要访问/dev/kmsg、/sys/fs/cgroup等宿主机资源配置起来非常繁琐。而通过 DindK3s 可以把内部的 Docker 守护进程当作容器运行时来使用借助 Docker 已经处理好的内核接口大大简化了配置。2.2 K3s 在容器中的启动流程当你在 Dind 容器中启动 K3s 时实际发生的事情可以拆解为以下几个阶段第一阶段是 Docker 守护进程的初始化。容器启动后dockerd会在后台启动监听/var/run/docker.sock并初始化默认的bridge网络。这个阶段通常需要两到三秒可以通过健康检查命令docker info来确认守护进程是否就绪。第二阶段是 K3s 服务端的启动。K3s 以server模式启动生成 TLS 证书、启动 API Server、调度器和控制器管理器。由于 K3s 默认使用 SQLite 作为数据存储不需要额外部署 etcd这个阶段非常快通常在五秒内完成。K3s 会监听 6443 端口并生成 kubeconfig 文件到/etc/rancher/k3s/k3s.yaml。第三阶段是容器运行时的对接。K3s 启动时会检测系统中可用的容器运行时。如果检测到 Docker 套接字存在它会自动配置使用 Docker 作为运行时。这一步是关键如果 Docker 守护进程没有在 K3s 之前就绪K3s 会回退到内置的 containerd导致后续创建 Pod 时出现权限错误。第四阶段是网络插件的加载。K3s 默认集成了 Flannel 作为 CNI 插件它会创建cni0网桥和flannel.1虚拟网卡并配置 iptables 规则。在 Dind 环境中这些网络操作需要NET_ADMIN权限否则会失败。整个流程从容器启动到 K3s 就绪在配置得当的情况下大约需要十五到二十秒。如果超过三十秒还没有就绪通常意味着某个环节出了问题需要检查日志。2.3 权限与内核能力的配置要点Dind 方案能否跑通九成的成败取决于权限配置。普通容器默认只拥有有限的 Linux capabilities而 K3s 和 Docker 守护进程需要的能力远超默认值。以下是必须关注的几个关键权限权限项作用缺失后的表现--privileged赋予容器几乎所有内核能力无法挂载文件系统、无法创建网络设备--cgroupnshost使用宿主机的 cgroup 命名空间cgroup 挂载失败容器无法限制资源--tmpfs /run提供可写的 /run 目录Docker 守护进程启动失败--tmpfs /var/lib/docker提供 Docker 数据目录镜像存储报错无法拉取镜像--privileged是最核心的配置。它让容器内的进程可以访问宿主机的所有设备包括/dev/mem、/dev/kmsg等。没有这个标志Docker 守护进程在启动时就会因为无法操作 iptables 而失败。--cgroupnshost是较新版本 Docker 才支持的选项。在 cgroup v2 环境下如果容器使用私有的 cgroup 命名空间K3s 内部的容器运行时将无法正确读取和设置 cgroup 参数导致 Pod 启动时卡在ContainerCreating状态。--tmpfs挂载则是为了解决容器内文件系统只读的问题。Docker 守护进程需要在/run下创建临时文件在/var/lib/docker下存储镜像层。如果不使用 tmpfs这些目录在容器重启后会被清空但更严重的是某些基础镜像的根文件系统是只读的直接写入会报错。提示在实际操作中我建议把--tmpfs /var/lib/docker替换为-v /some/host/dir:/var/lib/docker这样镜像层可以持久化避免每次重启都重新拉取镜像。但要注意宿主机目录的权限确保容器内的 root 用户可以写入。3. 从零搭建 Dind K3s 环境的完整操作链路3.1 基础镜像的选择与准备搭建环境的第一步是选择一个合适的 Dind 基础镜像。Docker 官方提供了docker:dind镜像它基于 Alpine Linux体积小巧内置了 dockerd 和 docker CLI。但这个镜像有一个问题它默认的存储驱动是vfs性能较差而且不包含 K3s 所需的依赖库。我的做法是基于docker:dind构建一个自定义镜像在它上面安装 K3s 和必要的工具。以下是 Dockerfile 的核心内容FROM docker:dind # 安装 K3s 所需的依赖 RUN apk add --no-cache \ curl \ bash \ iptables \ ip6tables \ conntrack-tools \ libc6-compat \ openssl # 下载 K3s 二进制文件 RUN curl -sfL https://get.k3s.io | INSTALL_K3S_SKIP_ENABLEtrue sh - # 复制启动脚本 COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod x /usr/local/bin/entrypoint.sh ENTRYPOINT [/usr/local/bin/entrypoint.sh]这个 Dockerfile 做了三件事安装 K3s 运行所需的系统工具、下载 K3s 二进制文件、设置自定义入口脚本。INSTALL_K3S_SKIP_ENABLEtrue这个环境变量的作用是阻止安装脚本自动启动 K3s 服务因为我们希望在容器启动时手动控制启动顺序。选择 Alpine 作为基础系统是因为它的体积小最终镜像可以控制在 300MB 以内。但 Alpine 使用 musl libc 而不是 glibc某些二进制文件可能不兼容。K3s 官方提供了静态编译的二进制文件所以这个问题不大。如果你遇到兼容性问题可以改用docker:stable作为基础镜像它基于 Debian兼容性更好但体积会大一些。3.2 启动脚本的关键逻辑入口脚本是整个方案的大脑它负责协调 Docker 守护进程和 K3s 的启动顺序。以下是我实际使用的脚本核心逻辑#!/bin/bash set -e # 启动 Docker 守护进程 dockerd-entrypoint.sh DOCKER_PID$! # 等待 Docker 就绪 echo Waiting for Docker daemon... until docker info /dev/null 21; do sleep 1 done echo Docker daemon is ready. # 启动 K3s echo Starting K3s... k3s server \ --docker \ --disable traefik \ --disable servicelb \ --write-kubeconfig-mode 644 K3S_PID$! # 等待 K3s 就绪 echo Waiting for K3s... until kubectl get nodes /dev/null 21; do sleep 2 done echo K3s is ready. # 保持容器运行 wait $K3S_PID这个脚本有几个关键点值得展开说明。第一dockerd-entrypoint.sh是docker:dind镜像自带的脚本它负责以正确的参数启动 Docker 守护进程。直接调用它比自己写dockerd命令更可靠因为它处理了各种环境变量和默认配置。第二等待 Docker 就绪的循环是必须的。K3s 启动时会检测 Docker 套接字如果套接字不存在它会回退到 containerd 模式。这个循环确保 Docker 完全就绪后再启动 K3s。第三--docker参数显式告诉 K3s 使用 Docker 作为容器运行时。虽然 K3s 会自动检测但显式指定可以避免歧义。--disable traefik和--disable servicelb是为了减少资源占用在测试环境中通常不需要这些组件。第四--write-kubeconfig-mode 644确保 kubeconfig 文件对非 root 用户可读方便在容器外部通过挂载的方式使用 kubectl。3.3 启动容器时的参数配置有了镜像和脚本接下来是启动容器。以下是我经过多次调试后确定的最佳参数组合docker run -d \ --name k3s-dind \ --privileged \ --cgroupnshost \ --tmpfs /run \ --tmpfs /var/lib/docker \ -v /lib/modules:/lib/modules:ro \ -p 6443:6443 \ -p 8080:80 \ k3s-dind:latest--privileged和--cgroupnshost前面已经解释过这里重点说-v /lib/modules:/lib/modules:ro。这个挂载把宿主机的内核模块目录以只读方式映射到容器内。K3s 在启动网络插件时可能需要加载br_netfilter、overlay等内核模块。如果容器内没有这些模块文件加载会失败。只读挂载可以避免容器意外修改宿主机的模块文件。端口映射方面6443 是 K3s API Server 的端口映射到宿主机后你可以在宿主机上直接使用 kubectl 连接容器内的集群。8080 映射到容器的 80 端口用于测试部署在 K3s 中的 Web 服务。如果你希望 kubeconfig 文件在容器重启后仍然可用可以把/etc/rancher/k3s/k3s.yaml挂载到宿主机目录。但要注意这个文件中的 server 地址默认是https://127.0.0.1:6443在宿主机上使用时需要改为宿主机的 IP 或localhost。3.4 验证环境是否真正可用容器启动后不要急着部署应用先做几项基础验证。第一步是确认 Docker 守护进程正常docker exec k3s-dind docker info如果输出中包含Server Version和Storage Driver: overlay2说明 Docker 运行正常。如果存储驱动显示为vfs说明--tmpfs /var/lib/docker没有生效需要检查挂载配置。第二步是确认 K3s 节点就绪docker exec k3s-dind kubectl get nodes预期输出中节点状态应为Ready。如果显示NotReady通常是网络插件还没有初始化完成等待十秒后重试。如果持续NotReady检查kubectl describe node中的事件信息。第三步是部署一个测试 Pod验证整个链路是否通畅docker exec k3s-dind kubectl run test --imagenginx --restartNever docker exec k3s-dind kubectl get pod test如果 Pod 能在三十秒内进入Running状态说明 Dind 与 K3s 的集成完全成功。如果卡在ContainerCreating查看kubectl describe pod test的事件通常是镜像拉取失败或网络配置问题。4. 实际部署中遇到的典型故障与排查过程4.1 Docker 守护进程启动失败cgroup 挂载问题这是我遇到的第一个坑也是最难排查的一个。容器启动后docker info一直报Cannot connect to the Docker daemon。查看容器日志发现dockerd在启动时输出了一行错误failed to mount cgroup at /sys/fs/cgroup/systemd: no such file or directory这个问题的根源在于 cgroup 命名空间的配置。在 cgroup v2 环境下如果容器使用私有的 cgroup 命名空间/sys/fs/cgroup目录的结构与宿主机不同Docker 守护进程找不到它期望的systemd子目录。排查过程是这样的我先用docker run -it --rm --privileged alpine sh启动一个临时容器然后执行mount | grep cgroup查看 cgroup 挂载情况。输出显示/sys/fs/cgroup被挂载为cgroup2但没有systemd子目录。接着我在宿主机上执行同样的命令发现宿主机的/sys/fs/cgroup下有完整的子系统目录。对比之后确认问题出在 cgroup 命名空间隔离上。解决方案就是加上--cgroupnshost参数让容器共享宿主机的 cgroup 命名空间。加上这个参数后Docker 守护进程顺利启动。注意--cgroupnshost在 Docker 20.10 及以上版本才支持。如果你使用的是更早的版本需要改用--privileged配合手动挂载 cgroup 的方式但那样配置起来会复杂很多。4.2 K3s 启动后 Pod 一直卡在 ContainerCreating环境搭建完成后我部署了一个 Nginx Pod 做测试结果它一直卡在ContainerCreating状态等了五分钟都没有变化。kubectl describe pod显示的事件信息是Failed to create pod sandbox: rpc error: code Unknown desc failed to setup network for sandbox: plugin typeflannel failed (add): failed to set bridge addr: cni0 already has an IP address different from 10.42.0.1/24这个错误信息指向 Flannel 网络插件的配置冲突。cni0网桥已经存在但它的 IP 地址与 Flannel 期望的不一致。在 Dind 环境中这个问题通常是因为容器重启后旧的网络配置没有被清理干净。排查思路是这样的首先进入容器执行ip addr show cni0查看网桥的当前 IP。然后检查 Flannel 的配置文件/var/lib/rancher/k3s/agent/etc/flannel/net-conf.json确认它期望的网段。两者对比后我发现cni0的 IP 是上一次运行残留的10.42.0.1/24而 Flannel 这次分配的网段是10.42.1.0/24。解决方案有两个一是删除旧的cni0网桥让 Flannel 重新创建二是在启动 K3s 时指定固定的集群 CIDR。我选择了第二种方案在启动参数中加入--cluster-cidr 10.42.0.0/16确保每次启动都使用相同的网段。同时在入口脚本中加入清理逻辑# 清理旧的网络配置 ip link delete cni0 2/dev/null || true ip link delete flannel.1 2/dev/null || true rm -rf /var/lib/cni/networks/*这段清理逻辑放在启动 K3s 之前执行可以有效避免网络配置残留导致的问题。4.3 镜像拉取超时DNS 配置的隐蔽陷阱另一个让我花了半天时间排查的问题是镜像拉取超时。Pod 事件显示Failed to pull image: rpc error: code DeadlineExceeded desc context deadline exceeded。一开始我以为是网络问题但在容器内手动执行docker pull nginx却能成功。这个现象说明 Docker 守护进程本身的网络是通的但 K3s 在拉取镜像时走了不同的路径。K3s 使用自己的容器运行时接口来拉取镜像在 Dind 模式下这个请求会转发给 Docker 守护进程。问题出在 DNS 解析上Docker 守护进程使用宿主机的/etc/resolv.conf而 K3s 的 Pod 使用集群内部的 DNS 配置。排查时我在容器内执行了cat /etc/resolv.conf发现它指向了一个内部 DNS 服务器这个服务器在容器网络中可以访问但响应速度很慢。当 K3s 尝试解析镜像仓库地址时DNS 查询超时导致整个拉取操作失败。解决方案是在启动 Docker 守护进程时显式指定 DNS 服务器。在dockerd-entrypoint.sh启动之前设置环境变量export DOCKER_DNS8.8.8.8 export DOCKER_DNS114.114.114.114或者在启动容器时通过--dns参数指定docker run -d \ --dns 8.8.8.8 \ --dns 114.114.114.114 \ ...这样 Docker 守护进程会使用指定的 DNS 服务器避免依赖宿主机的 DNS 配置。实测下来加上这个配置后镜像拉取时间从超时变成了十秒内完成。4.4 资源限制导致的 OOM 问题最后一个值得分享的坑是内存不足。在 CI 环境中我最初给 Dind 容器分配了 2GB 内存结果 K3s 启动后不久容器就被 OOM Killer 杀掉了。查看dmesg输出发现是 K3s 的 API Server 进程占用了超过 1.5GB 内存。K3s 虽然号称轻量级但在容器中运行时由于缺少宿主机的内存回收机制内存占用会比裸机部署高不少。经过测试我总结出以下资源建议组件最低内存推荐内存Docker 守护进程256MB512MBK3s 控制平面512MB1GB每个测试 Pod128MB256MB系统预留256MB512MB合计1.2GB2.3GB如果要在 CI 中同时运行多个测试 Pod建议给容器分配至少 4GB 内存。同时可以在 K3s 启动参数中加入--kubelet-argeviction-hardmemory.available200Mi让 Kubelet 在内存不足时主动驱逐 Pod而不是等待 OOM Killer 介入。5. 让方案真正“开箱即用”的优化技巧5.1 镜像预热与层缓存策略“开箱即用”的核心指标是启动速度。如果每次启动容器都要重新拉取 K3s 和测试应用的镜像那和传统虚拟机方案相比优势就不明显了。我的优化思路是把常用镜像预先打包进 Dind 镜像中。具体做法是在 Dockerfile 的最后阶段启动一个临时的 Docker 守护进程拉取常用镜像然后提交镜像层。以下是一个示例# 在 Dockerfile 中预热镜像 RUN dockerd \ sleep 5 \ docker pull nginx:alpine \ docker pull busybox:latest \ docker pull rancher/k3s:latest \ kill %1这样构建出来的镜像体积会增大但启动时不需要再拉取这些镜像节省了大量时间。实测下来预热三个常用镜像后容器启动到 K3s 就绪的时间从二十五秒缩短到了十二秒。另一个优化点是使用 Docker 的构建缓存。把不经常变动的层放在 Dockerfile 前面经常变动的层放在后面。比如安装系统依赖的RUN apk add放在最前面复制启动脚本的COPY放在最后面。这样修改脚本时不需要重新安装依赖构建速度会快很多。5.2 kubeconfig 的导出与外部访问在 CI 流水线中通常需要在容器外部使用 kubectl 来操作集群。K3s 默认生成的 kubeconfig 文件中的 server 地址是https://127.0.0.1:6443这个地址在容器外部无法访问。解决方案是在容器启动后把 kubeconfig 文件复制到宿主机并替换 server 地址。我通常会在入口脚本的最后加入以下逻辑# 导出 kubeconfig mkdir -p /output sed s/127.0.0.1/0.0.0.0/g /etc/rancher/k3s/k3s.yaml /output/kubeconfig.yaml然后在启动容器时挂载/output目录docker run -d \ -v /host/output:/output \ ...这样宿主机就能在/host/output/kubeconfig.yaml中找到可用的配置文件。使用时设置export KUBECONFIG/host/output/kubeconfig.yaml即可。提示把 server 地址改为0.0.0.0后kubectl 会尝试连接https://0.0.0.0:6443这在某些系统上可能无法解析。更稳妥的做法是改为宿主机的实际 IP 地址或者使用localhost并确保端口映射正确。5.3 健康检查与就绪探针的配置在 CI 环境中流水线需要知道 K3s 何时就绪才能开始部署应用。如果只是简单地sleep 30要么浪费时间要么在环境慢时导致失败。正确的做法是配置健康检查。Docker 提供了HEALTHCHECK指令可以定期检查容器状态。以下是我使用的健康检查配置HEALTHCHECK --interval5s --timeout3s --start-period30s --retries10 \ CMD kubectl get nodes | grep -q Ready || exit 1这个配置的含义是容器启动后等待三十秒然后每五秒检查一次节点状态如果连续十次检查失败容器状态变为unhealthy。在 CI 脚本中可以使用docker inspect --format{{.State.Health.Status}} k3s-dind来获取健康状态当状态为healthy时再继续执行后续步骤。--start-period30s这个参数很重要。它给 K3s 留出了足够的启动时间避免在启动过程中因为健康检查失败而被误判。根据我的经验三十秒是一个比较安全的初始值如果环境较慢可以适当延长。5.4 清理与资源回收的最佳实践临时环境用完后需要及时清理否则会占用大量磁盘空间和内存。清理工作分为两个层面容器层面的清理和集群层面的清理。容器层面的清理比较简单直接docker stop k3s-dind docker rm k3s-dind即可。但如果使用了-v /host/dir:/var/lib/docker挂载还需要手动清理宿主机目录中的镜像层数据。集群层面的清理是指在销毁容器之前先删除集群中部署的资源。虽然容器销毁后这些资源也会消失但先删除可以释放一些资源加快容器停止的速度。我通常会在 CI 脚本的after_script阶段执行docker exec k3s-dind kubectl delete all --all --force --grace-period0 docker stop k3s-dind docker rm k3s-dind--grace-period0和--force参数让删除操作立即完成不等待优雅终止。这在测试环境中是可以接受的因为测试 Pod 通常没有需要保存的状态。还有一个容易被忽略的点是 Docker 的日志文件。Dind 容器中的 Docker 守护进程会记录大量日志如果不限制日志大小长时间运行后可能占满磁盘。可以在启动 Docker 守护进程时配置日志轮转dockerd --log-driverjson-file --log-opt max-size10m --log-opt max-file3这样每个容器的日志文件最大 10MB最多保留三个文件有效控制了磁盘占用。6. 这套方案适合什么场景不适合什么场景经过多个项目的实际使用我对 Dind K3s 方案的适用边界有了比较清晰的认识。它最适合的场景是短生命周期的测试环境CI/CD 流水线中的集成测试、Operator 的端到端测试、Kubernetes 功能的快速验证。在这些场景中环境的启动速度和销毁的彻底性比稳定性更重要Dind 方案的优势非常明显。但它不适合长期运行的生产环境。容器嵌套带来的性能损耗是实实在在的网络延迟比裸机部署高百分之二十到三十磁盘 I/O 性能也有明显下降。而且Dind 容器一旦崩溃内部的所有数据都会丢失没有持久化保障。如果你需要的是一个稳定的开发环境建议使用轻量级虚拟机或者直接在宿主机上安装 K3s。另一个需要注意的限制是某些 Kubernetes 功能在 Dind 环境中无法正常工作。比如需要访问宿主机设备插件的 GPU 调度、需要特定内核模块的网络策略、需要持久化存储的 StatefulSet这些在 Dind 中要么无法使用要么需要额外的配置。在使用前最好先确认你的测试用例是否依赖这些功能。我在实际使用中总结了一条经验如果测试用例能在十分钟内跑完并且不依赖持久化存储那么 Dind K3s 是很好的选择。如果测试需要更长时间或者依赖特殊硬件还是老老实实用虚拟机方案更稳妥。