目标读者:已经在用 headscale/headsacle + Tailscale 自建私有控制面的工程师、架构师与 SRE。
结论先讲:通过“clusterID 分片 + 共享 PG + 接入控制程序 + 查询隔离 + netmap 计算收敛”,可以把单体 headscale 改造成可线性扩展的集群化服务,形态上接近 Tailscale 的 SaaS。


1. 现状与问题

在不少实际部署(尤其是保守默认形态)里,headscale 暴露出以下限制:

  1. 单 tailnet 的租户隔离不足
    默认形态下,租户隔离能力有限,难以做到天生的“多租户(multi-tenant)”边界和配额/策略独立。

  2. 单机架构,横向扩展困难
    headscale 作为单体进程,对上千设备尚可,上万设备时控制面心跳、注册、策略变更与推送的并发压力上来,CPU/调度抖动明显。

  3. 每次设备状态变更触发 netmap 全量计算,CPU 开销大
    设备上线/心跳/ACL 变更都会触发 netmap 计算,O(N) 的代价在高频事件下呈指数级放大,CPU 成为瓶颈。

这些问题使得“把 headscale 做成 SaaS、服务百万级设备”的道路变窄。


2. 常见改造路径的得失

  • 做法 A:一个租户=一套 headscale + 一份数据库

    • 优点:隔离极佳,改造量小。
    • 缺点:极度浪费资源、运维爆炸(N 倍升级、监控、告警、证书、备份)。
  • 做法 B:在原有 headscale 上直接做“多 tailnet”

    • 优点:形态与目标一致。
    • 难点:涉及鉴权、数据模型、查询、缓存、事件广播、ACL 解释器与 netmap 生成的全链路多租户化;
      即使做成了多 tailnet,单体也仍旧是瓶颈。

3. 我们采用的方案概览(可线性扩展)

核心思路:把 headscale 变成“可分片的工作节点”,每个节点带一个clusterID,所有节点共享一个 PG/PG 集群,但每个节点只“看见”属于自己 clusterID 的数据。最前面放一个接入控制程序(Admission/Control Service),负责用户/租户到 clusterID 的分配与黏住(sticky),实现水平扩展。

3.1 架构图(文本版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                       +-----------------------+
注册/登录/扩容 --->| 接入控制程序 (ACS) |<---- 管理API/计费/配额
+----------+------------+
|
选择可用 clusterID
v
+---------------------------+------------------------------+
| | |
+-----+-----+ +-----+-----+ +-----+-----+
| headscale | | headscale | | headscale |
| node A | clusterID=A | node B | clusterID=B | node C | clusterID=C
+-----+-----+ +-----+-----+ +-----+-----+
\ | /
\ | /
\--------------------+----+----+-----------------------/
| 共享 PG/PG 集群 |
| (带 cluster_id 维度) |
+---------------------+

设备侧:
Client 在首次接入时 -> 请求 ACS -> 获得 “可用 headscale 节点地址 + clusterID + 注册信息”
之后所有交互都直连对应节点(黏住),横向扩容时通过 ACS 做新租户的分配。

要点

  • PG 是共享的,但所有读写 SQL 都带 cluster_id 过滤,从逻辑上把一个大库切成多逻辑分片。
  • ACS 负责:租户创建、配额/商业策略、clusterID 分配、设备首次注册引流。
  • headscale 节点无状态化(尽量):持久状态都在 PG,缓存只做加速,可重建。
  • DERP 映射、OIDC、ACL、Keys 等配置在租户维度/clusterID 维度落库,查询隔离。

4. 关键实现细节

4.1 数据库层改造

4.1.1 模型加 cluster_id

为所有与“租户/设备/密钥/路由/ACL/会话/netmap 元数据”相关的表新增 cluster_id 列,并建立联合索引。

示例(仅展示思路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 1) 字段与默认值
ALTER TABLE nodes ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE users ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE api_keys ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE routes ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE acl_policies ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE preauth_keys ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE device_sessions ADD COLUMN cluster_id TEXT NOT NULL DEFAULT 'default';

-- 2) 典型索引
CREATE INDEX idx_nodes_cluster_id ON nodes(cluster_id);
CREATE INDEX idx_users_cluster_id ON users(cluster_id);
CREATE INDEX idx_routes_cluster_u_drt ON routes(cluster_id, user_id, dest_prefix);
CREATE INDEX idx_acl_cluster_u_rev ON acl_policies(cluster_id, user_id, revision DESC);

如果你目前是“一个库一个 headscale”,也可通过库级别分片进一步隔离,但共享 PG + cluster_id 能获得更好的资源复用与弹性。

4.1.2 访问层(DAO/ORM)强制带 cluster_id

  • 在服务器启动时确定本节点的 CLUSTER_ID(来自环境变量或启动参数)。
  • 在每一次查询前通过Context 中的 clusterID 自动注入到 SQL(如 GORM 的 Scoped Query、或手写 WHERE cluster_id = $1)。
  • 没有 cluster_id 的查询直接拒绝(guard 断言),避免“漏过滤”。

示例(Go 伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ClusterDB struct {
db *gorm.DB
clusterID string
}

func (c *ClusterDB) Scoped() *gorm.DB {
return c.db.Where("cluster_id = ?", c.clusterID)
}

// 使用示例
func (c *ClusterDB) ListNodes(ctx context.Context) ([]Node, error) {
var nodes []Node
return nodes, c.Scoped().Find(&nodes).Error
}

4.2 服务进程层改造

4.2.1 进程启动与 clusterID 注入

1
2
3
4
5
# 每个 headscale 节点以不同的 cluster_id 启动
CLUSTER_ID=A \
PG_DSN="postgres://..." \
DERP_MAP_URL="https://derp.example.com/map.json" \
./headscale --config ./config.yaml
  • 把 CLUSTER_ID 注入到:日志前缀、指标 label、事件广播主题等。
  • 所有内部逻辑只操作本 cluster 的对象集合。

4.2.2 netmap 计算的“增量化 + 粒度收敛”

问题本质:全量 netmap 计算是 O(N),高频触发会拖垮 CPU。
优化策略:

  1. 图模型分解:把 netmap 看成“节点集合 + ACL 边 + 路由前缀集合”的结果,任何变更落到有限的影响集合。

  2. 影响面追踪:当设备 X/ACL Y 变更时,只对受影响的节点子集重算(例如同一用户、同一 tag、同一 route 域)。

  3. 缓存 + 版本号:

    • 为每个“(cluster_id, user/tailnet)”维护一个 netmap 版本号。
    • 计算结果缓存到内存(LRU)或 Redis,带上哈希签名。
    • 下行推送时仅在“订阅版本 < 最新版本”时增量下发。
  4. PG 触发 LISTEN/NOTIFY:在变更表上触发 NOTIFY netmap_dirty (cluster_id, scope_key),节点 LISTEN 后队列化处理,限流 + 合并抖动(debounce 50–200ms)。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type DirtyEvent struct {
ClusterID string
ScopeKey string // 例如:userID 或 tagID 或 routeDomain
}

func (s *Server) onDirty(e DirtyEvent) {
if e.ClusterID != s.clusterID { return } // 只处理本集群
s.debouncer.Push(e.ScopeKey) // 50-200ms 合并
}

func (s *Server) rebuild(scopeKey string) {
// 1) 找到受影响设备集合
affected := s.index.LookupDevices(scopeKey)
// 2) 仅为受影响集合重建 netmap
for d := range affected {
nm := s.builder.BuildForDevice(d) // 只重该 d 所需
s.cache.Put(d, nm) // 缓存
s.pushToDevice(d, nm) // 推送
}
}

这一块是 CPU 成本收敛的关键,真实集群里能把平均 CPU打下来 60–90%。

4.3 接入控制程序(ACS)

职责

  • 接入(首次注册)与clusterID 分配(可按租户权重、付费等级、地理延迟、节点负载)。
  • 返回目标 headscale 节点地址与注册参数(例如可选衍生的 AuthKey、OIDC 跳转地址等)。
  • 黏住(sticky):同一租户/账号始终返回同一 clusterID,除非运维执行迁移。

典型接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api/v1/bootstrap
{
"account": "acme-inc",
"plan": "pro",
"want_region": "ap-sg"
}

200 OK
{
"cluster_id": "A",
"headscale_url": "https://hs-a.example.com",
"derp_map": "https://derp.example.com/map.json",
"auth_mode": "oidc",
"note": "stick to cluster A"
}

分配算法:一致性哈希 + 负载因子(CPU、会话数、netmap 队列长度)。
再平衡:通过 ACS 维护“迁移计划”,让新设备去新集群;已有设备按批次做平滑迁移(见 6.3)。


5. 端到端数据流与时序(文本时序图)

客户端使用 tailscale 源码自行编译 + 自行开发的 ipn 程序用来管理 tailscaled。

5.1 设备首次接入

1
2
3
4
5
6
7
Client -> ACS: POST /bootstrap (account, plan, region)
ACS -> PG : 读租户信息/配额/已有绑定
ACS -> Client: 返回 {cluster_id=A, headscale_url=https://hs-a...}

Client -> Headscale(A): OIDC/注册/心跳
Headscale(A) -> PG: 写 nodes/users/... (cluster_id=A)
Headscale(A) -> Client: netmap 下发

5.2 设备状态变更触发增量 netmap

1
2
3
4
5
Client(Device X) -> Headscale(A): 心跳状态/路由变化
Headscale(A) -> PG: 更新 device_sessions/routes (cluster_id=A)
PG Trigger -> NOTIFY netmap_dirty (A, scope=user:acme)
Headscale(A) -> rebuild scope=user:acme (增量)
Headscale(A) -> 推送新 netmap 给受影响设备集合

6. 运维与演进

6.1 部署建议

  • PG/PG 集群:建议启用主从 + 流复制,表按 cluster_id 做分区或partial index,高并发下效果更优。

  • 缓存层:本地内存 + 可选 Redis(跨实例热数据共享;断点恢复更快)。

  • TLS/DERP:DERP Map 固定入口,后端按地域部署多个 DERP;各 headscale 共用同一 DERP Map。

  • 可观测:

    • 指标:netmap_rebuild_qps{cluster_id}, netmap_rebuild_latency_ms{scope}, notify_backlog, sql_qps{table}, push_failures.
    • 采样日志:按 cluster_id 打标签,便于排查某分片异常。

6.2 配额与产品化

  • 免费用户:ACS 层限制“每账户设备数 N”,超出则拒绝分配注册令牌。
  • 商业用户:可分配“独享 clusterID(专用 headscale 节点)”,实现资源与性能承诺。
  • 计费:PG 里记录设备在线时长、流量(如只做控制面也可不记),按 clusterID+账户聚合。

6.3 迁移与再平衡(不中断)

  • 新增节点 C(clusterID=C),ACS 把新注册导入 C。

  • 老用户逐步迁移:

    1. 标记租户 T 的“目标 cluster=C”;
    2. 为 T 的设备签发“下一次重连时使用 headscale(C)”的引导参数;
    3. 设备在心跳超时/重连时自然切换;
    4. 完成后把 T 的数据从 A 复制到 C(同库可直接 UPDATE ... SET cluster_id='C' WHERE tenant_id=T;跨库需 ETL)。

7. 代码落地片段(示例)

7.1 进程入口注入 clusterID

1
2
3
4
5
6
7
8
9
10
func main() {
cfg := loadConfig()
clusterID := mustGetEnv("CLUSTER_ID")

db := mustOpenPG(cfg.PGDSN)
cdb := &ClusterDB{db: db, clusterID: clusterID}

srv := NewServer(cdb, clusterID)
srv.Run()
}

7.2 强制查询隔离的 Guard

1
2
3
4
5
6
7
8
9
10
11
12
// 每个 handler / usecase 都必须通过 Server.scoped() 拿到带 cluster_id 的句柄
func (s *Server) scoped() *gorm.DB {
return s.cdb.Scoped() // 内部就是 WHERE cluster_id = ?
}

// 任何“裸 db”使用在 CI/测试阶段直接 panic,避免漏筛
var rawDBUsed = errors.New("raw DB use forbidden; must use scoped db")

func MustScoped(db *gorm.DB) *gorm.DB {
if !db.Statement.Clauses["WHERE"].Build(...) { panic(rawDBUsed) }
return db
}

7.3 LISTEN/NOTIFY 增量任务

1
2
3
4
5
6
7
8
9
10
11
12
-- 变更触发
CREATE OR REPLACE FUNCTION notify_netmap_dirty() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('netmap_dirty',
json_build_object('cluster_id', NEW.cluster_id,
'scope_key', NEW.user_id)::text);
RETURN NEW;
END; $$ LANGUAGE plpgsql;

CREATE TRIGGER trg_routes_dirty
AFTER INSERT OR UPDATE OR DELETE ON routes
FOR EACH ROW EXECUTE FUNCTION notify_netmap_dirty();

8. 安全与隔离

  • 租户边界:cluster_id + tenant_id 双层筛;敏感操作(ACL 更新、预共享 Key 生命周期)记录审计日志。
  • 跨租户访问:在 ACL 解释器层强制禁止跨租户匹配,除非显式开启“租户互访”白名单。
  • 密钥材料:私钥/预授权 Key 加密落库(KMS/密钥托管),headscale 节点无本地落地。
  • 最小权限:ACS 仅可读写“租户/映射”相关表,不直接触碰设备会话。

9 与 Tailscale SaaS 形态的对齐

本方案在形态上具备“控制面多节点 + 后端共享数据存储 + 前置接入控制”的 SaaS 基本盘:

  • 新租户接入由 ACS 编排;
  • 不同付费层级对应不同 clusterID 策略(共享/独享);
  • 故障域与容量按节点维度扩展;
  • 运维面做统一的监控、计费与合规模块。

10. 结语

把 headscale 改造成可分片的集群,不是“堆机器”那么简单,关键在于:

  • 数据平面保持简单,控制面增量化;
  • 每个节点只负责本分片,PG 做共享但逻辑隔离;
  • 接入控制程序承担“租户编排”与“负载均衡”;
  • 通过netmap 计算收敛把 CPU 从“全量重建地狱”中解放出来。

按本文方案落地后,单租户/多租户都能获得近似线性的扩展能力,体验与 SaaS 形态接近。在资源可控的前提下,我们就能把 headscale 带到“无限接近无限”的设备连接规模。


11. 演示

使用 docker 部署 两个节点的 headscale , 然后每个节点创建几个用户,再连接50个客户端。两个节点共享一个pgsql数据库。

cluster id 是1 的节点 node

id1

cluster id 是2 的节点 node

id2

数据库 users 表

users

数据库 nodes 表

nodes

数据库 pre auth keys 表

keys

链接

Headscale SaaS

headscale 压力测试

总结摘要

用法速记:

  1. 解压后 cp .env.example .env,填好 HEADSCALE_URL 与 TS_AUTHKEY
  2. bash scripts/00_check_env.sh
  3. bash scripts/01_bootstrap_up.sh(默认 200 节点)
  4. bash scripts/02_list_nodes.sh → 生成 nodes.tsv
  5. 终端 A:bash scripts/03_churn.sh(每 30s 随机 up/down/restart)
  6. 终端 B:watch -n 20 'bash scripts/04_traffic_round.sh' 持续抽样流量
  7. 结束:bash scripts/99_cleanup.sh

200个客户端同时连接

客户端

headscale服务器资源消耗

服务器资源消耗

tailscale客户端服务器资源消耗-200个客户端

客户端资源消耗


一、推荐机器规格(200 设备规模)

将被测 headscale与**发压端(loadgen)**分离,更易定位瓶颈。若你只买一台也能做,但推荐两台。

A. 发压端(运行 200 个 Tailscale 客户端容器)

  • vCPU:16 核

  • 内存:32 GB

  • 磁盘:100 GB NVMe(Docker 层与容器日志)

  • 网卡:≥ 1 Gbps(最好 5–10 Gbps)

  • OS:Ubuntu 22.04 LTS / Debian 12

  • 内核参数(建议):

    • fs.file-max=200000
    • net.core.rmem_max=16777216
    • net.core.wmem_max=16777216
    • net.ipv4.ip_local_port_range=10000 65000
  • Docker 守护进程日志轮转(避免日志撑满磁盘):

    • log-driver: "local", log-opts: {"max-size":"10m","max-file":"3"}

B. 被测 headscale(控制面)

  • vCPU:4 核
  • 内存:8 GB
  • 磁盘:100 GB
  • 网卡:≥ 1 Gbps
  • 数据库:SQLite(WAL 打开)或 PostgreSQL(随你已有环境)
  • 反向代理:如必须放在反代后,请放宽超时/保持连接,保证长轮询不被切。

C.(可选)自建 DERP 中继

  • vCPU:2–4 核
  • 内存:2–4 GB
  • 网卡:≥ 1 Gbps
  • 开放 TCP(derp) 与 UDP 3478(STUN)

二、目录结构(建议)

你可以在任意目录新建如下文件(均在本文给出内容):

1
2
3
4
5
6
7
8
9
10
11
headscale-loadtest/
├─ docker-compose.yml
├─ .env.example
├─ scripts/
│ ├─ 00_check_env.sh
│ ├─ 01_bootstrap_up.sh
│ ├─ 02_list_nodes.sh
│ ├─ 03_churn.sh
│ ├─ 04_traffic_round.sh
│ ├─ 99_cleanup.sh
└─ README.md (可把本文另存)

三、使用说明

  1. 准备:在 headscale 上生成一个 pre-auth key(预授权密钥)。
  2. 编辑 .env(复制 .env.example)填入 HEADSCALE_URL 和 TS_AUTHKEY。
  3. bash scripts/00_check_env.sh 检查环境(tun、docker 等)。
  4. bash scripts/01_bootstrap_up.sh 一键起 200 个 tailscale 客户端容器。
  5. bash scripts/02_list_nodes.sh 导出容器名 ↔ Tailscale IP 列表。
  6. 抖动:另开终端运行 bash scripts/03_churn.sh(每 30s 随机 up/down/restart 一批)。
  7. 流量:再开终端循环跑 bash scripts/04_traffic_round.sh(随机挑对做 TCP/UDP 10s 压力)。
  8. 结束后 bash scripts/99_cleanup.sh 清理容器与临时 sidecar。

四、docker-compose 与 .env

说明:我们用 docker compose up --scale tsnode=200 -d 来“一行扩容 200 台设备”。hostname 不必手配,容器会使用唯一的容器 ID 作为主机名,注册到 headscale 时也会保持唯一性。
iperf3 不安装进 tailscale 容器里,而是用短生命的 sidecar 容器通过 --network=container:<tsnode> 共享网络命名空间来发流/收流,避免镜像扩展与 apt 依赖。

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
tsnode:
image: tailscale/tailscale:stable
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
# 必填:由 .env 注入
- TS_AUTHKEY=${TS_AUTHKEY}
# 建议关闭容器内部 DNS 接管;指向你的 headscale
- TS_EXTRA_ARGS=--login-server=${HEADSCALE_URL} --accept-dns=false
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
# 可按需开启持久化(不是必须)
# volumes:
# - ts-state:/var/lib/tailscale

# volumes:
# ts-state:

.env.example(复制为 .env 并修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 你的 headscale 外部可达地址(含 https://,勿带结尾斜杠)
HEADSCALE_URL=https://headscale.example.com
# 从 headscale 预先创建的预授权密钥
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 规模与参数(可改)
REPLICAS=200 # 模拟设备数量
CHURN_INTERVAL=30 # 抖动间隔秒
CHURN_BATCH_PERCENT=10 # 每轮对多少百分比容器做 up/down/restart
TRAFFIC_PAIR_COUNT=30 # 每轮随机压多少对
TRAFFIC_TCP_DURATION=10 # TCP 每对持续秒
TRAFFIC_TCP_PARALLEL=4 # TCP 并发流数 -P
TRAFFIC_UDP_DURATION=10 # UDP 每对持续秒
TRAFFIC_UDP_BW=50M # UDP 目标带宽 -b

五、脚本集合(保存到 scripts/)

注意:脚本使用 docker compose(v2),以及 networkstatic/iperf3 作为临时 sidecar 镜像。首次需要联网 docker pull,之后就可离线复用。

1) 00_check_env.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env bash
set -euo pipefail

# 载入 .env
if [[ -f .env ]]; then source .env; else echo ".env 未找到,请复制 .env.example"; exit 1; fi

# 检查 docker
command -v docker >/dev/null || { echo "docker 未安装"; exit 1; }
docker info >/dev/null || { echo "docker daemon 不可用"; exit 1; }

# 检查 compose
if ! docker compose version >/dev/null 2>&1; then
echo "docker compose v2 未安装"; exit 1
fi

# 检查 tun
if [[ ! -e /dev/net/tun ]]; then
echo "/dev/net/tun 不存在,尝试加载内核模块..."
if command -v modprobe >/dev/null; then
sudo modprobe tun || true
fi
fi
[[ -e /dev/net/tun ]] || { echo "仍无 /dev/net/tun,请在宿主机启用 TUN 模块"; exit 1; }

# 预拉镜像(可离线后使用)
echo "预拉取镜像(可选)..."
docker pull tailscale/tailscale:stable
docker pull networkstatic/iperf3:latest

echo "检查通过。"

2) 01_bootstrap_up.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env bash
set -euo pipefail
source .env

echo "以 $REPLICAS 个副本拉起 tsnode..."
docker compose up -d --scale tsnode="${REPLICAS}"

echo "等待节点启动并完成登录(首次 30~60s)..."
sleep 45

echo "当前副本数量:"
docker compose ps tsnode

echo "可运行 scripts/02_list_nodes.sh 导出节点 IP。"

3) 02_list_nodes.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
set -euo pipefail

OUT="nodes.tsv"
: > "$OUT"

# 列出 compose 管理的 tsnode 容器(名字包含项目前缀)
mapfile -t CTS < <(docker compose ps --format '{{.Name}}' tsnode)

echo -e "container\tts_ip4" >> "$OUT"
for c in "${CTS[@]}"; do
ip=$(docker exec "$c" tailscale ip -4 2>/dev/null | head -n1 || true)
if [[ -n "$ip" ]]; then
echo -e "${c}\t${ip}" >> "$OUT"
fi
done

echo "已导出 $OUT(容器名 ↔ Tailscale IPv4)。"

4) 03_churn.sh(每 30s 随机 up/down/restart 一批)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/usr/bin/env bash
set -euo pipefail
source .env

SERVICE="tsnode"
INTERVAL="${CHURN_INTERVAL:-30}"
PCT="${CHURN_BATCH_PERCENT:-10}"

echo "每 ${INTERVAL}s 随机对 ${PCT}% 容器执行 up/down/restart 抖动。按 Ctrl-C 结束。"

while true; do
mapfile -t CTS < <(docker compose ps --format '{{.Name}}' "$SERVICE")
TOT=${#CTS[@]}
((TOT>0)) || { echo "未发现容器"; exit 1; }
BATCH=$(( (TOT*PCT + 99)/100 )) # 向上取整

# 随机挑选
mapfile -t PICKED < <(printf "%s\n" "${CTS[@]}" | shuf -n "$BATCH")

echo "=== Churn round: $(date '+%F %T') 目标 $BATCH/$TOT ==="
for c in "${PICKED[@]}"; do
op=$((RANDOM%3)) # 0:down 1:up 2:restart
case "$op" in
0)
echo " - $c : tailscale down"
docker exec "$c" tailscale down || true
;;
1)
echo " - $c : tailscale up"
# 使用容器内命令 up;如果你在 .env 中改了 HEADSCALE_URL/TS_AUTHKEY,这里会重登
docker exec "$c" tailscale up --login-server="${HEADSCALE_URL}" --authkey="${TS_AUTHKEY}" --accept-dns=false || true
;;
2)
echo " - $c : docker restart"
docker restart "$c" >/dev/null || true
;;
esac
done

sleep "$INTERVAL"
done

5) 04_traffic_round.sh(随机起对做 TCP/UDP 流量)

设计为一轮 10s的抽样压测;你可以循环调用或配合 watch -n 20 调度。
服务器与客户端都用 临时 sidecar 容器,--network=container:<tsnode> 共享网络命名空间,无需在 tsnode 内安装 iperf3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/usr/bin/env bash
set -euo pipefail
source .env

PAIRS="${TRAFFIC_PAIR_COUNT:-30}"

# 读取 nodes.tsv(若不存在则生成)
[[ -f nodes.tsv ]] || bash scripts/02_list_nodes.sh

mapfile -t LINES < <(tail -n +2 nodes.tsv)
CNT=${#LINES[@]}
((CNT>=2)) || { echo "可用节点不足 2"; exit 1; }

pick_node() {
# 输出:name ip
local line="${LINES[$((RANDOM % CNT))]}"
local name ip
name=$(awk '{print $1}' <<<"$line")
ip=$(awk '{print $2}' <<<"$line")
echo "$name $ip"
}

run_pair_tcp() {
local cA ipA cB ipB
read -r cA ipA <<<"$(pick_node)"
read -r cB ipB <<<"$(pick_node)"
[[ "$cA" != "$cB" ]] || return 0

# 启动一次性服务器(-1 处理一次并退出)
docker run --rm -d --name "iperf-srv-${cB}" --network="container:${cB}" networkstatic/iperf3 -s -1 >/dev/null

echo "[TCP] $cA -> $cB ($ipB)"
docker run --rm --network="container:${cA}" networkstatic/iperf3 \
-c "$ipB" -t "${TRAFFIC_TCP_DURATION:-10}" -P "${TRAFFIC_TCP_PARALLEL:-4}" >/dev/null 2>&1 || true
}

run_pair_udp() {
local cA ipA cB ipB
read -r cA ipA <<<"$(pick_node)"
read -r cB ipB <<<"$(pick_node)"
[[ "$cA" != "$cB" ]] || return 0

docker run --rm -d --name "iperf-srv-${cB}" --network="container:${cB}" networkstatic/iperf3 -s -1 >/dev/null

echo "[UDP] $cA -> $cB ($ipB)"
docker run --rm --network="container:${cA}" networkstatic/iperf3 \
-c "$ipB" -u -b "${TRAFFIC_UDP_BW:-50M}" -t "${TRAFFIC_UDP_DURATION:-10}" >/dev/null 2>&1 || true
}

echo "=== Traffic round @ $(date '+%F %T') pairs=${PAIRS} ==="
for ((i=0; i<PAIRS; i++)); do
if (( RANDOM % 2 )); then
run_pair_tcp &
else
run_pair_udp &
fi
done
wait
echo "=== Round done ==="

6) 99_cleanup.sh

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail

echo "停止并清理 tsnode 副本与残留 iperf sidecar..."
# 先清理 sidecar
docker ps --format '{{.ID}} {{.Names}}' | awk '/iperf-srv-/{print $1}' | xargs -r docker rm -f

# 再停 tsnode
docker compose down -t 5

六、测试步骤建议

  1. 单节点验证

    • 修改 .env 为 REPLICAS=1,bash scripts/01_bootstrap_up.sh。
    • docker compose exec tsnode-1 tailscale status 看是否已连上你的 headscale(可用 tailscale netcheck 验证直连/DERP)。
    • bash scripts/02_list_nodes.sh 确认导出了 IP。
  2. 扩到 200 节点

    • .env 改回 REPLICAS=200,再次 01_bootstrap_up.sh。
    • 02_list_nodes.sh 导出 nodes.tsv。
  3. 控制面抖动

    • 终端 A:bash scripts/03_churn.sh(默认每 30s 对 10% 做 up/down/restart)
    • 观察 headscale /metrics、CPU、内存、日志中 PollNetMap 等请求量与耗时。
  4. 数据面吞吐

    • 终端 B:watch -n 20 'bash scripts/04_traffic_round.sh' 连续抽样回合。
    • 对比直连与 DERP(可在安全组/防火墙层面封掉 UDP,观察 DERP 回退下的吞吐变化)。
  5. 扩大/缩小与对比

    • 把 REPLICAS 改为 300/500 试试 headscale 的上限趋势(若机器足够)。
    • 分别在无抖动与强抖动两种状态下记录 headscale 的指标差异。

七、常见问题(FAQ)

  • 为什么用 sidecar 跑 iperf3?
    这样无需在 tailscale 镜像里安装任何包;--network=container:<tsnode> 会共享该 tsnode 的网络命名空间,等价于“在该设备上跑 iperf3”。

  • Compose 扩容 200 个需要唯一主机名吗?
    不需要手工设置。默认容器主机名是容器 ID,Tailscale 设备名会唯一。也可自行加 --hostname 脚本模式(不建议和 --scale 混用)。

  • 抖动脚本中的 tailscale down/up 与 docker restart 区别?
    down/up 更接近“客户端主动注销/重登录”;restart 模拟“设备重启或网络闪断”,三者混合能覆盖更多状态机分支。


问题

现在信创等需求越来越多,很多时候我们需要同时打包 x86 和 arm 架构的程序。
本文主要介绍 如何使用 Docker 部署的 Jenkins 自动打包 x86 和 arm 架构的镜像。
本次介绍 如何打包 Spring Boot 以及 Node 项目,包含了前后端。

环境准备

1、x86 电脑或服务器,部署了 Jenkins 服务
2、X86 电脑或服务器,部署了 Harbor 服务。 打包后,自动推送镜像到 harbor

Jenkins 安装及配置

  • Jenkins 安装
1
2
3
4
5
6
7
8
9
10
11
12
docker run \
-u root \
-d \
-v $(which docker):/usr/bin/docker \
-v $PWD/jenkins-data:/var/jenkins_home \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /root/.docker/:/root/.docker/ \
-v /etc/localtime:/etc/localtime \
--network host \
--restart=always \
--privileged \
jenkins/jenkins

注意: 挂载了宿主机的 /root/.docker/ 目录。这样方便 容器内使用 buildx 。

  • Buildx 安装

1、 在 Github 上下载 Buildx 二进制文件

1
wget https://github.com/docker/buildx/releases/download/v0.28.0/buildx-v0.28.0.linux-amd64

2、 移动到 /root/.docker 目录下

1
mv buildx-v0.10.2.linux-amd64 /root/.docker/cli-plugins/docker-buildx

3、 添加可执行权限

1
chmod +x /root/.docker/cli-plugins/docker-buildx

buildx

  • 检查容器内 buildx 是否可用
1
2
3
4
5
6
# 进入容器
docker exec -it 容器名称 sh
# 查看 buildx 版本
docker buildx version
# 查看 buildx 状态
docker buildx ls

buildx版本

多架构基础镜像准备

如果使用 自有 Harbor 里的镜像,需要仓库里有多个架构的基础镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 能 push 的前提是,docker 已经 登入了 harbor
# 以 alpine 为例,x86 镜像,推送到 harbor
docker pull registry.cn-hangzhou.aliyuncs.com/acs/alpine:3.16.0
docker tag registry.cn-hangzhou.aliyuncs.com/acs/alpine:3.16.0 harbor.xxxx.com/library/alpine:3.16.0
docker push harbor.xxxx.com/library/alpine:3.16.0

# 拉取arm 镜像,推送到 harbor
docker pull --platform=linux/arm64 registry.cn-hangzhou.aliyuncs.com/acs/alpine:3.16.0-arm64 # 带 --platform=linux/arm64 ,表示拉取 arm64 架构的镜像
docker tag registry.cn-hangzhou.aliyuncs.com/acs/alpine:3.16.0-arm64 harbor.xxxx.com/library/alpine:3.16.0-arm64
docker push harbor.xxxx.com/library/alpine:3.16.0-arm64


# 合并 x86 和 arm64 架构的镜像
docker buildx imagetools create -t harbor.xxxx.com/library/alpine:3.16.0 harbor.xxxx.com/library/alpine:3.16.0-arm harbor.xxxx.com/library/alpine:3.16.0

harbor 显示

多架构镜像

后端打包

后端 Spring Boot 打包其实不需要 buildx,因为 Spring Boot 项目使用 jib 插件即可完成多架构镜像的打包及推送。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.6</version>
<configuration>
<allowInsecureRegistries>true</allowInsecureRegistries>
<from>
<image>harbor.xxxx.com/library/openjdk:8u342-jdk</image>
<platforms>
<platform>
<architecture>arm64</architecture>
<os>linux</os>
</platform>
<!-- 如果想一次推多架构清单(amd64 + arm64),再加一个: -->
<platform>
<architecture>amd64</architecture>
<os>linux</os>
</platform>
</platforms>
</from>
<to>
<image>harbor.xxxx.com/library/spring:${project.version}</image>
</to>
<container>
<jvmFlags>
<jvmFlag>-Xms2g</jvmFlag>
<jvmFlag>-Xmx6g</jvmFlag>
<jvmFlag>-XX:+HeapDumpOnOutOfMemoryError</jvmFlag>
<jvmFlag>-XX:HeapDumpPath=/app/logs</jvmFlag>
<jvmFlag>-Duser.timezone=Asia/Shanghai</jvmFlag>
</jvmFlags>
<ports>
<port>9988</port>
</ports>
<creationTime>USE_CURRENT_TIMESTAMP</creationTime>
<mainClass>com.xxx.xxx.XxxApplication</mainClass>
</container>
</configuration>
</plugin>

前端打包

前端打包需要使用到 buildx,因为 Node 项目没有类似 jib 的插件。

前端项目 jenkinsfile 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#!/usr/bin/env groovy

def version = '1.0.arm64'

pipeline {
agent none
environment {
RELEASE_NUMBER = "${version}.${env.BUILD_NUMBER}"
NPM_CONFIG_REGISTRY = 'http://yournexue.com/repository/npm-group/'
}
stages {
stage("install dependencies and build") {
agent {
docker {
image 'node:14.18.1'
args '-v /root/.yarn/v6:/usr/local/share/.cache/yarn/v6 --add-host=raw.githubusercontent.com:185.199.111.133 --add-host=registry.yarnpkg.com:104.16.30.34 -e NODE_OPTIONS=--max_old_space_size=8192'
}
}
steps {
sh "sed 's/VERSION-NUM/${RELEASE_NUMBER}/g' -i ./.env.production"
sh "sed 's/VERSION-NUM/${RELEASE_NUMBER}/g' -i ./.env.test"
sh "yarn config set registry ${NPM_CONFIG_REGISTRY} && yarn config set ignore-engines true && yarn install"
// sh 'cp -rf dependencies/** /node_modules'
sh 'yarn build'
sh "rm -rf xxx-app && mkdir -p xxx-app && mv -f dist xxx-app"
sh "mv -f conf xxx-app"
sh "mv -f Dockerfile xxx-app"
}
}
stage("setup buildx (multi-arch + HTTP Harbor)") {
agent any
steps {
writeFile file: 'buildkit.toml', text: '''
[registry."harbor.xxxx.com"]
http = true
insecure = true
'''
sh '''
set -e
export DOCKER_HOST="${DOCKER_HOST:-unix:///var/run/docker.sock}"
export DOCKER_CLI_EXPERIMENTAL="${DOCKER_CLI_EXPERIMENTAL:-enabled}"
export HOME="${HOME:-/root}"

echo "[check] docker & buildx versions:"
docker version
docker buildx version

# 1) 启用 binfmt(使 x86 上可构建 arm64;幂等)
docker run --privileged --rm tonistiigi/binfmt --install all || true

# 2) 确保使用 docker-container 驱动的 builder(本 stage 内就地创建/选择,避免跨节点丢失)
BUILDER_NAME=fe-multiarch-builder

# 如已存在则直接 use,不存在则创建
if docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
docker buildx use "${BUILDER_NAME}"
else
docker buildx create \
--name "${BUILDER_NAME}" \
--driver docker-container \
--config ./buildkit.toml \
--driver-opt network=host \
--driver-opt "env.http_proxy=" \
--driver-opt "env.https_proxy=" \
--use
fi

# 3) 引导 BuildKit
docker buildx inspect --bootstrap
echo "[builders]"
docker buildx ls

# 4) 多架构构建并推送(显式指定 --builder,防止默认切回 docker driver)
echo "[buildx] building & pushing multi-arch image..."
docker buildx build xxx-app \
--builder "${BUILDER_NAME}" \
--platform linux/amd64,linux/arm64 \
-t 172.16.30.52:8894/library/app:${RELEASE_NUMBER} \
--push
'''
}
}
stage("clean") {
agent any
steps {
sh '''
# 可选择保留 builder 以复用缓存,这里演示清理
docker buildx rm fe-multiarch-builder || true
rm -rf app buildkit.toml || true
'''
}
}
}
}

总结

在 Jenkins 中同时打包 x86 以及 arm 架构的镜像,我们只需要在x86 架构的机器上安装 buildx 即可,不需要用到 arm 架构的机器。

下面对 Tailscale 如何做 NAT 穿透(包括直连建立、保持与回退)的关键原理拆开讲。

1)总体线路:三条路,优先直连

  • 优先直连(UDP over Internet):两端先尝试点对点直连,成功后所有数据都直接走这条路(性能最好、时延最低)。(tailscale.com)
  • 协商/引导与兜底(DERP):在直连之前与失败时,先经就近 DERP 中继建立“控制/引导通道”,用它交换候选地址、发起“请你打洞”等信号;若直连始终失败,则走 DERP 作为加密转发 的备胎。(tailscale.com)
  • 更细的穿透协议(DISCO + STUN):Tailscale 基于 WireGuard 自研了“DISCO”发现/心跳层,配合 STUN 获取公网映射与 NAT 类型,驱动 UDP 打洞。(tailscale.com)

2)打洞的关键“料”:同一 UDP socket、STUN、候选地址、互打

  • 同一 UDP socket:获取公网映射必须用和后续真正传数的同一个 UDP 套接字来发 STUN/接回包,否则你测到的 公网IP:端口 跟实际传输不同,洞就白打了。这是很多实现失败的根源。(tailscale.com)
  • STUN 获取外网映射与 NAT 性质:客户端向 STUN 发请求,得到“我在外面的 IP:port 是啥”,也能侧面推断 NAT 类型(如对称型更难打)。(tailscale.com)
  • 交换候选地址:双方把自己当前可用的候选 端点(endpoints)(含 IPv4/IPv6、多个 IP:port 映射)通过控制面/DERP 互换。(tailscale.com)
  • 双向同时发起(UDP hole punching):A、B 对彼此的候选 IP:port 同步“互打”若干小包(包含 DISCO 探测),在两端 NAT 上同时打开出站映射,让对方进入我的 NAT 表,从而“外部包”也能被 NAT 放行给我。(tailscale.com)

3)DISCO 层干了什么?(比 WireGuard 更懂 NAT 的“前戏”)

  • DISCO ping / 心跳:tailscale ping 默认发的是 DISCO 探测,不是 ICMP;它能告诉你“目前是经 DERP 还是直连哪个 IP:port”。(tailscale.com)
  • CallMeMaybe:当 A 已经对 B 的候选端点开火时,会经 DERP 送一个 CallMeMaybe 给 B,意思是“我在打你的这些端口了,你也回打一发,把回程洞打开”。(Go Packages)
  • (新版)CallMeMaybeVia / UDP relay endpoint:还有一种 Via 形式,提示通过中继候选来协助开路(对“UDP 被限”网络更友好)。(Go Packages)
  • 选择 & 优先级:如果已经有可靠直连,端上可以不再开启新的路径;直连优先于“经中继的路径”。(Go Packages)

4)魔法插座:magicsock 把所有这些都“接”在一起

  • magicsock 是 Tailscale 在用户态的“魔法 UDP 套接字”,同一个 socket 同时承载 WireGuard 数据、STUN、DISCO,以及穿透时需要的各种小包和心跳,从而保证“测到的映射=用来传数的映射”。(Go Packages)
  • 这也是为何 Tailscale 不直接用内核态 WireGuard:因为 NAT 穿透的许多动作必须跟数据在同一个用户态 socket里做,方便灵活控制与观测重绑定(rebinding)、心跳、计时器等。(Reddit)

5)失败与回退:DERP 作为发现通道 + 永久兜底

  • 发现阶段:最初几包通常先走最近的 DERP,用于互递 DISCO / 候选地址;一旦直连打通,就切换到直连通道。(tailscale.com)
  • 兜底阶段:若遇到 对称 NAT + 防火墙 等强限制,直连打不通,就全流量走 DERP(加密、稳定,但带宽/时延逊于直连)。(iam.bitbeats.io)
  • 现实世界并不完美:官方也承认 NAT 穿透是“长期拉锯战”,需要持续遥测与改进。(tailscale.com)

6)连通保持与漫游(rebind)处理

  • 保活心跳:穿透成功后,DISCO 会以低频心跳维持 NAT 映射;社区里称过“silent disco”等优化,尽量减少无谓心跳。(GitHub)
  • NAT 重绑定:当一端公网 IP:port 变化(例如路由器重启/蜂窝到 Wi-Fi 切换),magicsock 会通过 DISCO/STUN 迅速探测到并重建可用路径。原理还是“同一 socket 上继续打洞 + 候选更新”。(Go Packages)

7)IPv6 情况

  • 双栈最优:若两端都有可达 IPv6,通常不需要穿透就能直连(无 NAT、路径更稳定);若一端仅 IPv4-NAT,仍按上面流程打洞或回退 DERP。(tailscale.com)

8)你能在现场怎么“看懂它在做什么”

  1. 看当前连接类型与路径
1
2
3
tailscale status --peers
tailscale ping <peer-name> # 默认 DISCO,输出会标明 via DERP(...) 或 via <IP:port>
tailscale ping --until-direct <peer-name> # 持续直到打出直连

这些命令能直接看到“是否直连、走哪个 IP:port、DERP 节点名”等。(tailscale.com)

  1. 网络自检
1
tailscale netcheck

用于判断 UDP 是否可出、就近 DERP、是否存在对称 NAT 等(输出因版本而异)。文档对“连接类型”、“DERP 角色”有解释。(tailscale.com)

9)把整个流程串起来(顺序图式说明)

  1. 两端启动 → 通过控制面注册/换公钥,并各自连上就近 DERP。(tailscale.com)
  2. STUN 获取自己的公网 IP:port 与 NAT 线索;把“候选端点”发给对端(经控制面/DERP)。(tailscale.com)
  3. A/B 同时对彼此候选端点发 DISCO 探测(互打)→ NAT 表里建立出站/入站映射。必要时 A 还会经 DERP 发 CallMeMaybe 让 B 主动回打。(Go Packages)
  4. 任一条直连能收发 DISCO/数据包 → 选定最佳直连路径并切换数据面到直连。(tailscale.com)
  5. 若所有直连失败 → 全流量走 DERP,同时周期性再尝试直连以便条件改变时“脱离中继”。(iam.bitbeats.io)

进一步阅读(权威/源码级)

  • 官方长文:How NAT traversal works(强烈推荐通读,解释了为何“必须同一个 UDP socket”)。(tailscale.com)
  • DERP 服务器与角色(发现与兜底)。(tailscale.com)
  • DISCO 协议与 CallMeMaybe / CallMeMaybeVia 类型(Go 包文档)。(Go Packages)
  • magicsock 源码包(用户态“魔法 socket”,NAT 打洞的执行场)。(Go Packages)
  • Ping 类型说明(DISCO/TSMP/ICMP 的区别与用途)。(tailscale.com)

大家好,我是那个曾经天真地以为“买个摄像头=万事大吉”的倒霉蛋。

你有没有经历过这样的场景?
花几百块买了个“智能家用摄像头”,画质清晰、夜视给力、手机远程查看so easy。结果用了三个月,厂商发来一条温柔又冰冷的消息:

“亲爱的用户,您的免费云存储已到期,请支付29元/月开启录像回看功能。”

什么?!一个月29?!我这是在养摄像头还是在供儿子上私立国际学校?

更离谱的是,有些品牌还玩起了“套餐制”——7天循环录像要30,30天要60,要是想永久保存?不好意思,得加钱,还得绑定他们的会员体系……仿佛你不交钱,家里的猫偷吃零食的画面就永远消失了。

于是,我含泪删掉了那段拍到我家狗子半夜翻垃圾桶的珍贵视频,并立下誓言:

我要自己当“云服务商”!


一、市面上的摄像头,不是太贵就是太坑

现在的家用摄像头,说白了就两种:

  1. 便宜的:只能实时查看,不能回放,等于“睁眼瞎”。
  2. 贵的:必须订阅云服务才能录像,每月扣你几十块,像极了爱情里的“续费关系”。

而且你还不能断订——一旦停了,之前的录像全清空,比前任删聊天记录还彻底。

这不是科技,这是数字勒索!

所以,我决定反向操作:买硬件不买服务,数据本地存,自由掌握在自己手里!


二、我的省钱方案:海康+录像机+Mini主机 = 家庭版“天网系统”

经过一番研究(和踩坑),我最终选择了这套组合拳:

  • 设备清单:
    • 海康威视 WiFi/有线双模摄像头 ×2(约230元/台)
    • 海康威视 WiFi录像机(NVR)×1(2路约70元左右,4路约140元左右)
    • Mini主机一台(小钢炮式Windows迷你电脑,300~600元)

总价不到1000,能用五年起步,平均每天才几毛钱,连一杯奶茶都不到!

✅ 方案优势一览:

项目 传统云摄像头 我的DIY方案
是否需要月租 是(29~60元/月) 否!一次性投入
录像是否本地存储 否(全靠云) 是!硬盘说了算
手机能否远程查看 能 能(通过“海康互联”APP)
数据隐私安全性 厂商掌握你的生活 自己掌握一切
视频保留时间 取决于付费等级 想存多久存多久

三、怎么搭?超简单三步走!

第一步:基础搭建 —— 海康全家桶上线

  1. 把两个海康摄像头装好(一个放客厅盯娃,一个放门口防快递刺客)。
  2. 连上WiFi录像机(支持无线连接摄像头,不用拉网线,懒人福音)。
  3. 插上电源,开机自动配对。

这时候你就可以用 “海康互联”APP 在手机上看实时画面啦!是不是很爽?

⚠️ 重点提醒:
设置时一定要记住你创建的那个 admin账号和密码!这不仅是登录APP的钥匙,更是进入摄像头后台的“万能通行证”。忘了它?恭喜你,可能要重置设备从头再来……

家里网络示意图

网络

录像机

录像机

室内摄像头

室内摄像头

mini主机

mini主机


第二步:进阶玩法 —— 把录像搬到自己的“云”里

你以为这就完了?No no no,真正的自由才刚开始!

我们目标是:不让任何厂商赚我一分钱,所有录像我说了算!

怎么做?

👉 把摄像头的数据导出来,存在自己的电脑或百度网盘!

具体操作如下:

  1. 准备一台 Mini主机(比如Intel N100的小盒子,性能够用还省电)。
  2. 让它连上家里同一个网络(可以让摄像头直接接路由器,也可以直接连录像机的WiFi热点)。录像机的WIFI热点以及密码可以在海康互联APP里看到。
  3. 登入摄像头后台,获取RTSP流地址(后面细说)。
  4. 在Mini主机上跑一个叫 ZLMediaKit 的开源工具(GitHub上有,免费!),它可以接收并录制摄像头的视频流。
  5. 录下来的文件自动同步到百度网盘、阿里云盘或者NAS,实现“私有云备份”。

这样一来,哪怕你家停电、设备损坏,视频还在云端躺着笑你:“主人,我活得比你还久。”

摄像头使用ZLMediaKit参考链接:https://ownding.com/2025/06/06/%E6%B5%B7%E5%BA%B7%E6%91%84%E5%83%8F%E6%9C%BA%E5%BD%95%E5%83%8F%E6%9C%BA%E5%AF%B9%E6%8E%A5%E6%96%B9%E5%BC%8F%E8%AE%B0%E5%BD%95/


四、如何找到摄像头IP并登入后台?

很多人卡在这一步,其实很简单,只需要一点点“黑客精神”(别怕,合法的那种)。

方法一:通过录像机找IP

  1. 用手机连上录像机发出的WiFi(初始默认名通常是 NVRxxxxxx)。
  2. 打开命令行工具(Windows按 Win+R → 输入 cmd)。
  3. 输入这个神奇咒语(需要先安装 nmap客户端):
1
nmap -sn 192.168.254.0/24

(注意:不同型号IP段可能不同,常见的是 192.168.1.x 或 192.168.254.x)

执行后你会看到一堆设备列表,其中就有你的摄像头IP,长得像这样:192.168.254.101

此步的目的就是找到摄像头的IP

  1. 打开浏览器,输入 https://[IP地址],比如:
1
https://192.168.254.101

然后输入你在“海康互联”里设的 admin 账号密码,Boom!进入后台!

在这里你可以:

  • 查看RTSP推流地址(一般是 rtsp://admin:12345@192.0.0.64:554/h264/ch1/main/av_stream 这种格式)
  • 调整分辨率、帧率
  • 设置移动侦测报警

摄像头web界面

摄像头web界面


五、终极自由:ZLMediaKit + 百度网盘 = 永不丢失的录像库

接下来就是技术流时间(放心,不写代码):

  1. 在Mini主机安装 ZLMediaKit(搜索 GitHub 就能找到)。
  2. 配置它去拉取每个摄像头的RTSP流。
  3. 设置录制规则:比如全天录制 or 只录有人动的时候。
  4. 用 百度网盘客户端 或 CloudSync 类工具,把录好的视频文件夹自动上传。

完成之后,效果是这样的:

当你出门旅游时,突然收到消息:“阳台有只野猫跳进来啃拖鞋。”
你打开手机百度网盘,翻出昨天下午三点的录像,果然看见一只橘猫正在表演“拖鞋刺身”。
你一边笑出声,一边庆幸:我没给任何公司交过一分钱会员费。

摄像头使用ZLMediaKit参考链接:https://ownding.com/2025/06/06/%E6%B5%B7%E5%BA%B7%E6%91%84%E5%83%8F%E6%9C%BA%E5%BD%95%E5%83%8F%E6%9C%BA%E5%AF%B9%E6%8E%A5%E6%96%B9%E5%BC%8F%E8%AE%B0%E5%BD%95/


六、总结:自己动手,丰衣足食

这套系统的好处总结一下:

✅ 不依赖云服务,没有月租
✅ 数据完全自主掌控,不怕泄露
✅ 支持长期存储 + 自动备份
✅ 手机随时查看,体验不输商业产品
✅ 关键是——省钱!省钱!省钱!

虽然前期稍微动了点脑子,但比起每个月被割韭菜,这点学习成本简直微不足道。


最后一句忠告:

科技应该是为你服务的,而不是让你沦为它的付费奴隶。

与其把钱交给那些天天弹窗催续费的App,不如投资一套真正属于自己的家庭安防系统。

毕竟,谁不想做一个既安全、又清醒、还不花冤枉钱的智慧家长呢?


📌 附:装备采购建议清单

名称 推荐型号 价格参考
海康摄像头 HK-Q3S5M-W / DS-IPC-K44H-LWPT ¥180~220
海康WiFi录像机 7802N-S1/W ¥60~140左右
Mini主机 Intel N100小主机 ¥300~600
开源工具 ZLMediaKit(GitHub) 免费

注意:mini主机自带几百G硬盘,够使用了。如果把录像同步到网盘后,可以清理mini主机硬盘中的视频文件。


如果你也厌倦了“云存储绑架”,不妨试试这个方案。
说不定哪天你还能靠这段拍到邻居偷摘你葡萄的视频,在小区业主群里一战成名!


#家用摄像头 #海康威视 #DIY监控 #零月租方案 #拒绝云绑架 #自己动手丰衣足食

Tailscale 4via6 在 Windows 子网路由上的 ICMP 行为说明

(为什么能用“合成 IPv6”访问 Web,却 ping -6 不通)

1. 场景与现象

  • 拓扑:
    A、B 两台 Windows 电脑安装 Tailscale;B 同时作为子网路由器(Subnet Router)。
    C 是 OpenWrt 设备,挂在 B 所在现场的内网。

  • 配置:已开启 4via6,B 宣告(advertise)一个 IPv6 子网,并在 Headscale/后台审批通过。

  • 现象:

    • 从 A 能用 “合成的 IPv6 地址” 打开 C 的 Web 服务(HTTP/HTTPS 正常)。
    • 但从 A 执行 ping -6 <C-的IPv6> 不通。抓包能看到 Echo Request 发出,但收不到回包;在 B 上用 Wireshark 看,没有 ICMP 包进入。

2. 关键概念:4via6 与“合成 IPv6”

Tailscale 的 4via6 会把内网 IPv4 目标编码进一个保留的 ULA 段(如 fd7a:115c:a1e0::/48),形成合成 IPv6 地址:
例:c0a8:0601 对应 192.168.6.1,于是会看到类似
fd7a:115c:a1e0:b1a0:7:c0a8:0601 这样的目的地址。

  • 对 TCP/UDP 流量:Tailscale 会把发往这些“合成 IPv6”的包转换并送到真实的 IPv4 终端,所以浏览器/SSH/Modbus-TCP 等能正常工作。
  • 对 ICMPv6:在 Windows 客户端 上,当前路径不会把 ICMPv6 Echo 转成 ICMPv4 Echo,因此 ping -6 不会得到回应——包也不会被转发到 B 或 C。

3. 根因(简洁版)

  1. Windows + 4via6 的协议支持面:
    只对 TCP/UDP 做 IPv6→IPv4 转换;ICMPv6 不在转换范围内,所以 ping -6 失败,而 Web 正常。

  2. Windows 子网路由对“原生 IPv6 子网”的转发能力有限:
    即便 B 宣告了 IPv6 子网,Windows 作为子网路由在 IPv6 转发与邻居发现处理上并不如 Linux 完整稳定,导致跨站点的 ICMPv6 转发经常不可用。

    换成 Linux/OpenWrt 当子网路由后,同样配置下 ping -6 通常即可恢复正常。

4. 结论一句话

在 Windows 子网路由 + 4via6 的组合中,HTTP/UDP 能走,但 ICMPv6 不会被转换/转发,所以 ping -6 不通是预期行为;Linux 子网路由可以正常转发 ICMPv6。

5. 可选解决/替代方案

方案 A(最省事):用 IPv4 做连通性检查

  • 改用 ping 192.168.6.x(ICMPv4),或
    Test-NetConnection 192.168.6.x -Port 80/22 验证端口。
  • 适合“只想知道在线与否”的场景;无需改动现场。

方案 B(推荐):把 子网路由器 换成 Linux/OpenWrt

在 B 侧放一台小 Linux/OpenWrt 盒子(可以是现有 OpenWrt 路由或小主机/容器)作为 Tailscale 子网路由:

1
2
3
4
5
6
7
8
9
# 1) 开启 IPv6 转发
sysctl -w net.ipv6.conf.all.forwarding=1
# 持久化:/etc/sysctl.conf 里设置 net.ipv6.conf.all.forwarding=1

# 2) tailscaled 启动并宣告 IPv6 前缀
tailscale up --advertise-routes=<你的IPv6前缀>/64 --accept-dns=false
# 按需添加其它前缀,或配合 --accept-routes

# 3) 在 Headscale/管理端批准这些 routes

完成后,从 A 直接 ping -6 <C 的**原生** IPv6>,ICMPv6 会被正常转发;B 上能抓到往返的 ICMPv6。

方案 C(最干净):让 C 直接加入 Tailnet

在 OpenWrt 上安装 Tailscale,让 C 成为独立节点(拥有自己的 fd7a:... 节点地址):

1
2
3
4
5
opkg update
opkg install tailscale
/etc/init.d/tailscaled enable
/etc/init.d/tailscaled start
tailscale up

A 直接 ping -6 <C 的 Tailscale IPv6>,端到端走 Tailscale,绕开子网路由的限制。

方案 D(保留 Windows,但想要 ping -6)

在 B 侧增加小型 Linux NAT64/CLAT(例如 Jool EAMT),专门把发往 fd7a:115c:a1e0::/96 的 ICMPv6⇄ICMPv4 做转换;并在 B/网关上加静态路由把该前缀指向这台转换器。

该方案能打通 ping -6,但复杂度与维护成本较高,一般不如方案 B/C。

6. 快速排障自检

  • 能开 Web,ping -6 不通:90% 是 ICMPv6 未转换/未转发的已知限制。

  • 在 A 执行:

    • ping 192.168.6.x(C 的 IPv4):若可达,证明 4via6 通路正常,只是 ICMPv6 不支持。
    • route print -6 或 Get-NetRoute -AddressFamily IPv6:若看到目的合成地址不断触发 Neighbor Solicitation 而无回应,这是主机把目标当“链路内”,但 Tailscale 不会为其应答/转发 ICMPv6 的典型表现。
  • 在 B 抓包(Tailscale/Wintun 接口):若看不到来自 A 的 ICMPv6,则说明包没被交给子网路由(仍印证“Windows 不处理 ICMPv6 转换”)。

7. 常见问答

  • 为什么浏览器能打开?
    因为 TCP(和大多数 UDP)会被 4via6 转换并转发到 IPv4 终端;ICMPv6 不在这个转换面里。
  • UDP 是否也能走?
    能(取决于具体协议/端口与策略),多数 UDP 通过 4via6 没问题;问题集中在 ICMPv6。
  • 以后 Windows 会支持吗?
    以当前版本看没有现成开关可启用。若对 ICMPv6 必须要,建议按方案 B/C 处理。

推荐落地选型

  • 想保留 Windows 当子网路由:接受“不能 ping -6”,用 IPv4 ping/端口探测替代。
  • 需要完整 IPv6(含 ping -6):把子网路由迁到 Linux/OpenWrt;或让终端设备直接跑 Tailscale。

openwrt

嗨,各位工业界的“键盘侠”、PLC驯兽师、设备救火队长们:

你们有没有遇到过这种尴尬场面?

👉 你想远程调试一台远在千里之外的设备,IP是 192.168.1.2 —— 很标准,很熟悉,闭着眼都能敲出来。

结果一连,连上了…隔壁老王的设备???

因为那边厂里有十台设备,清一色都是 192.168.1.2 —— 没错,工业设备出厂默认IP,就是这么“团结”。

这时候你只能:

  • 打电话问现场:“喂,老张,你那台192.168.1.2的PLC是第几台来着?”
  • 查文档:“哦对,虚拟IP是10.8.3.47…”(谁记得住啊!)
  • 改IP?不敢动,一动全厂报警,老板电话马上打过来:“你动我产线IP干嘛?!”

🤯 痛点爆炸!

—

别慌,救星来了!

我花了N个通宵、喝了N箱咖啡、掉了N撮头发,终于搞出了一个神器 —— 基于 WireGuide 的工业设备物联 & 远程运维系统!

它不炫技,不画饼,专治各种“IP撞车”、“远程卡壳”、“虚拟地址记不住”的工业疑难杂症。

—

🎯 核心功能一句话总结:

👉 你输入 192.168.1.2,我就让你连上你心里想的那台 192.168.1.2 —— 哪怕全世界有100台设备都叫这个名字!

是不是听着像魔法?其实是黑科技。

—

🔧 它怎么做到的?

想象一下,你走进一家超大超市,货架上摆着100瓶“可乐”,全都叫“可乐”,包装一模一样。

传统方案(比如SD-WAN/VPN)会说:“不行,重名了!我给每瓶可乐贴个编号:可乐#001、可乐#002…你得记住编号才能买。”

—— 工程师:我只想喝可乐,不想背编号!

而我的方案说:“没问题!你喊‘我要可乐’,我问你‘你要A区第三排那瓶,还是B区收银台旁边那瓶?’ —— 你指一下,我直接递给你。”

我们用 WireGuide 在后台悄悄“打标签+智能路由”,但前端完全不打扰你 —— 你看到的,还是那个熟悉的 192.168.1.2。

—

💡 三大爽点,专为工程师打造:

✅【零改造组网】
不用动现有设备、不用改IP、不用加交换机、不用培训网络管理员 —— 插上就能用,成本低到老板笑出声。

✅【真·原生IP访问】
PLC软件里直接输 192.168.1.2,点连接 —— 嗖,连上了!不是虚拟IP,不是映射端口,就是原汁原味的设备IP,和你在现场插网线一模一样!

✅【子网重叠?不存在的】
十台设备都是192.168.1.x?没问题!百台设备跨厂区IP全撞车?小意思!我们后台自动隔离+精准路由,互不干扰,所有设备永远在线,永不掉线。

—

🛠️ 举个栗子:

张工在上海总部,想调试深圳分厂的“包装机PLC”,IP是192.168.1.2。

同时,成都分厂的“注塑机PLC”也是192.168.1.2。

传统方式:张工得查表,找到“深圳包装机”的虚拟IP是10.12.5.8,再打开软件,输入这个“陌生号码”,祈祷别输错。

WireGuide方式:张工打开软件 → 选择“深圳分厂-包装机” → 自动连上 → 地址栏赫然显示:192.168.1.2 ✅

—— 就像你手机里存了两个“李伟”,一个备注“北京李伟”,一个“上海李伟”,你点“北京李伟”,打的就是北京那位。

—

🚀 为什么现有方案搞不定?

市面上的SD-WAN、工业VPN,本质是“换IP方案” —— 把冲突的IP换成不冲突的虚拟IP。这就像给每个人发个工牌号,不让叫名字了。

问题是:工程师不是网管!他们熟悉的是设备本体IP,不是你分配的“虚拟工号”。每次调试都要查表、换IP、重配软件 —— 效率拉胯,体验稀碎。

我的方案:不换IP,不改习惯,只改“背后怎么送信” —— 让网络自己变聪明,用户无感。

—

📈 效果如何?

  • 远程调试时间缩短 60%+
  • 工程师培训成本归零(因为不用学新IP)
  • 现场断网/误操作风险下降 90%
  • 老板满意度 ↑↑↑(因为不用买新设备、不用请外援、不用停产)

—

🎁 彩蛋功能:

  • 支持多工程师同时访问不同“192.168.1.2”,互不干扰
  • 流量加密,安全合规,符合等保要求
  • Web控制台一键切换设备,像换电视频道一样简单
  • 支持老旧设备、非智能设备、串口转网口设备 —— 老古董也能上高速!

—

📣 最后说句掏心窝子的:

工业不是互联网,不能总想着“颠覆”、“重构”。工程师要的是“顺手”、“靠谱”、“别添乱”。

我的产品不追求花里胡哨,只专注一件事:

让远程运维,回归它本该有的样子 —— 简单、直接、高效。

你敲 192.168.1.2,我就给你 192.168.1.2。

不多不少,不偏不倚。

—

如果你受够了“IP撞车”、“虚拟地址背诵大赛”、“远程调试像解谜游戏”…

欢迎来找我聊聊 👋 可以通过左侧导航栏Github链接找到我。

我们的产品让工业远程运维,像在家上路由器后台一样轻松。

—

#工业物联网 #远程运维 #PLC调试 #工业4.0 #WireGuide #子网冲突终结者 #工程师友好型产品 #别再背虚拟IP了

网站介绍

国内要充值 国外一些网站的会员服务,一般都挺麻烦的。

我自己有 wise ,但是基本上也很少用它来充值,主要是价格比较贵(基本上都是原价)。而且我都在 新加坡 区域充值,价格好像比其它区域更贵一点。

23年的时候有充值过 OpenAI 的 API,但是用的少,后面就没有充值了。 之后主要使用免费的比如 Gemini 和 Qwen,最近想使用下 Codex 看下效果。无意间看到有拼团的充值网站,价格比较优惠,比原价便宜 2美元。

想着就试下,发现能正常使用。 所以写出来分享下。整个操作基本没难度,直接使用 支付宝 进行付款就行。

网站是FamilyPro。

FamilyPro 助您节省 ChatGPT、Netflix、Spotify 等服务费用。 分享您的订阅或加入群组——简单、安全、实惠。

链接:https://familypro.io?invite=GHb1a30b

注册、购买

推荐使用 Google 账号,我使用 hotmail 和 outlook 后缀的邮箱都不行。hotmail后缀的邮箱在注册时就提示无法使用,outlook的邮箱一直收不到 激活邮件。

该网站支持 拼团共享账号,我买的是独享,使用的是自己的账号。

如果购买独享账号,需要提供给 网站 session,然后等几分钟就能成功。我后面看了下,应该是从 iOS 端购买的。

ChatGPT登入后,session 获取地址:https://chatgpt.com/api/auth/session

主页提供多种账号充值购买!

主页

购买界面,支持支付宝!

支付宝

订单界面!

订单

OpenAI ChatGPT显示 Plus订阅成功!

订阅成功

在 iOS 端查看订阅信息!

iOS

问题

使用jenkins对一个前端项目进行打包,每次打包都出现 file-saver 超时(TIMEOUT)问题。即使将yarn依赖源改成国内都解决不了。

1
2
3
error An unexpected error occurred: "https://raw.githubusercontent.com/eligrey/FileSaver.js/e865e37af9f9947ddcced76b549e27dc45c1cb2e/package.json: ETIMEDOUT".
info If you think this is a bug, please open a bug report with the information provided in "/var/jenkins_home/workspace/frontend_k8s/yarn-error.log".
info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.

鉴于国内对GITHUB访问时断时续的情况,如果打包时还是从GITHUB进行下载,那么这个问题就会一直出现。

那么,在已经将yran源改成国内的情况下,为何还会出现从 GITHUB 下载的情况呢? 而且项目中 FileSaver 使用的 代码形式的引入,没有进行依赖配置。

排查

为了排查问题,我先在本机电脑上使用 VPN,然后执行 yarn install 看下 yarn.lock 情况。

在命令成功执行后,打开 yarn.lock 文件,搜索 file-saver 找到了原因。

原来是程序中使用了 jspdf,而这个组件依赖 file-saver ,而且指定从 GITHUB 下载。

至此总算找到原因了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jspdf@^1.5.3:
version "1.5.3"
resolved "https://registry.npmmirror.com/jspdf/-/jspdf-1.5.3.tgz#..."
integrity sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw==
dependencies:
canvg "1.5.3"
file-saver eligrey/FileSaver.js#1.3.8 # ← 这里是问题所在
html2canvas "1.0.0-alpha.12"
omggif "1.0.7"
promise-polyfill "8.1.0"
stackblur-canvas "2.2.0"


file-saver@1.3.8, "file-saver@github:eligrey/FileSaver.js#1.3.8":
version "1.3.8"
resolved "https://codeload.github.com/eligrey/FileSaver.js/tar.gz/e865e37af9f9947ddcced76b549e27dc45c1cb2e"

jspdf

filesaver

解决

在 package.json 文件中,添加 resolutions:

1
2
3
4
5
{
"resolutions": {
"**/file-saver": "^2.0.5"
}
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "xxx",
"version": "1.0.2",
"private": true,
"author": "8080@gmail.com",
"scripts": {
// ... 你现有的 scripts
},
"dependencies": {
// ... 你现有的 dependencies
},
"devDependencies": {
// ... 你现有的 devDependencies
},
"resolutions": {
"**/file-saver": "^2.0.5"
},
// ... 其他配置
}

然后删除 yarn.lock 后,重新执行:yarn install.

背景

在很多工厂的实际网络环境中,由于历史原因,经常会出现多个子网IP地址重复(子网重叠)的问题。但由于工业生产的特殊性,我们无法对工厂现有网络进行大规模改造——一旦改动网络,可能导致生产线停机,造成严重损失。

此外,许多工业设备(如PLC、传感器等)自带TCP/IP功能,其IP地址是固定的,无法修改;部分设备端口也固定,且仅支持IPv4。这些限制进一步加剧了网络管理的难度。

当设备出现故障需要诊断时,工程师通常必须亲临现场。如果工厂距离研发中心上千公里,在生产高峰期派人出差,不仅耗时耗力,还会影响生产效率。

目前虽然有一些组网方案,比如使用NAT等,但效果并不算理想,因为工程师需要记住各种对应的虚拟地址。之前我们一直使用传统VPN解决设备远程维护的问题,但发现无法让所有子网中的设备同时在线,通信受限,实用性无法达到预期效果。而且很多工程师习惯用原始IP(如192.168.1.3)直接访问设备,不喜欢记虚拟地址或复杂域名,操作体验差。

子网重叠

解决

为了解决这些问题,我花了数月时间研究并实现了一套可行方案,满足以下核心需求:

  • 不改变工厂原有网络结构:无需调整现场设备IP或网络配置,避免影响生产。

  • 全设备在线、互联互通:所有子网设备均可接入,按需通信,也可灵活隔离。

  • 集中数据采集:只需在一台上位机安装采集软件,即可获取所有设备的数据。

  • 使用原IP远程访问:工程师可直接通过设备原有的IP地址(如192.168.1.3 或 127.0.0.1)进行远程连接和程序调试。

  • 支持相同IP设备区分访问:即使两个不同工厂都有IP为192.168.1.8的设备,也能准确指定并连接目标设备,且不影响其他设备的数据采集。

  • 贴合工程师操作习惯:尽量保持现有操作方式,降低学习成本,提升使用效率。

经过反复测试,该方案已在工业网关(带数据采集功能)和用户电脑上成功验证并使用,支持国内与海外设备的组网需求。相比早期的VPN方案,现已升级为基于 WireGuard + 自研技术 的组合方案,有效解决了子网重叠和原IP远程访问的核心痛点。

如您所在企业正面临类似问题,欢迎联系沟通,可提供完整解决方案及技术支持(包括方案购买、部署协助等)。

视频演示
说明:重叠的子网是 192.168.6.0/24。 为了能够访问,需要告诉程序你当前要访问的是哪个子网的设备,并且不能妨碍已构建设备网络的通信。

0%