上一篇 下一篇 分享链接 返回 返回顶部

短视频平台如何在香港服务器的 CentOS 7 中设置、部署、优化分布式转码节点,缩短上传到发布的时延

发布人:Minchunlin 发布时间:2025-09-13 10:13 阅读量:753


夜里 2:17,香港葵涌机房的监控屏上“Upload→Publish p95”那条红线一次次越过 5 分钟的 SLA。运营群里不断有人 @我:“晚高峰短视频排队太久了,能不能快点发出来?”我合上保温杯,深吸一口机房里带着冷凝水味的空气,对同事说:今晚我们把分布式转码集群重搭一遍,目标是 p95 压到 120 秒以内。下面是那一夜到次日清晨我做过的每一步——坑、解法、参数、命令,一个不落。

1. 业务目标与指标

业务目标:将“用户上传完成→视频可发布”的端到端时延(含排队、探测、转码、切片、审核入库)压到 p95 ≤ 120s,p50 ≤ 45s。

负载画像:

  • 晚高峰上传峰值:1.2 万视频/小时(约 3.3 r/s)。
  • 平均原始文件大小:80–250 MB,主流分辨率 720p/1080p,H.264 + AAC。
  • 目标产物:H.264 HLS 自适应码率(ABR)三档 + 封面图 + 低码预览。

硬约束:

  • 必须部署在香港机房(跨境时延低,链路到国内/东南亚用户较优)。
  • 操作系统:CentOS 7(7.9, 3.10 内核)。
  • 尽量复用现有机房资源,允许增配 GPU。

2. 集群架构总览(简图)

[Upload/APP] --HTTPS--> [INGEST 节点: Nginx + tusd] --S3 PUT--> [存储: MinIO 集群]
                                                   \--AMQP--> [队列: RabbitMQ]
                                                                    \
                                                                     -> [调度: Coordinator API]
                                                                             |  \--Redis 状态
                                                                             |
           [Transcode Worker x N: FFmpeg + NVENC] <--AMQP--> [RabbitMQ]
                 |--S3 GET 原始--|--S3 PUT HLS 切片/封面--|--回写状态到 API/Redis--|

[CDN] <--S3 后端源站(MinIO 网关或 Nginx-S3 反代)

职责划分:

  • Ingest:断点续传、鉴权、直传对象存储,入队转码任务。
  • Queue(RabbitMQ):削峰填谷,按优先级分发任务。
  • Coordinator:任务编排、重试与幂等、状态机、速率限制。
  • Worker:GPU 加速转码、切片、图像抽帧、质量探测。
  • Storage/CDN:MinIO(S3 兼容)作主存与 CDN 回源。

3. 硬件与网络选型(我们实际落地的配方)

3.1 转码 Worker(GPU 型)

型号/参数 选择理由
机型 1U/2U Supermicro / DELL R740xd 港区托管普及款,易拿到备件
CPU Intel Xeon Gold 6226R(16C)或 AMD EPYC 7313(16C) NVENC 主要由 GPU 负责,CPU 够用即可
内存 128 GB DDR4 并发 I/O + 缓存空间
GPU NVIDIA T4 × 2(或单卡起步) NVENC 性价比高,CentOS 7 可用 R470 驱动
本地盘 NVMe 2 TB(PCIe3 x4) 作为转码 scratch(临时)与 ffmpeg 缓冲
网卡 10GbE(Intel X710) HLS 切片上行与对象存储吞吐

HLS 切片上行与对象存储吞吐

为什么不是 L4/A10? CentOS 7 对新驱动/新内核依赖更苛刻,T4 + R470 驱动路线稳,兼容 ffmpeg-nvenc。后续可在过渡到 Rocky/Alma 8/9 时再换新卡。

3.2 Ingest + Coordinator

Ingest:双机 Nginx + tusd(断点续传)+ OpenResty 鉴权;系统盘 SSD;10GbE。

Coordinator:两台虚机或物理机(4C/8G 起),跑 API(Go)+ Redis(主从)+ RabbitMQ(镜像队列)。

3.3 存储与回源

MinIO:3–4 节点纠删码(EC: 4+2 起步),机型 8×NVMe(U.2)+ 25GbE 更佳;我们实测 10GbE 也够晚高峰。

CDN:Cloudflare/R2 回源或自建 Nginx-S3 反代。

4. 系统初始化(CentOS 7 定制)

4.1 基础与源

yum -y update && yum -y install epel-release yum-utils jq git make gcc-c++
yum -y install htop iotop iftop numactl tuned tmux rsync psmisc iproute net-tools

4.2 内核与网络参数(/etc/sysctl.d/99-transcode.conf)

fs.file-max = 2097152
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.ipv4.tcp_rmem = 4096 87380 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432
vm.swappiness = 10
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5

sysctl --system

4.3 ulimit(/etc/security/limits.d/99-nofile.conf)

* soft nofile 1048576
* hard nofile 1048576

4.4 tuned 与 CPU 绑定

tuned-adm profile latency-performance
# GPU/IO 密集:为 ffmpeg 工作进程预留 CPU 亲和

4.5 SELinux 与防火墙

建议 SELinux enforcing + 有策略(下文给策略示例),不是一刀切关闭。

开放端口:443(ingest)、5672/15672(MQ)、9000/9001(MinIO)、自定义 API 端口等。

firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-port=5672/tcp --add-port=15672/tcp
firewall-cmd --reload

5. NVIDIA 驱动与 FFmpeg(NVENC)

5.1 安装 R470 驱动与 CUDA Runtime(精简)

注意:CentOS 7 内核 3.10,选择 NVIDIA-Linux-x86_64-470.xx.run 更稳。

# 黑名单 nouveau
echo -e "blacklist nouveau\noptions nouveau modeset=0" > /etc/modprobe.d/blacklist-nouveau.conf
dracut --force
reboot
# 安装驱动(离线包或本地仓)
sh NVIDIA-Linux-x86_64-470.239.06.run --disable-nouveau --dkms
nvidia-smi

5.2 编译 FFmpeg(启用 nvenc / fdk-aac)

yum -y install nasm yasm pkgconfig autoconf automake libtool cmake \
  openssl-devel zlib-devel xz-devel bzip2-devel \
  libass-devel freetype-devel
# 安装 fdk-aac(EPEL 或源码构建)
# ...略去打包过程,可用已编译 RPM。

# 获取 FFmpeg 5.x LTS 源码(与 R470 驱动兼容的 nv-codec-headers)
git clone https://git.ffmpeg.org/ffmpeg.git && cd ffmpeg && git checkout n5.1
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \
./configure \
  --enable-gpl --enable-nonfree \
  --enable-libx264 --enable-libx265 --enable-libfdk_aac \
  --enable-libass --enable-libfreetype \
  --enable-cuda-nvcc --enable-nvenc --extra-cflags=-I/usr/local/cuda/include \
  --extra-ldflags=-L/usr/local/cuda/lib64
make -j$(nproc) && make install
ffmpeg -hwaccels | grep cuda

5.3 校验 NVENC 会话并发

nvidia-smi --query-gpu=name,utilization.gpu,temperature.gpu --format=csv
nvidia-smi encodersessions

T4 单卡稳定并发 8–10 路 1080p → ABR 三档(详见压测章节)。

6. ABR 策略与转码配置

6.1 我们的 HLS 三档(短视频场景)

级别 分辨率 视频码率(kbps) 音频 目标 Usecase
360p 640×360 450 AAC 64 kbps 低网速预览
720p 1280×720 1200 AAC 96 kbps 主流发布
1080p 1920×1080 2500 AAC 128 kbps 高清播放

6.2 FFmpeg 命令模版(NVENC + 分段并行)

# 输入:/tmp/input.mp4   输出:/mnt/hls/{video_id}/
ffmpeg -y -hwaccel cuda -hwaccel_output_format cuda -i /tmp/input.mp4 \
  -filter_complex "[0:v]split=3[v0][v1][v2]; \
                   [v0]scale_cuda=w=1920:h=1080:format=yuv420p,hwdownload,format=yuv420p[v1080]; \
                   [v1]scale_cuda=w=1280:h=720:format=yuv420p,hwdownload,format=yuv420p[v720]; \
                   [v2]scale_cuda=w=640:h=360:format=yuv420p,hwdownload,format=yuv420p[v360]" \
  -map "[v1080]" -c:v h264_nvenc -preset p4 -profile high -b:v 2500k -maxrate 2800k -bufsize 5000k -g 48 -keyint_min 48 -sc_threshold 0 \
  -map a:0 -c:a aac -b:a 128k -ac 2 -ar 48000 -hls_time 4 -hls_list_size 0 -hls_segment_filename "/mnt/hls/%VID%/1080p_%03d.ts" \
  -master_pl_name master.m3u8 -var_stream_map "v:0,a:0 name:1080p" \
  -f hls "/mnt/hls/%VID%/1080p.m3u8" \
  -map "[v720]" -c:v h264_nvenc -preset p4 -profile high -b:v 1200k -maxrate 1400k -bufsize 2400k -g 48 -keyint_min 48 -sc_threshold 0 \
  -map a:0 -c:a aac -b:a 96k  -ac 2 -ar 48000 -hls_time 4 -hls_list_size 0 -hls_segment_filename "/mnt/hls/%VID%/720p_%03d.ts" \
  -var_stream_map "v:1,a:1 name:720p" \
  -f hls "/mnt/hls/%VID%/720p.m3u8" \
  -map "[v360]" -c:v h264_nvenc -preset p4 -profile main -b:v 450k -maxrate 600k -bufsize 900k -g 48 -keyint_min 48 -sc_threshold 0 \
  -map a:0 -c:a aac -b:a 64k -ac 2 -ar 48000 -hls_time 4 -hls_list_size 0 -hls_segment_filename "/mnt/hls/%VID%/360p_%03d.ts" \
  -var_stream_map "v:2,a:2 name:360p" \
  -f hls "/mnt/hls/%VID%/360p.m3u8"

说明:

  • -g 48 对应 24fps 的 2s GOP(我们切 4s 段,两个 GOP 做边界,利于首开)。
  • 为了方便 S3 写入,/mnt/hls 挂载到本地 NVMe,完成后统一 aws s3 cp/mc cp 推送到 MinIO。

7. 队列与调度(RabbitMQ + 协程 Worker)

7.1 消息结构(JSON)

{
  "vid": "20240918_abcdef",
  "src": "s3://upload-bucket/user/2024/09/18/raw.mp4",
  "dst": "s3://vod-bucket/2024/09/18/20240918_abcdef/",
  "priority": 5,
  "retries": 0,
  "deadline": 1726632000,
  "profile": "hls_3tier",
  "callback": "https://api.internal/v1/transcode/callback"
}

7.2 Worker(Python 异步示例)

import asyncio, aiohttp, aioboto3, os, json, uuid, subprocess, time
import pika

AMQP = os.getenv("AMQP", "amqp://guest:guest@mq:5672/")
S3_EP = os.getenv("S3_EP", "http://minio:9000")
S3_AK = os.getenv("S3_AK")
S3_SK = os.getenv("S3_SK")

TMP = "/nvme/scratch"
FFMPEG = "/usr/local/bin/ffmpeg"

def run(cmd):
    p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    for line in p.stdout:
        print(line.strip())
    return p.wait()

async def handle(job):
    vid = job['vid']
    src = job['src']
    dst = job['dst']
    workdir = f"{TMP}/{vid}"
    os.makedirs(workdir, exist_ok=True)
    input_path = f"{workdir}/input.mp4"

    # 下载源文件
    session = aioboto3.Session()
    async with session.client('s3', endpoint_url=S3_EP, aws_access_key_id=S3_AK, aws_secret_access_key=S3_SK) as s3:
        bucket, key = src.replace("s3://", "").split("/", 1)
        await s3.download_file(Bucket=bucket, Key=key, Filename=input_path)

    # 执行转码
    outdir = f"{workdir}/hls"
    os.makedirs(outdir, exist_ok=True)
    cmd = f"VID={vid} %s -y -hwaccel cuda -hwaccel_output_format cuda -i %s ...(略,上文命令替换路径)..." % (FFMPEG, input_path)
    rc = run(cmd)

    # 上传产物
    async with session.client('s3', endpoint_url=S3_EP, aws_access_key_id=S3_AK, aws_secret_access_key=S3_SK) as s3:
        for root, _, files in os.walk(outdir):
            for f in files:
                full = os.path.join(root, f)
                rel = os.path.relpath(full, outdir)
                bucket, key = dst.replace("s3://", "").split("/", 1)
                await s3.upload_file(Filename=full, Bucket=bucket, Key=f"{key}{rel}")

    # 回调
    async with aiohttp.ClientSession() as http:
        await http.post(job['callback'], json={"vid": vid, "status": "done", "rc": rc})

    # 清理
    try:
        subprocess.call(f"rm -rf {workdir}", shell=True)
    except Exception:
        pass

# AMQP 消费主循环(单机多协程)略

实战中我们将 Worker 进程与 GPU 绑定(CUDA_VISIBLE_DEVICES),每张卡限制同时 8 路任务,防止 NVENC 饱和导致掉帧。

7.3 调度关键点

  • 优先级队列:新鲜内容(发起即看)优先级 1,高;异步补转档优先级 5,低。
  • 幂等:vid 作为幂等键,MinIO 上同路径覆盖写;失败重试最多 3 次。
  • 限速:每台 Worker 基于 GPU 数量做令牌桶,队列侧 x-max-priority: 10。

8. Ingest:Nginx + tusd(断点续传)

8.1 Nginx 片段(OpenResty 鉴权)

server {
  listen 443 ssl http2;
  server_name upload.example.com;
  client_max_body_size 0;  # tusd 处理大文件

  location /tus/ {
    proxy_pass http://127.0.0.1:1080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
  }
}

8.2 tusd 到 S3(MinIO)

./tusd -host=0.0.0.0 -port=1080 \
  -s3-endpoint=http://minio:9000 -s3-bucket=upload-bucket \
  -s3-access-key=$AK -s3-secret-key=$SK \
  -behind-proxy -hooks-http=http://127.0.0.1:9009/hooks

hooks 服务在 Upload 完成时写入 RabbitMQ 一条转码任务。

9. 部署编排(Ansible + systemd)

9.1 目录与用户

useradd -r -s /sbin/nologin transcoder
mkdir -p /nvme/scratch /opt/transcoder
chown -R transcoder:transcoder /nvme/scratch /opt/transcoder

9.2 systemd 服务(/etc/systemd/system/transcoder@.service)

[Unit]
Description=Transcode Worker %i
After=network.target

[Service]
User=transcoder
Environment=CUDA_VISIBLE_DEVICES=%i
Environment=AMQP=amqp://user:pass@mq:5672/
Environment=S3_EP=http://minio:9000
Environment=S3_AK=***
Environment=S3_SK=***
ExecStart=/usr/bin/python3 /opt/transcoder/worker.py
Restart=always
RestartSec=3
LimitNOFILE=1048576

[Install]
WantedBy=multi-user.target

systemctl daemon-reload
systemctl enable --now transcoder@0.service
systemctl enable --now transcoder@1.service

9.3 Ansible 任务片段

- hosts: workers
  become: yes
  tasks:
    - name: Ensure packages
      yum:
        name: [epel-release, htop, tmux, git]
        state: present
    - name: Push worker code
      synchronize:
        src: files/worker/
        dest: /opt/transcoder/
        recursive: yes
    - name: Deploy systemd unit
      template:
        src: templates/transcoder@.service.j2
        dest: /etc/systemd/system/transcoder@.service
    - name: Start workers
      systemd:
        name: "transcoder@{{ item }}.service"
        state: started
        enabled: yes
      loop: [0,1]

10. 对象存储与回源(MinIO)

10.1 MinIO 部署要点

EC: 4+2 起步,目录区分 upload-bucket 与 vod-bucket。

HLS 切片建议关闭版本化,避免堆积;启用生命周期规则,7 天内热存,30 天冷归档。

10.2 CDN 回源

Nginx 反代到 MinIO,开启 gzip_types application/vnd.apple.mpegurl,并为 .m3u8 设置 Cache-Control: max-age=30,.ts/.mp4 设置 max-age=31536000。

11. 监控与告警(Prometheus/Grafana)

11.1 指标面板(我们上线的关键指标)

端到端:Upload→Publish p50/p95。

队列:待处理消息、消费者滞后、拒绝率。

Worker:

  • GPU 利用率、温度、功耗(DCGM 或 nvidia-smi --query 导出器)。
  • 任务成功率、平均转码时长、按分辨率分布。
  • 本地 NVMe IOPS/带宽(node_exporter + diskstats)。
  • 存储:S3 PUT/GET QPS、错误率、延迟。

11.2 自定义导出器(示意)

from flask import Flask, Response
app = Flask(__name__)

@app.route('/metrics')
def metrics():
    # 读取本地 JSON 状态,输出 Prometheus 格式
    out = []
    out.append('transcode_jobs_total 12345')
    out.append('transcode_fail_total 12')
    out.append('transcode_duration_seconds_avg 21.3')
    return Response("\n".join(out)+"\n", mimetype='text/plain')

12. 压测结果(真实数据,节选)

12.1 单卡 T4 并发能力(1080p→三档 HLS)

并发路数 平均转码时长(s) p95(s) GPU 利用率 NVMe 写带宽
4 24.8 32.1 58% 180 MB/s
6 28.7 38.5 72% 220 MB/s
8 34.2 46.9 85% 260 MB/s
10 42.8 59.3 96% 310 MB/s

结论:8 路是性价比较优的稳定点。

12.2 端到端(晚高峰)上线前后对比

阶段 p50(s) p95(s) 备注
优化前 138 322 CPU 转码 + NFS 写入瓶颈
优化后 37 108 NVENC + 本地 NVMe + 队列削峰

13. 现场遇到的坑与解决过程

驱动与内核不匹配:

现象:nvidia-smi 间歇性超时,Worker 偶发 Device not available。

根因:自动更新装了新内核小版本,DKMS 未重编译成功。

解决:yum versionlock 锁定内核与驱动版本;每次变更做维护窗重启校验。

PCIe 拓扑导致带宽打折:

现象:双 T4 与 NVMe 共用同一根上行,8 路并发时 NVMe 写带宽抖动。

解决:移动 NVMe 到另一 CPU 的 PCIe 槽位,设置 NUMA 亲和 numactl --cpunodebind --membind,抖动消失。

RabbitMQ 镜像队列脑裂:

现象:网络抖动后队列不可用。

解决:引入 quorum queue(拉姆达一致性),并在 HA 网络上启用双上联 LACP。

MinIO 小文件过多:

现象:大量 4s TS 段导致元数据压力。

解决:合并切片策略为 6s 段(非首开场景),首段预热;同时开启分层存储策略与 GC。

ffmpeg 进程泄露:

现象:失败重试时遗留孤儿进程占 GPU。

解决:Worker 统一以 PGID 组启动,失败时 kill -TERM -<pgid>,并加 systemd KillMode=control-group。

CDN 首帧延迟:

现象:部分地区首次播放需要等 1–2 个段。

解决:转码完成后主动预热 master.m3u8 与首两个 .ts 到 CDN;回源开启 keepalive 与 sendfile。

14. 安全与合规小结

  • 上传域名与业务域名分离,强制 TLS1.2+。
  • 产物桶最小权限:Worker 仅有 PutObject,读取由 CDN 回源承担。
  • SELinux policy:允许 ffmpeg 访问 NVMe 挂载点与 /dev/nvidia*,不要直接 setenforce 0。

示例策略(节选):

cat > transcode.te <<'EOF'
module transcode 1.0;
require {
  type device_t; type usr_t; class chr_file rw_file_perms; class dir { read write add_name }; class file { read write execute open getattr };
}
allow usr_t device_t:chr_file rw_file_perms;
EOF
checkmodule -M -m -o transcode.mod transcode.te
semodule_package -o transcode.pp -m transcode.mod
semodule -i transcode.pp

15. 运维日常与应急 Runbook(摘)

  • 扩容:新增 Worker → 装驱动 → 注册 systemd 实例 → 10 分钟内生效。
  • 降级:GPU 紧张时只产 720p/360p;高优视频可单独白名单 1080p。
  • 应急:RabbitMQ 队列堆积 > 10 万条时,自动触发临时 CPU-only Worker(低速)兜底。
  • 版本管理:FFmpeg 构建打上 git describe,回溯问题更快。

16. 成本与性价比(粗算)

方案 单机成本(含托管) 1080p→三档 吞吐 成本/千路/小时
CPU-only(32C) ¥X ~2 路
T4×1 ¥X+Δ ~8 路
T4×2 ¥X+2Δ ~16 路

结合 p95 目标,我们最终选择 T4×2 的 Worker 作为标准规格。

17. 我们还做了哪些“细枝末节”

  • 上传完成时先 ffprobe:时长、旋转元数据、音视频轨一致性,不合规直接回退给用户端重新录制。
  • 统一封装容器 MP4(-movflags +faststart),避免部分浏览器首开卡顿。
  • 抽帧生成 WebP 封面(-vframes 1 -vf fps=1/2,scale=-2:720),并产低码 240p 预览用于审核。
  • 引入质量闸门:采样做 VMAF ≥ 85 才放行(异步),低于阈值自动提高码率重试。

清晨 5:40,我们把最后一台 Worker 的风扇灰尘吹干净,Grafana 上那条红线终究掉回了 120 秒以下。运营同事发来几个“牛”的表情,我也终于有空喝完那杯已经凉透的咖啡。后来很多人问我,分布式转码到底难在哪?我想,难不在“会不会 ffmpeg”,而在于把每一段路——上传、入队、GPU、IO、存储、回源——都打磨到刚刚好的稳定。技术之外,还有人,要在凌晨两点能顶住压力、能把线缆从一台机器耐心移到另一台。愿你也能在属于你的那个夜里,把红线拉回到心里期待的刻度

目录结构
全文