技术文章2026 年 9 月 11 日约 13.3 分钟

把博客从 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 过一遍

把现有的每个容器想清楚对应成什么:

text
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 五秒就下完:

bash
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 的加速源:

yaml
# /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

ingress-nginx 和 cert-manager 的安装命令:

bash
# 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

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: blog

#Secret(密钥不进仓库)

模板长这样,真值不写进文件:

yaml
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 注入,避免密钥落盘:

bash
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:

yaml
# 博客内容
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:

yaml
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 三个是硬约束,下面注释里写了原因:

yaml
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 先指引导进来的证书,签好之后再把这一行改掉:

yaml
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:

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

#证书:ClusterIssuer + Certificate

先建 staging 用来验证,避免撞生产限流:

yaml
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 注解,别硬用):

yaml
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'"

再让控制器引用它:

bash
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:

bash
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,这里我先给的是最终没用的版本,后面会讲为什么换了。

yaml
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,公网无感):

bash
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.yaml

NodePort 验证(公网还走旧 nginx,登录测试必须走 30443,因为 cookie 是 secure 的):

bash
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,导入是这么写的:

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

看起来没问题,结果一校验 md5 对不上——PVC 里只有 96KB,写到三分之一就断了。试了两次都一样,应该是我这台机器上的 exec stdin 流不太可靠。

后来换了个最土也最稳的办法:local-path 的 PV 本来就是宿主机上的一个目录,直接找到它 cp 过去:

bash
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,切换完成。
bash
# 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,没有空窗:

bash
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,之后的续期就全自动了:

bash
# 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 见上文)

#验收与回滚

上线后按清单过了一遍:

bash
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 都在,后台能登录能写文章,外面浏览器看证书也没有告警。

回滚也提前想好了,脚本就四步:

bash
# 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

留言 · remarks

00 条

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

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