逻辑卷的瞬时生成、以空间换时间副本

LVM 快照在时间上定格一块块设备

LVM 快照既不是拷贝,也不是 reflink:它是一个挂在源逻辑卷上的、大小固定的写时复制异常存储。lvcreate -s -L 5G -n snap vg/data 从卷组里划走 5 GiB,之后 device-mapper 的 snapshot 目标会在某个源 chunk 第一次被写时,把它的原始内容记录下来。无论源卷多大,创建都是瞬时的,因为创建时什么都没复制。 这个大小就是你全部的安全余量,也正是最常被写错的参数。按“一周内没什么写入”配的存储,会被一次数据库 checkpoint 或一次 rsync 在一小时内吃光;而它填满时快照不会降级,而是直接失效并开始返回 I/O 错误。恢复点恰好在你最需要它的那一刻消失,所以存储的剩余空间该进监控面板,而不是躺在 runbook 里。 异常存储,不是副本 一个经典的 LVM 快照用到两个 device-mapper 目标:源卷被重新映射为 snapshot-origin,另有一个新的 snapshot 设备把它与 COW 设备组合起来。读未改动的 chunk 直接落到源卷;某个 chunk 第一次被写时,旧 chunk 被复制进 COW 设备,然后新数据才写下去。所以消耗量跟“发生过改动的不同 chunk 数”成正比,而不是跟写入次数成正比:fixture 往 16 个热 chunk 写了一百万次,只消耗 15 个异常,约 60 KiB。 ...

四月 10, 2028 · 1 分钟 · XB Field Notes
在真正 accept 之前暂存待握手连接的内核队列

TCP backlog 收纳还在握手中的连接

一个监听中的 TCP 套接字有两个队列,不是一个;而本文标题里那个 sysctl 管的,恰恰是几乎从不丢东西的那一个。传给 listen(2) 的 backlog 是 accept 队列,装的是已经完成握手、正等应用来取走的连接;SYN 队列装的才是半开请求。把两者混为一谈,就会开错药方。 这件事值得掰开,因为那句著名的建议——把 tcp_max_syn_backlog 调大——基本已经过时。tcp_syncookies 默认就是 1,而按 listen(2) 的说法,一旦启用 syncookies,SYN 队列就没有逻辑上的长度上限,这个设置会被忽略。真正会卡住你的是 accept 队列,以及应用调用 accept() 的速度。 backlog 参数管的是 accept 队列 listen(2) 明确写着,这个行为在 Linux 2.2 就变了:backlog 现在指的是「已完成建立、正等待被 accept 的套接字队列长度」,而不是半开请求的数量。半开队列由 /proc/sys/net/ipv4/tcp_max_syn_backlog 单独限制。而如果 backlog 参数超过 /proc/sys/net/core/somaxconn,它会被静默截断到那个值——Linux 5.4 之后是 4096,之前是 128。所以在默认配置的机器上写 listen(8192),实际拿到的是 4096,而且不会有任何日志告诉你。 ...

三月 24, 2028 · 2 分钟 · XB Field Notes
来自内核计数器的进程级磁盘占用实况

iotop 能看出是哪个进程在烧磁盘

磁盘突然飙升却找不到明显的写入者时,iotop 把吞吐直接指回那个在捣乱的进程。系统级工具只给症状——await 高、util 打满——不给嫌疑人:五十个进程共享磁盘,平均值平等地怪每一个。终结调查的永远是按进程的问题——谁、多少、何时开始——iotop 从内核计数器实时回答,应用藏都没处藏。 把它放在系统级视图旁边,而不是代替。iostat 或 vmstat 说磁盘是瓶颈,iotop 点名是谁造成的。两者合起来把存储悬案压成两条命令:确认资源,锁定消费者。 逐个进程的计数器 iotop 从内核读每个进程的 IO,于是实时读写速率能对应到一个 pid,而不是一个说不清来源的负载均值。加 -o 只显示真正在做 IO 的进程,一眼看清。默认视图读写分开,还带 swap-in 百分比,把真吃磁盘的和仅仅在场的分开——DISK READ 高、写零的是在扫,写高的在产; throttle、迁移还是杀,取决于这个区分。 注意计数器量的到底是什么:穿过 IO 层的按进程字节,含还没落盘的缓冲写。进程写速率巨大、磁盘看着却闲(页缓存吸收),或速率平平、磁盘打满(大量小同步写),都可能。iotop 跟设备 util 一起读:进程侧定罪加设备侧饱和才是完整画面,任一半单独都误导。 看到谁在扫盘 按带宽排序,那个罪魁就现形了,无论是日志囤积、索引器还是失控备份。跟住这个 pid,就能定位到确切的进程(往往还有文件),再动手不迟。惯犯名单:debug 级别没轮转的日志、排在高峰的重建索引、同盘读写备份、坏镜像上循环解包的容器层。修法各不同——轮转、改期、分开、清理——杀进程治标,病因明天定时再来。 动手前把 pid 解析到完整上下文:命令行、工作目录、lsof 看开文件、父进程。一个烧写的 java 进程背后可能是一个 PID 命名空间里的十个应用;修要修对那一个。先定罪后动手——每个以遗憾收场的存储 war story 都跳过了第一步。 ...

三月 15, 2028 · 1 分钟 · XB Field Notes
进程仍持有引用时,删除名称不会释放整个网络命名空间。

网络命名空间隔离网络资源,名称并不拥有其中所有进程

Linux 网络命名空间分离接口、路由、邻居表、套接字端口和包过滤规则等网络资源。它与其他命名空间共享内核,不是独立操作系统。进程通常继承父进程的网络命名空间,工具也可以在另一个范围中执行进程。这种隔离针对网络资源,不会自动涵盖全部进程身份和文件系统访问,因此需要按实际机制说明边界。 坏掉的样子 运行 ip netns exec 不会自动获得完整容器隔离。PID、用户身份和主机名隔离属于其他机制。应用可能看到不同路由,却仍与父环境共享普通文件和主机名。如果把一个网络命名空间当成完整沙箱,就会赋予它从未配置过的保证。应区分实际改变了哪类资源,以及哪些属性仍然来自原有运行环境。 新命名空间也不会自动得到有用的外部连接。接口需要有意放置和配置,本地回环可能需要启用。移动接口会改变管理它的命名空间,原范围不再把该设备列为自己的接口。因此,从错误范围执行配置命令,可能报告接口不存在,尽管预期设备仍位于另一个命名空间。诊断前应先确认命令的执行上下文。 名称还有独立生命周期。ip netns 按约定在 /run/netns 暴露引用,通常也能通过 /var/run/netns 访问。删除名称会移除这项引用,但进程或其他引用仍可维持命名空间存活。如果清理流程把名称列表中消失,直接等同于所有服务终止,就可能留下网络资源和运行中的进程。名称只是管理入口,不是进程所有权的完整表达。 快速验证 将脚本保存为 check-network-namespace.py,准备 Docker 后运行 python3 check-network-namespace.py。固定环境使用 Python 3.11.16 和 iproute2 6.1.0,通过 NET_ADMIN 与 SYS_ADMIN 在一次性容器内进行嵌套命名空间操作。临时 proc 挂载支持其中的网络参数设置,不移动宿主接口,也不发布宿主端口;实验资源随容器清理: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,os,select,signal,subprocess,time def run(*args,check=True,input=None,timeout=8): result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs) def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs) def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=2) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2) def line(process,timeout=3): assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded' value=process.stdout.readline();assert value,'child exited without a response' return value def spawn(stack,command): process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) stack.callback(stop,process);return process @contextmanager def lab(names): with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack: root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc)) stack.callback(run,'umount',str(proc)) for name in names: run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False) ip(name,'link','set','lo','up') def setting(name,key,value): assert name in names path=proc/'sys'/key.replace('.','/') return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip() yield root,stack,setting def wire(left,left_if,right,right_if,left_addresses,right_addresses): run('ip','link','add',left_if,'type','veth','peer','name',right_if) for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]: run('ip','link','set',interface,'netns',name) for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else [])) ip(name,'link','set',interface,'up') def nft(name,script):return ns(name,'nft','-f','-',input=script) def counters(name): data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={} for entry in data['nftables']: rule=entry.get('rule',{}) if 'comment' in rule: for expression in rule['expr']: if 'counter' in expression:found[rule['comment']]=expression['counter']['packets'] return found def server(stack,name,address,port=18080,label='server'): code=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json,socket,sys address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3] class Handler(BaseHTTPRequestHandler): def do_GET(self): body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode() self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) def log_message(self,*args):pass class Server(ThreadingHTTPServer): address_family=socket.AF_INET6 if ':' in address else socket.AF_INET def server_bind(self): if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) super().server_bind() service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever() ''' process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label]) assert line(process).strip()=='ready';return process def request(name,address,port=18080): code=r''' import http.client,json,sys client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7) try: client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read()) assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body})) except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno})) finally:client.close() ''' return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout) with lab(['blue','green']) as (root,stack,setting): probe='import json,os,socket;print(json.dumps({"net":os.readlink("/proc/self/ns/net"),"pid":os.readlink("/proc/self/ns/pid"),"hostname":socket.gethostname()}))' parent=json.loads(run('python3','-c',probe).stdout) views={name:json.loads(ns(name,'python3','-c',probe).stdout) for name in ['blue','green']} assert len({parent['net'],views['blue']['net'],views['green']['net']})==3 assert all(v['pid']==parent['pid'] and v['hostname']==parent['hostname'] for v in views.values()) for name in ['blue','green']: ip(name,'link','add','dummy0','type','dummy');ip(name,'addr','add','192.0.2.10/24','dev','dummy0');ip(name,'link','set','dummy0','up') ip('blue','route','add','203.0.113.0/24','dev','dummy0') assert json.loads(ip('blue','-j','route','show','203.0.113.0/24').stdout) assert not json.loads(ip('green','-j','route','show','203.0.113.0/24').stdout) setting('blue','net.ipv4.ip_forward',0);setting('green','net.ipv4.ip_forward',1) forwarding={name:ns(name,'sysctl','-n','net.ipv4.ip_forward').stdout.strip() for name in ['blue','green']};assert forwarding=={'blue':'0','green':'1'} marker=root/'shared.txt';marker.write_text('shared filesystem') assert ns('blue','cat',str(marker)).stdout==ns('green','cat',str(marker)).stdout=='shared filesystem' daemon=server(stack,'blue','192.0.2.10',label='blue') named=request('blue','192.0.2.10');assert named['ok'] and named['body']['server']=='blue' run('ip','netns','delete','blue');assert not Path('/run/netns/blue').exists() assert daemon.poll() is None and os.readlink(f'/proc/{daemon.pid}/ns/net')==views['blue']['net'] by_pid=run('nsenter','-t',str(daemon.pid),'-n','python3','-c', 'import http.client,json; c=http.client.HTTPConnection("192.0.2.10",18080,timeout=1);c.request("GET","/");r=c.getresponse();print(json.dumps({"status":r.status,"body":json.loads(r.read())}));c.close()') surviving=json.loads(by_pid.stdout);assert surviving['status']==200 and surviving['body']['server']=='blue' print(json.dumps({'parent':parent,'namespace_views':views,'blue_only_route':True,'forwarding':forwarding, 'same_file_visible':True,'before_name_deleted':named,'name_deleted_process_alive':True,'after_name_deleted':surviving})) """ name='xbcatm-netpath-'+uuid.uuid4().hex[:10] def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'Dockerfile').write_text(recipe) build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN', '--memory','256m','--pids-limit','128',name,'python3','-',input=program) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),sort_keys=True)) finally: docker('rm','-f',name);docker('image','rm',name) 父环境、blue 和 green 报告三个不同网络命名空间标识,但 PID 命名空间标识与主机名保持相同。父进程创建的标记文件,也能被两个子范围读取。这些正向观察明确了本例边界:网络范围改变了,而所检查的这些进程和文件系统属性仍然共享。不能由网络标识不同,推导其他所有隔离也已经建立。 ...

二月 19, 2028 · 2 分钟 · XB Field Notes
跟随子进程能看到两份缺失文件,但只有必需文件决定程序失败。

strace 解释选定的系统调用边界

strace 在选定 Linux 进程与内核之间的边界上观察系统调用和信号,不需要重新构建程序,就能展示尝试访问的路径、参数、返回值和错误。应用日志漏掉操作时,这些证据很有帮助;但它不会暴露每个用户态决定,也不会自动指出哪次失败调用导致最终结果。正确诊断仍需把边界行为与程序控制流程联系起来。 坏掉的样子 父进程可能启动子进程执行真正工作。只追踪父进程,会看到等待和退出,却漏掉子进程中的文件打开失败。使用 -f 跟随新建进程可以扩大范围,但不会自动追踪所有既有兄弟进程或请求涉及的外部服务。被选中的进程集合,本身就是证据范围的一部分,不能只看到主命令被追踪就认定全部参与者都在视野内。 错误返回不一定是根因。程序经常尝试可选文件、搜索多个位置,并从预期失败中恢复。找到第一条 ENOENT,或者退出前最后一次不成功调用,都不能替代理解应用控制流程。真正需要回答的是,这个返回值如何影响正在调查的操作。相同错误名在不同分支里,可能分别代表正常探测和不可继续的条件。 过滤与计时也会改变解释。把输出限制为文件打开,就有意排除了网络及其他活动。追踪还可能改变调度和延迟,尤其影响系统调用频繁的工作负载,没有通用固定降速倍数。过滤后的记录看起来正常,不能证明被排除操作成功,也不能证明没有追踪器时会出现完全相同的竞争时序。 快速验证 把脚本保存为 straceprobe.py,在安装 strace 的一次性 Linux 环境中运行 python3 straceprobe.py。实测使用 Linux 6.12.76、Python 3.11.2 和 strace 6.1。样本启动自己的无害程序,使用临时配置路径,并要求环境允许追踪新启动子进程: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,sys child=r''' from pathlib import Path import sys try:Path(sys.argv[1]).read_bytes() except FileNotFoundError:pass try:content=Path(sys.argv[2]).read_bytes() except FileNotFoundError:sys.exit(7) assert content==b'configured' ''' parent='import subprocess,sys\nchild='+repr(child)+'\nsys.exit(subprocess.run([sys.executable,"-c",child,*sys.argv[1:]]).returncode)' with TemporaryDirectory(prefix='xbcatm-strace-') as directory: root=Path(directory);optional=root/'optional.conf';required=root/'required.conf' command=[sys.executable,'-c',parent,str(optional),str(required)] def trace(name,follow): output=root/(name+'.trace') args=['strace']+(['-f'] if follow else [])+['-tt','-T','-yy','-s','200','-e','trace=openat,close','-o',str(output),'--',*command] result=subprocess.run(args,capture_output=True,text=True,timeout=10) return result.returncode,output.read_text(),result.stderr code,unfollowed,error=trace('parent-only',False) assert code==7 and str(required) not in unfollowed,(code,error) code,followed,error=trace('with-children',True) assert code==7,(code,error) def calls(text,path):return [line for line in text.splitlines() if 'openat(' in line and '"'+str(path)+'"' in line] for path in [optional,required]: lines=calls(followed,path);assert len(lines)==1 and 'ENOENT' in lines[0],lines print('failing run:',lines[0]) print('without -f the parent trace misses the child opens; following the child exposes both missing paths') required.write_bytes(b'configured') code,recovered,error=trace('recovered',True);assert code==0,(code,error) assert 'ENOENT' in calls(recovered,optional)[0] assert ' = -1 ' not in calls(recovered,required)[0] print('successful run:',calls(recovered,optional)[0]) print('successful run:',calls(recovered,required)[0]) print('a successful application can contain ENOENT; the fixture distinguishes optional lookup from required configuration') summary=root/'summary' result=subprocess.run(['strace','-f','-c','-e','trace=openat','-o',str(summary),'--',*command],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr text=summary.read_text();assert 'openat' in text and 'total' in text print('syscall summary:\n'+text) 应用子进程先尝试可选配置文件,允许它不存在;随后读取第二份必需配置,缺失时以状态七退出。父进程返回子进程状态。这构成一份完整失败案例,原因可以直接从样本代码查明,不依赖工作区中根本不存在的占位应用。判断追踪结果时,已知控制流程提供了可以比较的依据。 ...

二月 14, 2028 · 1 分钟 · XB Field Notes
用户任务表以所属账号执行,系统任务表则另外指定执行账号。

Cron 系统任务表与用户任务表有不同契约

Cron 可以从不止一种任务表读取计划。用户任务表属于某个账号,而系统任务表能够为每一条记录指定运行账号。把一行原样复制到另一种表中,如果没有调整字段,cron 对命令起点的理解就会变化。排查看似正确的五字段日历表达式之前,应先确认正在编辑的到底是哪种任务表,而不是只检查分钟和小时。 账号列改变了解析边界 传统用户记录依次填写分钟、小时、月内日期、月份、星期,再接命令正文,由 crontab 命令安装给选定用户。Debian 的 /etc/crontab 和 /etc/cron.d 下文件,则在五个日历字段之后额外增加用户名。这个用户名属于系统表格式,并不是可以随意放进用户任务表的说明性字段;多写或少写都会改变后续解释。 月份限制仍然必须满足。当月内日期与星期都受到限制时,Debian/Vixie 的日期规则允许满足其中之一,但月份与星期并不是二选一。把表达式迁移到带秒字段或采用其他日期组合方式的调度产品前,应核对具体方言。表面上相似的空格布局,不能证明两个调度器提供相同语义。 系统任务文件还受所有权、权限、名称及末尾换行要求约束。Debian 中应采用 root 所有、组和其他用户不可写且名称被接受的文件,不要把任意备份后缀当成有效任务名。/etc/cron.daily 等周期目录通常通过发行版安排和 run-parts 被调用,不是每份脚本都独立当作 crontab 解析。实际运行时间需要查看本机已安装的调度安排。 坏掉的样子 在 root 用户自己的任务表中,五字段后再写 root,意味着让 shell 执行一个名为 root 的命令。任务表安装成功,不证明这个命令存在。反过来,系统表漏掉账号列,就会把命令文字当成用户名。审阅时需要同时检查解析边界与最终身份,尤其是任务会写文件或读取受保护数据时,不能仅凭“都是 root 配置的”推断执行账号。 任务环境同样是一份契约。不要默认交互别名、shell 启动文件或启动 cron 的父进程环境都会传给任务。需要的值应明确配置,同时选择合适的 PATH 和解释器。cron.d 文件中的环境也不应被认为自动继承 /etc/crontab 中的设置。验证时要采用真正的运行账号检查权限与输出,避免管理员手工执行成功掩盖低权限任务失败。 ...

一月 12, 2028 · 2 分钟 · XB Field Notes
未加引号的展开会悄悄把一个字符串拆成多个参数。

Shell 的字段拆分把未加引号的变量拆成多个参数

变量保存的是一个字符串,但没有引号的展开可能把它变成多个命令参数。Bash 的分词阶段使用 IFS 指定的分隔字符检查展开结果,因此,手动引用后能正常使用的路径,放入变量再传递时却可能突然找不到文件。排查时首先应观察接收命令实际拿到的参数数量和每个参数的内容,也要区分空字符串与完全没有参数,不能仅凭终端上看起来相同的一行文本判断输入一致。 是否分词取决于上下文 默认 IFS 包含空格、制表符和换行符,它们的连续组合会分隔字段,开头和结尾的这类空白也会被忽略。未引用变量里有两个单词时,命令通常收到两个参数;变量为空时,则通常连一个参数都没有。使用 "$value" 会保留一个参数,即使值是空字符串。对于命令行接口,显式传入空值和省略选项值可能触发完全不同的行为。 分词针对适用上下文中的未引用参数展开、命令替换和算术展开结果,并不是重新解析整条命令。普通赋值语句 copy=$value 会保留原字符串,不执行分词和文件名展开。但随后把 $copy 不加引号地作为命令参数使用,仍会重新引入两个问题。Bash 双方括号条件表达式也具有不同于普通命令参数的展开规则,所以赋值或条件判断成功,不能证明后面的外部程序调用也安全。 IFS 是一组分隔字符,不是多字符分隔串,也不是正则表达式。非空白分隔符和空白的处理并不相同,例如连续两个冒号可以产生一个空字段,而连续默认空白通常只是一个字段边界。把 IFS 设为空字符串会关闭分词,但不会把未引用的空展开变成显式空参数。若数据格式有引号、转义或嵌套结构,应使用理解该格式的解析器,不能把调整 IFS 当作通用解析方法。 坏掉的样子 关闭分词并没有关闭随后的文件名展开。即使 IFS 为空,变量里的星号仍可能匹配当前目录下的文件;目录里刚增加一个文件,同一条命令的参数数量就会改变。引用变量能够同时阻止这两种转换,而修改 IFS 只处理了其中一个阶段。控制通配符的 shell 选项也独立存在,不能把它们和分词配置混为一谈。 另一个常见问题是用一个字符串保存整组参数,然后往字符串内部塞引号。Bash 不会在变量展开后把这些字符重新当作 shell 源码里的引号,因此它们不能恢复原有参数边界。不要用 eval 补救:它会增加一次 shell 解析,让原本的数据获得执行命令的机会。需要在 Bash 中逐步组装命令时,使用数组保存各项,并通过 "${args[@]}" 传递,每项内容和空值都能独立保留。 ...

十二月 31, 2027 · 2 分钟 · XB Field Notes
更大的页面粒度,把更多地址空间塞进更小的 TLB。

巨型页用灵活性换更少的 TLB 缺失

数据库及高吞吐服务往往因此受益,但要背负需要连续内存、池子要预先算好大小的成本——这是一笔架构账,不是免费的。 为什么 4 KB 会吃亏 默认页是 4 KiB,TLB 能容纳的条目又很有限。管理百万级工作集的大型应用,很容易在访问一个页面时就 miss 掉一个 TLB 表项,于是不得不到页表里去翻。这里的成本不是页本身,而是反复的页表遍历;一旦工作集的错杀超过 TLB 回填的速度,大应用就会明显吃到痛。更大的页让每条表项覆盖更多内存,同样大小的工作集约只需要很少的表项,这正是吃内存沉重的负载最需要的缓解。 hugetlb 与 THP 的区别 想要大页有两条路。hugetlb 让你从一块预留的内存里自己分配,页的大小是确定的(常见 2 MiB 或更大),行为可预期、由你掌控;transparent(THP)则在后台自动把映射整合成大页,省心,却可能在某些负载上带来延迟尖峰、碎片与写时复制的问题。对延迟敏感的业务,hugetlb 让你自己抱住那份池和那份 trade-off,而不是把决定权丢给一个你不知道用不用得着的通用策略。选哪条路,其实是在决定「可预测性由谁掌握」——你,还是内核。 池子要提前量 大页的真正收益,建立在它被真用满、并且真的常驻内存的前提上。一页 2 MiB 若只用了很小一部分,却仍占着一个整页表项,被锁住的那块内存就等同于浪费,而池子到底定多大,才是真正的功夫。给池子只留那份最诚实、最稳定使用的内存,而不是凭感觉很轻率地划一大块,并要为「稀疏大页」附带的多占 RAM 留出余量。当这些都成立——页被用满、工作集收得小、居住集成一两个表项——大页的收益才完整兑现;要是把它喂给几乎填不满的负载,那就只是一笔平白消耗。 坏掉的样子 池子给小了,失败多在启动瞬间。数据库要的共享缓冲超过预留大页能覆盖的范围,进程会直接退出,常见报错是无法分配内存;此时系统空闲内存可能还很充足,容易误判。关键是显式大页不能自动回退到小页,缺几页就是缺几页。做法是按应用文档的需求加少量余量定池子,重启后核对总页数与已预留数,而不是只看启动参数。 启动正常、运行中分配失败,多半是碎片。大页要求物理连续的整块区间,机器跑久之后,空闲总量充足,却拼不出完整区间,新映射会被拒绝。典型信号是空闲大页数停在大于零不再下降,同时内核日志出现大页分配失败;此时重试映射没有意义,应回到启动阶段预留或用启动参数固定池子。 更隐蔽的是位置放错与自动整理。池子落在远端节点上,每次命中都要跨互联访问,省下的表项优势会被延迟吃掉,绑定前要先看分节点分布。透明大页则是另一种风险:后台整理与写时复制拆分会把平稳的尾延迟打成尖峰,从合并等待与整理线程占用能看到痕迹。延迟敏感服务建议设为按需启用,只在实测有效的地方打开。 快速验证 下面脚本只读内存信息与系统页大小,不做任何分配,也不需要特权,因此在无特权容器里同样可直接运行。若环境没有内存信息文件或不支持大页,它会打印跳过提示并继续给出页表项换算,而不是抛异常退出。 把下面代码保存为大页检查脚本并运行,即可看到本机覆盖率与固定工作集的表项对比。脚本先解析总页数、空闲数、已预留数、溢出数与单页大小,算出预留覆盖总量;再用系统调用得到的基准页大小,对比同样工作集在基准页与大页下各需多少表项,估算省下的数量。 #!/usr/bin/env python3 """Report hugepage coverage and estimated TLB entries saved (read-only).""" import os import re from pathlib import Path WORKING_SET_BYTES = 8 * 1024**3 # 8 GiB example working set def parse_meminfo(text): vals = {} for key in ("HugePages_Total", "HugePages_Free", "HugePages_Rsvd", "HugePages_Surp", "Hugepagesize"): m = re.search(r"^" + key + r":\s+(\d+)", text, re.M) if m: vals[key] = int(m.group(1)) return vals def main(): vals = {} try: text = Path("/proc/meminfo").read_text(encoding="utf-8") except OSError as exc: print(f"SKIP: /proc/meminfo unavailable ({exc}); showing page-table math only") text = "" if text: try: vals = parse_meminfo(text) if "HugePages_Total" not in vals or "Hugepagesize" not in vals: print("SKIP: no HugePages fields in /proc/meminfo (hugepages unsupported or disabled)") else: size_kb = vals["Hugepagesize"] total = vals["HugePages_Total"] free = vals.get("HugePages_Free", 0) rsvd = vals.get("HugePages_Rsvd", 0) surp = vals.get("HugePages_Surp", 0) cov_mb = total * size_kb / 1024 print(f"Hugepagesize: {size_kb} kB") print(f"HugePages total/free/rsvd/surp: {total}/{free}/{rsvd}/{surp}") print(f"Reserved coverage: {cov_mb:.1f} MiB ({cov_mb/1024:.2f} GiB)") except Exception as exc: print(f"SKIP: failed to parse /proc/meminfo ({exc})") try: base = os.sysconf("SC_PAGE_SIZE") except (AttributeError, ValueError, OSError): base = 4096 huge = vals.get("Hugepagesize", 2048) * 1024 base_entries = WORKING_SET_BYTES // base huge_entries = WORKING_SET_BYTES // huge print(f"Base page size (sysconf): {base} bytes") print(f"8 GiB needs ~{base_entries} base-page entries vs ~{huge_entries} huge-page entries") print(f"Estimated TLB entries saved: ~{base_entries - huge_entries}") if __name__ == "__main__": main() 在支持大页的系统上,预期能看到单页大小、各类计数与预留覆盖容量,以及两种粒度各自的表项数和节省量;在笔记本或极简容器里,则先看到一行跳过提示,再看到同样的表项换算,且本地基准页可能不是常见的四千字节。该脚本已在缺少内存信息文件的环境验证通过。它只衡量预留量与潜在节省,不能证明真实缺失下降、碎片或节点距离,调池子前还要用内核日志、分节点计数与延迟分位交叉确认。 ...

十二月 21, 2027 · 2 分钟 · XB Field Notes