我最早开始正经打包 deb是因为交付给某客户的一个内部采集程序。编译好的二进制扔过去对方运维一脸茫然地问这玩意儿怎么开机自启配置文件在哪卸载的时候会不会留一堆垃圾当场被问住之后我就老老实实去研究 Go 项目的 deb 打包了。现在回头看Go 项目的二进制交付如果不走 deb/rpm 这条路后面维护成本只会越来越高。这篇文章围绕“GO 项目构建 deb”这个主题完整讲清楚我从零开始做 deb 包的全过程先判断哪些项目需要打 deb再手写最小可用的 deb 包结构然后补上 systemd 集成和脚本钩子接着对比几种自动化打包方案最后把我在实践中踩过的坑和最终验证流程都列出来。无论是刚接触 Linux 打包的 Go 开发者还是已经在用 fpm/goreleaser 但对细节不放心的人这篇都值得花十分钟看完。1. 先想清楚Go 项目到底需不需要打 deb 包1.1 裸二进制交付的四个痛点很多人第一次交付 Go 项目都是go build完事把二进制用网盘一发就结束。这种做法在个人工具或者内部一次性脚本上完全没问题但一旦进入“别人要长期维护”的阶段问题就全冒出来了。第一个痛点是依赖缺失。Go 二进制虽然静态编译出货但运行时的很多东西并不在内比如访问 HTTPS 需要 ca-certificates依赖时区数据需要 tzdata系统里没有这些文件程序跑起来会报各种奇怪错误。手动 scp 一个二进制过去对方机器缺什么你根本不知道。第二个痛点是配置和目录结构没人管。二进制放/usr/local/bin还是/opt/xxx/bin配置文件丢/etc还是项目目录数据放/var/lib还是临时目录没有统一约定每个服务器都可能不一样。第三个痛点是系统服务集成。用 systemd 开机自启、日志走 journald、异常退出自动拉起这些都要写 unit 文件。裸二进制交付下unit 文件靠手抄路径稍微写错一点服务就起不来。第四个痛点是卸载残留。手动删除二进制配置文件、数据目录、systemd 残留全都留在机器上。等对方审计发现问题你连装了什么上去都说不清楚。1.2 deb 包到底改变了什么deb 包做的事情本质上就是把“我给你的东西”变成了“系统替你管理的东西”。装包之后dpkg 数据库里记录了每个文件的路径和校验和比如dpkg -L myapp能列出这个包安装的所有文件dpkg -s myapp能查版本和状态dpkg -r myapp卸载时包内声明的文件会被自动清理配合脚本钩子还能把服务停掉、数据目录删掉。你交付的不再是一个孤零零的二进制而是一套“安装即正确”的完整环境。对 Go 项目来说deb 包还有一个额外的优势Go 编译出来的单文件结构非常适合被 deb 包管理。没有复杂的动态库依赖没有多目录的散装文件控制文件里Installed-Size清清楚楚这比打包一堆 Python 依赖要干净得多。1.3 什么情况下不值得打 deb也不是所有 Go 项目都适合打 deb。如果你只是给自己写的命令行小工具跑完就删打成 deb 反而是负担——每次改版本都要走一遍打包流程安装还要 sudo。另外如果你主要在容器环境或者 Kubernetes 里运行直接用 Docker 镜像交付更合理deb 包那套系统服务管理在容器里意义不大。所以做 deb 前先问自己三个问题目标机器是不是裸机/虚拟机使用者是不是不熟悉你的项目结构的人有没有开机自启或者系统级配置的需求只要有一项是“是”打 deb 就有价值。这一步想清楚了后面才不至于做了个花架子。2. 手搓第一个 deb 包目录结构与 control 字段逐项详解2.1 准备一个干净的目录骨架deb 包的内部结构其实分成两大部分DEBIAN/目录存放包的控制信息和安装/卸载脚本其余所有目录就是将来要释放到系统根目录的内容。先看一个最小的例子myapp_1.0.0_amd64/ ├── DEBIAN/ │ ├── control │ └── md5sums ├── usr/ │ └── local/ │ └── bin/ │ └── myapp └── etc/ └── myapp/ └── config.yaml这个目录名叫myapp_1.0.0_amd64虽然不是强制要求但保持“包名_版本_架构”的命名习惯后面用 dpkg 命令操作时会少很多困惑。打包时对这个目录执行dpkg-deb --build --root-owner-group myapp_1.0.0_amd64就能生成最终的myapp_1.0.0_amd64.deb。--root-owner-group是我强烈建议必须加的选项它确保包内所有文件的所有者被强制写成 root:root而不是你当前构建用户的 UID。不然后果很严重装到别人机器上文件可能显示成一个不存在的用户或者启动时权限错乱。我见过很多人卡在第一步手动 mkdir 了一堆目录文件放进去之后忘了生成 md5sums。其实 md5sums 不是必须的但没有它dpkg 就只能记录文件名而无法校验文件完整性——安全性是个硬伤而且后面出问题排查也没法依赖校验和。2.2 control 文件的每一个字段都要较真DEBIAN/control是 deb 包的心脏。下面是一份经过实测的模板Package: myapp Version: 1.0.0 Section: utils Priority: optional Architecture: amd64 Maintainer: ops opsexample.com Installed-Size: 12 Depends: libc6 ( 2.14), ca-certificates Description: Internal metrics collection agent A lightweight Go-based agent for collecting server metrics and forwarding them to the central collector. Homepage: https://example.com/myapp逐字段说几个我在实际打包中踩过、也看别人踩过的点。Package必须是全小写字母、数字和-.组成的字符串不能有下划线也不能有大写字母。这个很多人忽略一旦包名里有非法字符dpkg 直接拒绝安装。Version的讲究最多。Debian 的版本号排序规则比 RPM 复杂不少版本号只能由字母、数字和~.组成。我最常用的版本来源是git describe --tags --always比如v1.2.3-5-gabcdef0这种需要把开头的v去掉然后将中间的-转换成或者~否则会被 dpkg 判定为非法版本号。我在 Makefile 里一般这么处理VERSION : $(shell git describe --tags --always 2/dev/null | sed s/^v// | sed s/-//g)Architecture必须如实填写。Go 交叉编译产物是 amd64 就写 amd64是 arm64 就写 arm64。有的方案会在“包名_版本_架构”目录名里写 amd64control 里却忘写或者写成 all这种不一致到安装时就是一场灾难。如果这个包是纯脚本、任何架构都能跑才写all。Installed-Size虽然 dpkg 会自动估但我在打包脚本里仍会显式写du -s算出来的值单位是 KiB。这个字段影响安装时的磁盘空间提示写小了虽然不阻塞安装但会让系统在磁盘紧张时做出错误预估。Depends这里值得多说一句。纯静态编译出的 Go 二进制通常不依赖 libc但如果你没关CGO_ENABLED0或者用了某些依赖 cgo 的库二进制就会动态链接 glibc。稳妥的做法是先在构建机上看一眼ldd ./myapp如果输出里有一堆libc.so.6 之类的内容Depends 里就要写libc6 ( 2.14)这种下限约束如果输出是“not a dynamic executable”那 Depends 可以只留ca-certificates。另外如果你的程序要发起 HTTPS 请求或者处理时区ca-certificates和tzdata几乎是必写的静态编译不含这些系统文件缺了照样跑不通。2.3 打包、拆包、看内容的完整验证循环打完包第一件事不是急着找机器装而是先用两个命令自查dpkg -c myapp_1.0.0_amd64.deb # 列出包内文件列表 dpkg -I myapp_1.0.0_amd64.deb # 查看控制信息dpkg -c会打印所有将释放的文件路径重点核对配置文件位置、二进制位置对不对dpkg -I则打印 control 里的全部字段可以顺便检查 Version、Architecture、Depends 是否和预期一致。这一步肉眼扫一遍比装了再卸载省太多事。然后才是最小安装验证sudo dpkg -i myapp_1.0.0_amd64.deb myapp --version sudo dpkg -r myapp一键装、一键卸确认二进制能跑、文件被清理。到这里一个最小可用 deb 包就已经走通全流程了。3. 脚本钩子与 systemd 集成让安装包自己完成启动注册3.1 为什么安装包需要碰系统服务只把文件放进系统目录还不算合格的 deb。对于需要长期运行的后台服务安装后还要注册 systemd unit、启动服务卸载前要停服务卸载后要清理数据。这些动作不能靠人工一条条敲而是应该由 deb 包自带的维护者脚本完成。deb 里有几个标准钩子脚本postinst安装后执行、prerm卸载前执行、postrm卸载后执行。名字很直白但在哪个阶段触发什么内容是有详细规则的。最容易被忽略的是升级时也会触发这些脚本。也就是说postinst不仅在初次安装时执行包升级时同样会执行prerm和postrm也同理。所以脚本里不能直接写“安装则启动、卸载则停止”要用$1参数区分动作。3.2 postinst/prerm/postrm 脚本的正确写法以我的采集程序为例postinst脚本长这样#!/bin/bash set -e case $1 in configure) if [ -x /lib/systemd/systemd ]; then systemctl daemon-reload systemctl enable myapp.service systemctl restart myapp.service || true fi ;; abort-upgrade|abort-remove|abort-deconfigure) ;; *) ;; esac exit 0注意几个细节第一set -e必须加在脚本顶部。维护者脚本一旦返回非零状态dpkg 会认为安装失败可能把包状态搞成半配置状态。没有任何理由让脚本里的单个命令失败导致整个安装回滚所以必须严格掌控错误处理。第二systemctl restart ... || true这个|| true是我被教训出来的。如果服务因配置错误启动失败脚本返回非零会导致整个安装失败包虽然装了一半但状态是坏的。服务配置有问题应该是运行时报错而不是安装时报错。所以我会容忍启动失败但daemon-reload和enable不能容忍这些必须成功。第三case $1区分动作。升级时$1是configure初次安装也是configure所以这里的逻辑天然覆盖升级场景。abort-*分支只是保证安全退出不写任何实质操作。prerm负责在卸载前停服务#!/bin/bash set -e case $1 in remove|upgrade|deconfigure) if [ -x /lib/systemd/systemd ]; then systemctl stop myapp.service || true fi ;; esac exit 0postrm负责卸载后清理和禁用服务#!/bin/bash set -e case $1 in remove) if [ -x /lib/systemd/systemd ]; then systemctl disable myapp.service systemctl daemon-reload fi rm -rf /var/log/myapp ;; purge) rm -rf /var/lib/myapp ;; upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) ;; esac exit 0关于清理数据目录我默认不在 remove 时删数据只在 purge 时才删。标准和 Debian policy 也是这个思路remove 只是卸载程序purge 才是清除一切痕迹。有些朋友图省事全在 remove 里删用户只是暂时卸载装回去数据全没了这种包会被骂的。3.3 conffiles配置文件更新不丢用户改动deb 包管理的etc目录下的配置文件有一个特例机制把它们写进DEBIAN/conffiles之后升级时如果用户改过这个文件dpkg 不会直接覆盖而是把新版本文件装成.dpkg-dist后缀让用户手动对比合并。/etc/myapp/config.yaml只有一行但意义重大。没写 conffiles 的配置文件升级时直接被新文件覆盖用户改过的参数全丢写了 conffiles虽然升级后会弹出说明但至少数据安全。这就是conffiles和普通文件之间的本质区别conffiles 明确表示“这个文件属于管理员不是程序的”。3.4 目录权限与默认数据目录Go 程序运行时经常需要创建数据目录或者读写日志。安装时我不会把/var/lib/myapp直接写进包的数据部分而是让postinst负责创建并设置权限if ! getent group myapp /dev/null 21; then groupadd --system myapp fi if ! getent passwd myapp /dev/null 21; then useradd --system --gid myapp --shell /usr/sbin/nologin \ --home-dir /var/lib/myapp --no-create-home myapp fi install -d -o myapp -g myapp -m 0750 /var/lib/myapp install -d -o myapp -g myapp -m 0750 /var/log/myapp用专用系统用户跑服务而不是 root这是我在被安全同事提醒后养成的习惯。注意前提是没这个用户才创建getent的判断不能省——重复执行postinst不会因为重复创建用户而报错但写不好会埋雷。4. 把构建流程固化Makefile、nfpm 与 goreleaser 怎么选4.1 手动 dpkg-deb 的维护成本在哪里走到这一步还停留在dpkg-deb --build手搓是完全可以的。但一旦项目开始频繁迭代手动维护的痛点会很明显每次版本号要手动改目录名、control 文件里的 Version、md5sums 要重新生成、脚本钩子可能忘了可执行权限。我还见过因为目录名和实际 Version 不一致装完dpkg -s显示的版本和文件名对不上的情况。手动流程适合一次性交付和纯个人项目。只要你的项目要持续迭代我建议直接跳到 nfpm或者把流程写进 Makefile 半自动化。4.2 nfpm用 yaml 描述包内容nfpm 是一个专门把任意目录结构打包成 deb/rpm/apk 的命令行工具Go 写的单文件CI 里可以直接拉二进制来用。它的核心是一个nfpm.yamlname: myapp arch: amd64 platform: linux version: 1.0.0 section: utils priority: optional maintainer: ops opsexample.com description: Internal metrics collection agent homepage: https://example.com/myapp depends: - ca-certificates contents: - src: ./bin/myapp dst: /usr/local/bin/myapp file_info: mode: 0755 - src: ./config/config.yaml dst: /etc/myapp/config.yaml type: config - src: ./packaging/myapp.service dst: /lib/systemd/system/myapp.service - src: ./packaging/postinstall.sh dst: /usr/local/share/myapp/postinstall.sh type: symlink # 这行只是示例表示特殊类型由 nfpm 处理 scripts: postinstall: ./packaging/postinstall.sh preremove: ./packaging/preremove.sh postremove: ./packaging/postremove.sh好处很明显type: config会自动写入 conffilesfile_info.mode可以精确控制权限scripts 段把钩子脚本从包目录里抽离出来不再需要手动拼 DEBIAN 目录。打包命令就是一句nfpm package --packager deb --target dist/版本号可以从环境变量传入nfpm package --packager deb --target dist/ --version $VERSION我把 nfpm 的 yaml 和脚本钩子放在packaging/目录下和cmd/、internal/完全隔离。这样看仓库结构的人一眼就知道业务代码归业务代码打包逻辑归打包逻辑。4.3 goreleaser 适合什么场景goreleaser 是另一个常见选择它把 Go 二进制构建和发布整个旅程都接管了包括交叉编译、生成 arch Linux 包、上传 GitHub Release。如果你的项目要同时发布Windows、macOS、Linux 多平台、多架构的二进制并且还要打 deb、rpm、以及生成 checksum 文件goreleaser 是省心的方案。goreleaser 的配置里嵌入了 nfpm所以懂 nfpm 之后迁移过去成本很低release: github: owner: yourname name: myapp nfpms: - id: deb package_name: myapp file_name_template: {{ .ProjectName }}_{{ .Version }}_{{ .Arch }} formats: - deb contents: - src: ./config/config.yaml dst: /etc/myapp/config.yaml type: config - src: ./packaging/myapp.service dst: /lib/systemd/system/myapp.service我发现 goreleaser 的一个问题是如果项目只需要打一个 amd64 的 deb用它的全量流程有点杀鸡用牛刀。配置文件的增长速度和复杂度和手动流程差不多但引入的间接层更多。4.4 我的建议矩阵为了帮读者快速定位我把选型逻辑压缩成了一张表场景推荐方案理由一次性交付只出 Linux amd64手动 dpkg-deb零依赖改动少内部工具持续迭代主要是自己打包Makefile nfpm配置清晰CI 好接入开源项目或多人维护多平台发布goreleaser一套配置打所有包省心依赖 deb/rpm 同时输出版本矩阵的压力goreleaser nfpm 共享配置一次配置多处复用我个人现在的组合是 Makefile 负责编译和版本号生成nfpm 负责打包CI 里只跑make package和make test-install。这一步走完发布一个版本从改动到出包只需要一行命令错误率直线下降。5. 踩坑实录十个月打包以来最容易被忽视的细节5.1 架构与 libc不解决这个问题会在老系统上翻车Go 的交叉编译让很多人误以为“编译完就是万能二进制”其实有两个坑。第一个是CGO_ENABLED 没关干净。Go 的net包在 cgo 开启时会走操作系统提供的 resolver也就可能链接 glibc。而目标机器是 CentOS 7 或者某老 Debian 时glibc 版本比构建机的旧装完包一跑就是version GLIBC_2.34 not found。解决办法是在构建命令里显式声明CGO_ENABLED0 go build -trimpath -ldflags -s -w -o bin/myapp ./cmd/myappCGO_ENABLED0直接从根上断绝动态链接。能用纯静态就用纯静态这是 Go 编译器给我们的礼物。第二个是架构必须精确匹配目标机器。amd64 的包装不到 arm64 机器上这一点大部分人知道但容易忽略的是旧系统的i386和amd64混用问题。如果你还有跑在 32 位环境里的老设备记得单独交叉编译 i386 包。Go 对交叉编译支持极好一个环境变量就能解决但 control 文件的 Architecture 也要对应改。5.2 md5sums 不匹配打包完修改文件的连锁反应我在初学阶段犯过这个错先dpkg-deb --build生成了包然后发现 service 文件的ExecStart路径写错了就直接解压 deb改完里面的内容再重新 build 一遍。看似没问题实际上 md5sums 里记录的校验和还是旧的dpkg 一旦发现校验和不匹配之后所有依赖该校验的操作都会告警甚至升级时拒绝继续。这个问题的根源是md5sums 是构建瞬间生成的文件快照。任何事后改动都必须重新生成 md5sums否则包就是脏的。用 nfpm 或 goreleaser 时不会遇到这个问题因为它们是先备齐所有文件再一次性打包。如果坚持手动流程可以用下面这行生成 md5sumscd myapp_1.0.0_amd64 find . -type f ! -path ./DEBIAN/* -exec md5sum {} \; | sed s# ./# # DEBIAN/md5sums cd -注意这里移除了./前缀DEBIAN 目录自身不参与校验。5.3 版本号规范与时间戳稳定性Debian 版本号不能有下划线、不能以数字开头引用 git tag 的v、-会被解析成 “上游版本-修订版本” 分隔符。这些都是小问题真正玄学的是包内文件时间戳。dpkg 记录文件 mtime 的目的是辅助校验构建时间的随机性会让“同一版本打两次包”得到的 md5 和 mtime 都不同。为了可复现构建打包前把 mtime 固定成同一时刻很有必要。nfpm 支持通过环境变量SOURCE_DATE_EPOCH把所有文件时间统一SOURCE_DATE_EPOCH$(git show -s --format%ct HEAD) nfpm package ...这个技巧是从可复现构建的实践经验里学来的。虽然对个人项目未必非要追求 bit-for-bit 可复现但团队协作时两个成员用同一 tag 打出来的包内容完全一致能省掉很多“是不是环境不同”的怀疑。5.4 lintian 检查被警告不等于失败但要看懂警告装完 lintian 之后跑一句lintian myapp_1.0.0_amd64.deb它会输出一堆警告。我在项目里看到的常见警告无非这几类警告示例含义处理方式no-manual-page没有 man page可接受补一个更好但不算错误description-starts-with-leading-spacesDescription 格式不规范改成每行顶格第一行短描述后空一行再写长描述non-standard-dir-perm目录权限非 0755检查是不是把数据目录的 0750 权限写进了包systemd-service-file-missing-install-keyservice 文件缺[Install]段补WantedBymulti-user.targetlintian 里的E:错误必须处理W:警告大部分也值得处理。我见过有人把 lintian 的输出全忽略结果包在某些自动化托管平台上被拒绝收录原因就是description-starts-with-leading-spaces这种低级问题。5.5 其他细碎但致命的小坑脚本权限postinst、prerm、postrm 这些钩子脚本如果忘了加chmod xdpkg 会在执行阶段报Permission denied。手动流程里最容易踩nfpm 里需要给file_info.mode显式设置 0755。systemd unit 文件的[Install]段没有WantedBymulti-user.targetsystemctl enable会失败或者没有效果导致重启后服务不自启。写好之后建议在干净环境里跑一次systemctl is-enabled myapp.service验证。二进制执行路径和 unit 文件不一致/usr/local/bin与/usr/bin的区别不同发行版的 systemd 环境变量不同如果 unit 文件里写死某个路径服务就会起不来。现在统一为/usr/local/bin或者/usr/bin后在测试机上多跑几遍就心理踏实了。6. 发布前的最终验证流程从安装到卸载走一遍6.1 用一个干净容器做冒烟测试打出来的 deb 不能只在你自己机器上装因为你的环境很可能已经放了二进制或配置文件掩盖了“安装过程本身是否正确”。我习惯在干净的容器里做完整冒烟测试比如用 Ubuntu LTS 的 docker 镜像docker run --rm -it -v $PWD:/pkg ubuntu:22.04 bash进容器后执行apt-get update apt-get install -y /pkg/myapp_1.0.0_amd64.deb myapp --version systemctl status myapp.service systemctl is-enabled myapp.service这里有一个小坑容器默认没有 systemd 作为 PID 1systemctl可能会报错。真正要验证 systemd 集成可以启动一个带 systemd 支持的容器镜像或者在测试虚拟机里跑。我通常是两条腿走路容器里只验证文件路径、二进制能跑虚拟机里专门验证 systemctl 起停、开机自启。6.2 验证清单安装、配置保留、升级、卸载、清理我会把最终的验证步骤列成一个 checklist只要有一项不通过就不发布安装阶段dpkg -i退出码为 0。dpkg -s myapp显示Status: install ok installed。二进制--version输出版本号与包名一致。配置文件的默认值完整写入/etc/myapp/config.yaml。systemd 服务处于 running 状态systemctl is-enabled返回值是enabled。配置保留阶段手动修改/etc/myapp/config.yaml里的一个参数。再次dpkg -i升级到新版本检查用户修改的参数是否还在。如果不存在.dpkg-dist文件说明 conffiles 没生效。升级阶段dpkg -i myapp_1.1.0_amd64.deb成功。服务在升级后自动重启不需要手动systemctl restart。卸载阶段systemctl stop成功后dpkg -r myapp。检查/usr/local/bin/myapp、/lib/systemd/system/myapp.service已删除。/etc/myapp/config.yaml是否保留remove 时系统会自动保留这个符合预期。数据目录是否保留我特意设计成 remove 保留数据purge 才删除。最后dpkg --purge myapp确认/var/lib/myapp被彻底删除。6.3 一个小技巧把版本号塞进二进制再验证打包前在 Makefile 里把版本号写进二进制LDFLAGS : -X main.version$(VERSION) -X main.commit$(COMMIT) -s -w build: CGO_ENABLED0 go build -trimpath -ldflags $(LDFLAGS) -o bin/myapp ./cmd/myapp这样安装后执行myapp --version立刻能看到源码版本、构建时间的组合。这个动作看似和打 deb 无关但它能保证包管理和源码版本之间不会出现对不上。实际跑过一轮完整验证之后我觉得整个流程里最有价值的其实是最后那个--purge测试。很多包能做到“能装”“能跑”但很少有人验证“卸载后系统回到初始状态”。而运维同事最怕的就是卸不掉、卸不干净。当一个 deb 包能够优雅地装了、升级了、又完全卸掉这个包的交付质量才算真的过关。
阅读完成 · 觉得有帮助?