Docker Compose生产部署避坑指南:从插件配置到Nacos编排实践
公司上个月做基础组件容器化迁移第一批要把 Nacos 3.x 和一套向量检索服务用 Docker Compose 编排起来。我接手之后第一件事是在一台新初始化的机器上执行docker compose up -d结果终端甩出来一行冷冰冰的报错docker: unknown command: docker compose。当时我下意识以为是版本太老但docker --version显示的是 24.0.x一点也不老。后来排查下来才发现这其实是 Docker CLI 插件机制没生效连带着我意识到一个更大的问题企业环境里部署 Docker Compose真正容易踩坑的地方根本不在怎么写 yaml而在环境准备、生产化配置、资源管控和长期运维这几个环节。这篇文章就按我这次实际推进的顺序来写。我不会整篇堆一堆最佳实践的大词只讲我验证过、踩过、最后调通的东西。如果你贵司也正在从docker run裸命令迁移到 Compose或者想把跑在测试环境的 Compose 工程真正搬到生产这篇应该能帮你少走几周弯路。1. 从 unknown command 说起Compose 环境准备中的三个高频坑先把这个最经典的报错彻底讲清楚因为你在公司环境里大概率也会碰到而且原因往往不是你以为的那个。1.1 docker-compose 和 docker compose 是两代东西很多运维老哥还在用docker-compose带横杠这个命令那是 Compose v1 时代的产物本质是一个用 Python 写的独立二进制。而docker compose中间有空格是 Compose v2它改成了 Docker CLI 的官方插件不再单独安装一个可执行程序而是以docker-cli-plugin-docker-compose这个文件的形式放到 Docker CLI 的插件目录里。这个差异在 Docker 20.10 之后非常明显。新版 Docker 默认只认识插件形式的docker compose如果你机器上只装了老的docker-compose输入新命令就会报 unknown command。反过来如果脚本里还写着docker-compose up在新版 Docker 上也可能因为找不到 v1 二进制而失败。我见过最多的坑是一些人两台机器各装各的混着用最后 CI 脚本在 A 机器跑通、在 B 机器就跑不通。1.2 踩到 docker: unknown command 的完整排查链路遇到这个报错别急着去网上翻一堆apt install的帖子按下面这条链路五分钟左右就能定位第一步先确认你的 Docker 客户端版本和支持的插件机制docker --version docker compose version如果docker compose version同样报 unknown command说明插件大概率缺失如果它能输出版本那问题就变成为什么命令不能直接执行——通常是当前 shell 环境变量或 PATH 的问题。第二步检查插件是否真的装到了 Docker 可以识别的目录。Docker CLI 会按固定顺序扫描插件目录常见的有/usr/local/lib/docker/cli-plugins /usr/lib/docker/cli-plugins /root/.docker/cli-plugins正常情况下的插件文件长这样ls -l /usr/local/lib/docker/cli-plugins/ # 总用量 68340 # -rwxr-xr-x 1 root root 69976000 3月 21 10:22 docker-compose注意文件名必须是docker-compose没有 .exe 之类后缀而且要有可执行权限。网上很多教程让你curl -L 下载到/tmp结果你直接mv到插件目录后没chmod xDocker 会静默跳过这个插件然后继续报 unknown command。第三步检查你的 Docker 版本是否够老。Docker 20.10 之前的版本要支持 Compose v2 插件比较费劲我建议这种老机器直接放弃插件方式老老实实用 v1 的docker-compose或者干脆升级 Docker。企业环境里如果你没法轻易重启 docker daemon升级这条路线要慎重后面我会讲离线升级的替代方案。1.3 内网环境与国产 Linux 发行版的安装补充我们这次的目标机器是麒麟 V10 x86_64系统是 CentOS 系的底子但内网完全不能访问外网。这种环境下在线执行apt install docker-compose-plugin或者yum install docker-compose-plugin基本都会失败因为官方源不一定在里面第三方源的安全你也不敢信。我的做法是准备离线安装包。但这里有个容易踩的坑Docker 的 Compose 插件官方没有单独的 rpm/deb 包所谓docker-compose-plugin其实是跟随 docker-ce 整套一起发布的。你单独用yum install docker-compose-plugin往往会把一堆依赖带上反而麻烦。最稳妥的离线路线是下载 Compose v2 的独立二进制# 在有外网的机器上到 GitHub Releases 页面下载对应架构的包 # 例如 linux-x86_64 版本 curl -L -o docker-compose-linux-x86_64 \ https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-x86_64 chmod x docker-compose-linux-x86_64 # 拷贝到插件目录 sudo mv docker-compose-linux-x86_64 /usr/local/lib/docker/cli-plugins/docker-compose # 验证 docker compose version这个过程在内网机器上只需要拷贝一次文件不触碰任何包管理器依赖问题归零。唯一要注意的是 GitHub Releases 的下载链接偶尔会变最好先在能联网的机器上验证 URL 有效再打包进你的部署目录。版本选择上我建议别追求最新优先挑企业场景里被验证了半年以上的稳定版。Compose v2.20 之后有一些行为变化比如对depends_on的语义调整新版本你在测试环境玩可以生产环境尽量锁死一个版本避免后续手滑升级导致行为不一致。2. 生产可用的 Compose 文件设计从能跑到敢上线环境问题解决后下一步当然是把服务编排文件写出来。但我要说句实话网上 90% 的 Compose 示例都是开发环境能跑的水平直接搬到企业环境会出大问题。我自己用下面的几个标准来评判一份 Compose 文件能不能上生产。2.1 多环境配置.env 与 env_file 的职责拆清楚企业里同一个 Compose 工程往往要同时跑在开发、测试、预发、生产四套环境不可能每套环境都维护一份 yaml。Docker Compose 自己支持变量插值它读的文件叫.env作用是往 compose 文件里填变量。而另一个概念env_file是指定容器内部的环境变量文件。这两个东西很容易混但用途完全不同。我习惯这样拆分.env只放 compose 层面的变量比如镜像 tag、对外端口、数据卷路径前缀、项目名。env_file放应用自己读的环境变量比如 JVM 参数、数据库连接串、日志级别。密钥类信息绝不进.env更不写死在 yaml 里。Compose v2 支持secrets直接挂载到容器文件系统Docker 19.03 之后建议优先用这种方式。举个实际的片段services: app: image: registry.internal.example.com/app-server:${APP_TAG:-v1.0.0} env_file: - ./config/${ENV:-dev}/app.env secrets: - db_password ... secrets: db_password: file: ./secrets/${ENV:-dev}/db_password这样开发环境不填任何变量也能跑起来预发环境只需要在启动前指定ENVstaging生产环境则可以由配置中心下发避免把敏感信息留在仓库里。你可能会觉得多套了一层配置有点烦但等你要做灰度、要做同环境多副本的时候会发现这是救命的设计。2.2 健康检查与依赖顺序让 down 掉的服务能自己恢复企业环境里最恼人的不是服务半夜挂了而是它挂了你第二天早上才知道。Compose 的restart: always能解决一部分自动拉起的问题但它不知道服务是否真正就绪——MySQL 容器启动了不代表端口能接受连接Nacos 进程起了不代表集群已经选主完成。这时候就要用healthcheck。我给团队定的规矩是所有有状态服务、所有对外提供接口的服务必须配健康检查否则不允许合并到主工程的 compose 文件里。举例给 MySQL 配健康检查services: mysql: image: mysql:8.0 healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -p$$MYSQL_ROOT_PASSWORD] interval: 10s timeout: 5s retries: 5 start_period: 30s注意两个细节一是start_period一定要给足容器从启动到 MySQL 真正能响应请求有时候要 30 秒以上这段时间内的失败不应该立刻标记为 unhealthy二是健康检查命令里的环境变量要用$$转义否则 Compose 会在宿主机层面就把它展开掉口令根本传不到容器里。有了健康检查depends_on才能真正发挥价值。很多新手以为depends_on会等依赖服务健康再启动自己实际上 Compose v2 默认只是等依赖服务容器已启动而已并不会等接口可用。你在 yaml 里显式声明condition: service_healthy才行services: app-server: depends_on: mysql: condition: service_healthy nacos: condition: service_healthy这样 Compose 会等依赖方健康后才启动 app-server后面你会发现服务宕机重建时整体可用性比原来瞎等 sleep 好很多。2.3 网络与数据卷规划别让所有容器挤在一个默认网络里开发环境里一个docker compose up拉起所有服务它们共享一个默认网络确实省事。但生产里我强烈建议按业务边界拆网络。最简单的做法是用外部网络让 Compose 工程自己创建的容器加入到一个已经规划好的 overlay 网络里docker network create --driver overlay --attachable prod-infra-network对应 compose 文件networks: default: name: prod-infra-network external: true这样多个 Compose 工程可以共享同一张网络A 工程的 Nacos 和 B 工程的业务服务能互相解析服务名但网络层面可以统一由网络策略管控。如果你有多个环境dev/staging/prod一定要避免默认的default网络名称冲突因为 Compose 默认会把项目名加进去形成唯一网络名而外部网络不存在这个问题。数据卷方面我也做了一次取舍革命。以前贪方便全部用 bind mount 挂宿主机目录后来发现跨环境路径不一致、权限混乱、备份策略必须跟着宿主机走太痛。命名卷named volume在这几方面都更干净services: nacos: volumes: - nacos-data:/home/nacos/data - nacos-logs:/home/nacos/logs volumes: nacos-data: name: nacos-${ENV:-dev}-data nacos-logs: name: nacos-${ENV:-dev}-logs命名卷的一个额外好处是可以用docker volume create --label把备份策略、存储池配置打进去配合存储驱动能直接把卷落到企业 NAS 或分布式存储上。bind mount 适合放配置文件、日志目录这种我知道宿主机一定存在的路径不适合乱挂应用数据。3. 真实案例用 Compose 把 Nacos 3.x 和配套服务一起编排起来光讲原则不过瘾我把这次迁移过程中真正写出来的一个 Compose 工程拆给大家看以现在热门的 Nacos 3.x 为主线。3.1 Nacos 3.x 部署的 Compose 配置Nacos 3.x 相比 2.x 在模块、配置上都有变化但好消息是它依然支持通过环境变量覆盖启动参数。我们这次用单机模式先跑起来但数据目录必须持久化否则每次重启配置和注册服务数据全没。services: nacos: image: nacos/nacos-server:v3.0.0 container_name: nacos-server env_file: - ./env/nacos-${ENV:-dev}.env ports: - 8848:8848 - 9848:9848 - 9849:9849 volumes: - nacos-data:/home/nacos/data - nacos-logs:/home/nacos/logs healthcheck: test: [CMD-SHELL, curl -f http://localhost:8848/nacos/ || exit 1] interval: 15s timeout: 5s retries: 10 start_period: 40s restart: always networks: - default关于端口要多说一句8848 是控制台和 HTTP API9848 是 gRPC 端口9849 是 9848 的偏移端口。很多人只映射了 8848结果客户端连接时 Nacos 上报 9848 连不上又是一轮抓瞎。如果你的客户端和 Nacos 不在同一网络段这三个端口都要开了。Nacos 3.x 的镜像对配置文件的管理也细化了。生产环境我推荐把关键参数放到启动环境变量中例如NACOS_AUTH_ENABLEtrue显式开启鉴权默认是关闭的内网裸奔很危险。NACOS_AUTH_TOKEN自定义 token别用文档里的默认值。MODEstandalone单机模式测试用企业集群化部署后面再看集群模式。start_period: 40s是调过的。Nacos 首次启动要建库建表、初始化缓存快则 20 秒、慢则 40 秒健康检查间隔 15 秒的话第一次检查大概率会失败但这个失败不能立刻判定它挂了否则服务端会不断重启它形成启动即被杀的循环。3.2 模型推理服务的编排思路容量预估与资源隔离这次还要一起拉起来的是一套 BGE-M3 embedding 模型推理服务。这类服务有一个特点模型加载阶段 CPU 和内存都会冲高但加载完成后日常推理的 CPU 占用反而稳定。你要是不管资源限制它可能会把宿主机搞到 OOM。我在编排这类 AI 推理容器时除了常规的环境变量、模型目录挂载之外最看重的就是给容器划定明确的 CPU 和内存预算。最终 Compose 文件大概是services: bge-m3: image: registry.internal.example.com/bge-m3-server:1.0.0 volumes: - /data/models/bge-m3:/models/bge-m3:ro environment: - MODEL_PATH/models/bge-m3 - MAX_BATCH_SIZE64 ports: - 8081:8080 deploy: resources: limits: cpus: 4.0 memory: 8G reservations: cpus: 2.0 memory: 4G healthcheck: test: [CMD-SHELL, curl -f http://localhost:8080/health || exit 1] interval: 20s timeout: 5s retries: 5 start_period: 60s注意一个细节deploy.resources在单机 Compose 下非 Swarm 模式也不是空摆设它会被 Docker 的容器运行时强制执行。limits.memory是硬限制超过会触发 OOMreservations是软预留帮调度器理解这个容器至少需要多少资源。这个设计在共享宿主机上特别重要免得一个模型加载把旁边的 Nacos 拖死。4. 资源限制与性能优化把 CPU、内存和启动时间都管起来企业环境从来都是多服务混部在一台物理机上谁都想多压榨一点资源但谁也都不想被别人连累。这一章我说几个和资源管理相关、并且我在迁移中真正遇到瓶颈的点。4.1 容器里的 JVM/.NET 运行时为什么 CPU 会异常升高排查过程中我注意到一个现象容器内跑的 Java 服务启动后宿主机top里有几个线程的 CPU 占用很高甚至持续几分钟降不下来。后来发现是 JVM 在启动阶段做 JIT 编译预热加上容器感知能力没开全导致 JVM 以为还是宿主机级别的 CPU 数量于是开了一堆并行编译线程。对应的解决办法是两件事一是给 JVM 传-XX:ActiveProcessorCount4之类的参数让它明确知道自己只有 4 个核可用二是在 Compose 里用healthcheckdeploy.resources.limits把容器 CPU 上限固定下来。热词里也有人提到.NET Runtime Optimization占用 CPU这其实是 .NET 运行时在后台做程序集预编译的线程。容器部署 .NET 服务时这个优化任务同样可能造成启动期 CPU 冲高解决办法和 JVM 思路一样让运行时明确感知容器资源边界同时通过ulimits和资源限制把预编译任务控制在合理水位。具体做法是services: dotnet-service: image: registry.internal.example.com/dotnet-svc:2.1.0 deploy: resources: limits: cpus: 2.0 memory: 2G ulimits: nofile: soft: 65536 hard: 65536ulimits里nofile是开源社区里最容易忽略的一项。容器内的进程默认会继承宿主机对文件描述符的软限制如果太小高并发下会直接报 too many open files这问题在裸机上不明显因为它常常是ulimit -n 65535但容器内可能是 1024。手动设成 65536 是标准做法。4.2 启动速度优化给健康检查留足start_period启动慢不一定会导致故障但一定会导致发布变长、回滚变长这在企业里就是实打实的成本。很多服务慢不在容器创建而在应用初始化。JVM 要加载类、Nacos 要跟配置中心同步、模型推理服务要把模型从磁盘读进显存这些都需要时间。我优化启动体验的核心思路就一条把启动慢这件事用start_period明确告诉健康检查器让它在指定时间内不判定服务异常同时把依赖服务的启动顺序串好。像上面 Nacos 的start_period: 40s、BGE-M3 的start_period: 60s都是实测后调出来的值。给太短健康检查会在服务还没起来时就把它标记为 unhealthy 然后反复重启结果反而更慢给太长服务真挂了也要等很久才被拉起来。实测下来比原来靠sleep 60硬等的方式快得多而且更可靠。你可以用这条命令持续观察服务从启动到 healthy 的时间docker inspect --format{{json .State.Health}} nacos-server看Log数组里的start和end时间戳能精确到秒拿这个数据去调start_period比拍脑袋准得多。4.3 重启策略要谨慎restart 不是万能的默认的restart: always确实能保证容器退出后自动拉起但它有个副作用如果容器是因为资源 OOM 被 kill 的它会在几秒钟内又被拉起然后再次 OOM形成反复崩溃重启的死循环。宿主机 CPU 和内存都会被拖垮。我的建议是分场景对于无状态服务可以restart: unless-stopped这样手动 stop 后不会被自动拉起避免排障时误操作。对于有状态服务比如 Nacos、数据库应该先用健康检查兜底再配置restart: on-failure:3连续失败超过 3 次就停下来报警让值班同学先看日志再决定是否拉起而不是让机器自己硬扛。这算是排障文化的问题让机器自己不断重试往往掩盖了真实原因。真正的高可用依赖的是监控、告警和可观测性不是无限重启。5. 运维阶段最容易忽略的坑项目名、日志轮转与升级策略Compose 文件写对了、服务也跑起来了这只是开始。真正让运维头疼的是接下来几个月的日常维护。这里几个坑我都踩过逐个说。5.1 项目名project name会决定网络名、卷名和容器名Compose 默认用工程目录名作为项目名也就是说你把同一个 Compose 文件放到不同目录下跑出来的容器、网络、卷名全不一样。如果 CI 或发布脚本里用了固定的容器名或网络名那换个目录部署就会找不到资源。所以生产环境我强烈建议显式指定项目名不要依赖目录名推断docker compose -p nacos-prod up -d或者在环境变量里设置export COMPOSE_PROJECT_NAMEnacos-prod显式指定项目名还有个好处你可以在同一台机器上并行跑多个相互隔离的环境比如一套预发、一套金丝雀只要项目名不同网络层天然隔离互不干扰。我在预发环境就用COMPOSE_PROJECT_NAMEnacos-staging跑了一套完整副本通过外部网络桥接少量端口给测试用成本极低。5.2 日志轮转JSON 日志驱动的大小控制数据卷持久化你做了、健康检查你配了但如果日志不管理磁盘被写满是迟早的事。Compose 默认的日志驱动是 json-file会无限收集容器日志到宿主机而且文件大小没有任何上限。我见过一台磁盘 100G 的机器被容器日志三天写满的情况。在 Compose 服务定义里给每个服务配日志上限services: app-server: logging: driver: json-file options: max-size: 10m max-file: 5max-size10m表示单个日志文件到 10MB 就滚动max-file5表示保留最近 5 个文件也就是单容器日志上限 50MB。这个配置对几乎所有容器都适用不给它配日志上限等于给磁盘埋了个定时炸弹。如果是企业里有集中的日志采集系统比如 Kafka ELK 或 Loki建议直接把 stdout 接入采集端宿主机上就不保留日志文件了驱动可以换gelf或直接用journald。还有一条命令要提醒docker system prune很常用但它默认不会清数据卷你千万别以为它会把没用的卷一起清掉反而会让宿主机上堆一堆孤儿卷。要清卷得手动docker volume prune而且执行前一定确认没有正在使用的服务。5.3 平滑升级滚动更新和回滚的 Compose 姿势生产服务免不了要发新版本Compose 的更新逻辑比想象中简单但也有一些细节。最直接的方式是改镜像 tag然后执行docker compose -p app-prod up -dCompose 会比较配置差异发现镜像变了会重建对应容器。这里有个常见的坑如果你没有显式指定镜像 tag而是写image: xxx:latest那 Compose 默认不会主动去拉新镜像因为本地已有 latest 就认为没变化。所以生产文件里必须用明确的版本号比如image: registry.internal.example.com/app-server:v1.0.0升级时改版本号再 up。对于多副本场景用--scale可以做到滚动更新docker compose -p app-prod up -d --scale app-server3配合健康检查Compose 会创建新的容器等服务 healthy 后再继续建下一个整体对流量影响很小。我发现很多团队以为 Compose 做不了滚动更新其实它是能做基础版本的滚动扩缩容的只是不如 K8s 那么精细化。如果你的业务规模真的超过两三台机器再考虑引入编排平台规模没到那个量级Compose 的这套手感已经足够顺滑。回滚也一样简单把镜像 tag 改成上一个稳定版本号再docker compose up -d即可。配置文件没有结构性变更时回滚通常 30 秒内完成比手动删容器重建快得多。最后再分享一个实际操作中的体会这套 Compose 化改造从环境准备、生产配置到资源管控总共花了大概两周的零碎时间。真正让我觉得值得的不只是把服务装起来了而是整个部署过程从一个人盯着终端敲命令变成一条命令可复现、一个文件可审查。Git 里维护 compose 工程版本任何人 checkout 下来只要环境变量给对就能在十分钟内拉起一整套和线上一致的环境这个确定性在团队协作里价值极大。如果你现在也正卡在某台机器上报 unknown command 或者容器时不时重启先别急着换编排平台。把本章提到的资源限制、健康检查、项目名和日志轮转这四件事做好你的 Compose 工程至少能在生产环境里稳定跑上半年。最后留个习惯每次改动 compose 文件后跑一遍docker compose config --quiet它能帮你第一时间发现 yaml 语法和字段拼写错误别等到up -d才被报错打脸。