目录

1-单服务、最快上手:Hello Compose

1.1-小点总结

2-用 Compose 构建镜像:本地热更新/自动重建

2.1-Dockerfile

2.2-package.json

2.3-docker-compose.yml

2.4-小点总结

3-双服务协作:API + Postgres(健康检查 & 依赖启动)

3.1-src/server.js(最小 Node API)

3.2-Dockerfile

3.3-package.json

3.4-.env

3.5-docker-compose.yml

3.6-小点总结

4-多网络隔离:前后端网络分层

4.1-注意

4.2-docker-compose.yml

4.3-环境搭建与测试

启动

网络结构验证

外部访问验证

内部通信测试

4.4-小点总结

5-观测与治理:日志轮转、健康检查、重启策略、只读根

5.1-docker-compose.yml

5.2-验证和模拟环境

1. 构建+启动

2. 健康检查验证

3. 日志轮转验证

模拟生成日志

然后进入宿主机查看日志

4. 重启策略验证

5. 只读文件系统验证

6. 安全性验证

7. 健康状态失效模拟

6-扩容与反向代理:Nginx 负载到多副本 API

6.1-docker-compose.yml

6.2-Nginx 配置(nginx.conf)

6.3-src/server.js

6.4-环境搭建与测试

启动服务 + 扩容

查看 DNS 解析(Nginx 自动负载)

验证负载均衡

模拟容器宕机

6.5-小点总结

7-进阶构建:多阶段、缓存加速、环境分离(dev/prod)

7.1-.dockerignore(演示“构建上下文”与体积差异)

7.2-package.json(用最小脚本演示 dev/prod 差异)

7.3-src/server.js(暴露可观察“模式差异”的接口)

7.4-Dockerfile(多阶段 + BuildKit 缓存)

7.5-docker-compose.yml(生产基线:使用 runner 阶段)

7.6-docker-compose.override.yml(开发覆盖:使用 deps 阶段 + 热更新)

7.7-生产模式(仅使用 docker-compose.yml,不加载 override)

7.8-开发模式(默认会合并 override:target=deps + 热更新)

验证热更新

7.9-BuildKit 缓存:命中与失效的“可视化对比”

首次构建(观察完整构建日志)

再次构建(不改任何文件)

只改源代码(不改 package.json)

改 package.json(模拟升级依赖)

7.10-.dockerignore 影响:构建上下文大小可视化

7.11-环境分离对比(prod vs dev)

7.12-小点总结


# 版本与环境
docker version
docker info
 
# 搜索/拉取/查看镜像
docker search nginx
docker pull nginx:alpine
docker images
docker rmi <image-id>
 
# 运行容器(-d 后台,-p 端口映射,--name 命名)
docker run -d -p 8080:80 --name web nginx:alpine
 
# 日志/进入容器/查看进程/资源使用
docker logs -f web
docker exec -it web sh
docker top web
docker stats
 
# 停止/启动/重启/删除
docker stop web
docker start web
docker restart web
docker rm -f web
 
# 导出/导入
docker save nginx:alpine > nginx.tar
docker load < nginx.tar
 

前置配置:创建好文件夹,方便后续学习

mkdir -p ~/compose-labs/{01-hello,02-build,03-api-db,04-multinet,05-ops,06-scale-nginx,07-build-advanced,08-final}
apt install npm -y
ls compose-labs/

1-单服务、最快上手:Hello Compose

目标:用 Compose 起一个最小的 Nginx 服务,学会 up/down/logs/ps。

yaml对格式要求很高,不使用tab,使用空格进行缩进

cd ~/compose-labs/01-hello
tee > docker-compose.yml << "EOF"
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    # 显示版本与启动参数
    command: ["nginx", "-g", "daemon off;"]
EOF

docker compose up -d        # 后台启动
docker compose ps           # 查看状态
docker compose logs -f web  # 跟随日志

curl http://localhost:8080  # 测试
docker compose down         # 停止并清理容器网络

1.1-小点总结

  • Compose = 把“多条 docker run 命令”写进 YAML。

  • ports: 主机:容器,只映射需要暴露的端口。

2-用 Compose 构建镜像:本地热更新/自动重建

目标:Compose 不仅能拉镜像,还能构建镜像;开发期支持绑定挂载热更新。

cd ~/compose-labs/02-build

2.1-Dockerfile

tee > dockerfile << "EOF"
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM base AS dev
COPY . .
CMD ["npm","run","dev"]   # 假设用 nodemon 或 tsx 热启动

FROM base AS runner
COPY . .
RUN npm run build
CMD ["node","dist/server.js"]
EOF

2.2-package.json

tee > package.json << "EOF"
{
  "name": "hello-compose",
  "private": true,
  "scripts": {
    "dev": "node -e \"require('http').createServer((_,r)=>r.end('dev ok')).listen(3000)\"",
    "build": "echo \"(这里替换为你的真实构建命令)\""
  }
}
EOF

2.3-docker-compose.yml

tee > docker-compose.yml << "EOF"
services:
  app:
    build:
      context: .
      target: dev          # 使用 dev 阶段
    ports:
      - "3000:3000"
    volumes:
      - ./:/app            # 开发期热更新
    environment:
      - NODE_ENV=development
    init: true             # 使用 tini 作为 PID 1,避免僵尸进程
EOF

# 构建,检验,清理
npm install
docker compose up --build -d

curl http://localhost:3000  # dev ok

docker compose down

2.4-小点总结

  • build.context 指向源码目录;target 指明多阶段构建的目标层。

  • volumes 绑定挂载,开发期热更新;生产一般不这样做。

3-双服务协作:API + Postgres(健康检查 & 依赖启动)

目标:用 Compose 编排 API 与数据库,健康检查、依赖启动、数据持久化。

cd ~/compose-labs/03-api-db && mkdir src

3.1-src/server.js(最小 Node API)

tee > src/server.js << "EOF"
import http from "http";
import { Client } from "pg";

const port = process.env.PORT || 3000;
const client = new Client({ connectionString: process.env.DATABASE_URL });

const server = http.createServer(async (req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200); res.end("ok"); return;
  }
  if (req.url === "/") {
    try {
      await client.connect();
      const { rows } = await client.query("SELECT NOW() as now");
      await client.end();
      res.setHeader("Content-Type","application/json");
      res.end(JSON.stringify({ now: rows[0].now }));
    } catch (e) {
      res.writeHead(500); res.end(String(e));
    }
    return;
  }
  res.writeHead(404); res.end("not found");
});

server.listen(port, () => console.log("API on", port));
EOF

3.2-Dockerfile

tee > dockerfile << "EOF"
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
EXPOSE 3000
HEALTHCHECK --interval=20s --timeout=2s --retries=5 CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))"
CMD ["node","src/server.js"]
EOF

3.3-package.json

tee > package.json << "EOF"
{
  "name": "api-db",
  "type": "module",
  "dependencies": {
    "pg": "^8.12.0"
  }
}
EOF

3.4-.env

tee > .env << "EOF"
POSTGRES_PASSWORD=example
POSTGRES_DB=appdb
DATABASE_URL=postgres://postgres:example@db:5432/appdb
TZ=UTC
EOF

3.5-docker-compose.yml

tee > docker-compose.yml << "EOF"
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U postgres -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 3s
      retries: 5

  api:
    build: .
    env_file: .env
    ports:
      - "8080:3000"
    depends_on:
      db:
        condition: service_healthy
    restart: always
    init: true

volumes:
  dbdata:
EOF

# 构建,日志,检验,清理
npm install
docker compose up --build -d

docker compose logs -f db

curl http://localhost:8080/        # 返回 { now: 时间 }
curl http://localhost:8080/healthz # ok

docker compose ps
docker compose down -v             # 清理包含卷(会删数据库数据)

3.6-小点总结

  • .env 提供变量;env_file 导入。

  • depends_on.condition=service_healthy 让 API 等 DB 真正健康后再启动。

  • 命名卷 dbdata 用于数据持久化。

4-多网络隔离:前后端网络分层

目标:让 Web 只暴露对外端口,DB 隐身在后端网络里,提高安全性。

cd ~/compose-labs/04-multinet/

4.1-注意

后面的实操延用03实操的文件,仅修改docker-compose.yml文件

cp -r ~/compose-labs/03-api-db/* ./

关键点

配置 作用
networks 声明了两个独立网络:frontend、backend
db.networks: [backend] 数据库只在后台网络中可见
api.networks: [frontend, backend] API 同时连前后端
外部访问 只能访问 api:8080db 完全对宿主机和外部隔离

4.2-docker-compose.yml

tee > docker-compose.yml << "EOF"
networks:
  frontend:
  backend:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    networks: [backend]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 10s
      timeout: 3s
      retries: 5

  api:
    build: .
    environment:
      DATABASE_URL: postgres://postgres:example@db:5432/appdb
    ports:
      - "8080:3000"
    depends_on:
      db:
        condition: service_healthy
    networks: [frontend, backend]
    restart: always

volumes:
  dbdata:
EOF

4.3-环境搭建与测试

启动

npm install
docker compose up --build -d
docker compose ps

输出:

NAME              STATE    HEALTH
04-multinet-db-1  running  (healthy)
04-multinet-api-1 running  (healthy)

网络结构验证

docker network ls

输出:

NETWORK ID     NAME                     DRIVER    SCOPE
xxxxxxx        04-multinet_frontend     bridge    local
xxxxxxx        04-multinet_backend      bridge    local

外部访问验证

curl http://localhost:8080  # 输出时间

宿主机访问数据库端口:

nc -zv localhost 5432
# 输出:
# nc: connect to localhost (127.0.0.1) port 5432 (tcp) failed: Connection refused

数据库隔离成功。

内部通信测试

网络拓扑分析:外部-->frontend 网络-->api-->backend 网络-->db

进入 api 容器内:

docker compose exec api sh
ping -c 2 db
exit  # 退出

输出:

PING db (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: icmp_seq=0 ttl=64 time=0.09 ms

说明:api 能解析到 db


进入 db 容器内:

docker compose exec db sh
ping -c 2 api
exit  # 退出

输出:

PING api (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.047 ms

docker compose ps
docker compose down -v             # 清理包含卷(会删数据库数据)

在docker-compose.yaml文件中

services:
  db:
    networks: [backend]
  api:
    networks: [frontend, backend]

这意味着:

  • dbbackend 网络;

  • apifrontend + backend

  • 两者 共享 backend 网络

❗重点:共享同一网络的容器一定可以互相通信。

4.4-小点总结

  • api 同时连前后端网络;db 只在 backend

  • 外部只能访问到映射端口的 api

5-观测与治理:日志轮转、健康检查、重启策略、只读根

目标:限制日志体积、防止日志爆盘;加固容器运行安全;加健康检查。

cd ~/compose-labs/05-ops
cp -r ~/compose-labs/03-api-db/* ./

5.1-docker-compose.yml

tee > docker-compose.yml << "EOF"
x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

x-health: &api-health
  test: ["CMD","node","-e","fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))"]
  interval: 20s
  timeout: 2s
  retries: 5

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    logging: *default-logging
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U postgres -d appdb"]
      interval: 10s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  api:
    build: .
    environment:
      DATABASE_URL: postgres://postgres:example@db:5432/appdb
      TZ: UTC
    ports:
      - "8080:3000"
    depends_on:
      db:
        condition: service_healthy
    healthcheck: *api-health
    logging: *default-logging
    restart: always
    init: true
    read_only: true              # 根文件系统只读
    tmpfs:
      - /tmp                     # 需要写入的目录挂 tmpfs
    security_opt:
      - no-new-privileges:true   # 降低提权风险
    user: "1000:1000"            # 尽量非 root 运行

volumes:
  dbdata:
EOF

5.2-验证和模拟环境

1. 构建+启动

npm install
docker compose up --build -d
docker compose ps

2. 健康检查验证

apt install jq -y
docker inspect --format='{{json .State.Health}}' $(docker compose ps -q api) | jq

3. 日志轮转验证

docker-compose.yml中:

logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

文件中的这段代码表示:

  • 容器日志会自动分片(最大 10MB)

  • 最多保留 3 个文件,旧日志自动删除

模拟生成日志
docker compose exec api sh -c 'for i in $(seq 1 100000); do echo "log-$i"; done'
然后进入宿主机查看日志
docker inspect $(docker compose ps -q api) | grep LogPath
# 输出路径例如:
# /var/lib/docker/containers/xxxx-json.log
ls -lh /var/lib/docker/containers/18d737cc459c7d09f76c7d8b3cd2ef0d1f0f5010691f14635840080c5989c1fc/

输出类似如下:

xxxx-json.log
xxxx-json.log.1
xxxx-json.log.2

超过 30MB 时旧日志会被自动清理。

4. 重启策略验证

restart: always 的容器会在崩溃时自动重启。

下面模拟容器崩溃

docker compose exec api pkill node
docker compose ps

几秒内你会发现 api 自动恢复为 running 状态(重启计数增加)。

docker inspect $(docker compose ps -q api) | jq '.[0].RestartCount'
# 输出为重启次数

若是 db 服务(unless-stopped),它也会重启,但如果你用 docker compose stop 主动停止,它不会再自动启动。

5. 只读文件系统验证

read_only: true 表示容器根目录(/)不能写。

测试 1:尝试写根目录

docker compose exec api sh -c 'echo hi > /root/test.txt'
# 报错:Read-only file system

测试 2:临时目录 /tmp 可写

docker compose exec api sh -c 'echo hi > /tmp/test.txt && cat /tmp/test.txt'
# 输出:hi

因为单独挂载了:

tmpfs:
  - /tmp

6. 安全性验证

检查当前用户

docker compose exec api id # uid=1000 gid=1000

说明容器内不是 root 用户运行。

尝试提权(会失败)

docker compose exec api sh -c 'apk add sudo' # 报错:read-only file system
docker compose exec api sh -c 'chmod 777 /etc/passwd' # 报错:Read-only file system

no-new-privileges:true 确保即使进程有漏洞,也无法提权。

7. 健康状态失效模拟

编辑 src/server.js(或健康路由)让 /healthz 返回错误:

tee > src/server.js <<'EOF'
import http from "http";
import { Client } from "pg";

const port = process.env.PORT || 3000;
const client = new Client({ connectionString: process.env.DATABASE_URL });

const server = http.createServer(async (req, res) => {
  // 模拟健康检查失效:返回 500
  if (req.url === "/healthz") {
    res.writeHead(500);
    res.end("bad");
    return;
  }

  if (req.url === "/") {
    try {
      await client.connect();
      const { rows } = await client.query("SELECT NOW() as now");
      await client.end();
      res.setHeader("Content-Type", "application/json");
      res.end(JSON.stringify({ now: rows[0].now }));
    } catch (e) {
      res.writeHead(500);
      res.end(String(e));
    }
    return;
  }

  res.writeHead(404);
  res.end("not found");
});

server.listen(port, () => console.log("API on", port));
EOF

重启容器:(重启后刚开始的状态是starting)

docker compose up -d --build

docker inspect --format='{{json .State.Health}}' $(docker compose ps -q api) | jq

状态变为:

"Status": "unhealthy"

此时 depends_on.condition=service_healthy 的下游服务将不会继续启动(或在依赖场景下被阻止)。

docker compose ps
docker compose down -v             # 清理包含卷(会删数据库数据)

6-扩容与反向代理:Nginx 负载到多副本 API

目标:用 --scale 扩容 API 多实例,Nginx 作为反向代理对外暴露一个端口。

cd ~/compose-labs/06-scale-nginx
cp -r ~/compose-labs/03-api-db/* ./

6.1-docker-compose.yml

tee > docker-compose.yml << "EOF"
version: "3.9"

services:
  db:
    image: postgres:16-alpine
    container_name: db
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: appdb
    volumes:
      - dbdata:/var/lib/postgresql/data
    networks:
      - appnet
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 10s
      timeout: 3s
      retries: 5

  api:
    build: .
    environment:
      DATABASE_URL: postgres://postgres:example@db:5432/appdb
    networks:
      - appnet
    depends_on:
      db:
        condition: service_healthy
    restart: always

  web:
    image: nginx:1.27-alpine
    container_name: web
    depends_on:
      - api
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - appnet
    restart: always

networks:
  appnet:

volumes:
  dbdata:
EOF

6.2-Nginx 配置(nginx.conf)

tee > nginx.conf << "EOF"
# 使用 Docker 内置 DNS 解析容器服务名
resolver 127.0.0.11 valid=5s;

upstream api_backend {
    zone api_backend 64k;
    server api:3000 resolve;   # resolve: 让 Nginx 动态解析多个副本 IP
}

server {
    listen 80;
    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # 健康检查路径(可选)
    location /healthz {
        return 200 "ok";
    }
}
EOF

6.3-src/server.js

tee > src/server.js << "EOF"
import http from "http";
import { Pool } from "pg";

const port = process.env.PORT || 3000;
const pool = new Pool({ 
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30000
});

// 测试连接池是否正常工作
pool.query("SELECT 1")
  .then(() => console.log("数据库连接池初始化成功"))
  .catch(err => console.error("数据库连接池初始化失败:", err));

const server = http.createServer(async (req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200);
    res.end("ok");
    return;
  }

  if (req.url === "/") {
    let client;
    try {
      client = await pool.connect();
      const { rows } = await client.query("SELECT NOW() as now");
      res.setHeader("Content-Type", "application/json");
      res.end(JSON.stringify({ 
        now: rows[0].now,
        container: process.env.HOSTNAME,
        poolSize: pool.totalCount // 显示当前连接池大小
      }));
    } catch (e) {
      console.error("请求处理错误:", e);
      res.writeHead(500);
      res.end(`错误: ${String(e)}`);
    } finally {
      if (client) {
        client.release(); // 释放回连接池,而非关闭
      }
    }
    return;
  }

  res.writeHead(404);
  res.end("not found");
});

server.listen(port, () => console.log(`API 服务启动在 ${port} 端口,容器: ${process.env.HOSTNAME}`));
EOF

6.4-环境搭建与测试

启动服务 + 扩容

# 清理 Docker 构建缓存
docker compose down --rmi all -v
# docker compose up -d --build
npm install
docker compose up -d --scale api=3
docker compose ps

输出:

NAME                STATE
06-scale-nginx-api-1   running
06-scale-nginx-api-2   running
06-scale-nginx-api-3   running
06-scale-nginx-web-1   running
06-scale-nginx-db-1    running

查看 DNS 解析(Nginx 自动负载)

docker compose exec web nslookup api
docker compose exec web nslookup api 127.0.0.11

输出:

Name:   api
Address: 172.18.0.5
Name:   api
Address: 172.18.0.6
Name:   api
Address: 172.18.0.3

说明:api 有多个容器 IP,Nginx 会轮询转发。

验证负载均衡

# 查看 api 服务日志,确认连接池初始化成功
docker compose logs api | grep "数据库连接池"

连续多次请求:

echo -e "\n测试请求结果:"
for i in {1..5}; do 
  curl http://localhost:8080/; 
  echo -e "\n"; 
done

返回结果中 "now" 时间变化微小(多副本响应),可以在日志中看到多个容器轮流输出。

看到每个副本均有日志打印。

模拟容器宕机

docker stop 06-scale-nginx-api-2
curl http://localhost:8080/
# 仍可访问

# 清理
docker compose down --rmi all -v

结构说明

服务 说明
db PostgreSQL 数据库,只在内部网络中
api 你从 03 复制的 Node.js 应用
web Nginx 反向代理,将请求转发给 api(可多副本)
网络 所有服务共用同一 appnet,便于反向代理自动发现

6.5-小点总结

  • --scale api=3 会起 api-1/api-2/api-3,服务名 api 的 DNS 会返回多个 IP。

  • Nginx server api:3000 resolve; 让它感知到多 IP 并轮询。

7-进阶构建:多阶段、缓存加速、环境分离(dev/prod)

目标:BuildKit 缓存、区分 dev/prod 配置、override 文件。

cd ~/compose-labs/07-build-advanced
cp -r ~/compose-labs/03-api-db/* ./

7.1-.dockerignore(演示“构建上下文”与体积差异)

tee > .dockerignore << "EOF"
node_modules
dist
.git
*.log
.DS_Store
bigfile
EOF

7.2-package.json(用最小脚本演示 dev/prod 差异)

tee > package.json << "EOF"
{
  "name": "build-advanced-lab",
  "private": true,
  "type": "module",
  "version": "1.0.0",
  "scripts": {
    "dev": "NODE_ENV=development node --watch src/server.js",
    "build": "mkdir -p dist && cp -r src/* dist/",
    "start": "node dist/server.js",
    "print:env": "node -e \"console.log(process.env.NODE_ENV || 'none')\""
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  },
  "dependencies": {
    "pg": "^8.12.0"
  }
}
EOF

7.3-src/server.js(暴露可观察“模式差异”的接口)

tee > src/server.js << "EOF"
import http from "http";

const port = process.env.PORT || 3000;
const startedAt = new Date().toISOString();

const server = http.createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200); res.end("ok"); return;
  }
  if (req.url === "/mode") {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({
      mode: process.env.NODE_ENV || "undefined",
      startedAt,
      pid1: process.pid
    }));
    return;
  }
  if (req.url === "/") {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({ hello: "compose-07", now: new Date().toISOString() }));
    return;
  }
  res.writeHead(404); res.end("not found");
});

server.listen(port, () => console.log(`server on ${port}, NODE_ENV=${process.env.NODE_ENV}`));
EOF

7.4-Dockerfile(多阶段 + BuildKit 缓存)

tee > dockerfile << "EOF"
# syntax=docker/dockerfile:1.7

# 依赖阶段:仅安装依赖,并使用缓存挂载加速
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

# 构建阶段:拷贝源码并产出 dist(这里用复制代替编译)
FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run build

# 运行阶段:仅带运行所需文件;非 root 运行
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
USER app
# 仅复制 dist,刻意不复制 package*.json / node_modules,证明生产镜像更干净
COPY --from=build --chown=app:app /app/dist ./dist
EXPOSE 3000
CMD ["node","dist/server.js"]
EOF

7.5-docker-compose.yml(生产基线:使用 runner 阶段)

tee > docker-compose.yml << "EOF"
services:
  api:
    build:
      context: .
      target: runner
    environment:
      NODE_ENV: production
      PORT: "3000"
    ports:
      - "8080:3000"
    restart: always
EOF

7.6-docker-compose.override.yml(开发覆盖:使用 deps 阶段 + 热更新)

tee > docker-compose.override.yml << "EOF"
services:
  api:
    build:
      target: deps
    command: ["npm", "run", "dev"]
    environment:
      NODE_ENV: development
      PORT: "3000"
    volumes:
      - ./:/app
EOF

目标 你能“看到”的差异
多阶段构建 生产镜像不包含 src/node_modules/(干净轻量)
BuildKit 缓存 第二次构建出现 CACHED,构建明显变快
环境分离 使用 docker-compose.yml(prod)与默认(dev+override)时,容器启动命令、环境、行为不同
.dockerignore 生效 构建时“发送的上下文大小”明显不同
非 root 运行 生产容器内 id 显示 uid=1000(app),不是 root
运行路径差异 prod 运行 node dist/server.js;dev 运行 node --watch src/server.js(热更新)

7.7-生产模式(仅使用 docker-compose.yml,不加载 override)

# 构建+启动
npm install
DOCKER_BUILDKIT=1 docker compose -f docker-compose.yml up --build -d

# 验证服务
curl http://localhost:8080/
curl http://localhost:8080/mode
# 预期返回:
# { "mode": "production", "startedAt": "...", "pid1": <数字> }

# 更一步验证生产镜像
CID=$(docker compose -f docker-compose.yml ps -q api)

# 1. 没有 node_modules
docker exec -it $CID sh -lc 'ls -al | sed -n "1,50p"; test -d node_modules || echo "no node_modules"'

# 2. 没有 src(只带 dist)
docker exec -it $CID sh -lc 'test -d src && echo "has src" || echo "no src"; ls -al dist'

# 3. 非 root
docker exec -it $CID sh -lc 'id'
# 预期:uid=1000(app) gid=1000(app)

# 查看生产容器启动命令
docker inspect $CID | grep -A3 '"Path"' | sed -n '1,6p'
# 预期看到 ["node","dist/server.js"]

# 镜像体积对比(当前目录只有一个服务时仅看单镜像)
docker images | head -n 5
docker history $(docker inspect $CID --format '{{.Image}}') --no-trunc | sed -n '1,20p'
# 你会看到 runner 阶段层数少、体积更小(没有依赖层)

7.8-开发模式(默认会合并 override:target=deps + 热更新)

# 停掉生产容器
docker compose -f docker-compose.yml down
# 用“默认加载”跑(= 基线 + override 合并)
DOCKER_BUILDKIT=1 docker compose up --build -d
docker compose ps

# 验证容器启动命令不同
CID=$(docker compose ps -q api)
docker inspect $CID | grep -A4 '"Path"' | sed -n '1,6p'
# 预期:["npm","run","dev"] -> node --watch src/server.js

验证热更新

# 当前 /mode
curl http://localhost:8080/mode
# 预期:{"mode":"development", ...}
# 查看 /
curl http://localhost:8080/
# 预期:{"hello":"compose-07","now":"..."}

修改src/server.js文件,添加一行内容

  if (req.url === "/") {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({ hello: "compose-07", now: new Date().toISOString(), devTick: Date.now() }));  // 添加 devTick: Date.now()
    return;
  }
curl http://localhost:8080/
# 预期:{"hello":"compose-07","now":"...","devTick":...}

# 预期:返回 JSON 改变(无需重建镜像,体现 dev 模式差异)

# 验证开发容器有源码 & 可写
docker exec -it $CID sh -lc 'test -d src && echo "has src" || echo "no src"'
docker exec -it $CID sh -lc 'ls -al src | sed -n "1,50p"'
# 输出"has src"为开发模式

7.9-BuildKit 缓存:命中与失效的“可视化对比”

首次构建(观察完整构建日志)

# 清理缓存,对其它服务无影响
docker builder prune -af
DOCKER_BUILDKIT=1 docker compose build --progress=plain

再次构建(不改任何文件)

DOCKER_BUILDKIT=1 docker compose build --progress=plain
# 预期日志中大量出现 "CACHED"
# 特别是 deps 阶段的 "npm ci" 会命中缓存挂载

只改源代码(不改 package.json)

echo "// 随机修改标记: $(head -c 8 /dev/urandom | xxd -p)" >> src/server.js
DOCKER_BUILDKIT=1 docker compose build --progress=plain
# 预期:deps (npm ci) 使用缓存;build & runner 层重建
# 三个阶段(deps、build、runner)确实都存在,但是平时被 BuildKit 合并输出,所以没有观察到build & runner层构建时的日志

改 package.json(模拟升级依赖)

# 往 dependencies 增加一行(比如一个不存在的版本也行,目的仅是触发)
# "left-pad": "1.3.0"  (举例;你可以仅改版本号触发变化)
sed -i 's/"pg": "[^"]*"/"pg":"8.12.0","left-pad":"1.3.0"/' package.json
npm install  # 重新下载,保证package.json 和 package-lock.json 内容完全同步
DOCKER_BUILDKIT=1 docker compose build --progress=plain
# 预期:deps (npm ci) 层缓存失效 -> 重新下载依赖,构建时间明显变长

7.10-.dockerignore 影响:构建上下文大小可视化

制造一个 100MB 的大文件(模拟“无意义大文件”)

dd if=/dev/zero of=bigfile bs=1M count=100

确保 .dockerignore 已包含 bigfile

grep bigfile .dockerignore

构建(观察“Sending build context”或 BuildKit 的 transfer 量)

# 方式 A:不使用 BuildKit(输出有明显 "Sending build context")
DOCKER_BUILDKIT=0 docker build -t tmp-context-test .
# 方式 B:使用 BuildKit,看 "transfer" 更小且更快
DOCKER_BUILDKIT=1 docker build --progress=plain -t tmp-context-test .

被忽略的大文件不会被打进构建上下文,传输更快、构建更快。
这一步直观解释了“为什么 .dockerignore 对 CI/CD 性能影响巨大”。

7.11-环境分离对比(prod vs dev)

prod(仅基线文件)

docker compose -f docker-compose.yml up -d --build
CID=$(docker compose -f docker-compose.yml ps -q api)

# 这里需要等待容器重启成功再进行下面的操作
docker exec -it $CID sh -lc 'node -p process.env.NODE_ENV'
# 预期:production

docker inspect $CID | grep -A4 '"Path"'
# 预期:node dist/server.js(非 watch,非 volumes)

docker exec -it $CID sh -lc 'test -d src || echo "prod: no src"'
# 预期:prod: no src

dev(基线 + override 合并)

docker compose down
docker compose up -d --build
CID=$(docker compose ps -q api)
docker exec -it $CID sh -lc 'node -p process.env.NODE_ENV'
# 预期:development

docker inspect $CID | grep -A4 '"Path"'
# 预期:npm run dev -> node --watch src/server.js

docker exec -it $CID sh -lc 'test -d src && echo "dev: has src"'
# 预期:dev:has src

7.12-小点总结

  • docker-compose.override.yml 会自动合并;生产可用 -f 指定只用基础文件。

  • BuildKit:DOCKER_BUILDKIT=1 docker compose build 可加速并启用缓存挂载。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐