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

夜里 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、存储、回源——都打磨到刚刚好的稳定。技术之外,还有人,要在凌晨两点能顶住压力、能把线缆从一台机器耐心移到另一台。愿你也能在属于你的那个夜里,把红线拉回到心里期待的刻度