面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月6日 20:29

Docker Swarm 怎么用?集群部署和运维命令

Docker Swarm 是 Docker 自带的容器编排工具——不需要额外安装,几条命令就能把多台服务器组成集群,部署高可用服务。和 Kubernetes 比,Swarm 简单得多,适合中小规模部署。Swarm vs Kubernetes:怎么选| 维度 | Docker Swarm | Kubernetes ||------|-------------|------------|| 复杂度 | 低(几条命令) | 高(概念多、配置复杂) || 学习成本 | 半天 | 几周 || 适用规模 | 3-50 节点 | 10-10000+ 节点 || 自动扩缩容 | 手动 | 自动(HPA) || 自愈能力 | 有(重启失败容器) | 强(多种控制器) || 生态 | Docker 原生 | CNCF 全家桶 |选 Swarm 的情况:团队小、服务器少、不想花时间学 K8s选 K8s 的情况:大规模部署、需要自动扩缩容、团队有 K8s 经验初始化集群Manager 节点# 在第一台服务器上初始化docker swarm init --advertise-addr 192.168.1.10# 输出加入命令# docker swarm join --token SWMTKN-xxx 192.168.1.10:2377Worker 节点# 在其他服务器上执行加入命令docker swarm join --token SWMTKN-xxx 192.168.1.10:2377查看集群状态# 查看所有节点docker node ls# 输出# ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS# abc123 * node1 Ready Active Leader# def456 node2 Ready Active# ghi789 node3 Ready ActiveManager 节点负责调度和管理,Worker 节点只跑容器。Manager 也会跑容器——小集群不需要单独的管理节点。节点管理# 标记节点角色(让某些任务只跑在特定节点)docker node update --label-add role=backend node2docker node update --label-add role=database node3# 排空节点(维护时把容器迁移走)docker node update --availability drain node2# 恢复节点docker node update --availability active node2部署服务基本部署# 部署一个服务,3 个副本docker service create \ --name myapp \ --replicas 3 \ --publish 3000:3000 \ myapp:latestSwarm 自动把 3 个副本分散到不同节点,内置负载均衡——访问任意节点的 3000 端口都会被路由到健康的副本。使用 docker-compose 部署# docker-compose.ymlservices: api: image: myapp:latest ports: - "3000:3000" deploy: replicas: 3 update_config: parallelism: 1 # 每次更新 1 个副本 delay: 10s # 间隔 10 秒 failure_action: rollback # 失败自动回滚 rollback_config: parallelism: 0 # 回滚时一次全部替换 restart_policy: condition: on-failure delay: 5s max_attempts: 3 resources: limits: cpus: '1.0' memory: 2G redis: image: redis:7 deploy: replicas: 1 placement: constraints: - node.role == manager # 只跑在 manager 节点 postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data deploy: replicas: 1 placement: constraints: - node.labels.role == database # 只跑在标记了 database 的节点volumes: pg_data:# 部署docker stack deploy -c docker-compose.yml myapp# 查看服务docker service ls# 查看某个服务的副本docker service ps myapp_api滚动更新# 更新镜像版本docker service update --image myapp:v2.0 myapp_api# 查看更新进度docker service ps myapp_apiupdate_config 控制更新策略:parallelism: 1 每次只更新 1 个副本delay: 10s 每个副本更新后等 10 秒再更新下一个failure_action: rollback 新版本启动失败时自动回滚到旧版本手动回滚# 回滚到上一版本docker service rollback myapp_apiOverlay 网络:跨主机通信Swarm 模式下的 Overlay 网络让不同主机上的容器直接通信:# 创建 Overlay 网络docker network create -d overlay my-netservices: api: networks: - my-net redis: networks: - my-netnetworks: my-net: external: trueapi 容器在 node1,redis 在 node2——通过 redis:6379 直接访问,和单机体验一样。配置和敏感信息Config(非敏感配置)# 创建配置echo "server.port=8080" | docker config create app_config -services: api: configs: - source: app_config target: /app/config.propertiesconfigs: app_config: external: trueSecret(敏感信息)# 创建 Secretecho "db_password_123" | docker secret create db_password -services: api: secrets: - db_passwordsecrets: db_password: external: trueSecret 在容器内挂载为 /run/secrets/db_password,只有容器内可读,不会出现在 docker inspect 里。常用运维命令# 查看服务日志docker service logs myapp_api# 扩缩容docker service scale myapp_api=5# 查看服务详情docker service inspect myapp_api# 删除服务docker service rm myapp_api# 删除整个 Stackdocker stack rm myapp# 查看集群事件docker events --filter type=service什么时候该从 Swarm 迁移到 K8s需要自动扩缩容(HPA)需要 CronJob(定时任务)需要 Ingress 控制器(7 层路由)需要 Pod 级别的健康检查集群超过 50 个节点在以上需求出现之前,Swarm 够用且省心。
服务端阅读 06月6日 20:29

Docker 怎么限制容器资源?CPU、内存和磁盘 IO 配置

不限制容器资源,一个失控的容器就能吃光宿主机内存,拖垮同一台机器上的所有服务。Docker 提供了 CPU、内存、磁盘 IO 的精细限制手段,核心是 docker run 的资源参数和 docker-compose 的 deploy.resources 配置。内存限制:最常用也最重要# 限制最大内存 4GBdocker run -d --name myapp --memory=4g myapp:latest# 限制内存 + 禁用 swapdocker run -d --name myapp --memory=4g --memory-swap=4g myapp:latest--memory-swap=4g 等于 --memory 的值意味着容器不能用 swap。如果 --memory-swap 比 --memory 大,差值就是允许的 swap 大小。docker-compose 配置services: app: image: myapp:latest deploy: resources: limits: memory: 4G # 硬上限,超过会被 OOM Kill reservations: memory: 1G # 软保底,调度时保证至少 1GOOM 时会发生什么容器内存超过 limits.memory 时,内核 OOM Killer 杀掉容器里内存占用最大的进程:# 检查容器是否被 OOM 杀掉docker inspect myapp --format '{{.State.OOMKilled}}'# true = 被 OOM 杀了# 查看 OOM 事件dmesg | grep -i oomOOM 优先级调整多个容器抢内存时,可以设 OOM 优先级:# 不容易被 OOM Kill(-1000 到 1000,越小越不容易被杀)docker run -d --name important-app --oom-score-adj=-500 myapp# 容易被 OOM Kill(优先杀这个)docker run -d --name cache-app --oom-score-adj=500 redis核心服务(数据库、API 网关)设低值,缓存类服务(Redis、CDN)设高值。CPU 限制限制 CPU 核数# 最多用 2 核docker run -d --cpus=2.0 myapp# 最多用 0.5 核docker run -d --cpus=0.5 myappservices: app: deploy: resources: limits: cpus: '2.0' reservations: cpus: '0.5'--cpus=2.0 不是绑核——容器可以在任意 2 个核心上运行,只是总使用时间不超过 200%。绑核用 --cpuset-cpus:# 只在第 0 和第 2 个核心上运行docker run -d --cpuset-cpus=0,2 myapp# 只在第 1-3 个核心上运行docker run -d --cpuset-cpus=1-3 myapp绑核适合对 CPU 缓存一致性敏感的应用(如高性能计算),一般 Web 服务不需要。CPU 权重多个容器抢 CPU 时,按权重分配:# 默认权重 1024# 高权重 = 抢到更多 CPU 时间docker run -d --cpu-shares=2048 high-priority-appdocker run -d --cpu-shares=512 low-priority-app注意:--cpu-shares 只在 CPU 资源紧张时生效。CPU 空闲时低权重容器也能用满 CPU。磁盘 IO 限制限制容器读写磁盘的速率,防止一个容器把磁盘 IO 吃光:# 限制写速率 10MB/sdocker run -d \ --device-write-bps /dev/sda:10mb \ myapp# 限制读速率 20MB/sdocker run -d \ --device-read-bps /dev/sda:20mb \ myapp# 限制 IOPS(每秒 IO 操作数)docker run -d \ --device-write-iops /dev/sda:1000 \ myapp磁盘 IO 限制在 docker-compose 里不支持——需要 docker run 方式启动。PIDs 限制:防止进程炸弹限制容器内的进程数,防止 fork 炸弹:# 最多 100 个进程docker run -d --pids-limit=100 myappservices: app: deploy: resources: limits: pids: 100没有这个限制,一个容器可以 fork 出几千个进程,耗尽宿主机的 PID 表。运行时修改资源限制不需要重建容器就能调整限制:# 动态调整内存限制docker update --memory=8g myapp# 动态调整 CPU 限制docker update --cpus=4.0 myapp# 同时调整多个docker update --memory=8g --cpus=4.0 myapp注意:docker update 不能修改 --pids-limit 和 --cpuset-cpus——这些需要重建容器。资源限制最佳实践| 容器类型 | 内存 | CPU | 其他 ||---------|------|-----|------|| Web API | 2-4G | 1-2 核 | pids-limit: 200 || 数据库 | 4-16G | 2-4 核 | 绑核、禁 swap || 缓存 Redis | 2-8G | 1-2 核 | 禁 swap || 日志采集 | 512M-1G | 0.5 核 | 限制磁盘 IO || 后台任务 | 1-2G | 0.5-1 核 | pids-limit: 50 |原则:所有生产容器都设内存限制,防止单个容器拖垮宿主机数据库容器禁 swap(--memory-swap 等于 --memory)核心服务设低 OOM 优先级不确定资源需求时先设宽松限制,用 docker stats 观察实际用量再收紧
服务端阅读 06月6日 20:29

Docker 私有仓库怎么搭建?Registry 和 Harbor 选型

Docker Hub 是公开的,你的私有镜像不想让外人看到。企业内部需要一个私有 Registry 存放自己的镜像,CI/CD 推送镜像到私有仓库,生产服务器从私有仓库拉取。最简方案:Docker 官方 RegistryDocker 官方提供了一个极简的 Registry 镜像,几分钟就能跑起来:# 启动私有仓库docker run -d -p 5000:5000 --name registry registry:2# 推送镜像docker tag myapp:latest localhost:5000/myapp:latestdocker push localhost:5000/myapp:latest# 拉取镜像docker pull localhost:5000/myapp:latest这就够了——一个能推能拉的私有仓库。但生产环境需要持久化存储、认证、TLS。持久化存储services: registry: image: registry:2 ports: - "5000:5000" volumes: - registry_data:/var/lib/registryvolumes: registry_data:默认镜像存在 /var/lib/registry,用 Volume 持久化防止容器重启后镜像丢失。启用 TLSHTTP 模式下 Docker 客户端会拒绝推送(安全限制)。加 TLS:services: registry: image: registry:2 ports: - "5000:5000" environment: REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt REGISTRY_HTTP_TLS_KEY: /certs/domain.key volumes: - registry_data:/var/lib/registry - ./certs:/certs:ro# 自签名证书(测试用)mkdir certsopenssl req -newkey rsa:4096 -nodes -sha256 \ -keyout certs/domain.key \ -x509 -days 365 \ -out certs/domain.crt \ -subj "/CN=registry.example.com"所有拉取镜像的机器都要信任这个证书:# 把证书复制到 Docker 信任目录sudo mkdir -p /etc/docker/certs.d/registry.example.com:5000sudo cp certs/domain.crt /etc/docker/certs.d/registry.example.com:5000/ca.crtsudo systemctl restart docker基本认证# 创建用户密码文件mkdir authdocker run --entrypoint htpasswd httpd:2 -Bbn admin password123 > auth/htpasswdservices: registry: image: registry:2 environment: REGISTRY_AUTH: htpassd REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm" REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd volumes: - ./auth:/auth:ro# 登录后才能推送docker login registry.example.com:5000# Username: admin# Password: password123Harbor:企业级私有仓库Docker 官方 Registry 功能太简陋——没有 Web 界面、没有镜像扫描、没有 RBAC。Harbor 是 VMware 开源的企业级 Registry,补全了这些能力。Docker Compose 部署 Harbor# 下载 Harborwget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-offline-installer-v2.10.0.tgztar xzf harbor-offline-installer-v2.10.0.tgzcd harbor# 编辑配置cp harbor.yml.tmpl harbor.yml# harbor.yml 关键配置hostname: registry.example.comhttp: port: 80https: port: 443 certificate: /certs/domain.crt private_key: /certs/domain.keyharbor_admin_password: Harbor12345data_volume: /data/harbor# 安装./install.sh# 访问 Web 界面# https://registry.example.com# 用户名: admin 密码: Harbor12345Harbor 的企业级功能| 功能 | 说明 ||------|------|| Web 管理界面 | 浏览镜像、标签、层信息 || RBAC | 项目级权限控制(只读/开发/管理员) || 镜像扫描 | Trivy 集成,自动扫描漏洞 || 镜像签名 | Docker Content Trust,防止篡改 || 垃圾回收 | 清理无引用的镜像层,回收磁盘 || 复制规则 | 跨 Registry 同步镜像 || 审计日志 | 记录所有推送/拉取操作 |项目和权限Harbor 用"项目"组织镜像,类似 GitHub 的仓库:项目: frontend ├── api-gateway:v1.2.3 ├── web-app:v2.0.0 └── auth-service:v1.0.0项目: backend ├── user-service:v3.1.0 └── order-service:v2.5.0每个项目可以设不同的成员和权限——前端团队只能推拉 frontend 项目,后端团队只能推拉 backend 项目。云厂商托管 Registry不想自己运维 Registry,用云厂商的托管服务:| 云厂商 | 服务名 | 特点 ||--------|--------|------|| AWS | ECR | 与 IAM 集成,按存储计费 || GCP | Artifact Registry | 与 GCP IAM 集成 || Azure | ACR | 与 Azure AD 集成 || 阿里云 | 容器镜像服务 | 国内访问快 |# AWS ECR 示例aws ecr get-login-password | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.comdocker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestdocker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest托管服务的优势:不需要维护服务器、自动 TLS、自动漏洞扫描。劣势:网络延迟(国内拉海外镜像慢)、费用随存储量增长。选择决策| 场景 | 推荐方案 ||------|---------|| 个人/小团队测试 | Docker 官方 Registry || 团队 5-20 人 | Harbor(Docker Compose 部署) || 企业生产环境 | Harbor(高可用部署) || 全部在云上 | 云厂商托管 Registry || 国内访问为主 | 阿里云容器镜像服务 |起步建议:先用官方 Registry 跑起来,等需要 Web 界面和权限管理时迁移到 Harbor。
服务端阅读 06月6日 20:29

Docker 容器日志怎么管理?轮转、结构化和聚合方案

容器日志管理不只是 docker logs ——那只能看单个容器的标准输出。生产环境需要日志轮转防止磁盘撑满、日志聚合实现集中查询、结构化日志方便检索。这篇从本地管理到集中式方案逐步展开。docker logs 的局限docker logs myapp # 查看日志docker logs -f myapp # 实时跟踪docker logs --tail 100 myapp # 最近 100 行问题:容器删了日志就没了多容器没法一起搜没有日志轮转,磁盘会被撑满没有结构化字段,搜索靠 grep本地日志轮转:防止磁盘撑满json-file 驱动的轮转配置services: app: image: myapp:latest logging: driver: json-file options: max-size: "10m" # 单个日志文件最大 10MB max-file: "3" # 最多保留 3 个文件这样每个容器最多占 30MB 日志(10MB × 3 个文件)。超过 10MB 自动轮转,超过 3 个文件自动删除最老的。local 驱动:更省磁盘services: app: logging: driver: local options: max-size: "10m" max-file: "5"local 驱动用压缩存储,同样内容比 json-file 省 50% 空间。而且日志格式更易读。全局配置不想每个容器都写 logging 配置?在 daemon.json 里设全局默认:// /etc/docker/daemon.json{ "log-driver": "local", "log-opts": { "max-size": "10m", "max-file": "3" }}重启 Docker 后所有容器都用这个配置。强烈建议加上——我见过太多服务器因为 Docker 日志占满磁盘而崩溃。结构化日志:让检索更高效非结构化日志只能全文搜索。结构化日志可以按字段过滤:# Python - 用 structlog 输出 JSONimport structloglogger = structlog.get_logger()logger.info("user_login", user_id=123, ip="1.2.3.4")// Node.js - 用 pino 输出 JSONconst pino = require('pino')()pino.info({ userId: 123, action: 'login' }, 'User logged in')输出示例:{"level":"info","time":1704067200,"userId":123,"action":"login","msg":"User logged in"}在日志聚合平台里可以按 userId=123 或 action=login 精确过滤,不用全文搜索。日志级别管理# 动态调整日志级别(不需要重启容器)# Spring Bootcurl -X POST http://localhost:8080/actuator/loggers/com.example \ -d '{"configuredLevel": "DEBUG"}'# Node.js(需要应用支持)# 通过环境变量控制LOG_LEVEL=debug node app.js生产环境默认 INFO 级别,排查问题时临时切 DEBUG,不需要重新部署。日志聚合:集中式管理轻量方案:Grafana LokiLoki 只索引标签不索引正文,存储成本是 ELK 的 1/10:services: loki: image: grafana/loki:2.9.0 ports: - "3100:3100" promtail: image: grafana/promtail:2.9.0 volumes: - /var/lib/docker/containers:/var/lib/docker/containers:ro - ./promtail.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000"Promtail 自动从 Docker 容器目录读取日志,推送到 Loki。Grafana 查询:{container_name="myapp"} |= "error" | json | level="error"重量方案:EFK Stack需要全文搜索、复杂聚合时用 EFK(Elasticsearch + Fluentd + Kibana):services: elasticsearch: image: elasticsearch:8.12.0 environment: - discovery.type=single-node - xpack.security.enabled=false volumes: - es_data:/usr/share/elasticsearch/data fluentd: image: fluent/fluentd:v1.16 volumes: - ./fluentd/conf:/fluentd/etc ports: - "24224:24224" kibana: image: kibana:8.12.0 ports: - "5601:5601"容器配置 Fluentd 日志驱动:services: app: logging: driver: fluentd options: fluentd-address: localhost:24224 tag: myappEFK 最少需要 4GB 内存。团队小于 10 人用 Loki 就够了。日志管理最佳实践| 检查项 | 建议 ||--------|------|| 日志轮转 | 全局配 max-size: 10m, max-file: 3 || 日志级别 | 生产用 INFO,排查切 DEBUG || 结构化 | 用 JSON 格式输出 || 敏感信息 | 不在日志里打印密码、token || 聚合 | 小团队用 Loki,大团队用 EFK || 持久化 | 关键日志用 Volume 存储,不依赖容器可写层 || 监控 | 对 ERROR 日志设置告警 |起步建议:先配好本地日志轮转(10 分钟的事),再按需加 Loki 聚合。
服务端阅读 06月6日 20:29

Docker 容器间怎么通信?同一项目、跨项目和跨主机方案

两个容器要互相访问,怎么连通?同一个 compose 项目的容器用服务名直接访问,不同项目的容器需要共享网络,跨主机通信就得用 Overlay 网络。这篇按场景从简单到复杂讲清楚。同一 docker-compose 项目:默认网络Docker Compose 自动为每个项目创建一个网络,项目内的容器可以互相用服务名访问:# docker-compose.ymlservices: api: image: myapp-api ports: - "3000:3000" redis: image: redis:7 postgres: image: postgres:16# api 容器内直接用服务名访问curl http://redis:6379 # 访问 Rediscurl http://postgres:5432 # 访问 PostgreSQL不需要 IP,不需要 --link——Docker 内置 DNS 自动把服务名解析为容器 IP。自定义网络名默认网络名是 项目名_default。如果要自定义:services: api: networks: - frontend - backend redis: networks: - backendnetworks: frontend: backend:api 同时在 frontend 和 backend 两个网络里——可以访问两边。redis 只在 backend 里——frontend 网络的容器访问不到 redis,实现网络隔离。不同 docker-compose 项目:外部网络两个独立的 compose 项目需要通信时,共享一个外部网络:# 创建共享网络docker network create shared-net# 项目 A: docker-compose.ymlservices: api: networks: - shared-netnetworks: shared-net: external: true# 项目 B: docker-compose.ymlservices: worker: networks: - shared-netnetworks: shared-net: external: true项目 A 的 api 和项目 B 的 worker 通过服务名互相访问。容器访问宿主机容器里需要访问宿主机上的服务(比如宿主机上的 MySQL):# 专用 DNS 名curl http://host.docker.internal:3306host.docker.internal 是 Docker Desktop 提供的特殊 DNS,自动解析为宿主机 IP。Linux 上需要手动添加:services: api: extra_hosts: - "host.docker.internal:host-gateway"容器间直接用 IP不推荐但有时需要:# 查看容器 IPdocker inspect myapp --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'# 查看所有容器的 IPdocker network inspect bridge --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{end}}'容器 IP 每次重启可能变化——硬编码 IP 是反模式,应该用服务名或 DNS。端口映射:容器对外暴露服务services: api: ports: - "3000:3000" # 宿主机 3000 → 容器 3000 - "8080:80" # 宿主机 8080 → 容器 80 - "127.0.0.1:3306:3306" # 只允许本机访问127.0.0.1:3306:3306 这种写法限制了只监听 loopback 接口——外部无法访问,只有宿主机本身可以连。适合数据库等不需要对外暴露的服务。端口冲突排查# 查看宿主机端口占用lsof -i :3000# 或ss -tlnp | grep 3000# Docker 占用的端口docker port myapp网络模式选择| 模式 | 说明 | 适用场景 ||------|------|---------|| bridge(默认) | 容器有独立 IP,通过 NAT 访问外部 | 大部分场景 || host | 容器直接用宿主机网络栈,无隔离 | 需要极致网络性能 || none | 无网络 | 离线计算任务 || overlay | 跨主机容器通信 | Docker Swarm / 多主机 |host 模式services: api: network_mode: hosthost 模式下容器没有独立 IP,直接用宿主机的端口和网络。好处是没有 NAT 性能损耗,坏处是端口冲突风险高(容器和宿主机共享端口空间)。不要在生产环境用 host 模式——失去了网络隔离,一个容器被攻破等于宿主机被攻破。跨主机通信:Overlay 网络Docker Swarm 多主机环境下,不同主机上的容器需要通信:# 创建 Overlay 网络docker network create -d overlay my-overlay# 在 Overlay 网络上启动服务docker service create --network my-overlay --name api myappdocker service create --network my-overlay --name worker myworkerOverlay 网络底层用 VXLAN 隧道——api 和 worker 即使跑在不同的物理机上,也能通过服务名直接通信,和单机体验一致。DNS 排查容器间访问不通时,先排查 DNS:# 进入容器测试 DNS 解析docker exec myapp nslookup redisdocker exec myapp ping redis# 查看 DNS 配置docker exec myapp cat /etc/resolv.conf# 临时指定 DNSdocker run --dns 8.8.8.8 myapp常见问题:自定义了 docker-compose.yml 的 networks 但忘了在服务里引用,或者两个服务不在同一个网络里。
服务端阅读 06月6日 20:23

Docker 容器文件系统怎么工作?分层和 Copy-on-Write 机制

容器里写了一个文件,它到底存在哪里?删掉容器后文件去哪了?为什么镜像层是只读的但容器可以写入?理解 Docker 文件系统的工作原理,这些问题就都清楚了。从镜像到容器:分层文件系统Docker 镜像不是一个大文件——它是多层只读文件系统的堆叠。每个 Dockerfile 指令产生一层:FROM ubuntu:22.04 # 层 1:操作系统基础(~70MB)RUN apt-get update # 层 2:包索引更新RUN apt-get install -y python3 # 层 3:Python 运行时COPY . /app # 层 4:应用代码CMD ["python3", "/app/main.py"] # 不产生新层(只是元数据)┌────────────────────────┐│ 可写层(容器运行时添加) │ ← 容器启动后才有├────────────────────────┤│ 层 4:应用代码 │ ← COPY 产生├────────────────────────┤│ 层 3:Python 运行时 │ ← RUN 产生├────────────────────────┤│ 层 2:包索引 │ ← RUN 产生├────────────────────────┤│ 层 1:Ubuntu 基础 │ ← FROM 产生└────────────────────────┘关键:每个只读层都是不可变的。删除了上一层的文件,并不会真的删除——只是在新层里标记为"已删除"(whiteout 文件)。Copy-on-Write:容器写入的核心机制容器启动时,Docker 在所有只读层上面加一层可写层。容器内的写操作都走 CoW:修改已有文件1. 容器要修改 /etc/config.yml(存在于镜像层 2)2. Docker 从层 2 把 config.yml 复制到可写层3. 修改发生在可写层的副本上4. 后续读取这个文件时,可写层的版本"遮盖"了镜像层的原始版本新建文件直接写在可写层,不涉及复制。删除文件在可写层创建一个 whiteout 文件(标记为已删除),不真的从只读层删除。overlay2 的实现overlay2 是 Docker 默认的存储驱动,基于 Linux 内核的 OverlayFS:容器看到的是 merged 视图:┌──────────────┐│ merged │ = upperdir + lowerdir 合并后的视图├──────────────┤│ upperdir │ = 可写层(容器修改的文件)├──────────────┤│ lowerdir │ = 镜像的所有只读层└──────────────┘在宿主机上的实际位置# Docker 的存储根目录ls /var/lib/docker/overlay2/# 每个镜像层一个目录/var/lib/docker/overlay2/<layer-id>/ diff/ # 这一层的文件内容 link # 指向层的短链接 lower # 指向下层层的链接 merged/ # 合并视图(容器运行时才出现) work/ # OverlayFS 工作目录查看容器的 overlay2 层# 找到容器的 overlay2 路径docker inspect myapp --format '{{.GraphDriver.Data.MergedDir}}'# /var/lib/docker/overlay2/abc123/merged# 查看可写层的内容ls /var/lib/docker/overlay2/abc123/diff/diff/ 目录里就是容器内所有修改过的文件——和 docker diff 命令看到的一致。为什么容器删除后数据会丢1. docker run → 创建可写层 + 启动容器2. 容器运行中 → 数据写在可写层3. docker rm → 删除可写层 + 容器元数据可写层随容器一起删除,里面的数据也消失了。这就是为什么需要 Volume:services: postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data # 数据存在 Volume,不在可写层Volume 的数据存在 /var/lib/docker/volumes/pg_data/,容器删除不影响它。镜像层的共享和节省空间多个镜像共享相同的基础层:镜像 A(Node.js 应用) 镜像 B(Python 应用)┌──────────────┐ ┌──────────────┐│ 层 3:App A │ │ 层 3:App B │ ← 不同├──────────────┤ ├──────────────┤│ 层 2:Node.js│ │ 层 2:Python │ ← 不同├──────────────┤ ├──────────────┤│ 层 1:Ubuntu │ │ 层 1:Ubuntu │ ← 共享!只存一份└──────────────┘ └──────────────┘# 查看 Docker 磁盘占用docker system df -v# SHARED SIZE 列显示被多个镜像共享的大小# UNIQUE SIZE 列显示该镜像独有的层大小镜像构建优化:减少层和大小合并 RUN 指令# 差:每条 RUN 一层RUN apt-get updateRUN apt-get install -y python3RUN apt-get install -y pip# 好:合并成一层RUN apt-get update && \ apt-get install -y python3 pip && \ rm -rf /var/lib/apt/lists/*合并的好处:层数减少rm -rf /var/lib/apt/lists/ 在同一层执行——删除操作真的释放了空间,而不是在下一层标记为已删除多阶段构建FROM node:20 AS builderWORKDIR /appCOPY . .RUN npm ci && npm run buildFROM node:20-alpineCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCMD ["node", "dist/index.js"]最终镜像只有 dist/ 和 node_modules/——没有源码、没有构建工具,体积缩小 60-80%。.dockerignore 减少构建上下文# .dockerignore.gitnode_modules.env*.mdtest/.dockerignore 排除的文件不会进入构建上下文——既加快构建,又防止敏感文件进镜像。常见文件系统问题镜像越来越大# 查看每层大小docker history myapp:latest# 找出最大的层docker history --format "{{.Size}}\t{{.CreatedBy}}" myapp:latest常见的膨胀原因:apt-get update 的缓存、npm 缓存、临时文件。在同一层里清理掉。容器写入慢可写层写大量小文件时性能差——特别是 Mac/Windows 上 Docker Desktop 的文件共享。解决方案:大量写入用 Volume。inode 耗尽大量小文件会消耗 inode。df -i 查看剩余 inode。overlay2 比 overlay 的 inode 使用更少——这也是推荐 overlay2 的原因之一。
服务端阅读 06月6日 20:23

Docker 容器出问题了怎么排查?分层排查流程

容器启动失败、运行中崩溃、网络不通、数据丢失——Docker 故障排查有一套固定流程,按层级从外到内逐步缩小范围。排查流程总览容器状态异常?├── 容器没启动 → 查 docker logs├── 容器运行但行为异常 → 查 docker logs + docker exec├── 容器网络不通 → 查 docker network + 端口映射├── 容器数据问题 → 查 Volume 挂载└── 宿主机资源不足 → 查 docker stats + 系统资源第一步:确认容器状态# 查看所有容器(包括停止的)docker ps -a# 关注 STATUS 列# Up 2 hours → 正常运行# Exited (0) 5 mins → 正常退出# Exited (1) 5 mins → 错误退出# Exited (137) 1 min → 被 SIGKILL 杀掉(通常是 OOM)# Restarting → 不断重启(崩溃循环)退出码含义:| 退出码 | 含义 ||--------|------|| 0 | 正常退出 || 1 | 应用错误 || 137 | OOM Killed(内存不足) || 139 | Segmentation Fault || 143 | SIGTERM(正常停止) |第二步:查日志docker logs# 查看容器日志docker logs myapp# 实时跟踪docker logs -f myapp# 最近 100 行docker logs --tail 100 myapp# 带时间戳docker logs -t myapp# 查看某个时间段的日志docker logs --since "2024-01-01T00:00:00" --until "2024-01-01T12:00:00" myapp容器已经退出了?# 已退出的容器仍然可以查日志docker logs myapp # 只要容器没被 docker rm 删掉# 如果容器被删了,日志文件还在(json-file 驱动)ls /var/lib/docker/containers/<container-id>/# <container-id>-json.log日志里什么都没有?应用可能把日志写到了文件而不是 stdout/stderr。Docker 只捕获标准输出/错误流:# 进入容器查看日志文件docker exec -it myapp shcat /app/logs/app.log最佳实践:让应用把日志输出到 stdout/stderr,这样 docker logs 直接能看到。不要写到容器内的文件——容器删除后日志也丢了。第三步:进入容器排查# 进入容器执行命令docker exec -it myapp sh# 如果容器没有 sh,用 bashdocker exec -it myapp bash# 如果都没有(alpine 镜像可能只有 sh)docker exec -it myapp /bin/sh# 不进入容器,直接执行命令docker exec myapp ps auxdocker exec myapp cat /etc/config.yml容器里能做的事:ps aux 看进程top 看 CPU/内存cat /etc/hosts 看网络配置curl localhost:3000 测试内部端口env 看环境变量容器一启动就退出了怎么办docker exec 需要容器在运行状态。容器一启动就退出的情况,用覆盖入口点的方式:# 覆盖 CMD,保持容器运行docker run -it --entrypoint sh myapp:latest# 或者在 Dockerfile 末尾临时加# CMD ["sleep", "3600"]进入容器后手动执行原来的启动命令,观察报错。第四步:排查网络问题容器端口没暴露# 检查端口映射docker port myapp# 3000/tcp -> 0.0.0.0:3000# 如果没有输出,说明没做端口映射# 重新启动时加 -pdocker run -d -p 3000:3000 myapp容器间网络不通# 查看容器的网络docker inspect myapp --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'# 查看所有 Docker 网络docker network ls# 同一网络的容器可以互相通过容器名访问# 不同网络的容器互相隔离# 测试容器间连通性docker exec myapp ping redisdocker exec myapp curl http://api-service:8080/healthDNS 解析失败# 查看容器的 DNS 配置docker exec myapp cat /etc/resolv.conf# 自定义 DNSdocker run --dns 8.8.8.8 myapp第五步:排查 Volume 问题文件没有出现在容器内# 检查 Volume 挂载docker inspect myapp --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'# /host/path -> /container/path常见原因:宿主机路径写错了(相对路径问题)文件权限不匹配(容器内用户没读权限)SELinux 阻止访问(加 :z 或 :Z 后缀)# SELinux 环境docker run -v /host/path:/container/path:z myapp # 多容器共享docker run -v /host/path:/container/path:Z myapp # 单容器专用容器内修改没有反映到宿主机检查是不是挂载方向反了,或者挂载的是文件而不是目录:# 错误:挂载不存在的文件,Docker 会创建一个目录docker run -v ./config.yml:/app/config.yml myapp# 如果 ./config.yml 不存在,Docker 会创建 /app/config.yml 目录# 正确:确保文件先存在touch ./config.ymldocker run -v ./config.yml:/app/config.yml myapp常见故障速查| 症状 | 排查命令 | 常见原因 ||------|---------|---------|| 容器启动就退出 | docker logs | CMD 执行失败、配置错误 || 容器被杀 | docker inspect --format '{{.State.OOMKilled}}' | 内存不足 || 端口访问不到 | docker port + curl | 没映射端口、防火墙 || 容器间不通 | docker exec ping | 不在同一网络 || 磁盘满 | df -h + docker system df | 日志太多、镜像太多 || 构建慢 | docker build --progress=plain | 没用缓存、上下文太大 || 权限错误 | docker exec ls -la | UID 不匹配、SELinux || 容器不断重启 | docker logs --tail 50 | 启动脚本报错、依赖服务未就绪 |调试技巧用 debug 镜像替换生产镜像可能是 distroless 或 alpine,没有 curl、ping 等工具。临时用 debug 镜像排查:# 把镜像临时换成有工具的版本docker run -d --name myapp-debug \ --entrypoint sh \ -p 3000:3000 \ myapp:latest -c "sleep 3600"docker diff 看文件变更# 查看容器内哪些文件被修改了docker diff myapp# A /app/newfile.txt → Added# C /etc/config.yml → Changed# D /tmp/old.log → Deleteddocker events 实时监控# 监控 Docker 事件(容器启停、OOM、健康检查等)docker events --filter container=myapp
服务端阅读 06月6日 20:23

Docker 容器怎么接入 CI/CD?GitHub Actions 和 GitLab CI 集成

代码提交后自动构建镜像、跑测试、部署——这就是 CI/CD 和 Docker 的结合。Docker 让构建环境一致、部署产物标准化,CI/CD 让这一切自动运行。这篇讲 Docker 在主流 CI/CD 平台中的集成方式。Docker 在 CI/CD 中的三个角色构建环境:CI Runner 本身跑在 Docker 容器里,保证每次构建环境一致构建产物:docker build 产出镜像,推到 Registry,部署时直接拉镜像部署目标:生产环境拉镜像启动容器,不需要在服务器上装运行时GitHub Actions + Docker基本构建和推送# .github/workflows/deploy.ymlname: Build and Push Docker Imageon: push: branches: [main]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | myorg/myapp:latest myorg/myapp:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=maxcache-from: type=gha 用 GitHub Actions 的缓存加速构建——没有变化的层直接复用,不用重新构建。首次构建可能要 5 分钟,后续只要 1-2 分钟。多阶段构建减小镜像# 构建阶段FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build# 运行阶段——只有产物,没有源码和 devDependenciesFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCMD ["node", "dist/index.js"]多阶段构建让最终镜像只有运行时需要的文件——从 1GB+ 缩小到 200MB 以下。GitLab CI + DockerDocker-in-Docker 构建# .gitlab-ci.ymlbuild: image: docker:24 services: - docker:24-dind variables: DOCKER_TLS_CERTDIR: "/certs" before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHAdocker:24-dind 服务启动一个 Docker 守护进程,让 CI 容器里可以执行 docker build。注意要加 DOCKER_TLS_CERTDIR 确保通信安全。使用 GitLab Container RegistryGitLab 自带 Container Registry,不用额外配 Docker Hub:script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA$CI_REGISTRY_IMAGE 是 GitLab 预设变量,指向当前项目的 Registry 地址。镜像标签策略# 不推荐:只用 latestmyapp:latest# 推荐:语义化版本 + Git SHA 双标签myapp:1.2.3myapp:sha-abc1234# 回滚时指定版本docker pull myapp:1.2.2| 标签 | 用途 | 示例 ||------|------|------|| latest | 默认最新版 | 开发环境用 || 语义化版本 | 生产部署 | 1.2.3 || Git SHA | 精确追溯 | sha-abc1234 || 分支名 | 预览环境 | main, staging |生产环境永远用精确版本号或 SHA,不用 latest——latest 指向的镜像随时可能变,出了问题无法回滚。部署阶段SSH 部署到服务器# GitHub Actions- name: Deploy to server uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: deploy key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull myorg/myapp:${{ github.sha }} docker stop myapp || true docker rm myapp || true docker run -d --name myapp \ --restart unless-stopped \ -p 3000:3000 \ myorg/myapp:${{ github.sha }}docker-compose 部署服务器上放一个 docker-compose.yml,CI 只需要触发 docker compose pull && docker compose up -d:# 服务器上的 docker-compose.ymlservices: app: image: myorg/myapp:latest ports: - "3000:3000" restart: unless-stopped# CI 部署脚本ssh deploy@server "cd /app && docker compose pull && docker compose up -d"零停机部署上面的方式会短暂中断服务。用双容器切换实现零停机:# 1. 启动新容器在不同端口docker run -d --name myapp-new -p 3001:3000 myorg/myapp:new-version# 2. 健康检查until curl -f http://localhost:3001/health; do sleep 1; done# 3. 切换 Nginx 上游# 更新 nginx upstream 指向 3001docker exec nginx nginx -s reload# 4. 停掉旧容器docker stop myapp-old && docker rm myapp-old# 5. 重命名新容器docker rename myapp-new myapp-old安全最佳实践不要用 root 跑容器:Dockerfile 里加 USER app扫描镜像漏洞:docker scout cves myapp:latest 或 Trivy最小基础镜像:用 alpine 或 distroless 代替 ubuntuSecret 不进镜像:数据库密码等通过环境变量或 Secret 注入,不写进 Dockerfile.dockerignore 排除无关文件:.git、node_modules、.env 不应该进构建上下文# .dockerignore.gitnode_modules.env*.md.dockerignore
服务端阅读 06月6日 20:23

Docker 存储驱动该选哪个?overlay2 和其他驱动的区别

Docker 镜像的每一层怎么存、容器怎么在只读层上写入、不同存储驱动有什么性能差异——理解存储驱动的工作原理,能帮你解决容器写入慢、镜像占磁盘、数据丢失这些问题。容器文件系统的分层结构Docker 镜像由多个只读层组成,容器启动时在最上面加一层可写层:┌─────────────────────┐│ 可写层(容器层) │ ← docker run 产生的可写层├─────────────────────┤│ 镜像层 3(App) │ ← COPY / RUN 指令产生的层├─────────────────────┤│ 镜像层 2(Runtime)│├─────────────────────┤│ 镜像层 1(OS 基础) │└─────────────────────┘只读层:镜像的每一层都是只读的,多个容器可以共享同一组只读层可写层:容器的所有修改(新建文件、修改配置、写日志)都写在这层容器删除后,可写层一起消失——这就是为什么容器内写的数据会丢Copy-on-Write 机制容器修改镜像中的文件时,不是直接改只读层——而是把文件复制到可写层再修改:# 镜像里有个 /etc/config.yml# 容器内修改它:echo "new value" >> /etc/config.yml# 发生了什么:# 1. 从只读层复制 config.yml 到可写层# 2. 在可写层修改# 3. 后续读这个文件时读可写层的版本(遮盖了只读层的原始文件)CoW 的性能影响:第一次修改大文件时会比较慢(需要完整复制)。频繁修改大文件(比如数据库的数据文件)不适合放在容器可写层——应该用 Volume。主流存储驱动对比| 驱动 | 文件系统 | 适用场景 | 性能 ||------|---------|---------|------|| overlay2 | OverlayFS | 默认推荐,所有 Linux 发行版 | 好 || devicemapper | devicemapper | 老系统、RHEL/CentOS 6 | 一般 || btrfs | Btrfs | 需要快照、子卷管理 | 写入好,读取一般 || zfs | ZFS | 需要数据完整性校验 | 功能强但内存需求大 |overlay2:默认且最优overlay2 是当前 Docker 的默认存储驱动,也是性能最好的:# 查看当前存储驱动docker info --format '{{.Driver}}'# overlay2overlay2 的工作方式:lowerdir(只读层)= 镜像的所有层upperdir(可写层)= 容器的修改merged = 合并视图(容器看到的文件系统)优势:只有一层 lowerdir 目录,不像老版 overlay 有多层,inode 消耗少页缓存共享——多个容器读同一个文件只占一份内存build 性能好——Docker Build 时层操作都是 O(1)99% 的场景用 overlay2 就对了,不需要换。devicemapper:老系统的选择RHEL/CentOS 6 时代内核不支持 OverlayFS,只能用 devicemapper。有两种模式:loopback(默认):用文件模拟块设备,性能差,生产环境不能用direct-lvm:直接用 LVM 块设备,性能可以// /etc/docker/daemon.json - direct-lvm 配置{ "storage-driver": "devicemapper", "storage-opts": [ "dm.directlvm_device=/dev/sdb", "dm.thinp_percent=95", "dm.thinp_metapercent=1" ]}现在大部分系统已经支持 overlay2,不需要再折腾 devicemapper。存储驱动和磁盘占用的关系每个存储驱动管理镜像层的方式不同,磁盘占用差异很大:# 查看 Docker 占用的磁盘docker system df# 详细信息docker system df -vTYPE TOTAL ACTIVE SIZE RECLAIMABLEImages 15 5 4.2GB 2.1GB (50%)Containers 8 3 120MB 80MB (66%)Local Volumes 5 3 800MB 200MB (25%)Build Cache 50 0 1.5GB 1.5GB (100%)RECLAIMABLE 是可以回收的空间。清理命令:# 清理悬空镜像(没有标签的)docker image prune# 清理所有未使用的镜像docker image prune -a# 清理构建缓存docker builder prune# 一键全清docker system prune -aVolume:数据持久化的正确方式容器可写层的数据会随容器删除而丢失。需要持久化的数据(数据库、日志、配置)用 Volume:services: postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data # 命名 Volume - ./init.sql:/docker-entrypoint-initdb.d/init.sql # 绑定挂载 app: image: myapp:latest volumes: - ./config:/app/config:ro # 只读绑定挂载 - app_logs:/app/logs # 命名 Volumevolumes: pg_data: app_logs:命名 Volume vs 绑定挂载| | 命名 Volume | 绑定挂载 ||---|---|---|| 存储位置 | Docker 管理(/var/lib/docker/volumes/) | 宿主机任意目录 || 性能 | 好(Docker 优化) | Mac/Win 上可能慢 || 可移植性 | 好 | 依赖宿主机路径 || 备份 | docker volume inspect 找路径 | 直接访问宿主机目录 || 适用场景 | 数据库、应用数据 | 配置文件、代码挂载 |存储驱动选择决策| 你的情况 | 推荐驱动 ||---------|---------|| Linux 内核 4.0+ | overlay2(默认) || RHEL/CentOS 7+ | overlay2 || 需要 ZFS 快照和校验 | zfs || 需要 Btrfs 子卷管理 | btrfs || 老系统无法升级内核 | devicemapper (direct-lvm) |不确定就用 overlay2——它已经是默认选项了。除非有明确的特殊需求,否则不需要改存储驱动。
服务端阅读 06月6日 20:23

Docker 容器资源怎么监控?从 docker stats 到 Prometheus 的完整方案

容器跑着跑着内存爆了、CPU 拉满了、磁盘写满了——这些问题的根源都能通过资源监控提前发现。Docker 提供了从命令行到 API 多层级的资源监控手段,这篇按从简单到复杂的顺序讲清楚。docker stats:最快上手# 查看所有运行中容器的资源使用docker stats# 只看特定容器docker stats myapp# 只输出一次(不刷新)docker stats --no-stream# 只看特定指标docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"输出示例:NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/Omyapp 12.5% 256MiB / 4GiB 6.25% 1.2MB / 340kB 45MB / 0Bredis 0.3% 8MiB / 512MiB 1.56% 56kB / 32kB 0B / 0BMEM USAGE / LIMIT 最关键——如果 Usage 接近 Limit,说明容器快 OOM 了。BLOCK I/O 高说明磁盘读写频繁,可能是日志写入太多或数据库查询没走缓存。局限:docker stats 只能看实时数据,没有历史趋势。要看趋势得上 Prometheus + Grafana。设置资源限制:监控的前提监控资源使用的目的是发现问题,而限制资源是防止问题蔓延:# docker-compose.ymlservices: app: image: myapp:latest deploy: resources: limits: cpus: '2.0' # 最多用 2 核 memory: 4G # 内存上限 4GB reservations: cpus: '0.5' # 保底 0.5 核 memory: 1G # 保底 1GB# 命令行方式docker run -d --name myapp \ --cpus=2.0 \ --memory=4g \ --memory-swap=4g \ # 禁止 swap myapp:latest--memory-swap=4g 等于 --memory 的值意味着容器不能用 swap——全部用物理内存。OOM 时容器会被直接杀掉而不是被 swap 拖慢。CPU 限制的两种方式# 1. --cpus:限制 CPU 时间占比(推荐)docker run --cpus=1.5 myapp # 最多用 1.5 核# 2. --cpu-period + --cpu-quota:更精细的控制docker run --cpu-period=100000 --cpu-quota=50000 myapp# quota/period = 50000/100000 = 0.5 核日常用 --cpus 就够了。--cpu-period/--cpu-quota 只在需要非整数的精确控制时用。cgroups:底层数据源Docker 的资源限制和监控都基于 Linux cgroups。直接读 cgroups 文件可以拿到最原始的数据:# 容器的内存使用(字节)cat /sys/fs/cgroup/memory/docker/<container-id>/memory.usage_in_bytes# 容器的 CPU 使用(纳秒)cat /sys/fs/cgroup/cpu/docker/<container-id>/cpuacct.usage# 内存限制cat /sys/fs/cgroup/memory/docker/<container-id>/memory.limit_in_bytes一般不需要直接读 cgroups——docker stats 和 Prometheus 已经做了封装。但如果容器内的监控工具和宿主机的 docker stats 数据对不上,可能是 cgroups 版本差异(cgroups v1 vs v2)导致的。Docker API:程序化监控# 获取容器资源使用统计(JSON 格式)curl --unix-socket /var/run/docker.sock \ http://localhost/containers/<container-id>/stats?stream=false返回的 JSON 包含 CPU、内存、网络、磁盘 IO 的详细数据。适合自建监控脚本:import dockerclient = docker.from_env()for container in client.containers.list(): stats = container.stats(stream=False) cpu_delta = stats['cpu_stats']['cpu_usage']['total_usage'] - \ stats['precpu_stats']['cpu_usage']['total_usage'] system_delta = stats['cpu_stats']['system_cpu_usage'] - \ stats['precpu_stats']['system_cpu_usage'] cpu_percent = (cpu_delta / system_delta) * 100 if system_delta > 0 else 0 mem_usage = stats['memory_stats']['usage'] / 1024 / 1024 mem_limit = stats['memory_stats']['limit'] / 1024 / 1024 print(f"{container.name}: CPU={cpu_percent:.1f}%, MEM={mem_usage:.0f}/{mem_limit:.0f}MB")cAdvisor + Prometheus:生产级监控docker stats 和 API 只适合临时查看。需要历史趋势和告警时,用 cAdvisor 采集 + Prometheus 存储 + Grafana 展示:services: cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro ports: - "8080:8080" prometheus: image: prom/prometheus:latest volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090"cAdvisor 自动采集所有容器的 CPU、内存、网络、磁盘指标,Prometheus 每 15 秒拉取一次。Grafana 里导入 Dashboard ID 11600 即可看到完整的容器资源监控面板。OOM 处理:内存爆了怎么办容器因内存不足被杀时,docker inspect 会记录原因:docker inspect <container-id> --format '{{.State.OOMKilled}}'# true 说明是 OOM 杀的处理步骤:查看内存限制是否太小——docker stats 看 MEM USAGE 是否经常顶到 LIMIT分析内存泄漏——进入容器 docker exec -it <id> sh,用 top 或 ps aux --sort=-%mem 找内存大户临时解决:加大内存限制 docker update --memory=8g <container-id>根本解决:修复应用的内存泄漏监控方案选择| 场景 | 方案 | 成本 ||------|------|------|| 临时排查 | docker stats | 零成本 || 自建脚本监控 | Docker API + Python | 低 || 小团队日常监控 | cAdvisor + Prometheus + Grafana | 中 || 生产环境 | 完整监控栈 + Alertmanager | 高 |起步建议:先用 docker stats 日常看一眼,发现问题了再搭 Prometheus 长期监控。
服务端阅读 06月5日 22:29

Docker Desktop 怎么用?安装配置和常见问题

Docker Desktop 是在 Mac 和 Windows 上用 Docker 最省事的方式——装一个应用就拥有完整的 Docker 环境,不需要折腾虚拟机或 Linux 双系统。但它不只是个安装包,里面的 WSL2 集成、Kubernetes 支持、资源管理有些门道值得了解。Docker Desktop 里装了什么Docker Desktop 不是 Docker Engine 的 GUI 包装——它是一个完整的开发环境:| 组件 | 作用 ||------|------|| Docker Engine | 容器运行时 || Docker CLI | 命令行工具 || Docker Compose | 多容器编排 || Docker BuildKit | 高性能构建引擎 || Kubernetes (可选) | 单节点 K8s 集群 || Docker Scout | 镜像漏洞扫描 || Docker Extensions | 扩展市场 |Mac 上 Docker Desktop 通过一个轻量级 Linux 虚拟机运行 Docker Engine(因为 Docker 本质上需要 Linux 内核)。Windows 上通过 WSL2 运行。安装后的关键配置资源分配默认配置经常不够用——4GB 内存跑不了几个容器:Settings → Resources CPUs: 4-6(建议宿主机的 50%) Memory: 8-12GB(建议宿主机的 50%) Swap: 2GB Disk image size: 60GB+调完后 Docker Desktop 会重启虚拟机。分配太多会导致宿主机卡顿,太少容器 OOM。建议 CPU 和内存各分一半给 Docker。WSL2 集成(Windows)Docker Desktop 在 Windows 上跑在 WSL2 里。开启后可以在 WSL2 的 Linux 发行版中直接使用 docker 命令:Settings → Resources → WSL Integration ✅ Enable integration with my default WSL distro ✅ Ubuntu (或其他发行版)这样在 Windows Terminal 的 Ubuntu 标签页里直接 docker run,不需要额外安装 Docker。文件共享性能Mac 上 Docker 挂载目录特别慢——因为文件要在 macOS 和 Linux 虚拟机之间同步。VirtioFS 是 Docker Desktop 4.x 以后的新方案,比之前的 gRPC FUSE 快很多:Settings → General ✅ Choose file sharing implementation for your containers: VirtioFS另外 node_modules 这种大量小文件的目录不要挂载——用匿名 volume 代替:services: app: volumes: - .:/app # 代码目录挂载 - /app/node_modules # node_modules 用容器内的,不走文件共享日常使用的核心功能镜像管理Docker Desktop → Images可以搜索、拉取、删除镜像,查看镜像层信息。比命令行更直观——特别是看哪个镜像占了多少磁盘。容器管理Docker Desktop → Containers启动、停止、删除容器,查看日志,进入容器终端。小技巧:点击容器的端口号可以直接在浏览器打开。Volume 管理Docker Desktop → Volumes查看所有 Docker Volume 占用的磁盘空间。容器删了 Volume 不会自动删——时间久了会积累大量废弃数据。定期清理:docker volume prune # 删除所有未被容器引用的 volume构建缓存清理docker builder prune # 清理构建缓存docker system prune -a # 一键清理所有未使用的资源(镜像、容器、网络、缓存)Docker Desktop 的 Troubleshoot 页面也有 "Clean / Purge data" 按钮——重置整个 Docker 环境。Kubernetes 支持Docker Desktop 内置了单节点 Kubernetes 集群,一键开启:Settings → Kubernetes ✅ Enable Kubernetes开启后 kubectl 直接可用:kubectl get nodes# NAME STATUS ROLES AGE VERSION# docker-desktop Ready control-plane 1m v1.28.0适合本地开发测试 K8s manifest,不需要装 minikube 或 kind。注意:开启 K8s 会额外占用 2-3GB 内存。不用时建议关掉。Docker ExtensionsDocker Desktop 支持扩展,常用的几个:| 扩展 | 功能 ||------|------|| Disk Usage | 可视化磁盘占用分析 || Trivy | 镜像安全扫描 || DDEV | 本地开发环境管理 || Tilt | 实时开发工作流 |安装方式:Extensions → Browse → InstallDocker Desktop 的替代方案Docker Desktop 对个人免费,但大企业(250+ 员工或 1000 万+ 美元年收入)需要付费订阅。如果不想付费:| 替代方案 | 平台 | 说明 ||---------|------|------|| OrbStack | Mac | 比 Docker Desktop 快 3-5 倍启动,内存占用少 || Rancher Desktop | Mac/Win/Linux | 开源免费,支持 containerd 和 dockerd || Colima | Mac | 命令行工具,轻量,基于 Lima || Podman Desktop | Mac/Win/Linux | Red Hat 出品,无守护进程 |推荐:Mac 用户优先试 OrbStack——启动快、内存省、文件共享性能好,个人免费。常见问题Docker Desktop 启动慢Mac 上首次启动需要 30-60 秒。加快方法:不要关 Docker Desktop,用 docker stop 停容器即可。Docker Desktop 自身在后台几乎不占 CPU。磁盘空间持续增长Docker 的虚拟磁盘(Docker.raw / data.vhdx)只会增大不会自动缩小。即使删了镜像,虚拟磁盘文件也不会缩小。解决:# Mac:压缩虚拟磁盘docker system prune -a# 然后重启 Docker Desktop → Troubleshoot → Clean / Purge data容器网络访问宿主机服务容器里访问宿主机(比如宿主机上的数据库):# 从容器内访问宿主机curl http://host.docker.internal:5432host.docker.internal 是 Docker Desktop 提供的特殊 DNS 名,自动解析为宿主机 IP。
服务端阅读 06月5日 22:29

Docker 反向代理该用 Nginx 还是 Traefik?部署和自动发现对比

Docker 部署反向代理的核心问题是:容器 IP 每次重启都会变,手动配 upstream 不现实。Nginx 需要手动更新配置,Traefik 能自动发现容器。选哪个取决于你的场景。Nginx:手动配置但性能最强基本反向代理# docker-compose.ymlservices: nginx: image: nginx:1.25 ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro networks: - frontend app1: image: myapp:latest networks: - frontend app2: image: another-app:latest networks: - frontendnetworks: frontend:# nginx/conf.d/default.confupstream app1 { server app1:3000;}upstream app2 { server app2:8080;}server { listen 80; server_name app1.example.com; location / { proxy_pass http://app1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}server { listen 80; server_name app2.example.com; location / { proxy_pass http://app2; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}Docker Compose 的服务名(app1、app2)会自动解析为容器 IP——不需要硬编码 IP。但容器重启后如果 upstream 数量变了(比如扩容),Nginx 不会自动感知。SSL 配置(Let's Encrypt)手动获取证书:certbot certonly --standalone -d example.comserver { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; location / { proxy_pass http://app1; }}问题:证书 90 天过期,需要 cron 定时续期 + docker exec nginx nginx -s reload。Nginx 什么时候合适流量非常大,需要极致性能配置不频繁变动团队熟悉 NginxTraefik:自动发现的反向代理Traefik 的核心卖点:容器启动/停止时自动更新路由,不需要改配置文件或重启代理。基本配置services: traefik: image: traefik:v2.10 command: - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" ports: - "80:80" - "443:443" - "8080:8080" # Dashboard volumes: - /var/run/docker.sock:/var/run/docker.sock:ro app1: image: myapp:latest labels: - "traefik.enable=true" - "traefik.http.routers.app1.rule=Host(`app1.example.com`)" - "traefik.http.routers.app1.entrypoints=web" - "traefik.http.services.app1.loadbalancer.server.port=3000"关键点:--providers.docker=true 启用 Docker Provider,自动监听容器事件exposedbydefault=false 只代理有 traefik.enable=true 标签的容器容器的路由规则通过 labels 配置,不需要单独的配置文件自动 SSL(Let's Encrypt)services: traefik: command: - "--certificatesresolvers.le.acme.email=you@example.com" - "--certificatesresolvers.le.acme.storage=/acme.json" - "--certificatesresolvers.le.acme.tlschallenge=true" volumes: - ./acme.json:/acme.json app1: labels: - "traefik.http.routers.app1.tls=true" - "traefik.http.routers.app1.tls.certresolver=le"Traefik 自动申请和续期证书——不需要 cron,不需要手动 reload。这是 Traefik 相比 Nginx 最大的优势。负载均衡同一个服务多个实例,Traefik 自动负载均衡: app1: deploy: replicas: 3 labels: - "traefik.http.services.app1.loadbalancer.server.port=3000"3 个副本自动分摊流量,扩容缩容 Traefik 实时感知。Caddy:最简配置Caddy 的卖点是一个 Caddyfile 搞定反向代理 + 自动 HTTPS:services: caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/datavolumes: caddy_data:# Caddyfileapp1.example.com { reverse_proxy app1:3000}app2.example.com { reverse_proxy app2:8080}就这样——Caddy 自动申请 Let's Encrypt 证书、自动续期、自动 HTTP→HTTPS 重定向。比 Nginx 少 90% 的配置。局限:不如 Nginx 灵活(复杂的 rewrite/条件判断不好写),不如 Traefik 自动化(不自动发现容器),适合简单的反向代理场景。选择决策| 场景 | 推荐 | 原因 ||------|------|------|| 简单反向代理 + HTTPS | Caddy | 配置最少,自动 HTTPS || 容器频繁变动 | Traefik | 自动发现、自动配置 || 流量极大 | Nginx | 性能最强 || 需要 Let's Encrypt | Traefik 或 Caddy | Nginx 需要手动续期 || 已有 Nginx 运维经验 | Nginx | 熟悉的工具不容易出问题 || Docker Compose 项目 | Traefik | labels 配置和 compose 一体 || Kubernetes 环境 | Nginx Ingress | K8s 生态标配 |起步建议:Docker Compose 项目用 Traefik,省心省力。需要极致性能或复杂路由时切换 Nginx。
服务端阅读 06月5日 22:29

Docker 容器怎么监控?Prometheus + Grafana 完整方案

容器出问题了,docker stats 只能看到 CPU 和内存——磁盘 IO、网络、进程状态全不知道。完整的监控系统需要指标采集、存储、可视化、告警四层。Docker 监控的四层架构容器 → 采集器 → 存储后端 → 可视化 cAdvisor Prometheus Grafana Node Loki Exporter采集层:从容器和宿主机收集指标数据存储层:时序数据库存指标,日志库存日志可视化层:Dashboard 展示趋势、图表告警层:超过阈值自动通知指标采集:cAdvisor + Node ExportercAdvisor——容器指标Google 出品,自动发现所有容器,采集 CPU、内存、网络、磁盘 IO:services: cadvisor: image: gcr.io/cadvisor/cadvisor:latest ports: - "8080:8080" volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:rocAdvisor 自带一个简单的 Web UI(http://localhost:8080),可以看每个容器的实时指标。Node Exporter——宿主机指标cAdvisor 只管容器,宿主机本身的 CPU、内存、磁盘、网络由 Node Exporter 采集:services: node-exporter: image: prom/node-exporter:latest ports: - "9100:9100" volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--path.rootfs=/rootfs'Prometheus——指标存储和查询Prometheus 定时从 cAdvisor 和 Node Exporter 拉取指标,存到自己的时序数据库:services: prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus_data:/prometheusvolumes: prometheus_data:# prometheus/prometheus.ymlglobal: scrape_interval: 15sscrape_configs: - job_name: 'cadvisor' static_configs: - targets: ['cadvisor:8080'] - job_name: 'node' static_configs: - targets: ['node-exporter:9100'] - job_name: 'app' static_configs: - targets: ['app:8080']scrape_interval: 15s 表示每 15 秒采集一次。容器数量多时可以调大到 30s-60s 减少负载。常用 PromQL 查询# 所有容器的 CPU 使用率rate(container_cpu_usage_seconds_total{name!=""}[5m]) * 100# 容器内存使用量(MB)container_memory_usage_bytes{name!=""} / 1024 / 1024# 宿主机磁盘使用率(1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100# 容器网络流入速率rate(container_network_receive_bytes_total[5m])Grafana——可视化 DashboardPrometheus 的 UI 只适合临时查询。正式的监控面板用 Grafana:services: grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana_data:/var/lib/grafana配置步骤:打开 http://localhost:3000(默认 admin/admin)添加数据源 → Prometheus → URL 填 http://prometheus:9090导入 Dashboard:推荐 ID 11600(Docker 监控)和 1860(Node Exporter 全量)导入方式:Dashboard → Import → 输入 ID → Load → 选择 Prometheus 数据源告警:AlertmanagerPrometheus 本身只负责判断规则,告警通知由 Alertmanager 发送:# prometheus/alert_rules.ymlgroups: - name: docker_alerts rules: - alert: ContainerCpuHigh expr: rate(container_cpu_usage_seconds_total{name!=""}[5m]) * 100 > 80 for: 5m labels: severity: warning annotations: summary: "容器 {{ $labels.name }} CPU 超过 80%" - alert: ContainerMemoryHigh expr: container_memory_usage_bytes / container_spec_memory_limit_bytes * 100 > 90 for: 5m labels: severity: critical - alert: DiskSpaceLow expr: (1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 > 85 for: 10mfor: 5m 表示持续 5 分钟才告警——避免短暂波动误报。完整的 docker-compose 监控栈version: "3.8"services: prometheus: image: prom/prometheus:latest volumes: - ./prometheus:/etc/prometheus - prometheus_data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.retention.time=30d' grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" volumes: - grafana_data:/var/lib/grafana cadvisor: image: gcr.io/cadvisor/cadvisor:latest volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro node-exporter: image: prom/node-exporter:latest volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' alertmanager: image: prom/alertmanager:latest volumes: - ./alertmanager:/etc/alertmanagervolumes: prometheus_data: grafana_data:这套组合约需 2-3GB 内存,覆盖了指标采集、存储、可视化、告警四层。监控方案选择| 规模 | 推荐方案 | 内存需求 ||------|---------|---------|| 单机开发 | docker stats + cAdvisor Web | < 500MB || 小团队(<20 容器) | Prometheus + Grafana + cAdvisor | 1-2GB || 中等规模 | 完整监控栈 + Alertmanager | 2-4GB || 大规模/生产 | Kubernetes + Prometheus Operator | 按需扩展 |起步建议:先跑 cAdvisor + Prometheus + Grafana 三件套,够用了再加告警。
服务端阅读 06月5日 22:29

Docker 容器日志怎么聚合?ELK、Loki 和 EFK 怎么选

容器一多,日志分散在各处——docker logs 只能看单个容器的输出,排查问题要一个一个翻。日志聚合把所有容器的日志集中到一个地方,搜索、过滤、告警一站搞定。Docker 日志驱动:日志的入口Docker 支持多种日志驱动,决定容器日志的去向:# 查看当前日志驱动docker info --format '{{.LoggingDriver}}'# 默认是 json-file# docker-compose.yml 全局配置services: app: logging: driver: json-file options: max-size: "10m" # 单个日志文件最大 10MB max-file: "3" # 最多保留 3 个文件json-file 默认不限制大小——跑久了磁盘会被日志撑满。务必配 max-size 和 max-file。其他日志驱动| 驱动 | 去向 | 适用场景 ||------|------|---------|| json-file | 本地文件 | 默认,简单场景 || local | 本地文件(压缩) | 省磁盘 || journald | systemd journal | CentOS/Ubuntu 系统日志 || fluentd | Fluentd | 接入日志聚合栈 || syslog | syslog 服务 | 传统运维 |生产环境推荐用 fluentd 或 local 驱动——前者直接接入日志聚合,后者比 json-file 省磁盘。ELK Stack:经典方案Elasticsearch + Logstash + Kibana,功能最全但最重:# docker-compose.ymlservices: elasticsearch: image: elasticsearch:8.12.0 environment: - discovery.type=single-node - xpack.security.enabled=false ports: - "9200:9200" volumes: - es_data:/usr/share/elasticsearch/data logstash: image: logstash:8.12.0 volumes: - ./logstash/pipeline:/usr/share/logstash/pipeline ports: - "5044:5044" kibana: image: kibana:8.12.0 ports: - "5601:5601" environment: - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 app: image: myapp:latest logging: driver: fluentd options: fluentd-address: localhost:24224 tag: myappvolumes: es_data:问题:ELK 最少需要 4GB 内存才能跑起来。小团队或开发环境用太重了。EFK Stack:轻量替代用 Fluentd 替代 Logstash,更省资源:services: fluentd: image: fluent/fluentd:v1.16 volumes: - ./fluentd/conf:/fluentd/etc ports: - "24224:24224" environment: - FLUENTD_CONF=fluent.conf# fluentd/conf/fluent.conf<source> @type forward port 24224</source><match **> @type elasticsearch host elasticsearch port 9200 logstash_format true logstash_prefix fluentd <buffer> @type file path /var/log/fluentd/buffer flush_interval 5s </buffer></match>容器配置 logging.driver: fluentd 后,所有 stdout/stderr 输出自动发到 Fluentd,Fluentd 转存到 Elasticsearch。Grafana Loki:最轻量的选择Loki 只索引标签不索引日志内容,存储成本比 Elasticsearch 低 10 倍以上:# docker-compose.ymlservices: loki: image: grafana/loki:2.9.0 ports: - "3100:3100" command: -config.file=/etc/loki/local-config.yaml promtail: image: grafana/promtail:2.9.0 volumes: - /var/log:/var/log - /var/lib/docker/containers:/var/lib/docker/containers:ro - ./promtail/config.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=adminPromtail 从 Docker 容器日志目录读取日志,推送到 Loki。Grafana 查询和可视化。Loki 的查询语法(LogQL)比 Kibana 的 KQL 简单:{container_name="myapp"} |= "error" | json | line_format "{{.message}}"意思是:从 myapp 容器日志中过滤包含 "error" 的行,解析 JSON,只显示 message 字段。日志结构化:让聚合更有效非结构化日志在聚合平台里只能做全文搜索。结构化日志可以按字段过滤、聚合统计:# Python 结构化日志(JSON 格式)import loggingimport jsonclass JSONFormatter(logging.Formatter): def format(self, record): return json.dumps({ "timestamp": self.formatTime(record), "level": record.levelname, "message": record.getMessage(), "service": "user-service", "trace_id": getattr(record, "trace_id", None), })// Node.js 用 pino 直接输出 JSONconst logger = require('pino')({ level: 'info', formatters: { level: (label) => ({ level: label }) },})logger.info({ userId: 123, action: 'login' }, 'User logged in')选择决策| 场景 | 推荐方案 | 理由 ||------|---------|------|| 开发/测试 | docker logs + json-file | 够用 || 小团队(<10 服务) | Grafana Loki + Promtail | 轻量,1GB 内存够 || 中大型团队 | EFK Stack | 功能全、社区大 || 需要全文搜索 | ELK Stack | Elasticsearch 全文检索最强 || 已有 Grafana | 直接加 Loki | 不用额外装 Kibana || 日志量巨大 | Loki(只索引标签) | 存储成本最低 |起步建议:先用 Loki + Grafana,轻量够用。等日志量大到需要全文搜索时再迁移到 ELK。
服务端阅读 06月5日 22:29

Docker 容器配置怎么管理?环境变量、挂载和配置中心怎么选

Docker 容器的配置管理不是把配置写死在镜像里——那样每次改配置都得重新构建。正确做法是配置与镜像分离,容器启动时注入配置,运行时能动态更新。这篇讲清楚 Docker 环境下四种配置管理方式的适用场景和实现方法。环境变量:最简单的配置注入适合少量、扁平的配置项(数据库地址、端口、开关):# docker-compose.ymlservices: app: image: myapp:latest environment: - DB_HOST=postgres - DB_PORT=5432 - LOG_LEVEL=info - FEATURE_FLAG=true或者用 .env 文件:# .envDB_HOST=postgresDB_PORT=5432LOG_LEVEL=infoservices: app: env_file: .env优势:简单、Docker 原生支持、docker-compose 直接读取局限:只有字符串值、不能表示嵌套结构、修改需要重启容器敏感信息别放环境变量环境变量会被 docker inspect 暴露,也会出现在进程列表里。密码、密钥等用 Docker Secret 或文件挂载:# Docker Swarm Secretecho "my_password" | docker secret create db_password -services: app: secrets: - db_passwordsecrets: db_password: external: true配置文件挂载:复杂配置的首选当配置是结构化的(YAML、JSON、TOML),用 Volume 挂载比环境变量更合适:services: app: image: myapp:latest volumes: - ./config/app.yml:/app/config/app.yml:ro - ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro:ro 表示只读挂载——容器不能修改配置文件,只能读取。防止容器内进程意外修改配置。只挂载需要的文件,别挂载整个目录# 好:只挂载需要的文件volumes: - ./config/app.yml:/app/config/app.yml:ro# 差:挂载整个目录,可能暴露无关文件volumes: - ./config:/app/config:ro配置热更新:不重启容器更新配置挂载的文件修改后,容器内立即可见——但应用是否自动重新加载取决于应用本身:Nginx:docker exec nginx nginx -s reloadSpring Boot:配合 Spring Cloud Config 自动刷新Node.js:用 chokidar 监听文件变化如果应用不支持热加载,可以配合 inotifywait 检测文件变化后发送信号:#!/bin/bashinotifywait -m -e modify /app/config/app.yml | while read event; do kill -SIGHUP 1 # 发送 HUP 信号给主进程done配置中心:分布式系统的统一配置多服务、多实例的场景,配置文件挂载管理成本太高——改一个配置要同步到所有机器。配置中心解决的就是这个问题。Consul + Consul-Template# docker-compose.ymlservices: consul: image: consul:1.15 ports: - "8500:8500" command: agent -dev -client=0.0.0.0 app: image: myapp:latest volumes: - ./templates:/templates command: > consul-template -consul-addr=consul:8500 -template="/templates/app.ctmpl:/app/config/app.yml:docker restart app"consul-template 监听 Consul KV 变化,自动重新生成配置文件并触发容器重启。Etcd + Confd和 Consul-Template 类似的模式,Etcd 做存储,Confd 做模板渲染:# 写入配置etcdctl set /myapp/db_host "postgres.prod"# confd 读取 etcd 并渲染模板confd -onetime -backend etcd -node http://etcd:2379Spring Cloud Config ServerJava 生态的标准方案:services: config-server: image: springcloud/configserver ports: - "8888:8888" environment: - SPRING_CLOUD_CONFIG_SERVER_GIT_URI=https://github.com/org/config-repo app: image: my-spring-app environment: - SPRING_CLOUD_CONFIG_URI=http://config-server:8888配置存在 Git 仓库里,有版本历史。应用启动时从 Config Server 拉取配置,配合 /actuator/refresh 端点实现热更新。Kubernetes ConfigMap 和 Secret如果跑在 K8s 上,环境变量和文件挂载都由 ConfigMap/Secret 管理:# ConfigMapapiVersion: v1kind: ConfigMapmetadata: name: app-configdata: DB_HOST: postgres app.yml: | server: port: 8080 logging: level: info---# Pod 使用 ConfigMapapiVersion: v1kind: Podspec: containers: - name: app envFrom: - configMapRef: name: app-config volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: app-configSecret 和 ConfigMap 用法一样,但值是 base64 编码的,且访问可以加 RBAC 控制:kubectl create secret generic db-credentials \ --from-literal=username=admin \ --from-literal=password=s3cretConfigMap/Secret 更新后 Pod 不会自动重启——需要手动 kubectl rollout restart 或用 Reloader 之类的工具自动触发。选择决策| 场景 | 推荐方案 | 原因 ||------|---------|------|| 单容器、少量配置 | 环境变量 + .env | 最简单 || 单容器、复杂配置 | Volume 挂载配置文件 | 支持结构化配置 || 多容器、同一台主机 | docker-compose + 挂载 | compose 管理方便 || 多主机、多服务 | Consul/Etcd 配置中心 | 统一管理、动态更新 || Kubernetes 环境 | ConfigMap + Secret | K8s 原生方案 || 敏感信息 | Docker Secret / K8s Secret | 不暴露在环境变量里 |原则:配置与代码分离,敏感信息加密,变更可追溯。能用简单的就不用复杂的——别为了用 Consul 而用 Consul。
服务端阅读 06月5日 22:02

TypeORM 查询该用 find 还是 QueryBuilder?三种方式适用场景对比

TypeORM 查询数据有三条路:find 系列方法、QueryBuilder、原生 SQL。很多人上来就用 QueryBuilder,其实 80% 的查询 find 就够了——更简洁、类型安全、不容易出错。这篇把三种方式的适用边界和常见坑讲清楚。find:日常查询的首选基础查询// 查所有const users = await userRepository.find();// 按 ID 查一条const user = await userRepository.findOne({ where: { id: 1 } });// 按条件查const activeUsers = await userRepository.find({ where: { active: true },});条件组合import { And, Or, LessThan, MoreThan, Like, Between, In, IsNull } from 'typeorm';// AND 条件——多个字段自动 ANDconst users = await userRepository.find({ where: { active: true, age: MoreThan(18), },});// OR 条件——数组内自动 ORconst users = await userRepository.find({ where: [ { role: 'admin' }, { age: MoreThan(30) }, ],});// 常用 FindOperatorconst users = await userRepository.find({ where: { name: Like('%john%'), // 模糊搜索 id: In([1, 2, 3]), // IN 查询 createdAt: Between(start, end), // 范围查询 deletedAt: IsNull(), // IS NULL },});find 的 OR 有个坑:数组里每个元素是一个独立的 OR 分支,不是字段级别的 OR。如果你需要 role = 'admin' AND (age > 30 OR name LIKE '%john%') 这种混合逻辑,find 写不出来——得上 QueryBuilder。选择字段const users = await userRepository.find({ select: { id: true, name: true, email: true, // 不选 password 等敏感字段 }, where: { active: true },});排序和分页const users = await userRepository.find({ where: { active: true }, order: { createdAt: 'DESC' }, skip: 0, take: 20,});// 带总数的分页——一次查询拿到数据 + 总数const [users, total] = await userRepository.findAndCount({ where: { active: true }, order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize,});加载关联// 简单关联const users = await userRepository.find({ relations: { posts: true, profile: true },});// 嵌套关联const users = await userRepository.find({ relations: { posts: { comments: true } },});find 的 N+1 陷阱find 加载关联时会发多条 SQL——每个关联单独查一次。如果查 100 个用户且关联了 posts,可能产生几十条查询:// 可能触发 N+1const users = await userRepository.find({ relations: { posts: true },});// SQL 1: SELECT * FROM user// SQL 2: SELECT * FROM post WHERE userId IN (1, 2, 3, ... 100)TypeORM 0.3+ 已经优化了这个问题——会用 IN 批量查而不是逐个查。但如果关联层级很深(用户 → 文章 → 评论 → 作者),查询数仍然会膨胀。深层关联建议用 QueryBuilder 的 leftJoinAndSelect 一次性 JOIN。QueryBuilder:find 搞不定的场景关联查询带条件过滤find 的 relations 只能全量加载,不能过滤。QueryBuilder 可以:// 只查有已发布文章的用户const users = await userRepository .createQueryBuilder('user') .innerJoinAndSelect( 'user.posts', 'post', 'post.isPublished = :published', { published: true }, ) .getMany();第三个参数是 JOIN 条件——find 做不到这个。条件组合:AND + OR + 括号const users = await userRepository .createQueryBuilder('user') .where('user.active = :active', { active: true }) .andWhere( new Brackets((qb) => { qb.where('user.role = :admin', { admin: 'admin' }).orWhere( 'user.createdAt > :date', { date: '2024-01-01' }, ); }), ) .getMany();// WHERE active = true AND (role = 'admin' OR createdAt > '2024-01-01')Brackets 生成括号——保证 OR 的优先级正确。没有它,AND 和 OR 的优先级会让你拿到错误的结果。聚合查询const stats = await userRepository .createQueryBuilder('user') .select('user.role', 'role') .addSelect('COUNT(*)', 'count') .addSelect('AVG(user.age)', 'avgAge') .groupBy('user.role') .getRawMany();// 返回: [{ role: 'admin', count: '5', avgAge: '32.5' }, ...]聚合查询必须用 getRawMany()——返回原始数据库行,字段类型都是字符串。getMany() 返回实体对象,但聚合结果不是实体,强用会出错。子查询const users = await userRepository .createQueryBuilder('user') .where((qb) => { const subQuery = qb .subQuery() .select('post.authorId') .from(Post, 'post') .groupBy('post.authorId') .having('COUNT(post.id) > :count') .getQuery(); return `user.id IN ${subQuery}`; }) .setParameter('count', 5) .getMany();QueryBuilder 的类型安全坑find 是完全类型安全的——where 里的字段名写错了 TypeScript 直接报错。QueryBuilder 的 SQL 片段是字符串,写错了只有运行时才知道:// 字段名拼错了,TypeScript 不会报错,运行时才炸.where('user.actve = :active', { active: true })// ↑ typo: active vs actve建议:QueryBuilder 的 SQL 片段尽量短,把条件值用参数传递(:param),减少字符串拼写出错的可能。原生 SQL:最后的手段QueryBuilder 也搞不定时,直接写 SQL:// 查询const result = await dataSource.query( 'SELECT * FROM user WHERE created_at > $1', [startDate],);// 事务中执行const result = await queryRunner.query( 'UPDATE user SET last_login = NOW() WHERE id = $1', [userId],);什么时候用原生 SQL:数据库特有的函数或语法(如 PostgreSQL 的 JSONB 操作、UPSERT)复杂的窗口函数(ROW_NUMBER() OVER (...))需要极致优化的性能关键查询迁移老项目的 SQL 不想重写注意:$1 是 PostgreSQL 占位符,MySQL 用 ?。原生 SQL 没有方言抽象——换数据库要手动改。性能对比同样的查询,三种方式的性能差异:// 1. find —— 最慢(多条 SQL + 实体转换开销)const users = await userRepository.find({ where: { active: true }, relations: { posts: true } });// 2. QueryBuilder —— 中等(一条 JOIN SQL,但仍有实体转换)const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.active = :active', { active: true }) .getMany();// 3. 原生 SQL —— 最快(一条 SQL,跳过实体转换)const rows = await dataSource.query( 'SELECT u.*, p.id as post_id, p.title FROM user u LEFT JOIN post p ON p.userId = u.id WHERE u.active = $1', [true],);差距不大时(毫秒级),优先用 find 或 QueryBuilder——可维护性和类型安全比那几毫秒更有价值。只有查询确实是瓶颈时才用原生 SQL 优化。选择决策| 场景 | 用什么 | 原因 ||------|--------|------|| 单表简单查询 | find | 简洁、类型安全 || 单表条件组合 | find + FindOperator | In、Like、Between 够用 || 多表关联 + 过滤 | QueryBuilder | find 的 relations 不能加条件 || 聚合/分组 | QueryBuilder | find 不支持 GROUP BY || 子查询 | QueryBuilder | find 不支持 || 复杂 OR + AND 混合 | QueryBuilder | find 的 OR 只支持数组级 || 数据库特有语法 | 原生 SQL | TypeORM 不覆盖所有特性 || 性能关键查询 | 原生 SQL | 跳过实体转换 |经验法则:先试 find,搞不定再上 QueryBuilder,最后才用原生 SQL。层级越低灵活性越高,但可维护性和类型安全越差。
服务端阅读 06月5日 22:01

Next.js 状态管理该用哪个方案?Server Component 到 Zustand 怎么选

Next.js App Router 引入了 Server Components 后,状态管理的思路和纯 SPA 完全不同。很多数据根本不需要客户端状态——服务端直接获取、渲染、返回 HTML,客户端零 JS 开销。只有真正需要交互的数据才用客户端状态管理。先问:这个状态真的需要客户端管理吗?| 场景 | 方案 | 需要 JS 吗 ||------|------|-----------|| 页面初始数据 | Server Component 直接 fetch | 不需要 || 用户个人信息 | Server Component + cookies | 不需要 || 表单输入值 | useState | 需要 || 弹窗开关 | useState | 需要 || 跨页面共享的购物车 | URL 参数 / Cookie / 外部状态库 | 看场景 |原则:能用 Server Component 解决的不用客户端状态。每多一个客户端状态,就多一份 JS 发送到浏览器。第一选择:URL 状态搜索关键词、分页页码、筛选条件——这些状态天然属于 URL:// app/products/page.tsxexport default async function ProductsPage({ searchParams,}: { searchParams: { q?: string; page?: string }}) { const products = await searchProducts({ query: searchParams.q, page: Number(searchParams.page) || 1, }) return <ProductList products={products} />}URL 状态的优势:可分享:用户复制链接给别人,状态不丢浏览器后退/前进天然支持SEO 友好:搜索引擎能抓到分页和筛选状态不需要 JS:Server Component 直接读取 searchParamsClient Component 中更新 URL 状态'use client'import { useSearchParams, useRouter } from 'next/navigation'function SearchBar() { const searchParams = useSearchParams() const router = useRouter() function handleSearch(query: string) { const params = new URLSearchParams(searchParams) params.set('q', query) params.delete('page') // 搜索时重置分页 router.push(`/products?${params.toString()}`) } return <input defaultValue={searchParams.get('q') || ''} onChange={(e) => handleSearch(e.target.value)} />}第二选择:Server Component 传递 props父组件获取数据,通过 props 传给子组件——最简单的状态传递方式:// app/dashboard/page.tsxexport default async function DashboardPage() { const [stats, recentOrders] = await Promise.all([ fetchStats(), fetchRecentOrders(), ]) return ( <div> <StatsCard stats={stats} /> <RecentOrders orders={recentOrders} /> </div> )}Promise.all 并行获取,不需要任何状态库。子组件拿到的是渲染好的数据,不需要 loading 状态。第三选择:React Context跨组件共享数据,但不需要跨页面共享:// providers/theme-provider.tsx'use client'import { createContext, useContext, useState } from 'react'type Theme = 'light' | 'dark'const ThemeContext = createContext<{ theme: Theme toggleTheme: () => void}>({ theme: 'light', toggleTheme: () => {} })export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'), }}> {children} </ThemeContext.Provider> )}export function useTheme() { return useContext(ThemeContext)}在根 layout 中注册:// app/layout.tsximport { ThemeProvider } from '@/providers/theme-provider'export default function RootLayout({ children }) { return ( <html> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> )}注意:Context Provider 必须是 Client Component,包在 Server Component 的 layout 里使用。这会导致 Provider 下面的所有子组件在客户端渲染——所以只放必要的 Provider,不要把整个应用包在一个巨大的 Provider 树里。第四选择:轻量外部状态库当 Context 不够用时(跨页面、需要 devtools、需要中间件),用 Zustand——最小最轻的 React 状态库:npm install zustand// store/cart-store.tsimport { create } from 'zustand'interface CartItem { id: string name: string price: number quantity: number}interface CartStore { items: CartItem[] addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void clearCart: () => void total: () => number}export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find(i => i.id === item.id) if (existing) { return { items: state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ), } } return { items: [...state.items, { ...item, quantity: 1 }] } }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),}))// components/cart-button.tsx'use client'import { useCartStore } from '@/store/cart-store'export function CartButton() { const items = useCartStore((s) => s.items) const total = useCartStore((s) => s.total) return ( <button>购物车 ({items.length}) ¥{total()}</button> )}Zustand 的优势:不需要 Provider 包裹,任何 Client Component 里直接 useCartStore()。Zustand 持久化到 localStorageimport { create } from 'zustand'import { persist } from 'zustand/middleware'export const useCartStore = create( persist<CartStore>( (set, get) => ({ // ... store 定义 }), { name: 'cart-storage', // localStorage key } ))刷新页面后购物车数据不丢失。但注意 SSR hydration 不匹配的问题——首次渲染用空状态,hydration 后从 localStorage 读取:// 解决 hydration 不匹配function CartButton() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted) return <button>购物车</button> const count = useCartStore((s) => s.items.length) return <button>购物车 ({count})</button>}Server Actions 替代表单状态传统方式:表单输入 → 客户端状态 → 提交 → API 调用。Server Actions 简化了这整个流程:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { const name = formData.get('name') as string const price = Number(formData.get('price')) await db.product.create({ data: { name, price } }) revalidateTag('products')}// app/products/page.tsximport { createProduct } from './actions'export function AddProductForm() { return ( <form action={createProduct}> <input name="name" required /> <input name="price" type="number" required /> <button type="submit">添加</button> </form> )}不需要 useState 管理表单值,不需要 fetch 调 API,提交后自动重新验证缓存。这是 App Router 推荐的表单处理方式。状态管理选择流程需要跨页面共享吗?├── 不需要 → 页面级用 Server Component,组件级用 useState└── 需要 → 能放 URL 吗? ├── 能 → URL 参数(searchParams) └── 不能 → 需要持久化吗? ├── 不需要 → React Context └── 需要 → Zustand + persist各方案对比| 方案 | 跨页面 | 持久化 | 需要 JS | 复杂度 ||------|--------|--------|---------|--------|| Server Component | 否 | - | 不需要 | 最低 || URL 参数 | 是 | 是 | 最少 | 低 || useState | 否 | 否 | 需要 | 低 || React Context | 是(同一 Provider 树) | 否 | 需要 | 中 || Zustand | 是 | 可选 | 需要 | 中 || Redux | 是 | 可选 | 需要 | 高 |Next.js 项目中,80% 的状态可以用 Server Component + URL 参数解决。只有真正需要在客户端管理交互状态时,才用 Context 或 Zustand。不要上来就装 Redux——那是 SPA 时代的思维。
服务端阅读 06月5日 21:52

Next.js 错误处理全指南:error.tsx、global-error 和 API 错误分层方案

Next.js 应用出错时,用户看到的不能是一个白屏或一堆报错代码。App Router 提供了分层错误处理机制——从组件级到全局级,每一层都有专门的文件处理。搞清这些层级,就能让错误发生时用户仍然能看到有意义的提示,而不是整个应用崩溃。错误处理层级组件内 try/catch → 处理可预期的业务错误 ↓ 未捕获error.tsx → 路由级 Error Boundary ↓ 仍未捕获global-error.tsx → 全局兜底(根 layout 也崩了)从内到外,每一层兜住上一层没处理的错误。error.tsx:路由级错误边界App Router 中,每个路由段可以有一个 error.tsx,捕获该路由段及其子组件的运行时错误:// app/dashboard/error.tsx'use client' // 必须是 Client Componentexport default function DashboardError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <div className="error-container"> <h2>出错了</h2> <p>{error.message}</p> <button onClick={reset}>重试</button> </div> )}关键点:error.tsx 必须是 Client Component('use client')——Error Boundary 是客户端概念reset 函数重新执行渲染该路由段的尝试——适合临时性错误(网络波动)error.digest 是 Next.js 生成的错误哈希,可以在日志中追踪error.tsx 捕获什么子组件的运行时错误子组件中 Server Component 的数据获取失败子路由的 page.tsx 抛出的错误error.tsx 不捕获什么同级 layout.tsx 的错误——layout 在 error boundary 外面loading.tsx 不会影响 error.tsx根 app/layout.tsx 的错误——需要 global-error.tsxglobal-error.tsx:最后的兜底当根 layout 崩溃时,error.tsx 也渲染不了(因为 layout 本身都挂了)。这时需要 global-error.tsx:// app/global-error.tsx'use client'export default function GlobalError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <html> <body> <h2>应用发生了严重错误</h2> <button onClick={reset}>重新加载</button> </body> </html> )}global-error.tsx 必须自带 <html> 和 <body>——因为根 layout 已经崩溃,什么都复用不了。这个文件应该尽量简单,不要引入任何可能也出错的组件。Server Component 中的错误处理Server Component 里用 try/catch 处理可预期的错误:// app/dashboard/page.tsxexport default async function DashboardPage() { let stats try { stats = await fetchDashboardStats() } catch { stats = { activeUsers: 0, revenue: 0 } // 降级数据 } return ( <div> <h1>仪表盘</h1> <StatsCard data={stats} /> </div> )}可预期的错误(API 超时、数据不存在)用 try/catch 降级处理,不让它冒泡到 error.tsx。只有真正无法恢复的错误才让 Error Boundary 接管。API Route 的错误处理基本错误响应// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET(request: Request) { try { const users = await fetchUsers() return NextResponse.json(users) } catch (error) { console.error('Failed to fetch users:', error) return NextResponse.json( { error: '获取用户列表失败' }, { status: 500 } ) }}业务异常封装// lib/errors.tsexport class AppError extends Error { constructor( public statusCode: number, public code: string, message: string, ) { super(message) }}// app/api/users/route.tsexport async function POST(request: Request) { const body = await request.json() if (!body.email) { throw new AppError(400, 'MISSING_EMAIL', '邮箱不能为空') } if (await isEmailTaken(body.email)) { throw new AppError(409, 'EMAIL_TAKEN', '邮箱已被注册') } const user = await createUser(body) return NextResponse.json(user, { status: 201 })}全局错误中间件在 middleware 或单独的 error handler 中统一处理 AppError:// 在 route handler 外层包一个 wrapperfunction withErrorHandler(handler: Function) { return async (...args: any[]) => { try { return await handler(...args) } catch (error) { if (error instanceof AppError) { return NextResponse.json( { error: error.code, message: error.message }, { status: error.statusCode } ) } console.error('Unexpected error:', error) return NextResponse.json( { error: 'INTERNAL_ERROR', message: '服务器内部错误' }, { status: 500 } ) } }}// 使用export const POST = withErrorHandler(async (request: Request) => { const body = await request.json() // ... 业务逻辑,直接 throw AppError})notFound:比 Error Boundary 更优雅的"错误""找不到"不是错误,是一种正常状态。用 notFound() 比抛异常更语义化:// app/products/[id]/page.tsximport { notFound } from 'next/navigation'export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id) if (!product) { notFound() // 触发 not-found.tsx,不触发 error.tsx } return <ProductDetail product={product} />}自定义 not-found 页面:// app/not-found.tsxexport default function NotFound() { return ( <div className="not-found"> <h2>页面不存在</h2> <a href="/">返回首页</a> </div> )}notFound() 的好处:它不会在日志中记录为错误,不会触发 Error Boundary,返回 404 状态码——语义更准确。Client Component 中的错误处理Client Component 里可以用 React 的 Error Boundary class 组件做更细粒度的捕获:// components/error-boundary.tsx'use client'import { Component } from 'react'type Props = { fallback: React.ReactNode children: React.ReactNode}export class ErrorBoundary extends Component<Props, { hasError: boolean }> { state = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return this.props.fallback } return this.props.children }}// 使用<ErrorBoundary fallback={<p>加载失败</p>}> <Chart data={data} /></ErrorBoundary>这比 error.tsx 更灵活——可以包在单个组件外面,不影响整个页面。错误监控和日志生产环境中,错误信息不能只在前端打印——需要上报到监控系统:// app/error.tsx'use client'import { useEffect } from 'react'export default function Error({ error, reset }: { error: Error; reset: () => void }) { useEffect(() => { // 上报错误到监控系统 reportError(error) }, [error]) return ( <div> <h2>出了点问题</h2> <button onClick={reset}>重试</button> </div> )}Server Component 中的错误上报:// app/dashboard/page.tsxexport default async function DashboardPage() { try { const data = await fetchData() return <Dashboard data={data} /> } catch (error) { reportError(error) // 上报 throw error // 让 error.tsx 处理 UI }}错误处理策略速查| 错误类型 | 处理方式 | 文件位置 ||---------|---------|---------|| 数据获取失败 | try/catch 降级 | Server Component 内 || 路由级运行时错误 | error.tsx | app/[segment]/error.tsx || 根 layout 崩溃 | global-error.tsx | app/global-error.tsx || 页面不存在 | notFound() + not-found.tsx | app/not-found.tsx || API 错误 | try/catch + 状态码 | route.ts 内 || 单组件错误 | ErrorBoundary 包裹 | 任意 Client Component || 未预期错误 | 上报监控 + 兜底 UI | error.tsx + 监控 |
服务端阅读 06月5日 21:51

Next.js 缓存全解析:四层缓存机制和失效策略实战

Next.js 的缓存机制是它的性能杀手锏,也是最容易让人困惑的部分。一个 fetch 请求可能被缓存 5 秒也可能永久缓存,取决于一行配置。搞不清缓存层级,就会出现"数据改了但页面没更新"的诡异问题。四层缓存,各有各的失效机制请求进来 → 请求记忆(同一渲染周期内的去重) → 数据缓存(fetch 结果持久化) → 路由缓存(Router Cache,客户端缓存) → 完整路由缓存(构建时静态化)从上到下,缓存粒度从细到粗。任何一层命中都不会继续往下查。第一层:请求记忆(Request Memoization)同一个 Server Component 渲染周期内,多次 fetch 同一个 URL 只会真正发一次请求:// 这两个 fetch 在同一个渲染周期只会发一次网络请求async function Layout() { const user = await fetch('/api/user') // 真正请求 // ...}async function Page() { const user = await fetch('/api/user') // 命中记忆,不请求 // ...}这是 React 的自动去重机制,不需要你做任何配置。只在 Server Component 的单次渲染内有效——渲染完成后记忆清除,下次请求重新获取。第二层:数据缓存(Data Cache)这是最容易出问题的缓存层。Next.js 扩展了 fetch API,默认行为是永久缓存:// 默认:永久缓存(等效 force-cache)const data = await fetch('https://api.example.com/data')// 和上面等价const data = await fetch('https://api.example.com/data', { cache: 'force-cache' })是的,你没看错——一次 fetch 的结果会被永久缓存,除非你主动让它失效。控制缓存行为// 不缓存,每次都请求const data = await fetch('https://api.example.com/data', { cache: 'no-store' })// 缓存 60 秒后重新验证const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 }})三种策略对比| 策略 | 配置 | 行为 | 适用场景 ||------|------|------|---------|| 静态 | cache: 'force-cache' | 构建时获取,永久缓存 | 不变的数据(配置、字典) || 动态 | cache: 'no-store' | 每次请求都获取 | 实时数据(用户信息、支付) || ISR | next: { revalidate: 60 } | 缓存 60 秒,到期后台重新验证 | 经常更新但不需实时的数据 |ISR 的工作原理revalidate: 60 不是到期立刻更新——而是到期后第一个请求仍然返回缓存旧数据,同时后台触发重新验证。下一个请求就能拿到新数据。这叫 stale-while-revalidate,用户永远不会等到过期数据的重新获取。按路由段配置 revalidate不想在每个 fetch 里写 revalidate?在页面级统一设置:// app/products/page.tsxexport const revalidate = 3600 // 整个页面 1 小时重新验证export default async function ProductsPage() { const products = await fetch('https://api.example.com/products') // ...}页面级 revalidate 会覆盖 fetch 级别的设置。手动失效缓存import { revalidatePath, revalidateTag } from 'next/cache'// 失效整个路由revalidatePath('/products')// 按 tag 失效(更精确)const data = await fetch('https://api.example.com/products', { next: { tags: ['products'] }})// 数据变更时清除 tagasync function updateProduct() { await db.updateProduct(...) revalidateTag('products') // 所有带 products tag 的缓存失效}revalidateTag 比 revalidatePath 更灵活——一个 tag 可以跨多个页面和组件,清除一次全部失效。第三层:路由缓存(Router Cache)这是客户端缓存,存在浏览器内存中。用户在页面间导航时,Next.js 会缓存已访问路由的 RSC Payload,返回时不需要重新请求服务端。// 在 layout.tsx 中配置export const experimental = { staleTimes: { dynamic: 30, // 动态路由缓存 30 秒 static: 300, // 静态路由缓存 5 分钟 },}路由缓存的失效用户刷新页面 → 缓存失效调用 router.refresh() → 缓存失效revalidatePath / revalidateTag → 对应缓存失效常见问题:用户在 A 页面修改了数据,切到 B 页面,再切回 A 页面——看到的还是旧数据。这就是路由缓存在作怪。解决:修改数据后调用 router.refresh()。'use client'import { useRouter } from 'next/navigation'function UpdateButton() { const router = useRouter() async function handleClick() { await updateData() router.refresh() // 刷新当前路由,清除客户端缓存 } return <button onClick={handleClick}>更新</button>}第四层:完整路由缓存(Full Route Cache)构建时,Next.js 会把静态路由的 RSC Payload 和 HTML 都生成好,部署后直接从 CDN 返回——根本不走 Node.js 服务器。// 默认行为:构建时静态生成export default async function Page() { const data = await fetch('https://api.example.com/static-data') // force-cache return <div>{data}</div>}什么时候路由变成动态的以下任一条件满足,路由就不会被静态生成:fetch 使用了 cache: 'no-store'fetch 使用了 next: { revalidate: 0 }使用了 cookies()、headers() 等动态 API页面导出了 export const dynamic = 'force-dynamic'// 强制动态渲染export const dynamic = 'force-dynamic'Server Actions 与缓存失效Server Actions 是触发缓存失效的最佳位置——数据变更和缓存失效在同一处代码:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { await db.product.create({ data: { name: formData.get('name') } }) revalidateTag('products') // 创建后立刻失效}调试缓存开发模式和生产模式的缓存行为完全不同——开发模式下几乎所有缓存都被禁用。缓存问题只在生产环境中复现。# 生产构建npm run build && npm start# 查看每个路由的渲染策略# 构建输出会显示:# ○ /products (静态)# ● /products/[id] (动态)# ƒ /api/products (动态)符号含义:○ 静态生成,● 服务端渲染,ƒ 动态路由。缓存策略速查| 数据特征 | fetch 配置 | 路由类型 ||---------|-----------|---------|| 不变数据 | cache: 'force-cache' | 静态 || 偶尔更新 | next: { revalidate: 3600, tags: ['xxx'] } | ISR || 频繁更新 | next: { revalidate: 60 } | ISR || 实时数据 | cache: 'no-store' | 动态 || 用户相关 | cookies() + cache: 'no-store' | 动态 || 问题 | 原因 | 解决 ||------|------|------|| 数据更新了页面没变 | 数据缓存未失效 | revalidateTag / revalidatePath || 返回上一页看到旧数据 | 路由缓存 | router.refresh() || 开发环境正常生产不对 | 开发模式禁用缓存 | 生产构建调试 || fetch 没有执行 | 请求记忆去重 | 正常行为,同一周期只请求一次 |
服务端阅读 06月5日 21:49

Next.js App Router vs Pages Router:核心区别和渐进式迁移指南

Next.js 13 引入的 App Router 不是 Pages Router 的替代品——它是一套全新的架构,基于 React Server Components,改变了数据获取、渲染和路由的整个思维方式。迁移不是改个目录名那么简单。核心区别一表看懂| 维度 | Pages Router | App Router ||------|-------------|------------|| 目录 | pages/ | app/ || 路由文件 | pages/about.js | app/about/page.tsx || API 路由 | pages/api/users.js | app/api/users/route.ts || 布局 | _app.js + 唯一 layout | 嵌套 layout(每个目录可以有) || 数据获取 | getServerSideProps / getStaticProps | 组件内直接 async/await || 渲染模型 | 客户端组件为主 | 服务端组件为主(RSC) || 状态 | 天然客户端 | 服务端组件无状态 |数据获取:最大的变化Pages Router 方式// pages/users.tsxexport async function getServerSideProps() { const users = await fetchUsers() return { props: { users } }}export default function UsersPage({ users }) { return <UserList users={users} />}每个页面需要单独的 getServerSideProps 或 getStaticProps 函数,数据只能在页面级获取,子组件不能自己获取。App Router 方式// app/users/page.tsxexport default async function UsersPage() { const users = await fetchUsers() // 直接在组件里 await return <UserList users={users} />}不需要特殊的 getXXXProps 函数——组件本身就是 async 函数,直接 await 获取数据。子组件也能自己获取:// components/user-stats.tsxexport default async function UserStats() { const stats = await fetchStats() // 子组件自己获取数据 return <div>活跃用户:{stats.active}</div>}这是 RSC 的核心优势:组件级数据获取,不再需要在页面顶层把所有数据攒齐再一层层传 props。布局系统:从全局到嵌套Pages Router:一个布局管所有页面// pages/_app.tsx - 全局唯一的布局export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> )}所有页面共享同一个 _app.js。想做"某些页面有侧边栏,某些没有"很别扭——要靠条件判断或把布局塞进每个页面。App Router:嵌套布局,每个目录可以有app/ layout.tsx # 根布局(必须有 html + body) page.tsx # 首页 dashboard/ layout.tsx # dashboard 专属布局(侧边栏) page.tsx # dashboard 首页 settings/ page.tsx # settings 页(继承 dashboard 布局)// app/dashboard/layout.tsxexport default function DashboardLayout({ children }) { return ( <div className="flex"> <Sidebar /> <main className="flex-1">{children}</main> </div> )}/dashboard/settings 会渲染三层布局:根 layout → dashboard layout → settings page。切换 settings 页面时,dashboard layout 不会重新渲染——侧边栏状态保留。这是 App Router 布局的核心:嵌套且持久化。Pages Router 做不到这一点——每次路由切换都重新挂载整个页面。Server Components vs Client Components这是迁移时最容易搞混的概念:Server Components(默认)// app/page.tsx - 自动是 Server Componentexport default async function Page() { const data = await fetch('https://api.example.com/data') return <div>{data.title}</div>}在服务端渲染,不会发送 JS 到客户端可以直接访问数据库、文件系统不能用 useState、useEffect、浏览器 API不能绑定事件处理函数(onClick 等)Client Components(需要显式声明)'use client' // 这行声明是 Client Componentimport { useState } from 'react'export default function Counter() { const [count, setCount] = useState(0) return <button onClick={() => setCount(count + 1)}>{count}</button>}在客户端渲染(可以 hydration)可以用所有 React Hooks可以绑定事件、使用浏览器 API不能直接 await 异步数据选择原则| 需要 | 选择 ||------|------|| 获取数据 | Server Component || 访问后端资源 | Server Component || useState/useEffect | Client Component || 事件处理(onClick) | Client Component || 浏览器 API(window) | Client Component |默认用 Server Component,只在需要交互时才用 Client Component。 这和 Pages Router 的思维完全相反——Pages Router 默认全是客户端组件。API 路由的变化Pages Router// pages/api/users.tsimport type { NextApiRequest, NextApiResponse } from 'next'export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { res.status(200).json({ users: [] }) }}App Router// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET() { const users = await fetchUsers() return NextResponse.json({ users })}export async function POST(request: Request) { const body = await request.json() const user = await createUser(body) return NextResponse.json(user, { status: 201 })}变化:从单个 handler 函数 + if (req.method) 拆成独立的方法函数。每个 HTTP 方法一个导出函数,更清晰。迁移策略不要一次性全迁移——两个 Router 可以共存:1. 渐进式迁移pages/ old-page.tsx # 还在用 Pages Routerapp/ new-page/ page.tsx # 新页面用 App RouterNext.js 同时支持两种 Router。新页面用 App Router,旧页面保持不动,逐步迁移。2. 迁移优先级API 路由:最简单,改动最小纯展示页面:不需要交互,直接改成 Server Component有交互的页面:拆成 Server + Client 组件混合3. 常见迁移坑useRouter 不一样了:Pages Router 用 next/router,App Router 用 next/navigationgetServerSideProps 变成组件内 async:不再需要序列化/反序列化 props_app.js 里的 Provider:移到 app/layout.tsx,但要包在 Client Component 里图片组件:next/image API 有变化,注意 fill 属性替代了 layout="fill"什么时候还该用 Pages Router不是所有项目都需要迁移:老项目稳定运行:没有性能问题,不需要 RSC,不必迁移团队不熟悉 RSC:App Router 的思维模式完全不同,迁移成本不只是改代码重度依赖客户端状态:如果你的页面几乎全是客户端交互,RSC 的优势体现不出来App Router 的核心价值是服务端渲染优先 + 嵌套布局。如果你的项目确实受益于这两点,值得迁移;否则 Pages Router 继续用就好——Next.js 没有废弃它的计划。