数据科学项目Docker化:解决环境一致性与可复现性难题
1. 为什么数据科学项目必须容器化——从“在我机器上能跑”到“在任何环境都稳如磐石”你有没有经历过这样的场景凌晨两点模型训练快收尾了你兴冲冲地把代码推到团队共享仓库结果同事拉下来一运行报错堆满屏幕——ModuleNotFoundError: No module named xgboost再换台服务器又卡在OSError: libcusparse.so.11: cannot open shared object file好不容易配好环境发现自己本地用的是 Python 3.9.7而生产服务器只允许用 3.8.10某个依赖包的 API 已悄然变更……这不是玄学这是数据科学项目落地前最真实的“环境地狱”。我带过六支跨地域数据团队平均每个新成员入职前三天有1.7天耗在环境配置上每次模型上线前的联调42% 的阻塞问题根源不在算法而在环境不一致。Dockerize Your Data Science Project这个动作本质不是加一道技术炫技而是把“数据科学家写的代码”和“工程团队要交付的服务”之间那道模糊的、充满摩擦的边界用可版本化、可复现、可审计的镜像彻底焊死。它解决的不是“能不能跑”而是“能不能被信任地、批量地、无差别地跑”。核心关键词——Docker化、数据科学项目、环境一致性、可复现性、MLOps基础——每一个词背后都是血泪教训Docker化是手段数据科学项目是对象环境一致性是刚需可复现性是科研伦理底线MLOps基础是项目从实验室走向产线的必经跳板。这篇文章写给三类人刚跑通第一个 Kaggle 比赛、正为导师催实验报告焦头烂额的研究生手握成熟模型、却被运维一句“环境不兼容”卡住三个月无法上线的算法工程师以及天天被业务方追问“模型啥时候能嵌进APP里”的技术负责人。你不需要是 DevOps 专家但必须理解当你的 Jupyter Notebook 里那行model.fit(X_train, y_train)能在开发机、测试机、GPU 云服务器、甚至客户内网离线集群上用同一份指令、同一套依赖、同一秒启动时间完成训练——那一刻你才真正拥有了对项目生命周期的掌控力。2. 整体设计思路与方案选型逻辑——为什么不用 Conda 环境导出为什么不用虚拟机2.1 核心矛盾拆解数据科学项目的特殊性决定了容器化不能照搬 Web 应用很多工程师第一反应是“不就是打包环境吗pip freeze requirements.txt然后pip install -r requirements.txt不就完了”——这恰恰是数据科学项目 Docker 化最容易踩的第一个深坑。Web 应用的依赖树相对扁平、纯 Python、编译少而一个典型的数据科学项目其依赖链是立体的、跨层的、强硬件绑定的底层系统级依赖CUDA 驱动版本nvidia-driver-525、cuDNN 版本8.6.0、NCCL2.14.3——这些不是 pip 能装的它们必须与宿主机 GPU 驱动严格匹配否则nvidia-smi能看到卡torch.cuda.is_available()却返回False中间层科学计算库PyTorch/TensorFlow 的预编译二进制包torch-2.0.1cu117必须与 CUDA 版本精确对齐差一个小版本号import torch就 segmentation fault上层领域专用包lightgbm编译时需指定 OpenMP 支持fbprophet依赖pystan而pystan又需要 C17 编译器geopandas依赖gdal而gdal的二进制分发极其混乱数据与模型资产训练数据可能上百 GB、预训练模型权重.pt,.h5、特征工程缓存.joblib——这些大文件若直接 COPY 进镜像会导致镜像体积爆炸、构建缓慢、网络传输成本高且违反“镜像只含代码与依赖”的最佳实践。因此我们的整体设计必须直面这四重矛盾系统依赖与 CUDA 的硬约束、多层依赖的版本锁死、大文件资产的分离管理、以及最终镜像的轻量化与可维护性。方案选型不是比谁命令更酷而是比谁更贴近数据科学工作流的真实痛点。2.2 为什么放弃 Conda 环境导出——一次失败的线上事故复盘去年我们曾尝试用conda env export environment.yml生成环境定义再在 Dockerfile 中conda env create -f environment.yml。表面看很优雅实则埋下三颗雷通道污染Channel PollutionConda 环境导出默认包含defaults、conda-forge、pytorch等多个通道源。当environment.yml在不同网络环境下重建时conda-forge的包可能被defaults的旧版覆盖导致scikit-learn1.3.0在开发机是conda-forge编译的上线机却装成defaults的1.2.2HistGradientBoostingClassifier的max_iter参数名悄然变成max_iterations二进制不兼容conda env export导出的是当前环境的完整状态包括libcxx、libgcc-ng等底层 C 库版本。这些库在 Alpine Linux轻量镜像常用上根本不存在强行安装会触发 glibc 冲突容器启动即崩溃构建不可重现Conda 的solve过程是非确定性的。今天conda env create装出的numpy是1.24.3明天可能因通道索引更新变成1.24.4而后者在某次 CUDA 内核调用中存在已知内存泄漏 Bug。提示Conda 是绝佳的本地开发环境管理工具但绝不是生产环境部署的可靠载体。它的哲学是“快速获得可用环境”而 Docker 的哲学是“绝对精确的环境复现”。二者目标冲突强行嫁接只会放大不确定性。2.3 为什么不用虚拟机VM——成本与效率的残酷现实有同事提议“干脆整个 Ubuntu Server 虚拟机镜像打包把 Anaconda、Jupyter、所有依赖全装进去一劳永逸。”这个想法在小团队验证阶段可行但一旦进入规模化立刻暴露致命缺陷资源开销巨大一个最小化 Ubuntu Server VM 镜像约 800MB加上 Anaconda3GB、CUDA Toolkit2GB、PyTorch1.2GB单个镜像轻松突破 7GB。而同等功能的 Docker 镜像通过多阶段构建和 Alpine 基础镜像可压缩至 1.8GB 以内启动延迟显著VM 启动需加载完整内核、初始化设备驱动、启动 systemd 服务冷启动平均耗时 12 秒Docker 容器基于宿主机内核docker run启动时间稳定在 0.3 秒内这对需要频繁启停的超参数搜索Hyperparameter Tuning任务是降维打击安全审计困难VM 镜像无法像 Docker 镜像那样通过docker history image逐层追溯每一行指令的执行效果也无法用trivy或snyk扫描出openssl的 CVE-2023-XXXX 漏洞——因为漏洞可能藏在 VM 的某个未打补丁的内核模块里而你根本不知道那个模块是否存在。所以我们坚定选择 Docker它用操作系统级的隔离cgroups namespaces替代了硬件级的模拟Hypervisor在保证环境一致性的同时将资源消耗和启动延迟压到极致。这不是技术偏见而是经过 37 次线上模型部署、累计节省 2100 小时运维时间后用真金白银换来的结论。3. 核心细节解析与实操要点——Dockerfile 的每一行都在解决一个具体问题3.1 基础镜像选型NVIDIA 官方pytorch镜像为何是起点而非终点Docker 化数据科学项目第一步永远是选基座。网上教程常推荐python:3.9-slim或continuumio/anaconda3但这对 GPU 计算项目是灾难性起点。正确姿势是直接使用 NVIDIA 官方维护的pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime镜像。原因如下CUDA/cuDNN 版本锁定该镜像已预装CUDA 11.7.1和cuDNN 8.6.0.163且经过 NVIDIA 全面兼容性测试。你无需在 Dockerfile 中手动apt-get install cuda-toolkit-11-7避免因 APT 源版本滞后导致的libcudnn.so.8符号缺失PyTorch 二进制精准匹配镜像中的torch2.0.1cu117是 PyTorch 官方用 CUDA 11.7 编译的torch.version.cuda返回11.7torch.backends.cudnn.version()返回8600三者严丝合缝精简的运行时环境-runtime后缀表示它只包含运行 PyTorch 所需的最小动态库libcudart.so.11.7,libcublas.so.11等不含nvcc编译器等开发工具镜像体积仅 3.2GB比完整devel镜像小 40%。但请注意官方镜像只是起点绝非终点。它解决了底层 CUDA 问题却没解决你的项目依赖。比如它默认不装pandasscikit-learn版本是1.1.3而你的项目需要1.3.0更不会预装lightgbm。因此我们必须在此基础上进行“增量式加固”。3.2 多阶段构建Multi-stage Build如何让最终镜像体积减少 65%一个未经优化的数据科学 Docker 镜像体积常达 4~6GB其中 70% 是构建过程产生的垃圾pip缓存、apt下载的 deb 包、git clone的源码、make生成的.o文件。多阶段构建是 Docker 提供的“构建-运行分离”机制核心思想是用一个臃肿的“构建阶段”编译安装所有依赖再用一个精简的“运行阶段”只复制最终产物。我们的 Dockerfile 结构如下# 构建阶段承担所有“脏活累活” FROM pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime AS builder # 1. 升级系统包管理器安装编译依赖 RUN apt-get update apt-get install -y --no-install-recommends \ build-essential \ libglib2.0-0 \ libsm6 \ libxext6 \ libxrender-dev \ rm -rf /var/lib/apt/lists/* # 2. 创建非 root 用户提升安全性重要 RUN useradd -m -u 1001 -G root -d /home/appuser appuser USER appuser WORKDIR /home/appuser # 3. 复制 requirements.txt 并安装 Python 依赖关键--no-cache-dir --find-links COPY requirements.txt . RUN pip install --no-cache-dir --find-links https://download.pytorch.org/whl/cu117/torch_stable.html -f https://download.pytorch.org/whl/cu117/torch_stable.html -r requirements.txt # 运行阶段只保留运行必需的最小集合 FROM pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime # 4. 复制构建阶段安装好的 Python 包到运行阶段 COPY --frombuilder /opt/conda/lib/python3.9/site-packages /opt/conda/lib/python3.9/site-packages COPY --frombuilder /opt/conda/bin /opt/conda/bin # 5. 复制项目代码注意.dockerignore 必须排除 data/, models/, __pycache__/ COPY . . # 6. 切换回非 root 用户设置工作目录 USER appuser WORKDIR /home/appuser这里的关键细节--no-cache-dir强制 pip 不缓存 wheel 包避免镜像层中残留数百 MB 的~/.cache/pip--find-links显式指定 PyTorch 的 CUDA 11.7 wheel 源确保pip install torch不会错误地下载 CPU 版本torch-2.0.1COPY --frombuilder只复制/opt/conda/lib/python3.9/site-packages目录而非整个/opt/conda剔除conda自身、pip缓存、文档等冗余内容用户权限全程以非 root 用户appuser运行符合最小权限原则避免容器逃逸风险。实测效果原始单阶段构建镜像 4.8GB采用此多阶段方案后降至 1.7GB体积减少 65%推送至私有 Registry 时间从 12 分钟缩短至 3 分钟。3.3 requirements.txt 的科学写法如何避免“版本地狱”requirements.txt是 Docker 化的灵魂写法错误一切归零。常见错误包括只写包名不写版本pandas→ 容器构建时会装最新版可能引入不兼容 API用锁死所有版本pandas1.3.0numpy1.21.5scipy1.7.3→ 表面安全实则脆弱。当pandas 1.3.0依赖numpy1.20.0,1.22.0而你硬锁numpy1.21.5看似完美但scipy 1.7.3可能要求numpy1.21.0此时pip会陷入版本冲突死循环忽略平台特定依赖lightgbm在 Linux 需要openmp在 macOS 需要libomprequirements.txt无法区分。我们的解决方案是分层依赖管理base-requirements.txt定义核心框架的 CUDA 兼容版本由 NVIDIA 镜像保证# 此文件由 Dockerfile 显式指定不参与 pip install # torch2.0.1cu117 # 已由基础镜像提供 # torchvision0.15.2cu117requirements.txt只锁“顶层应用包”用和定义安全区间pandas1.3.0,1.4.0 scikit-learn1.3.0,1.4.0 lightgbm3.3.0,3.4.0 # 注意不写 torch/torchvision它们由基础镜像提供constraints.txt关键用pip install -c constraints.txt -r requirements.txt强制约束传递依赖# constraints.txt - 由 pip-tools 生成确保传递依赖不冲突 numpy1.23.5 scipy1.9.3 joblib1.2.0 # 生成命令pip-compile --generate-hashes --output-file constraints.txt requirements.in注意constraints.txt必须定期更新建议每周pip-compile一次并提交到 Git。它是你对抗“依赖漂移”的最后一道防线。3.4 数据与模型资产的外部化管理——为什么绝不把data/COPY 进镜像新手最容易犯的错误是在 Dockerfile 中写COPY data/ /app/data/ # ❌ 千万别这么干后果是灾难性的镜像体积失控一个 50GB 的data/目录会让镜像瞬间膨胀且每次数据微调哪怕只改一个 CSV 文件Docker 都会重新构建整个镜像层浪费数小时违反不可变性原则Docker 镜像应是只读的、不可变的。数据是易变的必须与代码分离安全风险训练数据常含敏感信息用户 ID、手机号若误推送到公共 Registry后果不堪设想。正确做法是运行时挂载Runtime Mounting# 启动容器时用 -v 参数将宿主机目录挂载为卷 docker run -it \ --gpus all \ # 启用所有 GPU -v $(pwd)/data:/app/data:ro \ # 只读挂载 data/ -v $(pwd)/models:/app/models:rw \ # 读写挂载 models/用于保存训练结果 -v $(pwd)/notebooks:/app/notebooks:rw \ # 挂载 Jupyter 工作区 my-ds-project:latest \ jupyter lab --ip0.0.0.0 --port8888 --allow-root:ro表示只读防止容器内误删原始数据:rw表示读写允许模型训练后将.pt权重文件写入宿主机models/目录所有挂载路径在容器内保持一致代码中pd.read_csv(/app/data/train.csv)无需修改。4. 实操过程与核心环节实现——从零开始构建一个可运行的镜像4.1 项目结构标准化让 Dockerfile “一眼看懂”你的项目一个 Docker 友好的数据科学项目必须有清晰、约定俗成的目录结构。这是我十年踩坑总结出的黄金模板my-ds-project/ ├── Dockerfile # 核心构建脚本本文重点 ├── docker-compose.yml # 本地开发一键启动含 Jupyter、TensorBoard ├── requirements.txt # 应用层依赖pandas, sklearn... ├── constraints.txt # 传递依赖约束由 pip-tools 生成 ├── .dockerignore # 必须排除构建无关文件 ├── src/ # Python 模块化代码非 notebook │ ├── __init__.py │ ├── data_loader.py │ ├── model_trainer.py │ └── inference.py ├── notebooks/ # Jupyter Notebook仅用于探索不放训练逻辑 ├── scripts/ # 可执行脚本train.sh, predict.sh ├── data/ # 原始数据Git LFS 管理绝不进镜像 ├── models/ # 模型权重Git LFS 管理绝不进镜像 ├── configs/ # 配置文件YAML/JSON可进镜像 └── README.md.dockerignore是隐形守护者内容必须包含# 忽略所有本地开发文件 __pycache__/ *.pyc *.pyo *.pyd .Python env/ venv/ .venv/ pip-log.txt pip-delete-this-directory.txt # 忽略数据与模型核心 data/ models/ notebooks/*.ipynb # Notebook 通常含大输出不进镜像 # 忽略 Git 和 IDE 文件 .git .gitignore .vscode/ .idea/没有.dockerignore你的COPY . .会把整个.git目录含所有历史提交和__pycache__可能几百 MB全塞进镜像这是新手最常犯的低级错误。4.2 Dockerfile 逐行详解每一步都在解决一个真实痛点以下是一个生产就绪的Dockerfile我为你逐行注释其设计意图# 第1行选择 NVIDIA 官方 CUDA 11.7 运行时镜像基石 FROM pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime # 第2-3行创建非 root 用户安全基石 # UID 1001 是标准非 root 用户 ID避免与宿主机用户冲突 RUN useradd -m -u 1001 -G root -d /home/appuser appuser # 第4-5行切换用户并设置工作目录权限最小化 USER appuser WORKDIR /home/appuser # 第6-8行安装系统级依赖解决 import 报错 # libglib2.0-0: 解决 matplotlib 的 backend 初始化问题 # libsm6, libxext6: 解决 OpenCV GUI 操作cv2.imshow的 X11 错误 # libxrender-dev: 解决某些字体渲染问题 RUN apt-get update apt-get install -y --no-install-recommends \ libglib2.0-0 \ libsm6 \ libxext6 \ libxrender-dev \ rm -rf /var/lib/apt/lists/* # 第9-11行升级 pip 并安装 pip-tools为生成 constraints.txt 做准备 # pip-tools 是管理复杂依赖的工业级工具比手写 requirements 更可靠 RUN pip install --no-cache-dir pip22.0 pip-tools6.14 # 第12-13行复制依赖文件并生成约束核心 # 先复制 requirements.in顶层依赖再用 pip-compile 生成 constraints.txt # 这样 constraints.txt 总是基于当前 requirements.in 的最新状态 COPY requirements.in . RUN pip-compile --generate-hashes --output-file constraints.txt requirements.in # 第14-15行安装 Python 依赖使用约束文件确保版本锁死 # --find-links 指向 PyTorch 官方 wheel 源避免装错 CPU 版本 RUN pip install --no-cache-dir --find-links https://download.pytorch.org/whl/cu117/torch_stable.html -f https://download.pytorch.org/whl/cu117/torch_stable.html -c constraints.txt -r requirements.in # 第16-17行复制项目代码注意.dockerignore 已排除 data/ models/ # COPY 命令按顺序执行越靠前的层缓存命中率越高所以依赖在前代码在后 COPY . . # 第18-19行设置环境变量让 Python 找到自定义模块 # 这样在 notebooks/ 或 scripts/ 中 import src.data_loader 就能成功 ENV PYTHONPATH/home/appuser/src:${PYTHONPATH} # 第20行暴露端口Jupyter 默认 8888TensorBoard 默认 6006 EXPOSE 8888 6006 # 第21行定义默认启动命令可被 docker run 覆盖 CMD [jupyter, lab, --ip0.0.0.0, --port8888, --allow-root, --no-browser]构建命令# 构建镜像打标签 docker build -t my-ds-project:latest . # 推送至私有 Registry假设 registry.example.com docker tag my-ds-project:latest registry.example.com/my-ds-project:latest docker push registry.example.com/my-ds-project:latest4.3 docker-compose.yml本地开发的一键魔法单靠docker run启动太繁琐。docker-compose.yml将所有参数固化为声明式配置让团队新人 30 秒启动完整环境version: 3.8 services: jupyter: image: my-ds-project:latest # 启用 GPU关键 deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] # 挂载数据、模型、notebooks 目录 volumes: - ./data:/home/appuser/data:ro - ./models:/home/appuser/models:rw - ./notebooks:/home/appuser/notebooks:rw - ./configs:/home/appuser/configs:ro # 端口映射 ports: - 8888:8888 - 6006:6006 # TensorBoard # 设置环境变量 environment: - JUPYTER_TOKENmy-secret-token - PYTHONUNBUFFERED1 # 重启策略 restart: unless-stopped # 日志驱动便于排查 logging: driver: json-file options: max-size: 10m max-file: 3 # 可选添加一个独立的 TensorBoard 服务方便查看训练曲线 tensorboard: image: tensorflow/tensorflow:2.12.0-gpu-jupyter volumes: - ./models:/logs:ro ports: - 6006:6006 command: tensorboard --logdir /logs --host 0.0.0.0 --port 6006启动命令# 一键启动 Jupyter Lab 和 TensorBoard docker-compose up -d # 查看日志 docker-compose logs -f jupyter # 访问 http://localhost:8888/?tokenmy-secret-token4.4 验证镜像是否真正“可复现”——三步压力测试法构建完镜像绝不能只docker run看它是否启动。必须做三步验证GPU 可用性验证docker run --gpus all my-ds-project:latest python -c import torch print(CUDA available:, torch.cuda.is_available()) print(CUDA version:, torch.version.cuda) print(GPU count:, torch.cuda.device_count()) print(Current device:, torch.cuda.current_device()) print(Device name:, torch.cuda.get_device_name(0)) 输出必须是CUDA available: True CUDA version: 11.7 GPU count: 1 Current device: 0 Device name: NVIDIA A100-SXM4-40GB依赖完整性验证# 进入容器检查所有 requirements.in 中的包是否安装且版本正确 docker run -it my-ds-project:latest pip list | grep -E (pandas|scikit-learn|lightgbm) # 输出应类似 # pandas 1.3.5 # scikit-learn 1.3.0 # lightgbm 3.3.5端到端功能验证用最小数据集# 准备一个极小的测试数据集 test_data.csv3 行 5 列 echo a,b,c,d,label 1,2,3,4,0 5,6,7,8,1 9,10,11,12,0 test_data.csv # 启动容器并运行训练脚本假设你有 train.py docker run -it \ -v $(pwd)/test_data.csv:/home/appuser/data/test.csv:ro \ -v $(pwd)/test_model:/home/appuser/models/test:rw \ my-ds-project:latest \ python src/model_trainer.py --data-path /home/appuser/data/test.csv --model-path /home/appuser/models/test/model.pt若脚本成功完成训练并生成model.pt说明整个数据流水线读取 - 训练 - 保存完全打通。5. 常见问题与排查技巧实录——那些文档里不会写的“血泪经验”5.1 经典报错“OSError: libcusparse.so.11: cannot open shared object file”现象容器启动后import torch成功但model.cuda()或torch.mm()报错OSError: libcusparse.so.11: cannot open shared object file。根因分析libcusparse.so.11是 cuSPARSE 库的符号链接指向具体的版本文件如libcusparse.so.11.7.4.163。NVIDIA 镜像中该链接存在但如果你在 Dockerfile 中执行了apt-get upgrade它会升级cuda-toolkit-11-7包导致libcusparse.so.11.7.4.163被删除而libcusparse.so.11链接失效。解决方案绝对禁止在基于 NVIDIA 镜像的 Dockerfile 中使用apt-get upgrade或apt-get dist-upgrade如果必须安装其他系统包用apt-get install -y --no-install-recommends pkg并确保apt-get clean清理缓存若已发生手动修复不推荐应重建镜像# 在 Dockerfile 中添加仅应急 RUN ln -sf /usr/local/cuda-11.7/targets/x86_64-linux/lib/libcusparse.so.11.7.4.163 /usr/local/cuda-11.7/targets/x86_64-linux/lib/libcusparse.so.115.2 经典报错“ModuleNotFoundError: No module named sklearn”现象pip list显示scikit-learn已安装但import sklearn报错。根因分析pip install时未指定用户导致包被安装到 root 用户的 site-packages而容器以appuser运行appuser的PYTHONPATH未包含该路径。解决方案始终在 USER 指令之后执行 pip install确保包安装到当前用户的 site-packages或显式指定安装路径pip install --target /home/appuser/.local/lib/python3.9/site-packages -r requirements.in检查pip show scikit-learn的Location:字段确认路径属于appuser。5.3 经典报错“Permission denied: /home/appuser/models”现象容器内训练脚本尝试torch.save(model, /home/appuser/models/model.pt)时 Permission denied。根因分析宿主机的./models目录所有者是root或你的个人 UID而容器内appuser的 UID 是1001Linux 的 UID 机制导致权限不匹配。解决方案最佳实践在宿主机创建目录时指定 UIDmkdir -p models sudo chown 1001:1001 models # 与 Dockerfile 中 useradd 的 UID 一致次选方案在docker run时用--user覆盖docker run --user $(id -u):$(id -g) -v $(pwd)/models:/home/appuser/models:rw my-ds-project:latest ...不推荐方案在 Dockerfile 中chmod 777 /home/appuser/models破坏最小权限原则。5.4 经典问题“Jupyter Lab 打不开提示 ‘Connection refused’”现象docker-compose up后浏览器访问http://localhost:8888显示This site can’t be reached。排查清单检查容器是否真在运行docker ps | grep jupyter确认 STATUS 是Up检查端口是否被占用lsof -i :8888或netstat -tulpn | grep :8888若被占用改docker-compose.yml中的ports检查 Jupyter 是否监听 0.0.0.0docker logs container-id查找http://0.0.0.0:8888/若显示http://127.0.0.1:8888/说明启动命令漏了--ip0.0.0.0检查防火墙sudo ufw status若为active执行sudo ufw allow 8888终极验证docker exec -it container-id curl http://localhost:8888若返回 HTML说明服务正常问题在宿主机网络。5.5 高级技巧如何为不同环境dev/staging/prod构建差异化镜像生产环境中常需为开发、测试、生产构建不同配置的镜像如 dev 启动 Jupyterprod 启动 Flask API。用单一 Dockerfile 构建参数Build Args即可实现# 在 Dockerfile 开头添加 ARG ENVIRONMENTdev ENV ENVIRONMENT${ENVIRONMENT} # 在 CMD 中根据 ENV