让容器接入 Tailnet:深入理解 Tailscale 与 Docker
原文:Alex Kretzschmar,Contain your excitement: A deep dive into using Tailscale with Docker,Tailscale Blog,2024 年 2 月 7 日。本文为获得授权后的完整技术内容中文整理;核对截至 2026 年 10 月 8 日。涉及凭据语义和容器默认值的更新说明,以及示例配置中的安全修订,均标为编辑补充,不作为原作者当年的测试结论。
Tailscale 的一个核心用途,是把家人、朋友、同事的设备组成小而可信的网络。容器也可以成为这个网络中的节点。容器按设计隔离进程和资源;把运行服务所需的网络访问明确接入 tailnet,就能让不同网络拓扑下的服务互相连接。
每个服务配一个 Tailscale 容器,便可以获得自己的节点身份、名称和访问策略。许多场景不再需要在路由器上配置公网端口转发。原文将这一体验概括为不必搭建复杂的反向代理和防火墙规则。编辑补充:Tailscale 的 NAT 穿透并非保证所有连接都直连,必要时仍会中继;主机防火墙、Docker 网络和应用认证也仍然要管理。
阅读本文需要具备 Docker、Docker Compose 和基本网络概念。原文同时提供了视频与示例仓库,可从原文对应链接访问。下文依次介绍 auth key、OAuth client secret、边车共享网络命名空间,以及用 Serve 和 Funnel 代理 Web 服务。

先选择认证方式:auth key 还是 OAuth
Tailscale 官方提供容器镜像,可通过环境变量配置。让容器加入 tailnet,原文介绍了两种方式:直接使用 auth key,或提供 OAuth client secret,由容器启动程序按需生成 auth key。
| 比较项 | Auth key | OAuth client |
|---|---|---|
| 主要作用 | 无需浏览器交互地登记设备 | 按 scopes 授予 API 访问能力;本例仅用它生成 auth key |
| 凭据有效期 | 创建时指定,最长 90 天 | 原文强调 client secret 可长期使用;它换得的 API access token 并非永久有效 |
| 标签 | 可以不带标签,也可以配置标签 | 通过 auth_keys scope 登记节点时需要对应标签 |
| 节点身份 | 未标记设备使用生成者身份;带标签后使用标签身份 | 本例登记的设备由标签持有 |
编辑校正:2024 年原文表格把 auth key 写成“授予完整 API 访问”,并在正文把它描述为 fully scoped。这会混淆节点登记凭据与 API 访问凭据。当前官方 auth key 文档明确将其用于登记节点;应据此理解,而不把 auth key 当成管理员 API token。tagOwners 定义谁能赋予标签,也不是用户所有权在内部必然“表现为一个标签”。
auth key 过期,不等于由它登记的机器立即掉线。设备有自己的 node key;当前官方文档列出的默认 node key 过期周期是 180 天,带标签设备默认关闭 node key 过期。凭据过期主要阻止继续用它登记新设备。持久化状态的长期容器并不因为 auth key 最长 90 天就必须每 90 天重建身份。
用 auth key 把 nginx 接入 tailnet
在管理控制台的 Keys 页面创建密钥。原文演示值是:说明 docker-testing,Reusable 设为 yes,有效期 7 天,Ephemeral 设为 No,标签 tag:container。可复用密钥泄露后可被反复用于登记设备,因此这里的选择只是演示;单次登记场景可选一次性密钥,长期保管应使用受控的秘密存储。
使用标签前,先在 tailnet 访问策略中定义标签所有者。以下是策略片段,需要合并进已有策略,而不是覆盖整份策略:
"tagOwners": {
"tag:container": ["autogroup:admin"]
}
原文还提到可用 get-authkey 自动化生成密钥。无论手动还是自动生成,都应把 auth key 当作密码;不要把真实值写入公开仓库、截图或教程。TS_AUTHKEY=file:/run/secrets/tailscale_authkey 让 Tailscale 容器从挂载的 secret 文件读取凭据,避免把密钥内容直接写进 Compose 环境变量。TS_STATE_DIR=/var/lib/tailscale 则指定状态目录,必须同时挂载持久卷;只有环境变量、没有持久存储,重建容器后仍会丢失身份。
以下是原文 nginx Compose 示例的编辑修订版。用受审查的镜像版本或摘要填写 TAILSCALE_IMAGE、NGINX_IMAGE。认证密钥通过 Compose secret 文件挂载给 Tailscale 容器;只把宿主机文件路径放入变量,并把密钥文件限制访问、排除在版本控制之外。Tailscale 容器通过 file: 读取该文件。Compose 的本地文件 secret 不是远端密钥库,仍须保护宿主机文件和 Docker 主机:
services:
ts-webserver1:
image: ${TAILSCALE_IMAGE:?set a reviewed version or digest}
hostname: webserver1
environment:
TS_AUTHKEY: file:/run/secrets/tailscale_authkey
TS_AUTH_ONCE: "true"
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
volumes:
- tailscale-data-webserver1:/var/lib/tailscale
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
secrets:
- tailscale_authkey
restart: unless-stopped
webserver1:
image: ${NGINX_IMAGE:?set a reviewed version or digest}
network_mode: service:ts-webserver1
depends_on:
- ts-webserver1
volumes:
tailscale-data-webserver1:
driver: local
secrets:
tailscale_authkey:
file: "${TAILSCALE_AUTHKEY_FILE:?set a protected local path}"
相对于原文,这里移除了过时的顶层 version: "3.7";把 tailscale/tailscale:latest 和未固定标签的 nginx 改为必填镜像参数;纠正了原网页 devices 行混用制表符的缩进;增加 TS_AUTH_ONCE=true 和显式 TS_USERSPACE=false;去掉了 SYS_MODULE 能力,并按 Tailscale 当前 Compose 示例列出 NET_RAW。该版本假定 Linux 主机已经提供 TUN 设备,不在容器内加载内核模块;运行前仍须按目标主机、镜像版本和所需功能核对能力是否适用。
当前Docker 参数文档说明 userspace networking 默认启用,内核模式要显式关闭它并提供 TUN 与权限;持久状态场景可用 TS_AUTH_ONCE 避免每次重启重新登录。原示例没有明确这一网络模式,整理版将其写出。本文没有运行修订配置;实际部署前仍应按所选镜像版本核对参数。
nginx 的 network_mode: service:ts-webserver1 是关键:它共享 Tailscale 服务的网络命名空间。Tailscale 容器是边车,nginx 是应用。执行 docker compose up 后,原文预期可在 tailnet 中看到名为 webserver1 的节点。MagicDNS、访问控制和路由功能可在该节点身份下使用,但子网路由等功能仍需各自显式配置。
状态持久化且节点已经完成登记后,可以移除 TS_AUTHKEY、对应的服务级 secret 挂载与顶层 secret 声明,同时保留 TS_AUTH_ONCE 和状态卷。首次登记时必须仍提供有效密钥文件;若状态丢失或要登记新节点,也必须重新提供有效凭据。密钥自然过期不影响现有 node key 的独立生命周期。
改用 OAuth client secret
OAuth 允许给应用分配受限的 API scopes。本教程仅需要生成认证密钥所对应的写入权限,避免给容器配置能够修改 ACL 或 DNS 的权限。2024 年原文的控制台步骤是:进入 OAuth 页面,选择 Generate OAuth client,填写说明,勾选 Auth Keys: Write(会同时选择 Read),指定 tag:container,然后生成客户端。
界面更新:当前OAuth 文档把入口列为 Trust credentials → Credential → OAuth。以实际控制台为准。Client ID 不是本例直接传入 TS_AUTHKEY 所需的值;Client secret 则是秘密,关闭创建页面后无法再次复制。原文建议轮换时撤销并重建客户端,不应把“长期有效”理解为永不需要撤销或轮换。
将上面的环境变量和 secret 声明替换成下面内容,就可复用相同的持久卷、网络设备和 nginx 结构。OAuth secret 文件内容应为 OAuth secret 加上 ?ephemeral=false,文件本身应受限并排除在版本控制之外。如果要与第一个演示同时运行,应像原文第二例那样把服务和主机名改为 ts-webserver2/webserver2,卷名也改成独立的 tailscale-data-webserver2:
environment:
TS_AUTHKEY: file:/run/secrets/tailscale_oauth_secret
TS_EXTRA_ARGS: "--advertise-tags=tag:container"
TS_AUTH_ONCE: "true"
TS_STATE_DIR: /var/lib/tailscale
TS_USERSPACE: "false"
secrets:
- tailscale_oauth_secret
# Merge the following top-level secret with the Compose file's other secrets:
# secrets:
# tailscale_oauth_secret:
# file: "${TS_OAUTH_SECRET_FILE:?set a protected local path}"
容器识别 secret 文件中的 OAuth secret 后,会用它生成 auth key。这里的 --advertise-tags 必须是客户端获准使用的标签。OAuth 凭据登记的节点默认是 ephemeral,即离线一段时间后自动移除;CI 临时容器适合这一默认值,长期服务可在 secret 文件内容中添加 ?ephemeral=false,如上所示。
OAuth client secret、API access token、生成的 auth key 和设备 node key 是四种不同对象。当前官方文档说明 API access token 1 小时过期;不能把原文表格中的“never expires”延伸到所有 OAuth 相关 token 或节点。带标签节点的过期行为还取决于设备设置。
边车究竟共享了什么
原文第三份演示把服务命名为 ts-nginx-test 和 nginx-test,并把状态目录绑定到主机的 ts-nginx-test/state。它仍然使用相同的 network_mode: service:ts-nginx-test。这样,从另一台 tailnet 设备请求 http://nginx-test,就能访问 nginx 默认页面,而不需要把容器 80 端口映射到 Docker 主机的公网接口。
curl http://nginx-test
docker exec -it ts-nginx-test netstat -pant
原文先只运行 Tailscale 容器。此时域名能解析,但没有 Web 进程监听 80,curl 会连接失败。netstat 输出显示 tailscaled 的监听和对外连接;加入 nginx 并共享网络后,输出增加了 80 端口监听以及本地回环连接。
两个容器的进程仍分别运行,但 Linux 网络命名空间相同,因此共享接口、路由、端口和 localhost。nginx 可能在 Tailscale 容器中看到监听端口,却不显示进程名,因为 PID 命名空间并未随之共享。原文的输出包含 0.0.0.0:80 监听和 127.0.0.1:80 连接,不能把前者误读为只监听回环。
编辑补充:没有主机端口映射,不等于容器“所有入站和出站流量唯一经过 Tailscale”。Docker 默认桥接网络、共享命名空间中的监听地址、同网络其他容器和默认路由仍影响可达性与出口。原文观察到的 TCP 80/443 连接是当时环境的输出,也不能只凭端口断定所有连接都是 DERP。要隔离服务,仍须核对实际网络、策略和监听地址。Docker 的 Compose network_mode 文档说明,service:{name} 会让一个容器使用指定服务的网络命名空间;这不是独立网络边界。
共享命名空间也意味着同一个地址和端口只能由一个服务占用。原文建议一个服务对应一个 Tailscale 边车,并称其当时常见内存占用低于 20 MB。这是原作者的环境观察,不是本次实测,也不是所有版本与负载的资源保证。
用 Serve 与 Funnel 访问 Mealie
服务并不总在 80 端口运行。原文以自托管食谱应用 Mealie 为例:当时的 v1.0.0 镜像默认监听 9000。Tailscale Serve 可以把 HTTPS 请求代理到这个本地端口;Funnel 则进一步让公网用户访问服务。前者适合共享家庭食谱给明确授权的 tailnet 成员,后者会改变服务的暴露范围。
以下沿用原文的结构,但仍是未执行的编辑修订版。原 Mealie v1.0.0 属于历史版本,应自行选取并验证适合当前部署的镜像版本,同时核对端口、数据迁移和环境变量。原文设置 ALLOW_SIGNUP=true,这里改为 false;若所选版本初始化需要创建管理员,应按该版本官方流程完成,不能把关闭注册当成已经配置应用认证。
services:
ts-mealie:
image: ${TAILSCALE_IMAGE:?set a reviewed version or digest}
container_name: ts-mealie
hostname: mealie
environment:
TS_AUTHKEY: file:/run/secrets/tailscale_oauth_secret
TS_EXTRA_ARGS: "--advertise-tags=tag:container"
TS_SERVE_CONFIG: /config/mealie.json
TS_STATE_DIR: /var/lib/tailscale
TS_AUTH_ONCE: "true"
TS_USERSPACE: "false"
volumes:
- ./ts-mealie/state:/var/lib/tailscale
- ./ts-mealie/config:/config:ro
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
secrets:
- tailscale_oauth_secret
restart: unless-stopped
mealie:
image: ${MEALIE_IMAGE:?set a reviewed version or digest}
container_name: mealie
network_mode: service:ts-mealie
depends_on:
- ts-mealie
volumes:
- mealie-data:/app/data/
environment:
ALLOW_SIGNUP: "false"
restart: unless-stopped
volumes:
mealie-data:
driver: local
secrets:
tailscale_oauth_secret:
file: ${TS_OAUTH_SECRET_FILE:?set a protected local path}
把配置保存在单独目录下的 compose.yaml。相对于原文,绑定目录从 ${PWD}/ts-mealie/... 改成相对 Compose 文件目录的 ./ts-mealie/...,配置目录增加只读挂载,并去掉未使用的卷声明。生产环境也可写成经过核对的绝对路径。务必为状态和应用数据建立独立、受限的存储与备份。
Tailnet 侧的前提
- 在管理控制台启用 MagicDNS,以使用节点名称。
- 在 DNS 设置中启用 HTTPS,并确认预期的
*.ts.net名称。 - 只有决定使用 Funnel 时,才按当前策略文档为对应节点允许 Funnel。原文使用
nodeAttrs,节点建立后再核对 IP 或标签范围。
截至本文核对日期,Tailscale 将 Funnel 标为 beta。策略中的 Funnel node attribute 允许相应节点创建 Funnel;配置中的 AllowFunnel 则决定此域名是否通过 Funnel 暴露。两者都不能替代应用自己的认证与授权,公网访问者没有 tailnet 身份。食谱、内部面板和家庭数据是否适合公开,应在切换之前决定。可查阅当前Funnel 官方文档核对要求与限制。
Serve 的 JSON 配置
TS_SERVE_CONFIG=/config/mealie.json 指向容器内文件,对应主机目录 ./ts-mealie/config/mealie.json。应挂载整个目录,而不是只挂载单个文件,这样文件替换时的配置变化才能被监测到。可先按版本配置 Serve,再使用下列只读命令导出准确格式:
tailscale serve status --json
原文先演示把 mealie.auto-generated.ts.net:443 直接写进 JSON,随后改成 ${TS_CERT_DOMAIN},由容器启动程序替换。以下保留其动态域名方案,并关闭 Funnel:
{
"TCP": {
"443": { "HTTPS": true }
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": { "Proxy": "http://127.0.0.1:9000" }
}
}
},
"AllowFunnel": {
"${TS_CERT_DOMAIN}:443": false
}
}
该 JSON 是单独的配置文件,${TS_CERT_DOMAIN} 应原样留在文件中;不要先让 shell 展开它。请求到达域名根路径 / 时,Serve 将其代理到共享命名空间中的 http://127.0.0.1:9000。
若明确要公开服务,原文的操作是把 AllowFunnel 中对应域名的值从 false 改成 true。策略允许且功能工作后,互联网用户便可能访问该服务。它不是普通的样式或性能开关;应先验证身份认证、注册策略、敏感数据和回退方式。本文保留此步骤的原理说明,交付配置默认不开放。
启动、检查与故障定位
原文通过以下命令后台启动容器、追踪日志,并查看边车视角中的 tailnet 状态。它们会启动真实服务或读取运行环境;这里只展示,没有执行:
docker compose up -d
docker compose logs -f
docker exec -it ts-mealie tailscale status
在原作者的环境里,Mealie 日志出现 Uvicorn running on http://0.0.0.0:9000,tailnet 状态列出 mealie 节点及其 100.x.y.z 地址。那是原文观测,不是本文测试输出。实际排查时,先看认证凭据是否过期、复制是否正确、YAML 缩进是否有效,再检查节点是否已经出现、Serve 的目标端口是否与应用一致。
访问失败时还应分别检查:名称解析、tailnet 访问策略、应用监听、Serve JSON 路径与格式、HTTPS 配置。depends_on 的启动顺序本身不保证应用已就绪。阅读日志时避免把秘密和内部节点资料上传到公开工单。
原文最终得到一个具有 HTTPS 的食谱服务:tailnet 内部成员按策略访问,若主动使用 Funnel 则可公开访问。相同的边车结构还可用于其他自托管服务,并扩展到多容器部署。每个服务拥有独立身份,有助于给它配置各自的访问边界;它并不替代应用安全与系统维护。
配置边界与来源
配置示例按截至 2026 年 10 月 8 日可查的 Tailscale 与 Docker 文档做静态核对,未运行;不构成兼容性或安全保证。示例凭据均为占位符,没有包含有效密钥。部署前应按选定镜像版本和实际网络环境复核配置。Tailscale 与 Docker 官方文档链接见文中。
原作者 Alex Kretzschmar;原始文章与示例归 Tailscale 及相应权利人所有。本文依据已取得的授权翻译转载,并保留作者与原文链接;原文未被标为开放许可证。原创配图由未完纪绘制。Tailscale 为 Tailscale Inc. 注册商标,WireGuard 为 Jason A. Donenfeld 注册商标。











暂无评论内容