把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程
在一台 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s:镜像 side-load、PVC、Ingress、cert-manager、数据迁移、五到十五秒切换与回滚,附完整 YAML 与踩坑清单。
| 项目 | 说明 |
|---|---|
| 适用环境 | 单机 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 import | k3s ctr images ls 能看到 | 四、五 |
| 3 资源 | apply Namespace / Secret / PVC / Deployment / Service / Ingress / Certificate | Pod Ready、Endpoints 有地址 | 五、六 |
| 4 数据 | 临时 Pod 从宿主机导入 blog.json;Redis 不迁数据 | 校验和一致、应用能读 | 七 |
| 5 证书 | 先导入现有证书 Secret,再切 cert-manager | HTTPS 全程无空窗 | 五、八 |
| 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 mount | PVC + 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.yamlNodePort 的用意在切换阶段会体现:迁移期间公网仍由旧 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: 1Gilocal-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.yamlrollout 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 最后没用上,删掉了事。
#八、切换:把中断压到五到十五秒
真正切换就三步,顺序很重要:
- 缩容 k8s 应用,把最新的
blog.json同步进 PVC,再拉起应用等就绪——这一步线上还由老 nginx 服务,没有中断; - 停掉 compose 的 nginx 和应用,80/443 释放出来;
- 把 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 服务翻译清楚,再按"安装、清单、部署、数据、切换、验证"的顺序走,卡住时回来看"边界与坑"。
#延伸阅读
- 一个 jar 包部署到 k8s 上的全流程 —— Deployment / Service / Ingress / 探针的最小闭环。
- 5 台服务器,从二进制装 K8s 到 Spring Cloud 上线 —— 从单机 k3s 再往前走一步:多节点与二进制安装。
- Kubernetes 核心组件详解与实战 —— 把本文用到的控制面与 kubelet 机制展开。
- Kubernetes 滚动发布与回滚 —— 发布策略与回滚的完整验证。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。