k3s 单机容器编排实战:个人站长用 500M 内存跑起 K8s,安装三坑与部署排错全流程

为什么个人站长该看看 k3s,而不是直接上 Kubernetes

很多人一听到容器编排就觉得那是大厂才用得上的东西,一台 2 核 4G 的小机器谈 Kubernetes 简直是笑话。但实际上一套完整版 Kubernetes 装下来,控制平面自己就要吃掉 1.5G 到 2G 内存,还没跑任何业务,机器就先喘上了。个人站长真正会遇到的问题不是"我要不要用编排",而是"我有三五个小服务、两个容器、一个数据库,怎么用最少的手工步骤让它们自己起来、自己重启、自己换证书"。

k3s 就是为这个场景做的。它是 Rancher 出的一个经过认证的 Kubernetes 发行版,但把大量非必需的组件打包替换掉了:etcd 换成 SQLite(单机场景完全够用),云控制器、存储插件、旧版 API 都做了裁剪,最终是一个不到 100MB 的二进制文件加一个 systemd 服务。装完之后控制平面空闲内存占用大约在 400M 到 600M 之间,512M 内存的小鸡都能跑起来。

这篇文章不讨论架构哲学,只讲一个真实场景:在一台 4 核 8G 的 Debian 机器上,用 k3s 把几个站点相关的服务(Nginx、一个 Node 应用、一个定时任务、PostgreSQL)组织起来,并且把过程中真正会让人卡住的那些点讲清楚。这些点如果不知道,报错信息往往和真实原因差着十万八千里。

安装 k3s:一行命令背后的三个默认值陷阱

官方安装命令确实就一行:

curl -sfL https://get.k3s.io | sh -

跑完之后 kubectl get node 就能看到节点是 Ready 状态。但如果你直接这样装完就开始部署业务,多半会在半小时内踩到下面三个坑之一。

陷阱一:默认用了 Flannel 并且认错了网卡

k3s 默认使用 Flannel 作为 CNI,它启动时会自动挑选一块网卡来承载 Pod 网络。挑选逻辑是找默认路由对应的接口——对于绝大多数只有一块公网网卡的 VPS 来说是对的。但如果你的机器上装了 Docker,出现了一个 docker0 网桥,或者你之前配过 VPN 有 tun0、wg0,Flannel 有可能挑错接口,然后表现出一系列非常迷惑的症状:Pod 之间 ping 不通、CoreDNS 一直 CrashLoopBackOff、服务起来之后访问超时。

判断方法很简单,看节点上 Flannel 使用的地址:

kubectl get node -o wide
# INTERNAL-IP 那一列如果不是你的公网/内网真实 IP,就是挑错了
ip -4 addr show
ip route | head -5

修复方式是在安装或启动时显式指定接口。用环境变量的形式写进 systemd:

# 编辑 /etc/systemd/system/k3s.service(或 /etc/systemd/system/k3s.service.env)
sudo tee /etc/systemd/system/k3s.service.env >/dev/null <<'EOF'
K3S_NODE_IP=10.0.0.5
FLANNEL_IFACE=eth0
EOF
sudo systemctl daemon-reload && sudo systemctl restart k3s

注意 K3S_NODE_IP 用的是机器真实 IP,不是 127.0.0.1。这一点在后面配 Nginx 反代的时候还会再遇到一次。

陷阱二:默认的 Service 网段和你的内网撞车

k3s 默认给的 Pod 网段是 10.42.0.0/16,Service 网段是 10.43.0.0/16。如果你机器所在的内网本身就用了 10.42.x.x(很多云厂商的内网、机房的内网、家里路由器用的就是这个段),那从节点上访问内网的其他机器就会莫名其妙地跑到集群内部去,表现为 SSH 其他机器超时、访问内网数据库连不上。

排查方式:

ip route | grep 10.42
ip route | grep 10.43
# 如果这两条出现在路由表里,而你内网也用这个段,就是撞了

改法是在安装时用参数指定别的网段,这个必须一次性定好,装完再改要清理 CNI 配置和 iptables 规则,很麻烦:

curl -sfL https://get.k3s.io | sh -s - \
  --cluster-cidr=10.244.0.0/16 \
  --service-cidr=10.245.0.0/16 \
  --cluster-dns=10.245.0.10

改完之后记得确认集群 DNS 的地址也跟着变了,cluster-dns 必须落在 service-cidr 范围内,否则 CoreDNS 的 Service 建不起来。

陷阱三:默认的 kubeconfig 权限是 600 且只有一个用户能读

/etc/rancher/k3s/k3s.yaml 的属主是 root,权限 600。普通用户执行 kubectl 会报 connection refused 或者 permission denied。这不是集群有问题,就是读不到配置。做法是复制一份到自己的 home 并改好 server 地址:

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
# 默认里面写的是 127.0.0.1,如果要远程用 kubectl 就改成节点真实 IP
sed -i 's/127.0.0.1/10.0.0.5/' ~/.kube/config

如果想从自己的电脑上远程管理,直接把这份文件拷到本地,改掉 server 地址,再把 /etc/rancher/k3s/k3s.yaml 里对应 CA 的公钥带上即可——k3s 默认的证书里已经签进了节点 IP,只要用这个 IP 访问就不会报证书错误。不建议把 6443 端口暴露到公网,正确做法是走 SSH 隧道或者 WireGuard。

从 docker run 迁移到 Deployment:一份最小可用的站点编排

假设你原来用 docker compose 跑着一个 Node 应用加一个 Nginx,现在想搬进 k3s。最小可用的写法是这样,保存成 site.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: web
        image: registry.example.com/web:1.0.0
        ports:
        - containerPort: 3000
        env:
        - name: NODE_ENV
          value: production
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
        livenessProbe:
          httpGet:
            path: /healthz
            port: 3000
          initialDelaySeconds: 20
          periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 3000

这里有几个从 docker 思维切换过来时最容易忽略的地方。

镜像不能再用本地缓存

docker 里 image: web:latest 如果本地 build 过就能直接用。k3s 里每个节点是独立的容器运行时(默认 containerd),本地 docker 的镜像它看不见。单机 k3s 有两种解法:一是老老实实推到一个 registry(可以用 Harbor 或者云厂商的免费私有仓库);二是把镜像导进去:

docker save web:1.0.0 -o /tmp/web.tar
sudo k3s ctr images import /tmp/web.tar
sudo k3s ctr images ls | grep web

用 k3s ctr 而不是 ctr,因为 k3s 用的是自己那套 containerd 的 socket,用错命令会报连不上。另外 imagePullPolicy 在 tag 不是 latest 时默认是 IfNotPresent,如果你改了镜像但 tag 没变,Pod 重建后跑的还是旧镜像——这是非常经典的"我明明改了代码怎么没生效"。想强制拉取就显式写 imagePullPolicy: Always。

探针不是可选项

上面那份配置里 livenessProbe 指的是 /healthz。如果你的应用没有这个端点,探针会一直失败,Pod 会被反复重启,kubectl get pods 里 RESTARTS 列迅速涨到几十,然后进入 CrashLoopBackOff。要么给应用加上健康检查端点,要么先把探针去掉。不要随便写个 / 当探针——如果应用启动要加载缓存,20 秒内首页返回 500,同样会被判定为不健康然后杀掉重启,形成死循环。启动慢的服务应该用 startupProbe 单独给一段宽限时间。

replicas 大于 1 之前先想清楚有没有状态

很多人会很自然地写 replicas: 3 觉得更稳。但如果应用把 session 存在本地内存、或者往本地磁盘写上传文件,多副本会立刻出问题:用户刷新一下就从 A 副本跳到 B 副本,登录状态没了;上传的图片只存在于某一个副本里,另外两个访问就是 404。修正方向是 session 外置到 Redis,上传目录改成对象存储或者挂一个共享的 PVC。没有把状态清干净之前,副本数就老老实实写 1。

把 Ingress 和自动证书接上

k3s 自带 Traefik 作为 Ingress 控制器,装完就已经在跑了,不需要另外装 nginx-ingress:

kubectl get pods -n kube-system | grep traefik
kubectl get svc -n kube-system traefik

Traefik 的 Service 是 LoadBalancer 类型,在 k3s 里由内置的 ServiceLB 组件实现,它会在节点上开 80 和 443。所以你不必再自己装一个 Nginx 监听 80。但如果机器上已经有一个 Nginx 占着 80 端口,Traefik 就会起不来,表现为 kubectl get svc 里 traefik 一直 <pending>,日志里报 address already in use。要么停掉原来的 Nginx,要么把 Traefik 的端口改成别的:

# 在 /var/lib/rancher/k3s/server/manifests/traefik-config.yaml 里
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
      web:
        port: 8088
        expose:
          default: true
      websecure:
        port: 8443
        expose:
          default: true

然后配一条 Ingress 指向你的服务:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
  - hosts: [www.example.com]
    secretName: web-tls
  rules:
  - host: www.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80

注意 ingressClassName: traefik 这行。k3s 新版本里如果不写,或者集群里有多个 IngressClass 而你用的是别的默认值,这条规则会被无视,访问域名就一直 404——而 kubectl describe ingress 里不会给你明显的错误提示,只会显示规则已创建。这个坑排查起来很费时间,因为你会以为是 DNS 或者防火墙问题。

证书用 cert-manager 自动签发。装完之后要在集群里建一个 ClusterIssuer,并且把 ACME 的 HTTP-01 challenge 走通。这里的关键是 Traefik 必须能收到 /.well-known/acme-challenge/ 的请求,而 cert-manager 生成的 solver 是一个独立的 Pod 和一个 Service。如果 Traefik 的 80 端口没在监听公网,或者域名解析还没生效,验证就会失败,kubectl describe certificate 里会显示 Waiting for HTTP-01 challenge propagation 然后超时。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
    - http01:
        ingress:
          class: traefik

证书签发成功后,会以 Secret 的形式出现在和 Ingress 同一个 namespace 里,名字对应 spec.tls[].secretName。Traefik 会自动读取并热加载,不需要重启任何东西。kubectl get certificate 的 READY 列变成 True 就说明成了,可以 curl -vI https://www.example.com 看一下证书链是不是 LE 签的。这里要特别注意:LE 的生产环境有速率限制,同一个域名一周只能签 50 张证书,反复删除重建测试很容易把自己锁死,建议先用 staging 环境的 server 地址调试。

几个高频故障的定位路径

k3s 出问题的时候,报错信息经常指向表象。下面这张对照表是实际运维中最常遇到的几种。

Pod 一直 Pending,事件里说 Insufficient memory

说明节点的可分配内存不够了。但要注意 kubectl top node 显示的已用内存可能远低于总量——因为 Kubernetes 计算的是 Allocatable,也就是总量减去 kubelet 预留和系统预留。kubectl describe node 里能看到详细的 Allocatable 值。如果你有一个 requests.memory: 2Gi 的 Pod,而节点 Allocatable 只有 1.8Gi,那它就永远起不来,哪怕当时实际空闲内存有 3G。这就是为什么 requests 要写得贴近真实用量,而不是随手写个整数。

Pod 起来了但服务访问 502

按顺序查三层:Service 的 selector 是否和 Pod 的 label 匹配(kubectl describe svc web 看 Endpoints 是不是空的);Pod 是否真的在监听 targetPort 指定的端口(kubectl exec 进去 ss -lntp 看);应用是否绑定了 127.0.0.1 而不是 0.0.0.0。第三条是容器化最常见的问题——很多应用的默认配置绑的是 localhost,容器外面就访问不到。Endpoints 为空这一条尤其值得记住:Service 只要 selector 匹配不上任何 Pod,它的 Endpoints 就是空的,访问就会超时或者返回 503,而 Service 本身不会报任何错。

磁盘被日志写满导致整个集群异常

k3s 的日志默认走 journald,容器日志存在 /var/log/pods 下,而镜像是存在 /var/lib/rancher/k3s/agent/containerd 下。长期运行后镜像层会累积,几十个 G 不稀奇。清理方式:

sudo k3s crictl rmi --prune
du -sh /var/lib/rancher/k3s/agent/containerd/*
# 限制容器日志大小,编辑 /etc/rancher/k3s/config.yaml 加上
cat >> /etc/rancher/k3s/config.yaml <<'EOF'
kubelet-arg:
  - "container-log-max-size=50Mi"
  - "container-log-max-files=3"
EOF
sudo systemctl restart k3s

磁盘写满之后的表现很有迷惑性:kubelet 会先把自己标记成 NotReady,然后上面的 Pod 被驱逐到其他节点(单机就是无处可去,卡在 Terminating),接着 API Server 因为无法写 etcd(SQLite 文件写不进)开始返回错误,kubectl 各种命令超时。很多人第一反应是集群崩了要重装,其实可能只是磁盘满了。养成习惯把 df -h 和节点水位告警加进监控。

要不要给个人站上 k3s:一份实际的判断标准

k3s 有学习成本,这部分成本不是花在安装上,而是花在理解 Kubernetes 的对象模型和排错上——同样的时间你也许能写十几个 systemd unit。所以判断标准应该落在"这些服务是不是需要编排"上。

值得用的信号:你有超过 3 个相互依赖的服务要一起发布和回滚;你需要滚动更新(新版本起不来时自动保留旧版本);你有定时任务需要和主应用共享镜像和配置;你想用统一的声明式文件管理所有服务而不用维护七八个互相引用的 compose 文件。

不值得用的信号:你只有一两个静态站加一个 PHP 应用;你习惯用 docker compose up -d 一条命令搞定一切并且没有扩展需求;你没有多余的机器,而现有机器内存小于 2G。这些情况下 k3s 带来的复杂度超过了它解决的问题,用 systemd 加 docker compose 是更清醒的选择。

如果决定用,有一条经验值得记住:把 k3s 集群当成一个黑盒,服务本身的存在性和配置都写进 YAML 并纳入版本管理,不要 kubectl edit 直接改线上的对象。直接编辑的对象不会被版本控制记录,下次 kubectl apply 又会被覆盖回去,最终你会分不清线上到底是什么状态。这一点和"改了 Nginx 配置要提交到 git"是同一个道理,只是对象从文件变成了集群 API。

Last modification:September 28th, 2026 at 08:26 pm

Leave a Comment