服务端面试题手册

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

服务端阅读 06月20日 11:26

TailwindCSS Typography 插件怎么用才稳?

Typography 插件适合解决什么问题?如果页面里有 Markdown、CMS 富文本、博客正文或文档内容,直接用 Tailwind 原子类逐个给 h2、p、ul、code、blockquote 写样式会很累。@tailwindcss/typography 的作用就是给这类“长文本内容”提供一套默认排版样式。它的核心类名是 prose。把 prose 加到内容容器上,容器里的标题、段落、列表、链接、代码、引用等元素都会获得更适合阅读的样式。<article class="prose"> <h2>标题</h2> <p>这里是一段来自 Markdown 或 CMS 的正文。</p> <a href="/docs">查看文档</a></article>这种写法特别适合不方便逐个控制标签的内容来源,比如 Markdown 渲染后的文章页、CMS 后台录入的富文本、文档站正文、产品介绍页里的长文案模块。用户生成内容也可以用,但前提是已经做好 XSS 过滤。Tailwind v3 怎么安装和配置?在 Tailwind v3 中,Typography 是官方插件,但需要手动安装并加入 tailwind.config.js。npm install -D @tailwindcss/typography然后在配置里引入插件:// tailwind.config.jsmodule.exports = { content: [ './src/**/*.{js,ts,jsx,tsx,mdx}', './content/**/*.{md,mdx}', ], theme: { extend: {}, }, plugins: [require('@tailwindcss/typography')],}如果文章内容在 content、posts、docs 这类目录里,记得把路径加入 content 扫描范围。否则 Tailwind 可能不会生成你在模板或 MDX 里用到的类名。Tailwind v4 怎么使用?Tailwind v4 的配置方式更偏向 CSS 入口文件。Typography 插件可以通过 @plugin 引入:@import "tailwindcss";@plugin "@tailwindcss/typography";如果项目已经升级到 v4,就不要照搬 v3 的 plugins: [require(...)] 写法。实际项目里最稳的做法是先确认当前 Tailwind 版本,再选对应配置方式。prose 类怎么控制字号和宽度?prose 默认会限制正文宽度,让长段落更好读。这个限制对文章页通常是好事,但对后台预览、全宽文档、落地页模块可能会显得太窄。<article class="prose prose-lg max-w-none"> ...</article>常用类:| 类名 | 作用 ||---|---|| prose | 启用 Typography 默认排版 || prose-sm | 更小的正文排版 || prose-base | 默认尺寸 || prose-lg | 更适合文章页的稍大字号 || prose-xl | 更醒目的长文排版 || max-w-none | 取消 Typography 默认最大宽度 |如果是博客详情页,prose prose-lg 通常就够用;如果页面外层已经控制了宽度,可以加上 max-w-none,避免被插件再限制一次。深色模式怎么处理?Typography 插件内置了深色模式反转样式,常用类是 dark:prose-invert。<article class="prose prose-slate dark:prose-invert"> ...</article>prose-invert 会让正文、标题、引用、代码等颜色更适合深色背景。如果项目本身使用 Tailwind 的 dark 类模式,外层切换 dark 后,这段内容就会自动适配。prose-slate、prose-zinc 这些颜色类有什么用?Typography 提供了一组颜色主题,常见的有 prose-slate、prose-gray、prose-zinc、prose-neutral、prose-stone。<article class="prose prose-slate"> ...</article>它们会影响正文、标题、引用、边框、代码等元素的整体色调。一般来说,prose-slate 偏清爽,适合技术文章;prose-zinc 和 prose-neutral 更中性,适合后台、文档站或产品页。颜色类不建议频繁混用。一个站点最好统一一种正文色调,否则不同文章页看起来会像拼在一起的。如何单独修改链接、代码和图片样式?Typography 插件支持元素修饰符,格式通常是 prose-元素名:工具类。<article class="prose prose-slate max-w-none prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-code:rounded prose-code:bg-slate-100 prose-code:px-1 prose-img:rounded-xl prose-img:shadow-sm"> ...</article>常用元素修饰符包括 prose-a:*、prose-headings:*、prose-h2:*、prose-p:*、prose-ul:*、prose-li:*、prose-code:*、prose-pre:*、prose-blockquote:*、prose-img:*。这比在全局 CSS 里写 .article a {} 更可控,也更符合 Tailwind 的写法。not-prose 什么时候用?如果 prose 容器里有一块内容不想被 Typography 接管,可以给那块内容加 not-prose。<article class="prose"> <p>这段会使用 Typography 样式。</p> <div class="not-prose"> <button class="rounded bg-blue-600 px-4 py-2 text-white"> 这个按钮不受 prose 影响 </button> </div></article>典型场景是文章正文里嵌入组件,比如按钮、卡片、提示框、交互式 Demo。它们本来就有自己的样式,不应该被 prose 的段落、链接、列表规则改掉。有一个细节要注意:not-prose 里面不要再嵌套新的 prose,这类嵌套在实际项目里容易出现样式不符合预期。需要新的长文区域时,最好把它放到外层结构里单独处理。如何自定义 Typography 样式?如果只是改几个元素,优先用 prose-a:*、prose-code:* 这类修饰符。需要站点级统一样式时,再考虑在主题里扩展 typography。Tailwind v3 可以在 tailwind.config.js 里这样写:module.exports = { theme: { extend: { typography: ({ theme }) => ({ DEFAULT: { css: { '--tw-prose-links': theme('colors.blue.600'), '--tw-prose-bold': theme('colors.slate.900'), h2: { scrollMarginTop: '5rem', }, code: { fontWeight: '500', }, }, }, }), }, }, plugins: [require('@tailwindcss/typography')],}Typography 内部大量使用 CSS 变量,例如 --tw-prose-body、--tw-prose-headings、--tw-prose-links、--tw-prose-code。改变量通常比硬改一堆选择器更稳。在 Markdown 或 CMS 页面里怎么落地?真实项目里,Typography 最常见的用法是包住渲染后的 HTML。export function Article({ html }: { html: string }) { return ( <article className="prose prose-slate prose-lg max-w-none dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} /> )}如果内容来自 CMS 或用户输入,重点不是 prose,而是安全处理。渲染前要做 HTML 清洗,避免把恶意脚本一起渲染出来。Typography 只负责排版,不负责内容安全。MDX 场景会更灵活一些。普通正文交给 prose,交互组件放进 not-prose,这样排版和组件样式不互相打架。常见问题怎么避免?正文太窄这是 prose 默认最大宽度导致的。页面外层已经控制宽度时,给正文加 max-w-none。<article class="prose max-w-none"> ...</article>深色模式下颜色不对检查是否加了 dark:prose-invert,以及项目的深色模式是否真的生效。只写 prose-invert 会让它一直使用暗色排版,不一定符合预期。自定义类没有生成先看 content 扫描路径。Markdown、MDX、CMS 模板、组件目录如果没被扫描,Tailwind 就可能删掉没识别到的类。对于动态拼接类名也要小心:const size = 'lg'const className = `prose-${size}`这种写法可能不会被 Tailwind 正确识别。更稳的方式是写完整类名,或在配置里 safelist。组件样式被 prose 改乱在文章里嵌按钮、卡片、表单时,用 not-prose 包起来。不要让 Typography 去管理本来就有设计规范的组件。prose 嵌套 prose尽量避免。外层已经是长文排版时,里面再放一个 prose,可能会出现间距、字体、颜色重复叠加的问题。需要分区时,用普通容器拆开更清楚。一套比较稳的默认写法技术文章或文档页可以从下面这套类名开始:<article class="prose prose-slate prose-lg max-w-none dark:prose-invert prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-code:rounded prose-code:bg-slate-100 prose-code:px-1 dark:prose-code:bg-slate-800"> ...</article>这套写法覆盖了几个关键点:正文排版、阅读尺寸、深色模式、全宽控制、链接样式和行内代码样式。后续如果要调品牌色或标题间距,再放到主题配置里统一处理。Typography 插件最适合管理不可控的长文本内容。能用 prose 解决的,不必给每个标签手写一遍样式;不该被它接管的组件,就用 not-prose 隔开。把这条边界分清,文章页和文档页的排版会省很多事。
服务端阅读 06月20日 11:24

Docker 数据卷怎么用?Volume、Bind Mount 和 tmpfs 有什么区别?

Docker 容器本身是“可丢弃”的:容器删了,容器可写层里的文件通常也就没了。数据卷解决的就是这个问题。它把数据放到容器生命周期之外,让数据库文件、上传文件、日志、配置等数据可以在容器重建后继续存在。说白了,镜像负责运行环境,容器负责进程,数据卷负责把真正重要的数据留下来。Docker 数据卷有什么用?Docker 数据卷主要有几个作用:持久化数据:容器删除、重建、升级后,数据仍然保留。容器之间共享数据:多个容器可以挂载同一个卷,例如一个写文件、另一个读取文件。方便迁移和备份:卷可以独立备份,不必把数据混在容器可写层里。降低容器可写层压力:数据库、日志这类频繁写入的数据,不适合长期放在容器层。让运行环境和数据解耦:升级镜像时只替换应用,不动业务数据。例如 MySQL、PostgreSQL、Redis、MinIO、Elasticsearch 这类服务,如果不把数据目录挂出来,容器一删,数据很可能也跟着消失。Volume、Bind Mount 和 tmpfs 有什么区别?| 类型 | 数据位置 | 是否持久化 | 适合场景 | 注意点 ||---|---|---|---|---|| volume | Docker 管理的存储目录 | 是 | 数据库、生产数据、需要备份的数据 | 路径由 Docker 管理,不建议手动改内部文件 || bind mount | 宿主机指定目录或文件 | 是 | 本地开发、挂载配置文件、源码热更新 | 容器可直接改宿主机文件,权限和安全要小心 || tmpfs | 宿主机内存 | 否 | 临时缓存、敏感临时文件 | 容器停止后数据消失,不能用于持久数据 |volume:最适合持久化业务数据docker volume create mysql-datadocker run -d --name mysql -e MYSQL_ROOT_PASSWORD=example -v mysql-data:/var/lib/mysql mysql:8这里的 mysql-data 是一个命名卷,/var/lib/mysql 是容器里的数据库数据目录。容器删掉后,mysql-data 仍然存在。bind mount:适合开发和明确指定宿主机路径docker run -d --name nginx -v /Users/me/site:/usr/share/nginx/html:ro nginx这个例子把宿主机的 /Users/me/site 挂到 Nginx 的静态目录,并用 :ro 设置为只读。bind mount 很适合本地开发,但也更危险:如果挂载了 /etc、/var/run/docker.sock 这类敏感路径,容器就可能影响宿主机。tmpfs:只放临时数据docker run --tmpfs /run:rw,noexec,nosuid,size=64m nginx它适合放运行期临时文件、缓存、敏感中间文件。容器停止后,数据就没了,所以不要把数据库、上传文件放在 tmpfs 里。-v 和 --mount 应该用哪个?-v 更短,适合日常快速使用:docker run -v mysql-data:/var/lib/mysql mysql:8--mount 更清晰,适合脚本、生产环境和团队协作:docker run --mount type=volume,source=mysql-data,target=/var/lib/mysql mysql:8新项目或生产脚本更推荐 --mount,减少误挂载的风险。命名卷和匿名卷有什么区别?命名卷有明确名字,方便复用、查看、备份和删除。docker volume create app-datadocker run -v app-data:/data alpinedocker volume lsdocker volume inspect app-data匿名卷没有手动指定名称,Docker 会生成一串 ID:docker run -v /data alpine这种写法能持久化,但不好识别,也容易越积越多。生产环境建议优先使用命名卷,不要依赖匿名卷保存关键数据。Docker Compose 里怎么声明数据卷?services: db: image: postgres:16 environment: POSTGRES_PASSWORD: example volumes: - pg-data:/var/lib/postgresql/data app: image: my-app:latest depends_on: - dbvolumes: pg-data:这里的 pg-data 是命名卷。执行 docker compose up -d 后,Docker 会自动创建它。本地开发也可以用 bind mount 挂源码:services: app: image: node:22 working_dir: /app volumes: - ./:/app command: npm run dev这类写法适合开发环境,不建议直接照搬到生产环境。生产环境更应该把代码打进镜像,数据用命名卷或外部存储管理。数据卷的生命周期怎么管理?删除容器:docker rm mysql命名卷通常不会自动删除。你需要手动删:docker volume rm mysql-data如果创建容器时使用了匿名卷,删除容器时可以加 -v 一起删除匿名卷:docker rm -v container-name清理所有未被容器使用的卷:docker volume prune执行 prune 前要谨慎确认,尤其是生产服务器。只要某个卷当前没有被容器引用,就可能被清理掉。数据卷怎么备份和恢复?备份命名卷:docker run --rm -v mysql-data:/data:ro -v $(pwd):/backup alpine tar czf /backup/mysql-data.tar.gz -C /data .恢复到新卷:docker volume create mysql-data-newdocker run --rm -v mysql-data-new:/data -v $(pwd):/backup alpine sh -c "cd /data && tar xzf /backup/mysql-data.tar.gz"如果备份的是数据库,最好使用数据库自己的备份工具,例如 mysqldump、pg_dump、物理备份工具或快照方案。直接打包数据库目录时,必须确保数据库已停止或处于一致性备份状态,否则可能得到一个不能恢复的备份。权限问题:UID 和 GID 为什么经常出错?挂载卷后,容器里的进程会用自己的用户身份读写文件。这个用户的 UID/GID 可能和宿主机用户不一致,于是就会出现“容器写的文件宿主机删不了”或“容器没有权限写目录”。docker exec -it app idsudo chown -R 1000:1000 ./datadocker run --user 1000:1000 -v $(pwd)/data:/data my-app数据库镜像通常有自己的用户。挂载目录时不要只看宿主机当前用户,要看镜像文档里要求的数据目录权限。数据库使用数据卷有什么注意事项?不要多个数据库容器同时写同一个数据目录,除非数据库本身明确支持这种集群模式。不要随便跨版本复用数据目录,例如从 MySQL 5.7 直接换到 8.0,先看升级文档。不要用普通文件同步工具实时同步数据库目录,容易同步到不一致状态。不要把高频写入数据库放在性能很差的网络盘上。备份不能只靠 docker volume,还要有可验证的恢复流程。对数据库来说,卷负责保存数据,备份策略负责保证数据能恢复。两件事不能混为一谈。安全上要注意什么?能只读就只读,例如 :ro 或 readonly。不要把宿主机根目录、系统目录、SSH 密钥目录挂进容器。不要随便挂载 /var/run/docker.sock,这几乎等于把宿主机 Docker 控制权交给容器。生产环境避免用宽泛的 bind mount,优先使用命名卷或专门的存储方案。敏感临时数据可以考虑 tmpfs,减少落盘风险。什么时候该选哪一种?| 需求 | 推荐方式 ||---|---|| 数据库持久化 | 命名 volume || 上传文件持久化 | 命名 volume 或对象存储 || 本地开发挂源码 | bind mount || 挂单个配置文件 | bind mount,尽量只读 || 临时缓存、运行期文件 | tmpfs || 生产环境长期数据 | 命名 volume、外部存储或云盘方案 |Docker 数据卷的核心不是命令有多复杂,而是把数据和容器分开管理。容器可以随时删、随时重建;真正要保护的是卷里的数据。
服务端阅读 06月20日 11:24

Docker 镜像分层是什么?如何优化构建体积和缓存?

Docker 镜像分层到底是什么?Docker 镜像不是一个单独的大文件,而是一组只读层叠加出来的文件系统。Dockerfile 里的多数指令,例如 FROM、RUN、COPY、ADD,都会生成新的镜像层。容器启动时,Docker 会在这些只读层上再加一层可写层,应用运行时产生的文件修改就落在这一层里。这些层通常基于内容寻址保存。简单说,Docker 会根据层内容计算摘要,内容一样的层可以被复用、共享和缓存。两个镜像如果都基于同一个 node:20-alpine,底层基础镜像层通常只需要在机器上保存一份。在 Linux 上,Docker 常见的存储驱动是 overlay2。它会把多个只读层通过 OverlayFS 叠在一起,对容器表现成一个完整目录。当上层修改下层已有文件时,并不是直接改原文件,而是把文件复制到可写层再修改,这就是常说的 copy-on-write。分层带来了哪些好处?分层最直接的价值是构建缓存。Docker 构建镜像时会从上到下执行 Dockerfile,如果某一层的指令和上下文没有变化,就可以直接复用缓存,不必重新执行。它也能减少存储和传输成本。相同的基础层可以被多个镜像共享,拉取镜像时已经存在的层不会重复下载。镜像仓库推送和拉取时,也可以按层并行处理,所以一个设计合理的镜像通常构建更快、传输更省。但分层也有副作用:每一层都会记录文件系统变化。你在一层里创建了大文件,下一层再删除它,最终镜像里仍可能保留前一层的大文件内容。很多“明明删了缓存,镜像还是很大”的问题,都和这个机制有关。构建缓存为什么会失效?Dockerfile 的缓存是顺序命中的。某一层缓存失效后,它后面的层通常也要重新构建。最常见的坑是过早执行大范围 COPY:COPY . .RUN npm install只要项目里任意文件变化,COPY . . 这一层就会变,后面的依赖安装也会重新执行。前端或 Node.js 项目更推荐先复制依赖清单,再安装依赖,最后复制源码:COPY package.json package-lock.json ./RUN npm ciCOPY . .这样只改业务代码时,依赖安装层仍能命中缓存。Python、Go、Java 项目也有类似思路:先复制依赖描述文件,再下载依赖,最后复制源码。如何减少镜像体积?选择合适的基础镜像基础镜像决定了镜像体积的起点。能用运行时镜像就不要用完整构建环境,能用官方 slim 版本就不要默认拉 full 版本。FROM node:20-slimalpine 很小,但不是所有场景都适合。它使用 musl libc,部分依赖 glibc 的原生库可能需要额外处理,排查成本反而更高。对 Node.js、Python 原生扩展较多的项目,slim 有时比 alpine 更稳。使用多阶段构建多阶段构建适合把“编译环境”和“运行环境”分开。第一阶段安装编译工具并产出构建结果,第二阶段只复制运行所需文件。FROM node:20-slim AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run buildFROM nginx:alpineCOPY --from=build /app/dist /usr/share/nginx/html这样最终镜像里不会带上 node_modules、源码、构建工具和临时缓存,只保留真正运行需要的产物。清理必须发生在同一层如果安装包和清理缓存拆成两条 RUN,上一层里的缓存仍然会留在镜像历史中:RUN apt-get update && apt-get install -y curlRUN rm -rf /var/lib/apt/lists/*更好的写法是放在同一个 RUN 里:RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*包管理器缓存、临时文件、编译中间产物,都应该遵守这个原则:创建和删除放在同一层。合并 RUN 指令,但别过度合并合并 RUN 可以减少层数,也能避免临时文件残留。但不要为了少一层把完全无关的命令揉成一大坨,否则可读性和缓存命中都会变差。比较好的做法是按变化频率拆分:系统依赖一层,应用依赖一层,业务代码一层。这样既便于缓存,也便于排查问题。.dockerignore 为什么很重要?.dockerignore 用来排除不应该进入构建上下文的文件。没有它时,Docker 可能会把 .git、日志、测试产物、本地依赖、临时文件一起发送给 Docker daemon。常见配置如下:.gitnode_modulesdistcoverage*.log.env.DS_Store它不只是减少镜像体积,还会影响缓存。构建上下文越干净,COPY . . 越不容易因为无关文件变化而失效。能不能用 squash 压成一层?镜像 squashing 可以把多层压成更少的层,看起来能减少历史包袱。但它不是默认首选。原因有两个:第一,压扁后层复用能力会变差,多个镜像之间不容易共享中间层;第二,它可能掩盖 Dockerfile 本身的问题,比如缓存清理位置不对、构建产物没有隔离。多数情况下,优化 Dockerfile 比依赖 squash 更可靠。怎么检查镜像哪里变大了?先用 docker history 看每一层的大小和对应指令:docker history your-image:tag它能快速定位是哪条 Dockerfile 指令引入了大体积文件。需要更细的分析时,可以用 dive 查看每一层新增、修改、删除了哪些文件:dive your-image:tag如果发现某一层新增了大量缓存,下一层又删除,说明清理时机不对;如果发现源码、测试文件、.git 目录被打进镜像,通常是 .dockerignore 没写好。一个更合理的 Dockerfile 思路以 Node.js 应用为例,比较稳的结构通常是这样:FROM node:20-slim AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciFROM node:20-slim AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run buildFROM node:20-slim AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=dev && npm cache clean --forceCOPY --from=build /app/dist ./distCMD ["node", "dist/index.js"]这个写法的重点不是模板本身,而是顺序:依赖文件先复制,依赖安装尽量缓存;源码后复制,避免频繁改代码导致依赖层失效;最终阶段只保留运行时需要的内容。小结Docker 镜像优化的关键不是单纯减少层数,而是理解每一层留下了什么、哪些层能复用、哪些改动会让缓存失效。实际项目里优先做好几件事:基础镜像选小但别盲目选,依赖文件先复制,清理和安装放同一层,多阶段构建隔离编译产物,用 .dockerignore 控制构建上下文,再用 docker history 或 dive 找出真正的大层。
服务端阅读 06月20日 11:19

Docker 容器隔离机制是什么?边界在哪里?

Docker 的容器隔离主要靠 Linux 内核能力完成,不是靠 Docker 自己“虚拟出一台机器”。一句话说:Namespace 负责把容器“看见的世界”隔开,Cgroup 负责限制它能“用多少资源”,再叠加联合文件系统、Capabilities、seccomp、AppArmor 或 SELinux 等机制,形成一套相对完整的运行边界。Namespace 隔离了容器能看到什么Namespace 可以理解成 Linux 给进程准备的“视角隔离”。同一台宿主机上的进程,放进不同 Namespace 后,看到的进程、网络、挂载点、主机名可能完全不同。PID Namespace:隔离进程编号PID Namespace 让容器拥有自己的进程树。容器内的第一个进程通常看到自己是 PID 1,但在宿主机上它仍然只是一个普通进程,有宿主机上的真实 PID。这也是为什么在宿主机执行 ps 能看到容器进程,而容器内默认看不到宿主机其他进程。NET Namespace:隔离网络栈NET Namespace 隔离网卡、IP、路由表、端口和防火墙规则。每个容器可以有自己的虚拟网卡、独立 IP 和端口空间。Docker 默认会通过 veth pair 和 bridge 网络把容器接到宿主机网络上。容器觉得自己有一张独立网卡,实际流量仍然经过宿主机内核转发。MNT Namespace:隔离文件系统挂载点MNT Namespace 让容器看到自己的根目录和挂载结构。容器里看到的 / 不是宿主机真正的 /,而是 Docker 准备好的镜像层和可写层组合后的文件系统视图。但如果把宿主机目录挂进去,例如 -v /:/host,隔离边界就会被主动削弱。所以挂载权限是容器安全里很关键的一环。UTS、IPC 和 USER NamespaceUTS Namespace 隔离 hostname 和 domain name。IPC Namespace 隔离 System V IPC、POSIX message queue 等进程间通信资源。USER Namespace 用来把容器内的用户 ID 映射到宿主机上的另一个用户 ID,例如容器里看起来是 root,映射到宿主机后可能只是普通用户。这对安全很重要,因为容器内 root 如果直接等于宿主机 root,一旦逃逸风险会更高。Rootless Docker 和 userns-remap 都是在利用这个思路降低权限面。Cgroup 限制了容器能用多少资源Namespace 解决“看见什么”,Cgroup 解决“能用多少”。Docker 可以通过 Cgroup 限制 CPU、内存、磁盘 I/O、进程数量等资源,避免一个容器拖垮整台宿主机。| 资源 | 作用 ||---|---|| CPU | 限制 CPU 使用比例、权重或可用核心 || Memory | 限制内存上限,超出后可能触发 OOM || Block I/O | 限制磁盘读写权重或吞吐 || PIDs | 限制进程数量,防止 fork bomb || Devices | 控制容器能访问哪些设备 |Cgroup v1 按资源类型拆成多个独立控制器,配置灵活但层级容易混乱。Cgroup v2 统一了层级模型,资源控制更一致,现代 Linux 发行版和新版本容器运行时越来越多地使用 v2。需要注意,Cgroup 是资源控制,不是完整的安全沙箱。它能限制资源滥用,但不能替代权限隔离和系统调用过滤。联合文件系统提供镜像层隔离Docker 镜像通常由多层只读层组成,容器启动时再叠加一个可写层。常见实现包括 overlay2。这样做有两个好处:多个容器可以共享相同镜像层,节省磁盘空间;容器写入文件时只写自己的可写层,不会直接修改镜像原始层。不过,联合文件系统不是安全边界的全部。真正决定容器能不能访问宿主机敏感路径的,还是挂载配置、权限、Capabilities 和安全策略。Capabilities、seccomp 和 LSM 继续收紧权限Linux root 权限被拆成很多 Capabilities。Docker 默认会去掉一部分高危能力,比如直接加载内核模块通常不应该出现在普通容器里。seccomp 用来过滤系统调用。比如某些危险 syscall 可以被默认策略拦截,降低容器利用内核攻击面的机会。AppArmor 和 SELinux 属于 Linux Security Module,可以进一步限制进程能访问哪些文件、执行哪些操作。它们更像一层强制访问控制,防止“进程有权限但不该做”的行为。生产环境常见做法是:不要使用 --privileged,按需添加 Capability,启用默认 seccomp 配置,并配合 AppArmor 或 SELinux 策略。Docker 隔离的边界在哪里Docker 容器和虚拟机最大的区别是:容器共享宿主机内核,虚拟机通常有独立内核。这意味着容器隔离的边界主要在 Linux 内核能力上。如果内核存在可利用漏洞,或者容器被授予了过高权限,例如 --privileged、挂载 Docker socket、挂载宿主机根目录,容器就可能影响宿主机。Rootless 模式和 USER Namespace 可以降低风险,但也不是万能的。它们能减少容器进程在宿主机上的实际权限,却不能消除所有内核攻击面,也可能受到功能兼容性限制。实际使用时怎么判断是否安全判断一个容器是否隔离得足够好,不能只看它是不是 Docker 跑起来的,还要看这些配置:是否避免 --privileged;是否限制了不必要的 Capabilities;是否启用了 seccomp、AppArmor 或 SELinux;是否给内存、CPU、PIDs 设置了合理 Cgroup 限制;是否避免挂载宿主机敏感目录和 Docker socket;是否使用 USER Namespace 或 Rootless 模式降低 root 风险;镜像和宿主机内核是否及时更新。Docker 的隔离机制不是单点能力,而是一组内核机制的组合。Namespace 让容器看起来像独立系统,Cgroup 控制资源使用,联合文件系统隔离文件变更,安全模块收紧权限。它足够适合大多数应用隔离场景,但不能把它误认为和虚拟机一样强的硬隔离。
服务端阅读 06月20日 11:19

Dockerfile CMD 和 ENTRYPOINT 有什么区别?

Dockerfile 里的 CMD 和 ENTRYPOINT 都和容器启动命令有关,但职责不一样:ENTRYPOINT 更像“固定要运行的程序”,CMD 更像“默认参数”或“默认命令”。如果只记一句话:想让镜像像一个可执行程序一样运行,用 ENTRYPOINT;想给容器提供一个可以轻松替换的默认启动命令,用 CMD。两者一起用时,通常是 ENTRYPOINT 写可执行文件,CMD 写默认参数。CMD 是什么?CMD 用来指定容器启动时的默认命令。它最大的特点是:容易被 docker run 后面的参数覆盖。常见写法有三种:CMD ["node", "server.js"]CMD node server.jsCMD ["--help"]前两种是完整命令,第三种通常配合 ENTRYPOINT 使用,表示给 ENTRYPOINT 传默认参数。比如:FROM node:20-alpineWORKDIR /appCOPY . .CMD ["node", "server.js"]直接运行:docker run my-node-app实际执行的是:node server.js但如果这样运行:docker run my-node-app node worker.jsCMD ["node", "server.js"] 会被 node worker.js 覆盖。所以 CMD 适合放“可以被用户改掉的默认行为”。ENTRYPOINT 是什么?ENTRYPOINT 用来指定容器启动时固定执行的程序。普通的 docker run 参数不会直接覆盖它,而是会追加到 ENTRYPOINT 后面,作为参数传入。FROM alpineENTRYPOINT ["ping"]CMD ["localhost"]运行:docker run ping-image等价于:ping localhost运行:docker run ping-image 8.8.8.8等价于:ping 8.8.8.8这里 ping 是固定程序,localhost 只是默认参数。用户传了 8.8.8.8 后,覆盖的是 CMD,不是 ENTRYPOINT。exec 形式和 shell 形式有什么区别?CMD 和 ENTRYPOINT 都有 exec 形式和 shell 形式。exec 形式写成 JSON 数组:ENTRYPOINT ["node", "server.js"]CMD ["--port", "3000"]shell 形式写成普通字符串:ENTRYPOINT node server.jsCMD node server.js推荐优先用 exec 形式,原因有两个。第一,exec 形式不会额外套一层 shell,参数传递更准确。第二,exec 形式对信号处理更友好。容器里的主进程通常是 PID 1,Docker 停止容器时会先发送 SIGTERM。如果用 shell 形式,真正的业务进程可能变成 shell 的子进程,信号不一定能正确传过去,容易出现容器停止慢、进程残留、优雅退出失效等问题。ENTRYPOINT node server.js实际可能是:/bin/sh -c "node server.js"更推荐:ENTRYPOINT ["node", "server.js"]如果确实需要 shell 能力,比如环境变量展开、管道、&&,可以使用 shell 形式,但要知道它带来的信号处理问题。生产镜像里更稳妥的方式通常是把复杂逻辑放到脚本里,并在脚本最后使用 exec:#!/bin/shset -eexec node server.js再配合:ENTRYPOINT ["./entrypoint.sh"]docker run 怎么覆盖它们?覆盖 CMD 很简单,直接在镜像名后面追加命令或参数。docker run my-image bash如果镜像只有:CMD ["node", "server.js"]那么 bash 会覆盖原来的 CMD。如果镜像是:ENTRYPOINT ["node", "server.js"]CMD ["--port", "3000"]执行:docker run my-image --port 8080最终命令是:node server.js --port 8080如果连 ENTRYPOINT 也想覆盖,需要显式使用 --entrypoint:docker run --entrypoint sh my-image注意,--entrypoint 只替换入口程序,镜像原来的 CMD 仍可能作为参数拼到后面。排查问题时可以看镜像的 Config.Entrypoint 和 Config.Cmd:docker inspect my-image什么时候只用 CMD?如果镜像只是给一个默认启动命令,用户经常需要替换整个命令,用 CMD 就够了。FROM node:20-alpineWORKDIR /appCOPY . .CMD ["npm", "run", "dev"]默认启动开发服务,但用户也可以方便地执行:docker run my-app npm test这种场景下,CMD 的可覆盖特性反而是优点。什么时候用 ENTRYPOINT + CMD?如果希望镜像表现得像一个固定工具,推荐 ENTRYPOINT + CMD。FROM alpineRUN apk add --no-cache curlENTRYPOINT ["curl"]CMD ["--help"]默认运行会输出 curl 帮助;传入参数时:docker run curl-image -I https://example.com实际执行:curl -I https://example.com服务型镜像也可以这样写:ENTRYPOINT ["node", "server.js"]CMD ["--port", "3000"]这样镜像默认监听 3000,用户需要改端口时只覆盖参数即可。docker compose 里的 command 和 entrypoint 对应什么?在 Docker Compose 里,command 对应 Dockerfile 里的 CMD,entrypoint 对应 Dockerfile 里的 ENTRYPOINT。services: app: image: my-app command: ["--port", "8080"]如果镜像里有:ENTRYPOINT ["node", "server.js"]CMD ["--port", "3000"]Compose 的 command 会把默认参数改成:node server.js --port 8080如果同时覆盖入口和参数:services: app: image: my-app entrypoint: ["sh"] command: ["-c", "env && sleep 3600"]这常用于临时调试。常见写法怎么选?| 场景 | 推荐写法 | 原因 ||---|---|---|| 默认启动一个服务,用户可能替换整条命令 | CMD | 覆盖方便 || 镜像就是一个 CLI 工具 | ENTRYPOINT + CMD | 固定工具名,参数可变 || 服务程序固定,只想允许改参数 | ENTRYPOINT + CMD | 程序稳定,参数灵活 || 需要临时进入容器排查 | --entrypoint sh | 直接绕过原入口 || 需要信号正确传给主进程 | exec 形式 | 避免 shell 吞信号 |实际项目里可以这样记:CMD 管默认值,ENTRYPOINT 管主程序;exec 形式优先,shell 形式慎用;Compose 里的 command 改 CMD,entrypoint 改 ENTRYPOINT。
服务端阅读 06月20日 00:02

Docker COPY 和 ADD 有什么区别?什么时候该用 ADD?

Dockerfile 里复制文件时,默认优先用 COPY。它的语义很单纯:把构建上下文里的文件或目录复制到镜像指定路径。ADD 也能复制文件,但它多了几个“隐式动作”,尤其是本地 tar 包自动解压和远程 URL 下载。正因为这些行为不够直观,日常构建里更推荐 COPY,只有明确需要 ADD 的特殊能力时再用它。COPY 做什么?COPY 只负责复制本地文件、目录,行为可预测:COPY package.json package-lock.json ./COPY src/ /app/src/注意目标路径的斜杠:COPY file.txt /appCOPY file.txt /app/如果 /app 不存在,第一种可能把文件复制成名为 /app 的文件;第二种明确表示复制到 /app/ 目录下。写 Dockerfile 时建议目录目标都带上 /,减少歧义。COPY 也支持通配符,例如:COPY *.json ./但通配符匹配的是构建上下文里的文件,不是容器里的路径。构建上下文会受 .dockerignore 影响,所以别把 node_modules、日志、临时文件、密钥文件一起送进镜像构建,否则不仅镜像变大,还可能泄露敏感信息。ADD 多了哪些能力?ADD 可以做 COPY 能做的事,还多了两类常见行为。第一,本地 tar 包会自动解压:ADD app.tar.gz /app/如果 app.tar.gz 是本地构建上下文里的 tar 归档,Docker 会把它解压到 /app/。这个功能适合你明确想把本地归档展开进镜像的场景。第二,ADD 可以从远程 URL 下载文件:ADD https://example.com/tool.tar.gz /tmp/tool.tar.gz但这通常不推荐。远程下载会让构建结果依赖网络、服务端响应和缓存规则,可复现性变差。使用 BuildKit 时,HTTP(S) URL 可以配合 --checksum 校验内容:ADD --checksum=sha256:<hash> https://example.com/tool.tar.gz /tmp/tool.tar.gz有校验比裸下载安全,但大多数场景仍然更适合用 RUN curl 或 wget,因为你可以在同一层里校验、解压、删除缓存文件。为什么下载文件更推荐 RUN curl 或 wget?比如安装一个二进制包,更推荐这样写:RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/tool.tar.gz && echo "<hash> /tmp/tool.tar.gz" | sha256sum -c - && tar -xzf /tmp/tool.tar.gz -C /usr/local/bin && rm /tmp/tool.tar.gz好处很直接:下载、校验、解压、清理都在同一层完成,不会把临时压缩包留在镜像层里。失败时也更容易定位问题。缓存失效有什么区别?COPY 和 ADD 都会影响 Docker 构建缓存。只要被复制的源文件内容发生变化,对应层以及后面的 RUN 层通常都会失效。所以常见优化是先复制依赖描述文件,再安装依赖,最后复制业务代码:COPY package.json package-lock.json ./RUN npm ciCOPY src/ ./src/这样业务代码变了,不会轻易让依赖安装层重新执行。.dockerignore 也会影响缓存。忽略无关文件能减少构建上下文变化,避免因为日志、缓存目录、编辑器临时文件导致 Docker 反复失效。--chown、--chmod 和多阶段构建COPY 和 ADD 都可以配合权限参数使用:COPY --chown=node:node --chmod=755 app.sh /usr/local/bin/app.sh这比复制后再 RUN chown、RUN chmod 更干净,少一层,也更容易读懂。多阶段构建里还常用 COPY --from 从前一个阶段复制产物:FROM node:20 AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run buildFROM nginx:alpineCOPY --from=build /app/dist/ /usr/share/nginx/html/这也是 COPY 的高频用法:只把最终产物放进运行镜像,构建工具、源码缓存、依赖中间文件都不带进去。安全上怎么选?规则可以很简单:只是复制文件:用 COPY需要本地 tar 自动解压:可以用 ADD需要下载远程文件:优先 RUN curl 或 wget,并做 checksum 校验不要把密钥、.env、SSH 私钥放进构建上下文用 .dockerignore 控制复制范围目录目标路径尽量写成 /path/,避免歧义ADD 不是不能用,而是它会“顺手多做事”。Dockerfile 越显式,镜像构建越稳定,也越容易排查问题。
服务端阅读 06月20日 00:02

Docker 容器生命周期有哪些状态和管理命令?

Docker 容器生命周期管理,核心就是管理一个容器从创建、运行、暂停、停止、重启到删除的全过程。常见状态包括 created、running、paused、exited、restarting 和 dead,对应的命令主要有 create、run、start、stop、kill、pause、unpause、restart、rm。Docker 容器有哪些生命周期状态?| 状态 | 含义 | 常见场景 ||---|---|---|| created | 容器已创建,但还没有启动 | 执行 docker create 后 || running | 容器正在运行 | 执行 docker start 或 docker run 后 || paused | 容器进程被暂停 | 执行 docker pause 后 || exited | 容器主进程已退出 | 程序运行结束、报错退出或被停止 || restarting | 容器正在按重启策略重启 | 配置了 restart policy 后 || dead | Docker 无法正常管理该容器 | 删除失败、底层资源异常等少见情况 |可以用 docker ps 查看运行中的容器,用 docker ps -a 查看所有容器及其状态。create 和 run 有什么区别?docker create 只创建容器,不启动。容器创建后会停留在 created 状态,适合先准备配置、网络、挂载参数,再手动启动。docker create --name app nginxdocker run 更常用,它等价于“创建 + 启动”。如果加上 -d,容器会在后台运行。docker run -d --name app nginx简单说:create 是先把容器准备好,run 是准备好以后立刻跑起来。启动、停止和强制终止怎么用?启动已创建或已停止的容器,用 docker start:docker start app停止容器优先用 docker stop。它会先向容器内的 PID 1 进程发送 SIGTERM,给程序一个优雅退出的机会;如果超时仍未退出,再发送 SIGKILL 强制结束。docker stop appdocker stop -t 30 appdocker kill 则是直接发送强制终止信号,默认是 SIGKILL:docker kill app生产环境通常先用 stop,只有容器卡死、无法正常退出时才用 kill。尤其要注意,容器里的 PID 1 如果没有正确处理 SIGTERM,应用可能来不及关闭连接或刷盘,导致数据状态不一致。pause、unpause 和 restart 分别做什么?docker pause 会暂停容器中的所有进程,容器仍存在,但进程不会继续执行:docker pause appdocker unpause app它适合短时间冻结容器,比如临时排查资源占用。但它不是正常停机方式,不能替代 stop。docker restart 相当于先停止再启动:docker restart app如果应用需要平滑重启,要确认它能正确处理 SIGTERM,否则 restart 也可能变成一次粗暴中断。如何查看容器运行情况?生命周期管理不只是执行命令,还要能看懂容器发生了什么。docker logs appdocker inspect appdocker stats appdocker eventsdocker logs 看容器标准输出和错误输出;docker inspect 看容器配置、网络、挂载、退出码等详细信息;docker stats 看 CPU、内存、网络、磁盘 IO;docker events 可以观察 Docker 守护进程记录的创建、启动、停止、销毁等事件。如果容器异常退出,重点看 docker inspect 里的退出码。0 通常表示正常退出,非 0 往往表示程序异常、启动命令错误、权限问题或依赖服务不可用。restart policy 怎么控制自动重启?Docker 可以通过重启策略控制容器退出后的行为:| 策略 | 含义 ||---|---|| no | 默认策略,容器退出后不自动重启 || on-failure | 只有非 0 退出码时才重启 || always | 只要容器退出就自动重启,Docker 服务重启后也会拉起 || unless-stopped | 类似 always,但如果容器被手动停止,Docker 重启后不会再自动拉起 |示例:docker run -d --restart unless-stopped --name app nginx服务型容器更常用 unless-stopped。它既能在异常退出后自动恢复,又不会和人工停机操作打架。删除容器前要注意什么?删除容器用 docker rm,但容器必须先停止:docker stop appdocker rm app如果确认不需要保留容器,也可以强制删除:docker rm -f app不过要分清“容器数据”和“卷数据”。删除容器不会自动删除 Docker volume 中的数据,挂载到 volume 或宿主机目录里的文件通常还在。这是好事,也是坑:好处是数据不会因为容器重建就丢;坑是不用的 volume 会越积越多,需要定期清理。常见清理命令包括:docker container prunedocker volume prunedocker system prune清理前一定确认资源不再使用,尤其是 volume,误删后数据库、上传文件这类持久化数据可能无法恢复。小结Docker 容器生命周期可以理解为:先创建,再启动运行;运行中可以暂停、恢复、停止或重启;退出后根据退出码和重启策略决定是否自动拉起;不再需要时再删除容器并清理无用资源。日常管理时,stop 比 kill 更安全,inspect 和 logs 是排查退出问题的入口,volume 数据是否保留则要单独确认。
服务端阅读 06月20日 00:02

Docker 环境变量怎么配置?ENV、ARG 和 Compose 怎么选?

Docker 环境变量到底能配置在哪?Docker 容器环境变量常见配置方式有四类:写在 Dockerfile 里、运行容器时传入、通过环境变量文件批量传入、在 Docker Compose 中配置。它们看起来都叫“环境变量”,但生效时机、覆盖规则和安全风险并不一样。一句话先说结论:固定默认值可以放 Dockerfile,环境差异参数用运行时传入,生产敏感信息不要直接当环境变量裸奔。Dockerfile 里的 ENV 和 ARG 有什么区别?Dockerfile 里最容易混淆的是 ENV 和 ARG。ENV:构建后仍然存在ENV 用来设置镜像默认环境变量,构建出的镜像和启动后的容器里都能看到。FROM node:20-alpineENV NODE_ENV=productionENV APP_PORT=3000CMD ["node", "server.js"]适合放默认运行参数,比如 NODE_ENV、默认端口、默认语言环境。它不适合放密码、Token、数据库连接串,因为这些值可能进入镜像层历史,也可能被 docker inspect 看到。ARG:只在构建阶段使用ARG 只在镜像构建时生效,默认不会保留到运行时环境里。FROM node:20-alpineARG BUILD_VERSIONRUN echo "build version: $BUILD_VERSION"构建时传入:docker build --build-arg BUILD_VERSION=2026.06.19 -t my-app .如果需要把 ARG 写入运行时环境,必须显式转成 ENV。这也意味着它会进入最终镜像环境。不要把“ARG 构建时用”误解成“ARG 一定安全”,构建日志、镜像历史和 CI 记录里仍可能留下痕迹。docker run 怎么传环境变量?运行容器时最直接的方式是 -e 或 --env。docker run -e NODE_ENV=production -e APP_PORT=3000 my-app也可以只写变量名,让 Docker 从当前 Shell 环境里取值:export API_BASE_URL=https://api.example.comdocker run --env API_BASE_URL my-app如果变量比较多,用 --env-file 更清爽。docker run --env-file .env.production my-app.env.production 示例:NODE_ENV=productionAPP_PORT=3000API_BASE_URL=https://api.example.com--env-file 不是 Shell 脚本,通常按 KEY=VALUE 读取,不要指望它执行命令、展开复杂表达式。值里如果有空格、引号、换行,最好先确认解析行为。Docker Compose 里 environment、env_file 和 .env 有什么区别?Compose 里有三个名字很像的东西:.env、env_file、environment。它们不是一回事。.env:主要用于 compose 文件变量插值项目根目录的 .env 常用于替换 compose.yml 里的占位变量。IMAGE_TAG=1.2.0HOST_PORT=8080services: web: image: my-app:${IMAGE_TAG} ports: - "${HOST_PORT}:3000"这里 .env 的作用是让 Compose 在解析配置文件时,把 ${IMAGE_TAG}、${HOST_PORT} 替换掉。它不等于自动把所有变量都塞进容器。env_file:把文件里的变量传给容器services: web: image: my-app:1.2.0 env_file: - .env.production这会把 .env.production 里的变量注入容器运行环境。environment:直接在 compose 里声明容器变量services: web: image: my-app:1.2.0 environment: NODE_ENV: production APP_PORT: "3000" API_BASE_URL: ${API_BASE_URL}environment 的好处是配置直观,适合少量关键变量;变量很多时,env_file 更容易维护。环境变量优先级怎么算?对单个 docker run 来说,通常可以按这个理解:docker run -e KEY=value 显式传入的值优先级最高;docker run --env-file 中的值次之;镜像里 Dockerfile ENV 的默认值最后兜底。Compose 的优先级更细一些:environment 里直接声明的值通常会覆盖 env_file 同名变量;env_file 会覆盖镜像 ENV 默认值;.env 主要影响 ${VAR} 插值,本身不自动等于容器环境变量;命令行临时覆盖通常最高。如果变量来源很多,不要靠猜。可以用下面命令看 Compose 最终解析结果:docker compose config再进容器确认实际值:docker compose exec web env | grep NODE_ENV敏感信息能不能放环境变量?能用,但不推荐把它当成安全存储。环境变量很方便,也很容易泄漏:docker inspect 可能看到容器环境变量,应用启动日志可能把配置整体打印出来,CI/CD 日志中也可能出现明文。所以数据库密码、私钥、访问 Token 这类内容,生产环境更建议使用 Docker Swarm Secrets、Kubernetes Secrets、云厂商密钥管理服务,或者运行平台提供的 Secret 注入能力。Secrets 不是环境变量,那应用怎么读?很多运行平台会把 Secret 挂载成文件,而不是直接放进环境变量。Docker Swarm Secrets 常见路径类似:/run/secrets/db_password不少官方镜像支持 _FILE 约定,例如:MYSQL_PASSWORD_FILE=/run/secrets/mysql_passwordPOSTGRES_PASSWORD_FILE=/run/secrets/postgres_password这个约定不是 Docker 强制标准,而是很多镜像和框架采用的习惯。自己写应用时也可以照这个思路做:普通配置走环境变量,敏感值走文件或密钥服务。一个更接近生产的配置方式开发环境可以简单一点:services: web: build: . ports: - "3000:3000" env_file: - .env.development environment: NODE_ENV: development生产环境建议把默认值、环境差异和密钥分开:services: web: image: my-app:1.2.0 environment: NODE_ENV: production APP_PORT: "3000" DB_HOST: db DB_PASSWORD_FILE: /run/secrets/db_password secrets: - db_passwordsecrets: db_password: external: true应用启动时读取:import fs from 'node:fs';function readSecret(name) { const file = process.env[`${name}_FILE`]; if (file) return fs.readFileSync(file, 'utf8').trim(); return process.env[name];}const dbPassword = readSecret('DB_PASSWORD');配置后怎么验证?查看 Compose 渲染后的配置:docker compose config查看容器环境变量:docker exec <container> env | sort查看镜像默认环境变量:docker image inspect my-app --format '{{json .Config.Env}}'查看容器环境变量时要小心,不要在共享终端、CI 日志或工单截图里暴露敏感值。很多泄漏不是黑客攻破系统,而是排查问题时顺手贴了一段日志。生产环境建议怎么定规则?比较稳的做法是把变量按用途分层:镜像默认值放无敏感、跨环境都合理的默认配置;部署环境变量放不同环境会变化的普通配置;Secret 放密码、Token、私钥、证书;CI/CD 参数放构建版本、提交哈希、构建时间这类构建期信息。还要给变量命名留点规矩。比如统一使用 APP_、DB_、REDIS_ 前缀;布尔值固定用 true/false;端口统一写字符串,避免 YAML 把值解析成奇怪的类型。最后,给应用启动加一层配置校验。缺了关键变量就直接失败,不要带着默认空值跑起来。Docker 环境变量配置没有唯一答案,关键是边界清楚:默认配置归默认配置,环境差异归部署系统,敏感信息归 Secrets。
服务端阅读 06月19日 23:48

Docker 端口映射怎么配置?-p、Compose 和排查怎么做?

Docker 端口映射的作用,是把容器网络命名空间里的端口发布到宿主机上。最常见的写法是 docker run -p 8080:80 nginx:外部访问宿主机的 8080 端口,请求会被转发到容器里的 80 端口。这里最容易混淆的是:EXPOSE 只是镜像或 Dockerfile 里的声明,告诉别人“这个容器通常会监听哪些端口”;真正让宿主机能访问容器端口的,是 -p/--publish 或 Compose 里的 ports。-p 的完整写法是什么?-p 的通用格式是:docker run -p [hostIP:]hostPort:containerPort[/protocol] image几个常见例子:# 宿主机 8080 转发到容器 80docker run -p 8080:80 nginx# 只允许本机访问宿主机 8080docker run -p 127.0.0.1:8080:80 nginx# 绑定所有网卡的 8080docker run -p 0.0.0.0:8080:80 nginx# 映射多个端口docker run -p 8080:80 -p 8443:443 nginx# 映射 UDP 端口docker run -p 5353:5353/udp some-image默认协议是 TCP。如果服务用的是 UDP,比如 DNS、游戏服务、部分实时通信服务,必须显式写 /udp,否则你映射的是 TCP,访问当然不通。0.0.0.0、127.0.0.1 和安全性有什么区别?0.0.0.0:8080:80 表示监听宿主机所有网卡。只要机器的公网 IP、内网 IP 能被访问,别人就可能连到这个端口。127.0.0.1:8080:80 只绑定本机回环地址,通常只有宿主机自己能访问,适合数据库、管理后台、本地调试服务这类不该直接暴露的端口。所以生产环境里不要随手写:docker run -p 0.0.0.0:3306:3306 mysql这等于把数据库端口暴露在宿主机所有网卡上。更稳妥的方式是只绑定本地地址,再通过反向代理、堡垒机、VPN 或内网访问控制处理入口。为什么映射了端口还是访问不了?端口映射只负责把流量送到容器端口,但容器里的应用也要真的监听在正确地址上。如果应用在容器内只监听 127.0.0.1,外部流量通常到不了它。因为 Docker 转发过来的目标地址是容器的网络地址,不是容器里的 loopback。容器里的 Web 服务应监听:0.0.0.0:80而不是:127.0.0.1:80很多 Node、Python、Go 开发服务默认只监听 localhost,本地直接跑没问题,放进容器再做端口映射就会踩坑。EXPOSE、-P 和 -p 有什么区别?EXPOSE 不会自动发布端口,例如:EXPOSE 80它只是元数据。你运行容器时仍然需要:docker run -p 8080:80 image-P 是大写 P,会把镜像里 EXPOSE 声明的端口随机映射到宿主机端口:docker run -P nginx这种方式适合临时调试,不适合稳定对外服务,因为宿主机端口不固定。要知道实际映射到了哪个端口,可以查:docker port 容器名Docker Compose 里怎么写?Compose 里最常见的是 ports:services: web: image: nginx ports: - "8080:80" - "127.0.0.1:8443:443"ports 会把容器端口发布到宿主机。expose 不会发布到宿主机,只是让同一个 Docker 网络里的其他服务知道这个服务使用了哪些端口:services: api: image: my-api expose: - "3000"在同一个 Compose 网络里,服务之间通常不需要端口映射。比如 web 访问 api:3000 即可,Docker 内部 DNS 会把服务名解析到对应容器。只有当宿主机或外部用户需要访问容器时,才需要 ports。常用排查命令有哪些?先看 Docker 是否真的发布了端口:docker port 容器名再看容器是否在运行:docker ps检查宿主机端口是否监听:ss -lntp | grep 8080如果是 macOS 没有 ss,可以用:lsof -i :8080从宿主机访问测试:curl -v http://127.0.0.1:8080进容器里看应用是否监听正确地址:docker exec -it 容器名 shss -lntp如果 Docker 映射没问题、容器服务也在监听,但外部机器访问不了,继续查三件事:宿主机防火墙是否放行端口,比如 ufw、firewalld、云服务器安全组;是否绑定成了 127.0.0.1,导致只能本机访问;协议是否写错,UDP 服务不能只映射 TCP。端口映射配置时要记住什么?-p 8080:80 的方向是“宿主机端口:容器端口”,不要写反。EXPOSE 只是声明,-p 或 ports 才是真正发布端口。对外服务可以绑定 0.0.0.0,但数据库、后台管理、调试端口更适合绑定 127.0.0.1 或放在 Docker 内部网络里。大多数端口映射问题最后都落在四处:Docker 没发布、应用没监听 0.0.0.0、宿主机端口被占用、防火墙或安全组没放行。按这个顺序查,通常很快能定位。
服务端阅读 06月19日 23:48

Docker 容器时区配置怎么做才稳妥?

Docker 容器里的时间不对,最先影响的一般不是页面展示,而是日志、定时任务和排查问题的效率。比如宿主机已经是北京时间,容器日志却显示 UTC,凌晨任务提前 8 小时执行,排查起来很容易绕晕。Docker 容器时区配置常见有几种做法:设置 TZ 环境变量、安装 tzdata、挂载宿主机时区文件,或者在 Compose、Kubernetes 中统一声明。实际选哪一种,要看镜像基础系统、应用运行时和部署环境。用 TZ 环境变量设置时区最轻量的方式是在容器中设置 TZ 环境变量:ENV TZ=Asia/Shanghai运行容器时也可以临时传入:docker run -e TZ=Asia/Shanghai your-image这种方式优点是简单、可移植,不依赖宿主机的 /etc/localtime。如果同一个镜像要部署到不同地区,只需要改环境变量,不用重新改镜像逻辑。不过要注意,TZ 是否生效取决于镜像里是否有可用的时区数据。很多精简镜像没有完整的 timezone 数据库,只设置环境变量可能不够。Debian 和 Alpine 镜像要安装 tzdata如果基础镜像是 Debian 或 Ubuntu,可以在 Dockerfile 里安装 tzdata:RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo Asia/Shanghai > /etc/timezone && rm -rf /var/lib/apt/lists/*ENV TZ=Asia/Shanghai如果是 Alpine:RUN apk add --no-cache tzdataENV TZ=Asia/ShanghaiAlpine 镜像更精简,很多时候没有预装时区数据。只写 ENV TZ=Asia/Shanghai,但没有 tzdata,程序可能仍然按 UTC 或默认时区处理。挂载宿主机时区文件可以用,但别过度依赖另一种常见写法是挂载宿主机的时区配置:docker run -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro your-image这样容器会跟随宿主机时区。它适合单机部署、内部工具或对宿主机环境强绑定的服务。但它也有几个坑:不同 Linux 发行版不一定都有 /etc/timezone;macOS、Windows Docker Desktop 的路径语义和 Linux 不完全一样;容器和宿主机绑定过紧,迁移到 Kubernetes 或其他环境时容易失效;如果宿主机时区配置错误,容器也会一起错。所以生产环境更推荐把时区配置显式写进镜像或部署文件,而不是假设宿主机一定正确。Docker Compose 里怎么写Compose 中通常直接配置环境变量:services: app: image: your-image environment: TZ: Asia/Shanghai如果你的镜像需要系统级时区文件,也可以挂载:services: app: image: your-image environment: TZ: Asia/Shanghai volumes: - /etc/localtime:/etc/localtime:ro更稳妥的做法是:镜像里安装好 tzdata,部署层只负责传 TZ。这样 Compose、Kubernetes、普通 docker run 都能复用同一套镜像。Kubernetes 中怎么配置Kubernetes 里一般通过环境变量设置:containers: - name: app image: your-image env: - name: TZ value: Asia/Shanghai如果确实要挂载宿主机的 /etc/localtime,可以用 hostPath,但这会让 Pod 依赖节点环境,不利于迁移和调度。除非你很清楚集群节点的时区配置一致,否则不建议作为默认方案。UTC 还是本地时区,怎么选很多团队会纠结:容器到底该用 UTC,还是用 Asia/Shanghai?如果是国际化系统、跨地区服务、分布式链路追踪,UTC 更适合作为统一存储和计算时间。数据库、消息、审计日志使用 UTC,可以减少夏令时和跨时区换算问题。如果是面向国内业务、内部管理后台、定时任务强依赖本地时间,用 Asia/Shanghai 会更直观。比如每天 9 点发报表、凌晨 2 点跑清算,本地时区能降低理解成本。比较稳的原则是:系统内部时间尽量统一,展示层再转换成本地时区。如果日志、数据库、应用各用各的时区,问题会非常难查。日志和 cron 最容易暴露时区问题时区配置不一致,最常见的两个问题是日志和定时任务。日志方面,容器内 date 显示北京时间,但应用日志仍然是 UTC,通常说明应用运行时没有读取系统时区,或者日志框架单独配置了时区。cron 方面,容器里的 cron 会按容器系统时区执行。如果容器实际是 UTC,而你按北京时间写了 crontab,任务就会偏 8 小时。可以先在容器里确认时间:docker exec -it container-name date也可以看时区文件:docker exec -it container-name sh -c 'date && ls -l /etc/localtime'如果安装了 tzdata,还可以检查:zdump Asia/Shanghai | head应用运行时也可能有自己的时区规则容器系统时区正确,不代表应用一定正确。Java、Node.js、Python 都可能有自己的处理方式。JavaJava 常见做法是设置 JVM 参数:-Duser.timezone=Asia/ShanghaiSpring Boot 项目还可能涉及 Jackson 序列化时区、数据库连接时区等配置。如果接口返回时间偏移,不能只看容器的 date。Node.jsNode.js 会受 TZ 环境变量影响,但不同运行环境和镜像差异较大。建议在启动前设置:TZ=Asia/Shanghai node server.js如果项目使用 dayjs、moment、luxon 这类库,还要确认是否启用了对应的 timezone 插件或配置。PythonPython 的 datetime.now()、time.localtime() 会受系统时区影响,但推荐业务代码使用明确的 timezone-aware datetime,避免混用 naive datetime。例如在 Python 3.9+ 中可以使用:from zoneinfo import ZoneInfofrom datetime import datetimenow = datetime.now(ZoneInfo("Asia/Shanghai"))这样代码表达更清楚,也不完全依赖容器系统配置。NTP 负责校准时间,不负责选择时区NTP 解决的是“时间准不准”,不是“显示哪个时区”。容器通常不需要单独跑 NTP 客户端,因为容器共享宿主机内核时间,宿主机时间同步正常,容器拿到的时间基准也正常。如果容器时间整体漂移,应该优先检查宿主机或节点的 NTP、chrony、systemd-timesyncd 配置。容器里单独跑 NTP 反而会增加权限和运维复杂度。推荐做法如果只是普通业务容器,推荐这样处理:镜像中安装 tzdata;使用 ENV TZ=Asia/Shanghai 或部署文件传入 TZ;不默认依赖宿主机 /etc/localtime;Java、Node.js、Python 等运行时单独确认时区行为;用 docker exec date、应用日志、cron 执行时间一起验证。容器时区配置看起来只是一个小参数,但它会影响日志排查、定时任务、数据审计和用户看到的时间。最怕的不是用 UTC 或北京时间,而是系统里同时混着几套时间规则。
服务端阅读 06月19日 19:34

Docker 容器成本优化有哪些实用方法?

Docker 容器成本优化不能只盯着“少跑几个容器”。真正花钱的地方通常藏在镜像存储、节点空转、日志膨胀、网络出口、过度预留和不合理扩缩容里。比较稳妥的做法是先看账单和监控,再决定优化顺序。先把镜像做小镜像越大,构建、拉取、存储和发布都会变慢,也会增加镜像仓库费用。常见做法有三类:使用更轻的基础镜像,例如 alpine、slim 或 distroless;用多阶段构建,只把运行时真正需要的二进制、依赖和配置复制到最终镜像;清理构建缓存、包管理器缓存、临时文件,避免把测试文件、源码和文档一起打进生产镜像。不过轻量镜像不是无脑选择。比如 Alpine 使用 musl libc,某些依赖在兼容性和性能上可能踩坑。更稳的做法是对核心服务做一次启动耗时、镜像体积和运行稳定性的对比,再决定是否切换。管好镜像仓库生命周期很多团队只优化 Dockerfile,却忘了 registry 也在持续花钱。每次 CI/CD 都推一个新 tag,半年后镜像仓库里可能堆着几千个历史版本。可以设置镜像生命周期策略:生产镜像保留最近 N 个稳定版本;开发、测试、PR 临时镜像设置较短过期时间;未被部署引用的镜像定期清理;大镜像单独告警,避免某次构建把体积突然拉高。如果使用 Harbor、ECR、GCR、ACR 等仓库,通常都支持保留规则或自动清理策略。注意不要只按 tag 删除,最好确认当前集群、回滚版本和灾备流程不会依赖这些镜像。正确设置 requests 和 limits在 Kubernetes 里,成本浪费最常见的来源之一是资源申请不准。requests 决定调度时预留多少资源,limits 决定容器最多能用多少资源。如果 requests 设得太高,节点看起来已经满了,但真实 CPU 使用率可能只有 20%。这会导致集群不断扩容,钱花在空转节点上。反过来,如果设得太低,Pod 容易被挤在一起,出现 CPU 抢占、内存 OOM 或延迟抖动。建议做法是:根据最近 7 到 30 天的真实监控数据设置 requests;CPU requests 可以相对保守,CPU limits 不一定必须设置得很死;内存 limit 要更谨慎,因为超过后可能直接 OOMKilled;对不同服务分层,核心链路比离线任务留更多余量。有条件的话,可以配合 VPA 或成本分析工具给出建议值,但不要让它在生产环境里随意自动改核心服务配置。自动扩缩容要设好上下限HPA、KEDA、Cluster Autoscaler 可以让容器数量和节点数量跟随负载变化,但配置不好也会浪费钱。关键是三个参数:最小副本数、最大副本数和扩缩容指标。最小副本数太高,低峰期也会空跑;太低,流量上来时冷启动又可能扛不住。最大副本数太高,异常流量或错误指标可能把成本瞬间拉爆。比较实用的做法是:核心在线服务保留合理的最低副本数;后台任务、消费型服务按队列长度或事件数扩缩容;设置最大副本数,避免异常流量导致无限扩容;配置缩容冷却时间,防止频繁扩缩容造成抖动。扩缩容不是为了“永远省钱”,而是在低峰少花钱、高峰不崩。提高节点装箱率容器成本优化还有一个很容易被忽略的词:bin packing,也就是把 Pod 更合理地装进节点里。如果每个服务的 requests 都偏大,或者节点规格选得不合适,就会出现很多碎片资源:这个节点还剩一点 CPU,那个节点还剩一点内存,但都不足以再调度一个 Pod。结果就是集群明明总体资源没用完,却还要继续加节点。可以从这些方向优化:选择更匹配业务负载的节点规格;把 CPU 密集型和内存密集型服务合理混部;使用 node affinity、taints、tolerations 控制关键服务位置;对离线任务使用低优先级,避免抢占在线服务资源;定期查看节点利用率和不可调度 Pod 的原因。Kubernetes 的调度不是魔法,它只能根据你填的 requests 做判断,所以前面的资源设置会直接影响装箱率。使用 Spot 或抢占式实例要留后手Spot、Preemptible、竞价实例通常能明显降低节点成本,适合跑可重试、可中断、无状态或离线计算任务。但它们的风险也很明确:实例可能随时被回收。如果把核心数据库、关键在线服务、长时间不可重试任务放上去,省下来的钱可能不够一次故障损失。更稳的使用方式是:在线核心服务优先跑在按量或预留实例上;批处理、CI、异步消费、数据处理放到 Spot 节点池;配置 PodDisruptionBudget,避免一次回收影响太多副本;应用层支持重试、断点续跑和幂等处理;保留一定按量节点兜底。Spot 是降成本工具,不是免费午餐。做 rightsizing,不要长期用大规格很多容器一开始为了省事会给很大的 CPU、内存和节点规格,后面业务稳定了却没人再回头看。这类“历史遗留余量”会持续烧钱。Rightsizing 的思路是把实际使用量、峰值、SLO 和资源配置放在一起看:长期 CPU 使用率很低的服务,可以下调 requests;内存稳定且峰值清晰的服务,可以收紧 limit;节点长期低利用率,可以换更小规格或减少节点数;有明显周期波动的业务,用定时扩缩容比固定高配更划算。不要只看平均值。平均 CPU 10% 的服务,可能每天有 10 分钟冲到 90%。优化前要看 P95、P99 和业务高峰窗口。控制日志、存储和网络出口费用容器本身不贵,旁边的配套资源经常更贵。日志是典型例子。默认把 debug 日志全量打到集中式日志系统,时间一长,采集、索引和存储都会变成大头。可以按环境和服务级别调整日志等级,给高频日志做采样,设置合理保留周期。存储也类似。共享数据卷可以避免重复存储,但要注意容量、快照、备份和 IOPS 是否过度配置。临时文件、缓存目录、构建产物最好有明确清理策略。网络出口费用更容易被低估。跨可用区、跨地域、出公网传输都可能收费。如果服务频繁拉取大镜像、跨区访问对象存储,账单会很难看。可以通过就近部署、镜像缓存、私有网络访问和减少跨区调用来控制成本。清理资源要安全docker system prune、清理未使用镜像和删除停止容器确实能释放空间,但生产环境不能随手执行。更安全的做法是:docker system dfdocker image prune -a --filter "until=168h"docker container prune --filter "until=168h"先查看占用,再按时间窗口清理。不要在不了解依赖的情况下删除 volume,因为数据卷里可能有业务数据。Kubernetes 环境下,也要区分节点本地缓存、PVC、镜像缓存和日志文件,清理策略不能一刀切。用监控和成本分摊定位问题没有成本归因,优化只能靠猜。建议至少按 namespace、应用、团队、环境打标签,把 CPU、内存、存储、日志、网络和节点费用分摊到具体业务。常见观察指标包括:Pod 的 CPU、内存 requests 与真实使用量对比;节点利用率和空闲资源;镜像体积和拉取频率;日志写入量、索引量和保留周期;网络出口流量和跨区流量;每个 namespace 或团队的单位成本。这样才能知道该先优化镜像、节点、日志,还是网络。很多时候,最值得动手的不是技术上最酷的部分,而是账单里增长最快的那一项。Docker 和 Kubernetes 场景有什么不同如果只是单机 Docker,重点通常是镜像体积、容器数量、资源限制、磁盘清理和日志轮转。如果是 Kubernetes,成本优化会多出调度和集群层面的内容:requests/limits、HPA、VPA、Cluster Autoscaler、节点池、Pod 分布、PDB、namespace 成本分摊、Spot 节点池等都要一起看。所以 Docker 容器成本优化可以按这个顺序推进:先减小镜像和仓库存储,再校准资源申请,然后优化扩缩容和节点装箱率,最后处理日志、存储、网络出口和成本归因。这样改动风险比较低,也更容易看到真实账单变化。
服务端阅读 06月19日 19:34

Docker 容器灾难恢复计划要备份和演练什么?

Docker 容器灾难恢复计划,不能只写一句“定期备份镜像和数据卷”。真正出问题时,决定恢复速度的往往不是镜像在不在,而是配置能不能还原、数据是不是一致、依赖服务有没有顺序、账号密钥是否还能用,以及团队是否知道第一步该做什么。一个可执行的 Docker 灾备方案,至少要回答三个问题:丢了什么能恢复、多久能恢复、最多能接受丢多少数据。先定清楚 RTO 和 RPO灾难恢复计划先别急着写命令,先定两个指标:RTO(恢复时间目标):服务中断后,最多允许多久恢复。例如官网 30 分钟、内部报表 4 小时。RPO(恢复点目标):最多允许丢多少数据。例如订单库最多丢 5 分钟数据,日志系统可以丢 1 小时。这两个值会直接影响备份频率、存储成本和架构复杂度。RPO 要求越小,越不能只靠每天一次的文件备份,通常需要数据库主从、增量备份、对象存储版本控制或跨区域复制。RTO 要求越短,就越依赖自动化脚本、预热环境和清晰的恢复 runbook。Docker 灾备到底要备份什么很多事故恢复失败,是因为只备份了镜像,却漏掉了运行时配置和数据。Docker 环境至少要覆盖下面几类资产。镜像和镜像仓库镜像可以用 docker save 导出:docker save -o app-web.tar registry.example.com/app/web:2026-06-01docker load -i app-web.tar但 docker save/load 更适合少量镜像或离线环境兜底,不适合作为长期主备方案。它不会替你管理镜像版本、扫描漏洞或清理过期层,也不解决服务如何重新跑起来。更稳妥的方式是维护私有镜像仓库,并备份 registry 存储后端、访问凭证、复制策略和保留规则。编排配置和启动参数恢复容器不能靠记忆。下面这些配置都应该进入版本管理或安全备份:docker-compose.yml、.env、override 文件;Kubernetes 的 Deployment、StatefulSet、Service、Ingress、ConfigMap、Secret、PVC 等 YAML;容器启动参数,例如端口映射、网络、挂载路径、健康检查、重启策略;Nginx、网关、服务发现、负载均衡配置;定时任务、消费者、后台 worker 的启动方式;CI/CD 部署脚本和环境变量模板。如果历史服务没有 compose 文件,可以用下面的命令把现有容器配置先导出来,作为整理依据:docker inspect app-web > app-web.inspect.jsondocker inspect 更像事故调查记录,真正可维护的恢复配置应该沉淀为 Compose、Helm Chart、Kustomize 或 Terraform 等可重复执行的文件。数据卷、挂载目录和上传文件容器本身应该尽量无状态,真正要命的是 volume、数据库和用户上传文件。Docker volume 可以用临时容器打包:docker run --rm -v app_data:/data -v /backup:/backup alpine tar czf /backup/app_data_$(date +%F).tar.gz -C /data .恢复时反向解包:docker run --rm -v app_data:/data -v /backup:/backup alpine tar xzf /backup/app_data_2026-06-01.tar.gz -C /data如果 volume 中存的是数据库文件,不建议在数据库运行时直接 tar 目录。MySQL、PostgreSQL、MongoDB、Redis 都应该使用各自的备份工具或快照机制,例如 mysqldump、pg_dump、WAL 归档、逻辑备份、存储卷快照等。数据库、外部依赖和密钥应用能不能恢复,还取决于 MySQL、PostgreSQL、Redis、消息队列、对象存储、CDN、DNS、证书、OAuth 回调地址、支付和短信服务。建议维护一张依赖关系图:哪个容器依赖哪个数据库、队列、存储桶、域名和密钥。恢复时先恢复底层依赖,再恢复业务服务。.env、Kubernetes Secret、TLS 证书、JWT 密钥、数据库密码不能和普通配置一样随便放进仓库。它们需要加密备份,并明确谁有权限解密。灾备演练时要验证密钥是否能被正确拉取,而不是只验证文件存在。恢复流程要写成 runbook灾难发生时,没人愿意在凌晨临时翻聊天记录。恢复流程应该写成 runbook,按步骤执行:确认事故范围:单容器异常、宿主机损坏、机房故障,还是镜像仓库不可用。冻结现场信息:保留日志、容器 inspect、宿主机磁盘和网络状态。准备目标环境:确认 Docker 版本、内核参数、磁盘挂载、网络、防火墙、时区和系统依赖。恢复镜像:优先从镜像仓库拉取固定 tag 或 digest,必要时使用 docker load。恢复配置:应用 compose、Kubernetes manifests、环境变量、Secret、证书和网关配置。恢复数据:先恢复数据库,再恢复 volume、上传文件、对象存储索引等业务数据。按依赖顺序启动服务:数据库、缓存、队列、后端服务、前端网关、定时任务依次恢复。验证功能:检查健康接口、登录、下单、上传、异步任务、回调等关键路径。切换流量:通过 DNS、负载均衡、网关或 Kubernetes Service 将流量切到恢复环境。复盘记录:记录实际 RTO、实际 RPO、失败步骤和需要补齐的自动化脚本。容器状态是 running 不代表业务已经恢复。真正的验证应该来自业务探针,例如能否创建订单、能否写入数据库、队列是否消费、文件是否能访问。高可用设计不能等灾难后再补备份解决的是“坏了以后能不能回来”,高可用解决的是“坏的时候能不能少中断”。Docker 单机部署至少要配置重启策略、健康检查、磁盘和 Docker daemon 监控,并把日志采集到集中系统。如果业务要求更高,应该考虑多节点和跨区域:使用 Kubernetes、Docker Swarm 或云容器服务做多副本调度;数据库采用主从复制、跨可用区部署或托管数据库;镜像仓库做跨区域复制;对象存储开启版本控制和跨区域复制;流量入口支持 DNS Failover 或负载均衡故障转移。跨区域灾备要特别小心数据一致性。应用服务跨区域比较容易,数据库跨区域才是难点。同步复制延迟低但成本高,异步复制便宜但可能丢数据,这要回到 RPO 来取舍。备份是否可用,必须靠演练证明没有恢复演练的备份,只能算心理安慰。建议至少做三类测试:备份完整性检查、局部恢复演练、全链路灾备演练。演练时要记录实际 RTO、实际 RPO、失败步骤、人工操作和业务验证结果。如果每次演练都要靠某个老员工“凭经验操作”,这本身就是风险。灾备计划应该让新同事也能按文档恢复到可用状态。监控和告警也属于灾备的一部分灾备不是从服务挂掉才开始。建议监控容器重启次数、退出码、健康检查状态、宿主机 CPU/内存/磁盘/inode、Docker daemon 状态、镜像仓库拉取失败率、数据库复制延迟、备份任务成功率、队列积压和关键接口成功率。备份任务也要有告警。最糟糕的情况不是备份失败,而是备份失败了三个月没人知道。常见坑只备份容器,不备份数据卷:容器能启动,但业务数据没了。只备份数据,不备份配置:数据在,但服务跑不起来。镜像使用 latest 标签:恢复时拉到的可能不是事故前版本。数据库热备方式不对:直接压缩运行中的数据目录,恢复后数据损坏。Secret 没有备份或无法解密:服务启动后连不上数据库和第三方接口。没有依赖顺序:应用先启动,数据库和队列没恢复,导致大量报错。没有恢复验证:容器显示 running,但核心业务路径不可用。备份和生产在同一台机器:宿主机磁盘坏了,备份也一起没了。一份可落地的 Docker 灾备清单RTO、RPO 已按业务等级定义;镜像使用固定 tag 或 digest,并有可用镜像仓库;Compose、Kubernetes manifests、环境变量模板进入版本管理;Secret、证书、Token 有加密备份和恢复权限说明;volume、上传文件、对象存储有独立备份策略;数据库使用官方工具或可靠快照备份;依赖关系图清楚标出数据库、队列、缓存、外部接口;恢复 runbook 写明执行顺序、命令、负责人和验证方式;备份任务、复制延迟、容器健康状态都有监控告警;至少定期做一次局部恢复演练,核心系统做全链路演练。Docker 容器灾难恢复计划的重点,不是把所有命令都背下来,而是把“能不能恢复”变成一件可验证、可重复、可交接的事。镜像、配置、数据、密钥、依赖和演练缺一不可。
服务端阅读 06月19日 19:07

Docker 容器安全扫描怎么做?工具怎么选?

容器安全扫描的核心,是在镜像进入生产环境前尽早发现风险:基础镜像有没有 CVE,应用依赖有没有漏洞,构建文件里有没有把密钥写进去,许可证会不会带来合规问题。它只适用于自己有权构建、维护或部署的镜像和仓库,属于防御性安全检查,不是拿工具去扫描别人的系统。容器安全扫描到底要扫什么?只扫“镜像漏洞”远远不够。一次比较完整的容器安全扫描,通常会覆盖这些内容:| 扫描对象 | 重点看什么 | 常见处理方式 || --- | --- | --- || 操作系统包 | glibc、openssl、curl、apt/apk/yum 包里的 CVE | 更新基础镜像、升级系统包、换更小的基础镜像 || 应用依赖 | npm、pip、Maven、Go module、Ruby gem 等依赖漏洞 | 升级依赖、替换废弃库、锁定安全版本 || 敏感信息 | Token、私钥、数据库密码、云厂商 AK/SK | 立即撤销泄露凭据,改用 Secret Manager 或 CI 密钥注入 || Dockerfile 和 IaC | root 用户运行、特权模式、暴露多余端口、危险挂载 | 调整 Dockerfile、Kubernetes YAML、Helm Chart、Terraform 配置 || SBOM | 镜像里到底包含哪些包、版本和来源 | 生成 CycloneDX 或 SPDX 清单,便于审计和追踪 || License | GPL、AGPL、商业限制许可证等 | 按公司策略拦截或人工确认 || 签名和来源 | 镜像是否来自可信构建流程,是否被篡改 | 使用 cosign 等工具签名和验签 |如果团队只在发布前跑一次漏洞扫描,很容易漏掉两类问题:一类是密钥、配置和许可证风险,另一类是镜像发布后新披露的 CVE。因此扫描不应该只发生在构建阶段,还要覆盖仓库中的存量镜像和生产准入环节。常用工具怎么选?| 工具 | 特点 | 更适合的场景 | 注意点 || --- | --- | --- | --- || Docker Scout | Docker 官方工具,和 Docker CLI、Docker Hub、Docker Desktop 集成较好,能给出基础镜像更新建议 | 已经使用 Docker 官方生态,希望快速看到镜像风险和修复建议 | 企业级策略、跨平台治理能力要看套餐和使用方式 || Trivy | 开源,能扫镜像、文件系统、Git 仓库、Kubernetes 配置、IaC、Secret、SBOM、License | 中小团队、CI/CD 集成、想用一个工具覆盖多类风险 | 默认规则很多,建议按项目配置忽略规则和严重级别 || Grype | Anchore 开源漏洞扫描器,常和 Syft 搭配生成 SBOM | 想先生成 SBOM,再基于 SBOM 做漏洞分析的团队 | Secret、IaC 不是它的核心能力,需要配合其他工具 || Clair | 开源镜像漏洞扫描服务,适合接入镜像仓库 | 自建 Registry、需要服务化扫描能力 | 主要聚焦镜像包漏洞,落地和维护成本比 CLI 工具高 || Anchore | 企业级供应链安全平台,包含策略、SBOM、合规和治理能力 | 大型团队、多业务线、需要统一策略管理和审计 | 成本和平台建设复杂度更高 || Snyk | 商业服务,依赖漏洞、容器、IaC、许可证和开发者工作流体验较成熟 | 已经重度使用 GitHub/GitLab/Jira,希望把修复流转起来 | 扫描效果和可用功能与套餐相关 |简单选型可以这样看:个人项目或小团队先用 Trivy;偏 SBOM 工作流可用 Syft + Grype;已经在 Docker Hub 和 Docker Desktop 里协作,可以先接 Docker Scout;需要企业级策略、审计和工单流转,再评估 Anchore 或 Snyk;自建镜像仓库并愿意维护扫描服务,可以考虑 Clair。CI/CD 里怎么设置拦截规则?安全扫描最怕两个极端:要么只出报告不拦截,大家慢慢无视;要么所有漏洞都拦,最后流水线天天红。比较稳的做法是分层处理。可以先设置这些阈值:Critical 必须阻断发布,尤其是有可用修复版本、可远程利用、出现在运行路径上的漏洞。High 默认阻断,但允许短期例外;例外必须写明原因、负责人和过期时间。Medium 进入缺陷池,结合是否暴露在公网、是否运行在生产环境决定修复优先级。Secret 命中直接阻断,并且不能只删除代码,还要轮换已经泄露的密钥。License 命中按公司策略处理,例如 AGPL、未知许可证进入人工审批。基础镜像过旧要提醒或阻断,例如超过 30-60 天没有重建,哪怕应用代码没变也要重新构建。一个常见的 Trivy CI 命令大概是这样:trivy image --severity HIGH,CRITICAL --exit-code 1 your-image:tag但生产里不要只靠一条命令。更合理的是把策略写进 CI 模板:哪些严重级别失败、哪些目录跳过、哪些漏洞暂时忽略、忽略到什么时候失效。这样规则才不会散落在每个仓库里。如何处理误报和“看起来没法修”的漏洞?容器扫描会遇到误报,尤其是发行版 backport 补丁。比如 Debian 或 Ubuntu 可能已经把修复补丁回合到旧版本包里,但版本号没有升到上游公告里的新版本,工具如果只看版本号,就可能误判。处理方式不是简单把漏洞加入 ignore,而是先确认三件事:漏洞对应的包是否真的存在于最终镜像层里;漏洞代码路径是否会被应用调用;发行版安全公告是否已经说明该版本已修复。如果确实是误报,可以加入忽略文件,但要写清原因和过期时间。没有过期时间的忽略规则,最后往往会变成“永久免死金牌”。还有一种情况是漏洞暂时没有修复版本。这时可以做风险缓解:移除不需要的包,关闭相关功能,限制容器权限,缩小网络访问范围,或者换一个维护更及时的基础镜像。基础镜像更新和重建为什么重要?很多团队以为应用代码没变,就不用重新构建镜像。实际不是这样。基础镜像里的 openssl、glibc、ca-certificates 等包会持续更新,旧镜像即使昨天还是安全的,今天也可能因为新披露的 CVE 变成高危。建议把基础镜像治理纳入日常流程:优先使用官方、可信、仍在维护的基础镜像;尽量固定 digest,避免同一个 tag 在不同时间指向不同内容;定期刷新基础镜像并重新构建业务镜像;使用多阶段构建,最终镜像只保留运行所需文件;能用 slim、distroless、alpine 时再用,不要为了小而牺牲兼容性和可维护性;删除构建工具、包管理缓存和临时文件,减少攻击面。修复漏洞不一定是“在容器里 apt upgrade 一下”。更推荐修改 Dockerfile 或基础镜像版本,然后重新构建、扫描、签名和发布,保证过程可追踪。签名、准入控制和运行时扫描怎么配合?镜像扫描解决的是“镜像里有什么风险”,签名解决的是“这个镜像是不是可信流程产物”,准入控制解决的是“能不能进入集群”。三者最好连起来。常见链路是:CI 构建镜像;生成 SBOM;执行漏洞、Secret、IaC、License 扫描;达到策略阈值后用 cosign 签名;推送到镜像仓库;Kubernetes 准入控制检查签名、镜像来源、扫描结果和基础安全配置;不满足条件的镜像禁止部署。准入控制可以用 Kyverno、OPA Gatekeeper、Connaisseur 或云厂商自带策略能力。规则不必一开始写得很重,可以先从“禁止 latest 标签”“必须来自可信 Registry”“必须非 root 运行”“必须有签名”这些低争议规则开始。运行时扫描也有边界。镜像扫描看不到容器启动后的异常行为,例如进程被注入、容器逃逸尝试、异常网络连接、运行时挂载了危险目录。这部分要靠 Falco、eBPF Runtime Security、Kubernetes Audit、云安全产品或主机入侵检测来补齐。所以,容器安全不是某一个工具的事。镜像扫描负责提前发现已知风险,CI 策略负责把风险挡在发布前,签名和准入控制负责防止不可信镜像进入环境,运行时监控负责发现上线后的异常行为。把这几步串起来,才算真正把 Docker 容器安全扫描落到了工程流程里。
服务端阅读 06月19日 18:55

Docker 容器与 Kubernetes 是什么关系?

Docker 容器和 Kubernetes 不是替代关系。Docker 更像开发者打包、构建、运行容器的一套工具,Kubernetes 是在一组机器上管理容器的编排系统。生产集群里,Kubernetes 通常不会直接调用 Docker CLI,而是通过 CRI 调用 containerd、CRI-O 这类运行时,再由 runc 创建真正的 Linux 容器进程。还有一个容易误解的点:Kubernetes 不再内置 dockershim,不等于 Docker 镜像不能用了。Docker 构建出的镜像只要符合 OCI 标准,containerd 和 CRI-O 仍然可以正常拉取、运行。Docker 负责什么?日常说 Docker,通常混着指几件事:Docker CLI / Dockerfile / BuildKit:写 Dockerfile、构建镜像、推送到镜像仓库;Docker Engine:在本机管理容器的守护进程;containerd / runc:拉取镜像、管理容器生命周期、创建容器进程的底层组件。本地开发时,Docker Desktop 或 Docker Engine 很方便。一个 docker build 生成镜像,一个 docker run 就能验证服务能不能跑起来。它主要解决的是“怎么把应用和依赖打成一个可迁移的包”。Kubernetes 负责什么?Kubernetes 关心的是另一层问题:当容器不只一个,而是跑在几十台、几百台机器上时,谁来决定它们放在哪台机器?挂了谁来拉起?流量怎么进来?副本怎么扩容?这些才是 Kubernetes 的核心职责:调度:根据资源、亲和性、污点等规则把 Pod 放到合适节点;自愈:容器或节点异常时重新创建 Pod;扩缩容:按副本数或指标调整服务规模;服务发现与负载均衡:用 Service、Ingress 等把流量导向后端 Pod;配置与发布管理:配合 ConfigMap、Secret、Deployment 做滚动发布和回滚。所以,Docker 解决的是“怎么把应用装进容器并运行”,Kubernetes 解决的是“怎么在集群里可靠地管理大量容器”。Docker 和 Kubernetes 怎么衔接?Kubernetes 不会直接执行 docker run。它通过 CRI(Container Runtime Interface) 和节点上的容器运行时通信。常见运行时是 containerd 和 CRI-O。运行时再调用更底层的 runc 创建容器进程。早期 Kubernetes 为了兼容 Docker Engine,内置过一个 dockershim 适配层。Kubernetes v1.20 开始弃用 dockershim,v1.24 正式移除。移除的是内置 Docker Engine 适配层,不是 Docker 镜像格式。现在大多数新集群会直接使用 containerd。Docker Engine 本身不是 CRI 运行时;如果确实要让 Kubernetes 继续对接 Docker Engine,需要额外安装 cri-dockerd 来做适配。实际项目里怎么分工?常见流程是:开发者写 Dockerfile;用 Docker CLI 或 CI 中的 BuildKit 构建镜像;把镜像推到镜像仓库;Kubernetes 从仓库拉取镜像;节点上的 containerd 运行容器;Kubernetes 负责调度、扩容、重启和对外暴露服务。本地开发继续用 Docker 很正常,因为它简单、反馈快。生产环境通常把 Docker 的“构建镜像”能力留在开发机或 CI,把“运行和编排”交给 Kubernetes + containerd。这样分工更清楚,也更符合现在 Kubernetes 集群的主流做法。
服务端阅读 06月19日 18:55

Docker 容器版本管理如何避免 latest 和回滚事故?

Docker 容器版本管理的关键不是给镜像随手打个 v1.0.0,而是让每一次发布都能回答三件事:运行的到底是哪份镜像、它从哪次代码构建出来、出问题时能不能快速回到上一版。镜像标签怎么设计?推荐同时使用三类标签:不可变发布标签:1.8.3 或 v1.8.3,一旦推送后禁止覆盖。可追踪构建标签:1.8.3-git.ab12cd3-build.4821,把 semver、Git SHA、CI build id 绑在一起。晋级标签:dev、staging、prod 只表示当前环境正在验证或运行哪个版本,不要把它当长期版本号。latest 可以用于本地测试,但生产环境尽量不要使用。它会随着推送变化,同一个部署文件今天和明天拉到的可能不是同一份镜像,排查事故时会很痛苦。为什么要固定 digest?标签可以被覆盖,digest 不会。部署到生产时最好写成:image: registry.example.com/app/api:1.8.3@sha256:xxxx标签方便人读,digest 保证机器拉到的字节内容一致。Kubernetes、Docker Compose、Helm 都可以使用 digest pinning,这也是做可重复部署的底线。CI/CD 应该怎么发版?常见流程是:代码合并后构建镜像,打上 semver + Git SHA + build id,生成 SBOM,使用 cosign 签名,再推送到 registry。镜像通过测试后,不要重新构建一份“生产镜像”,而是把同一个 digest 从 staging 晋级到 prod。发版记录也要跟上。release notes 至少写清楚变更内容、镜像 digest、数据库迁移、配置变动和回滚方式。出了问题时,靠“我记得好像是上周那个包”基本等于没有版本管理。环境标签有哪些坑?dev、staging、prod 这类标签适合表达环境状态,但它们天然是可变的。如果团队直接部署 app:prod,又没有记录它当时指向哪个 digest,回滚时很容易回到错误版本。更稳的做法是:环境标签只做索引,部署清单仍固定具体版本和 digest。回滚怎么做?Docker Compose 可以把镜像改回上一版后执行:docker compose pulldocker compose up -dKubernetes 更推荐保留 Deployment 修订记录:kubectl rollout undo deployment/apikubectl rollout status deployment/api前提是上一版镜像还在 registry 里。所以 registry retention 不能只按“保留最近 N 天”粗暴清理,至少要保留当前线上版本、上一稳定版、最近若干个发布版,以及审计要求范围内的镜像。安全和治理要补哪些?版本管理不只是回滚,还包括供应链安全。建议为镜像生成 SBOM,用 cosign 或类似工具签名,并在部署阶段校验签名。旧镜像要定期清理,但不能删掉仍被生产、回滚或审计依赖的版本。一句话,生产环境的 Docker 版本管理要做到:标签可读、digest 可验、构建可追踪、晋级不重构、回滚有旧包、清理有规则。latest 很省事,但它省掉的,往往是事故发生后最需要的证据。
服务端阅读 06月19日 16:50

Docker自动化测试CI怎么稳定落地?

很多团队把测试搬进 Docker 后,第一反应是“终于不用在 CI 机器上装一堆依赖了”。但只把 npm test 或 pytest 放进容器,还不能算真正的 Docker 自动化测试。更关键的是:测试依赖怎么启动、数据库怎么隔离、报告怎么拿出来、并行任务怎么互不影响,以及失败后现场是否还能定位。Docker 适合放在哪些测试环节里Docker 最适合解决两类问题:环境不一致和依赖难复现。本地能过、CI 挂掉,往往不是代码突然变坏,而是 Node、JDK、浏览器、数据库版本或系统库不一样。把测试环境写进镜像后,测试运行条件就从“某台机器刚好装好了”变成“镜像里明确声明了”。常见做法可以分成四层:单元测试:在测试镜像中运行 Jest、Vitest、pytest、JUnit 等,不依赖外部服务,执行最快。集成测试:用 Docker Compose 或 Testcontainers 启动数据库、Redis、MQ、对象存储等依赖,验证真实交互。E2E / 浏览器测试:用 Selenium Grid、Playwright 容器或内置浏览器镜像跑完整页面流程。性能测试:用 JMeter、k6 或 Locust 容器生成压力,配合独立网络和报告目录保存结果。这几层不要混在一个命令里。单元测试应该几分钟内结束;集成测试可以慢一点,但要稳定;E2E 和性能测试成本高,通常放在合并前、夜间任务或发布前流水线里。单元测试:先把运行环境固定住单元测试最简单,核心是用测试镜像锁定语言版本和依赖安装方式。比如 Node 项目可以这样写一个多阶段测试镜像:FROM node:20-alpine AS depsWORKDIR /appCOPY package*.json ./RUN npm ciFROM deps AS testCOPY . .ENV NODE_ENV=testCMD npm test -- --runInBandFROM deps AS buildCOPY . .RUN npm run build多阶段构建的好处是,依赖安装、测试、构建可以复用同一套基础层。CI 里缓存 Docker layer 后,速度通常比每次从零安装依赖更稳。对 Java、Go、Python 项目也一样:测试阶段保留测试工具和调试符号,生产阶段只复制构建产物,避免把测试依赖带进运行镜像。单元测试容器里最好显式传入环境变量,例如:docker run --rm \ -e NODE_ENV=test \ -e TZ=Asia/Shanghai \ -v ./reports:/app/reports \ myapp:test-v ./reports:/app/reports 很重要。容器退出后文件系统会消失,测试报告、覆盖率、截图、trace 如果不挂载出来,CI 页面上只能看到一段失败日志,排查会很痛苦。集成测试:Docker Compose 管依赖,健康检查管时机集成测试难点不在测试代码,而在“依赖什么时候真的可用”。数据库容器启动完成不代表可以接收连接,消息队列端口打开也不代表 topic 已经初始化。所以 Compose 文件里应该写 healthcheck,并让测试服务等待依赖健康后再跑。services: db: image: postgres:16-alpine environment: POSTGRES_DB: app_test POSTGRES_USER: test POSTGRES_PASSWORD: test healthcheck: test: pg_isready -U test -d app_test interval: 3s timeout: 3s retries: 20 tmpfs: - /var/lib/postgresql/data redis: image: redis:7-alpine healthcheck: test: redis-cli ping interval: 3s timeout: 3s retries: 20 test: build: context: . target: test depends_on: db: condition: service_healthy redis: condition: service_healthy environment: DATABASE_URL: postgres://test:test@db:5432/app_test REDIS_URL: redis://redis:6379 NODE_ENV: test volumes: - ./reports:/app/reports command: npm run test:integration这里的 tmpfs 会让 PostgreSQL 数据写在内存里,测试结束后自然消失,适合临时数据库。也可以每个测试任务创建随机库名,例如 app_test_${CI_JOB_ID},跑完再 drop。原则只有一个:测试数据必须是一次性的,不能让上一次失败污染下一次结果。如果项目语言支持,Testcontainers 更灵活。它可以在测试代码里按需启动容器,拿到动态端口,再把连接信息注入测试。适合需要并行跑很多测试文件,或每个测试套件都要独立数据库的场景。import { PostgreSqlContainer } from "@testcontainers/postgresql";const db = await new PostgreSqlContainer("postgres:16-alpine").start();process.env.DATABASE_URL = db.getConnectionUri();// run integration testsawait db.stop();Compose 更像“固定一套测试环境”,Testcontainers 更像“测试自己声明需要什么依赖”。团队规模小、依赖固定时用 Compose 就够;测试隔离要求高、并行任务多时,Testcontainers 会更省心。E2E 和浏览器测试:别忘了浏览器也是依赖端到端测试经常因为浏览器版本、字体、系统库不同而误报。把 Playwright 或 Selenium 放进容器,可以把浏览器、驱动和系统依赖一起锁住。Playwright 官方镜像已经带好浏览器:services: app: build: . environment: NODE_ENV: test healthcheck: test: wget -qO- http://localhost:3000/health || exit 1 interval: 5s timeout: 3s retries: 30 e2e: image: mcr.microsoft.com/playwright:v1.44.0-jammy working_dir: /work volumes: - ./:/work - ./reports/e2e:/work/playwright-report environment: BASE_URL: http://app:3000 depends_on: app: condition: service_healthy command: npx playwright test --reporter=htmlSelenium 也类似,可以用 selenium/standalone-chrome 或 Selenium Grid。重点是测试代码访问服务时不要写 localhost:3000。在 Compose 网络里,localhost 指的是当前容器自己,应该用服务名,比如 http://app:3000。E2E 报告建议至少保留三样东西:失败截图、视频或 trace、浏览器控制台日志。很多 UI 问题只看断言信息看不出来,trace 文件往往能省半小时。性能测试:容器化工具不等于随便压测JMeter、Locust、k6 都可以容器化运行,但性能测试要特别注意网络和资源隔离。压测容器和被测服务如果跑在同一台 CI 机器上,CPU 抢占会让结果变形;如果目标是外部测试环境,又要避免多个流水线同时压测同一套服务。Locust 的例子:services: locust: image: locustio/locust:2.31.1 volumes: - ./perf:/mnt/locust - ./reports/perf:/reports environment: TARGET_HOST: http://app:3000 command: -f /mnt/locust/locustfile.py --headless -u 100 -r 10 -t 5m --html /reports/locust.htmlJMeter 也可以把 .jmx 脚本和结果目录挂进去,输出 JTL、HTML 报告。性能测试不要只看平均响应时间,至少要记录 p95、p99、错误率和吞吐量。CI 里可以设置门槛,例如错误率超过 1% 或 p95 超过 800ms 就失败。CI/CD 里怎么串起来一条实用的流水线通常是这样的:stages: - unit - integration - e2e - performanceunit: script: - docker build --target test -t myapp:test . - docker run --rm -v $PWD/reports:/app/reports myapp:testintegration: script: - docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test after_script: - docker compose -f docker-compose.test.yml down -v --remove-orphans--abort-on-container-exit 可以在测试容器结束后停止依赖服务,--exit-code-from test 能把测试失败正确传给 CI。down -v --remove-orphans 则负责清理匿名卷、网络和残留容器。很多“偶发失败”其实是清理不干净造成的,比如旧数据库卷还在、旧网络里有同名服务、上一次的测试容器没退出。并行测试时要避免共享固定资源。常见做法包括:用 COMPOSE_PROJECT_NAME=$CI_JOB_ID 给每个任务生成独立网络和容器名。给数据库名、Redis key 前缀、对象存储 bucket 加随机后缀。不把端口映射到宿主机,容器之间走内部网络,减少端口冲突。报告目录按任务拆开,例如 reports/$CI_NODE_INDEX。Docker 自动化测试的优势把测试放进 Docker 后,收益通常很直接:环境一致:本地、CI、预发用同一份镜像,减少“只在某台机器失败”。依赖可复制:数据库、缓存、浏览器、压测工具都能随测试启动。清理成本低:容器、网络、临时卷可以在任务结束后统一销毁。并行更容易:每个任务一套隔离网络和临时数据库,互不抢状态。报告可沉淀:覆盖率、JUnit XML、HTML 报告、trace 都能通过 volume 交给 CI 归档。这些优势不是 Docker 自动发生的,需要在镜像、网络、数据和报告目录上提前设计。否则容器只是把混乱从宿主机搬进了另一个黑盒。常见坑第一,依赖没健康就开始测。 只写 depends_on 不够,要配 healthcheck,或者在测试入口里等待服务可用。第二,测试数据不隔离。 共享一个长期数据库最容易制造偶发失败。优先用临时库、tmpfs、事务回滚或 Testcontainers。第三,报告留在容器里。 容器退出后现场没了,记得把 reports、coverage、playwright-report 挂载到宿主机或 CI artifact。第四,网络理解错。 Compose 里服务互访用服务名,不是宿主机的 localhost。需要访问宿主机时,再考虑 host.docker.internal 或显式网络配置。第五,清理命令太温柔。 CI 失败后也要执行 docker compose down -v --remove-orphans,否则下一次任务可能接住上一次的脏状态。什么时候不该全都放进容器Docker 很适合标准化测试环境,但不是越多越好。纯函数单元测试如果本机运行只要 10 秒,没必要每次都强制走 Compose。性能测试如果需要稳定数据,也不应该和普通 PR 流水线挤在同一台 runner 上。更合理的方式是分层:提交时跑快速单元测试和必要集成测试,合并前跑 E2E,发布前或定时跑性能测试。最终目标不是“所有测试都容器化”,而是让每类测试都有可复现的环境、清晰的隔离边界和可追踪的结果。做到这三点,Docker 自动化测试才真正能在 CI/CD 里长期稳定运行。
服务端阅读 06月19日 16:48

Next.js 性能优化该从哪些关键指标入手?

Next.js 性能优化最容易踩的坑,是一上来就改配置、加缓存、拆组件,却没有先量化问题。真正有效的做法应该从指标开始:LCP 慢就看首屏图片、字体、服务端响应;INP 差就看客户端 JavaScript、长任务和交互组件;CLS 高就看图片尺寸、广告位、字体切换和异步内容插入。下面这些优化手段不是越多越好,而是要和指标对应起来。先用 Lighthouse、Chrome DevTools、Web Vitals、真实用户监控确认瓶颈,再决定该动哪一块。先测量,再优化性能优化不能只看本地开发环境。本地机器快、网络近、缓存热,很容易把问题藏起来。至少要同时关注两类数据:实验室数据:Lighthouse、WebPageTest、Chrome Performance 面板,适合定位问题。真实用户数据:Web Vitals、RUM、日志平台,适合判断线上用户到底慢在哪里。Next.js 可以上报 Web Vitals:// pages/_app.jsimport { useReportWebVitals } from 'next/web-vitals';export function reportWebVitals(metric) { console.log(metric); // 可以发送到 GA、Sentry、Datadog 或自建埋点服务}export default function App({ Component, pageProps }) { useReportWebVitals(reportWebVitals); return <Component {...pageProps} />;}重点看这些指标:| 指标 | 主要反映 | 常见原因 ||---|---|---|| LCP | 首屏主要内容加载速度 | 大图、慢接口、字体阻塞、服务端响应慢 || INP | 用户交互响应 | 客户端 JS 过重、长任务、重复渲染 || CLS | 页面布局稳定性 | 图片无尺寸、字体切换、异步插入内容 || TTFB | 服务端响应速度 | SSR 过重、缓存缺失、数据库慢查询 |如果没有测量,所谓优化很可能只是把问题从一个地方挪到另一个地方。用动态导入减少首屏 JavaScriptNext.js 会按页面自动做代码分割,但这不代表所有组件都应该进首屏包。图表、富文本编辑器、地图、弹窗、低频面板这类重组件,通常适合动态加载。import dynamic from 'next/dynamic';const ChartPanel = dynamic(() => import('../components/ChartPanel'), { loading: () => <p>图表加载中...</p>, ssr: false,});export default function Dashboard() { return ( <section> <h2>数据概览</h2> <ChartPanel /> </section> );}ssr: false 不是默认答案。它会让组件只在浏览器端渲染,适合强依赖 window、Canvas、地图 SDK 的组件;如果组件内容对 SEO 或首屏体验重要,就不要随手关掉 SSR。更实用的判断方式是:这个组件首屏必须看到吗?用户不操作也会用到吗?如果答案是否定的,可以考虑动态导入。用 next/image 处理 LCP 图片图片是 Next.js 项目里最常见的 LCP 问题。next/image 能自动处理尺寸、格式、懒加载和响应式图片,但首屏大图仍然需要认真配置。import Image from 'next/image';export default function Hero() { return ( <Image src="/hero.jpg" alt="产品首页截图" width={1200} height={630} priority sizes="(max-width: 768px) 100vw, 1200px" /> );}几个细节很关键:首屏最大图片通常是 LCP 元素,应该加 priority。必须提供准确的 width 和 height,避免 CLS。sizes 不要省略,否则浏览器可能下载过大的图片。非首屏图片保持默认懒加载,不要到处加 priority。如果使用远程图片,记得在 next.config.js 里配置允许的图片域名。placeholder="blur" 可以改善观感,但它不是性能银弹。真正决定 LCP 的,还是图片体积、尺寸、CDN、缓存和首屏请求链路。用 next/font 降低字体带来的抖动字体加载慢会影响文本显示,也可能造成布局偏移。next/font 会在构建时优化字体加载,减少额外网络请求,并自动处理字体相关的性能问题。import { Inter } from 'next/font/google';const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter',});export default function RootLayout({ children }) { return ( <html lang="zh-CN" className={inter.variable}> <body>{children}</body> </html> );}中文站点要特别小心字体文件体积。全量中文字体非常大,很多时候系统字体栈反而更稳。如果确实要使用品牌字体,建议做子集化,并观察字体对 LCP 和 CLS 的影响。选对 fetch 缓存和 ISR 策略App Router 里,服务器组件可以直接 fetch 数据。性能好不好,很大程度取决于缓存策略是否匹配业务。async function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['home-data'], }, }).then(res => res.json()); return <div>{data.title}</div>;}常见选择可以这样理解:内容不常变:用缓存或 ISR,减少重复渲染和接口压力。内容按分钟更新:用 revalidate,让页面定期刷新。内容必须实时:用 cache: 'no-store',但要接受更高的 TTFB。需要后台主动刷新:用 tags 配合按需重新验证。不要把所有页面都做 SSR。很多列表页、详情页、营销页并不需要每次请求都重新渲染。对这类页面,ISR 往往比 SSR 更稳,也更省服务器资源。客户端数据用 SWR 或 React Query 管理用户态数据、筛选结果、通知数量、管理后台表格这类内容,通常放在客户端请求更合适。直接用 useEffect 也能写,但缓存、去重、重试和重新验证很快会变得难维护。'use client';import useSWR from 'swr';const fetcher = url => fetch(url).then(res => res.json());export default function UserPanel() { const { data, error, isLoading } = useSWR('/api/user', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败</p>; return <p>{data.name}</p>;}React Query 也适合复杂后台场景,尤其是分页、筛选、乐观更新和失效刷新较多的页面。关键不是选哪个库,而是别让同一个接口在多个组件里重复请求。谨慎使用 Link prefetchNext.js 的 Link 会帮助预取路由资源,通常能提升页面切换速度。import Link from 'next/link';export default function Navigation() { return ( <nav> <Link href="/about">关于我们</Link> <Link href="/reports" prefetch={false}>大型报表</Link> </nav> );}预取不是越多越好。移动端网络差、列表链接很多、目标页面很重时,过度 prefetch 反而会抢占带宽。对低概率点击的链接、大型后台页面、无限列表里的链接,可以考虑关闭预取。用 next/script 控制第三方脚本第三方脚本经常是性能问题的来源,比如统计、客服、广告、A/B 测试和热力图。next/script 的价值在于让脚本加载时机更可控。import Script from 'next/script';export default function Page() { return ( <> <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" /> <Script id="ga" strategy="afterInteractive"> {` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_MEASUREMENT_ID'); `} </Script> </> );}常见策略:beforeInteractive:非常少用,只给真正影响页面初始化的脚本。afterInteractive:页面可交互后加载,适合大多数统计脚本。lazyOnload:浏览器空闲时加载,适合不影响核心流程的脚本。如果一个第三方脚本让 INP 变差,先问它是否真的必须首屏加载。很多脚本延后几秒,业务上没有任何损失。memo 和 useMemo 不是免费午餐React.memo、useMemo、useCallback 可以减少重复计算和重复渲染,但它们也有成本。为了“看起来更专业”到处包一层,通常不会让页面更快。'use client';import { memo, useMemo } from 'react';const ExpensiveList = memo(function ExpensiveList({ items }) { const visibleItems = useMemo(() => { return items.map(item => ({ ...item, label: expensiveFormat(item), })); }, [items]); return <div>{visibleItems.map(item => <p key={item.id}>{item.label}</p>)}</div>;});适合使用它们的场景:计算真的昂贵,比如大数组处理、复杂格式化、图形计算。子组件很重,而且 props 在多数渲染中保持稳定。已经通过 React Profiler 看到重复渲染带来的开销。如果 items 每次渲染都会被重新创建,memo 也救不了。先处理数据结构和状态位置,再考虑记忆化。长列表要虚拟化几千条 DOM 节点同时渲染,浏览器会很吃力。后台表格、聊天记录、日志列表、搜索结果页,都可能需要虚拟化。'use client';import { useRef } from 'react';import { useVirtualizer } from '@tanstack/react-virtual';export default function VirtualList({ items }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, }); return ( <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {virtualizer.getVirtualItems().map(row => ( <div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)`, height: row.size, width: '100%', }} > {items[row.index]} </div> ))} </div> </div> );}虚拟化会增加实现复杂度,也可能影响浏览器搜索、无障碍和滚动定位。数据量不大时,分页或“加载更多”可能更简单。用服务器组件减少客户端边界App Router 默认使用服务器组件。服务器组件不会把组件逻辑发送到浏览器,可以减少客户端 JavaScript,尤其适合纯展示内容、详情页、列表页和数据聚合页面。async function ProductInfo({ id }) { const product = await getProduct(id); return ( <section> <h2>{product.name}</h2> <p>{product.description}</p> </section> );}需要交互的地方再切到客户端组件:'use client';import { useState } from 'react';export function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>;}客户端边界要尽量小。不要因为一个按钮需要交互,就把整个页面都标成 'use client'。边界越大,浏览器要下载和执行的 JavaScript 就越多。用 Streaming 和 Suspense 改善等待体验有些接口就是慢,比如报表、推荐、权限聚合。与其让整页等最慢的接口,不如让重要内容先出来,慢内容放进 Suspense。import { Suspense } from 'react';async function SlowPanel() { const data = await slowFetch(); return <div>{data.title}</div>;}export default function Page() { return ( <main> <h2>账户概览</h2> <Suspense fallback={<p>明细加载中...</p>}> <SlowPanel /> </Suspense> </main> );}Streaming 不能让慢接口变快,但能让用户更早看到可用内容。它适合“部分内容慢,但页面不应该整体卡住”的场景。Redis 缓存适合放在服务端热点路径Next.js 自带缓存能解决很多页面级和请求级问题,但有些业务缓存更适合放到 Redis:排行榜、权限结果、推荐结果、第三方接口响应、数据库聚合结果。import { Redis } from '@upstash/redis';const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN,});export async function getCachedData(key) { const cached = await redis.get(key); if (cached) return cached; const data = await fetchData(); await redis.set(key, data, { ex: 3600 }); return data;}缓存要同时考虑失效策略。没有失效策略的缓存,后面很容易变成线上事故。可以按时间过期,也可以在内容更新时主动清理。用 Bundle Analyzer 找出真正的大包包体积问题不要靠猜。@next/bundle-analyzer 可以直接看到哪些依赖进了客户端包。// next.config.jsconst withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true',});module.exports = withBundleAnalyzer({});运行分析:ANALYZE=true npm run build常见优化方向:替换体积过大的工具库。避免把服务端依赖带进客户端组件。对图表、编辑器、地图做动态导入。检查是否误引入整个组件库或整包图标库。先看分析结果,再决定怎么拆。盲目拆包可能让请求数量变多,收益并不稳定。压缩和构建配置要看 Next.js 版本compress: true 可以启用 Next.js 服务端 gzip 压缩,但如果前面已经有 Nginx、CDN、Vercel 或其他网关做压缩,就要避免重复配置带来的误判。实际线上通常还要确认 Brotli、gzip、缓存头是否生效。// next.config.jsmodule.exports = { compress: true, productionBrowserSourceMaps: false, images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], },};旧文章里常见的 swcMinify: true 需要更新一下:现代 Next.js 已经默认使用 SWC 压缩,较新的版本中这个配置不再需要,甚至可能被提示为无效配置。不要为了“优化”保留过时选项,应该以当前 Next.js 版本文档和构建输出为准。CDN 和缓存头别忽略前端代码写得再好,如果静态资源没有 CDN、缓存头混乱,用户还是会慢。Next.js 构建后的静态资源文件名带 hash,适合长期缓存;HTML 和接口响应则要按业务设置缓存策略。可以重点检查:静态资源是否命中 CDN。JS、CSS、字体、图片是否有合理的 Cache-Control。接口是否重复请求,是否能被服务端缓存。页面是否因为个性化内容过多而无法缓存。性能优化经常不是某一个 React API 的问题,而是浏览器、网络、服务端和缓存一起决定的结果。一个实际排查顺序如果线上 Next.js 页面“感觉很慢”,可以按这个顺序查:看 Web Vitals,确认是 LCP、INP、CLS 还是 TTFB 问题。如果 LCP 慢,先看首屏图片、字体、接口瀑布流和服务端响应。如果 INP 差,用 Performance 面板找长任务,再看客户端包和重复渲染。如果 CLS 高,检查图片尺寸、动态内容插入和字体切换。如果 TTFB 高,检查 SSR、数据库、外部接口、缓存和部署区域。用 Bundle Analyzer 确认客户端包体积,不要凭感觉删依赖。改完后重新测量,并对比真实用户数据。Next.js 性能优化不是把所有功能都打开,而是让每个页面只加载它真正需要的东西。图片、字体、数据缓存、脚本、客户端边界、流式渲染、Redis、CDN、构建产物都值得看,但优先级应该由指标决定。能被测量验证的优化,才算真的优化。
服务端阅读 06月19日 16:48

Next.js 数据获取方法该怎么选才合适?

很多人查 Next.js 数据获取方法时,最容易混在一起的是两件事:Pages Router 的 getStaticProps / getServerSideProps,和 App Router 里的服务器组件 fetch。它们不是同一套 API,缓存规则也不完全一样。选错了,轻则页面更新不及时,重则把本来能静态缓存的页面做成每次请求都跑服务器。先分清两套路由如果项目还在 pages/ 目录里,数据获取主要靠 Pages Router 的三个函数:getStaticProps、getServerSideProps、getStaticPaths。如果项目使用 app/ 目录,优先在 Server Component 里直接 await fetch(),再配合 cache、next.revalidate、next.tags、revalidateTag 和 revalidatePath 控制缓存。客户端交互数据再交给 useEffect、SWR 或 TanStack Query。Pages Router:三个函数各管一类场景getStaticProps:构建时生成,适合可公开缓存的数据getStaticProps 只用于 pages/ 下的页面文件。它在服务端运行,不会进浏览器 bundle;生产环境默认在 next build 时执行,返回的 props 会生成 HTML 和 JSON。用户通过 next/link 跳转时,Next.js 读取这份 JSON,不会在浏览器里重新执行 getStaticProps。export async function getStaticProps() { const post = await getPost() return { props: { post }, revalidate: 60, notFound: !post, }}export default function Page({ post }) { return <article>{post.title}</article>}它适合博客、文档、商品详情这类“不需要根据当前用户变化”的页面。revalidate 开启 ISR 后,旧页面会先继续可用,Next.js 在后台重新生成新版本。不要把 token、内部权限信息放进 props,因为这些数据会随初始 HTML 暴露给客户端。getServerSideProps:每次请求执行,适合强实时或个性化页面getServerSideProps 也只能从 pages/ 页面导出。它在每次请求时运行,可以读取 req、res、cookies、headers、query、params,所以适合用户中心、权限页面、AB 实验、地理位置相关内容。export async function getServerSideProps({ req }) { const token = req.cookies.token const profile = await getProfile(token) return { props: { profile } }}代价也明显:每次访问都要等服务端取数和渲染。能用 getStaticProps + revalidate 解决的内容,不要习惯性上 SSR。确实要缓存 SSR 响应时,可以在 res 上设置 Cache-Control,但优先判断 ISR 是否已经够用。getStaticPaths:给动态路由提前列路径动态路由如果配合 getStaticProps,就需要 getStaticPaths 告诉 Next.js 哪些路径要预生成。export async function getStaticPaths() { const posts = await getPosts() return { paths: posts.map((post) => ({ params: { slug: post.slug } })), fallback: 'blocking', }}export async function getStaticProps({ params }) { const post = await getPost(params.slug) return { props: { post }, revalidate: 300 }}fallback 的选择很关键:| fallback | 行为 | 适合场景 ||---|---|---|| false | 没在 paths 里的页面直接 404 | 页面数量少,构建时能全部生成 || true | 先返回 fallback 页面,再在后台生成真实页面 | 有自定义骨架屏,能处理临时空状态 || 'blocking' | 首次访问时服务端生成,生成完再返回 HTML | 详情页很多,又希望首屏就是完整 HTML |SEO 页面通常更偏向 false 或 'blocking'。如果用 true,页面组件要处理 router.isFallback,否则用户可能先看到缺数据的内容。App Router:默认在服务器组件里取数App Router 不再使用 getStaticProps 和 getServerSideProps。在 app/ 下,页面、布局和服务器组件可以直接写成 async 函数。export default async function Page() { const res = await fetch('https://api.example.com/posts', { next: { revalidate: 300, tags: ['posts'] }, }) if (!res.ok) throw new Error('Failed to fetch posts') const posts = await res.json() return <PostList posts={posts} />}这段代码运行在服务端,浏览器拿到的是渲染结果,不会把取数逻辑直接打包给客户端。Server Component 里相同 URL、相同配置的 GET fetch 在一次服务端渲染过程中会被自动 memoize,同一个请求树里多处调用通常只会真正请求一次。App Router 的 fetch 缓存别按旧印象理解Next.js 扩展了服务端 fetch。这里的 cache 不是浏览器 HTTP 缓存,而是 Next.js 服务端缓存策略。await fetch(url, { cache: 'force-cache' })await fetch(url, { cache: 'no-store' })await fetch(url, { next: { revalidate: 60 } })await fetch(url, { next: { tags: ['posts'] } })常用规则可以这样记:默认是 auto no cache:开发环境通常每次请求源站;生产构建时,如果路由能静态预渲染,可能在 build 阶段取一次并进入静态结果;如果路由使用了 cookies、headers、searchParams 等请求时 API,通常会在每次请求时取数。cache: 'force-cache':优先读 Next.js 服务端缓存;没有命中或已过期时再请求源站并更新缓存。cache: 'no-store':每次都请求源站,适合强实时、用户私有、不能共享缓存的数据。next.revalidate: false:语义上接近长期缓存;0 表示不缓存;数字表示最多缓存多少秒。不要同时写 { cache: 'no-store', next: { revalidate: 60 } },这是冲突配置,开发环境会警告,配置会被忽略。同一路由里相同 URL 如果设置了不同的 revalidate,更小的时间会生效;单个 fetch 的 revalidate 低于路由默认值时,也会拉低整个路由的重新验证间隔。如果项目启用了较新的 Cache Components 模型,'use cache'、cacheLife()、cacheTag() 会成为更明确的缓存表达方式;但在大量 App Router 项目里,fetch 的 cache、next.revalidate、next.tags 仍然是最常见的取数入口。revalidate、tags、revalidateTag 和 revalidatePath 怎么配合next.revalidate 解决的是“多久自动刷新一次”。如果内容由 CMS webhook、后台发布、管理端操作触发更新,就需要按需重新验证。// app/blog/page.jsexport default async function BlogPage() { const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, }).then((res) => res.json()) return <PostList posts={posts} />}然后在 Server Action 或 Route Handler 里触发:'use server'import { revalidatePath, revalidateTag } from 'next/cache'export async function publishPost() { await savePost() revalidateTag('posts', 'max') revalidatePath('/blog')}两者用途不同:revalidateTag('posts', 'max'):让所有使用 posts 标签的数据变旧。推荐传第二个参数 'max',它采用 stale-while-revalidate 语义:下次访问先可用旧内容,同时后台刷新。revalidatePath('/blog'):让指定页面或布局路径重新验证。传动态路由模式时要带类型,例如 revalidatePath('/blog/[slug]', 'page')。只调用 revalidatePath('/blog') 不会自动刷新其他也用了 posts 标签的页面;只调用 revalidateTag('posts', 'max') 也不等于指定某个路径重新生成。需要一致性时两个一起用。标签也有限制:自定义 tag 最长 256 个字符,一次最多 128 个。tag 名大小写敏感,最好用稳定的业务名,比如 post:${id}、posts、category:${slug}。Suspense 和 Streaming:解决“慢数据拖慢整页”App Router 里可以把慢数据拆到子组件,用 <Suspense> 包起来。静态外壳先返回,慢组件完成后再流式补上。import { Suspense } from 'react'async function Comments({ postId }) { const comments = await fetch(`https://api.example.com/posts/${postId}/comments`, { cache: 'no-store', }).then((res) => res.json()) return <CommentList comments={comments} />}export default function Page({ params }) { return ( <> <PostDetail id={params.id} /> <Suspense fallback={<p>评论加载中...</p>}> <Comments postId={params.id} /> </Suspense> </> )}注意,Suspense 本身不是“强制动态渲染”的开关。它负责 fallback 和 streaming;是否缓存、是否每次请求取数,还要看组件里的数据访问方式,比如 no-store、请求时 API,或 Cache Components 下是否使用了 'use cache'。客户端数据获取:只在需要浏览器状态时使用不是所有数据都应该放到客户端取。客户端取数会晚于 HTML,到达首屏时更容易出现 loading,也不利于 SEO。它适合这些场景:依赖浏览器状态、用户操作频繁、需要焦点重试、轮询、乐观更新,或者数据完全不影响搜索索引。useEffect:够简单,但很多事要自己处理'use client'import { useEffect, useState } from 'react'export default function Profile() { const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { let ignore = false fetch('/api/profile') .then((res) => { if (!res.ok) throw new Error('Request failed') return res.json() }) .then((json) => !ignore && setData(json)) .catch((err) => !ignore && setError(err)) .finally(() => !ignore && setLoading(false)) return () => { ignore = true } }, []) if (loading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <pre>{JSON.stringify(data, null, 2)}</pre>}useEffect 胜在没有依赖,缺点是缓存、去重、重试、竞态处理都要自己写。页面稍微复杂一点,SWR 或 TanStack Query 通常更稳。SWR:适合轻量客户端缓存'use client'import useSWR from 'swr'const fetcher = (url) => fetch(url).then((res) => res.json())export default function UserCard() { const { data, error, isLoading } = useSWR('/api/user', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }) if (isLoading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <p>{data.name}</p>}SWR 的心智模型很直接:先用缓存,后台重新验证。适合用户信息、通知数量、列表筛选这类客户端体验数据。TanStack Query:适合复杂交互和服务端状态'use client'import { useQuery } from '@tanstack/react-query'function getUser() { return fetch('/api/user').then((res) => { if (!res.ok) throw new Error('Request failed') return res.json() })}export default function UserPanel() { const { data, error, isLoading } = useQuery({ queryKey: ['user'], queryFn: getUser, staleTime: 60_000, gcTime: 300_000, }) if (isLoading) return <p>加载中...</p> if (error) return <p>加载失败</p> return <p>{data.name}</p>}TanStack Query 更适合有分页、筛选、mutation、乐观更新、失效联动的后台系统。用 v5 时是 gcTime,老文章里的 cacheTime 是 v4 叫法。错误和加载状态要按路由类型处理Pages Router 里,getStaticProps 可以返回 notFound 或 redirect,getServerSideProps 抛错会走 500 页面。页面组件里的客户端请求还要自己渲染 loading 和 error。App Router 里,常用文件约定更清楚:loading.js / loading.tsx:当前路由段的加载 UI。error.js / error.tsx:当前路由段的错误边界,必须是 Client Component。not-found.js / not-found.tsx:配合 notFound() 渲染 404。<Suspense fallback={...}>:给局部慢组件提供 fallback,并支持 streaming。取数代码里不要只写 return res.json()。至少检查 res.ok,否则 404、500 返回的错误 HTML 也可能被当成 JSON 解析,最后报一个很绕的异常。并行数据获取:别无意中写成瀑布流如果两个请求互不依赖,不要先 await A 再 await B。export default async function Dashboard() { const postsPromise = getPosts() const statsPromise = getStats() const userPromise = getUser() const [posts, stats, user] = await Promise.all([ postsPromise, statsPromise, userPromise, ]) return <DashboardView posts={posts} stats={stats} user={user} />}如果 B 依赖 A 的结果,顺序请求没问题;如果只是写起来顺手,就会把 300ms + 500ms 变成 800ms。App Router 里拆组件时也一样,能并行就把 promise 提前创建,慢组件再交给 Suspense。到底该选哪种方法?| 需求 | Pages Router | App Router ||---|---|---|| 内容公开、变化不频繁、重视 SEO | getStaticProps + revalidate | Server Component fetch + next.revalidate 或 force-cache || 动态详情页很多 | getStaticPaths + 'blocking' | generateStaticParams 预生成热门路径,其余按需处理 || 每个用户看到的数据不同 | getServerSideProps | 使用请求时 API,或 cache: 'no-store' 的服务端取数 || 由后台发布触发刷新 | ISR / 按需 revalidate | next.tags + revalidateTag,必要时加 revalidatePath || 浏览器交互驱动的数据 | 客户端请求、SWR、TanStack Query | Client Component + SWR / TanStack Query || 慢数据不想挡住整页 | 自定义 loading 或拆客户端请求 | <Suspense> + Streaming |一句话判断:能静态就别 SSR,能在服务器取就别放客户端,能按标签失效就别全站刷新。Next.js 的数据获取并不难,难的是把“数据什么时候变、给谁看、能不能缓存”这三个问题先想清楚。
服务端阅读 06月19日 16:44

Next.js SEO 优化有哪些关键做法?

Next.js 的 SEO 优化,重点不是在页面里堆 keywords,而是让搜索引擎稳定拿到可理解、可分享、加载快的 HTML。老项目可能还在 Pages Router,用 next/head 管页面头部;新项目更建议用 App Router 的 Metadata API、generateMetadata、sitemap.ts 和 robots.ts 统一处理。如果只做一件事,先保证每个可索引页面都有准确的标题、描述、canonical、结构化数据和可预渲染的正文。搜索引擎能读懂页面,用户点进来后也能快速打开,SEO 才有基础。元数据:新项目优先用 Metadata APIApp Router 里不再建议到处手写 <Head>。站点级信息放在 app/layout.tsx,页面级信息由各页面覆盖。这样标题模板、OG、Twitter、robots、canonical 等配置更集中,也不容易漏。// app/layout.tsximport type { Metadata } from 'next';export const metadata: Metadata = { metadataBase: new URL('https://example.com'), title: { default: 'Example', template: '%s | Example', }, description: 'Example 提供前端工程与 Web 开发内容', openGraph: { type: 'website', siteName: 'Example', locale: 'zh_CN', url: '/', images: [{ url: '/og.png', width: 1200, height: 630, alt: 'Example' }], }, twitter: { card: 'summary_large_image', images: ['/og.png'], }, robots: { index: true, follow: true, googleBot: { index: true, follow: true, 'max-image-preview': 'large', 'max-snippet': -1, }, },};export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="zh-CN"> <body>{children}</body> </html> );}这里故意没有把 keywords 当重点。现代搜索引擎不会因为 meta keywords 写得多就给更高排名,甚至会把堆词看成低质量信号。关键词应该自然出现在标题、首段、正文小标题、图片 alt 和链接上下文里;如果团队内部需要标签,可以保留少量 keywords,但不要指望它解决 SEO。Pages Router 旧项目仍可用 Head如果项目还在 pages/ 目录,next/head 仍然能用。重点是每个页面只输出一组准确的 title、description、canonical、OG 和 Twitter 信息,避免多个组件重复写同一类标签。import Head from 'next/head';export default function BlogPost({ post }) { const url = `https://example.com/blog/${post.slug}`; return ( <> <Head> <title>{post.title} | Example</title> <meta name="description" content={post.excerpt} /> <link rel="canonical" href={url} /> <meta property="og:title" content={post.title} /> <meta property="og:description" content={post.excerpt} /> <meta property="og:image" content={post.image} /> <meta property="og:type" content="article" /> <meta property="og:url" content={url} /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content={post.title} /> <meta name="twitter:description" content={post.excerpt} /> <meta name="twitter:image" content={post.image} /> </Head> <article>{post.content}</article> </> );}动态页面用 generateMetadata 补全搜索结果信息博客详情、商品详情、文档详情这类动态路由,不能只依赖默认 metadata。搜索结果里显示什么,社交软件里展开什么,通常都来自当前数据。// app/blog/[slug]/page.tsximport type { Metadata } from 'next';async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 }, }); if (!res.ok) return null; return res.json();}export async function generateMetadata( { params }: { params: { slug: string } }): Promise<Metadata> { const post = await getPost(params.slug); if (!post) return { title: '文章不存在' }; const url = `/blog/${post.slug}`; return { title: post.title, description: post.excerpt, alternates: { canonical: url }, openGraph: { title: post.title, description: post.excerpt, url, type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: [post.author.name], images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }], }, twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [post.cover], }, };}description 不一定直接影响排名,但会影响搜索结果摘要和点击率。写法上要像一句正常的内容摘要,别塞一串同义词。JSON-LD 让搜索引擎更容易理解页面结构化数据适合文章、产品、课程、面包屑、组织信息等页面。Next.js 里常见做法是在页面组件中输出 JSON-LD。不要虚构评分、作者、发布时间;结构化数据必须和页面可见内容一致。// app/blog/[slug]/page.tsxexport default async function BlogPostPage({ params }: { params: { slug: string } }) { const post = await getPost(params.slug); const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, image: post.cover, author: { '@type': 'Person', name: post.author.name }, datePublished: post.publishedAt, dateModified: post.updatedAt, mainEntityOfPage: `https://example.com/blog/${post.slug}`, }; return ( <article> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <h1>{post.title}</h1> <p>{post.excerpt}</p> </article> );}JSON-LD 不是排名捷径,它更像给搜索引擎的说明书。内容本身薄,结构化数据写得再完整也救不了。SSR、SSG、ISR 要按内容变化频率选Next.js SEO 的优势在于预渲染。搜索引擎拿到的是完整 HTML,而不是等待浏览器执行一堆客户端 JavaScript 后才出现正文。SSG:适合文档、博客、营销页。内容构建时已知,访问速度快,缓存友好。ISR:适合会更新但不要求秒级实时的内容,比如文章、商品列表、公开资料页。SSR:适合强实时内容,比如库存、价格、个性化页面,但要控制接口耗时,否则 TTFB 会拖累体验。App Router 中可以用 revalidate 或 fetch 的 next.revalidate 控制 ISR:export const revalidate = 3600;export default async function Page() { const posts = await fetch('https://api.example.com/posts', { next: { revalidate: 3600 }, }).then((res) => res.json()); return <PostList posts={posts} />;}Pages Router 里的 getStaticProps、getServerSideProps、getStaticPaths 仍然是旧项目的核心选择。迁移到 App Router 后,对应思路变成服务器组件、fetch 缓存、generateStaticParams 和路由段配置。动态路由要让重要页面提前可访问列表页能被抓到,不代表详情页一定能被抓到。动态路由需要稳定的 URL、可发现的内部链接,以及可生成的静态参数。// app/blog/[slug]/page.tsxexport async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then((res) => res.json()); return posts.map((post) => ({ slug: post.slug }));}export const dynamicParams = true;对于内容量很大的网站,不必一次生成所有详情页。可以先生成核心页面,再用 ISR 补齐长尾页面。需要注意的是,低价值、重复或空内容页面不要放进 sitemap,也不要让站内到处链接过去。语义化 HTML 比花哨 DOM 更有用搜索引擎和辅助技术都依赖清晰结构。文章页应使用 article、header、time、section、nav、footer 等语义标签,标题层级不要跳来跳去。页面只有一个 H1,正文区域从 H2 往下组织更稳。<article> <header> <h1>{post.title}</h1> <time dateTime={post.publishedAt}>{post.publishedAtText}</time> </header> <section aria-labelledby="summary-title"> <h2 id="summary-title">核心观点</h2> <p>{post.summary}</p> </section> <footer> <p>作者:{post.author.name}</p> </footer></article>站内链接也要写清楚。了解更多 这种锚文本信息量太低,查看 Next.js Metadata API 示例 会更明确。图片用 next/image,同时写好 alt 和尺寸图片会影响 LCP、CLS 和图片搜索流量。next/image 能处理懒加载、响应式尺寸和格式优化,但前提是你给出正确的宽高、sizes 和描述性 alt。import Image from 'next/image';export function CoverImage({ post }) { return ( <Image src={post.cover} alt={`${post.title} 封面图`} width={1200} height={630} sizes="(max-width: 768px) 100vw, 768px" priority /> );}首屏主图可以用 priority,正文里的普通图片交给默认懒加载。不要为了 SEO 在 alt 里重复堆关键词,描述图片本身就够了。sitemap.ts 和 robots.ts 要跟页面策略一致App Router 可以直接在 app/ 下生成 sitemap 和 robots。它们不是装饰文件,应该和实际索引策略一致:重要页面放进 sitemap,不想被抓取的后台、搜索结果页、重复参数页用 robots 或 noindex 控制。// app/sitemap.tsimport type { MetadataRoute } from 'next';export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getPosts(); const baseUrl = 'https://example.com'; return [ { url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 }, ...posts.map((post) => ({ url: `${baseUrl}/blog/${post.slug}`, lastModified: new Date(post.updatedAt), changeFrequency: 'weekly' as const, priority: 0.8, })), ];}// app/robots.tsimport type { MetadataRoute } from 'next';export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] }, ], sitemap: 'https://example.com/sitemap.xml', };}如果某些页面已经 canonical 到主页面,通常就不该再把大量重复 URL 塞进 sitemap。Core Web Vitals 会影响真实搜索表现SEO 不只看内容,也看体验。Core Web Vitals 里最常见的问题是 LCP 慢、CLS 抖动、INP 差。LCP:首屏主图太大、接口太慢、字体阻塞都会拖慢。用 next/image、服务端渲染、CDN 和合理缓存处理。CLS:图片没写宽高、广告位突然插入、字体切换都会造成布局偏移。INP:客户端 JavaScript 太重、长任务太多,会让交互变钝。能放服务器组件的逻辑别搬到客户端。Pages Router 可以用 reportWebVitals 上报指标;App Router 项目也可以放一个客户端组件专门采集指标,再发送到分析服务。'use client';import { useReportWebVitals } from 'next/web-vitals';export function WebVitalsReporter() { useReportWebVitals((metric) => { navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(metric)); }); return null;}数据要按页面类型看。首页、文章页、商品页的问题往往不一样,把所有页面混在一起看平均值,很容易误判。多语言页面要处理 hreflang、canonical 和路径国际化站点最容易出问题的是重复内容。中文、英文、西语页面如果只是 URL 不同,metadata 却全一样,搜索引擎很难判断该给哪个地区用户展示。export async function generateMetadata({ params }: { params: { locale: string } }) { const t = await getTranslations(params.locale); return { title: t.title, description: t.description, alternates: { canonical: `https://example.com/${params.locale}`, languages: { 'zh-CN': 'https://example.com/zh-CN', en: 'https://example.com/en', es: 'https://example.com/es', }, }, };}hreflang 的核心是互相指向:中文页声明英文页,英文页也要声明中文页。canonical 则指向当前语言的主 URL,不要所有语言都 canonical 到英文页。OG 和 Twitter 决定分享时像不像一个正常页面社交分享本身不是传统排名因素,但会影响点击和传播。文章页至少准备标题、描述、封面图、类型和 URL。封面图建议使用 1200×630,标题不要截断,描述不要和全站默认文案一模一样。App Router 的 metadata 已经能覆盖大多数 OG/Twitter 场景。如果页面有特殊分享图,可以用 opengraph-image.tsx 动态生成,但要注意生成耗时和缓存。一份更贴近项目的检查顺序做 Next.js SEO 时,可以按这个顺序排查:页面是否能在禁用 JavaScript 后看到核心内容。标题、描述、canonical、OG/Twitter 是否按页面动态生成。App Router 是否使用 Metadata API 和 generateMetadata,旧 Pages Router 是否正确使用 Head。详情页是否有 JSON-LD,且数据和页面可见内容一致。重要动态路由是否能通过内部链接和 sitemap 被发现。渲染策略是否合理:静态内容用 SSG/ISR,强实时内容才用 SSR。图片是否使用 next/image,首屏图是否优化 LCP,尺寸是否避免 CLS。sitemap.ts、robots.ts、i18n hreflang、canonical 是否互相一致。Web Vitals 是否按页面类型监控,而不是只看全站平均值。Next.js 已经把很多 SEO 基础能力放进框架里,但框架不会替你判断页面值不值得索引,也不会替你写出有用内容。真正有效的做法,是让 App Router 的元数据、预渲染、结构化数据、语义化 HTML 和性能优化服务于同一个目标:用户打开页面后,看到的正是搜索结果承诺的内容。
服务端阅读 06月19日 16:44

Next.js 生产环境部署怎么选,Vercel、Docker 和自托管差在哪?

Next.js 部署到生产环境,真正难的不是敲哪条命令,而是先判断项目需要哪种运行时。只要页面里有 SSR、Route Handler、Server Action、ISR、默认图片优化或读 cookies,就不能把它当成普通静态站扔到 CDN 上完事;如果只是文档、营销页、博客归档,静态导出反而更省钱、更稳定。下面按生产环境里最常见的几条路来选:Vercel 适合想少管运维的团队;Docker standalone 适合自托管和云容器;PM2 + Nginx 适合传统服务器;output: 'export' 适合纯静态站。选对路线,比后面补十个配置都重要。先按功能选择部署方式| 部署方式 | 适合项目 | Next.js 功能支持 | 主要代价 ||---|---|---|---|| Vercel | App Router、SSR、API、预览环境都要省心 | 支持最好 | 成本和平台绑定更明显 || Node.js 服务 | 普通服务器、PaaS、需要完整 Next.js 能力 | 完整支持 | 要自己管进程、日志、发布回滚 || Docker standalone | Kubernetes、Cloud Run、ECS、Fly.io、Render、自建机器 | 完整支持 | 要维护镜像、环境变量和健康检查 || 静态导出 | 静态页面、SPA、文档站、营销页 | 有限制 | 不支持依赖服务器的特性 || 平台适配器 | Cloudflare、Netlify、AWS Amplify 等 | 取决于平台 | ISR、图片、Edge、缓存行为要逐项确认 |一句话:需要服务端能力就选 Vercel、Node.js 或 Docker;不需要服务端能力才选静态导出。Vercel:最省心的默认选择Vercel 是 Next.js 官方团队维护的平台,GitHub 仓库一连,通常就能自动识别框架、安装依赖、构建并发布。它的价值不只是“能部署”,而是把预览环境、HTTPS、CDN、Serverless/Edge Runtime、环境变量、回滚这些杂事都收在一个流程里。最小化的项目脚本一般这样写:{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }}CLI 部署也很直接:npm i -g vercelvercel loginvercelvercel --prod大多数项目不需要手写 vercel.json。只有在你要改构建命令、设置安全响应头、调整函数超时或指定特殊路由行为时,才建议加配置。例如:{ "framework": "nextjs", "buildCommand": "npm run build", "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "DENY" } ] } ]}上 Vercel 前要重点检查两件事。第一,生产环境变量和预览环境变量要分开,不要把测试库地址带到 Production。第二,项目如果依赖特定区域的数据库,函数区域、数据库区域和用户主要访问区域要尽量靠近,否则页面渲染很快,数据库往返却把 TTFB 拖慢。Docker standalone:自托管最推荐的打包方式如果你要部署到自己的服务器、Kubernetes、Google Cloud Run、AWS ECS、Fly.io 或 Render,优先用 Next.js 的 standalone output。它会在构建时追踪运行所需文件,把最小运行集输出到 .next/standalone,镜像会比“把整个项目和 node_modules 全塞进去”干净很多。先在 next.config.js 里启用:/** @type {import('next').NextConfig} */const nextConfig = { output: 'standalone'}module.exports = nextConfig再用多阶段 Dockerfile:FROM node:22-alpine AS baseWORKDIR /appFROM base AS depsCOPY package.json package-lock.json ./RUN npm ciFROM base AS builderCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run buildFROM base AS runnerENV NODE_ENV=productionENV PORT=3000ENV HOSTNAME=0.0.0.0RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjsCOPY --from=builder /app/public ./publicCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/staticUSER nextjsEXPOSE 3000CMD ["node", "server.js"]这里有个容易踩的坑:.next/standalone 默认不会自动带上 public 和 .next/static。如果这些资源不交给 CDN,就要像上面的 Dockerfile 一样手动复制,否则页面能打开,静态资源却可能 404。如果是 monorepo,Next.js 默认只从项目目录追踪文件。服务端代码如果读取了工作区上层的共享文件,要配置 outputFileTracingRoot,否则本地构建正常,容器里可能找不到文件:const path = require('path')module.exports = { output: 'standalone', outputFileTracingRoot: path.join(__dirname, '../../')}用 Docker Compose 跑单机也可以:services: web: build: . ports: - "3000:3000" environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL} restart: unless-stoppedPM2 + Nginx:传统服务器仍然可用如果项目部署在一台固定 Linux 服务器上,PM2 负责守护 Node 进程,Nginx 负责 HTTPS、反向代理、静态缓存和访问日志,是一套很常见的组合。不用 standalone 时,可以直接跑 next start:// ecosystem.config.jsmodule.exports = { apps: [ { name: 'nextjs-app', script: 'node_modules/next/dist/bin/next', args: 'start -p 3000', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z' } ]}常用命令:npm cinpm run buildpm2 start ecosystem.config.jspm2 statuspm2 logs nextjs-apppm2 reload nextjs-appNginx 反向代理可以这样写:server { listen 80; server_name example.com; location /_next/static/ { proxy_pass http://127.0.0.1:3000; add_header Cache-Control "public, max-age=31536000, immutable"; } location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; }}生产环境还要补 HTTPS、日志轮转、磁盘告警和回滚策略。PM2 能把进程拉起来,但它不等于完整发布系统;构建产物、环境变量、数据库迁移和回滚包仍然要自己管。静态导出:只适合不依赖服务器的页面纯静态站可以用 output: 'export'。从 Next.js 14 开始,老的 next export 已移除,应该通过配置启用静态导出,然后运行 next build。/** @type {import('next').NextConfig} */const nextConfig = { output: 'export', images: { unoptimized: true }}module.exports = nextConfig构建后会生成 out/ 目录,可以放到 GitHub Pages、AWS S3 + CloudFront、Cloudflare Pages、Netlify、Firebase Hosting 或普通 Nginx 静态目录。但静态导出不是“免费获得所有 Next.js 能力”。下面这些能力不适合静态导出:运行时 SSR依赖请求对象的 Route Handlercookies、headers、rewrites、redirectsISR 和按需重新验证默认 next/image 图片优化Server Actions未通过 generateStaticParams() 固定好的动态路由如果项目现在是静态站,但半年后可能要登录态、支付回调、后台预览或个性化渲染,最好一开始就评估迁移成本。静态导出很稳,但边界也很硬。云平台怎么选云平台的选择通常不是技术优劣,而是谁来承担运维成本。AWS Amplify Hosting:适合已经在 AWS 上的团队,Next.js 支持较完整,但要留意 App Router、SSR、图片优化和缓存能力的版本说明。Google Cloud Run:很适合 Docker standalone,按容器扩缩容,发布模型清晰。AWS ECS / Kubernetes:适合已有容器平台的公司,能力强,但发布、扩缩容、日志和监控都要工程化。Fly.io / Render / Railway:比自建机器省心,适合中小项目快速上线。Cloudflare / Netlify:可以跑部分 Next.js 能力,但依赖各自适配器;用到 ISR、Edge Runtime、图片优化、Streaming SSR 时要先做验证。Azure Static Web Apps:更适合静态导出或前后端分离形态。选择云平台时,不要只看“能不能部署成功”。要拿你的真实页面测一次:动态路由、API、图片、缓存、预览环境、环境变量、日志、回滚,这些全过了才算生产可用。CI/CD:先验证,再发布CI/CD 的底线是:依赖安装、类型检查、Lint、测试、构建必须先过,部署只是最后一步。GitHub Actions 可以从这个版本开始:name: build-and-deployon: push: branches: [main]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint --if-present - run: npm test --if-present - run: npm run build如果部署到 Vercel,可以在构建验证后增加 Vercel CLI 的预构建部署;如果部署 Docker,则在构建后推镜像,再由平台滚动更新。无论哪条路,都不要把密钥写在 YAML 里,统一放到 GitHub Secrets 或平台自己的 secret manager。环境变量:区分构建时和运行时Next.js 里环境变量最容易混的地方是 NEXT_PUBLIC_。带这个前缀的变量会被打进浏览器 bundle,适合公开的 API base URL、埋点 ID,不适合数据库密码、JWT secret、第三方私钥。常见做法:# .env.local:本地开发,不提交DATABASE_URL=postgresql://localhost/appNEXT_PUBLIC_SITE_URL=http://localhost:3000# 生产环境:放到平台环境变量或 secret managerDATABASE_URL=postgresql://prod/appNEXT_PUBLIC_SITE_URL=https://example.comDocker 场景还要注意:如果变量在 next build 时就被读取,构建镜像时必须提供;如果只在服务端运行时读取,可以在容器启动时注入。两类变量混在一起,会出现“本地没问题,生产配置没生效”的怪问题。性能配置:别再写 swcMinify老文章里常见的 swcMinify: true 已经不适合新项目。Next.js 13 开始 SWC minify 默认开启;Next.js 15 之后继续把 swcMinify 留在 next.config.js 里,可能会出现 Unrecognized key(s) in object: 'swcMinify' 警告。现在应该直接删掉这项,而不是继续复制旧配置。更值得保留的是这些配置和检查:/** @type {import('next').NextConfig} */const nextConfig = { poweredByHeader: false, productionBrowserSourceMaps: false, images: { formats: ['image/avif', 'image/webp'] }}module.exports = nextConfig还可以做几件更实际的事:用 @next/bundle-analyzer 定期看 bundle,别让后台图表库跑进首页。首屏图片用 next/image 并设置合理的 priority,不要所有图片都抢优先级。_next/static 走长期缓存,HTML 和接口按业务设置缓存。Node 自托管时可以开 compress,但如果 Nginx 或 CDN 已经负责 gzip/br,就不要重复压缩。数据库和服务端渲染区域尽量靠近,很多慢页面不是 JS 慢,而是跨区域查询慢。监控和日志:上线后才知道真问题生产部署至少要能回答三个问题:现在挂没挂、哪里慢、用户报错在哪里。Vercel 项目可以接 Vercel Analytics 和 Speed Insights;自托管项目可以接 Sentry、OpenTelemetry、Prometheus + Grafana 或云厂商 APM。错误监控建议在服务端和浏览器端都接入,尤其是 Route Handler、Server Actions、支付回调这类不容易从页面发现的问题。一个简单的健康检查接口也很有用:// app/api/health/route.tsexport async function GET() { return Response.json({ ok: true, ts: Date.now() })}容器平台可以用它做 readiness/liveness probe。真正的生产检查还应包含数据库、缓存、对象存储等依赖,否则“应用进程活着”和“业务可用”不是一回事。上线前检查清单npm run build 在干净环境里能通过,不依赖本机缓存。Node.js 版本与 Next.js 当前版本要求一致,CI、Docker、服务器不要各用各的版本。生产环境变量已配置,且没有把 secret 打进客户端 bundle。SSR、API、ISR、图片优化、动态路由在目标平台逐项验证过。Docker standalone 已复制 public 和 .next/static,monorepo 已检查文件追踪范围。Nginx/CDN 已配置 HTTPS、缓存、请求体大小、代理头和日志。CI/CD 有测试、构建、回滚和密钥管理,不只是一条 deploy 命令。Sentry/APM/日志/健康检查已接入,告警能找到负责人。静态导出项目确认没有使用 cookies、Server Actions、运行时 API 等服务器特性。Next.js 生产部署没有唯一答案。小团队想省心,Vercel 是最短路径;有容器基础设施,standalone Docker 更容易标准化;只是静态页面,就别硬上 Node 服务。先让部署方式匹配项目功能,再谈优化和自动化,后面的坑会少很多。