Docker系统集成一键部署实战:从Dockerfile到docker-compose

发布时间:2026/9/13 2:28:09
Docker系统集成一键部署实战:从Dockerfile到docker-compose 做系统集成的朋友应该都有这种体会本地跑得好好的一到客户现场就各种水土不服。我把整个交付包改造成了Docker一键部署后这个问题基本绝迹了。之前接手一个系统集成项目需要把用户端Web、管理后台、API服务、定时任务和文件存储完整部署到客户机房用传统方式折腾了快一周才稳定。改成Docker之后从解压到服务全部起来只需要十几分钟客户自己也能操作。这篇文章就把这套方案从头拆一遍包括Dockerfile编写、docker-compose编排、部署脚本设计和源码目录结构希望能给正在做系统集成的朋友一份可以直接抄作业的参考。1. 整体设计为什么非要用Docker做系统集成交付1.1 传统部署方式到底卡在哪回想最早做项目交付通常是一份部署文档加一个tar包。文档里写着需要JDK 1.8、MySQL 5.7、Nginx 1.18、Redis 6.0还要手动改三个配置文件。听起来不难但客户环境千奇百怪有装过旧版JDK的、有MySQL端口被占的、有服务器时间不准导致登录态过期的甚至有的机器连openssl版本都不对。每次排错都在本机和客户机器之间来回对比“环境差异”效率极低。有一次我花了整整两个下午最后发现是客户机器的/etc/hosts里没有映射主机名导致服务间调用失败。这类问题本质上是“部署环境不可控”。传统软件包假设了固定的运行环境但又没法把整个环境一起带走。Docker解决的就是这个问题把应用和它的运行环境操作系统库、JDK、Python、依赖包、配置模板一起打包成镜像部署的时候只需要一个能跑Docker的Linux环境镜像一加载所有“环境差异”就消失了。对于系统集成项目来说交付物从“文档压缩包”变成“镜像编排文件部署脚本”整个流程的确定性和可复现性提高了不止一个量级。对比维度传统方式Docker方式环境一致性完全依赖手工配置镜像内置开箱即跑交付物文档 压缩包镜像 编排文件 脚本故障恢复靠人肉回滚脚本自动备份回滚扩容调整很少做怕搞坏环境改一下compose副本数即可新人上手成本需要理解整套中间件安装会几条docker命令就能启动1.2 这一版系统集成的整体架构设计我做的这个项目是典型的“多服务集成”类型。前端是两个Web应用用户端和管理后台后端是一个Spring Boot API服务另外还有定时任务模块、文件存储模块数据库用了MySQL 8.0缓存用的Redis文件存储直接挂宿主目录。整个依赖关系大概是Web - API - MySQL/Redis定时任务依赖API里的业务逻辑文件存储模块独立读写数据卷。直接把这么多服务丢到一个Docker容器里是最省事的做法团队里也有人建议“一个容器装所有”。但我坚持拆成多个容器用docker-compose统一编排。原因有三个第一每个服务独立扩容比如API服务压力大可以单独启动多个副本第二故障隔离定时任务挂了不会影响Web入口第三日志和配置可以按服务分别管理排错的时候一眼看到是哪个容器在报错。拆开之后的代价是需要额外处理服务间网络docker-compose天然创建了内部网络服务名就是域名这部分复杂度被语法承担了完全可以接受。1.3 交付物包含哪些内容这套方案的最终交付物是一个文件夹结构大致如下system-integration/ ├── docker-compose.yml # 服务编排文件 ├── .env # 环境变量模板 ├── deploy.sh # 一键部署/更新/回滚脚本 ├── backup/ # 部署前的自动备份目录 ├── services/ │ ├── api/ │ │ ├── Dockerfile │ │ ├── app.jar │ │ └── config/ │ ├── web/ # 前端Nginx镜像 │ │ ├── Dockerfile │ │ └── dist/ │ ├── admin/ │ │ └── ... │ ├── mysql/ │ │ └── init.sql │ └── redis/ │ └── redis.conf └── logs/ # 宿主机日志目录这个结构对客户来说非常友好不懂Docker也能按文档操作因为真正动手的只有deploy.sh一个脚本。源码不是跟着tar包走的而是放到内部GitLab仓库交付时给客户提供源码仓库地址和构建说明书镜像可以由我们在CI流水线中构建好也可以由客户在自己内网执行build-all.sh。两种方式在部署脚本里都做了支持。2. 从Dockerfile到编排文件核心细节拆解2.1 后端API的Dockerfile怎么写才算过关后端服务是Spring Boot应用传统方式是java -jar。写Dockerfile的时候我见过很多同事直接用maven镜像去构建然后把整个构建产物目录COPY进去这样会导致镜像体积膨胀到几个GB。我这里用了多阶段构建第一阶段用maven容器编译代码第二阶段只拷贝最终的jar包。多阶段的好处是构建环境不会残留在最终镜像里交付的镜像只包含运行时需要的JRE和jar包。基础镜像我选用的是eclipse-temurin:8-jre而不是openjdk:8因为Temurin在时区处理和安全更新上维护更积极。生产环境必须处理时区问题如果容器默认是UTC时间业务报表和对账会差8小时非常坑。所以我把时区配置直接做进镜像里FROM eclipse-temurin:8-jre LABEL maintaineryour-teamexample.com RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone WORKDIR /app COPY --frombuilder /build/target/system-api.jar app.jar RUN useradd -r -u 1001 appuser USER appuser EXPOSE 8080 ENTRYPOINT [java,-XX:UseContainerSupport,-XX:MaxRAMPercentage75.0,-jar,app.jar]有几个细节值得解释。第一为什么要创建appuser而不是用root启动。容器里用root启动应用一旦应用被攻击攻击者就有容器内最高权限再加上docker默认的root映射风险会进一步放大。用非root用户启动是安全基线要求。第二JVM参数里的UseContainerSupport和MaxRAMPercentage是给JVM“认容器内存”用的否则JVM会取宿主机的总内存来算堆大小容器限制2GBJVM却按16GB机器去设置很容易被系统杀掉。第三ENTRYPOINT里没有写--spring.profiles.active留给环境变量在compose文件里指定保持镜像的可复用性。2.2 前端和文件存储镜像的处理方式前端是打包好的静态文件我直接用Nginx镜像。这里也有一个常见的坑不要用最新版nginx必须锁定大版本比如nginx:1.22-alpine。原因是Nginx配置语法和模块在不同版本间有细微差异一旦镜像在服务器上被更新成不兼容的版本几百个客户站点会同时出问题。锁版本之后每次启动都拉同一个镜像行为可预期。Dockerfile很简单FROM nginx:1.22-alpine COPY dist/ /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/conf.d/default.confnginx.conf里我做了gzip压缩、前端路由history模式的try_files回退以及静态资源缓存。特别要注意的是Nginx容器默认会把access log打到底层stdout方便docker logs查看不要再单独写文件否则日志会无限膨胀占满磁盘。文件存储模块不需要Web服务直接挂宿主机目录在compose里用volume声明即可不需要单独写Dockerfile。因为服务之间通过内部网络通信Nginx配置里上行的API地址不要写localhost要写compose服务名比如proxy_pass http://api:8080。这也是新手最容易困惑的地方在容器里localhost指向的是当前容器自己不是宿主机“localhost:8080”根本连不到API服务。这个内部DNS解析能力是docker-compose创建的network自带的也是服务编排的价值所在。2.3 docker-compose.yml编排服务的顺序与陷阱这是整个交付包里最核心的文件。我把详细版本贴出来version: 3.8 networks: app-net: driver: bridge ipam: config: - subnet: 172.28.0.0/16 volumes: mysql-data: redis-data: services: api: build: ./services/api image: registry.internal.local/system-api:${TAG:-latest} restart: unless-stopped environment: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE:-prod} DB_HOST: mysql DB_PORT: 3306 DB_NAME: system_db DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis depends_on: mysql: condition: service_healthy redis: condition: service_healthy networks: - app-net ports: - 8080:8080 logging: options: max-size: 50m max-file: 5 web: build: ./services/web image: registry.internal.local/system-web:${TAG:-latest} restart: unless-stopped depends_on: - api networks: - app-net ports: - 80:80 admin: build: ./services/admin image: registry.internal.local/system-admin:${TAG:-latest} restart: unless-stopped depends_on: - api networks: - app-net ports: - 8081:80 mysql: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: system_db MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --lower_case_table_names1 volumes: - mysql-data:/var/lib/mysql - ./services/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1, -uroot, -p${MYSQL_ROOT_PASSWORD}] interval: 10s timeout: 5s retries: 5 networks: - app-net redis: image: redis:6.2 restart: unless-stopped command: [redis-server, --appendonly, yes, --requirepass, ${REDIS_PASSWORD}] volumes: - redis-data:/data healthcheck: test: [CMD, redis-cli, -a, ${REDIS_PASSWORD}, ping] interval: 10s timeout: 5s retries: 5 networks: - app-net这里有几个关键设计。第一mysql和redis加了healthcheckapi服务的depends_on条件是service_healthy这样可以确保数据库先准备好再启动API而不是用sleep 30来碰运气。第二数据库密码和连接串通过环境变量注入不写死在compose文件里实际使用的时候放在.env文件这个文件在交付包中只给模板具体值由部署人员填写。第三restart: unless-stopped保证服务器重启后容器自动拉起对于系统集成交付来说客户不可能半夜去手动启动服务这个配置能省很多售后电话。很多人会问为什么mysql的端口没有映射到宿主机。这是个故意的选择数据库只给内部网络访问不需要暴露到外部API和定时任务都通过app-net连接。如果要从运维端管理可以临时用docker exec进入容器或者单独开一个跳板机出口。端口暴露越少攻击面越小这个思路在面向客户的交付中尤其重要。3. 一键部署脚本把复杂操作封装成黑盒3.1 脚本要覆盖哪些场景一键部署的核心不是“执行一条命令”而是把部署过程中可能遇到的环境差异、异常状态、误操作风险全部隔离在脚本内部。我写的deploy.sh主要覆盖四类场景首次部署、更新版本、回滚版本、状态查看。首次部署要做环境检查、镜像准备、配置检查、启动服务更新版本要备份旧数据卷或数据库文件、拉取新镜像、优雅停止再启动回滚要恢复到上一个可用版本状态查看则是一个简单的docker compose ps。脚本的语言用Bash兼容性最好。不要依赖bash的某个高级特性我尽量用POSIX语法。整个脚本入口的用法是./deploy.sh install # 首次安装 ./deploy.sh update # 更新到新版本 ./deploy.sh rollback # 回滚到上一个版本 ./deploy.sh status # 查看所有服务状态3.2 环境检查把“客户机器能不能装”前置脚本第一步不是急着拉镜像而是检查宿主机的Docker环境。很多客户机器上要么没装Docker要么装了老版本甚至会出现Docker Desktop的虚拟化没开启导致Docker根本起不来的情况。我在脚本里封装了一个check_env函数依次检查操作系统发行版和版本、是否已安装docker、docker服务是否可运行、docker compose子命令是否可用这里兼容老旧的docker-compose独立命令、当前用户是否在docker组里。每项检查都会打印明确的提示不满足条件时直接退出并告诉用户怎么处理。Docker的安装函数我也内置了但只在检测到Docker缺失时才执行。支持Ubuntu、Debian、CentOS三套命令安装源用的是系统自带源或镜像源整个安装过程在脚本里是不交互的用户不需要按任何确认键。这个安装函数主要是给客户现场救急用的正常情况下建议运维同学在初始化机器时就把Docker装好。check_env() { if ! command -v docker /dev/null 21; then echo [ERROR] 未检测到Docker将尝试自动安装... install_docker || exit 1 fi if ! docker info /dev/null 21; then echo [ERROR] Docker服务未运行请检查虚拟化是否开启 exit 1 fi if ! docker compose version /dev/null 21 ! command -v docker-compose /dev/null 21; then echo [ERROR] 未找到docker compose命令 exit 1 fi }3.3 镜像准备与构建流程脚本支持两种镜像来源从私有镜像仓库拉取或者在客户机器上本地构建。默认情况下如果设置了REGISTRY_ADDRESS环境变量就执行docker compose pull拉取镜像如果没有就执行docker compose build本地构建。本地构建有一个好处不依赖外部网络内网交付时非常实用坏处是客户机器需要有Maven或Node的构建环境构建时间也会比较长。我在脚本里做了一个优化如果服务目录下没有app.jar和dist目录脚本会自动提示先执行build-all.sh进行源码编译避免直接对着空目录构建出错误镜像。prepare_images() { if [ -n $REGISTRY_ADDRESS ]; then echo [INFO] 从镜像仓库拉取镜像... docker compose pull else echo [INFO] 本地构建镜像... docker compose build --no-cache 2/dev/null || docker compose build fi }这里有个细节本地构建我默认加了--no-cache生产交付时建议每次构建都要保证产物是最新的不要依赖缓存。但如果客户机器性能太差--no-cache会非常慢所以我加了fallback逻辑如果带--no-cache失败就退回去不带参数重新构建。这个兜底是踩过一次坑才加的有一次构建工具版本升级缓存里的旧层直接导致镜像里的配置文件是老的服务起来之后登录接口全部报错。3.4 数据库初始化与版本数据备份数据库初始化和数据备份是系统集成交付里最容易翻车的一环。我在compose文件里已经把init.sql挂到mysql容器的docker-entrypoint-initdb.d目录mysql初始化数据卷时就会自动执行建表脚本。但问题是如果服务已经跑过一次mysql数据卷里已经存在初始化数据再挂init.sql是不生效的。所以deploy.sh update的时候不能重启后用初始化脚本覆盖必须先做数据备份再启动新版本。备份函数我写成这样backup_data() { BACKUP_DIRbackup/$(date %Y%m%d%H%M%S) mkdir -p $BACKUP_DIR docker compose exec -T mysql mysqldump -uroot -p${DB_ROOT_PASSWORD} --all-databases $BACKUP_DIR/mysql.sql 2/dev/null tar czf $BACKUP_DIR/upload.tar.gz -C ./storage . 2/dev/null || true echo [INFO] 数据备份完成$BACKUP_DIR }注意mysqldump是在容器内执行输出重定向到宿主机所以不能用-t要用-T选项禁止分配伪终端。备份完成后还会把存储目录里的上传文件也一起打包这对带文件管理的系统集成项目来说特别重要。回滚的时候除了把镜像TAG切回上一个版本还要用备份出的SQL恢复数据。我遇到过客户误删数据的场景回滚只做到了代码层面数据却没有还原最后还是凌晨手动从备份文件里捞数据非常痛苦。从那以后update之前强制备份就成了脚本里不可跳过的步骤。3.5 启动、健康检查与优雅退出启动服务之后不能立刻说“部署完成”还要等所有容器都进入healthy状态。我的脚本里有一个wait_healthy函数循环检查docker compose ps输出的健康状态最多等待120秒。如果超过时间还没healthy就自动打印相关容器的docker logs尾部日志方便当场定位问题而不是让用户把错误信息发回来再猜。优雅退出也很重要。docker compose stop会先向容器内PID 1发送SIGTERM让应用有机会做资源清理和事务回滚而不是直接kill。Spring Boot默认收到SIGTERM后会优雅停机Nginx容器收到SIGTERM也会正常退出。脚本里我特意没有用docker compose down因为down会把网络也删掉如果只是更新版本保留网络能缩短重建时间。只有首次安装和完全卸载时才用down。4. 源码结构与核心模块拿到源码后怎么改4.1 仓库目录到底该怎么组织这个项目的源码我放在内部GitLab和交付包结构不完全一样。源码仓库包含四个maven模块和三个前端工程这七个代码库分别对应七个Docker服务。为了让“含源码”这件事真正有用我把每个模块的README都写清楚包括本地开发怎么跑、环境变量有哪些、Dockerfile位置在哪。很多项目源码虽然开放了但没写怎么跑接收源码的同学光猜目录结构就要花半天所以这部分我特别在意。整体目录system-integration-src/ ├── backend/ │ ├── system-api/ # API服务 │ ├── system-task/ # 定时任务服务 │ ├── system-common/ # 公共模块 │ └── system-file/ # 文件存储模块 ├── frontend/ │ ├── user-web/ # 用户端Web │ └── admin-web/ # 管理后台 ├── deploy/ │ └── ... # 前文交付包内容 └── sql/ ├── init.sql # 建表脚本 └── update.sql # 增量脚本4.2 核心后端源码片段解读system-api模块里我设置了一个自动配置类负责从环境变量读取数据库和Redis配置。Spring Boot本身支持环境变量覆盖配置文件属性所以代码里不需要做太多特殊处理。但有一个点需要注意如果数据库连接串里包含特殊字符环境变量里要做转义否则compose解析会出错。我在application.yml里用这样的占位符spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD}源码里还提供了一个DatabaseInitializer类用于在应用启动后检查基础数据是否存在如果不存在就插入默认配置。这个类不是必需的功能但它是系统集成项目里的一个保险即使init.sql因为某种原因没有执行成功应用启动时也能自动把最基础的字典数据补齐。代码思路是使用Spring的ApplicationRunner在run方法里通过JdbcTemplate查询一张配置表的行数为零则插入初始化语句。4.3 前端如何通过环境变量对接不同后端地址前端是纯静态页面部署时如果API地址写死换一个环境就要重新打包非常不灵活。我的做法是在构建前端时只生成一个config.js文件里面用全局变量存放API基础地址Nginx容器启动时由entrypoint脚本根据环境变量渲染这个config.js。这样同一份前端镜像可以通过环境变量适配开发、测试、生产等多个环境。nginx入口脚本的核心#!/bin/sh cat /usr/share/nginx/html/config.js EOF window.API_BASE_URL ${API_BASE_URL:-/api}; EOF exec nginx -g daemon off;然后在Nginx配置里把/api反向代理到api服务。依赖这个机制客户要换服务器地址不需要改代码重打包只需修改.env文件里的API_BASE_URL然后重启web容器。这对面向多客户的系统集成商来说节省了大量重复构建时间。4.4 拿到源码后的自定义修改路径如果读者想把这套方案改造成自己的项目建议按这个路径走第一步保留deploy目录和sql目录不动替换backend和frontend里的业务代码第二步确保自己的后端服务端口、健康检查路径与compose文件保持一致第三步修改.env里的密码和数据库名执行deploy.sh install先在本机跑通第四步再跑一遍update验证备份和回滚。按照这个顺序通常半天内能跑起来。最忌讳的是先改编排文件再改业务代码出现问题后不知道是业务bug还是部署配置问题排错成本会翻倍。5. 常见问题与排查技巧实录5.1 Windows环境Docker起不来怎么办虽然最终交付目标大部分是Linux服务器但开发人员有一半时间在Windows上。最常见的问题是Docker Desktop启动时报虚拟化未开启。这类报错往往不是因为CPU不支持而是BIOS里没开VT-x或者Windows功能里的虚拟机平台没有启用。处理办法是先到任务管理器性能页确认虚拟化状态如果显示“已启用”再去控制面板开启“适用于Linux的Windows子系统”和“虚拟机平台”两个可选功能然后重启。这里有一个容易忽略的点如果你用的是Windows家庭版某些虚拟化功能入口找起来很费劲建议直接用Linux服务器或云主机做部署验证不要浪费时间在Docker Desktop上。5.2 容器启动后又立即退出怎么定位“docker compose up -d”之后容器status显示Exited这是交付现场遇到最多的故障。排查思路有固定套路先看容器日志再进容器看进程最后检查组网和配置。我通常按这个顺序# 查看退出码和最近状态 docker compose ps -a # 查看具体日志 docker compose logs --tail200 api # 检查配置是否被正确渲染 docker compose exec api env退出码是关键线索。Spring Boot应用如果数据库连不上通常会在日志里打出connection refused配置文件错误会打出Failed to bind properties端口被占用会打印Port already in use。把日志尾部200行截图发给研发基本能解决八成问题。如果日志完全没有输出就退出了大概率是启动命令不对或缺少执行权限这时候检查Dockerfile里的ENTRYPOINT是否可执行用户是否有权限。5.3 数据库时区、字符集和大小写敏感的坑MySQL 8.0和旧版本在默认配置上有差异。客户之前用的MySQL 5.7数据库名和表名都顺手写了大写迁移到MySQL 8.0后Linux环境下表名默认大小写敏感应用启动时报找不到表。我在compose里加的--lower_case_table_names1就是为了规避这个问题。同理字符集如果不强制指定utf8mb4默认latin1会导致中文乱码。这两项配置必须在mysql容器的command里固定下来不要依赖MySQL的默认值因为不同版本默认值真的不一样。还有时区前面在Dockerfile里设置了Asia/Shanghai但MySQL容器本身也需要设置TZ环境变量。否则会出现一种诡异现象API日志时间是北京时间数据库里的created_at却是UTC时间前后端差8小时。排查数据问题时很容易被这种表象迷惑。我现在的习惯是所有涉及时间的服务、数据库、脚本统一加TZAsia/Shanghai不做任何例外。5.4 服务器资源不足时的编排优化系统集成项目经常被部署在2核4GB的低配机器上同时跑MySQL、Redis、两个Nginx和一个Spring Boot内存很容易爆。我通常会在compose文件里给每个服务加上部署资源限制deploy: resources: limits: memory: 1g reservations: memory: 512m这里要特别注意docker compose的resource限制在老版本里只对Swarm模式生效本地docker compose可能需要指定--compatibility参数。我的做法是直接写到compose里同时在脚本中保留--compatibility开关。如果内存还是不够优先关掉admin前端或者把定时任务和API合并成同一个容器牺牲一点隔离换稳定运行。5.5 日志无限增长和镜像占磁盘的清理服务跑久了容器日志和旧镜像会占满磁盘。我在compose里给每个服务配置了json-file日志驱动限制最大50MB保留5个文件。同时部署脚本里加了一条clean命令一键清理悬空镜像和停止的容器。客户现场没有专门的运维磁盘满了系统会自动停机所以这个清理命令要提前教给客户的对接人。case $1 in clean) echo [INFO] 清理悬空镜像和停止的容器... docker system prune -f docker image prune -a -f --filter until720h ;; esac6. 从这套方案里沉淀的几个习惯6.1 交付之后的运维交接交付系统集成项目不能把deploy.sh丢给客户就完事。我在交付文档里会附上一页“日常运维速查”包括查看状态用./deploy.sh status查看日志用docker compose logs -f api修改配置后重启用./deploy.sh update手动备份用docker compose exec -T mysql mysqldump。这几条命令能覆盖客户90%以上的问题。另外我要求每次更新前必须执行一次备份如果客户忘了脚本也会自动做这能避免很多售后纠纷。6.2 镜像仓库与版本管理一开始我们直接用latest标签结果有次误更新导致全量回滚。后来我强制所有交付镜像打上版本号比如system-api:1.0.3update脚本默认拉取当前.env里指定的TAG回滚时把TAG改为上一个版本即可。镜像仓库内部用Registry 2搭建只允许内网访问既保证安全又不会因为公网拉取失败影响交付。使用TAG管理后发布和回滚都变得非常可控再也没有出现过“不小心把开发镜像部署到生产”的问题。6.3 致新手的几个建议如果你刚接触这个方案第一件事不是写Dockerfile而是先把自己项目的启动步骤列清楚依赖什么服务、需要哪些环境变量、有没有外部文件。列清楚之后再看这文章里的Dockerfile和compose文件思路会通得很快。我见过太多人把大量时间花在用什么基础镜像、怎么瘦身上结果忽略了最重要的事情让自己能在任何一台机器上把服务一键跑起来。先做到“能跑”再谈“跑得好”。容器化只是一个工具最终目标是省心。