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


痛点:为什么普通的 Docker 镜像不行?
虽然 PVE 支持了 OCI,但普通的 Docker 镜像直接跑在 LXC 容器里通常是不行的,或者说体验极差。
Docker 容器的设计哲学是微服务,PID 1 通常是应用本身;而 LXC 是系统容器,更像是一个轻量级虚拟机。直接在 LXC 中跑 Docker 镜像(尤其是基于 scratch 或 distroless 的镜像)会导致以下问题:
- 无法进入终端:PVE 的 Console 可能会卡死,无法登录。
- 调试困难:因为缺少 Shell 或基础工具,连
pct enter <ID>都会报错,变成“黑盒”。 - 配置不便:Docker 习惯用环境变量注入,但 LXC 对此支持需要适配,且不支持 Docker 的 Volume 挂载逻辑。
- 不支持热重载:修改配置通常需要重启整个容器。
解决方案:自制适配 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 容器环境变量配置以下变量:

| 变量名 | 示例值 | 说明 |
|---|---|---|
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.comfalse: 生成 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

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";
}
}