1. 为什么在鲲鹏 ARM64 上安装 argon2 不能照抄 x86 那套笔记
先说一个特别典型的现场。有人把原来 x86 服务器上的一整套部署脚本,原封不动搬到一台鲲鹏 ARM 架构的机器上,系统是麒麟 V10。make一路绿灯,make install也没报错,看着特别顺。结果业务进程一起,日志里直接躺着一行Illegal instruction,进程秒退。再换台机器试,pip install argon2-cffi卡在编译阶段,报一堆看不懂的_cffi_backend错误。这时候大部分人的第一反应是“argon2 是不是有问题”,其实 argon2 本身稳得很,问题全在aarch64这个指令集和麒麟 V10 这套软件栈的配合上。
argon2 是密码哈希领域目前公认的第一梯队算法,2015 年密码哈希竞赛的最终胜出者,RFC 9106 里给了推荐参数。它的核心卖点是内存硬(memory-hard)——不是靠堆 CPU 循环来拖慢攻击者,而是强行占用一大块内存,让 GPU、ASIC 这类靠并行堆算力的破解设备失去性价比优势。只要你的系统里有用户密码、有需要加盐哈希的凭据,argon2 就是一个很值得上的选择。这篇内容面向的是需要在鲲鹏 ARM64 服务器上、在麒麟 V10 系统里把 argon2 真正跑起来的人:可能你是运维,要在一批机器上批量部署;可能你是后端开发,代码里要调argon2id;也可能你在内网离线环境,连不上软件源,只能源码编译。三种情况后面的路径不一样,坑也不一样。
1.1 argon2 到底解决了什么,值得你专门折腾一次
很多人对密码哈希的理解还停留在“加个盐、多迭代几次”。MD5 加盐、SHA256 加盐这套东西在今天的显卡面前基本等于没穿衣服。一张中端显卡每秒能跑几十亿次 SHA256,八位密码的哈希空间在几小时内就能被打穿。bcrypt 是上一个时代的正确答案,它通过 Blowfish 的密钥调度做成了可调成本的慢哈希,但它的内存占用固定在 4KB 左右,这个数字在 FPGA 面前太便宜了。scrypt 引入了内存硬,方向对了,但参数配置复杂、并行度支持一般,实际落地时经常被配成“看起来很强其实很弱”的形态。
argon2 的设计思路是把这三件事一起解决:内存占用可调、迭代次数可调、并行度可调,并且提供两种变体——argon2i偏抗侧信道(适合共享硬件的场景),argon2d偏抗 GPU 破解,而argon2id是两者的混合体,也是现在几乎所有安全规范和框架默认推荐的选项。它的输出是一个自描述的编码字符串,形如$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$hash...,把版本、参数、盐、哈希值全塞在一个字符串里,存储时一个字段就够,校验时自己解析参数,升级参数也不用改表结构。这个设计对业务方极其友好,也是我一直推荐它的重要原因。
那它跟鲲鹏、跟 ARM 有什么关系?关系大了。argon2 的性能表现高度依赖底层 SIMD 指令和内存带宽,而 ARM64 和 x86_64 在这两点上的实现路径完全不同,后面会细讲。你在 x86 上调出来一组“跑 80 毫秒”的参数,原样搬到鲲鹏上,可能变成 300 毫秒,也可能因为编译时没开 NEON 而变成 600 毫秒。参数不重新标定,登录接口的 P99 延迟就会莫名其妙地涨上去,然后一堆人开始怀疑是数据库慢。
1.2 ARM64 的 SIMD 现实:NEON 有,AVX 没有
argon2 参考实现里有几条不同的核心循环路径:纯 C 的ref.c,以及针对 SIMD 优化的opt.c。在 x86 上,opt.c会走 SSE2、AVX2 甚至 AVX-512 的分支,一个寄存器能塞下 4 到 8 个 64 位字,压缩函数G的吞吐非常可观。到了 ARM64,能用的只有 NEON,寄存器宽度 128 位,一次处理 2 个 64 位字。也就是说,同样一份 argon2 源码,在 ARM64 上的 SIMD 加速比理论上就比 x86 弱一截,这不是实现写得差,是硬件位宽决定的。
更关键的是编译开关。参考实现的opt.c里,NEON 分支是被__ARM_NEON这个宏包起来的。如果你编译时用的编译器默认没定义这个宏,或者你手动加了-march=armv8-a但没带+simd,那么编译器会安安静静地走纯 C 路径,编出来的库能跑、结果也对,但性能直接掉一个档次。这种“错得很安静”的问题最难查,因为你没有任何报错可以看,只有压测的时候发现 CPU 打满了但 QPS 上不去。
还有个更隐蔽的坑:-march=native。为了让编译器榨干本机性能,很多构建脚本会加这个参数。在编译机和运行机是同一台的时候没问题。但如果你在鲲鹏 920 上编好,然后把二进制分发到一批配置略老的机器上,那些机器可能不认识某些指令,直接Illegal instruction退出。生产环境分发二进制,老老实实用-march=armv8-a这种保守基线,或者按机型分构建产物,别图省事。
1.3 麒麟 V10 的软件源里,argon2 到底有没有
这是每次都要先确认的事,别急着编译。麒麟 V10 是 RPM 体系,用yum或dnf。argon2 在这个生态里通常拆成几个包:运行时库(类似libargon2)、开发包(带argon2.h和.so软链,类似libargon2-devel)、以及可能的命令行工具包。不同 SP 版本、不同架构的仓库收录情况差异很大,x86_64 有的包在 aarch64 上没打,这是常有的事,不是你的问题。
先跑一条命令探路:
# 先看仓库里有没有现成的 yum search argon2 yum provides '*/argon2.h' yum provides '*/libargon2.so*'yum provides比yum search更靠谱,因为它直接告诉你“哪个包提供这个文件”,不会因为包名里没有 argon2 字样就漏掉。如果三条命令都有结果,恭喜,yum install libargon2-devel就完事,后面的源码编译章节你可以跳过。如果只有运行时库没有开发包,那意味着你能跑别人编好的程序,但自己写代码调argon2id_hash_raw就没法链接,还是得编。如果一条都没有,那就是纯离线编译的活儿了。
提示:内网离线环境的机器,先在能联网的同架构机器上用
yumdownloader --resolve把依赖链拉全,再拷进去。ARM64 的包不能拿 x86_64 的凑,混淆了会报wrong ELF class,这个错后面会专门讲。
2. 上机第一步:把环境底牌全部翻出来
我见过太多人跳过这一步,直接wget源码然后make,报错了再回头一点点查。正确的顺序是先把“这台机器是什么底子”摸清楚,五分钟的事,能省掉后面两小时。
2.1 架构、内核、glibc 三件套
uname -m # 期望输出 aarch64 uname -r # 内核版本 cat /etc/os-release # 发行版标识和版本号 getconf LONG_BIT # 期望 64 ldd --version | head -n 1 # glibc 版本,重点uname -m输出aarch64才是对的。如果输出armv7l,那是 32 位 ARM,很多现代版本的 argon2 和语言绑定根本不支持,别浪费时间。getconf LONG_BIT是防止你装了 32 位用户态——这种情况少见但确实存在,混装之后链接会报wrong ELF class: ELFCLASS32。
glibc 版本是重中之重。麒麟 V10 各 SP 版本的 glibc 大致在 2.28 这个量级附近,具体以你机器上ldd --version的输出为准。为什么要盯这个?因为 argon2 的语言绑定、预编译 wheel 包、第三方二进制,基本都是按某个 glibc 基线编的。有些预编译产物要求GLIBC_2.34,在你 2.28 的机器上加载时会直接报version 'GLIBC_2.34' not found。这种报错跟 argon2 一点关系没有,纯粹是运行库版本不够,唯一的解法是用源码在你自己的机器上重编,或者找匹配低版本 glibc 的产物。
2.2 编译工具链和依赖别漏
gcc --version make --version ld --version | head -n 1 rpm -qa | grep -E 'gcc|glibc-devel|make|binutils'要源码编译,gcc、make、binutils、glibc-devel这几样一个都不能少。麒麟 V10 最小化安装的镜像往往只带了运行时,gcc和glibc-devel都得单独装。这里有个细节值得提醒:glibc-devel提供的是头文件和crt*.o这类启动目标文件,缺了它,configure阶段会报一个特别迷惑的错——C compiler cannot create executables。字面意思是“编译器造不出可执行文件”,很多人以为是编译器坏了要重装,其实九成是缺glibc-devel。想看具体原因,去翻config.log,里面会把链接失败的真正原因写清楚,比终端上那行摘要有用得多。
argon2 本身还依赖 pthread。现代 glibc 已经把 pthread 并进主库了,但编译时仍然建议显式加-pthread,因为头文件里的条件编译可能依赖_REENTRANT宏。麒麟 V10 上不加通常也能编过,但我习惯加上,省事。
2.3 判断走包管理器还是走源码
我给你一个简单的判断流程,按顺序过一遍就行:
| 检查项 | 命令 | 结论 |
|---|---|---|
| 仓库有无开发包 | yum provides '*/argon2.h' | 有,直接装,别折腾 |
| 只有运行时库 | yum provides '*/libargon2.so*' | 能跑现成程序,自己写代码仍需编 |
| 完全搜不到 | 上面命令全空 | 源码编译或离线 RPM |
| 需要特定版本 | argon2 --version不满足业务要求 | 源码编译,用--prefix隔离安装 |
最后一行值得展开说。有些团队的安全基线要求 argon2 版本不低于某个编号(比如修复了某个参数校验问题的版本),而系统源里的版本太老。这时候不要在系统目录上覆盖安装,用--prefix装到/opt/argon2-<版本号>下,通过PKG_CONFIG_PATH和LD_LIBRARY_PATH或者ld.so.conf.d指向它。这样系统自带的版本还能给老程序用,新程序用新版本,互不干扰。多版本共存在生产环境是刚需,一开始就规划好,比后面拆弹舒服得多。
3. 四条安装路线的取舍,别一上来就源码编译
同样是“在鲲鹏麒麟上装 argon2”,实际有四条路,适用场景完全不同。选错路线的代价是后面维护成本翻倍。
3.1 四条路线的横向对比
| 路线 | 命令形态 | 优点 | 缺点 | 适合谁 |
|---|---|---|---|---|
| 系统包管理器 | yum install libargon2-devel | 一条命令、依赖自动、升级方便 | 版本受限于仓库、aarch64 可能缺包 | 能联网、版本要求不严 |
| 源码编译 | make && make install | 版本自由、可指定优化参数、可隔离安装 | 需手工处理库路径和 pkg-config | 内网、需特定版本、要调优 |
| 语言生态自带 | pip install argon2-cffi等 | 不用管 C 层、版本跟随语言包 | 依赖编译器、aarch64 常需现场编 | 应用开发为主 |
| 离线 RPM 包 | yumdownloader --resolve+rpm -ivh | 标准化、可批量、可进内部仓库 | 依赖链要拉全,架构必须匹配 | 大批量内网部署 |
绝大多数生产环境最后走的是源码编译 + 内部仓库的组合:先在一台机器上编译出 RPM,放进内部仓库,其他机器yum install拉下来。这样既有版本控制,又不用每台机器都装编译器。如果你是第一次搞,建议先在一台机器上手工编译跑通,把make install装了什么文件、装到哪、pkg-config 怎么写,全部摸清楚,再考虑打包。
3.2 什么情况下源码编译是唯一选项
三种情况我明确建议直接上源码,别在包管理器上浪费时间:一是内网完全隔离且内部仓库里没有这个包;二是业务代码需要静态链接(libargon2.a),而系统包通常只提供动态库;三是你需要针对鲲鹏的 NEON 做特定优化编译,包管理器给的二进制是按通用基线编的,榨不出那部分性能。
还有一个小众但真实的需求:交叉编译。在 x86 的开发机上给 aarch64 目标机编 argon2。argon2 参考实现的 Makefile 对交叉编译支持得不算特别顺,需要显式传CC和AR。如果只是偶尔为之,我更推荐在目标机或者同架构的构建机上直接编,省掉一堆工具链配置的麻烦。
4. 源码编译 argon2 的完整过程,一步不省
下面这套流程是我在麒麟 V10 + 鲲鹏 ARM64 上反复验证过的顺序。命令都不复杂,但每一步都有个“为什么”,理解了才不会在变体环境里翻车。
4.1 依赖准备与源码获取
先把工具链补齐。麒麟 V10 上大概是:
yum install -y gcc make binutils glibc-devel git tar # 如果打算跑测试脚本,还需要这些 yum install -y whichargon2 参考实现用的是普通 Makefile,不是 autotools 那套./configure。这一点很多人搞混——网上不少笔记写的是./configure --prefix=...,那是别的项目或者别的分支。官方参考实现直接make,通过环境变量和命令行参数控制安装路径。你如果照着 autotools 的笔记敲,会得到No such file or directory: ./configure,然后就懵了。
源码获取(离线环境用 U 盘或者内部文件服务器拷 tar 包):
tar -xzf phc-winner-argon2-<版本号>.tar.gz cd phc-winner-argon2-<版本号> ls # 应该能看到 Makefile、src/、include/、libargon2.pc.in、argon2-specs.pdf目录里include/argon2.h是对外头文件,src/下面是实现,libargon2.pc.in是 pkg-config 模板——这个文件的存在说明较新的版本会生成.pc文件,对后续业务集成很有帮助。
4.2 编译参数的取舍,比命令本身更重要
先看默认行为。直接make会做这些事:探测uname -m决定架构、编译libargon2.a、libargon2.so.1、以及命令行工具argon2。默认的优化目标是auto,在 aarch64 上会尝试启用 NEON 相关的编译选项。
关键参数就三个,我列个表说清楚:
| 参数 | 取值 | 作用 | 我的建议 |
|---|---|---|---|
OPTTARGET | auto/native/none | 控制-march优化级别 | 构建机自用选native,分发选auto或显式基线 |
NO_THREADS | 空 /1 | 设1则禁用多线程,库变成串行 | 只在极端受限环境用,正常别设 |
PREFIX | 任意路径 | 安装根目录 | 隔离安装用/opt/argon2-<版本>,全局用/usr/local |
OPTTARGET=native会加-march=native,编出来的二进制只保证在本机(或同型号 CPU)上跑。我自己在压测调优的时候会用它,能多榨出一点 SIMD 性能;但只要是分发出去的产物,一律不用。这个纪律得守住,不然哪天扩容混进来一批不同批次的机器,就会出现“部分节点启动失败”这种排查起来极其恶心的问题。
NO_THREADS=1这个选项要特别说明一下。argon2 的并行度参数p是靠线程实现的,p=4意味着一次哈希会拉起多个工作线程。如果你把NO_THREADS设成 1,库内部就变成串行执行,虽然算出来的哈希值仍然正确(算法本身定义了 lane 的合并规则,串行和并行结果一致),但p参数就失去了加速意义,只有安全语义。生产环境不要动这个开关,除非你在一个线程创建被严格限制的容器里,pthread_create直接失败。
正式编译,我通常这样写:
# 清理上次编译残留,别跳过这步 make clean # 通用基线,适合分发 make OPTTARGET=auto # 或者本机自用,榨性能 # make OPTTARGET=native # 跑官方测试集,确认算法实现正确 make testmake test这一步别省。它会跑一组已知向量和内部一致性测试,能验证你编出来的库在各种参数组合下结果都正确。编译优化开太猛偶尔会踩到编译器 bug,表现为测试挂了或者结果不一致,这时候降优化等级(去掉-O3之类)重编往往能绕过。这种概率很低,但一旦碰上,没有测试集你根本发现不了。
4.3 安装、ldconfig 和 pkg-config 三件事一起做
装到哪,是个需要提前想清楚的问题。装/usr/local最省事,但和系统包管理器装的东西混在一个命名空间里,将来yum update或者别人装了个同名包,就容易打架。装/opt/argon2-<版本>干净,但每个用它的程序都得知道去哪找。
我推荐的做法是:默认/usr/local,多版本共存需求才用/opt隔离。别过度设计。
# 装到 /usr/local make install PREFIX=/usr/local # 或者隔离安装 # make install PREFIX=/opt/argon2-20240101装完之后立刻做三件收尾:
# 1. 让动态链接器认识新库 ldconfig # 2. 验证库能被找到 ldconfig -p | grep argon2 # 3. 验证命令行工具 which argon2 && argon2 --help | head -n 5ldconfig这一步是最容易被忘的,也是后面error while loading shared libraries: libargon2.so.1这个错的直接原因。库文件躺在/usr/local/lib里,但/etc/ld.so.cache没更新,动态链接器根本不知道它的存在。ldconfig就是重建这个缓存。麒麟 V10 的/etc/ld.so.conf里通常已经include了/etc/ld.so.conf.d/*.conf,而/usr/local/lib是否在其中,不同发行版处理不一样。保险起见,加一个配置文件:
echo "/usr/local/lib" > /etc/ld.so.conf.d/argon2.conf ldconfig ldconfig -p | grep argon2如果是隔离安装在/opt下:
echo "/opt/argon2-20240101/lib" > /etc/ld.so.conf.d/argon2.conf ldconfigpkg-config 那边,较新版本的 argon2 会在make install时生成libargon2.pc。检查一下:
pkg-config --cflags --libs libargon2 # 期望输出类似 -I/usr/local/include -L/usr/local/lib -largon2如果报Package libargon2 was not found,但文件明明在/usr/local/lib/pkgconfig/libargon2.pc,那就是PKG_CONFIG_PATH没包含这个目录:
export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:$PKG_CONFIG_PATH # 想持久化就写进 /etc/profile.d/ 或者项目的构建脚本隔离安装的话路径换成/opt/argon2-<版本>/lib/pkgconfig。这个环境变量最好写进构建脚本而不是依赖登录 shell,因为 CI 环境里往往不走交互式登录,.bashrc根本不生效,一上流水线就挂。
4.4 用命令行工具做一次冒烟测试
安装完别急着接业务代码,先用自带工具跑一遍,确认端到端通了:
# 用固定的盐和参数生成一个 argon2id 哈希 echo -n "my-test-password" | argon2 fixedsaltsalt -id -t 3 -m 16 -p 4 -l 32 -e参数含义:-id选 argon2id 变体,-t 3迭代三次,-m 16内存 2^16 = 65536 KiB = 64 MiB,-p 4并行度 4,-l 32输出 32 字节,-e输出编码后的完整字符串。正常情况下你会看到类似这样的东西:
$argon2id$v=19$m=65536,t=3,p=4$Zml4ZWRzYWx0c2FsdA$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx再跑一次相同的命令,输出应该完全一样——因为盐和参数都固定了。如果两次不一样,说明你的盐处理有问题(命令行工具会自动把传入的盐做 base64 编码,只要输入一致结果就一致)。这一步验证的是“库能正常调用、参数解析正常、输出格式正确”。
再验证一下校验路径,用程序算一遍对比,或者用带验证功能的工具。argon2 命令行本身没有--verify子命令,验证逻辑一般在应用层做。你可以写个十行的 C 小程序,也可以直接用 Python(如果你已经把argon2-cffi装上):
from argon2 import PasswordHasher ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4, hash_len=32) h = ph.hash("my-test-password") print(h) print(ph.verify(h, "my-test-password")) # 通过则返回 True注意 Python 布局里的memory_cost单位是 KiB,和命令行的-m参数含义一致,但命令行那个-m 16是指数,实际值是 2 的 16 次方。这个差异坑过不少人,写代码时把 16 直接填进memory_cost,结果只用了 16 KiB 内存,安全性直接归零,而且程序跑得飞快,你反而觉得“性能真好”。这种事一定要在参数对照表上核对一遍。
5. 参数怎么定:在鲲鹏上跑出真实的性能曲线
参数配置是 argon2 落地最容易出问题的地方。x86 上抄来的参数,在 ARM 上大概率不合适;不标定就直接上生产,要么安全强度不够,要么登录接口被拖垮。
5.1 三个参数到底在管什么
m(内存,单位 KiB)决定一次哈希占用多少内存。这是 argon2 的核心防线,直接决定破解设备的成本上限。t(迭代次数)决定在这块内存上跑几轮,影响时间成本。p(并行度)决定分成几条 lane 并行计算,影响的是单次哈希能用上几个核。
三者的关系不是独立的。m和t的乘积近似决定总计算量,但内存比迭代次数更值钱——内存是被攻击者必须实打实付出的硬件成本,迭代次数只是时间成本,GPU 可以靠并行把时间成本摊薄。所以在资源允许的前提下,优先加m,其次加t。
p这一项在鲲鹏上要特别注意。p=4意味着单次哈希会拉起最多 4 个线程,如果你的服务已经跑了几十个 worker,再叠加这个并行度,CPU 上下文切换的开销可能吃掉大部分收益。核数少的机器上,p设成 1 或 2 往往更划算。
RFC 9106 给了两组推荐:第一组是m=2 GiB, t=1, p=4,适合内存充裕、并发不高的场景(比如全盘加密的密钥派生);第二组是m=64 MiB, t=3, p=4,这是内存受限场景的推荐,也是绝大多数 Web 登录接口该用的档位。
具体到落地,我给一张按场景分的参考表:
| 场景 | m | t | p | 单次耗时量级 | 说明 |
|---|---|---|---|---|---|
| 高并发登录接口 | 19456 KiB (19 MiB) | 2 | 1 | 数十毫秒 | 内存占用低,抗并发能力强 |
| 常规业务默认 | 65536 KiB (64 MiB) | 3 | 4 | 百毫秒量级 | 均衡档,需控制并发数 |
| 高安全(管理员、密钥派生) | 262144 KiB (256 MiB) | 3 | 4 | 数百毫秒 | 必须限流,否则内存爆 |
| 低配边缘节点 | 12288 KiB (12 MiB) | 3 | 1 | 数十毫秒 | 内存紧张时的下限 |
表里的耗时是量级参考,具体数字必须自己在目标机器上跑。我见过同一批鲲鹏机器因为 CPU 频率策略不同(有的开了性能模式,有的还是节能模式),同样的参数跑出来差了将近一倍。参数标定一定要在和生产同配置、同频率策略的机器上做,在开发机上测出来的数字没有参考价值。
5.2 自己动手跑一条基准曲线
别信任何人的数字,包括我上面表里的。自己测,方法很简单:写个小程序,固定m和p,把t从 1 往上加,记录每次耗时,找到落在你延迟预算内的最大组合。
// bench.c 需要包含 argon2.h #include <stdio.h> #include <stdlib.h> #include <time.h> #include <argon2.h> int main(void) { const char *pwd = "benchmark-password"; const char *salt = "benchmarksalt12"; // 至少 8 字节 uint8_t hash[32]; uint32_t t_cost = 3, m_cost = 65536, parallelism = 4; /* 先跑一次热身,避免首次调用的冷启动影响测量 */ argon2id_hash_raw(t_cost, m_cost, parallelism, pwd, strlen(pwd), salt, strlen(salt), hash, sizeof(hash)); struct timespec a, b; clock_gettime(CLOCK_MONOTONIC, &a); for (int i = 0; i < 10; i++) { argon2id_hash_raw(t_cost, m_cost, parallelism, pwd, strlen(pwd), salt, strlen(salt), hash, sizeof(hash)); } clock_gettime(CLOCK_MONOTONIC, &b); double ms = ((b.tv_sec - a.tv_sec) * 1000.0 + (b.tv_nsec - a.tv_nsec) / 1e6) / 10.0; printf("m=%u t=%u p=%u -> %.2f ms/次\n", m_cost, t_cost, parallelism, ms); return 0; }编译:
gcc bench.c -o bench -largon2 -pthread -O2 ./bench三个细节。第一,一定要热身。第一次调用涉及内存页分配和可能的线程池初始化,耗时明显偏高,直接算进平均值会失真。第二,循环十次取平均,单次测量受调度抖动影响大。第三,用CLOCK_MONOTONIC,别用clock(),后者测的是 CPU 时间而不是墙上时间,多线程场景下会严重低估。
测出结果后,往两个方向调:如果耗时远低于预算(比如你预算 200ms,实测 30ms),说明强度不够,把m翻倍再看;如果耗时超预算,先降t再降m,别先动p,因为p对延迟的影响不线性,核数不够时加p反而更慢。
5.3 内存账要和并发数一起算
这是最关键的一条,也是我看过最多事故的一条。m=64 MiB听起来不多,但你算一下:如果登录接口并发 100 个请求同时在做哈希,瞬时内存占用是 64 MiB × 100 = 6.4 GiB。这还没算进程本身、JVM 堆或者 Python 解释器的开销。一台 8 GB 内存的机器,一个登录高峰就能把它打到 OOM。
所以 argon2 的调用点必须加并发限制。用一个固定大小的信号量或者线程池,把同时进行哈希的数量卡死。具体卡多少,按你的内存预算倒推:
最大并发 = (可用内存 × 安全系数 0.6) / m比如给密码哈希预留 2 GB,m=64 MiB,那就是 2048 / 64 × 0.6 ≈ 19 个并发。超出的请求排队等待。这里要权衡:排队会让延迟上升,但总比 OOM 强。经验上,登录场景的哈希并发不需要开很大,因为正常用户登录频率远低于浏览请求,排队深度设在 2 到 3 倍并发数就够。
注意:给 argon2 预留内存时,别只看物理内存总量,还要看容器的
memory limit。K8s 环境下 pod 超限会被直接 kill,日志里只有一个OOMKilled,看不出跟 argon2 有关,排查起来非常费劲。
6. 各语言绑定在 aarch64 上的安装差异
业务代码用什么语言,安装方式就完全不一样。这一节把常见的几种过一遍,重点讲 aarch64 上的特殊之处。
6.1 Python:argon2-cffi 与它的编译依赖
pip install argon2-cffi在 x86 上这行命令通常秒过,因为 PyPI 上有现成的 manylinux wheel。到了 aarch64,情况取决于你的 Python 版本和 pip 版本——较新的 manylinux 标准是支持 aarch64 的,但不是所有包都发布了 aarch64 的 wheel。没有 wheel 时 pip 会退化成源码构建,这时候就需要本机有 C 编译器和 Python 开发头文件。
源码构建失败最常见的两个原因:一是缺python3-devel(提供Python.h),二是缺libffi-devel(cffi 的依赖)。麒麟 V10 上:
yum install -y gcc python3-devel libffi-devel较新版本的argon2-cffi会把 argon2 的 C 源码一起打包进 sdist,编译时不需要系统里预先装好 libargon2。但如果你明确希望链接系统里那份(比如你已经按第 4 节编好并想统管版本),就需要设置相应的环境变量让构建走系统库路径。这个开关在不同版本间有变化,动手前先看一遍包里的构建说明,别凭记忆。
还有个坑:pip install默认可能去拉一个很老的缓存版本。加--no-cache-dir排除缓存干扰,加-v看详细日志,出问题时能直接看到是哪一步挂的。
6.2 Node.js:node-gyp 的现场编译
npm install argon2argon2这个 npm 包是原生扩展,靠node-gyp编译。aarch64 上大概率没有预编译二进制可用,所以必然走现场编译。需要:
yum install -y gcc-c++ make python3报错gyp ERR! stack Error: Can't find Python executable就是缺 Python,尽管你系统里明明有python3——老版本的 node-gyp 只认python。解法是显式指定:
npm config set python /usr/bin/python3 # 或者安装时临时传参 npm install argon2 --python=/usr/bin/python3另一个常见报错是node-gyp找不到对应版本的 Node 头文件,尤其在离线环境。这时候需要预先下载node-vX.Y.Z-headers.tar.gz放到本地缓存目录,再用--nodedir指过去:
npm install argon2 --nodedir=/opt/node-headers编译时间在 ARM 上会比 x86 长一些,别以为是卡死了,耐心等。
6.3 Go 和 Java:压根不用装系统库
Go 这边特别省心。golang.org/x/crypto/argon2是纯 Go 实现,不带 cgo,交叉编译也不受影响。直接:
go get golang.org/x/crypto/argon2唯一的差别是纯 Go 实现没有 NEON 的汇编优化,性能比 C 版本慢一些。如果你的场景对哈希延迟极敏感,可能要考虑 cgo 包装系统库,但绝大多数登录场景完全够用,而且纯 Go 版本部署简单、没有动态库依赖,我一般优先选它。
import ( "golang.org/x/crypto/argon2" "crypto/rand" ) func hashPassword(pwd string) ([]byte, []byte) { salt := make([]byte, 16) rand.Read(salt) // 参数:time=3, memory=64*1024 KiB, threads=4, keyLen=32 key := argon2.IDKey([]byte(pwd), salt, 3, 64*1024, 4, 32) return key, salt }注意 Go 版本的memory参数单位是 KiB,64*1024就是 64 MiB,别写成 64。
Java 侧两条路:一是 BouncyCastle 的Argon2BytesGenerator,纯 Java 实现,加个依赖就能用,不依赖系统库;二是 JNA 绑定的方案,需要系统里有libargon2.so并且 JVM 能找到它。纯 Java 方案在麒麟 V10 上更稳妥,因为不涉及本地库路径问题。选 JNA 方案的话,记得给 JVM 加-Djna.library.path=/usr/local/lib,否则运行时会报找不到库。
PHP 的话,password_hash($pwd, PASSWORD_ARGON2ID)需要 PHP 在编译时带了--with-password-argon2。用包管理器装的 PHP 往往没开这个选项,PASSWORD_ARGON2ID常量直接不存在。这种情况要么换一个编译时启用了的 PHP 包,要么自己重编 PHP,比较麻烦。上生产前先php -r "var_dump(defined('PASSWORD_ARGON2ID'));"确认一下,别等到跑测试才发现。
7. 踩坑链路复盘:那些报错背后的真实原因
这一节按我实际遇到的频率排序,把排查思路完整走一遍。直接给答案没意义,关键是要能从报错信息推到根因。
7.1 编译期:C compiler cannot create executables
这是最经典的一个,看起来像编译器坏了。完整排查链路是这样的:
第一步,看终端上那句摘要没用,去翻config.log。虽然 argon2 参考实现不用 autotools,但你如果编的是别的依赖(比如某个语言绑定带的构建脚本),这个文件会在源码目录里。搜索error关键字,看真正的失败命令是什么。
第二步,如果失败命令里出现crt1.o、crti.o找不到,那就是缺glibc-devel。crt*.o是链接阶段需要的启动目标文件,纯运行时环境里没有。yum install glibc-devel解决。
第三步,如果报cannot find -lc,检查是不是 32 位/64 位混装,或者libc.so的软链丢了。用ls -l /usr/lib64/libc.so看,正常应该指向libc.so.6之类。
第四步,如果报权限问题,检查/tmp是不是被挂成了noexec。有些加固过的系统会把/tmp挂成noexec,编译器在/tmp里生成临时可执行文件然后执行测试,就会失败。这个坑特别隐蔽,因为报错信息完全不提/tmp。用mount | grep /tmp能看出来,解法是给编译器指定别的临时目录:export TMPDIR=/var/tmp。
7.2 运行期:error while loading shared libraries
程序编好了,一跑就报这个,后面跟着libargon2.so.1: cannot open shared object file。
第一反应应该是ldconfig -p | grep argon2。如果这里什么都没有,说明缓存没更新或者库不在搜索路径里。先ldconfig再查。
如果ldconfig -p有输出,但程序还是找不到,那大概率是编译时链接的版本和运行时的版本不一致。用ldd ./your_program | grep argon2看程序实际在找哪个文件。如果显示not found,再看readelf -d ./your_program | grep NEEDED,确认它要的 SONAME 是什么(比如libargon2.so.1),然后去/usr/local/lib里看有没有这个名字的文件。有时候make install只装了libargon2.so.1.x.x而软链没建好,手动补一个:
cd /usr/local/lib ln -sf libargon2.so.1.x.x libargon2.so.1 ln -sf libargon2.so.1 libargon2.so ldconfiglibargon2.so这个不带版本号的软链是给链接器用的(-largon2会找它),libargon2.so.1是给运行时用的。两个都要有,缺哪个补哪个。
还有一种情况:程序是用LD_LIBRARY_PATH指到/opt/argon2-xxx/lib编的,但跑的时候是在 systemd 服务里,环境变量没传进去。systemd 的服务环境是干净的,不继承登录 shell 的变量。要么在 service 文件里写Environment=LD_LIBRARY_PATH=/opt/argon2-xxx/lib,要么老老实实走ld.so.conf.d,后者更可靠。
7.3Illegal instruction和wrong ELF class
Illegal instruction(有时候是SIGILL)几乎总是编译优化过头导致的。用-march=native在较新的鲲鹏上编,拿到较老的机器上跑,就会触发。验证方法:objdump -d ./your_program | grep -i "指令名",但更快的办法是直接检查编译参数,去掉-march=native重编一遍,问题消失就确认了。
还有一种可能是 NEON 相关指令在不支持的环境里被执行,比如某些虚拟化平台对 SIMD 指令做了限制。这种情况少见,但如果你在虚拟机里跑,可以往这个方向查一下。
wrong ELF class: ELFCLASS32或者ELFCLASS64,就是 32/64 位不匹配。file ./your_program一看就知道。库是 64 位的,程序是 32 位的,或者反过来。麒麟 V10 上装 32 位兼容库的情况不多,但如果是从一个老项目迁移过来的,值得查一下。
7.4 一张速查表,出问题时对着看
| 报错片段 | 大概率原因 | 处置动作 |
|---|---|---|
argon2.h: No such file or directory | 头文件没装,或-I路径不对 | yum install libargon2-devel或检查PREFIX |
undefined reference to 'pthread_create' | 没加-pthread | 编译命令补上-pthread |
cannot open shared object file | ldconfig未执行或路径未配置 | ldconfig+ 检查ld.so.conf.d |
Illegal instruction | -march=native跨机型分发 | 用保守基线重编 |
wrong ELF class | 32/64 位混用 | file检查,统一架构 |
version 'GLIBC_2.xx' not found | 二进制要求的 glibc 高于本机 | 本机源码重编 |
Can't find Python executable | node-gyp 找不到 python | npm config set python /usr/bin/python3 |
No module named '_cffi_backend' | cffi 未正确安装 | 重装cffi,确认libffi-devel |
| 哈希结果每次不一致 | 盐没固定或每次随机生成 | 检查调用方盐的生成逻辑 |
| 哈希快得离谱 | m参数单位搞错(填了指数值) | 确认m是 KiB 数值 |
最后两行是我特意加的。结果不一致和快得离谱这两个现象,表面看不出错,但都是实实在在的安全事故。前者可能是盐处理逻辑写错,后者是参数填错导致内存占用趋近于零,等于用了个加强版 SHA。上线前一定要拿已知密码和已知参数做一次对照验证,别只看程序不报错就当成功了。
8. 从 bcrypt 迁移过来:工程上要注意的几件事
很多团队不是从零开始用 argon2,而是从 bcrypt 或者 PBKDF2 迁过来。这一步的工程细节比安装本身更容易出问题。
8.1 渐进式迁移,别搞一次性重算
最直接的想法是“把数据库里所有密码重算一遍”。但 bcrypt 的哈希是不可逆的,你没有明文密码,压根没法重算。唯一的路径是渐进式迁移:用户下次登录时,用老的 bcrypt 校验通过后,顺手用 argon2 重新哈希一遍存回去。这个过程可能要跑几个月甚至几年,因为有些用户一年才登录一次。
对应的存储设计是:密码字段要能同时容纳两种格式。最省事的做法是靠前缀自动识别——bcrypt 的哈希以$2a$、$2b$、$2y$开头,argon2 的以$argon2id$开头。校验时读前缀,分派到对应的算法。这样不需要加字段、不需要改表结构,迁移过程中新旧数据混存也完全没问题。
def verify_and_upgrade(stored_hash: str, plain: str, user_id: int): if stored_hash.startswith("$argon2"): return argon2_verify(stored_hash, plain) # 新格式,直接校验 if stored_hash.startswith(("$2a$", "$2b$", "$2y$")): if bcrypt_verify(stored_hash, plain): # 老格式校验通过 new_hash = argon2_hash(plain) # 顺手升级 save_hash(user_id, new_hash) return True return False raise ValueError("unknown hash format")这段逻辑要注意并发。同一个用户短时间内登录两次,两个请求可能同时判定“需要升级”然后各写一次,虽然结果无害(后写的覆盖先写的,两个都是合法的 argon2 哈希),但在高并发下会产生额外的写压力。加个轻量的判断——比如只在两次登录间隔超过一定时间时才升级——就能缓解。
还有一个细节:升级失败不能影响登录。如果 argon2 计算过程中抛异常(内存分配失败、库加载失败),用户的密码校验其实已经通过了,不能因为升级失败就让人登不进去。用一个 try-catch 把升级逻辑包起来,失败就记日志、放行,下次登录再试。这个降级设计很重要,我见过因为升级路径抛异常导致大面积登录失败的案例。
8.2 存储格式与字段长度要提前算好
argon2 的编码字符串比 bcrypt 长不少。$argon2id$v=19$m=65536,t=3,p=4$这一段前缀就 32 个字符,加上 16 字节盐的 base64(22 字符)和 32 字节哈希的 base64(43 字符),总共接近 100 字符。如果参数调高,m=262144这种,前缀还要再长几个字符。
数据库字段长度按 255 设计基本够用,但从 bcrypt 迁移过来的老表,字段长度可能只开了 60(bcrypt 哈希正好 60 字符)。这个长度在写入 argon2 哈希时会被截断,而截断后的哈希校验永远失败,表现为“用户密码正确但登不进去”,而且日志里什么错都没有。这种问题排查起来极其费劲,因为数据库不会报错(很多数据库在非严格模式下是静默截断的)。
迁移前先跑一句:
-- 以常见的 255 为例,实际按你的表结构调整 ALTER TABLE users MODIFY password_hash VARCHAR(255) NOT NULL;改完再验证一下最大长度:
h = ph.hash("x" * 128) print(len(h)) # 确认实际长度,留足余量8.3 我踩过的两个具体坑
第一个是盐的字节数。argon2 要求盐至少 8 字节,推荐 16 字节。有些从 bcrypt 迁过来的代码,沿用了原来生成盐的逻辑,只生成了 8 字节甚至更少,然后 argon2 的库直接返回ARGON2_SALT_TOO_SHORT错误码。这个错误码在很多语言绑定里不会抛异常,只是返回一个错误值,如果你没检查返回值,就会把空哈希写进数据库,用户下次登录永远失败。所有 argon2 调用都要检查返回码,这不是可选项。
第二个是参数变更后的兼容。argon2 的编码字符串自带参数,所以你把默认参数从m=65536调到m=131072之后,老用户的老哈希仍然能正常校验——verify会解析字符串里的参数,用老参数重新计算对比。这一点比 bcrypt 强(bcrypt 的 cost 也自带,但要手动提取)。不过要注意,校验和生成必须用不同的参数配置:生成用新参数,校验用字符串里的参数。很多封装的库默认就是这样处理的,但如果你自己写底层调用,很容易把校验也写成新参数,导致老哈希全部校验失败。
# 生成用新参数 ph_new = PasswordHasher(time_cost=3, memory_cost=131072, parallelism=4) # 校验时库会自动从哈希字符串里读参数,不要手动覆盖 try: ph_new.verify(stored_hash, plain) # 内部会解析 $m=...,t=...,p=... except VerifyMismatchError: return False这一点值得单独测一遍:拿一个用旧参数生成的哈希,用新参数的PasswordHasher去校验,确认能通过。如果通不过,说明你的封装库版本行为有变化,得换写法。
最后分享一个我在实际部署中养成的小习惯:把 argon2 的编译参数、版本号、标定出来的基准耗时,写进项目的README或者一份docs/crypto-notes.md里。原因很实在——这套东西换了人接手,或者过一年机器换代了要重新标定,那些数字和参数选择的理由如果没记下来,就得从头再摸索一遍。我吃过这个亏,一台老机器的参数没人说得清是怎么定的,最后只能重新跑基准、重新压测,白白多花了两天。