从裸机部署到K8s迁移:一个传统IDC客户的上云全过程

摘要:一个跑了三年的电商平台,从裸机Nginx+Java+MySQL架构迁移到K8s。不是一步到位,经历了三个阶段:先容器化、再编排、最后上K8s。本文完整记录这个过程——哪些环节比预想的顺利、哪些坑踩得猝不及防、最终效果怎么样。

关键词:K8s迁移、容器化、Docker、裸机部署、IDC上云

分类:K8s / 运维 / IDC


起点:一台机器跑一切

客户做电商的,跑了三年。架构简单到不能再简单:

一台物理服务器(16核64G)
  ├── Nginx(反向代理+静态资源)
  ├── Tomcat(Java应用)
  ├── MySQL 5.7
  ├── Redis
  └── 定时任务(crontab)

所有东西跑在同一台机器上。没有容器、没有编排、没有自动化部署。上线就是把jar包传上去,重启Tomcat。

三年了也没出过大问题。用户量稳定在日活几千,一台机器完全够用。

但今年他们拿到了一笔融资,要扩品类、做推广,预计半年内用户量翻好几倍。现有架构的问题会越来越明显:

单点故障。 机器挂了全站不可用。没有备份节点。

部署粗暴。 每次上线都是一次冒险。传jar包、重启Tomcat、祈祷不出问题。回滚更痛苦——得手动把旧jar包换回去再重启。

扩展性为零。 用户量涨了只能升级硬件。加CPU、加内存、换SSD。升级一次停机一次。

数据库跟应用绑在一起。 数据库跑在同一台机器上,应用和数据库抢CPU、抢内存、抢IO。

他们问我怎么办。我的建议是分三步走,不要一步到位。


第一阶段:拆分和容器化(第1-2个月)

第一步:把数据库从应用服务器上拆出来

这是最优先的事情。数据库和应用在同一台机器上,不光抢资源,还导致任何一方的扩容都要动另一方。

# 新开一台服务器专门跑MySQL
# 迁移过程:
# 1. 新机器装MySQL
# 2. 用mysqldump导出旧数据
mysqldump -u root -p --single-transaction --routines --triggers --all-databases > full_dump.sql

# 3. 导入新机器
mysql -u root -p < full_dump.sql

# 4. 改应用的数据库连接地址
# application.yml
spring:
  datasource:
    url: jdbc:mysql://新数据库IP:3306/mydb

# 5. 重启应用验证

MySQL拆出去之后,应用服务器的CPU和内存占用降了一大截。数据库也有了独立的资源池,不再受应用影响。

拆数据库这个过程比预想的顺利。停了15分钟的机做切换,数据量不大(十几个GB),导出导入加起来20分钟搞定。

第二步:容器化

把应用装进Docker容器。这一步的目的是让部署标准化——不管在哪个环境,容器里的应用行为是一致的。

写Dockerfile:

FROM eclipse-temurin:17-jre-alpine

WORKDIR /app
COPY target/app.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
# 构建镜像
docker build -t my-app:1.0.0 .

# 本地测试
docker run -d -p 8080:8080 -e SPRING_DATASOURCE_URL=jdbc:mysql://数据库IP:3306/mydb my-app:1.0.0

容器化本身不难。Java应用打好jar包塞进Docker镜像就行了。

但踩了几个坑:

坑1:配置文件硬编码了IP地址。

# application.yml 里写死了
spring:
  datasource:
    url: jdbc:mysql://10.0.1.60:3306/mydb
redis:
  host: 10.0.1.60

容器化之后这些配置要改成环境变量注入:

spring:
  datasource:
    url: ${DB_URL:jdbc:mysql://localhost:3306/mydb}
redis:
  host: ${REDIS_HOST:localhost}
docker run -d -p 8080:8080 \
  -e DB_URL=jdbc:mysql://数据库IP:3306/mydb \
  -e REDIS_HOST=redisIP \
  my-app:1.0.0

改配置不难,但项目里散布着几十处硬编码的IP地址,一个个找出来改掉花了一整天。

坑2:日志写在容器里。

原来应用日志写在 /var/log/app/ 下面。容器化之后这个路径在容器内部,容器重启日志就没了。

改成stdout输出:

<!-- logback.xml -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
</appender>
<root level="INFO">
    <appender-ref ref="STDOUT" />
</root>

容器化之后日志通过 docker logs 查看。后面K8s阶段再接日志收集系统。

坑3:文件存储。

用户上传的图片存在本地磁盘。容器化之后文件存在容器里,容器没了文件就没了。

短期方案:挂载宿主机目录作为持久存储。

docker run -d -p 8080:8080 \
  -v /data/uploads:/app/uploads \
  my-app:1.0.0

长期方案:迁移到对象存储(OSS/S3)。这个放到第三阶段做。

第三步:Nginx也容器化

FROM nginx:alpine

COPY nginx.conf /etc/nginx/nginx.conf
COPY static/ /usr/share/nginx/html/static/
docker build -t my-nginx:1.0.0 .

现在架构变成了:

物理机1:Nginx容器 → 代理到应用容器
物理机1:Java应用容器
物理机2:MySQL

还是在同一台物理机上,但应用已经容器化了。下一步上编排。


第二阶段:Docker Compose编排(第2-3个月)

在正式上K8s之前,先用Docker Compose把容器编排起来。这一步的目的不是"上K8s",而是理清楚服务之间的依赖关系和启动顺序。

# docker-compose.yml
version: '3.8'

services:
  nginx:
    image: my-nginx:1.0.0
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - app
    restart: always

  app:
    image: my-app:1.0.0
    environment:
      - DB_URL=jdbc:mysql://mysql:3306/mydb
      - REDIS_HOST=redis
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    restart: always

  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_PASSWORD}
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: always

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    restart: always

volumes:
  mysql-data:
  redis-data:
# 一键启动所有服务
docker-compose up -d

# 查看状态
docker-compose ps

Docker Compose阶段踩的坑:

数据库初始化。 MySQL容器第一次启动时需要初始化数据库和导入数据。不能只靠docker-compose up。

mysql:
  image: mysql:5.7
  volumes:
    - mysql-data:/var/lib/mysql
    - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # 首次启动自动执行

健康检查很重要。 没有healthcheck的话,docker-compose启动顺序只保证容器"启动",不保证服务"就绪"。MySQL容器起来了但还没初始化完,应用容器就连不上数据库,启动失败。

加上healthcheck和 depends_on: condition: service_healthy 之后,应用容器会等MySQL真正就绪了才启动。

环境变量管理。 密码不能写在docker-compose.yml里(会提交到Git)。用.env文件:

# .env(加入.gitignore)
MYSQL_PASSWORD=xxxxx
DB_URL=jdbc:mysql://mysql:3306/mydb

Docker Compose跑了一段时间,稳定性没问题。但它只适合单机部署——所有容器跑在一台物理机上。要做多机部署、自动扩缩容、滚动更新,还是得上K8s。


第三阶段:迁移到K8s(第3-5个月)

为什么上K8s

Docker Compose解决了部署标准化的问题,但没解决:

单机限制。 所有容器在一台机器上,机器挂了全完。

手动扩缩。 用户量涨了只能手动加机器、改配置。

没有滚动更新。 每次部署还是要停一下。

K8s解决的就是这些问题。但K8s本身也有学习和运维成本。不要为了上K8s而上K8s——如果单机Docker Compose能满足需求,不上也行。

客户的用户量预期要翻几倍,而且后续要拆微服务。这才有了上K8s的必要性。

搭集群

用kubeadm搭了一个最小可用集群:

# 主节点初始化
kubeadm init --pod-network-cidr=10.244.0.0/16 --apiserver-advertise-address=主节点IP

# 配置kubectl
mkdir -p $HOME/.kube
cp /etc/kubernetes/admin.conf $HOME/.kube/config

# 安装网络插件(Calico)
kubectl apply -f https://docs.projectcalico.org/manifests/calico.yaml

# 加入工作节点
kubeadm join 主节点IP:6443 --token xxx --discovery-token-ca-cert-hash sha256:xxx
# 验证集群状态
kubectl get nodes
NAME          STATUS   ROLES           AGE   VERSION
master-1      Ready    control-plane   5d    v1.28.2
worker-1      Ready    <none>          5d    v1.28.2
worker-2      Ready    <none>          5d    v1.28.2

3个节点(1主2工作),对这个规模的业务够了。后面用户量涨了再加工作节点。

把Docker Compose转成K8s资源

Docker Compose里的每个service对应K8s的一个Deployment+Service。

应用服务:

# app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: app
        image: my-app:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: DB_URL
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: db-url
        - name: REDIS_HOST
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: redis-host
        resources:
          requests:
            cpu: "500m"
            memory: "1Gi"
          limits:
            cpu: "2000m"
            memory: "2Gi"
        readinessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: app-service
spec:
  selector:
    app: my-app
  ports:
  - port: 8080
    targetPort: 8080

Nginx(Ingress替代):

K8s里不用单独的Nginx容器做反向代理,用Ingress:

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - www.example.com
    secretName: tls-secret
  rules:
  - host: www.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: app-service
            port:
              number: 8080
# 安装Ingress Controller
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/baremetal/deploy.yaml

敏感信息用Secret:

kubectl create secret generic app-secrets \
  --from-literal=db-url='jdbc:mysql://数据库IP:3306/mydb' \
  --from-literal=mysql-password='xxxxx'

非敏感配置用ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  redis-host: "redis-service"

数据库要不要迁到K8s里

这个问题纠结了很久。最终决定:MySQL和Redis不进K8s,继续跑在物理机上。

原因:

MySQL有状态。 K8s擅长管无状态服务,有状态服务(数据库)的运维复杂度高得多。PVC、StorageClass、数据备份、主从复制——在K8s里管这些比在裸机上难不少。

数据安全。 客户的数据库跑了三年了,数据是命脉。迁移到K8s里的PV(PersistentVolume)上,万一PV出问题数据丢了,风险太大。

性能。 K8s里的Pod通过网络访问存储,跟裸机直连本地SSD比,IO性能有差距。数据库对IO延迟敏感,不值得为了"全容器化"牺牲性能。

最终架构:

K8s集群(2工作节点):
  ├── Ingress Controller(替代Nginx)
  ├── Java应用(2个Pod,自动扩缩)
  └── 未来的新服务(搜索、推荐等)

K8s集群外(裸机):
  ├── MySQL(独立服务器)
  ├── Redis(独立服务器)
  └── 对象存储(文件上传)

数据库放在K8s外面不是"不完整",是务实的选择。很多团队为了"架构好看"把数据库塞进K8s,结果运维难度上去了,稳定性反而下来了。


迁移过程中的几个坑

坑1:Pod IP不能直接从外部访问

K8s的Pod IP是集群内部的,外部访问不了。需要通过Service或Ingress暴露。

刚开始的时候他们直接ping Pod IP发现不通,以为是网络配错了。其实是K8s的网络模型就是这样的——Pod IP只在集群内部可达。

# 在集群内部可以访问Pod IP
kubectl exec -it debug-pod -- curl http://10.244.1.5:8080/health

# 外部访问要通过Service或Ingress
curl -H "Host: www.example.com" http://IngressController的IP/

坑2:健康检查配错了导致Pod反复重启

Liveness Probe配得太激进了:

# 问题配置
livenessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 10    # Java应用启动要30秒以上
  periodSeconds: 5
  failureThreshold: 3

Java应用启动要30多秒,但liveness probe只等了10秒就开始检查。应用还没起来,探针连续3次失败,K8s直接杀掉Pod重启。然后又等10秒又杀。反复重启。

# 修正后的配置
livenessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 60    # 给足启动时间
  periodSeconds: 30
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

liveness和readiness的区别:liveness失败→K8s杀掉Pod重启;readiness失败→从Service的Endpoints里摘掉,不接受新流量,但不重启。

Java应用的initialDelaySeconds一定要给够。Spring Boot启动30-60秒是正常的,探针太早检查只会造成无意义的重启。

坑3:资源requests设得太高导致调度失败

一开始把requests设得跟limits一样高:

resources:
  requests:
    cpu: "2000m"
    memory: "2Gi"
  limits:
    cpu: "2000m"
    memory: "2Gi"

两个工作节点各8核16G,两个Pod各request 2核2G,加上系统组件,节点的可分配资源很快就满了。第三个Pod调度不上去。

改成requests低于limits:

resources:
  requests:
    cpu: "500m"     # 保底0.5核
    memory: "1Gi"   # 保底1G
  limits:
    cpu: "2000m"    # 最多2核
    memory: "2Gi"   # 最多2G

CPU可以超分,request设低一点不影响正常运行,但让调度器有更多空间安排Pod。

坑4:Docker Compose到K8s的环境变量不通用

Docker Compose里的环境变量直接写在docker-compose.yml或者.env文件里。K8s里要用Secret和ConfigMap。

应用代码里的环境变量引用方式不用改(都是从环境变量读),但运维流程完全变了。

# Docker Compose时代:改.env文件,docker-compose restart
# K8s时代:改ConfigMap,kubectl rollout restart
kubectl edit configmap app-config
kubectl rollout restart deployment/app

这一步没有技术难度,但运维习惯要调整。


迁移后的效果

部署流程

迁移前:
  传jar包到服务器 → 手动替换旧jar → 重启Tomcat → 验证 → 出问题手动回滚

迁移后:
  CI/CD构建Docker镜像 → 推送到镜像仓库 → kubectl set image → 滚动更新 → 出问题kubectl rollout undo
# 滚动更新
kubectl set image deployment/app app=my-app:1.1.0

# 验证
kubectl rollout status deployment/app

# 回滚(如果出问题)
kubectl rollout undo deployment/app

部署从"一次冒险"变成了"一个命令"。回滚也是一个命令的事。

可用性

迁移前:
  1台服务器,挂了全站不可用
  恢复时间:手动处理,几分钟到几小时

迁移后:
  2个Pod分布在不同节点,一个Pod挂了另一个顶上
  K8s自动拉起新的Pod
  恢复时间:秒级(Pod自动重建)

扩容

迁移前:
  用户量涨了 → 升级硬件 → 停机 → 换机器 → 恢复
  周期:几天

迁移后:
  用户量涨了 → 加副本 → kubectl scale deployment/app --replicas=4
  周期:几秒(Pod启动时间)
  加节点:几分钟(新服务器加入集群)

资源利用率

迁移前单机部署,高峰期CPU到80%就紧张了,低峰期CPU只有10%。资源浪费严重。

迁移后K8s的requests和limits机制让资源可以超分。两个节点8核16G,实际跑4个Pod总共request 2核4G,但高峰期可以burst到8核16G。低峰期资源释放出来给其他任务。


费用对比

迁移前:
  1台物理服务器托管:约3000元/月
  MySQL在同一台上(不额外收费)

迁移后:
  K8s集群(3台较小的服务器):约4500元/月
  MySQL独立服务器:约2000元/月
  合计:约6500元/月

月费从3000涨到了6500。多花了一倍多。

但换来的是:自动扩缩容、滚动更新、秒级故障恢复、支持后续微服务拆分。对一个拿了融资要快速发展的团队来说,这个投入是值得的。

如果用户量一直不大、不需要快速迭代,其实不用上K8s。Docker Compose甚至裸机部署都够用。上不上K8s要看业务阶段和团队能力,不是"别人都上了我也要上"。


一个没有走的弯路

客户最初想一步到位:直接上云厂商的托管K8s(ACK/EKS/TKE),把数据库也迁到云RDS。

我拦住了。

原因:

他的服务器刚续了一年托管费。 搬到云上等于浪费了这笔钱。

他对K8s没有经验。 先在自己的裸机集群上踩坑、熟悉K8s的运维,再考虑上云。直接上托管K8s出了问题更难排查——你不知道是K8s的问题还是云平台的问题。

数据库跑了三年很稳定。 迁到云RDS要改连接方式、改备份策略、改运维流程。没有必要为了"全上云"而冒险。

先在自己的机器上跑K8s,等团队熟悉了K8s运维、业务稳定了、托管费到期了,再考虑上云迁移。那时候迁移的成本和风险都小很多。


总结

这个迁移历时5个月,分三个阶段:

第1-2月:拆分+容器化
  数据库从应用服务器拆出来
  应用和Nginx打包成Docker镜像
  解决配置外部化、日志、文件存储等问题

第2-3月:Docker Compose编排
  用docker-compose管理容器
  理清服务依赖和启动顺序
  解决健康检查、环境变量、数据持久化

第3-5月:迁移到K8s
  搭建K8s集群
  把Docker Compose转成K8s资源
  数据库继续裸机,不进K8s
  灰度切换,观察稳定后全量迁移

几个核心经验:

不要一步到位。 先容器化再编排再K8s。每一步都能独立验证和回退。一步到位出了问题你不知道是哪层的问题。

数据库不一定进K8s。 有状态服务在K8s里运维复杂度高。数据库跑了几年很稳定的话,没必要为了"架构好看"迁移。

健康检查要认真配。 liveness和readiness的区别搞清楚,initialDelaySeconds给足。配错了Pod反复重启是最常见的新手坑。

资源requests别设太高。 设太高节点很快就满了,新Pod调度不上去。CPU可以超分,requests设低一点没问题。

先在自己的机器上跑。 熟悉了K8s运维再上云。直接上托管K8s出了问题排查更难。


有问题评论区聊。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐