consul

Consul #

目录 #


0. 一分钟心智模型 #

Consul = 注册中心 + KV 配置中心 + 健康检查,三合一。

              ┌──────────────── Consul(一个进程,三种能力)────────────────┐
              │  ① 注册表(Catalog)  谁在线、地址、端口、带的 Traefik 标签      │
              │  ② KV 配置中心       config/* 键值,可热改                     │
              │  ③ 健康检查          周期探 /healthz,失败标 critical          │
              └──────────────────────────────────────────────────────────────┘
                     ▲注册+健康     ▲拉/监听KV(热更新)    ▲每5s读注册表
                     │              │                    │
                  业务服务        业务服务            Traefik(consulcatalog)
                (自己注册)      (watch config/)       (从Consul读路由)

和 Traefik docker provider 的根本区别

docker provider:  Traefik 挂 docker.sock,读容器 label   (Traefik 主动看 Docker)
consulcatalog:    服务自己注册到 Consul,Traefik 读 Consul (服务主动报到,Traefik 不碰 Docker)

1. 全景:五项能力一张图(09 demo) #

                       浏览器 http://localhost
                                │
                                ▼
           ┌──────────────────────┐  每5s读注册表  ┌───────────────────────────┐
           │   Traefik :80        │ ◀──────────── │          Consul            │
           │   网关 / 路由         │               │  注册表 · KV配置 · 健康检查 │
           └──┬──────┬──────┬─────┘               └──▲────────▲────────▲───────┘
           /a │  /b  │  /   │(兜底)                   │①注册     │②拉/监听 │每5s探
              ▼      ▼      ▼                         │+健康检查 │KV(热更新)│/healthz
         ┌──────┐┌──────┐┌─────────┐                  │          │          │
         │echo-a││echo-b││ monitor │──────────────────┴──────────┴──────────┘
         └──────┘└──────┘└────┬────┘
                              └─ 读「期望表」↔ Consul实际健康 → 不一致就预警
角色 端口 在哪看
consul 8500 Consul UI http://localhost:8500(Services=注册/健康,Key/Value=配置)
traefik 80 / 8080 Dashboard http://localhost:8080
echo-a / echo-b 8080 经 Traefik /a/b
monitor 9000 治理面板 http://localhost/

五项能力:① 服务注册 ② 服务发现(Traefik 原生对接) ③ KV 配置中心 ④ 配置热更新 ⑤ 健康检查 + 掉线/缺失预警。后两项里"谁没注册"的对账预警是 monitor 自己实现的,非 Consul 原生。


2. 能力一:服务注册与发现 #

2.1 流程 #

 echo-a 启动
    │  ① PUT /v1/agent/service/register {name, addr, port, tags=traefik.*, check=/healthz}
    ▼
 Consul ──登记──▶ 注册表
    ▲
    │  ② Traefik 每5s 拉 catalog(consulcatalog provider)→ 按 tags 生成路由
 Traefik ──▶ /a ⇒ echo-a:8080
    ▲
    │  ③ GET http://localhost/a
 浏览器 ─────────▶ echo-a 响应

关掉服务再开,它会自己重新注册、Traefik 自动恢复路由 —— 这就是"注册与发现"。

2.2 注册代码(Go 官方客户端 github.com/hashicorp/consul/api#

client.Agent().ServiceRegister(&api.AgentServiceRegistration{
    ID:      "echo-a",
    Name:    "echo-a",
    Address: "echo-a",          // 容器网络内可达的地址(=compose 服务名;Windows 上=127.0.0.1)
    Port:    8080,
    Tags:    buildTags(),       // ★ Traefik 路由规则就藏在 tags 里(见 3.2)
    Check: &api.AgentServiceCheck{
        HTTP:                           "http://echo-a:8080/healthz",
        Interval:                       "5s",
        Timeout:                        "2s",
        DeregisterCriticalServiceAfter: "1m",   // 挂满 1 分钟自动从注册表摘除
    },
})
  • 启动时注册一次 + reRegisterLoop 每 30s 重注册(防丢)。
  • 优雅退出时 client.Agent().ServiceDeregister("echo-a") 主动注销。

2.3 验证 #

curl.exe -s http://localhost/a                          # 路由通了(服务自己注册进去的)
curl.exe -s http://localhost:8500/v1/catalog/services   # Consul 里有谁注册了
curl.exe -s http://localhost:8080/api/http/routers | findstr /i consulcatalog
#   "name":"echo-a@consulcatalog" ... "provider":"consulcatalog"  ← 路由来自 Consul,非 docker label

3. 能力二:Traefik consulcatalog 对接(路由怎么来的) #

3.1 Traefik 端配置(不挂 docker.sock!) #

# command 写法(09 Docker)
- "--providers.consulcatalog=true"
- "--providers.consulcatalog.endpoint.address=consul:8500"
- "--providers.consulcatalog.exposedByDefault=false"   # 只暴露带 traefik.enable=true 的
- "--providers.consulcatalog.refreshInterval=5s"        # 5s 拉一次,路由更新更快
# traefik.yml 写法(10 Windows)
providers:
  consulCatalog:
    endpoint:
      address: "127.0.0.1:8500"
      scheme: "http"
    exposedByDefault: false
    refreshInterval: "5s"

3.2 路由组怎么注册给 Traefik —— 答案在 tags #

Tag 内容 = Traefik label 内容。服务注册时把路由规则写进 tags:

traefik.enable=true
# 主路由组 /a
traefik.http.routers.echo-a.rule=PathPrefix(`/a`)
traefik.http.routers.echo-a.entrypoints=web
traefik.http.routers.echo-a.service=echo-a
traefik.http.routers.echo-a.middlewares=echo-a-strip
traefik.http.middlewares.echo-a-strip.stripprefix.prefixes=/a
# 额外路由组 /admin-a(同一个 echo-a,多一条 router)
traefik.http.routers.echo-a-admin.rule=PathPrefix(`/admin-a`)
traefik.http.routers.echo-a-admin.service=echo-a
traefik.http.routers.echo-a-admin.middlewares=echo-a-admin-strip
traefik.http.middlewares.echo-a-admin-strip.stripprefix.prefixes=/admin-a

consulcatalog provider 读到这些 tag,自动建出对应的 router + middleware。生成这些 tag 的代码见 10.1 buildTags()

3.3 路由组 vs 接口(重要区分) #

是什么 怎么让网关知道
路由组 (route group) 一条 router 规则:前缀/Host + 中间件 → 指向 service 注册时写进 traefik.* tags
接口 (endpoint) service 内部一个 handler(如 /time 不用注册;在所属路由组前缀下天然可达
# 加路由组:改服务 EXTRA_GROUPS 重启 → 自注册推上去,Traefik ~5s 拿到
curl.exe -s http://localhost/admin-a/   # viaPrefix=/admin-a(新路由组,同一个 echo-a)
# 加接口:只加 handler,无需注册
curl.exe -s http://localhost/a/time         # 经 /a 路由组访问
curl.exe -s http://localhost/admin-a/time   # 经 /admin-a 也能访问(整段前缀早转发进来了)

stripprefix 去掉前缀转给后端(后端只看到干净路径 //time),并注入 X-Forwarded-Prefix → 返回里 viaPrefix 能看出经哪个路由组进来。


4. 能力三:KV 配置中心 + 热更新 #

4.1 配置结构(全放 Consul KV) #

 config/
 ├─ shared/color           ← 公共,全员拉
 ├─ shared/banner
 ├─ echo-a/greeting        ← 私有,只 echo-a 拉
 ├─ echo-b/greeting
 └─ expected-services      ← 期望表(JSON 数组),给 monitor 对账用

每个服务只拉 config/shared/* + config/<自己名字>/*

4.2 热更新原理(官方 api/watch 的 keyprefix 计划) #

 改KV: consul kv put config/shared/color green
        │
        ▼
 Consul KV (ModifyIndex+1)
        │  官方 api/watch(keyprefix) 监听,变更立即回调(不用自己写长轮询)
        ▼
 echo-a/echo-b 进程内热替换内存配置 → configVersion+1   (★ 不重启)
        │
        ▼  curl /a → config["shared/color"]=green
// 等价于命令行:consul watch -type=keyprefix -prefix=config/
plan, _ := watch.Parse(map[string]interface{}{
    "type":   "keyprefix",
    "prefix": "config/",
})
plan.HybridHandler = func(_ watch.BlockingParamVal, raw interface{}) {
    pairs := raw.(api.KVPairs)         // 首次回调带全量,之后每次变化再回调
    // ... 挑出本服务关心的键(shared/* 与 <name>/*),原子热替换内存配置
}
go plan.Run(consulAddr)

完整实现见 10.1 startConfigWatch / applyConfig

4.3 改配置(三种方式,效果一样) #

# 方式1:docker exec consul CLI
docker compose exec -T consul consul kv put config/shared/color green        # 公共,全员变
docker compose exec -T consul consul kv put config/echo-a/greeting "热更新!"  # 私有,只 echo-a 变
# 方式2:Windows 原生
.\bin\consul.exe kv put config/shared/color green
# 方式3:Consul UI → Key/Value 直接编辑保存

1~2 秒后生效,值变、configVersion 增大、进程没重启。日志:[echo-a] 🔄 配置热更新 version=33:shared/color "blue"→"green"

4.4 初始灌配置(非破坏式 setdefault) #

# 仅当 key 不存在时写入 → 你热改过的值重跑不会被重置
setdefault() {
  if consul kv get "$1" >/dev/null 2>&1; then echo "skip $1"; else consul kv put "$1" "$2"; fi
}
setdefault config/shared/color "blue"
setdefault config/expected-services '["echo-a","echo-b","monitor","ghost-svc"]'

-dev 模式 Consul 是内存态down/停掉后配置清空,重启回到初始默认。要持久化见 第 8 节


5. 能力四 & 五:健康检查 + 掉线预警 #

5.1 monitor 怎么对账(期望表 vs 实际) #

Consul 原生只知道"谁注册了/健康不健康",不知道"谁应该在但没来"。monitor 用 KV 里的 expected-services 期望表补上这块:

 echo-b 掉线
    │
    ▼
 Consul 每5s 探 /healthz 失败 → 该实例 critical
    │
    ▼  monitor 每5s 对账:期望表里有 echo-b,但它健康不过
 monitor ──预警──▶ 🚨 echo-b HEALTHY→DOWN
    ├─▶ 面板 http://localhost/ (echo-b 行变红,顶部 🚨)
    ├─▶ 日志 docker compose logs -f monitor
    └─▶ Consul UI(健康检查变红)

对账逻辑:遍历期望表,没注册→MISSING、注册了但健康检查不过→DOWN、健康→HEALTHY;再扫注册表里不在期望表的→UNEXPECTED。完整代码见 10.2 reconcile

5.2 四种状态(速查) #

状态 含义
HEALTHY 已注册且健康检查通过
DOWN 在册,但健康检查失败(崩溃/失联)
MISSING 期望表里有,却没注册(从没起 / 已优雅注销)
UNEXPECTED 注册了,但不在期望表

5.3 两种掉线,状态不同(关键) #

操作 信号 结果状态 真实对应
docker compose kill echo-b SIGKILL DOWN(在册但健康不过) 进程崩溃/失联,来不及注销
docker compose stop echo-b SIGTERM MISSING(已从 Consul 摘除) 优雅下线,主动注销

⚠️ Windows 上 Stop-Process硬杀(无 SIGTERM),所以停哪个都先变 DOWN,挂满 DeregisterCriticalServiceAfter(1分钟) 后 Consul 才摘成 MISSING。 ⚠️ 别拿 curl /b 当判据 —— 掉线后路由被摘,请求会落到兜底页。以面板/Consul 健康状态为准。

5.4 验证 #

docker compose kill echo-b           # 模拟崩溃 → 等~10s 面板变红 DOWN
curl.exe -s http://localhost/status  # 对账结果(JSON)
docker compose start echo-b          # 恢复 → ✅ DOWN→HEALTHY

# "谁还没注册"开箱即见:期望表里有永不启动的 ghost-svc → 一直 MISSING
docker compose exec -T consul consul kv get config/expected-services
docker compose exec -T consul consul kv put config/expected-services '["echo-a","echo-b","monitor"]'  # 去掉它,预警消失

真实环境把 monitor 的 pushAlert 换成 webhook/钉钉/邮件即可。


6. 常用 HTTP API / CLI 速查 #

HTTP API(curl 直接调) #

GET  /v1/status/leader                 # 探活:Consul 是否就绪
GET  /v1/catalog/services              # 有哪些服务注册了
GET  /v1/health/service/<name>         # 某服务各实例的健康(Checks[].Status == "passing")
GET  /v1/kv/<key>?raw                  # 读单个 KV 原始值
GET  /v1/kv/<prefix>?recurse=true      # 递归读前缀下全部(返回里 Value 是 base64)
PUT  /v1/kv/<key>                      # 写 KV(body=值)
PUT  /v1/agent/service/register        # 注册服务(body=JSON)
PUT  /v1/agent/service/deregister/<id> # 注销

CLI #

consul agent -dev -client=0.0.0.0              # 单节点开发模式(含 UI,内存态)
consul members                                 # 集群成员(健康检查脚本用)
consul kv put config/shared/color green        # 写
consul kv get config/shared/color              # 读
consul kv get -recurse config/                 # 看配置中心全部内容
consul watch -type=keyprefix -prefix=config/   # 命令行版热监听(代码里用 api/watch 等价)

7. 两种跑法:Docker(09)vs Windows 原生(10) #

        09-consul(Docker)                    10-consul-windows(原生 .exe)
 ┌─────────────────────────────┐     ┌─────────────────────────────┐
 │ docker compose up -d --build │     │ .\start.ps1                 │
 │ consul/traefik = 镜像        │     │ consul.exe / traefik.exe    │
 │ 服务=容器,按容器名互通       │     │ 服务=.exe,全在127.0.0.1     │
 │ 网关入口 :80                 │     │ 各占端口;网关入口 :8000     │
 └─────────────────────────────┘     └─────────────────────────────┘
              └──────── 注册/发现/热更新/预警逻辑一模一样 ────────┘

09 Docker #

cd 09-consul
docker compose up -d --build
docker compose logs -f
docker compose down            # -dev 内存态,down 后配置清空
# 面板 http://localhost/ | Consul http://localhost:8500 | Traefik http://localhost:8080

10 Windows 原生 #

cd 10-consul-windows
.\download.ps1     # 下载 consul.exe / traefik.exe 到 .\bin(只做一次)
.\build.ps1        # 用 09 源码编译 echo-a/echo-b/monitor.exe(只做一次)
.\start.ps1        # 拉起全部(幂等:已在跑的跳过)→ 面板 http://localhost:8000/
Get-Content logs\monitor.log -Wait    # 看日志(≈ docker logs -f)
.\stop.ps1                            # 全停
.\stop.ps1 echo-b                     # 只停一个

⚠️ 09 和 10 都占 8500/8080,一次只跑一个;切换前先停掉另一个。 Windows 入口用 8000(避开 80 需管理员/被占)。下载的 .exe 被 SmartScreen 拦时右键属性"解除锁定"。

进程与端口(10) #

进程 监听 角色
consul.exe 127.0.0.1:8500 注册中心+KV+健康检查
traefik.exe :8000 / :8080 网关 / Dashboard
echo-a.exe 127.0.0.1:8081 业务 A → /a
echo-b.exe 127.0.0.1:8082 业务 B → /b
monitor.exe 127.0.0.1:9000 面板 → /

8. 想做"单机版/要持久化"? #

-dev 是内存态,停了清空。要重启仍在,换成单节点 server + 落盘:

consul.exe agent -server -bootstrap-expect=1 -data-dir .\data `
  -bind=127.0.0.1 -client=127.0.0.1 -ui

再把 consul.exe/traefik.exesc.exe createNSSM 注册成 Windows 服务 → “开机自启、无人值守"的单机产品形态。


9. 排错速查 #

现象 多半原因
路由 404 / 访问不到 服务没注册成功(看服务日志有无 ✅ 已注册);tag 写错
路由不是 consulcatalog Traefik 没开 consulcatalog provider,或还连着 docker.sock
改 KV 不生效 watch 没跑起来;改的 key 不在服务关心的前缀(shared/<name>/)下
服务一直 MISSING 没启动 / 已优雅注销 / 注册地址容器网络不可达
服务一直 DOWN /healthz 不通;健康检查地址(addr:port)填错
8500/8080 端口冲突 09 和 10 同时在跑,停掉一个
配置重启丢了 -dev 内存态正常现象;要持久化见第 8 节

排错主入口

docker compose logs -f               # 09:全部服务日志交织
Get-Content logs\monitor.log -Wait   # 10:单服务日志
curl.exe -s http://localhost/status  # 对账+预警 JSON

10. 完整可运行范例(复现用) #

09 与 10 共用同一份 Go 源码echosvc + monitor),区别只在启动方式。 复现步骤:建好下面的文件 → 09 用 docker compose up -d --build;10 用 download.ps1build.ps1start.ps1

10.1 echosvc/main.go(业务服务:自注册 + 拉配置 + 热更新) #

一个二进制靠环境变量区分身份(echo-a/echo-b)。环境变量:SERVICE_NAMESERVICE_ADDRPORTROUTE_PREFIX(主路由组前缀如 /a)、EXTRA_GROUPS(额外路由组,分号分隔的 组名:前缀,如 admin:/admin-a)、CONSUL_ADDR

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "os/signal"
    "sort"
    "strconv"
    "strings"
    "sync"
    "syscall"
    "time"

    "github.com/hashicorp/consul/api"
    "github.com/hashicorp/consul/api/watch"
)

func env(k, def string) string {
    if v := os.Getenv(k); v != "" {
       return v
    }
    return def
}

var (
    serviceName = env("SERVICE_NAME", "echo")
    serviceAddr = env("SERVICE_ADDR", "127.0.0.1")
    listenPort  = env("PORT", "8080")
    routePrefix = env("ROUTE_PREFIX", "/")
    extraGroups = env("EXTRA_GROUPS", "")
    consulAddr  = env("CONSUL_ADDR", "http://127.0.0.1:8500")
    kvPrefix    = "config"
)

// ── 当前生效配置(支持热更新) ──
type configState struct {
    mu      sync.RWMutex
    version uint64
    values  map[string]string
}

var cfg = &configState{values: map[string]string{}}

func (c *configState) snapshot() (uint64, map[string]string) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    cp := make(map[string]string, len(c.values))
    for k, v := range c.values {
       cp[k] = v
    }
    return c.version, cp
}

func main() {
    port, _ := strconv.Atoi(listenPort)
    client, addr, err := newConsul()
    if err != nil {
       log.Fatalf("[%s] 创建 Consul 客户端失败: %v", serviceName, err)
    }
    // 1) 注册
    if err := registerWithRetry(client, port); err != nil {
       log.Fatalf("[%s] 注册 Consul 失败: %v", serviceName, err)
    }
    go reRegisterLoop(client, port)
    // 2) 官方 watch 监听 config/ 热更新
    plan, err := startConfigWatch(addr)
    if err != nil {
       log.Fatalf("[%s] 启动配置 watch 失败: %v", serviceName, err)
    }
    // 3) HTTP(路由组都带 stripprefix,所以这里是去掉前缀后的干净路径)
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
    mux.HandleFunc("/time", handleTime)
    mux.HandleFunc("/", handleRoot)
    srv := &http.Server{Addr: ":" + listenPort, Handler: mux}
    go func() {
       log.Printf("[%s] 监听 :%s  路由组=%v  consul=%s", serviceName, listenPort, groupSummary(), consulAddr)
       if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
          log.Fatalf("[%s] http 退出: %v", serviceName, err)
       }
    }()
    // 4) 优雅退出
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
    <-stop
    if plan != nil {
       plan.Stop()
    }
    deregister(client)
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx)
}

// ── HTTP handlers ──
func handleRoot(w http.ResponseWriter, r *http.Request) {
    ver, vals := cfg.snapshot()
    writeJSON(w, map[string]any{
       "service": serviceName, "address": serviceAddr, "port": listenPort,
       "viaPrefix": r.Header.Get("X-Forwarded-Prefix"), // 经哪个路由组进来的
       "configVersion": ver, "config": vals, "servedAt": time.Now().Format("15:04:05"),
    })
}

func handleTime(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, map[string]any{
       "service": serviceName, "endpoint": "/time",
       "now": time.Now().Format("2006-01-02 15:04:05"), "viaPrefix": r.Header.Get("X-Forwarded-Prefix"),
       "note": "新接口=只加 handler,无需在 Consul/Traefik 注册",
    })
}

func writeJSON(w http.ResponseWriter, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    _ = json.NewEncoder(w).Encode(v)
}

// ── 路由组 → Traefik tags ──
type routeGroup struct {
    router string // Traefik router 名(全局唯一)
    prefix string // 对外前缀
}

func routeGroups() []routeGroup {
    gs := []routeGroup{{router: serviceName, prefix: routePrefix}}
    for _, item := range strings.Split(extraGroups, ";") {
       item = strings.TrimSpace(item)
       if item == "" {
          continue
       }
       parts := strings.SplitN(item, ":", 2)
       if len(parts) != 2 {
          continue
       }
       gs = append(gs, routeGroup{router: serviceName + "-" + strings.TrimSpace(parts[0]), prefix: strings.TrimSpace(parts[1])})
    }
    return gs
}

func groupSummary() []string {
    var out []string
    for _, g := range routeGroups() {
       out = append(out, g.router+"="+g.prefix)
    }
    return out
}

// buildTags:把每个路由组翻译成 Traefik tags(一条 router + 一个 stripprefix 中间件)
func buildTags() []string {
    tags := []string{"traefik.enable=true"}
    for _, g := range routeGroups() {
       strip := g.router + "-strip"
       tags = append(tags,
          fmt.Sprintf("traefik.http.routers.%s.rule=PathPrefix(`%s`)", g.router, g.prefix),
          fmt.Sprintf("traefik.http.routers.%s.entrypoints=web", g.router),
          fmt.Sprintf("traefik.http.routers.%s.service=%s", g.router, serviceName),
          fmt.Sprintf("traefik.http.routers.%s.middlewares=%s", g.router, strip),
          fmt.Sprintf("traefik.http.middlewares.%s.stripprefix.prefixes=%s", strip, g.prefix),
       )
    }
    return tags
}

// ── 注册 / 注销 ──
func newConsul() (*api.Client, string, error) {
    u, err := url.Parse(consulAddr)
    if err != nil {
       return nil, "", err
    }
    c := api.DefaultConfig()
    c.Address = u.Host
    if u.Scheme != "" {
       c.Scheme = u.Scheme
    }
    cl, err := api.NewClient(c)
    return cl, u.Host, err
}

func register(client *api.Client, port int) error {
    return client.Agent().ServiceRegister(&api.AgentServiceRegistration{
       ID: serviceName, Name: serviceName, Address: serviceAddr, Port: port, Tags: buildTags(),
       Check: &api.AgentServiceCheck{
          HTTP:                           fmt.Sprintf("http://%s:%d/healthz", serviceAddr, port),
          Interval:                       "5s",
          Timeout:                        "2s",
          DeregisterCriticalServiceAfter: "1m",
       },
    })
}

func registerWithRetry(client *api.Client, port int) error {
    var err error
    for i := 0; i < 30; i++ {
       if err = register(client, port); err == nil {
          log.Printf("[%s] ✅ 已注册到 Consul(addr=%s:%d,路由组 %v)", serviceName, serviceAddr, port, groupSummary())
          return nil
       }
       log.Printf("[%s] 注册失败(%d/30): %v,2s 后重试", serviceName, i+1, err)
       time.Sleep(2 * time.Second)
    }
    return err
}

func reRegisterLoop(client *api.Client, port int) {
    t := time.NewTicker(30 * time.Second)
    defer t.Stop()
    for range t.C {
       if err := register(client, port); err != nil {
          log.Printf("[%s] 重注册失败: %v", serviceName, err)
       }
    }
}

func deregister(client *api.Client) {
    if err := client.Agent().ServiceDeregister(serviceName); err != nil {
       log.Printf("[%s] 注销失败: %v", serviceName, err)
       return
    }
    log.Printf("[%s] 已从 Consul 注销", serviceName)
}

// ── 配置热更新(官方 api/watch keyprefix) ──
func startConfigWatch(addr string) (*watch.Plan, error) {
    plan, err := watch.Parse(map[string]interface{}{"type": "keyprefix", "prefix": kvPrefix + "/"})
    if err != nil {
       return nil, err
    }
    plan.HybridHandler = func(_ watch.BlockingParamVal, raw interface{}) {
       if raw == nil {
          return
       }
       pairs, ok := raw.(api.KVPairs)
       if !ok {
          return
       }
       all := map[string]string{}
       var version uint64
       for _, p := range pairs {
          all[p.Key] = string(p.Value)
          if p.ModifyIndex > version {
             version = p.ModifyIndex
          }
       }
       applyConfig(version, all)
    }
    go func() {
       if err := plan.Run(addr); err != nil {
          log.Printf("[%s] 配置 watch 退出: %v", serviceName, err)
       }
    }()
    return plan, nil
}

// applyConfig:从全量 KV 挑出本服务关心的键,原子热替换
func applyConfig(version uint64, all map[string]string) {
    sharedPfx := kvPrefix + "/shared/"
    ownPfx := kvPrefix + "/" + serviceName + "/"
    next := map[string]string{}
    for k, v := range all {
       switch {
       case strings.HasPrefix(k, sharedPfx):
          next["shared/"+strings.TrimPrefix(k, sharedPfx)] = v
       case strings.HasPrefix(k, ownPfx):
          next[strings.TrimPrefix(k, ownPfx)] = v
       }
    }
    cfg.mu.Lock()
    old := cfg.values
    cfg.values = next
    cfg.version = version
    cfg.mu.Unlock()
    if changes := diff(old, next); len(changes) == 0 {
       log.Printf("[%s] 配置加载 version=%d(共 %d 项)", serviceName, version, len(next))
    } else {
       log.Printf("[%s] 🔄 配置热更新 version=%d:%s", serviceName, version, strings.Join(changes, ";"))
    }
}

func diff(a, b map[string]string) []string {
    keys := map[string]bool{}
    for k := range a {
       keys[k] = true
    }
    for k := range b {
       keys[k] = true
    }
    var ks []string
    for k := range keys {
       ks = append(ks, k)
    }
    sort.Strings(ks)
    var out []string
    for _, k := range ks {
       av, ao := a[k]
       bv, bo := b[k]
       switch {
       case !ao && bo:
          out = append(out, fmt.Sprintf("新增 %s=%q", k, bv))
       case ao && !bo:
          out = append(out, fmt.Sprintf("删除 %s", k))
       case av != bv:
          out = append(out, fmt.Sprintf("%s %q→%q", k, av, bv))
       }
    }
    return out
}

echosvc/go.mod

module consuldemo/echosvc

go 1.22

require github.com/hashicorp/consul/api v1.31.0
// 其余为 consul/api 的间接依赖,go mod tidy 自动补全

echosvc/Dockerfile(注意有 go.sum,需 COPY go.mod go.sum):

FROM golang:1.24-alpine AS build
ENV GOTOOLCHAIN=local
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/echosvc .

FROM alpine:3.20
RUN adduser -D -u 10001 appuser
COPY --from=build /out/echosvc /echosvc
USER appuser
EXPOSE 8080
ENTRYPOINT ["/echosvc"]

10.2 monitor/main.go(治理面板 + 对账 + 预警) #

用 Consul 原生数据拼出"期望表对账"与"预警”。环境变量:SERVICE_NAMESERVICE_ADDRPORTCONSUL_ADDRRECONCILE_INTERVAL_SEC。它自己也注册进 Consul(priority=1 的兜底路由 /)。

package main

import (
    "bytes"
    "context"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "os/signal"
    "sort"
    "strconv"
    "sync"
    "syscall"
    "time"
)

func env(k, def string) string {
    if v := os.Getenv(k); v != "" {
       return v
    }
    return def
}
func envInt(k string, def int) int {
    if v := os.Getenv(k); v != "" {
       if n, err := strconv.Atoi(v); err == nil {
          return n
       }
    }
    return def
}

var (
    serviceName  = env("SERVICE_NAME", "monitor")
    serviceAddr  = env("SERVICE_ADDR", "127.0.0.1")
    listenPort   = env("PORT", "9000")
    consulAddr   = env("CONSUL_ADDR", "http://127.0.0.1:8500")
    reconcileSec = envInt("RECONCILE_INTERVAL_SEC", 5)
)

var httpClient = &http.Client{Timeout: 10 * time.Second}

type SvcStatus struct {
    Name     string `json:"name"`
    Expected bool   `json:"expected"`
    Status   string `json:"status"` // HEALTHY | DOWN | MISSING | UNEXPECTED
    Healthy  int    `json:"healthy"`
    Total    int    `json:"total"`
    Note     string `json:"note"`
}

type Snapshot struct {
    Time     string            `json:"time"`
    Services []SvcStatus       `json:"services"`
    Config   map[string]string `json:"config"`
    Alerts   []string          `json:"alerts"`
    AnyAlert bool              `json:"anyAlert"`
}

var (
    snapMu     sync.RWMutex
    snapshot   Snapshot
    prevStatus = map[string]string{} // 上一轮各服务状态,用于检测变化预警
    alertsMu   sync.Mutex
    alertsRing []string
)

func setSnapshot(s Snapshot) { snapMu.Lock(); snapshot = s; snapMu.Unlock() }
func getSnapshot() Snapshot  { snapMu.RLock(); defer snapMu.RUnlock(); return snapshot }

func main() {
    port, _ := strconv.Atoi(listenPort)
    if err := registerWithRetry(port); err != nil {
       log.Fatalf("[%s] 注册 Consul 失败: %v", serviceName, err)
    }
    go reRegisterLoop(port)
    go reconcileLoop()
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
    mux.HandleFunc("/status", handleStatus) // ★ 不能用 /api/*,会被 echo-a 的 PathPrefix(`/a`) 抢走
    mux.HandleFunc("/", handleDashboard)
    srv := &http.Server{Addr: ":" + listenPort, Handler: mux}
    go func() {
       log.Printf("[%s] 监听 :%s  对账间隔=%ds  consul=%s", serviceName, listenPort, reconcileSec, consulAddr)
       if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
          log.Fatalf("[%s] http 退出: %v", serviceName, err)
       }
    }()
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
    <-stop
    deregister()
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx)
}

func handleStatus(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    _ = json.NewEncoder(w).Encode(getSnapshot())
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    io.WriteString(w, dashboardHTML) // dashboardHTML 见 dashboard.go(见下方说明)
}

// ── 对账 + 预警 ──
func reconcileLoop() {
    reconcile()
    t := time.NewTicker(time.Duration(reconcileSec) * time.Second)
    defer t.Stop()
    for range t.C {
       reconcile()
    }
}

func reconcile() {
    expected, _ := readExpected()
    catalog, _ := catalogServices()
    cfgMap, _ := kvRecurse("config")
    var statuses []SvcStatus
    seen := map[string]bool{}
    for _, name := range expected {
       seen[name] = true
       st := SvcStatus{Name: name, Expected: true}
       if _, ok := catalog[name]; !ok {
          st.Status = "MISSING"
          st.Note = "在期望表中,但从未注册 / 已掉线被移除"
       } else {
          healthy, total := healthService(name)
          st.Healthy, st.Total = healthy, total
          if healthy > 0 {
             st.Status = "HEALTHY"
          } else {
             st.Status = "DOWN"
             st.Note = "已注册但健康检查未通过"
          }
       }
       statuses = append(statuses, st)
    }
    for name := range catalog {
       if seen[name] || name == "consul" {
          continue
       }
       statuses = append(statuses, SvcStatus{Name: name, Status: "UNEXPECTED", Note: "已注册但不在期望表"})
    }
    sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
    detectAlerts(statuses)
    any := false
    for _, s := range statuses {
       if s.Status == "DOWN" || s.Status == "MISSING" {
          any = true
       }
    }
    setSnapshot(Snapshot{Time: time.Now().Format("15:04:05"), Services: statuses, Config: cfgMap, Alerts: getAlerts(), AnyAlert: any})
}

// detectAlerts 仅在状态变化时告警,避免刷屏
func detectAlerts(statuses []SvcStatus) {
    cur := map[string]string{}
    for _, s := range statuses {
       cur[s.Name] = s.Status
    }
    for name, st := range cur {
       old, ok := prevStatus[name]
       if !ok {
          if st == "DOWN" || st == "MISSING" {
             pushAlert(fmt.Sprintf("🚨 %s 初始即异常:%s", name, st))
          }
          continue
       }
       if old != st {
          icon := "ℹ️"
          switch st {
          case "DOWN", "MISSING":
             icon = "🚨"
          case "HEALTHY":
             icon = "✅"
          }
          pushAlert(fmt.Sprintf("%s %s:%s → %s", icon, name, old, st))
       }
    }
    prevStatus = cur
}

func pushAlert(msg string) {
    line := time.Now().Format("15:04:05") + "  " + msg
    log.Printf("[%s/预警] %s", serviceName, msg)
    alertsMu.Lock()
    alertsRing = append(alertsRing, line)
    if len(alertsRing) > 12 {
       alertsRing = alertsRing[len(alertsRing)-12:]
    }
    alertsMu.Unlock()
}

func getAlerts() []string {
    alertsMu.Lock()
    defer alertsMu.Unlock()
    out := make([]string, len(alertsRing))
    copy(out, alertsRing)
    for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
       out[i], out[j] = out[j], out[i]
    }
    return out
}

// ── Consul 读取助手 ──
func readExpected() ([]string, error) {
    raw, err := kvGetRaw("config/expected-services")
    if err != nil {
       return nil, err
    }
    var arr []string
    if err := json.Unmarshal([]byte(raw), &arr); err != nil {
       return nil, fmt.Errorf("expected-services 不是 JSON 数组: %w", err)
    }
    return arr, nil
}

func kvGetRaw(key string) (string, error) {
    resp, err := httpClient.Get(consulAddr + "/v1/kv/" + key + "?raw")
    if err != nil {
       return "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
       return "", fmt.Errorf("kv %s status %d", key, resp.StatusCode)
    }
    b, _ := io.ReadAll(resp.Body)
    return string(b), nil
}

func kvRecurse(prefix string) (map[string]string, error) {
    resp, err := httpClient.Get(consulAddr + "/v1/kv/" + prefix + "?recurse=true")
    if err != nil {
       return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusNotFound {
       return map[string]string{}, nil
    }
    if resp.StatusCode != http.StatusOK {
       return nil, fmt.Errorf("kv recurse status %d", resp.StatusCode)
    }
    var items []struct {
       Key   string `json:"Key"`
       Value string `json:"Value"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
       return nil, err
    }
    out := map[string]string{}
    for _, it := range items {
       dec, _ := base64.StdEncoding.DecodeString(it.Value)
       out[it.Key] = string(dec)
    }
    return out, nil
}

func catalogServices() (map[string][]string, error) {
    resp, err := httpClient.Get(consulAddr + "/v1/catalog/services")
    if err != nil {
       return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
       return nil, fmt.Errorf("catalog status %d", resp.StatusCode)
    }
    var m map[string][]string
    if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
       return nil, err
    }
    return m, nil
}

// healthService 返回 (健康实例数, 总实例数);一个实例所有 check 都 passing 才算健康
func healthService(name string) (healthy, total int) {
    resp, err := httpClient.Get(consulAddr + "/v1/health/service/" + name)
    if err != nil {
       return 0, 0
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
       return 0, 0
    }
    var entries []struct {
       Checks []struct {
          Status string `json:"Status"`
       } `json:"Checks"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
       return 0, 0
    }
    for _, e := range entries {
       total++
       allPassing := true
       for _, c := range e.Checks {
          if c.Status != "passing" {
             allPassing = false
             break
          }
       }
       if allPassing {
          healthy++
       }
    }
    return healthy, total
}

// ── 注册 / 注销(priority=1 让 "/" 兜底) ──
func registrationPayload(port int) map[string]any {
    name := serviceName
    tags := []string{
       "traefik.enable=true",
       fmt.Sprintf("traefik.http.routers.%s.rule=PathPrefix(`/`)", name),
       fmt.Sprintf("traefik.http.routers.%s.entrypoints=web", name),
       fmt.Sprintf("traefik.http.routers.%s.priority=1", name), // 最低优先级 → /a /b 优先
       fmt.Sprintf("traefik.http.routers.%s.service=%s", name, name),
    }
    return map[string]any{
       "ID": name, "Name": name, "Address": serviceAddr, "Port": port, "Tags": tags,
       "Check": map[string]any{
          "HTTP":                           fmt.Sprintf("http://%s:%d/healthz", serviceAddr, port),
          "Interval":                       "5s",
          "Timeout":                        "2s",
          "DeregisterCriticalServiceAfter": "1m",
       },
    }
}

func register(port int) error {
    body, _ := json.Marshal(registrationPayload(port))
    req, _ := http.NewRequest(http.MethodPut, consulAddr+"/v1/agent/service/register", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    resp, err := httpClient.Do(req)
    if err != nil {
       return err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
       b, _ := io.ReadAll(resp.Body)
       return fmt.Errorf("status %d: %s", resp.StatusCode, string(b))
    }
    return nil
}

func registerWithRetry(port int) error {
    var err error
    for i := 0; i < 30; i++ {
       if err = register(port); err == nil {
          log.Printf("[%s] ✅ 已注册到 Consul(addr=%s:%d)", serviceName, serviceAddr, port)
          return nil
       }
       time.Sleep(2 * time.Second)
    }
    return err
}

func reRegisterLoop(port int) {
    t := time.NewTicker(30 * time.Second)
    defer t.Stop()
    for range t.C {
       _ = register(port)
    }
}

func deregister() {
    req, _ := http.NewRequest(http.MethodPut, consulAddr+"/v1/agent/service/deregister/"+serviceName, nil)
    resp, err := httpClient.Do(req)
    if err != nil {
       return
    }
    resp.Body.Close()
}

注意:monitor 注册用的是裸 HTTP API(不像 echosvc 用官方 client),所以 monitor/go.mod 零依赖module consuldemo/monitor + go 1.22。Dockerfile 同 echosvc 但 COPY go.mod ./(无 go.sum)、-o /out/monitorEXPOSE 9000

monitor/dashboard.go:仅一个常量 const dashboardHTML = "...",是治理面板的 HTML/JS(dark theme)。前端逻辑:每 2s 拉 /status 渲染健康表 + 预警 + KV 配置;并直接拉 /a/b/a/time/admin-a/ 展示各服务实时生效配置与新接口/新路由组。它是展示外壳,非 Consul 功能本身;要恢复时按此行为用任意前端重建即可(核心数据全在 /status JSON)。

10.3 09-consul/docker-compose.yml #

name: consul-demo

services:
  consul:
    image: hashicorp/consul:latest
    container_name: cc-consul
    command: agent -dev -client=0.0.0.0 -log-level=info  # -dev 单节点(含UI),数据在内存
    ports: ["8500:8500"]
    networks: [app]
    healthcheck:
      test: ["CMD", "consul", "members"]
      interval: 5s
      timeout: 3s
      retries: 12

  init:                                    # 一次性灌初始配置后退出
    image: hashicorp/consul:latest
    container_name: cc-init
    depends_on:
      consul:
        condition: service_healthy
    entrypoint: ["/bin/sh", "/seed.sh"]
    volumes: ["./init/seed.sh:/seed.sh:ro"]
    networks: [app]
    restart: "no"

  traefik:                                 # 纯 consulcatalog 发现(无 docker.sock)
    image: traefik:v3.7.1
    container_name: cc-traefik
    command:
      - "--api.insecure=true"
      - "--providers.consulcatalog=true"
      - "--providers.consulcatalog.endpoint.address=consul:8500"
      - "--providers.consulcatalog.exposedByDefault=false"
      - "--providers.consulcatalog.refreshInterval=5s"
      - "--entrypoints.web.address=:80"
      - "--accesslog=true"
      - "--log.level=INFO"
    ports: ["80:80", "8080:8080"]
    depends_on:
      consul:
        condition: service_healthy
    networks: [app]

  echo-a:
    build: ./echosvc
    container_name: cc-echo-a
    environment:
      - SERVICE_NAME=echo-a
      - SERVICE_ADDR=echo-a
      - PORT=8080
      - ROUTE_PREFIX=/a
      - EXTRA_GROUPS=admin:/admin-a
      - CONSUL_ADDR=http://consul:8500
    depends_on:
      consul: {condition: service_healthy}
      init: {condition: service_completed_successfully}
    networks: [app]

  echo-b:
    build: ./echosvc
    container_name: cc-echo-b
    environment:
      - SERVICE_NAME=echo-b
      - SERVICE_ADDR=echo-b
      - PORT=8080
      - ROUTE_PREFIX=/b
      - CONSUL_ADDR=http://consul:8500
    depends_on:
      consul: {condition: service_healthy}
      init: {condition: service_completed_successfully}
    networks: [app]

  monitor:
    build: ./monitor
    container_name: cc-monitor
    environment:
      - SERVICE_NAME=monitor
      - SERVICE_ADDR=monitor
      - PORT=9000
      - CONSUL_ADDR=http://consul:8500
      - RECONCILE_INTERVAL_SEC=5
    depends_on:
      consul: {condition: service_healthy}
      init: {condition: service_completed_successfully}
    networks: [app]

networks:
  app:
    driver: bridge

init/seed.sh(灌初始配置,非破坏式):

#!/bin/sh
set -e
export CONSUL_HTTP_ADDR="${CONSUL_HTTP_ADDR:-http://consul:8500}"
setdefault() {
  if consul kv get "$1" >/dev/null 2>&1; then echo "[seed] 跳过(已存在) $1"; else consul kv put "$1" "$2"; fi
}
setdefault config/shared/color  "blue"
setdefault config/shared/banner "Hello from Consul 配置中心"
setdefault config/echo-a/greeting "我是 A 服务"
setdefault config/echo-b/greeting "我是 B 服务"
setdefault config/expected-services '["echo-a","echo-b","monitor","ghost-svc"]'
consul kv get -recurse config/

10.4 10-consul-windows 脚本集 #

traefik.yml

api:
  insecure: true
  dashboard: true
entryPoints:
  web:
    address: ":8000"    # 避开 80(需管理员/易被占)
providers:
  consulCatalog:
    endpoint:
      address: "127.0.0.1:8500"
      scheme: "http"
    exposedByDefault: false
    refreshInterval: "5s"
accessLog: {}
log:
  level: INFO

download.ps1(下载 consul.exe / traefik.exe 到 .\bin):

$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$root = $PSScriptRoot; $bin = Join-Path $root 'bin'
New-Item -ItemType Directory -Force -Path $bin | Out-Null

function Get-Tool($name, $url, $exeInZip, $outName) {
  $out = Join-Path $bin $outName
  if (Test-Path $out) { Write-Host "已存在,跳过: $outName"; return }
  $tmp = Join-Path $env:TEMP ('dl-' + [guid]::NewGuid().ToString('N'))
  New-Item -ItemType Directory -Force -Path $tmp | Out-Null
  $zip = Join-Path $tmp "$name.zip"
  Invoke-WebRequest -Uri $url -OutFile $zip
  Expand-Archive -Path $zip -DestinationPath $tmp -Force
  Copy-Item (Join-Path $tmp $exeInZip) $out -Force
  Remove-Item $tmp -Recurse -Force
}
# Consul:checkpoint API 取最新稳定版
$consulVer = (Invoke-RestMethod 'https://checkpoint-api.hashicorp.com/v1/check/consul').current_version
Get-Tool 'consul' "https://releases.hashicorp.com/consul/$consulVer/consul_${consulVer}_windows_amd64.zip" 'consul.exe' 'consul.exe'
# Traefik:GitHub releases/latest
$tag = (Invoke-RestMethod 'https://api.github.com/repos/traefik/traefik/releases/latest' -Headers @{ 'User-Agent' = 'demo' }).tag_name
Get-Tool 'traefik' "https://github.com/traefik/traefik/releases/download/$tag/traefik_${tag}_windows_amd64.zip" 'traefik.exe' 'traefik.exe'

build.ps1(用 09 源码编译服务到 .\bin):

$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot; $bin = Join-Path $root 'bin'
$src  = Join-Path (Split-Path $root -Parent) '09-consul'   # ..\09-consul(复用其源码)
New-Item -ItemType Directory -Force -Path $bin | Out-Null
if (-not (Test-Path (Join-Path $src 'echosvc\main.go'))) { throw "找不到 09-consul 源码:$src" }

Push-Location (Join-Path $src 'echosvc')
& go build -o (Join-Path $bin 'echo-a.exe') .          # echo-a 与 echo-b 同一二进制
if ($LASTEXITCODE -ne 0) { Pop-Location; throw 'go build echosvc 失败' }
Pop-Location
Copy-Item (Join-Path $bin 'echo-a.exe') (Join-Path $bin 'echo-b.exe') -Force

Push-Location (Join-Path $src 'monitor')
& go build -o (Join-Path $bin 'monitor.exe') .
if ($LASTEXITCODE -ne 0) { Pop-Location; throw 'go build monitor 失败' }
Pop-Location

start.ps1(拉起全部,幂等):

$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot; $bin = Join-Path $root 'bin'; $logs = Join-Path $root 'logs'
New-Item -ItemType Directory -Force -Path $logs | Out-Null
foreach ($f in 'consul.exe','traefik.exe','echo-a.exe','echo-b.exe','monitor.exe') {
  if (-not (Test-Path (Join-Path $bin $f))) { throw "缺少 $f。先 .\download.ps1 再 .\build.ps1" }
}
function Test-Running($name) { [bool](Get-Process -Name $name -ErrorAction SilentlyContinue) }
function Start-Bg($name, $exe, $argList, $envVars) {
  if (Test-Running $name) { Write-Host "已在运行,跳过: $name"; return }
  if ($envVars) { foreach ($k in $envVars.Keys) { Set-Item "env:$k" $envVars[$k] } }
  $out = Join-Path $logs "$name.log"; $err = Join-Path $logs "$name.err.log"
  if ($argList) { Start-Process -FilePath $exe -ArgumentList $argList -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err | Out-Null }
  else          { Start-Process -FilePath $exe -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err | Out-Null }
}
function Wait-Consul {
  for ($i = 0; $i -lt 40; $i++) {
    try { if (Invoke-RestMethod 'http://127.0.0.1:8500/v1/status/leader' -TimeoutSec 2) { return $true } } catch {}
    Start-Sleep -Milliseconds 800
  }
  return $false
}
$consulAddr = 'http://127.0.0.1:8500'
Start-Bg 'consul' (Join-Path $bin 'consul.exe') @('agent','-dev','-client=127.0.0.1') $null
if (-not (Wait-Consul)) { throw "Consul 未就绪,看 logs\consul.err.log" }
& (Join-Path $root 'seed.ps1')                                  # 灌配置
Start-Bg 'traefik' (Join-Path $bin 'traefik.exe') @("--configFile=$(Join-Path $root 'traefik.yml')") $null
Start-Bg 'echo-a'  (Join-Path $bin 'echo-a.exe')  $null @{ SERVICE_NAME='echo-a'; SERVICE_ADDR='127.0.0.1'; PORT='8081'; ROUTE_PREFIX='/a'; CONSUL_ADDR=$consulAddr }
Start-Bg 'echo-b'  (Join-Path $bin 'echo-b.exe')  $null @{ SERVICE_NAME='echo-b'; SERVICE_ADDR='127.0.0.1'; PORT='8082'; ROUTE_PREFIX='/b'; CONSUL_ADDR=$consulAddr }
Start-Bg 'monitor' (Join-Path $bin 'monitor.exe') $null @{ SERVICE_NAME='monitor'; SERVICE_ADDR='127.0.0.1'; PORT='9000'; CONSUL_ADDR=$consulAddr; RECONCILE_INTERVAL_SEC='5' }

seed.ps1(灌配置,非破坏式;需 Consul 已运行):

$ErrorActionPreference = 'Stop'
$base = 'http://127.0.0.1:8500/v1/kv/'
try { Invoke-RestMethod -Uri 'http://127.0.0.1:8500/v1/status/leader' -TimeoutSec 2 -ErrorAction Stop | Out-Null }
catch { Write-Host "✗ 连不上 Consul,先 .\start.ps1" -ForegroundColor Yellow; exit 1 }
function Set-Default($k, $v) {
  $exists = $false
  try { Invoke-RestMethod -Uri ($base + $k) -Method Get -ErrorAction Stop | Out-Null; $exists = $true } catch { $exists = $false }
  if ($exists) { Write-Host "  skip $k(已存在)" }
  else { Invoke-RestMethod -Uri ($base + $k) -Method Put -Body ([System.Text.Encoding]::UTF8.GetBytes($v)) | Out-Null; Write-Host "  put  $k" }
}
Set-Default 'config/shared/color'  'blue'
Set-Default 'config/shared/banner' 'Hello from Consul 配置中心'
Set-Default 'config/echo-a/greeting' '我是 A 服务'
Set-Default 'config/echo-b/greeting' '我是 B 服务'
Set-Default 'config/expected-services' '["echo-a","echo-b","monitor","ghost-svc"]'

stop.ps1(无参全停,带名只停一个):

param([string]$Name)
$ErrorActionPreference = 'SilentlyContinue'
if ($Name) {
  Stop-Process -Name $Name -Force; Write-Host "已停止: $Name"
} else {
  foreach ($n in 'echo-a','echo-b','monitor','traefik','consul') { Stop-Process -Name $n -Force }
  Write-Host "已停止全部。注意:consul 是 -dev 内存态,停掉后 KV 配置会清空。"
}

附:核心对照(一图记住 Consul 在干嘛) #

注册中心  =  服务名册(谁在线、在哪、带什么 Traefik 路由标签)
配置中心  =  共享的可热改键值表(config/*),watch 监听变更
健康检查  =  周期心跳(/healthz),失败标 critical,挂久了自动除名
对账预警  =  期望表(应该有谁) vs 实际(注册+健康),差异即告警   ← monitor 自己实现,非 Consul 原生
Traefik   =  从注册表读路由、按 tag 自动建 router,5s 刷新