Docker Compose 中用 Tailscale Sidecar 运行应用
Tailscale 官方 Compose 示例会把应用留在公网端口。改用共享网络命名空间的 sidecar,不发布端口,仅通过 tailnet 名称访问服务。
在 Docker Compose 栈中运行 Tailscale
在 Docker Compose 中运行 Tailscale 需要两个服务。一个是加入 tailnet 的 tailscale/tailscale 容器。另一个是应用容器,它共享第一个容器的网络命名空间,而不是在主机上发布端口。这样,您的笔记本电脑可以通过名称访问该服务,而公网完全无法访问它。
tailnet 是 Tailscale 在您登录的设备之间建立的专用网络。如果您不熟悉这个术语,请先阅读Tailscale 是什么,以及它如何连接两台计算机。本指南假定 Docker Engine 和 Compose v2 插件已经正常运行,相关设置请参阅在 VPS 上创建第一个 Docker Compose 栈。
厂商示例及其留下的开放端口
Tailscale 官方的 Compose 指南发布了一个与此接近的服务栈。
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: tailscale-nginx
environment:
- TS_AUTHKEY=tskey-auth-REPLACE-ME
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./tailscale-state:/var/lib/tailscale
cap_add:
- net_admin
- net_raw
restart: unless-stopped
nginx:
image: nginx:latest
container_name: nginx_server
ports:
- "8080:80"
depends_on:
- tailscale
restart: unless-stoppedtailscale 服务中的每一行配置都正确。TS_AUTHKEY 用于对节点进行身份验证。TS_STATE_DIR 告诉 tailscaled 将状态写入何处,绑定挂载则将该状态保存在磁盘上。问题出在第二个服务。
这两个容器位于默认的 Compose bridge 网络上,并且各自拥有独立的地址。这是 Compose 网络如何通过服务名称连接容器 中介绍的常规行为。tailscale 容器只为自身加入 tailnet,不会将流量转发到 nginx 容器。因此,访问 nginx 的唯一途径是主机的 8080 端口。
发布端口时,如果不在端口前指定地址,端口会绑定到 0.0.0.0;因此在 VPS 上,该端口会通过公网 IP 接收请求。应用在名称上属于 tailnet,实际上却暴露在互联网中。主机防火墙也无法解决此问题,因为 Docker 会在 ufw 的链之前插入自己的转发规则。这就是 为什么 ufw deny 规则无法关闭已发布的 Docker 端口 中所述的陷阱。
还有一个细节需要说明。示例授予了 net_admin 和 net_raw,但从未映射 /dev/net/tun。TS_USERSPACE 默认值为 true,因此容器运行 userspace 网络栈,而这两个 capability 不会产生任何作用。
Sidecar:共享网络命名空间,不发布端口
使用 network_mode: service:tailscale 将应用放入 tailscale 容器的网络命名空间。随后,两个进程会看到相同的回环接口和相同的 tailnet 地址,尽管它们运行在不同的容器中。
services:
tailscale:
image: tailscale/tailscale:v1.102.3
container_name: ts-nginx
hostname: nginx-demo
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_HOSTNAME=nginx-demo
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- ./ts-state:/var/lib/tailscale
restart: unless-stopped
nginx:
image: nginx:1.30.4-alpine
network_mode: service:tailscale
depends_on:
- tailscale
restart: unless-stopped将密钥放入 compose 文件旁边的 .env 文件中,而不是写入 YAML。这样,提交的文件中不会包含任何机密信息。文件只需包含一行:TS_AUTHKEY=tskey-auth-...。避免将机密信息写入已提交的 compose 文件介绍了这种模式的其他注意事项。
启动服务并检查两部分。
docker compose up -d
docker compose exec tailscale tailscale status
docker compose logs --tail 20 tailscaletailscale status 应输出一行包含本节点及其 100.x 地址的信息,后面列出 tailnet 中的其他计算机。在另一台已登录 tailnet 的笔记本电脑上,curl http://nginx-demo/ 应返回 nginx 欢迎页面。在 VPS 本机上,sudo ss -lntp | grep 8080 不会返回任何内容,因为没有发布端口。
为什么使用端口 80,而不是 8080:在 userspace 模式下,tailscaled 会将传入的隧道连接转发到 localhost 上的同一端口。nginx 在共享命名空间内监听 80,因此 tailnet 通过端口 80 访问它。修改应用的监听端口后,tailnet 端口也会随之改变。这种共享命名空间的技巧并不特定于 Tailscale;关于如何访问主机和堆栈中其他服务的相同问题,也会出现在由 Gluetun 容器管理相邻容器网络中。
容器需要哪种身份验证密钥?
密钥类型决定第二次启动时的行为,因此请在部署前选择。您可以在管理控制台的 Keys 页面生成密钥。对话框只显示一次。
- 一次性密钥用于验证单个设备。没有状态目录的 stack 重新创建后将无法恢复。
- 可重复使用的密钥可验证任意数量的设备。这通常是 Compose stack 所需的类型。
- 临时密钥会将节点标记为自动清理。Tailscale 会在临时设备最后一次活动后的 30 到 60 分钟将其移除。
- 预批准密钥会跳过手动设备批准。只有在您的 tailnet 启用了设备批准时,这一选项才有意义。
- 带标签的密钥会在身份验证时应用 ACL 标签,例如
tag:container。设备不再归属于某个用户,并且默认会禁用其密钥过期时间。
最后一点是运维上的关键。节点密钥默认在 180 天后过期。过期节点会从 tailnet 中移除,直到有人再次为其完成登录。带标签的密钥会取消这个到期限制,因此服务器和容器需要使用标签。
身份验证密钥过期是另一件事,很多人会混淆这两种情况。身份验证密钥的有效期为 1 到 90 天,默认值为 90 天。密钥达到过期日期后,不会断开已经通过它完成身份验证的设备,只会停止添加新设备。对于长期运行的服务,请使用可重复使用、带标签且非临时的密钥。对于经常拆除的 stack,例如预览环境,请使用临时密钥。这样无需手动删除设备,就能保持管理控制台整洁。
为什么容器会以一台新机器的身份重新出现?
因为 tailscaled 将状态写入了容器的可写层,而 docker compose down 删除了该容器。
节点身份保存在该状态目录中。持久化此目录后,容器在重启时会保留其名称和 100.x 地址,以及所有服务配置。丢失该目录后,下一次启动就会被视为首次启动:容器使用相同的密钥重新进行身份验证,管理控制台中会新增第二台机器。两台机器都声明主机名为 nginx-demo,因此 MagicDNS 会为较新的机器添加数字后缀,而您保存的所有链接都会指向已失效的节点。
以下两点必须同时满足。必须设置 TS_STATE_DIR=/var/lib/tailscale,因为在 Kubernetes 外部它没有默认值。该路径也必须完成挂载,可以使用上文的绑定挂载或命名卷,相关选择见 绑定挂载与命名卷的区别。只设置其中一项是最常见的错误,且通常不会立即报错:堆栈会一直正常运行,直到第一次 down。
请验证,不要凭假设判断。
docker compose down
ls -l ./ts-state
docker compose up -d
docker compose exec tailscale tailscale status在第二次 up 之前,./ts-state 中应已包含 tailscaled.state,并且节点应使用之前的地址重新出现。地址发生变化,说明挂载未正常工作。
固定镜像,并记录使用的标签
tailscale/tailscale:latest 会跟随最新的稳定版本。六个月后,docker compose pull 可能会静默地将 tailscaled 替换为其他版本,下一次重启时将运行你从未选择的代码。上面的配置固定使用 v1.102.3,即截至 September 2026 的稳定版本。Docker Hub 还会发布适用于补丁版本线的 v1.102,以及不应在服务器上使用的 unstable 标签。
请有计划地升级。
docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale version编辑标签并运行 up -d 会重新创建容器,这与重启容器不同。在排查版本更改未生效的问题之前,建议阅读restart、up 和 rebuild 的区别。
用户态网络及其代价
TS_USERSPACE默认为 true。此时,容器会在用户态运行 TCP/IP 协议栈,并且不会访问 /dev/net/tun。因此,即使主机不允许容器使用 TUN 设备,也可以采用这种方式。入站连接仍然可用,因为传入的隧道连接会转发到 localhost 上的同一端口。这正是上文 sidecar 无需设备和任何 capabilities 的原因。
代价主要体现在出站流量上。在用户态模式下,应用不能直接打开到另一个 tailnet 节点的 socket。tailscaled会提供 SOCKS5 代理和 HTTP 代理,因此需要在 tailscale 服务上设置 TS_SOCKS5_SERVER=localhost:1055,在应用上设置 ALL_PROXY=socks5://localhost:1055,并且应用必须支持这些代理设置。任何忽略代理环境变量的程序都无法访问 tailnet。
用户态协议栈本身也有一些限制。它只承载 TCP 和 UDP,因此 SCTP 等其他 IP 协议无法通过。ICMP 仅支持 ping,由守护进程重建,因此会产生少量表观延迟。连接会在本节点终止,然后重新连接到目标节点,因此不是端到端连接。用户态节点也不能使用出口节点或其他节点通告的子网路由,但可以自行通告这些路由。
需要透明地转发出站流量时,请向 tailscale 服务添加以下三项,以切换到内核网络。
environment:
- TS_USERSPACE=false
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin先使用 test -c /dev/net/tun && echo ok检查主机是否可以提供该设备。在 KVM 上,该设备通常存在。在共享主机内核的容器虚拟化环境中,该设备可能缺失,此时只能使用用户态模式。如果容器需要作为通告私有网段的子网路由器或供其他设备使用的出口节点,请将 TUN 设备提供给容器,因为这两种角色在用户态模式下受影响最大。
访问服务:使用 serve,或使用普通的 MagicDNS 名称
最简单的方式是使用 MagicDNS 名称。在 tailnet 中的任何设备上,http://nginx-demo/ 都可访问,完整名称 http://nginx-demo.your-tailnet.ts.net/ 也可以。这里的普通 HTTP 不会以明文在网络上传输,因为 WireGuard 会加密两个节点之间的流量;协调服务器能够和不能看到哪些内容说明了这项保证的边界。由于没有证书,浏览器会将该来源标记为不安全,任何要求安全上下文的 Web 功能都不会运行。
另一种方式是使用在容器内运行的 Tailscale Serve。
docker compose exec tailscale tailscale serve --bg localhost:80
docker compose exec tailscale tailscale serve status它会通过 Tailscale 配置的证书,在 https://nginx-demo.your-tailnet.ts.net 发布应用。必须在管理控制台的 DNS 页面启用 MagicDNS 和 HTTPS 证书,否则没有可写入证书的名称。--bg 会将配置写入已持久化的 tailscaled 状态,因此容器恢复时配置也会恢复;tailscale serve reset 会删除该配置。如果希望将配置保存在仓库中,而不是写入 shell 命令,可以使用 TS_SERVE_CONFIG 指定 JSON 文件。Serve 仅在 tailnet 内提供服务。Funnel 是将同一服务发布到公网的独立命令,因此在输入任一命令前,请先阅读Serve 与 Funnel 的区别。
故障模式及您将看到的消息
Error response from daemon: conflicting options: port publishing and the container type network mode。 您在 sidecar 服务中保留了一个 ports: 块。只有拥有该网络命名空间的容器可以发布端口,而仅通过 tailnet 访问的应用不应发布任何端口。请删除该块。
应用容器正在运行,但没有流量到达它。 您单独重新创建了 tailscale 服务。它拥有的网络命名空间随服务一起被销毁,而应用仍连接到已不存在的对象。请使用 docker compose up -d --force-recreate 重新创建这两个服务。
管理控制台中没有节点。 查看 docker compose logs tailscale。其中会报告被拒绝的密钥。已使用过的一次性密钥,以及超过有效期的密钥,都会在节点加入 tailnet 前阻止节点启动。
节点已启动,tailscale status 看起来正常,但 curl http://nginx-demo/ 一直无响应。 应用监听的地址不是您以为的地址。请使用 docker compose exec nginx wget -qO- http://localhost/ 在共享网络命名空间中访问应用。如果这里也失败,问题出在应用,而不是 Tailscale。如果这里成功,说明应用只绑定到了一个网络接口,而不是所有接口。
您停止堆栈一小时后,机器从控制台中消失了。 使用的是临时密钥。节点在最后一次活动后的 30 到 60 分钟内被移除,这说明该功能正常工作。
从版本 1.78 开始,该镜像可以提供未经身份验证的 /healthz 端点:设置 TS_ENABLE_HEALTH_CHECK=true,该端点监听 TS_LOCAL_ADDR_PORT,默认值为 [::]:9002。将 Compose 健康检查 指向该端点。这样,身份验证失败的节点会被报告为不健康,而不会继续显示为正常。如果您不希望依赖 Tailscale 的协调服务器,同一个 compose 文件也可以通过 TS_EXTRA_ARGS=--login-server=https://headscale.example.com 连接到您自己的控制平面。这是将 Headscale 作为您自己的控制服务器运行的起点。
FAQ
为什么 tailnet 上的其他设备无法访问我的应用容器?
因为 Tailscale 容器只为自身加入了 tailnet。如果应用运行在默认的 Compose bridge 网络上,并拥有自己的地址,Tailscale 节点不会将流量转发给它,唯一的访问方式就是已发布的主机端口。为应用设置 network_mode: service:tailscale,使其共享 Tailscale 容器的网络命名空间,然后删除其 ports: 配置块。之后,应用即可通过 tailnet 使用自身监听的端口访问。
在 Docker Compose 中运行 Tailscale 必须使用 /dev/net/tun 吗?
对于入站访问,不需要。TS_USERSPACE 的默认值为 true。在该模式下,tailscaled 使用自身的网络栈,并将传入的隧道连接转发到 localhost 上的同一端口,因此 sidecar 无需设备或额外 capabilities 即可工作。当容器必须透明地向 tailnet 发起出站连接,或充当子网路由器或出口节点时,才需要 /dev/net/tun、TS_USERSPACE=false 和 net_admin。
Compose 堆栈应使用临时 auth key 还是可复用 auth key?
对于长期运行的堆栈,请使用已添加标签且非临时的可复用密钥。添加标签会禁用节点密钥过期,因此容器不会在等待人工批准登录 180 days 后从 tailnet 中掉线。只有经常销毁的堆栈才应使用临时密钥,例如预览环境,因为 Tailscale 会在临时设备最后一次活动后的 30 to 60 minutes 将其删除,同时保持管理控制台整洁。
为什么我的容器每次重启都会显示为一台新机器?
因为状态目录没有持久化,所以 tailscaled 启动时没有身份信息,并会作为全新节点进行身份验证。设置 TS_STATE_DIR=/var/lib/tailscale;在 Kubernetes 之外,它没有默认值,并将该路径挂载到 bind mount 或 named volume。只设置其中一项时,直到第一次 docker compose down 之前看起来都没有问题。确认堆栈停止时,挂载目录中存在 tailscaled.state。