ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

企业微服务基于Docker与DevOps的发布系统实践

企业微服务基于Docker与DevOps的发布系统实践 这阵子终于把公司那套乱糟糟的微服务发布流程理顺了。这次聊的这套基于Docker容器和DevOps理念搭建的企业业务代码发布系统算是我自己在生产环境里踩了无数坑之后慢慢沉淀下来的方案。标题里虽然挂着“enterprise-microservices-deployment-practice”其实就是想讲清楚一件事几十个微服务怎么用Docker管起来、怎么让发布变成一条自动化的流水线以及这套东西在企业真实业务里怎么落地。如果你正在搭DevOps平台或者被多环境、多服务的发布搞得焦头烂额这篇应该能给你指个方向。我先说结论这套方案不是某种“开箱即用”的软件也不是非得有多少台机器才能跑起来的重平台。它本质上是一套组合拳——Docker做运行环境标准化Harbor管镜像GitLab CI/CD负责打通“代码提交→镜像构建→远程部署”的链路再用Docker Compose做单机或小集群上的服务编排。服务规模在几个到几十个之间的时候这套组合足够稳等真到了需要弹性伸缩和跨机房调度的规模再平滑往K8s上迁也不迟。1. 先说背景微服务多起来之后发布就成了灾难1.1 这套发布系统到底解决什么问题我是在业务从单体应用拆分成30多个微服务之后开始认真做这件事的。拆分之前发布就是一台上线机把war包或jar包扔上去重启Tomcat完事。但服务一拆多问题全冒出来了。首先是环境的差异性。开发、测试、生产三套环境配置内容不一样连数据库地址、Redis地址、注册中心地址都要跟着改。以前靠人肉改配置文件改错一个字段测试环境就崩一次。加上服务之间的依赖关系复杂A服务依赖B服务的接口B服务依赖C服务每次发布都像叠乐高顺序错了就全盘推倒重来。其次是手工操作步骤的不可控。早期发布靠运维手工执行脚本备份旧包、停服务、替换jar包、起服务、看日志。操作步骤本身不难难的是30多个服务都要来一遍而且还要保证每个服务都在正确的顺序、正确的时间点发布。人不是机器半夜两三点发布的时候特别容易漏掉某一步。这套“业务代码发布系统”本质上是把发布这件事从“手工流程”变成“标准化流水线”。代码推送到仓库之后剩下的构建、打镜像、推送镜像、远程拉取镜像、重启容器、健康检查全部由流水线自动完成。开发人员只需要点击一个“发布”按钮或者打一个Git tag系统就把后续工作接过去了。这样做的直接收益有三个发布耗时从过去的一次平均20分钟降到3分钟左右发布失败率明显下降回滚变成了一条命令而不是又一轮手工操作。1.2 为什么选择Docker DevOps这套组合而不是直接上K8s当初做技术选型的时候团队内部不是没有争论过要不要直接上Kubernetes。我个人的看法是在团队运维能力还比较薄弱、服务规模还没到需要大规模弹性伸缩的时候直接上K8s反而会引入一堆新的复杂度。K8s本身要维护Master节点、Etcd、Ingress Controller、RBAC权限还得理解Pod、Service、Deployment、Namespace这些抽象概念学习成本很高。而且K8s在企业内部落地通常还要搭配一套完整的网络插件和存储方案。对于只有二三十个微服务、流量相对平稳的业务来说这些复杂度完全用不上。Docker解决的恰恰是最关键的问题——环境一致性。本地能跑的服务容器里跑不起来不存在。开发、测试、生产环境用的是同一份镜像差异只体现在配置和环境变量上。镜像构建一次到处运行这就在源头避免了“我本地是好的啊”这类经典甩锅。Docker Compose虽然看着简陋但在单机和少量宿主机场景下非常好用。它把多个容器的启动方式、网络配置、端口映射、依赖关系写在一个YAML文件里一条docker compose up -d就把整个服务栈拉起来。没有K8s的负担又能解决“一堆容器怎么统一管理”的问题。这套方案跑了一年多线上稳定性很可靠。等以后服务量再翻几倍需要弹性伸缩的时候再往K8s迁移也不迟——因为镜像没变Compose里的服务模型也很容易映射成Deployment。DevOps这套理念之所以要引入是因为它把开发和运维的边界打通了。开发者不再需要提工单求运维帮忙部署而是自己就能把代码发到测试环境运维不再把时间耗在重复的执行脚本上而是专注优化流水线、监控告警和稳定性。本质上DevOps不是某个工具而是一种协作方式。Docker和CI/CD工具只是这种协作方式的载体。2. 环境搭建三台物理机上的容器化基座2.1 Docker CE和Compose V2的安装细节先交代一下我这边的基础环境三台Linux物理机统一用的CentOS 7.9配置大体是8核16G内存、200G SSD数据盘。为什么要统一操作系统版本因为不同发行版的包管理器和内核特性有差异统一能少踩很多坑。理论上这套方案在Ubuntu 22.04上也能跑命令换成apt就行。安装Docker CE的步骤其实已经非常成熟但还是有几个细节值得单独提醒。第一CentOS系统的yum源默认不带Docker包需要先安装yum-utils再添加Docker官方yum仓库。仓库地址在/etc/yum.repos.d/docker-ce.repo里。设置好之后yum install docker-ce docker-ce-cli containerd.io一步到位。第二装完之后先别急着启动先把/etc/docker/daemon.json写好。我给一份目前生产环境在用的配置每一行的作用都标注过{ registry-mirrors: [https://你的加速地址.mirror.aliyuncs.com], data-root: /data/docker, exec-opts: [native.cgroupdriversystemd], log-driver: json-file, log-opts: { max-size: 50m, max-file: 3 }, storage-driver: overlay2 }>sudo groupadd docker sudo usermod -aG docker $USER newgrp docker这一步对应网上经常有人问的“docker权限错误怎么解决”。报错信息Got permission denied while trying to connect to the Docker daemon socket几乎都是当前用户不在docker组导致的。改完重新登录shell就生效。2.2 镜像仓库与CI/CD工具的选择镜像仓库和CI/CD工具是整个发布系统的两个“心脏”。镜像仓库负责存镜像CI/CD工具负责把代码流变成镜像流。我这边选的是Harbor GitLab CI这个组合。为什么不用Docker官方Registry因为私有化部署的企业场景Registry过于简陋。Harbor自带Web管理界面、镜像复制、访问控制配额、漏洞扫描最重要的是它支持镜像的tag保留策略和回收机制能防止仓库里的镜像无限膨胀。GitLab CE安装本身也可以直接用Docker容器跑起来官方提供了docker compose部署方式一条命令就能拉起一个带PostgreSQL和Redis依赖的GitLab实例。至于CI/CD工具早期我纠结过Jenkins还是GitLab CI。Jenkins插件生态丰富几乎什么场景都有现成插件但它的缺点是Pipeline定义和代码仓库是分离的维护成本高。GitLab CI的优势在于“Pipeline即代码”——.gitlab-ci.yml文件直接放在仓库里改代码和改流水线一起走版本控制可追溯性完全不一样。两者选型做个简单对比对比维度JenkinsGitLab CI配置存放位置集中在Jenkins服务端靠Job配置或Jenkinsfile.gitlab-ci.yml跟随代码仓库天然版本可控并发构建依赖Agent节点数量Runner支持Docker自动伸缩并发能力强学习门槛插件多功能杂新手容易迷失语法简洁只保留必要的stages/jobs关键字与代码平台集成需要额外装GitLab插件并配Token同一个GitLab体系权限模型复用适合场景异构脚本多、已有维护团队GitLab重度用户、DevOps流程标准化选GitLab CI还有个原因企业里代码权限管理本来就靠GitLabCI的权限模型直接沿用下来什么人能触发正式发布、什么人只能跑测试构建在项目设置里就能精细控制。2.3 基础网络规划与数据卷设计容器网络是Docker方案里最容易被忽略、后期最头疼的一环。我的做法是先在每台宿主机上建一个统一的外部Docker网络所有跨容器的通信都走这个网络docker network create -d bridge ops_net业务服务在docker-compose.yml里声明使用这个外部网络。这样即使多个Compose项目之间需要互相访问也不需要依赖links或暴露端口直接用服务名通信即可。links在Compose V2里已经标记为废弃跨项目容器互联的唯一合理方式就是把它们拉到同一个自定义网络上。数据卷设计上凡是需要持久化的数据都必须挂载宿主机的指定目录。MySQL的数据目录挂在/data/mysqlRedis的AOF和RDB文件挂在/data/redis统一规划的好处是备份只需针对这个目录做快照。容器本身被视为“无状态”的随时可以删除重建数据留在宿主机上这是容器化运维的基本原则。3. 微服务容器化改造从一堆Jar包到标准镜像3.1 多阶段Dockerfile怎么写得又快又小微服务层面我这边技术栈以Java Spring Boot为主。给Java服务写Dockerfile最推荐的方式就是多阶段构建。多阶段构建的好处有两点一是构建环境用Maven镜像运行环境用JRE镜像两者隔离最终镜像不包含编译工具链体积能小很多二是构建过程可以充分利用Docker层的cache单一层变化时不会让整个build cache失效。给一份生产环境正在用的Dockerfile做了详细注释# 第一阶段编译打包 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests -B # 第二阶段运行 FROM eclipse-temurin:11-jre WORKDIR /app COPY --frombuilder /app/target/*.jar app.jar ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone EXPOSE 8080 ENTRYPOINT [sh, -c, java -Xms512m -Xmx512m -XX:UseG1GC -jar app.jar]为什么第一阶段的Maven命令要拆成两行第一次只拷贝pom.xml然后执行dependency:go-offline是为了让Maven拉取依赖这一步单独成为一个Docker缓存层。后续如果只改源码不会触发依赖重新拉取构建速度会快很多。如果直接把源码一次性拷进去再统一构建改一行代码就要重新拉一遍所有依赖几十个服务累计下来的时间成本非常吓人。运行阶段用eclipse-temurin而不是老的openjdk镜像是因为OpenJdk官方镜像已经停止维护ADOPTEclipse的Temurin项目是目前社区推荐的Java运行镜像。基础镜像一定要选长期维护的这同样是踩过坑得出的教训——之前用的某个镜像因为不再更新遇到厂商的CVE通告之后根本没法升级补救。-Xms512m -Xmx512m这个JVM参数组合值得单独说。容器限制512M内存JVM堆最大也设为512M很多人会觉得“给满了”。但实际情况是JVM除了堆内存还有元空间、线程栈、直接内存等开销。如果容器限制是1GJVM最大堆绝不要设到1G至少预留25%给非堆内存和系统本身否则会被容器OOM Kill。我给一般服务定的基线是容器内存1GJVM堆512M这个比例在实战中表现很稳定。3.2 依赖中间件容器化MySQL 8和Redis主从的部署坑业务服务都容器化之后中间件如果还在物理机上裸奔管理上依然不通。我这边把MySQL 8.0、Redis主从、RocketMQ都陆续迁到了Docker里。这里只挑最容易踩坑的两个讲。MySQL 8.0容器化最需要注意的是数据目录和初始化脚本。直接docker run命令其实够用docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORD你的密码 \ -e TZAsia/Shanghai \ -v /data/mysql:/var/lib/mysql \ -v /data/mysql-conf:/etc/mysql/conf.d \ -p 3306:3306 \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_bin数据卷必须挂载否则容器删除之后MySQL数据全部丢失这是Docker化中间件的底线。第二个容易忽略的是字符集参数8.0默认字符集虽然是utf8mb4但排序规则在某些场景下会踩索引坑显式指定更稳妥。还有一点不要用latest标签拉取MySQL镜像一定要锁定具体版本号比如mysql:8.0.36。中间件的镜像版本不能漂移否则哪天升级了个小版本行为变化导致宕机再排查就晚了。Redis主从的容器化同样要挂载数据和配置文件。先起主节点docker run -d \ --name redis-master \ --network ops_net \ -p 6379:6379 \ -v /data/redis/master:/data \ redis:7.0 redis-server --appendonly yes --requirepass 你的密码再起从节点关键点是让从节点通过Docker网络内的容器名连接主节点而不是写死IP。因为主节点容器一旦重启IP可能变化重新拉起从节点时找不到原来的IP主从关系就直接断了。docker run -d \ --name redis-slave \ --network ops_net \ -p 6380:6379 \ -v /data/redis/slave:/data \ redis:7.0 redis-server --appendonly yes \ --slaveof redis-master 6379 \ --masterauth 你的密码这里有个很实用的细节从节点端口映射到了宿主机的6380这是为了方便本地调试直连容器内部的服务通过redis-master:6379访问完全不用关心映射端口。Redis主从在容器环境里的坑主要就两个一个是网络模式没选对导致主从间通信失败另一个是认证配置不一致masterauth没配上就抓瞎。3.3 服务之间如何通信网络模式与服务发现微服务之间通信我这边统一用Nacos做注册中心和配置中心。在Docker环境里Nacos本身也是跑在容器里的。但Nacos的服务注册地址有个大坑如果微服务往Nacos注册的是容器内网IP桥接网络分配的IP其他服务通过这个IP访问时会发现根本连不通——因为另一个服务可能根本不在同一个宿主机上或者虽然在同一宿主机但容器网络的IP段并不对外路由。解决思路有三种我最终采用的是最简单的一种所有微服务在注册到Nacos时上报的地址是宿主机IP端口用映射出来的宿主机端口。可以通过配置spring.cloud.nacos.discovery.ip宿主机IP和spring.cloud.nacos.discovery.port映射端口来显式指定。这样服务调用的时候走的是宿主机端口既有Nacos的注册发现优势又不依赖Docker内部网络的可达性。等以后服务规模进一步增长再切换到K8s环境时这个方案可以直接升级成K8s的Service或者Headless Service加DNS解析。所以现在虽然土一点但演进路径是清晰的不会白做。4. 发布流水线落地从git push到docker run4.1 GitLab CI的任务拆分与Runner配置流水线设计是整个发布系统的核心。我的设计思路是把流水线拆成三个阶段构建build、打包镜像image、发布release。这样拆的好处是每个阶段职责单一出问题也好定位。.gitlab-ci.yml文件的关键部分长这样variables: MAVEN_OPTS: -Dmaven.repo.local/cache/m2 stages: - build - image - release build: stage: build image: maven:3.8-openjdk-11 script: - mvn clean package -DskipTests artifacts: paths: - target/*.jar expire_in: 1 day only: - tags image: stage: image image: docker:20.10 services: - docker:20.10-dind script: - docker build -t registry.ops.com/enterprise/user-svc:$CI_COMMIT_TAG . - docker login -u $HARBOR_USER -p $HARBOR_PASSWORD registry.ops.com - docker push registry.ops.com/enterprise/user-svc:$CI_COMMIT_TAG only: - tags release: stage: release script: - ssh deploy目标服务器 cd /opt/deploy/user-svc ./deploy.sh $CI_COMMIT_TAG only: - tags注意到我这里对正式发布只允许tags触发。为什么不用分支直接发布因为分支的粒度太粗开发随便push一个unstable的提交到develop如果自动触发生产发布后果不堪设想。用git tag v1.2.3这种方式发布行为就变成一个经过测试验证的、有明确版本号的“正式操作”。开发环境可以用分支自动构建生产环境必须打tag。Runner我选择了shell executor而不是docker executor。原因是release阶段需要访问目标服务器的docker socket和SSH密钥shell执行器在这些权限控制上更直接。代价是Runner所在机器需要预装Docker和JDK但这些都是一次性配置可接受。4.2 远程服务器上的滚动发布与健康检查release阶段通过ssh deploy目标服务器执行deploy.sh这是整个发布动作的关键一步。deploy.sh脚本的设计决定了发布是优雅还是粗暴。我的脚本里强制做了三件事拉取新镜像、用Compose重启、健康检查不通过就报错。#!/bin/bash set -e APP_NAMEuser-svc IMAGE_TAG$1 BASE_DIR/opt/deploy/$APP_NAME cd $BASE_DIR docker compose down --remove-orphans || true docker pull registry.ops.com/enterprise/$APP_NAME:$IMAGE_TAG docker compose up -d for i in $(seq 1 30); do code$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/actuator/health) if [ $code 200 ]; then echo 服务启动成功health check通过 exit 0 fi echo 等待服务启动... $i/30 sleep 2 done echo 健康检查失败发布不通过 exit 1set -e保证任何一步出错脚本立刻终止避免带着残缺状态继续往下走。docker compose down --remove-orphans是为了把旧的容器实例和孤儿容器一并清掉确保容器名不会冲突。docker compose up -d重新以新镜像启动服务容器名保持不变外部依赖的端口映射也不会变更。健康检查是这套方案里我认为最重要的一环。为什么不只是看docker ps -q是否返回容器ID因为容器进程虽然在不代表业务已经准备好了——Spring Boot启动一个服务可能持续二三十秒如果这时候Nginx就把流量导进来用户看到的一定是502。网上很多微服务发布方案没有健康检查这一步等于赌博。我这里用Spring Boot Actuator的/actuator/health接口做探针30次循环每隔2秒探一次最多等60秒。超过60秒没通过就直接fail流水线会打出红叉。这个机制上线后成功拦截过好几次镜像配置错误导致的启动失败。4.3 版本回滚与多环境隔离怎么做Docker镜像的tag天然就是版本所以回滚变得异常简单。我发现新版本有Bug想回滚到上一个版本只需要重新执行一次旧的发布cd /opt/deploy/user-svc docker compose down docker pull registry.ops.com/enterprise/user-svc:v1.2.2 docker compose up -d旧镜像的tagv1.2.2还在Harbor里pull下来直接跑通。整个回滚动作在1-2分钟之内完成相比过去手工替换jar包、重启、盯着日志看十分钟这个体验是完全不一样的。多环境隔离方面我采取的策略是一个环境一套Compose运行目录通过环境变量文件区分配置。比如/opt/deploy/user-svc/.env.test和/opt/deploy/user-svc/.env.prodCompose文件里用${SPRING_PROFILES_ACTIVE}这样的变量占位具体值由环境文件注入。发布脚本针对不同环境传不同的参数./deploy.sh user-svc v1.2.3 test ./deploy.sh user-svc v1.2.3 prod这样做的好处是整个Compose编排文件只有一份不会因为环境不同而维护出多个副本。差异点全部收敛到环境变量里任何一个环境的配置变更都能在git里看到历史记录。5. 生产环境踩坑实录与排查方法5.1 Docker服务起不来的几个高频原因这套方案跑了一年多生产环境里Docker本身出过的问题不算多但一旦出了问题就是大事。我列一下出现频率最高的几个以及我的排查顺序。“docker服务启动失败”这个现象在安装初期最容易遇到。比如我遇到过systemctl start docker报Job for docker.service failed because the control process exited with error code。第一反应应该是journalctl -xu docker.service看日志而不是反复重启。造成这个报错最常见的原因有三个一是daemon.json写坏了JSON格式不对Docker进程一启动就解析失败退出二是>environment: - TZAsia/Shanghai同时挂载宿主机的localtime文件。Compose文件里统一写volumes: - /etc/localtime:/etc/localtime:roJava服务的JVM本身会读取user.timezone和系统时区这两项设置好之后无论是Spring Boot的日志时间还是new Date()输出的时间都会恢复正常。5.3 镜像构建慢、镜像运行内存溢出怎么办镜像构建慢分两个层面看。一个是网络层面基础镜像和Maven依赖都要从外网拉取国内网络环境有时候很不稳定。解决办法有两个Docker的daemon.json里面配置镜像加速器我在前面已经写过了Maven依赖则用本地私服Nexus把中央仓库的依赖全部代理到内网这样构建阶段就不再依赖公网。第二个层面是缓存利用不充分构建过程中反复拉取同一份依赖。最好的解决办法就是我在Dockerfile里写的那个缓存技巧先单独COPY pom.xml并执行dependency:go-offline让依赖层稳定落在Docker cache里。容器运行内存溢出这是Java服务容器化后比较常见的“隐形杀手”。表面看容器运行正常日志里也没有OutOfMemoryError但服务突然无响应。这时候docker inspect看状态往往会发现容器处于Exited状态查看docker inspect 容器名 -f {{.State.OOMKilled}}返回true。说明容器被内核OOM Killer杀掉而不是JVM自己抛异常退出。这种情况基本就是容器内存限制设置得太紧或者JVM堆参数分配不合理。我调整参数的思路是先给容器内存1G起步JVM堆内存在512M观察一段时间docker stats的输出。如果实际内存峰值在700M-800M且稳定说明容器内存正好如果经常800M以上就适当放宽JVM堆或容器内存。实际部署中业务高峰期内存容易缓慢爬升建议给足15%-20%的余量别卡得太死。5.4 日志与监控的落地建议容器化之后日志收集方式也变了。默认的docker logs只适合调试时看生产环境里几十个服务分布在三台机器上用docker logs一个个翻实在低效。我这边目前的处理方式是每个容器统一把日志写到挂载的宿主机目录比如/opt/logs/服务名然后一台日志服务器上用Filebeat采集推给ElasticsearchKibana做检索和可视化。这套EFK方案相比直接装一堆日志采集插件灵活度更高也不侵入业务代码。容器的资源监控我先用的还是docker stats配合宿主机的基础监控系统。docker stats的问题是不能持久化和报警所以我又加了cAdvisor来采集容器级指标然后输出到Prometheus再用Grafana展示。这三个工具都是容器化直接可以跑的配置比较简单但对排查线上问题帮助非常大。有一次线上某个服务CPU飙高就是通过Grafana上容器CPU曲线和发布记录的对应关系快速定位到是某个新版本代码引入了死循环然后果断回滚。还有一个细节所有发布动作尽量安排在低峰期并且每次发布只动一个服务不要批量并发发布多个服务。因为微服务之间有调用关系同时发布多个服务风险叠加出了问题连排查基准都没有。我吃过一次亏同时发布了三个服务结果线上报错根本分不清是哪个服务引入的回归。后来定了规矩一次只发布一个服务等健康检查通过之后再发下一个。这个规矩虽然保守但在企业环境里稳定压倒一切。最后再说几句个人经验。这套基于Docker和DevOps的发布系统真正难的不是某个具体技术点而是“习惯”的养成。开发人员要接受用tag发布、要主动看流水线日志、要在PR描述里写清楚变更内容运维要接受从手工操作转为写脚本和排查平台问题。我把这套流程跑通之后业务方最大的感受是“发布变快了”但只有团队内部知道这份快是建立在一层一层的检查和约束之上的。如果你也在企业里搭发布系统我建议别想着一步到位先把一个服务从代码提交到健康检查跑通整个闭环再横向铺开。等技术成熟了再上K8s就是水到渠成的事。
返回列表