如何加固 VPS 上的单节点 k3s 集群
单行安装后的 k3s 并非默认安全:检查并限制 6443 和 10250,修复 kubeconfig 权限,处理防火墙看不到的 NodePort,并阻止特权 Pod。
单节点 k3s 集群首日暴露的内容
在公共 VPS 上运行的单节点 k3s 集群,会在单行安装程序完成后的第二天暴露在以下 5 个位置:TCP 6443 上的 Kubernetes API 服务器、TCP 10250 上的 kubelet、存储在磁盘上的 kubeconfig 文件、NodePort 范围(防火墙无法识别该范围),以及任何被允许请求 privileged 或 hostPath 的 pod。每个问题都可在几分钟内修复。本指南假设 k3s 已在运行;如果尚未运行,请先参阅在 VPS 上安装单节点 k3s 集群,然后返回本指南。
在修改任何配置前,先查看当前正在监听的端口。
sudo ss -tulpn | grep -E '6443|10250|10256|8472'默认安装会显示 6443(API 服务器)、10250(kubelet)、10256(kube-proxy 健康检查)和 8472/udp(flannel 覆盖网络,它使用 VXLAN,即虚拟可扩展局域网)。k3s 默认绑定到 0.0.0.0,因此这些端口都会位于公共地址上,而不只是在回环地址上监听。
为什么端口 6443 关系到整个集群
任何能够以管理员权限通过端口 6443 完成身份验证的主体,都可以创建 Pod,而 Pod 可以在主机上获得 root 权限。端口 6443 就是进入这台机器的入口。
开放 6443 并不意味着服务器会立即被入侵,因为 Kubernetes 不接受密码。它要求客户端证书或 bearer token。但以下两点仍然成立。
首先,API server 会在完全没有凭据的情况下响应某些请求。默认的 Kubernetes RBAC(基于角色的访问控制)会将组 system:unauthenticated 绑定到名为 system:public-info-viewer 的角色,该角色允许执行 /version、/healthz、/livez 和 /readyz。在另一台机器上执行:
curl -sk https://YOUR_SERVER_IP:6443/version该命令会返回准确的 Kubernetes 版本。这是搜索 CVE(常见漏洞和暴露)时使用的输入,也是扫描器判断你的服务器值得关注的依据。超出这些路径的请求都会被拒绝,拒绝响应会明确指出你的身份:
forbidden: User "system:anonymous" cannot get path "/api"其次,只要该端口保持开放,API server 的每个漏洞都可以被远程利用。因此,打补丁不再是可选项。
直接的解决方法是添加防火墙规则。这里 ufw 需要多条规则,因为 k3s 会通过同一个内核转发集群流量。
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow from 203.0.113.10 to any port 6443 proto tcp
sudo ufw allow from 10.42.0.0/16 to any
sudo ufw allow from 10.43.0.0/16 to any
sudo ufw enable最后两条规则直接来自 k3s 文档。10.42.0.0/16 是默认 Pod 网络,10.43.0.0/16 是默认 Service 网络。如果没有这两条规则,ufw 会丢弃集群内部流量,导致 Pod 无法访问 API server 和彼此通信。k3s 示例允许来自所有地址的 6443 流量;将其替换为你自己的地址,是值得进行的修改。如果你不熟悉 ufw,VPS 的 ufw 防火墙基础介绍了这些规则所依赖的默认策略。
k3s 文档明确说明了覆盖网络端口:“节点上的 VXLAN 端口不应暴露给公网,否则任何人都可以访问你的集群网络。”对入站流量设置默认拒绝策略即可处理这一问题,无需单独指定该端口。
更强的解决方法是完全停止通过公网地址访问 API,改用 VPN 或网状网络地址。服务器证书必须包含你连接的地址,因此应将该地址作为 SAN(主题备用名称)添加到 /etc/rancher/k3s/config.yaml 中:
tls-san:
- 10.8.0.1
- k3s.example.com
secrets-encryption: truesudo systemctl restart k3s
sudo k3s secrets-encrypt statussecrets-encryption: true 会加密数据存储中的 Secret 对象。k3s 文档指出,“启用 Secret 加密后,必须重启现有服务器”,并且在修改前写入的 Secret 会继续保留旧格式,直到你运行 sudo k3s secrets-encrypt reencrypt。必须明确这项措施的作用。它可以保护从备份中复制出来的数据存储文件。但它无法防御能够访问 API server 的人员,因为 API server 会为有权读取 Secret 的主体解密 Secret。任何自托管 Secret 存储都存在同样的区别,这也是为什么Vaultwarden 加固检查重点关注管理员令牌和备份文件,而不是加密本身。
为什么必须关注 10250 端口上的 kubelet
kubelet 是负责启动容器的代理。它在 10250 端口上提供的 API 可以列出 Pod,并在其中执行命令。接受匿名请求的 kubelet,相当于向能够访问该主机的所有工作负载提供远程 shell。
检查当前配置:
curl -sk https://127.0.0.1:10250/pods | head -c 60当前的 k3s 会返回 Unauthorized,因为 kubelet 要求 API server 对每个调用方进行身份验证和授权。如果返回 JSON 格式的 Pod 列表,说明已启用匿名访问。任何能够访问 10250 端口的用户都可以读取容器中的数据并执行命令。
无论结果如何,都应从外部阻止该端口。在单节点环境中,kubelet 的唯一客户端是同一台主机上的 control plane,流量通过 loopback 接口进入,ufw 默认允许该接口。禁止来自互联网的 10250 端口访问不会造成影响。如果你是因为故障的 kubectl top 或无法稳定运行的 metrics-server 来到这里,相关原因汇总在 kubelet 10250 端口错误。
您的 kubeconfig 是集群管理员凭据
k3s 会写入 /etc/rancher/k3s/k3s.yaml,文件所有者为 root,权限模式为 600。文档说明了更改权限模式的后果:“kubeconfig 文件由 root 所有,默认以 600 模式写入。将权限模式改为 644 后,主机上的其他非特权用户也可以读取该文件。”
应将其理解为:权限模式 644 会让每个本地账户都成为集群管理员。许多操作指南确实建议这样做,通常执行 --write-kubeconfig-mode 644,以便 kubectl 可以在不使用 sudo 的情况下运行。其原理是将管理员凭据交给任何拥有 shell 访问权限的用户。
改为只将文件复制给一个用户。
mkdir -p ~/.kube
sudo install -o "$USER" -g "$USER" -m 600 /etc/rancher/k3s/k3s.yaml ~/.kube/config
kubectl get nodes然后确认原文件的权限仍然严格:
stat -c '%a %U:%G' /etc/rancher/k3s/k3s.yaml600 root:root 才是您需要的结果。该文件包含 system:masters 成员的客户端证书;API 服务器会无条件允许该组,因此不会检查 RBAC 规则。Kubernetes 没有证书吊销列表,这意味着泄露的副本会一直有效,直到您轮换集群证书颁发机构。应像保护 SSH 私钥一样保护它,并限制能够访问该文件的账户数量。这与 VPS 上的最小权限用户账户所遵循的原则相同。
防火墙无法看到 NodePort 流量
type: NodePort 服务会在节点持有的每个地址上打开一个 30000 到 32767 之间的端口,包括公网地址。type: LoadBalancer 服务在 k3s 上更进一步:内置负载均衡器 ServiceLB 会在 kube-system 中为每个服务调度一个小型 Pod,直接在主机上占用该服务端口。
kubectl -n kube-system get pods | grep svclb
sudo ss -tulpn | grep -E ':3[0-2][0-9]{3}'下面是最容易让人意外的部分。使用 ufw 封锁该端口后,它仍然会响应。
sudo ufw deny 30080/tcp
curl http://YOUR_SERVER_IP:30080页面仍然可以加载,因为数据包经过的路径不同。kube-proxy 会将 DNAT(目标网络地址转换)规则写入 nat 表的 PREROUTING 链,而 PREROUTING 在任何过滤决策之前执行。目标地址会变成 Pod 地址,而不是主机地址,因此内核会将数据包发送到 FORWARD 链,而不会经过 INPUT 链。ufw 的规则位于 INPUT 链中,因此数据包不会匹配这些规则。跳转顺序如下:
sudo iptables -S PREROUTING -t nat | head
sudo iptables -S FORWARD | headKUBE-SERVICES 位于 PREROUTING 链的顶部,Kubernetes 在 FORWARD 链中的跳转位于 ufw 自有链之前。这与 Docker 绕过 ufw 发布端口 使用的是同一机制,因此处理方法也相同。
- 在云服务商的网络防火墙上进行过滤。它位于主机之前,不受内核路由方式影响。
- 不使用 NodePort 和 LoadBalancer。将服务保留在
ClusterIP,通过现有 SSH 会话使用kubectl port-forward访问它们。 - 只通过 80 和 443 暴露一个 ingress,不开放其他端口。
- 在
kube-apiserver-arg下使用service-node-port-range缩小端口范围,避免意外创建的 NodePort 落在无人监控的端口上。
ufw 仍然值得运行。它管理目标为主机本身的流量,包括 SSH 和 API server 流量。但它不会过滤 Pod 流量。误以为它可以过滤这类流量,数据库就可能因此被暴露。
具有 hostPath 或 privileged 的 Pod 等同于 VPS 上的 root
容器只是运行在宿主机内核上的普通进程,但只能看到内核的受限视图。某些 Pod 字段会取消这些限制。
securityContext.privileged: true为容器提供所有 Linux capability,并允许访问宿主机设备。hostPath将宿主机目录挂载到 Pod 中。以读写方式挂载/的 Pod 可以向/root/.ssh/authorized_keys追加密钥。hostPID: true让容器加入宿主机的进程命名空间,在其中针对 PID 1 执行nsenter即可打开宿主机 shell。hostNetwork: true让容器使用宿主机的网络协议栈,因此它可以绑定宿主机端口,并访问绑定到 loopback 的服务。
因此,“谁能在这里创建 Pod”与“谁是这台 VPS 上的 root”其实是同一个问题。除非有其他机制先拒绝该 Pod,否则任何在任意命名空间的 Pod 上拥有 create 的 ServiceAccount,都等同于拥有 root 权限。
这个机制就是 Pod Security admission,由 API server 内置。最简单的配置方式是为每个命名空间设置一个标签,无需重启。
kubectl label namespace default \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restrictedbaseline 会拒绝上述所有 4 个字段。restricted 的限制更严格:它要求使用非 root 用户和 seccomp(secure computing mode)配置文件,禁止权限提升,并将 capabilities 降至 ALL;这会导致许多公开发布的 chart 无法运行。在强制执行 baseline、同时仅对 restricted 发出警告时,您可以先查看哪些配置会失败,再决定是否启用更严格的策略。
验证配置是否生效:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: pstest
spec:
containers:
- name: app
image: busybox
command: ["sleep", "60"]
securityContext:
privileged: true
EOFAPI server 会拒绝该请求,并指出被拒绝的字段:
Error from server (Forbidden): error when creating "STDIN": pods "pstest" is forbidden: violates PodSecurity "baseline:latest": privileged (container "app" must not set securityContext.privileged=true)如果要设置集群范围的默认策略,而不是为每个命名空间添加标签,k3s 文档说明可以在 /var/lib/rancher/k3s/server/psa.yaml 使用 admission 配置文件:
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1beta1
kind: PodSecurityConfiguration
defaults:
enforce: "baseline"
enforce-version: "latest"
warn: "restricted"
warn-version: "latest"
exemptions:
namespaces: [kube-system]在 /etc/rancher/k3s/config.yaml 中让 API server 使用该文件,然后重启 k3s:
kube-apiserver-arg:
- 'admission-control-config-file=/var/lib/rancher/k3s/server/psa.yaml'kube-system 例外不是可选项。k3s 自带的 ServiceLB Pod 会占用宿主机端口,而 baseline 会禁止这种行为。因此,如果将 kube-system 从列表中删除,这些 Pod 在下次被重新创建时就会被拒绝。修改 admission 配置后重启 k3s 时,请保持第二个 SSH 会话处于打开状态。
此外,您还可以利用一个现成的功能:k3s 自带网络策略控制器,并且默认启用。因此,NetworkPolicy 对象会在此集群中直接生效,无需额外安装组件。并非所有 Kubernetes 发行版都如此;网络策略可用于阻止某个已被攻破的 Pod 访问其他资源。
删除不使用的捆绑组件
安装程序会部署一组附加组件。每个组件都会增加一个监听器,以及一项需要修补的内容。--disable 接受以下值:coredns、servicelb、traefik、local-storage、metrics-server、runtimes。
保留 coredns。集群中的任何内容都无法在没有它的情况下解析名称。其余组件可按需选择。在 /etc/rancher/k3s/config.yaml 中:
disable:
- traefik
- servicelb
disable-helm-controller: truesudo systemctl restart k3s
kubectl get pods -Ak3s 会删除已禁用的组件,因此 traefik pod 和 svclb- pod 会自动消失。请先了解相关影响。删除 servicelb 后,所有 type: LoadBalancer 服务都会永久保持在 <pending>,因为没有任何组件为其分配地址。删除 traefik 后,集群中不会有 ingress controller,因此 Ingress 对象完全不会生效。只有在通过其他方式提供流量时才禁用这些组件,例如在主机上运行反向代理;如果正在使用它们,则保留启用状态。disable-helm-controller: true 会删除监视 HelmChart 资源的 controller;如果您自行运行 helm,则不需要这个特权组件。
停止自动挂载默认的 ServiceAccount 令牌
除非另有指定,否则每个 pod 都会在 /var/run/secrets/kubernetes.io/serviceaccount/token 获取一个 ServiceAccount 令牌。default ServiceAccount 没有 RBAC 权限,因此仅凭该令牌通常无法完成多少操作。对于已被攻陷容器内的攻击者来说,它提供的是有效凭据和可访问的 API server,而这正是大多数集群权限提升过程的第一步。
k3s 加固指南按 namespace 禁用此功能:
kubectl patch serviceaccount --namespace default default --patch '{"automountServiceAccountToken": false}'
kubectl patch serviceaccount --namespace kube-node-lease default --patch '{"automountServiceAccountToken": false}'
kubectl patch serviceaccount --namespace kube-public default --patch '{"automountServiceAccountToken": false}'确认设置已生效。先等待几秒,让 pod 启动。
kubectl run t --image=busybox --restart=Never --command -- sleep 30
kubectl exec t -- ls /var/run/secrets/kubernetes.io/serviceaccount该路径已不存在,因此 ls 会输出 No such file or directory。确实需要访问 API 的工作负载可在自己的 pod spec 中设置 automountServiceAccountToken: true,因此不会被永久阻止访问。单独来看,这只是一个小幅改进。更重要的是,不要向工作负载授予具有实际权限的 ServiceAccount。您可以查看令牌当前具有什么权限:
kubectl auth can-i --list --as=system:serviceaccount:default:default资源限制:避免单个 Pod 令整个集群停止运行
在单节点环境中,控制平面和工作负载共享同一个内核和同一组内存。发生内存泄漏的 Pod 不一定只会自行退出。内核的 OOM(内存不足)杀手会根据评分选择目标,并倾向于终止占用内存较多的进程。k3s 是一个长期运行且占用较多资源的进程,因此消失的可能是集群,而不是 Pod。此时没有其他组件会重启工作负载,因为负责重启工作负载的组件已经终止。
LimitRange 会为未设置资源限制的 Pod 补充默认值:
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: default
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 50m
memory: 128MiResourceQuota 会限制整个命名空间可以申请的资源总量:
apiVersion: v1
kind: ResourceQuota
metadata:
name: cap
namespace: default
spec:
hard:
limits.cpu: "3"
limits.memory: 3Gi
pods: "20"然后在 /etc/rancher/k3s/config.yaml 中为 k3s 本身预留资源:
kubelet-arg:
- 'system-reserved=cpu=250m,memory=512Mi'两者的区别会体现在两个地方。容器因超出自身限制而被终止时,kubectl describe pod 中的 Last State 下会报告 Reason: OOMKilled,节点上的其他工作负载仍可继续运行。节点完全耗尽内存时,dmesg 中会出现 Killed process 行,并且通常会连带影响其他工作负载。前者说明资源限制正在发挥作用。后者正是资源限制要避免的情况。
按计划扫描 CI 中的清单和 k3s 集群
扫描应放在两个位置,因为它们发现的问题不同。先安装 Trivy。该项目每次发布都会提供 Debian 软件包;2026 年 8 月的当前版本是 0.74.0,因此在将版本固定到自动化流程前,请先查看 releases 页面是否有更新版本。
sudo apt-get install -y wget
wget -q https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.deb
sudo dpkg -i trivy_0.74.0_Linux-64bit.deb
trivy --version第一个位置是清单进入集群之前。
trivy fs --scanners misconfig --severity HIGH,CRITICAL --exit-code 1 ./deploy--exit-code 1 在发现问题时使 CI(持续集成)作业失败。每个结果都会列出出错字段及其严重性。因此,未计划提交的 privileged: true 会使构建失败,而不会进入 API server。决定接受的问题应写入 .trivyignore 文件,并与导致该问题的清单一起保存在 git 中。
第二个位置是运行中的集群,按计划进行扫描。
trivy k8s --compliance=k8s-cis-1.23 --report summary需要说明该命令的一个限制。trivy k8s 会部署一个 node collector pod,需要访问主机才能检查节点级设置。如果你刚开始拒绝特权 pod,就应注意这一点,而不是绕过限制。trivy k8s --report summary --disable-node-collector 会跳过 collector,同时失去节点级检查。
kube-bench 检查主机侧配置:文件权限和进程参数。CIS(互联网安全中心)基准的大部分内容都在这里。它提供 k3s 配置文件。使用上游的 Job 清单并进行调整。
curl -sfL -o kube-bench-job.yaml https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml将容器命令改为 ["kube-bench", "--benchmark", "k3s-cis-1.7"],并将 /etc/kubernetes 和 /var/lib/etcd 挂载替换为 /etc/rancher 和 /var/lib/rancher,因为 k3s 将文件保存在这些路径下。然后:
kubectl apply -f kube-bench-job.yaml
kubectl logs -l app=kube-bench --tail=-1注意该 Job 的组成:hostPID: true 加上主机目录挂载。这正是你在上一节开始拒绝的 pod 形态。将它运行在已豁免的 kube-system namespace 中,读取输出,然后执行 kubectl delete job kube-bench。扫描器必须使用特权,并不意味着你应停止禁止特权工作负载。
镜像内容是另一个问题。trivy image ghcr.io/example/app:1.4 会读取镜像内的软件包数据库,并列出已知存在漏洞的软件包;这与检查服务器是否存在已知 CVE相同。
现在说明扫描器输出的实际含义。单节点个人集群会有一长串 CIS 控制项失败,其中大多数失败既是正确的,也与你无关。该基准面向多节点、多租户集群:etcd 运行在独立主机上,审计日志发送到其他位置,kubelet 使用独立的证书颁发机构,并启用适用于特定合规要求的 admission plugin。k3s 有意将控制平面作为单个进程运行,并使用一个配置文件。因此,检查 kube-scheduler 清单文件权限的控制项无法通过,因为该文件不存在。
按以下顺序阅读失败项,并在收益耗尽时停止:检查 /etc/rancher 和 /var/lib/rancher 下的文件模式与所有权;检查提及匿名或未认证访问的项目;检查报告某组件绑定到 0.0.0.0 的项目;检查没有合理原因却以 UID 0 运行的容器。其余项目可以等到增加第二个节点,或有第二位具备访问权限的人员后再处理。一份你不会处理的百行报告,不如一份你会采取行动的五行报告。
单节点 k3s 加固顺序
- 将 ufw 的默认入站策略设为 deny,允许 SSH,从您自己的地址允许 6443,并允许 Pod 网络和 Service 网络。
- 将 kubeconfig 以 mode 600 复制到您的用户目录中,绝不要设置
--write-kubeconfig-mode 644。 - 确认 kubelet 在 10250 上拒绝匿名请求,并确保该端口不暴露到互联网。
- 禁用不使用的内置组件,然后重启 k3s。
- 使用
enforce=baseline和warn=restricted为命名空间设置 Pod Security 准入标签。 - 关闭默认的 ServiceAccount token 自动挂载。
- 添加 LimitRange 和 ResourceQuota,并为 k3s 预留 CPU 和内存。
- 在 CI 中加入
trivy fs --scanners misconfig,并每月运行一次 CIS 扫描。
底层主机仍需像其他服务器一样维护,而 k3s 还带来一个额外问题。它由脚本安装,而不是通过 apt 安装,因此 apt upgrade 永远不会处理它。请按照单独的计划为操作系统安装补丁,使用 在 Ubuntu 上启用 unattended upgrades,并通过重新运行其安装程序,按需指定 channel 或 version,有计划地升级 k3s。同一台机器上存在两条更新路径,很容易被遗忘,因此请记录每个组件分别遵循哪条路径。
FAQ
将 k3s API 服务器的 6443 端口暴露到互联网是否安全?
仅开放该端口本身并不等于直接开放访问,因为 API 服务器要求客户端证书或令牌,否则会返回 forbidden: User "system:anonymous"。但仍有两个风险。匿名调用方仍可读取 /version,扫描器可据此确定要查询的 Kubernetes 版本。端口开放期间,未来 API 服务器中的任何漏洞都可能被远程利用。单节点集群不需要让外部访问 6443,只有您自己的 kubectl 需要访问。因此,请使用 sudo ufw allow from YOUR_IP to any port 6443 proto tcp 仅允许您的地址访问,并让默认拒绝策略处理其他流量。
为什么我的 ufw 规则无法阻止 NodePort 服务?
因为数据包不会到达您的规则所在的链。kube-proxy 会在 nat 表的 PREROUTING 链中添加 DNAT 规则。该链首先运行,并将目标地址改写为 Pod 地址。随后数据包会被转发,而不是在本机交付,因此会经过 FORWARD,跳过 ufw 规则所在的 INPUT。使用 sudo iptables -S PREROUTING -t nat | head 可确认这一点。在云服务商的网络防火墙中过滤 NodePort,或者避免使用 type: NodePort,改用 kubectl port-forward 访问服务。
我应该使用 --write-kubeconfig-mode 644 运行 k3s 吗?
不应该。k3s 文档明确说明了该设置的作用:“将模式改为 644 后,主机上的其他非特权用户也可以读取该文件。”该文件包含用于 system:masters 的客户端证书,因此模式 644 会使每个本地账户都成为集群管理员。请改用 sudo install -o "$USER" -g "$USER" -m 600 /etc/rancher/k3s/k3s.yaml ~/.kube/config 将其复制给一个用户,并将原文件保留为 600 root:root。
k3s 应使用哪个 kube-bench 基线?
使用 k3s-cis-1.7。kube-bench 文档说明:“kube-bench 包含 Rancher K3S 平台的基线。运行 kube-bench 命令时,必须指定 --benchmark k3s-cis-1.7。”请显式传递该参数,因为自动检测会假定使用 kubeadm 布局,而 k3s 会将文件保存在 /etc/rancher 和 /var/lib/rancher 下。单节点环境中有些检查失败并不适用。应首先处理文件权限和匿名访问问题。
Pod Security admission 会破坏 k3s 自带的组件吗?
如果您在 kube-system 上强制启用它,就会。k3s 为 type: LoadBalancer 服务创建的 ServiceLB Pod 会在主机上占用端口,而 baseline 禁止使用主机端口,因此这些 Pod 下次重建时会被拒绝。请在 admission 配置文件中豁免 kube-system,或者只对您管理的命名空间使用标签启用 Pod Security。先使用 enforce=baseline 和 warn=restricted,以便在启用前查看 restricted 会导致哪些组件无法运行。