HandBrake Web 0.8.1 Docker Compose 部署指南
Docker Compose 部署 HandBrake Web 0.8.1:项目名归一化实测、8128 端口与 10.89.66.0/24 网段规划、server 加双 worker 与卷映射、硬件直通与端到端转码回显。
HandBrake Web 0.8.1 Docker Compose 部署指南
目标:用一份
compose.yaml在单机上拉起 HandBrake Web 0.8.1 的 server 与两个 worker 容器,端口、网络、卷、硬件直通全部落在可控范围内,并给出端到端转码的真实回显。文中的命令与输出来自 2026-10-27 的实机验证,镜像版本、项目名归一化、worker 注册等"不按预期"的现象都保留了原始报错。
环境信息
| 项目 | 取值 | 说明 |
|---|---|---|
| Docker Engine | 29.9.2 | docker version 客户端与服务端一致 |
| Docker Compose | v5.6.1 | 独立插件版本,docker compose version |
| server 镜像 | ghcr.io/thenickoftime/handbrake-web-server:0.8.1 | 224 MB,distroless node24,EXPOSE 9999 |
| worker 镜像 | ghcr.io/thenickoftime/handbrake-web-worker:0.8.1 | 398 MB,distroless node24,内置 HandBrakeCLI |
| worker 内置 HandBrakeCLI | 1.10.2 | 不是 1.11.0,见 2.3 节 |
| 镜像内 Node.js | v24.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 服务端、队列、SQLite | 8128:9999 | /data、/video | 1 核 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
镜像里预置了这些环境变量,部署时不必重复声明,除非要覆盖:
| 镜像 | 变量 | 值 |
|---|---|---|
| server | NODE_ENV | production |
| server | DATA_PATH | /data |
| server | VIDEO_PATH | /video |
| worker | DATA_PATH | /tmp/handbrake-web |
| worker | VIDEO_PATH | /video |
| 两者 | HANDBRAKE_MODE | server / 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
两点结论:
- 镜像版比发行版旧。Ubuntu 软件源现在是 1.11.0,镜像是 1.10.2。预设里的编码器名、参数在两者间大体兼容,但部分新预设(例如依赖新编码器默认值的)在容器里可能回落到 CPU 编码或直接报预设错误。
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_ID | worker 的唯一名字 | 同名不会被拒绝,见 5.2 节 |
SERVER_URL | worker 找 server 的主机名 | 同一网络内用服务名 handbrake-server 即可,DNS 由 Docker 提供 |
SERVER_PORT | 拼接目标地址的端口 | 必须是容器内端口 9999,不是宿主 8128 |
networks.ipam.config.subnet | 固定网段 | 避免与宿主上其它 10.89.x 网桥重叠 |
worker 的 /tmp/handbrake-web | worker 的 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 的项目名只允许小写字母、数字、连字符和下划线,点号被直接去掉。由此带来三个连带效果:
- 网络名变成
handbrake-web-081-stack_handbrake-net,不是handbrake-web-0.8.1-stack_handbrake-net; - 卷名、默认网络名同样按归一化后的项目名生成;
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 没有拒绝,也没有断开旧连接。后果:
- 两张连接的
get-properties都会被执行,后到的会覆盖 properties; - 队列按
worker_id找连接时可能命中任意一张,任务被派给"另一个同名的 worker"; - 任一副本断开时,
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 级日志,真正的错误对象被丢弃。所以看到这句话时,排查顺序是:
docker compose ps确认 server 在跑;- 从 worker 容器内解析服务名:
docker run --rm --network <net> busybox nslookup handbrake-server; - 确认
SERVER_PORT写的是容器端口 9999 而不是宿主映射端口 8128; - 确认两个服务在同一个网络里(
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.db | SQLite 主库 | 必须 |
handbrake.db-wal / -shm | WAL 与共享内存 | 备份时一起带上 |
log/server-YYYY-MM-DD.log | 服务端日志,保留 5 天 | 建议 |
log/<worker-id>-job-N.log | worker 回传的任务日志 | 建议,排障主要靠它 |
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
判断链路是否打通的顺序:
ls /dev/dri有renderD128;- 容器里
ls -l /dev/dri能看到同样两个设备(说明devices:生效); - 宿主驱动装的是 non-free 的 Intel Media Driver(free 版只解码不编码);
- 宿主
vainfo能看到VAProfileH264...等编码 profile; - 以上都对仍 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 logs | stdout,彩色格式,进程重启即重来 | 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 use | 8128 被占 | 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 属主非 1000 | chown -R 1000:1000 |
unable to find group video | group_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/sh | distroless 无 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 介绍与选型指南》。