每次给电脑重装 Ubuntu,坐在终端前敲那二三十条命令的时候,我都觉得自己像个手动流水线上的工人:git 要装、curl 要装、软件源要换、显卡驱动要找半天、Python 环境配来配去……等这些都折腾完,两三个小时就没了,这一天原本想干的正事一个字都没动。
后来我花了一下午时间,把这些重复劳动固化成一整套 Ubuntu 系统初始化脚本,从那以后重装系统的耗时从半天压缩到半小时出头,而且装出来的环境比我手动装的时候更干净、更统一。今天这篇就聊聊这套脚本是怎么设计的、每一段代码干什么用、以及我在反复重装和改脚本过程中踩过的那些坑。适合所有被"重装系统后还要手动配环境"折磨过的开发者和桌面用户参考。
1. 重装一次系统,哪些时间是被白白浪费掉的
1.1 手动重建环境的真实时间账单
先算一笔账:装完 Ubuntu 之后,有多少时间是花在"必须做的事"上,又有多少是花在"重复劳动+无谓搜索"上?
以我自己的使用习惯为例,一次干净的系统安装之后,至少要执行这些操作:替换软件源、apt update && apt upgrade、安装build-essential、git、curl、vim、htop、网络工具;然后装 GPU 驱动(如果显卡是 NVIDIA,还要先确认型号、找驱动版本、禁用 nouveau);接着是 Python 相关环境、Node 环境、Docker、配置 git 用户名和邮箱;最后还有输入法、字体、终端、常用软件。这一套下来少说三四十条命令,遇到网络波动、源同步延迟、驱动冲突,时间就直奔一下午去了。
更麻烦的是手动操作很难保持一致。同一个软件,这次忘了装某个依赖,下次又多加了一个参数,最终两台机器上的环境根本对不上。环境不一致带来的问题比环境本身难搞多了——排查半天发现是某台机器少装了一个库,这种事情我遇到太多次。
1.2 哪些工作适合写进脚本,哪些不适合
动手写脚本之前,第一件事是划分边界。不是所有东西都适合塞进安装脚本,否则脚本会变成一个谁都不敢碰的大炸弹。
适合脚本化的操作有三个特征:确定性强、无需人工决策、重复成本高。比如替换软件源、安装固定列表的软件包、写入配置文件、启用 systemd 服务、创建用户组、克隆配置仓库,这些都是"不管执行多少次,预期结果都一样"的操作。
不适合脚本化的操作也有三类:第一类是需要物理介入或硬件相关的,比如提前在 BIOS 里改启动项、处理磁盘分区,这些必须在系统安装阶段完成;第二类是需要交互授权的,比如涉及公司内部证书、需要输入密码的私密配置,我不会明文写进脚本;第三类是频繁变化的软件版本策略,例如某个工具链的版本策略每个月都在变,与其写死版本号不如在脚本里留一个开关,由使用者自己决定。
1.3 脚本的适用边界:桌面版与服务器的差异
这套脚本我最初是给桌面版 Ubuntu 写的,后来发现服务器上也基本通用,只是要砍掉一批桌面相关的模块。所以脚本里我会刻意区分两个分支:一个目标是"装完能直接干活、能正常上网办公",另一个目标是"最小化开发环境,不装任何图形界面相关的东西"。
两者的差异集中在输入法、字体、GUI 软件和 GPU 驱动这几个模块上。写脚本的时候我建议用一个开关变量来控制,而不是维护两套脚本——两套代码迟早会因为修改不同步而漂移。
2. 安装脚本的模块设计:先装驱动还是先配源不是拍脑袋定的
2.1 模块清单与优先级背后的依赖逻辑
我的脚本分六个模块:系统基础配置、开发工具链、运行时环境、容器与虚拟化、桌面与常用软件、收尾清理。每个模块的先后顺序不是随便排的,背后是依赖关系和稳定性考量。
系统基础配置必须放在最前面,因为apt的安装源决定了所有软件包的下载速度和可用性;换源之后紧接着apt update和apt upgrade,保证系统在干净的基线状态上继续操作。开发工具链放在第二,因为大部分后续安装过程都依赖gcc、make、git、curl这些基础工具。
运行时环境(Python、Node、Java 等)放在第三,它会依赖前面安装的工具来编译或者下载。Docker 和虚拟化放在第四,因为 Docker 的安装源和配置最好在系统稳定之后再动。桌面软件放在第五,最后再统一清理缓存。显卡驱动我会单独抽出来做成一个独立脚本,不放进主流程——原因是它太容易出问题,一旦装坏了会导致进不了桌面,应该单独可控地执行。
2.2 为什么把"重复执行安全"当作第一原则
这个原则值得永远放在第一位:同样的脚本跑十次,结果应当是收敛的,既不能报错,也不能产生重复配置。
我第一版脚本就是没想清楚这一点,每次都无脑apt install一大堆包。重装系统后跑一次没问题,但第二次调试脚本时就开始出现各种"Package already installed"的噪音,甚至配置文件被重复追加。后来我写了一个简单的幂等思路:安装软件包前先检查是否已安装,配置文件的写入用"不存在才写入"的逻辑,需要追加内容时先检查是否已包含目标字符串。
实现幂等并不复杂,核心就是用 markdown 脚本里的"检测-执行"代替"直接执行"。以 apt 为例,先dpkg -s 包名判断包是否存在,再决定是否执行安装。配置文件更简单,用grep -q判断目标内容是否已经存在,不存在才执行写入。
2.3 参数化与开关:让同一个脚本兼顾两类机器
一个脚本打天下的前提是参数化。我在脚本头部定义了几个开关变量,比如INSTALL_DESKTOP、INSTALL_NVIDIA、INSTALL_DOCKER,默认值按最常用的场景设定。执行的时候可以通过命令行参数覆盖,比如./ubuntu-init.sh --minimal表示只装服务器最小化环境,./ubuntu-init.sh --with-nvidia表示这次要装显卡驱动。
这么做的好处是显而易见的。我只需要维护一套脚本,不同需求之间的差异通过参数控制,而不是每个人拉一个分支改一个副本。脚本里的每个函数都是相对独立的,main入口根据参数决定调用哪些函数,这个结构让脚本的可读性和可维护性都好了很多。
3. 核心脚本代码拆解:每一段都值得存进你的备份仓库
3.1 脚本骨架与日志函数
先看脚本的整体骨架。我用 Bash 写,没用什么复杂的框架,但开头的基础代码值得每一行都理解清楚:
#!/bin/bash set -euo pipefail SCRIPT_NAME="ubuntu-init" LOG_FILE="$HOME/${SCRIPT_NAME}-$(date +%Y%m%d-%H%M%S).log" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 开关参数,默认按常规桌面环境 INSTALL_DESKTOP="${INSTALL_DESKTOP:-1}" INSTALL_DOCKER="${INSTALL_DOCKER:-1}" INSTALL_NVIDIA="${INSTALL_NVIDIA:-0}" SKIP_UPDATE="${SKIP_UPDATE:-0}"set -euo pipefail这三句话是 Bash 脚本保命三件套:遇到任何命令返回非零立即退出(-e),使用未定义变量直接报错(-u),管道中只要一个环节失败就算失败(pipefail)。没有这三行,脚本经常会带着隐患继续跑下去,最后报的错误让你根本找不到源头。
日志函数是脚本的可观测性基础:
log_info() { echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $*" | tee -a "$LOG_FILE" } log_error() { echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $*" | tee -a "$LOG_FILE" >&2 }我用tee同时把日志写到终端和文件里,重装完系统后如果发现哪里不对劲,直接翻日志文件就能看到每一步执行的时间和结果。这个小习惯排查问题的时候能省下一大半时间。
3.2 系统基础配置段:软件源、更新与常用工具
换软件源是整个脚本里最敏感的一步,出问题就是全网失联的节奏。我的做法是先检查当前系统的版本代号,再判断源文件是否已经改过:
config_apt_source() { local codename codename=$(lsb_release -cs) local sources_file="/etc/apt/sources.list" local target_mirror="mirrors.example.com/ubuntu" if grep -q "$target_mirror" "$sources_file" 2>/dev/null; then log_info "软件源已经指向镜像,跳过替换" return 0 fi log_info "正在备份并替换软件源,系统代号: $codename" sudo cp "$sources_file" "$sources_file.bak.$(date +%Y%m%d)" # 这里是关键:直接写入目标镜像的源配置 log_info "source list 已替换,执行 apt update 验证" sudo apt update }注意codename变量很重要。Ubuntu 每个版本的代号不同(比如 20.04 是 focal,22.04 是 jammy,24.04 是 noble),镜像站的路径会带代号。很多人在网上抄来一段源配置,没注意版本对应关系,最后apt update报一堆"仓库没有 Release 文件"的错,根源就在这。我最初也吃过亏,所以现在脚本里永远先读取当前系统代号,再拼镜像地址。
基础工具安装我用了一个包裹函数:
install_if_missing() { if dpkg -s "$1" >/dev/null 2>&1; then log_info "已安装: $1,跳过" else log_info "正在安装: $1" sudo apt install -y "$1" fi } install_base_tools() { local packages=(curl wget vim git htop net-tools unzip zip tree \ build-essential software-properties-common apt-transport-https ca-certificates) for pkg in "${packages[@]}"; do install_if_missing "$pkg" done }dpkg -s判断是否安装,装了就直接跳过。这样整个函数天然可重复执行,不会因为中途断掉再重跑就报错。
3.3 开发环境段:Python、Node 与 Git 配置
Python 环境这块,我的原则是:系统全局只装一个python3-pip和python3-venv,多版本管理交给 pyenv,项目依赖一律用虚拟环境。不在全局环境里乱装包,是避免环境冲突的根本手段。
setup_python_env() { install_if_missing python3-pip install_if_missing python3-venv # 配置 pip 使用国内镜像加速,并设置默认超时 local pip_conf="$HOME/.pip/pip.conf" if [ ! -f "$pip_conf" ]; then mkdir -p "$(dirname "$pip_conf")" cat > "$pip_conf" <<'EOF' [global] index-url = https://mirrors.example.com/pypi/simple trusted-host = mirrors.example.com timeout = 60 EOF log_info "pip 镜像配置已写入 $pip_conf" fi }这里有一个细节值得留意:我在代码中使用cat > file <<'EOF'的写法,EOF加了引号后变量不会展开,可以避免意外把脚本里的变量值写到配置文件里。如果我不加引号,$HOME会被当场展开成当前用户目录,这在某些场景下是不希望发生的。
Node 环境的思路类似。我不直接用 apt 装 nodejs,因为 apt 源里的 Node 版本往往偏老,而且安装后还跟 npm 的升级逻辑纠缠在一起。我更喜欢用 nvm(Node Version Manager)来管理,安装脚本如下:
setup_node_env() { if command -v node >/dev/null 2>&1; then log_info "Node 已存在,跳过 nvm 安装" return 0 fi log_info "安装 nvm,Node 版本由 nvm 管理" curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash export NVM_DIR="$HOME/.nvm" # shellcheck disable=SC1091 [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" nvm install --lts npm config set registry https://mirrors.example.com/npm/ log_info "Node 与 npm 镜像配置完成" }Git 配置没有技术难度,但很容易被人忽略。我在脚本里统一写入user.name、user.email、默认分支名和常用别名,保证每台机器的 Git 行为一致。这些配置是用户级别的,写入${HOME}/.gitconfig,不涉及任何全局改动。
3.4 桌面环境段:输入法、字体与常用软件
桌面环境的配置因人而异,我只说两个大部分用户都会碰到的模块:输入法和终端。
输入法框架现在主流是 Fcitx5 和 iBus 两套体系,我选 Fcitx5,因为整体兼容性和配置清晰度更好。安装命令很直接,装完以后要注意设置环境变量,否则输入法启用不了:
setup_desktop() { sudo apt install -y fcitx5 fcitx5-chinese-addons fonts-noto-cjk # 写入输入法环境变量 local im_config="/etc/environment" if ! grep -q "GTK_IM_MODULE" "$im_config"; then echo 'GTK_IM_MODULE=fcitx' | sudo tee -a "$im_config" echo 'QT_IM_MODULE=fcitx' | sudo tee -a "$im_config" echo 'XMODIFIERS=@im=fcitx' | sudo tee -a "$im_config" fi }/etc/environment是系统级环境变量文件,写入后注销重新登录即可生效。每次重装完系统最容易忘记的就是这几行环境变量,忘了写好多人还以为是输入法软件本身有问题,其实是系统根本没有把输入法挂到图形环境上。
常用的图形软件(VS Code、浏览器、截图工具等)我统一集中在install_misc_apps函数里。这里我特别想提醒一点:像 VS Code 这类软件的安装,官方推荐的方式是把官方 GPG key 和源仓库写进 apt 配置,再通过 apt 安装。这个流程完全可以用脚本自动化,唯一要注意的是 GPG key 的下载地址偶尔会有网络波动,脚本里要做好失败重试或日志告警。
4. 最容易翻车的三个环节:软件源、显卡驱动和 Python 环境
4.1 软件源:版本代号对不上,update 查到怀疑人生
软件源是最前置的步骤,也是我最开始踩的第一个坑。当时我图省事,从网上复制了一段源配置直接写进/etc/apt/sources.list,结果apt update报错说找不到 Release 文件。我盯着屏幕愣了半天,后来仔细一看才发现配置里写的是另一个发行版的代号。
这种错误的坑点在于:镜像站的路径中确实包含发行版代号,不同版本的源是不能通用的。我在脚本里改用lsb_release -cs动态获取代号之后,再也没出过这个问题。这里也给大家交个底:如果你在改源之前不确定自己系统的代号,直接在终端执行lsb_release -a看输出即可,只需要记住你的配置必须和你实际版本完全对应。
另外还有一个容易被忽略的点:手动编辑软件源之前务必先备份。我现在脚本里会自动把原始文件复制成带时间戳的.bak文件。系统装好之后如果发现某些包版本异常,随时可以回退到官方源重新更新,这比出了问题再想办法恢复容易得多。
4.2 显卡驱动:装完进不了桌面怎么办
NVIDIA 驱动应该是 Linux 桌面用户遇到最多问题的重灾区,表现形式无非三种:装完黑屏、进不了桌面、进是进去了但分辨率异常或nvidia-smi报错。
我的建议是不要一开始就上官网手动下载驱动。多数情况下 APT 源里的驱动足够稳定,而且能用系统更新机制持续跟进。先执行ubuntu-drivers devices查看系统推荐的驱动版本,再执行sudo apt install -y nvidia-driver-xxx。驱动装完通常需要重启,脚本只能做到"安装+标记",重启这一步没办法替你完成。
如果你不小心装坏了,进不了桌面,也不用急着重装系统。在登录界面按快捷键切到虚拟终端(通常是Ctrl+Alt+F2或F3),登录后把驱动包彻底卸载再重新安装,大多数情况下都能救回来。这里多说一句:如果涉及禁用 nouveau 的步骤,建议单独做一个内核参数添加的脚本片段,不要混在通用模块里——因为它在不同型号显卡、不同内核版本上的表现差异很大。
4.3 Python 环境:系统解释器与用户环境互踩
Python 环境是另一个重灾区,典型场景是这样的:系统自带 Python 3.10,你自己又装了 Python 3.11 并改了PATH;某个包用pip install装进去了,但命令行工具怎么都调不出来。大多数情况不是软件坏了,而是"你装的包在哪个解释器里"和"你在终端敲的 python 是哪个解释器"根本不是一回事。
我的经验是用统一的习惯来约束环境:只要涉及 Python 项目,一律在项目目录里执行python3 -m venv .venv创建虚拟环境,然后激活环境后再装依赖。系统层面的 Python 不做任何除了pip/venv之外的扩展安装。脚本层面只需要保证系统里存在python3-venv,余下的交给每个项目自己去隔离。
额外再提一个很多人不注意的细节:pip装了一些带命令行入口的包之后,如果提示"command not found",先检查python3 -m pip show 包名的输出,看包的Location是全局还是用户目录。然后检查~/.local/bin是否在你的PATH中——这个目录经常不在默认的 PATH 里,导致"装成功了却用不了"。脚本里我会显式地把/home/$USER/.local/bin追加到PATH配置,这是一个低成本高回报的修复。
5. 提高脚本复用度:日志、断点和备份三件套
5.1 日志不只是记录,它是排查问题的地图
早期版本的脚本只在屏幕输出,遇到问题一头雾水。后来我加上了日志文件,每次执行就生成一个带时间戳的*.log,脚本跑挂了,直接查看最后几十行就能锁定挂在哪一步。
我更推荐的做法是:日志信息区分两类。普通步骤用[INFO],真正失败的地方用[ERROR]并且带完整上下文。这样即便你的脚本被安排成无人值守方式运行,回来只需要一条grep ERROR ~/ubuntu-init-*.log | tail就能找到最需要关注的点。
5.2 断点续跑:中断之后不用从头再来
脚本跑了一半被用户中断、或者某一步网络超时导致失败,这种情况在真实环境里太常见。如果脚本不设计断点续跑,你不得不从头执行一遍——虽然幂等设计保证了重复执行安全,但前面那些已经装好的包还要重来一遍,很浪费时间。
我用的方案是"标记文件"机制。每个模块执行成功之后,在脚本目录下写一个.done标记文件,下一次执行时先检查标记文件是否存在,存在就跳过整个模块。
mark_done() { local module_name="$1" touch "$SCRIPT_DIR/.done-${module_name}" } is_done() { local module_name="$1" [ -f "$SCRIPT_DIR/.done-${module_name}" ] } if is_done "base_tools"; then log_info "基础工具模块已完成,跳过" else install_base_tools mark_done "base_tools" fi这套机制配合幂等设计,脚本的运行效率和稳定性都上了一个台阶。当然,如果你想强制重新执行,删掉对应模块的.done文件即可。我还加了一个--force参数,方便一键清理所有标记文件重新跑全套。
5.3 备份现有配置:别让旧配置成为新系统的"历史包袱"
每次重装系统之前,我会先想办法把旧系统里的关键配置文件备份出来。这里面最有价值的往往不是"点文件"本身,而是"点文件里积累的自定义内容"。
我在脚本里做了一个backup_old_config的函数,它会扫描用户的.bashrc、.zshrc、.gitconfig、.config下几个重点目录,打包成一个归档文件放到自定义位置。这样重装之后可以对比新旧配置,看看哪些习惯值得保留,哪些旧的配置可以狠心抛弃——毕竟系统都重装了,那些堆了好几年的无效配置也该清理掉了。
6. 脚本的日常维护:装上只是开始
6.1 跨版本升级时的适配清单
Ubuntu 大版本每两年发布一次,升级之后脚本很可能需要跟着调整。我的维护经验是按照固定清单过一遍:发行版代号是否变化(影响了软件源路径)、默认 Python 版本是否变化(影响了 pyenv 和各项目的版本策略)、输入法框架是否发生变化、Docker 安装源是否需要更新。
这些适配工作看起来琐碎,但每次大版本升级只需要花半小时就能完成。如果你把脚本扔在那里两年不管,下一次重装系统大概率会遇到一批过时的源和安装方式,到时候反而更浪费时间。
6.2 我常用的测试方式和维护节奏
脚本的测试策略我只有一个建议:在虚拟机里先跑一遍。我习惯用一台虚拟机挂载新版 Ubuntu 镜像,先执行最小化安装场景,再执行完整桌面场景。虚拟机的网络和实际使用环境越接近越好,因为源配置、GPG 请求这些环节最容易受到网络环境影响。
测试的重点不是"能不能装完",而是"装完之后环境是否一致"。我会在跑完脚本后手动检查几个指标:能否在一个新目录里创建 Python 虚拟环境、能否成功拉取 docker 镜像、输入法是否自动启动、显卡驱动是否加载成功。这些检查全过,才说明脚本在新版本系统上真的可以放心用。
6.3 最后分享一个习惯
写这套脚本之后最大的收获,不是"重装系统变快了"这么简单,而是我开始用"可复用、可验证"的思路去处理环境配置。以前哪个工具出问题就临时敲命令修一下,修完也不会留下任何痕迹;现在我会顺手把这个修复写进脚本,让下次重装系统时少踩一遍同一个坑。
脚本本质上是一种"可执行的经验记录"。我建议你也从自己的实际需求出发,把每次重装系统后的安装命令整理成一个简单脚本,先不管写得多优雅,跑通了再逐步优化。你不需要一开始就把所有模块都设计完,从最常做的几个步骤开始,随着反复重装慢慢积累,这套脚本会越来越贴合你自己的使用习惯。重装系统的效率翻倍只是顺带的结果,更值钱的是你终于把"手动流水线工人"这份工作辞掉了。