最近在折腾边缘设备上的容器编排,把自己社区活动这段时间的k3s实战笔记重新整理了一遍。先说结论:如果你的场景是树莓派、Jetson、工业网关这类资源受限设备,又希望用Kubernetes的声明式部署和自愈能力,k3s基本是当前最顺手的方案。这篇文章从"为什么非它不可"讲到"集群搭起来之后怎么用",包含了完整命令、yaml和我在真实设备上踩过的坑,适合刚接触k8s、想在边缘侧落地容器编排的开发者参考。
1. 边缘计算场景里,K8s的"重"是原罪
1.1 全量K8s在边缘设备上到底卡在哪
先聊聊为什么标准Kubernetes在边缘场景经常"水土不服"。K8s本身设计目标是支撑大规模、高可用的数据中心集群,这决定了它的运维底座必须足够重:etcd承担集群状态的强一致性存储,kube-apiserver、kube-controller-manager、kube-scheduler分别负责API网关、资源调谐和调度决策,再加上kubelet和容器运行时,一套控制面组件光是常驻内存就要占掉2GB左右。这个成本在服务器上可以忽略,但在只有2GB内存甚至1GB内存的边缘设备上,几乎就是灾难。
更麻烦的是安装复杂度。标准K8s集群要么用kubeadm走一堆预检和初始化步骤,要么依赖各个云厂商的托管服务。边缘设备通常分布在网络条件一般的现场环境,很多设备还在NAT后面,控制面和业务节点之间的通信模式也跟数据中心里"内网全通"的假设完全不同。如果坚持用标准K8s,你会发现大量精力不是花在业务容器上,而是花在"怎么把集群跑起来"以及"怎么让它在弱网环境下保持存活"上。
另外还有一个被很多人忽视的点:边缘设备的磁盘压力和日志压力。标准K8s默认会起一堆系统级组件,比如各种admission controller、调度器扩展、云控制器,这些组件在数据中心的场景是合理的,但在边缘设备上每次启动、每次watch都会产生额外的IO。SD卡和eMMC这类存储本来就寿命有限,长期高IO很容易让存储先于设备报废。
1.2 k3s的核心策略:把不必要的都拆出去
k3s做了一件很朴素的事:把所有边缘场景用不到或者可以被替代的组件全部重新审视一遍,能内嵌的内嵌,能替换的替换,能拆掉的拆掉。安装产物是一个单一的二进制文件,同时包含server端、agent端和所有依赖的运行时组件,大小几十MB级别。
几个关键设计:
- 用SQLite替代etcd:单节点模式下,k3s直接使用SQLite作为存储后端。多Server高可用时,可以换用内置的DQLite,或者外接etcd。对于边缘场景,绝大多数集群是单控制面节点,SQLite足够满足几十个节点的调度数据量。
- 内置containerd:k3s把containerd直接打包进二进制里,不需要单独安装Docker或containerd。它和kubelet之间通过CRI沟通,这条链路是官方支持的、也是K8s社区主流的方向。
- 组件可裁剪:Traefik Ingress、ServiceLB、flannel、local-path-provisioner、CoreDNS、metrics-server这些默认组件都支持通过
--disable参数关闭。边缘设备上如果你用不到HTTP入口,可以直接把Traefik摘掉省出一块内存。 - 单进程多角色:一个k3s二进制既可以是server,也可以是agent。server自带kubelet,所以最极端的场景下,一台设备同时承担控制面和业务节点两种角色。
我在Jetson Nano和树莓派4B上都跑过,空闲状态下server进程的内存占用大约在300到500MB之间,agent节点基本在150到250MB。这个数字比标准K8s控制面动辄1GB往上的占用友好太多了。
1.3 Docker、K8s、k3s是什么关系:从一道高频面试题说起
很多人经常把Docker和K8s放在对立面比较,网上文章也动不动就写"K8s和Docker的区别"。严格来说,K8s调度的最小单位是Pod,Pod里跑着由一个或多个容器组成的业务实例,K8s本身不负责启动容器,而是通过CRI接口调用容器运行时。Docker可以作为一个运行时实现,但K8s现在的默认运行时通常是containerd。
k3s在这个层级里做的事情是:把K8s控制面和运行时打包成一个好分发的产物。它内部用的不是Docker,而是containerd。所以你会发现装完k3s的机器上没有docker命令,取而代之的是k3s ctr和k3s cri ctl。如果你有业务依赖Docker特殊能力,k3s也提供了--docker参数可以改用Docker作为运行时,但我不推荐在资源有限的设备上这么做,多一个daemon就多一层内存开销。
下面的表可以帮你快速理清这几个概念的分工:
| 组件 | 定位 | 一句话说明 |
|---|---|---|
| Docker | 容器运行时+镜像构建工具链 | 负责"把一个容器跑起来"的底层引擎 |
| containerd | 容器运行时 | 更轻的运行时层,被K8s/k3s默认使用 |
| K8s | 容器编排平台 | 负责"在成百上千台机器上调度和运维容器" |
| k3s | K8s轻量发行版 | 把K8s控制面+containerd等打包成单二进制,开箱即用 |
所以K8s和Docker不是二选一的关系,它们在技术栈中处于不同层次;k3s也不是某个全新的编排系统,它就是K8s的一种"打包方式"。把这个底层关系理解了,后面看安装参数和报错信息都会顺很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零搭起一套k3s集群:安装、验证、扩容
2.1 环境准备:哪些边缘设备能跑k3s
先明确一个经验值。k3s官方给的最低要求是512MB内存,但这个数字只够"勉强启动",我实际测试下来,server节点最好有1GB以上内存才能在跑业务容器时保持稳定;agent节点512MB勉强能跑,但别忘了你还要在它上面调度业务Pod。CPU方面,ARMv7、ARM64、AMD64都支持,树莓派3B+、4B、Jetson Nano之类的设备完全在支持范围内。
磁盘方面,建议至少准备8GB可用空间。容器镜像会占一部分,local-path-provisioner还会给每个PV预留空间。如果设备用的是TF卡,强烈建议后续做一下OverlayFS或把容器数据目录挂到外置SSD上,别让镜像写入直接怼在卡上,否则卡寿命会掉得很快。
操作系统建议用Ubuntu 20.04以上的LTS版本,或者Debian 11/12。CentOS 7的内核和iptables版本容易跟k3s的默认网络策略打架,遇到问题的概率偏高。
2.2 安装server节点:一条命令搞定
k3s的官方安装脚本是很多人第一次接触它的入口,命令非常简单:
bash复制curl -sfL https://get.k3s.io | sh -
这条命令会把k3s安装为systemd服务,默认使用containerd作为运行时,并自动创建/etc/rancher/k3s/k3s.yaml这个kubeconfig文件。装完以后,直接使用:
bash复制k3s kubectl get node
就能看到集群状态,一个节点,Ready状态。这里的k3s kubectl是一个内置的kubectl命令,本质上就是把kubectl二进制嵌进了k3s,省去了额外安装的步骤。你完全可以用系统里单独的kubectl,只要把k3s.yaml里的server地址改成https://127.0.0.1:6443、并指定权限文件就能正常连接。
需要说明的是,get.k3s.io这个安装脚本在国内网络环境下有时下载速度不理想。我自己的做法是:在可以正常访问外网的机器上,把安装脚本和中英文release tar包一起下载好,再传到目标设备执行离线安装。具体做法是:到GitHub Releases页面下载k3s和k3s-airgap-images-amd64.tar(ARM设备对应的文件名是k3s-airgap-images-arm64.tar),放到/var/lib/rancher/k3s/agent/images/目录下,再执行INSTALL_K3S_SKIP_DOWNLOAD=true ./install.sh,这样安装过程完全不依赖外网。
2.3 加入agent节点:把另一台设备变成计算节点
边缘场景里你通常不止一台设备,可能是"一台边缘网关做主控,几台采集设备做算力节点"的布局。这时候需要把其他设备作为agent加入server。
在server上先取token:
bash复制cat /var/lib/rancher/k3s/server/node-token
然后在要加入的设备上执行:
bash复制curl -sfL https://get.k3s.io | K3S_URL=https://<server-ip>:6443 K3S_TOKEN=<node-token> sh -
K3S_URL中的<server-ip>必须是agent能够访问到的地址。我在实际项目里遇到过一个很典型的问题:server节点同时有有线网卡和无线网卡,agent明明就在同一个无线网段,安装时却怎么都连不上。排查半天发现,安装脚本默认使用默认路由对应的IP,而server进程绑定的是有线网卡的IP。解决办法是装server时显式指定--node-ip和--advertise-address,把真正用于边缘互联的网卡IP固定下来。
2.4 一步到位的微调:安装时直接配置常用参数
如果你已经知道这台设备接下来要承担什么角色,建议在安装阶段就把参数带好,避免后面再改。我常用的组合:
bash复制curl -sfL https://get.k3s.io | sh -s - server \
--node-ip=192.168.1.100 \
--advertise-address=192.168.1.100 \
--disable=traefik \
--disable=servicelb \
--disable=metrics-server \
--data-dir=/data/k3s
参数说明:
--node-ip:指定节点对外通信的IP,多网卡设备务必设置。--advertise-address:server端向agent广播的地址,多网卡场景的必选项。--disable=traefik:不需要HTTP入口时可以关掉,省内存。--disable=servicelb:不需要LoadBalancer类型服务时关掉。--data-dir:将k3s数据目录移到数据盘,有SSD或独立分区时强烈建议这么做。
2.5 多server高可用:边缘场景是否需要
边缘场景到底要不要做高可用,我的判断标准很简单:如果这个边缘点位挂了会导致产线停工或核心业务中断,那就值得上多server;如果只是数据采集边缘盒,偶尔重启一次无伤大雅,单节点反而更省心。
k3s多server模式有两种实现方式。一种是用内置DQLite,安装时给每个server节点带上--cluster-init参数,其他节点再以server模式加入,k3s会自动组成一个DQLite集群;另一种是外部etcd,适合已经有etcd运维经验、希望复用现有监控体系的团队。DQLite模式部署简单,但它在高并发写入和网络抖动时的表现不如外部etcd,边缘场景网络不稳定,我反而建议有条件就直接用嵌入式etcd或外部etcd。
2.6 三个验证集群状态的基本命令
装完集群后,我一般会依次跑三个命令确认一切正常:
bash复制k3s kubectl get nodes
k3s kubectl get pods -A
k3s kubectl get events --sort-by=.lastTimestamp | tail -20
第一个命令看节点是否Ready,第二个命令看系统组件是否都在Running状态,第三个命令看有没有异常的调度事件。如果某个Pod一直Pending,大概率是资源不足;一直CrashLoopBackOff,优先看镜像是否拉取成功和配置是否正常,这类问题后面实战部分会展开。
3. 实战:在k3s上部署一套边缘数据采集服务
3.1 先想清楚:边缘节点上跑应用和云上有什么区别
集群搭好以后,接下来要面对的问题是:在边缘设备上部署应用时,选型和设计上和云上到底差在哪?我的体会是三个字:资源省。边缘节点CPU主频普遍不高、内存吃紧、存储IO慢,镜像能精简就精简,组件能合并就合并,不要把云上那套"微服务拆到底、中间件上一堆"的做法直接搬过来。
本次实战我在Jetson Nano这台设备上部署一套"温度数据采集+MQTT上报"的边缘服务,包含两个组件:一个模拟温度传感器的采集器,一个MQTT Broker用来中转数据。整个方案一共有3类资源:ConfigMap、Deployment、Service,完整覆盖了日常使用k3s最核心的对象类型。
3.2 ConfigMap:把环境差异从镜像里剥离出来
边缘设备最大的特点是"每个点位的配置都不一样":设备编号不同、采集间隔不同、数据上报的服务端地址不同。如果你把这些东西写死在镜像里,每次改配置都要重新构建镜像,非常麻烦。k3s(或者说K8s)的做法是用ConfigMap把配置从镜像里剥出来。
以下是我在项目里实际用到的ConfigMap:
yaml复制apiVersion: v1
kind: ConfigMap
metadata:
name: sensor-collector-config
namespace: edge
data:
device-id: "device-001"
sample-interval: "5"
mqtt-broker: "tcp://edge-mosquitto:1883"
mqtt-topic: "sensor/temperature"
其中mqtt-broker字段的值是edge-mosquitto,这是后面Service的名字,Pod之间通过DNS就能直连,周期性的采集任务完全不需要知道MQTT Broker在哪个节点上。这个细节就是K8s服务发现机制带来的实际便利。
3.3 业务代码与Deployment编排
采集器的代码逻辑不复杂,轮询传感器数据、转成JSON、通过MQTT发布,这里把核心部分简化成下面的Python脚本:
python复制import os
import json
import random
import time
import paho.mqtt.client as mqtt
device_id = os.getenv("DEVICE_ID", "unknown")
interval = int(os.getenv("SAMPLE_INTERVAL", "5"))
broker = os.getenv("MQTT_BROKER", "localhost")
topic = os.getenv("MQTT_TOPIC", "sensor/temperature")
client = mqtt.Client()
client.connect(broker, 1883, 60)
while True:
data = {
"device_id": device_id,
"temperature": round(random.uniform(20.0, 45.0), 2),
"humidity": round(random.uniform(40.0, 70.0), 2),
}
client.publish(topic, json.dumps(data))
print(f"published: {data}")
time.sleep(interval)
对应的Deployment如下:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: sensor-collector
namespace: edge
spec:
replicas: 1
selector:
matchLabels:
app: sensor-collector
template:
metadata:
labels:
app: sensor-collector
spec:
containers:
- name: sensor-collector
image: python:3.11-alpine
command: ["python", "/scripts/main.py"]
env:
- name: DEVICE_ID
valueFrom:
configMapKeyRef:
name: sensor-collector-config
key: device-id
- name: SAMPLE_INTERVAL
valueFrom:
configMapKeyRef:
name: sensor-collector-config
key: sample-interval
- name: MQTT_BROKER
valueFrom:
configMapKeyRef:
name: sensor-collector-config
key: mqtt-broker
- name: MQTT_TOPIC
valueFrom:
configMapKeyRef:
name: sensor-collector-config
key: mqtt-topic
volumeMounts:
- name: script
mountPath: /scripts
volumes:
- name: script
configMap:
name: sensor-collector-script
这个示例里有两个关键点。一是env里的配置直接取自ConfigMap,换一个设备只要换ConfigMap,不需要动镜像。二是脚本本身通过configMap卷挂载到容器内,我把main.py作为key放进了configMap,然后在Pod里以卷形式挂载。这样可以完全避免为了一个脚本去构建私有镜像。
不过要说明:用ConfigMap挂载大脚本不是最佳实践,生产环境建议把业务构建成镜像上传到私有仓库。这里选择这个演示方式,只是为了让你看到"k3s不要求你必须先搭一套镜像仓库才能开始玩"。
3.4 Service:让采集器和Broker在集群内部找得到彼此
有了Deployment后,Pod会被调度到某个节点上,Pod自身的IP是临时的、重启后就变。Service的作用就是提供一个稳定的访问入口。我在这里给MQTT Broker创建了一个ClusterIP类型的Service,采集器直接访问edge-mosquitto:1883:
yaml复制apiVersion: v1
kind: Service
metadata:
name: edge-mosquitto
namespace: edge
spec:
selector:
app: mosquitto
ports:
- port: 1883
targetPort: 1883
创建这个Broker的Deployment时,注意镜像选择:Eclipse Mosquitto官方镜像只有几MB,非常适合边缘设备,不要选择带Web管理界面的镜像,管理界面意味着额外的前端静态资源和依赖库,没必要占那几十MB空间。
3.5 最终验证:把Pod日志和MQTT消息串起来
所有资源创建完成后,验证环节是必不可少的。先看Pod都起来了没有:
bash复制kubectl -n edge get pods
然后看采集器日志:
bash复制kubectl -n edge logs deploy/sensor-collector
如果一切正常,日志里会周期性输出published: {...},同时在MQTT Broker的Pod日志里也能看到对应的连接和上报消息。这个验证闭环是K8s体系里最基础的"编排成功"判断标准——不是看某个容器单独跑起来,而是看它们通过网络互相能访问、数据能流通。
4. 边缘设备上绕不开的4个性能与存储坑
4.1 单SQLite存储:并发写入的瓶颈与对策
很多从标准K8s迁移过来的同学会担心:SQLite并发性能行吗?这里要先把场景说清楚。k3s的SQLite存储的是"集群状态",比如Pod、Deployment、Node的spec和status,不是业务数据。业务数据走的是数据库或文件系统,跟k3s的存储没有任何关系。集群状态的写入频率远低于业务数据,在几十个节点的规模下SQLite完全能扛。
但有一个例外需要注意:如果你的边缘设备上大量使用kubectl exec、频繁创建并销毁短生命周期Job、或者反复watch某个对象,这些操作会产生大量的API请求和etcd/state store读写。在SQLite模式下,这些操作全部落在单个节点的磁盘IO上,SD卡性能会直接影响集群响应速度。我在树莓派4B上做过一次测试:连续创建销毁100个Job,SD卡的IO等待时间明显升高,集群API响应时间从正常的20ms涨到了100ms以上。对策是:尽量不要在边缘节点上做大量短周期Job调度,把这类任务放到中心侧执行,或者用cronJob时拉大周期。
4.2 小内存设备的内存回收方案
边缘设备最容易出现的故障是Pod被OOM Killer干掉,最典型的场景是Jetson Nano只有4GB内存,运行一个模型推理服务加上k3s系统组件后,剩余内存往往不到1GB。我遇到过的案例是:某个采集器Pod刚启动时申请了200MB内存,运行一段时间后随着缓存增长超过了limit,被内核杀掉重启,配合RestartPolicy看起来像"一直CrashLoopBackOff"。
解决方案分两层。第一层在Pod层面,给每个容器设置合理的requests和limits,让调度器知道这台设备还能塞下多少Pod,同时避免单个Pod无上限吃内存。第二层在节点层面,调整kubelet的--kubelet-arg参数,比如:
bash复制--kubelet-arg=system-reserved=memory=256Mi \
--kubelet-arg=kube-reserved=memory=256Mi \
--kubelet-arg=eviction-hard=memory.available<200Mi
这样做的意思是:先给系统进程和k3s自身留出充足内存,再通过硬驱逐阈值在内存即将耗尽之前开始回收Pod,而不是等OOM Killer直接乱杀。
4.3 local-path-provisioner与边缘存储选型
k3s默认集成了local-path-provisioner,这意味着你创建一个PVC,它会自动在节点本地目录里划分一个PV,不需要额外的NFS或Ceph。这个设计可以说非常懂边缘场景:边缘设备根本没有条件搭分布式存储,本地目录能满足八成以上"数据要落盘"的需求。
但这里有一个很容易忽略的细节:local-path-provisioner默认的数据目录是/var/lib/rancher/k3s/storage,如果你用的是TF卡,那就等于所有PV都写到了TF卡上。每次容器写日志、写中间结果,都是在磨损这张卡。我踩过这个坑之后,会把--data-dir指到SSD目录上,同时建议在创建StorageClass时调整参数:
yaml复制apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-path-ssd
provisioner: rancher.io/local-path
parameters:
nodePath: /data/k3s-storage
把nodePath指向SSD挂载点,之后再建PVC时指定storageClassName: local-path-ssd,PV就会落在SSD目录,而不是默认的SD卡目录。这样做的好处是每次读写PV都不再拖慢整机IO。
4.4 时间同步和断电重启对集群的影响
边缘环境的网络条件远不如数据中心,很多设备放在户外或有线网络不稳定的点位,时间偏差的问题经常被忽略。K8s集群对时间非常敏感:证书签发需要时间判断,与API Server的加密通信依赖TLS证书有效期,如果节点时间比Server晚了几个小时,kubectl get nodes里可能直接看不到这个节点,或者报证书无效。
解决方案很朴素,但又必须落地:在所有边缘节点部署systemd-timesyncd或chrony,并配置可靠的时间源。我在项目里直接在安装脚本的最后追加了NTP服务安装和启动,保证每次装完系统自动同步时间。另外,断电重启也是边缘设备的常态。k3s的systemd服务默认设置了Restart=on-failure,但重启后如果存储目录没有正确挂载,数据可能出现不一致。建议在设备重启脚本里加上一条"确保数据盘先挂载、再启动k3s"的顺序控制,避免k3s在数据盘尚未就绪时抢先启动导致数据目录损坏。
5. 把k3s玩得更顺手的几点额外配置
5.1 私有镜像仓库的二三事
边缘设备对外访问网络不总是畅通,很多环境里既访问不了Docker Hub,也访问不了GitHub Releases,这时候必须有一个方案解决镜像分发问题。k3s的离线安装能力前面已经提过,镜像的离线导入也有对应手段。在可以联网的机器上拖下镜像,保存为tar包:
bash复制docker pull python:3.11-alpine
docker save python:3.11-alpine -o python-alpine.tar
传到目标设备后,用k3s内置的containerd接口导入:
bash复制k3s ctr images import python-alpine.tar
这样可以完全脱离Docker Hub完成镜像加载。如果你管理的设备数量比较多,更推荐在局域网内搭建一个轻量镜像仓库,比如用Harbor或者直接用Registry2容器,把边缘设备统一配置到私有仓库拉镜像。
5.2 边缘节点如何划分职责
多节点集群里,职责划分很重要。边缘场景常见的做法是:控制面节点不要运行业务Pod,或者只运行轻量业务;数据采集型任务固定调度到某些网段的节点。k3s支持给节点打标签,然后通过nodeSelector把Pod固定到这组节点上:
bash复制kubectl label node node-1 role=edge-collector
然后在Deployment里加上:
yaml复制spec:
nodeSelector:
role: edge-collector
这样做的意义在于:控制面节点随时可能因为k3s升级或维护而重启,如果业务Pod和控制面耦合在一起,一次重启就会同时影响集群控制能力和业务服务,排查问题时要多绕几个弯。
5.3 监控和告警的轻量组合
边缘集群不需要部署全套Prometheus生态,太占资源。我目前在用的组合是:k3s自带的metrics-server采集节点和Pod的基础指标,再通过kubectl top nodes和kubectl top pods查看资源占用。这些数据足够覆盖日常巡检的大部分需求。如果你想做更长期的趋势分析,可以在中心侧部署一组Prometheus + Grafana,通过remote write把多个边缘集群的指标汇到一起统一展示。不过这是后话,初学阶段先把kubectl top用熟就够了。
最后再分享一个小技巧:k3s的/etc/rancher/k3s/config.yaml配置文件可以让安装脚本自动读取,把之前命令行里的参数全部固化到文件里,这样重装节点时不用重复敲那一长串命令,而且团队协作时也能保证所有节点安装参数一致。我把这些细节都放进配置文件之后,边缘节点扩容的时间基本控制在十分钟以内——前提是镜像和二进制都提前备好。这也是k3s在边缘场景里最打动我的地方:它足够轻,轻到你可以像管理一组普通Linux服务一样去管理一个Kubernetes集群;而它又是标准的K8s,意味着你学到的编排思想、yaml写法、排障思路,将来面对再大规模的生产集群时全部能用得上。
