HandBrake Web 0.8.1 Docker Compose 部署指南

Docker Compose 部署 HandBrake Web 0.8.1:项目名归一化实测、8128 端口与 10.89.66.0/24 网段规划、server 加双 worker 与卷映射、硬件直通与端到端转码回显。

HandBrake WebHandBrake视频转码运维自动化Node.js2026/10/27

HandBrake Web 0.8.1 Docker Compose 部署指南

目标:用一份 compose.yaml 在单机上拉起 HandBrake Web 0.8.1 的 server 与两个 worker 容器,端口、网络、卷、硬件直通全部落在可控范围内,并给出端到端转码的真实回显。文中的命令与输出来自 2026-10-27 的实机验证,镜像版本、项目名归一化、worker 注册等"不按预期"的现象都保留了原始报错。

环境信息

项目取值说明
Docker Engine29.9.2docker version 客户端与服务端一致
Docker Composev5.6.1独立插件版本,docker compose version
server 镜像ghcr.io/thenickoftime/handbrake-web-server:0.8.1224 MB,distroless node24,EXPOSE 9999
worker 镜像ghcr.io/thenickoftime/handbrake-web-worker:0.8.1398 MB,distroless node24,内置 HandBrakeCLI
worker 内置 HandBrakeCLI1.10.2不是 1.11.0,见 2.3 节
镜像内 Node.jsv24.11.1宿主机上是 v24.22.1,两者无需一致
暴露端口8128宿主侧;容器内仍固定 9999
Compose 项目名handbrake-web-081-stack声明的 handbrake-web-0.8.1-stack 被后端归一化,见 3.3 节
自定义网络10.89.66.0/24同宿主上 10.89.56/59/61/62/63 已被占用
宿主媒体目录stack-b2/video挂到 server 与两个 worker 的 /video
宿主数据目录stack-b2/data挂到 server 的 /data
worker 本地目录stack-b2/worker-data/worker-0X挂到 worker 的 /tmp/handbrake-web
容器运行用户1000:1000镜像默认 65532(nonroot),官方模板显式覆盖
实测宿主机 GPU/dev/dri/card0、/dev/dri/renderD128直通后 QSV 仍不可用,见 7.3 节

说明:镜像 tag 用 0.8.1。同一个仓库的 v0.8.1 标签不存在,拉取会返回 not found,见 2.2 节。

一、部署拓扑与资源规划

1.1 三个容器,两种角色

容器角色端口卷资源
handbrake-web-0.8.1-server页面、Socket.IO 服务端、队列、SQLite8128:9999/data、/video1 核 1 GB 足够
handbrake-web-0.8.1-worker-01领任务、转码无入站/video、/tmp/handbrake-web转码时 CPU 跑满
handbrake-web-0.8.1-worker-02同上无入站/video、/tmp/handbrake-web同上

worker 是客户端,主动连 server 的 /worker 命名空间,因此不需要映射任何端口,也不需要在防火墙上开洞。浏览器连 server 的 /client 命名空间,走的是同一个 9999。

1.2 端口选段(8128–8152)

实测宿主机在该区段内的占用情况:

端口状态
8121、8122、8123已被其它 docker-proxy 占用
8129被本机 python3 占用(仅监听 127.0.0.1)
8130、8131、8132、8133、8137已被其它 docker-proxy 占用
8135、8150被编辑器后端占用(仅监听 127.0.0.1)
8128空闲,本文选用
8134、8136、8138–8149、8151、8152空闲

挑选命令(不做端口探测脚本,直接看内核监听表):

ss -lntp | grep -E ':(812[0-9]|81[3-5][0-9])\b'

候选端口确定后必须再验一次"没人监听",否则 docker compose up 会在 ports 绑定阶段报 bind: address already in use:

ss -lnt | grep -q ':8128 ' && echo "8128 已占用" || echo "8128 空闲"

1.3 网络选段(10.89.56–75)

Compose 默认会给项目建一个 <项目名>_default 网桥并随机取段。手工指定时先看宿主已有的 10.89.x 路由:

ip route | grep -oE '10\.89\.[0-9]+\.' | sort -u

实测输出:

10.89.56.
10.89.59.
10.89.61.
10.89.62.
10.89.63.

10.89.56.0/24 等已被同宿主的其它项目占用,因此本文在候选区间内选 10.89.66.0/24:网段不与现有路由冲突,给三个容器留出 250 个地址,且未来扩容不必重新规划。

1.4 目录规划

/srv/handbrake-web/
├── data/                     # server 的 /data
├── video/
│   ├── input/
│   └── output/
└── worker-data/
    ├── worker-01/            # worker-01 的 /tmp/handbrake-web
    └── worker-02/

/data 与 /video 必须是宿主上的真实目录,不能指望容器启动时自动创建(bind mount 的源目录不存在时 Docker 会创建一个空的 root 属主目录,随后容器里的 1000:1000 写入失败)。

mkdir -p /srv/handbrake-web/{data,video/input,video/output,worker-data/worker-01,worker-data/worker-02}
chown -R 1000:1000 /srv/handbrake-web

二、镜像准备

2.1 镜像来源与标签

官方发布的镜像放在 GitHub 容器仓库,两个镜像分别对应 server 与 worker。0.8.1 的标签写作 0.8.1,不带 v 前缀:

docker pull ghcr.io/thenickoftime/handbrake-web-server:0.8.1
docker pull ghcr.io/thenickoftime/handbrake-web-worker:0.8.1

拉取完成后核对尺寸与入口:

docker image inspect ghcr.io/thenickoftime/handbrake-web-server:0.8.1 \
  --format 'size={{.Size}} entry={{.Config.Entrypoint}} cmd={{.Config.Cmd}} user={{.Config.User}}'

size=224191175 entry=[/nodejs/bin/node] cmd=[/handbrake-web/server/server.js] user=65532

worker 镜像的对应信息:

size=398121326 entry=[/nodejs/bin/node] cmd=[/handbrake-web/worker/worker.js] user=65532

镜像里预置了这些环境变量,部署时不必重复声明,除非要覆盖:

镜像变量值
serverNODE_ENVproduction
serverDATA_PATH/data
serverVIDEO_PATH/video
workerDATA_PATH/tmp/handbrake-web
workerVIDEO_PATH/video
两者HANDBRAKE_MODEserver / worker

2.2 v0.8.1 标签不存在

按常见习惯加 v 前缀会直接失败,原样报错:

Error response from daemon: manifest unknown

排查方式:

docker manifest inspect ghcr.io/thenickoftime/handbrake-web-server:v0.8.1 2>&1 | tail -2

结论:用 0.8.1,不要用 v0.8.1、latest 或 release-0.8.1。

2.3 镜像内的 HandBrakeCLI 是 1.10.2

worker 镜像构建时把 HandBrake 源码编进去,版本写死在构建文件里(HANDBRAKE_VERSION=1.10.2)。容器里查版本:

docker run --rm --entrypoint /usr/local/bin/HandBrakeCLI \
  ghcr.io/thenickoftime/handbrake-web-worker:0.8.1 --version

输出:

Cannot load libnvidia-encode.so.1
[14:13:58] vcn: not available on this system
[14:13:58] qsv: not available on this system
[14:13:59] hb_init: starting libhb thread
[14:13:59] thread 7c49386906c0 started ("libhb")
HandBrake 1.10.2

两点结论:

  1. 镜像版比发行版旧。Ubuntu 软件源现在是 1.11.0,镜像是 1.10.2。预设里的编码器名、参数在两者间大体兼容,但部分新预设(例如依赖新编码器默认值的)在容器里可能回落到 CPU 编码或直接报预设错误。
  2. Cannot load libnvidia-encode.so.1 与 qsv/vcn: not available 是预期输出,不代表镜像坏了。没有直通 GPU 时 HandBrake 必然探测失败,worker 会据此把 nvenc/qsv/vcn 能力上报为 false。

三、编写 compose.yaml

3.1 完整文件

name: handbrake-web-0.8.1-stack

x-worker: &worker
  image: ghcr.io/thenickoftime/handbrake-web-worker:0.8.1
  user: "1000:1000"
  restart: unless-stopped
  depends_on:
    - handbrake-server
  networks:
    - handbrake-net

services:
  handbrake-server:
    image: ghcr.io/thenickoftime/handbrake-web-server:0.8.1
    container_name: handbrake-web-0.8.1-server
    user: "1000:1000"
    restart: unless-stopped
    ports:
      - "8128:9999"
    volumes:
      - ./data:/data
      - ./video:/video
    networks:
      - handbrake-net

  handbrake-worker-01:
    <<: *worker
    container_name: handbrake-web-0.8.1-worker-01
    environment:
      - WORKER_ID=worker-01
      - SERVER_URL=handbrake-server
      - SERVER_PORT=9999
    volumes:
      - ./video:/video
      - ./worker-data/worker-01:/tmp/handbrake-web

  handbrake-worker-02:
    <<: *worker
    container_name: handbrake-web-0.8.1-worker-02
    environment:
      - WORKER_ID=worker-02
      - SERVER_URL=handbrake-server
      - SERVER_PORT=9999
    volumes:
      - ./video:/video
      - ./worker-data/worker-02:/tmp/handbrake-web

networks:
  handbrake-net:
    ipam:
      config:
        - subnet: 10.89.66.0/24

路径提醒:上面的 ./data、./video 是本文实测时用的相对路径(compose 文件所在目录下)。官方的建议是把宿主目录写成绝对路径,例如 - /srv/handbrake-web/data:/data、- /srv/handbrake-web/video:/video。两者语义完全一致,只是相对路径的解析基准是 compose 文件所在目录,换目录执行会挂到别处。生产环境请统一改成绝对路径。

3.2 关键字段解释

字段作用实测要点
user: "1000:1000"覆盖镜像默认的 65532宿主目录必须 chown 1000:1000,否则 /data 写入失败
ports: "8128:9999"宿主 8128 → 容器 9999右侧不能改,server 源码里写死 9999
WORKER_IDworker 的唯一名字同名不会被拒绝,见 5.2 节
SERVER_URLworker 找 server 的主机名同一网络内用服务名 handbrake-server 即可,DNS 由 Docker 提供
SERVER_PORT拼接目标地址的端口必须是容器内端口 9999,不是宿主 8128
networks.ipam.config.subnet固定网段避免与宿主上其它 10.89.x 网桥重叠
worker 的 /tmp/handbrake-webworker 的 DATA_PATH只放任务日志;不挂载也能跑,重启即丢

3.3 项目名会被归一化

docker compose config 是启动前最省事的校验方式,它会把最终生效的配置完整展开:

cd stack-b2
docker compose config --quiet && echo "config OK"
docker compose config | head -3

实测输出:

config OK
name: handbrake-web-081-stack
services:
  handbrake-server:

声明的是 handbrake-web-0.8.1-stack,生效的是 handbrake-web-081-stack:Compose 的项目名只允许小写字母、数字、连字符和下划线,点号被直接去掉。由此带来三个连带效果:

  1. 网络名变成 handbrake-web-081-stack_handbrake-net,不是 handbrake-web-0.8.1-stack_handbrake-net;
  2. 卷名、默认网络名同样按归一化后的项目名生成;
  3. container_name 不受影响——容器名仍然是 handbrake-web-0.8.1-server,因为它是原样字符串。

如果想显式指定一个合法的项目名,用 -p:

docker compose -p HandBrake_Web.081 up -d

会直接拒绝:

invalid project name "HandBrake_Web.081": must consist only of lowercase alphanumeric characters, hyphens, and underscores as well as start with a letter or number

所以项目名里既不要大写也不要点号。本文保留 name: 字段,接受归一化结果。

四、启动与基础验证

4.1 拉取并启动

docker compose pull && docker compose up -d

实测输出(节选):

 Network handbrake-web-081-stack_handbrake-net Creating
 Network handbrake-web-081-stack_handbrake-net Created
 Container handbrake-web-0.8.1-server Created
 Container handbrake-web-0.8.1-worker-01 Created
 Container handbrake-web-0.8.1-worker-02 Created
 Container handbrake-web-0.8.1-server Started
 Container handbrake-web-0.8.1-worker-02 Started
 Container handbrake-web-0.8.1-worker-01 Started
docker compose ps --format '{{.Name}}\t{{.Status}}\t{{.Ports}}'
handbrake-web-0.8.1-server      Up Less than a second   0.0.0.0:8128->9999/tcp, [::]:8128->9999/tcp
handbrake-web-0.8.1-worker-01   Up Less than a second
handbrake-web-0.8.1-worker-02   Up Less than a second

4.2 server 启动日志

docker compose logs --no-color handbrake-server | tail -45

关键片段(时间戳为容器内 UTC):

14:09:23 [server] [database] Initialized the 'watchers' table.
14:09:23 [server] [database] Initialized the 'watcher_rules' table.
14:09:23 [server] [database] Successfully initialized all database tables!
14:09:23 [server] [database] [migration] Marking all migrations up to 'migration-2' as completed.
14:09:23 [server] [database] The database is ready!
14:09:23 [server] [queue] The queue status does not exist in the database, initializing to the state 'stopped'.
14:09:23 [server] [database] Sucessfully updated the status of 'queue' to 'Stopped'.
14:09:23 [server] Available at 'http://localhost:9999'.
14:09:23 [server] [version] Checking if there is an application update...
14:09:23 [server] [socket] Worker 'worker-02' has connected with ID 'POwi3tYrWkle_9unAAAB'.
14:09:23 [server] [socket] Getting worker 'worker-02' properties...
14:09:23 [server] [socket] Worker properties = {
  "version": {
    "handbrake": "1.10.2",
    "application": "0.8.1"
  },
  "capabilities": {
    "cpu": true,
    "qsv": false,
    "nvenc": false,
    "vcn": false
  }
}
14:09:23 [server] [socket] Checking worker 'worker-02' for an existing job in progress...
14:09:23 [server] [socket] Worker 'worker-02' is not busy with an existing job.
14:09:23 [server] [socket] Worker 'worker-01' has connected with ID 'hXHQRnhiOi-N1a0lAAAD'.
14:09:23 [server] [version] The application is up to date.

Available at 'http://localhost:9999' 是 server 内部日志,描述的是容器内监听地址;从宿主访问要走 http://<宿主IP>:8128。

4.3 worker 启动日志

docker compose logs --no-color handbrake-worker-01 | tail -12
14:09:23 [worker-01] [properties] Capability 'nvenc' support = false
14:09:23 [worker-01] [properties] Capability 'vcn' support = false
14:09:23 [worker-01] The worker process has started.
14:09:23 [worker-01] [socket] Connected to the server 'http://handbrake-server:9999/worker' with id 'hXHQRnhiOi-N1a0lAAAD'.
14:09:23 [worker-01] [socket] The server is requesting this worker's properties...
14:09:23 [worker-01] [properties] HandBrake Web Application Version = 0.8.1
14:09:23 [worker-01] [properties] HandBrake Version = 1.10.2
14:09:23 [worker-01] [properties] Capability 'cpu' support = true
14:09:23 [worker-01] [properties] Capability 'qsv' support = false
14:09:23 [worker-01] [properties] Capability 'nvenc' support = false
14:09:23 [worker-01] [properties] Capability 'vcn' support = false
14:09:23 [worker-01] [socket] The server is requesting the status of this worker...
14:09:23 [worker-01] [socket] This worker is currently not busy with a job - reporting to the server.

注意日志里的两行顺序:The worker process has started. 出现在 Connected to the server 之前。这只是连接是异步发起造成的,不代表注册失败。判断注册成功的唯一依据是 server 侧出现 Worker 'xxx' has connected with ID ...。

4.4 页面与静态资源

镜像预置了 NODE_ENV=production,所以 server 会挂载内置的前端目录,直接从宿主 8128 抓首页就是 200:

curl -s -D - -o /tmp/b2_index.html http://127.0.0.1:8128/ | head -12
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Wed, 10 Dec 2025 20:01:40 GMT
ETag: W/"190-19b09dae8a0"
Content-Type: text/html; charset=utf-8
Content-Length: 400
Date: Tue, 27 Oct 2026 14:09:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

正文只有 400 字节,是一个壳页面:

<!DOCTYPE html>
<html lang="en" data-theme="dark">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>HandBrake Web</title>
		<script type="module" crossorigin src="/assets/index-Cum2pTXs.js"></script>

注意 Last-Modified 是镜像构建日期(2025-12-10),不是本次启动时间——静态文件是打进镜像的,容器重建不会刷新它。

4.5 网络与地址分配

docker network inspect handbrake-web-081-stack_handbrake-net \
  --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}| {{range .Containers}}{{.Name}}={{.IPv4Address}} {{end}}'
10.89.66.0/24 | handbrake-web-0.8.1-worker-02=10.89.66.3/24 handbrake-web-0.8.1-server=10.89.66.2/24 handbrake-web-0.8.1-worker-01=10.89.66.4/24

IP 由 Docker 按启动顺序分配(.2 是网桥网关之外的第一个可用地址,被先启动的 server 拿走),不要把这个顺序当契约:重建后 worker-01 与 worker-02 的地址可能互换。任何配置都只能用服务名互相访问。

五、多副本 worker 与注册

5.1 两个副本的正确做法

本文用两个独立服务(handbrake-worker-01 / -02),各自 WORKER_ID 不同。这样:

  • 容器名、卷、日志目录都互不干扰;
  • 队列按 WORKER_ID 记录任务归属,日志文件名是 worker-01-job-N.log、worker-02-job-N.log;
  • 扩容时复制整段服务定义、改 WORKER_ID 与卷路径即可。

官方也支持跨机部署:把 worker 服务单独放到另一台机器,SERVER_URL 改成 server 的可达地址(带 TLS 时写 https://host),SERVER_PORT 写对外端口。

5.2 同名 WORKER_ID 不会被拒绝

这是实测中最容易踩的坑。额外起一个容器,故意复用 worker-01:

docker run -d --name hb-dup-worker \
  --network handbrake-web-081-stack_handbrake-net \
  -e WORKER_ID=worker-01 -e SERVER_URL=handbrake-server -e SERVER_PORT=9999 \
  -v "$PWD/video:/video" -u 1000:1000 \
  ghcr.io/thenickoftime/handbrake-web-worker:0.8.1

server 侧日志:

14:09:23 [server] [socket] Worker 'worker-01' has connected with ID 'hXHQRnhiOi-N1a0lAAAD'.
14:11:47 [server] [socket] Worker 'worker-01' has connected with ID 'gFudyavggkQdvWp_AAAH'.
14:11:47 [server] [socket] Getting worker 'worker-01' properties...
14:11:47 [server] [socket] Checking worker 'worker-01' for an existing job in progress...
14:11:47 [server] [queue] Worker with ID 'worker-01' is available, checking for available jobs...

同一个 WORKER_ID 出现了两个不同的 socket id,server 没有拒绝,也没有断开旧连接。后果:

  1. 两张连接的 get-properties 都会被执行,后到的会覆盖 properties;
  2. 队列按 worker_id 找连接时可能命中任意一张,任务被派给"另一个同名的 worker";
  3. 任一副本断开时,RemoveWorker 只摘掉自己那张 socket,但队列里该 worker_id 正在执行的任务会被标成 Unknown,造成假故障。

规避方式只有一条:WORKER_ID 由部署侧保证唯一。想省事可以用宿主机名或 docker inspect 拿容器名做后缀,不要用固定字符串。

5.3 注册失败的典型现场

把 SERVER_URL 故意拼错(handbrake-servre),worker 不会退出,而是一直重连:

timeout 12 docker run --rm --network handbrake-web-081-stack_handbrake-net \
  -e WORKER_ID=worker-03 -e SERVER_URL=handbrake-servre -e SERVER_PORT=9999 \
  -v "$PWD/video:/video" -u 1000:1000 \
  ghcr.io/thenickoftime/handbrake-web-worker:0.8.1
14:12:09 [worker] [permissions] Finished checking permissions across 2 paths with 0 errors and 0 warnings.
14:12:09 [worker-03] The worker process has started.
14:12:17 [worker-03] [socket] Connection to server lost, will attempt reconnection...
14:12:21 [worker-03] [shutdown] The process has been terminated, HandBrake Web will now begin to shutdown...

日志里没有 DNS 错误的原因。worker 的 connect_error 分支只在"曾经连上过、现在掉了"时打印 Connection to server lost;首次连接就失败也走同一句 info 级日志,真正的错误对象被丢弃。所以看到这句话时,排查顺序是:

  1. docker compose ps 确认 server 在跑;
  2. 从 worker 容器内解析服务名:docker run --rm --network <net> busybox nslookup handbrake-server;
  3. 确认 SERVER_PORT 写的是容器端口 9999 而不是宿主映射端口 8128;
  4. 确认两个服务在同一个网络里(docker inspect -f '{{json .NetworkSettings.Networks}}' <容器>)。

SERVER_URL 与 SERVER_PORT 只要有一个缺失,worker 直接放弃连接并打印:

The SERVER_URL or SERVER_PORT environment variables are not set, no valid server to connect to.

5.4 不要用 --scale 复制 worker

docker compose up --scale handbrake-worker=3 这类做法对 worker 不可行:

  • container_name 与固定卷路径会让副本直接冲突;
  • 即使去掉 container_name,所有副本共享同一份 environment,WORKER_ID 必然相同,落进 5.2 节的陷阱;
  • 任务日志会写进同一个 /tmp/handbrake-web/log。

需要多副本时,请显式复制服务定义并给每个副本独立的 WORKER_ID 与数据卷。

六、卷映射与权限

6.1 server 的 /data 里有什么

启动后立刻落地:

ls -ln data data/log
data:
-rw-r--r-- 1 1000 1000    225 Oct 27 22:09 config.yaml
-rw-r--r-- 1 1000 1000   4096 Oct 27 22:09 handbrake.db
-rw-r--r-- 1 1000 1000  32768 Oct 27 22:11 handbrake.db-shm
-rw-r--r-- 1 1000 1000 201912 Oct 27 22:11 handbrake.db-wal
drwxr-xr-x 2 1000 1000   4096 Oct 27 22:11 log
drwxr-xr-x 2 1000 1000   4096 Oct 27 22:09 presets

data/log:
-rw-r--r-- 1 1000 1000  6213 Oct 27 22:12 server-2026-10-27.log
-rw-r--r-- 1 1000 1000 11715 Oct 27 22:11 worker-02-job-1.log
路径内容是否必须持久化
config.yaml配置项(见 6.3 节)必须,删了会重建默认值
handbrake.dbSQLite 主库必须
handbrake.db-wal / -shmWAL 与共享内存备份时一起带上
log/server-YYYY-MM-DD.log服务端日志,保留 5 天建议
log/<worker-id>-job-N.logworker 回传的任务日志建议,排障主要靠它
presets/用户自定义预设必须

6.2 worker 的 /tmp/handbrake-web

find worker-data -type f -printf '%s %p\n'
11715 worker-data/worker-02/log/worker-02-job-1.log

worker 的本地目录只有日志,而且同一份日志会在任务结束时通过 send-log 事件回传到 server 的 /data/log,所以这个卷是可选的。不挂载时日志落在容器 /tmp,容器重建即消失,但 server 侧仍有副本。

6.3 首次启动生成的 config.yaml

cat data/config.yaml
config:
  version: 2
paths:
  media-path: /video
  input-path: /video
  output-path: ""
presets:
  show-default-presets: true
  allow-preset-creator: false
application:
  queue-startup-behavior: 0
  update-check-interval: 12

字段含义与改法在《HandBrake Web 0.8.1 配置参数详解》里逐项展开,这里只需知道:它是运行时读的,容器重启才生效,改完要 docker compose restart handbrake-server。

6.4 权限踩坑

镜像默认用户是 65532(distroless 的 nonroot)。compose 里写 user: "1000:1000" 后,容器里的进程就是 1000,那么:

  • 宿主目录属主不是 1000 时,SQLite 建库会失败(SqliteError: unable to open database file);
  • /video/output 不可写时,转码到最后一步重命名会失败,临时文件 .transcoding.<ext> 留在原地;
  • 某些宿主的媒体目录是 NFS/SMB,root_squash 会让 1000 也写不进去。

排查命令:

docker compose exec -u 1000:1000 handbrake-server /nodejs/bin/node -e \
  "const fs=require('fs');for(const p of ['/data','/video']){try{fs.accessSync(p,fs.constants.W_OK);console.log(p,'OK')}catch(e){console.log(p,e.code)}}"

实测输出:

/data OK
/video OK

(这条命令绕开了"镜像没有 shell"的限制,见 8.1 节。)

七、硬件加速直通

7.1 Intel QSV

宿主先确认设备与组号:

ls /dev/dri
getent group video
by-path  card0  renderD128
video:x:44:

worker 服务追加:

  handbrake-worker-01:
    # ...
    devices:
      - /dev/dri:/dev/dri
    group_add:
      - "44"

group_add 必须写数字 GID,不能写组名。实测写 --group-add video 直接失败:

docker: Error response from daemon: unable to find group video: no matching entries in group file

原因是 distroless 镜像里没有 /etc/group 条目,Docker 无法在容器内解析组名。

7.2 NVIDIA NVENC

宿主要装 NVIDIA 驱动与容器工具包,compose 侧给 worker 声明 device reservation:

  handbrake-worker-01:
    # ...
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu, compute, video]

capabilities 里的 video 不能省,NVENC 走的是 video 能力而不是 compute。

7.3 直通成功 ≠ QSV 可用

本文实测宿主确实有 /dev/dri/card0 与 renderD128,把设备与 44 组都传进 worker 后,worker 上报的能力仍然是 false:

14:13:15 [worker-gpu] [properties] HandBrake Version = 1.10.2
14:13:15 [worker-gpu] [properties] Capability 'cpu' support = true
14:13:15 [worker-gpu] [properties] Capability 'qsv' support = false
14:13:15 [worker-gpu] [properties] Capability 'nvenc' support = false
14:13:15 [worker-gpu] [properties] Capability 'vcn' support = false

容器内直接跑 CLI 能看到根因:

Cannot load libnvidia-encode.so.1
[14:13:58] vcn: not available on this system
[14:13:58] qsv: not available on this system

判断链路是否打通的顺序:

  1. ls /dev/dri 有 renderD128;
  2. 容器里 ls -l /dev/dri 能看到同样两个设备(说明 devices: 生效);
  3. 宿主驱动装的是 non-free 的 Intel Media Driver(free 版只解码不编码);
  4. 宿主 vainfo 能看到 VAProfileH264... 等编码 profile;
  5. 以上都对仍 false,多半是核显本身不支持 QSV 编码,或运行在虚拟化环境里没有把 GPU 完整暴露给 guest。

此外,HandBrake 官方对 QSV 有一个已知限制:部分核显需要 lowpower=0 才能稳定编码,否则表现为无明确报错的失败,可在预设的 Advanced Options 里加上。

实用建议:CPU 编码仍是默认推荐。同一预设下硬件编码普遍更快,但同码率画质更差、文件更大。先用 CPU 跑通整条链路,再按需开硬件编码。

八、运维

8.1 镜像没有 shell

server 与 worker 都基于 distroless,没有 /bin/sh:

docker compose exec handbrake-server /bin/sh -c 'ls /data'
OCI runtime exec failed: exec failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directory

可用替代:

需求命令
看 Node 版本docker compose exec handbrake-server /nodejs/bin/node -v(实测 v24.11.1)
执行单行脚本docker compose exec handbrake-server /nodejs/bin/node -e "..."
看文件直接在宿主看 bind mount 的 data/、video/
看进程docker top handbrake-web-0.8.1-server

因为拿不到交互式 shell,健康检查也别写 curl(镜像里没有):要么依赖 docker compose ps 的 Up,要么用 Node 发请求:

docker compose exec handbrake-server /nodejs/bin/node -e \
  "require('http').get('http://127.0.0.1:9999/',r=>{console.log(r.statusCode);process.exit(0)}).on('error',e=>{console.error(e.message);process.exit(1)})"

8.2 日志

位置内容轮转
docker logs / compose logsstdout,彩色格式,进程重启即重来Docker 默认 json-file
data/log/server-YYYY-MM-DD.log完整服务端日志按天,保留 5 份
data/log/<worker>-job-N.log单任务转码日志随任务增长

docker compose logs 只保留当前容器的输出,重建后历史就没了;长期排障看 /data/log。如果宿主日志暴涨,给 compose 加日志上限:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

8.3 备份

SQLite 处于 WAL 模式,只复制 handbrake.db 会丢最近的事务。正确做法有两种:

# 方法一:停机备份(最稳)
docker compose stop handbrake-server
tar czf data-$(date +%F).tar.gz data/
docker compose start handbrake-server

# 方法二:热备份,先做 checkpoint
docker compose exec handbrake-server /nodejs/bin/node -e \
  "const db=require('better-sqlite3')('/data/handbrake.db');db.pragma('wal_checkpoint(full)');console.log('checkpointed');db.close()"
cp data/handbrake.db /backup/handbrake-$(date +%F).db

wal_checkpoint(full) 之后主库文件才是完整状态;不确定时把 -wal、-shm 一并带走。

8.4 升级与回滚

docker compose pull
docker compose up -d
docker compose ps
  • up -d 会重建镜像有变化的容器;/data 与 /video 是 bind mount,不受影响;
  • 升级后先看 docker compose logs handbrake-server | grep -E 'database|migration' 确认迁移完成;
  • 回滚就是把 image: 的 tag 改回旧版本再 up -d。SQLite 迁移不保证向下兼容,回滚前先备份 /data。

8.5 停止与清理

docker compose down

实测输出:

 Container handbrake-web-0.8.1-worker-02 Stopped
 Container handbrake-web-0.8.1-worker-02 Removing
 Container handbrake-web-0.8.1-worker-01 Stopped
 Container handbrake-web-0.8.1-worker-01 Removing
 Container handbrake-web-0.8.1-server Stopping
 Container handbrake-web-0.8.1-server Stopped
 Container handbrake-web-0.8.1-server Removing
 Network handbrake-web-081-stack_handbrake-net Removing
 Network handbrake-web-081-stack_handbrake-net Removed

down 不删镜像,也不删 bind mount 的数据目录。要连镜像一起清:

docker compose down --rmi local

九、端到端转码实测

9.1 准备素材

把任意一个测试视频放进 video/input/sample.mp4,确保属主是 1000。本文使用的样本约 5.5 MB。

9.2 用 Socket.IO 客户端投递任务

页面之外最省事的验证方式是用脚本直接连 /client(协议细节见本专栏《HandBrake Web 0.8.1 Socket.IO 事件协议与内部接口详解》)。把下面内容存成 b2-queue.js:

const { io } = require('socket.io-client');
const s = io('http://127.0.0.1:8128/client');
const input = '/video/input/sample.mp4';
const output = '/video/output/sample-out.mkv';
let started = false;

s.on('connect', () => console.log('client connected', s.id));
s.on('properties-update', (p) => console.log('properties-update:', JSON.stringify(p)));
s.on('queue-update', (q) => {
  console.log('queue-update:', JSON.stringify(q.map(j => ({
    id: j.job_id, stage: j.transcode_stage,
    pct: j.transcode_percentage, worker: j.worker_id
  }))));
  if (q.length && !started) { started = true; s.emit('start-queue'); console.log('start-queue sent'); }
  if (q.length && q[0].transcode_stage === 3) { console.log('DONE stage=Finished'); process.exit(0); }
  if (q.length && q[0].transcode_stage === 5) { console.log('ERROR stage=Error'); process.exit(2); }
});

setTimeout(() => {
  const job = {
    input_path: input, output_path: output,
    preset_category: 'Default: General', preset_id: 'Very Fast 720p30'
  };
  console.log('add-job', JSON.stringify(job));
  s.emit('add-job', job, () => console.log('add-job ack'));
}, 1500);

setTimeout(() => { console.log('TIMEOUT'); process.exit(1); }, 180000);

运行(socket.io-client 可以从项目的前端依赖里借用,也可以 npm i socket.io-client 装一份):

NODE_PATH=/path/to/socket.io-client/node_modules node b2-queue.js

9.3 实测输出

client connected nTPSIjR_tCtvfaxxAAAF
queue-update: []
properties-update: {"worker-02":{"version":{"handbrake":"1.10.2","application":"0.8.1"},"capabilities":{"cpu":true,"qsv":false,"nvenc":false,"vcn":false}},"worker-01":{"version":{"handbrake":"1.10.2","application":"0.8.1"},"capabilities":{"cpu":true,"qsv":false,"nvenc":false,"vcn":false}}}
add-job {"input_path":"/video/input/sample.mp4","output_path":"/video/output/sample-out.mkv","preset_category":"Default: General","preset_id":"Very Fast 720p30"}
queue-update: [{"id":1,"stage":0,"pct":0,"worker":null}]
start-queue sent
add-job ack
queue-update: [{"id":1,"stage":1,"pct":0,"worker":null}]
queue-update: [{"id":1,"stage":2,"pct":0.12916666269302368,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.24444444477558136,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.36597222089767456,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.5006944537162781,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.636805534362793,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.7673611044883728,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.9048610925674438,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":2,"pct":0.9993055462837219,"worker":"worker-02"}]
queue-update: [{"id":1,"stage":3,"pct":1,"worker":null}]
DONE stage=Finished

关键点:

  • stage 是整数枚举:0=Waiting、1=Scanning、2=Transcoding、3=Finished、5=Error;
  • add-job 时 worker 为 null,扫描开始后仍为 null,进入 Transcoding 才出现 worker-02;
  • 任务被分给了 worker-02——两个 worker 都空闲时,队列按内部顺序取第一个可用者,不保证轮询;
  • 结束时 worker 又变回 null,因为 server 在 Finished 时清空了任务归属。

9.4 server 侧调度日志

14:11:33 [server] [socket] Client 'nTPSIjR_tCtvfaxxAAAF' has connected.
14:11:35 [server] [socket] Client 'nTPSIjR_tCtvfaxxAAAF' has requested to add a job for '/video/input/sample.mp4' to the queue.
14:11:35 [server] [database] Inserted a new job with id '1' into the database at order index '1'.
14:11:35 [server] [queue] There are 1 available jobs and 1 eligible for 'worker-02'.
14:11:35 [server] [queue] Assigning worker with ID 'worker-02' to job with id '1'.
14:11:35 [server] [queue] There are 0 available jobs and 0 eligible for 'worker-01'.
14:11:35 [server] [database] Sucessfully updated the status of 'queue' to 'Active'.
14:11:35 [server] [database] Status for job with id '1' has changed from 'Waiting' to 'Scanning'.
14:11:35 [server] [queue] Worker 'worker-02' has started work on job '1'.
14:11:36 [server] [database] Status for job with id '1' has changed from 'Scanning' to 'Transcoding'.
14:11:38 [server] [database] Status for job with id '1' has changed from 'Transcoding' to 'Finished'.
14:11:38 [server] [database] Removing job with id '1' from the 'jobs_order' table.
14:11:38 [server] [socket] Worker 'worker-02' has sent the log 'worker-02-job-1.log' to be saved to '/data/log'.
14:11:38 [server] [log] Log file from worker 'worker-02' has been written to '/data/log/worker-02-job-1.log'.

9.5 产物与任务日志

ls -ln video/output
-rw-r--r-- 1 1000 1000 4752926 Oct 27 22:11 sample-out.mkv

任务日志开头与结尾:

14:11:35 [worker-02] [transcode] [error] 
Compile-time hardening features are enabled

14:11:35 [worker-02] [transcode] [error] 
Cannot load libnvidia-encode.so.1
vcn: not available on this system
...
14:11:38 [worker-02] [transcode] [info] Renaming the file 'sample-out.transcoding.mkv' to 'sample-out.mkv'.

[error] 是误标。worker 把 HandBrake 写到 stderr 的所有内容都按 error 级记录,而 Cannot load libnvidia-encode.so.1 只是硬件探测提示。判断任务是否真的失败,看队列的状态字段,不要看日志级别。

转码时先写 .transcoding.<ext> 临时文件,成功后才重命名,所以看到 sample-out.transcoding.mkv 说明任务还在进行或已失败未清理。

十、故障排查

现象原因处理
manifest unknown(拉取 v0.8.1)标签不带 v改用 0.8.1
bind: address already in use8128 被占ss -lntp | grep :8128 换空闲端口
network ... overlaps with existing子网冲突换 10.89.66–75 内未占用的段
容器 Up 但页面 404自定义镜像没设 NODE_ENV=production补 NODE_ENV=production 或让反代托管前端
unable to open database file/data 属主非 1000chown -R 1000:1000
unable to find group videogroup_add 写了组名改成数字 GID(getent group video 查询)
worker 一直重连,server 无连接日志SERVER_URL/SERVER_PORT 错见 5.3 节排查顺序
队列有任务但不动没有可匹配能力的 worker看 server 日志 eligible for '<id>'
转码后文件不存在,只剩 .transcoding.*输出目录不可写或重命名失败检查 /video/output 权限
exec: "/bin/sh": stat /bin/shdistroless 无 shell用 /nodejs/bin/node -e

两个值得单独记的报错:

OCI runtime exec failed: exec failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directory
docker: Error response from daemon: unable to find group video: no matching entries in group file

十一、小结

  • Compose 部署 0.8.1 只需要两个镜像、三个容器、一份 compose.yaml;容器内端口固定 9999,宿主端口自选。
  • 项目名里的点号会被去掉(handbrake-web-0.8.1-stack → handbrake-web-081-stack),网络与卷名跟着变;container_name 不变。
  • 同名 WORKER_ID 不会被拒绝,必须由部署侧保证唯一。
  • worker 镜像内置 HandBrake 1.10.2,与发行版的 1.11.0 不同;升级镜像是升级 CLI 的唯一途径。
  • 直通 /dev/dri 与 group_add: 44 只是必要条件,QSV 是否可用还要看核显与驱动,实测直通后仍为 false 是常见结果。
  • 镜像没有 shell,排障走 /nodejs/bin/node -e 与宿主侧的 bind mount。

延伸阅读:裸机安装与本篇互补,见 《Ubuntu 22.04 安装 HandBrake Web 0.8.1 指南》;整体架构与选型见 《HandBrake Web 0.8.1 介绍与选型指南》。