Skip to content

本轮为理论笔记与静态审读,示例、安装命令和验收器均未执行。下文命令用于解释步骤,预期结果不代表本轮实测;“历史验收”仅指此前批次记录。阅读和书面练习不需要启动服务。

完整 etcd 配置 CAS 与短租约注册程序 ​

项目合同与依赖 ​

前置:完成键、revision、lease 与 watch。应用是一个配置 CLI:读取配置、按预期 revision 更新、短租约登记工作者,并可监听键变化。Node >=22 仅使用标准库,无 npm 依赖;真正 v3 请求由同版本官方 etcdctl 执行。需要 Docker CLI 与已运行的 showdocs-m4b-etcd-main。默认键 /course/config,所有命令从仓库根目录执行。

退出码是可供脚本使用的合同:读取或成功更新为 0,CAS 比较冲突为 2,参数、Docker 或网络错误为 1。冲突也会输出随后重新读取的值;它不保证是事务失败分支那个瞬间的值,因为二次读取前还可能有其他写者。程序不自动覆盖。ETCD_CONTAINER 可指向独立恢复容器,但不能把任意外部命令插入参数。

完整源码 ​

这是页面与验证共用的同一份源码,不是伪代码。spawnSync 将每个参数独立传给进程,不通过 Shell 拼接;transaction 对 key/value 作 JSON 引号转义,避免换行或引号破坏 txn 的输入语法;revision 要求非负安全整数。大型多语言服务建议用官方 gRPC 客户端,这个封装适合看清协议和构建小工具。

mjs
import { spawn, spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
export function transaction(key, revision, value) {
  if (!Number.isSafeInteger(revision) || revision < 0) throw new Error('invalid revision');
  return `mod(${JSON.stringify(key)}) = "${revision}"\n\nput ${JSON.stringify(key)} ${JSON.stringify(value)}\n\nget ${JSON.stringify(key)}\n\n`;
}
export function ctl(args, input, container = process.env.ETCD_CONTAINER || 'showdocs-m4b-etcd-main') {
  const result = spawnSync('docker', ['exec', '-i', container, 'etcdctl', '--endpoints=http://127.0.0.1:2379', '--dial-timeout=2s', '--command-timeout=3s', ...args], { input, encoding: 'utf8', timeout: 7000 });
  if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr || `exit ${result.status}`);
  return result.stdout;
}
export function read(key) {
  const response = JSON.parse(ctl(['get', key, '-w', 'json']));
  const kv = response.kvs?.[0];
  return { revision: kv?.mod_revision || 0, value: kv ? Buffer.from(kv.value, 'base64').toString() : null, clusterRevision: response.header.revision };
}
export async function main([command, key = '/course/config', value = '', revision = '0']) {
  if (command === 'get') console.log(JSON.stringify(read(key)));
  else if (command === 'cas') {
    const result = JSON.parse(ctl(['txn', '-w', 'json'], transaction(key, Number(revision), value)));
    console.log(JSON.stringify({ updated: Boolean(result.succeeded), current: read(key) }));
    if (!result.succeeded) process.exitCode = 2;
  } else if (command === 'register') {
    // A short lived worker: attach a lease, renew once, then let it expire.
    const grant = ctl(['lease', 'grant', '3']);
    const lease = grant.match(/lease ([0-9a-f]+) granted/)?.[1];
    if (!lease) throw new Error('invalid lease response');
    ctl(['put', key, value, `--lease=${lease}`]);
    ctl(['lease', 'keep-alive', '--once', lease]);
    console.log(JSON.stringify({ registered: key, lease, ttl: 3 }));
  } else if (command === 'watch') {
    const child = spawn('docker', ['exec', process.env.ETCD_CONTAINER || 'showdocs-m4b-etcd-main', 'etcdctl', 'watch', key, '--rev', revision === '0' ? '1' : revision, '-w', 'json'], { stdio: 'inherit' });
    child.on('exit', code => { process.exitCode = code || 0; });
  } else throw new Error('usage: node app.mjs get|cas|register|watch KEY [VALUE] [REVISION]');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(error => { console.error(error.message); process.exitCode = 1; });

单元测试源码覆盖输入转义和非法 revision;历史服务验证器描述竞争、到期、历史与恢复。两者是不同层次的验收设计,本轮都未执行:

mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { transaction } from './app.mjs';
test('CAS compares mod revision and safely quotes untrusted values', () => {
  assert.equal(transaction('/config/demo', 7, 'a\n"b'), 'mod("/config/demo") = "7"\n\nput "/config/demo" "a\\n\\\"b"\n\nget "/config/demo"\n\n');
  assert.throws(() => transaction('/x', -1, 'a'), /revision/);
});

辅助启动脚本与正文使用同一个固定节点参数,供核对文件完整性;不执行它:

ps1
$ErrorActionPreference = 'Stop'
docker run -d --name showdocs-m4b-etcd-main -p 127.0.0.1:22379:2379 -v showdocs-m4b-etcd-data:/etcd-data gcr.io/etcd-development/etcd:v3.6.8 /usr/local/bin/etcd --name main --data-dir /etcd-data --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://127.0.0.1:2379 --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://main:2380 --initial-cluster main=http://main:2380 --initial-cluster-token showdocs-m4b-etcd-source
if ($LASTEXITCODE -ne 0) { throw 'etcd start failed' }

运行与预期 ​

powershell
node --test examples/etcd/app.test.mjs
node examples/etcd/app.mjs get /course/config
node examples/etcd/app.mjs cas /course/config first 0
node examples/etcd/app.mjs get /course/config

不存在的键可用 revision=0 创建;若前面练习已创建则第二条会返回实际 revision,使用它替代 0。更新成功输出 updated=true。两个终端同时使用相同 revision 更新为不同值,预期一个成功,另一个 updated=false、退出 2。PowerShell 用 $LASTEXITCODE 检查,Bash 用 $?。register 写入后只续约一次,是短命工作者模拟;要观察到期先开 watch。

验收器与失败 ​

下面是历史验收器的使用条件与副作用说明,不是阅读前提:仅在首次创建恢复容器之前运行 node examples/etcd/verify.mjs。它会清理本任务 /course/ 前缀、并行 CAS、等待 lease 删除、压缩历史、保存快照,创建独立恢复卷与新 cluster,最后停止源确认应用非零并重启源。恢复容器已存在时先核对并按运维清理指定恢复资源,不能直接覆盖。它生成的 course.db 是本机实验产物,不应提交到源码库。

mjs
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { ctl, read } from './app.mjs';
const events = [];
function docker(args) {
  const r = spawnSync('docker', args, { encoding: 'utf8', timeout: 30000 });
  events.push({ command: ['docker', ...args].join(' '), exit: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim() });
  assert.equal(r.status, 0, r.stderr); return r.stdout.trim();
}
function app(args) {
  return new Promise(resolve => {
    const child = spawn(process.execPath, ['examples/etcd/app.mjs', ...args]); let out = ''; let err = '';
    child.stdout.on('data', d => { out += d; }); child.stderr.on('data', d => { err += d; });
    child.on('exit', exit => { events.push({ command: `node examples/etcd/app.mjs ${args.join(' ')}`, exit, stdout: out.trim(), stderr: err.trim() }); resolve({ exit, out }); });
  });
}
try {
  docker(['exec', 'showdocs-m4b-etcd-main', 'etcd', '--version']);
  ctl(['del', '/course/', '--prefix']);
  ctl(['put', '/course/config', 'initial']);
  const old = read('/course/config');
  const race = await Promise.all([app(['cas', '/course/config', 'writer-A', String(old.revision)]), app(['cas', '/course/config', 'writer-B', String(old.revision)])]);
  assert.deepEqual(race.map(r => r.exit).sort(), [0, 2]);
  assert.equal(JSON.parse(race.find(r => r.exit === 2).out).updated, false);
  const from = read('/course/config').clusterRevision + 1;
  const watch = spawn('docker', ['exec', 'showdocs-m4b-etcd-main', 'etcdctl', 'watch', '/course/worker', '--rev', String(from), '-w', 'json']);
  let output = ''; watch.stdout.on('data', d => { output += d; });
  await app(['register', '/course/worker', 'worker-A']);
  const deadline = Date.now() + 15000;
  while (!output.includes('"type":1') && Date.now() < deadline) await new Promise(r => setTimeout(r, 300));
  watch.kill();
  assert.match(output, /"type":1/); assert.equal(read('/course/worker').value, null);
  events.push({ command: 'lease grant/keep-alive once/expiry/watch', exit: 0, stdout: output.trim() });
  const current = read('/course/config'); ctl(['compact', String(current.clusterRevision)]);
  const compact = spawnSync('docker', ['exec', 'showdocs-m4b-etcd-main', 'etcdctl', 'get', '/course/config', '--rev', String(old.revision)], { encoding: 'utf8' });
  assert.notEqual(compact.status, 0); assert.match(compact.stderr, /compacted/);
  events.push({ command: 'get historical compacted revision', exit: compact.status, stderr: compact.stderr.trim() });
  docker(['exec', 'showdocs-m4b-etcd-main', 'etcdctl', 'snapshot', 'save', '/etcd-data/course.db']);
  docker(['cp', 'showdocs-m4b-etcd-main:/etcd-data/course.db', 'examples/etcd/course.db']);
  docker(['create', '--name', 'showdocs-m4b-etcd-restore', '-v', 'showdocs-m4b-etcd-restore-data:/restore', 'gcr.io/etcd-development/etcd:v3.6.8', '/usr/local/bin/etcd', '--name', 'restored', '--data-dir', '/restore/data', '--listen-client-urls', 'http://0.0.0.0:2379', '--advertise-client-urls', 'http://127.0.0.1:2379', '--listen-peer-urls', 'http://0.0.0.0:2380', '--initial-advertise-peer-urls', 'http://restored:2380', '--initial-cluster', 'restored=http://restored:2380', '--initial-cluster-token', 'showdocs-m4b-etcd-restored']);
  docker(['run', '--rm', '--name', 'showdocs-m4b-etcd-import', '-v', 'showdocs-m4b-etcd-restore-data:/restore', '-v', `${process.cwd()}/examples/etcd:/backup:ro`, 'gcr.io/etcd-development/etcd:v3.6.8', '/usr/local/bin/etcdutl', 'snapshot', 'restore', '/backup/course.db', '--data-dir', '/restore/data', '--name', 'restored', '--initial-cluster', 'restored=http://restored:2380', '--initial-advertise-peer-urls', 'http://restored:2380', '--initial-cluster-token', 'showdocs-m4b-etcd-restored']);
  docker(['start', 'showdocs-m4b-etcd-restore']);
  await new Promise(r => setTimeout(r, 2000));
  const restored = JSON.parse(ctl(['get', '/course/config', '-w', 'json'], undefined, 'showdocs-m4b-etcd-restore'));
  assert.equal(Buffer.from(restored.kvs[0].value, 'base64').toString(), current.value);
  assert.notEqual(restored.header.cluster_id, JSON.parse(ctl(['get', '/course/config', '-w', 'json'])).header.cluster_id);
  events.push({ command: 'independent restored cluster read', exit: 0, stdout: JSON.stringify(restored) });
  docker(['stop', 'showdocs-m4b-etcd-main']);
  assert.notEqual((await app(['get', '/course/config'])).exit, 0);
  docker(['start', 'showdocs-m4b-etcd-main']);
  await new Promise(r => setTimeout(r, 1500)); assert.equal(read('/course/config').value, current.value);
  events.push({ command: 'restart source and read', exit: 0, stdout: JSON.stringify(read('/course/config')) });
} finally {
  writeFileSync('migration/knowledge-rebuild/m4-next/coordination-storage/etcd-results.json', JSON.stringify(events, null, 2));
}

进程设置拨号与命令超时;目标停止应明确失败,不能把空字符串默认为“没有配置”。watch 是长期进程,可 Ctrl+C 结束;封装不提供生产级自动重新同步,断线恢复算法在监听解释。Node 解析 JSON 数值有安全整数上限,示例规模以内有效;长期大 revision 应采用能保留 64 位整数的客户端。

练习与参考解法 ​

题目:给配置加校验,拒绝空值,并保证冲突退出码仍为 2。先添加测试,再实现;不要对更新自动无限重试。

提示:分别写出空值、CAS 冲突和成功的预期退出码,再定位 CLI 分派分支。

参考答案与边界

在 CLI 分派的 cas 分支校验 value,再生成事务;非法值抛错进入统一退出 1。冲突由 succeeded=false 识别,不能用 stderr 是否为空判断。输入校验保护业务合同,CAS 保护并发,两者都需要。当前程序允许空值,因为 etcd 字节值本身可以为空;练习是业务规则扩展。

继续阅读