Linux-03 目录结构与路径解析:FHS 的设计骨架与软链接下的路径陷阱
上一篇讲软件怎样通过包管理器进入系统,这一篇继续解决「东西该放哪、路径怎么解析」。同样先看五个问题:
/usr里放的全是系统程序,为什么叫 usr(user)?/bin和/usr/bin有什么区别?为什么现代系统里/bin只是个软链接?- 自己编译的软件装
/usr/local还是/opt?Go 服务的日志、配置、pid 文件各放哪? cd /a/b/../c和cd /a/c一定等价吗?pwd和pwd -P什么时候输出不一样?
前三个的答案在 FHS 这份标准里,后两个的答案在软链接身上。
1. FHS:目录结构不是约定俗成,是一份标准
目录结构叫 FHS(Filesystem Hierarchy Standard,文件系统层次标准),现行 3.0 版。它规定了根目录下每个目录放什么,各发行版遵守它,所以你在 Ubuntu 上学的目录知识在 Rocky 上同样适用。
ls / --group-directories-first
# bin boot dev etc home lib lib64 media mnt opt proc root run
# sbin srv sys tmp usr var
1.1 FHS 的设计骨架:两个维度
死记 20 个目录没有意义。FHS 真正的设计逻辑是用两个维度给数据分类,这才是「所以然」:
| 静态(装完就不变) | 可变(运行时会改) | |
|---|---|---|
| 可共享(多台机器能用同一份) | /usr、/opt |
/home、/var/mail |
| 不可共享(每台机器必须有自己的) | /etc、/boot |
/var/log、/var/run、/proc |
这个 2×2 的划分不是学院派分类,它直接决定了运维手段:
# ① /usr 是【静态可共享】 -> 可以只读挂载,也可以多机共享一份
mount -o remount,ro /usr
# ^^ 只读挂载 /usr 是嵌入式设备和高安全环境的常规做法:
# 系统程序不可能被篡改,因为分区本身就是只读的
# 容器镜像的分层设计也是同一个思路:/usr 那层是只读的镜像层
# ② /etc 是【静态不可共享】 -> 每台机器的配置不同,但装完基本不变
# 所以 /etc 应该进版本控制(etckeeper),也应该重点备份
# ③ /var 是【可变不可共享】 -> 唯一会持续增长的地方
# 所以生产机的标准做法是给 /var 单独分区,防止日志打满根分区导致系统不可用
df -h /var
# /dev/vdb1 100G 12G 88G 12% /var
# ^^^^^^^^ 独立分区。就算日志写爆了,/ 还有空间让你 ssh 进来处理
# ④ /home 是【可变可共享】 -> 所以企业里常用 NFS 挂载家目录,
# 用户在任意一台机器登录都能看到自己的文件
记住这条:能只读挂载的(/usr)、必须本机独有的(/etc)、会不断长大的(/var)——分区规划和备份策略都是按这三类来的。
1.2 根目录下每个目录的职责
| 目录 | 全称 / 来源 | 放什么 | 服务端是否常打交道 |
|---|---|---|---|
/bin |
binaries | 基本命令(ls、cp、bash)。现代系统是 /usr/bin 的软链接 |
间接 |
/sbin |
system binaries | 系统管理命令(fdisk、ip、iptables) |
是 |
/usr |
Unix System Resources | 绝大多数程序、库、文档。系统的主体 | 是 |
/etc |
etcetera(“其他”) | 所有配置文件 | 非常 |
/var |
variable | 会变化的数据:日志、数据库、缓存、队列 | 非常 |
/run |
run(time) | tmpfs,运行时数据:pid 文件、unix socket。重启清空 | 是 |
/tmp |
temporary | 临时文件,重启清空,所有人可写(带 Sticky 位) | 是 |
/home |
— | 普通用户家目录 | 是 |
/root |
— | root 的家目录(不是根目录,根目录是 /) |
是 |
/opt |
optional | 第三方整包软件(自带完整目录结构的) | 是 |
/srv |
service | 本机对外提供的服务数据(实际很少用) | 少 |
/proc |
process | 虚拟文件系统:进程与内核信息,不占磁盘 | 非常 |
/sys |
sysfs | 虚拟文件系统:设备与内核对象,cgroup 在这 | 是 |
/dev |
devices | 设备文件(/dev/null、/dev/sda) |
是 |
/boot |
— | 内核镜像、initramfs、引导器配置 | 少(第 19 篇讲) |
/lib /lib64 |
libraries | 共享库。同样是 /usr/lib 的软链接 |
间接 |
/media |
— | 可移动介质的自动挂载点(U 盘、光盘) | 少 |
/mnt |
mount | 临时手动挂载点 | 是 |
/lost+found |
— | fsck 修复时找回的孤儿文件(每个 ext 分区都有一个) |
极少 |
2. 历史包袱:/bin、/usr/bin、/usr/local/bin 的三层
2.1 /usr 为什么叫 usr
开篇第一问。教科书上说 /usr = Unix System Resources,但这是后来编的解释(backronym)。真实历史是:
1970 年代初,Unix 跑在 PDP-11 上,配的 RK05 硬盘只有 1.5MB。系统装着装着,根分区满了。开发者(Ken Thompson 和 Dennis Ritchie)就把一部分内容挪到第二块盘上,而那块盘当时挂在
/usr——它原本是放用户家目录的地方(就是字面意思 user)。于是
/bin装不下的程序被放进了/usr/bin,/lib放不下的库进了/usr/lib。这个纯粹因为硬盘太小而产生的分裂,被后世当成了设计,一直延续了 50 年。
分裂之后,人们需要给它编一个理由,于是有了这套说法:
/bin、/sbin、/lib放启动过程必需的东西(因为它们在根分区上,开机就能用)/usr放其他的(/usr可能是单独分区甚至网络挂载,挂载时机较晚)
这个理由在 initramfs(初始内存文件系统)出现后彻底失效了——现在系统启动时先加载 initramfs,由它负责挂载所有分区,等真正的根文件系统就位时 /usr 早就挂好了,「启动必需」的划分不再有意义。
2.2 usrmerge:把分裂合并回去
于是 2012 年起(Fedora 17 带头,Debian/Ubuntu 后来跟进)有了 usrmerge 改革:把 /bin、/sbin、/lib 全部变成指向 /usr/ 下对应目录的软链接。
ls -ld /bin /sbin /lib /lib64
# lrwxrwxrwx 1 root root 7 Apr 22 2023 /bin -> usr/bin
# lrwxrwxrwx 1 root root 8 Apr 22 2023 /sbin -> usr/sbin
# lrwxrwxrwx 1 root root 7 Apr 22 2023 /lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Apr 22 2023 /lib64 -> usr/lib64
# ^^^^^^^^^^^^^^^^ 全是软链接
# 所以这两条命令跑的是【同一个文件】
ls -i /bin/ls /usr/bin/ls
# 2097347 /bin/ls 2097347 /usr/bin/ls <- inode 号相同
这解释了一个常见困惑:为什么有的脚本写 #!/bin/bash,有的写 #!/usr/bin/bash,两个都能跑?因为它们指向同一个文件。但注意可移植性:
#!/bin/bash # 大多数 Linux 通用(/bin 一定存在)
#!/usr/bin/bash # 在没做 usrmerge 的老系统上可能不存在
#!/usr/bin/env bash # ✅ 最可移植:去 PATH 里找 bash
# macOS 上 bash 在 /opt/homebrew/bin,这种写法才能兼容
顺带澄清 sbin 的 s:常见说法是 super user,但更准确的历史来源是 statically linked binaries(静态链接的程序,那时候为了在库还没挂载好时也能运行)。现在它的实际含义是 system binaries——系统管理类命令,非 root 通常也能执行只是干不了实事。现代发行版(Debian 13)甚至在推进把 /usr/sbin 也合并进 /usr/bin。
2.3 三层的现代分工:装软件到底该装哪
这才是每天要做的决策:
| 位置 | 谁在管 | 什么时候用 |
|---|---|---|
/usr/bin、/usr/lib |
包管理器(apt/dnf) | 你不该手动往这写东西——下次升级会被覆盖,还会让包管理器状态错乱 |
/usr/local/bin、/usr/local/lib |
你(系统管理员) | 手动编译安装(make install 默认前缀就是 /usr/local)、下载的单个二进制 |
/opt/<软件名>/ |
软件自己 | 自带完整目录结构的第三方整包(如 /opt/google/chrome) |
~/.local/bin |
你(仅当前用户) | 不想影响其他用户,或者没有 root 权限时 |
# 场景一:手动装 Go(官方 tarball 建议解压到 /usr/local)
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# ^^^^^^^^^^^^^^^^^ Go 是"自带目录结构"的,所以放 /usr/local/go 整个目录
# 场景二:编译安装某个工具(GNU 三部曲)
./configure --prefix=/usr/local # 默认就是它,会装到 /usr/local/{bin,lib,share,include}
make && sudo make install
# 卸载困难是这种方式的最大问题(没有清单),所以更推荐 checkinstall 或直接用包管理器
# 场景三:下载的单个二进制
sudo install -m 755 kubectl /usr/local/bin/kubectl
# ^^^^^^^ 用 install 而不是 cp:一步完成复制 + 权限(见 04 篇 10.14)
# 场景四:自己写的 Go 工具,只给自己用
go install github.com/x/tool@latest # 装到 $(go env GOPATH)/bin,即 ~/go/bin
export PATH=$PATH:$(go env GOPATH)/bin
一条铁律:永远不要手动往 /usr/bin 里塞文件。 它属于包管理器,你放进去的东西会在系统升级时被覆盖或造成文件冲突,而且 dpkg -S/rpm -qf 查不到归属,接手的人无从判断这个文件哪来的。
3. 关键目录详解
3.1 /etc:所有配置的家
etc 来自 et cetera(拉丁语「等等、其他」)——最初 Unix 就是把不属于任何分类的杂项丢这,结果它成了配置文件的标准位置。
/etc/
├── passwd, shadow, group # 用户与组(第 10 篇)
├── hosts, resolv.conf, hostname # 网络基础(第 15 篇)
├── fstab # 开机挂载表(第 11 篇)
├── crontab, cron.d/ # 定时任务(第 14 篇)
├── sudoers, sudoers.d/ # 提权规则(第 10 篇)
├── ssh/sshd_config # SSH 服务端配置(第 16 篇)
├── systemd/system/ # systemd 单元文件(第 14 篇)
├── nginx/, mysql/ # 各软件自己的配置目录
├── profile, profile.d/, bash.bashrc # 全局 shell 配置
├── default/ (Debian 系) # 服务的环境变量与启动参数
└── sysconfig/ (RHEL 系) # 同上,RHEL 的叫法
有三个模式值得注意:
① conf.d/ 目录模式。 现代软件都支持「主配置文件 + 一个目录里的碎片文件」:
grep -r 'include' /etc/nginx/nginx.conf
# include /etc/nginx/conf.d/*.conf;
# include /etc/nginx/sites-enabled/*;
# 为什么这么设计?因为它让【自动化】变得安全:
# 加一个站点 = 往目录里丢一个文件(幂等、可回滚、不用改主文件)
# 而不是用 sed 去主配置文件里插一段(容易改坏、无法回滚、并发写会冲突)
# 所以你的部署脚本应该这样做
sudo tee /etc/nginx/conf.d/myapp.conf > /dev/null <<'EOF'
server { listen 8080; location / { proxy_pass http://127.0.0.1:3000; } }
EOF
sudo nginx -t && sudo systemctl reload nginx
# ^^ 永远先测试语法再 reload
② /etc/default/(Debian)与 /etc/sysconfig/(RHEL)。 存放服务的启动参数和环境变量,与主配置分开,避免升级时冲突:
cat /etc/default/grub # GRUB 的配置在这,改完要 update-grub
③ /etc 应该进版本控制。 生产机上配置漂移(drift)是事故的常见来源:
sudo apt install etckeeper # 自动把 /etc 变成 git 仓库,每次 apt 操作自动提交
cd /etc && sudo git log --oneline | head
# 谁在什么时候改了什么配置,一目了然
3.2 /var:唯一会持续长大的地方
/var/
├── log/ # 日志 ← 磁盘爆满的头号元凶
├── lib/ # 程序的持久化数据(/var/lib/mysql、/var/lib/docker)
├── cache/ # 缓存,删了不影响正确性(只是变慢)
├── spool/ # 队列:待发邮件、打印任务、cron 任务
├── tmp/ # 临时文件,【跨重启保留】
├── run -> /run # 软链接,见下
└── www/ # 网站文件(有些发行版放这)
区分 lib 和 cache 很重要:/var/cache 里的东西删掉只影响性能,/var/lib 里的删掉就丢数据了。 清理磁盘时先动 cache:
sudo du -xh --max-depth=1 /var | sort -h | tail
# 12G /var/lib <- 数据,慎动
# 8.2G /var/log <- 日志,可清
# 3.1G /var/cache <- 缓存,随便清
sudo apt clean # 清 /var/cache/apt/archives 里的 deb 包
sudo journalctl --vacuum-size=500M # 把 journal 日志压到 500MB 以内
sudo docker system prune -a # 清未使用的镜像(/var/lib/docker 常是最大头)
3.3 /run:为什么 pid 文件从 /var/run 搬走了
/run 是一个 tmpfs(内存文件系统),重启后一定是空的:
findmnt /run
# TARGET SOURCE FSTYPE OPTIONS
# /run tmpfs tmpfs rw,nosuid,nodev,noexec,relatime,size=1608468k,mode=755
# ^^^^^ 内存文件系统,不落盘
ls /run
# containerd/ docker.sock lock/ nginx.pid sshd.pid systemd/ user/ utmp
ls -ld /var/run
# lrwxrwxrwx 1 root root 4 Aug 12 22:00 /var/run -> /run <- 历史兼容软链接
为什么要从 /var/run 搬到 /run? 因为启动顺序:/var 可能是独立分区(甚至 NFS),在启动早期还没挂载;但 udev、systemd 这些早期服务就已经需要写 pid 文件和 socket 了。把它放在根分区下的 tmpfs(/run)就没有这个依赖。
顺带解决了另一个问题:tmpfs 保证重启后一定干净。旧时代 /var/run 在磁盘上,机器异常断电后会残留陈旧的 pid 文件,导致服务启动时误判「已有实例在运行」而拒绝启动。
# 你的 Go 服务如果要写 pid 或 unix socket,正确位置是
/run/myapp/myapp.pid
/run/myapp/myapp.sock
# 目录需要提前创建,systemd 提供了声明式的方式(不用自己 mkdir)
# /etc/systemd/system/myapp.service
# [Service]
# RuntimeDirectory=myapp <- systemd 自动创建 /run/myapp,停止时自动删除
# RuntimeDirectoryMode=0750
3.4 /tmp 与 /var/tmp 的区别
/tmp |
/var/tmp |
|
|---|---|---|
| 生命周期 | 重启即清空 | 跨重启保留 |
| 实现 | 很多发行版挂成 tmpfs(在内存里) | 一定在磁盘上 |
| 大小限制 | tmpfs 时受内存限制(默认物理内存的一半) | 受分区大小限制 |
| 用途 | 短命的临时文件 | 需要熬过重启的中间文件(如大型编译的中间产物) |
| 权限 | 1777(人人可写 + Sticky 位) |
1777 |
findmnt /tmp
# /tmp tmpfs tmpfs rw,nosuid,nodev,size=8065536k <- 是 tmpfs,写它等于写内存
ls -ld /tmp
# drwxrwxrwt 20 root root 4096 Aug 12 22:00 /tmp
# ^ 这个 t 是 Sticky 位:人人可写,但【只能删自己的文件】
# 没有它,任何人都能删别人的临时文件(第 10 篇讲)
两个实战要点:
# ① /tmp 是 tmpfs 时,往它写大文件 = 吃内存,可能触发 OOM
dd if=/dev/zero of=/tmp/big bs=1M count=10000 # 💀 直接吃掉 10GB 内存
# 大文件应该写 /var/tmp 或专门的数据目录
# ② systemd 服务默认可能启用 PrivateTmp,此时服务看到的 /tmp 不是你看到的 /tmp
systemctl show nginx -p PrivateTmp
# PrivateTmp=yes
# -> nginx 的 /tmp 实际是 /tmp/systemd-private-xxx-nginx.service-yyy/tmp
# 这解释了"程序说文件写在 /tmp 但我找不到"的困惑
sudo ls /tmp/systemd-private-*/tmp/
3.5 /proc 与 /sys:不占磁盘的虚拟文件系统
这两个目录里的「文件」不存在于任何磁盘上——它们是内核在你读取的瞬间生成的。
df -h /proc /sys
# Filesystem Size Used Avail Use% Mounted on
# proc 0 0 0 - /proc <- 大小是 0
# sysfs 0 0 0 - /sys
ls -l /proc/meminfo
# -r--r--r-- 1 root root 0 Aug 12 22:00 /proc/meminfo
# ^ 大小显示 0,但 cat 出来有几十行 —— 内容是读时才生成的
/proc 里最常用的:
# 系统级
cat /proc/cpuinfo # CPU 详情
cat /proc/meminfo # 内存详情(free 命令的数据来源)
cat /proc/loadavg # 平均负载(uptime 的数据来源)
cat /proc/version # 内核版本
cat /proc/mounts # 当前挂载表(比 /etc/mtab 可靠)
cat /proc/net/tcp # TCP 连接表(ss 的数据来源之一)
ls /proc/sys/net/ipv4/ # 内核参数,可读可写(sysctl 操作的就是这里)
# 进程级:/proc/<pid>/ ← 排查问题的金矿
PID=1234
ls -l /proc/$PID/exe # 程序的真实路径(即使被删了也能看到)
ls -l /proc/$PID/cwd # 进程的当前工作目录
ls -l /proc/$PID/fd/ # 打开的所有 fd(查 fd 泄漏、查已删除文件)
cat /proc/$PID/cmdline | tr '\0' ' ' # 完整启动命令(参数用 \0 分隔)
cat /proc/$PID/environ | tr '\0' '\n' # 环境变量(查"配置没生效")
cat /proc/$PID/status # 状态汇总:内存、线程数、uid、信号掩码
cat /proc/$PID/limits # 资源限制(查 too many open files)
cat /proc/$PID/stack # 内核栈(进程 D 状态卡死时看它卡在哪个内核函数)
wc -l < /proc/$PID/maps # 内存映射区域数量
# 自己进程的快捷方式
ls -l /proc/self/cwd
/sys 主要是设备与内核对象,服务端最常打交道的是 cgroup:
cat /sys/fs/cgroup/cpu.max # 容器的 CPU 限额(cgroup v2)
cat /sys/fs/cgroup/memory.max # 内存限额
cat /sys/fs/cgroup/memory.current # 当前用量
cat /sys/block/vda/queue/scheduler # 磁盘 IO 调度器
cat /sys/class/net/eth0/mtu # 网卡 MTU
一个容器里的重要陷阱:/proc 默认没有被容器命名空间隔离,所以容器里 cat /proc/meminfo、nproc、free -h 看到的都是宿主机的数据:
# 容器里(限额 512MB)
free -h
# total used free
# Mem: 62Gi 20Gi 30Gi <- 宿主机的数据!
# 真实限额要读 cgroup
cat /sys/fs/cgroup/memory.max
# 536870912 <- 512MB,这才是真的
# 后果:JVM/Go 的自动内存调优会按 62GB 来算,然后被 OOMKilled
# 解法:Go 设 GOMEMLIMIT,或用 lxcfs 让 /proc 反映 cgroup 值
3.6 /dev:设备也是文件
ls -l /dev/null /dev/zero /dev/urandom /dev/sda
# crw-rw-rw- 1 root root 1, 3 Aug 12 /dev/null <- c = 字符设备
# crw-rw-rw- 1 root root 1, 5 Aug 12 /dev/zero
# crw-rw-rw- 1 root root 1, 9 Aug 12 /dev/urandom
# brw-rw---- 1 root disk 8, 0 Aug 12 /dev/sda <- b = 块设备
# ^^^^ 主设备号,次设备号 —— 内核靠这两个数字找到驱动,
# 而不是靠文件名(文件名只是给人看的)
常用的几个「虚拟设备」:
cmd > /dev/null 2>&1 # 黑洞:丢弃所有输出
dd if=/dev/zero of=f bs=1M count=100 # 零源:产生无限个 \0
head -c 32 /dev/urandom | base64 # 随机数(生成密钥/密码)
# /dev/random vs /dev/urandom:现代内核(5.6+)两者行为已基本一致,
# 直接用 urandom 即可,别再迷信 random 更安全(它只是可能阻塞)
echo "hi" > /dev/tty # 直接写到终端(绕过重定向,脚本里给用户提示用)
cat /dev/stdin # 标准输入(等价 -)
ls -l /dev/std{in,out,err} # 都是指向 /proc/self/fd/{0,1,2} 的软链接
3.7 家目录:隐藏文件的意外起源与 XDG 规范
先讲一个「所以然」:隐藏文件(点文件)是一个 bug 的产物。
早期 Unix 的
ls需要跳过.和..这两条特殊目录项。实现时程序员图省事,写成了「跳过所有以.开头的名字」。于是任何以点开头的文件都被顺带藏了起来——这个偷懒的判断被当成了特性,后来所有程序都开始把配置文件命名成.xxx丢进家目录。Rob Pike 在回忆里明确说过:这是个 bug,不是设计。
后果是家目录变成了垃圾场:
ls -A ~ | head -20
# .bashrc .bash_history .profile .vimrc .gitconfig .ssh .cache
# .config .local .npm .docker .kube .aws .gnupg .python_history ...
ls -A ~ | wc -l # 几十上百个,配置、缓存、数据全混在一起
XDG Base Directory 规范就是来收拾这个烂摊子的,它把家目录下的东西按用途分成四类:
| 环境变量 | 默认值 | 放什么 | 要不要备份 |
|---|---|---|---|
XDG_CONFIG_HOME |
~/.config |
配置(用户手改的东西) | 要,最重要 |
XDG_DATA_HOME |
~/.local/share |
数据(程序生成、丢了会疼) | 要 |
XDG_STATE_HOME |
~/.local/state |
状态(日志、历史记录、上次窗口位置) | 可选 |
XDG_CACHE_HOME |
~/.cache |
缓存(删了只是变慢) | 不用 |
XDG_RUNTIME_DIR |
/run/user/<UID> |
运行时的 socket、锁(tmpfs,登出即清) | 不用 |
ls ~/.config # nvim/ git/ gh/ htop/ systemd/
ls ~/.local/share # nvim/ fish/ containers/
du -sh ~/.cache # 3.2G <- 经常是家目录最大的部分,随时可删
rm -rf ~/.cache/* # 安全操作(程序会自己重建)
echo $XDG_RUNTIME_DIR # /run/user/1000
ls -ld /run/user/1000
# drwx------ 8 lhx lhx 240 Aug 12 22:00 /run/user/1000
# ^^^^^^ 只有你自己能进,由 systemd-logind 在登录时创建、登出时销毁
# podman、pipewire、tmux 的 socket 都放这
这个划分的实际价值:备份和迁移时规则变得极简——备份 ~/.config 和 ~/.local/share,忽略 ~/.cache。而在老式的点文件模式下,你根本分不清 ~/.npm(缓存,40GB)和 ~/.ssh(密钥,必须备份)哪个该带走。
Go 程序应该遵守它(标准库直接支持):
// ✅ 用标准库,自动遵守 XDG(并在 macOS/Windows 上给出对应位置)
cfgDir, _ := os.UserConfigDir() // Linux: ~/.config macOS: ~/Library/Application Support
cacheDir, _ := os.UserCacheDir() // Linux: ~/.cache macOS: ~/Library/Caches
homeDir, _ := os.UserHomeDir()
configPath := filepath.Join(cfgDir, "myapp", "config.yaml")
// ❌ 不要硬编码
configPath := os.Getenv("HOME") + "/.myapp/config.yaml" // 又往家目录扔垃圾了
注意 XDG_RUNTIME_DIR 有个真实的坑:它只在有登录会话时存在。用 su - user 切过去或在 cron 里执行时,这个变量是空的,依赖它的程序(如 rootless podman、systemctl --user)会报错:
sudo -u app systemctl --user status
# Failed to connect to bus: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined
# ✅ 解法:用 machinectl shell 或给该用户开启 linger
sudo loginctl enable-linger app # 让该用户的 /run/user/<uid> 常驻,不随登出销毁
4. 一个 Go 服务的文件应该怎么摆
把 FHS 落到实处。假设服务叫 myapp:
| 内容 | 路径 | 权限 | 理由 |
|---|---|---|---|
| 二进制 | /usr/local/bin/myapp |
755 root:root |
管理员手动装的程序 |
| 配置 | /etc/myapp/config.yaml |
640 root:myapp |
配置在 /etc;含密钥时 other 不可读 |
| 配置片段 | /etc/myapp/conf.d/*.yaml |
640 |
便于自动化增删 |
| 环境变量 | /etc/default/myapp |
640 |
与主配置分离,systemd 的 EnvironmentFile 读它 |
| 持久数据 | /var/lib/myapp/ |
750 myapp:myapp |
丢了就出事的数据 |
| 缓存 | /var/cache/myapp/ |
750 myapp:myapp |
删了只影响性能 |
| 日志 | /var/log/myapp/ |
750 myapp:myapp |
或者直接写 stdout 交给 journald |
| pid / socket | /run/myapp/ |
750 myapp:myapp |
tmpfs,重启自动清 |
| systemd 单元 | /etc/systemd/system/myapp.service |
644 root:root |
管理员定义的单元放这(不是 /lib/systemd) |
| 大型整包安装 | /opt/myapp/{bin,conf,data} |
— | 不遵循 FHS 拆分时的替代方案 |
对应的 systemd 单元(RuntimeDirectory 这类指令让你不用自己 mkdir 和 chown):
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Service
After=network-online.target
[Service]
Type=notify
User=myapp
Group=myapp
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
EnvironmentFile=-/etc/default/myapp
# systemd 自动创建这些目录并设好属主,服务停止时按需清理
RuntimeDirectory=myapp # -> /run/myapp (停止时删除)
StateDirectory=myapp # -> /var/lib/myapp (保留)
CacheDirectory=myapp # -> /var/cache/myapp
LogsDirectory=myapp # -> /var/log/myapp
ConfigurationDirectory=myapp # -> /etc/myapp
# 顺带一提,这几个加固选项建议默认开
ProtectSystem=strict # /usr /boot /etc 只读(呼应 1.1 的"静态可共享")
ProtectHome=yes # 看不到 /home
PrivateTmp=yes # 独立的 /tmp
NoNewPrivileges=yes # 禁止提权
[Install]
WantedBy=multi-user.target
为什么值得遵守这套约定? 三个实际收益:① 备份策略可以简单表达为「备份 /etc 和 /var/lib」;② 权限和 SELinux 上下文有现成的规则可套;③ 接手的人不用问你「数据在哪、配置在哪」。容器里可以简化(配置走环境变量、日志走 stdout),但目录语义仍然沿用同一套。
5. 路径解析:内核怎么一段段走
5.1 绝对路径与相对路径
/var/log/app.log # 绝对路径:以 / 开头,从根目录开始
var/log/app.log # 相对路径:从【当前工作目录】开始
./app.log # 显式相对(. 表示当前目录)
../sibling/f # 上一级
~/project/main.go # ~ 由 shell 展开成 $HOME,【内核不认识 ~】
~root/.ssh # 展开成 root 用户的家目录
注意 ~ 是 shell 的展开,不是路径语法。这带来一个常见的坑:
# ✅ 能用:shell 展开了 ~
ls ~/data
# ❌ 不能用:引号阻止了展开,内核收到的是字面量 "~/data"
ls "~/data"
# ls: cannot access '~/data': No such file or directory
# ✅ 正确写法
ls "$HOME/data"
ls ~/"data with space" # ~ 在引号外,路径其余部分在引号内
内核解析一条路径时,是**逐段(component by component)**走的,每一段都要:查目录 → 找到 inode → 检查执行权限(x)→ 继续下一段。这就是 04 篇讲的那条链:
/var/log/app.log
│ │ └── 在 log 目录里查 "app.log" -> inode
│ └────── 在 var 目录里查 "log" -> inode,需要 var 有 x 权限
└────────── 从根 inode(固定为 2)开始
由此得到两个重要推论:
① 路径中间的每一级目录都需要 x(执行/通行)权限,否则整条路径走不通——哪怕最终文件是 777。这是「权限明明给了却还是 Permission denied」的头号原因,5.4 节给排查工具。
② 路径越深,open() 的开销越大(要多走几级)。内核用 dentry cache 缓解,但在超深路径 + 冷缓存时仍可测量到差异。
5.2 路径的语法细节
# ① 多个连续斜杠等价于一个
ls /var//log///app.log # 与 /var/log/app.log 完全相同
# 例外:开头恰好两个斜杠(//)POSIX 允许实现自定义含义,Linux 上仍等价于 /
# ② 末尾斜杠 = 断言"这必须是个目录"
ls /etc/ # 正常
ls /etc/passwd/ # ls: cannot access: Not a directory
# 实用价值:mv src dst/ 能确保 dst 是目录,避免把 src 重命名成 dst 文件
# ③ 长度限制
getconf PATH_MAX / # 4096 整条路径的最大字节数
getconf NAME_MAX / # 255 单个文件名的最大字节数(注意是字节,中文占 3 字节)
# 中文文件名最多 85 个字(255/3)
python3 -c "print(len('很长的中文名'.encode()))"
# ④ 路径里什么字符都行,除了 / 和 \0(见 04 篇第 8 节)
5.3 . 和 .. 是真实存在的目录项
它们不是 shell 的语法糖,而是每个目录里真实存在的两条记录:
ls -ai /tmp | head -3
# 2621441 . <- 指向自己
# 2 .. <- 指向父目录(这里 /tmp 的父是 /,inode 号 2)
# 2621500 somefile
stat -c '%i' /tmp /tmp/. /tmp/..
# 2621441
# 2621441 <- . 和 /tmp 是同一个 inode
# 2 <- .. 就是 / 的 inode
这解释了 04 篇那个「空目录硬链接数为 2」的现象,也解释了根目录的特殊性:
stat -c '%i' / /.. /../..
# 2
# 2 <- 根目录的 .. 指向它自己(到顶了就不再往上)
# 2
5.4 排查「Permission denied」:namei
这是本篇最实用的一个工具。当某个路径访问被拒,用 namei -l 逐层列出权限,一眼看出是哪一级挡住的:
namei -l /var/www/html/index.html
# f: /var/www/html/index.html
# dr-xr-xr-x root root /
# drwxr-xr-x root root var
# drwx------ root root www <- 就是这一层!other 没有 x,任何非 root 都进不去
# drwxr-xr-x root root html
# -rw-r--r-- root root index.html
# ^^^^^^^^^^ 文件本身是 644 人人可读,但根本走不到这
# 参数
namei -l path # l = long,显示权限、属主、属组
namei -m path # 只显示权限
namei -x path # 用 ? 标记不存在的部分
namei -o path # 显示属主
namei /path/to/link # 会把软链接的每一跳都展开显示
namei 对软链接的展示尤其有用:
namei -l /opt/app/current/bin/server
# f: /opt/app/current/bin/server
# drwxr-xr-x root root /
# drwxr-xr-x root root opt
# drwxr-xr-x root root app
# lrwxrwxrwx root root current -> releases/v1.2.3 <- 展开了软链接
# drwxr-xr-x root root releases
# drwxr-xr-x root root v1.2.3
# drwxr-xr-x root root bin
# -rwxr-xr-x root root server
没有 namei 时的替代方案:
# 手工逐层看
d=/var/www/html/index.html; while [ "$d" != / ]; do ls -ld "$d"; d=$(dirname "$d"); done
# 或者用 sudo -u 直接验证某用户能不能访问(最直接)
sudo -u www-data test -r /var/www/html/index.html && echo OK || echo DENIED
sudo -u www-data ls /var/www/html/
6. 软链接让路径解析变得诡异
开篇第四、五问。这一节的所有现象都源于一件事:shell 记的路径(逻辑路径)和内核认的路径(物理路径)可能不一致。
6.1 cd /a/b/../c 不一定等于 cd /a/c
先造一个场景:
mkdir -p /tmp/demo/real/sub /tmp/demo/other
ln -s /tmp/demo/real /tmp/demo/link # link 是指向 real 的软链接
cd /tmp/demo/link
pwd
# /tmp/demo/link <- shell 说我在 link 里
cd ..
pwd
# /tmp/demo <- shell 认为上一级是 /tmp/demo
看起来很正常。但换成 -P 模式,结果完全不同:
cd -P /tmp/demo/link # -P = physical,先解析成真实路径再进去
pwd
# /tmp/demo/real <- 直接落到真实目录
cd ..
pwd
# /tmp/demo <- 这次上一级是 real 的父目录,恰好也是 /tmp/demo
真正会出问题的是软链接跨目录时:
mkdir -p /tmp/x/deep/dir /tmp/y
ln -s /tmp/x/deep/dir /tmp/y/mylink
cd /tmp/y/mylink && pwd # /tmp/y/mylink (逻辑路径)
cd .. && pwd # /tmp/y (shell 按字符串算的)
cd -P /tmp/y/mylink && pwd # /tmp/x/deep/dir (物理路径)
cd .. && pwd # /tmp/x/deep (真实的父目录!完全不同)
为什么会这样? 因为有两套 .. 的处理逻辑:
shell 的默认行为(-L 逻辑模式) |
内核 / -P 物理模式 |
|
|---|---|---|
.. 怎么算 |
纯字符串操作:把 $PWD 末尾一段去掉 |
真的去读目录里的 .. 条目,得到真实父目录 |
| 记录的路径 | 你走过来的路径(含软链接名) | 真实路径 |
# 验证:内核(也就是任何外部程序)看到的永远是物理路径
cd /tmp/y/mylink
pwd # /tmp/y/mylink <- bash 内建的 pwd,读的是 $PWD 变量
/bin/pwd # /tmp/x/deep/dir <- 外部程序,调 getcwd() 问内核
pwd -P # /tmp/x/deep/dir <- 显式要物理路径
echo $PWD # /tmp/y/mylink <- $PWD 只是 shell 维护的一个字符串
ls -l /proc/$$/cwd
# lrwxrwxrwx ... /proc/1234/cwd -> /tmp/x/deep/dir <- 内核记的也是物理路径
所以第五问的答案是:pwd 是 bash 内建,默认输出 shell 维护的逻辑路径(含软链接);pwd -P 和 /bin/pwd 输出内核记录的物理路径。当路径里有软链接时两者不同。
这个差异在脚本里是真实的 bug 来源:
# ❌ 危险:在有软链接的路径下,用 .. 拼路径可能指向意料之外的地方
cd "$APP_DIR/current" # current 是软链接
rm -rf ../old_data # 你以为删的是 $APP_DIR/old_data
# 但如果用了 cd -P 或在子进程里执行,可能删到别处
# ✅ 安全:先解析成绝对物理路径再操作
APP_ROOT=$(cd -P "$APP_DIR" && pwd)
rm -rf "$APP_ROOT/old_data"
# 全局改变 shell 的默认行为(很少用,但要知道有)
set -o physical # 让 cd/pwd 默认走物理模式
set +o physical # 恢复默认(逻辑模式)
shopt -s autocd # 顺带一提:直接敲目录名就能 cd 进去
6.2 四个路径解析命令的区别
mkdir -p /tmp/p/real && ln -s /tmp/p/real /tmp/p/link
cd /tmp/p
| 命令 | 作用 | /tmp/p/link |
不存在的路径 |
|---|---|---|---|
readlink link |
只读一层链接的内容 | /tmp/p/real |
报错 |
readlink -f X |
递归解析,最后一层可以不存在 | /tmp/p/real |
仍输出结果 |
readlink -e X |
递归解析,所有部分必须存在 | /tmp/p/real |
无输出,退出码 1 |
readlink -m X |
递归解析,完全不检查存在性 | /tmp/p/real |
输出结果 |
realpath X |
等价 readlink -e,语义更清晰,推荐 |
/tmp/p/real |
报错 |
realpath -m X |
允许不存在 | — | 输出结果 |
realpath -s X |
只做词法规范化(去掉 .、..),不解析软链接 |
/tmp/p/link |
— |
realpath /tmp/p/link # /tmp/p/real 解析软链接
realpath -s /tmp/p/link # /tmp/p/link 不解析
realpath --relative-to=/tmp /tmp/p/real # p/real 算相对路径
realpath -m /nonexistent/a/b # /nonexistent/a/b 不检查存在
readlink -f /proc/self/exe # 当前进程的真实程序路径
readlink -f "$(command -v python3)" # python3 最终指向哪个版本 ← 排查多版本必用
# /usr/bin/python3.10
# 脚本里获取自身所在的真实目录(标准写法)
SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f "$0")")" && pwd)
# ^^^^^^^^^^^ 先解析软链接,
# 否则脚本被软链接调用时会拿到链接所在目录而不是真实目录
6.3 软链接循环与 40 层限制
ln -s a b && ln -s b a # 造一个环
cat a
# cat: a: Too many levels of symbolic links
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 对应错误码 ELOOP
# 内核的保护:解析一条路径时最多跟随 40 层软链接
cat /proc/sys/fs/... # (这个限制是编译期常量 MAXSYMLINKS=40,不可调)
# 检测环
realpath a
# realpath: a: Too many levels of symbolic links
find /opt -follow 2>&1 | grep -i loop # find 会报告检测到的环
这也解释了 04 篇提到的「为什么硬链接不能指向目录」:硬链接成环时没有任何计数器能发现(因为它们是平等的目录项,不像软链接有跟随层数可数),递归工具会真的无限循环下去。软链接允许指向目录,正是因为有这个 40 层的可检测边界。
7. 目录跳转的效率技巧
cd # 不带参数 = 回家目录(等价 cd ~ 或 cd $HOME)
cd - # 回到【上一个】目录(在两个目录间来回切最常用)
cd -P dir # 物理模式进入
cd -e # 配合 -P:如果 getcwd 失败则返回非零(脚本里防御用)
# 目录栈:适合"临时去别处,办完事回来"
pushd /var/log # 进入 /var/log,并把当前目录压栈
# ~/project /var/log <- 打印当前栈
pushd /etc/nginx # 再压一层
dirs -v # 带编号查看栈
# 0 /etc/nginx
# 1 /var/log
# 2 ~/project
popd # 弹栈并回到 /var/log
popd # 回到 ~/project
cd ~2 # 直接跳到栈里第 2 个(配合 dirs -v 的编号)
# CDPATH:给 cd 加搜索路径(谨慎使用)
export CDPATH=".:$HOME/projects"
cd myapp # 会在当前目录找不到时,去 ~/projects/myapp
# ⚠️ 坑:CDPATH 里【必须】把 . 放第一个,否则 cd somedir 可能跳到意想不到的地方;
# 脚本里绝对不要设 CDPATH,会让 cd 的行为不可预测
# 现代工具(需安装):按使用频率智能跳转
# zoxide:z log -> 直接跳到最常用的含 log 的目录
# autojump / fasd 同类
8. 知识点扩展
8.1 cd — change directory
全称:cd = change directory。shell 内建命令(原因见 01 篇 3.2:必须改自己进程的 cwd)。
| 参数 | 全称 | 作用 |
|---|---|---|
| (无) | — | 回 $HOME |
- |
— | 回上一个目录($OLDPWD),并打印新路径 |
-L |
logical | 默认:保留路径中的软链接名 |
-P |
physical | 解析软链接,进入真实目录 |
-e |
— | 配合 -P,若 getcwd() 失败则返回非零退出码 |
-@ |
— | 把文件的扩展属性当目录访问(极少用) |
cd /var/log && cd - && cd - # 在两个目录间反复横跳
cd -P /opt/app/current # 进入软链接指向的真实目录
(cd /tmp && ls) # ✅ 用子 shell 临时切换,不影响当前 shell 的 cwd
最后这个技巧很有用:用括号包起来就是子 shell,里面的 cd 不影响外面。脚本里比「cd 过去再 cd 回来」安全得多(后者在中途出错时回不来)。
8.2 pwd — print working directory
| 参数 | 作用 |
|---|---|
(无)/ -L |
打印逻辑路径($PWD 的值,含软链接名)——bash 内建的默认行为 |
-P |
打印物理路径(问内核要,getcwd()) |
pwd; pwd -P; /bin/pwd # 三者在有软链接时可能不同
echo $PWD # shell 变量,等价 pwd -L
echo $OLDPWD # 上一个目录,cd - 用的就是它
readlink /proc/self/cwd # 内核视角的真实 cwd
8.3 realpath / readlink
见 6.2 的对比表。补充常用参数:
| 命令 | 参数 | 作用 |
|---|---|---|
realpath |
-e |
所有部分必须存在(默认) |
-m |
允许不存在 | |
-s / --no-symlinks |
只做词法规范化 | |
--relative-to=DIR |
输出相对于 DIR 的路径 | |
--relative-base=DIR |
在 DIR 内部时输出相对路径,否则输出绝对路径 | |
-q |
静默(出错不打印) | |
-z |
输出以 \0 结尾(配合 xargs -0) |
|
readlink |
-f / -e / -m |
三种存在性策略(见上表) |
-n |
不输出末尾换行 | |
-z |
\0 结尾 |
8.4 namei — 逐层解析路径
全称:namei = name to inode(follow a pathname until a terminal point is found)
| 参数 | 全称 | 作用 |
|---|---|---|
-l |
--long |
显示权限、属主、属组(最常用) |
-m |
--modes |
只显示权限 |
-o |
--owners |
只显示属主 |
-x |
--mountpoints |
用 D 标出挂载点 |
-v |
--vertical |
竖排输出 |
-n |
--nosymlinks |
不跟随软链接 |
输出首字母的含义:f: 起始路径、d 目录、l 软链接、- 普通文件、? 出错(权限不足或不存在)。
8.5 挂载点相关:findmnt / mount / df
判断「这个目录属于哪个文件系统」时用:
| 命令 | 作用 |
|---|---|
findmnt |
树形列出所有挂载点(比 mount 好读) |
findmnt /path |
查某个路径属于哪个挂载点 |
findmnt -t ext4,xfs |
只看指定类型 |
findmnt --df |
带用量的挂载表 |
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS |
自定义列 |
mount |
列出挂载(旧式,输出杂乱) |
cat /proc/mounts |
内核视角的挂载表(最可靠,脚本用) |
df -hT /path |
看用量与类型 |
stat -f /path |
看文件系统级信息(块大小、inode 总数) |
lsblk -f |
块设备树 + 文件系统 + UUID + 挂载点 |
mountpoint -q /path |
判断是不是挂载点(脚本里防「往未挂载的目录写数据」) |
findmnt /var/log
# TARGET SOURCE FSTYPE OPTIONS
# /var /dev/vdb1 ext4 rw,relatime
# ^^^^ 说明 /var/log 属于 /var 这个挂载点,不是根分区
# 部署脚本里的防御性检查(数据盘没挂载就往上写,会把根分区写爆)
mountpoint -q /data || { echo "/data 未挂载,中止"; exit 1; }
# 判断两个路径是否在同一文件系统(决定 mv 是否原子、能否硬链接)
[[ $(stat -c '%d' /a) == $(stat -c '%d' /b) ]] && echo "同一文件系统"
8.6 目录栈与其他
pushd DIR # 压栈并进入
pushd # 交换栈顶两个目录
pushd +2 # 把栈里第 2 个转到栈顶并进入
popd # 弹栈并进入新的栈顶
popd +1 # 只删除栈里第 1 个条目,不改变当前目录
dirs -v # 带编号显示目录栈
dirs -c # 清空栈
tree -L 2 -d /etc # 看目录骨架(见 04 篇 10.11)
du -xh -d1 /var | sort -h # 看哪个子目录大(-x 不跨文件系统)
find / -maxdepth 1 -type d # 列出根下所有目录
9. 面试题
Q:FHS 是按什么逻辑组织目录的?为什么 /usr 可以只读挂载而 /var 要单独分区?
FHS 用两个维度给数据分类:静态 / 可变(装完是否还会改)和 可共享 / 不可共享(多台机器能否用同一份)。
/usr 是「静态 + 可共享」——装完就不变,所有机器的内容可以一样。所以它能只读挂载(嵌入式和高安全环境的标准做法,系统程序在物理上就不可能被篡改),也能通过 NFS 多机共享一份。容器镜像的只读层本质上是同一个思路。
/var 是「可变 + 不可共享」——它是系统里唯一会持续增长的地方(日志、数据库、缓存)。给它单独分区的理由是故障隔离:日志写爆了只会撑满 /var,根分区还有空间让你 ssh 进去处理;如果共用根分区,磁盘满了会导致连登录都做不到(登录要写 utmp、要创建临时文件)。
/etc 是「静态 + 不可共享」——每台机器配置不同但装完基本不变,所以它是备份和版本控制的重点(etckeeper)。
Q:/bin 和 /usr/bin 有什么区别?为什么现在 /bin 是软链接?
历史上的区别是「启动必需 vs 其他」:/bin 在根分区,开机就能用;/usr 可能是独立分区甚至网络挂载,挂载时机晚。
但这个分裂的真实起因不是设计,而是硬盘太小——1970 年代 PDP-11 的硬盘只有 1.5MB,根分区装满后开发者把溢出的程序挪到了第二块盘,那块盘恰好挂在 /usr(当时是放用户家目录的地方,就是字面意思 user)。「Unix System Resources」是后来编的解释。
initramfs 出现后,启动早期由它负责挂载所有分区,「启动必需」的划分失去意义。于是 2012 年起有了 usrmerge:/bin、/sbin、/lib 全部变成指向 /usr/ 下对应目录的软链接。可以用 ls -i /bin/ls /usr/bin/ls 验证它们 inode 相同。
实践含义:脚本的 shebang 用 #!/usr/bin/env bash 最可移植(macOS 上 bash 根本不在 /bin)。
Q:/tmp 和 /var/tmp 有什么区别?往 /tmp 写大文件有什么风险?
/tmp 重启即清空,且多数发行版把它挂成 tmpfs(内存文件系统);/var/tmp 在磁盘上,跨重启保留,适合放需要熬过重启的中间产物。两者都是 1777 权限——人人可写,但带 Sticky 位(只能删自己的文件,否则任何人都能删别人的临时文件)。
风险是:/tmp 是 tmpfs 时写它等于吃内存,默认上限是物理内存的一半,往里 dd 一个 10GB 文件可能直接触发 OOM。大文件应该写 /var/tmp 或专门的数据目录。
还有一个容易困惑的点:systemd 服务如果启用了 PrivateTmp=yes,它看到的 /tmp 是 /tmp/systemd-private-xxx-服务名-yyy/tmp,与你在 shell 里看到的不是同一个目录——这解释了「程序说文件写在 /tmp 但我找不到」。
Q:/run 是什么?为什么 pid 文件和 socket 要放这里而不是 /var/run?
/run 是挂在根分区下的 tmpfs,存放运行时数据(pid 文件、unix socket、锁文件),重启后必定为空。/var/run 现在只是指向它的兼容软链接。
搬家的原因是启动顺序:/var 可能是独立分区甚至 NFS,在启动早期尚未挂载,但 udev、systemd 这些早期服务就已经需要写 pid 文件了。放在根分区的 tmpfs 上就没有这个依赖。
附带解决了另一个老问题:tmpfs 保证重启后干净,不会像磁盘上的 /var/run 那样在异常断电后残留陈旧 pid 文件,导致服务误判「已有实例在运行」而拒绝启动。
服务端实践:Go 服务的 pid/socket 放 /run/myapp/,用 systemd 的 RuntimeDirectory=myapp 声明,systemd 会自动创建并在服务停止时清理,不用自己 mkdir + chown。
Q:cd /a/b/../c 和 cd /a/c 一定等价吗?pwd 和 pwd -P 什么时候不同?
不一定等价——当 b 是指向别处的软链接时就不同。
根源是有两套 .. 的处理方式:shell 默认是逻辑模式(-L),.. 是纯字符串操作(把 $PWD 末尾一段去掉);内核是物理模式,.. 是真的去读目录里的 .. 条目,得到真实的父目录。所以 cd /tmp/y/mylink && cd .. 在 shell 里回到 /tmp/y,而 cd -P /tmp/y/mylink && cd .. 回到的是链接目标的真实父目录。
pwd 是 bash 内建,输出 shell 维护的 $PWD(逻辑路径,含软链接名);pwd -P 和外部的 /bin/pwd 调 getcwd() 问内核,输出物理路径。readlink /proc/self/cwd 也是物理路径——内核只知道物理路径,逻辑路径纯粹是 shell 的记账。
脚本里的安全写法是先解析成绝对物理路径再操作:APP_ROOT=$(cd -P "$dir" && pwd),避免用 .. 拼路径时删错东西。
Q:一个文件权限是 644,属主也对,为什么读不了?怎么快速定位?
因为路径上每一级目录都需要 x(通行)权限,内核逐段解析路径时,任何一级缺 x 就直接返回 EACCES,根本走不到文件本身。
定位工具是 namei -l /完整/路径,它逐层打印每一级的权限、属主、属组,一眼就能看出是哪一级挡住了(比如 drwx------ root root www 这一层)。它还会展开路径中的软链接,显示每一跳。
另一种直接验证方式是 sudo -u www-data test -r /path && echo OK,直接以目标用户身份试一次。
Q:/proc 占磁盘空间吗?容器里 free -h 为什么显示的是宿主机内存?
/proc 是 procfs 虚拟文件系统,内容由内核在你读取的瞬间生成,不占任何磁盘(df /proc 显示大小为 0,ls -l /proc/meminfo 显示大小也是 0 但能 cat 出内容)。
容器里看到宿主机数据,是因为 /proc 没有被容器的命名空间隔离——free、nproc、top 读的都是 /proc 里的宿主机全局信息,cgroup 的限额它们不认。后果是 JVM/Go 的自动调优会按宿主机的内存和核数计算,然后被 OOMKilled 或产生大量无效调度。
真实限额要读 cgroup:/sys/fs/cgroup/memory.max、/sys/fs/cgroup/cpu.max(v2)。解法是 Go 里设 GOMEMLIMIT + 引入 automaxprocs,或者在宿主上部署 lxcfs 让容器的 /proc 反映 cgroup 值。
Q:自己编译的软件应该装到哪?为什么不能装 /usr/bin?
/usr/bin 属于包管理器。手动往里放文件有三个问题:系统升级时可能被覆盖或产生文件冲突;dpkg -S/rpm -qf 查不到归属,接手的人不知道这文件哪来的;卸载时无从清理。
正确的位置分三种:/usr/local/——管理员手动装的东西(./configure 的默认 --prefix 就是它,下载的单个二进制也放 /usr/local/bin);/opt/<软件名>/——自带完整目录结构的第三方整包(如 Go 官方 tarball 建议解压到 /usr/local/go);~/.local/bin——只给当前用户、或者没有 root 权限时。
PATH 的默认顺序把 /usr/local/bin 排在 /usr/bin 前面,正是为了让手动装的新版本能覆盖系统自带的旧版本,这是设计意图不是巧合。
Q:软链接为什么允许指向目录,硬链接却不允许?
两者都会导致目录树成环,区别在于环是否可检测。
软链接的解析是「跟随」语义,内核给了一个硬性上限 40 层(MAXSYMLINKS),超过就返回 ELOOP(Too many levels of symbolic links)。这是一个廉价且可靠的兜底。
硬链接是「平等的目录项」,成环之后没有任何计数器能发现——find、du、rm -r 递归时会真的无限循环下去;而且 .. 该指向哪个父目录变得无解,getcwd() 也无法唯一还原路径。所以内核直接禁止普通用户给目录建硬链接(. 和 .. 是内核自己维护的例外)。
小结
- FHS 的骨架是 静态/可变 × 可共享/不可共享 这个 2×2,分区规划和备份策略都从它推导:
/usr可只读、/etc要版控、/var要单独分区 /usr的名字来自 1970 年代硬盘装不下的意外,usrmerge 之后/bin、/sbin、/lib都只是软链接- 装软件:包管理器管
/usr,你管/usr/local,整包软件用/opt,永远别手动写/usr/bin /run是 tmpfs 且在根分区,所以 pid 和 socket 放它;/tmp可能是内存,别往里写大文件/proc、/sys不占磁盘,但没有被容器隔离——容器里要读 cgroup 才知道真实限额- 路径逐段解析,每一级目录都要
x权限,排查用namei -l - 逻辑路径是 shell 的记账,物理路径才是内核认的:
pwdvspwd -P、cdvscd -P,有软链接时它们不同
下一篇(04 篇)讲文件与目录操作的深水区:inode 到底是什么、删除为什么叫 unlink、mv 什么时候是原子的、cp 默认丢了什么、ls 在百万文件目录里为什么会卡住。
xingliuhua