1. Docker 到底在解决什么问题
我最早认真对待 Docker 容器化技术,是在一个前后端联调反复出问题的项目上。同一份代码,在开发同学的机器上跑得好好的,到了测试环境就报找不到某个动态库;换到另一台机器,连运行时版本都对不上。那段时间最常听到的一句话就是"在我机器上是好的"。Docker 容器化技术解决的正是这类问题:它把应用连同运行它所需要的运行时、系统库、环境变量、配置文件一起打包成一个标准单元,让"开发环境"和"部署流程"这两件过去靠口口相传和手写文档维系的事情,变成可复制、可版本化、可移植的产物。这篇文章面向刚接触容器、或者用过几条docker run但没系统梳理过的开发者,我会从它到底解决什么、底层凭什么做到、怎么落地到开发环境和部署流程三个层面拆开讲,把容易踩的坑和我自己踩过的坑一并说清楚。
1.1 "在我机器上跑得好好的"是怎么产生的
一个应用能不能跑起来,依赖的东西比大多数人想象的要多。语言运行时版本、系统级共享库、编译工具链、环境变量、配置文件路径、时区、字符编码、监听端口,甚至文件系统的权限模型,任何一项对不上,程序就可能起不来或者行为异常。传统做法是靠文档记录,靠运维手工配置,靠"装一遍就好了"的口头传承。这套方式在单机时代勉强能用,一旦服务拆多、环境分开发/测试/预发/生产四套,配置漂移就必然发生,而且往往在交付前夜才暴露。
从我的经验看,这类问题有两个特征:一是发现晚,本地一切正常,集成时才炸;二是难复现,因为出问题的环境本身是手工搭出来的,没人能准确说出它和正常环境差在哪里。容器化带来的改变不是让程序变简单了,而是把"环境"这个隐形依赖显式化,写进一个文本文件里,让它跟代码一起进版本库、一起评审、一起回溯。
提示:判断一个项目是否真的把环境显式化了,有个很简单的标准——把仓库 clone 到一台只装了容器运行时的干净机器上,能不能在十分钟内跑起来。做不到,说明还有东西藏在某个人的本机里。
1.2 容器与虚拟机:隔离层次不同,代价也不同
很多人第一次接触容器会把它当成"轻量虚拟机",这个理解不算错,但会让你在选型和调优时做出错误判断。虚拟机的隔离边界在硬件虚拟化层,每台虚机有自己完整的内核,启动要经历引导过程;容器的隔离边界在操作系统层面,多个容器共享宿主内核,靠内核提供的隔离机制把彼此隔开。所以容器的启动是"起一个进程"的量级,不是"起一台机器"的量级。
| 对比维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离边界 | 硬件虚拟化,各自独立内核 | 操作系统层,共享宿主内核 |
| 启动耗时 | 数十秒到分钟级 | 毫秒到秒级 |
| 单实例体积 | GB 级 | 数十 MB 到数百 MB |
| 资源开销 | 每台预留完整系统开销 | 接近进程本身开销 |
| 隔离强度 | 更强,适合强多租户场景 | 足够,适合应用级隔离 |
| 典型用途 | 整机交付、异构内核需求 | 应用打包、微服务、CI 流水线 |
这张表里最容易被误读的是"隔离强度"这一行。容器不是"隔离不好",而是隔离的维度不同:它擅长隔离文件系统、进程视图、网络栈和资源配额,但没有独立内核。这意味着如果你的应用需要加载特定版本的内核模块,或者需要跟宿主内核版本强绑定,容器不是好选择;反过来,绝大多数 Web 服务、批处理任务、数据处理管道,容器完全够用,而且换来了部署密度和迭代速度上的巨大优势。
1.3 镜像、容器、仓库:三个概念其实是一条链
我把这三个概念讲给新人时,常用一个类比:镜像是"安装模板",容器是"按模板跑起来的那一个实例",仓库是"放模板的柜子"。这个类比不精确但足够用。更准确的说法是:镜像是只读的分层文件系统加上一份启动配置;容器是镜像的一次运行实例,在只读层之上加了一层可写层;仓库负责镜像的存储与分发,用"名字 + 标签"来寻址。
理解这条链的关键在于只读这两个字。镜像在构建完成后不会被修改,任何在容器里发生的写操作都落在最上层的可写层里,容器删掉,这层就没了。这解释了两个新手最常见的困惑:为什么容器里改的配置文件重启就没了;为什么同一份镜像能同时跑出一百个互不干扰的容器。把这条链想通,后面关于数据持久化、关于镜像标签策略、关于构建缓存的所有问题,都会顺很多。
2. 核心机制拆解:凭什么又快又可移植
用起来简单的东西,背后往往有一套精心设计。容器能做到"构建一次,到处运行",靠的不是某一项黑科技,而是三件事叠在一起:分层文件系统让镜像可以增量复用,内核隔离机制让进程彼此看不见,构建缓存让重复构建变得廉价。这一章我不打算罗列内核文档,而是从实操角度讲清楚这三件事分别会在什么时候影响你,以及为什么某些写法会让构建变慢十倍。
2.1 分层文件系统与写时复制:镜像为什么能省空间
镜像由一层一层的只读文件系统叠加而成,每条构建指令通常产生一层。多条镜像如果共享前几层,这些层在磁盘上只存一份。这就是为什么一个团队里十来个服务的镜像,加起来占的空间远小于"十几个独立镜像"的直觉估算——它们大概率共享同一个基础镜像层。
写时复制是配套的机制。容器启动时不会把镜像层整个复制一份,而是在最上面挂一个可写层;只有真正发生修改时,才把对应的文件从只读层复制到可写层再改。这带来两个直接影响:第一,容器启动几乎不花时间在文件复制上;第二,可写层会随着写入量增长,长期运行且频繁写磁盘的容器,可写层的膨胀需要单独关注。
实操层面有个容易被忽略的细节:在容器里删除大文件不会释放镜像占用的空间。因为删除这个动作只是在可写层记了一条"此文件在下面几层中不可见"的标记,底层的字节还在。所以如果你在 Dockerfile 里先COPY一个大压缩包,再RUN解压后删掉它,最终镜像里那个压缩包照旧占着体积。正确做法是在同一条RUN指令里完成下载、解压、清理,让这一层只保留最终结果。
注意:
RUN后面的清理命令一定要和产生垃圾的命令写在同一行并接&&。分成两条RUN是新手最常见的体积浪费来源,一个几百 MB 的临时文件能让镜像白白胖一圈,而且在docker history里看得一清二楚。
2.2 命名空间与控制组:隔离与限额分别管什么
隔离和限额是两件事,很多人混为一谈。命名空间负责"看不见",它让容器里的进程只能看到自己这一组进程、自己的网络设备、自己的挂载点、自己的主机名。常用的几类包括进程视图、网络、挂载点、主机名与域名、进程间通信、用户。控制组负责"用不了那么多",它限制这组进程能用多少 CPU、多少内存、多少 IO 带宽。
这个划分对排查问题特别有用。容器里ps只看得到自己的进程,那是命名空间在起作用;容器被系统杀掉、退出码是 137,那通常是控制组的内存上限被撞到了,137 等于 128 加 9,对应的是强制终止信号。搞清这两者的分工,你看到的现象和要查的方向就能对应上:看不见东西是隔离问题,被限制住、被打死是配额问题。
用户命名空间这一项值得单独说一句。默认情况下容器内的进程以 root 身份运行,虽然它被隔离在容器内,但映射到宿主上仍然可能是 root,一旦挂载了宿主目录,写入的文件属主就会变成 root,给宿主上的日常操作添麻烦。我在本地开发时习惯在运行参数里显式指定当前用户的 uid 和 gid,让容器产出的文件属主跟宿主一致,省掉后续一堆chown。
2.3 构建缓存:可复现性的真正来源
构建缓存是容器化在工程效率上价值最大的一环。构建时会逐条检查指令,如果某条指令的输入(基础镜像、执行命令、复制的文件内容)跟上次完全一致,就直接复用上次的层,跳过执行。这套机制让"改一行代码重新构建"从几分钟缩短到几秒。
但缓存的失效规则很严格:一旦某一层失效,它后面的所有层全部失效。这就解释了为什么 Dockerfile 的指令顺序如此关键。如果你把"复制全部源码"放在"安装依赖"前面,那么每次改代码都会让依赖安装这一层缓存失效,明明依赖没变却要重新装一遍。正确顺序是把变化频率低的东西放前面:先基础镜像,再系统级依赖,再语言级依赖清单,最后才是源码。
我在给团队做代码评审时,看 Dockerfile 的顺序基本就能判断这个人有没有被构建速度折磨过。依赖清单单独拷贝、先装依赖再拷贝源码,这个习惯能省下的等待时间,一年下来是很可观的数字。
3. 从零搭一套开发环境:完整实操
前面讲的是原理,这一章直接上手。我按"验证运行时正常、写 Dockerfile、用编排工具拉起整套依赖、处理数据持久化"的顺序走一遍,每一步都给出可以直接抄的命令和配置,并说明为什么这么写。这套流程我在好几个项目上复用,改动的主要是语言和依赖管理方式,骨架是一样的。
3.1 安装后的最小验证:别跳过这一步
装完容器运行时,不要急着跑业务镜像,先做三层验证。第一层看客户端和服务端版本是否都正常返回;第二层看整体信息,重点确认存储驱动和控制组模式,这决定了后续资源限制能不能生效;第三层跑一个最小的测试镜像,确认拉取、解压、启动、退出整条链路通畅。
docker version docker info | grep -E "Storage Driver|Cgroup|Server Version" docker run --rm hello-worlddocker info里我通常重点看三处:存储驱动是不是预期的那种,控制组版本是 v1 还是 v2,可用磁盘空间还剩多少。第三项经常被忽略,容器镜像堆到把磁盘写满时,报错信息往往跟磁盘毫无关系,排查起来很绕。
如果你在拉取公共镜像时速度不理想,先检查运行时的仓库地址配置和 DNS 解析,很多时候是解析到了不可达的地址,改一下仓库配置就能解决,不用过度折腾。
3.2 Dockerfile 编写:顺序决定构建速度
下面这份是一个 Python 服务的典型写法,我加了注释说明每一步的意图:
FROM python:3.11-slim # 环境变量:不生成 pyc、日志不缓冲、pip 不检查版本 ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ TZ=Asia/Shanghai WORKDIR /app # 先装系统级依赖,变化频率低 RUN apt-get update \ && apt-get install -y --no-install-recommends curl tzdata \ && rm -rf /var/lib/apt/lists/* # 先拷依赖清单,依赖没变时这层缓存可复用 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 最后拷源码,变化最频繁 COPY . . # 非 root 运行 RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 CMD ["python", "-m", "app.main"]这里面有几个刻意为之的选择。--no-install-recommends是为了避免 apt 顺带拉进一堆用不上的包,这个参数经常能砍掉几十 MB。清理 apt 列表目录必须和安装写在同一条RUN里,否则缓存层的字节还是留着。PIP_NO_CACHE_DIR和--no-cache-dir是双保险,避免 pip 缓存被打进镜像。PYTHONUNBUFFERED让日志实时输出,否则容器里看不到日志,只能等进程退出,本地调试会非常难受。
还有一个配套文件容易被忘记:.dockerignore。它决定哪些文件不进入构建上下文。默认情况下构建会把当前目录整个上传给构建引擎,如果你的目录里有node_modules、.git、虚拟环境、日志文件,构建上下文可能几百 MB,每次构建都要先传一遍。我在一个项目里见过构建上下文 1.2 GB 的情况,加了.dockerignore之后掉到 8 MB,构建时间从两分多钟降到十几秒。
.git node_modules __pycache__ *.pyc .venv .env logs *.log3.3 用编排描述整套开发栈:一条命令起服务
真实的开发环境很少只有一个进程,数据库、缓存、消息队列往往都得有。这时候一条条的docker run就变得难以维护,用声明式的编排文件更合适。下面是一个典型的开发栈:
services: app: build: context: . dockerfile: Dockerfile ports: - "127.0.0.1:8000:8000" environment: DATABASE_URL: postgres://app:secret@db:5432/appdb REDIS_URL: redis://cache:6379/0 volumes: - .:/app - /app/node_modules depends_on: db: condition: service_healthy user: "${UID:-1000}:${GID:-1000}" db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: secret POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d appdb"] interval: 5s retries: 10 cache: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: - redisdata:/data volumes: pgdata: redisdata:这份配置里有几个点值得展开。DATABASE_URL里主机名写的是db,不是localhost,因为同一编排网络里的服务可以用服务名互相解析。这是新手最容易卡住的地方:在容器里连localhost:5432永远连不上,那个地址指的是容器自己。depends_on配合condition: service_healthy让应用在数据库真正就绪后再启动,比单纯按启动顺序等待靠谱得多。端口映射只绑到127.0.0.1是刻意为之,避免开发机的服务被局域网里其他人访问到。
volumes里那条匿名卷/app/node_modules是个常用技巧:把宿主的源码目录挂进容器,但让依赖目录保留在容器内,避免宿主编译的依赖和容器内不一致。这条规则在 Node 项目里几乎是标配。
3.4 数据持久化:绑定挂载和命名卷怎么选
容器的可写层随容器销毁而消失,所以需要留存的数据必须落到卷上。两种方式:绑定挂载直接把宿主的一个目录映射进容器;命名卷由运行时管理,存在自己的一块区域里。
| 对比项 | 绑定挂载 | 命名卷 |
|---|---|---|
| 数据位置 | 你指定的宿主路径 | 运行时管理区域 |
| 适合场景 | 开发时挂源码、挂配置文件 | 数据库、缓存、需长期保留的数据 |
| 跨平台一致性 | 较差,文件权限行为因系统而异 | 较好,行为统一 |
| 备份方式 | 直接拷文件 | 需通过容器或专用命令导出 |
| 性能 | 依赖宿主文件系统,大量小文件可能慢 | 通常更接近原生 |
我的习惯是:开发环境挂源码用绑定挂载,方便改完立刻生效;数据库、对象存储这类有状态服务一律用命名卷,避免宿主路径差异带来的权限问题。有个坑要提醒:绑定挂载会覆盖容器镜像里该路径下的原有内容。曾经有同学把配置文件目录挂到一个只在宿主上存在的空目录,结果容器里的默认配置全被遮住,服务启动直接报找不到配置。这种情况要么只挂单个文件,要么提前把默认配置拷到宿主目录。
4. 部署流程落地:从本地镜像到线上运行
开发环境跑通只完成了一半,真正的价值在部署链路上。这一章讲怎么把镜像做小、怎么做运行时的资源约束、怎么让日志和健康状态可观测、怎么给镜像打标签才能避免发布事故。这些内容看起来琐碎,但每一条都是线上事故复盘里反复出现的主题。
4.1 多阶段构建:把编译工具留在构建阶段
镜像体积直接影响拉取时间、启动速度和存储成本。最常见的瘦身手段是多阶段构建:用一个大而全的镜像做编译,再把产物拷进一个精简的运行镜像。
# 阶段一:构建 FROM python:3.11 AS builder WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential libpq-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --prefix=/install --no-cache-dir -r requirements.txt # 阶段二:运行 FROM python:3.11-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 curl \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /install /usr/local COPY . . EXPOSE 8000 CMD ["python", "-m", "app.main"]体积账可以这样算:第一阶段的基础镜像约 1.0 GB,加上编译工具链约 400 MB,构建阶段合计约 1.4 GB,但这部分不会进入最终镜像。第二阶段基础镜像约 130 MB,系统运行库约 20 MB,Python 依赖装完约 90 MB,源码约 5 MB,最终镜像在 250 MB 上下。相比单阶段方案的 1.4 GB,体积降到约 18%,拉取时间按同比例缩短,在网络条件一般的情况下差别非常明显。
拷贝依赖目录用的是--from=builder /install /usr/local,前提是前一步用了--prefix=/install。这个写法比复制整个虚拟环境更干净,避免把构建阶段的路径信息带进运行镜像。
4.2 运行时参数:内存、CPU、重启策略
镜像做好只是开始,运行参数决定了它在压力下会不会把宿主拖垮。我通常至少显式设置四项:
docker run -d \ --name app \ --restart unless-stopped \ --memory 512m --memory-swap 512m \ --cpus 1.5 \ -p 127.0.0.1:8000:8000 \ --log-driver json-file \ --log-opt max-size=10m \ --log-opt max-file=3 \ app:1.4.2内存和--memory-swap设成一样大,是为了禁用交换,让内存超限直接触发终止而不是悄悄变慢。这个选择有代价:进程会因为内存不足被强制结束,所以内存上限必须根据实际峰值留出余量,我一般按压测峰值的 1.5 倍来设。--cpus 1.5表示最多用 1.5 个核的算力,不是独占,空闲时其他容器可以用。
日志驱动和轮转参数几乎是必配项。默认的日志驱动会把容器的标准输出原样写到宿主文件里,长期不清理能吃掉几十 GB 磁盘,而且没有任何提示。我遇到过磁盘写满导致服务假死的情况,查了半天才发现是日志文件涨到了 40 GB。设上max-size和max-file之后就再没出现过。
健康检查的作用不是"自动修复",而是让编排层知道实例到底能不能干活。HEALTHCHECK里那个命令要选真正能反映业务可用性的端点,别用ping或者只检查端口通不通。一个端口监听正常但数据库连不上的服务,在端口检查下永远是健康的,流量会被一直导进来。
4.3 日志、退出码与故障信号
排障时最先看的三样东西:日志、退出码、资源使用。日志靠上面的轮转配置保证可用;退出码是最容易被忽略的信息源。137 表示被强制终止,通常是内存超限;143 表示收到正常终止信号,一般出现在停止容器时;1 是应用自己报错退出。把这几个记住,能省下大量瞎猜的时间。
docker inspect里的状态字段值得熟练使用,尤其是退出码、启动时间、重启次数、健康检查的最近几次输出。一个反复重启的容器,看重启次数增长速度就能判断是启动即崩还是运行一段时间后崩,这两种情况的排查方向完全不同。
4.4 镜像标签策略:别用 latest 上生产
标签策略是发布流程里最容易埋雷的地方。latest是个浮动标签,同一个名字今天拉和明天拉可能是完全不同的内容。一旦线上出问题需要回滚,你会发现根本没有一个确定的版本可以回到。
我的做法是:每次构建打三个标签,语义化版本号(如1.4.2)、提交短哈希(如a1b2c3d)、环境别名(如prod)。版本号用于发布和回滚,提交哈希用于精确追溯代码,环境别名只做当前指向。生产环境只允许用版本号或提交哈希启动,禁止用环境别名或latest。这条规则写进发布检查清单后,回滚从"重新构建一遍碰运气"变成了"改一个数字重跑"。
5. 常见问题与排查技巧实录
这一章是我这些年积累的问题清单,基本都是文档里不会写、但实际一定会遇到的。我把它们按类型归拢,配上判断依据和处理方式,遇到问题时可以直接对号入座。
5.1 构建类问题速查
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 改代码后构建特别慢 | 依赖安装层被源码拷贝层污染 | 调整顺序,先拷依赖清单再拷源码 |
| 构建上下文体积巨大 | 缺.dockerignore | 排除.git、依赖目录、日志 |
| 镜像比预期大很多 | 清理命令与产生命令不在同一层 | 合并为一条RUN并接&& |
| 明明改了文件但镜像没变 | 缓存命中了旧层 | 对特定层用--no-cache或调整指令 |
| 构建时找不到文件 | 被.dockerignore排除了 | 检查忽略规则是否过宽 |
关于缓存这块再补一句实操心得:调试 Dockerfile 时频繁全量重建很浪费时间,我一般先把不稳定的指令临时改一下(比如加个无意义的注释行)来强制该层之后的缓存失效,等调试完再改回去。比起每次都加--no-cache从头构建,能省下大量等待。
5.2 网络与端口类问题
端口映射写反是最经典的低级错误。-p 8080:80是"宿主 8080 映射到容器 80",顺序记不住就记"左外右内"。容器里服务监听的是0.0.0.0还是127.0.0.1也经常出问题:如果应用只监听回环地址,端口映射是通不了的,因为映射过来的流量是从容器的网络接口进来的,不走回环。
容器之间互相访问时,用服务名而不是localhost,这一点前面提过,但值得重复,因为它是新手排障时耗时最多的一个点。另外注意编排文件里默认会建一个独立网络,不同编排项目之间默认互相看不见,跨项目通信需要显式加入同一个外部网络。
5.3 磁盘与资源类问题
磁盘被吃满时,先看整体占用再决定清什么:
docker system df docker image prune -a docker builder prune docker system prune -a --volumesdocker system df会分四类列出占用:镜像、容器、卷、构建缓存。其中构建缓存经常是最大的一块,而且清理它相对安全。带--volumes的清理命令要格外小心,它会删掉没有被任何容器使用的命名卷,如果里面有你以为已经不需要的数据,删了就找不回来。我在生产机器上从不随手跑带--volumes的命令。
内存类问题的典型表现是容器莫名消失、退出码 137、应用日志在某个时间点戛然而止。判断方法是对比容器内存峰值和设定上限,峰值贴近上限基本可以确认。处理上要么提高上限,要么查出是谁在短时间内申请了大量内存,后者往往是真正需要修的问题。
5.4 权限与文件属主问题
绑定挂载配合容器内 root 运行,会在宿主目录里留下一堆 root 属主的文件,后续用普通用户操作就被拒绝。解决办法是在运行参数里指定当前用户的 uid 和 gid,让容器以同样的身份写文件。如果镜像里没有对应的用户,可以在 Dockerfile 里创建一个固定 uid 的用户,注意 uid 要和宿主常用用户对齐,否则还是会不一致。
还有一类问题出在时区和编码上。容器默认是 UTC 时区,应用里打印的时间跟本地差八小时,日志对不上。解决办法是在镜像里装好时区数据并设置TZ环境变量。编码问题现在少见了,但如果你的应用要处理中文文件名,构建时确认一下语言环境变量,能避免不少诡异问题。
5.5 启动即退出类问题
容器刚起来就退出,最有效的排查手段是看它的输出和退出码:
docker ps -a docker logs --tail 100 <容器名> docker inspect --format '{{.State.ExitCode}} {{.State.Error}}' <容器名>如果日志一条都没有,说明进程根本没启动起来,重点查入口命令和镜像里的文件路径。如果日志有几行然后中断,通常是应用在初始化阶段挂了,比如连不上数据库、读不到配置文件、端口被占用。还有一种情况是容器里跑的是个前台进程但被写成了后台运行,主进程一退容器就结束,这时候要么把入口命令改成前台,要么用一个能守住的前台进程。
ENTRYPOINT和CMD的区别也经常在这类问题里出现:前者是固定的可执行入口,后者是默认参数,运行时的额外参数会覆盖CMD而追加到ENTRYPOINT后面。写错这两者的位置,会导致你以为传进去的参数根本没生效。
6. 我个人的落地节奏建议
最后说点方法论层面的东西。我看到不少团队引入容器化时一上来就想做全套,镜像仓库、编排、流水线、监控一起上,结果半年过去还在调基础设施,业务没往前走一步。我自己更推荐的节奏是分三步走。
第一步只做"本地环境容器化",把这套东西当成一个能一键拉起依赖的工具,先把"环境不一致"这个最痛的问题解决掉。这一步的门槛很低,收益却很直接:新人上手时间从几天降到半小时,联调时扯皮的情况基本消失。第二步做"构建产物标准化",把 Dockerfile 纳入代码评审,把镜像标签规则定下来,让每次构建的结果可追溯。第三步才谈部署流水线和运行时治理,因为这时候你已经有了稳定的构建产物,讨论资源配额和发布策略才有意义。
还有一个细节值得提前规划:镜像基础层的选择。团队里如果有十来个服务,尽量统一基础镜像的大版本,这样层能共享,机器上的磁盘占用和拉取时间都会好很多。我见过每个服务各用一套基础镜像的仓库,磁盘占用是同规模统一基础镜像的三倍还多,全量发布时网络压力也非常大。
至于我自己最常用的一条经验:把每一次"手动跑通的命令"都记下来,然后想办法把它变成文件。容器化最大的价值不是让你少敲几条命令,而是让环境从"某个人知道怎么配"变成"仓库里有一份可执行的描述"。凡是还需要口头交代的步骤,就是还没做完的部分。这个习惯坚持下来,环境问题会越来越少,省出来的时间都够把功能多做几轮了。