这两年我经手维护的项目里,环境管理方案出现了很明显的分化:老一点的仓库还靠一串 Shell 脚本打天下,新一点的项目基本都切到了 Docker Compose。两种方案我都在实际项目里深度用过,也分别踩过不少坑。今天不站队说谁一定更好,而是把选型时真正需要考虑的因素拆开讲清楚——什么时候该继续用脚本,什么时候上 Compose 更划算,以及两者怎么配合使用,而不是非要二选一。
1. 先搞清楚"环境管理"到底在管什么
1.1 配置漂移才是真正的敌人
聊方案之前,得先定义问题。我们说的"环境管理",本质上是在对抗一个东西:配置漂移。同一个项目,三个月前装好的依赖、半年前设过的环境变量、某台机器上手工改过的系统配置,都会让环境一点点偏离"标准状态"。等到某天新同事加入,照着文档从头搭一遍环境,发现根本跑不起来,这时候你才意识到,团队其实从来没有一份真正可信的环境说明。
我见过最典型的场景是这样的:某同学的开发机上一切正常,代码切到另一个人手里就起不来,折腾半天发现是 Python 版本差了 0.1、某个系统库没装、某个环境变量没 export。这些细碎差异单看都不严重,叠在一起就是一整天的排查时间。环境管理方案要解决的,不是"怎么装依赖",而是"怎么让每台机器上的环境都收敛到同一个状态"。
1.2 方案谱系:文档、脚本、容器
围绕这个目标,常见做法可以放在一条谱系上:
- 手工文档:环境全靠 README 描述。成本最低,但文档天然滞后,写的人默认读者和自己有相同的机器和习惯。
- Shell 脚本:把安装步骤固化成可执行文件。比文档进了一步,但脚本本身依然依赖宿主机的各种前提条件。
- 容器化编排(Docker Compose):把整个环境要素——系统库、运行时、依赖、服务编排——全部刻画成声明式配置。环境不再"安装"到机器上,而是被"描述"出来,每次从镜像重建。
这三个层级不是谁替代谁的关系。脚本可以内嵌在 Compose 方案里做辅助,Compose 也可以反过来被脚本调用。理解这条谱系,后面选型才不会钻牛角尖。
2. Shell 脚本方案:轻量、直接,但容易"假成功"
2.1 脚本真正擅长的场景
Shell 脚本不是原罪。它最大的优势是零额外依赖——任何一台 Unix 系的机器天然有 bash,你不必先安装 Docker 才能安装别的工具。所以下面这些场景,我至今仍然推荐用脚本:
- 项目只有一两个外部依赖,比如"装个 Python 3.11 + pip 安装 requirements.txt"。
- 团队规模很小,机器类型高度统一,比如全员标配同版本 macOS。
- 操作本身是一次性的,比如从 Git 仓库拉代码后做初始化。
- 你只是想封装几个本地命令,减少敲键盘的次数。
这类脚本最怕写复杂。我见过最好的一个"轻量脚本",其实只有 15 行,做的事情就是检查 pyenv、安装固定 Python 版本、创建虚拟环境、装依赖。它长这样:
#!/usr/bin/env bash set -euo pipefail PYTHON_VERSION="${PYTHON_VERSION:-3.11}" if ! command -v pyenv >/dev/null 2>&1; then echo "请先安装 pyenv: https://github.com/pyenv/pyenv" exit 1 fi pyenv install -s "$PYTHON_VERSION" pyenv local "$PYTHON_VERSION" python -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r requirements.txt echo "环境初始化完成,运行: source .venv/bin/activate"这个脚本值得注意的细节是set -euo pipefail和前置检查。前者保证任何一步失败就立刻终止,不会给你"看起来成功"的错觉;后者让脚本在缺工具时直接报错而不是继续跑出更诡异的故障。很多人写环境脚本不写这两行,等到出错时才发现脚本已经带着残缺状态跑了半天。
2.2 那些"看起来成功"的失败
Shell 脚本方案最大的坑,是它经常静默产生偏离,而不是报错。我列几个实战里反复遇到的:
- 解释器差异:脚本第一行写
#!/bin/sh还是#!/usr/bin/env bash,行为完全不同。某些系统里sh是dash,不支持数组、source、[[ ]],脚本照样能跑,但跑出来的结果就是不对。 - 非交互 Shell 不加载 rc 文件:新开一个终端跑脚本,
nvm、pyenv、rbenv这些版本管理器经常处于未激活状态,command -v node找不到。问题不在脚本本身,而在脚本假设了一个已经初始化好的交互环境。 - GNU 工具和 BSD 工具的差异:
sed -i在 Linux 和 macOS 上的参数格式不同,date、ls的输出格式也五花八门。一个为 Ubuntu 写的脚本,放到 macOS 上可能每个命令都有一点点不同。 - 包管理器分裂:apt、yum、brew 各有各的写法。脚本为了兼容几个平台,往往演变成一堆
if分支,每个分支还只能"尽力而为"。
这些坑的共同特点是:脚本执行完没报错,但环境并没有按预期收敛。等新同事跑完脚本说"装好了",实际一编译才发现缺了头文件。脚本方案的本质是把文档的可执行化,它继承了文档的一个毛病:无法验证结果。
2.3 维护成本随人数线性上涨
如果团队只有一个人,脚本方案可以长期运转得很好。但到三五个人的时候,机器的多样性开始侵蚀你最初的那份干净脚本。Windows 同事要装 WSL 才能跑 bash,某台旧机器系统库版本太老,新版本工具链不再支持……
我统计过自己维护过的一个中等规模项目:从最初 30 行安装脚本,到后来兼容各种分支、各种检查,膨胀到将近 400 行,期间还不断有人给 README 补充"如果遇到 XX 错误,请手动执行 YY"的附录。这个状态非常典型——脚本方案的成本不是写出来的,是长出来的。每多一台新机器、一个新成员,就等于多一份需要调试的边界条件。
3. Docker Compose:把环境本身变成"可复现的描述"
3.1 环境即代码:换个角度看问题
Docker Compose 的核心理念和脚本截然不同。脚本关心的是"怎么把环境装到这台机器上",Compose 关心的是"用一段声明把环境精确描述出来,再用同一段声明在任意机器上重建"。
拿上面的 Python 项目举例,用 Compose 之后你会发现,你不再需要关心宿主机上装没装 Python、pyenv 版本是多少、系统库缺不缺。Dockerfile 里写清楚基础镜像和依赖,Compose 文件里写清楚有哪些服务、它们之间怎么连接,剩下的交给容器运行时去处理。这就是所谓的环境即代码——环境配置进入版本库,跟着代码一起走,天然可审计、可回滚。
3.2 一个典型 Compose 文件拆解
真实项目里,Compose 的价值在多个服务协作时才充分体现。比如一个 Web 应用通常需要数据库、缓存和业务服务三个角色,Compose 文件可以这样写:
services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: app_dev POSTGRES_USER: app POSTGRES_PASSWORD: dev_password ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine ports: - "6379:6379" app: build: . depends_on: postgres: condition: service_healthy redis: condition: service_started environment: DATABASE_URL: postgresql://app:dev_password@postgres/app_dev REDIS_URL: redis://redis:6379/0 ports: - "8080:8080" volumes: - .:/app volumes: pgdata:几个关键点我重点解释一下:
depends_on配合condition才是正确的启动顺序控制。很多新手只写depends_on,结果数据库容器虽然启动了,但内部还没准备好接受连接,应用照样连不上。加上healthcheck和service_healthy,Compose 会等数据库真正就绪后才启动应用,这才是声明式编排的价值。
数据卷必须显式声明。数据库容器删掉再启动,数据要靠pgdata这个命名卷保留下来。如果忘了定义volumes,容器一重建数据就没了,这个坑比想象的常见。
应用代码用 bind mount 挂载进容器。.:/app让你在宿主机上改代码,容器里立即生效,配合热重载开发体验和本地几乎一致。这也是 Compose 在开发环境比纯docker run好用的原因——配置文件把端口、挂载、环境变量这些散落在命令参数里的东西全部收纳到一起了。
3.3 引入容器化的隐性成本
我不建议无脑吹 Compose。它真实存在的成本有三块:
- 概念门槛:镜像、容器、卷、网络、构建上下文,每个概念都需要时间消化。团队里如果有人对 Linux 本身都不熟,上手会非常挣扎。
- 本地资源开销:macOS 上 Docker 靠虚拟机跑,内存和 CPU 占用都不小。笔记本只有 8G 内存的话,几个容器一起起,风扇直接起飞。
- 调试链路变长:应用报错了,你得先分清是容器内的问题还是镜像是旧的,要
docker compose exec进去查看,日志也要通过logs命令聚合。多了一层抽象,就多一层排障成本。
这些成本在项目初期、单机单人场景下尤其明显。如果一个项目本来就只有一个进程、宿主机环境也简单,你用 Compose 属于杀鸡用牛刀,还给自己添了维护 Dockerfile 的负担。
3.4 Compose 收益最高的时刻
从我几次实践的体会来说,Compsoe 的收益拐点出现在:**团队里开始有人问"为什么我这边跑不起来"**的时候。新成员加入后,从克隆代码到跑起完整环境,如果只需要执行docker compose up一个命令,这个体验对团队效率的提升是立竿见影的。另一个高收益场景是项目本身有多个服务——数据库、缓存、队列、定时任务——脚本方案要把这些全部装到宿主机上,彼此还可能版本冲突,Compose 把它们隔离在各自的容器里,互不干扰。
4. 选型决策:别问"哪个好",问"你处在哪个阶段"
4.1 四个决策维度
我给团队做环境方案评估时,通常从四个维度打分,而不是凭感觉。
维度一:服务数量与依赖复杂度。1 到 2 个服务、依赖都是简单的 pip/npm 包,脚本完全够。3 个以上服务,或者牵扯到不同版本的系统库、中间件,直接考虑 Compose。我有一个判断标准:如果环境的搭建步骤超过 15 条,或者需要手动修改系统级配置,脚本方案的脆弱性就会迅速暴露。
维度二:团队规模与人员流动。单人项目随便选,顺手就好。多人协作、特别是经常有新成员加入的团队,Compose 的"一条命令拉起全部环境"优势无与伦比。脚本方案在新人手里大概率变成一场"跟着文档猜步骤"的冒险。
维度三:机器与系统多样性。全员都是同样的 macOS,脚本能撑很久。一旦出现 Windows + Linux + macOS 混杂,脚本方案的if分支会爆炸,这时 Compose 的跨平台一致性优势非常明显。
维度四:团队的容器经验。这里我最想说句实话:如果团队没人用过 Docker,第一次就上 Compose 大概率会翻车。容器概念对新手有陡峭的学习曲线,项目进度会被"什么是镜像层""挂载目录怎么变了"这类问题拖慢。这种情况下,可以先从脚本方案过渡,或者让一个有经验的人先把 Compose 方案搭好,其他人只负责"使用"而不是"理解"。
4.2 一张表快速定位
我把上面的维度整理成表,方便你对照自己的情况:
| 决策维度 | 倾向继续用 Shell 脚本 | 倾向迁移到 Docker Compose |
|---|---|---|
| 服务数量 | 1~2 个,依赖少 | 3 个及以上,含中间件 |
| 团队规模 | 单人 / 2 人以内 | 3 人以上,经常有新人 |
| 系统一致性 | 全员统一系统版本 | Windows / Linux / macOS 混杂 |
| 容器经验 | 零基础,没有老手带 | 有人熟悉 Docker,能主导搭建 |
| 部署目标 | 本机开发,单台服务器 | 多环境、云环境、多台机器 |
这张表不是二分法。大部分团队会落在中间区域,这恰恰是下一节要讲的混合方案的空间。
5. 混合方案:用 Shell 管操作,用 Compose 管环境
5.1 我目前最喜欢的组合
折腾了几年之后,我现在的默认方案是混合式:基础设施和环境细节交给 Docker Compose,日常操作和参数化交给 Shell 脚本,两者通过.env文件和 Makefile 粘合起来。
具体来说,项目里放一个docker-compose.yml管所有服务,再放一个Makefile暴露高频命令,让不熟悉容器的人也不用碰 Docker 命令。比如:
.PHONY: up down logs ps env up: docker compose up -d down: docker compose down logs: docker compose logs -f app ps: docker compose ps env: cp .env.example .env @echo "请编辑 .env 填写本地配置"这样新同事的流程是:复制仓库 → 执行make env创建本地配置 →make up拉起环境 →make logs看日志。他完全不需要理解容器原理,也不需要背 Docker 命令,所有操作都被收敛成了几个好记的入口。而docker compose.yml里的细节由熟悉容器的人维护,双方各得其所。
5.2.env是配置注入的正确姿势
混合方案最容易踩的坑是把环境变量硬编码进 compose 文件。密码、端口、密钥这些东西一旦写死,换个环境就得改文件,还会被带进版本库造成泄露隐患。
正确做法是用.env文件配合 Compose 的变量替换:
# .env.example —— 提交到版本库 POSTGRES_DB=app_dev POSTGRES_USER=app POSTGRES_PASSWORD=dev_password APP_PORT=8080然后在docker-compose.yml里这样引用:
services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - "5432:5432" app: environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres/${POSTGRES_DB} ports: - "${APP_PORT}:8080".env本身不提交到版本库,只提交.env.example模板。每个人都有独立配置,又不互相影响。这比脚本里export一堆变量优雅得多——脚本方案里你为了覆盖一个端口,往往要改脚本源码;env方案里只需要改自己的.env。
5.3 我踩过的几个真实坑
混合方案也不是银弹,有几个坑我付出了不少时间,写出来给你避雷:
坑一:数据卷权限问题。在 Linux 上跑容器,pgdata卷里的文件属主经常变成容器内用户,导致宿主机上删除或备份时需要 root 权限。补救办法是给pgdata卷指定命名并保持长期复用,不要频繁down -v销毁数据卷,销毁前务必确认数据已经备份。
坑二:跨平台的换行符问题。有人在 Windows 上把.env文件保存成带CRLF换行,传给容器后值里莫名其妙多个\r,数据库连接串就废了。这个错误极其隐蔽,排查时我一度以为是自己密码写错了。建议在.env.example顶部加一行注释提醒,或者让脚本入口强制做一次dos2unix。
坑三:镜像版本漂移。写image: postgres:latest是定时炸弹。某次基础镜像更新大版本后,本地数据卷里的数据格式不兼容,容器直接起不来。从此我所有 compose 文件都固定到具体小版本,比如postgres:16.2-alpine,升级时走明确的迁移流程而不是被动接受 latest。
坑四:忘记健康检查导致启动竞态。前面提过的depends_on+healthcheck是标配。没有它,应用启动时数据库还没就绪,应用进程退出,restart 策略又会拉起它,形成反复崩溃循环。这时候看日志会发现"Connection refused"刷屏,非常搞心态。
6. 最后一点个人体会
做了这么多对比和剖析,我最终的倾向其实很简单:先想清楚环境方案的消费者是谁。如果环境只服务于你自己,选你熟悉的——脚本也好、Compose 也罢,怎么省事怎么来。如果环境方案要服务于一个团队,那就要把这个方案的维护成本计算进去,不只是"我写着顺手",还要考虑"别人读起来顺不顺、改起来慌不慌"。
我在实际使用中的最大感受是:Shell 脚本和 Docker Compose 的关系不是替代,而是互补。脚本适合做"一次性、面向人的交互",Compose 适合做"反复重现、面向状态的声明"。把两者组合起来,让脚本去调 Compose、让 Compose 去管环境,这套组合在多个项目里都跑得很稳,新同事上手的平均时间从以前的两三天缩短到了半小时以内。
如果你现在正纠结要不要把项目环境迁到 Compose,我的建议是挑一个周末,把当前所有依赖服务和安装步骤列成清单,对照文章里的四个维度打打分。如果服务数量超过 3 个、团队超过 2 个人,迁过来大概率是值得的;如果是纯个人玩具项目,那继续用脚本就好了,不用为赶时髦买单。