把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程
在 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s。附完整 YAML 清单(Deployment、Service、PVC、Ingress、Certificate 等)与全部配置命令,记录部署、数据迁移与切换上线的踩坑过程。
这台博客原来一直跑 docker-compose,四个容器:Node 应用、nginx、Redis、Certbot,用了大半年没出过什么问题。直到有天我想在这台机器上试试 Kubernetes,才动了迁移的念头。这篇文章把整个过程记下来,包括全部 YAML 清单、配置命令,以及几个把我坑了半天的细节。
先交代环境:腾讯云单机,Rocky Linux 9.8,4 vCPU、3.6G 内存、40G 系统盘,没什么余粮。
#单机到底跑不跑得动 K8s
结论是能,但别上 kubeadm。标准 K8s 光控制面(apiserver + etcd + scheduler + controller-manager)就要 1.5~2G 内存,这台机器总共 3.6G,还没算 kubelet、CNI 和本来就在跑的应用,很容易 OOM。
所以选了 k3s:控制面打包成一个进程,默认用 SQLite 顶掉 etcd,自带 flannel、CoreDNS、local-path 存储,内存占用能压到 1G 以内。API 和标准 K8s 完全一致,kubectl、YAML 都能直接用。
#迁移前先对着 compose 过一遍
把现有的每个容器想清楚对应成什么:
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几个判断:
- 应用单副本、Recreate。数据是一个 JSON 文件,启动全量读进内存、写的时候整文件落盘,多副本必然互相覆盖。这个必须钉死。
- Redis 可以不迁。看了眼里面两千多个 key 全是
sess:*,只有登录态和 CSRF token,丢了顶多后台重新登一次,直接空库起新的。 - 入口用 ingress-nginx,不用 k3s 自带的 Traefik。原来的安全头、CSP、gzip、跳转全是 nginx 的写法,平移过去最省事,cert-manager 的 HTTP-01 也最成熟。
- 证书不能有窗口期。先不重新签,把现有的 Let's Encrypt 证书导成一个 Secret 顶上,等 cert-manager 签好了再切过去。
#装 k3s:国内下载是第一个坑
装之前先给机器加了 2G swap。kubelet 默认禁止 swap,得在安装时带上 --kubelet-arg=fail-swap-on=false,不加的话 k3s 起不来。
然后就是下载。直接 curl -sfL https://get.k3s.io | sh - 会从 GitHub 拉二进制,我这台机器上卡了 17 分钟一个字没下来,测速是 0。换成 Rancher 的中国镜像,75MB 五秒就下完:
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 ---disable traefik 是必须的,不然它会通过 servicelb 先把宿主 80/443 占了,等一会儿装 ingress-nginx 就打架。
镜像拉取也慢,顺手配了 containerd 的加速源:
# /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.ioingress-nginx 和 cert-manager 的安装命令:
# 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#全部 YAML 清单
下面是从 compose 翻译过来的完整清单,都放在 k8s/ 目录,命名空间统一是 blog。
#Namespace
apiVersion: v1
kind: Namespace
metadata:
name: blog#Secret(密钥不进仓库)
模板长这样,真值不写进文件:
apiVersion: v1
kind: Secret
metadata:
name: blog-secrets
namespace: blog
type: Opaque
stringData:
ADMIN_USERNAME: "REPLACE_ME"
ADMIN_PASSWORD: "REPLACE_ME"
SESSION_SECRET: "REPLACE_ME_64_HEX"实际创建时直接用命令从服务器的 .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 -#PVC:两条持久卷
内容数据一条,Redis 一条,都用 k3s 自带的 local-path:
# 博客内容
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 的 reclaimPolicy 是 Delete,误删 PVC 数据就没了,所以 blog.json 我另外在 git 和宿主机各留了一份冷备。
#Redis:Deployment + Service
Service 名字必须叫 redis,因为应用里默认 REDIS_URL=redis://redis:6379:
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#博客应用:Deployment + Service
这是核心。strategy: Recreate、replicas: 1、imagePullPolicy: IfNotPresent 三个是硬约束,下面注释里写了原因:
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#Ingress:入口和 TLS
一条 Prefix / 就把首页、文章、后台、/api、/static、RSS、sitemap 全覆盖了,静态资源交给应用自带的 express.static。tls.secretName 先指引导进来的证书,签好之后再把这一行改掉:
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切换证书用一条 patch:
kubectl -n blog patch ingress blog --type merge \
-p '{"spec":{"tls":[{"hosts":["lxhhub.top","www.lxhhub.top"],"secretName":"lxhhub-top-tls"}]}}'#证书:ClusterIssuer + Certificate
先建 staging 用来验证,避免撞生产限流:
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#安全头 ConfigMap
原来 nginx 里的那些安全头,搬到 ingress-nginx 的全局 ConfigMap(新版默认禁了 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'"再让控制器引用它:
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
平时没有裸 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 直接报错。
数据迁移那段用一个挂载 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,公网无感):
cd k8s
kubectl apply -f 00-namespace.yaml
kubectl apply -f 02-pvc-blog-data.yaml -f 03-pvc-redis-data.yaml
# 密钥(见上文命令)
# ...
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(见下一节,实际用的是宿主机直拷)
# ...
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.yamlNodePort 验证(公网还走旧 nginx,登录测试必须走 30443,因为 cookie 是 secure 的):
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#数据迁移:被 kubectl exec 摆了一道
博客的内容全在一个 blog.json 里,316KB。我第一版脚本就是上面那个 data-loader Pod,导入是这么写的:
kubectl exec -i data-loader -- sh -c 'cat > /data/blog.json' < ./blog.json看起来没问题,结果一校验 md5 对不上——PVC 里只有 96KB,写到三分之一就断了。试了两次都一样,应该是我这台机器上的 exec stdin 流不太可靠。
后来换了个最土也最稳的办法:local-path 的 PV 本来就是宿主机上的一个目录,直接找到它 cp 过去:
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,切换完成。
# 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 目录(见上节)
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从中断开始到恢复,实测五到十五秒。因为数据和证书都提前备好了,切换只是把端口交出去。
证书这块再补一句:切之前我把现有的 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 验证
kubectl apply -f 07-clusterissuer-staging.yaml
# 建一张 staging 证书,Ready 后删掉
# 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 见上文)#验收与回滚
上线后按清单过了一遍:
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 都在,后台能登录能写文章,外面浏览器看证书也没有告警。
回滚也提前想好了,脚本就四步:
# 1) 缩容并导出 PVC 里的最新数据回宿主机
kubectl -n blog scale deploy/blog-app --replicas=0
# 宿主机直拷 PV 里的 blog.json 回 /opt/blog/data/
# 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旧的 compose 文件、证书目录、数据目录我一个没动,就是为了这一天。
#经验
- 单机 k3s 够用,但别追求「像生产那样」。3.6G 的机器上,k3s 加 ingress、cert-manager、应用、Redis 一共也就 1.6G 左右,再留 2G swap 兜底就稳了。要真上规模再加机器。
- 国内环境要先解决下载。k3s 二进制走 Rancher 镜像、GitHub release 走代理、容器镜像配 mirror,这三件事不做,光下载就能耗掉一晚上。
- 别迷信 kubectl exec 写大文件。流式 stdin 什么时候给你截断了你都不知道,能用带校验的方式就别用管道。
- 切入口的功夫在顺序。把不产生中断的步骤(数据、镜像、证书、验证)全前置,最后再动那一下端口,中断自然就短了。
整个过程我的原则就一句话:每一步都能退回去。迁移这种事,快不是本事,收得回来才是。
写于 2026 年 9 月 11 日
- 栏目
- 技术文章
- 约
- 13.3 分钟
- 字数
- 1.3W
- 阅读
- 112
本文为原创记录,转载请注明出处。如果这篇替你省了时间,欢迎留言说说你踩到的坑。
同题 · related
- DevOps 运维中 AI 那些事把 AI 放进运维的真实分工:从服务器硬件、网络、GPU、内存,到虚拟化、Kubernetes、应用构建、CI/CD、可观测性、资源分配,再到 AI 自身的部署与算力调度。讲清楚哪些活它能接、边界划在哪、以及我踩过的那些坑。
- Kubernetes 核心组件详解与实战:从控制平面到应用上线系统讲解 kube-apiserver、etcd、Scheduler、Controller Manager、kubelet、容器运行时、CNI、Service、Ingress、存储、调度与安全,并提供完整的部署、验收和故障排查实践。
- Kubernetes CI/CD:GitHub Actions 构建并发布镜像用 GitHub Actions 搭发布流水线:OIDC 短期凭据、不可变镜像、环境审批与 rollout 验证。
留言 · remarks
00 条还没有留言,来说点什么吧。