SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

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-stopped

tailscale 服务中的每一行配置都正确。TS_AUTHKEY 对节点进行身份验证。TS_STATE_DIR 告诉 tailscaled 将状态写入何处,绑定挂载则将该状态保存在磁盘上。问题出在第二个服务。

这两个容器位于默认的 Compose bridge 网络中,各自拥有一个地址。这是 Compose 网络如何通过服务名连接容器 中介绍的常规行为。tailscale 容器只为自身加入 tailnet,没有向 nginx 容器转发任何流量。因此,tailnet 根本无法访问 nginx;从 VPS 外部进入的唯一方式就是主机上的 8080 端口。

发布的端口默认绑定到 0.0.0.0,除非在端口前指定地址,或在网络上设置其他默认绑定地址。因此,在 VPS 上,该端口会通过公网 IP 响应。应用名义上位于 tailnet 中,实际上却暴露在互联网中。主机防火墙也无法解决这个问题,因为 Docker 会在 ufw 的链之前插入自己的转发规则。这就是 为什么 ufw deny 规则无法关闭已发布的 Docker 端口 中所述的陷阱。

还有一个细节需要说明。示例授予了 net_admin 和 net_raw,却从未映射 /dev/net/tun。TS_USERSPACE 默认为 true,因此容器运行的是用户态网络堆栈,而这两个 capability 实际上不起作用。

Sidecar:共享网络命名空间,不发布端口

使用 network_mode: service:tailscale 将应用放入 tailscale 容器的网络命名空间。这样,两个进程会看到相同的回环地址和相同的 tailnet 地址,尽管它们运行在不同的容器中。

services:
  tailscale:
    image: tailscale/tailscale:v1.102.5
    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 tailscale

tailscale status 应输出一行包含此节点及其 100.x 地址的信息,后面列出 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 页面生成密钥。对话框只显示一次。

  • 一次性密钥用于验证单个设备。删除状态目录后重新创建的堆栈将无法恢复。
  • 可重复使用的密钥可验证任意数量的设备。Compose 堆栈通常需要这种密钥。
  • 临时密钥会将节点标记为自动清理。Tailscale 会在临时设备最后一次活动后的 30 到 60 分钟内将其删除。
  • 预批准密钥会跳过手动设备批准。只有在 tailnet 启用了设备批准时,这项功能才有意义。
  • 带标签的密钥会在身份验证时应用 ACL 标签,例如 tag:container。设备不再归属于某个用户,并且默认会禁用其密钥过期功能。

最后一项是运维上的关键点。节点密钥默认在 180 天后过期。过期的节点会从 tailnet 中移除,直到用户再次登录该节点,或管理员禁用其密钥过期功能。带标签的密钥会取消这一过期限制,因此服务器和容器通常使用标签。

身份验证密钥过期是另一件事,容易与节点密钥过期混淆。身份验证密钥的有效期为 1 到 90 天,默认值为 90 天。密钥达到过期日期后,不会断开已经通过其身份验证的设备,只会阻止添加新设备。对于长期运行的服务,请使用可重复使用、带标签且非临时的密钥。对于经常销毁的堆栈(例如预览环境),临时密钥可在无需手动删除的情况下保持管理控制台整洁。

为什么容器会像一台新机器一样重新出现?

因为 tailscaled 将状态保存在了不会持久保留的位置。未设置 TS_STATE_DIR 时,镜像只会将状态保存在内存中。设置了 TS_STATE_DIR 但未挂载该路径时,状态会写入容器的可写层,而 docker compose down 会删除容器。

节点身份保存在该状态目录中。持久化这个目录后,容器在重启时会保留其名称和 100.x 地址,以及其中的服务配置。丢失该目录后,下次启动就会被视为首次启动:容器会使用同一个密钥再次进行身份验证,管理控制台中也会新增一台机器。两个节点都会声明主机名 nginx-demo,因此 MagicDNS 会为较新的节点添加数字后缀,而您保存的所有链接都会指向已失效的节点。

必须同时满足两个条件。必须设置 TS_STATE_DIR=/var/lib/tailscale,因为在 Kubernetes 之外它没有默认值。还必须挂载该路径,可以使用上面的 bind mount,也可以使用 named volume,具体选择见 bind mount 与 named volume 的对比。只设置其中一项是最常见的错误。没有 TS_STATE_DIR 的挂载会在每次重启时丢失身份,而没有挂载的 TS_STATE_DIR 会静默失败:在首次 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;下一次 docker compose up -d 会使用你从未选择过的代码重新创建容器。直接重启会继续使用旧镜像,因此变更会在容器下次重新创建时生效,而不是在你拉取镜像时生效。上面的堆栈固定了 v1.102.5,也就是 Docker Hub 的 stable 和 latest 标签在 10 October 2026 指向的镜像。Tailscale 的变更日志已经列出 1.104.1 客户端,该版本于 7 October 2026 发布。因此,在修改固定版本前,请先在 Docker Hub 检查是否存在匹配的镜像标签。Docker Hub 还会为该补丁版本线发布 v1.102,以及一个 unstable 标签;服务器不应使用后者。

按计划升级。

docker compose pull tailscale
docker compose up -d
docker compose exec tailscale tailscale version

修改标签并运行 up -d 会重新创建容器,这与重启容器不同。在排查版本变更未生效的问题前,建议阅读重启、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,且由 daemon 重建,因此会增加少量表观延迟。连接会在本节点终止,然后重新连接到目标节点,因此不是端到端连接。从 version 1.20 开始,用户态节点可以访问其他人发布的 exit node 或 subnet route,但只能通过这些代理访问;它也可以自行发布这些路由。

需要透明出站流量时,请向 tailscale 服务添加以下三项,以切换到内核网络。

    environment:
      - TS_USERSPACE=false
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin

首先使用 test -c /dev/net/tun && echo ok检查主机是否能提供该设备。在 KVM 上,该设备通常存在。在共享主机内核的容器虚拟化环境中,该设备可能缺失,此时只能使用用户态网络。如果容器需要作为发布私有网段的子网路由器或供其他设备使用的 exit node,请向容器提供 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 无需设备,也无需额外 capability。只有当容器必须透明地向 tailnet 建立出站连接,或充当子网路由器或出口节点时,才需要 /dev/net/tun、TS_USERSPACE=false 和 net_admin。

Compose 堆栈应使用临时 auth key 还是可复用 auth key?

对于长期运行的堆栈,请使用已添加标签且非临时的可复用 key。使用带标签 key 进行身份验证的设备默认会禁用节点 key 过期,因此容器不会在等待人工重新登录时于 180 天后退出 tailnet。为已登录的节点添加标签不会立即改变其过期设置,只有节点再次进行身份验证后才会生效。只有在经常销毁堆栈时才选择临时 key,例如预览环境,因为 Tailscale 会在临时设备最后一次活动后的 30 到 60 分钟内将其删除,同时保持管理控制台整洁。

为什么我的容器每次重启都会显示为新机器?

因为状态目录没有持久化,所以 tailscaled 启动时没有身份,并会以全新节点进行身份验证。设置 TS_STATE_DIR=/var/lib/tailscale;该项在 Kubernetes 之外没有默认值,并将此路径挂载到 bind mount 或 named volume。没有 TS_STATE_DIR 时,状态会保存在内存中,并在每次重启时丢失。设置该项但不进行挂载时,在第一次 docker compose down 之前看起来一切正常。堆栈停止时,确认挂载目录中存在 tailscaled.state。