把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程

在一台 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s:镜像 side-load、PVC、Ingress、cert-manager、数据迁移、五到十五秒切换与回滚,附完整 YAML 与踩坑清单。

技术文章约 33 分钟161 次阅读
项目说明
适用环境单机 Rocky Linux 9.8 / 4 vCPU / 3.6G 内存 / 40G 系统盘
版本k3s v1.36.4-k3s1、ingress-nginx v1.11.3、cert-manager v1.18.2
前置条件root、公网(走国内镜像)、现有 docker-compose 栈与数据备份
最后核对2026-10-27

先说结论

  • compose 的 service / volume / network 分别翻译成 Deployment + Service / PVC / Ingress,服务名即 DNS 的语义不变。
  • 镜像不进仓库就必须 side-load,并把 imagePullPolicy 设为 IfNotPresent。
  • blog.json + ReadWriteOnce 卷决定单副本 + Recreate,滚动发布会写坏数据。
  • 切换靠「先起新栈 → NodePort 验证 → 再换 80/443」,把中断压到五到十五秒。
  • 每一步都留回退路径:镜像、数据、证书、端口各自可退。

#迁移检查清单

阶段动作验证详见
1 盘点列出 compose 服务与 K8s 对象的对应关系概念映射表无遗漏一
2 镜像docker save → k3s ctr images importk3s ctr images ls 能看到四、五
3 资源apply Namespace / Secret / PVC / Deployment / Service / Ingress / CertificatePod Ready、Endpoints 有地址五、六
4 数据临时 Pod 从宿主机导入 blog.json;Redis 不迁数据校验和一致、应用能读七
5 证书先导入现有证书 Secret,再切 cert-managerHTTPS 全程无空窗五、八
6 切换NodePort 验证 → 改回 80/443中断五到十五秒八
7 回滚保留 compose 栈、旧证书与数据冷备回滚演练通过九、十

这篇记录把一个跑了半年多的博客系统从 docker-compose 迁到 k3s 的完整过程:一台腾讯云单机、Rocky Linux 9.8、4 vCPU、3.6G 内存、40G 系统盘,原来四个容器(Node 应用、nginx、Redis、Certbot)靠 docker-compose 编排,迁移后换成 k3s 上的 Deployment、Service、PVC、Ingress 和 cert-manager,全部 YAML、安装命令、数据迁移、切换与回滚步骤都可复现。

整个方案成立的最基础机制,是两类编排模型对"容器应该怎么跑"的回答不同:docker-compose 是单机执行声明——它只描述"在这台机器上把这组进程跑起来,它们之间如何连接";Kubernetes 是集群期望状态声明——它描述"最终应该有哪些副本、什么配置、什么存储",至于进程怎么被调度、崩溃了怎么恢复、配置漂移了怎么纠正,由控制面组件在一个持续收敛的循环里替你完成。这个差异是后面所有翻译决策的源头:理解了它,compose 的 service、volume、network 各自翻译成 K8s 的什么对象、为什么这样翻译,都能从机制上推出来,而不是死记清单。

#一、原理层:从单机声明到集群声明

#compose 的"单机声明":把一组进程跑起来

docker-compose 解决的是多容器进程的本地编排。compose 文件里每个 service 描述一个镜像、端口映射、环境变量、卷和依赖关系,docker compose up 把这组声明翻译成一组 Docker 容器的创建、连接和启动动作。它的执行单元是"这台机器上的 Docker daemon",不存在调度:容器落在哪、节点资源够不够、节点挂了怎么办,这些语义在 compose 里都没有。容器退出后由 Docker 的 restart 策略决定是否重启,进程间的名字解析由 Docker 内置 DNS 提供,service 里的服务名就是容器在 compose 网络里的主机名。

所以 compose 的定位是"构建与运行的便捷封装",它默认假设:单机、单用户、过程式地"起一次"。这决定了它没有期望状态的概念——compose 文件里的配置是命令的参数,而不是需要持续维护的目标。

#Kubernetes 的"集群声明":期望状态与收敛循环

Kubernetes 换了一种模型。用户提交的不是"怎么跑"的指令,而是"最终应该是什么"的声明:replicas: 1 意思是"任何时候都要有一个 Pod 运行这个镜像"。控制面(apiserver 保存声明,controller-manager 里的一组控制器负责观察)和节点上的 kubelet 在一个循环里不断比较"期望状态"和"当前状态",有差距就动手收敛:Pod 死了,ReplicaSet 控制器重建它;镜像更新了,Deployment 控制器滚动替换;配置改了,相关控制器重新生效。

这个机制的三个直接推论,在本迁移里处处可见:

  • 声明不等于就绪。kubectl apply 只是把声明写进 apiserver,副本真正起来、探针通过是异步发生的,所以部署脚本里必须跟着 rollout status、probe 和 wait --for=delete 这类等待命令,否则下一步会踩到还没就绪的资源。
  • 控制器是"持续修正"而非"一次启动"。这正是 compose 和 K8s 最本质的差别:同样的应用,在 compose 下是"你用 docker compose up 起一次",在 K8s 下是"声明在,控制器就保证它一直在"。
  • 资源之间的依赖靠名字和标签表达,不靠启动顺序。compose 里 service 按 depends_on 排序启动;K8s 里 Deployment 先 apply 哪个都行,Pod 起来后通过 Service 的名字解析找到依赖,探针负责告诉控制器"我还没准备好,别把流量给我"。

#从 apply 到 Pod 就绪:一次调度的完整链路

K8s 的"声明"到"跑起来"不是一条命令,而是一条有明确环节的链路,迁移后排查问题全靠这条链路:

kubectl apply -> apiserver 持久化声明(k3s 里落到 SQLite)
scheduler 给 Pod 选节点 -> kubelet 拿到绑定关系
kubelet 通过 CRI 调用 containerd:拉镜像、起沙箱、起容器
CNI(flannel)给 Pod 分配 Pod IP 并打通网络
readinessProbe 通过 -> Endpoints 收录 Pod -> Service 开始转发流量

每个环节都有自己的失败模式和对应的检查命令:声明没进去看 kubectl apply 的报错;调度失败看 kubectl describe pod 的 Events;镜像拉不动看 kubelet 日志;探针不通过看 kubectl get pod 的 READY 列和 readiness 失败原因。理解这条链路,后面所有"为什么明明 apply 了却还没好""为什么 curl 不通"之类的问题,都能按环节定位。

#概念映射:service / volume / network 逐一翻译

compose 概念K8s 对应对象语义变化
service(无状态)Deployment + Service进程声明 → 期望状态;Service 提供稳定 DNS
service(有状态)StatefulSet + Service + PVC多了稳定网络标识与按序编排
volume / bind mountPVC + PV(StorageClass 动态供给)生命周期归集群管,删除策略由 reclaimPolicy 决定
networks(服务名即 DNS)Service 的 ClusterIP + CoreDNS名字解析从 Docker DNS 换成 CoreDNS,语义不变
ports 发布到宿主Service(ClusterIP / NodePort)+ Ingress端口暴露拆成"集群内"与"集群外"两层
nginx(反代 / TLS / 安全头)Ingress 资源 + ingress-nginx 控制器路由规则与实现解耦,配置进 annotations / ConfigMap
certbot(ACME 客户端)cert-manager(ClusterIssuer + Certificate)手动跑 ACME → 声明式签发与自动续期

原文里给的对应关系就是这张表的压缩版:

blog-app (Express:3000)   -> Deployment + Service + PVC
redis    (只存 session)    -> Deployment + Service + PVC
nginx    (80/443 + TLS)   -> ingress-nginx + Ingress
certbot  (自动续期)        -> cert-manager
data/blog.json            -> local-path PVC

有几个点不是一一对应,而是"一对多"或"多对一",最容易翻译错:

  • compose service → Deployment 还是 StatefulSet。StatefulSet 的价值是稳定网络标识(Pod 名固定、序号化)和按序启停,适合有状态服务;Deployment 适合无状态服务。本例两个服务都选了 Deployment:Redis 里的数据是可以重建的会话,博客应用的数据是单个 JSON 文件、架构上就是单副本,均不需要 StatefulSet 的稳定身份。
  • compose 的 volume 在 K8s 里拆成了两层:PVC(声明"我要多大、什么访问模式")和 PV(实际存储),中间由 StorageClass 的供给器(provisioner)按需创建。k3s 内置的 local-path 就是这样一个供给器:每个 PVC 在宿主机上划一块目录出来。
  • compose 的 network 对应到两处:应用之间的服务发现对应 Service(ClusterIP + CoreDNS 名字),对外暴露对应 Ingress。Redis 那条 REDIS_URL=redis://redis:6379 迁移后一个字都不用改,因为 Service 的名字也叫 redis,集群内 DNS 把 redis 解析到 ClusterIP;这里的"服务名即 DNS"两层模型完全一致,只是解析器从 Docker 内置 DNS 换成了 CoreDNS。

#Service 是怎么把"名字"变成"流量"的

容器要能通过 redis:6379 访问 Redis,背后有两层机制:名字解析和流量转发。名字解析发生在 Pod 的 resolv.conf 指向 CoreDNS,redis 被解析成一条 Service 的 A 记录,返回 ClusterIP。流量转发发生在数据面:kube-proxy(k3s 内置,语义与标准 K8s 一致)把"ClusterIP:port → 后端 Pod IP:targetPort"的规则写进节点的 iptables(或 ipvs),Service 收到请求后按负载均衡规则转发给某个 Ready 的 Pod。

为什么中间要隔一个 ClusterIP?因为 Pod IP 是易变的——每次重建都会变,控制器把真实 Pod IP 写进 Endpoints,Service 永远用同一个虚拟 IP 对外。compose 里"服务名即容器地址"不需要这层抽象,是因为 compose 网络里的容器只在创建时连接一次;K8s 里 Pod 生生死死,必须有一个不随 Pod 变化的稳定锚点。这也是为什么迁移后"应用怎么连 Redis"这件事,从"看 compose 的 network"变成了"看 Service 的 selector 和 port/targetPort"——selector 必须匹配 Pod 标签,port 是集群内端口,targetPort 才是容器里的真实端口。

#为什么单机小内存反而选 k3s

标准 Kubernetes 控制面是四个独立进程:kube-apiserver、etcd、kube-scheduler、kube-controller-manager,其中 etcd 作为分布式共识存储本身就是内存大户。在一台 3.6G 内存的机器上,光控制面就要 1.5~2G,还没算 kubelet、CNI 和本来的业务进程,OOM 风险很高。

k3s 的轻量不是阉割功能,而是换实现:

  • 控制面合并:apiserver、scheduler、controller-manager 打进同一个 k3s server 二进制,进程数从四个降到约一个;
  • etcd 换成 SQLite:k3s 用 kine 组件实现 etcd 的 API 兼容层、落到 SQLite 上,单节点不需要分布式共识,省掉一大块常驻内存;
  • 内置网络与存储:自带 flannel(CNI)、CoreDNS、local-path 供给器、servicelb(klipper-lb)和 Traefik,装完即用;
  • 对外 API 完全一致:kubectl、YAML、CRD 与标准 Kubernetes 兼容,所有 manifest 可以直接 apply。

结果是控制面常驻内存被压到 1G 以内,在这台机器上与业务共存是可行的。代价同样要讲清楚:单 server 节点没有 etcd 级的高可用,控制面故障就是集群故障;这台机器的用法是"把 K8s 当单机平台用",不是"搭一个小型生产集群"。要真上规模,就按 k3s 的多节点方式加 agent 或加 server。

#镜像与数据卷在迁移中的真实约束

镜像和数据卷是迁移里最容易出"方向性问题"的两处,机制如下:

  • 镜像:compose 时代镜像构建在 Docker daemon 里,docker compose up 直接用;K8s 时代拉镜像的是节点上的 containerd(k3s 默认容器运行时)。这台机器的镜像没推到任何 registry,所以只有两条路:推仓库,或者在本机直接把镜像从 Docker 侧载到 containerd——后者就是本文用的 side-load 方式(docker save 导出 tar,k3s ctr 导入)。镜像既然不进仓库,Pod 的 imagePullPolicy 就必须是 IfNotPresent,否则 kubelet 会尝试去远程 registry 拉一个不存在的 tag。
  • 数据卷:local-path 的本质是"带 PVC/PV 语义的 hostPath"——PV 就是宿主机上的一个真实目录。它有两个特性要记住:reclaimPolicy 默认是 Delete(删除 PVC 即删除数据),以及 ReadWriteOnce(同一时刻只能被一个节点挂载)。前者决定了数据必须另留冷备,后者和 JSON 单文件的架构一起,把应用钉死在"单副本"上。

#二、迁移前:对着现有 compose 过一遍

动手前先把现有四个容器想清楚,每个对应成什么、能不能动、有没有坑,比直接写 YAML 重要:

  • blog-app(Express:3000)→ Deployment + Service + PVC。应用数据是一个 blog.json,启动时全量读进内存、写入时整文件落盘,多副本必然互相覆盖同一个文件;加上 PVC 是 ReadWriteOnce,所以单副本 + Recreate 是硬约束,不能用默认的 RollingUpdate——RollingUpdate 会先起新 Pod 再杀旧 Pod,新旧并存期间两个进程同时持有同一个文件,写入互相打架。
  • redis(只存 session)→ Deployment + Service + PVC。迁移前看了下 Redis 里两千多个 key 全是 sess:*,只有登录态和 CSRF token,丢了顶多后台重新登录一次,所以不迁数据,空库起新的。注意 Service 名字必须叫 redis,因为应用里写死了 REDIS_URL=redis://redis:6379。
  • nginx(80/443 + TLS)→ ingress-nginx + Ingress。原来的安全头、CSP、gzip、HTTP 跳转全是 nginx 的写法,平移给 ingress-nginx 最省事;不启用 k3s 自带的 Traefik,因为 cert-manager 的 HTTP-01 校验在 ingress-nginx 上最成熟。
  • certbot(自动续期)→ cert-manager。ACME 流程从"定时跑客户端"变成"声明式资源",签发、续期交给控制器。
  • 证书不能有窗口期。切换瞬间 HTTPS 必须是可信的,所以先把现有 Let's Encrypt 证书导成一个 Secret(lxhhub-top-tls-bootstrap)顶上,等 cert-manager 签好新证书再切换 secretName,中间没有空窗。

#三、操作层:环境与版本

迁移目标机器的硬性条件是:腾讯云单机、Rocky Linux 9.8、4 vCPU、3.6G 内存、40G 系统盘;需要 root 权限、能访问公网(下载走国内镜像)。涉及版本:k3s v1.36.4-k3s1、ingress-nginx controller-v1.11.3、cert-manager v1.18.2、Redis 7-alpine、数据迁移临时 Pod 用 alpine:3.20。下述命令以这些版本为例,后续版本如有差异以官方发布说明为准。

#四、安装 k3s:国内下载、swap 与镜像加速

安装前先给机器加了 2G swap。kubelet 默认禁止使用 swap 分页,所以安装时带 --kubelet-arg=fail-swap-on=false,不加这个参数 k3s 根本起不来。

然后是下载问题。直接 curl -sfL https://get.k3s.io | sh - 会从 GitHub 拉二进制,原稿作者在这台机器上卡了 17 分钟一个字没下下来,测速是 0;换成 Rancher 中国镜像后,75MB 五秒下完。步骤是先手动下载二进制到 /usr/local/bin/k3s,再用 INSTALL_K3S_SKIP_DOWNLOAD=true 让安装脚本跳过下载、直接使用本地二进制:

K3S_VERSION=v1.36.4-k3s1
curl -fL "https://rancher-mirror.rancher.cn/k3s/${K3S_VERSION}/k3s" -o /usr/local/bin/k3s
chmod +x /usr/local/bin/k3s
curl -sfL https://get.k3s.io | \
  INSTALL_K3S_SKIP_DOWNLOAD=true \
  INSTALL_K3S_VERSION="${K3S_VERSION/-/+}" \
  INSTALL_K3S_EXEC="server --disable traefik --write-kubeconfig-mode 644 --kubelet-arg=fail-swap-on=false" sh -

两个参数说明:${K3S_VERSION/-/+} 是 shell 参数展开,把 v1.36.4-k3s1 换成安装脚本期望的 v1.36.4+k3s1 格式;--write-kubeconfig-mode 644 让生成的 kubeconfig 对当前用户可读,省掉后续每次 kubectl 的权限调整。

--disable traefik 是必须的:Traefik 会先通过 servicelb 创建负载均衡器 Pod,把宿主机的 80/443 占掉,等会儿装 ingress-nginx 再想用这两个端口就会打架。

容器镜像拉取也慢,顺手配了 containerd 的镜像加速源,写入后重启 k3s 生效:

# /etc/rancher/k3s/registries.yaml
mirrors:
  docker.io:
    endpoint:
      - https://mirror.ccs.tencentyun.com
      - https://docker.m.daocloud.io
  registry.k8s.io:
    endpoint:
      - https://k8s.m.daocloud.io
  quay.io:
    endpoint:
      - https://quay.m.daocloud.io

配置修改后执行 sudo systemctl restart k3s 才能生效。

安装 ingress-nginx(controller-v1.11.3,baremetal 清单,先以 NodePort 暴露,避开线上的 80/443)和 cert-manager(v1.18.2,GitHub release 走 gh-proxy 代理):

# ingress-nginx(先以 NodePort 暴露,避开线上的 80/443)
kubectl apply -f "https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml"
kubectl -n ingress-nginx patch svc ingress-nginx-controller --type=json -p '[
  {"op":"replace","path":"/spec/ports/0/nodePort","value":30080},
  {"op":"replace","path":"/spec/ports/1/nodePort","value":30443}
]'

# cert-manager(GitHub release 走代理)
curl -fL "https://gh-proxy.com/https://github.com/cert-manager/cert-manager/releases/download/v1.18.2/cert-manager.yaml" -o cert-manager.yaml
kubectl apply -f cert-manager.yaml

NodePort 的用意在切换阶段会体现:迁移期间公网仍由旧 nginx 服务,k8s 侧先用 30080/30443 在宿主机上做内部验证。

#五、全部 YAML 清单:compose 到 K8s 的逐一翻译

这一节是完整清单,约 1.1 万字。赶时间可以先跳到「六、部署命令」;回来查某个资源时,按下面的小节标题定位即可。

以下清单都在 k8s/ 目录,命名空间统一是 blog。除了 Secret,其余文件都可以直接进 git。

#Namespace

apiVersion: v1
kind: Namespace
metadata:
  name: blog

#Secret:密钥不进仓库

compose 的 .env 里有 ADMIN_USERNAME、ADMIN_PASSWORD、SESSION_SECRET 三个值,K8s 侧用一个 Opaque 类型的 Secret(blog-secrets)承接:结构上是三个键的 stringData,但真值不写进任何 YAML 文件,创建时直接从服务器上的 /opt/blog/.env 注入:

set -a; . /opt/blog/.env; set +a
kubectl -n blog create secret generic blog-secrets \
  --from-literal=ADMIN_USERNAME="$ADMIN_USERNAME" \
  --from-literal=ADMIN_PASSWORD="$ADMIN_PASSWORD" \
  --from-literal=SESSION_SECRET="$SESSION_SECRET" \
  --dry-run=client -o yaml | kubectl apply -f -

set -a; . /opt/blog/.env; set +a 把 .env 里的变量先导进当前 shell 环境;--dry-run=client -o yaml | kubectl apply -f - 是"先生成清单再交给 apply"的两段式,命令本身可重入,重复执行不会报"已存在"。

#PVC:两条持久卷

内容数据一条、Redis 一条,都用 k3s 自带的 local-path 存储类,各 1Gi、ReadWriteOnce:

# 博客内容
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: blog-data
  namespace: blog
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi
---
# Redis 会话
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: blog
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi

local-path 的机制值得先讲清楚:它是一个 StorageClass 供给器,watch 所有带 storageClassName: local-path 的 PVC,在宿主机上创建一块目录、生成对应的 PV 并与 PVC 绑定,Pod 挂载的就是这块宿主目录(类似 hostPath,但多了 PVC/PV 的生命周期语义)。PV 的目录位置不写在 PVC 清单里,要用 kubectl get pv <名称> -o jsonpath='{.spec.local.path}' 查——这正是后面数据迁移直拷方案的基础。

必须再强调一次 local-path 的 reclaimPolicy 是 Delete:误删 PVC 数据就没了。所以 blog.json 在 git 和宿主机各留了一份冷备,下面"数据迁移"一节还会用到。

#Redis:Deployment + Service

Redis 不需要跨副本,单副本即可。Service 名字必须叫 redis,因为应用里默认 REDIS_URL=redis://redis:6379,CoreDNS 会把 redis 解析到 Service 的 ClusterIP:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: blog
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          imagePullPolicy: IfNotPresent
          args: ["redis-server", "--appendonly", "yes"]
          ports:
            - containerPort: 6379
              name: redis
          volumeMounts:
            - name: data
              mountPath: /data
          readinessProbe:
            exec:
              command: ["sh", "-c", "redis-cli ping | grep PONG"]
            initialDelaySeconds: 3
            periodSeconds: 5
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 300m
              memory: 128Mi
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: blog
spec:
  selector:
    app: redis
  ports:
    - name: redis
      port: 6379
      targetPort: 6379

--appendonly yes 开启 AOF 持久化,让会话数据跨重启保留;readinessProbe 用 redis-cli ping 确认实例真正可服务,而不是端口通了就算就绪。

#博客应用:Deployment + Service

这是核心对象。strategy: Recreate、replicas: 1、imagePullPolicy: IfNotPresent 三个是硬约束:单副本 + RWO 卷不能用默认的 RollingUpdate;镜像 side-load 进来的,不能去远程拉。探针路径和资源限制都来自原 compose 侧的实际观察:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: blog-app
  namespace: blog
spec:
  replicas: 1
  strategy:
    type: Recreate          # 单副本 + RWO 卷,不能用默认的 RollingUpdate
  selector:
    matchLabels:
      app: blog-app
  template:
    metadata:
      labels:
        app: blog-app
    spec:
      containers:
        - name: blog-app
          image: docker.io/library/blog-blog-app:latest
          imagePullPolicy: IfNotPresent   # 镜像 side-load 进来的,别去远程拉
          ports:
            - containerPort: 3000
              name: http
          env:
            - name: NODE_ENV
              value: production
            - name: DATA_FILE
              value: /app/data/blog.json
            - name: REDIS_URL
              value: redis://redis:6379
            - name: TZ
              value: Asia/Shanghai
            - name: ADMIN_USERNAME
              valueFrom:
                secretKeyRef:
                  name: blog-secrets
                  key: ADMIN_USERNAME
            - name: ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: blog-secrets
                  key: ADMIN_PASSWORD
            - name: SESSION_SECRET
              valueFrom:
                secretKeyRef:
                  name: blog-secrets
                  key: SESSION_SECRET
          volumeMounts:
            - name: data
              mountPath: /app/data
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 20
            periodSeconds: 20
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 500m
              memory: 256Mi
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: blog-data
---
apiVersion: v1
kind: Service
metadata:
  name: blog-app
  namespace: blog
spec:
  selector:
    app: blog-app
  ports:
    - name: http
      port: 3000
      targetPort: 3000

镜像名 blog-blog-app:latest 是 compose 构建时的产物(工程名 blog 加服务名 blog-app),说明 compose 侧构建与 K8s 侧运行共用同一份镜像,只是搬运方式不同(见下文 side-load)。

#Ingress:入口和 TLS

Ingress 资源本身不做事,它只是一份路由声明;真正干活的是集群里的 ingress-nginx 控制器。控制器的循环大致是:watch Ingress、Service、Endpoints 三类对象的变化,把它们翻译成一份 nginx 配置(server、location、upstream),配置变更时 reload 进程;Ingress 注解(annotations)里的 proxy-body-size、超时时间等会翻译成 nginx 指令;tls.secretName 指向的 Secret 会被加载为 TLS 证书,在控制器这一层终止 HTTPS。所以"把 nginx 迁到 K8s"不是把 nginx 配置原样搬进来,而是把配置拆成两部分:路由和 TLS 归 Ingress 资源,全局行为(安全头、协议、gzip)归控制器的全局 ConfigMap。

下面这份清单里,一条 Prefix / 就把首页、文章、后台、/api、/static、RSS、sitemap 全覆盖了,静态资源交给应用自带的 express.static。两个 host(lxhhub.top、www.lxhhub.top)指向同一个 Service。tls.secretName 先指向引导证书 lxhhub-top-tls-bootstrap,cert-manager 签好之后用一条 patch 换掉:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: blog
  namespace: blog
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - lxhhub.top
        - www.lxhhub.top
      secretName: lxhhub-top-tls-bootstrap   # 证书签发后改为 lxhhub-top-tls
  rules:
    - host: lxhhub.top
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: blog-app
                port:
                  number: 3000
    - host: www.lxhhub.top
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: blog-app
                port:
                  number: 3000

切换证书用一条 merge patch:

kubectl -n blog patch ingress blog --type merge \
  -p '{"spec":{"tls":[{"hosts":["lxhhub.top","www.lxhhub.top"],"secretName":"lxhhub-top-tls"}]}}'

#证书:ClusterIssuer + Certificate

cert-manager 的编排方式:ClusterIssuer 声明"证书从哪来"(ACME 服务器),Certificate 声明"我要哪个域名、签完放哪个 Secret"。先建 staging(acme-staging-v02)验证流程,避免撞生产环境限流;验证通过后再签生产证书。

HTTP-01 的完整流程是:Certificate 控制器向 ACME 服务器下订单 → ACME 返回一个 challenge token → 控制器在 /.well-known/acme-challenge/ 路径下临时提供 token 应答(通过 Ingress 暴露)→ Let's Encrypt 用公网 HTTP 请求验证应答 → 验证通过后签发证书 → 证书和私钥写进 secretName 指定的 Secret → 到期前自动续期。这个流程不碰 443,只依赖 80 端口上的 HTTP 可达——所以"80 端口必须先交给 ingress-nginx"是证书签发的前置条件,也是切换顺序里把签名放在切端口之后的原因。staging 与 prod 的唯一差别是 ACME 服务器地址;staging 的签发结果不计入 Let's Encrypt 的证书速率限制,先拿它跑通流程,可以避免在生产限流上反复试错。

下面是两个 Issuer 和 Certificate 的清单:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: lxhhub-top-tls
  namespace: blog
spec:
  secretName: lxhhub-top-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  commonName: lxhhub.top
  dnsNames:
    - lxhhub.top
    - www.lxhhub.top

清单里的 email: you@example.com 是示例值,改成你自己注册 ACME 的邮箱即可;不配置 email 也能正常签发,只是收不到到期提醒。HTTP-01 校验走的是 Ingress 的 HTTP 入口,所以要求 80 端口已经路由到 ingress-nginx——这也是切换顺序里"先把端口交出去、再签证书"的原因。

#安全头 ConfigMap

原来 nginx 里的安全头搬到 ingress-nginx 的全局 ConfigMap。新版 ingress-nginx 默认禁用了 per-Ingress 的 snippet 注解,所以统一走全局配置,别硬用 snippet:

apiVersion: v1
kind: ConfigMap
metadata:
  name: blog-security-headers
  namespace: ingress-nginx
data:
  X-Frame-Options: "SAMEORIGIN"
  X-Content-Type-Options: "nosniff"
  Referrer-Policy: "no-referrer-when-downgrade"
  Permissions-Policy: "camera=(), microphone=(), geolocation=()"
  Strict-Transport-Security: "max-age=31536000"
  Content-Security-Policy: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'self'"

再让控制器引用它,并补上 TLS 协议与 gzip,之后重启控制器让配置生效:

kubectl -n ingress-nginx patch configmap ingress-nginx-controller --type merge -p '{"data":{
  "add-headers":"ingress-nginx/blog-security-headers",
  "ssl-protocols":"TLSv1.2 TLSv1.3",
  "ssl-prefer-server-ciphers":"false",
  "use-gzip":"true"
}}'
kubectl -n ingress-nginx rollout restart deploy/ingress-nginx-controller

#临时 Pod:镜像导入和数据迁移

平时没有裸 Pod,应用和 Redis 都是 Deployment 管理的 Pod;只有两个场景会临时用 Pod:导镜像、数据迁移。镜像 side-load 进 containerd:

docker save blog-blog-app:latest -o /tmp/blog-app-image.tar
k3s ctr -n k8s.io images import /tmp/blog-app-image.tar

注意 -n k8s.io 是 ctr 的全局参数,必须放在 images 子命令之前;作者第一次写成 images import --namespace 直接报错。k8s.io 命名空间是 k3s 默认的 containerd 镜像命名空间,Pod 能直接引用这里导入的镜像。

数据迁移那段用一个挂载 PVC 的临时 Pod,先给出的是第一版方案,后面"数据迁移"一节会讲为什么废弃:

apiVersion: v1
kind: Pod
metadata:
  name: data-loader
  namespace: blog
spec:
  restartPolicy: Never
  containers:
    - name: data-loader
      image: alpine:3.20
      command: ["sh", "-c", "sleep 86400"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: blog-data

#六、部署命令:从空集群到跑起来

清单准备好之后,部署分这么几步。并跑阶段线上还是老 nginx,公网无感;k8s 侧的所有验证都走 NodePort:

cd k8s
kubectl apply -f 00-namespace.yaml
kubectl apply -f 02-pvc-blog-data.yaml -f 03-pvc-redis-data.yaml

# 密钥:从宿主机 .env 注入(见"Secret"一节)
set -a; . /opt/blog/.env; set +a
kubectl -n blog create secret generic blog-secrets \
  --from-literal=ADMIN_USERNAME="$ADMIN_USERNAME" \
  --from-literal=ADMIN_PASSWORD="$ADMIN_PASSWORD" \
  --from-literal=SESSION_SECRET="$SESSION_SECRET" \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl apply -f 04-redis.yaml
kubectl -n blog rollout status deploy/redis --timeout=300s

kubectl apply -f 05-blog-app.yaml
# 起应用前先缩到 0,导完数据再拉起来
kubectl -n blog scale deploy/blog-app --replicas=0
kubectl -n blog wait --for=delete pod -l app=blog-app --timeout=60s

# 导入 blog.json(见"数据迁移"一节,实际用宿主机直拷 PV 目录)
PV=$(kubectl -n blog get pvc blog-data -o jsonpath='{.spec.volumeName}')
PVPATH=$(kubectl get pv "$PV" -o jsonpath='{.spec.local.path}')
cp -f /opt/blog/data/blog.json "$PVPATH/blog.json"
md5sum /opt/blog/data/blog.json "$PVPATH/blog.json"

kubectl -n blog scale deploy/blog-app --replicas=1
kubectl -n blog rollout status deploy/blog-app --timeout=180s

kubectl apply -f 06-ingress.yaml
kubectl apply -f 10-headers-configmap.yaml

rollout status 是对"期望状态收敛"的直接验证:命令返回前,副本达到声明数量且探针通过,才表示这步真的完成了。

NodePort 验证(公网还走旧 nginx;登录测试必须走 30443,因为生产 cookie 是 secure 的,走 30080 的 HTTP(curl -s -H 'Host: lxhhub.top' http://127.0.0.1:30080/api/health 只适合探活,不适合登录测试)):

curl -s -H 'Host: lxhhub.top' http://127.0.0.1:30080/api/health
curl -sk --resolve lxhhub.top:30443:127.0.0.1 https://lxhhub.top:30443/ | head

两条命令解释:第一条用 -H 'Host: lxhhub.top' 伪造 Host 头,直接打 30080 的 HTTP 端口,验证 Ingress 路由和 /api/health;第二条用 --resolve lxhhub.top:30443:127.0.0.1 把该域名的解析定向到本机 30443,走 HTTPS 验证完整入口,-k 只是暂时跳过证书校验(此时 tls 段指向 bootstrap 证书),能输出首页 HTML 即说明链路通。

#七、数据迁移:被 kubectl exec 摆了一道

博客内容全在一个 blog.json 里,316KB。第一版脚本就是上面的 data-loader Pod,导入用 kubectl exec 的 stdin 重定向:

kubectl exec -i data-loader -- sh -c 'cat > /data/blog.json' < ./blog.json

看起来没问题,结果一校验 md5 对不上——PVC 里只有 96KB,写到三分之一就断了,试了两次都一样。结论:这台机器上 kubectl exec 的流式 stdin 传输大文件不可靠,什么时候截断都不知道,不能用管道。

这条坑的本质是"单向管道没有校验":kubectl exec -i 走的是 kubectl 与 apiserver 之间的流式通道,写入端退出不代表接收端写完整,任何一端异常都只表现为静默截断,没有任何错误码。凡是传输结果有校验和可对比的(文件、数据库导出、镜像),都用"先传 + 校验"的路径;如果场景必须走 exec,退路是先把内容编码(如 base64)分块写入再解码并校验,但代价是更慢更易错,能用宿主机直拷就不要用它。

后来换了个最土也最稳的办法:local-path 的 PV 本来就是宿主机上的一个目录,直接用宿主机的 cp 拷过去,再用 md5 校验两边一致:

PV=$(kubectl -n blog get pvc blog-data -o jsonpath='{.spec.volumeName}')
PVPATH=$(kubectl get pv "$PV" -o jsonpath='{.spec.local.path}')
cp -f /opt/blog/data/blog.json "$PVPATH/blog.json"
md5sum /opt/blog/data/blog.json "$PVPATH/blog.json"   # 两边一致才继续

导入前务必把应用缩到 0:如果应用还在跑,它内存里的旧数据会在下一次写入时把刚拷进去的文件覆盖掉。data-loader Pod 最后没用上,删掉了事。

#八、切换:把中断压到五到十五秒

真正切换就三步,顺序很重要:

  1. 缩容 k8s 应用,把最新的 blog.json 同步进 PVC,再拉起应用等就绪——这一步线上还由老 nginx 服务,没有中断;
  2. 停掉 compose 的 nginx 和应用,80/443 释放出来;
  3. 把 ingress-nginx 的 Service 从 NodePort 改成 LoadBalancer,k3s 的 servicelb 接管宿主 80/443,切换完成。

这个"接管"的机制是:k3s 的 servicelb(klipper-lb)watch 所有 type: LoadBalancer 的 Service,发现一个就创建一个 svclb-<Service名> 的负载均衡器 Pod(所以后面 kubectl -n kube-system get pods -l app=svclb-ingress-nginx-controller 能看到它),这个 Pod 监听宿主的 80/443 并把流量转发给 Service 的 ClusterIP。于是 Service 从 NodePort 改成 LoadBalancer 的那一刻,宿主 80/443 的实际监听者就从旧 nginx 换成了 svclb Pod——端口交接完成,没有中间空档。

对应的命令(t1/t2/t3 是数据同步与应用就绪,无中断):

# t1/t2/t3:数据同步与应用就绪(无中断)
kubectl -n blog scale deploy/blog-app --replicas=0
kubectl -n blog wait --for=delete pod -l app=blog-app --timeout=60s
cp /opt/blog/data/blog.json /opt/blog/data/blog.json.cutover.$(date +%Y%m%d%H%M%S)
# 宿主直拷 blog.json 到 PV 目录(见"数据迁移"一节)
PV=$(kubectl -n blog get pvc blog-data -o jsonpath='{.spec.volumeName}')
PVPATH=$(kubectl get pv "$PV" -o jsonpath='{.spec.local.path}')
cp -f /opt/blog/data/blog.json "$PVPATH/blog.json"
md5sum /opt/blog/data/blog.json "$PVPATH/blog.json"
kubectl -n blog scale deploy/blog-app --replicas=1
kubectl -n blog rollout status deploy/blog-app --timeout=180s
# t4:停旧入口(中断开始)
cd /opt/blog && docker compose stop nginx blog-app

# t5:servicelb 接管宿主 80/443(中断结束)
kubectl -n ingress-nginx patch svc ingress-nginx-controller -p '{"spec":{"type":"LoadBalancer"}}'
kubectl -n kube-system get pods -l app=svclb-ingress-nginx-controller -w
# t6:本机验收
curl -sI --resolve lxhhub.top:443:127.0.0.1 https://lxhhub.top/ | head -3
curl -s  --resolve lxhhub.top:443:127.0.0.1 https://lxhhub.top/api/health

从中断开始到恢复,实测五到十五秒。原因:数据和证书都提前备好了,切换只是把 80/443 从旧 nginx 交给 servicelb,中间没有数据搬运、没有等待签发。

证书这块再补一句:切之前把现有 Let's Encrypt 证书导进了 lxhhub-top-tls-bootstrap 这个 Secret,Ingress 先指向它,所以切换瞬间就是可信 HTTPS,没有空窗:

kubectl -n blog create secret tls lxhhub-top-tls-bootstrap \
  --cert=/opt/blog/certbot/conf/live/lxhhub.top/fullchain.pem \
  --key=/opt/blog/certbot/conf/live/lxhhub.top/privkey.pem

等 80 端口归 ingress-nginx 之后,cert-manager 先用 staging 跑通一次 HTTP-01,再签生产证书,最后把 Ingress 的 secretName 切到 lxhhub-top-tls,之后的续期就全自动了:

# 1) staging 验证:apply 后建一张 staging 证书,Ready 后删掉
kubectl apply -f 07-clusterissuer-staging.yaml

# 2) 生产签发
kubectl apply -f 08-clusterissuer-prod.yaml
kubectl apply -f 09-certificate.yaml
kubectl -n blog wait --for=condition=Ready certificate/lxhhub-top-tls --timeout=300s

# 3) 切 Ingress 证书(patch 命令见"Ingress"一节)

#九、验证与回滚

上线后按清单过一遍:所有公开路径应返回 200,对象状态应正常:

for p in / /about /robots.txt /sitemap.xml /feed.xml /admin /api/health; do
  printf "%-14s -> %s\n" "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://lxhhub.top$p)"
done
kubectl -n blog get deploy,rs,svc,ingress,pvc,certificate

预期结果:首页、文章页、后台、robots、sitemap、feed、静态资源全部 200;HTTP 自动 301 到 HTTPS;HSTS、CSP、X-Frame-Options 都在;后台能登录能写文章;外部浏览器看证书无告警。

证书切换后,除了看浏览器无告警,还可以直接核验 Secret 里的内容和到期时间:

kubectl -n blog get secret lxhhub-top-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates

预期输出里的 notBefore / notAfter 应覆盖当前日期,Subject 是 CN=lxhhub.top(SAN 含两个域名)。另外 kubectl -n blog get certificate lxhhub-top-tls -o yaml 的 status.conditions 里 Ready=True 是 cert-manager 自己的验收口径,两种查法互相印证:一个证明"Secret 里的证书真实可用",一个证明"签发流程认为它已完成"。

回滚也提前想好了,脚本就四步(旧 compose 文件、证书目录、数据目录全程未动,就是为了保这条退路):

# 1) 缩容并导出 PVC 里的最新数据回宿主机
kubectl -n blog scale deploy/blog-app --replicas=0
# 宿主直拷 PV 里的 blog.json 回 /opt/blog/data/(方向与导入相反)
PV=$(kubectl -n blog get pvc blog-data -o jsonpath='{.spec.volumeName}')
PVPATH=$(kubectl get pv "$PV" -o jsonpath='{.spec.local.path}')
cp -f "$PVPATH/blog.json" /opt/blog/data/blog.json

# 2) 释放宿主 80/443
kubectl -n ingress-nginx patch svc ingress-nginx-controller -p '{"spec":{"type":"NodePort"}}'

# 3) 拉起旧栈
cd /opt/blog && docker compose up -d
docker compose ps

原稿的"导出 PV 数据回宿主机"是描述性步骤,这里补上了实际的直拷命令,方向与导入相反即可;切回 NodePort 后 servicelb 退出,80/443 归还旧 nginx。

#十、边界与坑

这一节把这条迁移路径的适用边界和踩过的坑集中列出:

  • swap 与 kubelet:kubelet 默认禁止 swap,装 k3s 必须带 --kubelet-arg=fail-swap-on=false 且机器上要有 swap,否则 k3s 起不来。
  • Traefik 与端口冲突:k3s 默认装 Traefik,会先占用宿主 80/443;计划用 ingress-nginx 就必须 --disable traefik,否则两个控制器抢端口。
  • 国内下载是第一个坑:k3s 二进制走 Rancher 中国镜像、GitHub release 走 gh-proxy、容器镜像配 mirror(registries.yaml),三件事不做齐,光下载就能耗掉一晚上;镜像源配置改完要重启 k3s 才生效。
  • local-path 的 Delete 策略:默认 reclaimPolicy=Delete,误删 PVC 等于删数据;单机场景数据只有一份,必须另留冷备(git、宿主机)。
  • kubectl exec 写大文件不可靠:流式 stdin 传输 316KB 都能截断到 96KB 且无报错;凡是"写入物有校验和"的场景,用带校验的方式(cp + md5sum),别用管道。
  • RWO 卷 + 单文件数据 = 单副本钉死:blog.json 全量读入内存、整文件落盘,多副本必然互相覆盖;RollingUpdate 的新旧 Pod 并存期也会打架,所以 Recreate 不能省。
  • cookie 的 secure 属性:生产 cookie 是 secure 的,HTTP 的 30080 端口登录测试无效,验证必须走 30443 的 HTTPS。
  • 什么时候不该用这套方案:单节点 k3s 的控制面故障等于集群故障,没有高可用;当服务需要多副本、多个 worker 节点或强一致存储时,应上标准多节点集群或托管 K8s;当数据是真正的有状态数据且要求多副本持久化时,local-path 也不合适,应换成分布式存储。另外,这套迁移把"应用入口"从 nginx 换成了 ingress-nginx,如果业务重度依赖 nginx 的特定指令(复杂 rewrite、Lua 等),平移成本会明显上升。
  • NodePort 的默认范围:kube-apiserver 的 --service-node-port-range 默认是 30000~32767,本文选的 30080/30443 落在这个范围内;想用其他端口要先改 apiserver 参数(k3s 里通过安装参数 --kube-apiserver-arg=service-node-port-range= 调整)。
  • local-path 是节点本地的:PV 目录只存在于本机,单机没问题;一旦扩成多节点,Pod 被调度到别的节点就挂不上这块卷。多节点场景应换共享存储或分布式存储。
  • HSTS 的副作用:Strict-Transport-Security: max-age=31536000 一下发,浏览器会把该域名强制 HTTPS 记住一年;回滚演练或将来换域名时要意识到这一点,避免"明明域名回到了 HTTP 却打不开"的错觉。
  • 探针参数要从应用实际启动时间定:readiness 的 initialDelaySeconds: 5、liveness 的 initialDelaySeconds: 20 来自应用的实测启动耗时;设太短会在应用还没就绪时反复重启,设太长又拖慢故障收敛,这两个值值得上线前用 staging 验证一轮。

#十一、收尾:每一步都能退回去

复盘整个迁移,k3s 在这台 3.6G 的机器上加 ingress、cert-manager、应用、Redis,总体内存大约 1.6G,再留 2G swap 兜底,运行稳定。单机跑 K8s 是可行的,但心态上别追求"像生产集群那样",它就是一台可以用 kubectl 和声明式清单管理的单机平台。

迁移本身的原则只有一句:每一步都能退回去。数据、镜像、证书、验证全部前置,最后再动端口那一下——切换动作越小,中断越短,收不回来的时候退路也越清晰。如果你也要做类似的 compose 到单机 K8s 迁移,建议先对照本文的"概念映射"表把每个 compose 服务翻译清楚,再按"安装、清单、部署、数据、切换、验证"的顺序走,卡住时回来看"边界与坑"。

#延伸阅读

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程 · LXH·BLOG