PVE 更新原生 OCI 支持:如何构建一个完美的 FRPC LXC 镜像?

PVE 的重大更新

Proxmox VE (PVE) 在最近的更新中(9.x.x 版本及以上)终于原生支持了 OCI (Open Container Initiative) 标准。这意味着我们现在可以直接在 PVE 的 CT 模板中通过 “Pull from oci registry” 的方式拉取镜像并运行,而不需要像以前那样繁琐地转换。而且这次更新支持热升级,无需重启宿主机。

PVE OCI Update

PVE OCI Pull

痛点:为什么普通的 Docker 镜像不行?

虽然 PVE 支持了 OCI,但普通的 Docker 镜像直接跑在 LXC 容器里通常是不行的,或者说体验极差。

Docker 容器的设计哲学是微服务,PID 1 通常是应用本身;而 LXC 是系统容器,更像是一个轻量级虚拟机。直接在 LXC 中跑 Docker 镜像(尤其是基于 scratchdistroless 的镜像)会导致以下问题:

  1. 无法进入终端:PVE 的 Console 可能会卡死,无法登录。
  2. 调试困难:因为缺少 Shell 或基础工具,连 pct enter <ID> 都会报错,变成“黑盒”。
  3. 配置不便:Docker 习惯用环境变量注入,但 LXC 对此支持需要适配,且不支持 Docker 的 Volume 挂载逻辑。
  4. 不支持热重载:修改配置通常需要重启整个容器。

解决方案:自制适配 PVE 的 FRPC 镜像

为了解决上述痛点,我们需要构建一个专门适配 PVE OCI 环境的镜像。我们的目标是:

  • 极致轻量:基于 Go 静态编译 + UPX 压缩。
  • 热重载:修改配置文件后,自动检测并重启隧道,无需重启容器。
  • 配置灵活:支持环境变量注入服务器信息,支持自定义端口映射。
  • 去前缀功能:支持生成不带主机名的短域名(如 web.domain.com)。
  • 可维护:基于 Alpine 底座,允许通过 PVE 宿主机 pct enter 进入容器调试。

第一步:修改 Go 源码 (cmd/frpc/my_loader.go)

为了实现热重载环境变量读取,我们需要重写加载器的逻辑。这段代码去掉了复杂的混淆,保留了最实用的功能。

将以下代码覆盖 cmd/frpc/my_loader.go

package main

import (
	"bufio"
	"context"
	"crypto/md5"
	"fmt"
	"net"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/fatedier/frp/client"
	v1 "github.com/fatedier/frp/pkg/config/v1"
)

// ================= 默认配置 =================
var (
	ServerAddr = "1.2.3.4"
	ServerPort = 7000
	AuthToken  = "12345678"
	Domain     = "orcl.cc"

	// 【新增】是否禁用主机名前缀
	// false: 生成 hostname-web.domain.com (默认,防冲突)
	// true:  生成 web.domain.com (清爽,但在多台机器用同一个别名时会冲突)
	DisableHostPrefix = false
)

const SingleInstancePort = 64123

// ===========================================

var defaultLocalIP = "127.0.0.1"
var instanceLock net.Listener

// 初始化配置:读取环境变量
func initConfigFromEnv() {
	if v := os.Getenv("FRP_SERVER_ADDR"); v != "" {
		ServerAddr = v
	}
	if v := os.Getenv("FRP_SERVER_PORT"); v != "" {
		if p, err := strconv.Atoi(v); err == nil {
			ServerPort = p
		}
	}
	if v := os.Getenv("FRP_TOKEN"); v != "" {
		AuthToken = v
	}
	if v := os.Getenv("FRP_DOMAIN"); v != "" {
		Domain = v
	}
	if v := os.Getenv("FRP_DEFAULT_IP"); v != "" {
		defaultLocalIP = v
	}
	// 【新增】读取禁用前缀开关
	if v := os.Getenv("FRP_DISABLE_HOST_PREFIX"); v == "true" {
		DisableHostPrefix = true
	}
}

func RunCustomClient() {
	initConfigFromEnv()

	// 打印当前模式状态
	prefixStatus := "自动添加 (主机名-别名)"
	if DisableHostPrefix {
		prefixStatus = "已禁用 (仅使用别名)"
	}
	fmt.Printf("[Init] Server: %s:%d | Domain: %s | 前缀模式: %s\n", ServerAddr, ServerPort, Domain, prefixStatus)

	if !checkSingleInstance() {
		return
	}
	defer releaseSingleInstance()

	sysSigCh := make(chan os.Signal, 1)
	signal.Notify(sysSigCh, syscall.SIGINT, syscall.SIGTERM)
	reloadCh := make(chan struct{})

	go monitorConfigFile(reloadCh)

	for {
		ctx, cancel := context.WithCancel(context.Background())
		svr := startService(ctx)

		select {
		case <-reloadCh:
			fmt.Println("\n[*] 配置文件变更,正在热重载...")
			cancel()
			if svr != nil {
				svr.Close()
			}
		case <-sysSigCh:
			fmt.Println("\n[*] 正在退出...")
			cancel()
			if svr != nil {
				svr.GracefulClose(500 * time.Millisecond)
			}
			return
		}
	}
}

func startService(ctx context.Context) *client.Service {
	cfg := &v1.ClientCommonConfig{}
	cfg.Log.Level = "error"
	cfg.Log.To = "console"

	cfg.ServerAddr = ServerAddr
	cfg.ServerPort = ServerPort
	cfg.Auth.Method = "token"
	cfg.Auth.Token = AuthToken
	cfg.Transport.PoolCount = 5
	cfg.Complete()

	proxyCfgs := loadPortsConf()

	if len(proxyCfgs) == 0 {
		fmt.Println("[Warn] 未检测到有效端口规则,等待 frp_ports.conf ...")
		return nil
	}

	svr, err := client.NewService(client.ServiceOptions{
		Common:    cfg,
		ProxyCfgs: proxyCfgs,
	})

	if err != nil {
		fmt.Printf("[Error] 初始化失败: %v\n", err)
		return nil
	}

	printWelcome(len(proxyCfgs))

	go func() {
		err := svr.Run(ctx)
		if err != nil && ctx.Err() == nil {
			fmt.Printf("[Error] 运行中断: %v\n", err)
		}
	}()

	return svr
}

func loadPortsConf() []v1.ProxyConfigurer {
	var proxies []v1.ProxyConfigurer
	ensurePortsFile()

	file, err := os.Open("frp_ports.conf")
	if err != nil {
		return proxies
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	hostname := getCleanHostname()

	if os.Getenv("FRP_DEFAULT_IP") == "" {
		defaultLocalIP = "127.0.0.1"
	} else {
		defaultLocalIP = os.Getenv("FRP_DEFAULT_IP")
	}

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		if strings.Contains(line, "=") {
			kv := strings.SplitN(line, "=", 2)
			key := strings.TrimSpace(strings.ToLower(kv[0]))
			val := strings.TrimSpace(kv[1])
			if key == "default_ip" {
				defaultLocalIP = val
				fmt.Printf("[Config] 本次加载默认IP: %s\n", defaultLocalIP)
			}
			continue
		}

		parts := strings.Split(line, ":")
		if len(parts) < 1 {
			continue
		}

		localPortStr := strings.TrimSpace(parts[0])
		targetIP := defaultLocalIP
		protocol := "http"
		alias := localPortStr
		remotePort := 0

		isLayer4 := false
		if len(parts) >= 2 {
			p2 := strings.ToLower(strings.TrimSpace(parts[1]))
			if p2 == "tcp" || p2 == "udp" {
				isLayer4 = true
				protocol = p2
			}
		}

		if isLayer4 {
			if len(parts) > 2 {
				fmt.Sscanf(strings.TrimSpace(parts[2]), "%d", &remotePort)
			}
			if len(parts) > 3 {
				targetIP = strings.TrimSpace(parts[3])
			}
		} else {
			if len(parts) > 1 {
				alias = strings.TrimSpace(parts[1])
			}
			if len(parts) > 2 {
				targetIP = strings.TrimSpace(parts[2])
			}
		}

		// ProxyName 依然建议带上 Hostname 以保证内部唯一性,不影响外部访问
		proxyName := fmt.Sprintf("%s_%s_%s", hostname, protocol, localPortStr)
		if protocol == "http" {
			proxyName = fmt.Sprintf("%s_%s", hostname, alias)
		}

		portInt := 0
		fmt.Sscanf(localPortStr, "%d", &portInt)
		var newCfg v1.ProxyConfigurer

		switch protocol {
		case "tcp":
			cfg := &v1.TCPProxyConfig{}
			cfg.Name = proxyName
			cfg.Type = "tcp"
			cfg.LocalIP = targetIP
			cfg.LocalPort = portInt
			cfg.RemotePort = remotePort
			newCfg = cfg
		case "udp":
			cfg := &v1.UDPProxyConfig{}
			cfg.Name = proxyName
			cfg.Type = "udp"
			cfg.LocalIP = targetIP
			cfg.LocalPort = portInt
			cfg.RemotePort = remotePort
			newCfg = cfg
		default:
			cfg := &v1.HTTPProxyConfig{}
			cfg.Name = proxyName
			cfg.Type = "http"
			cfg.LocalIP = targetIP
			cfg.LocalPort = portInt

			// 【核心逻辑】根据开关决定子域名格式
			if DisableHostPrefix {
				// 模式: 别名.域名 (例如: web.orcl.cc)
				cfg.SubDomain = alias
			} else {
				// 模式: 主机名-别名.域名 (例如: winpc-web.orcl.cc)
				cfg.SubDomain = fmt.Sprintf("%s-%s", hostname, alias)
			}

			newCfg = cfg
		}
		newCfg.Complete("")
		proxies = append(proxies, newCfg)

		printProxyLog(protocol, targetIP, portInt, remotePort, newCfg)
	}
	return proxies
}

func monitorConfigFile(reloadCh chan struct{}) {
	filename := "frp_ports.conf"
	var lastModTime time.Time
	for {
		info, err := os.Stat(filename)
		if err == nil {
			if lastModTime.IsZero() {
				lastModTime = info.ModTime()
			} else if info.ModTime() != lastModTime {
				lastModTime = info.ModTime()
				select {
				case reloadCh <- struct{}{}:
				default:
				}
			}
		}
		time.Sleep(2 * time.Second)
	}
}

func printWelcome(count int) {
	fmt.Println("┌──────────────────────────────────────────────────┐")
	fmt.Printf("│  [+] FRP Docker Client Started                   │\n")
	fmt.Printf("│  [S] Server: %-35s │\n", fmt.Sprintf("%s:%d", ServerAddr, ServerPort))
	fmt.Printf("│  [#] Tunnels: %-35d │\n", count)
	fmt.Println("└──────────────────────────────────────────────────┘")
}

func printProxyLog(protocol string, targetIP string, localPort int, remotePort int, cfg v1.ProxyConfigurer) {
	arrow := "->"
	if protocol == "http" {
		httpCfg := cfg.(*v1.HTTPProxyConfig)
		url := fmt.Sprintf("https://%s.%s", httpCfg.SubDomain, Domain)
		fmt.Printf(" [%-4s] %-15s:%-5d %s %s\n", strings.ToUpper(protocol), targetIP, localPort, arrow, url)
	} else {
		remote := fmt.Sprintf("[Remote]:%d", remotePort)
		fmt.Printf(" [%-4s] %-15s:%-5d %s %s\n", strings.ToUpper(protocol), targetIP, localPort, arrow, remote)
	}
}

func ensurePortsFile() {
	if _, err := os.Stat("frp_ports.conf"); os.IsNotExist(err) {
		content := `# FRP Config
# =========================================================
#               多设备穿透配置 (支持转发局域网IP)
# =========================================================

# [全局设置] 默认转发 IP (如果不写具体IP,就用这个)
default_ip = 127.0.0.1

# ---------------------------------------------------------
# 1. 转发本机服务
# ---------------------------------------------------------
# 格式: 端口:别名
# 8080:web
# 3111:api

# ---------------------------------------------------------
# 2. 转发局域网其他设备 (HTTP)
# ---------------------------------------------------------
# 格式: 端口:别名:目标IP
# 例如: 转发 NAS 的管理页面
# 5000:nas:192.168.1.50
# 80:router:192.168.1.1

# ---------------------------------------------------------
# 3. 转发 TCP/UDP (远程桌面/SSH)
# ---------------------------------------------------------
# 格式: 本地端口:协议:远程端口[:目标IP]

# 本机远程桌面
# 3389:tcp:33389

# 局域网 Linux 服务器 SSH (将 192.168.1.100 的 22 转发到外网 60022)
# 22:tcp:60022:192.168.1.100

`
		os.WriteFile("frp_ports.conf", []byte(content), 0644)
	}
}

func checkSingleInstance() bool {
	var err error
	instanceLock, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", SingleInstancePort))
	if err != nil {
		fmt.Println("[!] 端口占用,可能已在运行")
		return false
	}
	return true
}

func releaseSingleInstance() {
	if instanceLock != nil {
		instanceLock.Close()
	}
}

func getCleanHostname() string {
	h, _ := os.Hostname()
	hash := md5.Sum([]byte(h))
	return fmt.Sprintf("%x", hash[:3])
}

再修改> 将以下代码覆盖 cmd/frpc/main.go

package main

// 这里不需要引入 cobra 等复杂的命令行库了
// 只需要引入我们刚才写逻辑所在的包 (同一个 main 包)

func main() {
	// 直接调用 my_loader.go 里的函数
	RunCustomClient()
}


第二步:构建 Dockerfile (全架构适配 + Alpine 底座)

为了解决 PVE LXC 无法方便进入容器的问题,我们放弃了 scratch,改用 alpine。同时配置了国内源加速,并支持 AMD64/ARM64 自动构建。

# ==========================================
# Stage 1: 编译 (保持不变)
# ==========================================
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
ARG TARGETARCH
RUN apk add --no-cache git upx tzdata
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -trimpath -ldflags "-s -w" -o /app/frpc_mini ./cmd/frpc
RUN upx --best --lzma /app/frpc_mini

# ==========================================
# Stage 2: 运行时 - 完美适配版
# ==========================================
FROM alpine:latest

# 1. 安装依赖
RUN apk add --no-cache openrc util-linux ca-certificates tzdata nano bash

# 2. 配置 OpenRC
RUN sed -i 's/^\(tty\d\:\:\)/#\1/g' /etc/inittab && \
    sed -i \
    -e 's/#rc_sys=""/rc_sys="lxc"/g' \
    -e 's/^#rc_provide=".*"/rc_provide="loopback net"/g' \
    /etc/rc.conf && \
    echo 'rc_provide="loopback net"' >> /etc/rc.conf && \
    echo 'rc_cgroup_mode="hybrid"' >> /etc/rc.conf

# 3. 复制程序
COPY --from=builder /app/frpc_mini /usr/local/bin/frpc
RUN chmod +x /usr/local/bin/frpc

# 4. 创建 OpenRC 服务脚本 (带日志)
RUN echo '#!/sbin/openrc-run' > /etc/init.d/frpc && \
    echo 'name="frpc"' >> /etc/init.d/frpc && \
    echo 'command="/usr/local/bin/frpc"' >> /etc/init.d/frpc && \
    echo 'command_background=true' >> /etc/init.d/frpc && \
    echo 'pidfile="/run/frpc.pid"' >> /etc/init.d/frpc && \
    echo 'directory="/data"' >> /etc/init.d/frpc && \
    echo 'output_log="/var/log/frpc.log"' >> /etc/init.d/frpc && \
    echo 'error_log="/var/log/frpc.log"' >> /etc/init.d/frpc && \
    chmod +x /etc/init.d/frpc && \
    rc-update add frpc default

# 5. 日志快捷命令
RUN echo '#!/bin/sh' > /usr/local/bin/logs && \
    echo 'tail -f /var/log/frpc.log' >> /usr/local/bin/logs && \
    chmod +x /usr/local/bin/logs

# 6. 【核心】创建启动引导脚本 (entrypoint.sh)
# 这个脚本会把 Docker 传入的环境变量,写入到 /etc/conf.d/frpc 中
RUN echo '#!/bin/sh' > /entrypoint.sh && \
    echo '# 提取所有以 FRP_ 开头的环境变量,以及 TZ 变量' >> /entrypoint.sh && \
    echo 'env | grep -E "^(FRP_|TZ)" | sed "s/^/export /" > /etc/conf.d/frpc' >> /entrypoint.sh && \
    echo '# 启动 OpenRC 初始化系统' >> /entrypoint.sh && \
    echo 'exec /sbin/init' >> /entrypoint.sh && \
    chmod +x /entrypoint.sh

WORKDIR /data

# 7. 将入口改为引导脚本
ENTRYPOINT ["/entrypoint.sh"]

第三步:在 PVE 中部署

构建并推送到私有仓库后,即可在 PVE 中通过 OCI 方式拉取。

1. 环境变量配置

在 PVE 的 OCI 容器环境变量配置以下变量:
image-1765350849493

变量名 示例值 说明
FRP_SERVER_ADDR 1.2.3.4 FRP 服务器 IP
FRP_SERVER_PORT 7000 FRP 服务器端口
FRP_TOKEN xxxxxx 连接密钥
FRP_DOMAIN 123.com 你的泛域名
FRP_DISABLE_HOST_PREFIX true true: 生成 web.123.com
false: 生成 hostname-web.123.com

2. 配置文件 (frp_ports.conf)

容器启动后会自动生成配置文件,支持热重载。你可以在 PVE 宿主机挂载目录,或者直接进入容器修改:

# FRP Config
# =========================================================
#               多设备穿透配置 (支持转发局域网IP)
# ======================================================
# [全局设置] 默认转发 IP (如果不写具体IP,就用这个)
default_ip = 127.0.0.1

# ---------------------------------------------------------
# 1. 转发本机服务
# ---------------------------------------------------------
# 格式: 端口:别名
# 8080:web
# 3111:api

# ---------------------------------------------------------
# 2. 转发局域网其他设备 (HTTP)
# ---------------------------------------------------------
# 格式: 端口:别名:目标IP
# 例如: 转发 NAS 的管理页面
# 5000:nas:192.168.1.50
# 80:router:192.168.1.1

# ---------------------------------------------------------
# 3. 转发 TCP/UDP (远程桌面/SSH)
# ---------------------------------------------------------
# 格式: 本地端口:协议:远程端口[:目标IP]

# 本机远程桌面
# 3389:tcp:33389

# 局域网 Linux 服务器 SSH (将 192.168.1.100 的 22 转发到外网 60022)
# 22:tcp:60022:192.168.1.100

3. 运维小技巧

得益于使用 Alpine 底座,在 PVE 宿主机 Shell 中,你可以直接“穿透”进容器进行管理,而不会像 Docker 镜像那样被拒绝, 并且内置函数logs可以一键查看日志:

# 查看容器 ID 为 100 的内部
pct enter 100

# 进入后直接修改配置,保存即生效
vi /data/frp_ports.conf

image-1765350737558

frp服务端也要补充配置

bindPort = 7000

auth.token = "123123"        
        
# 【新增】HTTP 虚拟主机端口,改为 8080,不要用 80
vhostHTTPPort = 9889 

# 【关键】设置泛域名根域名
subdomainHost = "123.com"             
        

泛域名下的nginx

# /etc/nginx/conf.d/frp_wildcard.conf (新建或追加)

server {
    listen 80;
    listen [::]:80;
    # 匹配所有子域名
    server_name *.123.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    # 泛域名匹配
    server_name *.123.com;

    # 【重要】这里必须使用泛域名证书 (*.123.com)
   ssl_certificate /etc/nginx/certs/123.com/123.com.pem;
   ssl_certificate_key /etc/nginx/certs/123.com/123.com.pem;

    # SSL 参数保持你原有的即可
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers on;

    location / {
        # 将流量转发给 FRP 监听的 HTTP 端口
        proxy_pass http://x.x.x.x:9889;

        # 传递域名给 FRP,FRP 靠这个 Host 头来区分是哪个子域名
        proxy_set_header Host $host;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 支持 WebSocket (很多本地开发服务需要)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}