fork 从父进程 P 分出新子进程 C,P 继续运行。成功 exec 在 C 内替换程序映像,并保留同一子进程 PID。

fork 创建进程,exec 替换其中的程序

fork 后接 exec,是 Unix 常见的进程启动模型之一。fork 创建子进程,成功的 execve 则替换调用进程中的程序映像。它们解决不同问题,也具有不同继承规则。在一个程序为另一个命令安排输入、输出、环境和生命周期管理时,必须分清这两个动作,否则很容易把创建新身份与替换运行内容混为一谈。 fork 创建子进程,成功 exec 替换其中的程序。私有内存、共享资源、继承描述符和显式环境分别遵循不同边界;需要验证启动行为,也要观察子进程实际完成。 坏掉的样子 fork 不会让之后所有状态都相互独立。普通私有内存通常通过写时复制逐渐分离,但共享映射仍然共享。继承描述符也继续引用父进程对应的同一份打开文件描述,因此任一侧读取普通文件,都可能改变另一侧观察到的偏移。复制描述符表,不等于为每个文件重新独立执行 open;进程不同,也不能证明资源状态完全不同。 exec 同样不会简单保留全部启动设置。带 close-on-exec 的描述符在成功执行时关闭,其他符合条件的描述符可以继续存在。execve 接收显式环境数组,不是自动复制调用者当前环境。被捕获信号的处理方式会重置,旧内存映射也会消失,即使进程身份和若干其他属性仍然保留。需要逐项理解哪些状态跨越了替换边界。 ...

十月 12, 2027 · 2 分钟 · XB Field Notes
可执行文件属主经 exec 条件检查影响有效与保存 UID,真实 UID 保持不变。

Setuid 在 exec 时改变有效身份

Linux 可执行文件上的 setuid 位,可以在 exec 成功时把进程的有效用户 ID 改为文件属主的 ID。它不会把进程全部凭据都替换成属主的登录身份。特权辅助程序因此能够代表另一个账户完成受控操作,但输入处理和特权持续时间都会成为安全边界的一部分。模式位本身既不能证明身份转换已经发生,也不能证明这个辅助程序安全。 文件位与进程凭据分开观察 通常的 set-user-ID 转换会改变有效 UID,再将结果复制到保存的 set-user-ID;真实 UID 保持不变。组凭据是另一套状态:setuid 不会自动装入文件属主的附加组。Linux 还存在文件系统身份和能力集合,因此真实、有效与保存 UID 是有用的观察,却不是权限的完整描述。不能看到有效身份变成某个账户,就假设已经拥有该账户登录会话的全部上下文。 应检查实际部署的文件,不要推断整个发行版都采用同一权限。用于修改密码的特权程序,在容器或定制镜像里可能有不同安装方式。属主执行位位置的小写 s 表示同时具备 setuid 和属主执行位,大写 S 则表示存在 setuid 但没有那个执行位。两种显示都不能替代属主核验、目标文件核验,以及对真实启动上下文的检查。 执行环境可以抑制转换 Linux 文档列出了 no_new_privs、nosuid 挂载和被跟踪等条件,它们可能抑制特权转换;某些文档记录的组合也可能直接让执行失败。文件能力同样受到执行约束。不能为了让演示成功,就关闭这些保护。应确定目标路径与调用进程实际适用哪些政策,如实说明限制,而不是把受保护环境当作必须修复的故障。 Linux 忽略解释器脚本上的 set-user-ID 与 set-group-ID 位。因此,把原生辅助程序改成脚本并保留模式,不会保留原先的提权行为。给通用解释器增加特权也不是安全替代方案。特权程序另外启动的可执行文件,还可能主动重置凭据,所以运行独立的 id 命令,不一定忠实显示原辅助程序内部的身份。观测点必须与要验证的进程一致。 ...

十月 2, 2027 · 2 分钟 · XB Field Notes
sticky 目录限制同级用户删除目录项;内容写入走独立路径,由文件模式决定。

sticky bit 限制共享目录中的删除操作

Linux 目录上的 sticky bit,为删除或重命名目录项增加所有权限制。它常与宽松的目录写入、搜索权限一起使用,让多个用户能够创建临时文件,又不能任意删除彼此的条目。普通目录权限检查和 sticky 所有权检查都需要满足;sticky 是额外条件,不会替代正常访问控制,也不能只凭这一位判断整个目录里的数据是否安全。 坏掉的样子 没有 sticky 时,用户只要具备目录所需的写入与搜索权限,通常就能删除另一个用户的文件,即使文件模式完全不允许内容访问。unlink 改变的是目录里的名字与对象关系,不要求调用者能够读取或覆盖文件内容。因此,在广泛可写目录中把文件设为 000,并不能单独保护该路径不被他人删除;应分别检查父目录和文件自身的规则。 启用 sticky 后,普通用户还需要拥有该条目或包含它的目录,才能执行相关删除。具有适当 CAP_FOWNER 能力的特权进程,可以绕过这项所有权限制。因此 sticky 约束的是具体权限模型下的同级用户,并不抵御所有管理员,也不保护文件免受目录所有者删除。把它描述为绝对隔离,会夸大真实保证。 sticky 不阻止本来被允许的内容访问。一个用户可能无法 unlink 某个 0666 文件,却仍然可以读取或覆盖它。新建子目录也不会自动继承父目录的 sticky 位。混淆路径保护、内容隐私和继承,会让共享目录树表现出与顶层看起来完全不同的行为,需要沿实际数据位置检查,而不是只看根部权限字符串。 sticky 限制目录项删除,并有所有权与能力例外。文件模式另行控制内容;实验结果是预期,不是新观察。 快速验证 把脚本保存为 sticky.py,只在获准的一次性 Linux 环境中启用断言运行 python3 sticky.py。控制进程需要 root 权限以分配三个数字身份并启动普通子进程,不会创建账号。历史文章报告 Linux 6.12.76 与 Python 3.11.2,但未找到原始日志,本轮也没有重跑。文件系统所有权、UID 映射与子进程能力必须符合预期权限模型,不能只看数字身份: ...

十月 1, 2027 · 2 分钟 · XB Field Notes
父进程导出的 local 产生两份子进程环境副本。子进程 A 将自己的 local 改为 child;子进程 B 与父进程仍保留 local。箭头只指向子进程,没有修改回传箭头。

环境变量属于各自的进程

环境是程序启动时收到的一组名称与字符串值。子进程通常接收父进程选定的环境,修改自己的副本不会回写父进程或兄弟进程。这是一条有用的配置边界,却不是命令没有副作用的保证:程序仍可能根据配置修改共享文件、数据库或其他资源。理解变量如何传递,与判断程序实际会做什么,是两个需要分别核对的问题。 沿直接启动者传入的值检查。环境副本不会回写;但子 Shell、新执行的 Bash 程序与当前 Shell 中的点命令,保留和修改的状态并不相同。 导出决定另一个程序能收到什么 Shell 变量与环境条目有关联,但并不是同一概念。DEMO_SETTING=local 创建或更新 Shell 参数,export DEMO_SETTING 才把它标记为启动外部命令时应包含的环境项。父 Shell 能打印变量,不代表新启动的 Bash 就能看到未导出的值。把赋值写进某个配置文件,也不会让每个 Shell 自动读取它;还必须确认文件是否在对应启动路径上被加载。 Bash 从环境继承的变量已经带有导出属性,之后重新赋值会保留该属性。因此子进程修改值后,再启动孙进程时可以直接传递新值,不要求每一代重复写一次导出命令。unset 删除变量,Bash 的 export -n 则取消导出属性但保留当前 Shell 中的值。真正需要确认的是直接启动者传入了什么,而不是机械地搜索祖先脚本里出现了多少次导出语句。 ...

九月 25, 2027 · 1 分钟 · XB Field Notes
对于外部可执行文件,裸命令词 tool 沿外部查找路径选择文件;./tool 则沿独立的直接路径选择指定文件。

shell 找不到那条裸命令时

“找不到命令”说明一次查找失败,却不代表 Shell 每次都遍历了 PATH 的全部目录。在普通 Bash 中,不带斜杠的命令词可能对应函数、内建命令、缓存的可执行路径,或者刚刚搜索到的程序。更早的输入读取阶段,别名还可能已经改写命令。面对报错,应先检查真正发生故障的那个 Shell,而不是立即重装一个其实已经存在的工具。 在真正出错的 Shell 中检查解析结果。函数、内建命令、路径缓存和 PATH 顺序可能选择不同命令;含斜杠时直接选择路径。选中命令不等于执行成功。缓存顺序来自原文示例,不是本轮新做的运行测试。 先确认是哪一层查找给出结果 展开之后,Bash 先检查函数,再检查内建命令,之后才查找外部程序。type name 描述当前解析结果,type -a name 可以看到其他定义或候选项。command -v name 适合检查该 Shell 中是否存在某个命令,但结果不保证是文件路径,也可能说明它是函数或内建命令。因此不能无条件把输出交给复制文件或读取文件的工具。 ...

五月 20, 2027 · 1 分钟 · XB Field Notes
本地准入闸口拒绝新工作,已接受的一个请求仍被计数。释放后完成,到期则明确放弃。

优雅关闭:停止准入,再排空工作

优雅关闭改变的是服务接收与完成工作的方式,信号只是触发条件。它能够提供的保证比“绝不丢失任何东西”更有限,也更具体:停止接收新的业务工作,记录此前已经接受的任务,在有界时间内等待完成,并在期限耗尽时如实报告剩余工作。仅仅捕获一个信号,并不等于已经实现安全关闭。 信号开始的是协调过程 应用可以处理 SIGTERM,然后启动自己的关闭流程;SIGKILL 则不能捕获、阻塞或忽略。首先应确认谁真正收到了停止信号:如果外层包装进程收到信号却不转发,内部工作进程即使安装了正确的处理器,也可能根本没有机会执行它。 信号处理器应保持简短。Python 在主解释器的主线程执行信号处理函数,并明确警告不要在其中使用同步锁。实验只设置一个标记,把修改就绪、关闭准入和等待任务的工作留给普通控制流程,不在信号处理器里执行网络操作或等待工作线程。 应该尽早撤下就绪,让路由变更开始传播,但本地业务准入仍需要独立闸口,因为已有连接和传播延迟都可能带来后续请求。关闭闸口与登记已经接受的任务,必须使用一致的边界;否则看似已经停止接收,实际上却可能漏记同时进入的新任务。 截止期限约束完整生命周期 任务应一直记账到真正的完成点。调用方等待超时,不证明工作线程已经停止;响应已经发送,也不证明业务事务已经持久化。应根据操作契约选择完成边界,而不是把任意一次连接关闭都解释成成功,更不能仅凭客户端离开就提前回收在途任务。 进程内的经过时间应由单调时钟计算,并在监督器实际允许的时间内为清理留出余量。期限到达后,对剩余工作必须有明确的取消、放弃或可靠移交政策。从等待循环里跳出来,本身不会终止正在运行的线程,也不会撤销已经执行的业务效果。 正常排空和未完整关闭,应在记录中保持可区分。某个非零退出码是服务自身的约定,不是能够保证正确恢复的通用规则。客户端没有拿到响应时,仍可能需要根据幂等契约或业务记录确认结果,不能仅凭服务正在关闭就断言重试一定安全。 与监督器核对同一份契约 systemd 的 TimeoutStopSec 管理配置中的停止命令等待和服务停止等待;KillMode 选择被终止的进程范围,不决定排空能持续多久。mixed 模式首先向主进程发送终止信号,最后的强制终止覆盖控制组内剩余进程;主进程提前退出,也可能触发这一升级。真实 unit 中的停止钩子和通知延长机制必须一起检查。 Kubernetes 在执行已配置的 preStop 钩子之前,就开始计算终止宽限时间,钩子不是额外领取另一份独立预算。EndpointSlice 的终止状态更新与本地关闭同时推进,并不是完成一次全局同步屏障后,从此再也不会有请求到达。因此,端点变更不能替代应用自己的准入判断。 实际停止信号也可能受到镜像或运行时配置影响。应测试部署中的进程树和停止约定,而不是把本地直接执行程序的结果扩大为容器、sidecar 与代理协调全部正确的证据。这些组件各自的关闭顺序和宽限时间,需要沿真实调用链核对。 坏掉的样子 一个只修改就绪状态的关闭标记,可能让已有的持久连接继续不断提交新工作;反过来,立即切断全部活动连接,又会丢掉原本有时间完成的请求。停止新的业务准入,与完成已经获准的工作,是两个需要分别控制和观察的决定。 无界等待线程可能超过监督器允许的时长。守护线程允许进程退出时不等待它们,但这意味着放弃,不等于安全取消或事务回滚。库自身也有关闭契约:Python socketserver 的 shutdown 必须从不同于 serve_forever 的线程调用,否则会造成死锁,关闭路径反而永远无法结束。 应同时观察接受、完成、拒绝和未完成的工作,以及关闭耗时和退出结果。只记录一个“退出成功”,却不区分这些数量,可能掩盖真正丢掉的任务。验收对象应该是完整生命周期,而不是那一行信号注册代码或某个看起来正常的退出状态。 停止新业务准入,并将已接受工作计到真实完成边界。原文区分请求完成与刻意放弃守护线程;不证明部署中的监督器协调。 快速验证 保存为 shutdown_check.py,在类 Unix 系统上用 Python 3.10 以上版本执行 python3 shutdown_check.py,并启用断言。2026-10-18,两个语言版本各在 CPython 3.14.4 / Darwin 25.5.0 arm64 环境运行一次;其他版本未测试。每种情况创建子进程和回环 HTTP 服务,SIGTERM 只发给该子进程;POST release 仅用于测试协调,不是生产管理接口。 ...

十二月 19, 2026 · 4 分钟 · XB Field Notes
保留的命名卷跨停止再启动与容器替换存在;可写层只随原容器保留,tmpfs 内容在停止时消失。

让持久状态和临时空间拥有不同生命周期

持久应用状态和可丢弃临时文件,需要不同的保留、容量与恢复策略。分开路径有助于表达这些策略,但目录本身不会创造持久性,也不会隔离磁盘容量。真正决定进程重启、容器替换、宿主故障或清理后保留什么的,是路径背后的存储。可靠布局需要把每类数据连接到明确生命周期,而不是仅仅给文件夹起两个不同名字。 坏掉的样子 如果清理任务把混合目录全部当作可丢弃内容,可能在删除临时下载时一起删除已提交记录。相反,也可能备份了大量可重建缓存,却漏掉真正重要的小数据库。文件扩展名和年龄不能代替对应用所有权的理解;需要知道路径由谁管理,以及其内容是否真的能从权威来源重新构造,才能决定能否删除。 容器重启和替换也不同。停止再启动同一个容器,通常保留其可写文件系统层;删除容器才会丢弃该层,而明确保留的命名卷可以继续存在。tmpfs 又有不同生命周期,容器停止时其内容会消失。把这三类位置统称为临时,会掩盖影响恢复流程的区别,导致测试重启成功后就误以为替换也安全。 同一个文件系统里的两个目录,仍然竞争该文件系统的剩余空间。只把临时路径从 /data/tmp 改成 /scratch,却不改变存储策略,并不会自动保护数据库写入。容量隔离需要合适的挂载、配额或其他可执行预算,同时监控应区分临时空间增长与持久状态增长。路径整齐不能替代真正的资源边界。 停止再启动与容器替换对应不同存储生命周期。原文另检查受限临时挂载与同卷数据库副本重开;两者均不是独立灾难恢复证明。 快速验证 先取得固定镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16 提供 SQLite 模块。它创建一个私有命名卷和八 MiB 的 tmpfs 临时挂载,只删除自身容器与卷。小规模临时空间耗尽被限制在实验内部,不修改既有服务存储: import subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def execute(container,code):return docker('exec',container,'python','-c',code).stdout.strip() name='xbcatm-state-'+uuid.uuid4().hex[:10];volume=name+'-data';containers=[name+'-one',name+'-replacement'] try: docker('volume','create',volume) def start(container): docker('run','-d','--name',container,'--network','none','--mount','type=volume,src='+volume+',dst=/state','--tmpfs','/scratch:rw,size=8m',BASE,'python','-c','import time;time.sleep(180)') start(containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;c=sqlite3.connect("/state/data.sqlite");c.execute("create table records(value text)");c.execute("insert into records values (?)",("committed",));c.commit();c.close();Path("/ephemeral").write_text("container-layer");Path("/scratch/crumb").write_text("scratch")') docker('stop','--time','1',containers[0]);docker('start',containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;assert Path("/ephemeral").exists();assert not Path("/scratch/crumb").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('stop/start retains the container writable layer and volume data, while the tmpfs scratch mount loses its file') docker('rm','-f',containers[0]);start(containers[1]) execute(containers[1],'from pathlib import Path;import sqlite3;assert not Path("/ephemeral").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('replacement loses the old writable layer but retains the named-volume database') execute(containers[1],'''import errno,os,sqlite3 fd=os.open('/scratch/fill',os.O_CREAT|os.O_WRONLY,0o600) try: try: while True:os.write(fd,b'x'*1048576) except OSError as error:assert error.errno==errno.ENOSPC finally:os.close(fd) source=sqlite3.connect('/state/data.sqlite');source.execute('insert into records values (?)',('after-scratch-full',));source.commit() backup=sqlite3.connect('/state/check-copy.sqlite');source.backup(backup);backup.close();source.close() restored=sqlite3.connect('/state/check-copy.sqlite');assert restored.execute('select value from records order by rowid').fetchall()==[('committed',),('after-scratch-full',)];restored.close() ''') print('bounded scratch reaches ENOSPC while state still commits; a SQLite backup copy reopens with both committed records') finally: docker('rm','-f',*containers,check=False);docker('volume','rm',volume,check=False) 第一个容器在 /state 下创建 SQLite 数据库并提交一条记录,在普通可写层写入 /ephemeral,再把临时文件放进 /scratch。三个路径都通过普通文件接口访问,背后却是不同存储位置。持久应用记录通过 SQLite commit 提交,不依赖尚未关闭的输出缓冲,从而让后续生命周期对照具有明确数据边界。 ...

十二月 3, 2026 · 2 分钟 · XB Field Notes
三列获胜来源把 host 127.0.0.1 对应文件、port 9001 对应环境、mode prod 对应命令行;明确优先级为文件低于环境,环境低于命令行。

命名的配置文件偏爱清晰

配置容易找、易读、可 diff,这不只是一种偏好;它正是变更保持可评审的方式。用清晰的约定命名文件,是迈向环境特定、单一来源配置的第一步。 文件是一份意图记录 一份带名字、进仓库、能追溯版本的配置文件,本身就是一份意图记录:已经提交的取值是显式可查的,改动也像代码一样被 review、被 diff。项目历史与 pull request 说明了哪些内容发生变化;若要确认实际运行的配置,还必须记录部署版本与运行时覆盖,仓库本身不能揭示这些状态。清晰的命名约定(应用参数放 app.conf、环境形态放 app.env)让每份文件的用途一眼可见,而不是被四处散落的命令行参数悄悄改写。 环境变量与文件解决不同问题 环境变量与文件要解决的是不同问题。变量的优势在于同一份构建可以部署到很多环境而不必改镜像——运行时按环境注入不同的值。文件的好处是能表达结构化的配置,而不是用一串拼字符串的变量去硬凑。多数团队两者并用:文件负责结构,环境负责在部署时注入值。让这套做法保持诚实的是——真值的来源明确,结构与可注入值之间的边界是刻意划开的,而不是随手撞出来的。 密钥仍然要移出去 把配置文件放进仓库,并不等于就允许把密钥一起提交。任何属于凭据的东西,都必须在运行时被注入或从密钥仓库里引用,绝不能写死在你提交进去的那个文件里。配置与密钥在架构上就该分开:文件管形态,密钥库管机密。存储加密、传输保护与访问控制仍需单独配置,环境注入也不等于加密。启动日志可以记录键名与获胜来源,但敏感取值必须脱敏,不能把整份生效配置无条件打印。 坏掉的样子 配置名漂移会让加载器读错文件。部署时写下的是 app-prod.json,程序启动时打开的却是 app.prod.json,预期文件根本不存在,加载器又悄悄回退到一份陈旧的开发配置。服务看似正常启动,实际连错了数据库,而版本差异里没有任何痕迹,因为错的是文件名而不是某个具体的值。正确做法是启动时只解析一条显式路径并把它打印出来,找不到就直接报错退出,绝不去猜测某个名字相近的兄弟文件。 环境变量覆盖则是另一个方向的静默失败。APP_PORT 被写成空字符串,或者把下划线误写成连字符,加载器可能用空值覆盖掉文件里的正确值,也可能什么都不覆盖,具体行为取决于实现。无论哪一种,运行时的真实值都已经和仓库里的文件对不上了,却又没有留下任何差异记录,排查的人反复阅读配置文件,只会得出什么都没变的错误结论。正确做法是为每个配置键记录赢家来源,明确区分文件、环境与命令行参数,并在启动时打印出来,明确拒绝已经提供的空值,不悄悄退回旧来源;本例还检查未知应用键、类型、模式与端口范围。 密钥进仓库是最贵的一类失败。有人为了临时修复预发布环境,把携带生产凭据的 .env 提交上去,下一次提交又把它删掉,以为事情已经过去。实际上凭据还留在版本历史、镜像与备份里,中间每一次克隆都把它带走了,后续删除并不能收回已经扩散的秘密。正确做法是提交的文件只保留形态,把敏感位置写成 password = "from-store" 这样的占位符,真实值只从密钥库或运行时环境注入,并用提交前扫描钩子在凭据落地之前就把它拦下来,否则清理历史与轮换密钥的代价会远超当初图方便省下的时间。 展示每个值的获胜来源。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 把下面程序保存为 config_precedence_probe.py,然后运行 python3 config_precedence_probe.py。它需要 Python 3.7 或更新版本,只用标准库,在临时目录里写多组 JSON 示例,不会触碰任何真实配置。运行时不要使用 -O、-OO 或非零的 PYTHONOPTIMIZE,以免断言被跳过。好样本从 {"host": "127.0.0.1", "port": 8000, "mode": "dev"} 出发,先通过模拟环境的字典传入 APP_PORT=9001 覆盖端口,再通过参数列表传入 --mode prod 覆盖模式,最后断言合并结果与每个键的来源映射完全符合文件小于环境、环境小于参数的优先级。 ...

十一月 21, 2026 · 2 分钟 · XB Field Notes