简单总结下 Docker
在 Docker 之前,时不时会出现类似噩梦:dev环境明明很好,怎么上线就出问题。原因很简单:应用运行依赖操作系统版本、系统库、运行时版本、环境变量……任何一环不一致,行为就可能不同。
虚拟机(VM)曾经是标准答案,但 VM 需要模拟完整硬件、运行独立内核,一台机器开几个 VM 资源就吃紧,启动也要几十秒。
Docker 换了个思路:不虚拟化硬件,只隔离进程。容器和宿主机共享同一个内核,靠 Linux 内核的几项能力实现"看起来像独立系统"的效果:
三者结合,容器的启动速度是毫秒到秒级,资源开销远低于 VM,这也是 Docker 能在过去十年里成为事实标准的根本原因。
Docker 采用 C/S(客户端-服务端)架构:
docker build、docker run 等命令,本质是向 Daemon 发 REST API 请求。containerd(容器生命周期管理,CNCF 毕业项目)和 runc(真正调用 Linux 内核创建 namespace/cgroup 的 OCI 标准运行时)。这套架构的好处是解耦:你可以在本地 Client 上操作远程服务器上的 Daemon(比如 CI/CD 流水线里常见的 DOCKER_HOST 配置),镜像可以在任意一台机器构建、推送,再在另一台机器上拉取运行。
镜像(Image) 是一个只读模板,容器(Container) 是镜像的一个运行实例。这句话说烂了,但真正重要的是"分层"这件事。
Dockerfile 里的每一条指令(FROM、RUN、COPY 等)都会生成一个新的只读层,层与层之间通过 UnionFS 叠加成完整的文件系统视图。容器启动时,Docker 会在最上面加一层可写层,容器内所有的文件修改都只发生在这一层,删除容器时可写层随之销毁,底层镜像不受影响。
这个机制带来两个直接的工程价值:
以一个 Node.js 服务为例,一个新手写法和一个经过优化的写法对比:
❌ 常见的低效写法:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
问题:任何代码改动都会导致 COPY . . 之后的所有层缓存失效,npm install 每次都要重新跑;而且用的是完整版 node 镜像,体积臃肿;没有区分构建依赖和生产依赖;用 root 用户运行也不安全。
✅ 优化后的多阶段构建:
# ---- 构建阶段 ----
FROM node:20-alpine AS builder
WORKDIR /app
# 先只拷贝依赖描述文件,最大化利用缓存
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- 运行阶段 ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 创建非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这里用到几个关键实践:
COPY package*.json 在 COPY . . 之前,只要依赖没变,npm ci 这层就能复用缓存。.dockerignore:别忘了配置,排除 node_modules、.git、.env 等,既加快构建又避免敏感文件被打进镜像。真实项目很少只有一个容器——服务本身、数据库、缓存、反向代理,往往要一起启动、一起联调。docker-compose.yaml 就是描述"这一组容器该怎么协作"的配置文件。
一个典型的全栈应用示例:
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
image: myapp:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/mydb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- app-net
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=mydb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-net
cache:
image: redis:7-alpine
networks:
- app-net
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
networks:
- app-net
networks:
app-net:
driver: bridge
volumes:
db-data:
几个容易踩坑、也是理解 Compose 的关键点:
depends_on 不等于"等服务真正就绪":默认只保证容器按顺序启动,不保证里面的服务(比如 Postgres)已经能接受连接。要用 condition: service_healthy 配合 healthcheck,才能真正等到数据库就绪再启动应用,否则应用启动瞬间连库会报错。db、cache 这些 service 名可以直接当主机名用(如上面的 DATABASE_URL 里的 db:5432),不需要关心容器实际 IP。volumes 用来持久化数据:容器本身是"一次性"的,数据库数据必须挂载到具名卷或宿主机目录,否则容器一删数据全丢。.env 文件:Compose 会自动读取同目录下的 .env 文件填充变量,敏感信息(密码、密钥)不要硬编码在 yaml 里,用 ${DB_PASSWORD} 引用环境变量。restart: unless-stopped:生产环境建议配置重启策略,容器异常退出后自动拉起,除非是人为手动停止的。常用命令:
docker compose up -d # 后台启动全部服务
docker compose logs -f app # 跟踪某个服务的日志
docker compose exec app sh # 进入运行中的容器
docker compose down -v # 停止并删除容器、网络(-v 连数据卷一起删,谨慎使用)
本地 docker compose up 跑通只是第一步,真正上线还有几件事必须考虑:
1. 镜像要打版本 tag,别用 latest。
生产环境用 latest 意味着你没法确定线上跑的到底是哪个版本的代码,回滚也无从谈起。用 Git commit hash 或语义化版本号打 tag,比如 myapp:v1.4.2 或 myapp:a1b2c3d。
2. 资源限制必须显式配置。 不加限制的容器可能因为内存泄漏拖垮整台宿主机。Compose 里可以这样写:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
3. 日志不要无限增长。
Docker 默认的 json-file 日志驱动不会自动轮转,长期运行的容器日志文件可能撑爆磁盘。配置日志轮转:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
4. 健康检查(healthcheck)是编排系统判断存活的依据。 无论是 Compose、Kubernetes 还是云厂商的容器服务,健康检查决定了流量能不能被正确路由到这个实例、故障实例能不能被及时替换。
5. 敏感信息不要打进镜像。 数据库密码、API Key 等要通过环境变量、Secret 管理(如 Docker Secrets、Kubernetes Secrets、Vault)注入,绝不能写进 Dockerfile 或提交到镜像仓库。
6. 单机 Compose vs 集群编排。
docker compose 适合单机部署、开发环境、小型项目。当你需要多机调度、自动扩缩容、滚动更新、服务发现,就需要 Kubernetes 或 Docker Swarm 这类编排系统了——但理解 Compose 里的这些概念(Service、Network、Volume)之后,再学 K8s 的对应概念会顺畅很多,它们本质上是同一套思路的不同实现规模。
docker exec vs docker attach:exec 在容器里新开一个进程(比如进入 shell 调试),退出不影响容器主进程;attach 是接到容器主进程的标准输入输出上,如果主进程被你 Ctrl+C 中断,容器可能直接退出。日常调试优先用 exec。.dockerignore、合并 RUN 指令减少层数(如 RUN apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*)、选择 slim/alpine 基础镜像,都能明显缩小体积、加快分发。bridge(默认,容器间通过虚拟网桥通信)、host(容器直接共用宿主机网络栈,性能好但隔离性差)、none(完全无网络),生产环境里选择哪种要结合安全与性能需求权衡。docker ps 看到的是容器实例,docker images 看到的是镜像本身,同一个镜像可以启动出任意多个容器实例,互不影响。Docker 的核心思想其实很简洁:把"环境一致性"这个老大难问题,通过内核级的隔离机制和分层文件系统解决掉。理解了 Namespace、Cgroup、UnionFS 这三件事,再回头看 Dockerfile 和 Compose 的种种最佳实践,会发现它们都是在为这套底层机制服务,而不是一堆孤立的"记住就好"的规则。