post: publish the QSV calibration + the OOM-killer pair (EN+ZH) + og/banner
Deploy / build (push) Successful in 15s

- calibrating-intel-quick-sync-hevc-quality (tutorials): pubDate 2026-10-14, updatedDate 2026-10-16
  (its real work date; sitemap lastmod stays honest). ffmpeg 9.0.2 pins it to 2026-09-18 at the earliest,
  so it cannot be backdated — see project-state B2l.
- docker-reported-exit-0-the-oom-killer-disagreed (devops): pubDate 2026-10-16, date-pinned by its own
  dmesg table, ships on the real date.
- og measured rows=1/overflow=0, banners delta=2/overflow=0 on both; build 159 pages, no warnings.
This commit is contained in:
hoelee committed 2026-10-16 13:22:13 +08:00
1 parent 609fc75609
commit 7b001dc630
8 files changed
+663

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -0,0 +1,220 @@
---
title: "Calibrating Intel Quick Sync HEVC: A Cliff at 28, and a Preset That Does Nothing"
description: "I measured QSV HEVC on real 1080p files: ICQ 18–22 inflate thin sources, the quality cliff sits between 28 and 30, and fast equals medium byte for byte."
pubDate: 2026-10-14
updatedDate: 2026-10-16
category: tutorials
tags: ["ffmpeg", "hevc", "qsv", "transcoding", "video"]
ogImage: /og/calibrating-intel-quick-sync-hevc-quality.png
banner: /banners/calibrating-intel-quick-sync-hevc-quality.png
draft: false
---
## The question I could not answer: which number do I ship?
My transcoding queue decides, per file, which encoder does the work: `copy`
when the source is already efficient, NVENC on the GPU, x265 on the CPU —
and now Intel Quick Sync. Each tier has two knobs that decide quality, and
every tier except QSV had numbers I had measured on my own library.
QSV had none. And the code refused to guess: a shipping default of
`qsv_quality = 0` / `qsv_preset = ""` means *nobody has calibrated this*,
and the engine treats that as a hard exclusion — QSV can exist in the code
and never be selected on any machine, including the one that has the
hardware.
So the task was not "turn on QSV". It was "produce measured numbers for two
knobs, so the encoder becomes selectable honestly".
## Problem one: the hardware is not in the machine where the app runs
The queue runs on Windows 11 in a VM. That VM has an RTX 3060 passed through
and no Intel graphics of any kind — which is exactly why QSV had never been
measured: the obvious move is to run one test locally and read the numbers
off.
The iGPU is on the host instead (unRaid, i9-13900K), idle and unclaimed:
```text
00:02.0 VGA compatible controller: Intel Raptor Lake-S GT1 [UHD Graphics 770]
driver: i915 refcount: 0
/dev/dri/card0 /dev/dri/renderD128 (present, world-accessible)
no running container declares a dri device
virsh dumpxml Win11: hostdevs = 01:00.0, 01:00.1 (the 3060 + its HDMI audio)
```
Two conclusions, both useful. The VM's `Error creating a MFX session` is
**expected** and not a bug to chase — the VM can never see that iGPU. And
the only way to measure QSV is on the Linux side, in a container, on the
host.
## Measure with the engine's own gate, not with ffmpeg
This is the part that makes the numbers worth anything. I did not write
ffmpeg commands and compute SSIM myself — the calibration runs **the engine
that will do the work**, with its own verification and its own accept/reject
gate:
```bash
python3 library_run.py --work-dir /work --tier qsv --force-tier qsv \
--qsv-quality Q --qsv-preset P --path <real source> --keep-local --overwrite
```
`--keep-local` means encode, verify, run the gate, and then *do not* land
the file — the verdict is what I am collecting, not a new library. Sources
came in read-only over an existing SMB mount, and I hashed each source
before and after: unchanged.
The image is `linuxserver/ffmpeg` (ffmpeg 9.0.2, iHD 26.3.5, oneVPL 2.17)
plus `python3` — plus **numpy**, which is not optional. Without it the gate
prints `flat-frame scan skipped: No module named 'numpy'` and quietly
changes how near-flat frames are judged, which changes verdicts.
`-global_quality` maps to `RateControlMethod: ICQ` on `hevc_qsv`, which is
the knob being calibrated.
## The ladder: on a thin source, low ICQ makes the file *bigger*
First run, on a real 1080p25 source at 951 kbps (0.0184 bits per pixel — a
"thin" file, the kind that is already squeezed):
| ICQ | engine verdict | mean SSIM | video = source |
|---|---|---|---|
| 18 | **FAIL size** — 176.8% | — | — |
| 20 | **FAIL size** — 141.8% | — | — |
| 22 | **FAIL size** — 107.6% | — | — |
| 24 | ok | 0.9967 | 81.1% |
| 26 | ok | 0.9959 | 61.0% |
| 28 | ok | 0.9951 | 46.4% |
| 30 | **FAIL quality** | 0.9942 | — |
| 32 | **FAIL quality** | 0.9942 | — |
Three things fall out of that table.
The counterintuitive one: **low ICQ does not mean safe.** ICQ 18–22 produce
files *larger* than the source, so the engine's size ceiling (100% of the
source's video bitrate) rejects them. QSV is not alone in this — NVENC at a
low `cq` does the same thing on thin inputs — but the boundary sits higher
here: the usable range starts at 24, not 18.
Second, **there is a cliff between 28 and 30.** 30 and 32 fail on quality,
and they fail on the *first attempt*. That phrase matters, because the
engine retries a failed quality attempt at `global_quality - 2` — lower
ICQ, higher quality — and logs the retry. A line reading "ok at quality+2"
is a **first-attempt failure**, not a pass. I read that line backwards in my
own notes the first time; the rule is to read the first attempt, not the
recovery.
Third, the floor is where the choice gets made: with a **0.995 mean-SSIM
floor**, q28 passes with 0.0001 of margin and q26 with 0.0010.
## Full files confirm the ladder — and one shortcut survives
Excerpts (180-second lossless slices) are how you rank ten candidates
without ten full encodes. They are only legitimate if they agree with
full-file runs, so I ran the real files end to end:
| source | ICQ | preset | video = source | file | SSIM min / mean | encode |
|---|---|---|---|---|---|---|
| 254 MB, 2023 s, 872 kbps | 26 | medium | **61.0%** | 168,435,266 B (66%) | 0.9938 / **0.9960** | 360 s ≈ 5.6× realtime |
| the same file | 28 | medium | 46.3% | 54% | 0.9927 / 0.9952 | 330 s |
| 315 MB, 3280 s, 546 kbps | 26 | medium | 50.4% | 60% | 0.9965 / **0.9977** | 600 s ≈ 5.5× realtime |
The 180-second excerpt of the first file measured mean 0.9959; the full file
measured 0.9960. That is close enough to trust the shortcut **for quality
ranking** — and I now have the evidence to say so rather than hope so.
## The preset that does nothing
The excerpt runs said preset only changes speed: veryfast 30 s / 61.6%, fast
30 s / 61.0%, medium 60 s / 61.0%, slow and veryslow 60 s. Clean story: pick
veryfast, save 30 seconds per file.
Then the full files disagreed with it. The same source at ICQ 26, one run
with `fast` and one with `medium`:
```text
fast: 168,435,266 B enc 360 s mean SSIM 0.9960
medium: 168,435,266 B enc 360 s mean SSIM 0.9960
```
Byte-for-byte identical, same encode time, same quality. The 30-vs-60-second
difference in the excerpt layer was per-run fixed overhead, not encoding
speed — and it is the kind of number that would have gone into a config file
and a blog post if I had stopped one step early.
So: **excerpts rank quality, never speed.** Speed has to be measured on full
files, where the fixed overhead disappears.
## What I shipped
`qsv_quality = 26`, `qsv_preset = medium` — 61.0% of the source's video
bitrate at mean SSIM 0.9960 (min 0.9938) at roughly 5.6× realtime, and 50.4%
at mean 0.9977 on the thinnest file in the library. q28's margin over the
floor is 0.0002, and the cost of a quality failure is two full encodes plus
a park, so the extra 15 percentage points of savings are not worth the tail
risk.
Two supporting measurements worth recording, because they answer "is it QSV
or is it the file?":
- **Cross-encoder control.** One 720p source with non-uniform cadence scored
QSV q24 mean 0.9154, QSV q28 mean 0.9146, and x265 crf26 mean 0.9164 — and
the engine's alignment search reported
`no alignment explains the mismatch`. The rejection belongs to the source,
not to Quick Sync.
- **Concurrency.** The engine refuses a second encoder on a shared work
root:
`another transcode is already running (pid 7, /work/run-qsv.lock, 3 s old)`.
With separate work roots, two jobs run — and take 201 s where one takes
107 s. Twice the wall clock, zero throughput gain. One QSV job at a time.
## Four traps, each of which cost a cycle
1. **Never mount a shared library at `/lib`.** I mounted the SMB share into
the container at `/lib` — which is where the container's own dynamic
loader lives. All eight jobs died in under a second with
`exec /bin/bash: no such file or directory`, which reads like a broken
image, not a bad mount. `/library` works.
2. **Synthetic sources lie about ICQ.** `hevc_qsv`'s rate control is
rejected by the driver for `rgb24` input
(`Selected ratecontrol mode is unsupported`) — so a
`testsrc`/`mandelbrot` self-test concludes the opposite of the truth. Use
a real file, or add `format=nv12`.
3. **Already-HEVC sources cannot be calibrated at all.** The gate answers
`SKIP already HEVC hvc1` and `--overwrite` does not lift it. The fat
1080p H.264 originals in this library had all been converted long ago;
the remaining fat files are HEVC — so that tier has nothing left to
measure on. That is a missing input, not a skipped task.
4. **Read the first attempt, not the retry** (see the cliff above).
## What I would do differently
1. **Measure speed on the artifact you will actually run**, not on a slice
of it. My one wrong number in this whole exercise came from trusting an
excerpt's wall clock.
2. **Make the machine prove it has the hardware before you plan the test.**
Six minutes of `lspci` and `virsh dumpxml` retired the "why does QSV fail
in the VM" question that had been open for weeks.
3. **Keep the gate in the loop.** Because the engine's own verification
judged every run, the tables above are decisions, not opinions — the same
gate that will reject a bad file in production rejected my ICQ 30.
4. **Write the honest scope line into the notes.** These numbers are Linux
iHD 26.3.5 + oneVPL 2.17 on a UHD 770; the production target is the
Windows MSDK path on the same silicon, and I cannot claim they agree.
What this establishes is that the command runs on real hardware, where
the quality↔size curve sits, and how the lock behaves — leaving a
re-measurement on Windows+Intel as one table, instead of another
afternoon.
## The result
QSV went from "present in the code and never selectable" to a calibrated
tier with published numbers, and the safety rule changed with it: the test
that used to pin *shipping defaults must mean uncalibrated* now pins
*shipping defaults must be measured values*, while keeping every assertion
that an empty value is a hard exclusion. The doctor output on a machine with
no Intel GPU moved from an informational "waiting for hardware" line to a
warning — which is its own small lesson about what a calibrated encoder
failing self-check should look like, and why hardware the machine does not
have belongs in a summary row rather than a warning.
@@ -0,0 +1,196 @@
---
title: "Docker Reported Exit 0. The Kernel Had Already OOM-Killed It."
description: "A container on my NAS kept stopping unexpectedly while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills."
pubDate: 2026-10-16
category: devops
tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/docker-reported-exit-0-the-oom-killer-disagreed.png
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.png
draft: false
---
## The notification I expected to be a config mistake
My NAS sent the notification every Docker user learns to skim: **container
stopped unexpectedly**. This one is the edge proxy in front of my
self-hosted wiki, and it had been up for two weeks. Nobody had deployed
anything that morning and nothing in the compose file had been touched, so
the two available stories were "it crashed" and "something restarted it".
Both were wrong, and the fact that I could not tell from Docker's own report
is the part worth writing down.
## Docker's report says nothing happened
```text
State:
Status: running
Running: true
ExitCode: 0 <- a clean exit
OOMKilled: false <- not killed by the kernel
FinishedAt: 2026-10-15T19:39:33.63057879Z
StartedAt: 2026-10-15T19:39:38.769326672Z <- restarted 5 s later
RestartCount: 6
RestartPolicy: always
```
Read that as written and the conclusion is: the process exited cleanly, six
times, and the restart policy gave it a new life each time. A long-running
proxy does not exit with status 0 on its own — and a clean exit that repeats
six times is a contradiction, not an answer. `ExitCode` and `OOMKilled` are
exactly the two fields I would have trusted here. On this host, both were
wrong.
## The kernel keeps its own record
On Linux, a cgroup memory kill is logged by the kernel even when the
container runtime's bookkeeping does not carry it forward:
```text
[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB
```
The uptime stamp `1737797` corresponds to 19:39:33 UTC — to the second, the
container's `FinishedAt`. That is the whole diagnosis, and it took one
`dmesg` to get it. The container did not exit; it was killed inside its own
memory limit and restarted 5 seconds later.
dmesg holds **six** such events, and `RestartCount` was **6**. Every other
container in that stack has a restart count of zero and a start time from
weeks ago, so the six kills pin to this one container:
| dmesg uptime | local time | anon-rss at kill |
|---|---|---|
| 1406539 | Oct 12, ~07:37 | 491 MB |
| 1407759 | Oct 12, ~07:57 | 494 MB |
| 1432574 | Oct 12, ~14:49 | 486 MB |
| 1734793 | Oct 16, ~02:45 | 487 MB |
| 1735495 | Oct 16, ~02:58 | 485 MB |
| 1737797 | Oct 16, 03:39 ← the notification | 478 MB |
Every kill happens at 478–494 MB against a limit of 512 MiB (`536,870,912`
bytes). That is a ceiling being touched, not a runaway.
Two more readings made the mechanism clear. The cgroup's recorded peak — the
counter that survives the restart — was already back at exactly
`536,870,912` **18 minutes** after the restart, i.e. the container touched
its ceiling again in the same cycle *without* being killed. And the usage
curve oscillated (176 → 308 → 361 MB) instead of climbing monotonically, so
this is allocation churn under load hitting a small cap, not a provable slow
leak. The prime suspect is a geoblock plugin doing an external HTTPS lookup
for every client IP it has not seen before.
A seventh kill landed at 05:33 that morning, before the fix went in.
On this DSM build the truth was only in `dmesg` plus the cgroup's
`memory.max_usage_in_bytes`. If you are chasing the same shape on a normal
host, `docker inspect -f '{{.State.OOMKilled}}'` is the field to read first
— it is supposed to be authoritative. Here it read `false` five seconds
after the kernel had logged the kill, which is the reason this post exists.
## What was actually breaking: a crawler flood
Host memory was never the problem — 64 GB installed, ~39 GB available. One
container's 512 MiB ceiling was.
The driver was traffic, and the numbers came out of the container's own
access log. In the last 50,000 lines:
```text
distinct client IPs 25,976
403 responses 33,636 (geoblock / deny-list doing its job)
429 responses 16,082 (inflight and rate limiting doing their job)
container CPU 118–230%
host load average 26–32
```
Each restart refilled the heap in 12 to 40 minutes. Nothing in the
configuration was wrong, and neither were the 403s and 429s — they are the
filter rejecting crawlers exactly as designed. The problem is who is
knocking: this container is the public edge for a multi-terabyte Wikipedia
corpus served from a home NAS, behind a country allowlist and a bot filter.
That is an extremely attractive target for a residential-proxy crawler
cloud, and the responses telling it to go away are still work.
## The fix, and the sentence that has to come with it
```yaml
deploy:
resources:
limits:
- memory: 512M
+ memory: 2g
restart: always
+memswap_limit: 2g
```
Verification, because "I raised the limit and it feels better" is not a
verification:
- **Only that container was recreated.** The wiki, the proxy core and the
robots container still reported two weeks of uptime — the gap users saw
was seconds, not a stack restart.
- `HostConfig.Memory = 2147483648`, `MemorySwap = 2147483648`, and the
cgroup's `memory.limit_in_bytes` agreed with both; `RestartCount` went
back to 0.
- Peak usage after 18 minutes: **210 MB**, against a ceiling it had been
pinning at 512 MB. By midday it sat at 94 MiB.
- The mount list still matched the file, including the plugin directory
mount — losing that one had once disabled every router in this stack.
- Both plugins were loaded and active (country allowlist, bot filter), and
the routes returned 200 once the filter's score window expired.
**And what it is not:** this is a stopgap. The flood is the driver, so an
order-of-magnitude bigger flood reaches 2 GB too. The real fix is shrinking
the public entry surface, not enlarging the cage around it — and a limit
bump that is not labelled as a stopgap becomes a permanent explanation.
I chose to disable swap for this container rather than let it swap:
`memswap_limit` equal to the memory limit does exactly that. The host
already had 16 GB of its 40 GB swap in use with committed memory at 92 GB,
and an edge proxy that starts swapping slows the entire NAS — the disk array
is the bottleneck here, so swapping buys a slower failure rather than a
longer life.
## The trap that nearly made me report a self-inflicted outage
During verification, my first bare `curl` probes against the site returned
**403**, and it looked exactly like a regression I had just caused by
restarting the proxy. It was not: the bot filter had scored **my own client
IP** (`blocked user-agent`, score 80) and placed it in a 15-minute score
window, because I had been hammering the host.
The tell was in the same test run — `/robots.txt` returned 200 and the
internal `/cdn` path returned 400 at the same moment, i.e. the router was
healthy and only my own requests were being filtered. On a host where you
are also a client, never diagnose with your own bare curl: probe a path the
filter ignores, or read a 200 out of the access log.
## What I would do differently
1. **Treat "stopped unexpectedly" plus `ExitCode 0` as an instruction to
read `dmesg`**, not as evidence of a clean exit. A restart count above
zero is a memory investigation until proven otherwise.
2. **Alert on the cgroup peak, not only on restarts.** The post-restart
reading that was already pinned at the ceiling told me a seventh kill was
coming about eighteen minutes before the notification arrived.
3. **Get the "who is hitting me" number early.** 25,976 distinct IPs in one
log sample is the entire story, and I found it after spending time on the
memory curve.
4. **Question the container, not the server.** "The server is out of memory"
and "one container has a 512 MiB ceiling on a 64 GB machine" need
completely different responses, and the second one is invisible unless
you look at the limit.
## The result
No OOM kill since the change, with a peak at 210 MB after 18 minutes against
a ceiling that used to be touched in every single cycle — and the seven
kills that had been silently restarting the public edge for four days
stopped appearing in dmesg. The crawler flood did not stop, which is why I
recorded the failure mode in my ops notes next to the stack: "container
stopped unexpectedly" plus `exit 0` / `OOMKilled=false` is a misleading
signature, and on this platform the honest evidence lives in the kernel log
and the cgroup's high-water mark.
@@ -0,0 +1,127 @@
---
title: "校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset"
description: "我在真实 1080p 文件上量了 QSV HEVC:ICQ 18–22 会把瘦文件撑大,质量悬崖落在 28 与 30 之间,而 fast 与 medium 逐字节同大小。"
pubDate: 2026-10-14
updatedDate: 2026-10-16
category: tutorials
tags: ["ffmpeg", "hevc", "qsv", "transcoding", "video"]
ogImage: /og/calibrating-intel-quick-sync-hevc-quality.png
banner: /banners/calibrating-intel-quick-sync-hevc-quality.png
draft: false
---
## 一个我回答不了的问题:到底该出货哪个数字?
我的转码队列按文件逐个决定用哪个编码器:源文件本来就高效就用 `copy`、GPU 走 NVENC、CPU 走 x265——现在又多了一个 Intel Quick Sync。每一档都有两个决定质量的旋钮,除了 QSV,其他每一档我都有在自己片库上量过的数字。
QSV 一个都没有。而代码拒绝猜:出货默认值是 `qsv_quality = 0` / `qsv_preset = ""`,含义是「没人标定过这一档」,引擎把它当成硬排除——于是 QSV 可以存在于代码里,却在任何机器上都不会被选中,包括真的有这块显卡的那台。
所以任务不是「把 QSV 打开」,而是「为两个旋钮产出量过的数字,让这个编码器能被诚实地选中」。
## 问题一:硬件不在跑这个程序的机器里
队列跑在 Windows 11 虚拟机里。那台 VM 直通了一张 RTX 3060,没有任何 Intel 核显——这正是一直没量过 QSV 的原因:最顺手的主意就是在本地跑一次、把数字读出来。
核显在宿主上(unRaid,i9-13900K),而且空闲、无人占用:
```text
00:02.0 VGA compatible controller: Intel Raptor Lake-S GT1 [UHD Graphics 770]
driver: i915 refcount: 0
/dev/dri/card0 /dev/dri/renderD128 (存在,全局可读)
没有任何运行中的容器声明 dri 设备
virsh dumpxml Win11: hostdev = 01:00.0, 01:00.1(3060 + 它的 HDMI 音频)
```
两个结论都很有用。VM 里那句 `Error creating a MFX session` 是**预期行为**,不是一个要去查的 bug——那台 VM 永远看不到这块核显。而量 QSV 的唯一办法,是在宿主上、在容器里、用 Linux 侧去量。
## 用量尺的那个人自己的闸门来量,而不是用 ffmpeg
这是让上面这些数字真正有价值的部分。我没有自己写 ffmpeg 命令、自己算 SSIM——整场标定跑的是**将来真正干活的那个引擎本体**,带着它自己的校验和自己的判收闸门:
```bash
python3 library_run.py --work-dir /work --tier qsv --force-tier qsv \
--qsv-quality Q --qsv-preset P --path <真原件> --keep-local --overwrite
```
`--keep-local` 的意思是:编码、校验、过闸门,但**不落盘**——我收集的是判决,不是一批新文件。素材通过已有的 SMB 挂载只读进入,而且每个源档在编码前后都做了哈希核对:未变。
镜像是 `linuxserver/ffmpeg`(ffmpeg 9.0.2、iHD 26.3.5、oneVPL 2.17)加 `python3`,再加 **numpy**——后者不是可选项。没有它闸门会打 `flat-frame scan skipped: No module named 'numpy'`,悄悄改变近平坦帧的判定方式,也就改变了判决。
`-global_quality` 在 `hevc_qsv` 上对应 `RateControlMethod: ICQ`,这就是被标定的旋钮。
## 质量阶梯:在瘦源上,低 ICQ 会把文件**变大**
第一轮,源是一支真实的 1080p25、951 kbps(bpp 0.0184——已经很瘦、榨过一遍的那种):
| ICQ | 引擎判决 | mean SSIM | 视频 = 源 |
|---|---|---|---|
| 18 | **FAIL size** — 176.8% | — | — |
| 20 | **FAIL size** — 141.8% | — | — |
| 22 | **FAIL size** — 107.6% | — | — |
| 24 | ok | 0.9967 | 81.1% |
| 26 | ok | 0.9959 | 61.0% |
| 28 | ok | 0.9951 | 46.4% |
| 30 | **FAIL quality** | 0.9942 | — |
| 32 | **FAIL quality** | 0.9942 | — |
这张表掉出三个结论。
第一个反直觉的:**低 ICQ 不等于安全**。ICQ 18–22 产出的文件比源还**大**,于是被引擎的体积闸门(源视频码率的 100%)拒掉。这不是 QSV 独有的毛病——NVENC 对瘦输入用低 `cq` 也是同一个形状——但这里的边界更靠上:可用区间从 24 起,不是 18。
第二个,**28 与 30 之间有一道悬崖**。30 和 32 都质量失败,而且失败在**首次尝试**。这句话很重要,因为引擎在质量失败时会用 `global_quality - 2` 重试一次——ICQ 更低、质量更高——并把重试写进日志。一行写着「ok at quality+2」的日志其实是**首次尝试失败**,不是通过。我第一遍在自己的笔记里把这条读反了;规矩是读首次尝试,不读救济结果。
第三个,地板决定取舍:在 **0.995 的 mean SSIM 下限**下,q28 的余量是 0.0001,q26 的余量是 0.0010。
## 完整文件确认了这张阶梯——而且有一个捷径站得住
摘录(180 秒无损切片)是「不用跑十次完整编码就能排出候选顺序」的办法。它只有在与完整文件结果一致时才成立,所以我把真原件端到端跑了一遍:
| 源 | ICQ | preset | 视频 = 源 | 文件 | SSIM min / mean | 编码 |
|---|---|---|---|---|---|---|
| 254 MB,2023 秒,872 kbps | 26 | medium | **61.0%** | 168,435,266 B(66%) | 0.9938 / **0.9960** | 360 秒 ≈ 5.6× 实时 |
| 同一支 | 28 | medium | 46.3% | 54% | 0.9927 / 0.9952 | 330 秒 |
| 315 MB,3280 秒,546 kbps | 26 | medium | 50.4% | 60% | 0.9965 / **0.9977** | 600 秒 ≈ 5.5× 实时 |
第一支的 180 秒摘录量到 mean 0.9959,完整文件量到 0.9960。这个接近程度足以让人相信「摘录用来排质量档」这条路——而且现在这是有证据的,不是靠希望。
## 那个毫无作用的 preset
摘录那一层给出的故事很干净:preset 只影响速度——veryfast 30 秒 / 61.6%、fast 30 秒 / 61.0%、medium 60 秒 / 61.0%、slow 与 veryslow 60 秒。结论似乎就是选 veryfast,每支文件省 30 秒。
然后完整文件否掉了它。同一支源、同一个 ICQ 26,一次用 `fast`、一次用 `medium`:
```text
fast: 168,435,266 B 编码 360 秒 mean SSIM 0.9960
medium: 168,435,266 B 编码 360 秒 mean SSIM 0.9960
```
逐字节同大小、同编码时间、同质量。摘录层的 30 秒对 60 秒,是每一轮固定开销造成的差别,不是编码速度——这正是那种「如果只走到一步就停」会写进配置文件、再写进一篇博客的数字。
所以:**摘录只能排质量,永远不能排速度。** 速度必须在完整文件上量,固定开销在那里才会消失。
## 最后落了什么
`qsv_quality = 26`、`qsv_preset = medium`——源视频码率的 61.0%,mean SSIM 0.9960(min 0.9938),约 5.6× 实时;在片库里最瘦的一支上是 50.4%、mean 0.9977。q28 相对下限的余量只有 0.0002,而一次质量失败的代价是两次整片编码再加一次搁置,所以那多出来的 15 个百分点不值得换这条尾部风险。
另外两条值得记下来的量测,它们回答的是「到底是 QSV 的问题还是文件的问题」:
- **跨编码器对照。** 一支 720p、节奏不均匀的源,QSV q24 量到 mean 0.9154、QSV q28 量到 0.9146、x265 crf26 量到 0.9164——引擎的对齐搜索同时报 `no alignment explains the mismatch`。这条拒绝属于源档,不属于 Quick Sync。
- **并发。** 引擎拒绝在共享 work root 上起第二个编码器:`another transcode is already running (pid 7, /work/run-qsv.lock, 3 s old)`。换成各自独立的 work root,两个作业能跑——但墙钟 201 秒对单人 107 秒。两倍时间,零吞吐收益。QSV 一次只跑一个作业。
## 四个坑,每个都花掉一轮
1. **永远不要把共享片库挂到容器的 `/lib`。** 我把 SMB 共享挂进了容器的 `/lib`——而那是容器自己的动态加载器目录。八个作业全在 1 秒内死掉,报 `exec /bin/bash: no such file or directory`,读起来像镜像坏了,而不是挂载点错了。挂 `/library` 就正常。
2. **合成源会给出与事实相反的 ICQ 结论。** `hevc_qsv` 的码率控制对 `rgb24` 输入会被驱动拒绝(`Selected ratecontrol mode is unsupported`),所以用 `testsrc`/`mandelbrot` 自检会得出相反的结论。要么用真文件,要么加 `format=nv12`。
3. **已经是 HEVC 的源根本没法标定。** 闸门会回 `SKIP already HEVC hvc1`,`--overwrite` 也不解除。这个片库里肥档 1080p H.264 原件早就转完了,剩下还在肥档的都是 HEVC——所以那一档已经没有可量的素材了。这是缺输入,不是漏做任务。
4. **读首次尝试,不读重试**(见上面那道悬崖)。
## 如果重来一次
1. **速度要在你真正会跑的那个产物上量**,不要在它的一片切片上量。整件事里我唯一报错的数字,就是信了摘录的墙钟。
2. **先让机器证明它有没有这块硬件,再排测试计划。** 六分钟的 `lspci` 加 `virsh dumpxml`,就把「为什么 QSV 在 VM 里失败」这个悬了几周的问题结掉了。
3. **把闸门留在回路里。** 因为每一次运行都由引擎自己的校验判收,上面这些表格是决策,不是观点——将来在生产里会拒掉坏文件的,正是这个把我的 ICQ 30 拒掉的闸门。
4. **把诚实的适用范围写进文档。** 这些数字是 UHD 770 上 Linux iHD 26.3.5 + oneVPL 2.17 的数;生产目标是同一块硅片上的 Windows MSDK 路径,我不能声称两者一致。这里站得住的是:命令在真硬件上跑得通、质量↔体积曲线的位置、以及锁的行为——留给将来的,只是「在 Windows+Intel 上按同一张表复测一次」,而不是又一个下午。
## 结果
QSV 从「存在于代码里、永远选不到」变成了一个带公开数字的已标定档,安全规则也跟着改了:原来那条钉住「**出货默认值必须代表未标定**」的测试,现在钉的是「**出货默认值必须是量过的值**」,同时保留「空值 = 硬排除」的全部断言。在没装 Intel 显卡的机器上,doctor 那行也从信息级的「等硬件」变成了警告——这本身是另一个小教训:一个已标定的编码器自检失败该长什么样,以及为什么机器上不存在的硬件应该进一行汇总,而不是进警告。
@@ -0,0 +1,120 @@
---
title: "Docker 说它干净退出,内核说它被 OOM 杀掉了"
description: "NAS 上的容器不停「意外停止」,而 Docker 报的是 exit 0 / OOMKilled=false。真正的证据在 dmesg 里:六次 cgroup 内存击杀。"
pubDate: 2026-10-16
category: devops
tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/docker-reported-exit-0-the-oom-killer-disagreed.png
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.png
draft: false
---
## 一条我以为又是配置写错的告警
我的 NAS 发来那条所有 Docker 用户都学会一眼扫过的通知:**容器意外停止**。出事的是我自托管 wiki 前面的那个边缘代理,它已经连着跑了两周。那天早上没人部署过东西,compose 文件也没被动过,所以当时只有两个故事可选:「它崩了」,或者「有东西把它重启了」。
两个都错。而真正值得写下来的是:**光看 Docker 自己的报告,我根本分不出来。**
## Docker 的报告说「什么都没发生」
```text
State:
Status: running
Running: true
ExitCode: 0 <- 干净退出
OOMKilled: false <- 不是被内核杀的
FinishedAt: 2026-10-15T19:39:33.63057879Z
StartedAt: 2026-10-15T19:39:38.769326672Z <- 5 秒后重启
RestartCount: 6
RestartPolicy: always
```
照字面读,结论就是:进程干净退出了六次,每次都由重启策略续了命。一个长期运行的代理不会自己以状态 0 退出——而「干净退出」重复六次本身是矛盾,不是答案。`ExitCode` 和 `OOMKilled` 恰好是我在这里最信任的两个字段。在这台机器上,两个都是错的。
## 内核自己留了另一份记录
在 Linux 上,cgroup 的内存击杀由内核记录,哪怕容器运行时的账本没有把它带过来:
```text
[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB
```
开机秒数 `1737797` 对应 19:39:33 UTC——和容器的 `FinishedAt` 一秒不差。整个诊断到此结束,代价只是一次 `dmesg`。容器没有退出,它是在自己的内存上限内被杀掉,5 秒后又被拉起来。
dmesg 里这样的记录一共 **6 条**,而 `RestartCount` 正是 **6**。同一个 stack 里其他容器的重启计数都是 0、启动时间都是几周前,所以这六次击杀可以钉在这一个容器上:
| dmesg 开机秒 | 本地时间 | 被杀时 anon-rss |
|---|---|---|
| 1406539 | 10-12 约 07:37 | 491 MB |
| 1407759 | 10-12 约 07:57 | 494 MB |
| 1432574 | 10-12 约 14:49 | 486 MB |
| 1734793 | 10-16 约 02:45 | 487 MB |
| 1735495 | 10-16 约 02:58 | 485 MB |
| 1737797 | 10-16 03:39 ← 那条通知 | 478 MB |
每一次被杀都发生在 478–494 MB,而上限是 512 MiB(`536,870,912` 字节)。这是天花板被摸到,不是失控。
还有两个读数把机制说清了。cgroup 记录的高水位(重启后仍然保留的那个计数器)在重启 **18 分钟**后就已经回到了正好 `536,870,912`——也就是说同一个周期里它又摸到了天花板,只是这次没被杀。而用量曲线是来回震荡的(176 → 308 → 361 MB),不是单调爬升,所以这是负载下的分配抖动撞上小上限,不是一个能证明的缓慢泄漏。最大嫌疑是那个 geoblock 插件:每遇到一个没见过的客户端 IP,它就要做一次外部 HTTPS 查询。
同一天早上 05:33 又来第 7 次击杀——在改动落下去之前。
在这台 DSM 上,真相只存在于 `dmesg` 加上 cgroup 的 `memory.max_usage_in_bytes`。如果你在普通主机上追同一个形状,先看 `docker inspect -f '{{.State.OOMKilled}}'`——它本该是权威。而这里它读出来是 `false`,就在内核记下那次击杀的 5 秒后。这就是这篇文存在的理由。
## 真正在崩的是什么:一场爬虫洪水
宿主内存从来不是问题——64 GB 里约 39 GB 可用。出问题的是某个容器的 512 MiB 上限。
驱动因素是流量,数字是从容器自己的访问日志里读出来的。最后 50,000 行里:
```text
不同客户端 IP 25,976
403 响应 33,636 (geoblock / 拒绝名单在正常工作)
429 响应 16,082 (inflight 与限流在正常工作)
容器 CPU 118–230%
宿主 load average 26–32
```
每次重启后,堆在 12 到 40 分钟内又被填满。配置里没有任何东西是错的,那些 403 和 429 也不是——它们正是过滤器按设计拒绝爬虫的结果。问题在于谁在敲门:这个容器是一个多 TB 级 Wikipedia 语料的公网入口,托管在家庭 NAS 上,前面挂着一份国家白名单和一个机器人过滤器。对住宅代理爬虫云来说,这是个极有吸引力的目标;而那些「请你离开」的响应,本身也是工作量。
## 修法,以及必须跟着的那句话
```yaml
deploy:
resources:
limits:
- memory: 512M
+ memory: 2g
restart: always
+memswap_limit: 2g
```
验证(因为「我把上限调大之后感觉好多了」不叫验证):
- **只有那一个容器被重建。** wiki、代理内核、robots 容器都还报着两周的 uptime——用户看到的是几秒的空窗,而不是整个 stack 重启。
- `HostConfig.Memory = 2147483648`、`MemorySwap = 2147483648`,cgroup 的 `memory.limit_in_bytes` 与两者一致;`RestartCount` 归零。
- 18 分钟后峰值用量 **210 MB**——而之前它一直贴着 512 MB 的天花板。到中午是 94 MiB。
- 挂载列表仍与文件一致,包括插件目录那条挂载——丢掉它曾经让这个 stack 里所有路由全部失效。
- 两个插件都加载且在生效(国家白名单、机器人过滤器),等过滤器的计分窗口过期后,各条路由都返回 200。
**以及它不是什么:** 这是权宜之计。驱动因素是洪水,所以大一个数量级的洪水照样能摸到 2 GB。真正的修法是缩小公网入口面,而不是把笼子做大——一条没被标明为权宜之计的上限调整,最终会变成一个永久解释。
我选择让这个容器不换页:`memswap_limit` 与内存上限相等就是关掉它的 swap。这台宿主当时已在 40 GB swap 里用掉 16 GB,commit 总量 92 GB,而一个开始换页的边缘代理会把整台 NAS 拖慢——磁盘阵列才是瓶颈,所以换页买到的是更慢的失败,不是更长的命。
## 那个差点让我报告「自伤事故」的坑
验证期间,我第一次用裸 `curl` 打过去,返回 **403**,看起来完全像我刚重启代理造成的事故。并不是:机器人过滤器给我**自己的客户端 IP** 打了分(`blocked user-agent`,分数 80),然后把它放进一个 15 分钟的计分窗口——因为我一直在猛敲这台机器。
线索就在同一轮测试里:`/robots.txt` 返回 200,内部的 `/cdn` 路径返回 400,而两者是同一时刻——说明路由是健康的,只有我自己的请求被过滤。在一台「你同时是客户端」的宿主上,永远不要用裸 curl 做判断:去探一个过滤器会忽略的路径,或者从访问日志里读出一个 200。
## 如果重来一次
1. **把「意外停止」加 `ExitCode 0` 当成「去读 dmesg」的指令**,而不是干净退出的证据。重启计数大于零的容器,在证明清白之前都当作内存事故来查。
2. **对 cgroup 高水位告警,而不是只对重启告警。** 重启后那个已经贴着天花板的读数,在通知到达前约十八分钟就告诉我第 7 次击杀要来了。
3. **早点拿到「谁在打我」这个数字。** 一份日志样本里 25,976 个不同 IP 就是全部故事,而我是在看完整条内存曲线之后才找到它。
4. **怀疑容器,别怀疑服务器。** 「服务器内存不够」和「一台 64 GB 的机器上某个容器只有 512 MiB 上限」需要完全不同的应对,而后者你不去看那个上限就根本看不见。
## 结果
改动之后再没有出现 OOM 击杀,18 分钟后的峰值是 210 MB,而过去每一个周期都会摸到上限——四天里一直悄悄重启公网入口的那七次击杀,也从 dmesg 里消失了。爬虫洪水并没有停,所以我把这个失败形态记进了那套 stack 的运维笔记:**「容器意外停止」+ `exit 0` / `OOMKilled=false` 是一个会误导人的签名**,而在这类平台上,诚实的证据住在内核日志和 cgroup 的高水位里。