即便一台很安静的服务器,在真正需要随机数之前也早就完成了播种,所以今天还会出问题的随机性事件几乎都不是「熵不够」,而是「状态被重放」:镜像里烤死的种子、被恢复的快照、回滚到上一次重播种之前的状态。内核并不朝你一滴一滴地滴熵;它把一点点设备噪声浓缩成一个生成器,然后无限地吐密钥流。
这个反转正是为什么围绕熵的大部分运维经验已经失效。盯着 entropy_avail、为取密钥去读 /dev/random、装一个补熵守护进程,这三件事针对的都是内核早已不再存在的稀缺;而真正会毁掉密钥的那件事——两台机器拿到同一个种子——在这些习惯里一个都看不出来。
熵池是输入,生成器是产物
内核把物理噪声混进来:中断时序抖动、设备延迟、平台自带的硬件随机数发生器,以及基于 CPU 的 jitterentropy;再用 BLAKE2s 把它们抽取进熵池。熵池给一组 ChaCha20 CRNG 播种,真正产出字节的是这些 CRNG。播种之后收集仍在继续,生成器也会被周期性重播种,但你已经吐出去的每一个字节,其强度由它出生时的那个状态决定,之后再发生什么都改不了。
一旦初始化完成,输出就是密钥流,而不是有限资源。entropy_avail 稳定在 256 位,也就是一个 ChaCha20 密钥的长度,而且你读它它也不会掉;读走 1 MiB 不消耗任何池内配额。random(4) 至今仍把 entropy_avail 描述成 0 到 4096 之间的取值、把 poolsize 描述成 Linux 2.6 之后固定 4096 位——正是这类文档让一条过期的告警一直活着。
/dev/random 不再是「更谨慎」的选择
从 Linux 5.6 起,/dev/random 在早期启动之外不再阻塞,其上的 O_NONBLOCK 也被忽略。那个独立的阻塞池已经消失,剩下的只是同一个生成器顶着历史名字。getrandom(2)(Linux 3.17 引入)默认从 urandom 源取数,只在那个源尚未初始化时阻塞;GRND_NONBLOCK 把这种阻塞变成 EAGAIN,GRND_RANDOM 则改为选择 random 源,也就是那个仍然可能无限期阻塞的源。
random(7) 把话说得很直:除非你在做长期密钥生成,而且多半连那种场景也不必,否则不要读 /dev/random,也不要传 GRND_RANDOM。初始化完成后,一次不超过 256 字节的读取总是完整返回,也不会被信号打断。所谓「更安全」的接口,代价是一次可能永久挂住的调用。
真正要紧的窗口只有启动那一小段
真正的故障发生在初始化完成之前,内核会用一行 crng init done 宣告它结束。没有种子文件的机器只能从设备噪声里慢慢攒;有种子文件、有 virtio-rng 设备或 RDRAND 的机器则立刻就有。random(4) 给的办法仍然最便宜:关机时从 /dev/urandom 用 dd 取 512 字节写进权限 600 的种子文件,开机时在任何东西索要密钥之前把它 cat 回 /dev/urandom。
差距非常悬殊:一个 32 字节的种子文件,或者一次 virtio-rng 读取,立刻就能跨过 256 位的初始化门槛;纯靠中断的启动则要先攒够成千上万个样本。所以「密钥是在 crng init done 之前还是之后生成」是一个配置问题,而不是运气问题。
坏掉的样子
第一种故障是首次启动取密钥时卡住。某个单元在第一次启动时就生成 TLS 密钥或 token 密钥,可能在初始化完成前调用 getrandom(),然后就那么挂着,日志里一句有用的话都没有。无盘机器、小型路由器、从冻结文件系统恢复的虚拟机,以及没有熵设备的容器,是常见的受害者。把密钥文件的 mtime 和那行 crng init done 对比一下:比它更早的密钥,就是从没初始化的生成器里出来的。
第二种故障是无声的,因为没有任何东西阻塞,所有看板都很健康。给虚拟机做快照、回滚,或者用同一个黄金镜像批量开出十台云主机,每一份副本都会带着同一个生成器状态和同一个种子文件继续跑。于是它们吐出完全相同的 SSH 主机密钥、来自 /proc/sys/kernel/random/uuid 的重复 UUID,以及互相重叠的 TLS 会话票据;谁拿到其中一台的输出,就能预测它的兄弟们。共享同一个烤死种子的十份克隆只产生了一个不同指纹,重新播种的十份则产生了十个。要从镜像里删掉种子,并在首次启动时重新生成主机身份。
快速验证
按顺序执行这四条命令:
dmesg | grep -i "crng init done"
cat /proc/sys/kernel/random/entropy_avail /proc/sys/kernel/random/poolsize
python3 -c 'import os; print(os.getrandom(8, os.GRND_NONBLOCK).hex())'
stat -c '%y %n' /etc/ssh/ssh_host_ed25519_key /var/lib/random-seed
在现代内核上,计数器读数是 256,poolsize 读数是 4096:第一个数字是密钥长度,不是油表,所以读数偏低并不需要去修。getrandom 那一条应当立刻返回八个十六进制字节;如果抛 BlockingIOError,说明这个源还没初始化,而一次阻塞调用会就此停住。stat 的输出才是真正的检查——如果主机密钥的时间戳早于 crng init done 那一行,这把密钥就来自未初始化的生成器,应当轮换。
来源
- Linux random(4) 设备,/dev/random 与 /dev/urandom 语义、entropy_avail 与 poolsize,以及种子文件的写法,已核验 2026-09-16。
- Linux getrandom(2),GRND_NONBLOCK 与 GRND_RANDOM,以及 urandom 源未初始化时的阻塞行为,已核验 2026-09-16。
- Linux random(7),三种接口的对比表,以及何时不该用 GRND_RANDOM,已核验 2026-09-16。
- ArchWiki:随机数生成,用 BLAKE2s 抽取并为 ChaCha20 CRNG 播种,以及 entropy_avail 为何停在 256,已核验 2026-09-16。