为什么需要容器:从「我这能跑」说起

开发最经典的尴尬是:代码在我机器上跑得好好的,一上测试环境就报错。原因往往不是代码,而是环境差异——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,否则应用还是会连不上库而崩溃重启。