3

frpc 部署与网络探测

·12 分钟

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

vault 密码报错与解决

这里为了方便,可以在项目文件夹之外创建一个明文密码文件(不进项目,所以项目本身是安全的)。

# 在家目录创建密码文件,里面写上自己的 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 的部署过程类似。我们等会再写脚本,先准备配置文件。

复制 playbook

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

deploy-frpc 运行结果

可以看到成功运行,3090 检测到了无法直连,并自动配置代理。

💬

留言区待配置

部署 Twikoo 后端后,设置环境变量 NEXT_PUBLIC_TWIKOO_ENV_ID