一次可靠交付,需要知道这次改了什么、检查了什么、发布了哪个产物,以及失败后怎么恢复。Git、Shell、GitHub Actions 和 Docker 分别承担其中一部分职责,只有把它们连接起来,才能解释“本地正常,线上失败”发生在哪个环节。
本文围绕一个独立的 delivery-demo 练习项目展开。它用 TypeScript 提供健康检查与版本接口,用 PostgreSQL 验证依赖连通性;重点是交付流程,不是完整订单业务。文中的配置不是本作品集当前的部署配置,请放到单独练习目录中使用。
修改代码 → 本地检查 → Git 提交 → PR 检查
↓
合并 main → 再次验证 → 构建并推送镜像 → 记录 digest
↓
部署 → 健康检查 → 观察或回滚1. 先约定交付对象和项目结构
1.1 每个阶段应该产出什么
| 阶段 | 输入 | 输出与失败条件 |
|---|---|---|
| 开发 | 源码、依赖声明 | 可解释的改动;本地检查失败先修复 |
| CI | 指定 Git 提交和锁文件 | 检查结果、测试日志;失败不能继续发布 |
| 构建 | 同一提交、基础镜像、依赖 | 可运行镜像;缺产物或依赖应暴露 |
| 发布 | 已构建镜像 | Registry 中的 digest 与发布记录 |
| 部署 | 指定 digest 和环境配置 | 运行实例;依赖或健康检查失败需处理 |
| 回滚 | 上个可用 digest、兼容数据结构 | 恢复应用版本;不自动撤销数据库变化 |
这里选择 Node.js 24 作为练习运行时,并让本地、CI 和镜像保持同一主版本。主版本一致仍不代表完全可重复;正式发布还需要锁定补丁版本或镜像 digest,并维护升级流程。Node.js 发布与支持状态
1.2 文件之间的约定
delivery-demo/
├─ package.json # scripts and dependencies
├─ package-lock.json # generated once, then committed
├─ tsconfig.json
├─ eslint.config.mjs
├─ src/readiness.ts
├─ src/server.ts
├─ tests/unit.test.mjs
├─ tests/integration.test.mjs
├─ db/migrate.mjs
├─ scripts/check.sh
├─ Dockerfile
├─ .dockerignore
├─ .env.example
├─ compose.yaml
└─ .github/workflows/ci.ymlsrc 编译到 dist;单元测试加载编译后的函数,集成测试使用真实 PostgreSQL。npm run migrate 准备练习表,npm start 执行 dist/server.js。先理解这些约定,再看自动化文件,避免出现“命令都写了,但对应脚本不存在”的情况。
2. Git:跟踪改动,而不只是记住命令
2.1 工作区、暂存区、提交与分支
工作区是当前文件,暂存区描述下一次提交的内容,提交是带父关系的快照。分支名指向某个提交,HEAD 通常指向当前分支;origin/main 是上次获取的远程跟踪状态,不是实时读取服务器的 main。
在已有 main 分支的练习仓库中,可以这样开始一次变更:
git status --short
git switch -c feature/readiness
git diff
git add -p
git diff --cached
git commit -m "feat: add database readiness check"
git push -u origin feature/readinessgit add -p 适合选择已跟踪文件中的改动;新文件需要显式 git add path/to/file。提交前检查暂存差异,能避免把调试文件、凭证或另一个任务的改动混在一起。
提交后再修改文件,不会自动改变旧提交。暂存也不是永久备份,仍需要提交或其他明确保存方式。
2.2 fetch、merge 与 rebase 做了什么
git fetch origin 更新本地掌握的远程提交,不会自动把当前分支合并成远程状态。git pull 则还会按参数或配置整合历史,初学时拆开看更容易解释。
假设 main 新增 C,你的功能分支从 B 开始提交了 D:
Before:
A──B──C main
└──D feature
Merge:
A──B──C──M feature
└──D──┘
Rebase:
A──B──C──D' featureMerge 可以保留分叉并产生合并提交,也可能直接快进,不是每次都新增合并节点。Rebase 则重新应用改动,得到新的提交身份 D'。对尚未共享的个人分支,线性历史往往方便;对多人基于其工作的历史,重写需要协调。Git rebase 文档
git fetch origin
git rebase origin/main
# Resolve conflicts, then stage only resolved files.
git add src/server.ts
git rebase --continue
# Alternative while the rebase is still in progress:
# git rebase --abort不要把冲突标记机械删除就当作解决了。应理解两边意图、重新检查结果并运行测试。如果已推送的个人分支必须重写,先确认协作约定,再考虑 --force-with-lease;它比盲目覆盖更有保护,但不是无需协调的许可。
2.3 撤销之前,先确定要撤销哪一层
| 目的 | 常用方式 | 影响 |
|---|---|---|
| 撤回暂存,保留文件修改 | git restore --staged path |
修改暂存区 |
| 丢弃某文件未暂存修改 | git restore path |
会丢弃该工作区差异,先确认已保存需要的内容 |
| 撤销本地最后提交,保留暂存 | git reset --soft HEAD~1 |
移动分支,保留文件及暂存内容 |
| 撤销本地最后提交,取消暂存 | git reset --mixed HEAD~1 |
重置暂存区,保留工作区内容 |
| 撤销已共享的普通提交 | git revert <commit> |
新建反向提交,不删除旧历史 |
reset --hard 还会丢弃跟踪文件修改,并可能删除阻碍恢复的未跟踪路径,不应作为“遇到问题先试一下”的命令。路径形式的 reset 与移动分支的形式也不同。Git reset 与 Git restore
若提交到了错误分支,先为当前提交建立保留分支,再决定 cherry-pick、revert 或其他整理方式。误移动分支后,可以先检查 git reflog,找到提交并创建恢复分支;reflog 是本地且会过期,不能保证找回从未提交或暂存过的内容。Git reflog
3. 最小服务:让后面的命令都有真实对象
3.1 依赖、编译和静态检查
以下内容保存为 package.json。版本范围用于初始化练习项目;第一次安装生成锁文件后应提交它,后续通过 npm ci 安装锁定依赖。
{
"name": "delivery-demo",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24 <25" },
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "node --test tests/unit.test.mjs",
"test:integration": "node --test tests/integration.test.mjs",
"migrate": "node db/migrate.mjs",
"start": "node dist/server.js"
},
"dependencies": { "pg": "^8.13.0" },
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.11.0",
"eslint": "^9.0.0",
"typescript": "^5.9.0",
"typescript-eslint": "^8.0.0"
}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noEmitOnError": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}eslint.config.mjs:
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
);lint 检查代码规则,typecheck 检查类型,build 生成产物,test 检查行为。它们有交集,但一个成功不能替代其余检查。
3.2 就绪判断和 HTTP 入口
src/readiness.ts 把探测结果转换成稳定的响应,不把数据库异常细节返回给调用者:
export async function readiness(probe: () => Promise<unknown>) {
try {
await probe();
return { status: 200, body: { status: "ready" } };
} catch {
return { status: 503, body: { status: "unavailable" } };
}
}src/server.ts 使用 pg 的 PGHOST、PGPORT、PGUSER、PGPASSWORD 和 PGDATABASE 环境变量。连接超时与查询超时让失败有边界:
import { createServer } from "node:http";
import pg from "pg";
import { readiness } from "./readiness.js";
const pool = new pg.Pool({
connectionTimeoutMillis: 2000,
query_timeout: 2000,
});
pool.on("error", () => console.error("database pool error"));
const server = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.method !== "GET") {
res.writeHead(405, { Allow: "GET" }).end();
return;
}
if (req.url === "/healthz") {
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (req.url === "/version") {
res.end(JSON.stringify({ revision: process.env.APP_REVISION ?? "dev" }));
return;
}
if (req.url === "/readyz") {
const result = await readiness(() =>
pool.query("SELECT id FROM service_checks LIMIT 0"),
);
res.statusCode = result.status;
res.end(JSON.stringify(result.body));
return;
}
res.writeHead(404).end(JSON.stringify({ error: "not_found" }));
});
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Invalid PORT");
}
server.listen(port, process.env.HOST ?? "0.0.0.0");
let stopping = false;
function shutdown() {
if (stopping) return;
stopping = true;
const deadline = setTimeout(() => process.exit(1), 10_000);
deadline.unref();
server.close(() => {
void pool.end().then(() => {
clearTimeout(deadline);
}).catch(() => process.exit(1));
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);/healthz 说明 HTTP 进程能响应,/readyz 还验证数据库连接和练习表,/version 关联构建提交。它们不是完整业务测试:数据库可读不代表所有写入权限、容量和业务操作正常。数据库短暂不可用时让就绪探测失败,也不等于必须立即反复重启进程。
3.3 迁移和测试
db/migrate.mjs 创建练习表。真实项目需要带版本记录、并发协调和失败恢复的迁移工具;IF NOT EXISTS 不会修复一个结构错误的已有表。
import pg from "pg";
const pool = new pg.Pool({ connectionTimeoutMillis: 2000 });
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS service_checks (
id UUID PRIMARY KEY,
note TEXT NOT NULL
)
`);
} finally {
await pool.end();
}tests/unit.test.mjs 无需数据库,验证成功和失败映射:
import test from "node:test";
import assert from "node:assert/strict";
import { readiness } from "../dist/readiness.js";
test("successful probe is ready", async () => {
const result = await readiness(async () => undefined);
assert.equal(result.status, 200);
assert.deepEqual(result.body, { status: "ready" });
});
test("failed probe does not expose internal errors", async () => {
const result = await readiness(async () => {
throw new Error("private database details");
});
assert.equal(result.status, 503);
assert.deepEqual(result.body, { status: "unavailable" });
});tests/integration.test.mjs 连接真实练习库,在同一个连接的事务内写入、读取并回滚:
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import pg from "pg";
test("database round trip", async () => {
const pool = new pg.Pool({ connectionTimeoutMillis: 2000 });
let client;
try {
client = await pool.connect();
await client.query("BEGIN");
const id = randomUUID();
await client.query(
"INSERT INTO service_checks (id, note) VALUES ($1, $2)",
[id, "integration-check"],
);
const result = await client.query(
"SELECT note FROM service_checks WHERE id = $1", [id],
);
assert.equal(result.rows[0].note, "integration-check");
} finally {
try {
if (client) await client.query("ROLLBACK");
} finally {
client?.release();
await pool.end();
}
}
});不要对一个连接 BEGIN,又通过连接池随机选择其他连接执行后续 SQL。连接可用、Schema 正确和业务正确也是三个不同的验证层次。
4. Bash:让失败清楚地传到调用方
4.1 标准流、重定向与顺序
进程通常有标准输入 0、标准输出 1、标准错误 2。管道默认传递标准输出,不会自动包含标准错误。
node dist/server.js > app.log 2> error.log
node dist/server.js >> app.log 2>&1第一条分开记录两条输出流;第二条把 stderr 指向当时的 stdout,一起追加到 app.log。> file 2>&1 与 2>&1 > file 的顺序不同,后者不表示所有输出都进文件。上述启动命令是替代方式,不是要同时启动两份服务。
4.2 管道为什么会吞掉测试失败
bash -c 'false | tee /dev/null; printf "status=%s\n" "$?"'
bash -o pipefail -c 'false | tee /dev/null; printf "status=%s\n" "$?"'第一条显示 0,第二条显示 1。默认管道结果通常来自最后一条命令;启用 pipefail 后,前面失败的命令也能影响结果。tee 成功保存日志,不表示测试成功。
set -e 对条件判断、部分 &&/|| 列表和管道等上下文有例外,不能当作完整错误处理。set -u 可发现未定义变量,但可选值仍应使用 ${NAME:-default} 等明确约定。本文脚本使用 Bash,不保证可由 /bin/sh 执行。
4.3 可在本地和 CI 复用的检查脚本
保存为 scripts/check.sh,运行 bash scripts/check.sh:
#!/usr/bin/env bash
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "$project_dir"
mkdir -p reports
trap 'code=$?; printf "check exit=%s\n" "$code" >&2' EXIT
npm run lint 2>&1 | tee reports/lint.log
npm run typecheck 2>&1 | tee reports/typecheck.log
npm run build 2>&1 | tee reports/build.log
npm test 2>&1 | tee reports/unit.log
if [[ "${RUN_INTEGRATION:-0}" == "1" ]]; then
npm run test:integration 2>&1 | tee reports/integration.log
fi脚本先确定项目目录,避免从不同路径调用产生不同结果;有空格的路径通过引用保持为一个参数。构建在测试之前,因为测试引用 dist。它不负责安装依赖或迁移数据库,这些步骤由调用者显式完成。
EXIT trap 输出结果,但不会把失败改成成功。清理临时文件或后台进程时也可以用 trap,前提是只操作脚本自己创建的资源。不要把 || true 加到关键测试后面,只为让流水线继续变绿。
若工具用非零表示正常分支,例如 grep 的 1 表示没有匹配,应显式区分这个状态与文件读取错误,不能把所有非零都忽略。生产日志可能有敏感信息,上传前应检查内容,不能通过 set -x 输出全部凭证和命令展开结果。
5. Git Hooks:更早反馈,但不能替代 CI
pre-commit 可以检查暂存内容,commit-msg 可以检查提交信息,pre-push 可以运行适当范围的测试。快速检查有助于提交习惯;每次提交都启动完整容器集成环境,往往拖慢反馈。
可以把自定义 hooks 保存在版本库的 .githooks。先创建一个只检查暂存差异中空白错误的 .githooks/pre-commit:
#!/usr/bin/env bash
set -euo pipefail
git diff --cached --check再由开发者安装配置:
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit配置不是随着 clone 自动对所有人启用的;可执行权限、解释器与平台差异也要处理。不同 hook 有不同的跳过方式,本地检查不能作为不可绕过的合并门槛。Git hooks 文档
还要注意“检查了什么”:直接 lint 工作区可能验证了未暂存的修复,而将要提交的旧内容仍有问题。关键验证放到服务器上的指定提交,并通过 required checks 或仓库规则控制合并。
6. GitHub Actions:检查的是哪个提交,失败停在哪里
6.1 workflow、job、step 与 runner
workflow 定义触发方式和 jobs;每个 job 在 runner 上执行步骤,step 可以使用 action 或运行命令。独立 jobs 默认可以并行,needs 声明依赖;同一 job 的步骤通常共享工作目录,不能假设另一个 job 自动拥有其文件。
本例将 PR 和 main 推送都做验证,但只允许 main 的 push 发布镜像。PR 检查可能验证的是平台准备的合并提交,不能把它直接当成 main 最终合并后的产物;main 更新后再次验证有意义。
6.2 一份带数据库、失败日志和发布门槛的工作流
保存为 .github/workflows/ci.yml。先提交 package-lock.json,并按仓库规则保护 main。示例采用 GitHub.com 官方 actions 文档中的 v7 主版本标签;正式使用应审核后固定完整提交 SHA,并按目标 runner/GHES 支持情况调整。checkout、setup-node、upload-artifact
name: CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 15
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: demo
POSTGRES_PASSWORD: ci-only-password
POSTGRES_DB: delivery
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U demo -d delivery"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
PGHOST: 127.0.0.1
PGPORT: "5432"
PGUSER: demo
PGPASSWORD: ci-only-password
PGDATABASE: delivery
RUN_INTEGRATION: "1"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm run migrate
- run: bash scripts/check.sh
- name: Preserve check logs
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: check-logs-${{ github.run_id }}-${{ github.run_attempt }}
path: reports/
if-no-files-found: ignore
retention-days: 7
image:
needs: verify
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Build, smoke-check and push
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_ACTOR: ${{ github.actor }}
REPOSITORY: ${{ github.repository }}
REVISION: ${{ github.sha }}
run: |
set -euo pipefail
image="ghcr.io/${REPOSITORY,,}:${REVISION}"
docker build --build-arg "REVISION=$REVISION" -t "$image" .
docker run --rm "$image" node --input-type=module -e \
'await import("pg"); await import("./dist/readiness.js");'
printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GH_ACTOR" --password-stdin
trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT
docker push "$image"
mkdir -p reports
docker image inspect --format '{{index .RepoDigests 0}}' "$image" > reports/image.txt
grep -Eq '^ghcr.io/.+@sha256:[0-9a-f]{64}$' reports/image.txt
printf '%s\n' "$REVISION" > reports/commit.txt
- uses: actions/upload-artifact@v7
with:
name: release-${{ github.sha }}-${{ github.run_attempt }}
path: reports/
retention-days: 30这里的密码只用于临时 CI 数据库。runner 直接运行 npm,所以通过映射端口访问 127.0.0.1;如果 job 自身在容器里运行,通常应通过服务名 postgres 访问。两种网络模式不能混用。GitHub PostgreSQL 服务容器
needs: verify 阻止验证失败后发布。image job 的导入检查只验证运行镜像中模块和产物可加载,不是 HTTP 或数据库端到端测试。workflow_dispatch 在本例只手动验证,不会发布;schedule 可用于定时检查,但也需要明确时区、分支与执行延迟预期。
6.3 cache、artifact 与环境变量的边界
setup-node 的 npm 缓存主要复用下载内容,不能替代 npm ci。缓存丢失时 job 仍应能成功;artifact 则保存这次运行产生的日志、报告或交付记录。GitHub 缓存与产物
在一个 run 步骤里 export NAME=value,不会自动影响后续独立步骤;跨步骤可按需要使用 GITHUB_ENV,跨 job 用 outputs 或 artifact。只传递明确需要的数据,不把整个环境打包上传。
本例取消旧 PR 检查,避免浪费资源,但不自动中断正在执行的发布。部署通常需要单独串行化,也不能假设 concurrency 队列永远保留每一次历史运行。
6.4 PR 代码不是可信的发布程序
外部 PR 可以修改脚本和依赖安装行为。测试它们时不应暴露生产凭证,也不应在具备内部访问能力的共享 runner 上随意执行。不要为拿到 Secrets 就把不可信 PR 改成 pull_request_target 并执行其中代码。
最小化 GITHUB_TOKEN 权限,可信发布 job 才获得 packages:write。来自标题、分支等上下文的值不要直接拼进 Shell 程序,先通过环境变量传递,再正确引用。云部署可以评估基于 OIDC 的短期身份,并限制允许的仓库、分支和环境。GitHub 安全使用指南
7. Docker:构建产物和运行环境分开设计
7.1 镜像、容器、层与构建上下文
镜像描述文件系统层和运行配置;容器是从镜像创建的实例,可以运行或停止。容器写入自己的可写层,不会自动修改镜像;容器删除后,要保留的数据应有明确外部存储。
docker build . 中的点是构建上下文。Dockerfile 的 COPY 从这个范围选择文件,不是任意读取宿主机路径。源码改变可能使后续层需要重建,因此依赖声明通常先于源码复制。
7.2 多阶段 Dockerfile
保存为 Dockerfile。构建阶段安装开发依赖、编译并运行单元测试;运行阶段只复制编译产物、迁移脚本和生产依赖。
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
COPY tests/unit.test.mjs ./tests/unit.test.mjs
RUN npm run build && npm test
FROM node:24-bookworm-slim AS production-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM node:24-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ARG REVISION=dev
ENV APP_REVISION=$REVISION
LABEL org.opencontainers.image.revision=$REVISION
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
COPY --chown=node:node db ./db
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]多阶段构建让编译器和测试文件留在构建阶段,而不是最终镜像。Docker 多阶段构建
CMD 的 exec 形式直接启动 Node,便于接收终止信号;代码随后停止接收连接并释放连接池。EXPOSE 3000 描述端口,不会自动把端口发布到宿主机。非 root 用户也需要正确的文件权限,不能只加 USER 就假设所有目录可写。Dockerfile 参考
7.3 排除上下文、保护秘密与配置
.dockerignore:
node_modules
dist
.git
.env
.env.*
reports
npm-debug.log*构建镜像不需要读取 .env.example,所以此处也排除了它。另在练习仓库的 .gitignore 中排除 node_modules、dist、reports 和真实 .env,但保留 .env.example、源码与锁文件。
ARG 是构建输入,ENV 可以成为运行时默认配置;两者都不是隐藏秘密的存储方式。安装私有包时应使用构建 secret mount 等机制,而不是复制凭证文件后在下一层删除。Docker 构建秘密
tag 可以移动,commit tag 也需要管理策略才能保持不变;digest 则标识具体内容。发布记录应同时保存提交和镜像 digest。共享基础镜像并不能消除 CPU 架构、内核、配置和外部依赖差异;原生模块也需要匹配目标运行环境。
8. Compose:把依赖、就绪与数据生命周期写清楚
8.1 本地环境文件
.env.example 只放练习值,复制成 .env 后使用。正式环境通过部署系统提供凭证,不复用这里的密码。
POSTGRES_PASSWORD=local-demo-only
APP_REVISION=devCompose 的 .env 用于配置插值,不代表自动把所有变量注入容器;下面通过 environment 显式传入。数据库密码单独作为 PGPASSWORD,避免把特殊字符未经编码拼进连接 URL。Compose 变量插值
8.2 应用、一次性迁移和数据库
compose.yaml:
x-app: &app
image: delivery-demo:local
build:
context: .
args:
REVISION: ${APP_REVISION:-dev}
environment:
PGHOST: db
PGPORT: "5432"
PGUSER: demo
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
PGDATABASE: delivery
services:
db:
image: postgres:16
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
POSTGRES_DB: delivery
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U demo -d delivery"]
interval: 5s
timeout: 3s
retries: 10
migrate:
<<: *app
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
api:
<<: *app
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 4s
retries: 10
start_period: 10s
stop_grace_period: 15s
volumes:
db-data:api 使用服务名 db 连接数据库,容器里的 localhost 指向自己。数据库未发布宿主机端口,但同一 Compose 网络中的应用仍能访问它。API 的端口只绑定宿主机回环地址,适合本机演示。
仅启动 db 容器不代表数据库就绪。这里先等 healthcheck,再完成迁移,最后启动 API;应用运行期间的断连仍需要自身恢复逻辑,depends_on 不会持续替它管理连接。Compose 启动顺序
pg_isready 说明数据库在接受连接,不证明应用凭证、表结构和业务查询全部正确,所以仍保留迁移与应用 readiness。
8.3 运行、检查与停止
在创建好以上文件、安装 Node 24 后执行:
npm install
cp .env.example .env
bash scripts/check.sh
docker compose config --quiet
docker compose up -d --build --wait --wait-timeout 120
curl --fail http://127.0.0.1:3000/readyz
curl --fail http://127.0.0.1:3000/version
docker compose ps
docker compose logs --tail=100 api migrate db预期 readiness 返回 ready,version 返回 dev。第一次 npm install 生成 package-lock.json,应将其提交;之后本地和 CI 都可用 npm ci。--wait 依赖服务运行或健康状态,不验证全部业务功能。docker compose up
本例没有把 tests 复制进运行镜像,因此不要在 api 容器中执行 npm test。集成测试由 CI 的主机 Node 进程连接临时数据库完成;镜像构建阶段已经运行单元测试。
docker compose stop 停止容器,down 移除容器和网络,命名卷默认保留;down -v 会删除此项目声明的命名卷和相关匿名卷,应明确接受丢失练习数据库后才使用。卷是持久化位置,不是备份。
Bind mount 适合本地源码热更新,但挂载 /app 可能遮住镜像里的 node_modules 或 dist。开发热更新与部署镜像应分别设计,不能把宿主机依赖直接当作跨平台容器依赖。
9. 发布与回滚:运行的是同一个已知产物
9.1 CI、持续交付与持续部署
CI 提供集成后的自动反馈。持续交付让系统保持可发布,并可在人为批准后部署;持续部署进一步让符合策略的变更自动进入目标环境。工作流里有一个 push 命令,并不表示生产发布已经完成。
前面的 image job 把镜像推入 GHCR 并保存 image.txt。第一次使用还要确认包权限与 Registry 策略;它没有配置你的服务器或部署凭证。测试、预发布和生产应尽量推广同一 digest,环境差异通过外部配置提供,而不是每个环境重新构建一个同名镜像。
9.2 单机 Compose 部署的明确前提
下面是独立的 compose.deploy.yaml,适用于已经有 Docker、Registry 拉取权限和外部反向代理的单机环境。它只从 Registry 拉取镜像,没有 build。数据库卷保留在这台主机,示例不提供高可用或零停机承诺。
x-runtime: &runtime
image: ${APP_IMAGE:?Set APP_IMAGE to a registry digest}
environment:
PGHOST: db
PGPORT: "5432"
PGUSER: demo
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
PGDATABASE: delivery
services:
db:
image: postgres:16
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
POSTGRES_DB: delivery
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U demo -d delivery"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
migrate:
<<: *runtime
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
api:
<<: *runtime
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 4s
retries: 10
start_period: 10s
restart: unless-stopped
stop_grace_period: 15s
volumes:
db-data:保存以下脚本为 scripts/deploy.sh。部署控制器应串行调用,并提前设置 APP_IMAGE、POSTGRES_PASSWORD;APP_IMAGE 使用 image.txt 中已审核的完整引用,不在服务器上挑选随时变化的 latest。
#!/usr/bin/env bash
set -euo pipefail
: "${APP_IMAGE:?Set APP_IMAGE}"
: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}"
if [[ ! "$APP_IMAGE" =~ ^ghcr\.io/.+@sha256:[0-9a-f]{64}$ ]]; then
printf 'APP_IMAGE must be a GHCR digest reference\n' >&2
exit 2
fi
export APP_IMAGE POSTGRES_PASSWORD
compose=(docker compose -p delivery -f compose.deploy.yaml)
"${compose[@]}" config --quiet
"${compose[@]}" pull
"${compose[@]}" up -d --wait --wait-timeout 120 db
"${compose[@]}" run --rm --no-deps migrate
"${compose[@]}" up -d --no-deps --wait --wait-timeout 120 api
curl --fail --silent --show-error --max-time 5 http://127.0.0.1:3000/readyz
curl --fail --silent --show-error --max-time 5 http://127.0.0.1:3000/version脚本从练习项目根目录执行。迁移失败会在替换 API 前退出;新 API 就绪失败也会非零退出,但脚本没有自动恢复旧容器,所以发布控制器必须处理失败。它还需要保存此前的 digest,并观察错误率、延迟和关键业务操作,不能只看到 curl 成功就结束。
数据库镜像主版本和数据目录必须匹配。这里只固定 postgres:16 方便示例,正式环境还应管理 digest 与升级过程;修改 POSTGRES_PASSWORD 环境变量也不会自动修改已有数据卷中的数据库密码。
9.3 回滚应用不等于回滚数据
如果新的应用不健康,且数据库结构仍兼容旧版本,可以把 API 切回事先保存的 PREVIOUS_IMAGE:
: "${PREVIOUS_IMAGE:?Set the previously verified digest}"
APP_IMAGE="$PREVIOUS_IMAGE" docker compose -p delivery -f compose.deploy.yaml \
up -d --no-deps --pull always --wait --wait-timeout 120 api这里不重新执行旧迁移,也不尝试删除新数据;仍需要有效的数据库环境配置。若新迁移删除了旧应用所需的列,仅回退镜像无法恢复服务。更稳妥的演进是先增加兼容字段、回填、切换读写,再在后续版本删除旧结构。
需要零停机、高可用或多实例流量切换时,应另行设计蓝绿、滚动更新和负载均衡。单机 Compose 的替换过程不能直接宣称具备这些能力。
10. 从现象定位到具体环节
| 现象 | 优先检查 | 不应急着做的事 |
|---|---|---|
| 本地通过,CI 失败 | Node 版本、锁文件、Linux 路径大小写、缺失环境变量 | 直接忽略失败步骤 |
| 测试报错但 CI 绿了 | 管道退出状态、continue-on-error、脚本是否覆盖错误码 | 只看最后一行日志 |
| 镜像构建成功,启动失败 | dist 是否存在、运行依赖、入口路径、权限 | 把所有开发工具装回去掩盖缺失文件 |
| 容器连不上数据库 | 服务名、端口、网络、凭证、就绪与迁移日志 | 在容器里继续使用宿主机 localhost |
| 重建后数据不见 | 卷挂载、项目名变化、数据库数据目录 | 反复执行 down -v |
| 发布了错误版本 | digest、提交标签、发布记录、目标主机 | 再推一次 latest 猜测结果 |
| 回滚后仍失败 | Schema 兼容、配置变化、外部依赖 | 假设镜像回退会撤销数据库变化 |
故障排查先保留日志与运行版本,再修改配置。日志中也可能有秘密,不应未经检查把完整环境或数据库连接信息复制到公开 issue。
11. 实践检查与自测
可以在独立练习项目中做以下实验:
- 故意让单元测试失败,确认 check.sh 返回非零,同时 reports 保留日志。
- 把项目路径改成包含空格的目录,确认脚本仍能找到根目录。
- 停止练习数据库,比较 healthz 与 readyz 的差异;恢复后观察连接是否恢复。
- 删除构建产物,验证测试或镜像运行会明确失败,而不是悄悄测试旧代码。
- 对比只改源码和修改锁文件时的构建缓存行为。
- 确认普通 PR 不执行 image job,main 的验证失败也不发布镜像。
- 在一次不兼容数据库迁移前,先写出旧版应用是否还能运行的答案。
- 保留一个已知 digest,在练习环境演练应用回退并核对 version。
复习时尝试回答:
- 为什么暂存后还要看 diff --cached? 下一次提交来自暂存区,不一定等于工作区。
- 为什么 rebase 后 commit ID 改了? 它重新建立提交及其父关系,而不是原地修改显示顺序。
- tee 成功为什么不能证明测试成功? 它只是管道末端,必须保留上游失败状态。
- 缓存和产物能互相替代吗? 缓存用于可重建内容的加速,产物记录某次执行的输出。
- depends_on 为什么不保证运行期间永远连得上数据库? 它处理启动条件,不代替应用连接管理。
- 构建参数能安全保存私钥吗? 不能,秘密需要专门的传递与访问机制。
- 为什么发布记录需要 digest? tag 可以移动,排查和回滚需要确定的产物身份。
- 应用镜像可回退,为什么发布仍可能不可逆? 数据迁移和外部副作用可能已经改变兼容条件。
能解释每一步的输入、产物、权限、失败状态和恢复路径,才算把这些工具连成了一条可理解的交付流程。