headscale系列:headsale压力测试

headscale 压力测试

总结摘要

用法速记:

  1. 解压后 cp .env.example .env,填好 HEADSCALE_URLTS_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_URLTS_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. 单节点验证

    • 修改 .envREPLICAS=1bash 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/updocker restart 区别?
    down/up 更接近“客户端主动注销/重登录”;restart 模拟“设备重启或网络闪断”,三者混合能覆盖更多状态机分支。