柳嘉希

UNSW 信息技术硕士 · 寻找软件开发机会

全栈与后端开发 · API 集成 · 自动化 · AI Agent 学习与实践

Git、GitHub Actions、Shell 与 Docker:从本地开发到 CI/CD 和容器化

一次可靠交付,需要知道这次改了什么、检查了什么、发布了哪个产物,以及失败后怎么恢复。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.yml

src 编译到 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/readiness

git 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'   feature

Merge 可以保留分叉并产生合并提交,也可能直接快进,不是每次都新增合并节点。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=dev

Compose 的 .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。

复习时尝试回答:

  1. 为什么暂存后还要看 diff --cached? 下一次提交来自暂存区,不一定等于工作区。
  2. 为什么 rebase 后 commit ID 改了? 它重新建立提交及其父关系,而不是原地修改显示顺序。
  3. tee 成功为什么不能证明测试成功? 它只是管道末端,必须保留上游失败状态。
  4. 缓存和产物能互相替代吗? 缓存用于可重建内容的加速,产物记录某次执行的输出。
  5. depends_on 为什么不保证运行期间永远连得上数据库? 它处理启动条件,不代替应用连接管理。
  6. 构建参数能安全保存私钥吗? 不能,秘密需要专门的传递与访问机制。
  7. 为什么发布记录需要 digest? tag 可以移动,排查和回滚需要确定的产物身份。
  8. 应用镜像可回退,为什么发布仍可能不可逆? 数据迁移和外部副作用可能已经改变兼容条件。

能解释每一步的输入、产物、权限、失败状态和恢复路径,才算把这些工具连成了一条可理解的交付流程。