第 3 章
frpc 部署与网络探测
frps 只需要部署一台云服务器,但 frpc 需要部署多台本地主机。这就需要我们来学习在配置文件中使用变量。同样地,我们先来处理 inventory.ini,定义控制对象,写清楚各台服务器的信息。
修改 inventory.ini
[gpu_servers]
2080ti ansible_host=10.177.16.130 ansible_user=lizeqi
5070ti-001 ansible_host=10.177.19.58 ansible_user=ubuntu
5070ti-002 ansible_host=10.177.16.232 ansible_user=yk
3090 ansible_host=10.177.11.62 ansible_user=lizeqi
4070ti-001 ansible_host=10.177.16.81 ansible_user=lizeqi
4070ti-002 ansible_host=10.177.18.132 ansible_user=yk
[cloud]
astra ansible_host=8.162.3.156 ansible_user=root
[all:vars]
ansible_port=22
ansible_ssh_private_key_file=~/.ssh/id_ed25519
frps_server=8.162.3.156
frps_port=7000
frp_token={{ vault_frp_token }}
squid_proxy=http://10.177.19.58:8888
可以看到,我们添加了几台 GPU 服务器,每台服务器都填写了 IP 和 SSH 登录用户名。现在我们来配置每台主机的额外信息。虽然你确实可以直接在 inventory.ini 中每个主机后面继续带参数,但参数多了之后可读性差。更规范的做法是在 inventory.ini 同级文件夹中创建 host_vars 目录,然后创建各台主机的变量文件。
创建各台主机的变量文件
# 创建目录
cd ~/ansible-infra
mkdir host_vars
# 创建6台主机的变量文件
cat > host_vars/2080ti.yml << 'EOF'
ansible_become_pass: "{{ vault_2080ti }}"
frp_remote_port: 12080
frp_node_exporter_port: 12081
frp_gpu_exporter_port: 12082
EOF
cat > host_vars/5070ti-001.yml << 'EOF'
ansible_become_pass: "{{ vault_5070ti_001 }}"
frp_remote_port: 15070
frp_node_exporter_port: 15071
frp_gpu_exporter_port: 15072
EOF
cat > host_vars/5070ti-002.yml << 'EOF'
ansible_become_pass: "{{ vault_5070ti_002 }}"
frp_remote_port: 25070
frp_node_exporter_port: 25071
frp_gpu_exporter_port: 25072
EOF
cat > host_vars/3090.yml << 'EOF'
ansible_become_pass: "{{ vault_3090 }}"
frp_remote_port: 13090
frp_node_exporter_port: 13091
frp_gpu_exporter_port: 13092
EOF
cat > host_vars/4070ti-001.yml << 'EOF'
ansible_become_pass: "{{ vault_4070ti_001 }}"
frp_remote_port: 14070
frp_node_exporter_port: 14071
frp_gpu_exporter_port: 14072
EOF
cat > host_vars/4070ti-002.yml << 'EOF'
ansible_become_pass: "{{ vault_4070ti_002 }}"
frp_remote_port: 24070
frp_node_exporter_port: 24071
frp_gpu_exporter_port: 24072
EOF
每个主机 yaml 文件名需要与 inventory 中的主机名严格对应。其中,ansible_become_pass 是用户密码,用于获取 sudo 权限(对应 playbook 中的 become: true),frp_remote_port 是预留的 SSH 端口,frp_node_exporter_port(节点信息上传)和 frp_gpu_exporter_port(GPU 信息上传)也是预留的端口,用于后面的监控看板。
Ansible 在工作时会逐层查找、运行时合并。例如在 inventory.ini 中看到主机名 2080ti,就会去找 host_vars/2080ti.yml,然后把里面的变量加到这台主机上。找不到也不报错,只是这台主机没有额外变量。
配置 Vault 加密变量文件
在实践规范中,密码和 token 通常不明文表示,所以我们需要创建 vault 文件来组织这些敏感信息。
# 创建 vault 文件
cd ~/ansible-infra
ansible-vault create group_vars/all/vault.yml
# 各节点的 sudo 密码
vault_2080ti: '[2080Ti的ssh密码]'
vault_5070ti_001: '[5070ti_001的ssh密码]'
vault_5070ti_002: '[5070ti_002的ssh密码]'
vault_3090: '[3090的ssh密码]'
vault_4070ti_001: '[4070ti_001的ssh密码]'
vault_4070ti_002: '[4070ti_002的ssh密码]'
# frp token
frp_token: [你的frp_token]
注意这里每台机器的 ssh 密码需要与管理账号的用户名对应,不能管理账号的用户名(ansible_user)是 A,但密码(ansible_become_pass)是另一个用户 B 的。
现在我们可以把之前 templates/frps.toml.j2 中的明文 frp_token 改成变量了:
# 修改这一块
[auth]
method = "token"
token = "{{ frp_token }}"
尝试重新运行 deploy-frps,发现不询问密码时报错。
# 不询问密码
ansible-playbook playbooks/deploy-frps.yml
# 询问密码,需要手动输密码
ansible-playbook playbooks/deploy-frps.yml --ask-become-pass

这里为了方便,可以在项目文件夹之外创建一个明文密码文件(不进项目,所以项目本身是安全的)。
# 在家目录创建密码文件,里面写上自己的 ansible vault 密码
nano ~/.vault_pass
# 让 Ansible 自己去找这个文件,之后每次运行 Playbook 不用再手动输密码
cat > ~/ansible-infra/ansible.cfg << 'EOF'
[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault_pass
EOF
准备 frpc 配置文件
我们先直接从原有部署 frps 的脚本(deploy-frps.yml)复制一份,因为 frps 和 frpc 的部署过程类似。我们等会再写脚本,先准备配置文件。

systemd 服务配置
比 frps 的 systemd 服务配置更简单,核心的启动语句是类似的。二进制程序文件是 /usr/local/bin/frpc,配置文件是 /etc/frp/frpc.toml。以下是 frpc.service.j2 文件内容:
[Unit]
Description=FRP Client - {{ inventory_hostname }}
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.toml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
frpc 配置模板
frpc.toml.j2 内容如下:
# FRP 客户端配置
# 主机名: {{ inventory_hostname }}
serverAddr = "{{ frps_server }}"
serverPort = {{ frps_port }}
log.level = "info"
log.maxDays = 7
# 连接失败后持续重试
loginFailExit = false
# ===== 性能优化 =====
# TCP 多路复用:多个连接共用一条隧道,减少开销
transport.tcpMux = true
transport.tcpMuxKeepaliveInterval = 30
# 连接池:预建连接,用户 SSH 时秒开
transport.poolCount = 5
# 心跳:防止 NAT 超时断连
transport.heartbeatInterval = 30
transport.heartbeatTimeout = 90
{% if needs_proxy %}
# 通过 Squid 代理连接 frps
transport.proxyURL = "{{ squid_proxy }}"
{% endif %}
auth.method = "token"
auth.token = "{{ frp_token }}"
[[proxies]]
name = "{{ inventory_hostname }}-ssh"
type = "tcp"
localIP = "127.0.0.1"
localPort = 22022
remotePort = {{ frp_remote_port }}
# 压缩:对 SSH 文本流效果好
transport.useCompression = true
# 监控用 Exporter
[[proxies]]
name = "{{ inventory_hostname }}-node-exporter"
type = "tcp"
localIP = "127.0.0.1"
localPort = 9100
remotePort = {{ frp_node_exporter_port }}
[[proxies]]
name = "{{ inventory_hostname }}-gpu-exporter"
type = "tcp"
localIP = "127.0.0.1"
localPort = 9835
remotePort = {{ frp_gpu_exporter_port }}
其中有两块值得注意。
一是使用代理的特殊情况: 在 6 台服务器中,3090 是不能直接连接外网的,因此我们在 5070Ti-001 上为 3090 搭建了一个用于连接公网的 Squid 代理。
{% if needs_proxy %}
# 通过 Squid 代理连接 frps
transport.proxyURL = "{{ squid_proxy }}"
{% endif %}
这一段配置模板的意思是:拿到一个布尔变量 needs_proxy,如果它为真,就在 frpc 配置中渲染出这一行,标识出它的出网代理 URL,从而让 frpc 知道去走这个代理。如果没有这个代理,3090 就连不上 frps。如果 needs_proxy 为假,那么渲染出来的 frpc 配置中就不会出现这一行。
二是 SSH 端口不是 22: 这里为远程 frp 预留的 ssh 端口是 22022,而不是标准的 22 端口,这是为了通过端口识别本地 ssh 和远程 ssh,从而应用不同的认证策略。因此,这里实际上有一个前置的 ssh 配置任务,我们后面再讲。
[[proxies]]
name = "{{ inventory_hostname }}-ssh"
type = "tcp"
localIP = "127.0.0.1"
localPort = 22022
remotePort = {{ frp_remote_port }}
撰写 Playbook
打开刚才复制好的 deploy-frpc.yml,修改成如下内容:
- name: 部署frpc到本地主机
hosts: gpu_servers
become: true
tasks:
# 第一阶段:网络环境探测
- name: 探测网络环境 - 尝试直连frps
wait_for:
host: "{{ frps_server }}"
port: "{{ frps_port }}"
timeout: 5
register: direct_connect
ignore_errors: yes
when: needs_proxy is not defined
- name: 探测结果 - 直连成功
set_fact:
needs_proxy: false
when: needs_proxy is not defined and not direct_connect.failed
- name: 探测网络环境 - 直连失败,尝试代理
shell: curl -x {{ squid_proxy }} --connect-timeout 5 -s -o /dev/null http://{{ frps_server }}:{{ frps_port }}
register: proxy_connect
ignore_errors: yes
when: needs_proxy is not defined and direct_connect.failed
- name: 探测结果 - 使用代理
set_fact:
needs_proxy: true
when: needs_proxy is not defined and direct_connect.failed and proxy_connect.rc == 0
- name: 探测结果 - 直连和代理都失败,中止
fail:
msg: "{{ inventory_hostname }} 无法连接 frps:直连超时,代理也不可达"
when: needs_proxy is not defined and direct_connect.failed and proxy_connect.rc != 0
- name: 显示探测结果
debug:
msg: "{{ inventory_hostname }}: needs_proxy={{ needs_proxy }}"
# 第二阶段:部署frpc
- name: 传输并解压安装包
unarchive:
src: ../files/frp/frp_0.65.0_linux_amd64.tar.gz
dest: /tmp
- name: 从安装包复制frpc程序文件
copy:
remote_src: yes
src: /tmp/frp_0.65.0_linux_amd64/frpc
dest: /usr/local/bin/frpc
mode: '0755'
- name: 创建frp配置目录
file:
path: /etc/frp
state: directory
mode: '0755'
- name: 生成frpc.toml配置文件
template:
src: ../templates/frpc.toml.j2
dest: /etc/frp/frpc.toml
mode: '0600'
notify: 重启frpc
- name: 生成systemd服务配置文件
template:
src: ../templates/frpc.service.j2
dest: /etc/systemd/system/frpc.service
mode: '0644'
notify: 重启frpc
- name: 应用systemd服务并启动frpc
systemd:
name: frpc
enabled: yes
state: started
daemon_reload: yes
handlers:
- name: 重启frpc
systemd:
name: frpc
state: restarted
daemon_reload: yes
理解网络探测
其中第一个阶段进行网络探测,这是因为 3090 不能直连公网,需要使用代理。第二个阶段进行 frpc 部署,与 frps 部署的过程是类似的。
如何理解这里的网络探测呢?我们首先用 wait_for 模块,向 frps 服务器 IP 和端口发送一个直连的请求,设置 5 秒钟超时,结果存为 direct_connect 参数,在 needs_proxy 未定义时执行。然后,如果直连成功,就用 set_fact 定义一个 needs_proxy 参数,值为 false。
- name: 探测网络环境 - 尝试直连frps
wait_for:
host: "{{ frps_server }}"
port: "{{ frps_port }}"
timeout: 5
register: direct_connect
ignore_errors: yes
when: needs_proxy is not defined
- name: 探测结果 - 直连成功
set_fact:
needs_proxy: false
when: needs_proxy is not defined and not direct_connect.failed
类似地,直连失败时会开始尝试代理,如果代理成功,needs_proxy 会被 set_fact 赋值 true,模板文件中通过 {% if needs_proxy %} {% endif %} 来决定是否渲染代理行。最后如果直连和代理都不成功就报错。
- name: 探测网络环境 - 直连失败,尝试代理
shell: curl -x {{ squid_proxy }} --connect-timeout 5 -s -o /dev/null http://{{ frps_server }}:{{ frps_port }}
register: proxy_connect
ignore_errors: yes
when: needs_proxy is not defined and direct_connect.failed
- name: 探测结果 - 使用代理
set_fact:
needs_proxy: true
when: needs_proxy is not defined and direct_connect.failed and proxy_connect.rc == 0
运行!
至此,我们完成了本地配置 frpc 的自动化工作,可以尝试运行了:
cd ~/ansible-infra
ansible-playbook playbooks/deploy-frpc.yml

可以看到成功运行,3090 检测到了无法直连,并自动配置代理。
留言区待配置
部署 Twikoo 后端后,设置环境变量 NEXT_PUBLIC_TWIKOO_ENV_ID