为什么需要容器:从「我这能跑」说起
开发最经典的尴尬是:代码在我机器上跑得好好的,一上测试环境就报错。原因往往不是代码,而是环境差异——Python 版本、系统库、环境变量、时区、字体,任何一项不同都可能让程序行为改变。虚拟机能解决差异,但一台机器装几个虚拟机,资源开销和启动时间都很难接受。
容器走的是另一条路:它不虚拟硬件,而是利用 Linux 的 namespace 做隔离、cgroups 做资源限制,让进程以为自己独占了一台机器。于是容器又轻又快——秒级启动、镜像几百兆、一台机器能跑几十个。部署从此变成一句「把镜像拉下来跑起来」。
容器解决的不是「怎么把程序跑起来」,而是「怎么让程序在任何地方都跑得一模一样」。
- 镜像只读的分层文件系统模板,容器是它在运行时的可写实例。
- 写时复制容器读共享镜像层,改写时才复制,所以启动极快。
- 数据卷把数据放在容器生命周期之外,容器删了数据还在。
- Compose用一份 YAML 描述多容器应用,一条命令起停整套环境。
镜像与容器:分层与写时复制
理解 Docker,关键是把「镜像」和「容器」两个概念分清。镜像是只读的模板,由若干层(layer)叠加而成,每一层对应 Dockerfile 里的一条指令;容器是镜像的运行时实例,在只读镜像之上加了一个薄薄的可写层。
这个设计带来一个非常重要的特性——写时复制(Copy-on-Write):容器启动时并不复制整个镜像,只是引用它;只有当你真的修改某个文件时,才会把那一份复制到可写层。所以同一镜像起 10 个容器,磁盘只多花各自改动的那一点。
| 概念 | 是什么 | 常用命令 |
|---|---|---|
| 镜像 | 只读、分层、可复用的模板 | docker images / docker pull |
| 容器 | 镜像的运行实例,含一个可写层 | docker ps -a / docker run |
| 仓库 | 镜像的集中存储与分发 | docker push / docker login |
| 数据卷 | 独立于容器生命周期的持久数据 | docker volume ls |
# 拉取并运行一个容器,端口映射到宿主机
docker run -d --name web -p 8080:80 nginx:alpine
# 查看运行中的容器与它占用的资源
docker ps
docker stats --no-stream
# 进入容器排查(生产慎用,优先用 logs / exec 单条命令)
docker exec -it web sh
# 查看容器日志(-f 跟踪,--tail 限制行数)
docker logs -f --tail 100 web
记住一句话:容器里的可写层会随容器删除而消失。任何需要保留的东西——数据库文件、上传目录、配置——都必须放在数据卷或挂载目录里。
编写第一个 Dockerfile
Dockerfile 是构建镜像的脚本。它的每一行都值得推敲,因为顺序直接影响构建缓存是否命中、最终镜像是否臃肿。下面是一个 Node.js 应用的典型写法:
# 1) 基础镜像固定小版本,保证可复现
FROM node:20-alpine
# 2) 设置工作目录
WORKDIR /app
# 3) 先拷贝依赖清单并安装 —— 单独一层,利用缓存
COPY package*.json ./
RUN npm ci --omit=dev
# 4) 再拷贝源码 —— 源码改动不会让依赖层失效
COPY . .
# 5) 声明端口、以非 root 用户运行
EXPOSE 3000
USER node
# 6) 启动命令
CMD ["node", "server.js"]
几个实践要点:
- 依赖先装、源码后拷:让变化频繁的源码位于构建链末端,复用前面昂贵的安装层缓存。
- 用
.dockerignore排除node_modules、.git、日志等,减小构建上下文。 - 用非 root 用户:默认 root 会在容器被攻破时放大风险,
USER一行成本极低。 - 一容器一进程:不要把数据库和 Web 塞进同一个镜像,水平扩展和排障都会变得困难。
# 构建并打标签(含版本号,便于回滚)
docker build -t registry.example.com/myapp:1.4.2 .
# 本地先验证再推送
docker run --rm -p 3000:3000 registry.example.com/myapp:1.4.2
docker push registry.example.com/myapp:1.4.2
容器网络与数据卷
容器默认处于各自的网络命名空间里,要互相通信必须接入同一个网络。Docker 提供了几种驱动,日常用到的其实就三类:
- bridge:默认驱动,宿主机内一张虚拟网桥,容器间通过容器名互访,适合单机。
- host:容器直接共享宿主机网络栈,性能最好但端口会冲突,适合对延迟敏感的服务。
- overlay:跨主机网络,Swarm 或 K8s 这类集群场景才需要。
# 自建一个 bridge 网络,容器之间可用服务名互相解析
docker network create app-net
docker run -d --name db --network app-net -e MYSQL_ROOT_PASSWORD=secret mysql:8
docker run -d --name api --network app-net -p 3000:3000 myapp:1.4.2
# 数据卷:让数据库数据不随容器删除而丢失
docker volume create db-data
docker run -d --name db2 -v db-data:/var/lib/mysql mysql:8
# 绑定挂载:把宿主机目录挂进容器,适合开发期
docker run --rm -v "${PWD}/src:/app/src" myapp:dev
数据卷与绑定挂载的区别要记住:卷由 Docker 管理、跨平台一致、适合持久数据;绑定挂载直接映射宿主机路径、便于本地开发,但可移植性差。生产环境里数据库一律用卷,不要把数据写进容器可写层。
docker compose:多容器编排
真实应用很少只有一个容器:Web、数据库、缓存、任务队列往往要一起起来。逐个 docker run 既啰嗦又容易漏参数,Compose 用一个 YAML 文件把这些关系声明清楚。
services:
api:
image: registry.example.com/myapp:1.4.2
ports:
- "3000:3000"
environment:
- DB_HOST=db
- REDIS_HOST=cache
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secret
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s
retries: 5
cache:
image: redis:7-alpine
volumes:
db-data:
有了这份文件,起停整套环境只需要两条命令:docker compose up -d 和 docker compose down。注意 depends_on 只保证启动顺序,不保证服务已经就绪——要等数据库真正能接受连接,必须像上面那样配 healthcheck,否则应用还是会连不上库而崩溃重启。
镜像瘦身与安全实践
镜像大小不只是磁盘问题,它直接影响拉取时间、冷启动速度和被攻击面。几条见效最快的做法:
- 换更小的基础镜像:
alpine、-slim或 distroless,动辄省下几百兆。 - 多阶段构建:编译工具只留在构建阶段,产物复制进运行镜像。
- 合并 RUN 指令、清理缓存:包管理器的缓存目录记得在同一层删掉。
- 固定版本号:避免
latest,让每次构建结果可预期。
# 多阶段构建:构建阶段装全套工具,运行阶段只带产物
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY . .
RUN go build -o /out/app ./cmd/app
FROM alpine:3.20
RUN adduser -D -u 10001 appuser
COPY --from=build /out/app /usr/local/bin/app
USER appuser
ENTRYPOINT ["/usr/local/bin/app"]
| 安全项 | 做法 |
|---|---|
| 最小权限 | 非 root 运行,只读根文件系统 --read-only |
| 能力收敛 | 丢弃多余 capability,按需 --cap-add |
| 镜像来源 | 只用可信基础镜像,定期扫描漏洞 |
| 密钥管理 | 不把密码写进镜像,用环境变量或密钥管理服务注入 |
从单机到集群:Kubernetes 一瞥
单机 Docker 能撑起中小规模部署,但一旦涉及多台机器、自动扩缩容、滚动升级与自愈,就需要编排系统。Kubernetes 是目前的事实标准,它把「容器」抽象成更上层的对象:
- Pod:最小调度单元,一个或多个共享网络的容器。
- Deployment:声明期望副本数,负责滚动升级与回滚。
- Service:为一组 Pod 提供稳定入口与负载均衡。
- ConfigMap / Secret:把配置与密钥从镜像中剥离。
值得注意的是,K8s 并没有取代 Docker 的知识:镜像怎么构建、数据卷怎么理解、健康检查怎么写,这些概念全部一脉相承。先把手上的 Docker 用扎实,再上集群,学习曲线会平缓得多。
容器化的真正收益不在「跑起来」,而在「可重复、可回滚、可水平扩展」。先做到可重复,再谈编排。