无 root 权限部署 RPM 软件包:以 KLayout 为例的完整实践

环境:EL8 系发行版 8.10(内核 4.18.0-553.x.el8_10.x86_64)、x86_64、普通用户账户、家目录位于 NFS
目标:部署 KLayout 0.30.12,含 GUI 与 Ruby / Python 脚本引擎
约束:无管理员权限,不可使用 sudo,不可修改系统目录

本文记录一次完整的无特权部署过程,包含三轮依赖补齐与一处下载完整性问题的定位。结论置于开头:该方案可行,全程仅写入家目录,但需由部署者自行承担包管理器原本提供的职责。


1. 原理:为何无需管理员权限

1.1 "安装"并非内核层面的概念

操作系统内核中不存在 install() 系统调用,内核亦无"软件已安装"这一状态。dnf install 表面上是一个原子动作,分解后均为常规文件操作:

操作 作用目标 需要 root 的原因
写入文件 /usr、/etc、/opt 目录属主为 root
登记元数据 /var/lib/rpm 同上
执行 scriptlet %pre / %post 可能调用useradd、systemctl
设置属主与权限 chown root:root、setuid 位 仅 root 可变更他人属主
刷新缓存 ldconfig 更新 /etc/ld.so.cache 写入/etc

包管理器需要 root,根源在于它选择写入 /usr,而非"安装"这一行为本身具有特权属性。变更写入位置后,特权需求即消失。

1.2 RPM 的文件结构

RPM 文件由两部分构成:

[Header 元数据]   包名、版本、依赖列表、scriptlet、文件清单
[Payload]        xz 压缩的 cpio 归档

rpm2cpio 的作用是跳过 header,将 payload 解压至标准输出;cpio -idmu 随后将其展开至当前目录。整个过程等价于读取一个文件并在家目录内写入文件。

deb 包的结构更为直接,其本身即 ar 归档:

ar x foo.deb                      # debian-binary / control.tar.* / data.tar.*
tar -xf data.tar.xz -C ~/opt      # data.tar 即文件树
dpkg-deb -x foo.deb ~/opt/foo     # 或一步完成

1.3 程序执行所需的条件

execve() 的全部要求如下:

  1. 文件对调用者具备 x 权限
  2. 所在文件系统未挂载 noexec
  3. 内核读取 ELF 头,依 PT_INTERP 加载动态链接器 /lib64/ld-linux-x86-64.so.2
  4. 链接器按 DT_NEEDED 定位依赖库,搜索顺序为:
DT_RPATH → LD_LIBRARY_PATH → /etc/ld.so.cache → /lib64, /usr/lib64

第 4 步是本方案成立的关键:LD_LIBRARY_PATH 的优先级高于系统缓存,且它是普通环境变量,任何用户均可设置。这是 glibc 的既定设计,其意图在于让非特权用户控制自身进程的库搜索路径。

该设计的例外可作为佐证:当进程为 setuid / setgid(内核置 AT_SECURE 标志)时,ld.so 将忽略 LD_LIBRARY_PATH。因为在该场景下注入库等同于提权。普通程序允许用户控制搜索路径,恰因其不存在需要防护的权限边界——用户仅在以自身权限加载自有库、执行自有代码。

真正需要 root 的是一组具体的内核能力(CAP_CHOWN、CAP_SYS_ADMIN、CAP_SYS_MODULE 等),与"执行程序"无关。本方案未涉及其中任何一项。

1.4 rpm --prefix 的局限

rpm 本身提供用户级安装模式:

rpm --dbpath ~/rpmdb --prefix ~/opt --nodeps -i foo.rpm

但 --prefix 仅对标记为 relocatable 的包有效,即 header 中含 Prefixes 标签者。KLayout 的 RPM 未作此标记,该路径不可用。这也是本文采用 rpm2cpio 方案的原因。


2. 代价与适用边界

2.1 放弃包管理器的后果

绕过包管理器意味着放弃其全部服务,具体代价如下:

  1. 无依赖解析——最主要的代价。dnf install 可一次性解析并拉取 Qt5、libgit2、rubygems;手工部署只能依靠 ldd 扫描 not found 后逐轮补齐。本文的三轮返工均源于此。
  2. 编译期固化的绝对路径不会自动适配——RPM 以 /usr 为前缀构建,程序运行时查找 /usr/share/rubygems,而副本位于 ~/opt/.../usr/share/rubygems。动态库可通过 LD_LIBRARY_PATH 重定向,但各脚本运行时(Ruby、Python、Perl)各有独立的路径变量,需逐一处理。
  3. 未登记至 RPM 数据库——rpm -q 无法查询,rpm -e 无法卸载,卸载需手工 rm -rf。
  4. scriptlet 全部跳过——%post 中的 ldconfig、桌面图标注册、MIME 关联均未执行。KLayout 不依赖这些步骤,其他软件未必如此。
  5. 无自动安全更新,且无法跨用户共享。

2.2 不适用的场景

下列情形无法通过本方案解决:

  • 需要 setuid root 的程序(如 ping、sudo)
  • 需注册 systemd 服务或加载内核模块的软件
  • 安装阶段即需向 /etc、/var 写入状态的软件(如数据库类)
  • 硬编码路径且未提供环境变量覆盖机制的程序——此类只能修改源码后重新编译
  • 家目录挂载了 noexec(安全加固的常见配置,可通过 mount | grep $HOME 确认)
  • SELinux 处于 enforcing 模式且策略限制从家目录加载共享库

2.3 KLayout 的适配性

KLayout 适用于本方案,原因在于其基本实现了位置无关:可执行文件依 argv[0] 推算自身 share/ 目录,共享库依 LD_LIBRARY_PATH,Qt 插件依 QT_PLUGIN_PATH。唯一未做到位置无关的 Ruby loadpath,可通过 RUBYLIB 覆盖。

其共性特征是:每一处"到何处查找文件"的决策均可由环境变量改写,因此整个目录树迁移至家目录后仍可正常运行。


3. 前置确认

3.1 检查既有安装

计算集群通常已预装此类工具,部署前应先行确认:

which klayout; klayout -v
ls -d /opt/* /usr/local/* 2>/dev/null | head -40
module avail 2>&1 | grep -i -E 'klayout|layout'
ldconfig -p | grep -c libQt5Core      # 大于 0 表明系统已有 Qt5

若 module avail 有匹配结果,直接 module load 即可。

3.2 仅需 Python API 的情形

若用途限于脚本化处理版图文件,不需要图形界面,则无需本文方案:

python3 -m pip install --user klayout

官方提供 manylinux wheel,单条命令即可完成。需注意模块名为 klayout.db / klayout.lay,而非 GUI 环境中的 pya。本文方案的 71 MB 包集合仅在确需 GUI 时才有必要。


4. 确定包清单

4.1 主包来源

官方在 https://www.klayout.de/build.html 按发行版提供预编译 RPM。CentOS 8 对应的构建可直接用于同系的 8.10 发行版,ABI 兼容。

# 从 build.html 提取真实链接
curl -s https://www.klayout.de/build.html | grep -oP 'href="\K[^"]*CentOS_8[^"]*\.rpm'

得到 https://www.klayout.org/downloads/CentOS_8/klayout-0.30.12-0.x86_64.rpm。

需注意 https://www.klayout.org/downloads/ 目录本身返回 403,无法列目录,但具体文件可正常下载。因此文件名必须从 build.html 中获取。

4.2 从 RPM 头部读取依赖

Qt5 的子包组合无需推测,可直接读取 header(header 未压缩):

rpm -qpR klayout-0.30.12-0.x86_64.rpm
# 无 rpm 命令时:
strings -a klayout-0.30.12-0.x86_64.rpm | grep -E '^(qt5|ruby|python3)' | sort -u

结果为:

qt5-qtbase  qt5-qtmultimedia  qt5-qtsvg
qt5-qttools  qt5-qttools-devel  qt5-qtxmlpatterns
ruby  python3

同时可见其链接了 libklayout_QtDesigner.so.0,对应 qt5-qttools-libs-designer。

4.3 版本对齐

Rocky Linux 的镜像支持目录列举,可用于查询精确文件名。应优先选择 el8_10 后缀的版本,与内核保持一致:

BASE=https://dl.rockylinux.org/pub/rocky/8.10
curl -s $BASE/AppStream/x86_64/os/Packages/q/ \
  | grep -oP 'href="\Kqt5-qtbase[^"]*x86_64\.rpm' | sort -u | tail -3

初始清单共 12 个包:

包 字节数 作用
klayout-0.30.12-0.x86_64 42,475,876 主程序
qt5-qtbase-5.15.3-9.el8_10 3,807,464 Qt 核心
qt5-qtbase-common-5.15.3-9.el8_10 42,224 Qt 公共文件
qt5-qtbase-gui-5.15.3-9.el8_10 6,435,652 Qt GUI
qt5-qtdeclarative-5.15.3-2.el8 4,395,396 QML,qtmultimedia 依赖
qt5-qtmultimedia-5.15.3-1.el8 902,336 主包声明依赖
qt5-qtsvg-5.15.3-2.el8 188,532 主包声明依赖
qt5-qtxmlpatterns-5.15.3-1.el8 1,195,392 主包声明依赖
qt5-qttools-libs-designer-5.15.3-6.el8 2,898,232 libklayout_QtDesigner
qt5-qttools-libs-help-5.15.3-6.el8 198,720 同上
ruby-libs-2.5.9-116.module+el8.10.0+40366+397889f9 3,067,058 DRC 脚本引擎
python3-libs-3.6.8-78.el8_10.rocky.0 8,216,648 Python 绑定

该清单尚缺 3 个包,详见第 6 节。


5. 下载阶段的完整性保证

5.1 HTTP 200 不代表传输完成

初次下载采用如下写法:

curl -sL -o "$f" -w "%{http_code}" "$u"      # 仅检查状态码

12 个包均返回 200。但主包实际仅下载 25,296,896 字节,而正确大小为 42,475,876 字节,缺失 17 MB。传输中途中断,curl 返回的仍是最初的 200。

HTTP 200 仅表示服务器已开始响应,不表示传输完成。正确做法是与 Content-Length 对账:

rem=$(curl -sIL "$u" | grep -i '^content-length:' | tail -1 | tr -dc '0-9')
curl -sL -C - --retry 5 --retry-delay 3 --retry-all-errors -o "$f" "$u"
loc=$(stat -c %s "$f")
[ "$rem" = "$loc" ] || { echo "截断: $loc / $rem"; exit 1; }

-C - 启用断点续传,配合重试循环可补齐中断的传输。服务端需支持 Accept-Ranges: bytes,本文涉及的两个站点均支持。

5.2 自生成校验和的局限

SHA256 校验未能发现上述问题,因为校验清单是基于残缺文件自身计算的:

==> 校验 SHA256
klayout-0.30.12-0.x86_64.rpm: 成功

自行生成的 checksum 只能验证传输一致性,无法验证来源正确性。 验证来源需依赖上游签名或官方公布的哈希值。

因此本文的脚本额外维护一份 SIZES 清单,先比对字节数、再比对哈希。字节数不符时,"截断"这一诊断信息比"哈希不匹配"更具指向性。

5.3 cpio 流完整性验证

比哈希更进一步的方法是解出 payload,检查 cpio 流是否完整。完整的 cpio 归档必须以 TRAILER!!! 结尾:

rpm2cpio foo.rpm > /tmp/p.cpio && tail -c 1024 /tmp/p.cpio | grep -aq 'TRAILER!!!' \
  && echo "完好" || echo "损坏"

对残缺主包执行该检查,结果为:解出 144,719,872 字节、无 TRAILER!!!、截断点位于 ./usr/lib64/klayout/libklayout_db.so.0.30.12(35 MB 的共享库)内部。完整包则解出 144,720,323 字节、335 个 cpio 条目、TRAILER 存在。

对应的报错为:

==> 解包 12 个 RPM
    klayout-0.30.12-0.x86_64.rpm
cpio: 文件非正常终止

cpio: premature end of archive 的字面含义即归档提前结束。定位该问题时,笔者先排查了 ulimit、磁盘配额与 NFS 状态,均正常,耗费了一轮无效排查。此处的经验是:当报错明确指向文件截断时,应优先按该方向验证。


6. 依赖补齐过程

6.1 第一轮:libgit2.so.26

12 个包解包完成后,自检报告:

==> 依赖自检
    !! 还缺下面这些共享库:
       libgit2.so.26

==> 版本验证
.../usr/bin/klayout: error while loading shared libraries:
libgit2.so.26: cannot open shared object file: No such file or directory

此处与预期不同:原本预计会缺失大量 X11 / xcb 库(libxcb-icccm、libxkbcommon-x11 等),实测系统均已提供,仅缺此一项。libgit2 是 KLayout 0.30 的包管理器(Salt)用于访问 Git 仓库的依赖。

curl -s $BASE/AppStream/x86_64/os/Packages/l/ | grep -oP 'href="\Klibgit2-[^"]*x86_64\.rpm'
# → libgit2-0.26.8-3.el8.x86_64.rpm   so.26 与 0.26.x 对应

补充该包时同步读取了它自身的依赖,发现还需 libhttp_parser.so.2,故将 http-parser-2.8.0-9.el8(41 KB)一并纳入,避免再增一轮往返。补包时应先读取新包的 Requires,可减少返工次数。

6.2 第二轮:rpm2archive 参数兼容性

    klayout-0.30.12-0.x86_64.rpm
    (rpm2archive 失败,回退 rpm2cpio)
    python3-libs-...
    (rpm2archive 失败,回退 rpm2cpio)
    ...每个包各报一次

原因是 rpm2archive -n(输出至 stdout)为 rpm 4.15+ 引入的参数,EL8 自带的 rpm 4.14 不支持。更需注意的是,它不会明确报错退出,而是忽略该参数、在 RPM 文件旁生成 .tar 文件,因而目录中多出 12 个 *.rpm.tar 冗余文件。

回退逻辑本身正确(功能等价),但不应对每个包重复输出提示。修正方式为探测一次后记录结果:

if [ "${RPM2ARCHIVE_OK:-unknown}" != "no" ] && command -v rpm2archive >/dev/null 2>&1; then
  ...
  if [ "${RPM2ARCHIVE_OK:-unknown}" = "unknown" ]; then
    echo "    (rpm2archive 不可用,本机 rpm 版本较旧;改用 rpm2cpio,功能等价)"
  fi
  RPM2ARCHIVE_OK=no
fi

6.3 第三轮:rubygems 与运行时搜索路径

本轮的依赖自检报告"无缺失",klayout -v 亦正常输出版本号:

==> 依赖自检
    无缺失,依赖完整。
==> 版本验证
KLayout 0.30.12

但启动 GUI 时报错:

$ klayout
Traceback (most recent call last):
        1: from <internal:gem_prelude>:2:in `<internal:gem_prelude>'
<internal:gem_prelude>:2:in `require': cannot load such file -- rubygems.rb (LoadError)

该现象由两个独立原因叠加导致。

6.3.1 原因一:rubygems.rb 未被安装

该文件位于独立的 rubygems 包中,ruby-libs 仅包含标准库。可验证:

rpm2cpio ruby-libs-*.rpm | cpio -t 2>/dev/null | grep -c rubygems    # → 0

而 gem_prelude.rb 编译于 libruby.so 内部,Ruby 解释器启动时必然执行 require 'rubygems.rb',缺失该文件即无法初始化。

6.3.2 原因二:Ruby 的库搜索路径为编译期固化的绝对路径

此点较原因一更为隐蔽:即使将 rubygems 解包至 ~/opt/.../usr/share/rubygems,Ruby 仍仅检索 /usr/share/rubygems。

LD_LIBRARY_PATH 仅作用于动态链接器查找 .so 的过程,不影响 Ruby 自身查找 .rb 文件。后者需通过 RUBYLIB 指定:

export RUBYLIB="$R/share/rubygems:$R/share/ruby:$R/share/ruby/vendor_ruby:$R/lib64/ruby:$R/lib64/ruby/vendor_ruby"
export GEM_PATH="$R/share/gems:$HOME/.klayout/gems"

补充 rubygems-2.7.6.3-116.module+el8.10.0+40366+397889f9(316,930 字节)。需确保 build tag 与 ruby-libs 完全一致(均为 116.module+el8.10.0+40366+397889f9),版本混用会引发难以定位的问题。

这是第 2.1 节所述"编译期固化绝对路径"的具体表现形式。各运行时的对应变量为:Ruby 使用 RUBYLIB,Python 使用 PYTHONPATH / PYTHONHOME,Perl 使用 PERL5LIB。遇到 LoadError、ModuleNotFoundError 一类"文件存在于磁盘但无法加载"的错误时,应优先排查此层。

Python 存在同类风险,但 PYTHONHOME 设置不当会影响系统 python3,故仅在系统确实缺少 3.6 标准库时才设置:

if [ ! -f /usr/lib64/python3.6/os.py ] && [ -f "$ROOT/usr/lib64/python3.6/os.py" ]; then
  PY_LINE="export PYTHONHOME=\"\$R\""
fi

6.4 错误类型的判读

三轮返工的报错分别为:

cpio: 文件非正常终止                              ← 文件损坏
libgit2.so.26: cannot open shared object file     ← ENOENT
cannot load such file -- rubygems.rb (LoadError)  ← ENOENT

全部为"无法找到",不含任何 Permission denied。

过程中唯一出现的 Permission denied 来自 zip 格式不保留 Unix 执行位,chmod +x 即可解决——修改自有文件的权限位同样无需 root。

这构成一个有效的判断依据:若失败原因均为 ENOENT 而非 EACCES / EPERM,说明方案在权限模型层面可行,剩余工作仅是补齐文件与校正路径。若确实触及权限边界,首个步骤即会返回 EACCES。


7. 完整脚本

7.1 脚本内容

最终清单含 15 个包,合计 71.2 MB。脚本可重复执行(cpio -idmu 为覆盖式),补包后再次运行即可。

#!/bin/bash
# KLayout 0.30.12 无 root 安装脚本 (EL8 系发行版, x86_64)
#
# 用法:  bash install-klayout-nonroot.sh
# 自定义目录: KL_ROOT=$HOME/somewhere bash install-klayout-nonroot.sh
#
# 全程只写 $KL_ROOT 和 $HOME/bin,不需要任何管理员权限。

set -euo pipefail

ROOT="${KL_ROOT:-$HOME/opt/klayout-local}"
PKGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BINDIR="$HOME/bin"

echo "=============================================="
echo " KLayout 无 root 安装"
echo "   包目录: $PKGDIR"
echo "   安装到: $ROOT"
echo "   启动器: $BINDIR/klayout"
echo "=============================================="
echo

# ---------- 0. 环境检查 ----------
if [ "$(uname -m)" != "x86_64" ]; then
  echo "错误: 这些包是 x86_64 的,当前是 $(uname -m)" >&2
  exit 1
fi
if ! command -v rpm2cpio >/dev/null 2>&1; then
  echo "错误: 找不到 rpm2cpio(rpm 包应该是系统自带的)" >&2
  exit 1
fi

# ---------- 1. 校验 ----------
# 先比大小再比哈希: 大小对不上说明传输被截断, 这比哈希不符的信息量更大
if [ -f "$PKGDIR/SIZES" ]; then
  echo "==> 校验文件大小"
  sz_bad=0
  while read -r want name; do
    [ -z "$want" ] && continue
    if [ ! -f "$PKGDIR/$name" ]; then
      echo "    缺失: $name" >&2; sz_bad=1; continue
    fi
    got=$(stat -c %s "$PKGDIR/$name")
    if [ "$got" != "$want" ]; then
      echo "    截断: $name  实际 $got / 应为 $want" >&2; sz_bad=1
    fi
  done < "$PKGDIR/SIZES"
  if [ "$sz_bad" != 0 ]; then
    echo "错误: 文件不完整,重新传输这几个文件" >&2
    exit 1
  fi
  echo "    全部文件大小正确"
  echo
fi

if [ -f "$PKGDIR/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then
  echo "==> 校验 SHA256"
  ( cd "$PKGDIR" && sha256sum -c SHA256SUMS ) || {
    echo "错误: 校验失败,传输过程可能损坏" >&2
    exit 1
  }
  echo
fi

# ---------- 2. 解包 ----------
mkdir -p "$ROOT" "$BINDIR"

extract() {
  local rpm="$1" tmp rc
  # 优先 rpm2archive: 走 tar, 不经过 cpio, 且解压错误不会被管道吞掉。
  # 注意 -n 是 rpm 4.15+ 才有的, EL8 自带 rpm 4.14 不支持 -> 首次探测后记住结果
  if [ "${RPM2ARCHIVE_OK:-unknown}" != "no" ] && command -v rpm2archive >/dev/null 2>&1; then
    tmp="$(mktemp "${TMPDIR:-/tmp}/klrpm.XXXXXX.tgz")"
    if rpm2archive -n "$rpm" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
      if tar -xf "$tmp" -C "$ROOT" 2>/dev/null; then
        rm -f "$tmp"; RPM2ARCHIVE_OK=yes; return 0
      fi
    fi
    rm -f "$tmp"
    if [ "${RPM2ARCHIVE_OK:-unknown}" = "unknown" ]; then
      echo "    (rpm2archive 不可用,本机 rpm 版本较旧;改用 rpm2cpio,功能等价)"
    fi
    RPM2ARCHIVE_OK=no
  fi
  # 回退: 先把 cpio 落盘再解, 这样能区分"解压断了"和"cpio 读不懂"
  tmp="$(mktemp "${TMPDIR:-/tmp}/klrpm.XXXXXX.cpio")"
  rpm2cpio "$rpm" > "$tmp"; rc=$?
  if [ $rc -ne 0 ]; then
    echo "错误: rpm2cpio 解压失败 (exit $rc): $(basename "$rpm")" >&2
    rm -f "$tmp"; return 1
  fi
  if ! tail -c 1024 "$tmp" | grep -aq 'TRAILER!!!'; then
    echo "错误: $(basename "$rpm") 的 cpio 流不完整(缺 TRAILER),这个 RPM 是坏的" >&2
    echo "      已解出 $(stat -c %s "$tmp") 字节。请重新下载此包。" >&2
    rm -f "$tmp"; return 1
  fi
  ( cd "$ROOT" && cpio -idmu --quiet ) < "$tmp"; rc=$?
  rm -f "$tmp"
  return $rc
}

shopt -s nullglob
rpms=( "$PKGDIR"/*.rpm )
if [ ${#rpms[@]} -eq 0 ]; then
  echo "错误: $PKGDIR 下没有 .rpm 文件" >&2
  exit 1
fi

echo "==> 解包 ${#rpms[@]} 个 RPM"
for f in "${rpms[@]}"; do
  printf '    %s\n' "$(basename "$f")"
  extract "$f"
done
echo

if [ ! -x "$ROOT/usr/bin/klayout" ]; then
  echo "错误: 解包后没找到 $ROOT/usr/bin/klayout" >&2
  exit 1
fi

# ---------- 3. 生成 wrapper ----------
# 环境变量只在这个脚本里生效,不污染平时的 shell
echo "==> 写入启动器 $BINDIR/klayout"

# Ruby/Python 的标准库搜索路径是编译期写死的绝对路径(/usr/share/ruby 等),
# 不会自动找到解在家目录的副本, 必须显式指过去。
RUBYLIB_VAL="\$R/share/rubygems:\$R/share/ruby:\$R/share/ruby/vendor_ruby:\$R/lib64/ruby:\$R/lib64/ruby/vendor_ruby"

# Python: PYTHONHOME 设错会连累系统 python, 所以只在系统确实缺 3.6 标准库时才设
PY_LINE="# 系统自带 python3.6 标准库, 无需 PYTHONHOME"
if [ ! -f /usr/lib64/python3.6/os.py ] && [ -f "$ROOT/usr/lib64/python3.6/os.py" ]; then
  PY_LINE="export PYTHONHOME=\"\$R\""
  echo "    (系统缺 python3.6 标准库,启动器将设置 PYTHONHOME)"
fi

cat > "$BINDIR/klayout" <<EOF
#!/bin/bash
# 由 install-klayout-nonroot.sh 生成
R="$ROOT/usr"
export LD_LIBRARY_PATH="\$R/lib64/klayout:\$R/lib64\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}"
export QT_PLUGIN_PATH="\$R/lib64/qt5/plugins\${QT_PLUGIN_PATH:+:\$QT_PLUGIN_PATH}"
export QT_QPA_PLATFORM_PLUGIN_PATH="\$R/lib64/qt5/plugins/platforms"
# Ruby 标准库 + rubygems(KLayout 启动时 gem_prelude 会 require 'rubygems.rb')
export RUBYLIB="$RUBYLIB_VAL\${RUBYLIB:+:\$RUBYLIB}"
export GEM_HOME="\$HOME/.klayout/gems"
export GEM_PATH="\$R/share/gems:\$GEM_HOME"
$PY_LINE
exec "\$R/bin/klayout" "\$@"
EOF
chmod +x "$BINDIR/klayout"
echo

# ---------- 4. 依赖自检 ----------
echo "==> 依赖自检"
export LD_LIBRARY_PATH="$ROOT/usr/lib64/klayout:$ROOT/usr/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"

MISS="$(mktemp)"
{
  ldd "$ROOT/usr/bin/klayout" || true
  for so in "$ROOT"/usr/lib64/klayout/*.so*; do [ -e "$so" ] && ldd "$so" || true; done
  for so in "$ROOT"/usr/lib64/qt5/plugins/*/*.so; do [ -e "$so" ] && ldd "$so" || true; done
} 2>/dev/null | grep 'not found' | awk '{print $1}' | sort -u > "$MISS" || true

if [ -s "$MISS" ]; then
  echo "    !! 还缺下面这些共享库:"
  sed 's/^/       /' "$MISS"
  echo
  echo "    查属于哪个包:  repoquery --whatprovides <库名>"
else
  echo "    无缺失,依赖完整。"
fi
rm -f "$MISS"
echo

# ---------- 5. 功能验证 ----------
# -v 只打印版本, 不初始化脚本引擎; 必须真跑一段脚本才能发现 Ruby/Python 的问题
echo "==> 功能验证"
FAIL=0

printf '    %-22s' "版本:"
VER="$("$BINDIR/klayout" -v 2>&1 | head -1)" && echo "$VER" || { echo "失败"; FAIL=1; }

TD="$(mktemp -d "${TMPDIR:-/tmp}/klchk.XXXXXX")"
trap 'rm -rf "$TD"' EXIT

# Ruby 引擎: gem_prelude 会 require rubygems, 这里能过说明 RUBYLIB 配对了
cat > "$TD/t.rb" <<'RB'
puts "OK ruby #{RUBY_VERSION}"
RB
printf '    %-22s' "Ruby 引擎:"
OUT="$(QT_QPA_PLATFORM=offscreen "$BINDIR/klayout" -b -r "$TD/t.rb" 2>&1)" || true
case "$OUT" in
  *"OK ruby"*) echo "${OUT##*OK }" | head -1 ;;
  *) echo "失败"; echo "$OUT" | sed 's/^/        /' | head -8; FAIL=1 ;;
esac

# Python 引擎
cat > "$TD/t.py" <<'PY'
import sys
print("OK python %s" % sys.version.split()[0])
PY
printf '    %-22s' "Python 引擎:"
OUT="$(QT_QPA_PLATFORM=offscreen "$BINDIR/klayout" -b -r "$TD/t.py" 2>&1)" || true
case "$OUT" in
  *"OK python"*) echo "${OUT##*OK python }" | head -1 ;;
  *) echo "失败"; echo "$OUT" | sed 's/^/        /' | head -8; FAIL=1 ;;
esac

# 版图内核: 建一个 cell 画个方框再读回来, 确认 GDS 读写链路通
cat > "$TD/t2.rb" <<'RB'
ly = RBA::Layout.new
top = ly.create_cell("TOP")
l1 = ly.layer(1, 0)
top.shapes(l1).insert(RBA::Box.new(0, 0, 1000, 2000))
ly.write(ARGV[0])
ly2 = RBA::Layout.new
ly2.read(ARGV[0])
c = ly2.top_cell
puts "OK gds #{c.name} #{c.bbox.width}x#{c.bbox.height}"
RB
printf '    %-22s' "GDS 读写:"
OUT="$(QT_QPA_PLATFORM=offscreen "$BINDIR/klayout" -b -r "$TD/t2.rb" "$TD/t.gds" 2>&1)" || true
case "$OUT" in
  *"OK gds"*) echo "${OUT##*OK gds }" | head -1 ;;
  *) echo "失败"; echo "$OUT" | sed 's/^/        /' | head -8; FAIL=1 ;;
esac

echo
if [ "$FAIL" != 0 ]; then
  echo "!! 有检查未通过,把上面的报错贴出来即可定位。"
  echo
fi

cat <<EOF
==============================================
 完成。把 ~/bin 加进 PATH(只需一次):

   echo 'export PATH=\$HOME/bin:\$PATH' >> ~/.bashrc
   source ~/.bashrc

 用法:
   klayout design.gds                  # GUI,需要 ssh -X
   klayout -e design.gds               # 编辑模式
   klayout -b -r script.rb             # 批处理

 卸载: rm -rf $ROOT $BINDIR/klayout
==============================================
EOF

7.2 成功输出

部署成功时的输出:

==> 校验文件大小
    全部文件大小正确
==> 校验 SHA256
    (15 个包全部成功)
==> 解包 15 个 RPM
    (rpm2archive 不可用,本机 rpm 版本较旧;改用 rpm2cpio,功能等价)
    ...
==> 写入启动器 /home/<user>/bin/klayout
==> 依赖自检
    无缺失,依赖完整。
==> 功能验证
    版本:                 KLayout 0.30.12
    Ruby 引擎:            ruby 2.5.9
    Python 引擎:          3.6.8
    GDS 读写:             TOP 1000x2000

8. 验证方法:不应仅依据 --version

这是第 6.3 节返工的直接原因。

klayout -v 仅打印版本号,不初始化脚本引擎,因此在 rubygems 缺失的情况下仍输出 KLayout 0.30.12。以该输出判定部署成功,随后启动 GUI 即失败。

通用原则是:自检应覆盖各子系统的真实代码路径。对 KLayout 而言即分别执行一段 Ruby、一段 Python,并实际创建 cell、写入 GDS 后读回,四项均通过方可判定部署完成。上述脚本第 5 节即实现该检查。

更一般地说,--version 通常是程序中最浅的执行路径,往往在主逻辑初始化之前即返回。以其作为部署成功的判据,验证强度接近于无。


9. 使用

9.1 配置 PATH

echo 'export PATH=$HOME/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

9.2 启动图形界面

ssh -X user@host       # 需带 -X,本地需 X Server
klayout design.gds

echo $DISPLAY 应有输出(形如 localhost:10.0);若为空则表明转发未建立,需检查服务端 sshd_config 中的 X11Forwarding 配置。

9.3 界面使用注意事项

  1. 默认为 Viewer 模式,不可编辑——需编辑时使用 klayout -e,或在 File → Setup → Application → Editing mode 中启用后重启。
  2. 打开后显示空白——通常为图层未显示。在右侧 Layers 面板右键选择 Add Other Layer Entries。
  3. 无法定位目标 cell——通过左侧 Cells 面板双击切换,Display → Show as new top 可将子 cell 作为顶层显示。

常用快捷键:* 全局缩放、F2 适配窗口、Ctrl+Shift+H 显示层级树。


10. 经验总结

10.1 可复用的七条结论

  1. RPM / deb 并非特权格式,payload 即标准压缩归档。使用 rpm2cpio | cpio -idmu 或 dpkg-deb -x 解包至家目录即可。rpm --prefix 仅对 relocatable 包有效,多数包不适用。
  2. HTTP 200 不等于下载完整。应与 Content-Length 对账,并配合 -C - 续传。
  3. 自行生成的 checksum 只验证传输、不验证来源。额外维护字节数清单,先比大小后比哈希——"截断"的诊断价值高于"哈希不匹配"。
  4. cpio 流完整性以 TRAILER!!! 为判据,相比哈希更能定位具体文件与截断位置。
  5. LD_LIBRARY_PATH 仅作用于 .so。脚本语言查找自身库文件使用独立机制:RUBYLIB / PYTHONPATH / PERL5LIB。遇到"文件存在但加载失败"类错误应优先排查该层。
  6. 不应仅以 --version 验证部署。该路径通常在主逻辑初始化前返回,需覆盖各子系统的真实代码路径。
  7. 依据错误类型判断方案可行性:全部为 ENOENT 表明属于文件缺失的工程问题;出现 EACCES / EPERM 才是真正的权限边界。本文三轮返工均不含权限类错误。

10.2 方案选择建议

若用途限于脚本化处理版图文件,应优先采用:

python3 -m pip install --user klayout
# 或 micromamba create -n kl -c conda-forge python=3.11 klayout

本文的 15 包 / 71 MB 方案仅在确需图形界面时才具备必要性。


附录:最终包清单(15 个,71.2 MB)

41496      http-parser-2.8.0-9.el8.x86_64.rpm
42475876   klayout-0.30.12-0.x86_64.rpm
464060     libgit2-0.26.8-3.el8.x86_64.rpm
8216648    python3-libs-3.6.8-78.el8_10.rocky.0.x86_64.rpm
3807464    qt5-qtbase-5.15.3-9.el8_10.x86_64.rpm
42224      qt5-qtbase-common-5.15.3-9.el8_10.noarch.rpm
6435652    qt5-qtbase-gui-5.15.3-9.el8_10.x86_64.rpm
4395396    qt5-qtdeclarative-5.15.3-2.el8.x86_64.rpm
902336     qt5-qtmultimedia-5.15.3-1.el8.x86_64.rpm
188532     qt5-qtsvg-5.15.3-2.el8.x86_64.rpm
2898232    qt5-qttools-libs-designer-5.15.3-6.el8.x86_64.rpm
198720     qt5-qttools-libs-help-5.15.3-6.el8.x86_64.rpm
1195392    qt5-qtxmlpatterns-5.15.3-1.el8.x86_64.rpm
316930     rubygems-2.7.6.3-116.module+el8.10.0+40366+397889f9.noarch.rpm
3067058    ruby-libs-2.5.9-116.module+el8.10.0+40366+397889f9.x86_64.rpm
posted @ 2026-10-14 16:45  起飞的乌龟  阅读(13)  评论(0)    收藏  举报