“在我机器上能跑”这句话几乎每个开发者都说过,也几乎每个接手的人都被坑过。Docker 的价值不在于概念多新潮,而在于它把”运行环境”本身变成了可以版本化、可以传递的产物:一个镜像装好依赖,谁拉下来跑,行为都一致。这篇从概念区分讲起,把 Dockerfile、分层缓存、卷与端口、compose 串一遍,最后落到开发环境容器化的实际收益和坑。
镜像与容器:模板和实例
镜像(image)是只读的模板,里面是文件系统快照加启动配置;容器(container)是镜像跑起来之后的实例,有自己的可写层、进程和网络。类比面向对象的语言:镜像是类,容器是对象。一次构建出的镜像可以起任意多个容器,容器之间互相隔离,改坏了删掉重建就行,这也是”环境可抛弃”的来源。
两个高频命令先记牢:
docker build -t myapp:dev . # 从 Dockerfile 构建镜像并打标签
docker run -d --name api -p 8080:8080 myapp:dev # 后台起容器并映射端口
docker exec -it api sh # 进容器里看看
docker logs -f --tail 100 api # 看日志
docker ps 看运行中的容器,加上 -a 才能看到已退出的——排查”容器一起来就挂”的问题时,-a 加 docker logs 是第一步。
Dockerfile:常用指令怎么用
一个最小的 Node 服务镜像长这样:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]
逐条看意图:
FROM:基础镜像,决定起点。带版本号的标签(node:20-alpine)比latest稳得多,构建可重现。WORKDIR:设定后续指令的工作目录,不存在会自动创建,比RUN cd /app干净。COPY:把宿主机文件拷进镜像。只拷需要的文件,别COPY . .把.git、本地缓存、密钥一起打进去。RUN:在构建阶段执行命令,提交成一层。能合并的安装命令合并成一条RUN,减少层数。CMD与ENTRYPOINT:都是容器启动时的命令,分工是ENTRYPOINT定”固定入口”,CMD定”默认参数”。两者都用 exec 形式(JSON 数组)而不是 shell 形式,信号能正确传给主进程,容器才能被优雅停止。只有默认启动命令时用CMD就够了。
.dockerignore 值得单独配一份,把 node_modules、target、*.log、.env 排除掉,既加快构建上下文的传输,也避免把敏感文件打进镜像。
分层与缓存:构建为什么有时快有时慢
Dockerfile 里每条会改文件系统的指令生成一层,层是只读的、可共享的。构建时如果某一层的内容和上次完全一致,就直接复用缓存,跳过执行——这就是改一行代码重新构建几秒钟就能完的原因,也是改一行代码却要重新下载全部依赖的原因。
关键在于指令顺序:变化频率低的放前面,变化频率高的放后面。依赖清单(package.json)比源代码改得少,所以先 COPY package*.json 再 RUN npm ci,最后才 COPY . .。反过来写,任何一次源码改动都会让 npm ci 那层缓存失效,每次都重新装依赖。
另外注意,RUN apt-get update 和 apt-get install 必须写在同一条 RUN 里,否则 update 的缓存和 install 的缓存各自独立,时间一长就装到过期的包索引。多阶段构建(多条 FROM)在编译型语言里很常用,编译阶段用大基础镜像,运行阶段只拷贝产物,最终镜像能小一大截。
卷与端口映射
容器的可写层随容器删除就没了,数据要持久化得靠卷(volume)或绑定挂载(bind mount):
docker run -d
-v app-data:/var/lib/app
-v $(pwd)/config.yml:/app/config.yml:ro
-p 8080:8080 myapp:dev
命名卷(app-data)由 Docker 管理,适合数据库数据这类”应用自己的状态”;绑定挂载($(pwd)/config.yml)把宿主机文件直接映射进去,适合配置文件和开发时的源码热加载,:ro 表示只读。
端口映射 -p 8080:8080 是”宿主机端口:容器端口”,顺序别搞反。容器内部监听 0.0.0.0 才能被映射到,只监听 127.0.0.1 的服务从宿主机访问不到——这是”端口映射了但连不上”最常见的原因。
docker-compose:把一组服务写成一个文件
单个容器够用之后,多个服务(应用 + 数据库 + 缓存)的编排就交给 compose:
services:
app:
build: .
ports:
- "8080:8080"
volumes:
- .:/app
- /app/node_modules
environment:
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=devpass
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
几个要点:depends_on 只保证启动顺序,不保证服务就绪,应用侧仍要处理”数据库还没起来”的重试;.:/app 把源码挂进来实现本地改代码即时生效,/app/node_modules 用匿名卷覆盖掉容器内的依赖目录,避免宿主机和容器的原生依赖(如 node_modules 里的二进制)互相冲突。整套环境一条 docker compose up -d 起来,新同事入职半天就能把全栈跑通。
开发环境容器化的收益与坑
收益是实打实的:环境一致性,”能构建出镜像”就是最硬的运行证明;依赖可抛弃,脏了删掉重建,不留长年累月的全局污染;团队共享,compose 文件即文档;和 CI 的距离变近,本地构建过的镜像在流水线上大同小异。
坑也集中在几处:
卷权限。 Linux 上绑定挂载的文件属主是宿主机 UID,容器里的进程跑的是另一个 UID,就会出现”写不进去”。按需 chown、用 :z/:Z 标签(SELinux 环境)、或者在镜像里创建和宿主机一致的 UID 用户,都是常见解法。Windows 上挂载路径的格式和文件事件的传播也有差异,文档要写清楚。
时区。 基础镜像默认多是 UTC,容器里打出来的日志时间戳和本地差几个小时,排查问题时对不上号。ENV TZ=Asia/Shanghai 加上安装 tzdata(apk add --no-cache tzdata 或 apt-get install -y tzdata)是标配。
日志。 不要把日志写进容器内的固定文件,容器一删就没了。写到标准输出,交给 docker logs 或日志驱动收集;实在要落盘的,挂卷出去。另外留意日志文件无限增长的问题,生产上配 log-opts 限制单文件大小和保留份数。
镜像体积与安全。 alpine、distroless 一类小基础镜像能显著缩小攻击面和拉取时间;docker scan、trivy 这类扫描器在构建流水线里跑一遍,依赖里有已知漏洞时能提前发现。基础镜像定期重建,别让一年前的系统包一直躺在生产里。
Docker 学到能顺畅起环境就够日常用了,更细的网络模型、存储驱动可以等真正需要时再深入。先把”镜像可重现、数据不丢、日志找得到”这三件事做到,容器化的好处就已经吃到了。
评论(1)
这个方向我关注很久了,终于有人系统写一遍。
请 登录 后参与评论。