一套照着敲就能跑通、并且知道为什么这么敲的 Linux 服务器部署教程。
默认环境:Ubuntu 22.04 LTS(apt 系)。遇到 RHEL / Rocky / AlmaLinux 差异大的地方,用可折叠对照块给出等价命令。
[!NOTE] 本教程所有版本号截至 2026 年 9 月。软件版本会持续变化,教程里每一章都教了你如何自己核对当前版本(见 版本速查),这是教程过期后仍然可用的关键。
| 部分 | 章节 | 内容 |
|---|---|---|
| PART 0 | 00 前言与阅读约定 | 安全基线、环境变量原理、危险命令红名单 |
| PART 1 | 10–18 | Shell 与文件系统、文件操作、文本处理、用户权限、进程、网络、防火墙、包管理、SSH 加固 |
| PART 2 | 20–33 | JDK、Python、Node.js、MySQL、PostgreSQL、MongoDB、Redis、RabbitMQ、Kafka、Nginx、Docker、Git 与构建工具、HTTPS 证书、国内镜像加速 |
| PART 3 | 40–44 | systemd、源码编译安装、进程守护选型、运维工具箱、备份与定时任务 |
| PART 4 | 50–55 | 日志基础、journalctl、查询技巧、统计聚合、logrotate、磁盘占满排查 |
| PART 5 | 60 | 端到端实战:从零部署一个完整项目 |
| 附录 | 90–92 | 命令速查、版本速查、排错手册 |
完整目录与三条推荐阅读路线见 → docs/README.md
| 文件 | 主题 |
|---|---|
| 10-shell-and-filesystem.md | FHS 目录地图、绝对路径/相对路径、软链接与硬链接 |
| 11-file-operations.md | 增删改查、打包压缩、scp / rsync 传输 |
| 12-text-processing.md | 重定向、管道、grep / find / sed / awk / xargs / sort |
| 13-users-and-permissions.md | 用户与组、rwx 权限、为什么是 755 不是 777、umask、特殊位 |
| 14-process-management.md | ps / top / kill、信号、前后台、负载怎么看 |
| 15-network-configuration.md | ip / ss / netplan、DNS、时间同步 |
| 16-firewall.md | 防火墙三层模型、ufw / firewalld / iptables |
| 17-package-management.md | apt / dpkg、如何查可用版本、signed-by、dnf 对照 |
| 18-ssh-hardening.md | 密钥登录、禁 root 直登、改端口、fail2ban |
| 文件 | 主题 |
|---|---|
| 20-jdk.md | JDK 17/21/25、Temurin、多版本切换、JAVA_HOME |
| 21-python.md | 不动系统 python3、deadsnakes / pyenv / 源码、venv、pip |
| 22-nodejs.md | nvm / NodeSource、pnpm、PM2 守护 |
| 23-mysql.md | 8.0 vs 8.4 LTS、安全初始化、认证插件、utf8mb4 |
| 24-postgresql.md | PGDG 源、双配置文件、角色与库 |
| 25-mongodb.md | 8.0 官方源、bindIp、先建管理员再开认证 |
| 26-redis.md | 8.x 安装、License 提示与 Valkey 替代、内存策略 |
| 27-rabbitmq.md | Erlang 版本对应表与安装顺序、用户 / vhost / 插件 |
| 28-kafka.md | KRaft 模式(4.x 无 ZooKeeper)、格式化存储、topic 管理 |
| 29-nginx.md | nginx.org 源、反向代理、nginx -t、reload vs restart |
| 30-docker.md | Engine 29.x、Compose v5 插件、daemon.json、镜像加速 |
| 31-git-and-build-tools.md | git、Maven / Gradle、build-essential、make |
| 32-https-certbot.md | Let's Encrypt 证书申请与自动续期 |
| 33-mirrors-acceleration.md | apt / pip / npm / Maven / Docker 国内镜像源 |
| 文件 | 主题 |
|---|---|
| 40-systemd.md | unit 结构、Restart= 为什么这么配、开机自启的本质 |
| 41-source-compile-install.md | configure → make → install、ldconfig、--prefix 隔离 |
| 42-process-supervision.md | systemd / supervisor / PM2 三种守护方式对比选型 |
| 43-ops-toolbox.md | htop / btop / iotop / ncdu / lsof / strace / tcpdump / fail2ban / tmux |
| 44-backup-and-cron.md | crontab 五段与常见坑、systemd timer、3-2-1 备份 |
| 文件 | 主题 |
|---|---|
| 50-log-fundamentals.md | /var/log 地图、rsyslog 与 journald 的分工 |
| 51-journalctl.md | 时间窗口、按 unit、按优先级、持久化与清理 |
| 52-log-query-recipes.md | tail -f/-F、less +F、上下文过滤、查历史压缩包 |
| 53-log-analysis.md | awk 聚合、错误 TOP N、按 IP/状态码、时间窗截取 |
| 54-logrotate.md | 配置逐条讲解、copytruncate 的坑、干跑调试 |
| 55-disk-full-troubleshooting.md | df -h vs df -i、定位大文件、已删文件仍占空间 |
| 文件 | 主题 |
|---|---|
| 60-hands-on-project.md | 端到端:Java 后端 + MySQL + Redis + Nginx 反代 + HTTPS + systemd |
| 文件 | 主题 |
|---|---|
| 90-cheatsheet-commands.md | 跨章高频命令索引 |
| 91-cheatsheet-versions.md | 版本速查表(全篇唯一权威来源) |
| 92-troubleshooting-playbook.md | 排错方法论 + 症状 → 章节索引 |
路线一:新手直通(按顺序读)
00 → 10 → 11 → 12 → 13 → 17 → 40 → 按需挑 PART 2 的组件章 → 60(精简版,完整路线见 docs/README.md)
路线二:有基础,直接装环境
00(只看约定部分)→ 挑 PART 2 对应组件章 → 40(systemd 托管)→ 29(Nginx 反代)
路线三:服务器出故障了
92(症状 → 章节索引)→ 55(磁盘满了)/ 53(日志分析)/ 16(连不上,防火墙)
$ 是提示符,不要复制它;$ sudo xxx 表示这条命令需要管理员权限<服务器IP>、<你的域名>、<强密码> 需要替换成你自己的值> [!NOTE] 补充说明 / [!TIP] 技巧 / [!IMPORTANT] 必须做 / [!WARNING] 会出事[!WARNING]
- 不要用 root 直接操作系统。用有 sudo 权限的普通用户,改配置前先备份。
- 不要动系统自带的
python3,它支撑着 apt 等系统工具,弄坏很难修。- 任何
rm -rf、chmod -R 777、kill -9、启用防火墙前,先确认目标写对了 —— 这些操作不可撤销。
这是一套面向零基础读者的《Linux 服务器部署教程》。在翻开任何一章之前,请先读完本章——它只有十几分钟的阅读量,却能帮你建立正确的操作姿势,避开后面 80% 的坑。本章不讲部署,讲的是怎么读这本书、怎么安全地操作服务器、以及 shell 背后最基本的运行规则。
[!NOTE] 本教程基于 Ubuntu 22.04 LTS 编写,版本号截至 2026-09。用 RHEL 系的读者请留意各章的「RHEL 对照」折叠块。
不同的人带着不同的目的来,我们准备了三条路线:
| 路线 | 适合谁 | 怎么走 |
|---|---|---|
| 新手直通 | 从零开始、想系统学习 | 按章节编号从 00 顺序读到 40,每章动手做完再往下 |
| 按组件查阅 | 有基础,只想装某个软件 | 直接跳到对应章节(如 MySQL、防火墙),章内自成闭环 |
| 救火 | 线上出事了,急需答案 | 先看 排错手册 定位章节,用「常见问题」表对症下药 |
完整章节目录见 README。
每章的「验证方法」小节都给了命令 + 预期输出。请务必亲手敲一遍、亲眼核对输出——服务器运维是肌肉记忆,光看不练等于没学。准备一台练习环境即可开始:本地 VirtualBox/VMware 虚拟机,或任意云厂商按量付费的低配服务器(用完记得销毁,防止持续扣费)。
全书代码块统一使用 $ 作为命令提示符:
# 以普通用户执行(绝大多数场景)
$ ls -la
# 需要 root 权限时,加 sudo 前缀
$ sudo apt update
三条规则:
$ 是提示符,不是命令的一部分,不要把它复制进去。$ sudo 开头表示该命令需要管理员权限,执行后可能要求输入当前用户的密码(输入时屏幕不显示任何字符,这是正常设计,输完回车即可)。sudo。这是安全基线,原因见下文「安全基线」一节。代码中出现的尖括号是占位符,需要替换成你自己的真实值,不要原样照抄:
| 占位符 | 替换成 |
|---|---|
<服务器IP> |
你服务器的公网/内网 IP,如 203.0.113.10 |
<你的域名> |
你在域名商处购买的域名,如 example.com |
<强密码> |
你自己生成的强密码(建议用密码管理器生成) |
<新主机名> |
你想要的主机名,如 web-01 |
<用户名> |
你的登录用户名 |
全书只用四种提示块,含义如下:
| 标记 | 含义 | 你该怎么做 |
|---|---|---|
> [!NOTE] |
补充说明、背景知识 | 可以跳过,回头再看也行 |
> [!TIP] |
更好的做法、省事的技巧 | 建议采纳 |
> [!IMPORTANT] |
关键前提,不做会失败或留隐患 | 必须照做 |
> [!WARNING] |
有风险,可能丢数据或失联 | 停下来读清楚再动手 |
全书代码块开头都标注语言:bash(命令)、console(终端交互含输出)、text(纯文本输出)、ini / yaml(配置文件)、powershell(Windows PowerShell 命令)。标注语言除了高亮,更重要的目的是让你一眼分辨「这是要输入的命令」还是「参考输出」。
Ubuntu 之外,很多公司用 RHEL / Rocky / AlmaLinux。涉及发行版差异的地方,我们会放一个可点击展开的折叠块,不影响 Ubuntu 读者的阅读连贯性。长这样(点击即可展开):
| Ubuntu (apt/ufw) | RHEL (dnf/firewalld) | 说明 |
|---|---|---|
sudo apt install nginx |
sudo dnf install nginx |
包管理器不同 |
sudo ufw allow 80/tcp |
sudo firewall-cmd --permanent --add-service=http |
防火墙工具不同 |
全书版本号截至 2026-09。 软件版本会持续更新,本书给出的具体小版本号(如 8.0.4x)可能已经过期。唯一权威的核对方式是查阅 版本速查表,以及动手前自己执行 apt policy <包名> 确认当前源里的候选版本。冲突时一律以官方文档为准,本书只负责告诉你「怎么自己核对」。
[!IMPORTANT] 任何一台新拿到的服务器(云主机、虚拟机都算),在部署任何业务之前,先完成下面 6 件事。顺序执行,全程不超过一小时。跳过任何一步,都是在给未来的自己埋雷。
用 SSH 密钥登录,禁用密码登录。 密码可以被暴力破解,密钥几乎不能。生成密钥并配置的完整步骤见 18 SSH 加固。
建一个普通用户并加入 sudo 组,禁止 root 直接登录。 root 权限太大,一条手滑命令就能毁掉系统;日常用普通用户 + sudo,既留了审计痕迹,也让误操作多一道确认门槛。
开启防火墙,但第一步必须先放行 SSH(22 端口)。 顺序反了会把自己锁在门外。Ubuntu 用 ufw,详见 16 防火墙:
# 先放行 SSH,再启用防火墙,顺序不能反!
$ sudo ufw allow 22/tcp
$ sudo ufw enable
更新系统。 新镜像自带的软件包往往落后于安全补丁:
# 刷新软件源索引,再升级已安装的包
$ sudo apt update && sudo apt upgrade
设置时区并开启时间同步。 时间不准会弄坏 HTTPS 证书校验、日志对齐和定时任务,原理与操作见 15 网络配置 的时间同步一节:
# 设置时区为北京时间
$ sudo timedatectl set-timezone Asia/Shanghai
改任何配置文件前,先备份。 养成条件反射:
# -a 归档模式复制:保留权限、属主、时间戳,还原时不会因权限问题出错
# $(date +%F) 是命令替换,展开成当天日期如 2026-09-29,备份文件带日期便于区分版本
$ sudo cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%F)
备份加日期后缀的好处:同一个文件改多次也不会覆盖旧备份,出问题时能精确回滚到「出事前一天」的版本。
环境变量是操作系统传给每个程序的「全局设置」,格式是 名字=值。你已经在不知不觉中用过它:HOME 是你的家目录,LANG 是语言设置,而其中最重要的是 PATH。
# 查看当前用户家目录
$ echo $HOME
# 查看 PATH:一串用冒号分隔的目录
$ echo $PATH
预期输出(具体内容因环境而异):
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
当你敲下 ls 回车,shell 不会搜索整个硬盘,而是按 PATH 里目录的先后顺序,逐个目录找名为 ls 的可执行文件,找到第一个就执行。这个机制解释了大量新手疑问:
./xxx.sh 前面要加 ./?」——因为当前目录 . 通常不在 PATH 里,必须显式指明。./xxx 能跑而 xxx 报 command not found?」——同上,shell 在 PATH 里找不到它。# which:显示命令的可执行文件在哪个目录(按 PATH 顺序找到的第一个)
$ which python3
# type:比 which 信息更全,能识别别名和内建命令
$ type ll
# whereis:把相关的二进制、源码、手册页位置全列出来
$ whereis nginx
# 临时添加环境变量:export 后当前终端立即生效,但关闭终端就消失
$ export APP_ENV=production
# 验证
$ echo $APP_ENV
想永久生效,必须写进配置文件。写哪里取决于你希望它对谁生效:
| 写在哪 | 对谁生效 | 适用场景 |
|---|---|---|
~/.bashrc |
只对你自己的交互终端 | 个人别名、个人常用变量 |
/etc/profile.d/*.sh |
所有用户(推荐做法) | 服务器级配置,如 JAVA_HOME |
systemd 服务的 Environment= |
只对该服务进程 | 给 nginx/应用服务注入变量,见 40 systemd |
给所有用户设置变量的推荐做法:
# 新建一个独立配置文件,命名建议带上用途前缀
$ sudo nano /etc/profile.d/myapp.sh
# /etc/profile.d/myapp.sh 的内容
export APP_ENV=production
export JAVA_HOME=/opt/jdk-21
最常见的根因:程序装了,但它所在目录不在 PATH 里。比如你把程序解压到 /opt/myapp/bin/,shell 自然找不到。排查三步:
# 第 1 步:确认程序文件真实存在
$ ls /opt/myapp/bin/
# 第 2 步:确认该目录在不在 PATH 里
$ echo $PATH | tr ':' '\n' | grep myapp
# 第 3 步:不在就把目录追加进 PATH(写进 ~/.bashrc 持久化)
$ echo 'export PATH=/opt/myapp/bin:$PATH' >> ~/.bashrc
$ source ~/.bashrc
shell 分三类,加载的配置文件不同,这是本书埋得最深的一个坑:
| 类型 | 什么时候出现 | 按序加载 |
|---|---|---|
| 登录 shell | SSH 登录、控制台登录 | /etc/profile → ~/.profile(其内部会 source ~/.bashrc;Ubuntu 22.04 默认没有 ~/.bash_profile) |
| 非登录交互 shell | 直接开一个终端窗口 | 只加载 ~/.bashrc |
| 非交互 shell | 执行脚本、cron 任务 | 不加载任何 bash 启动文件(除非设置了 BASH_ENV) |
[!WARNING] cron 任务的环境变量和你 SSH 登录后看到的完全不同。 你在
~/.bashrc里 export 的变量,cron 的非交互 shell 不会加载——这就是「命令行里跑得好好的脚本,放进 cron 就失败」的头号原因。解决方法见定时任务章节:要么在 crontab 里写绝对路径,要么在任务行里显式 source 配置文件。
source(简写就是一个点 .)的作用:在当前 shell 里执行指定脚本的命令,而不是新开一个子进程。
# 修改 ~/.bashrc 后让它立即生效,无需重新登录
$ source ~/.bashrc
# 点命令与 source 完全等价
$ . ~/.bashrc
为什么要区分?普通方式执行脚本(bash xx.sh 或 ./xx.sh)会开一个子进程,脚本里 export 的变量随子进程结束而消失;source 则把变量留在当前 shell 里。所以「让 .bashrc 的修改立即生效」只能用 source。
[!WARNING] 下面这些命令本身都是正常工具,但用错场景就是灾难:轻则删光数据,重则整台服务器报废。在生产服务器上敲任何本节命令前,请默念一遍「安全做法」。
| 危险命令 | 一句后果 | 安全做法 |
|---|---|---|
rm -rf <目录> |
递归强制删除,无确认、不进回收站,删了就没了 | 先 ls 确认路径;变量路径先 echo "$DIR" 确认非空 |
rm -rf $DIR/(变量为空时) |
变量为空时命令变成 rm -rf /,删除整个系统 |
用 "${DIR:?变量为空}" 语法强制检查:rm -rf "${DIR:?}/" |
chmod -R 777 <目录> |
所有文件对所有人可写可执行,是安全漏洞,还可能破坏 ssh 等对权限敏感的服务 | 按需最小授权,目录 755、文件 644,私钥 600 |
mkfs <设备> |
格式化磁盘,数据全部清空且无法恢复 | 执行前三次确认设备名;用 lsblk 核对,防止格错盘 |
dd if=... of=<设备> |
直接按字节写设备,写错目标 = 烧毁数据 | 先写清 of 目标再回车;可用 oflag=sync status=progress 观察进度及时 Ctrl+C |
> 重要文件(如 > /etc/fstab) |
重定向立即清空文件内容,没有确认提示 | 追加用 >>;覆盖前先备份(见安全基线第 6 条) |
ufw enable(未放行 SSH 前) |
防火墙一开,SSH 立即被拦,把自己锁在服务器外 | 永远先 sudo ufw allow 22/tcp 再 enable,见 16 防火墙 |
kill -9 <数据库进程> |
数据库来不及刷盘和释放锁,可能数据损坏、下次起不来 | 先 systemctl stop mysql 走正常停机流程,实在不行用普通 kill(SIGTERM)留善后时间 |
crontab -r |
一次性删除当前用户全部定时任务,且没有确认 | 用 crontab -e 删单条;删前先 crontab -l > crontab.bak 备份 |
一条通用原则:破坏性操作的命令,先在测试环境跑一遍,生产环境执行前先把命令完整写出来检查两遍,不要凭肌肉记忆直接敲。
Windows 的文本文件用 CRLF(\r\n)换行,Linux 用 LF(\n)。在 Windows 上编辑过的 shell 脚本传到 Linux 后,每行结尾多一个看不见的 \r,运行时就会报:
/bin/bash^M: bad interpreter: No such file or directory
^M 就是那个多余的 \r。三种解决办法:
# 方法 1:在 Linux 服务器上用 dos2unix 转换(没有就先安装)
$ sudo apt install dos2unix
$ dos2unix script.sh
更推荐在源头解决:编辑器设置成 LF。VSCode 点右下角状态栏的 CRLF 切换为 LF,或 settings.json 里加 "files.eol": "\n";Notepad++ 在「编辑 → 文档格式转换」里切换。这样保存的文件天然兼容 Linux。
# 在 PowerShell 里直接 ssh,与 Linux 上的用法一致
ssh <用户名>@<服务器IP>
如果你是零基础新手,建议严格按这个顺序走(编号即难度递进):
完整目录见 README。祝部署顺利。
本章解决三个问题:Linux 的文件都放在哪、怎么在目录间准确移动、为什么有些文件「看起来在但其实不在」(链接)。命令均在 Ubuntu 22.04 LTS 上验证,涉及版本号的信息一律截至 2026-09。
[!NOTE] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准,核对方法见 91 版本速查。
[!NOTE] 本章绝大多数命令不需要管理员权限,只有安装
tree那一步需要sudo。
Linux 没有 C 盘、D 盘,所有存储设备都挂到一棵以 / 为根的树上。FHS(Filesystem Hierarchy Standard)规定了哪个目录放什么:约定统一后,你在甲机器学会的路径到乙机器同样适用,包管理器也依赖它——apt 必须把程序放 /usr/bin,否则 Shell 找不到。
| 目录 | 放什么 / 提醒 |
|---|---|
/ |
根目录。根下不要放业务数据,根分区满了系统就起不来 |
/home |
普通用户家目录 /home/<用户名>,个人配置 .bashrc 在这里 |
/etc |
系统和程序的配置文件,改配置主要就是改这里 |
/var |
经常变化的数据:日志 /var/log、网站根 /var/www、缓存 /var/cache |
/usr |
系统级程序与只读资源:/usr/bin 命令、/usr/lib 库。名字来自 Unix System Resources,不是 user |
/opt |
第三方独立软件,如手动安装的 /opt/jdk-21 |
/tmp |
临时文件,会被定期清理,不要放重要数据 |
/root |
root 的家目录,不在 /home 下,普通用户无权进入 |
/boot |
内核与 grub,删错就开不了机 |
/dev |
设备文件:磁盘 /dev/sda、黑洞 /dev/null |
/proc |
内核与进程的虚拟视图,不占磁盘,如 /proc/cpuinfo |
/bin /sbin /lib |
基本命令与库,Ubuntu 22.04 上是到 /usr 同名目录的软链 |
/run |
运行时数据(pid、socket),重启即清空 |
/mnt /media |
临时挂载点 |
先亲眼看一遍,比背表格记得牢:
$ ls -1 /
$ ls -ld /bin /sbin /lib
第二行输出开头的 l 和 -> 说明它们是指向 /usr 的软链。养成习惯:任何写操作前先确认「我在哪、目标长什么样」,这是避免事故最便宜的习惯。
| 参数 | 作用 | 何时用 |
|---|---|---|
-1 |
每行一个名字 | 输出要交给脚本或管道 |
-l |
长格式:权限、属主、大小、时间 | 看细节 |
-d |
只显示目录本身不展开 | 看目录自己的属性 |
-F |
结尾加类型标记,目录加 / |
快速分辨目录和文件 |
-a |
显示点开头的隐藏文件 | 找 .env 这类文件 |
$ ls -ld /etc
预期输出:
drwxr-xr-x 183 root root 12288 Sep 20 10:02 /etc
d 是目录;两个 root 分别是属主和属组——所以改 /etc 下的配置需要 sudo;183 是硬链接数(子目录数 + 2);12288 是目录元数据大小,不是内容大小。
| 现象 | 根因 | 解决 |
|---|---|---|
源码放 /root 下,Nginx 报 403 |
/root 对其他用户完全不可读 |
放 /var/www 或家目录 |
/tmp 里的数据第二天没了 |
systemd-tmpfiles 定期清理 | 重要数据放 /var |
| 装了程序却找不到在哪 | 包管理器按 FHS 放 /usr/bin |
command -v <命令> 定位 |
| 根分区 100% 却没存大文件 | 日志在 /var/log 持续增长 |
用 du 定位,见 磁盘排查 |
绝对路径从根写起(/var/log/nginx/access.log),无论当前在哪指向都一样;相对路径以「当前目录」为起点,起点变了指向就变了。特殊符号:. 当前目录、.. 上级、~ 家目录、- 上一次所在目录。
为什么重要:定时任务和 systemd 服务启动时,当前目录往往不是你以为的那个。所以写脚本和服务配置一律用绝对路径。
$ pwd
$ cd /var/log
$ cd ../..
$ cd ~
$ cd -
$ ./deploy.sh
最后一行为什么必须带 ./:Shell 只在 PATH 列出的目录里找命令,故意不含当前目录(防止误执行被投放的同名程序)。给出路径才是安全的做法。
| 命令 / 参数 | 作用 | 说明 |
|---|---|---|
cd 不带参数 |
回到家目录 | 等价 cd ~ |
cd - |
切回上一个目录 | 依赖 OLDPWD,并打印目标目录 |
pwd / pwd -P |
逻辑路径 / 解析软链后的物理路径 | 软链目录下两者不同 |
$PWD $OLDPWD |
当前 / 上一个目录 | 脚本里用 $(pwd) 取位置 |
$ cd /var/log/nginx && pwd && cd - && pwd
预期输出:
/var/log/nginx
/home/ubuntu
/home/ubuntu
第二行是 cd - 打印的「回到哪里」,第三行确认已回来。
| 现象 | 根因 | 解决 |
|---|---|---|
No such file or directory |
相对路径起点不对或拼错 | 先 pwd,或改用绝对路径 |
| 目录名带空格被拆成两个参数 | Shell 按空格分词 | 加引号 cd "/data/my app" |
cd "~" 说目录不存在 |
~ 在引号里不展开 |
去掉引号或写 /home/ubuntu |
脚本里 cat app.conf 找不到 |
脚本当前目录是执行位置,不是脚本所在位置 | 用绝对路径,或 cd "$(dirname "$0")" |
ls 输出解读与 treels 列目录,tree 把目录画成树。既然天天用,就值得把 ls -l 的输出逐字段看懂——排查权限、判断文件是否被改动都靠这些字段。
$ ls -lah
典型输出:
total 48K
drwxr-xr-x 2 ubuntu ubuntu 4096 Sep 20 10:02 .
-rw-r--r-- 1 ubuntu ubuntu 1.2K Sep 20 09:58 app.conf
lrwxrwxrwx 1 ubuntu ubuntu 11 Sep 20 10:01 current -> /opt/app
逐字段:① 权限位 drwxr-xr-x,首字符是类型(d 目录、- 文件、l 软链),后 9 位分三组给属主、属组、其他人;② 硬链接数(文件通常 1,目录是子目录数 + 2);③ 属主;④ 属组;⑤ 大小(-h 变人类可读,目录的 4096 只是元数据,不代表内容大小);⑥ mtime;⑦ 名称,软链显示 -> 目标。
$ ls -lt
$ ls -lSh
$ sudo apt install -y tree
$ tree -L 2 -d /etc/nginx
ls -lt 按时间排(找最近被改的文件),ls -lSh 按大小排。tree 需安装,-L 限制层数、-d 只看目录——不限制层数在大目录上会刷屏。
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install -y tree |
sudo dnf install -y tree |
包名相同 |
/etc/nginx/sites-available |
/etc/nginx/conf.d |
RHEL 系没有 sites-available/-enabled 软链机制 |
| 参数 | 作用 | 何时用 |
|---|---|---|
-h |
大小用 K/M/G | 几乎总是加 |
-t / -S |
按时间 / 按大小排序 | 找最近改动、找大文件 |
-r |
逆序 | 常与 -t、-S 组合 |
-i |
显示 inode 号 | 判断硬链接关系 |
tree -L n |
限制层数 | 建议总是加 |
tree -d / -a / -h |
只看目录 / 含隐藏 / 显示大小 | 看结构、找 .env |
tree -I 'pattern' |
排除匹配项 | 如跳过 node_modules |
$ ls -lht /var/log | head -n 2
预期输出:
-rw-r----- 1 syslog adm 12M Sep 20 10:05 syslog
-rw-r----- 1 syslog adm 4.2M Sep 20 09:40 auth.log
| 现象 | 根因 | 解决 |
|---|---|---|
找不到 .env |
隐藏文件默认不显示 | 加 -a |
cannot open 'x': Permission denied |
父目录缺执行权限 x |
加 sudo,见 用户与权限 |
| 目录大小都显示 4.0K | ls 显示的是元数据大小 |
用 du -sh <目录> |
tree: command not found |
未安装 | sudo apt install -y tree |
tree 一执行就刷屏 |
没限制层数 | 加 -L 2,刷屏中按 Ctrl+C |
du 与 dfdf(disk free)看分区还剩多少,du(disk usage)看这个目录占了多少(累加文件大小)。排障顺序固定:先 df 定位是哪个分区满了,再用 du 在那个分区里逐级缩小范围。顺序反了会浪费大量时间。
$ df -h
$ du -sh /var/log
$ du -h --max-depth=1 /var | sort -hr
第三条最常用:把 /var 下每个子目录的大小列出来并从大到小排序,谁占用最大一目了然。
| 参数 | 作用 | 说明 |
|---|---|---|
-h |
人类可读单位 | 两个命令通用 |
du -s |
只给汇总值 | 只要「这个目录多大」 |
du --max-depth=n |
展开 n 层 | 配 sort -hr |
du -c / --exclude=P |
给合计 / 排除目录 | 跳过 node_modules |
df -T |
显示文件系统类型 | 区分 ext4 / xfs |
df -i |
显示 inode 使用率 | 有空间却报 No space 时必查 |
$ df -h /
预期输出:
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 40G 12G 26G 32% /
Use% 超 80% 就该排查,90% 以上要马上处理——很多程序在磁盘写满时直接崩溃,而不是报错。
| 现象 | 根因 | 解决 |
|---|---|---|
df 100% 但 du 加起来差很多 |
文件被删后仍被进程占用 | lsof | grep deleted 找到进程并重启 |
No space left 但还有空间 |
inode 耗尽(小文件太多) | df -i 确认 |
du: ... Permission denied |
部分目录无权进入 | 加 sudo,扫 /proc 的报错可忽略 |
du 跑得极慢 |
文件数量巨大 | 限定目录范围 |
理解链接先要理解 inode:文件系统把每个文件登记成一条 inode 记录(存权限、属主、数据块位置,不存文件名);文件名只存在于目录里——目录本质是一张「名字 → inode 号」的表。名字和文件是分开的两件事,于是有了两种链接:
| 硬链接 | 软链接(符号链接) | |
|---|---|---|
| 本质 | 多个名字指向同一个 inode | 特殊文件,内容是目标路径字符串 |
| 跨文件系统 | 不可以 | 可以 |
| 指向目录 | 不可以(会造成目录环) | 可以 |
| 源文件删除后 | 数据仍在,另一个名字照常访问 | 失效,访问报 No such file |
为什么需要软链:让 /opt/app/current 指向 /opt/app-1.2.3,程序永远读 current,升级换目录、回滚改指向,不用改任何配置。Nginx 的 sites-enabled、systemd 的开机自启都是这个机制。
$ ln -s /opt/app-1.2.3 /opt/app/current
$ ln -sfn /opt/app-1.2.4 /opt/app/current
$ ln app.conf app.conf.hardlink
$ ls -li app.conf app.conf.hardlink
$ readlink -f /opt/app/current
第二行是更新软链的正确写法:-n 防止在旧链接指向的目录里再套一个链接(否则会出现 current/current)。
[!WARNING] 删除软链接时不要在结尾加斜杠。
rm -rf /opt/app/current/会顺着链接删掉目标目录里的内容,而rm /opt/app/current只删链接本身。这是最经典的删库姿势之一。
| 参数 | 作用 | 说明 |
|---|---|---|
-s |
创建软链接 | 不加就是硬链接 |
-f |
目标已存在时强制覆盖 | 更新软链常用 |
-n |
把已存在的软链当文件而非目录 | 与 -f 一起用,防套娃 |
-i / -v |
覆盖前询问 / 显示过程 | 不确定时加 -i |
ls -i |
显示 inode 号 | 验证硬链接 |
readlink -f |
递归解析真实绝对路径 | 脚本里拿真实路径 |
$ ln app.conf app.conf.hardlink
$ ls -li app.conf app.conf.hardlink
预期输出:
131084 -rw-r--r-- 2 ubuntu ubuntu 1024 Sep 20 10:00 app.conf
131084 -rw-r--r-- 2 ubuntu ubuntu 1024 Sep 20 10:00 app.conf.hardlink
inode 相同、链接数变 2,说明是同一份数据的两个名字。此时删掉 app.conf,另一个名字仍能读到内容——数据要等链接数归 0 才释放。
| 现象 | 根因 | 解决 |
|---|---|---|
| 软链红色闪烁 / 报 No such file | 目标被删或移动 | ln -sfn <真实目标> <链接名> 重建 |
| 软链移动后失效 | 相对路径是相对链接所在目录解析的 | 建软链时源路径写绝对路径 |
Invalid cross-device link |
跨分区建硬链接 | 改用 ln -s 或 cp -a |
hard link not allowed for directory |
对目录建硬链接 | 系统禁止,用软链 |
出现 current/current 套娃 |
已存在指向目录的链接又被 -sf 覆盖 |
用 ln -sfn 重建 |
file 命令ls -l 第一个字符就是类型:- 普通文件、d 目录、l 软链、b 块设备(磁盘 /dev/sda)、c 字符设备(/dev/null)、s 套接字(/run/mysqld/mysqld.sock,进程间通信)、p 管道。Linux 不靠扩展名判断类型——app.log 也可能是二进制。要知道文件到底是什么,用 file。
$ file /var/log/syslog
$ file /dev/null /dev/sda
$ file -L /opt/app/current
第三行加 -L 才能跟随软链看目标的类型,否则 file 只会告诉你「这是软链」。
| 参数 | 作用 | 说明 |
|---|---|---|
file -b |
只输出类型不带文件名 | 脚本解析 |
file -i |
输出 MIME 类型 | 判断编码,如 charset=utf-8 |
file -L |
跟随软链 | 默认只报「symbolic link to ...」 |
file -s |
读取设备文件内容判断 | 查看磁盘分区文件系统 |
stat -c %F |
只输出类型描述 | 如 regular file |
$ file /dev/null /dev/sda
预期输出:
/dev/null: character special (1/3)
/dev/sda: block special (8/0)
| 现象 | 根因 | 解决 |
|---|---|---|
file: cannot open 'x' (Permission denied) |
无权读取 | 加 sudo file |
file 只说「symbolic link to ...」 |
默认不跟随 | 加 -L |
文本被识别成 data |
混入不可见字节或编码异常 | file -i 看编码,iconv 转换 |
脚本有 .sh 却不能执行 |
看的是执行权限位而非后缀 | 加执行位,见用户与权限 |
服务器没有图形界面,敲命令的速度就是排查速度。三件套:Tab 补全(不拼错)、历史命令(不重敲)、行编辑快捷键(不必重打整句)。另外通配符让你一次选中一批文件。
$ history
$ !102
$ sudo !!
$ history | grep nginx
sudo !! 是「忘了加 sudo」时的救星——!! 代表上一条命令。交互式快捷键:Tab 补全(连按两次列出候选)、Ctrl+R 反向搜索历史(继续按找更早的,回车执行)、Ctrl+A/Ctrl+E 跳行首行尾、Ctrl+U/Ctrl+K 删光标前后全部、Ctrl+W 删前一个词、Ctrl+L 清屏、Ctrl+C 中断。
通配符由 Shell 展开后再交给命令,echo 可以预览结果:
$ ls *.log
$ ls app?.log
$ mkdir -p /data/{logs,backups,tmp}
$ cp app.conf{,.bak}
$ echo *.conf
[!WARNING] 通配符和
rm组合是最危险的操作之一。执行rm *.log前请先用ls *.log看一遍匹配到了什么——展开结果若包含你没预料到的文件,删除不可逆。
| 符号 / 变量 | 含义 | 说明 |
|---|---|---|
* / ? |
任意长度字符 / 单个字符 | 不匹配点开头的隐藏文件 |
[abc] [a-z] [!a] |
括号中任一 / 范围 / 取反 | glob 语法 |
{a,b} |
依次展开成多个参数 | file.{txt,bak} |
| 引号 | 阻止展开 | "*.log" 是字面量 |
HISTSIZE / HISTFILESIZE |
内存 / 文件保存的历史条数 | Ubuntu 默认 1000 / 2000 |
history -a / -d N |
立即写入文件 / 删除第 N 条 | 防断线丢失、清掉带密码的命令 |
shopt -s histappend |
多终端追加而非覆盖 | 建议写进 ~/.bashrc |
[!IMPORTANT] glob 的
*和正则表达式的*不是一回事:glob 里是「任意字符」,正则里是「前一个字符重复任意次」。正则用法见文本处理。
$ echo app.conf{,.bak}
预期输出:
app.conf app.conf.bak
用 echo 预览展开结果,是确认通配符行为最安全的办法。
| 现象 | 根因 | 解决 |
|---|---|---|
cannot access '*.log': No such file |
没有匹配项,原样交给命令 | 确认目录内容;或 shopt -s nullglob |
| 文件名带空格被当成多个参数 | Shell 按空格分词 | 加引号 rm "my file.log" |
* 没匹配到隐藏文件 |
glob 默认跳过点开头文件 | 显式写 .*(会包含 . 和 ..) |
刚执行的命令 history 里没有 |
历史默认退出时才写文件 | history -a 立即写入 |
| 两个 SSH 窗口历史互相覆盖 | 后退出者覆盖了文件 | ~/.bashrc 加 shopt -s histappend |
| 命令 | 作用 |
|---|---|
pwd / pwd -P |
显示当前目录(逻辑 / 解析软链后的物理路径) |
cd ~ / cd - / cd .. |
回家目录 / 回上一次的目录 / 上一级 |
ls -lah |
长格式列出所有文件,大小人类可读 |
ls -lt / ls -lSh |
按时间排序 / 按大小排序 |
ls -ld <目录> |
只看目录自身属性 |
ls -i |
显示 inode 号,判断硬链接 |
tree -L 2 -d |
树状显示两层目录结构 |
df -h / df -i |
分区空间 / inode 使用率 |
du -sh <目录> |
目录总大小 |
du -h --max-depth=1 | sort -hr |
找目录下占用最大的子目录 |
ln -s <源> <链接名> |
创建软链接 |
ln -sfn <源> <链接名> |
强制更新软链接(防套娃) |
ln <源> <链接名> |
创建硬链接 |
readlink -f <路径> |
解析软链得到真实绝对路径 |
file <文件> / file -L |
判断类型 / 跟随软链判断 |
history / !n / !! |
查历史 / 执行第 n 条 / 重复上一条 |
Ctrl+R / Ctrl+A / Ctrl+E |
反向搜索 / 跳行首 / 跳行尾 |
本章覆盖部署中最频繁的动作:建目录、复制、移动、删除、查看内容、打包压缩、跨机器传输。命令均在 Ubuntu 22.04 LTS 上验证,涉及版本号的信息一律截至 2026-09。
[!NOTE] 贯穿全章的原则:写操作之前先确认。复制前确认目标,删除前确认清单,解压前确认压缩包内容。这个习惯能挡掉大多数运维事故。
mkdir、touchmkdir 建目录,touch 建空文件。touch 本职是"修改时间戳",文件不存在时顺手创建一个空的——所以它也常用于占位、让日志轮转程序识别到文件、触发依赖 mtime 的构建。
为什么要加 -p:不加时父目录不存在会报错;加了之后已存在也不报错。脚本和自动化部署一律用 mkdir -p,否则第二次运行就失败。
$ sudo mkdir -p /data/app/{logs,conf,backup}
$ touch README.md
$ touch app.log error.log
第一条同时建出嵌套目录和三个子目录(花括号展开,见通配符)。
| 参数 | 作用 | 说明 |
|---|---|---|
mkdir -p |
递归创建,已存在不报错 | 脚本必用 |
mkdir -m 755 |
创建时直接指定权限 | 省一步 chmod |
touch -c |
文件不存在时不创建 | 只想更新时间戳 |
touch -d "2026-09-01" |
指定具体时间戳 | 测试日志轮转 |
$ sudo mkdir -p /data/app/logs && ls -ld /data/app/logs
预期输出:
drwxr-xr-x 2 root root 4096 Sep 20 10:10 /data/app/logs
用 sudo 首次创建的目录属主是 root。若要交给普通用户使用,记得 sudo chown <用户名>:<属组> /data/app -R 调整属主。
| 现象 | 根因 | 解决 |
|---|---|---|
No such file or directory / File exists |
父目录不存在 / 已存在 | 都加 -p(幂等) |
Permission denied |
在 /etc、/var 下建目录需管理员权限 |
加 sudo 或改到家目录 |
| 建出的目录权限不对 | 受 umask 影响 |
-m 755 显式指定,见用户与权限 |
cp三种场景对应三种写法:日常复制 cp a b;复制目录 cp -r(不加 -r 会直接跳过目录);备份用 cp -a——保留权限、属主、时间戳和软链接本身。
为什么备份必须用 -a:部署包的权限位、软链结构、时间戳都承载信息。用 -r 备份 Nginx 配置,sites-enabled 里的软链会变成实体副本,恢复后维护关系就断了。
$ cp nginx.conf nginx.conf.bak
$ sudo cp -i nginx.conf /etc/nginx/
$ sudo cp -a /etc/nginx /etc/nginx.bak-$(date +%F)
$ cp -ru /data/src/ /data/dst/
[!TIP] 覆盖系统配置前先留一份带日期的副本,是成本最低的后悔药:
sudo cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%F)
| 参数 | 作用 | 说明 |
|---|---|---|
-r / -R |
递归复制目录 | 复制目录必须加 |
-a |
归档模式,保留权限/属主/时间/软链 | 备份首选 |
-i / -n |
覆盖前询问 / 绝不覆盖 | 交互式操作建议 -i |
-u / -p |
只复制更新的 / 保留权限时间属主 | -p 是 -a 的子集 |
$ sudo cp -a /etc/hostname /tmp/hostname.copy
$ ls -l /etc/hostname /tmp/hostname.copy
预期输出:
-rw-r--r-- 1 root root 14 Sep 20 09:00 /etc/hostname
-rw-r--r-- 1 root root 14 Sep 20 09:00 /tmp/hostname.copy
权限、属主、时间戳三者一致说明 -a 生效。去掉 -a 再试,时间会变成当前时间。
| 现象 | 根因 | 解决 |
|---|---|---|
omitting directory 'xxx' |
复制目录忘了 -r |
加 -r,备份用 -a |
| 复制后权限或属主变了 | 默认不保留属性 | 用 -a 或 -p |
| 软链变成了实体文件 | -r 会跟随链接复制内容 |
用 -a 保留链接 |
mv同一分区内 mv 不搬运数据,只改目录项里的名字(或换个父目录),所以瞬间完成;跨分区则是"复制+删除",大文件会很慢——这不是 bug,是文件系统的工作方式。重命名和移动在 Linux 里是同一个操作,因为路径本来就包含文件名。
$ mv app.conf app.conf.old
$ mv app.log /var/log/app/
$ mv -i app.conf /etc/app/
$ cp app.conf{,.bak}
最后一条是"移动/复制+改名"的快捷写法(花括号展开)。
| 参数 | 作用 | 说明 |
|---|---|---|
-i / -n |
覆盖前询问 / 不覆盖 | 目标已存在时保护数据 |
-f |
强制覆盖不询问 | 脚本中避免卡住,风险自负 |
-v / -u / -t <目录> |
显示过程 / 只移更新的 / 先写目标 | -t 配合 xargs |
$ mv app.conf app.conf.old && ls -l app.conf.old
预期输出:
-rw-r--r-- 1 ubuntu ubuntu 1024 Sep 20 10:15 app.conf.old
注意时间戳没变——同分区移动不改文件本身,这正是它快的原因。
| 现象 | 根因 | 解决 |
|---|---|---|
| 移动大文件特别慢 | 跨文件系统,实为复制+删除 | 正常;或用 rsync 看进度 |
| 移动后属主变了 | 跨分区复制按当前用户重建 | 用 rsync -a 或事后 chown |
| 同名文件被覆盖 | 未加 -i |
先 ls 目标;设 alias mv='mv -i' |
Permission denied |
对所在目录无写权限 | 检查父目录而非文件本身 |
rm 与它的安全替代方案Linux 的删除是"解除目录项与 inode 的关联",没有回收站,也没有确认弹窗。这就是它值得单独用一整节讲的原因。
[!WARNING]
rm -rf是最危险的命令组合:-r递归、-f强制不询问——不问你、不进回收站、不给第二次机会。下面这些写法都会造成不可逆的数据丢失,永远不要照抄:rm -rf / rm -rf $VAR/ rm -rf ./* rm -rf /opt/app/current/依次是:删掉整个系统(部分发行版有保护,但仍会毁掉数据盘)、变量为空时展开成
rm -rf /、通配符可能匹配到意外的目录、结尾斜杠会顺着软链删掉目标目录的内容。
四个安全替代方案,按推荐度排序:
# 1. 先确认清单再删(立刻能用)
$ ls -l *.log && rm -i *.log
# 2. 先移到隔离目录,观察几天再清理(可反悔)
$ mkdir -p ~/.trash && mv old-release ~/.trash/old-release-$(date +%F)
# 3. 用 trash-cli 提供真正的回收站
$ sudo apt install -y trash-cli && trash-put old-release
# 4. find 精确定位后删(比通配符可控)
$ find /var/log/app -name "*.log" -mtime +30
$ find /var/log/app -name "*.log" -mtime +30 -delete
方案 4 的顺序很关键:先不带 -delete 看清单,确认后再加 -delete。
| 参数 | 作用 | 说明 |
|---|---|---|
-i |
删除前逐个询问 | 交互式默认保护 |
-r / -R |
递归删除目录 | 删目录必须加 |
-f |
强制,不询问、忽略不存在 | 慎用,会屏蔽"路径写错"的提示 |
-d / -v |
删空目录(等价 rmdir) / 显示过程 |
|
trash-put |
移入回收站 | 需装 trash-cli,trash-restore 恢复 |
[!TIP] 在
~/.bashrc加一行alias rm='rm -i',让删除默认需要确认。代价是多敲一个y。
$ mkdir -p /tmp/rm-demo/sub && touch /tmp/rm-demo/sub/a.txt
$ rm -r /tmp/rm-demo
$ ls /tmp/rm-demo
预期输出:
ls: cannot access '/tmp/rm-demo': No such file or directory
| 现象 | 根因 | 解决 |
|---|---|---|
cannot remove 'x': Is a directory |
删目录没加 -r |
加 -r,空目录可用 rmdir |
Permission denied |
对父目录无写权限 | 用 sudo,但先确认路径 |
Argument list too long |
参数超过系统上限 | 用 find ... -delete 或 xargs,见文本处理 |
| 删了大文件但磁盘没释放 | 文件仍被进程打开 | lsof | grep deleted 找到进程重启 |
cat、less、head、tailcat 一次输出全部(只适合小文件);less 分页浏览并支持搜索(看日志的默认选择);head/tail 看开头/结尾几行,tail -f 实时跟踪。为什么看日志不能用 cat:几百 MB 的文件会疯狂刷屏,甚至卡死终端;less 只加载当前屏需要的内容,秒开。
$ cat /etc/hostname
$ less /var/log/syslog
$ head -n 20 /var/log/syslog
$ tail -f /var/log/nginx/access.log
less 里的按键(进入后按,不是命令行参数):空格/b 翻页、/关键词 搜索(n 下一个、N 上一个)、g/G 跳首尾、F 进入实时跟踪、q 退出。
| 参数 | 作用 | 说明 |
|---|---|---|
cat -n / -A |
显示行号 / 显示不可见字符 | 排查 Windows 换行 ^M |
less -N / -i / +F |
行号 / 搜索忽略大小写 / 打开即跟踪 | |
head -n N / -c N |
前 N 行 / 前 N 字节 | 可简写 -20 |
tail -n N / -n +N |
后 N 行 / 从第 N 行到结尾 | |
tail -f / -F |
跟踪文件增长 / 并按文件名重试 | 日志轮转场景用 -F |
$ head -n 2 /etc/passwd
预期输出:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
| 现象 | 根因 | 解决 |
|---|---|---|
cat 大文件刷屏停不下 |
一次性输出全部 | Ctrl+C 中断,改用 less |
tail -f 过一会儿不动 |
日志被轮转,跟踪的是旧 inode | 改用 tail -F,见日志基础 |
Permission denied |
日志通常仅 root 与 adm 组可读 | 加 sudo 或加入 adm 组 |
看到 ^M 符号 |
文件在 Windows 编辑过(CRLF) | sed -i 's/\r$//' 或 dos2unix |
tar、ziptar 只做打包,压缩由 gzip/xz 完成,所以常见后缀是 .tar.gz(默认选择,快、兼容性好)和 .tar.xz(压缩率更高,适合分发包)。zip 的优势是 Windows/macOS 原生支持,跨平台交换文件时用它;服务器之间优先 tar.gz,因为后者能完整保留权限和软链。
[!IMPORTANT] 解压前务必先看一眼压缩包内容:
$ tar tf app-1.2.3.tar.gz | head -n 5有些压缩包不是"一个目录装着所有文件",而是散装的一堆文件,直接解压会把当前目录铺满,清理起来非常麻烦。先看顶层结构,再
mkdir一个子目录用-C解压进去。
$ tar czf app-1.2.3.tar.gz app/
$ tar cJf app-1.2.3.tar.xz app/
$ tar xzf app-1.2.3.tar.gz -C /opt/app
$ zip -r app.zip app/ -x "app/node_modules/*"
$ unzip -l app.zip && unzip app.zip -d /opt/app
-C 指定的目录必须已存在,所以常先 mkdir -p。
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install -y zip unzip |
sudo dnf install -y zip unzip |
包名相同 |
sudo apt install -y rsync |
sudo dnf install -y rsync |
最小化安装通常不自带 |
sudo apt install -y trash-cli |
sudo dnf install -y trash-cli |
RHEL 需 EPEL 源 |
| 参数 | 作用 | 说明 |
|---|---|---|
-c / -x / -t |
创建 / 解包 / 列出内容 | -t 不解压只看清单 |
-z / -J / -j |
gzip / xz / bzip2 | 对应 .tar.gz / .tar.xz / .tar.bz2 |
-f <文件名> |
指定归档文件 | 必须紧挨文件名放最后 |
-v / -C <目录> |
显示过程 / 指定解压位置 | |
-p / --exclude=P |
保留权限 / 排除文件 | 如排除 node_modules |
zip -r / -x P / unzip -l |
递归压缩 / 排除 / 列内容 | 解压用 unzip -d <目录> |
$ tar tf app-1.2.3.tar.gz | head -n 3
预期输出:
app/
app/conf/
app/conf/app.conf
第一项是 app/ 目录,说明可以安全解压;若直接就是文件名(无统一目录),就该先建子目录再用 -C。
| 现象 | 根因 | 解决 |
|---|---|---|
| 解压后当前目录被铺满 | 包内没有统一顶层目录 | 先 tar tf,再 mkdir + -C |
| 解压后属主变成陌生数字 | 包里存的是打包机器的 uid | 加 --no-same-owner 或用 sudo |
Removing leading '/' |
打包时用了绝对路径 | 是安全保护,不是错误 |
scpscp 基于 SSH 传输,只要能 SSH 登录就能传,不需要额外开服务。语法始终是"源在前、目标在后",其中一端写成 用户@主机:路径 就是远程。
$ scp ./app.tar.gz <用户名>@<服务器IP>:/opt/app/
$ scp <用户名>@<服务器IP>:/var/log/app/error.log .
$ scp -r ./dist <用户名>@<服务器IP>:/var/www/
$ scp -P 2222 ./app.tar.gz <用户名>@<服务器IP>:/opt/app/
$ scp -i ~/.ssh/id_ed25519 ./app.tar.gz <用户名>@<服务器IP>:/opt/app/
[!WARNING] 传输会直接覆盖远程同名文件,没有任何提示。上传前先确认远程目录里有什么:
ssh <用户名>@<服务器IP> 'ls -l /opt/app/'
| 参数 | 作用 | 说明 |
|---|---|---|
-r |
递归传输目录 | 传目录必须加 |
-P <端口> |
指定 SSH 端口 | 大写 P;小写 -p 是保留时间戳 |
-p / -i <私钥> |
保留时间权限 / 指定身份文件 | |
-C / -v / -l <速率> |
压缩传输 / 显示详情 / 限速 | 限速单位 Kbit/s,如 -l 8000 |
$ scp ./app.tar.gz <用户名>@<服务器IP>:/opt/app/
预期输出:
app.tar.gz 100% 12MB 8.4MB/s 00:01
| 现象 | 根因 | 解决 |
|---|---|---|
Permission denied (publickey,password) |
密钥不对或该用户禁止登录 | 检查密钥与用户名,见SSH 加固 |
Connection refused |
SSH 端口未开或被防火墙拦 | 确认端口,见防火墙 |
No such file or directory |
远程目标目录不存在 | 先 SSH 上去 mkdir -p |
| 传大文件中断要重头再来 | scp 不支持断点续传 |
改用 rsync --partial |
rsync 与结尾斜杠的语义rsync 只传变化的部分,支持断点续传和保持两端一致,是部署代码和备份目录的首选。它最大的坑是源路径结尾的斜杠,语义完全不同:
| 写法 | 含义 | 结果 |
|---|---|---|
rsync -avz src/ dest/ |
同步 src 目录里的内容 |
dest/a.txt、dest/sub/ |
rsync -avz src dest/ |
同步 src 目录本身 |
dest/src/a.txt、dest/src/sub/ |
口诀:带斜杠是"里面的东西",不带斜杠是"连目录一起"。
[!WARNING]
--delete会删除目标端多出来的文件,让目标与源严格一致。若源路径写错(比如少了斜杠导致层级偏移),它会删掉目标目录下所有对不上的文件。 安全做法:先加-n(预演)看清单,确认后再去掉-n真正执行。
$ rsync -avz ./dist/ <用户名>@<服务器IP>:/var/www/html/
$ rsync -avzn --delete ./dist/ <用户名>@<服务器IP>:/var/www/html/
$ rsync -avz -e 'ssh -p 2222' --exclude 'node_modules' ./app/ <用户名>@<服务器IP>:/opt/app/
$ rsync -avzP ./bigdata.tar.gz <用户名>@<服务器IP>:/data/
第二条是预演(-n),只在屏幕上显示会做什么;确认无误后去掉 n 再执行。
| 参数 | 作用 | 说明 |
|---|---|---|
-a |
归档:保留权限、时间、软链并递归 | 几乎总是加 |
-v / -z |
显示详情 / 传输时压缩 | 跨公网加 -z |
-n |
预演,不实际改动 | 配 --delete 时必用 |
--delete |
删除目标端多余文件 | 保持严格一致,慎用 |
--exclude=P |
排除文件 | 可写多次 |
-P / -e 'ssh -p N' |
进度+断点续传 / 指定端口 | 非 22 端口时用 -e |
$ rsync -avzn ./dist/ /tmp/deploy-test/
预期输出:
sending incremental file list
./
index.html
assets/app.js
sent 220 bytes received 28 bytes 496.00 bytes/sec
把源路径的斜杠去掉再跑一次,对比第一行是 ./ 还是 dist/,就能直观看到差异。
| 现象 | 根因 | 解决 |
|---|---|---|
同步后多了一层 dest/dist/... |
源路径没带结尾斜杠 | 源写成 ./dist/ |
| 目标端文件被删光 | --delete + 源路径写错 |
立刻停止;先 -n 预演,从备份恢复 |
| 每次都全量重传 | 未用 -a,时间戳/权限未保留导致比对失败 |
加 -a |
wget 与 curl两者都能下载。简单选择:wget 专做下载(默认存成文件、支持断点续传);curl 是通用网络工具(默认输出到屏幕,调试接口也靠它)。
$ wget https://example.com/app.tar.gz
$ wget -O app-1.2.3.tar.gz https://example.com/download/latest
$ wget -c https://example.com/big.tar.gz
$ curl -O https://example.com/app.tar.gz
$ curl -Lo app.tar.gz https://example.com/download/latest
注意 curl 的大小写:大写 -O 按远程文件名保存,小写 -o 指定文件名。
[!IMPORTANT] 下载可执行文件后不要直接运行。先看内容确认来源可信:
less install.sh;正规项目会提供校验值,下载后用sha256sum核对。
| 参数 | 作用 | 说明 |
|---|---|---|
wget -O <名> / -P <目录> |
指定保存名 / 保存目录 | |
wget -c / -q |
断点续传 / 安静模式 | 大文件必备 -c |
curl -O / -o <名> |
按远程名保存 / 指定输出名 | |
curl -L / -s |
跟随重定向 / 安静模式 | 多数下载链接需要 -L |
curl -I / -f |
只看响应头 / HTTP 错误返回非 0 | 脚本里用 -f 判断成败 |
$ curl -sI https://example.com/app.tar.gz | head -n 2
预期输出:
HTTP/1.1 200 OK
Content-Length: 12582912
200 说明资源存在,Content-Length 告诉你文件大小——下载前先确认,能避免把 404 页面当安装包解压。
| 现象 | 根因 | 解决 |
|---|---|---|
| 下载下来是 HTML 文本 | 链接是跳转页或需登录 | 加 -L;检查是否需认证 |
403 Forbidden |
对方禁了默认 UA 或需鉴权 | 加 -H "User-Agent: ..." 或带 token |
| 下载速度很慢 | 源站在境外 | 换国内镜像,见镜像加速 |
| 脚本里 curl 失败仍继续 | curl 对 404 默认也返回 0 | 加 -f 并检查 $? |
| 命令 | 作用 |
|---|---|
mkdir -p /data/a/b |
递归创建目录,已存在不报错 |
touch <文件> |
创建空文件或更新时间戳 |
cp -a <源> <目标> |
归档复制,保留权限/时间/软链(备份首选) |
cp -r <目录> <目标> |
递归复制目录(不加 -r 会跳过目录) |
mv <源> <目标> |
移动或重命名(同分区瞬时完成) |
rm -i <文件> / rm -r <目录> |
逐个确认删除 / 递归删目录 |
trash-put <文件> |
移入回收站,可 trash-restore 恢复 |
cat <文件> |
输出全部内容(只适合小文件) |
less <文件> |
分页浏览:/搜索、G 到底、F 跟踪、q 退出 |
head -n 20 / tail -n 20 |
看开头 / 结尾 20 行 |
tail -F <文件> |
实时跟踪,支持日志轮转后重连 |
tar czf 包.tar.gz <目录> |
打包并 gzip 压缩 |
tar cJf 包.tar.xz <目录> |
打包并 xz 压缩(压缩率更高) |
tar tf 包.tar.gz |
只列内容不解压(解压前必做) |
tar xzf 包.tar.gz -C <目录> |
解压到指定目录 |
zip -r 包.zip <目录> / unzip -l |
zip 压缩 / 列出内容 |
scp -P 2222 -r <源> <用户>@<服务器IP>:<路径> |
通过 SSH 传文件或目录(-P 指定端口) |
rsync -avz src/ <服务器>:dest/ |
增量同步目录内容(结尾斜杠决定层级) |
rsync -avzn --delete src/ dest/ |
预演同步(不实际改动) |
wget -c <URL> / curl -LO <URL> |
下载文件,wget 支持断点续传 |
服务器上的信息绝大多数是纯文本:配置、日志、进程列表、CSV 数据。能不能在三分钟内从 20 万行日志里找出错误 TOP 10,靠的就是这一章。命令均在 Ubuntu 22.04 LTS 上验证,涉及版本号的信息一律截至 2026-09。
[!NOTE] 核心思想是"组合":每个命令只做一件事,用管道
|串起来。先学会单个命令,再学组合。
>、>>、2>、2>&1命令的输出分两条管道(文件描述符):1 号 stdout 走正常结果(>),2 号 stderr 走报错(2>)。默认都打到屏幕,重定向就是让它们改道。分开的意义:正常结果要存下来分析,报错要单独看,混在一起会干扰判断。
| 写法 | 含义 |
|---|---|
> file |
stdout 覆盖写(原有内容被清空) |
>> file |
stdout 追加(安全) |
2> / 2>> |
stderr 覆盖 / 追加 |
> file 2>&1 |
两者都进同一文件 |
&> / &>> |
同上,bash 简写 |
> /dev/null |
丢弃输出 |
$ ls -lh /var/log > loglist.txt
$ echo "$(date): backup done" >> /var/log/mybackup.log
$ nginx -t 2> nginx-error.txt
$ ./deploy.sh > deploy.log 2>&1
$ ./deploy.sh > /dev/null 2>&1
[!IMPORTANT]
2>&1必须写在>后面,顺序错了语义就变:# 正确:stdout 先去文件,stderr 再跟过去 $ ./deploy.sh > out.log 2>&1 # 错误顺序:stderr 先接到"当前的"屏幕,stdout 才去文件 $ ./deploy.sh 2>&1 > out.log原因:
2>&1的含义是"把 stderr 指向 stdout 当前所指向的地方",而不是永远跟着 stdout。重定向从左到右依次生效,所以顺序决定结果。
| 写法 | 作用 | 说明 |
|---|---|---|
> / >> |
覆盖 / 追加 stdout | 文件不存在则创建 |
2> / 2>> |
覆盖 / 追加 stderr | 数字 2 紧贴 >,中间不能空格 |
2>&1 |
stderr 指向 stdout 当前目标 | 必须写在 > 之后 |
&> / &>> |
两者一起覆盖 / 追加 | bash 简写,脚本可读性更好 |
> /dev/null |
丢弃 | 只关心退出码时用 |
set -o noclobber |
禁止 > 覆盖已存在文件 |
需覆盖时用 >| |
$ echo "hello" > t.txt
$ echo "world" >> t.txt
$ cat t.txt
预期输出:
hello
world
把第二个 >> 换成 > 再跑,文件里只剩 world——这就是覆盖与追加的区别。
| 现象 | 根因 | 解决 |
|---|---|---|
用 > 后原内容没了 |
> 是覆盖写 |
改用 >>;重要文件先备份 |
sudo echo x > /etc/a.conf 报 Permission denied |
重定向由当前 Shell 执行,sudo 只作用于 echo |
echo x | sudo tee /etc/a.conf |
加了 2> 屏幕还有输出 |
输出走的是 stdout 而非 stderr | 改用 > file 2>&1 |
2>&1 似乎没生效 |
顺序写反了 | 放到 > 之后 |
| 定时任务里没输出 | cron 输出默认丢弃或发邮件 | 显式写 >> /var/log/x.log 2>&1 |
| 与 tee管道把左边命令的 stdout 变成右边命令的 stdin。这是 Linux 设计的精髓:每个工具只做一件事,串联起来完成复杂任务。tee 是管道上的三通——一边往下传一边存文件,让你同时看到结果并保存。
$ ls -1 | wc -l
$ du -h /var/log/* | sort -hr | head -n 5
$ sudo apt install -y nginx | tee install.log
$ ./deploy.sh | tee -a deploy.log
$ echo "server_tokens off;" | sudo tee -a /etc/nginx/nginx.conf
最后一条能绕过权限问题:写文件的是被 sudo 提权的 tee,而不是 Shell。
| 写法 | 作用 | 说明 |
|---|---|---|
| |
左边 stdout 接右边 stdin | 只传 stdout,stderr 不传 |
|& |
连 stderr 一起传 | 等价 2>&1 | |
tee <文件> |
输出屏幕并覆盖写入 | |
tee -a <文件> |
追加写入 | 记录多次运行 |
tee f1 f2 |
同时写多个文件 | |
sudo tee |
以管理员权限写文件 | 解决重定向提权问题 |
$ echo "test-line" | tee out.txt
预期输出:
test-line
同时 out.txt 里也写入了 test-line——既看见又保存。
| 现象 | 根因 | 解决 |
|---|---|---|
管道后加 sudo 没效果 |
sudo 只提升后一个命令的权限 |
| sudo tee |
| 管道后错误信息消失 | 管道只传 stdout | 用 2>&1 | 或 |& |
tee 后原文件被清空 |
默认覆盖 | 用 tee -a |
| 管道太长难调试 | 一次写太多段 | 从最短开始逐段加,用 head 查中间结果 |
xargs:把标准输入变成参数很多命令(rm、cp、kill)只接受命令行参数,不读标准输入,而管道传过去的是文本流。xargs 把文本流转成参数列表,补上这个缺口:
find . -name "*.tmp" | rm ← 不行,rm 读不到管道
find . -name "*.tmp" | xargs rm ← 可以
$ find . -name "*.tmp" | xargs echo rm
$ find . -name "*.tmp" | xargs rm
$ find . -name "*.conf" | xargs -I{} cp {} {}.bak
$ cat hosts.txt | xargs -n 1 ping -c 1
$ find . -name "*.log" -print0 | xargs -0 rm
[!WARNING]
xargs rm会删除管道送来的每一个路径。请先xargs echo rm把将要执行的完整命令打印出来看一遍,确认清单后再去掉echo。
| 参数 | 作用 | 说明 |
|---|---|---|
-I{} |
{} 作占位符,参数插到指定位置 |
参数不在命令末尾时用 |
-n N |
每次取 N 个参数执行一次 | 逐个处理用 -n 1 |
-0 |
以 NUL 分隔输入 | 配 find -print0,正确处理含空格文件名 |
-p / -t |
执行前询问 / 打印要执行的命令 | 危险操作加 -p |
-r / -P N |
输入为空时不执行 / 并发 N 个 |
$ echo -e "a.txt\nb.txt" | xargs -I{} echo "cp {} {}.bak"
预期输出:
cp a.txt a.txt.bak
cp b.txt b.txt.bak
| 现象 | 根因 | 解决 |
|---|---|---|
Argument list too long |
参数数量超系统上限 | 用 xargs 分批,或 find -delete |
| 文件名含空格被拆开 | 默认按空白分词 | find -print0 | xargs -0 |
xargs: No such file or directory |
参数为空仍执行了命令 | 加 -r |
| 想在参数前后加内容 | 默认只能追加在末尾 | 用 -I{} 占位 |
grep:搜索文本grep 按模式匹配行并输出,是"在几百个配置里找某一项""在日志里找报错"的首选。最常用的写法是 grep -rn "关键词" .:递归搜索当前目录并显示行号。找不到配置在哪时,第一反应就应该是它。
$ grep -n "error" app.log
$ grep -rn "listen" /etc/nginx/
$ grep -v "^#" nginx.conf
$ grep -c "ERROR" app.log
$ grep -oE "[0-9]{1,3}(\.[0-9]{1,3}){3}" access.log
$ grep -n -C 3 "Exception" app.log
$ grep -rn "database" . --exclude-dir={.git,node_modules}
倒数第二条用 -C 3 显示匹配行的前后各 3 行,排错时看上下文必备。
| 参数 | 作用 | 说明 |
|---|---|---|
-i / -n |
忽略大小写 / 显示行号 | -n 总是加 |
-r / -R |
递归目录(-R 跟随软链) |
找配置用 -r |
-v / -c |
反向匹配 / 只统计行数 | 过滤噪声、计数 |
-o / -w |
只输出匹配部分 / 全词匹配 | 提取 IP;避免 err 命中 error |
-E / -F |
扩展正则 / 字面字符串 | 关键词含特殊字符用 -F(更快) |
-l / -m N |
只列文件名 / 每文件最多 N 条 | 只关心"哪些文件里有" |
-A3 -B3 -C3 |
显示后 / 前 / 前后各 3 行 | 看上下文 |
--exclude-dir= --include= |
排除目录 / 只搜匹配文件 | 支持花括号展开 |
$ grep -n "^root" /etc/passwd
预期输出:
1:root:x:0:0:root:/root:/bin/bash
^ 是行首锚点,1: 是行号。
| 现象 | 根因 | 解决 |
|---|---|---|
Binary file xxx matches |
文件被识别为二进制 | 加 -a 当文本处理,或 -I 跳过 |
| 明明有关键词却搜不到 | 大小写不符或被拆行 | 加 -i;用 -C 3 看上下文 |
| 搜出几千行没法看 | 没限定范围 | 加 --include,或接 | head -n 20 |
正则的 |、+ 不生效 |
基本正则不支持 | 加 -E |
关键词含 . * 匹配异常 |
被当正则元字符 | 加 -F 或转义 \. |
find:按条件查找文件grep 搜内容,find 找文件本身(按名字、类型、时间、大小)。两者常组合:先 find 圈定文件,再 grep 搜内容。
$ find /etc -name "*.conf"
$ find /var -type d -name "log"
$ find /etc -type f -mtime -7
$ find / -type f -size +100M
$ find /var/log -maxdepth 2 -name "*.log"
$ find /etc -name "*.conf" -exec grep -Hn "listen" {} \;
$ find /var/log -name "*.log" -mtime +30 -delete
模式必须加引号,否则 Shell 会先展开通配符。倒数第二行是 find 与 grep 的组合,-H 让 grep 总是显示文件名。
[!WARNING]
-delete和-exec rm {} \;都会真正删除文件,且没有回收站。务必先不带-delete跑一遍看清单,确认后再删。另外-delete必须放在所有条件之后:find从左到右求值表达式,-delete一旦出现就对当前遍历到的每个文件直接执行删除,写在它后面的条件形同虚设——find . -delete -name "*.log"会删光整棵目录树而不是只删.log,而且不会报错提醒你。
| 参数 | 作用 | 说明 |
|---|---|---|
-name "模式" / -iname |
按名匹配(区分 / 不区分大小写) | 模式要加引号 |
-type f d l |
文件 / 目录 / 软链接 | 常加,缩小范围 |
-mtime -N / +N |
N 天内 / N 天前修改 | -7 = 最近 7 天 |
-size +100M |
大于指定大小 | 单位 k M G |
-maxdepth N |
最多深入 N 层 | 建议加,避免全盘扫描 |
-exec cmd {} \; / {} + |
逐个执行 / 拼一批执行 | + 快得多 |
-delete / -print0 |
删除 / 以 NUL 分隔输出 | -delete 必须放在所有条件之后(放在前面会绕过条件全量删除且不报错);-print0 配 xargs -0 |
$ find /etc -maxdepth 1 -name "*.conf"
预期输出(因机器而异):
/etc/host.conf
/etc/resolv.conf
/etc/sysctl.conf
| 现象 | 根因 | 解决 |
|---|---|---|
| 结果奇怪或搜不到 | 模式没加引号被 Shell 展开 | 用引号 -name "*.conf" |
Permission denied 刷屏 |
遍历到无权目录 | 加 2>/dev/null 或用 sudo |
| 搜索特别慢 | 没限制深度,全盘遍历 | 加 -maxdepth 和起始路径 |
| 以为加了条件,结果删多了文件 | -delete 写在了条件前面,条件被整体跳过 |
-delete 永远放最后;先用 -print 预览清单再删 |
| 找到了却删不掉 | 对父目录无写权限 | 加 sudo |
sed:替换与行编辑sed 逐行处理文本,最常用两个能力:替换(批量改配置)和取行区间(截取日志的一段)。
$ sed 's/old/new/g' app.conf
$ sed -i 's/old/new/g' app.conf
$ sed -i.bak 's/old/new/g' app.conf
$ sed -n '10,20p' app.log
$ sed '/^#/d' nginx.conf
$ sed 's#/var/www/html#/data/www#g' nginx.conf
第一行不加 -i 只把结果打到屏幕(预览);第三行 -i.bak 会先备份再改。路径含斜杠时换 # 作分隔符,就不用写一堆转义。
[!TIP] 用
-i之前先不加-i跑一遍,确认屏幕上输出的正是你要的结果,再加-i。这一步能挡住绝大多数"改坏配置文件"的事故。改动重要配置时用sed -i.bak,改完立刻做语法检查(如nginx -t)。
| 语法 / 参数 | 作用 | 说明 |
|---|---|---|
s/old/new/ / s/old/new/g |
替换每行第一个 / 全部 | g = global |
-i / -i.bak |
原地修改 / 修改并备份 | 先预览再改 |
-n '10,20p' |
只打印 10–20 行 | -n 抑制默认输出 |
/pat/d |
删除匹配行 | 如删注释行 |
s#a#b#g |
用 # 作分隔符 |
处理路径时避免转义 |
& / -e 'cmd' |
代表匹配到的内容 / 执行多个规则 | s/[0-9]/[&]/g |
$ echo "port=8080" | sed 's/8080/9090/'
预期输出:
port=9090
| 现象 | 根因 | 解决 |
|---|---|---|
| 改完配置服务起不来 | 未预览、未备份 | 用 -i.bak,改完 nginx -t 校验 |
| 替换没生效 | 模式没匹配上(大小写、空格) | 先 grep 确认原文 |
| 路径替换报 unknown option to 's' | 替换串含 / 与分隔符冲突 |
换 s#a#b#g 或 `s |
sed -i 报 extra characters |
macOS/BSD sed 的 -i 必须带后缀 |
Linux 无此限制;跨平台脚本用 -i.bak |
| 只替换了每行第一个 | 忘了 g |
加 g |
awk:按列处理与统计awk 把每行按分隔符切成列:$1 第一列、$2 第二列、$0 整行、$NF 最后一列。它是一门小型语言,能做条件判断和累加统计,是日志分析的主力。默认按任意空白分列(多个空格算一个),这一点比 cut 好用得多。
$ awk -F: '{print $1}' /etc/passwd
$ awk -F: '{print $NF}' /etc/passwd
$ awk '{print NR": "$0}' app.log
$ awk '$3 > 100 {print $1, $3}' data.txt
$ awk 'NR>1 {print $1}' data.csv
$ awk '{sum+=$1} END {print sum}' numbers.txt
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -n 10
[!NOTE]
awk程序体必须用单引号'{print $1}'。用双引号时 Shell 会把$1当自己的变量展开,结果就不是你要的列了。
| 变量 / 参数 | 作用 | 说明 |
|---|---|---|
-F: |
指定输入分隔符 | 如 -F, 处理 CSV |
$0 / $1…$n / $NF |
整行 / 第 n 列 / 最后一列 | NF 是当前行列数 |
NR / FNR |
当前行号 / 当前文件内行号 | NR==1 是表头 |
BEGIN{} / END{} |
处理前 / 处理后执行一次 | 打印表头、输出汇总 |
条件 {动作} |
满足才执行 | 如 $3 > 100 |
-v OFS=',' |
指定输出分隔符 | 控制输出格式 |
$ echo -e "10\n20\n30" | awk '{sum+=$1} END {print sum}'
预期输出:
60
| 现象 | 根因 | 解决 |
|---|---|---|
| 输出为空或整行 | 用了双引号,$1 被 Shell 展开 |
改用单引号 |
| 取列不对 | 分隔符不是空白 | 用 -F 指定 |
| 求和结果是科学计数法 | 数值过大 | printf "%.0f\n", sum |
| 表头也被统计 | 未跳过第一行 | 加 NR>1 或 tail -n +2 |
| 多空格导致列错位 | 用了 cut 而非 awk |
优先用 awk |
sort、uniq、wc、cut、tr组合起来回答"哪个值出现得最多":
sort → 把相同的值排到一起(uniq 只能去掉相邻的重复行)
uniq -c → 统计每种值出现几次
sort -rn → 按次数从大到小,最大的排最前
必须先 sort 再 uniq:uniq 只比较相邻行,不去重全局,所以"先排序再去重"是固定套路。cut 按分隔符或字符位置截取,tr 做字符级替换/删除;两者都比 awk/sed 简单,适合格式固定的简单场景——但 cut 遇到连续空格会错位,处理命令输出优先用 awk。
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -n 10
$ sort -n numbers.txt
$ sort -t: -k3 -n /etc/passwd
$ wc -l access.log
$ cut -d: -f1,3 /etc/passwd
$ tr 'a-z' 'A-Z' < input.txt
$ tr -d '\r' < win.txt > unix.txt
| 参数 | 作用 | 说明 |
|---|---|---|
sort -n / -r |
数值排序 / 逆序 | 数字必须加 -n,否则 10 排在 9 前 |
sort -u / -h |
排序并去重 / 按人类可读大小排 | -h 配 du -h |
sort -k N / -t: |
按第 N 列排 / 指定列分隔符 | |
uniq -c / -d / -u |
计数 / 只显示重复 / 只显示唯一 | -c 前必须先 sort |
wc -l / -w / -c |
行数 / 单词数 / 字节数 | |
cut -d: -f1 |
指定分隔符 / 取第 1 列 | -f1,3 多列,-f2- 到第 2 列起 |
cut -c1-10 |
按字符位置截取 | 定长格式日志 |
tr -d / -s |
删除字符 / 压缩重复字符 | 如压缩多余空格 |
$ printf "b\na\nb\nc\n" | sort | uniq -c
预期输出:
1 a
2 b
1 c
去掉 sort 后 uniq -c 会把两个不相邻的 b 算成两次,得到错误结果——这就是必须先排序的原因。
| 现象 | 根因 | 解决 |
|---|---|---|
uniq 没去掉重复 |
相同值不相邻 | 先 sort | uniq |
| 排序结果 10 排在 9 前 | 按字符串排序 | 加 -n |
| 统计结果包含表头 | 未过滤第一行 | 加 awk 'NR>1' |
cut 取列错位 |
连续空格被当作多个分隔符 | 改用 awk '{print $1}' |
tr 提示 extra operand |
参数个数不对 | tr 需两个集合,删除用 -d |
tr 似乎没生效 |
它只处理 stdin,不接受文件名参数 | 用 tr ... < file |
| 命令 | 作用 |
|---|---|
cmd > f / cmd >> f |
输出覆盖写 / 追加写到文件 |
cmd 2> f |
只保存错误信息 |
cmd > f 2>&1 |
正常与错误都进同一文件(顺序不能反);换 /dev/null 即丢弃 |
cmd1 | cmd2 |
管道:前一个的输出交给后一个 |
cmd | tee -a f |
同时显示并追加保存 |
echo x | sudo tee /etc/f |
提权写系统文件(绕过重定向权限限制) |
... | xargs -I{} cmd |
把标准输入转成参数,{} 是占位符 |
find ... -print0 | xargs -0 |
安全处理含空格的文件名 |
grep -rn "关键词" . |
递归搜索并显示行号(找配置首选) |
grep -i -v -c -o |
忽略大小写 / 反向 / 计数 / 只输出匹配部分 |
grep -E / -F / -C3 |
扩展正则 / 字面匹配 / 显示前后各 3 行 |
find -name "*.conf" / -mtime -7 |
按名查找(模式加引号)/ 找 7 天内改过的 |
find -size +100M / -delete |
找大于 100MB 的文件 / 删除(先预览) |
sed 's/old/new/g' f |
替换并预览(不加 -i 不改文件) |
sed -i.bak 's/old/new/g' f |
原地修改并备份(-n '10,20p' 取行区间) |
awk -F: '{print $1}' f |
按冒号分隔取第 1 列(NR>1 跳过表头) |
awk '{sum+=$1} END {print sum}' |
对第一列求和 |
sort | uniq -c | sort -rn |
统计出现次数并倒序(三件套) |
sort -n / -h |
数值排序 / 按人类可读大小排序(如配 du -h) |
wc -l / cut -d: -f1 / tr -d '\r' |
统计行数 / 按分隔符取列 / 删除字符 |
Linux 天生是多用户系统:一台服务器上可同时存在几十个账号,谁能读哪个文件、谁能重启哪个服务,全靠一套规则划分。这套规则只有三块积木:账号(你是谁)、归属(文件归谁)、权限位(属主 / 属组 / 其他人分别能干什么)。
先把最容易踩的两个坑写在最前面,因为它们都会造成不可逆的后果:图省事执行 chmod 777(等于把服务器的所有门钥匙发给每一个能登录的人);用普通编辑器直接改 /etc/sudoers(语法写错就会把自己永久锁在管理员权限之外)。
[!NOTE] 本章命令与默认行为均基于 Ubuntu 22.04 LTS,版本相关信息截至 2026-09。
Linux 里没有"用户数据库",用户信息就是三个纯文本配置文件:
| 文件 | 存什么 | 谁能读 |
|---|---|---|
/etc/passwd |
用户名、UID、GID、家目录、登录 Shell | 所有人 |
/etc/shadow |
密码哈希与有效期 | 仅 root |
/etc/group |
组名、GID、附加成员名单 | 所有人 |
/etc/passwd 第二个字段恒为 x,意思是"密码不在这儿,去 /etc/shadow 找"——早期 Unix 把密码哈希放在人人可读的 passwd 里,容易被离线爆破,后来才拆出只给 root 读的 shadow。内核只认 UID(数字),用户名只是给人看的别名:0 是 root(绕过一切权限检查),1–999 是系统用户(服务账号,通常禁止登录,如 www-data=33),1000+ 是管理员创建的普通用户。
日常排错很少翻这三个文件,更多是用只读命令回答"我是谁、谁在机器上、谁登录过":whoami、id、groups、who(当前在线)、last(历史登录)。
# 我是谁 / 我的全部身份 / 我的组
$ whoami
$ id
$ groups
# 当前在线会话(用户名、终端、来源 IP)
$ who
# 最近 10 条登录历史(含重启记录)
$ last -n 10
# 查账号(含 LDAP 等外部源)
$ getent passwd <用户名>
[!WARNING] 不要用
vim/nano直接编辑这三个文件。少一个冒号就可能让所有用户无法登录。请始终用useradd、usermod、passwd、groupadd,它们保证格式正确与并发安全。
/etc/passwd 每行 7 个字段,冒号分隔:
| 字段 | 示例 | 说明 |
|---|---|---|
| 1 用户名 | alice |
登录名,最长 32 字符 |
| 2 密码占位 | x |
真身在 /etc/shadow |
| 3 UID | 1001 |
内核识别用户的数字 ID |
| 4 GID | 1001 |
主组,新建文件默认属该组 |
| 5 注释 | Alice |
GECOS 字段,仅注释用途 |
| 6 家目录 | /home/alice |
登录后进入的目录 |
| 7 登录 Shell | /bin/bash |
填 /usr/sbin/nologin 表示禁止登录 |
id -u / -g / -G |
— | 只打印 UID / 主组 / 全部组,id -n 显示名字而非数字 |
另外 last -n N 限制条数,who -a 信息更全。
$ id
预期输出:
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),27(sudo),100(users)
读作:我是 ubuntu,UID 1000,主组 ubuntu,另属 adm、sudo、users。出现在 sudo 组里,正是我能使用 sudo 的原因。
| 现象 | 根因 | 解决 |
|---|---|---|
/etc/passwd 里 grep 不到某用户 |
账号由 LDAP / SSO 提供,不在本地文件 | 用 getent passwd <用户名>,它同时检索外部源 |
cat /etc/shadow 报 Permission denied |
权限为 -rw-------,仅 root 可读 |
用 sudo cat;别为省事给它加读权限 |
| 进程属主显示为数字而非用户名 | 用户已删但进程还在,或在容器命名空间内 | 属正常,用 ps -o user= -p <PID> 复核 |
last 输出为空 |
/var/log/wtmp 被清空或被 logrotate 轮转 |
查 /var/log/auth.log 或 journalctl -u systemd-logind |
创建用户 = 在三个文件里追加记录 + 建家目录骨架 + 设初始密码。Ubuntu 给了两个命令:useradd(底层、不交互,不带 -m 时不建家目录,新手常被坑)和 adduser(交互式封装,自动建家目录、拷 /etc/skel 模板、问密码)。新手手动操作用 adduser,写自动化脚本用 useradd。
建好后的常见后续操作是改组和改状态:加进 sudo 组让他能提权、加进 docker 组让他能用 Docker、有人离职时锁定而不是立刻删除。配套命令是 groupadd 建组、gpasswd 加减成员。
# -m 建家目录,-s 指定登录 shell
$ sudo useradd -m -s /bin/bash <用户名>
# 设密码(没密码的账号处于锁定态,无法登录)
$ sudo passwd <用户名>
# 更友好的交互式方式
$ sudo adduser <用户名>
# 加入 sudo 组(-a 是 append,必须加!)
$ sudo usermod -aG sudo <用户名>
# 锁定 / 解锁账号
$ sudo usermod -L <用户名>
$ sudo usermod -U <用户名>
# 建组、加人、减人
$ sudo groupadd devops
$ sudo gpasswd -a <用户名> devops
$ sudo gpasswd -d <用户名> devops
# 删账号;加 -r 连家目录一起删
$ sudo userdel -r <用户名>
[!WARNING]
usermod -G不带-a会覆盖用户的全部附加组,这是 Linux 运维的经典事故。 用户原属sudo,docker,www-data,执行usermod -G devops alice之后他就只剩devops——刚丢掉的sudo意味着他再也提不了权。正确写法永远是usermod -aG devops alice,改完务必用id <用户名>复核。此外userdel -r会递归删除家目录且不可恢复,生产环境建议先usermod -L锁定观察一段时间再删。
| 参数 | 作用 |
|---|---|
-m |
建家目录,并把 /etc/skel 下的 .bashrc 等模板拷进去 |
-s <shell> |
指定登录 Shell;写 /usr/sbin/nologin 即建不可登录的服务账号 |
-aG <组列表> |
追加附加组,逗号分隔;缺 -a 则变成覆盖(见上方警告) |
-g <组> |
改主组(小写 g),影响此后新建文件的默认属组 |
-L / -U |
Lock / Unlock:在 shadow 密码字段前加 ! 或去掉它 |
-r |
建系统用户(UID < 1000);用在 userdel 上则是连同家目录一起删 |
gpasswd -a / -d |
往组里 add / delete 成员 |
[!NOTE]
-L只锁密码登录,配了 SSH 密钥的账号照样能进。要彻底禁止请同时usermod -s /usr/sbin/nologin,或移除~/.ssh/authorized_keys。RHEL 系的差异是adduser只是useradd的软链接、并不交互,所以那边必须显式加-m。
$ id <用户名>
预期输出:
uid=1001(alice) gid=1001(alice) groups=1001(alice),27(sudo),998(docker)
确认 27(sudo) 仍在列表中。若它消失了,说明误用了不带 -a 的 -G,立刻用 sudo usermod -aG sudo <用户名> 恢复。账号是否被锁定可用 sudo passwd -S <用户名> 确认,输出第二个字段 L=锁定、P=有可用密码、NP=空密码。
| 现象 | 根因 | 解决 |
|---|---|---|
新用户提示符是光秃秃的 $,没颜色不补全 |
用了 useradd 却没加 -m,.bashrc 没被拷贝 |
补建 sudo mkhomedir_helper <用户名> |
| 新用户无法 SSH 登录、或加了 sudo 组仍提示 "not in the sudoers file" | 未设密码处于锁定态(或 Shell 是 nologin);组成员变更要重新登录才生效 |
sudo passwd <用户名> 并检查 /etc/passwd 第 7 字段;退出重连 SSH 或 newgrp sudo 刷新 |
userdel: user X is currently used by process N |
该用户还有进程在跑 | 先 sudo pkill -u <用户名>,再删除 |
| 删了又重建同名用户,文件归属错乱 | 旧文件属主变成孤儿数字 UID | sudo find / -uid <旧UID> -exec chown <用户名> {} + |
root 的 UID 是 0,内核对它不做任何权限检查。直接用 root 登录有两个致命问题:没有刹车(一条手滑的 rm -rf / home/* 就是灾难),没有审计(三个人都用 root,日志里追不到人)。
sudo 的设计哲学是最小权限 + 可追溯:默认仍是你自己,只在需要时临时借用 root 身份执行一次命令,每次调用都写进 /var/log/auth.log,能查到谁、在什么时候、跑了什么。授权规则存在两处:/etc/sudoers(主配置,Ubuntu 上 sudo 组拥有全部权限)和 /etc/sudoers.d/*(推荐,一个应用一份,被主文件末尾的 @includedir 加载)。
要从"自己"变成别人,还有三个命令,关键差别在是否加载目标用户的环境:
| 命令 | 加载目标环境 | 需要谁的密码 |
|---|---|---|
su <用户> |
否,$HOME、$PATH 还是原来的 |
对方密码 |
su - <用户> |
是,等同于重新登录 | 对方密码 |
sudo -i / sudo -u <用户> -i |
是 | 自己的密码 |
[!NOTE]
su不加-的典型症状:切过去了,但echo $PATH还是原来用户的,java、node这些装在对方家目录里的命令全部找不到。只要是"要在这个账号下干活",就该用su -。
# 查看"我被授权了什么"——排查权限问题的第一步
$ sudo -l
# 以指定用户身份执行一条命令
$ sudo -u www-data whoami
# 打开完整的 root shell(环境也重置为 root 的)
$ sudo -i
# 完整切到其他用户;不切环境只跑一条命令则用 su -c
$ su - <用户名>
$ su <用户名> -c 'whoami'
# 只编辑独立文件,不碰主配置(visudo 保存时会做语法检查)
$ sudo visudo -f /etc/sudoers.d/deploy
写入一条"只允许免密重启某个服务"的规则;授予完整管理员权限(需输密码)则写 deploy ALL=(ALL:ALL) ALL。切换后离开用 exit:
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp
[!IMPORTANT] 永远用
visudo,永远不要sudo vim /etc/sudoers。visudo先把内容写临时文件做语法校验,只有正确才落盘;直接编辑一旦写错语法,系统里所有 sudo 调用都会失败——包括你想用来修复它的那次 sudo。这就是把自己锁在门外的经典方式。
[!WARNING]
NOPASSWD:ALL意味着该用户无需任何确认即可执行任意 root 命令,等同于送出一个不受限的 root 后门。它只允许出现在容器内一次性构建、CI runner 这类场景;任何人类账号、任何能从公网登录的账号都不要写。需要免密时把命令限定到具体路径:NOPASSWD: /usr/bin/systemctl restart myapp。
sudo 规则格式为 用户 主机=(可切换的身份) 命令:
| 片段 / 参数 | 含义 |
|---|---|
deploy |
被授权的用户;写 %devops 表示授权整个组 |
ALL= |
在哪些主机生效,单台写 ALL 即可 |
(ALL:ALL) |
可切换成的用户:组,省略则默认 root |
末尾 ALL |
允许执行的命令;写绝对路径则只允许那一条 |
NOPASSWD: |
该命令免密;不加则要求输入自己的密码 |
sudo -l / -l -U <用户> |
列出自己 / 指定用户的 sudo 规则 |
sudo -u <用户> |
以指定用户身份执行 |
sudo -i / sudo -s |
登录式 / 非登录式 root shell(后者保留部分原环境变量) |
sudo -k |
清空时间戳缓存,强制下次重新验密 |
su -c '<命令>' |
只执行这一条命令后返回,适合脚本 |
规则里的命令路径请写绝对路径(which systemctl 查到的那个)——写成相对命令名的话,攻击者只要在 PATH 里放一个同名脚本就能提权。
| Ubuntu (sudo 组) | RHEL (wheel 组) | 说明 |
|---|---|---|
sudo usermod -aG sudo <用户名> |
sudo usermod -aG wheel <用户名> |
组名不同,机制相同 |
| 安装时创建的首个用户默认在 sudo 组 | 需手动勾选 "Make this user administrator" | RHEL 装完要先确认 |
/etc/sudoers.d/* |
/etc/sudoers.d/* |
目录约定一致 |
$ sudo -l
预期输出:
User ubuntu may run the following commands on <主机名>:
(ALL : ALL) ALL
(ALL : ALL) ALL 表示可以以任意用户、任意组身份执行任意命令——这就是 sudo 组的默认授权。
su 加 - 与不加 - 的差别,用一条命令即可看清(先不加载环境、后加载环境):
$ sudo su alice -c 'echo $HOME' && sudo su - alice -c 'echo $HOME'
预期输出:
/root
/home/alice
| 现象 | 根因 | 解决 |
|---|---|---|
xxx is not in the sudoers file |
既不在 sudo 组,也没有自定义规则 |
管理员执行 sudo usermod -aG sudo <用户名>,然后重新登录 |
| 改了 sudoers 后所有 sudo 报语法错误 | 用普通编辑器写入了错误语法 | 见下方 IMPORTANT 的挽救办法 |
sudo: command not found |
PATH 问题(sudo 所在目录不在 PATH 里),或最小化镜像未安装 sudo |
用绝对路径 /usr/bin/sudo;未安装则先想办法 apt install sudo |
su - 输 root 密码总是错 |
Ubuntu 默认锁定 root 密码 | 日常提权改用 sudo -i |
| 脚本里 sudo 卡住等密码 | 非交互环境无法输入密码 | 配精确到命令的 NOPASSWD:,不要 echo 密码 | sudo -S |
[!IMPORTANT] 把自己锁在 sudo 门外后的挽救办法(代价从小到大):① 还有另一个 sudo 账号,用它登录修复;② 有桌面环境时用
pkexec visudo走 polkit 提权,不依赖 sudo 本身可用;③ 云服务器用厂商提供的 VNC / 串行控制台以 root 身份直连;④ 物理机重启进单用户模式(GRUB 按e,在linux行末尾加init=/bin/bash),改完exec /sbin/init重启。这也是为什么服务器上永远要保留两个可用的 sudo 账号。
每个文件都记着两套信息:归谁(属主 + 属组)和谁能干什么(三组 rwx)。ls -l 第一列就是权限位:
-rw-r--r-- 1 alice devops 2048 Sep 20 10:35 report.txt
drwxr-x--- 2 alice devops 4096 Sep 20 10:35 data/
第 1 字符是类型(-文件、d目录、l软链接),随后三组分别是属主(rwx)、属组(r--)、其他人(r--)的权限。匹配规则是一次命中即停止:你就是属主的话,只看前三位,后六位完全不参与判断。
rwx 对文件和目录含义差别很大,这是初学者最大的困惑点:
| 权限 | 对文件 | 对目录 |
|---|---|---|
r |
能看内容(cat) |
能列出目录里的文件名(ls) |
w |
能改内容 | 能新建 / 删除 / 重命名目录内的文件 |
x |
能当程序运行 | 能进入该目录(cd)并访问其中文件的元数据 |
[!IMPORTANT] 目录的
x位是"能否进入",没有x即使有r也列不出内容。 很多人以为"给了r就能看",于是chmod 644 somedir,结果ls somedir报Permission denied、cd也进不去——因为少了x,ls连读取目录项这一步都走不到。目录可读 =r-x(5),可写 =rwx(7),几乎没有例外。同理,删除文件的权限取决于其所在目录的w而非文件本身:444只读的文件,只要父目录可写照样能被删——因为"删除"改的是目录的内容清单。
# 查看权限位与归属
$ ls -l report.txt
# 查看目录本身(而不是它的内容)的权限
$ ls -ld /var/www
# 同时显示符号与数字形式,最直观
$ stat -c '%A %a %U:%G %n' report.txt
# 逐层列出路径上每一级的权限,排查长路径的神器
$ namei -l /var/www/html/index.html
八进制换算只有一句:r=4、w=2、x=1,每组相加,三组依次是属主、属组、其他人:
| 数字 | 组合 | 常见场景 |
|---|---|---|
755 |
rwxr-xr-x |
目录的标准权限;可执行脚本 |
644 |
rw-r--r-- |
普通文件的标准权限(配置、网页、文档) |
700 |
rwx------ |
家目录、备份目录、私密脚本 |
600 |
rw------- |
私钥(SSH key、TLS 私钥)、含密码的配置 |
777 |
rwxrwxrwx |
所有人可读可写可执行——永远不要用 |
[!WARNING] 为什么是 755 而不是 777?
777表示任何能登录这台机器的人都可以修改并执行该文件。放到/var/www上意味着普通用户能改写你的网页挂马;放到脚本上意味着别人能塞一行rm -rf等你或 cron 去执行。chmod 777不是"宽松的权限设置",而是把服务器控制权交给所有登录者。 遇到Permission denied时正确做法是查出该文件应该属于谁、需要什么权限再精确授权,而不是无脑加权限。唯一勉强可接受的 777 场景是/tmp,而它靠 sticky 位兜底,实际显示为drwxrwxrwt。
| 参数 | 作用 |
|---|---|
ls -l |
长格式:权限、硬链接数、属主、属组、大小、时间、名称 |
ls -ld |
对目录本身操作,而不是列出它的内容 |
ls -lh |
大小显示成 4.0K 这种人类可读形式 |
stat -c '%a' |
只输出八进制权限位(如 644) |
stat -c '%A' |
只输出符号形式(如 -rw-r--r--) |
stat -c '%U:%G' |
输出属主名:属组名 |
namei -l <路径> |
逐层列出路径上每一级的权限与归属 |
复现"目录没有 x 位"这个经典现象:
$ mkdir demo && echo hi > demo/a.txt
$ chmod 644 demo
$ ls demo
预期输出:
ls: cannot access 'demo/a.txt': Permission denied
明明有文件、r 也给了,却连文件名都看不到;执行 chmod 755 demo 之后 ls demo 才会正常显示 a.txt。
| 现象 | 根因 | 解决 |
|---|---|---|
目录有 r 但 ls 仍 Permission denied |
缺 x 位,进不去目录就读不到目录项 |
chmod 755 <目录> |
文件是 444 只读却被人删了 |
删除权限看父目录的 w |
chmod o-w <父目录> |
bash: ./deploy.sh: Permission denied |
脚本没有 x 位 |
chmod +x deploy.sh,或 bash deploy.sh 显式用解释器 |
| 改了权限仍被拒 | 卡在父目录,任一层没 x 都进不去 |
namei -l <完整路径> 逐层查 |
遇到 Permission denied 时按这个顺序排查,比乱加权限快得多:① 权限位 → ② 属主属组 → ③ 父目录每一级 → ④ AppArmor / SELinux → ⑤ 文件系统是否只读或 immutable。
chmod 改那三组 rwx,有两种写法:符号法(u/g/o/a 配合 +、-、=,只动你指定的那一位)和数字法(一次性给出三组最终值)。原则是微调用符号法,定值用数字法。
chown 改属主和属组,chgrp 只改属组。归属决定"这套权限对谁生效":最典型的部署场景是文件由 root 用 sudo 解压出来、服务却以 www-data 身份运行而读不到——这时该做的是 chown www-data:www-data,不是 chmod 777。
# 符号法:属主加执行 / 去属组写 / 清空其他人 / 所有人加读
$ chmod u+x deploy.sh
$ chmod g-w report.txt
$ chmod o= secret.txt
$ chmod a+r README.md
# 数字法
$ chmod 755 /var/www/html/index.html
$ chmod 600 ~/.ssh/id_ed25519
# 改归属:同时改属主属组 / 只改组
$ sudo chown www-data:www-data /var/www/html/index.html
$ sudo chgrp www-data /var/www/html/index.html
# 递归把整个目录树交给应用账号
$ sudo chown -R www-data:www-data /var/www/html
[!WARNING]
chmod -R无差别作用于所有层级,而文件和目录需要的权限并不相同:chmod -R 755会把普通文件也变成可执行,chmod -R 644则会抹掉所有子目录的x位、让整棵目录树再也进不去。安全做法是分开处理——目录find /var/www/html -type d -exec chmod 755 {} +、文件find /var/www/html -type f -exec chmod 644 {} +;也可以直接用大写X:chmod -R a+rX /var/www/html只给目录和已可执行文件加x。执行任何-R之前先确认路径写对了——多一个空格就可能改到完全不同的地方。
[!IMPORTANT] 递归
chown -R前请确认路径写对。最经典的事故是chown -R www-data:www-data /var/www/html /——末尾手滑多敲了一个空格和斜杠,在sudo下它会把整个根目录的所有权改掉,系统立刻崩溃且极难恢复。危险命令执行前先ls一遍路径,或先不加-R试一次看结果。
| 参数 | 作用 |
|---|---|
u / g / o / a |
属主 / 属组 / 其他人 / 三者 |
+ / - / = |
增加 / 移除 / 精确设定(= 会清空未列出的位) |
X(大写) |
只对目录和"已可执行的文件"加 x,配 -R 比小写安全 |
chmod -R / -v / -c |
递归 / 显示每条改动 / 只显示真正变化的 |
chmod --reference=<文件> |
从参照文件复制权限 |
chown user:group |
同时指定属主和属组 |
chown user: / :group |
只改属主(冒号不能省)/ 只改属组 |
chgrp -R <组> <目录> |
递归只改组,文件仍归原属主 |
只想让组可写又不想改属主时,用 chgrp 而不是 chown:sudo chgrp -R www-data /var/www/html && sudo chmod -R g+w /var/www/html。文件仍归你,只是同组的服务账号也能写。
$ chmod 644 demo.txt && stat -c '%a %A' demo.txt
预期输出:
644 -rw-r--r--
chmod o= demo.txt 会清空其他人的权限,得到 640 -rw-r-----。改归属后核对:
$ sudo chown www-data:www-data demo.txt && ls -l demo.txt
预期输出:
-rw-r--r-- 1 www-data www-data 12 Sep 20 11:05 demo.txt
| 现象 | 根因 | 解决 |
|---|---|---|
chmod: Operation not permitted |
你不是属主,也不是 root | 用 sudo chmod,或先 chown 到自己名下 |
chmod -R 644 之后整个目录进不去 |
目录的 x 位被抹掉 |
find <目录> -type d -exec chmod 755 {} + 恢复 |
脚本加了 x 仍报 bad interpreter |
内容有问题(CRLF 换行或 shebang 错),与权限无关 | head -1 script.sh;file script.sh 看是否 CRLF |
chown: invalid user: 'www-date' |
用户名拼错或不存在 | getent passwd www-data 核对 |
| 改了属主仍 Permission denied | 路径中间某层没权限,或服务被 systemd ProtectSystem= 隔离 |
namei -l 排查;systemctl show nginx | grep Protect |
umask 回答"为什么我新建的文件自动就是 644 / 755":系统先给一个满权限初值,再减去 umask 标出的位——普通文件初值 666(本来就不给 x,避免新建文件都可执行的荒唐事),目录初值 777(必须有 x 才可用),所以 umask 022 下文件得 644、目录得 755。
特殊权限位是三组 rwx 之外的第 4 位数字,平时用得少但你要能认出来:
| 位 | 数值 | 作用 |
|---|---|---|
| setuid | 4 |
执行时以文件属主身份运行(/usr/bin/passwd 靠它让你改自己的密码) |
| setgid | 2 |
对目录:其中新建的文件自动继承目录的属组,团队共享目录必配 |
| sticky | 1 |
对目录:只有文件属主或 root 能删自己的文件,即使目录是 777 |
/tmp 显示为 drwxrwxrwt —— 末尾的 t 就是 sticky 位,所有人都能建临时文件,却删不掉别人的。ACL 是 rwx 的补充:rwx 只能表达"属主 / 属组 / 其他"三类人,ACL 允许给某一个具体用户单独授权。
# 查看当前掩码 / 符号形式 / 临时修改(只对当前 shell 有效)
$ umask
$ umask -S
$ umask 027
# 设 setgid,使目录内新建文件继承目录属组(等价数字法 chmod 2775)
$ sudo chmod g+s /srv/shared
# 加 sticky 位
$ sudo chmod +t /srv/tmpdir
# ACL:单独给 bob 读写执行;查看;删除该条
$ sudo setfacl -m u:bob:rwx /srv/shared/report.txt
$ getfacl /srv/shared/report.txt
$ sudo setfacl -x u:bob /srv/shared/report.txt
[!NOTE] 设了 ACL 后
ls -l的权限位末尾会多一个+(如-rw-rwxr--+),提醒你"还有规则没显示"。备份用getfacl -R /srv > acl.bak,恢复用setfacl --restore=acl.bak。
| 命令 / 参数 | 作用 |
|---|---|
umask 022 |
其他人不可写——Ubuntu 默认值 |
umask 027 |
其他人完全无权限,适合同组协作 |
umask 077 |
只有自己能访问,最严格,适合跳板机 |
chmod u+s / g+s / +t |
设 setuid(4) / setgid(2) / sticky(1) |
chmod 4755 |
4=setuid + 755 |
setfacl -m u:<用户>:rwx |
给指定用户加 ACL(-m = modify) |
setfacl -m g:<组>:rx |
给指定组加 ACL |
setfacl -x u:<用户> / -b |
删除某条 ACL / 清除全部 ACL |
getfacl <文件> |
查看 ACL 规则 |
[!WARNING] 给脚本设 setuid 在 Linux 上无效(内核出于安全考虑忽略脚本的 setuid 位),别指望靠它提权。而给属主是 root 的二进制设
u+s,意味着任何人执行它都拥有 root 权限——若该程序有漏洞就是一条完整提权链。不要给来路不明的程序设 setuid;排查时可用find / -perm -4000 -type f 2>/dev/null列出系统上所有 setuid 文件。
验证 umask 如何决定新建文件的权限:
$ umask 022
$ touch newfile && mkdir newdir
$ stat -c '%a %n' newfile newdir
预期输出:
644 newfile
755 newdir
验证 setgid 的组继承效果:我(ubuntu)建的文件,属组自动变成目录的 devops 而非我的主组——这就是 setgid。
$ sudo chmod g+s /srv/shared
$ touch /srv/shared/a.txt && ls -l /srv/shared/a.txt
预期输出:
-rw-r--r-- 1 ubuntu devops 0 Sep 20 11:20 /srv/shared/a.txt
| 现象 | 根因 | 解决 |
|---|---|---|
改了 ~/.bashrc 里的 umask 不生效 |
Ubuntu 默认 ~/.profile 会 source ~/.bashrc,登录 shell 里其实同样生效;真正不生效的是非交互 shell(脚本/cron 不加载任何 bash 启动文件) |
非交互场景在脚本里显式 source ~/.bashrc;或写 /etc/profile.d/umask.sh 统一所有登录环境 |
| 服务创建的文件权限与命令行不同 | systemd unit 里单独设了 UMask= |
systemctl show <服务> | grep UMask,在 unit 里改 |
chmod +t 后显示大写 T 不是 t |
sticky 已设但该位没有 x |
补 chmod o+x <目录>,或直接用 chmod 1777 |
setfacl: Operation not supported 或备份后 ACL 丢失 |
文件系统未开 acl;普通 cp 不保留 ACL |
mount 检查挂载选项;备份改用 cp -a / rsync -a,tar 加 --acls |
| 命令 | 作用 |
|---|---|
id / id <用户名> |
查看 UID、GID 与所属的全部组 |
whoami / who / last / getent passwd |
当前用户名 / 在线会话 / 历史登录 / 查账号(含 LDAP) |
sudo adduser <用户名> 或 useradd -m -s /bin/bash |
创建用户(前者交互式,推荐新手) |
sudo passwd <用户名> |
设密码;passwd -S 看锁定状态 |
sudo usermod -aG sudo <用户名> |
加入 sudo 组(-a 不能忘) |
sudo usermod -L / -U <用户名> |
锁定 / 解锁账号 |
sudo userdel -r <用户名> |
删用户并删家目录(危险) |
sudo groupadd / gpasswd -a / -d |
建组 / 加人 / 减人 |
sudo -l |
查看自己被授予的 sudo 规则 |
sudo visudo -f /etc/sudoers.d/<名> |
安全编辑自定义授权规则 |
su - <用户名> / sudo -i |
完整切换身份 / 提权到 root |
ls -l / stat -c '%a %A %U:%G' |
查看权限位与归属;后者一次看全数字 + 符号 + 属主 |
chmod 755 / 644 / 700 / 600 |
目录 / 普通文件 / 私密目录 / 私钥 |
chmod u+x / g-w / o= / a+rX |
符号法微调权限 |
sudo chown <用户>:<组> <文件> |
改属主与属组;只改组用 chgrp;-R 递归 |
umask / chmod g+s / chmod +t |
查看默认掩码 / 设 setgid 继承属组 / 设 sticky 防互删 |
setfacl -m u:<用户>:rwx <文件> / getfacl |
给单个用户单独授权 / 查看 ACL |
/etc、/home 的定位cp -a / rsync -a 如何保留权限与归属ps -o user= 确认进程以什么身份运行~/.ssh 目录权限要求、禁 root 直登User=、UMask=、ProtectSystem= 如何影响权限lsof 定位「文件被谁占用」程序是躺在磁盘上的文件,进程是它被运行起来之后在内存里的实例。同一个 Nginx 程序可以同时跑出十几个进程,每个都有一份独立的运行时状态。服务器上"服务起不来""CPU 飙高""端口被占"本质上都是进程问题,所以这一章是排错的地基。
全章最重要的两条原则:先 SIGTERM、实在不行才 SIGKILL;杀进程前先确认你杀对了。
[!NOTE] 本章命令与默认行为均基于 Ubuntu 22.04 LTS,版本相关信息截至 2026-09。
内核给每个进程分配一个唯一编号 PID(Process ID),并记下是谁启动了它——PPID(Parent PID)。于是所有进程构成一棵树:开机后内核启动的第一个用户态进程是 systemd(PID 1),它拉起 sshd、cron、网络服务,sshd 再为你每个 SSH 连接开一个 bash,bash 再启动你敲的每一条命令。
这个"父子"关系在排错时非常有用:
<defunct>),占着一个 PID 条目| 概念 | 含义 |
|---|---|
| PID | 进程的唯一编号,kill 就是按它寻址 |
| PPID | 父进程编号,据此画出进程树 |
| PID 1 | systemd,所有进程的最终祖先 |
| 僵尸进程 | 已退出但父进程未回收,状态为 Z |
# 以树状结构查看进程关系(看清谁拉起了谁)
$ pstree -p
# 只看某个进程的树
$ pstree -p -s <PID>
# 查自己的 PID 与父进程
$ echo $$ # 当前 shell 的 PID
$ ps -o pid,ppid,cmd -p $$
# 查某个进程的父子信息
$ ps -o pid,ppid,cmd -p <PID>
| 参数 | 作用 |
|---|---|
pstree -p |
显示树状结构并标出每个进程的 PID |
pstree -p -s <PID> |
只显示指定进程及其祖先链(-s = show parents) |
pstree -u |
在进程名后标出属主,多用户机器上很直观 |
ps -p <PID> |
只看指定 PID |
$$ |
Shell 变量,展开为当前 shell 自己的 PID |
$! |
Shell 变量,展开为最近一个后台任务的 PID |
$ ps -o pid,ppid,stat,cmd -p $$
预期输出:
PID PPID STAT CMD
8123 8119 Ss -bash
当前 bash 的 PID 是 8123,它的父进程是 8119——顺着 8119 往上查,最终会走到 SSH 会话和 PID 1。
| 现象 | 根因 | 解决 |
|---|---|---|
pstree: command not found |
精简镜像没装 psmisc | sudo apt install psmisc |
| 子进程还在,父进程没了 | 父进程退出后子进程被过继给 PID 1 | 属正常;要一起停得显式杀子进程或改用 systemd 托管 |
进程名后面带 <defunct> |
僵尸进程,父进程未回收 | 杀不掉,需重启其父进程(真正要修的是父程序) |
| 杀掉父进程后子进程仍在跑 | Linux 不会级联杀子进程 | 用 pkill -P <PPID> 按父进程批量清理子进程 |
ps 是"拍一张快照",它只显示执行瞬间的状态,不会刷新。它有两大风格,混用是新手最常见的困扰:
| 风格 | 写法 | 来源 | 特点 |
|---|---|---|---|
| BSD 风格 | ps aux |
BSD 传统 | 选项不带短横线,输出含 %CPU %MEM、命令行完整 |
| System V 风格 | ps -ef |
UNIX 传统 | 选项带短横线,额外显示 PPID |
两者看到的进程完全一样,只是列不同。日常推荐 ps aux(列更实用),需要看 PPID 时用 ps -ef 或 ps -eo。
按名字找进程还有更省事的命令:pgrep 直接输出匹配到的 PID,pkill 直接对它们发信号。
# BSD 风格:看全部进程,含资源占用与完整命令行
$ ps aux
# System V 风格:额外带 PPID
$ ps -ef
# 找 nginx 相关进程(注意 grep 自身也会匹配到)
$ ps aux | grep nginx
# 用 pgrep 直接拿到 PID,不会匹配到自己
$ pgrep -f nginx
# 自定义输出列:PID、父进程、启动时长、完整命令
$ ps -o pid,ppid,cmd,etime -p <PID>
# 只看某个用户的所有进程
$ ps -u www-data
[!TIP]
ps aux | grep nginx的结果里总有一条是grep nginx自己(因为它也是个进程,命令行里含 "nginx")。想排除它,用ps aux | grep '[n]ginx',或者干脆用pgrep -f nginx——后者不会把检索命令本身算进去。
| 参数 | 作用 |
|---|---|
a |
显示所有用户的进程(不只是自己的) |
u |
以用户友好的格式显示,含属主、%CPU、%MEM |
x |
包含没有控制终端的进程(守护进程关键在这) |
-e |
显示所有进程,等价于 BSD 的 ax |
-f |
全格式,含 PPID 与完整命令行 |
-o <列名> |
自定义输出列,如 pid,ppid,cmd,etime,user,%cpu,%mem |
etime |
进程从启动到现在过了多久(比 TIME 的 CPU 时间更直观) |
pgrep -f <模式> |
按完整命令行匹配(不加 -f 只匹配进程名) |
pgrep -u <用户> |
只看某用户的进程 |
pgrep -a |
同时打印 PID 和完整命令行 |
$ ps -o pid,ppid,user,%cpu,%mem,etime,cmd -p $$
预期输出:
PID PPID USER %CPU %MEM ELAPSED CMD
8123 8119 ubuntu 0.0 0.1 02:31 -bash
各列含义:PID、父进程、属主、CPU 占用率、内存占用率、已运行 2 分 31 秒、完整命令。
确认 pgrep 不会匹配到自己:
$ pgrep -f nginx
预期输出:
1042
1043
1044
只列出真正的 nginx 进程 PID,没有多余条目。
| 现象 | 根因 | 解决 |
|---|---|---|
ps aux 看不到别人的进程 |
容器/PID 命名空间隔离,只能看到本命名空间 | 属正常;宿主机上 ps aux 才看得到全部 |
| 命令行被截断看不全 | 默认按终端宽度截短 | 加 -ww(ps auxww),或用 ps -ef |
grep 结果里总有一条多余的 |
grep 自身进程也被匹配 | 用 pgrep / grep '[n]ginx' |
ps aux 里 STAT 列出现 Z |
僵尸进程 | 见上一节,需处理其父进程 |
| 想看进程占用哪个端口 | ps 不显示端口 |
用 ss -lntp 或 lsof -i:<端口>(见常见问题汇总) |
ps 是快照,top 是实时刷新的动态视图(默认每 3 秒)。它的输出分两部分:上方 5 行是系统总览,下方是进程列表。
| 首部行 | 含义 |
|---|---|
| 第 1 行 | 当前时间、运行时长、登录用户数、load average(1/5/15 分钟) |
| 第 2 行 Tasks | 进程总数及 running / sleeping / stopped / zombie 数量 |
| 第 3 行 %Cpu(s) | CPU 时间分配:us用户态、sy内核态、id空闲、wa等待 IO、st被虚拟机偷走 |
| 第 4 行 MiB Mem | 物理内存:总量 / 空闲 / 已用 / 缓存与缓冲 |
| 第 5 行 MiB Swap | 交换分区:总量 / 空闲 / 已用 |
[!TIP] 第 3 行的
wa(iowait)高是重要信号:它说明 CPU 在等磁盘或网络 IO 返回,而不是在计算。这时去优化 CPU 是南辕北辙,该查的是磁盘(iostat)和慢查询。
htop 是 top 的增强版:支持鼠标、彩色、横向滚动看完整命令行、直接按树状显示。Ubuntu 22.04 默认没装,需要 sudo apt install htop。
# 启动实时监控(按 q 退出)
$ top
# 只监控某个用户的进程
$ top -u www-data
# 只盯着某个进程
$ top -p <PID>
# 安装并使用 htop
$ sudo apt install htop
$ htop
top 运行时的常用交互键:
| 按键 | 作用 |
|---|---|
q |
退出 |
P |
按 CPU 占用排序(默认) |
M |
按内存占用排序 |
1 |
展开显示每个 CPU 核心的单独占用率 |
k |
杀进程(会提示输入 PID 和信号) |
c |
显示完整命令行(不再截断) |
H |
按线程显示而非进程 |
| 参数 | 作用 |
|---|---|
-d <秒> |
刷新间隔,如 top -d 1 每秒刷新 |
-p <PID> |
只看指定进程,多个用逗号分隔 |
-u <用户> |
只看指定用户的进程 |
-b |
批处理模式,输出到标准输出,适合脚本采集(top -b -n 1) |
-n <次数> |
刷新 N 次后退出,配合 -b 用 |
-H |
显示线程 |
htop -t |
以树状结构显示父子关系 |
htop -u <用户> |
只看某用户的进程 |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install htop |
sudo dnf install htop |
包名一致,包管理器不同 |
top(procps-ng) |
top(procps-ng) |
命令与快捷键完全一致 |
sudo apt install psmisc(pstree) |
sudo dnf install psmisc |
同上 |
取一次性的系统总览(不进入交互界面,适合写进脚本或贴到工单里):
$ top -b -n 1 | head -n 5
预期输出:
top - 14:32:10 up 12 days, 3:41, 2 users, load average: 0.35, 0.42, 0.38
Tasks: 187 total, 1 running, 186 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.1 us, 1.2 sy, 0.0 ni, 95.4 id, 0.3 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 3916.9 total, 512.3 free, 1180.4 used, 2224.2 buff/cache
MiB Swap: 2048.0 total, 2048.0 free, 0.0 used
判读:负载 0.35 很低;zombie 为 0 说明没有僵尸进程;id 95.4% 说明 CPU 基本空闲;wa 0.3% 说明没有 IO 瓶颈;内存里真正被程序用的是 used,buff/cache 那部分内核会自动回收,不算占用。
| 现象 | 根因 | 解决 |
|---|---|---|
top 里 %CPU 超过 100% |
多核机器上按单核归一化,400% = 占满 4 核 | 属正常;按 1 看每核分别占用 |
| 内存看起来几乎用满 | Linux 会用空闲内存做磁盘缓存(buff/cache) |
看 used 而非 free;缓存可被内核自动回收 |
htop: command not found |
未安装 | sudo apt install htop |
top 里看不到想找的进程 |
默认按 CPU 排序,进程在后面 | 按 c 看完整命令行,或用 top -p <PID> |
zombie 数量不为 0 |
父进程没回收子进程 | 找到其父进程并重启父进程 |
load average 是 top 第一行末尾和 uptime 输出的三个数字,例如 0.35, 0.42, 0.38,分别是过去 1 分钟、5 分钟、15 分钟的系统平均负载。
它的含义常被误解:负载统计的是可运行(正在用 CPU 或排队等 CPU)的进程数 + 不可中断睡眠(等 IO)的进程数,不是 CPU 百分比。
判读的关键是核心数:
| 场景 | 判断 |
|---|---|
| 单核,负载 1.00 | CPU 刚好打满,所有任务无需排队 |
| 单核,负载 2.00 | 有一半进程在排队,明显过载 |
| 4 核,负载 4.00 | 刚好打满(等价于单核的 1.00) |
| 4 核,负载 1.00 | 只用了约 1/4 算力,很闲 |
[!IMPORTANT] 负载高 ≠ CPU 高。 如果
top里%Cpu的wa(iowait)很高而us/sy很低,说明进程都卡在等磁盘或网络 IO 上——它们不消耗 CPU,但仍然计入负载。同理,大量进程争抢一把锁(如数据库锁、NFS 挂死)也会让负载飙升而 CPU 空闲。所以看到负载高,第一步是看wa,而不是急着找 CPU 大户。
三个值的趋势同样重要:1 分钟高而 15 分钟低 = 刚刚开始有压力;1 分钟低而 15 分钟高 = 压力正在缓解;三个都高且持续 = 真的过载了。
# 看负载与运行时长
$ uptime
# 确认有几个核(判读负载的分母)
$ nproc
# 更详细的 CPU 信息
$ lscpu | grep -E '^CPU\(s\)|Model name'
# 看 CPU 各状态占比,重点看 wa
$ top -b -n 1 | head -n 3
| 命令 / 参数 | 作用 |
|---|---|
uptime |
一行显示运行时长、登录数、1/5/15 分钟负载 |
nproc |
打印可用 CPU 核心数——判读负载的分母 |
lscpu |
显示 CPU 架构、核心数、线程数、型号 |
cat /proc/loadavg |
直接读内核暴露的负载值,前三个即 1/5/15 分钟 |
top 的 wa |
iowait 占比,判断负载是否来自 IO |
top 的 st |
steal time,虚拟机上被宿主机"偷走"的 CPU,高说明宿主机超卖 |
$ nproc && uptime
预期输出:
4
14:32:10 up 12 days, 3:41, 2 users, load average: 1.20, 0.90, 0.60
判读:4 核机器,1 分钟负载 1.20 相当于占用了 30% 算力,不算过载;但 1 分钟 > 5 分钟 > 15 分钟,说明压力正在上升,值得继续观察。
$ cat /proc/loadavg
预期输出:
1.20 0.90 0.60 2/187 91234
前三个是 1/5/15 分钟负载,2/187 表示当前可运行进程数 2、总进程数 187,最后是最近创建的 PID。
| 现象 | 根因 | 解决 |
|---|---|---|
| 负载很高但 CPU 空闲 | 进程卡在 IO(wa 高)或争锁 |
查磁盘 iostat -x 1、查数据库慢查询、查 NFS |
| 负载很低但服务很慢 | 瓶颈不在 CPU(可能是网络延迟、DNS、连接池) | 换个方向排查,别只盯负载 |
容器里 uptime 显示的负载是宿主机的 |
容器共享宿主机内核,/proc/loadavg 未被隔离 |
属正常,看容器的 CPU 配额与用量 |
| 负载突然飙升又回落 | 定时任务(cron)、日志轮转、备份 | 对比 1/5/15 分钟三个值判断是瞬时还是持续 |
| 虚拟机里明明很闲但负载高 | st(steal)高,宿主机超卖 |
联系云厂商或迁移实例 |
在终端里直接敲命令,进程在前台运行并独占终端——它不结束你就敲不了下一条命令。在命令末尾加 &,进程就进入后台运行,终端立刻还给你。
但后台进程仍然依附于这个终端(会话)。SSH 断开时,系统会向该会话的所有进程发 SIGHUP(hangup,挂断信号),默认行为就是终止进程。这就是为什么"关掉终端,程序就死了"。
| 手段 | 原理 | 适合场景 |
|---|---|---|
& |
放到后台,但仍属于当前会话 | 临时让出终端 |
Ctrl+Z + bg |
先挂起再转后台 | 命令已经在前台跑起来了才想转后台 |
nohup |
忽略 SIGHUP,且自动把输出重定向到 nohup.out |
简单的一次性长任务 |
setsid |
让进程脱离当前会话,成为新会话组长 | 需要完全脱离终端 |
tmux / screen |
虚拟终端,断开会话也不影响里面的进程 | 长期运行、还要回去看输出(最推荐) |
# 后台运行(终端立即可用)
$ long-task &
# 查看当前会话的后台作业
$ jobs
# 把 1 号作业拉回前台 / 让它在后台继续
$ fg %1
$ bg %1
# 挂起当前前台进程:这是键盘操作,直接按 Ctrl+Z 键(不是命令),之后可用 bg 转后台
# 脱离终端运行:忽略 SIGHUP,输出自动写入 nohup.out
$ nohup long-task > task.log 2>&1 &
# 完全脱离当前会话
$ setsid long-task > task.log 2>&1 &
# 最推荐:用 tmux 开一个可随时回来的会话
$ sudo apt install tmux
$ tmux new -s work
[!WARNING]
nohup只解决"不会被 SIGHUP 杀掉",不解决输出无处可去的问题。 如果你写了nohup cmd &却没重定向,输出会写进当前目录的nohup.out——它可能在无人看管的情况下涨到几十 GB 撑爆磁盘。规范写法永远是显式重定向:nohup cmd > /var/log/myapp.log 2>&1 &,并且交给 logrotate 管理(见 54-logrotate.md)。线上长期服务请不要用 nohup,改用 systemd(见 40-systemd.md),它自带重启、日志和资源限制。
在 tmux 会话里,Ctrl+B 然后按 D 可以"脱离"(detach),进程继续跑;之后 tmux attach -t work 可以原样回来。
| 命令 / 参数 | 作用 |
|---|---|
& |
放到后台执行,$! 拿到它的 PID |
Ctrl+Z |
向前台进程发 SIGTSTP,挂起(暂停而非终止) |
jobs |
列出当前会话的后台作业,%1 是作业号 |
fg %N / bg %N |
把 N 号作业拉回前台 / 在后台继续运行 |
disown %N / disown -h |
把作业从 shell 的作业表移除,使其不受本次会话退出影响 |
nohup |
忽略 SIGHUP;未重定向时输出写 nohup.out |
setsid |
在新会话中启动进程,彻底脱离当前终端 |
> file 2>&1 |
标准输出和错误输出都写进 file |
tmux new -s <名> |
新建一个具名会话 |
tmux attach -t <名> |
重新连回会话 |
后台运行后确认它还在:
$ sleep 300 &
$ jobs -l
预期输出:
[1]+ 9128 Running sleep 300 &
[1] 是作业号,9128 是 PID,Running 是状态。
验证 nohup 已经忽略 SIGHUP(用 ps 看不出来,要发一个信号试):
$ nohup sleep 600 > /dev/null 2>&1 &
$ kill -HUP $! ; sleep 1 ; ps -p $! -o pid,cmd=
预期输出:
9347 sleep 600
进程仍然存在,说明 HUP 被忽略了。普通后台任务在收到 HUP 后会立刻消失。
| 现象 | 根因 | 解决 |
|---|---|---|
| SSH 断开后进程就没了 | 会话退出时内核对该会话发 SIGHUP | 用 nohup、setsid,或放进 tmux / systemd |
| 关掉终端后程序"卡住"不退出 | 进程仍持有终端,写输出时阻塞 | 启动时就把 stdout/stderr 重定向到文件或 /dev/null |
当前目录莫名出现巨大 nohup.out |
用了 nohup 却没重定向 |
显式重定向,并 rm 掉旧文件(注意先确认无人写入) |
jobs 看不到之前启动的后台任务 |
jobs 是 shell 内置,只认当前会话 |
换个终端就看不到了,用 ps aux | grep 找 |
disown 之后进程消失了 |
disown 只是移出作业表,并不阻止 SIGHUP |
先 nohup 或 setsid 再考虑 disown |
kill 这个名字有误导性——它真正的含义是向进程发一个信号,进程收到后怎么做由它自己决定。信号是进程间最基础的通信方式。
最常用的两个:
| 信号 | 编号 | 默认行为 | 什么时候用 |
|---|---|---|---|
SIGTERM |
15 | 请求退出,进程可以捕获并做收尾 | 默认第一选择 |
SIGKILL |
9 | 内核直接杀死,进程无法捕获、无法拒绝 | 只有 TERM 无效时才用 |
SIGTERM 相当于礼貌地请人离开:程序可以保存数据、关闭连接、清理临时文件、通知上游。"服务停不下来"时很多人直接 kill -9,代价是程序没机会做这些事。
其他常见信号:SIGHUP(1) 挂断,很多程序收到它会重新加载配置;SIGINT(2) 就是 Ctrl+C;SIGSTOP(19) 暂停进程(不可被捕获),SIGCONT(18) 继续。
# 列出所有信号
$ kill -l
# 优雅停止(默认就是 SIGTERM)
$ kill <PID>
# 只有 TERM 无效时才用 -9
$ kill -9 <PID>
# 让程序重读配置(nginx / sshd 等都支持)
$ sudo kill -HUP <PID>
# 按进程名批量终止
$ killall nginx
# 按完整命令行匹配终止
$ pkill -f "python app.py"
# 先确认会杀到谁(把 pkill 换成 pgrep 预演一遍)
$ pgrep -af "python app.py"
[!WARNING]
kill -9是最后的手段,不是第一选择。 进程收到SIGKILL时会被内核立刻抹掉,没有任何机会执行清理代码。对数据库(MySQL、PostgreSQL、Redis)和数据写入类程序,kill -9很可能造成数据文件损坏、事务丢失、启动时长时间崩溃恢复。正确顺序是:先kill <PID>(TERM)→ 等 10~30 秒 → 仍无反应再kill -9。如果是 systemd 托管的服务,一律用systemctl stop,它会先 TERM 再超时 KILL,并且知道正确的停止流程。
[!WARNING]
pkill -f按完整命令行做正则匹配,非常容易误杀。 例如pkill -f java会杀掉机器上所有命令行里含 "java" 的进程——可能包括你不想碰的另一个服务,甚至包括你自己正在跑的部署脚本。执行前请先用pgrep -af <模式>把将要被杀的进程完整列出来核对一遍,确认无误后再把pgrep换成pkill。
| 命令 / 参数 | 作用 |
|---|---|
kill -l |
列出全部信号名与编号 |
kill <PID> |
发 SIGTERM(15),默认信号 |
kill -9 <PID> / kill -KILL |
发 SIGKILL,强制、不可捕获 |
kill -HUP <PID> |
发 SIGHUP(1),多数守护进程用来重读配置 |
kill -STOP / -CONT <PID> |
暂停 / 继续进程(19 / 18) |
kill -0 <PID> |
不发信号,只探测进程是否存在(写脚本判断存活常用) |
killall <名字> |
按进程名终止所有匹配进程 |
pkill -f <模式> |
按完整命令行正则匹配终止(危险,见上方警告) |
pgrep -af <模式> |
安全地预演:-a 打印 PID + 完整命令行 |
pkill -u <用户> |
终止某用户的所有进程(踢人下机常用) |
先确认目标进程,再终止,最后确认已消失:
$ pgrep -af "sleep 900"
预期输出:
9150 sleep 900
$ kill 9150 ; sleep 1 ; ps -p 9150 -o pid,cmd=
预期输出:
(无输出,进程已退出)
ps -p 找不到该 PID 时输出为空,说明进程确实结束。用 kill -0 探测存活更明确:
$ kill -0 9150 2>/dev/null && echo "还在" || echo "已退出"
预期输出:
已退出
| 现象 | 根因 | 解决 |
|---|---|---|
kill -9 也杀不掉 |
进程处于 D 状态(不可中断睡眠,通常卡在磁盘/NFS),或已是僵尸 | D 状态只能等 IO 恢复或重启;僵尸进程需杀其父进程 |
kill: (PID) - Operation not permitted |
该进程不属于你,也不是 root | 用 sudo kill,或让属主操作 |
kill 后进程立刻又出现了 |
被 systemd / supervisor / PM2 自动拉起 | 要停服务就用 systemctl stop <服务>,别直接 kill |
killall: command not found |
未装 psmisc | sudo apt install psmisc,或用 pkill |
| 误杀了不该杀的进程 | 用了宽泛的 pkill -f 模式 |
以后先 pgrep -af 预演;被杀的服务用 systemctl start 恢复 |
| 端口被占用,要先找到是哪个进程 | 需要反查监听者 | sudo ss -lntp | grep :<端口> 或 sudo lsof -i:<端口> |
Linux 用 nice 值表示进程的"谦让程度",范围 -20(最优先,抢 CPU)到 +19(最谦让,让别人先跑),默认 0。nice 值越高,分到的 CPU 时间越少。它的意义在于:让备份、日志压缩这类批处理任务别拖垮线上服务。
按资源定位进程则是排错的核心动作——"CPU 飙高""内存爆了"都要能第一时间找出是哪个进程。
# 以低优先级启动(+10 表示谦让,不抢线上服务的 CPU)
$ nice -n 10 tar czf backup.tgz /data
# 调整已在运行的进程的优先级
$ sudo renice -n 10 -p <PID>
# 按内存占用找前 5 名
$ ps aux --sort=-%mem | head -n 6
# 按 CPU 占用找前 5 名
$ ps aux --sort=-%cpu | head -n 6
# 查看进程的 nice 值(NI 列)
$ ps -o pid,ni,cmd -p <PID>
[!NOTE] 调低 nice 值(提高到 -20 这样的负优先级)需要 root 权限,普通用户只能把自己的进程越调越谦让(
0→19)。这是防止普通用户抢占系统资源的设计。
| 命令 / 参数 | 作用 |
|---|---|
nice -n <值> <命令> |
以指定 nice 值启动进程,默认 10 |
renice -n <值> -p <PID> |
改运行中进程的 nice 值 |
renice -n <值> -u <用户> |
改某用户所有进程的 nice 值 |
ps --sort=-%mem |
按内存降序(- 表示降序) |
ps --sort=-%cpu |
按 CPU 降序 |
ps -o ni / top 的 NI 列 |
查看 nice 值 |
head -n 6 |
取 5 条进程 ——多出的 1 行是 ps 的表头 |
top -b -n 1 -o %MEM |
批处理模式下按内存排序,适合脚本 |
systemd 的 Nice= |
unit 里配置服务的固定优先级 |
$ ps aux --sort=-%mem | head -n 3
预期输出:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
mysql 1042 2.1 18.4 1758236 721356 ? Ssl Sep12 45:12 /usr/sbin/mysqld
www-data 2311 0.3 4.2 238412 165204 ? S Sep12 3:04 nginx: worker process
第一列之后看 %MEM:mysqld 占了 18.4% 内存(RSS 721356 KB ≈ 704 MB),是本机内存大户。
验证 nice 值生效:
$ nice -n 10 sleep 600 &
$ ps -o pid,ni,cmd -p $!
预期输出:
PID NI CMD
9180 10 sleep 600
NI 列为 10,说明该进程以谦让模式运行,会主动把 CPU 让给更重要的进程。
| 现象 | 根因 | 解决 |
|---|---|---|
renice: failed to set priority |
普通用户不能提高优先级(调低 nice 值) | 加 sudo |
明明有进程占内存,%MEM 却不高 |
机器内存很大,或看的是 VSZ(虚拟内存)而非 RSS |
看 RSS 列(实际物理内存) |
| 找不到 CPU 高的进程但负载很高 | 负载来自 IO(wa 高),不是 CPU |
查 iostat -x 1 和 iotop |
ps --sort 排序结果不对 |
列名要带 % 且区分大小写 |
用 --sort=-%cpu / --sort=-rss |
| 想限制进程最多用多少 CPU / 内存 | nice 只是相对权重,不是硬限制 | 用 systemd 的 CPUQuota=、MemoryMax=(见 40-systemd.md) |
| 负载高,不知从何入手 | 可能来自 CPU、IO、内存三个方向 | 先看 top 的 %Cpu 与 wa,再决定查 ps --sort 还是 iostat |
[!TIP] 负载高时的三步入手法:① 看
top的wa——高就是 IO 问题,去查磁盘和慢查询;②wa不高则按P按 CPU 排序找出 CPU 大户;③ 内存紧张时按M排序,并留意 swap 是否被用到(用到说明物理内存不足,性能会断崖式下降)。
| 命令 | 作用 |
|---|---|
pstree -p |
树状查看进程父子关系 |
ps aux / ps -ef |
查看全部进程(BSD 风格 / System V 风格) |
ps -o pid,ppid,cmd,etime -p <PID> |
自定义列查看进程与运行时长 |
pgrep -af <模式> |
按完整命令行查找并打印 PID + 命令(杀进程前预演) |
top / htop |
实时监控(后者需安装,交互更友好) |
top -b -n 1 | head -n 5 |
取一次系统总览,适合写进工单 |
uptime / nproc |
看负载 / 看核心数(判读负载的分母) |
命令 & / jobs |
后台运行 / 查看后台作业 |
fg %1 / bg %1 / disown |
拉回前台 / 后台继续 / 移出作业表 |
nohup cmd > log 2>&1 & |
忽略 SIGHUP 并重定向输出,断网也不停 |
setsid cmd > log 2>&1 & |
彻底脱离当前会话 |
tmux new -s <名> / attach -t |
建可重连的虚拟终端会话(长期任务推荐) |
kill -l |
列出所有信号 |
kill <PID> / kill -9 <PID> |
优雅终止(TERM)/ 强制杀死(KILL,最后手段) |
kill -HUP <PID> |
让守护进程重读配置 |
kill -0 <PID> |
只探测进程是否存活,不发信号 |
pkill -f <模式> / killall <名> |
按命令行 / 进程名批量终止(先 pgrep -af 预演) |
nice -n 10 <命令> / renice -n 10 -p <PID> |
以低优先级启动 / 调整运行中进程优先级 |
ps aux --sort=-%cpu | head -n 6 |
找 CPU 占用最高的 5 个进程 |
ps aux --sort=-%mem | head -n 6 |
找内存占用最高的 5 个进程 |
ps -o user= 确认进程以什么身份运行Nice=、CPUQuota=、MemoryMax=htop / iotop / lsof / strace 等排错工具服务器「连不上」是新手遇到频率最高、也最容易慌的问题。本章先给你一套从近到远的排错思路,再逐层讲清楚:怎么看 IP、怎么看监听端口、怎么测连通性、DNS 和主机名是怎么回事、怎么配静态 IP、为什么时间必须准。学完本章,面对「服务器不通」你不再靠猜,而是按层排查。
[!NOTE] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
网络排错的核心思想是:问题一定出在从本机到目标之间的某一段路上,逐段确认就能定位。就像家里停水时,你会先看自家水龙头,再看小区是否停水,而不是直接打电话问自来水厂。服务器排错同理,按「离自己由近到远」的顺序检查四层,每一层都有对应的命令。
先记住这张排查路线表,后面小节会逐条展开:
| 层 | 确认什么 | 命令 | 通了说明 |
|---|---|---|---|
| 1. 本机 IP | 网卡有没有拿到地址 | ip a |
本机网络配置存在 |
| 2. 网关 | 能不能出去(ping 网关 / 公网 IP) | ping -c 4 192.168.1.1 |
本机到出口的链路正常 |
| 3. DNS | 域名能不能解析成 IP | dig example.com |
名字解析正常 |
| 4. 目标端口 | 对方服务端口通不通 | curl -I http://<服务器IP>:80 |
端到端全部正常 |
排错时从上往下走,在哪一层断掉,问题就在哪一层:
# 第 1 层:本机有 IP 吗?
$ ip -br a
# 第 2 层:网关通吗?(网关地址从 ip r 里看)
$ ip r
$ ping -c 4 192.168.1.1
# 第 3 层:DNS 正常吗?直接 ping 一个公网 IP 对比
$ ping -c 4 223.5.5.5
$ dig example.com
# 第 4 层:目标端口通吗?
$ curl -I http://example.com
按顺序执行四层检查,正常情况下每层都应有响应:
# 第 1 层:确认本机有 IP
$ ip -br a
预期输出:
lo UNKNOWN 127.0.0.1/8 ::1/128
ens33 UP 192.168.1.10/24 fe80::20c:29ff:fe8a:1234/64
| 现象 | 根因 | 解决 |
|---|---|---|
| 不知道从哪查起,乱试命令 | 没有排查框架 | 严格按本节路线表从第 1 层走到第 4 层,断在哪层查哪层 |
| ping 网关都不通 | 网线/虚拟网络/云安全组问题 | 检查虚拟机网络模式(NAT/桥接)或云控制台安全组 |
| 四层都通但应用还是连不上 | 应用层问题(配置错端口、认证失败) | 看应用日志,网络问题已排除 |
ip 命令是 Linux 当代标准的网络查看/配置工具,属于 iproute2 包,Ubuntu 默认自带。老教程里的 ifconfig 属于 net-tools 包,已停止维护多年,Ubuntu 默认根本没装。看到教程让你 ifconfig,直接换成 ip a 即可,功能对应关系本章会给出。
# 查看所有网卡的详细地址信息(最常用)
$ ip a
# 简洁视图:一行一块网卡,日常排错更快
$ ip -br a
# 查看路由表,default 那一行就是默认网关
$ ip r
ifconfig → ip 的常用对应关系:
| 老命令(net-tools,过时) | 新命令(iproute2,推荐) |
|---|---|
ifconfig |
ip a |
route -n |
ip r |
ip -br a 里的 -br 是 --brief(简洁),只显示网卡名、状态、地址三列,适合快速扫一眼。ip r 输出里 default via 192.168.1.1 表示:去往任何未知网络的数据包,都先交给 192.168.1.1(网关)转发。网关地址就是你的「出口大门」,排错第 2 层 ping 的就是它。ens33、enp0s3、eth0 等,不再统一叫 eth0,写配置时以 ip a 看到的为准。# 简洁视图确认网卡和 IP;再看默认网关
$ ip -br a
$ ip r
预期输出:
lo UNKNOWN 127.0.0.1/8 ::1/128
ens33 UP 192.168.1.10/24
default via 192.168.1.1 dev ens33 proto dhcp src 192.168.1.10 metric 100
192.168.1.0/24 dev ens33 proto kernel scope link src 192.168.1.10
default via 192.168.1.1 就是默认网关,排错第 2 层要 ping 的就是它。
| 现象 | 根因 | 解决 |
|---|---|---|
ifconfig: command not found |
net-tools 已被淘汰,默认不装 | 用 ip a,不要安装过时包 |
ip a 里网卡显示 DOWN 且无 IP |
网卡没起来(驱动/虚拟网络问题) | sudo ip link set 网卡名 up 后观察,仍不行查虚拟网络配置 |
只有 lo,看不到 ens33 |
虚拟机网络适配器未连接 / 云主机网卡异常 | VMware/VirtualBox 检查「已连接」选项;云主机在控制台查看网卡状态 |
| Ubuntu (apt/ufw) | RHEL (dnf/firewalld) | 说明 |
|---|---|---|
ip a / ip r(自带) |
ip a / ip r(自带) |
iproute2 两边一致,命令通用 |
网卡名常见 ens33 |
常见 ens160、enp1s0 |
命名规则相同,以实际输出为准 |
| 网络由 netplan 管理 | 网络由 NetworkManager 管理 | 改 IP 的方式不同,见本章 netplan 一节的对照块 |
ss(socket statistics)是当代查看网络连接的标准工具,取代了老教程里的 netstat。它回答两个高频问题:「我的服务到底监听了哪个端口、监听在哪个地址上」和「现在有哪些连接连进来了」。判断「服务没起」还是「防火墙挡了」,全靠它。
# 查看所有 TCP 监听端口,并显示是哪个进程在监听(最常用)
$ sudo ss -lntp
# 汇总统计:各类 socket 数量一览
$ ss -s
# 只看已建立的连接(排查谁在连我 / 我连着谁)
$ sudo ss -tnp | grep ESTAB
netstat → ss 一一对应:netstat -lntp 即 ss -lntp,netstat -an 即 ss -tan,netstat -s 即 ss -s。看到老教程里的 netstat 命令,直接替换成 ss 即可。
ss -lntp 四个字母拆开记:-l 只显示 listening(正在监听)的 socket;-n 不解析域名和端口名,直接显示数字(快,且不受 DNS 故障影响);-t 只看 tcp;-p 显示占用端口的process(进程名和 PID),需要 sudo 才能看到全部。
输出里监听地址是关键:127.0.0.1:3306 表示只有本机能连;0.0.0.0:3306 表示所有网卡都能连。后面「判断本机端口为什么不通」一节全靠这个区别。
# 查看 TCP 监听端口及所属进程
$ sudo ss -lntp
预期输出:
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.53:53 0.0.0.0:* users:(("systemd-resolve",pid=512,fd=14))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=701,fd=3))
LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=1204,fd=28))
上面输出能直接读出三条信息:本机 DNS 由 systemd-resolved 监听在 127.0.0.53;SSH 对全网开放(0.0.0.0:22);MySQL 只监听 127.0.0.1,外部连不上是设计如此,不是故障。
| 现象 | 根因 | 解决 |
|---|---|---|
ss -lntp 不加 sudo 看不到进程名 |
-p 需要权限读取其他用户的进程信息 |
加 sudo |
找不到 netstat |
新系统默认不装 net-tools | 一律改用 ss,参数几乎一一对应 |
服务明明起了,ss 里却搜不到 |
端口号记错了、服务其实没在监听,或监听在别的地址/端口上 | 先不筛选,直接 ss -lntp 全量看一遍再核对(ss -t 本身就同时列出 IPv4 与 IPv6 监听) |
确认了本机 IP 和监听状态后,就该测试「路通不通」。ping 测最基本的可达性,traceroute/mtr 看路径上断在哪一跳,curl 直接测 HTTP 服务(Web 排错的终极大招),wget 用来下载文件顺带验证网络。
# ping 4 次就停(-c 指定次数,不加会一直 ping 下去)
$ ping -c 4 223.5.5.5
# 查看路由路径,每一跳到达哪个路由器
$ traceroute 223.5.5.5
# mtr = ping + traceroute 合体,实时刷新每跳丢包率,更好用
$ mtr 223.5.5.5
# 只看 HTTP 响应头:状态码 200 说明服务正常
$ curl -I http://example.com
# 显示详细过程:DNS 解析、TCP 连接、TLS 握手、请求响应全流程
$ curl -v https://example.com
# 下载文件,顺带验证网络与 DNS 都正常
$ wget https://example.com/file.tar.gz
traceroute 和 mtr 在 Ubuntu 最小安装里默认没有,需要安装:
# 安装路由追踪工具
$ sudo apt install traceroute mtr-tiny
ping -c 4:务必加 -c。裸 ping 会永远执行下去,新手常以为终端卡死。Ctrl+C 可随时中断。curl -I:-I(大写 i)表示只要响应头(HEAD 请求)。看状态码就够了:200 正常、404 路径错、502 后端挂了、connection refused 端口没开。curl -v:verbose 模式。当 -I 显示结果异常时,用 -v 看卡在了哪一步:卡在 DNS 说明解析问题,卡在 TCP connect 说明端口不通,卡在 TLS handshake 多半是证书或时间问题。mtr 比 traceroute 强在持续统计:单次 traceroute 某一跳超时可能只是路由器不响应 ping,不代表断;mtr 跑一分钟看丢包率才可信。# 测试到公网 DNS 的连通性
$ ping -c 4 223.5.5.5
# 只看响应头判断 HTTP 服务是否正常
$ curl -I http://example.com
预期输出:
PING 223.5.5.5 (223.5.5.5) 56(84) bytes of data.
64 bytes from 223.5.5.5: icmp_seq=1 ttl=118 time=8.32 ms
64 bytes from 223.5.5.5: icmp_seq=2 ttl=118 time=8.11 ms
64 bytes from 223.5.5.5: icmp_seq=3 ttl=118 time=8.25 ms
64 bytes from 223.5.5.5: icmp_seq=4 ttl=118 time=8.19 ms
--- 223.5.5.5 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
ping 的 0% packet loss 说明链路健康;curl 返回 HTTP/1.1 200 OK 说明服务正常。
| 现象 | 根因 | 解决 |
|---|---|---|
| ping 域名失败,ping IP 成功 | DNS 问题 | 跳到本章「DNS 解析」一节 |
curl 报 Connection refused |
目标端口没有服务在监听 | 去 4 层排查第 5 步:ss -lntp 看服务是否监听 |
| curl 卡在 TLS 握手后无响应 | 服务器时间错误导致证书校验失败 | timedatectl 检查时间,见本章时间同步一节 |
这是排错实战中出现率最高的场景:你从自己电脑 curl http://<服务器IP>:8080 连不上,但服务明明装了。原因只有三种可能,本节教你用两条命令把三种情况彻底区分开,不再靠猜。
先在服务器本机上测试(这一步必须在服务器上做,不要在自己电脑上):
# 测试本机 8080 端口是否有服务在响应
$ curl localhost:8080
# 用 netcat 精确测试端口连通性(-z 只扫端口不发数据,-v 显示过程)
$ nc -zv 127.0.0.1 8080
# 关键一步:看服务到底监听在哪个地址上
$ sudo ss -lntp | grep 8080
nc(netcat)在最小安装里同样需要安装:
# 安装 netcat
$ sudo apt install netcat-openbsd
三种情况的对照表(先在服务器本机测,再在自己电脑测,对比结果):
| 服务器本机 curl localhost | 自己电脑 curl <服务器IP> | ss -lntp 监听地址 |
结论 |
|---|---|---|---|
| 不通 | 不通 | 搜不到 8080 | 服务没起:去查服务状态和日志 |
| 通 | 不通 | 127.0.0.1:8080 |
只监听了本机回环:改服务配置绑到 0.0.0.0 |
| 通 | 不通 | 0.0.0.0:8080 |
防火墙/安全组拦截:查 ufw 和云安全组 |
nc -zv:-z 是 zero-I/O 模式,只探测端口开不开,不发送应用数据;-v 把结果打印出来。成功时输出 succeeded!,失败输出 refused 或超时。# 在服务器本机验证端口是否开放,并确认监听地址
$ nc -zv 127.0.0.1 8080
$ sudo ss -lntp | grep 8080
预期输出:
Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("myapp",pid=2301,fd=6))
nc 返回 succeeded、ss 显示监听在 0.0.0.0:8080,对应对照表第三行:如果外部仍连不上,就去查 ufw 和云安全组。
| 现象 | 根因 | 解决 |
|---|---|---|
nc 报 Connection refused |
端口上没有进程监听 | ss -lntp 确认服务是否启动、端口是否写对 |
| 本机通、外部不通、ss 显示 0.0.0.0 | ufw 或云安全组未放行 | sudo ufw status 查看;云控制台检查安全组规则 |
| 外部偶尔能连上偶尔超时 | 安全组只放行了部分来源 IP | 检查安全组授权对象是否包含你的出口 IP |
域名(如 example.com)和 IP 地址(如 93.184.216.34)之间的翻译工作由 DNS 完成。Ubuntu 22.04 用 systemd-resolved 服务统一管理 DNS:应用程序的解析请求先交给它,它再转发给真正的上游 DNS 服务器。理解这个架构,才能解释本节那个著名的坑。
# 安装 DNS 诊断工具(dig 和 nslookup 都在这个包里)
$ sudo apt install dnsutils
# 用 dig 查询域名对应的 IP(推荐,输出信息全)
$ dig example.com
# nslookup 更简单直观,适合快速确认
$ nslookup example.com
# 查看 systemd-resolved 的整体状态:当前用的哪个上游 DNS
$ resolvectl status
# 查看真实的 DNS 配置文件(注意它是个软链接)
$ cat /etc/resolv.conf
[!WARNING] 不要直接编辑
/etc/resolv.conf来「永久改 DNS」。 Ubuntu 22.04 上这个文件由 systemd-resolved 管理,是软链接,你的修改会在重启、systemd-resolved重载或 DHCP 续租时被静默覆盖回去。要持久修改 DNS,正确做法是改 netplan 的nameservers(见本章静态 IP 一节)或resolvectl dns 网卡名 <DNS-IP>(临时生效)。
dig 输出重点看两处:ANSWER SECTION 里的解析结果,和末尾 Query time(解析耗时)。status: NXDOMAIN 表示域名不存在,status: SERVFAIL 多为上游 DNS 故障。/etc/resolv.conf 里的 nameserver 127.0.0.53 就是 systemd-resolved 的本机代理地址,它不是真正的 DNS 服务器,真正的上游用 resolvectl status 查看。223.5.5.5、腾讯 119.29.29.29、谷歌 8.8.8.8(国内不稳)。# 验证 DNS 解析是否正常
$ dig example.com
预期输出:
; <<>> DiG 9.18.12 <<>> example.com
;; ANSWER SECTION:
example.com. 86400 IN A 93.184.216.34
;; Query time: 25 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
;; status: NOERROR
SERVER: 127.0.0.53 证明请求走了 systemd-resolved,NOERROR + ANSWER 记录说明解析成功。
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了 resolv.conf 过一会又变回去 | systemd-resolved 覆盖了手改内容 | 改 netplan 的 nameservers 后 sudo netplan apply |
dig 报 SERVFAIL 或超时 |
上游 DNS 不可用 | resolvectl status 看上游;临时换 resolvectl dns ens33 223.5.5.5 |
| 域名解析到内网假 IP | 运营商 DNS 劫持 | 换可信公共 DNS(223.5.5.5 / 119.29.29.29) |
主机名是机器的「名字」(如 web-01),日志、提示符、集群里互相识别都用它。/etc/hosts 则是一张本地手工解析表:系统查域名时先查它,查不到才去问 DNS。这个优先级是理解很多诡异地名问题的关键。
# 查看当前主机名与系统信息
$ hostnamectl
# 设置新主机名(把 <新主机名> 换成你想要的,如 web-01)
$ sudo hostnamectl set-hostname <新主机名>
# 编辑本地解析表,给新主机名配上 127.0.1.1,避免 sudo 报「无法解析主机」
$ sudo nano /etc/hosts
/etc/hosts 的典型内容:
127.0.0.1 localhost
127.0.1.1 web-01
192.168.1.20 db-01.internal
设置完主机名后,务必同步修改 /etc/hosts 里的 127.0.1.1 那行,否则每次执行 sudo 都会慢一下并报 sudo: unable to resolve host。
127.0.1.1 是 Debian/Ubuntu 系的习惯用法:给本机主机名一个回环地址,与 127.0.0.1(localhost)区分开。/etc/hosts > DNS。所以把 192.168.1.20 db-01.internal 写进 hosts 后,即使没有内网 DNS,ping db-01.internal 也能通。内网机器少时,用 hosts 管理互相访问是最简单的方案。web-01、db-01),多台服务器时日志里一眼可辨。# 确认主机名已生效,并验证 hosts 本地解析(不走 DNS,立即返回)
$ hostnamectl
$ ping -c 2 web-01
预期输出:
Static hostname: web-01
Icon name: computer-vm
Operating System: Ubuntu 22.04.5 LTS
PING web-01 (127.0.1.1) 56(84) bytes of data.
64 bytes from web-01 (127.0.1.1): icmp_seq=1 ttl=64 time=0.021 ms
| 现象 | 根因 | 解决 |
|---|---|---|
改完主机名,sudo 报 unable to resolve host |
/etc/hosts 里没有新主机名的记录 | 把 127.0.1.1 <新主机名> 加进 /etc/hosts |
| 提示符还显示旧主机名 | 当前 shell 是改之前开的 | 重新登录或执行 bash 刷新 |
| 域名指向了意料之外的 IP | /etc/hosts 里有历史残留记录 | grep 域名 /etc/hosts 检查并删除过期条目 |
Ubuntu Server 从 17.10 起用 netplan 管理网络配置:你写 YAML 描述「想要什么网络」,netplan 把它翻译给底层的 systemd-networkd 执行。配置文件在 /etc/netplan/ 目录下,Server ISO 安装的机器默认是 00-installer-config.yaml;云服务器(cloud-init 镜像)上常见的则是 /etc/netplan/50-cloud-init.yaml,由 cloud-init 生成,改前先 ls /etc/netplan/ 看清自己机器上是哪个。DHCP 是「自动获取 IP」,静态 IP 是「手动指定固定地址」——服务器通常必须用静态 IP,否则重启后 IP 一变,所有人都连不上它了。
先看清现状:
# 查看当前 netplan 配置(DHCP 模式长这样)
$ cat /etc/netplan/00-installer-config.yaml
network:
version: 2
renderer: networkd
ethernets:
ens33:
dhcp4: true
改成静态 IP 的完整写法(按你的实际环境替换地址、网卡名和网关):
network:
version: 2
renderer: networkd
ethernets:
ens33:
dhcp4: false
addresses:
- 192.168.1.10/24
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses: [223.5.5.5, 119.29.29.29]
# 应用配置(立即生效)
$ sudo netplan apply
# 更稳妥的方式:90 秒内不确认则自动回滚,适合改远程服务器
$ sudo netplan try
[!WARNING] YAML 对缩进极其敏感。 缩进必须用空格(不能有 Tab),同一层级的缩进格数必须一致。一个空格的差错都会导致
netplan apply直接报错或配置不生效。改完可先用sudo netplan --debug apply看详细报错。
[!WARNING] 修改远程服务器的 IP 配置前,必须先想好回退方案,否则改错一步就永久失联,SSH 再也连不上,只能靠云控制台的 VNC/串口登录进去救。动手前确认三件事:① 新 IP、网关、掩码与所在网络完全匹配(最好先在本地虚拟机演练一遍);② 备份原文件
sudo cp /etc/netplan/00-installer-config.yaml /etc/netplan/00-installer-config.yaml.bak;③ 优先用sudo netplan try——它会应用新配置并等待你确认,你不敲确认键就自动回滚旧配置,是远程改 IP 的救命机制。云服务器失联时的最后手段是控制台里的 VNC 登录。
| 字段 | 含义 | 注意 |
|---|---|---|
addresses: 192.168.1.10/24 |
IP + CIDR 掩码,/24 等价于 255.255.255.0 |
掩码写错会导致连不通网段 |
routes: to: default / via: |
默认网关 | 老写法 gateway4: 已废弃,新配置用 routes |
nameservers: addresses: |
DNS 服务器列表 | 持久改 DNS 的正确位置,见 DNS 一节的 WARNING |
dhcp4: false |
关闭 IPv4 自动获取 | 静态 IP 的前提;静态地址必须避开 DHCP 分配范围,防止 IP 冲突 |
# 确认新 IP、网关、DNS 都已生效
$ ip -br a && ip r
$ resolvectl status ens33
预期输出:
lo UNKNOWN 127.0.0.1/8 ::1/128
ens33 UP 192.168.1.10/24
default via 192.168.1.1 dev ens33
Link 2 (ens33)
DNS Servers: 223.5.5.5 119.29.29.29
| 现象 | 根因 | 解决 |
|---|---|---|
netplan apply 报 YAML 语法错误 |
缩进用了 Tab 或层级错位 | 全部改空格,对照本节示例逐行核对缩进 |
| 改完 netplan 后 SSH 失联 | IP/网关写错 | 云控制台 VNC 登录,还原 .bak 备份后 sudo netplan apply |
| 静态 IP 生效但上不了外网 | 网关或 DNS 没配对 | ip r 查 default 路由,resolvectl status 查 DNS |
| Ubuntu (netplan) | RHEL (NetworkManager/nmcli) | 说明 |
|---|---|---|
配置文件 /etc/netplan/*.yaml |
nmcli con mod 命令或 /etc/NetworkManager/system-connections/*.nmconnection |
RHEL 没有 netplan,配置方式完全不同 |
sudo netplan apply |
nmcli con up ens160 或 nmcli dev reapply ens160 |
应用配置 |
sudo netplan try |
无直接等价;可用 nmcli + 保留旧会话防失联 |
RHEL 远程改 IP 前同样要备好 VNC 退路 |
YAML 里写 nameservers |
nmcli con mod ens160 ipv4.dns "223.5.5.5" |
持久改 DNS |
服务器时间不准不是小事,它会直接弄坏服务:
所以每台服务器装完系统的第一件事之一,就是配置自动时间同步。Ubuntu 22.04 的标准方案是 timedatectl(查看/设置时区)+ chrony(持续校准时间)。
# 先看当前时间、时区、是否已开启 NTP 同步
$ timedatectl
# 设置时区为上海(北京时间,UTC+8)
$ sudo timedatectl set-timezone Asia/Shanghai
# 安装 chrony(Ubuntu 22.04 也可用系统自带的 systemd-timesyncd,chrony 精度与可控性更好)
$ sudo apt install chrony
# 启动并设置开机自启
$ sudo systemctl enable --now chrony
国内服务器建议把 chrony 的上游换成国内 NTP 源,编辑 /etc/chrony/chrony.conf,把默认 pool 行替换为:
pool ntp.aliyun.com iburst
pool ntp.tencent.com iburst
# 修改配置后重启服务
$ sudo systemctl restart chrony
timedatectl 输出里最关键的两行:Time zone(应为 Asia/Shanghai)和 System clock synchronized: yes(表示已同步成功)。iburst 表示启动时先快速连续发几个探测包,加快首次同步速度。09:00 和 01:00 当成两件事。# 确认时区与同步状态;再查看 chrony 的上游时间源
$ timedatectl
$ chronyc sources
预期输出:
Local time: Tue 2026-09-29 10:30:12 CST
Universal time: Tue 2026-09-29 02:30:12 UTC
Time zone: Asia/Shanghai (CST, +0800)
System clock synchronized: yes
NTP service: active
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^* 203.107.6.88 2 6 377 45 +12us[ +15us] +/- 18ms
^+ 42.192.0.10 2 6 377 44 -28us[ -25us] +/- 24ms
System clock synchronized: yes 表示已同步成功;chronyc sources 里 ^* 标记的行是当前正在使用的时间源,Reach 达到 377 表示通信稳定,^? 则表示该源不可达。
| 现象 | 根因 | 解决 |
|---|---|---|
| curl 报证书错误但证书没过期 | 系统时间偏差过大 | timedatectl 检查时间,配置 chrony 同步 |
chronyc sources 全是 ^? |
防火墙拦了 NTP(UDP 123)或上游不可达 | 放行出站 UDP 123;换国内 NTP 源 |
| 双系统/克隆出来的虚拟机时间漂移大 | 虚拟机时钟与宿主机不同步 | 装 chrony 后执行 sudo chronyc makestep 强制立即校准 |
| 命令 | 作用 |
|---|---|
ip a |
查看所有网卡详细地址 |
ip -br a |
简洁视图:网卡名、状态、IP 一行看清 |
ip r |
查看路由表与默认网关 |
sudo ss -lntp |
查看 TCP 监听端口及所属进程 |
ss -s |
socket 数量汇总统计 |
sudo ss -tnp | grep ESTAB |
查看已建立的连接 |
ping -c 4 <IP> |
测试连通性,4 次后自动停止 |
traceroute <IP> |
查看到目标经过的每一跳 |
mtr <IP> |
实时统计每跳丢包率(ping+traceroute) |
curl -I <URL> |
只看 HTTP 响应头,快速判断服务状态 |
curl -v <URL> |
显示完整请求过程,定位卡在哪一步 |
nc -zv <IP> <端口> |
精确测试某端口是否开放 |
dig <域名> |
查询 DNS 解析结果与耗时 |
resolvectl status |
查看 systemd-resolved 与上游 DNS |
sudo hostnamectl set-hostname <名> |
设置主机名 |
sudo netplan try |
应用 netplan 配置,不确认自动回滚 |
sudo netplan apply |
应用 netplan 配置(立即生效) |
timedatectl |
查看时间、时区、同步状态 |
sudo timedatectl set-timezone Asia/Shanghai |
设置时区为北京时间 |
chronyc sources |
查看 chrony 上游时间源状态 |
服务器一旦有公网 IP,从上线第一分钟起就会有扫描器试探 22、3306、6379 这些常见端口。防火墙就是你和它们之间的门。
[!NOTE] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
新手最常见的误解是:以为「配了 ufw 放行 8080」就等于「外网能访问 8080」。实际上请求要到达服务,必须连续通过三层关卡:
客户端请求
│
▼
┌───────────────────────────────┐
│ 第 1 层 云安全组(云控制台) │ 被挡:Connection timed out
├───────────────────────────────┤ 放行 ↓
│ 第 2 层 主机防火墙 ufw │ 被挡:timed out
├───────────────────────────────┤ 放行 ↓
│ 第 3 层 进程监听地址 │ 被挡:Connection refused
│ 0.0.0.0:8080 对外 │
│ 127.0.0.1:8080 只本机 │
└───────────────────────────────┘
│ 放行
▼
服务响应
[!IMPORTANT] 三层是「与」的关系,不是「或」。 三层全通才能连上,任一层挡住都不行。
| 层 | 在哪配置 | 谁负责 | 典型拦截表现 |
|---|---|---|---|
| 云安全组 | 云厂商控制台 | 云平台,流量到网卡前就过滤 | Connection timed out |
| 主机防火墙 | 服务器内 ufw / firewalld |
你自己,内核 netfilter 过滤 | Connection timed out |
| 进程监听地址 | 应用配置(bind / listen) |
应用程序自己 | Connection refused |
理解这个模型的价值:「端口不通」不再是玄学,而是一个可穷举的清单。绝大多数「明明开了防火墙还是访问不了」属于三种之一:安全组没开(包根本没进机房)、ufw 已启用但没放行该端口、程序只监听了 127.0.0.1。
[!IMPORTANT] 端口不通时严格按以下 4 步执行,每步排除一类原因。没走完 4 步之前不要改任何配置。
# 1. 进程是否真的在监听?注意地址是 127.0.0.1 还是 0.0.0.0
$ sudo ss -lntp | grep 8080
# 2. 本机自测,确认服务本身能响应
$ curl localhost:8080
# 3. 主机防火墙是否放行
$ sudo ufw status
# 4. 云安全组是否放行 —— 登录云控制台查看,服务器内查不到
| 步骤 1 | 步骤 2 | 步骤 3 | 结论 |
|---|---|---|---|
| 无输出 | — | — | 进程没起,先查服务(40) |
127.0.0.1 |
通 | 任意 | 监听地址不对,改绑 0.0.0.0 |
0.0.0.0 |
不通 | 任意 | 服务本身有问题,看应用日志 |
0.0.0.0 |
通 | 未放行 | 补 sudo ufw allow 8080 |
0.0.0.0 |
通 | 已放行 | 去查云安全组,九成问题在这 |
| 参数 | 全称 | 作用 |
|---|---|---|
-l |
listening | 只显示监听中的套接字 |
-n |
numeric | 不解析域名和服务名(否则显示 http 而非 80) |
-t |
tcp | 只看 TCP(看 UDP 换成 -u) |
-p |
process | 显示进程名和 PID(需 sudo) |
$ sudo ss -lntp | grep 8080
预期输出(监听所有网卡,上两层放行后外网可达):
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("java",pid=1234,fd=8))
预期输出(只监听回环,外网必然不通):
LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=1234,fd=8))
| 现象 | 根因 | 解决 |
|---|---|---|
| 防火墙全开,外网还是不通 | 进程只监听 127.0.0.1 |
改应用配置绑 0.0.0.0,或用 Nginx 反代 |
本机 curl 通、外网不通 |
安全组或 ufw 没放行 | 按 1 → 2 层顺序查 |
| ping 得通但端口不通 | 安全组只放了 ICMP | 控制台补业务端口规则 |
ufw(Uncomplicated Firewall)是 Ubuntu 自带的防火墙工具,本质是一个「写 iptables/nftables 规则的前端」,把复杂语法简化成 ufw allow 80 这样一句话。Ubuntu 22.04 上默认安装但未启用(inactive),即不拦截任何流量。
# 查看状态(未启用时显示 inactive)
$ sudo ufw status
# 详细状态:额外显示默认策略和日志开关
$ sudo ufw status verbose
# 带编号显示,删除规则时要用编号
$ sudo ufw status numbered
[!WARNING] 启用 ufw 前必须先放行 SSH,否则你会把自己永久锁在服务器外面。 这是云服务器第一号事故。 补救思路:登录云控制台,用 VNC / WebShell / 串口终端(各家叫法不同)进入服务器 —— 这个通道不走 22 端口,进去后执行
sudo ufw allow 22或sudo ufw disable即可恢复。 所以开新机器时,先确认控制台 VNC 能登录,再动防火墙。
# 第一步:放行 SSH(推荐后者,更语义化)
$ sudo ufw allow 22
$ sudo ufw allow OpenSSH
# 或只放行来自固定 IP 的 SSH(更安全)
$ sudo ufw allow from <你的IP> to any port 22
# 第二步:确认 22 已在规则里,再启用(重启后依然生效)
$ sudo ufw status
$ sudo ufw enable
| 命令 | 作用 |
|---|---|
ufw status |
查看规则与激活状态 |
ufw status verbose |
增加显示默认策略、日志级别 |
ufw status numbered |
给规则编号,供 ufw delete <编号> 使用 |
ufw enable / disable |
启用 / 停用(规则保留,只是不生效) |
ufw reload |
重新加载规则,不断开现有连接 |
$ sudo ufw status verbose
预期输出:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22 ALLOW IN Anywhere
22 (v6) ALLOW IN Anywhere (v6)
注意 sudo ufw allow 22 的规则显示为 22(不带协议后缀)——它同时放行 TCP 和 UDP 两个协议的 22 端口。显示成 22/tcp 的是 sudo ufw allow OpenSSH 或 sudo ufw allow 22/tcp 这类写法的输出,只放行 TCP。
| 现象 | 根因 | 解决 |
|---|---|---|
ufw enable 后 SSH 断线连不回 |
没先放行 22 | 云控制台 VNC 登录,sudo ufw allow 22 |
Status: inactive 但以为在保护 |
从未执行 enable |
先放行 SSH 再 enable |
| 重启后规则没了 | 只加规则没 enable | sudo ufw enable 写入开机自启 |
规则描述「允许/拒绝什么样的流量进来」,通常包含目标端口 + 协议 + 来源地址三部分。
# 放行 HTTP / HTTPS
$ sudo ufw allow 80
$ sudo ufw allow 443
# 只放行 TCP(推荐,Web 服务不需要 UDP)
$ sudo ufw allow 8080/tcp
# 放行端口范围
$ sudo ufw allow 6000:6010/tcp
# 只允许指定来源 IP 访问 22(SSH 强烈推荐)
$ sudo ufw allow from <你的IP> to any port 22
# 只允许内网网段访问数据库
$ sudo ufw allow from 10.0.0.0/8 to any port 3306
# 显式拒绝某端口 / 封某个 IP
$ sudo ufw deny 3306
$ sudo ufw deny from 203.0.113.66
删除规则:
# 方式一:按内容删除(写法必须与添加时完全一致)
$ sudo ufw delete allow 80
# 方式二:先编号,再按编号删(推荐,不会匹配错)
$ sudo ufw status numbered
$ sudo ufw delete 3
[!NOTE] 规则有先后顺序,先匹配的先生效。 前面已有
deny,后面再加allow也不会生效 —— 这就是「删除规则后仍不通」的真正原因:冲突的 deny 还在前面。
| 写法 | 含义 |
|---|---|
allow 80 |
放行 80,TCP + UDP 都放行 |
allow 80/tcp |
只放行 TCP,更精确 |
allow 6000:6010/tcp |
放行 6000~6010 共 11 个 TCP 端口 |
allow from <IP> |
只允许该来源,其余按默认策略处理 |
deny |
显式拒绝并记日志,优先级高于默认策略 |
$ sudo ufw status numbered
预期输出:
Status: active
To Action From
-- ------ ----
[ 1] 22/tcp ALLOW IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] 8080/tcp ALLOW IN Anywhere
[ 4] 6000:6010/tcp ALLOW IN Anywhere
[ 5] 3306 DENY IN Anywhere
| 现象 | 根因 | 解决 |
|---|---|---|
| 加了 allow 仍不通 | 前面有更优先的 deny | status numbered 看顺序,删掉冲突的 deny |
| 加了 allow 仍不通,规则没错 | Docker 直接写 iptables,绕过 ufw | 见下方 WARNING |
delete allow 80 说规则不存在 |
添加时写的是 80/tcp,删除没带协议 |
用编号删 |
| 允许了来源 IP,同事还是连不上 | 同事出口 IP 与配置的不同(NAT/多线路) | curl ifconfig.me 确认真实出口 IP |
[!WARNING] Docker 会绕过 ufw。 容器启动时 Docker 直接往 iptables 插入自己的规则,优先级高于 ufw 生成的链,结果
deny了某端口、容器的-p 8080:8080映射依然能被外网访问。 规避:容器只映射到127.0.0.1(-p 127.0.0.1:8080:8080)再用 Nginx 反代,不要依赖 ufw 管容器端口,详见 30-docker.md。
应用配置:部分软件(Nginx、OpenSSH)安装时会放一个描述文件声明自己用了哪些端口,于是可以写 ufw allow 'Nginx Full' 而不必记端口。
默认策略:对所有「未被任何规则匹配」的流量采取什么动作。
# 列出可用的应用配置,并查看某个配置包含哪些端口
$ sudo ufw app list
$ sudo ufw app info 'Nginx Full'
# 按应用名放行(名字带空格必须加引号)
$ sudo ufw allow 'Nginx Full'
# 默认策略:拒绝所有入站,允许所有出站
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
# 日志开关,日志写在 /var/log/ufw.log
$ sudo ufw logging on
# 重置:清空所有规则并停用
$ sudo ufw reset
[!IMPORTANT] 先
default deny incoming,再逐条allow需要的端口(白名单思路)。反过来「默认放行、只 deny 少数」迟早会漏。改默认策略前同样要确认 22 已放行。
[!WARNING]
sudo ufw reset会清空所有规则并把防火墙设为 inactive,包括放行 SSH 的那条。执行后不重新放行 22 就enable,等于把服务器锁在门外。只在规则彻底搞乱时用。
| 命令 | 作用 |
|---|---|
ufw app list |
列出所有应用配置名 |
ufw app info <名字> |
显示该配置包含的端口与协议 |
ufw default deny incoming |
未匹配的入站流量全部丢弃 |
ufw default allow outgoing |
服务器主动访问外网不受限 |
ufw logging on |
开启日志,记录在 /var/log/ufw.log |
deny incoming + allow outgoing 是推荐组合:别人进不来,我出去不受限。
$ sudo tail -n 5 /var/log/ufw.log
预期输出(关键字段:[UFW BLOCK] 表示拦截,SRC 来源 IP,DPT 目标端口):
Sep 29 10:22:31 ubuntu kernel: [UFW BLOCK] IN=eth0 OUT= SRC=203.0.113.66 DST=10.0.0.5 PROTO=TCP SPT=42122 DPT=3306 SYN URGP=0
| 现象 | 根因 | 解决 |
|---|---|---|
allow 'Nginx Full' 说找不到配置 |
没装 Nginx | sudo apt install nginx 后再看 |
| 应用配置的端口与实际不符 | 改过 Nginx 监听端口 | 直接用 ufw allow <端口>/tcp |
服务器访问外网失败(如 apt update) |
出站被改成了 deny | sudo ufw default allow outgoing |
RHEL / Rocky / AlmaLinux 9 默认用 firewalld,它比 ufw 多一层抽象:zone(区域)。zone 是一组规则的集合,网卡或来源 IP 被划到哪个 zone 就套用哪套规则(常用 public、internal、trusted、drop)。它还支持用 service(服务名) 代替端口号。
# 查看运行状态与当前 zone 的完整规则
$ sudo firewall-cmd --state
$ sudo firewall-cmd --list-all
# 按服务名放行
$ sudo firewall-cmd --permanent --add-service=http
$ sudo firewall-cmd --reload
# 按端口放行
$ sudo firewall-cmd --permanent --add-port=8080/tcp
$ sudo firewall-cmd --reload
# 把当前运行时规则一次性保存为永久规则
$ sudo firewall-cmd --runtime-to-permanent
# 移除规则
$ sudo firewall-cmd --permanent --remove-service=http
$ sudo firewall-cmd --reload
[!WARNING]
--permanent不加--reload不生效 —— firewalld 第一号坑:加了--permanent只写进配置文件,内核里的规则没变,服务依然访问不了。 反过来不加--permanent的规则立即生效,但重启后消失。生产环境统一写--permanent+--reload。
[!WARNING]
--panic-on会立刻切断所有网络连接(包括你当前的 SSH),只在被入侵需紧急隔离时使用。误执行后只能靠云控制台 VNC 执行sudo firewall-cmd --panic-off恢复。
| 参数 | 作用 |
|---|---|
--list-all |
显示默认 zone 的全部规则 |
--add-service=http |
按服务名放行(服务定义已含端口) |
--add-port=8080/tcp |
按端口号放行 |
--permanent |
写入配置文件永久生效,需配合 --reload |
--reload |
重载配置让永久规则生效,不中断现有连接 |
--runtime-to-permanent |
反向操作:把运行时规则保存到配置文件 |
$ sudo firewall-cmd --list-all
预期输出:
public (active)
target: default
interfaces: eth0
services: ssh http https
ports: 8080/tcp
forward: yes
| 现象 | 根因 | 解决 |
|---|---|---|
加了 --permanent 规则但端口不通 |
没执行 --reload |
sudo firewall-cmd --reload |
| 重启后规则失效 | 加规则时漏了 --permanent |
重新加,或用 --runtime-to-permanent |
| 同时装 ufw 和 firewalld,规则打架 | 两个工具都写 iptables | 只保留一个 |
| Ubuntu (apt/ufw) | RHEL (dnf/firewalld) | 说明 |
|---|---|---|
sudo apt install ufw |
sudo dnf install firewalld |
安装防火墙工具 |
sudo ufw enable |
sudo systemctl enable --now firewalld |
启用并开机自启 |
sudo ufw status |
sudo firewall-cmd --list-all |
查看当前规则 |
sudo ufw allow 80 |
sudo firewall-cmd --permanent --add-port=80/tcp && sudo firewall-cmd --reload |
放行端口 |
sudo ufw allow 'Nginx Full' |
sudo firewall-cmd --permanent --add-service=http |
按应用/服务放行 |
sudo ufw allow from <IP> to any port 22 |
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="<IP>" port port="22" protocol="tcp" accept' |
限定来源 IP |
sudo ufw delete allow 80 |
sudo firewall-cmd --permanent --remove-port=80/tcp |
删除规则 |
sudo ufw reload |
sudo firewall-cmd --reload |
重载生效 |
sudo tail /var/log/ufw.log |
sudo journalctl -u firewalld |
查看防火墙日志 |
iptables 是内核 netfilter 的用户态配置工具,规则按「表」和「链」组织:
filter(放行/丢弃,默认表)、nat(地址转换,Docker 端口映射用它)、mangle(改 TOS/TTL 等标记)、raw(连接跟踪前处理)PREROUTING(刚进网卡还没定去向)、INPUT(目标是本机)、FORWARD(转发给别人)、OUTPUT(本机发出)、POSTROUTING(即将离开网卡)[!NOTE] Ubuntu 22.04 底层已是 nftables,
iptables命令只是兼容层(iptables-nft),把 iptables 语法翻译成 nftables 规则。日常用 ufw 即可,不必深究。
# 查看 filter 表规则(-n 不解析域名,-v 显示命中计数)
$ sudo iptables -L -n -v
# 查看 nat 表(Docker 的规则都在这)
$ sudo iptables -t nat -L -n -v
# 查看 nftables 原生规则集
$ sudo nft list ruleset
[!WARNING] 不要混用多种防火墙工具。 ufw、firewalld、裸 iptables 本质都在改同一套内核规则,同时用会互相覆盖,行为完全不可预测。一台机器只保留一种:Ubuntu 用 ufw,RHEL 用 firewalld。
[!WARNING]
iptables -F(清空所有规则)是危险命令。 默认策略为DROP时执行它,你会立刻失去 SSH 连接;即使用 ufw,-F也会清掉 ufw 生成的链,让防火墙形同虚设。排错不要图省事执行-F。
| 参数 | 作用 |
|---|---|
-L |
list,列出规则 |
-n |
numeric,不解析域名(不加会很慢) |
-v |
verbose,显示命中数、接口 |
-t nat |
指定表名,默认 filter |
nft list ruleset |
查看 nftables 原生规则集 |
$ sudo iptables -L -n -v | head -n 5
预期输出(policy DROP + 一串 ufw- 链,正是 ufw 生成规则的证据,说明两者操作的是同一套机制):
Chain INPUT (policy DROP 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
120 7200 ufw-before-logging-input 0 -- * * 0.0.0.0/0 0.0.0.0/0
120 7200 ufw-before-input 0 -- * * 0.0.0.0/0 0.0.0.0/0
| 现象 | 根因 | 解决 |
|---|---|---|
输出里有大量 ufw- 链 |
ufw 已启用,规则由它生成 | 正常,统一用 ufw 管理 |
| 手动加的规则重启后没了 | 只存在于内存,未持久化 | 改用 ufw 或装 iptables-persistent |
| 容器端口 ufw 拦不住 | Docker 直接写 iptables,优先级更高 | 容器映射改用 127.0.0.1: |
防火墙不是「开了就安全」,核心原则是最小暴露面:能不对外开放的端口一律不开。
应该对外开放的:22(最好限来源 IP)、80(跳转 HTTPS)、443。
绝对不要对 0.0.0.0 开放的(弱认证或无认证,扫描器首要目标):3306 MySQL、5432 PostgreSQL、27017 MongoDB、6379 Redis、9200 Elasticsearch、11211 Memcached。
# 一(推荐):应用只监听 127.0.0.1,端口在公网上物理不可见
$ sudo ss -lntp | grep 3306
# 二:只对内网网段放行
$ sudo ufw allow from 10.0.0.0/8 to any port 3306
# 三:走 SSH 隧道,本地连 localhost:3306 即可
$ ssh -L 3306:127.0.0.1:3306 <用户名>@<服务器IP>
[!TIP] 能不放行就不放行,能限 IP 就限 IP。 每多开一个公网端口,就多一个需要长期打补丁的攻击面。临时远程访问数据库用 SSH 隧道,用完即关。
| 写法 | 含义 |
|---|---|
0.0.0.0 |
所有网卡,等同于对公网开放 |
127.0.0.1 |
只有本机,外网无法连接 |
10.0.0.0/8 |
内网网段(CIDR 写法) |
ssh -L |
本地端口转发,见 18 |
$ sudo ss -lntp | grep -E '3306|5432|27017|6379'
预期输出(安全,只监听本机):
LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=900,fd=21))
预期输出(危险,对公网开放,需立刻整改):
LISTEN 0 151 0.0.0.0:3306 0.0.0.0:* users:(("mysqld",pid=900,fd=21))
| 现象 | 根因 | 解决 |
|---|---|---|
| 数据库本机连得上,应用服务器连不上 | 监听在 127.0.0.1 |
改监听内网地址 + ufw 限内网 IP,或走 SSH 隧道 |
日志里大量 DPT=3306 的 BLOCK |
扫描器在爆破数据库 | 正常,说明防火墙拦住了;确认端口没对公网开 |
改了 bind 配置但 ss 结果没变 |
没重启服务 | sudo systemctl restart <服务> |
| 命令 | 作用 |
|---|---|
sudo ss -lntp | grep <端口> |
查看端口是否被监听、监听在哪个地址 |
curl localhost:<端口> |
本机自测,排除网络因素 |
sudo ufw status / verbose / numbered |
查看状态 / 含默认策略 / 带编号 |
sudo ufw allow 22 |
放行 SSH(启用 ufw 前必做) |
sudo ufw allow 80 / 443 / 8080/tcp |
放行 HTTP / HTTPS / 指定 TCP 端口 |
sudo ufw allow 6000:6010/tcp |
放行端口范围 |
sudo ufw allow from <IP> to any port 22 |
只允许指定 IP 访问 SSH |
sudo ufw deny <端口> |
显式拒绝某端口 |
sudo ufw delete <编号> |
按编号删除规则(推荐) |
sudo ufw enable / disable / reload |
启用 / 停用 / 重载 |
sudo ufw default deny incoming |
默认拒绝入站(白名单思路) |
sudo ufw app list / allow 'Nginx Full' |
列出应用配置 / 按应用放行 |
sudo ufw logging on + sudo tail -f /var/log/ufw.log |
开启并查看拦截日志 |
sudo ufw reset |
清空所有规则并停用(危险) |
sudo iptables -L -n -v |
查看底层规则(排错用) |
sudo firewall-cmd --list-all |
RHEL:查看 firewalld 规则 |
sudo firewall-cmd --permanent --add-port=8080/tcp && sudo firewall-cmd --reload |
RHEL:永久放行端口并生效 |
ip / ss 详解、DNS、netplan在 Linux 上装软件,第一课不是记住 apt install,而是理解为什么不能像 Windows 那样去官网下载安装包双击打开 —— 因为依赖。一个 Web 服务可能依赖几十个库,每个库又有自己的依赖,手工下载意味着自己解决全部依赖。包管理器把这件事自动化了。
本章三件事:包是怎么来的(仓库与源)、怎么装查删(apt / dpkg)、怎么安全地加第三方源。
软件供应链只有三步:仓库(远端放 .deb 包的地方)→ 索引(本地缓存的包清单)→ 安装(按索引下载并补齐依赖)。apt update 拉索引,apt search 查索引,apt install 按索引下载安装。
| 概念 | 说明 |
|---|---|
| 仓库(源) | 存放 .deb 包的远端服务器。Ubuntu 官方源、Nginx 源、Docker 源都是仓库 |
| 索引 | 仓库里所有包的清单,apt update 把它下载到 /var/lib/apt/lists/ |
| 依赖关系 | A 包运行需要 B 包,apt 自动算出并装上 B —— 这是它最大的价值 |
| 候选版本 | 当前索引里 apt install 会装上的那个版本 |
源配置放在两处:/etc/apt/sources.list(主配置)与 /etc/apt/sources.list.d/*.list(推荐,每个第三方源一个文件)。分文件放便于管理:不想要某个源就删那一个文件,不会误伤系统源 —— 系统源一旦被破坏,apt 就再也装不了任何东西。
一行源配置的写法:deb [arch=amd64 signed-by=/etc/apt/keyrings/example.gpg] https://example.com/repo jammy main,依次是类型、可选项(架构 + 验签公钥)、仓库地址、发行版代号、组件名,各字段含义见下方参数说明。
[!NOTE] 为什么
apt install前要先apt update? 因为apt只认本地缓存那份索引。索引是上周拉的就只知道上周的包,你刚添加的源它完全不知道 —— 这是Unable to locate package最常见的根因。 Ubuntu 22.04 也支持 deb822 格式(.sources后缀),把一行拆成多行键值对,两种格式可共存:Types: deb URIs: https://example.com/repo Suites: jammy Components: main Signed-By: /etc/apt/keyrings/example.gpg大多数第三方官方文档仍给
.list格式,会用一种即可。
# 查看主源配置与第三方源文件
$ cat /etc/apt/sources.list
$ ls -1 /etc/apt/sources.list.d/
# 停用某个源:改后缀名即可(apt 只认 .list / .sources)
$ sudo mv /etc/apt/sources.list.d/nginx.list /etc/apt/sources.list.d/nginx.list.disabled
$ sudo apt update
# 完整安装流程:拉索引 → 搜包名 → 看版本 → 安装
$ sudo apt update
$ apt search nginx
$ apt policy nginx
$ sudo apt install nginx
[!WARNING] 不要贸然替换默认
sources.list。 网上的「一键换源」教程让你整份覆盖,风险是第三方源(Docker、Nginx、PGDG)地址与镜像站目录结构不一致,混用后apt update大面积报 404,严重时连系统包都装不上。镜像源是可选加速手段,要换也只替换 Ubuntu 官方那几行,见 33 国内镜像加速。改之前先备份:# 备份原始源配置,出问题可直接还原 $ sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak
| 字段 / 术语 | 作用 |
|---|---|
deb |
二进制包源(日常用的就是这个) |
deb-src |
源码包源,一般注释掉以加快 apt update |
arch=amd64 |
限定架构;可用 $(dpkg --print-architecture) 自动取值 |
signed-by=<路径> |
指定该源用哪个 GPG 公钥验签(现代推荐写法) |
jammy |
Ubuntu 22.04 代号,必须与实际系统一致 |
main |
组件名;官方源还有 universe / restricted / multiverse |
# 查看系统版本代号,添加源时要用到
$ lsb_release -a
预期输出:
Distributor ID: Ubuntu
Description: Ubuntu 22.04.5 LTS
Release: 22.04
Codename: jammy
| 现象 | 根因 | 解决 |
|---|---|---|
Unable to locate package xxx |
没 apt update,或源里没有该包 |
先 sudo apt update,再 apt search 确认 |
apt update 报 404 |
发行版代号写错(如写成 focal) |
改回 jammy 或用 $(lsb_release -cs) |
| 装完发现版本很旧 | 用的是系统默认源 | 加官方源,见 91 版本速查 |
三个命令常被混为一谈,做的事完全不同:
| 命令 | 做的事 | 会删包吗 |
|---|---|---|
apt update |
只刷新本地索引(拉最新包清单) | 不会 |
apt upgrade |
按新索引升级已装的包 | 不会 |
apt full-upgrade |
升级,并允许为完成依赖而删包 | 可能会 |
[!TIP]
apt update像刷新应用商店的「可更新列表」,只告诉你有什么新版本;真正下载安装是upgrade的事。所以apt update是安全的,可以随便执行。
# 刷新索引
$ sudo apt update
# 先看会动哪些包(干跑,不实际执行)
$ sudo apt upgrade --dry-run
# 升级所有可升级的包
$ sudo apt upgrade
# 只升级某一个包 / 查看可升级列表
$ sudo apt install --only-upgrade nginx
$ apt list --upgradable
# 彻底升级(可能删包,谨慎)
$ sudo apt full-upgrade
# 清理孤立依赖与过期缓存
$ sudo apt autoremove
$ sudo apt autoclean
[!WARNING]
apt full-upgrade可能删除软件包。 执行前务必看--dry-run输出里有没有Remv(Remove)开头的行;若出现Remv mysql-server这类关键包,绝对不要执行。数据库大版本升级另见 91。
| 参数 | 作用 |
|---|---|
--dry-run / -s |
模拟执行,只显示会发生什么(升级前必做) |
-y |
自动回答 yes,适合脚本;手动执行时不加,留一次确认机会 |
--only-upgrade |
配合 install,只升级不新装 |
apt clean |
清空 /var/cache/apt/archives/ 下所有缓存 |
apt autoclean |
只清理已过期的旧版本缓存(比 clean 温和,推荐) |
apt autoremove |
删除作为依赖装上、如今无人依赖的包 |
$ apt list --upgradable
预期输出:
Listing... Done
nginx/jammy-updates 1.18.0-6ubuntu14.5 amd64 [upgradable from: 1.18.0-6ubuntu14.4]
openssl/jammy-updates 3.0.2-0ubuntu1.19 amd64 [upgradable from: 3.0.2-0ubuntu1.18]
没有可升级包时只输出 Listing... Done。
| 现象 | 根因 | 解决 |
|---|---|---|
The following packages have been kept back |
升级它需要装新包或删包,upgrade 不敢做 |
确认无风险后再用 apt full-upgrade 或 apt install <包> |
apt update 报 NO_PUBKEY XXXX |
源的公钥没导入或过期 | 重新导入公钥到 /etc/apt/keyrings/ |
| 升级中断网,包处于半装状态 | dpkg 状态不一致 | sudo dpkg --configure -a 再 sudo apt -f install |
安装会连带依赖一起装;卸载分两种:remove 保留配置文件,purge 连配置一起删。区别很重要:remove 后重装,旧配置还在(可能带着错误配置重新出问题);purge 则彻底干净。
# 安装单个 / 多个包
$ sudo apt install nginx
$ sudo apt install nginx curl vim git
# 装指定版本
$ sudo apt install nginx=1.18.0-6ubuntu14.4
# 不装推荐的附属包(镜像瘦身常用)
$ sudo apt install --no-install-recommends nginx
# 重装,修复被误删的文件
$ sudo apt install --reinstall nginx
# 卸载保留配置 / 连配置一起删 / 删孤立依赖
$ sudo apt remove nginx
$ sudo apt purge nginx
$ sudo apt autoremove
[!WARNING]
apt purge会删除配置文件、apt autoremove会删除包,两者都不可逆。 数据库、Nginx 这类有重要配置的服务,purge前先备份/etc/<服务名>/和数据目录;autoremove前务必看一遍它列出的清单 —— 偶尔会有你还在用的包被判定为「无人依赖」而误删。
| 参数 | 作用 |
|---|---|
install |
安装,已装则升级 |
remove |
卸载程序文件,保留配置文件 |
purge |
卸载程序文件 + 删除配置文件 |
autoremove |
删除作为依赖装上、现已被孤立的包 |
--reinstall |
重新安装同版本,修复被误删的文件 |
-f install |
修复依赖关系,不是安装某个包 |
# 查看安装状态(前两位是状态字符,含义见下)
$ dpkg -l | grep nginx
预期输出:
ii nginx 1.18.0-6ubuntu14.4 amd64 small, powerful, scalable web/proxy server
ii 表示已安装且配置完成;rc = 已 remove 但配置还在;iU = 已解包但未完成配置(安装被中断);un = 从未安装。
| 现象 | 根因 | 解决 |
|---|---|---|
| 卸载后重装,旧的错误配置还在 | 用的是 remove 不是 purge |
sudo apt purge <包> 后重装 |
dpkg -l 显示 iU,装什么都报错 |
上次安装被中断 | sudo dpkg --configure -a |
autoremove 想删掉我还在用的包 |
它被判定为无人依赖 | 先 apt-mark manual <包> 标记为手动安装 |
| 装完命令找不到 | 包名 ≠ 命令名,或不在 PATH | dpkg -L <包> | grep bin 看装到哪 |
查询类命令是排错和选版本的主力。其中 apt policy 最重要 —— 它回答三个问题:装了没?装了哪个版本?还能装哪个版本?
# 按关键字搜索包(搜包名和描述)
$ apt search nginx
# 查看包的详细信息:版本、依赖、描述、体积
$ apt show nginx
# 查看已装版本、候选版本及来源(最重要)
$ apt policy nginx
# 列出源里所有可用版本
$ apt-cache madison nginx
# 列出已安装 / 可升级的包
$ apt list --installed
$ apt list --upgradable
[!TIP] 装任何软件之前,先执行
apt policy <包名>。 这是本章最实用的习惯:它让你在动手前就发现「源里只有 1.18 而我要 1.30」,避免装错版本再卸载重装。版本选错的代价很大 —— 装旧版、写到一半发现不支持某指令、卸载重装、配置还得改一遍。
| 命令 | 输出内容 |
|---|---|
apt search <关键字> |
包名 + 一句话描述,忘记完整包名时用 |
apt show <包> |
版本、优先级、依赖、维护者、描述、体积 |
apt policy <包> |
Installed / Candidate / 版本表(含来源与优先级) |
apt-cache madison <包> |
所有可用版本的紧凑列表,装特定历史版本时用 |
apt list --installed |
所有已安装包及版本,盘点机器用 |
apt policy 输出里的 500 是优先级;同一包在多个源都有时,apt 选优先级高的版本。
$ apt policy nginx
预期输出(未安装时):
nginx:
Installed: (none)
Candidate: 1.18.0-6ubuntu14.4
Version table:
1.18.0-6ubuntu14.4 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages
已安装时 Installed 显示版本号,且版本表中当前版本前带 *** 标记。Candidate 就是 apt install 会装的版本。
| 现象 | 根因 | 解决 |
|---|---|---|
| Candidate 不是我想要的版本 | 有多个源,优先级高的那个较旧 | 加官方源,或 apt install <包>=<版本> |
apt search 搜不到明明存在的包 |
没 apt update 或包名不完整 |
apt update 后换关键字 |
| 想装的版本源里没有 | 系统源版本太旧 | 加官方源 / PPA / 源码编译,见 41 |
apt-mark hold 给包打上「冻结」标记,之后 apt upgrade 会跳过它,即使有更新版本也不动。
为什么需要?有些服务不能随便升:MySQL 8.0 与 8.4 数据格式有差异,Redis 6 与 8 配置项不同。一次无心的 apt upgrade 把数据库升了大版本,可能直接导致服务起不来。
[!IMPORTANT] 数据库、消息队列等有状态服务,上线后一律
apt-mark hold。 升级必须经过计划窗口、备份和验证,绝不能作为日常apt upgrade的副产品发生。
# 冻结单个 / 多个包
$ sudo apt-mark hold mysql-server
$ sudo apt-mark hold mysql-server redis-server nginx
# 查看所有被冻结的包
$ apt-mark showhold
# 解冻
$ sudo apt-mark unhold mysql-server
需要升级时的标准流程:备份数据 → 解冻 → --dry-run 确认 → 升级并验证 → 重新冻结。
$ sudo apt-mark unhold mysql-server
$ sudo apt upgrade --dry-run
$ sudo apt install --only-upgrade mysql-server
$ sudo apt-mark hold mysql-server
| 命令 | 作用 |
|---|---|
apt-mark hold <包> |
标记「保持」,阻止被 upgrade / autoremove 影响 |
apt-mark unhold <包> |
取消标记,恢复正常升级 |
apt-mark showhold |
列出所有被冻结的包 |
apt-mark manual <包> |
标记为「手动安装」,防止被 autoremove 删掉 |
[!NOTE]
hold只挡apt,不挡你手动apt install <包>=<新版本>。它是「防误操作」而不是「防人为升级」。
# 冻结后确认:upgrade 干跑时该包应被标记为「已保持」
$ sudo apt upgrade --dry-run
预期输出(节选):
The following packages have been kept back:
mysql-server
| 现象 | 根因 | 解决 |
|---|---|---|
冻结了但 apt upgrade 还是升了 |
apt 正常遵守 hold(dpkg selections),不会主动升级被冻结的包——被升多半是显式执行了 apt install <包>=<新版>、unattended-upgrades 的自动更新,或上次 unhold 升级后忘记重新 hold |
apt-mark showhold 确认 hold 状态;升级窗口结束后重新 apt-mark hold <包> |
| 冻结后安全补丁也装不上 | hold 会挡住所有升级 | 定期找窗口 unhold → 升级 → 重新 hold |
| 想冻结整个系列的所有包 | 一个个 hold 太麻烦 | dpkg -l | grep mysql | awk '{print $2}' 批量取包名 |
apt 是高层工具(处理依赖、联网下载),dpkg 是底层工具(只管解包、不管依赖)。apt install 内部就是先下载 .deb,再调用 dpkg -i 真正安装。
直接用 dpkg 的场景:装离线下载的 .deb,以及反查某个文件属于哪个包这类排错。
# 安装本地 .deb(不解决依赖)
$ sudo dpkg -i nginx_1.18.0-6ubuntu14.4_amd64.deb
# dpkg -i 报依赖错误时的修复命令
$ sudo apt -f install
# 列出某个包安装的所有文件
$ dpkg -L nginx
# 反查某个文件 / 命令属于哪个包(排错神器)
$ dpkg -S /usr/sbin/nginx
$ dpkg -S $(which ss)
# 查看已安装状态 / 卸载
$ dpkg -l | grep nginx
$ sudo dpkg -r nginx
[!TIP]
dpkg -S /path/to/file是本章最值得记住的排错命令。 看到「某个配置文件莫名被改」「某个命令版本不对」时,用它一秒定位是哪个包提供的文件。若提示不属于任何包,说明该文件是手工放的或脚本生成的,排错方向立刻清楚。
| 参数 | 作用 |
|---|---|
-i |
安装本地 .deb(依赖问题需再执行 apt -f install) |
-L <包> |
列出该包安装到系统的所有文件路径 |
-S <路径> |
反查该文件属于哪个包 |
-l |
列出所有已安装包(配合 grep 最常用) |
-r / -P |
卸载保留配置 / 连配置一起删 |
--configure -a |
配置所有未完成配置的包(安装中断后的修复) |
# 反查可执行文件属于哪个包
$ dpkg -S /usr/sbin/nginx
预期输出:
nginx-core: /usr/sbin/nginx
| 现象 | 根因 | 解决 |
|---|---|---|
dpkg -i 报 dependency problems |
dpkg 不管依赖 | sudo apt -f install 自动补齐 |
Could not get lock /var/lib/dpkg/lock |
另一个 apt/dpkg 正在运行 | 等它结束,不要删锁文件,见下方 WARNING |
dpkg -S 提示 no path found |
该文件是手工创建或脚本生成 | 说明不属于任何包,去查部署脚本 |
dpkg -i 装完服务没起来 |
正常系统上 .deb 的 postinst 脚本会调用 systemctl 自启;容器/chroot 环境中 policy-rc.d 会阻止安装时自启 |
容器/chroot 中需手动 systemctl enable --now <服务> |
[!WARNING] 报
Could not get lock /var/lib/dpkg/lock时不要直接删锁文件。 它的含义是:有另一个apt/dpkg进程正在操作包数据库(很可能是后台的unattended-upgrades)。 正确做法是等它跑完(几十秒到几分钟),或ps aux \| grep -E 'apt\|dpkg'看是谁在跑。直接rm锁文件会让两个进程同时改包数据库,极可能把 dpkg 状态搞坏到需要重装系统才能恢复。
系统源里的版本往往偏旧(22.04 源里的 Nginx 是 1.18,官方已到 1.30),要装新版就得加官方源。加源要解决两个问题:从哪下载(URL)和怎么确认没被篡改(GPG 签名)—— 仓库用私钥对索引签名,你的机器用公钥验签,这就是 signed-by 的作用。
其他安装方式:PPA(Ubuntu 特有的第三方源托管,个人维护)、snap(Canonical 的容器化包格式,自带依赖自动更新,certbot 官方推荐)、flatpak(偏桌面,服务器上少见)、源码编译(见 41)。
完整模板(照抄改名字即可):
# 下载 GPG 公钥到专用目录(不要用 apt-key)
$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://example.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/example.gpg
$ sudo chmod a+r /etc/apt/keyrings/example.gpg
# 添加源,用 signed-by 指定该源对应的公钥
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/example.gpg] https://example.com/repo jammy main" | sudo tee /etc/apt/sources.list.d/example.list
$ sudo apt update
[!IMPORTANT]
apt-key add已废弃,不要再使用。 老教程里的curl ... \| sudo apt-key add -会让该公钥对系统上所有源都生效 —— 一个来路不明的第三方源就能伪造系统包,这是严重安全漏洞。现代写法是公钥放/etc/apt/keyrings/并用signed-by=指定,做到一源一钥、互不影响。22.04 上用apt-key add会直接输出废弃警告。
# PPA(以 deadsnakes 为例)
$ sudo add-apt-repository ppa:deadsnakes/ppa
$ sudo apt update
# 移除 PPA
$ sudo add-apt-repository --remove ppa:deadsnakes/ppa
# snap 安装(certbot 官方推荐方式)
$ sudo snap install certbot --classic
[!WARNING] 第三方 PPA 有破坏系统的风险。 PPA 由个人维护,没有严格审核与兼容性保障,可能提供与系统包同名但不兼容的版本,一次
apt upgrade就把关键库替换掉,导致系统工具甚至 apt 本身崩溃。原则:优先用软件官方 apt 源;没有官方源才考虑 PPA;生产环境尽量不用。
[!NOTE] 用
echo ... \| sudo tee而不是sudo echo ... >的原因:重定向>由 shell 执行,sudo只提升了echo的权限,写文件那一步仍是普通用户权限,会报 Permission denied。tee是被提权的进程,由它写文件就没问题。
| 片段 | 作用 |
|---|---|
install -m 0755 -d |
创建目录并设权限,-d 表示目录 |
curl -fsSL |
-f 失败不输出错误页、-s 静默、-S 出错显示、-L 跟重定向 |
gpg --dearmor |
把 ASCII 装甲格式(.asc)转成二进制 .gpg |
$(dpkg --print-architecture) |
自动填当前架构(amd64/arm64) |
signed-by= |
指定该源用哪个公钥验签,现代标准写法 |
snap install --classic |
放宽沙箱限制,允许访问系统文件 |
# 确认源已生效(公钥路径正确则会列出来)
$ apt-cache policy
预期输出(节选,出现在 Package files 列表里说明源生效):
500 https://example.com/repo jammy/main amd64 Packages
release o=Example,a=jammy,n=jammy,l=Example,c=main,b=amd64
公钥文件还需可读:ls -l /etc/apt/keyrings/example.gpg 应显示 -rw-r--r--。源出现在列表里且 apt update 没报 NO_PUBKEY,说明源和公钥都配好了。
| 现象 | 根因 | 解决 |
|---|---|---|
NO_PUBKEY ABCDEF1234567890 |
公钥没导入或 signed-by 路径写错 |
重新下载公钥并修正 signed-by |
apt-key is deprecated 警告 |
用了废弃命令 | 改用 signed-by 写法 |
gpg: no valid OpenPGP data found |
下载到的不是公钥(可能是 404 页面) | 浏览器打开该 URL 确认内容 |
add-apt-repository: command not found |
未装相关包 | sudo apt install software-properties-common |
| PPA 加完系统包被升级坏 | PPA 提供了冲突版本 | ppa-purge ppa:xxx 或移除该源并降级 |
| Ubuntu (apt/dpkg) | RHEL (dnf/rpm) | 说明 |
|---|---|---|
sudo apt update |
sudo dnf check-update |
刷新索引 |
sudo apt install nginx |
sudo dnf install nginx |
安装包 |
sudo apt remove nginx |
sudo dnf remove nginx |
卸载(保留配置) |
sudo apt purge nginx |
sudo dnf remove nginx && sudo rm -rf /etc/nginx |
连配置一起删(dnf 无 purge) |
sudo apt upgrade |
sudo dnf upgrade |
升级所有包 |
sudo apt autoremove |
sudo dnf autoremove |
清理孤立依赖 |
apt search <关键字> |
dnf search <关键字> |
搜索包 |
apt show <包> |
dnf info <包> |
查看包信息 |
apt policy <包> |
dnf list --showduplicates <包> |
查看可用版本 |
apt list --installed |
dnf list installed / rpm -qa |
列出已安装 |
dpkg -L <包> |
rpm -ql <包> |
列出包装了哪些文件 |
dpkg -S /path/file |
rpm -qf /path/file |
反查文件属于哪个包 |
dpkg -i xxx.deb |
dnf install ./xxx.rpm |
装本地包(dnf 自动处理依赖) |
apt-mark hold <包> |
dnf versionlock add <包> |
冻结版本 |
/etc/apt/sources.list.d/*.list |
/etc/yum.repos.d/*.repo |
第三方源位置 |
| 命令 | 作用 |
|---|---|
sudo apt update |
刷新软件索引(不装任何东西) |
sudo apt upgrade |
升级已装包(不删包) |
sudo apt full-upgrade |
升级并允许删包(谨慎) |
sudo apt install <包> |
安装包(自动处理依赖) |
sudo apt install <包>=<版本> |
安装指定版本 |
sudo apt remove <包> |
卸载,保留配置文件 |
sudo apt purge <包> |
卸载并删除配置文件 |
sudo apt autoremove / autoclean |
删孤立依赖 / 清过期缓存 |
apt show <包> |
查看包详细信息 |
apt policy <包> |
查看已装/候选版本及来源(装前必查) |
apt-cache madison <包> |
列出源里所有可用版本 |
apt list --installed / --upgradable |
列出已安装 / 可升级的包 |
sudo apt-mark hold <包> |
冻结版本,禁止被 upgrade |
apt-mark showhold |
查看被冻结的包 |
sudo dpkg -i xxx.deb |
装本地 .deb(依赖问题用 apt -f install 修) |
dpkg -L <包> |
列出该包安装的所有文件 |
dpkg -S /path/file |
反查文件属于哪个包(排错神器) |
dpkg -l | grep <关键字> |
查看已安装包及状态 |
sudo apt -f install |
修复依赖/中断的安装 |
sudo add-apt-repository ppa:xxx |
添加 PPA(有风险) |
云服务器没有显示器、没有键盘,你与它之间唯一的通道就是 SSH。这意味着:SSH 不通,你就彻底失去这台机器;同时 SSH 是攻击者最先盯的入口 —— 公网上每台机器的 22 端口每天都会被扫描爆破成千上万次。
本章做三件事:改用密钥登录、收紧服务端配置、装 fail2ban 自动封禁爆破者。
[!NOTE] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
[!WARNING] 本章所有操作都可能让你把自己锁在服务器外面。两条铁律:① 任何禁用密码登录、改端口的操作,必须先在另一个终端窗口验证新方式能登录成功;② 操作前确认你能在云控制台用 VNC / WebShell / 串口终端登录 —— 这是最后一条退路。
SSH(Secure Shell)是加密的远程登录协议,取代了明文传输的 telnet。它解决三个问题:密码不被窃听(全程加密)、确认连的是真服务器(首次连接校验指纹)、命令不被篡改(完整性校验)。
密钥登录是核心机制,比密码安全得多:私钥留在你电脑、从不在网络上传输,服务器只用公钥验证你的签名;而密码登录时密码本身在传输,且可以被无限次猜。
# 基本登录(不指定用户名则用本地当前用户名)
$ ssh <用户名>@<服务器IP>
# 指定非 22 端口
$ ssh -p 2222 <用户名>@<服务器IP>
首次连接会提示确认服务器指纹 —— 这不是多此一举,它防止有人冒充你的服务器:
$ ssh <用户名>@<服务器IP>
The authenticity of host '<服务器IP>' can't be established.
ED25519 key fingerprint is SHA256:aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '<服务器IP>' (ED25519) to the list of known hosts.
输入 yes 后指纹记入本地 ~/.ssh/known_hosts,以后不再提示。
[!IMPORTANT] 第一次连接应当核对指纹:在云控制台 VNC 里执行
sudo ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub拿到真实指纹比对,对不上就立刻中止连接。 Windows 客户端:Windows 10/11 自带 OpenSSH,在 Windows Terminal 或 PowerShell 里直接用ssh命令;图形界面可选 PuTTY(它用.ppk私钥,需 PuTTYgen 转换)。
[!NOTE] Ubuntu 上 SSH 服务名是
ssh,不是sshd。systemctl restart sshd会报Unit sshd.service not found,RHEL 系才叫sshd;但进程名两边都是sshd,所以ss -lntp里看到的是sshd。
| 参数 | 作用 |
|---|---|
-p <端口> |
指定端口,默认 22(小写 p) |
-i <私钥路径> |
指定用哪个私钥登录 |
-v / -vv / -vvv |
输出详细握手过程,排错神器 |
~/.ssh/known_hosts |
客户端记录已信任主机指纹的文件 |
~/.ssh/id_ed25519 |
私钥,权限必须 600,绝不外传 |
~/.ssh/id_ed25519.pub |
公钥,要放到服务器上 |
$ sudo systemctl status ssh
预期输出(节选,关键看 active (running) 和 enabled):
● ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/lib/systemd/system/ssh.service; enabled; vendor preset: enabled)
Active: active (running) since Tue 2026-09-29 10:00:00 CST; 1h ago
| 现象 | 根因 | 解决 |
|---|---|---|
Unit sshd.service not found |
Ubuntu 服务名是 ssh |
用 systemctl status ssh |
| SSH 服务没装 | 最小化镜像可能没装 | sudo apt install openssh-server |
REMOTE HOST IDENTIFICATION HAS CHANGED |
服务器重装,指纹变了 | ssh-keygen -R <服务器IP>(确认确实重装过再删) |
Connection timed out |
安全组或防火墙没放行 | 见 16 防火墙 |
密钥对由一个私钥(留你电脑,绝不外传)和一个公钥(放服务器)组成。算法选择:ed25519(推荐,现代算法,256 位强度约等于 RSA 3072,密钥短速度快)、rsa(兼容老设备,需 -b 4096)、ecdsa(一般,不推荐)。
在你的本地电脑上执行(不是服务器上):
# 生成 ed25519 密钥对
$ ssh-keygen -t ed25519 -C "your_email@example.com"
# 老设备兼容时用 RSA 4096
$ ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
# 指定文件名(多密钥管理时区分公司/个人)
$ ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_work -C "work"
# 查看公钥内容(这个要放到服务器)
$ cat ~/.ssh/id_ed25519.pub
[!TIP] 建议给私钥设置 passphrase,这样私钥泄露攻击者没有口令也用不了。嫌每次输麻烦就用
ssh-agent记住:eval "$(ssh-agent -s)"然后ssh-add ~/.ssh/id_ed25519。空口令私钥等于「谁拿到文件谁就能登录」。
推送公钥到服务器:
# 推荐:一条命令完成建目录 + 追加公钥 + 设权限
$ ssh-copy-id <用户名>@<服务器IP>
# 端口不是 22 时
$ ssh-copy-id -p 2222 -i ~/.ssh/id_ed25519.pub <用户名>@<服务器IP>
# 客户端没有 ssh-copy-id 时(Windows PowerShell 等)
$ cat ~/.ssh/id_ed25519.pub | ssh <用户名>@<服务器IP> "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
[!IMPORTANT] 权限必须正确,否则 SSH 会「静默」拒绝密钥登录 —— 客户端只看到
Permission denied (publickey),服务端日志里才有权限报错。这是密钥登录失败最常见的原因:# 在服务器上修正:目录 700、文件 600、属主是该用户 $ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys $ chown -R <用户名>:<用户名> ~/.ssh另外追加公钥必须用
>>,用>会覆盖掉已有的其他公钥,可能把同事或你的另一台机器锁在外面。
验证:新开一个终端窗口测试,不要关掉当前的密码登录会话。
$ ssh <用户名>@<服务器IP>
| 参数 / 路径 | 作用 |
|---|---|
-t ed25519 |
指定密钥类型 |
-b 4096 |
指定位数(RSA 用;ed25519 固定长度) |
-C "注释" / -f <路径> |
添加注释(邮箱或用途)/ 指定输出文件名 |
ssh-copy-id -i <公钥> |
指定要推送的公钥(注意是 .pub) |
~/.ssh/authorized_keys |
服务器上的授权公钥文件,一行一个 |
# 在服务器上检查权限
$ ls -ld ~/.ssh && ls -l ~/.ssh/authorized_keys
预期输出:
drwx------ 2 <用户名> <用户名> 4096 Sep 29 10:05 /home/<用户名>/.ssh
-rw------- 1 <用户名> <用户名> 96 Sep 29 10:05 /home/<用户名>/.ssh/authorized_keys
| 现象 | 根因 | 解决 |
|---|---|---|
Permission denied (publickey) |
权限不对 / 公钥没写进去 / 用户名不匹配 | 检查 700、600 权限与 authorized_keys 内容 |
| 密钥登不上但密码可以 | 公钥没正确写入或权限不对 | 用密码登录后按上面 IMPORTANT 修正 |
Permissions 0644 for 'id_ed25519' are too open |
客户端私钥权限太宽 | chmod 600 ~/.ssh/id_ed25519 |
| 换了用户名登录就不认密钥 | 每个用户有自己的 authorized_keys |
把公钥加到对应用户家目录 |
| 老设备不认 ed25519 | 服务端 OpenSSH < 6.5 | 改用 rsa -b 4096 |
本地的 ~/.ssh/config 给每台服务器起别名,把用户名、端口、私钥路径记下来,之后 ssh myserver 即可登录,不用每次敲一长串。
Host myserver
HostName <服务器IP>
User <用户名>
Port 2222
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
# 等价于 ssh -p 2222 -i ~/.ssh/id_ed25519 <用户名>@<服务器IP>
$ ssh myserver
| 配置项 | 作用 |
|---|---|
Host / HostName |
别名(本地随便起)/ 真实 IP 或域名 |
User / Port |
登录用户名 / SSH 端口 |
IdentityFile |
私钥路径(多密钥时必配) |
ServerAliveInterval |
每 60 秒发保活包,防止连接被防火墙掐断 |
IdentitiesOnly yes |
只用指定的私钥,避免多密钥导致认证失败 |
Host * |
通配段,要放文件末尾(SSH 用先匹配到的配置) |
# 打印 SSH 解析配置后的实际参数
$ ssh -G myserver | head -n 5
预期输出:
user <用户名>
hostname <服务器IP>
port 2222
identityfile ~/.ssh/id_ed25519
serveraliveinterval 60
| 现象 | 根因 | 解决 |
|---|---|---|
| config 改了没生效 | Host 段没匹配上(如用真实 IP 登录时匹配不到别名段)、写了语法错误,或被文件中先匹配到的配置覆盖(Host * 段放在了前面) |
ssh -G <别名> 看实际解析出的参数;chmod 600 ~/.ssh/config 作为卫生习惯也建议做,但它不是没生效的根因 |
| 别名不生效 | 用真实 IP 登录时匹配不到 Host | 用 ssh <别名> 而非 ssh <IP> |
Host * 覆盖了单个主机的配置 |
SSH 取最先匹配到的段 | 把 Host * 放到文件最后 |
| 多密钥时登不上 | SSH 试了错误的密钥顺序 | 显式配置 IdentityFile |
/etc/ssh/sshd_config 是 SSH 服务端配置。默认配置以「能用」为目标,安全上相当宽松,加固就是逐项收紧。
# 先备份,改坏能秒还原
$ sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
$ sudo nano /etc/ssh/sshd_config
# 禁止 root 直接登录(改用普通用户 + sudo)
PermitRootLogin no
# 启用密钥登录;禁用密码登录 —— 确认密钥能登录后再改!
PubkeyAuthentication yes
PasswordAuthentication no
# 禁止空密码,30 秒内没登录成功就断开,单连接最多试 3 次
PermitEmptyPasswords no
LoginGraceTime 30
MaxAuthTries 3
# 只允许指定用户登录(白名单,强烈推荐)
AllowUsers <用户名>
# 关闭 X11 转发;每 300 秒探活一次
X11Forwarding no
ClientAliveInterval 300
# 改端口(可选,只是减少扫描噪音)
Port 2222
| 配置项 | 推荐值 | 默认值 | 理由 |
|---|---|---|---|
PermitRootLogin |
no |
prohibit-password |
root 每台机器都存在,等于帮攻击者省一半力气 |
PasswordAuthentication |
no |
yes |
最关键的加固项,彻底杜绝密码爆破 |
PubkeyAuthentication |
yes |
yes |
密钥登录开关 |
PermitEmptyPasswords |
no |
no |
防配置疏漏 |
MaxAuthTries |
3 |
6 |
限制单连接内的认证尝试次数 |
LoginGraceTime |
30 |
120 |
减少半开连接占用 |
AllowUsers |
你的用户名 | 无限制 | 白名单;也可用 AllowGroups sshusers 按组授权 |
X11Forwarding |
no |
yes |
无需图形转发,减少攻击面 |
ClientAliveInterval |
300 |
0 |
定期探活,及时发现断连 |
Port |
自定义 | 22 |
换端口,减少扫描噪音 |
[!WARNING]
PasswordAuthentication no是全章第二号「把自己锁在门外」事故(第一号是 ufw 没放行 SSH)。关掉密码登录后密钥又没配好,你就永远进不去了。 顺序必须是:① 密钥登录测试成功 → ② 关闭密码登录 → ③ 新开终端再次验证能登录 → ④ 才算完成。
[!IMPORTANT] 云服务器特别注意 drop-in 文件:22.04 云镜像的
/etc/ssh/sshd_config.d/50-cloud-init.conf里常含PasswordAuthentication yes,而 drop-in 文件优先生效——你在主配置里改成no也不会生效。改完务必用sudo sshd -T | grep passwordauthentication核对实际生效值,必要时同步修改该 drop-in 文件。
[!NOTE] 改端口不是真正的安全措施,只是减少噪音。 扫描器扫全端口只需几分钟,它唯一的作用是让日志少 99% 的自动扫描记录。真正的安全来自密钥登录 + 禁密码 + 禁 root。 改端口后防火墙和安全组要同步放行新端口(16);RHEL 系有 SELinux,还需
semanage port -a -t ssh_port_t -p tcp 2222。
| 语法要点 | 说明 |
|---|---|
| 格式 | 选项 值,空格分隔,大小写不敏感 |
| 注释 | # 开头的是注释,通常就是默认值 |
| 重复项 | 同一选项出现多次时,第一次出现的生效 |
AllowUsers |
空格分隔多个用户;可写 <用户名>@<IP> 限定来源 |
Match 块 |
对特定用户单独配置,直到下一个 Match |
| 自定义配置位置 | 推荐放 /etc/ssh/sshd_config.d/*.conf |
# 查看 sshd 实际生效的完整配置(含默认值)
$ sudo sshd -T | grep -E 'port|permitrootlogin|passwordauthentication|allowusers'
预期输出:
port 2222
permitrootlogin no
passwordauthentication no
allowusers <用户名>
| 现象 | 根因 | 解决 |
|---|---|---|
| 改完重启后连不上 | 配置语法错或把自己排除了 | VNC 登录还原备份文件 |
| 配置改了没生效 | 没重启服务,或写在 Match 块之后 |
sudo sshd -t 检查后 systemctl restart ssh |
AllowUsers 加了但所有人都登不上 |
用户名写错 | VNC 登录后修正 |
| 改端口后新连接失败 | 防火墙/安全组没放行新端口 | sudo ufw allow 2222 + 控制台放行 |
一套固定操作顺序,让「改坏 SSH 配置」永远不会导致你失去服务器。
[!IMPORTANT] 改 sshd 配置时,务必保留一个已登录的会话不要退出。 sshd 重启不会影响已建立的连接,万一新配置有问题,你还能在这个窗口里改回来。在新窗口验证成功之前,绝对不要关掉旧会话。
# 1. 备份并编辑
$ sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
$ sudo nano /etc/ssh/sshd_config
# 2. 语法检查(必做!有问题会直接指出行号)
$ sudo sshd -t
# 3. 重启(Ubuntu 服务名是 ssh)
$ sudo systemctl restart ssh
# 4. 【关键】新开终端窗口测试能否登录,成功后再关旧会话
$ ssh -p 2222 <用户名>@<服务器IP>
[!IMPORTANT]
sudo sshd -t是重启前的必做检查。 它会校验语法并直接报出文件和行号,例如/etc/ssh/sshd_config: line 42: Bad configuration option: PermitRootLoginn,能拦住绝大多数「改完重启直接起不来」的事故。 没有-t就重启 = 拿服务器可用性赌博。 若配置有错,重启时 sshd 加载失败而退出,已建立的连接不受影响,但新连接进不去。
改坏了就还原:
$ sudo cp /etc/ssh/sshd_config.bak /etc/ssh/sshd_config
$ sudo sshd -t
$ sudo systemctl restart ssh
| 命令 | 作用 |
|---|---|
sshd -t |
测试配置文件语法,无输出表示通过 |
sshd -T |
输出实际生效的完整配置(含默认值) |
systemctl restart ssh |
重启 SSH 服务,已有连接不受影响 |
systemctl reload ssh |
重载配置,比 restart 温和 |
journalctl -u ssh -n 50 |
看 sshd 启动失败的详细原因 |
# 语法检查:无输出即通过,退出码为 0
$ sudo sshd -t
$ echo $?
预期输出:
0
| 现象 | 根因 | 解决 |
|---|---|---|
| 重启 sshd 后当前连接会不会断? | 不会 —— 已有连接由独立子进程处理 | 放心重启,但要新开窗口验证新连接 |
sshd -t 报错看不懂 |
会给出文件名和行号 | 按行号定位,对照备份文件 |
Job for ssh.service failed |
端口被占用或配置错 | journalctl -u ssh -n 50 看详细错误 |
| 忘记备份就改了配置 | 无备份可还原 | VNC 登录,注释掉可疑新增行 |
fail2ban 扫描日志,发现某 IP 短时间内反复登录失败就自动把它加入防火墙黑名单。它不能替代密钥登录,但能大幅降低噪音和被爆破成功的概率。
| 概念 | 说明 |
|---|---|
| filter(过滤器) | 用正则定义「什么算一次失败」(匹配日志里的 Failed password) |
| jail(监狱) | filter + 动作 + 阈值的组合:失败多少次 → 封多久 → 怎么封 |
# 安装并开机自启
$ sudo apt install fail2ban
$ sudo systemctl enable --now fail2ban
# 配置一律写 jail.local,不要改 jail.conf(升级会被覆盖)
$ sudo nano /etc/fail2ban/jail.local
[DEFAULT]
# 务必加上自己的 IP 或公司网段,防止误封自己
ignoreip = 127.0.0.1/8 10.0.0.0/8
# 封禁 1 小时(秒,-1 为永久);10 分钟内失败 3 次触发
bantime = 3600
findtime = 600
maxretry = 3
# 封禁方式(Ubuntu 22.04 用 ufw 或 iptables 均可)
banaction = ufw
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400
注意 [sshd] 段里写的 port、maxretry、bantime 会覆盖 [DEFAULT] 中的同名值——上例中 sshd jail 实际生效的是封 86400 秒,而不是 [DEFAULT] 的 3600 秒。只想改全局默认时,只写 [DEFAULT],jail 段里不重复这些键。
[!WARNING]
ignoreip里务必加上你自己的固定出口 IP 或公司网段,否则手滑输错三次密码就把自己封在外面。真被封了只能换网络出口,或通过云控制台 VNC 登录后解封。
# 改完重启生效,并查看 sshd jail 状态(含被封 IP 列表)
$ sudo systemctl restart fail2ban
$ sudo fail2ban-client status sshd
# 手动解封 / 封禁
$ sudo fail2ban-client set sshd unbanip <被封IP>
$ sudo fail2ban-client set sshd banip 203.0.113.66
# 查看 fail2ban 自身日志
$ sudo tail -n 50 /var/log/fail2ban.log
| 参数 | 作用 |
|---|---|
ignoreip |
永不封禁的 IP 白名单(必须加自己的 IP) |
bantime |
封禁时长(秒),-1 为永久 |
findtime |
统计窗口(秒),这段时间内累计失败次数 |
maxretry |
触发封禁的失败次数 |
banaction |
用什么方式封(ufw / iptables / nftables) |
logpath |
监控的日志文件(Ubuntu 是 /var/log/auth.log) |
jail.local |
自定义配置,优先于 jail.conf,升级不覆盖 |
$ sudo fail2ban-client status sshd
预期输出:
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 47
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 3
|- Total banned: 3
`- Banned IP list: 203.0.113.66 198.51.100.23 192.0.2.44
| 现象 | 根因 | 解决 |
|---|---|---|
| 没人被封、没生效 | logpath 不对,或禁密码后根本没失败日志 |
确认 logpath = /var/log/auth.log |
| 把自己封在外面了 | ignoreip 没加自己的 IP |
换出口网络,或 VNC 登录后 unbanip |
status sshd 说 jail 不存在 |
enabled = true 没写或没重载 |
检查 jail.local 后重启 fail2ban |
| 改了 SSH 端口后不工作 | jail 里的 port 还是 22 |
改成实际端口 |
| 被封的 IP 立刻又来 | bantime 太短 | 调大 bantime,或用 recidive jail 累进加长 |
三种基于 SSH 的文件传输方式,都复用 SSH 的加密通道和认证,不需要额外开端口。
| 工具 | 特点 | 适用场景 |
|---|---|---|
scp |
语法像 cp,最简单 |
临时传一两个文件 |
sftp |
交互式,像 FTP 但有加密 | 手工浏览、上传下载 |
rsync |
增量传输,只传变化部分,可断点续传 | 部署代码、备份大目录(推荐) |
# scp 上传 / 下载(端口参数是 -P 大写,与 ssh 的 -p 不同!)
$ scp ./app.jar <用户名>@<服务器IP>:/opt/app/
$ scp -P 2222 ./app.jar <用户名>@<服务器IP>:/opt/app/
$ scp <用户名>@<服务器IP>:/var/log/nginx/access.log ./
# sftp 交互式
$ sftp -P 2222 <用户名>@<服务器IP>
# rsync 增量同步:先干跑看会同步什么,确认后再真跑
$ rsync -avz --dry-run ./dist/ <用户名>@<服务器IP>:/var/www/html/
$ rsync -avz --progress ./dist/ <用户名>@<服务器IP>:/var/www/html/
# rsync 指定 SSH 端口
$ rsync -avz -e "ssh -p 2222" ./dist/ <用户名>@<服务器IP>:/var/www/html/
[!WARNING]
rsync --delete会删除目标端存在、但源端没有的文件,用错方向可能清空服务器数据。永远先加--dry-run看一遍它会删什么。 另外注意源路径结尾斜杠的语义:./dist/(带斜杠)同步目录内容,./dist(不带斜杠)同步目录本身。
| 参数 | 作用 |
|---|---|
scp -P <端口> |
大写 P,与 ssh -p(小写)不同,容易记混 |
rsync -a |
archive 模式,保留权限、时间、软链接(最常用) |
rsync -v / -z |
显示过程 / 传输时压缩 |
rsync --progress |
显示传输进度 |
rsync --dry-run |
干跑,只显示会做什么 |
rsync --delete |
删除目标端多余文件(危险) |
rsync -e "ssh -p 2222" |
指定远程 shell 及端口 |
$ rsync -avz --dry-run ./dist/ <用户名>@<服务器IP>:/var/www/html/
预期输出:
sending incremental file list
./
index.html
assets/
assets/app.js
sent 220 bytes received 25 bytes 490.00 bytes/sec
total size is 15,432 speedup is 63.00
| 现象 | 根因 | 解决 |
|---|---|---|
scp 提示 Permission denied |
目标目录对该用户不可写 | 先传到 /tmp 再移动 |
scp -p 2222 报错 |
scp 的端口参数是大写 -P |
改成 -P 2222 |
rsync: command not found |
某一端没装 | 两端都 sudo apt install rsync |
| rsync 每次都全量传 | 没用 -a,时间戳没保留 |
加上 -a |
| 传完文件权限不对 | 没用 -a |
rsync -a 会保留权限 |
把 SSH 连不上时的三大症状与原因对应起来,避免盲目试错。
| 症状 | 含义 | 排查方向 |
|---|---|---|
Connection refused |
主机在线,端口上没有服务 | 服务没启动、端口写错 |
Connection timed out |
包被丢弃,主机或端口不可达 | 安全组 / 防火墙 / IP 错 |
Permission denied (publickey) |
连上了,但认证失败 | 密钥路径、权限、用户名 |
# 一、服务端:确认 sshd 在跑、端口对
$ sudo systemctl status ssh
$ sudo ss -lntp | grep sshd
# 二、客户端:确认端口可达,并看详细握手
$ nc -zv <服务器IP> 2222
$ ssh -vvv -p 2222 <用户名>@<服务器IP>
# 三、服务端:另开窗口实时看认证日志,然后让客户端试连
$ sudo tail -f /var/log/auth.log
ssh -vvv 输出里最该关注的部分:
debug1: Offering public key: /home/<用户名>/.ssh/id_ed25519 ED25519 SHA256:xxx
debug1: Server accepts key: /home/<用户名>/.ssh/id_ed25519 ED25519 SHA256:xxx
debug1: Authentication succeeded (publickey).
卡在 Connecting → 网络/防火墙问题;出现 Offering 但没有 Server accepts key → 公钥没登记或权限不对;完全没出现 Offering → 客户端没找到私钥,用 -i 指定。
服务端日志里权限问题的铁证(客户端只看得到 Permission denied (publickey)):
Authentication refused: bad ownership or modes for directory /home/<用户名>/.ssh
[!NOTE] 关于
Too many authentication failures:本地有多个密钥(或 ssh-agent 加载了很多)时,SSH 会依次尝试每一个,超过服务端MaxAuthTries就被断开。解决办法是在~/.ssh/config里用IdentityFile明确指定私钥,并加IdentitiesOnly yes让它只用这一把。
| 命令 / 参数 | 作用 |
|---|---|
nc -zv <IP> <端口> |
测试 TCP 端口是否可达 |
ssh -vvv |
输出完整握手与认证过程 |
IdentitiesOnly yes |
只用指定密钥,避免多密钥导致的失败 |
/var/log/auth.log |
Ubuntu 认证日志,登录成功/失败都在这 |
journalctl -u ssh |
systemd 视角的 sshd 日志 |
last / lastb |
查看登录成功 / 失败历史 |
# 在服务器上实时观察登录尝试
$ sudo tail -f /var/log/auth.log
预期输出(密钥登录成功 / 密码失败,后者通常来自扫描器):
Sep 29 10:35:11 ubuntu sshd[12399]: Accepted publickey for <用户名> from 203.0.113.1 port 55210 ssh2: ED25519 SHA256:xxx
Sep 29 10:31:02 ubuntu sshd[12345]: Failed password for invalid user admin from 203.0.113.66 port 54322 ssh2
| 现象 | 根因 | 解决 |
|---|---|---|
| 改端口后连不上 | ssh 没加 -p,或防火墙/安全组没放行新端口 |
ssh -p 2222 ...;sudo ufw allow 2222 + 控制台放行 |
| 密钥登不上但密码可以 | authorized_keys 没写进去或权限不是 700/600 |
按「SSH 密钥登录」一节修正 |
| 重启 sshd 后当前连接会不会断? | 不会断 —— 已有连接由独立子进程处理 | 放心重启,但务必新开窗口验证新连接 |
Too many authentication failures |
本地密钥太多,尝试次数超限 | 配 IdentityFile + IdentitiesOnly yes |
Connection refused |
sshd 没运行或端口不对 | systemctl status ssh + ss -lntp | grep sshd |
Connection timed out |
安全组/防火墙拦截 | 见 16 防火墙 四步排查 |
| 被 fail2ban 封了不知道 | 输错密码次数过多 | sudo fail2ban-client set sshd unbanip <IP> |
| Ubuntu 22.04 | RHEL / Rocky / AlmaLinux 9 | 说明 |
|---|---|---|
sudo systemctl restart ssh |
sudo systemctl restart sshd |
服务名不同 |
sudo apt install openssh-server |
sudo dnf install openssh-server |
安装服务端 |
/var/log/auth.log |
/var/log/secure |
认证日志路径不同 |
banaction = ufw |
banaction = firewallcmd-rich-rules |
fail2ban 封禁动作不同 |
sudo ufw allow 2222 |
sudo firewall-cmd --permanent --add-port=2222/tcp && sudo firewall-cmd --reload |
防火墙放行 |
| 改端口无需额外操作 | semanage port -a -t ssh_port_t -p tcp 2222 |
RHEL 有 SELinux,改 SSH 端口要登记 |
| 命令 | 作用 |
|---|---|
ssh <用户名>@<服务器IP> |
登录远程服务器 |
ssh -p 2222 <用户名>@<服务器IP> |
指定端口登录 |
ssh -i ~/.ssh/id_ed25519 <用户名>@<服务器IP> |
指定私钥登录 |
ssh -vvv <用户名>@<服务器IP> |
输出详细握手过程(排错用) |
ssh-keygen -t ed25519 -C "注释" |
生成 ed25519 密钥对 |
ssh-copy-id <用户名>@<服务器IP> |
推送公钥到服务器 |
ssh-copy-id -p 2222 -i ~/.ssh/xxx.pub <用户名>@<服务器IP> |
指定端口和公钥推送 |
chmod 700 ~/.ssh |
修正 .ssh 目录权限(必须) |
chmod 600 ~/.ssh/authorized_keys |
修正授权文件权限(必须) |
ssh-keygen -R <服务器IP> |
删除本地记录的旧指纹 |
sudo ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub |
查看服务器主机密钥指纹 |
sudo sshd -t |
检查 sshd 配置语法(重启前必做) |
sudo sshd -T | grep <选项> |
查看某选项实际生效值 |
sudo systemctl restart ssh |
重启 SSH 服务(Ubuntu 服务名 ssh) |
sudo tail -f /var/log/auth.log |
实时看认证日志 |
sudo apt install fail2ban |
安装 fail2ban |
sudo fail2ban-client status sshd |
查看 sshd jail 与被封 IP |
sudo fail2ban-client set sshd unbanip <IP> |
解封某个 IP |
scp -P 2222 file <用户名>@<服务器IP>:/path/ |
上传文件(-P 大写) |
rsync -avz -e "ssh -p 2222" ./dir/ <用户名>@<服务器IP>:/path/ |
增量同步目录 |
journalctl -u ssh 查服务日志[!IMPORTANT] 本章所有版本号截至 2026-09。全篇版本信息的唯一权威来源是 版本速查表。
装 Java 只要一条命令,难的是后面三件事:装哪个版本、系统怎么在多个版本里选、Maven / Tomcat / systemd 怎么找到它。
| 项目 | 本章基准 | Ubuntu 22.04 源默认 | 推荐获取方式 |
|---|---|---|---|
| 推荐(稳妥) | JDK 21 LTS | openjdk-21-jdk 可直接装 |
sudo apt install openjdk-21-jdk |
| 最新 | JDK 25 LTS(2025-09 GA) | 源里没有 | Temurin 源 或 手动解压 |
| 仍广泛使用 | JDK 17 LTS | openjdk-17-jdk 可直接装 |
apt;Oracle Premier Support 2026-09 到期 |
| 老项目兼容 | JDK 8 / 11 LTS | openjdk-11-jdk(源里没有 JDK 8) |
apt;JDK 8 需 Temurin 等外部源 |
| 不要用于生产 | 22 / 23 / 24 / 26 | 无 | —— 半年功能版;22/23/24 支持期已过,26(2026-03 发布)支持期到 2026-09 结束,即将到期 |
[!NOTE] 完整跨组件版本对照见 91 版本速查表。本教程约定:生产环境默认装 JDK 21。
一句话:JRE 只能跑 Java 程序,JDK 才能开发 Java 程序。 JRE(Java Runtime Environment)= JVM + 运行用类库,装了能把 jar 跑起来;JDK(Java Development Kit)= JRE + 编译器 javac + 调试器 + 打包工具等整套开发工具。
服务器上选 JDK。因为很多服务端程序运行时也需要编译器:Tomcat 首次访问 JSP 要现场编译成 class、部分框架依赖动态编译能力。Ubuntu 22.04 源里的 -jre-* 包不含 javac,选包名认准结尾的 -jdk。
Java 从 9 起改成「每 6 个月发一版」的节奏,但不是每版都长期维护:
| 类型 | 版本号 | 支持时长 | 适合谁 |
|---|---|---|---|
| LTS(长期支持) | 8、11、17、21、25 | 数年到十几年(视发行商而定) | 生产环境 |
| 半年功能版 | 22、23、24、26 | 发布后约 6 个月 | 尝鲜、写 demo |
[!WARNING] 不要用非 LTS 版本部署生产。 半年功能版到期后不再有任何安全补丁,等于把已知漏洞留在服务器上。
为什么推荐 21 而不是最新的 25? 这是「新特性收益 vs 稳定性风险」的取舍:21 已发布三年(2023-09 GA),Spring Boot 3.2+、Kafka 4.x、Elasticsearch、Flink 都已完整适配,踩过的坑搜索引擎里都有答案;而 25 刚 GA 一年,部分中间件的兼容验证还在追,出问题可参考的资料明显少。Java 向后兼容性很好,但**「好」不等于「完美」**,跨 LTS 升级偶尔会遇到反射、字节码库(ASM/CGLIB)、GC 参数相关的坑。选 25 的场景同样明确:新项目 + 确实需要虚拟线程正式版等新特性,且团队有人兜得住。另外 JDK 17 虽是 LTS,但 Oracle 对它的 Premier Support 已在 2026-09 到期,新项目不建议再选它起步。
版本不是看心情选的,是看你跑的东西要什么:
| 你要跑的东西 | 最低 JDK 要求 | 建议安装 |
|---|---|---|
| Spring Boot 3.x | 17(硬性) | 21 |
| Spring Boot 2.x | 8(部分 2.7 支持 17) | 11 或 17,不要上 21(2.x 官方不支持) |
| Kafka 3.x / 4.x | 17(4.0 起要求) | 21 |
| Elasticsearch 8.x | 自带 JDK 或 17/21 | 21 |
| Tomcat 10.1 | 11 | 17 或 21 |
| Hadoop 3.x | 8 / 11 | 11 |
[!IMPORTANT] 先看框架文档的版本要求,再定 JDK 版本。 反过来做(先装最新 JDK,再发现 Spring Boot 2.x 起不来)会浪费大量时间。
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt update |
sudo dnf check-update |
刷新软件源索引 |
sudo apt install openjdk-21-jdk |
sudo dnf install java-21-openjdk-devel |
RHEL 侧包名带 -devel 才是 JDK |
sudo apt install openjdk-21-jre-headless |
sudo dnf install java-21-openjdk-headless |
只要运行时 |
sudo apt install default-jdk |
sudo dnf install java-latest-openjdk-devel |
22.04 的 default-jdk 实际是 openjdk-11;右侧装的是最新版本 |
/usr/lib/jvm/java-21-openjdk-amd64 |
/usr/lib/jvm/java-21-openjdk |
RHEL 路径无 -amd64 后缀 |
sudo update-alternatives --config java |
sudo alternatives --config java |
命令名不同,用法一致 |
OpenJDK 是 Java 的开源参考实现,功能与 Oracle JDK 几乎一致,且完全免费商用(Oracle JDK 商用需付费订阅)。三条路线按优先级:能用包管理器就别手动解压,能用解压就别源码编译(OpenJDK 自行编译过程复杂且难维护,本教程不推荐)。
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ① apt 官方源 | 需要 JDK 21/17/11/8 | 一条命令、依赖自动解决、自动获得安全补丁 | 版本受限于发行版,没有 25 |
| ② Temurin 源 | 需要 JDK 25,又想保持包管理 | 官方构建、自动更新、多版本共存 | 多配一个第三方源 |
| ③ 手动解压 tar.gz | 内网离线、指定精确小版本、多版本共存 | 装到哪自己定、卸载就是删目录 | 不自动更新,需自己配 JAVA_HOME 与 alternatives |
方式一:apt 安装(推荐首选)。 装之前先看清源里有什么版本,别上来就装:
$ apt search openjdk-.*-jdk
# 安装 JDK 21(含 javac 的完整开发套件)—— 本章推荐
$ sudo apt install openjdk-21-jdk
# 要 17 就装 -17-jdk;注意 default-jdk 在 22.04 实际装的是 openjdk-11,要 21 请显式 apt install openjdk-21-jdk
$ sudo apt install openjdk-17-jdk
$ sudo apt install default-jdk
方式二:Temurin(Adoptium)源安装 JDK 25。 Ubuntu 22.04 源里最高只有 21,要装更高版本就得加外部源;Temurin 由 Eclipse Adoptium 社区构建,免费、无商标限制。
# 创建存放 apt 源密钥的目录(已存在也不会报错)
$ sudo install -m 0755 -d /etc/apt/keyrings
# 下载 Adoptium 的 GPG 公钥,转成 apt 能识别的二进制格式
$ curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public | \
gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null
# 设置只读权限
$ sudo chmod 0644 /etc/apt/keyrings/adoptium.gpg
# 写入源配置:用 signed-by 把「这条源」和「这个密钥」绑定
$ echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/adoptium.list > /dev/null
# 刷新索引并安装
$ sudo apt update
$ sudo apt install temurin-25-jdk
方式三:手动解压 tar.gz。 JDK 本质是绿色软件 —— 不往系统目录写东西,整个目录复制走或删掉都不影响别的软件,这正是多版本共存的基础。
# 创建规范的安装目录(/opt 专用于第三方软件)
$ sudo mkdir -p /opt/jdk
# 下载 Temurin 21(示例 URL,请到 adoptium.net 取最新链接)
$ curl -fSL -o /tmp/jdk21.tar.gz \
"https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jdk_x64_linux_hotspot_21.0.8_9.tar.gz"
# 解压到 /opt/jdk,会得到 /opt/jdk/jdk-21.0.8+9/ 这样的目录;tar 参数见下方参数说明
$ sudo tar -xzf /tmp/jdk21.tar.gz -C /opt/jdk
# 看看解压出来叫什么名字,后面配 JAVA_HOME 要用
$ ls /opt/jdk
apt 包名后缀与 Temurin 源配置片段:
| 片段 | 含义 / 作用 |
|---|---|
-jdk |
完整开发工具包(含 javac)。服务器上选这个 |
-jre |
仅运行环境,没有 javac |
-headless |
不含图形界面支持。服务器必选,省几十 MB 且少一堆 X11 依赖 |
-headful |
含图形库,桌面环境才用 |
/etc/apt/keyrings/ |
存放第三方源密钥的约定目录。apt 已不再读 /etc/apt/trusted.gpg |
signed-by= |
指定「这条源用哪个密钥验签」,实现一源一密钥 |
$(lsb_release -cs) |
自动展开为当前发行版代号,22.04 上即 jammy。比手写不易错 |
gpg --dearmor |
把 ASCII 公钥转成二进制 keyring 格式,是 signed-by= 要求的格式 |
方式三用到的参数:tar -x 解压、-z 先过 gzip、-f 指定文件名且必须放在最后、-C /opt/jdk 表示解压前先 cd 到目标目录;curl -fSL 分别是「报错时不生成垃圾文件」「出错时显示原因」「跟随重定向」。
[!WARNING] 绝对不要用
apt-key add。apt-key已在 Ubuntu 22.04 被废弃,它的致命问题是:加进去的密钥会成为全局信任,可以给系统里任意软件包签名,一旦泄露 apt 会信任攻击者伪造的所有包。看到老教程里的apt-key add请一律替换成上面的signed-by=写法。
[!WARNING] 不要只装
-jre-headless。 只装 JRE 时java -version正常返回,你会以为成功了;等到 Tomcat 要编译 JSP、或某框架调用javax.tools.JavaCompiler,才会抛出「找不到编译器」。
方式一,看版本号(小版本号如 21.0.8 会随系统安全更新变化,只要开头的 21 对就行):
$ java -version
预期输出:
openjdk version "21.0.8" 2026-07-21
OpenJDK Runtime Environment (build 21.0.8+9-Ubuntu-222.04)
OpenJDK 64-Bit Server VM (build 21.0.8+9-Ubuntu-222.04, mixed mode, sharing)
方式二,确认源已经生效:
$ apt policy temurin-25-jdk
预期输出:
temurin-25-jdk:
Installed: 25.0.1.0.0+8-1
Candidate: 25.0.1.0.0+8-1
Version table:
*** 25.0.1.0.0+8-1 500
500 https://packages.adoptium.net/artifactory/deb jammy/main amd64 Packages
方式三不要用 java -version 验证 —— 此时 PATH 还没配,查到的会是系统里别的 JDK,必须调用绝对路径:
$ /opt/jdk/jdk-21.0.8+9/bin/java -version
预期输出:
openjdk version "21.0.8" 2026-07-21
OpenJDK Runtime Environment (Temurin-21.0.8+9) (build 21.0.8+9)
OpenJDK 64-Bit Server VM (Temurin-21.0.8+9) (build 21.0.8+9, mixed mode, sharing)
输出中有 Temurin 字样,确认是刚解压的这一份。
这一节最容易出问题,请慢慢读。
两个机制解决两件事。
① update-alternatives:决定命令 java 是谁。 服务器上常同时存在多个 JDK,敲 java 时该用哪一个?Debian 系的答案是 alternatives 机制:
/usr/bin/java → /etc/alternatives/java → /usr/lib/jvm/java-21-openjdk-amd64/bin/java
即 /usr/bin/java 不是真实文件而是软链接,跳到 /etc/alternatives/java,后者再跳到真实 JDK。换 JDK 不需要动 PATH、不需要删文件,只改中间那个链接的指向 —— 好处是 /usr/bin/java 这个入口永远稳定,写死它的脚本、cron、systemd unit 都不用改。
② JAVA_HOME:告诉第三方工具 JDK 在哪。 它指向 JDK 安装根目录(注意:不是 bin 目录),Maven / Gradle / Tomcat / Kafka / Elasticsearch 靠它找 JDK。它们为什么不直接读 PATH 里的 java?因为需要的往往不只是可执行文件,还有 JDK 里 lib/ 下的模块与工具、release 文件等根目录资源 —— 只有知道根目录才能找到这些子目录。
第 1 步:把每个 JDK 注册进 alternatives(数字越大优先级越高):
# 注册 apt 装的 JDK 21
$ sudo update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-21-openjdk-amd64/bin/java 21
# 注册手动解压的那个(如果有)
$ sudo update-alternatives --install /usr/bin/java java /opt/jdk/jdk-21.0.8+9/bin/java 25
# 同样的方式注册编译器 javac —— 很多人漏了这一步
$ sudo update-alternatives --install /usr/bin/javac javac /usr/lib/jvm/java-21-openjdk-amd64/bin/javac 21
$ sudo update-alternatives --install /usr/bin/javac javac /opt/jdk/jdk-21.0.8+9/bin/javac 25
第 2 步:切换版本,二选一:
# 交互式选择(推荐新手)
$ sudo update-alternatives --config java
# 非交互式:直接指定用哪一个
$ sudo update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java
第 3 步:确定 JAVA_HOME 该填什么。 这是新手最常卡住的一步,推荐顺着 java 反查两层目录:
$ dirname $(dirname $(readlink -f $(which java)))
预期输出:
/usr/lib/jvm/java-21-openjdk-amd64
拆解:which java 得 /usr/bin/java(软链接)→ readlink -f 追到底得 .../bin/java → 第一层 dirname 去掉文件名得 .../bin → 第二层 dirname 去掉 bin,剩下的就是 JAVA_HOME。也可以 ls -1 /usr/lib/jvm 列出来肉眼挑,但要用带具体版本号的目录如 java-21-openjdk-amd64,不要用 default-java —— 后者也是软链接,指向会随系统更新悄悄变化,这正是「我没动环境,服务却突然报错」的典型来源。
第 4 步:写入配置。 三个位置按适用范围选择,关键是别写错地方:
| 位置 | 生效范围 | 适用场景 |
|---|---|---|
/etc/profile.d/jdk.sh |
所有用户的所有登录 shell | 服务器第一推荐,系统级统一 |
~/.bashrc |
只有当前用户的 bash | 多用户机器上各自用不同 JDK |
systemd unit 的 Environment= |
只有这一个服务 | 服务需与命令行用不同版本时 |
[!TIP] 为什么不直接改
/etc/profile? 它是系统自带文件,apt 升级base-files包时可能覆盖它,改动会丢失或引发冲突;而它末尾有一段代码会自动遍历执行/etc/profile.d/*.sh—— 这是官方预留给用户自定义的位置。
系统级推荐写法(用户级则把同样两行追加到 ~/.bashrc,效果同上但只影响当前用户):
$ sudo tee /etc/profile.d/jdk.sh > /dev/null <<'EOF'
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export PATH=$JAVA_HOME/bin:$PATH
EOF
# 0644 即可,脚本是被 /etc/profile source 执行的,无需可执行位
$ sudo chmod 0644 /etc/profile.d/jdk.sh
第 5 步:让配置生效。 写完文件不会立刻生效,在当前 shell 里用 source 启用:
$ source /etc/profile.d/jdk.sh
$ source ~/.bashrc
为什么必须 source?执行脚本是在子进程里跑的,子进程结束后变量随之消失;source(等价于 .)是在当前 shell 里逐行执行,所以变量能留下来。它只对当前这一个终端窗口有效 —— 新开 SSH 会话是全新的登录 shell,会自动执行 /etc/profile.d/*.sh。
alternatives 的命令开关:
| 参数 | 作用 |
|---|---|
--install <链接> <名称> <真实路径> <优先级> |
注册一个候选,四个参数缺一不可 |
--config <名称> |
弹出菜单让你选;自动模式下默认选中优先级数字最大的那个 |
--set <名称> <真实路径> |
设为手动模式,不再按优先级自动选 |
--display <名称> |
查看当前配置和所有候选,排查版本混乱时优先用这个 |
--remove <名称> <真实路径> |
注销某个候选 |
自动模式下系统永远选优先级数字最大的那个;一旦用 --config 手动选过就转为手动模式,此后再装新 JDK 也不会自动切走 —— 这正是「我装了新版怎么没生效」的原因。
jdk.sh 里两行配置的含义:export 把变量导出给子进程(不写则启动的程序读不到);JAVA_HOME= 指向 JDK 根目录,不要带 /bin;PATH=$JAVA_HOME/bin:$PATH 把 JDK 的 bin 接到 PATH 前面,因为 shell 从左到右扫描 PATH,排在前面的优先命中。
[!IMPORTANT] 写成
export PATH=$PATH:$JAVA_HOME/bin(拼后面)多数时候也能跑,但一旦系统里有别的 JDK 排在 PATH 前面,java就会命中它 —— JAVA_HOME 说用 21、PATH 实际跑了 25,这种不一致排查非常耗时。请统一拼在前面。
为什么会有「时有时无」的现象? 这是 JAVA_HOME 不生效的头号原因。登录 shell(SSH 登录后得到的那个)加载顺序是 /etc/profile → /etc/profile.d/*.sh → ~/.bash_profile;非登录 shell(登录后又敲 bash、脚本里、docker exec、su 出来的)只读 ~/.bashrc,不走 /etc/profile.d/。于是重新 SSH 登录后有值,在某个脚本里执行却为空。稳妥做法是写到 /etc/profile.d/jdk.sh(管住登录 shell)并且在 systemd unit 里用 Environment= 显式声明(管住服务进程),两者都配,服务就不依赖任何 shell 的加载顺序。
看 alternatives 的当前选择,再顺着软链接看到底:
$ update-alternatives --display java
$ readlink -f /usr/bin/java
预期输出:
java - auto mode
link currently points to /usr/lib/jvm/java-21-openjdk-amd64/bin/java
/usr/lib/jvm/java-21-openjdk-amd64/bin/java - priority 21
/opt/jdk/jdk-21.0.8+9/bin/java - priority 25
/usr/lib/jvm/java-21-openjdk-amd64/bin/java
确认 JAVA_HOME 与 PATH 已经对齐:
$ echo $JAVA_HOME
$ which java
预期输出:
/usr/lib/jvm/java-21-openjdk-amd64
/usr/lib/jvm/java-21-openjdk-amd64/bin/java
[!NOTE] 如果
which java输出的是/usr/bin/java,说明$JAVA_HOME/bin没排到 PATH 前面,回去检查export PATH那一行的顺序;另外 apt 安装的 JDK 通常已自动注册 alternatives,不需要手动--install,动手前先--display看看有没有,避免重复注册。
JDK 是「装完即用」,没有配置文件要改。做完这三件事就够了:
sudo update-alternatives --config java(及 javac),让两者指向同一个 JDK/etc/profile.d/jdk.sh,内容是「根路径 + PATH 前置」两行java -version 显示的版本,必须与 $JAVA_HOME 指向同一个 JDK服务器专属建议:生产环境尽量只留一个 JDK。 多版本共存是给开发机的便利,在生产服务器上是纯粹的复杂度来源。若某老服务确实需要 JDK 11,宁可在它的 systemd unit 里用绝对路径指定,也不要在系统层面做全局切换。JVM 参数(-Xms/-Xmx、GC 策略、字符集)应放在具体应用的启动脚本或 systemd unit 里,不在 JDK 全局层面改。
按顺序做完这三段,才算真的安装好了。
$ java -version
$ javac -version
$ echo $JAVA_HOME
预期输出:
openjdk version "21.0.8" 2026-07-21
OpenJDK Runtime Environment (build 21.0.8+9-Ubuntu-222.04)
OpenJDK 64-Bit Server VM (build 21.0.8+9-Ubuntu-222.04, mixed mode, sharing)
javac 21.0.8
/usr/lib/jvm/java-21-openjdk-amd64
[!IMPORTANT]
java -version和javac -version必须报同一个版本号。不一致说明 PATH 里混了两个 JDK,回到update-alternatives --config那节重新对齐。
版本号对不代表能用。 下面这段一次性验证「编译器存在 → 能生成 class → JVM 能加载执行 → 当前目录有写权限」整条链路:
$ cat > ~/HelloWorld.java <<'EOF'
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, Linux Server!");
}
}
EOF
$ cd ~
$ javac HelloWorld.java
$ java HelloWorld
预期输出:
Hello, Linux Server!
编译后目录里会多出 HelloWorld.class —— 这是 Java 字节码,JVM 执行的正是它。
jshell 是 JDK 9 起自带的交互式 REPL,只有 JDK 有、JRE 没有,所以它是「我装的是不是真 JDK」的天然检测器:
$ jshell
预期输出(提示符是 jshell 自己的,不是 shell):
| Welcome to JShell -- Version 21.0.8
jshell> System.out.println("JDK is working")
JDK is working
jshell> /exit
能进入 jshell> 提示符,说明是完整 JDK;输入 /exit 退出。三段一起做下来基本覆盖所有安装失败场景,养成习惯:新机器先跑一遍 HelloWorld,再去部署真正的服务。
Java 应用(Spring Boot jar、Tomcat 等)在服务器上由 systemd 托管:开机自启、崩溃自动拉起、日志统一进 journald。详见 systemd 服务管理。
这里必须再提一次 JAVA_HOME,因为 systemd 不走任何 shell 配置文件 —— 你在 /etc/profile.d/jdk.sh 里写得再正确,服务进程也读不到,它启动进程时用的是自己的一套最小 PATH。所以必须在 unit 文件里显式写出来。
假设 Jar 位于 /opt/myapp/myapp.jar:
[Unit]
Description=My Java Application
After=network.target
[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/myapp
# systemd 不读 /etc/profile.d,必须在这里显式指定
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"
Environment="JAVA_OPTS=-Xms256m -Xmx512m -Dfile.encoding=UTF-8"
# 用绝对路径启动,不依赖 PATH
ExecStart=/usr/lib/jvm/java-21-openjdk-amd64/bin/java $JAVA_OPTS -jar /opt/myapp/myapp.jar
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
# 写完 reload 让 systemd 重新读取配置
$ sudo systemctl daemon-reload
# 设为开机自启并立即启动
$ sudo systemctl enable --now myapp.service
# 查看状态
$ sudo systemctl status myapp.service
| 配置项 | 作用 |
|---|---|
Environment= |
给这个服务单独设环境变量。多个变量写多行,一行只能写一个 |
$JAVA_OPTS |
$JAVA_OPTS 与 ${JAVA_OPTS} 均可,systemd 在 ExecStart 行内做变量展开,展开的是本 unit Environment= 定义的值 |
ExecStart 用绝对路径 |
不依赖 PATH,避免「命令行跑得好好的,systemd 里说找不到 java」 |
Restart=always |
进程退出就拉起;配合 RestartSec=5 防止重启风暴 |
$ sudo systemctl is-active myapp.service
$ sudo systemctl show myapp.service -p Environment
预期输出:
active
Environment=JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 JAVA_OPTS=-Xms256m -Xmx512m -Dfile.encoding=UTF-8
第二行确认服务进程拿到的 JAVA_HOME 确实是我们指定的那个。
| 现象 | 根因 | 解决 |
|---|---|---|
echo $JAVA_HOME 输出为空 |
①忘了 source;②在非登录 shell(脚本、su)里,不走 /etc/profile.d/;③写进 ~/.bashrc 却用另一用户登录 |
先 source /etc/profile.d/jdk.sh;用 echo $0 确认 shell 类型;服务请在 unit 里加 Environment= |
which java 是 /usr/bin/java,但 JAVA_HOME 指向另一目录 |
PATH 里 $JAVA_HOME/bin 没拼在前面,被 alternatives 或其他 JDK 抢先命中 |
改成 export PATH=$JAVA_HOME/bin:$PATH(前置);再看 update-alternatives --display java |
装了新 JDK 但 java -version 还是旧的 |
alternatives 处于手动模式,不再自动切到高优先级版本 | sudo update-alternatives --config java 重选;想恢复自动模式用 --auto java |
java 能跑,但 javac: command not found |
装的是 JRE(-jre-headless)而非 JDK;或只给 java 注册了 alternatives、漏了 javac |
装 openjdk-21-jdk;补 sudo update-alternatives --install /usr/bin/javac javac <路径>/bin/javac 21 |
Maven 报 The JAVA_HOME environment variable is not defined correctly |
JAVA_HOME 多了 /bin、指向不存在目录、或指向 JRE |
用 dirname $(dirname $(readlink -f $(which java))) 重取,修正后 source |
| Tomcat 启动报找不到 JDK / 用了错的 JDK | Tomcat 优先读 CATALINA_HOME/bin/setenv.sh,那里可能写死了旧路径 |
在 setenv.sh 里显式 export JAVA_HOME=<正确路径>,比依赖系统环境可靠 |
cannot execute binary file: Exec format error |
架构不匹配:x86 装了 arm64 包,或 32 位系统装了 64 位 JDK | uname -m 确认架构(服务器一般 x86_64),重新下载对应架构的包 |
解压后执行报 Permission denied |
可执行位丢失,或目录权限不足无法进入 | sudo chmod -R 755 /opt/jdk/jdk-*/;确认父目录有 o+rx。不要图省事 chmod 777 |
Error: Could not find or load main class |
①类名与文件名不一致;②不在 class 所在目录;③CLASSPATH 被污染 | 确认 HelloWorld.java 里是 public class HelloWorld;cd 到 class 目录;不要乱设 CLASSPATH |
程序输出中文变 ??? 或方块 |
JVM 默认字符集非 UTF-8(容器、最小化安装常见) | 启动加 -Dfile.encoding=UTF-8;同时确认系统 locale 已配 UTF-8 |
| 命令 | 作用 |
|---|---|
apt search openjdk-.*-jdk |
列出源里可用的 JDK 包 |
sudo apt install openjdk-21-jdk |
apt 安装 JDK 21(推荐方式) |
sudo apt install temurin-25-jdk |
从 Temurin 源安装 JDK 25 |
ls -1 /usr/lib/jvm |
列出所有已安装 JDK 的根目录 |
dirname $(dirname $(readlink -f $(which java))) |
反查 JAVA_HOME 该填什么(最实用) |
readlink -f $(which java) |
查看 java 命令最终指向的真实文件 |
update-alternatives --display java |
查看 java 的所有候选与当前选择 |
sudo update-alternatives --install /usr/bin/java java <路径> 21 |
注册一个 JDK 候选 |
sudo update-alternatives --config java |
交互式切换 java 版本 |
source /etc/profile.d/jdk.sh |
让 JAVA_HOME 在当前 shell 立即生效 |
java -version / javac -version |
查看运行时 / 编译器版本(两者必须一致) |
echo $JAVA_HOME |
确认 JAVA_HOME 已生效 |
jshell |
进入交互式 REPL,顺带验证装的是完整 JDK |
javac X.java && java X |
编译 + 运行,验证整条工具链 |
sudo systemctl show <unit> -p Environment |
确认服务实际拿到的环境变量 |
signed-by= 取代 apt-key 的完整原理[!IMPORTANT] 本章所有版本号截至 2026-09。全篇版本信息的唯一权威来源是 版本速查表。
[!WARNING] 在读任何命令之前,先记住这一条:永远不要删除、替换或覆盖系统自带的
/usr/bin/python3。原因不是「不推荐」,而是「会炸」:apt 背后的多个系统工具(
add-apt-repository、unattended-upgrades、云初始化工具 cloud-init 等)是用 Python 写的,且 apt 本身依赖 python3-apt,它们内部写死了调用/usr/bin/python3。你一旦把它换成 3.13,就会出现因版本差异导致的语法不兼容,或者误删了它依赖的标准库。后果是:
apt挂了、add-apt-repository挂了、系统可能连有问题的包都修不回来,只能重装系统。这不是危言耸听,是运维圈里年年有人踩的经典坑。正确做法是「加法不是替换」:在系统 Python 之外另外装一个版本,然后每个项目用虚拟环境 venv 隔离。本文所有方案都遵循这条原则。
| 项目 | 本章基准 | Ubuntu 22.04 源默认 | 推荐获取方式 |
|---|---|---|---|
| 系统自带(不许动) | Python 3.10 | /usr/bin/python3 |
—— 已预装,apt 等系统工具依赖它 |
| 推荐(稳妥) | Python 3.12 / 3.13 | 源里没有 | deadsnakes PPA / pyenv / 源码编译到 /opt |
| 最新 | Python 3.14 | 源里没有 | deadsnakes PPA / pyenv / 源码编译 |
| 已 EOL,不要用 | 3.8 及更早(含 2.7) | 无 | —— 已无安全更新 |
[!NOTE] 完整跨组件版本对照见 91 版本速查表。本教程约定:新项目默认用 3.12 或 3.13。
先明确:3.10 本身没坏,它只是「系统的」,不是「你的」。
apt 包管理体系。你往里塞第三方包,就和包管理器产生冲突 —— 哪天 apt 要升级系统组件,可能和你塞进去的包版本打架requests==2.25,B 项目需要 requests==2.31,两者无法同时存在于同一个解释器里所以规矩是:系统 Python 留给操作系统,你的应用另起炉灶。
按 91 版本速查表 的建议:稳妥选 3.12 或 3.13。
numpy、pandas、torch、Django、FastAPI)都有对应的预编译 wheel,国内源的 wheel 覆盖也更全pip install 你真正要用的那几个包,确认装的是 wheel(几秒装完)而不是源码编译[!WARNING] 绝对不要用已经 EOL 的版本(Python 3.8 及更早,包括早已停止维护的 2.7)。 它们不再接收任何安全更新。老项目迁移确实痛苦,但延期不等于免费 —— 用 EOL 版本上线,等于把一堆已知且公开利用方式的漏洞留在服务器上。
| 场景 | 建议版本 |
|---|---|
| 全新项目,无历史包袱 | 3.13(主流库支持已完善) |
| 要用 PyTorch / TensorFlow 等重依赖 | 3.12(框架厂商的预编译包覆盖最广,踩坑最少) |
| 已有项目,目标是跟上 vendor 支持 | 3.12 或 3.13 |
| 明确的内部老系统要求版本 | 按其文档(但请同步推动升级) |
三条路线,先看对比:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ① deadsnakes PPA | 服务器上固定用一个版本(最常见) | apt 管理、卸载干净、升级方便 | 第三方源,有信任风险;版本更新略滞后 |
| ② pyenv | 开发者本机、频繁切换多个版本 | 用户级安装不动系统、切换极其方便 | 编译耗时;服务器上不如 PPA 直观 |
③ 源码编译到 /opt |
内网离线、需要特定编译参数、或源里根本没有的版本 | 完全可控、可装任意小版本 | 步骤多、要自己装编译依赖、不随 apt 更新 |
[!TIP] 服务器部署首选 ① deadsnakes PPA。 环境变量天然正确、不需要额外配置、卸载一条命令,运维负担最小。只有当你确实要在一台机器上跑多个不同 Python 版本的项目时,才考虑 ②。
方式一:deadsnakes PPA(服务器首选)。 Ubuntu 官方源在一个 LTS 生命周期内不会新增 Python 大版本(为了稳定性,这是有意为之),要在 22.04 上装 3.12/3.13 就得加第三方源。deadsnakes 是 Ubuntu 社区里维护 Python 打包历史最久、口碑最好的 PPA。
[!WARNING] PPA 是个人或团队维护的第三方源,不是 Ubuntu 官方源。 添加它意味着你信任 deadsnakes 的维护者:他们提供的包拥有 root 权限,会在你的系统上运行安装脚本。请选择用户量大的知名 PPA,不要在生产服务器上随便加来路不明的源。若公司有安全合规要求,优先用方式三或官方 Docker 镜像。
# 先安装添加 PPA 所需的工具(最小化安装可能没有)
$ sudo apt install software-properties-common
# 添加 deadsnakes PPA(按提示回车确认即可)
$ sudo add-apt-repository ppa:deadsnakes/ppa
# 刷新源索引
$ sudo apt update
# 安装 3.13 —— 注意要装三个包,别只装第一个
$ sudo apt install python3.13 python3.13-venv python3.13-dev
方式二:pyenv(多版本频繁切换)。 它是纯用户级的版本管理器:所有 Python 版本装到 ~/.pyenv/versions/ 下,完全不碰 /usr/bin/,通过在该用户 PATH 前插桩实现切换。
# 安装编译 Python 需要的依赖
$ sudo apt install make build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev liblzma-dev libffi-dev curl git wget
# 安装 pyenv
$ curl -fsSL https://pyenv.run | bash
# 把初始化代码写进 shell 配置,并让它立即生效
$ cat >> ~/.bashrc <<'EOF'
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
EOF
$ exec $SHELL
# 安装并使用 3.13(会现场编译,耗时较长)
# 补丁号以 python.org / 91 章核对方法为准,把 x 换成实际补丁号
$ pyenv install 3.13.x
$ pyenv global 3.13.x
方式三:源码编译到 /opt。 这是唯一能安装任意版本的途径(内网离线机尤其适用)。关键是把安装目录指定到 /opt/python3.13 这样的独立目录,而不是默认的 /usr/local —— 这样它和系统 Python 井水不犯河水。
# 安装编译依赖(与方式二那条相同,取其一即可)
$ sudo apt install build-essential libssl-dev zlib1g-dev libbz2-dev \
libreadline-dev libsqlite3-dev liblzma-dev libffi-dev wget
# 下载并解压源码(补丁号以 python.org / 91 章核对方法为准,把 x 换成实际补丁号)
$ cd /tmp
$ wget https://www.python.org/ftp/python/3.13.x/Python-3.13.x.tgz
$ tar -xzf Python-3.13.x.tgz
$ cd Python-3.13.x
# 配置:--prefix 指定独立安装目录,--enable-optimizations 开启性能优化
$ ./configure --prefix=/opt/python3.13 --enable-optimizations --with-ensurepip=install
# 并行编译后安装 —— 务必写成 altinstall,原因见下方警告
$ make -j$(nproc)
$ sudo make altinstall
| 参数 / 包名 | 作用 |
|---|---|
python3.13 |
解释器本体。不装这个别说 hello world |
python3.13-venv |
提供 venv 虚拟环境模块。 Ubuntu 的打包惯例把 venv 拆成独立包,漏装会得到「No module named venv」 |
python3.13-dev |
提供 Python.h 等头文件。凡是 pip 要编译 C 扩展(numpy、psycopg2、lxml、mysqlclient…)都必须有它,否则报 fatal error: Python.h: No such file or directory |
pyenv install <版本> |
下载源码编译安装到 ~/.pyenv/versions/<版本>/ |
pyenv global <版本> |
设当前用户全局默认版本;pyenv local 只作用于当前目录(写 .python-version),pyenv versions 列出所有已装版本 |
--prefix=/opt/python3.13 |
安装到独立目录,不占用 /usr/local,方便日后整个删除 |
--enable-optimizations |
开启 PGO 优化,运行性能提升约 10–20%,代价是编译时间翻倍 |
--with-ensurepip=install |
装完自动带上 pip,省得再单独引导 |
make -j$(nproc) |
nproc 输出 CPU 核心数,-j 并行编译 |
make altinstall |
见下方警告,这是本节最重要的一条 |
[!IMPORTANT] 方式一的三个包是一套,只装
python3.13是新手最常见的疏漏,表现为:能import标准库,一装第三方包就各种奇怪报错。
[!WARNING] 必须用
make altinstall,绝对不能用make install。 两者唯一差别是:altinstall不会创建不带版本号的符号链接。而make install会生成/usr/local/bin/python3—— 在 PATH 里/usr/local/bin排得比/usr/bin靠前,于是它会抢在系统 Python 前面生效。表面看只是版本变了,实际却是所有系统工具(apt、cloud-init…)从此开始用你的新解释器,一旦版本不兼容就全线崩溃(回忆本章开头的警告)。altinstall只生成/usr/local/bin/python3.13,多一个新命令,绝不影响python3—— 这正是我们要的「加法」。
方式一,务必用带版本号的命令验证,同时确认系统 Python 安然无恙:
$ python3.13 --version
$ python3 --version
预期输出:
Python 3.13.x
Python 3.10.x
[!NOTE] 两个版本并存是正常且正确的结果。
python3仍指向 3.10(系统用),多出来的python3.13给你的应用用。补丁号可能不同:python3.13以实际安装的补丁号为准(核对方法见 python.org / 91 章),python3以 3.10 开头为准。如果python3 --version变成了 3.13,说明哪里出错了,请立刻检查是不是有人动过/usr/bin/python3。
方式二,看已安装版本列表(* 标记当前生效的):
$ pyenv versions
预期输出:
system
* 3.13.x (set by /home/ubuntu/.pyenv/version)
方式三,验证的同时确认没有污染系统 —— 最后一行的报错才是正确的,说明没有生成那个会破坏系统工具的软链接:
$ /opt/python3.13/bin/python3.13 --version
$ which python3
$ ls -l /usr/local/bin/python3 2>&1
预期输出:
Python 3.13.x
/usr/bin/python3
ls: cannot access '/usr/local/bin/python3': No such file or directory
确认无误后把它加进 PATH 方便手动使用:
$ sudo tee /etc/profile.d/python313.sh > /dev/null <<'EOF'
export PATH=/opt/python3.13/bin:$PATH
EOF
$ source /etc/profile.d/python313.sh
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install python3.13 python3.13-venv python3.13-dev |
sudo dnf install python3.12 python3.12-devel |
RHEL 9 AppStream 自带 3.9 / 3.11 / 3.12,不需要 PPA |
python3.13-venv |
通常已包含在 python3.12-libs 里 |
RHEL 打包方式不同,venv 一般不独立成包 |
python3.13-dev |
python3.12-devel |
头文件包命名不同 |
sudo apt install build-essential |
sudo dnf groupinstall "Development Tools" |
编译器套件 |
sudo apt install libssl-dev |
sudo dnf install openssl-devel |
缺了会导致 SSL module not available |
make altinstall |
同左 | 这条规则跨发行版通用 |
| deadsnakes PPA | 无此概念,用 AppStream module 或源码 | dnf module list python |
PATH:决定敲 python 时跑哪个。 shell 从左到右扫描这些目录,命中第一个就停,所以先后顺序直接决定版本优先级 —— 这正是上面 make install 会出大事的原因(/usr/local/bin 排在 /usr/bin 前面,悄悄就把系统 Python 顶掉了)。
$ echo $PATH
预期输出:
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PYTHONPATH:一般不需要设。 它的作用是给 import 语句额外增加搜索目录。初学者请遵循不要设置它:它会污染所有 Python 进程的模块搜索路径;它是全局且隐式的 —— 半年后你自己都忘了设过它,然后对着一个「明明没装却 import 得到」的模块百思不得其解;而且虚拟环境(下节)已经更干净地解决了「找不到自己写的模块」这个问题。唯一例外是需要临时加目录时,用行内前缀、不 export:
# 只对这一条命令生效,不留后患
$ PYTHONPATH=/opt/myproj/src python3 script.py
VIRTUAL_ENV:虚拟环境的标记变量。 激活 venv 后 shell 里会多出这个变量,内容是那个虚拟环境目录的路径,由 activate 脚本自动设置,你不要手动改。程序可以用它判断自己是否运行在虚拟环境里,pip、python 等工具也会参考它。
$ source ~/myapp/.venv/bin/activate
$ echo $VIRTUAL_ENV
$ python3 -c "import sys; print(sys.executable)"
预期输出:
/home/ubuntu/myapp/.venv
/home/ubuntu/myapp/.venv/bin/python
[!TIP] 判断当前终端是否处于虚拟环境中,看命令行提示符开头的
(.venv)最直观 —— 这比echo变量快得多。sys.executable永远输出「当前正在运行的这个解释器的实际路径」,是定位「我到底在用谁」最权威的方式,比which更准。
Python 层面真正的「必要配置」,核心就是每个项目一个独立的 venv,外加一份顺手的 pip 配置。
是什么。 虚拟环境是一个目录,里面装着一份「轻量的 Python」:
/home/ubuntu/myapp/.venv/
├── bin/python → 指向创建它的那个解释器
├── bin/pip → 只往这个环境装包
├── bin/activate → 激活脚本
└── lib/python3.13/site-packages/ ← 第三方包只装在这里
为什么非用不可?① 版本冲突 —— A 项目要 Django 4.2、B 项目要 Django 5.1,而一个包在一个解释器里只能有一个版本;② 不污染系统 —— pip 默认往系统级 site-packages 装包,装多了系统 Python 会变成一锅不知道谁放的依赖的杂烩,且难以回滚。类比:每个项目一个 venv,就像每个工程一个独立的工具箱,工具互不干扰,丢了一个箱子也不影响别的工程。
怎么做。 假设项目在 /home/ubuntu/myapp:
$ cd /home/ubuntu/myapp
# 用 3.13 创建名为 .venv 的虚拟环境(-m venv 是标准写法)
$ python3.13 -m venv .venv
# 激活(激活后提示符前面会出现环境名)
$ source .venv/bin/activate
# 停用
$ deactivate
激活后的提示符长这样:
(.venv) ubuntu@server:~/myapp$
activate 到底做了什么。 理解这个,你就不会再遇到「明明 activate 了却还是系统 Python」。它只做两件事:① 把 .venv/bin 插到 PATH 最前面 —— 于是敲 python / pip 命中的是这个虚拟环境里的而不是系统的;② 改 shell 提示符加 (.venv) —— 一个纯视觉提醒。仅此而已,虚拟环境不改变你的代码,它就是一个 PATH 技巧。
[!WARNING] 正因为
activate是靠改 PATH 生效的,它只对当前这一个终端窗口有效。新开 SSH 会话、在 cron 里、在 systemd 服务里都不会继承它 —— 必须在那里重新激活,或者干脆用绝对路径(见 systemd 一节)。
参数说明(python3.13 -m venv .venv):
| 片段 | 作用 |
|---|---|
python3.13 |
用哪个解释器创建,环境里就是哪个版本。 想建 3.12 环境就写 python3.12 -m venv |
-m venv |
以模块方式运行 venv。不要直接写 venv .venv,那种老写法已过时且该命令可能不存在 |
.venv |
目录名。点开头是为了默认隐藏,且它不该提交到 Git;叫这个名字还因为主流编辑器会自动识别它 |
--system-site-packages(可选) |
允许访问系统包。默认不用,代价是失去隔离性 |
包的安装与导出。 装依赖务必先激活环境;最可靠的做法是把依赖交给 requirements.txt:
$ source .venv/bin/activate
$ pip install requests flask gunicorn
# 把当前环境里所有包及精确版本导出成文件
$ pip freeze > requirements.txt
# 换台机器 / 新环境时,一键还原
$ pip install -r requirements.txt
[!IMPORTANT]
pip freeze导出的是所有已安装包及其精确版本(包括依赖的依赖),价值在于可复现:另一台机器上pip install -r能得到完全一致的环境。请务必把requirements.txt提交到 Git —— 这是团队协作和灾难恢复的救命文件。
[!WARNING] 永远不要用
sudo pip install。sudo会以 root 身份执行 pip,此时虚拟环境的 PATH 设置被重置,pip 于是把包装进了系统 Python 的 site-packages。双重打击:① 违背 venv 隔离的初衷,你的虚拟环境里根本没装上这个包;② 可能覆盖 apt 管理的系统包,导致 apt 后续升级失败。症状通常是「虚拟环境里 pip install 成功了,但 import 还是找不到」。如果 pip 报权限错误,说明你还没激活虚拟环境,而不该用 sudo 解决。
pip 默认从官方 PyPI 拉包,国内直连常常很慢甚至超时,换镜像源是最简单有效的加速手段,详见 国内镜像加速。
# 永久配置(推荐):写入 ~/.config/pip/pip.conf,之后所有 pip 命令都生效
$ pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# 临时指定:只这一次生效
$ pip install flask -i https://pypi.tuna.tsinghua.edu.cn/simple
# 顺手把 pip 自己升级到最新
$ python -m pip install --upgrade pip
| 命令片段 | 作用 |
|---|---|
pip config set global.index-url <地址> |
写入用户级配置文件,之后所有 pip 命令都生效 |
-i <地址> |
index-url 的简写,只对当前这条命令生效 |
python -m pip |
比裸 pip 可靠,原因见下方 TIP |
[!TIP] 优先用
python -m pip而不是裸pip。 裸pip无法保证它对应哪个解释器 —— 尤其在多版本环境里,pip可能是 3.10 的而你的虚拟环境是 3.13,于是包装错了地方。python -m pip的含义是「用当前这个 python 解释器去运行 pip 模块」,两者必定一致,此法永远不会出错。
核对配置是否写成功:
$ pip config list
预期输出:
global.index-url='https://pypi.tuna.tsinghua.edu.cn/simple'
$ python3.13 --version
$ python3 --version
$ which python3
$ python3 -c "import sys; print(sys.executable)"
预期输出:
Python 3.13.x
Python 3.10.x
/usr/bin/python3
/usr/bin/python3
[!NOTE] 第二行必须以
3.10开头(补丁号可能不同)、第三行必须是/usr/bin/python3—— 这证明系统 Python 没被动过。如果变了,回到本章开头的 WARNING 重新读一遍。
$ cd ~/myapp
$ source .venv/bin/activate
$ which python
$ python -c "import sys; print(sys.prefix)"
$ deactivate
$ which python3
预期输出:
/home/ubuntu/myapp/.venv/bin/python
/home/ubuntu/myapp/.venv
/usr/bin/python3
[!IMPORTANT] 激活后
sys.prefix必须是.venv路径。如果输出的是/usr或/usr/local,说明没有真正激活虚拟环境 —— 此时 pip 会往系统里装包,请在搞砸系统之前回到上一节检查。
光看版本号不足以证明「这东西能干活」。下面起一个真实的 HTTP 服务并用 curl 打一个真实请求过去 —— 这一步同时验证了:虚拟环境生效、第三方包能装上能用、端口能监听、HTTP 响应正确。
$ source ~/myapp/.venv/bin/activate
$ pip install flask
$ cat > ~/myapp/app.py <<'EOF'
from flask import Flask, jsonify
import sys
app = Flask(__name__)
@app.route("/")
def index():
return jsonify({"python": sys.version.split()[0],
"executable": sys.executable})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
EOF
$ python ~/myapp/app.py
预期输出(注意最后一行,服务开始监听):
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment.
* Running on http://127.0.0.1:5000
另开一个 SSH 窗口(或先把上面这个进程用 Ctrl+Z + bg 放后台),请求它:
$ curl -s http://127.0.0.1:5000/
预期输出:
{"executable":"/home/ubuntu/myapp/.venv/bin/python","python":"3.13.x"}
看到 "python":"3.13.x"(补丁号以实际安装为准)且 "executable" 指向 .venv,就证明了 Flask 装在虚拟环境里、跑的就是这个虚拟环境的解释器、HTTP 链路全程通畅。验证完按 Ctrl+C 停止服务。
[!IMPORTANT]
app.run(...)这种 Flask 自带的开发服务器仅供调试,性能和安全性都不足以承载生产流量。正式部署请换成 gunicorn / uWSGI(WSGI 服务器)作为应用容器,再用 systemd 托管并用 Nginx 做反向代理,流程见下文和 29 Nginx 反代。另外上面示例只监听127.0.0.1,外部访问不到;要让外网能访问需调整监听地址并做好防火墙,详见 16 防火墙。
Python Web 应用上线同样交给 systemd 托管,详见 systemd 服务管理。
[!TIP] 在 systemd 里不要用
source .venv/bin/activate,直接写虚拟环境里 Python 的绝对路径。 原因:activate本质是个 shell 脚本,靠修改 PATH 生效;而 systemd 启动进程时不经过任何 shell,也不继承你终端里的环境变量 —— 你在终端里 activate 得再熟练,跟服务进程没有半点关系。直接写/home/ubuntu/myapp/.venv/bin/python(或 gunicorn 的路径)则完全不依赖 PATH、不依赖任何初始化顺序,这也是解决「服务起不来但命令行跑得好好的」这类经典问题的关键。
以 gunicorn 为例,app.py 位于 /home/ubuntu/myapp:
[Unit]
Description=My Python Flask Application
After=network.target
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/home/ubuntu/myapp
# 关键:全部用绝对路径,直接调用虚拟环境内的可执行文件
ExecStart=/home/ubuntu/myapp/.venv/bin/gunicorn \
--workers 2 \
--bind 127.0.0.1:8000 \
--access-logfile - \
--error-logfile - \
app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now myapp.service
$ sudo systemctl status myapp.service
| 配置项 | 作用 |
|---|---|
ExecStart=.../.venv/bin/gunicorn |
gunicorn 装在 venv 里,写它的绝对路径;误写成 /usr/bin/gunicorn 会导致启动失败 |
--workers 2 |
worker 进程数。经验公式 CPU 核心数 × 2 + 1 |
--bind 127.0.0.1:8000 |
只监听本地,由 Nginx 对外反代。这是更安全的做法 |
--access-logfile - |
输出到 stdout,交给 journald 统一收集 |
app:app |
前半是文件名 app.py,后半是文件里的 Flask 实例变量名 |
User= / Group= |
指定运行用户。虚拟环境目录必须对该用户可读 |
$ sudo systemctl is-active myapp.service
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8000/
$ sudo journalctl -u myapp.service -n 20 --no-pager
预期输出:
active
HTTP 200
第一行确认服务活着,第二行确认真的能响应请求,第三行看有没有异常日志。
| 现象 | 根因 | 解决 |
|---|---|---|
敲 python 报 command not found |
Ubuntu 只有 python3,故意不提供 python |
想省事可用 python-is-python3 或 alternatives;推荐:不要纠结,习惯用 python3 |
python 存在但指向 Python 2 |
老系统上 python 可能指向已 EOL 的 Python 2 |
别用裸 python,一律写 python3 |
apt search python3.12 找不到包 |
22.04 官方源没有 3.12+ | 添加 deadsnakes PPA,或用 pyenv / 源码编译 |
add-apt-repository: command not found |
缺 software-properties-common |
sudo apt install software-properties-common |
No module named venv |
漏装了 python3.X-venv 包 |
sudo apt install python3.13-venv |
pip 装包报 fatal error: Python.h: No such file or directory |
缺头文件包 | sudo apt install python3.13-dev |
No module named _ssl / SSL module not available |
编译前缺 OpenSSL 开发库 | sudo apt install libssl-dev 后重新 configure + make |
pip install 慢到超时 |
连的是国外官方源 | pip config set global.index-url ...,见 33 镜像加速 |
pip 装包报 externally-managed-environment |
Ubuntu 启用 PEP 668 保护,禁止往系统 Python 里装包。补注:EXTERNALLY-MANAGED 标记是 Ubuntu 23.04+/Debian 12 引入,22.04 自带的 pip 22.0.2 不报此错;若在 22.04 遇到请先想 venv | 这是保护机制,别用 --break-system-packages 绕过。 正确做法是建 venv |
明明 source activate 了,which python 还是 /usr/bin/python |
①用了 sh 而非 bash(activate 是 bash 脚本);②误用 sudo;③在脚本里 source 后没在同一进程继续跑 |
用 echo $0 确认当前是 bash;不要用 sh activate;systemd / 脚本场景改用绝对路径 |
venv 里 pip install 成功,运行时却 ModuleNotFoundError |
pip 和 python 不是同一个解释器(最常源于 sudo pip 或裸 pip) |
一律用 python -m pip install;用 which python 与 pip --version 核对路径一致 |
ModuleNotFoundError: No module named 'myapp' |
不在项目根目录运行,或该目录不在搜索路径 | 先 cd 到项目根目录;生产代码建议打包安装(pip install -e .)而不是设 PYTHONPATH |
不小心 make install 覆盖了系统 python3 |
生成了破坏性软链 /usr/local/bin/python3 |
立刻检查 which python3 是否变成 /usr/local/bin/python3;若是,谨慎处理该软链使 /usr/bin/python3 重新生效并核查系统包完整性。操作前先备份数据,此问题切勿再拖延扩大 |
| 命令 | 作用 |
|---|---|
python3 --version |
查看系统 Python 版本(应为 3.10,不变才对) |
python3.13 --version |
查看新装版本(用带版本号的命令,别只看 python3) |
which python3 |
查看系统调用的解释器路径(应为 /usr/bin/python3) |
python3 -c "import sys; print(sys.executable)" |
权威确认当前运行的解释器实际路径 |
sudo add-apt-repository ppa:deadsnakes/ppa |
添加 deadsnakes PPA 源 |
sudo apt install python3.13 python3.13-venv python3.13-dev |
安装 Python 3.13(三个都要装) |
pyenv install <版本> / pyenv global <版本> |
安装 / 切换 pyenv 管理的版本 |
./configure --prefix=/opt/python3.13 --enable-optimizations |
源码安装的配置步骤(关键是 --prefix) |
sudo make altinstall |
安装但不覆盖系统 python3(绝不能写成 install) |
python3.13 -m venv .venv |
创建虚拟环境 |
source .venv/bin/activate / deactivate |
激活 / 退出虚拟环境 |
python -m pip install <包> |
装包(比裸 pip 可靠,保证与当前解释器一致) |
pip install -r requirements.txt |
按清单批量还原依赖 |
pip freeze > requirements.txt |
导出依赖清单(提交到 Git) |
pip config set global.index-url <地址> |
永久配置镜像源 |
echo $VIRTUAL_ENV |
查看虚拟环境目录(激活时才有值) |
signed-by= 取代 apt-keychmod 755 与 777 的区别update-alternatives 多版本管理configure / make / altinstall 通用套路部署 Node 项目只需要三件事:装上运行时 → 装依赖 → 让进程别死。本章讲清这三步,以及服务器上最容易踩的坑:nvm 装的东西,systemd 看不见。
[!NOTE] 本章所有版本号截至 2026-09,以 版本速查表 为唯一权威来源。
Node.js 用奇偶区分版本性质:偶数版(20 / 22 / 24)是 LTS,有约 30 个月安全更新;奇数版(21 / 23 / 25)是短期版,只有约 6 个月支持期。这样设计是因为 Node 每 6 个月一个大版本,不区分就没人记得住该用哪个。记住一句话:偶数能上线,奇数只能玩。(注意:Node 20 已于 2026-04 停止支持(EOL),老项目仍能运行但不再有安全补丁,新项目请选 24。)
package.json 的 engines 字段,确认它对 Node 版本的要求apt policy nodejs 看源里有什么,别盲装# 看系统源里 nodejs 包的候选版本
$ apt policy nodejs
| 项目 | 值 | 说明 |
|---|---|---|
| 本章推荐 | Node.js 24 LTS | 偶数 = LTS,奇数 = 短期版 |
| Ubuntu 22.04 源默认 | 12.x(早已 EOL,视同没有) | 版本极旧且已停止维护,按「源里没有」处理 |
| 获取方式 | NodeSource 官方源 / nvm | 见下一节对比 |
| npm | 随 Node.js 一起提供 | 不需要单独安装 |
| 版本核对 | 版本速查表 | 全篇唯一权威来源 |
版本号 24.x.y:24 主版本(决定 LTS 与否)/x 次版本(新特性)/y 补丁(修 bug 与安全,建议跟随升级)。
$ node -v
预期输出:
v24.13.0
| 现象 | 根因 | 解决 |
|---|---|---|
apt install nodejs 装出来是 12.x |
用了系统源里的旧包 | 卸掉,改用 NodeSource 源或 nvm |
| 项目要求 Node 18,我装了 24 | 版本不匹配 | 用 nvm 多版本共存切换 |
| 别人用 22 也能跑,我选哪个 | LTS 线同时有多个 | 选 engines 允许范围内最新的 LTS |
| 想跟上补丁版本 | 小版本只修 bug 和安全 | sudo apt update && sudo apt upgrade nodejs |
三条路线本质是「装到哪、谁能用」的区别:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| NodeSource 官方源 | 生产服务器、只需一个版本 | 装到 /usr/bin,所有用户和 systemd 都能找到;可 apt 统一升级 |
换大版本要换源;需要 root |
| nvm | 开发机、需要多版本切换 | 一个命令切版本;不用 sudo;不污染系统目录 | 只装在当前用户家目录,其他用户和 systemd 看不到 |
系统源 apt install nodejs |
无 | 一条命令,最简单 | 版本极旧且已停止维护,装了等于埋雷 |
路线一:NodeSource 官方源(服务器推荐)
# 下载并执行 NodeSource 源配置脚本
$ curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
# 安装,nodejs 包自带 npm
$ sudo apt install -y nodejs
[!NOTE] 管道执行脚本有风险:你没看到内容就让它跑了。正规做法是先
curl -fsSL ... -o setup.sh、less setup.sh看过,再sudo -E bash setup.sh。习惯:任何curl ... | bash都先下载、看过、再执行。
路线二:nvm(开发机 / 多版本推荐)
# 安装 nvm(装到 ~/.nvm,并自动写入 ~/.bashrc);版本号以 nvm 的 GitHub releases 为准
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# 让当前终端立即生效,然后安装、切换、设为默认
$ source ~/.bashrc
$ nvm install 24
$ nvm use 24
$ nvm alias default 24
[!IMPORTANT] nvm 装的 Node 只存在于当前用户的
~/.nvm/versions/node/...目录里,这是服务器上最常见的坑:换一个用户登录node就不存在;systemd 服务、crontab、CI 脚本里也找不到 node —— 它们不加载~/.bashrc。生产服务器请用 NodeSource 路线。 已经用了 nvm 的,systemd unit 里必须写 node 的绝对路径。
[!WARNING] 路线三(系统源
sudo apt install -y nodejs npm)不要在服务器上用。 它装出来的是早已停止维护的旧版本,不再收到安全补丁,且npm install常因引擎不兼容直接失败。
| 片段 | 含义 |
|---|---|
curl -fsSL / sudo -E bash - |
-f 失败不输出错误页、-s 静默、-L 跟随重定向;-E 保留环境变量(如代理) |
nvm install 24 |
装 24 主版本下的最新 LTS |
nvm use 24 |
只影响当前终端,关掉就还原 |
nvm alias default 24 |
改默认版本,影响新开的终端 |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - |
curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - |
两个体系都提供源脚本 |
sudo apt install -y nodejs |
sudo dnf install -y nodejs |
包名相同,都自带 npm |
sudo apt remove -y nodejs |
sudo dnf remove -y nodejs |
卸载 |
$ which node
预期输出(NodeSource 路线;nvm 路线会是 /home/ubuntu/.nvm/versions/node/v24.13.0/bin/node,看到家目录路径就要警惕 systemd 找不到):
/usr/bin/node
| 现象 | 根因 | 解决 |
|---|---|---|
node: command not found |
nvm 刚装完没加载 | source ~/.bashrc,或重开终端 |
sudo node 找不到而 node 可以 |
nvm 在普通用户家目录,sudo 的 PATH 不同 | 服务器改用 NodeSource;或用 node 绝对路径 |
| 换用户登录后没有 node | nvm 只对安装它的那个用户生效 | 换 NodeSource 路线 |
curl: (60) SSL certificate problem |
CA 证书过旧或系统时间不对 | sudo apt install -y ca-certificates;date 核对时间 |
环境变量是「告诉程序当前在哪跑」的开关。Node 生态里最重要的是 NODE_ENV:设为 production 时,npm 装依赖会跳过 devDependencies(babel、eslint 这类开发期工具不装),框架也会关闭调试输出、启用缓存。
NodeSource 方式:node 在 /usr/bin,本来就在默认 PATH 里,不需要配任何环境变量。
nvm 方式:安装脚本已往 ~/.bashrc 写入加载片段,新终端自动生效。若没写进去,手动补(随后 source ~/.bashrc 生效):
# 追加 nvm 加载片段到 ~/.bashrc
$ cat >> ~/.bashrc <<'EOF'
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
EOF
$ source ~/.bashrc
给应用设置生产环境:命令行里写 NODE_ENV=production node app.js;systemd 服务则写在 unit 的 [Service] 段里,一行 Environment=NODE_ENV=production。
| 变量 | 作用 | 建议值 |
|---|---|---|
PATH |
命令搜索路径 | NodeSource 无需改;nvm 由加载片段自动追加 |
NVM_DIR |
nvm 安装根目录 | ~/.nvm,一般不用改 |
NODE_ENV |
运行环境标识 | 服务器一律 production |
NODE_OPTIONS |
传给 node 的额外参数 | 内存不足时 --max-old-space-size=2048 |
$ node -p "process.env.NODE_ENV"
预期输出:
production
| 现象 | 根因 | 解决 |
|---|---|---|
设了 NODE_ENV 但框架没切换 |
变量没传进进程 | printenv | grep NODE_ENV 确认;systemd 要写 Environment= |
| systemd 服务里 node 找不到 | systemd 不读 .bashrc,nvm 的 node 不在 PATH |
unit 里写绝对路径 /home/<用户>/.nvm/versions/node/v24.13.0/bin/node |
| 依赖里混进很多开发工具包 | 忘了 NODE_ENV=production |
部署时显式设置,或 npm ci --omit=dev |
| 等号两边加了空格导致失效 | shell 语法要求紧贴 | 写成 NODE_ENV=production,等号两侧不留空格 |
装完 Node 到能投产,三件事配齐即可:
# ① 生产环境标识:部署与运行统一 production(systemd 场景写 Environment=NODE_ENV=production)
$ NODE_ENV=production npm ci --omit=dev
# ② 生产启动方式:不要裸跑 node(崩了不会自起),用 PM2 托管(见下一节),启动命令固定进部署脚本
$ pm2 start app.js --name myapp
# ③ 日志位置:PM2 默认把 stdout/stderr 落在 ~/.pm2/logs/ 下,别只让日志打在终端里
$ pm2 logs myapp
registry.npmjs.org 指向国内镜像站,内容一样但快得多# 装依赖(读 package.json,写 package-lock.json)
$ npm install
# 【部署/CI 用这个】严格按 lock 文件安装,版本分毫不差
$ npm ci
# 安装全局命令 / 执行 package.json 里定义的脚本 / 装 pnpm
$ npm install -g pm2
$ npm run build
$ npm i -g pnpm
# 换国内源(registry.npmmirror.com 是国内最常用的 npm 镜像)
$ npm config set registry https://registry.npmmirror.com
[!TIP]
npm ci与npm install的区别是部署时必须知道的:install会按语义化版本更新 lock 里允许范围内的版本(本地开发用);ci不改 lock 且会先删掉node_modules再精确安装,保证「测试装的 = 生产装的」。 另外 Node.js 自带 corepack,一条corepack enable就能让pnpm/yarn按项目声明的版本自动切换,不必手动全局安装。(corepack 自 Node 25 起不再随发行版捆绑;本章基线的 Node 24 内可用。)
等价的用户级配置文件是 ~/.npmrc:
registry=https://registry.npmmirror.com
更多镜像与排错见 国内镜像加速。
| 命令 / 参数 | 含义 |
|---|---|
install / i |
安装依赖 |
-g |
全局安装(供命令行直接使用) |
--save-dev / -D |
装成开发依赖,生产环境不装 |
ci |
按 lock 精确安装,先清 node_modules |
run <脚本名> |
执行 package.json 的 scripts 里定义的脚本 |
registry |
包下载地址配置项 |
$ npm config get registry
预期输出:
https://registry.npmmirror.com/
| 现象 | 根因 | 解决 |
|---|---|---|
EACCES: permission denied 写 /usr/lib/node_modules |
用过 sudo npm install -g,目录属主变 root |
不要用 sudo npm,改用下方「修复 EACCES」 |
npm install 极慢或卡住 |
默认 registry 在国外 | 换国内镜像 |
npm ci 报 lock 与 package.json 不一致 |
有人只改了 package.json | 本地跑 npm install 生成新 lock 并提交 |
| 换源后拉私有包失败 | 镜像站没有私有包 | 单独配 @scope:registry=<私有源> |
修复 EACCES 的正确做法(不是加 sudo):
$ mkdir -p ~/.npm-global
$ npm config set prefix ~/.npm-global
$ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
$ source ~/.bashrc
直接在终端跑 node app.js 有两个致命问题:进程崩了不会自己起来、服务器重启后不会自启。PM2 是 Node 生态的进程管理器,负责崩溃自动重启、开机自启、日志归集、多实例负载均衡。
[!IMPORTANT] 本章以 PM2 承担 systemd 托管职能(对应全书七段结构中的「systemd 托管」段):
pm2 startup底层同样是生成 systemd unit 并 enable(见下文),崩溃自启、开机自启、日志归集由它统一负责。若更倾向直接手写 systemd unit(日志统一进 journald),见 进程守护;PM2 的优势在于一台机器跑多个 Node 应用、集群模式(多核多实例)、或团队已熟悉 PM2。
# 全局安装 pm2
$ npm i -g pm2
# 启动脚本文件并起个名字;靠 npm scripts 启动的用引号包住整条命令
$ pm2 start app.js --name myapp
$ pm2 start "npm run start" --name myapp
# 查看列表 / 实时日志 / 终端仪表盘
$ pm2 list
$ pm2 logs myapp
$ pm2 monit
# 重启 / 停止 / 删除
$ pm2 restart myapp
$ pm2 stop myapp
$ pm2 delete myapp
开机自启(两步缺一不可):
# 生成一个 systemd unit 并 enable 它
$ pm2 startup
# 把当前应用列表存盘,重启后按这份列表恢复
$ pm2 save
[!NOTE]
pm2 startup会打印一条sudo env PATH=... pm2 startup ...命令,照着执行那一条才真正生效。它做的事是:在/etc/systemd/system/下生成pm2-<用户>.service并systemctl enable,所以你也能用systemctl status pm2-<用户>看它状态。
| 命令 / 参数 | 含义 |
|---|---|
--name myapp |
给进程起名,后续操作用它指代 |
--watch |
文件变化自动重启(生产别用,开发用) |
-i max / -i 2 |
集群模式:按 CPU 核数 / 指定数量起多实例 |
--max-memory-restart 500M |
内存超阈值自动重启,防内存泄漏 |
pm2 save |
把应用列表写入 ~/.pm2/dump.pm2 |
pm2 startup |
生成 systemd unit 实现开机自启 |
$ pm2 list
预期输出(看两点:status 是 online、↺ 重启次数是 0):
┌────┬───────┬──────┬───────┬────────┬────────┐
│ id │ name │ mode │ pid │ uptime │ status │
├────┼───────┼──────┼───────┼────────┼────────┤
│ 0 │ myapp │ fork │ 12345 │ 3m │ online │
└────┴───────┴──────┴───────┴────────┴────────┘
用最小 HTTP 服务验证整条链路(hello.js 监听 3000 并返回 hello node):
$ node hello.js &
$ curl localhost:3000
预期输出:
hello node
| 现象 | 根因 | 解决 |
|---|---|---|
pm2: command not found |
npm 全局目录不在 PATH,或 nvm 装的其他用户看不到 | npm config get prefix 查路径并加进 PATH |
| 重启机器后服务没起来 | 只跑了 startup,没跑 pm2 save |
补 pm2 save,用 pm2 resurrect 验证能恢复 |
↺ 重启次数猛涨 |
启动即崩溃(端口占用、配置缺失) | pm2 logs myapp --lines 100 看报错 |
| systemd 服务里 node 找不到 | nvm 的 node 不在 systemd 的 PATH | 写绝对路径 /home/<用户>/.nvm/versions/node/v24.13.0/bin/node |
EADDRINUSE :::3000 |
端口已被占用 | sudo ss -lntp | grep 3000 查占用者,换端口或停旧的 |
JavaScript heap out of memory |
默认堆上限不够 | NODE_OPTIONS=--max-old-space-size=2048 |
listen EACCES 监听 80/443 |
1024 以下端口只有 root 能绑定 | 见下方 WARNING,不要 root 跑 node |
[!WARNING] 绝对不要用 root 运行 Node.js 进程。 应用一旦有漏洞(任意文件读取、命令注入),攻击者拿到的就是 root,整台机器沦陷。 需要对外提供 80/443 时,正确做法是:应用监听 3000 等高端口 + Nginx 反向代理,见 Nginx 反代。
一台机器跑多个 Node 应用、或要给应用配一堆环境变量时,命令行参数会又长又容易忘。
ecosystem.config.js 把应用列表、启动方式、环境变量写进文件,一条命令全拉起。
module.exports = {
apps: [
{
name: 'api',
script: './app.js',
instances: 2,
exec_mode: 'cluster',
env: { NODE_ENV: 'development', PORT: 3001 },
env_production: { NODE_ENV: 'production', PORT: 3001 }
},
{
name: 'worker',
script: './worker.js',
env_production: { NODE_ENV: 'production' }
}
]
};
# 用默认 env 启动配置里的所有应用
$ pm2 start ecosystem.config.js
# 用 env_production 段的变量启动,改完配置再重启 + 保存
$ pm2 start ecosystem.config.js --env production
$ pm2 restart ecosystem.config.js --env production
$ pm2 save
| 字段 | 含义 |
|---|---|
name |
应用名,pm2 命令里用它指代 |
instances |
实例数;max 表示按 CPU 核数 |
exec_mode |
fork(单进程)或 cluster(多实例共享端口) |
env / env_production |
默认变量 / --env production 时生效并覆盖同名项 |
max_memory_restart |
内存超阈值自动重启,如 '500M' |
$ pm2 list
预期输出(api 有两个 pid,说明 cluster 模式生效):
┌────┬────────┬─────────┬───────┬────────┐
│ id │ name │ mode │ pid │ status │
├────┼────────┼─────────┼───────┼────────┤
│ 0 │ api │ cluster │ 12345 │ online │
│ 1 │ api │ cluster │ 12346 │ online │
└────┴────────┴─────────┴───────┴────────┘
| 现象 | 根因 | 解决 |
|---|---|---|
| 启动报语法错误 | 用了 JSON 语法或中文逗号 | 必须是 module.exports = { ... } 的 JS 语法 |
--env production 后变量没变 |
段名写错 | 段名必须叫 env_production,与命令参数对应 |
| cluster 模式报端口冲突 | instances > 1 却用 fork |
多实例共享端口只能用 cluster |
| 重启机器后应用没了 | 忘记 pm2 save |
改完配置一定要 pm2 save |
| 命令 | 作用 |
|---|---|
node -v / npm -v |
查看 Node.js / npm 版本 |
apt policy nodejs |
查看系统源里的 nodejs 版本 |
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - |
添加 NodeSource 24.x 官方源 |
sudo apt install -y nodejs |
安装 Node.js(含 npm) |
nvm install 24 / nvm use 24 / nvm alias default 24 |
安装 / 切换 / 设为默认版本 |
npm install / npm ci |
安装依赖 / 按 lock 精确安装 |
npm run build |
执行 package.json 里的 build 脚本 |
npm config set registry <url> / get registry |
设置 / 查看 npm 镜像源 |
npm i -g pnpm / pnpm install |
安装 pnpm / 用它装依赖 |
pm2 start app.js --name myapp |
用 PM2 启动应用并命名 |
pm2 start "npm run start" --name myapp |
用 npm 脚本启动 |
pm2 list / pm2 logs myapp / pm2 monit |
看列表 / 看日志 / 终端仪表盘 |
pm2 restart | stop | delete myapp |
重启 / 停止 / 删除应用 |
pm2 startup / pm2 save / pm2 resurrect |
生成自启 unit / 保存列表 / 恢复列表 |
pm2 start ecosystem.config.js --env production |
按配置文件启动并加载生产变量 |
MySQL 是 Web 项目里最常见的开源关系型数据库。本章走完一条完整链路:选版本 → 安装 → 安全初始化 → 最小必要配置 → 建库建用户 → 备份 → 托管给 systemd。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
MySQL 从 8.0 之后把版本线拆成两条:LTS 线(长期支持,只修 bug 与安全问题)和创新线(Innovation,频繁加特性,支持期很短)。选错线是新手第一个坑。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 8.0.x | 生态最成熟,驱动、ORM、运维脚本兼容性最好 |
| 新项目可选 | 8.4 LTS | 更新,支持期更长,但老驱动兼容性更差 |
| 生产不建议 | 9.x | 创新版,支持期短,只适合尝鲜 |
| 维护周期 | 8.0 已进入生命周期后段,8.4 LTS 支持更久 | 精确到期日查官方文档或 endoflife.date |
| License | 社区版 GPL v2,另有商业授权 | 自用免费;闭源分发或要官方兜底支持就买商业许可 |
装之前先确认源里有什么版本 —— 这是避免「装完发现版本不对」的唯一办法:
# 看系统源里 mysql-server 的候选版本
$ apt policy mysql-server
预期输出:
mysql-server:
Installed: (none)
Candidate: 8.0.4x-0ubuntu0.22.04.1
Candidate 就是 apt install 会装上的版本。小版本号随系统安全更新变化,大版本是 8.0 即符合本章基线;完整的 Version table 会列出它来自哪个源。
| 场景 | 选哪个 | 为什么 |
|---|---|---|
| 学习、跟教程做、老项目迁移 | 8.0.x | 网上绝大多数教程都基于 8.0,出问题最好搜 |
| 全新项目、团队能自己排错 | 8.4 LTS | 支持周期更长,不用过两年就被迫大版本迁移 |
| 想尝鲜新特性 | 9.x | 可以,但别放生产:创新版到期后没有安全补丁 |
| 已有 8.0 生产库要升级 | 不要原地升 | 见下方 WARNING |
[!WARNING] 数据库大版本不要原地
apt upgrade。 正确做法:新版本另起实例 → 导数据 → 业务切换 → 观察。原地升到 8.4 可能直接起不来,且数据目录格式升级后无法回退。
[!NOTE] 8.4 的一个兼容性变化:8.4 起
mysql_native_password默认不再启用。若老驱动只能走旧插件,8.4 上需额外启用它(写法以官方文档为准),否则优先升级应用侧驱动。
# 装完回头核对真实版本
$ mysql --version
预期输出:
mysql Ver 8.0.4x for Linux on x86_64 ((Ubuntu))
| 现象 | 根因 | 解决 |
|---|---|---|
apt policy 里只有 8.0,想要 8.4 |
系统源只提供一条版本线 | 添加 MySQL 官方 apt 源,见「安装」 |
| 装的居然是 MariaDB | 某些镜像源/面板把 mysql-server 指向 MariaDB |
apt policy mysql-server 看 Candidate 的源地址,卸载后重装 |
| 方式 | 拿到的版本 | 复杂度 | 适合 |
|---|---|---|---|
| 系统源 apt | 8.0.x | 低 | 新手首选,一条命令,跟着系统安全更新走 |
| MySQL 官方 apt 源 | 8.0 / 8.4 自选 | 中 | 需要 8.4 LTS 或紧跟官方补丁 |
| Docker | 任意 | 中 | 本地开发、多版本并存,见 Docker |
# 刷新索引,再装服务端(会连带装好客户端)
$ sudo apt update
$ sudo apt install -y mysql-server
装完服务会自动启动并设为开机自启,可用 sudo systemctl status mysql 确认。
[!WARNING] 不要用
apt-key add。apt-key已废弃并在新版 Ubuntu 上被移除;把密钥加进全局钥匙环,等于信任它签名的所有源。正确做法:密钥放/usr/share/keyrings/,源条目里用signed-by=显式指定,一把钥匙只开一把锁。
方式二 A —— 官方源配置包(mysql-apt-config,有交互界面):
# 包版本号会更新,先到 dev.mysql.com/downloads/repo/apt/ 复制最新 .deb 链接
$ wget <官方页面复制的 mysql-apt-config 链接>
# 安装源配置包,弹出界面里选版本(如 mysql-8.4-lts)后确认退出
$ sudo dpkg -i mysql-apt-config_*.deb
# 加完源必须刷新索引,否则 apt 看不到新版本
$ sudo apt update
方式二 B —— 手写 signed-by 源:
# 1. 下载官方 GPG 密钥,转成二进制钥匙环
$ curl -fsSL <官方 GPG 密钥 URL> | sudo gpg --dearmor -o /usr/share/keyrings/mysql.gpg
# 2. 写源条目,组件名(mysql-8.4-lts)以官方文档为准
$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mysql.gpg] https://repo.mysql.com/apt/ubuntu jammy mysql-8.4-lts" | sudo tee /etc/apt/sources.list.d/mysql.list
# 3. 刷新并确认候选版本,再安装
$ sudo apt update
$ apt policy mysql-server
$ sudo apt install -y mysql-server
[!NOTE] 密钥 URL 和组件名会随官方调整,务必从官方文档复制。装之前先看
apt policy的 Candidate 是不是你要的版本。
一句带过,见 Docker:docker run -d --name mysql8 -e MYSQL_ROOT_PASSWORD='<强密码>' -p 127.0.0.1:3306:3306 mysql:8.0。
[!WARNING] 上面端口特意写成
127.0.0.1:3306:3306。不要写-p 3306:3306—— 那会把数据库暴露到所有网卡,等于对公网开放 3306。
| 包名 | 作用 |
|---|---|
mysql-server |
服务端 + 客户端 + 数据目录初始化脚本 |
mysql-client |
客户端命令(mysql、mysqladmin、mysqldump) |
$ mysql --version
$ dpkg-query -W -f='${Version}\n' mysql-server
预期输出:
mysql Ver 8.0.4x for Linux on x86_64 ((Ubuntu))
8.0.4x-0ubuntu0.22.04.1
| 现象 | 根因 | 解决 |
|---|---|---|
apt update 报 GPG 错误 |
密钥放错位置或 signed-by 路径写错 |
检查 /usr/share/keyrings/mysql.gpg 是否存在、路径是否一致 |
| 加了官方源还是装到 8.0 | 交互界面没选 8.4,或系统源优先级更高 | apt policy 看 Candidate,用 apt install mysql-server=版本号 精确指定 |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install mysql-server |
sudo dnf install mysql-server |
RHEL 9 源里默认是 MariaDB 血统,要用 Oracle 版 MySQL 需启用官方 yum 源 |
服务名 mysql |
服务名 mysqld |
包名一样但服务名不同,最容易写错 |
/etc/mysql/mysql.conf.d/mysqld.cnf |
/etc/my.cnf + /etc/my.cnf.d/* |
配置拆分方式不同 |
/var/log/mysql/error.log |
/var/log/mysqld.log |
日志默认路径不同 |
apt 安装时 mysql、mysqladmin、mysqldump 都在 /usr/bin,而 /usr/bin 默认就在 PATH 里,所以通常什么都不用配。本节只在源码安装(装到 /usr/local/mysql)或脚本免交互连接时才有用。
# 只有源码安装才需要:追加而不是覆盖原有 PATH
$ echo 'export PATH=/usr/local/mysql/bin:$PATH' >> ~/.bashrc
$ source ~/.bashrc
脚本里要免交互连接,把凭据写进配置文件而不要用环境变量传密码(~/.my.cnf,权限 600):
[client]
user = root
password = <强密码>
$ chmod 600 ~/.my.cnf
[!WARNING]
MYSQL_PWD极不安全。 密码会明文出现在ps输出、shell 历史(.bash_history)和进程环境里,同机任何用户都能看到。一律改用权限 600 的~/.my.cnf。
| 变量 / 文件 | 作用 | 建议 |
|---|---|---|
PATH |
决定敲 mysql 时去哪找可执行文件 |
apt 安装无需改动 |
MYSQL_PWD |
环境变量传密码 | 不要用,改 ~/.my.cnf |
~/.my.cnf |
客户端默认连接参数 | 权限必须 600,否则被忽略 |
$ command -v mysql && mysql -e "SELECT 1;"
预期输出:
/usr/bin/mysql
1
1
(第一行是命令路径,后两行是 SELECT 1; 的结果:列名与值都是 1。)
| 现象 | 根因 | 解决 |
|---|---|---|
mysql: command not found |
只装了精简版,或 PATH 被改坏 | sudo apt install mysql-client;echo $PATH 看 /usr/bin 在不在 |
配了 ~/.my.cnf 仍要输密码 / 脚本里找不到 mysql |
权限太宽松被忽略 / cron 的 PATH 精简 | chmod 600 ~/.my.cnf;脚本里写绝对路径 /usr/bin/mysql |
装完的 MySQL 能跑,但默认状态不适合接业务。最小必要配置只做四件事:数据目录在哪、监听谁的连接、谁能登进来、用什么字符集存。做完这四件事(外加备份与日志),就可以安全地把库交给应用。
$ sudo mysql_secure_installation
向导逐项含义:
| 向导提问 | 建议 | 为什么 |
|---|---|---|
| 启用 VALIDATE PASSWORD 组件 | 学习环境 No,生产 Yes | 强制密码强度,但会让后续设密码变麻烦 |
| 设置 root 密码 | 设强密码 | 注意:Ubuntu 上 root 走 auth_socket,这一步可能直接跳过 |
| 删除匿名用户 | Yes | 匿名用户任何人都能连,是历史遗留后门 |
| 禁止 root 远程登录 | Yes | root 只留本机,应用一律用专用账号 |
| 删除 test 数据库 | Yes | 默认人人可读写,纯属攻击面 |
| 立即重载权限表 | Yes | 让改动立刻生效,不用重启服务 |
编辑 /etc/mysql/mysql.conf.d/mysqld.cnf,在 [mysqld] 段下加:
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_0900_ai_ci
为什么不是 utf8:MySQL 的 utf8 是最多 3 字节的伪 UTF-8,存不了 emoji 和部分生僻汉字,写入时报 Incorrect string value。utf8mb4 才是真正的 4 字节 UTF-8。建库时也要显式指定,不要指望默认值:
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
Ubuntu 上 root 默认用 auth_socket 插件:它不校验密码,而是校验「当前 Linux 用户是不是 root」。所以 mysql -u root -p 会失败,必须这样进:
# 把身份提为 root,插件校验通过,无需密码
$ sudo mysql
确实需要密码登录时(如某些运维脚本),改插件:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '<强密码>';
FLUSH PRIVILEGES;
[!NOTE] 交叉引用:MySQL 8.4 默认禁用
mysql_native_password插件(见本章开头 NOTE)。上面的 ALTER 示例适用于 8.0;8.4 用户勿照抄此示例——需先按官方文档启用该插件,否则该语句会执行失败。
[!WARNING] 改 root 认证插件是高风险操作:改错的瞬间你就被锁在数据库外面,恢复要靠
--skip-grant-tables走后门。更稳妥的是不动 root,另建管理员账号日常使用,root 留给sudo mysql兜底。
caching_sha2_password 兼容问题(重点难点)MySQL 8 默认认证插件是 caching_sha2_password,比旧的 mysql_native_password 更安全,但老客户端和老驱动不认识它,连接时报:
Authentication plugin 'caching_sha2_password' cannot be loaded
按驱动能力二选一:
-- 方式 A(首选):驱动够新(Connector/J 8+、mysqlclient 2+、PHP 7.4+),用默认插件
CREATE USER 'appuser'@'localhost' IDENTIFIED BY '<强密码>';
-- 方式 B(兜底):驱动太老(老版 mysqlclient、PHP 5.x、部分老 ORM),显式指定旧插件
CREATE USER 'appuser'@'localhost' IDENTIFIED WITH mysql_native_password BY '<强密码>';
[!TIP] 优先方式 A 并升级驱动,方式 B 是权宜之计。已建好的用户也能改:
ALTER USER 'appuser'@'localhost' IDENTIFIED WITH mysql_native_password BY '<强密码>';
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY '<强密码>';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
三条原则:不用 root 跑应用(root 权限无限大,一次 SQL 注入就能 DROP DATABASE);不给 ALL PRIVILEGES(应用只需增删改查,多给的 DROP、ALTER、FILE 都是事故放大器,建表迁移可临时给、迁完收回);不给 WITH GRANT OPTION(它能让 appuser 把权限转授别人,权限会被无限扩散)。末尾的 FLUSH PRIVILEGES; 在 GRANT/CREATE USER 之后其实不必需(只有直接改 mysql.user 表才需要),写上是为了保险。
需要远程连接时,主机部分差别很大,必须配合 bind-address 和防火墙一起改:
-- 允许任意主机连(危险面最大,只在防火墙/安全组已收敛时用)
CREATE USER 'appuser'@'%' IDENTIFIED BY '<强密码>';
-- 只允许内网网段连(推荐)
CREATE USER 'appuser'@'192.168.1.%' IDENTIFIED BY '<强密码>';
[!IMPORTANT] MySQL 账号是
'用户名'@'主机'二元组:'appuser'@'localhost'和'appuser'@'%'是两个完全不同的账号,密码和权限各自独立。连不上时先确认客户端 IP 对应的 host 有没有账号。
# 备份单个库(会提示输入密码)
$ mysqldump -u root -p appdb > appdb.sql
# InnoDB 生产库推荐:一致性快照 + 带上存储过程和触发器
$ mysqldump -u root -p --single-transaction --routines --triggers appdb > appdb.sql
# 恢复(前提:目标库已存在且字符集正确)
$ mysql -u root -p appdb < appdb.sql
--single-transaction 在一个事务里做一致性快照,不锁表,业务照常写入;不加它会锁表,导致备份期间应用卡死。日志:错误日志 /var/log/mysql/error.log(排错第一站);慢查询日志需先开 slow_query_log = 1。定时备份见 备份与定时任务,日志怎么看见 日志基础。
/etc/mysql/mysql.conf.d/mysqld.cnf 的 [mysqld] 段常用参数:
| 参数 | 示例值 | 作用与注意 |
|---|---|---|
bind-address |
127.0.0.1 |
只监听本机;0.0.0.0 表示监听所有网卡(见 WARNING) |
port / datadir |
3306 / /var/lib/mysql |
端口与数据目录;数据目录不要手动删改 |
max_connections |
151(默认) |
并发连接上限,报 Too many connections 时调大 |
innodb_buffer_pool_size |
物理内存的 50%–70% | InnoDB 缓存池,单机专用库最重要的性能参数;别超过内存否则 OOM |
slow_query_log |
1 |
开慢查询日志,配合 long_query_time = 2 定位慢 SQL |
character-set-server |
utf8mb4 |
服务端默认字符集 |
[!WARNING] 不要把 3306 对公网开放。
bind-address = 0.0.0.0加上防火墙放行 3306,等于把数据库摆在互联网上,几小时内就会被自动化脚本扫到并暴力破解。正确做法三选一:① 保持127.0.0.1,应用与数据库同机;② 只监听内网 IP,防火墙只放行内网网段(见 防火墙);③ 跨公网时用 SSH 隧道或内网专线。
# 生效的字符集
$ sudo mysql -e "SHOW VARIABLES LIKE 'character_set_server'; SHOW VARIABLES LIKE 'collation_server';"
# 各账号用的认证插件
$ sudo mysql -e "SELECT user, host, plugin FROM mysql.user;"
# 备份文件不是空文件
$ ls -lh appdb.sql && head -n 3 appdb.sql
预期输出:
+----------------------+--------------------+
| Variable_name | Value |
+----------------------+--------------------+
| character_set_server | utf8mb4 |
| collation_server | utf8mb4_0900_ai_ci |
+----------------------+--------------------+
| user | host | plugin |
+---------+-----------+-----------------------+
| root | localhost | auth_socket |
| appuser | localhost | caching_sha2_password |
+---------+-----------+-----------------------+
-rw-r--r-- 1 root root 12K Sep 29 10:00 appdb.sql
-- MySQL dump 10.13 Distrib 8.0.4x, for Linux (x86_64)
| 现象 | 根因 | 解决 |
|---|---|---|
改配置重启后起不来 / unknown variable |
参数名拼错、值非法,或写进了 [client] 段 |
看 /var/log/mysql/error.log 末尾并注释掉刚加的行;服务端参数必须写在 [mysqld] 段下 |
设了 utf8mb4 但 emoji 存不进 |
连接或表仍是 utf8 | 库、表、连接串三处都要 utf8mb4;SHOW CREATE TABLE 核对 |
| 备份文件 0 字节 / 恢复后乱码 | 密码或库名写错;目标库建成 utf8 | 去掉重定向看 stderr;cron 里写绝对路径 /usr/bin/mysqldump |
验证按「由内到外」分四层:程序版本 → 服务活着 → 本机连得上 → 应用账号能读写。任何一层失败就停在那里排查,别往下走。
按四层顺序逐条执行下面的命令即可(见「验证方法」);注意最后一条会提示输入密码,不要把密码写在 -p 后面(会进 shell 历史)。
mysqladmin status 输出字段的含义:
| 字段 | 含义 |
|---|---|
Uptime |
已运行秒数,突然变小说明刚重启过(可能崩过) |
Threads / Questions |
当前连接数 / 累计查询数 |
Slow queries |
慢查询累计条数,不为 0 就该去看慢日志 |
$ mysql --version
$ sudo systemctl is-active mysql
$ sudo mysqladmin status
$ mysql -u appuser -p -e "SHOW DATABASES;"
预期输出:
mysql Ver 8.0.4x for Linux on x86_64 ((Ubuntu))
active
Uptime: 1234 Threads: 3 Questions: 57 Slow queries: 0 Opens: 148 Flush tables: 3 Open tables: 141 Queries per second avg: 0.046
Enter password:
+--------------------+
| Database |
+--------------------+
| information_schema |
| appdb |
+--------------------+
[!NOTE]
appuser能看到information_schema是正常现象(所有用户都可见),但它只能看到appdb的业务数据 —— 这正是最小授权生效的证据。
| 现象 | 根因 | 解决 |
|---|---|---|
is-active 返回 inactive |
服务没启动 | sudo systemctl start mysql,再看日志 |
mysqladmin: connect to server failed |
socket 路径不对或服务没起 | sudo systemctl status mysql;SHOW VARIABLES LIKE 'socket'; |
应用账号看不到 appdb |
授权没生效或账号 host 不匹配 | SHOW GRANTS FOR 'appuser'@'localhost'; 核对 |
apt 安装的 MySQL 自带 systemd unit(mysql.service),装完就在跑。你只需掌握两件事:日常怎么启停、怎么开机自启。unit 结构与 Restart= 的原理见 systemd 服务管理。
# 启停、看状态(含最近几行日志,排错第一命令)、开机自启
$ sudo systemctl start mysql
$ sudo systemctl restart mysql
$ sudo systemctl status mysql
$ sudo systemctl enable mysql
[!NOTE] 服务名 Ubuntu 与 RHEL 不同:Ubuntu 是
mysql,RHEL / Rocky / AlmaLinux 是mysqld;写错会报Unit mysql.service not found,用systemctl list-units | grep -i mysql找真实名称。另外改配置用restart,MySQL 没有可靠的reload语义。
| 命令 | 作用 | 开机生效 |
|---|---|---|
start / stop / restart |
现在启停 / 加载新配置 | 否 |
enable |
建开机启动软链 | 是,但不会现在启动 |
enable --now |
开机自启 + 立刻启动 | 是 |
enable 的本质是在 /etc/systemd/system/multi-user.target.wants/ 下建软链接,原理见 systemd 服务管理。
$ sudo systemctl is-active mysql
$ sudo systemctl is-enabled mysql
预期输出:
active
enabled
| 现象 | 根因 | 解决 |
|---|---|---|
Unit mysql.service not found |
服务名写错或没装服务端 | `systemctl list-units |
Job for mysql.service failed / 改了配置没生效 |
配置错误、数据目录权限不对,或忘了 restart | journalctl -u mysql -n 50 --no-pager 看具体报错;确认改的是 mysqld.cnf 再 restart |
这一节是「连不上 / 用不对」的集中排查表。先按下面的固定顺序定位,再查表。
固定排查顺序(由内到外,每层通过再下一层):① 服务活着吗 systemctl is-active mysql → ② 本机连得上吗 sudo mysql → ③ 账号对不对 SELECT user, host, plugin FROM mysql.user; → ④ 网络通不通 ss -lntp | grep 3306 → ⑤ 防火墙 sudo ufw status → ⑥ 云安全组(控制台)。
远程连接失败时下面四层必须同时满足,缺一层就表现为「连不上」:
| 层 | 检查点 |
|---|---|
| MySQL 配置 | bind-address 不是 127.0.0.1 |
| MySQL 账号 | 存在 'user'@'<客户端IP或网段>' 账号 |
| 主机防火墙 | ufw 放行 3306(且只放行内网网段) |
| 云安全组 | 控制台放行对应来源 IP |
| 报错关键词 | 指向的层 |
|---|---|
Can't connect to local MySQL server through socket |
服务没起或 socket 路径错了,与网络无关 |
Access denied for user / Host ... is not allowed |
账号 / 密码 / host 三者之一不对 |
Authentication plugin ... cannot be loaded |
客户端驱动太老 |
Too many connections |
连接打满:参数偏小或连接池泄漏 |
$ sudo ss -lntp | grep 3306
预期输出:
LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=1234,fd=22))
看到 127.0.0.1:3306 说明远程连接必然失败 —— 先改 bind-address。
| 现象 | 根因 | 解决 |
|---|---|---|
root 登不上,Access denied for user 'root'@'localhost' |
root 用了 auth_socket,密码被忽略 |
用 sudo mysql;或 ALTER USER 改插件(有风险,见上文 WARNING) |
Authentication plugin 'caching_sha2_password' cannot be loaded |
客户端驱动太老 | 升级驱动;或建用户时 IDENTIFIED WITH mysql_native_password |
Can't connect to local MySQL server through socket '...' |
服务没起 / socket 挪走 / 数据目录损坏 | sudo systemctl start mysql;journalctl -u mysql -n 50;查 socket 变量 |
| 远程连不上 | 四层之一没配:bind-address、账号 host、ufw、云安全组 |
按上表逐层核对 |
| 中文乱码、emoji 存不进 | 用了 3 字节 utf8,或连接未指定 utf8mb4 |
服务端 + 建库显式指定 + 连接串带 charset=utf8mb4 |
Too many connections |
max_connections 太小或连接池泄漏 |
调大参数;SHOW PROCESSLIST 看 Sleep 连接;修应用连接池 |
| 忘记 root 密码 | 没有任何可用凭据 | 见下方 WARNING 的挽救步骤 |
[!WARNING] 忘记 root 密码的挽救步骤(高风险,最后手段):
--skip-grant-tables会跳过所有权限校验,期间任何能连上本机的人都能读写全部数据。要点:停服务 →mysqld_safe --skip-grant-tables --skip-networking启动(必须带--skip-networking,否则等于把无鉴权的库挂在网上)→ 本地改密码 → 立刻正常重启 → 验证鉴权已恢复。
| 命令 | 作用 |
|---|---|
apt policy mysql-server |
看源里能装到哪个版本 |
sudo apt install -y mysql-server |
安装 MySQL |
sudo mysql_secure_installation |
安全初始化向导 |
sudo mysql |
以 root 免密登录(auth_socket) |
mysql -u <用户> -p -e "<SQL>" |
执行一条 SQL 后退出 |
sudo systemctl start|restart|status mysql |
服务启停与状态 |
sudo systemctl enable --now mysql |
开机自启并立刻启动 |
sudo systemctl is-active mysql |
是否在运行 |
sudo ss -lntp | grep 3306 |
看监听地址与端口 |
SELECT user, host, plugin FROM mysql.user; |
查账号与认证插件 |
mysqldump -u root -p --single-transaction appdb > appdb.sql |
一致性备份单库 |
mysql -u root -p appdb < appdb.sql |
恢复单库 |
enable 的本质、为什么用 restartPostgreSQL 是功能最完善的开源关系型数据库,复杂查询、JSON、GIS、事务语义都比 MySQL 更严谨。本章走完:选版本 → 用 PGDG 官方源安装 → 最小必要配置(含远程连接的两个文件)→ 建角色建库 → 备份 → 托管给 systemd。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
PostgreSQL 每年发一个大版本(17、18、19……),社区对每个大版本维护约 5 年。它不像 MySQL 有 LTS / 创新版之分,所以选型问题主要在于「用系统源自带的老版本」还是「用 PGDG 源的新版本」。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 17 / 18(18.4 为当前稳定) | 两者都在官方维护期内 |
| Ubuntu 22.04 源默认 | 14 | 太旧,且已接近维护尾声,不建议直接用 |
| 获取方式 | PGDG 官方源 | 系统源里没有 17/18,必须加官方源 |
| 维护周期 | 每个大版本约 5 年 | 精确到期日查 postgresql.org 或 endoflife.date |
| License | PostgreSQL License(类 BSD) | 比 GPL 更宽松,商用无限制 |
# 先看系统源里有什么版本 —— 这一步会暴露「只有 14」的问题
$ apt policy postgresql
预期输出:
postgresql:
Installed: (none)
Candidate: 14+238ubuntu0.1
Candidate 是 14,说明系统源不够用,需要加 PGDG 官方源(见下一节)。
| 场景 | 选哪个 | 为什么 |
|---|---|---|
| 稳妥优先、依赖多(插件/ORM/运维工具) | 17 | 已发布一两年,生态适配最全,坑被踩完 |
| 新项目、团队能自己排错 | 18 | 支持期更长,性能与新特性更好 |
| 已有旧版本要升大版本 | 不要原地升级 | 见下方 WARNING |
[!WARNING] PostgreSQL 大版本不要原地
apt upgrade。 数据目录格式跨大版本不兼容,原地升会直接起不来。正确做法:新版本另起实例 →pg_dump导出 → 导入新实例 → 业务切换 → 观察一段时间。只有小版本(18.3 → 18.4)才能原地升。
# 装完之后核对真实版本
$ psql --version
预期输出:
psql (PostgreSQL) 18.4
| 现象 | 根因 | 解决 |
|---|---|---|
apt install postgresql 装出来是 14 |
用了系统源 | 加 PGDG 官方源后重装,见下一节 |
| 不确定选 17 还是 18 | 两者都在维护期 | 看依赖:用到的扩展/驱动是否声明支持 18 |
| 方式 | 拿到的版本 | 适合 |
|---|---|---|
| 系统源 apt | 14(太旧) | 基本不推荐,除非你确实只要 14 |
| PGDG 官方源(推荐) | 17 / 18 自选 | 生产与学习的正解 |
| Docker | 任意 | 本地开发,见 Docker |
postgresql-common 包自带一个官方脚本,会自动配好 PGDG 源和密钥:
# 1. 装公共包(提供 pg_ctlcluster、pg_lsclusters 等集群管理工具)
$ sudo apt install -y postgresql-common
# 2. 执行官方脚本,自动添加 PGDG 源与密钥(-y 表示非交互)
$ sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
# 3. 刷新索引后安装指定大版本
$ sudo apt update
$ sudo apt install -y postgresql-18
也可以手写 signed-by 源(效果和官方脚本一致,便于写进自动化脚本):
# 下载 PGDG 官方密钥到独立钥匙环,不要用 apt-key
$ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/pgdg.gpg
# jammy-pgdg 是给 Ubuntu 22.04 用的仓库名,其他版本见官网
$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/pgdg.gpg] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
$ sudo apt update
$ sudo apt install -y postgresql-18
[!NOTE] 上面的脚本名、密钥地址和仓库名以 PGDG 官网(postgresql.org/download/linux/ubuntu/)为准,官方可能调整。另外,官方现行推荐写法已迁移到 deb822 格式(
/etc/apt/sources.list.d/pgdg.sources)+ 密钥存/usr/share/postgresql-common/pgdg/,与本节传统写法等价,任选其一即可。装之前先apt policy postgresql-18,确认 Candidate 是你要的版本。
一句带过,见 Docker:docker run -d --name pg18 -e POSTGRES_PASSWORD='<强密码>' -p 127.0.0.1:5432:5432 postgres:18。
[!WARNING] 端口特意写成
127.0.0.1:5432:5432。不要写-p 5432:5432—— 那会把数据库暴露到所有网卡,等于对公网开放 5432。
| 包名 | 作用 |
|---|---|
postgresql-18 |
18 的服务端 + 客户端 + 一个默认 main 集群 |
postgresql-client-18 |
只有客户端(psql、pg_dump) |
postgresql-common |
集群管理框架(pg_lsclusters、pg_ctlcluster)与源配置脚本 |
$ psql --version
$ apt policy postgresql-18
预期输出:
psql (PostgreSQL) 18.4
postgresql-18:
Installed: 18.4-1.pgdg22.04+1
Candidate: 18.4-1.pgdg22.04+1
| 现象 | 根因 | 解决 |
|---|---|---|
apt install postgresql-18 说找不到包 |
没加 PGDG 源或没 apt update |
重跑官方脚本,sudo apt update 后再装 |
apt update 报 GPG 错误 |
密钥路径与 signed-by 不一致 |
检查 /usr/share/keyrings/pgdg.gpg 是否存在 |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
apt.postgresql.org.sh(PGDG 源) |
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm |
都是装 PGDG 源,命令不同 |
sudo apt install -y postgresql-18 |
sudo dnf install -y postgresql18-server |
RHEL 的包名带 -server 后缀 |
| 装完自动建集群并启动 | 需手动 postgresql-18-setup initdb |
RHEL 不会自动初始化数据目录 |
sudo pg_ctlcluster 18 main restart |
sudo systemctl restart postgresql-18 |
RHEL 没有 pg_ctlcluster 框架 |
/etc/postgresql/18/main/ |
/var/lib/pgsql/18/data/ |
配置与数据目录位置不同 |
PGDG 装的 psql、pg_dump 通过 postgresql-common 的包装器链到 /usr/bin,所以敲命令一般没问题。需要手动设置的是连接参数:psql 默认用当前 Linux 用户名当数据库用户、用本地 socket 连接,脚本里必须显式指定连谁、连哪个库。
# 脚本里常用:一行指定主机、端口、用户、库
$ export PGHOST=127.0.0.1 PGPORT=5432 PGUSER=appuser PGDATABASE=appdb
$ psql -c "\conninfo"
密码不要用 PGPASSWORD 传,改用 ~/.pgpass:
# 格式:hostname:port:database:username:password
127.0.0.1:5432:appdb:appuser:<强密码>
$ chmod 600 ~/.pgpass
[!WARNING]
PGPASSWORD不安全。 密码会以明文出现在ps输出、shell 历史(.bash_history)和进程环境里,同机任何用户都能看到。必须用~/.pgpass或PGPASSFILE指向的凭据文件,且文件权限必须是 600 —— 权限不对时 PostgreSQL 会直接忽略该文件。
| 变量 / 文件 | 作用 | 建议 |
|---|---|---|
PGHOST / PGPORT |
连接地址与端口 | 不设则走本地 Unix socket |
PGUSER / PGDATABASE |
默认用户与库 | 不设则取当前 Linux 用户名 |
PGPASSWORD |
环境变量传密码 | 不要用,改 ~/.pgpass |
~/.pgpass |
密码文件(每行一条) | 权限必须 600,可用 PGPASSFILE 换路径 |
$ psql -c "\conninfo"
预期输出:
You are connected to database "appdb" as user "appuser" on host "127.0.0.1" at port "5432".
| 现象 | 根因 | 解决 |
|---|---|---|
psql: command not found |
只装了服务端精简包或 PATH 被改坏 | sudo apt install postgresql-client-18;echo $PATH |
配了 ~/.pgpass 仍要输密码 |
文件权限不是 600,或格式不对 | chmod 600 ~/.pgpass;确认是四段冒号分隔 |
cron 里 pg_dump 失败 |
cron 的 PATH 精简 | 写绝对路径 /usr/bin/pg_dump,见 备份与定时任务 |
PostgreSQL 装完会自动初始化一个数据目录(称为 cluster/集群)并启动,但默认只监听本机、且只有 postgres 一个角色。最小必要配置做四件事:监听谁的连接、谁能从哪连进来(认证)、建业务角色与库、能备份。
安装程序会自动创建名为 postgres 的 Linux 系统用户,以及同名的数据库角色。本机默认认证方式是 peer(用操作系统身份判断你是谁),所以必须切换成 postgres 用户再进:
# 不要 sudo su - 之后再敲 psql,也不要用 root 直接登
$ sudo -u postgres psql
[!IMPORTANT] 不要直接用 root 登录 PostgreSQL。 默认角色里没有 root,
psql用 root 跑会报role "root" does not exist或peer authentication failed。正确做法永远是sudo -u postgres psql。
进入后你会看到提示符变成 postgres=#,此时执行的就是 SQL 和 psql 元命令。
配置文件在 /etc/postgresql/18/main/ 下。只改 postgresql.conf 不改 pg_hba.conf,依然连不上 —— 这是新手卡住的头号原因。
postgresql.conf —— 决定「监听哪里」:
listen_addresses = 'localhost' # 改成 '*' 或 'localhost,192.168.1.10'
port = 5432
pg_hba.conf —— 决定「谁可以从哪来、用什么方式认证」(hba = host based authentication):
# TYPE DATABASE USER ADDRESS METHOD
host appdb appuser 192.168.1.0/24 scram-sha-256
| 字段 | 含义 |
|---|---|
TYPE |
local = Unix socket;host = TCP 连接 |
DATABASE / USER |
这条规则作用于哪个库、哪个角色 |
ADDRESS |
客户端网段,如 192.168.1.0/24;0.0.0.0/0 表示全部 |
METHOD |
scram-sha-256(推荐)、md5、peer、trust(绝不用) |
改完的生效方式因文件而异:pg_hba.conf 的改动只需 reload(不断开现有连接):
$ sudo systemctl reload postgresql
或在 psql 里执行 SELECT pg_reload_conf();,效果相同。只有 postgresql.conf 里 listen_addresses 这类必须重启才生效的参数(官方文档标注为 postmaster 级)才需要:
$ sudo systemctl restart postgresql
[!IMPORTANT] 两个文件是「与」的关系,但生效方式不同,不要一刀切 restart:
postgresql.conf不监听,连接根本到不了;pg_hba.conf没有匹配规则,连到了也会被拒(no pg_hba.conf entry for host)。远程访问两者都要改;日常调整pg_hba.conf用 reload 即可,restart 会断掉所有现有连接。
[!WARNING] 不要把 5432 对公网开放。
listen_addresses = '*'加上防火墙放行 5432,等于把数据库暴露在公网。正确做法:只监听内网 IP(listen_addresses = 'localhost,192.168.1.10'),pg_hba.conf里只写内网网段,防火墙只放行内网(见 防火墙),云安全组同样只放行内网来源。
CREATE ROLE appuser WITH LOGIN PASSWORD '<强密码>';
CREATE DATABASE appdb OWNER appuser;
GRANT ALL PRIVILEGES ON DATABASE appdb TO appuser;
PostgreSQL 里的 role(角色)= 用户 + 组的合体:加 WITH LOGIN 它才能登录(相当于用户),不加则只是一个可以被继承的权限集合(相当于组)。这也解释了为什么没有 CREATE USER 也能用 —— CREATE USER 只是 CREATE ROLE ... WITH LOGIN 的别名。
改密码用 psql 元命令,避免明文密码进 shell 历史:
\password appuser
[!TIP] 应用账号不要直接用
postgres超级用户,理由和 MySQL 一样:权限无限大,一次注入就能毁掉所有库。按库建角色,只给它需要的权限。
| 元命令 | 作用 |
|---|---|
\l |
列出所有数据库 |
\c appdb |
切换到 appdb |
\dt |
列出当前库的表 |
\du |
列出所有角色 |
\conninfo |
显示当前连接信息(排错很好用) |
\password <角色> |
改密码(交互式,不留明文) |
\q |
退出 |
\? |
查看全部元命令帮助 |
[!NOTE] 元命令以反斜杠开头,不要加分号;SQL 语句才以
;结尾。这是新手最常见的混淆点。
# 逻辑备份单个库(SQL 文本格式)
# 注意:不加 -h 默认走本地 socket + peer 认证,appuser 不是系统用户会被拒;
# 显式加 -h 127.0.0.1 走 TCP 密码认证(前提是 pg_hba.conf 里有对应 host 规则)
$ pg_dump -h 127.0.0.1 -U appuser appdb > appdb.sql
# 自定义格式(-Fc):压缩、可并行、支持按对象恢复,配合 pg_restore
$ pg_dump -Fc -h 127.0.0.1 -U appuser appdb > appdb.dump
$ pg_restore -h 127.0.0.1 -U appuser -d appdb appdb.dump
# 备份整个实例的所有库 + 角色定义
$ pg_dumpall -U postgres > alldb.sql
日志默认在 /var/log/postgresql/postgresql-18-main.log(服务起不来、认证失败都先看它)。定时备份见 备份与定时任务,日志查看见 日志基础。
/etc/postgresql/18/main/postgresql.conf 常用参数:
| 参数 | 示例值 | 作用与注意 |
|---|---|---|
listen_addresses |
'localhost' |
监听哪些地址;'*' 是全部网卡(见 WARNING) |
port / data_directory |
5432 / /var/lib/postgresql/18/main |
端口与数据目录,数据目录不要手动改 |
password_encryption |
scram-sha-256 |
新建密码的加密方式,要与 pg_hba.conf 的 METHOD 匹配 |
max_connections |
100 |
并发连接上限,连接池要算进去 |
shared_buffers |
物理内存的 25% | PostgreSQL 自己的缓存(与系统页缓存互补,不是越大越好) |
log_min_duration_statement |
2000 |
记录执行超过 2 秒的 SQL,轻量版慢查询日志 |
# 实际生效的监听地址、端口与密码加密方式
$ sudo -u postgres psql -c "SHOW listen_addresses;" -c "SHOW port;" -c "SHOW password_encryption;"
# 本机是否在听 5432
$ sudo ss -lntp | grep 5432
预期输出:
listen_addresses
------------------
localhost,192.168.1.10
(1 row)
port = 5432
password_encryption = scram-sha-256
LISTEN 0 244 192.168.1.10:5432 0.0.0.0:* users:(("postgres",pid=1234,fd=6))
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了配置没生效 | 忘了 restart,或改了别的集群目录 |
确认路径是 /etc/postgresql/18/main/,再 sudo systemctl restart postgresql |
no pg_hba.conf entry for host |
pg_hba.conf 里没有匹配的规则 |
按上面的格式加一行,注意网段写全(192.168.1.0/24) |
| 备份文件 0 字节 | 用户名或库名错 | 去掉重定向跑一次看 stderr;cron 里写绝对路径 /usr/bin/pg_dump |
验证按「由内到外」分三层:程序版本 → 服务与集群状态 → 本机连得上 → 从客户端机器连得上。失败就停在那一层。
按下面四条命令依次执行,见「验证方法」的预期输出。最后一条从另一台机器连,用来验证远程配置是否真的生效;-h 写服务器 IP 而不是 localhost,否则走的是 socket、测不到 TCP 与 pg_hba.conf。
| 检查命令 | 说明 |
|---|---|
psql --version |
客户端版本,与服务端大版本最好一致 |
sudo -u postgres psql -c "SELECT version();" |
服务端真实版本与编译信息 |
sudo ss -lntp | grep 5432 |
监听地址是 localhost 还是内网 IP |
psql -h <IP> -U appuser -d appdb |
端到端:TCP + 认证 + 授权,一次性验证三件事 |
$ psql --version
$ sudo -u postgres psql -c "SELECT version();"
$ sudo ss -lntp | grep 5432
$ psql -h 192.168.1.10 -U appuser -d appdb -c "\conninfo"
预期输出:
psql (PostgreSQL) 18.4
version
----------------------------------------------------------------------------------------------------------
PostgreSQL 18.4 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0) 11.4.0, 64-bit
LISTEN 0 244 192.168.1.10:5432 0.0.0.0:* users:(("postgres",pid=1234,fd=6))
Password for user appuser:
You are connected to database "appdb" as user "appuser" on host "192.168.1.10" at port "5432".
最后一行出现即代表:监听、认证(scram-sha-256)、授权全部通过。
| 现象 | 根因 | 解决 |
|---|---|---|
SELECT version(); 报连不上 |
服务没起,或集群状态是 down | sudo pg_lsclusters 看状态,见下一节 |
本地能连、-h <IP> 连不上 |
listen_addresses 没改或 pg_hba.conf 没规则 |
两个文件都改并重启 |
ss 里没有 5432 |
服务没起 / 端口被改 | sudo systemctl status postgresql |
PGDG 安装的 PostgreSQL 会在 systemd 里注册两层:一个外层 postgresql.service,以及每个集群一个 postgresql@<版本>-<集群名>.service(如 postgresql@18-main.service)。日常启停用外层即可。原理见 systemd 服务管理。
# 启停与状态(Ubuntu 上作用于所有集群)
$ sudo systemctl start postgresql
$ sudo systemctl restart postgresql
$ sudo systemctl status postgresql
$ sudo systemctl enable postgresql
Ubuntu 特有的集群管理工具(postgresql-common 提供,RHEL 没有):
# 列出所有集群:版本、名字、端口、状态、数据目录、日志文件
$ sudo pg_lsclusters
# 只重启某一个集群(一个版本可以有多个 cluster,各自独立的数据目录和端口)
$ sudo pg_ctlcluster 18 main restart
[!NOTE] 一个 PostgreSQL 版本可以有多个 cluster。 每个 cluster 是独立的数据目录 + 端口 + 配置,互不干扰;这正是
pg_lsclusters存在的原因。默认只有一个叫main的集群(端口 5432)。多集群场景下用pg_ctlcluster精确操作,别用systemctl restart postgresql一把梭。
| 命令 | 作用 |
|---|---|
systemctl enable postgresql |
开机自启(不会现在启动,--now 才两者都做) |
pg_lsclusters |
看有几个集群、各自端口与状态 |
pg_ctlcluster <版本> <集群> restart |
精确重启单个集群,不影响其他集群 |
$ sudo systemctl is-active postgresql
$ sudo pg_lsclusters
预期输出:
active
Ver Cluster Port Status Owner Data directory Log file
18 main 5432 online postgres /var/lib/postgresql/18/main /var/log/postgresql/postgresql-18-main.log
| 现象 | 根因 | 解决 |
|---|---|---|
Job for postgresql@18-main.service failed |
配置写错或数据目录权限不对 | journalctl -u postgresql@18-main -n 50 --no-pager;再查 /var/log/postgresql/ 下的日志 |
pg_ctlcluster: cluster does not exist |
集群名写错 | sudo pg_lsclusters 看真实名字(默认 main) |
| 重启后没起来 | 没 enable | sudo systemctl enable --now postgresql |
这一节是「连不上 / 认证失败」的集中排查表。先按下一节的固定顺序定位,再查表。
远程连接失败时下面四层必须同时满足,缺一层就表现为「连不上」:① postgresql.conf 的 listen_addresses 包含目标网卡 → ② pg_hba.conf 有匹配客户端 IP 的规则 → ③ 主机防火墙放行 5432(只放行内网) → ④ 云安全组放行对应来源。而且改完必须重启服务。
| 报错关键词 | 含义 |
|---|---|
no pg_hba.conf entry for host |
监听没问题,但 pg_hba.conf 没规则 —— 只改了 conf |
FATAL: password authentication failed |
有规则,但密码错或加密方式与 METHOD 不匹配 |
peer authentication failed |
走的是本地 peer 认证,当前系统用户对不上数据库角色 |
could not connect to server |
根本没连到:服务没起、监听地址不对或端口不通 |
# 一条命令同时看监听与连接来源,定位在四层中的哪一层
$ sudo ss -lntp | grep 5432
$ sudo tail -n 20 /var/log/postgresql/postgresql-18-main.log
预期输出:
LISTEN 0 244 192.168.1.10:5432 0.0.0.0:* users:(("postgres",pid=1234,fd=6))
2026-09-29 10:00:01.123 UTC [5678] appuser@appdb [local] LOG: connection authorized
日志里出现 no pg_hba.conf entry 或 password authentication failed 字样时,就能直接定位到认证层。
| 现象 | 根因 | 解决 |
|---|---|---|
| 远程连不上 | 只改了 postgresql.conf 没改 pg_hba.conf;改完没重启;防火墙;云安全组 |
两个文件都改 → sudo systemctl restart postgresql → 查 ufw 与安全组 |
FATAL: password authentication failed for user "appuser" |
密码错,或 pg_hba.conf 的 METHOD(md5/scram-sha-256)与密码实际加密方式不匹配 |
用 \password appuser 重设密码;让 METHOD 与 password_encryption 一致 |
peer authentication failed for user "postgres" |
没切换系统用户就登 | 用 sudo -u postgres psql;或改 pg_hba.conf 里 local 行的 METHOD |
| 端口 5432 被占用 | 已有另一个集群或别的程序占用 | sudo ss -lntp | grep 5432 看是谁;改新集群的 port 或停掉冲突服务 |
psql: command not found |
客户端包没装,或 PATH 被改坏 | sudo apt install postgresql-client-18;echo $PATH 看 /usr/bin |
| 磁盘满了起不来 | 数据目录所在分区 100% | df -h 定位,清理/扩容后再启动,见 磁盘排错 |
[!WARNING] 不要为了「先让它连上」把
pg_hba.conf的 METHOD 改成trust。trust表示不校验密码,任何人连上来都是你写的那个角色,等于把数据库完全公开。测试完忘了改回去是常见的生产事故,宁可多花五分钟把scram-sha-256配对。
| 命令 | 作用 |
|---|---|
apt policy postgresql-18 |
看源里能装到哪个版本 |
sudo apt install -y postgresql-18 |
安装 PostgreSQL 18 |
sudo -u postgres psql |
以 postgres 角色登录(首次登录必须这样) |
psql -h <IP> -U <角色> -d <库> |
远程连接 |
\l / \c <库> / \dt / \du |
列库 / 切库 / 列表 / 列角色 |
\conninfo / \password <角色> / \q |
连接信息 / 改密码 / 退出 |
sudo systemctl start|restart|status postgresql |
服务启停与状态 |
sudo systemctl enable --now postgresql |
开机自启并立刻启动 |
sudo pg_lsclusters |
列出所有集群及状态 |
sudo pg_ctlcluster 18 main restart |
精确重启单个集群 |
sudo ss -lntp | grep 5432 |
看监听地址与端口 |
SHOW listen_addresses; / SHOW password_encryption; |
查生效的监听与加密方式 |
pg_dump -U appuser appdb > appdb.sql |
备份单库(SQL 格式) |
pg_dump -Fc -U appuser appdb > appdb.dump |
备份单库(自定义格式) |
pg_restore -U appuser -d appdb appdb.dump |
从自定义格式恢复 |
pg_dumpall -U postgres > alldb.sql |
备份整个实例 |
enable 的本质、开机自启原理MongoDB 是最常用的文档型 NoSQL 数据库,数据以 JSON 风格的「文档」存储,适合字段不固定、快速迭代的项目。本章走完整链路:选版本 → 官方源安装 → 创建用户 → 开启认证 → 远程访问 → 备份。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
MongoDB 用大版本号(如 8.0)划分功能线,每个大版本有自己的维护周期。生产环境只跟「当前维护的大版本」走,不追最新,也不用最老的。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 8.0.x(截至 2026-09;补丁号以官方源为准,核对方法见 91 章) | 当前维护的稳定大版本,生态兼容性最好 |
| 旧版本 | 7.0 / 6.0 | 已进入或临近维护尾声,新装不要选 |
| License | SSPL(Server Side Public License) | 自用免费;把它做成对外服务再分发才涉及授权问题 |
| 获取渠道 | MongoDB 官方 apt 源 | Ubuntu 22.04 官方源里没有 MongoDB,这是它和 MySQL 最大的不同 |
先确认一个事实:Ubuntu 官方源里搜不到 MongoDB。
# 系统源里没有 mongodb-org 这个包
$ apt policy mongodb-org
预期输出:
mongodb-org:
Installed: (none)
N: 无法找到软件包 mongodb-org
所以必须添加 MongoDB 官方 apt 源,具体命令见下一节「安装」。选版本时只需要记住一点:源地址里的大版本号决定了你装的是什么(/8.0/ 就是 8.0 线),之后 apt 只会在 8.0.x 内做小版本更新。
| 场景 | 选哪个 | 为什么 |
|---|---|---|
| 新项目、跟本章做 | 8.0.x | 官方 apt 源当前主推,教程和驱动兼容性最好 |
| 老项目迁移 | 跟原项目版本走 | MongoDB 跨大版本的数据格式有差异,迁移要单独规划 |
| 在意开源许可 | 评估 SSPL 对你的影响 | 只在自己服务器上跑业务一般不受影响 |
版本选择阶段的验证,就是确认「官方源添加后能装到 8.0.x」:
# 添加官方源之后,查看候选版本
$ apt policy mongodb-org
预期输出:
mongodb-org:
Installed: (none)
Candidate: 8.0.x
Version table:
8.0.x 500
500 https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0/multiverse amd64 Packages
(8.0.x 代表实际补丁号,以官方源当时提供的为准。)
| 现象 | 根因 | 解决 |
|---|---|---|
apt install mongodb 报 Unable to locate package |
属正常:22.04 源里没有这个包(MongoDB 改用 SSPL 后被从 Ubuntu 官方源移除),与本章「系统源搜不到 MongoDB」一致 | 走本章的官方源装 mongodb-org |
通过 MongoDB 官方 apt 源安装。这样做的好处:后续 apt upgrade 能直接收到官方安全更新,不用手动下载 tar 包。
按官方文档的步骤走:装依赖 → 导入 GPG 公钥 → 添加源 → 安装。
# 安装依赖与 GPG 公钥(不要用 apt-key,已废弃)
$ sudo apt-get install gnupg curl
$ curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor
# 添加源(Ubuntu 22.04 代号为 jammy)
$ echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list
$ sudo apt-get update
$ sudo apt-get install -y mongodb-org
[!NOTE] 如果你在国内且官方源下载慢,可参考 镜像与加速 换用镜像站,但源地址的目录结构必须保持一致。
安装完成后先不要急着开认证,直接看下一节,顺序错了会把自己锁在外面。
mongodb-org 是一个「元包」,它自己不含程序,只负责把全套组件一起装上:
| 组件包 | 作用 |
|---|---|
mongodb-org-server |
mongod:数据库服务端本体(systemd 服务就是它) |
mongodb-org-mongos |
mongos:分片集群的路由组件(单机用不到) |
mongodb-org-shell |
旧版 mongo shell 的兼容入口(传统 shell 已停止维护) |
mongodb-mongosh |
mongosh:现行官方命令行客户端,连库、建用户都用它(正主是这个包) |
mongodb-org-tools |
工具元包:引入 mongodb-database-tools 等,mongodump / mongorestore 备份工具来自 mongodb-database-tools 包 |
mongodb-org-database-tools-extra |
只含 install_compass 脚本(Compass 图形客户端安装器),不是备份工具 |
# 确认 mongod 与 mongosh 都已就位
$ mongod --version
$ mongosh --version
预期输出:
db version v8.0.x
Build Info: {...}
---
2.5.x
(8.0.x、2.5.x 代表实际版本号,以官方源为准,核对方法见 91 章。)
| 现象 | 根因 | 解决 |
|---|---|---|
apt update 报 GPG 签名错误(NO_PUBKEY) |
公钥没导入成功,或导入路径和源里的 signed-by 不一致 |
重跑 curl ... | sudo gpg ... 那条命令,核对路径逐字符一致 |
apt-get install 报 Unable to locate package |
忘了 apt-get update,或源文件里代号不是 jammy |
先 sudo apt-get update;检查 /etc/apt/sources.list.d/mongodb-org-8.0.list |
[!IMPORTANT] 装完立刻固定版本,防止大版本被自动升级。 数据库大版本原地升级是高危操作,格式变了可能直接起不来。官方建议把装到的组件包全部「钉住」(完整列表):
$ sudo apt-mark hold mongodb-org mongodb-org-server mongodb-org-mongos mongodb-org-tools mongodb-org-database-tools mongodb-org-database-tools-extra mongodb-mongosh本章正文示例只钉
mongodb-org mongodb-org-server两个核心包,是为求简短做的取舍;要彻底防止组件各自升大版本,用上面的完整列表。以后想换大版本,走「新实例 → 导数据 → 切换」流程。包管理详见 软件包管理。
[!NOTE] RHEL / CentOS 用户:MongoDB 官方同样提供 yum/dnf 源,配置方式见 MongoDB 官方文档「Install on Red Hat」,步骤思想与本章一致(导入 GPG key → 加 repo 文件 → 安装)。
# 创建 repo 文件(RHEL 9 为例,8.0 线)
$ sudo tee /etc/yum.repos.d/mongodb-org-8.0.repo <<'EOF'
[mongodb-org-8.0]
name=MongoDB Repository
baseurl=https://repo.mongodb.org/yum/redhat/9/mongodb-org/8.0/x86_64/
gpgcheck=1
enabled=1
gpgkey=https://www.mongodb.org/static/pgp/server-8.0.asc
EOF
$ sudo dnf install -y mongodb-org
apt 安装的 MongoDB 会把所有可执行文件放进 /usr/bin,这个目录本来就在系统 PATH 里,所以本章不需要手动配任何环境变量。
# 确认命令能直接找到即可,无需额外配置
$ which mongod mongosh mongodump
预期输出:
/usr/bin/mongod
/usr/bin/mongosh
/usr/bin/mongodump
任意目录下执行 mongosh --version 能出版本号,就说明 PATH 没问题。
| 现象 | 根因 | 解决 |
|---|---|---|
command not found |
安装未成功,或用的是手动解压的 tar 包且没加 PATH | 重新走官方源安装,或参考 源码编译安装 的 PATH 配置 |
MongoDB 的配置文件是 /etc/mongod.conf,YAML 格式。最小必要配置只关心四件事:数据放哪、日志放哪、监听哪里、要不要认证。
第一步:理解默认配置。 包安装后的关键默认值:
# /etc/mongod.conf 中的关键段(摘录,非完整文件)
storage:
dbPath: /var/lib/mongodb
systemLog:
destination: file
logAppend: true
path: /var/log/mongodb/mongod.log
net:
port: 27017
bindIp: 127.0.0.1
第二步(重要):先启动服务、先建管理员,再开认证。 顺序绝对不能反,原因见下面的 WARNING。
# 1. 先以「无认证」状态启动服务
$ sudo systemctl enable --now mongod
# 2. 此时才进 mongosh 创建管理员用户(命令见下文)
第三步:在认证开启前创建管理员。 进 mongosh 执行:
use admin
db.createUser({
user: "admin",
pwd: "<强密码>",
roles: [ { role: "userAdminAnyDatabase", db: "admin" }, { role: "readWriteAnyDatabase", db: "admin" } ]
})
预期输出:
Successfully added user: { "user" : "admin", ... }
第四步:开启认证。 编辑 /etc/mongod.conf,找到 #security: 那行,改成:
# 去掉 security 前的注释号,并新增一行 authorization
# 注意:authorization 前面是两个空格缩进
security:
authorization: enabled
# 5. 重启服务使认证生效
$ sudo systemctl restart mongod
第五步:创建业务用户和业务库。 用 admin 登录后执行:
use appdb
db.createUser({
user: "appuser",
pwd: "<强密码>",
roles: [ { role: "readWrite", db: "appdb" } ]
})
核心顺序坑 —— 本章最重要的一段:
[!WARNING] 正确顺序:启动服务 → 创建管理员 → 开启认证。 如果先写
authorization: enabled再重启,此时库里一个用户都没有,认证开启后你自己也登不进去,只能把security.authorization: disabled临时改回去、重启、建完用户再开 —— 等于全部重来。记住口诀:「先有人,再锁门」。
security.authorization: enabled 的 YAML 缩进:
[!WARNING] YAML 对缩进极其敏感:
security:顶格,authorization:前面恰好两个空格(不能用 Tab)。缩进错了 mongod 直接拒绝启动,这是新手第一大翻车点。改完配置先用sudo systemctl restart mongod,马上看状态确认起来了。
role(角色)概念速记:
| 角色 | 权限范围 | 给谁用 |
|---|---|---|
readWrite |
指定库的读写 | 业务应用账号(如 appuser),权限最小化 |
dbAdmin |
指定库的管理(索引、统计) | 运维某库的人 |
userAdminAnyDatabase |
管理所有库的用户 | 管理员账号 |
readWriteAnyDatabase |
所有库的读写 | 管理员账号 |
root |
一切权限 | 日常不要用,应急才用 |
其余配置要点:
| 配置项 | 默认值 | 说明 |
|---|---|---|
net.bindIp |
127.0.0.1 |
默认只允许本机连接。要远程访问改成 127.0.0.1,<内网IP> |
net.port |
27017 |
默认端口,一般不改 |
storage.dbPath |
/var/lib/mongodb |
数据目录,磁盘要够大;改路径需同步改目录属主 |
systemLog.path |
/var/log/mongodb/mongod.log |
排错第一现场,起不来先看这里 |
[!WARNING] 绝对不要把
bindIp改成0.0.0.0而不开启认证。 历史上 MongoDB 爆发过大规模勒索事件:攻击者扫描全网 27017 端口,发现无认证的实例就直接清空数据、留下勒索信索要赎金。正确姿势永远是:认证开着 + 只监听本机或内网 IP + 防火墙/安全组兜底。
配置完成后的完整验证链:
# 1. 带认证登录管理员
$ mongosh -u admin -p --authenticationDatabase admin
预期输出:
Enter password: ********
Current Mongosh Log ID: ...
connecting to: mongodb://127.0.0.1:27017/
// 2. 在 mongosh 里确认连接身份
db.runCommand({ connectionStatus: 1 })
预期输出:
{ authInfo: { authenticatedUsers: [ { user: 'admin', db: 'admin' } ], authenticatedUserRoles: [...] }, ok: 1 }
// 3. 关键反向验证:不带凭据登录后查库,应被拒绝
show dbs
预期输出:
MongoServerError: command listDatabases requires authentication
能登录、身份正确、未认证被拒 —— 三条全过,认证才算配好。
| 现象 | 根因 | 解决 |
|---|---|---|
开了认证但登录报 Authentication failed |
没指定 --authenticationDatabase admin,客户端默认向当前库验证 |
登录命令补上 --authenticationDatabase admin |
| 远程机器连不上 | 四层排查:bindIp 只听 127.0.0.1 / 防火墙没放行 / 云安全组没放行 / 用户没授权远程来源 | 逐层检查:配置 bindIp 加内网 IP → 防火墙 放行 27017 限内网 → 安全组同规则 → 确认用户角色 |
systemctl restart mongod 后服务起不来 |
YAML 缩进错误(Tab、多空格、少空格) | tail -50 /var/log/mongodb/mongod.log 看报错行号,修配置文件缩进 |
| 服务运行中突然停止,日志有磁盘错误 | 数据目录所在分区写满 | 清理磁盘后重启;日常用监控/日志轮转防复发,见 备份与定时任务 |
执行命令报 Code=13 Unauthorized |
当前用户没有该命令对应的角色权限 | 换 admin 账号登录,或给业务账号补最小必需角色 |
独立的整体验证环节:从「服务活着」到「认证生效」到「远程可达」逐层确认,而不是装完就拍脑袋认为没问题。
# 1. 服务状态
$ sudo systemctl status mongod
# 2. 版本
$ mongosh --version
# 3. 认证登录
$ mongosh -u admin -p --authenticationDatabase admin
上面第 3 步的预期输出:
预期输出:
Enter password: ********
Current Mongosh Log ID: ...
connecting to: mongodb://127.0.0.1:27017/admin
进入 mongosh 后执行 show dbs 能列出 admin 等系统库,即代表「服务 + 认证」双验证通过。
| 现象 | 根因 | 解决 |
|---|---|---|
systemctl status mongod 显示 failed 或 activating (auto-restart) |
mongod 启动失败或异常退出后正处于自动重试中,不会显示 active (exited) |
查 /var/log/mongodb/mongod.log 定位退出原因(多为配置文件或权限问题) |
apt 包安装时已自动注册 mongod.service,开机自启和崩溃拉起都由 systemd 负责,不需要自己写 unit 文件。服务的名字是 mongod(不是 mongodb,也不是 mongodb-org),这个拼写细节新手经常记错。
# 开机自启并立即启动(安装时其实已自动执行,这里用于确认)
$ sudo systemctl enable --now mongod
# 日常管理
$ sudo systemctl status mongod
$ sudo systemctl restart mongod
$ sudo systemctl stop mongod
$ sudo systemctl is-active mongod
预期输出:
active
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了配置重启没生效 | 改的是别的路径下的配置文件 | 确认改的是 /etc/mongod.conf,且重启了服务 |
systemd 的启动原理、日志查看(journalctl)、自定义 unit 等通用知识见 systemd 服务管理。
官方工具 mongodump(导出)与 mongorestore(恢复),按库导出 BSON 文件,是 MongoDB 最常用的备份方式。
# 备份单个业务库(带认证时需指定账号与验证库)
$ mongodump --db appdb -u appuser -p --authenticationDatabase appdb -o /backup/
# 恢复到同名库
$ mongorestore --db appdb -u admin -p --authenticationDatabase admin --drop /backup/appdb/
| 参数 | 作用 |
|---|---|
--db appdb |
只备份/恢复这一个库 |
-o /backup/ |
输出目录,会自动建 appdb 子目录 |
--drop |
恢复前先清空目标集合,避免数据叠加 |
--authenticationDatabase |
指定向哪个库验证账号,漏掉必报认证失败 |
# 看备份产物是否生成
$ ls /backup/appdb/
预期输出:
collection1.bson collection1.metadata.json ...
| 现象 | 根因 | 解决 |
|---|---|---|
mongodump 报认证失败 |
没写 --authenticationDatabase |
补上该参数 |
定时自动备份、备份轮转、上传异地,见 备份与定时任务。
前面各节已分散列出高频坑,这里汇总一张总表,排错时先查这里。
按「现象 → 根因 → 解决」三栏快速定位,多数问题在日志(/var/log/mongodb/mongod.log)和 systemctl 状态里都能找到直接线索。
每次修改后,固定跑一遍验证链:systemctl is-active mongod → 带认证登录 → show dbs 正常返回。
| 现象 | 根因 | 解决 |
|---|---|---|
| 认证开了连不上 | --authenticationDatabase 没指定 |
登录命令加 --authenticationDatabase admin |
| 远程连不上 | bindIp / 防火墙 / 安全组 / 用户 host 四层中某层没通 | 见「最小必要配置」一节的四层排查清单 |
| mongod 起不来 | YAML 缩进错误 | 看日志报错行,修 /etc/mongod.conf 缩进 |
| 磁盘满导致服务停止 | 数据目录分区写满 | 清理磁盘 → 重启 → 建立定时备份与监控 |
Code=13 Unauthorized |
账号角色权限不足 | 用正确角色登录,或补授最小权限 |
# 安装(官方源三步:key → 源 → 装)
$ curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor
$ echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list
$ sudo apt-get update && sudo apt-get install -y mongodb-org
$ sudo apt-mark hold mongodb-org mongodb-org-server mongodb-org-mongos mongodb-org-tools mongodb-org-database-tools mongodb-org-database-tools-extra mongodb-mongosh
# 服务管理
$ sudo systemctl enable --now mongod
$ sudo systemctl status mongod
$ sudo systemctl restart mongod
# 用户与认证
$ mongosh
> use admin
> db.createUser({ user: "admin", pwd: "<强密码>", roles: [...] })
# 然后在 /etc/mongod.conf 中开启 security.authorization: enabled 并重启
$ mongosh -u admin -p --authenticationDatabase admin
$ mongosh -u appuser -p --authenticationDatabase appdb appdb
# 验证(mongosh 内命令用 > 提示符)
$ mongosh
> db.runCommand({ connectionStatus: 1 })
# 备份恢复
$ mongodump --db appdb -u appuser -p --authenticationDatabase appdb -o /backup/
$ mongorestore --db appdb --drop /backup/appdb/
| 章节 | 内容 |
|---|---|
| 版本速查表 | 全书软件版本基线的唯一权威来源 |
| 软件包管理 | apt-mark hold、apt 源配置原理 |
| 防火墙 | 远程访问 27017 时的端口放行 |
| systemd 服务管理 | mongod 服务的启动原理与日志 |
| 源码编译安装 | 不用包管理器时的安装思路 |
| 备份与定时任务 | mongodump 自动化与异地备份 |
Redis 是最流行的内存键值数据库,常做缓存、会话存储、限流计数器。它快,但也因为「默认无密码」出过海量安全事故。本章目标:装最新稳定版 → 把安全配扎实 → 会验证、会备份。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
Redis 在 2024-03 把许可证从 BSD 改成了 RSALv2 / SSPLv1 双许可,社区因此分叉出了 BSD 许可的 Valkey;2025-05 随 Redis 8.0 又新增 AGPLv3,才形成现在的「三选一」。选择前先搞清楚两件事:版本线、许可影响。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 8.x(截至 2026-09;小版本以官方源为准,核对方法见 91 章) | 当前稳定大版本,功能与性能最好 |
| Ubuntu 22.04 系统源 | 6.0 | 太旧,安全更新和命令支持都落后,不要用 |
| License | Redis 8.0 起 AGPLv3(另可选 RSALv2/SSPLv1) | AGPLv3 对一般商用(自己服务器跑业务)是自由的 |
| BSD 替代品 | Valkey | Linux 基金会托管的社区分叉,命令与 Redis 兼容,在意宽松许可可选它 |
| 获取渠道 | 官方 apt 源(packages.redis.io) | 系统源版本太旧,必须加官方源 |
先看系统源里默认装的是什么版本:
# 查看 Ubuntu 22.04 系统源的 Redis 候选版本
$ apt policy redis-server
预期输出:
redis-server:
Installed: (none)
Candidate: 5:6.0.16-1ubuntu1+deb12u3
Version table:
5:6.0.16-1ubuntu1+deb12u3 500
500 http://cn.archive.ubuntu.com/ubuntu jammy/universe amd64 Packages
Candidate 是 6.0,落后当前稳定版两个大版本,这就是必须加官方源的理由。加源命令见下一节。
| 场景 | 选哪个 | 为什么 |
|---|---|---|
| 新项目、缓存场景 | Redis 8.x(官方源) | 最新特性(如内建查询能力)+ 持续安全更新 |
| 在意宽松 BSD 许可 | Valkey | 命令兼容,客户端几乎无感,切换成本低 |
| 只想快速试玩 | 系统源 6.0 勉强可用 | 生产环境不要用,版本太旧 |
加完官方源后确认能装到 8.x:
$ apt policy redis-server
预期输出:
redis-server:
Installed: (none)
Candidate: 8:8.x-1rl1~jammy1
Version table:
8:8.x-1rl1~jammy1 500
500 https://packages.redis.io/deb jammy/main amd64 Packages
(8.x 代表实际小版本号,以官方源当时提供的为准。)
| 现象 | 根因 | 解决 |
|---|---|---|
apt install redis-server 装成了 6.0 |
没加官方源,或加了源但没 apt-get update |
补官方源并 update,再确认 apt policy 的 Candidate |
走 Redis 官方 apt 源(packages.redis.io)安装,后续 apt upgrade 即可收到官方更新。
$ sudo apt-get install lsb-release curl gpg
$ curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
$ sudo apt-get update
$ sudo apt-get install redis
[!NOTE] 注意包名:官方源安装用
apt-get install redis(不是redis-server,那是 Ubuntu 系统源的旧包名),它会把服务端、客户端一起装好并注册 systemd 服务。
备选安装方式(适合官方源不可达的场景):
make → make install 三步,自由度最高但升级麻烦,见 源码编译安装。docker run -d -p 127.0.0.1:6379:6379 redis:8 一行起服务,适合隔离测试,见 Docker。安装命令各部分作用:
| 片段 | 作用 |
|---|---|
lsb-release curl gpg |
拿系统代号(jammy)、下载 key、解 key 的工具 |
gpg --dearmor -o ...keyring.gpg |
把 ASCII 公钥转成 apt 可用的二进制 keyring(apt-key 已废弃) |
$(lsb_release -cs) |
自动取系统代号,输出 jammy |
signed-by= |
显式声明这个源只认哪个 key,比全局 key 更安全 |
# 客户端和服务端版本都要确认
$ redis-server --version
$ redis-cli --version
预期输出:
Redis server v=8.x ...
Redis client v=8.x ...
| 现象 | 根因 | 解决 |
|---|---|---|
apt update 报签名错误 |
key 没导入成功或路径不符 | 重跑 curl ... gpg --dearmor 那条命令,核对 -o 路径与源文件里 signed-by 一致 |
[!NOTE] RHEL / CentOS 用户:服务名是
redis而不是redis-server(Ubuntu 为redis-server);官方也提供 yum 源(或用 EPEL),配置思路与本章相同。
# RHEL 系可加 EPEL 获得较新版本(企业内更常见用 Remi 源)
$ sudo dnf install -y redis
# 服务名不同:RHEL 是 redis,Ubuntu 是 redis-server
$ sudo systemctl enable --now redis
apt 安装会把 redis-server、redis-cli 放进 /usr/bin,本就在 PATH 中,无需配置环境变量。
$ which redis-server redis-cli
预期输出:
/usr/bin/redis-server
/usr/bin/redis-cli
任意目录执行 redis-cli --version 能出版本号即可。
| 现象 | 根因 | 解决 |
|---|---|---|
command not found |
走了源码编译但没装到系统路径 | 确认 make install 是否执行,或参考 源码编译安装 |
Redis 的配置文件是 /etc/redis/redis.conf。默认状态下它是无密码 + 只监听 127.0.0.1——这两个默认值是它历史上无数次被黑的原因,也是本章要重点处理的部分。
[!WARNING] 为什么这么紧张:Redis 勒索/入侵史。 Redis 默认无密码,历史上大量运维把
bind改成0.0.0.0又不设密码,6379 端口直接裸奔公网。攻击者用redis-cli连上后,通过CONFIG SET dir把 Redis 的落盘目录改到/root/.ssh或/var/spool/cron,再写入 SSH 公钥或定时任务,直接拿下整台服务器。所以本章规则只有一条:改监听地址之前,必须先设密码;两者缺一不可,且 6379 永远不对公网开放。
第一步:设置访问密码(二选一,推荐先学 requirepass)。
编辑 /etc/redis/redis.conf:
# 设置一个强密码(requirepass 后面直接跟密码,没有等号)
requirepass <强密码>
Redis 6+ 支持 ACL(访问控制列表),能按「用户 / 键模式 / 命令组」三个粒度控制,比单一密码强得多:
# 方式 A:关闭默认用户,强制所有连接走 ACL 账号
user default off
# 方式 B(更简单):保留 requirepass,同时为应用建受限用户
# 语法:user <名字> on >密码 ~键模式 +命令组
user appuser on ><强密码> ~appdb:* +@read +@write
上述 appuser 只能读写键名以 appdb: 开头的键。用 redis-cli 交互式创建也一样:
$ redis-cli
127.0.0.1:6379> ACL SETUSER appuser on '>应用密码' ~appdb:* +@read +@write
127.0.0.1:6379> ACL SAVE
[!NOTE]
ACL SAVE的前提是在 redis.conf 里配置了aclfile(如aclfile /etc/redis/users.acl)并把用户定义迁移过去 —— 默认配置没有 aclfile 时执行会报错。若不想引入 aclfile,可改用CONFIG REWRITE方案:ACL 用户定义会随其他配置一起写回 redis.conf。
第二步:确认监听地址。
# 只监听本机;确需内网访问时追加内网 IP,逗号分隔
bind 127.0.0.1 <内网IP>
# 保护模式:既没 bind 内网又没设密码时,拒绝外部连接(保持 yes)
protected-mode yes
第三步:禁用或改名危险命令。
# 置空 = 禁用该命令(清空全部数据、改运行时配置,被入侵时是首选武器)
rename-command FLUSHALL ""
rename-command CONFIG ""
[!WARNING]
rename-command CONFIG ""置空后,CONFIG REWRITE与依赖CONFIG命令的部分监控 / 排查工具将不可用。若运维还需要这些能力,至少改名保留而非置空,例如rename-command CONFIG "REDIS_CONFIG_9f3a"—— 新名字只有你自己知道,效果接近禁用又不失运维手段。
第四步:调整内存与持久化(推荐基线)。
# 最多使用物理内存的 50-70%,给系统和持久化留余量(示例:8G 内存机器给 4G)
maxmemory 4gb
# 缓存场景:内存满时淘汰最久未用的键
maxmemory-policy allkeys-lru
# 开启 AOF 追加持久化,掉电最多丢 1 秒数据
appendonly yes
# 重启生效
$ sudo systemctl restart redis-server
maxmemory-policy 六种淘汰策略对比:
| 策略 | 内存满时的行为 | 适用场景 |
|---|---|---|
noeviction(默认) |
拒绝写入,报错 | 不能丢数据的场景(队列、计数),必须配监控 |
allkeys-lru |
淘汰全库中最久未使用的键 | 典型缓存,绝大多数项目选它 |
volatile-lru |
只在设了过期时间的键里淘汰 LRU | 部分键不许丢、部分可丢 |
allkeys-lfu |
淘汰全库中访问频率最低的键 | 热点数据缓存,比 LRU 更抗偶发扫描 |
volatile-ttl |
优先淘汰剩余存活时间最短的键 | 想让「快过期的先走」 |
allkeys-random / volatile-random |
随机淘汰 | 几乎不用 |
持久化方式取舍:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| RDB(默认开) | 定时把全量数据快照存盘 | 文件小、恢复快 | 两次快照之间宕机,这段数据全丢 |
| AOF | 每条写命令追加到日志 | 最多丢 1 秒(everysec) |
文件大、恢复慢 |
| 推荐 | 纯缓存只留 RDB;当数据存储用则 RDB + AOF 双开 | — | — |
其他关键参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
port |
6379 |
默认端口,一般不改 |
daemonize |
no |
由 systemd 托管时保持 no(Ubuntu 包默认已配好,不用动) |
supervised |
按 systemd 需要配置 | Ubuntu 官方包已处理好,不要照搬老教程去改 |
# 1. 不带密码 ping,应被拒绝
$ redis-cli ping
预期输出:
(error) NOAUTH Authentication required
# 2. 带密码 ping
$ redis-cli -a '<强密码>' ping
预期输出:
PONG
# 3. 用 URL 方式连(密码含特殊字符时更稳)
$ redis-cli -u "redis://:<强密码>@127.0.0.1:6379" ping
预期输出:
PONG
| 现象 | 根因 | 解决 |
|---|---|---|
| 设了密码后本机应用连不上 | 应用配置里没写密码 | 应用连接串补上密码 |
-a 报 (error) ERR Client sent AUTH, but no password is set |
配置没重启生效 | sudo systemctl restart redis-server |
安装与安全配置完成后的整体验收:版本、连通性、认证、内存策略逐项过。
# 版本
$ redis-server --version
# 认证连通
$ redis-cli -a '<强密码>' ping
# 版本细节(服务端运行时信息)
$ redis-cli -a '<强密码>' INFO server | grep redis_version
预期输出:
PONG
redis_version:8.x
| 现象 | 根因 | 解决 |
|---|---|---|
ping 返回 PONG 但不带密码也能连 |
密码配置没生效 | 检查配置文件是否写对段、是否重启 |
apt-get install redis 已自动注册 systemd 服务,Ubuntu 上服务名是 redis-server,开机自启无需额外配置。它由包自带 unit 文件托管,不要自己再写一个。
# 确认自启已开启并立即启动
$ sudo systemctl enable --now redis-server
# 日常管理
$ sudo systemctl status redis-server
$ sudo systemctl restart redis-server
$ sudo systemctl stop redis-server
$ sudo systemctl is-active redis-server
预期输出:
active
| 现象 | 根因 | 解决 |
|---|---|---|
systemctl start redis-server 报 unit 不存在 |
装的是系统源旧包或源码编译没配 unit | 用官方源安装;源码场景参考 systemd 服务管理 自己写 unit |
systemd 通用知识(unit 结构、journalctl 日志、资源限制)见 systemd 服务管理。
五分钟掌握最常用的六个命令,覆盖缓存读写的基本闭环:写入、读取、删除、过期。
$ redis-cli -a '<强密码>'
# 写入字符串
127.0.0.1:6379> SET greeting "hello"
# 读取
127.0.0.1:6379> GET greeting
# 删除
127.0.0.1:6379> DEL greeting
# 设置 60 秒过期
127.0.0.1:6379> SET session:abc "user1" EX 60
# 查看剩余存活秒数
127.0.0.1:6379> TTL session:abc
# 列出匹配键
127.0.0.1:6379> KEYS "session:*"
| 命令 | 作用 | 备注 |
|---|---|---|
SET key value |
写入 | EX 60 可同时设 60 秒过期 |
GET key |
读取 | 不存在返回 nil |
DEL key |
删除 | — |
EXPIRE key 60 |
给已有键设过期 | TTL 查剩余秒数,-1 表示永不过期 |
KEYS pattern |
按模式列键 | 见下方 TIP,生产禁用 |
[!TIP] 生产环境禁用
KEYS *。 它会一次性遍历全部键并阻塞 Redis,键多时整个服务卡死数秒。线上排查用SCAN 0 MATCH "session:*" COUNT 100渐进式遍历,不阻塞。
127.0.0.1:6379> SET greeting "hello"
OK
127.0.0.1:6379> GET greeting
"hello"
| 现象 | 根因 | 解决 |
|---|---|---|
SET 报 NOAUTH |
连接后没 AUTH |
先执行 AUTH <密码> 或登录时用 -a |
Redis 的持久化文件本身就是备份产物:RDB 快照在 /var/lib/redis/dump.rdb,AOF 在同目录的 appendonly.aof。备份 = 定期把这些文件拷走。
# 手动触发一次 RDB 快照(阻塞极短)
$ redis-cli -a '<强密码>' BGSAVE
# 确认落盘时间
$ redis-cli -a '<强密码>' INFO persistence | grep rdb_last_save_time
# 拷贝快照到备份目录
$ sudo cp /var/lib/redis/dump.rdb /backup/redis-$(date +%F).rdb
| 方式 | 场景 |
|---|---|
BGSAVE |
后台 fork 子进程做快照,生产用这个;SAVE 会阻塞主进程,禁用 |
拷 .rdb 文件 |
恢复时停服、替换文件、重启即可 |
$ ls -lh /backup/
预期输出:
-rw-r--r-- 1 root root 1.2M Sep 29 10:00 redis-2026-09-29.rdb
| 现象 | 根因 | 解决 |
|---|---|---|
BGSAVE 报错或日志出现 MISCONF |
数据目录磁盘满或属主不对 | 清磁盘 / chown redis:redis /var/lib/redis 后重启 |
定时备份与轮转见 备份与定时任务。
汇总全章高频故障,排错先查这张表,再查日志(journalctl -u redis-server)。
按「现象 → 根因 → 解决」三栏定位;远程连不上一律走「bind → protected-mode → 防火墙 → 云安全组」四层排查。
每次改动后固定跑:systemctl is-active redis-server → 带密码 ping 返回 PONG。
| 现象 | 根因 | 解决 |
|---|---|---|
| 远程连不上 | bind 没含内网 IP / protected-mode 拦截 / 防火墙 / 安全组 |
四层逐项排查;确认确实需要远程再放行,且必须已设密码 |
NOAUTH Authentication required |
连接未带密码 | redis-cli -a '<密码>' 或连接后 AUTH |
密码含特殊字符时 -a 报错 |
shell 把 !、$ 等做了历史展开/变量替换 |
改用 -u "redis://:<URL编码后的密码>@127.0.0.1:6379",特殊字符做 URL 编码 |
写入报 (error) OOM command not allowed |
内存达到 maxmemory 且策略是 noeviction |
纯缓存场景改 maxmemory-policy allkeys-lru;数据场景扩内存并加监控 |
MISCONF Redis is configured to save RDB snapshots |
RDB 落盘失败:磁盘满或目录无写权限 | 清理磁盘、修正 /var/lib/redis 属主,重启后 BGSAVE 验证 |
| RDB 保存一直失败 | 同上,另有内核 vm.overcommit_memory 限制 |
sysctl vm.overcommit_memory=1(写入 sysctl 配置持久化) |
# 安装(官方源)
$ sudo apt-get install lsb-release curl gpg
$ curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
$ echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
$ sudo apt-get update && sudo apt-get install redis
# 服务管理(Ubuntu 服务名是 redis-server)
$ sudo systemctl enable --now redis-server
$ sudo systemctl status redis-server
$ sudo systemctl restart redis-server
# 验证
$ redis-server --version
$ redis-cli -a '<强密码>' ping
$ redis-cli -u "redis://:<强密码>@127.0.0.1:6379" ping
$ redis-cli -a '<强密码>' INFO server | grep redis_version
# 基本使用
$ redis-cli -a '<强密码>'
> SET key value
> GET key
> DEL key
> TTL key
> SCAN 0 MATCH "prefix:*" COUNT 100
# 备份
$ redis-cli -a '<强密码>' BGSAVE
$ sudo cp /var/lib/redis/dump.rdb /backup/redis-$(date +%F).rdb
| 章节 | 内容 |
|---|---|
| 版本速查表 | 全书软件版本基线的唯一权威来源 |
| Docker | 用容器跑 Redis 的替代方案 |
| systemd 服务管理 | redis-server 服务与日志管理 |
| 源码编译安装 | 官方源不可达时的编译安装 |
| 备份与定时任务 | RDB 自动备份与轮转 |
| 防火墙 | 内网放行 6379 的正确姿势 |
RabbitMQ 是最常用的开源消息队列之一,负责在应用之间「异步传话」:下单后发短信、日志上报、任务分发,都可以丢给消息队列慢慢处理。本章带你走完:选版本 → 装 Erlang → 装 RabbitMQ → 最小配置 → 建用户和 vhost → 验证 → 交给 systemd 托管。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
RabbitMQ 不是独立程序,它是跑在 Erlang/OTP 运行时上的应用。Erlang 是一门为电信级高并发设计的语言,RabbitMQ 借用它获得了极强的消息吞吐和故障恢复能力 —— 代价是:装 RabbitMQ 之前必须先装一个版本匹配的 Erlang。版本不匹配轻则报依赖错误,重则运行时不稳定,这是 RabbitMQ 安装阶段 90% 的问题来源。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 4.3.x(如 4.3.5 / 4.3.6+) | 截至 2026-09 的稳定线 |
| 不建议 | 3.13.x 及更早 | 已陆续 EOL,别在新机器上装 |
| 依赖运行时 | Erlang/OTP 27.x(推荐) | 见下表 |
| Ubuntu 22.04 系统源 | 版本很旧 | 必须走 RabbitMQ 官方源 |
先看版本对应表,确定「RabbitMQ 版本 ↔ Erlang 版本」的搭配,再去安装:
| RabbitMQ 版本 | 最低 Erlang | 最高 Erlang | 说明 |
|---|---|---|---|
| 4.3.x(4.3.0 – 4.3.5) | 26.2 | 27.x | 4.3 全系完整支持 Erlang 27 |
| 4.3.6 及以上 | 26.2 | 28 / 29(28 仅限全新集群) | 从 4.3.6 起才支持 Erlang 28/29 |
| 4.2.x | 26.2 | 27.x | 上一稳定线,仍可运行但不建议新装 |
| 3.13.x | 25.x | 26.x | Erlang 26 已于 2026-05 EOL,不要再用 |
结论:装 Erlang 27.x + RabbitMQ 4.3.x(尽量 4.3.6+)。理由:
装完后用下面的命令核对版本(详见「验证方法」一节):
$ rabbitmqctl version
预期输出:
RabbitMQ 4.3.6
apt install erlang 装系统源的 Erlang? 不要。Ubuntu 22.04 源里的 Erlang 版本很旧,且依赖树里混入大量无关包,容易和官方 RabbitMQ 的依赖要求冲突。安装分两步:先装 Erlang(来自 RabbitMQ 官方 Erlang 源),再装 RabbitMQ(来自 RabbitMQ 官方服务器源)。两个源都是 Team RabbitMQ 官方维护的,签名可信、版本匹配。
[!WARNING] 安装顺序铁律:先配好官方 Erlang 源、绝不混入系统源的 Erlang,再一条命令装 rabbitmq-server(apt 会自动解析 Erlang 依赖)。 直接装而没配 Erlang 源,或中途混用系统源的 Erlang,会出现依赖冲突导致装不上(典型报错是
rabbitmq-server : Depends: erlang-...一长串)。如果已经装坏,先sudo apt purge 'erlang*' rabbitmq-server清干净再按本章顺序重来。
第 1 步:添加 RabbitMQ 官方 Erlang 源。
# 更新包索引并安装基础工具
$ sudo apt update && sudo apt install -y curl gnupg apt-transport-https
# 下载 RabbitMQ 团队签名的 GPG 密钥到 keyrings 目录
$ curl -1sLf "https://keys.openpgp.org/vks/v1/by-fingerprint/0A9AF2115F4687BD29803A206B73A36E6026DFCA" | sudo gpg --dearmor | sudo tee /usr/share/keyrings/io.rabbitmq.rabbitmq-erlang.gpg > /dev/null
# 添加 Erlang 官方源(jammy 是 Ubuntu 22.04 的代号)
$ echo "deb [signed-by=/usr/share/keyrings/io.rabbitmq.rabbitmq-erlang.gpg] https://ppa1.rabbitmq.io/rabbitmq/rabbitmq-erlang/deb/ubuntu jammy main" | sudo tee /etc/apt/sources.list.d/rabbitmq-erlang.list
[!NOTE] 源地址和密钥指纹以 RabbitMQ 官方文档(Install: Debian and Ubuntu 页面)为准,官方偶尔会更换密钥托管方式;本章写法截至 2026-09 可用。
第 2 步:添加 RabbitMQ 官方服务器源。
# 下载 RabbitMQ 服务器源的签名密钥
$ curl -1sLf "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xf77f1eda57ebb1c7" | sudo gpg --dearmor | sudo tee /usr/share/keyrings/io.rabbitmq.rabbitmq-server.gpg > /dev/null
# 添加 RabbitMQ 服务器源
$ echo "deb [signed-by=/usr/share/keyrings/io.rabbitmq.rabbitmq-server.gpg] https://ppa1.rabbitmq.io/rabbitmq/rabbitmq-server/deb/ubuntu jammy main" | sudo tee /etc/apt/sources.list.d/rabbitmq-server.list
# 刷新包索引
$ sudo apt update
第 3 步:安装。官方源会自动带上匹配版本的 Erlang 依赖,这是推荐路径 —— 你不需要手动装 Erlang。
# 确认候选版本来自官方源、是 4.3.x
$ apt policy rabbitmq-server
# 安装(apt 会自动解析并安装匹配的 Erlang 27.x)
$ sudo apt install -y rabbitmq-server
| 步骤 | 为什么这么做 |
|---|---|
GPG 密钥放 /usr/share/keyrings/ |
apt 只信任 signed-by 指定密钥签名的包,避免全局信任 |
| Erlang 单独建源 | 保证拿到 RabbitMQ 团队验证过的 Erlang 版本,而不是系统旧版 |
| 用官方 server 源一键装 | apt 自动解决「RabbitMQ 4.3 ↔ Erlang 27」的依赖匹配,不易装错 |
# 查看装上的版本
$ rabbitmqctl version
预期输出:
RabbitMQ 4.3.6
# 确认 Erlang 版本在 27.x 支持范围内
$ erl -noshell -eval 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().'
预期输出:
27
Depends: erlang-nox (= 1:27.x) 之类:说明混了系统源 Erlang,见上方 WARNING 清理重来。apt update 报 GPG 签名无效:密钥下载不完整,重新执行下载密钥那条命令。RabbitMQ 的高级行为(节点名、端口、文件路径等)可以通过环境变量调整;而日常运维参数(端口、内存阈值、监听地址)更推荐写在配置文件里。两者优先级不同,初学者容易混淆。
绝大多数情况你不需要设置任何环境变量,用默认值即可。只有改节点名 / 端口时才需要。环境变量写在 /etc/rabbitmq/rabbitmq-env.conf 里:
# 节点名(默认 rabbit@主机名,跨主机组集群时才有必要改)
NODENAME=rabbit@node1
# AMQP 监听端口(默认 5672,一般不改)
NODE_PORT=5672
改完环境变量必须重启服务才生效:
$ sudo systemctl restart rabbitmq-server
日常运维参数写 /etc/rabbitmq/rabbitmq.conf,本章「最小必要配置」给完整示例。
配置优先级(从高到低):
| 层级 | 位置 | 格式 | 初学者建议 |
|---|---|---|---|
| 1 | advanced.config |
Erlang 语法 | 不要碰,只在 rabbitmq.conf 表达不了的极少数场景用 |
| 2 | rabbitmq.conf |
key = value |
日常配置全写这里,新式主配置 |
| 3 | rabbitmq-env.conf |
环境变量 | 只放 NODENAME / NODE_PORT 等启动期变量 |
[!TIP] 网上老教程常让你改 Erlang 格式的配置文件,那是旧版做法。RabbitMQ 3.7 之后统一用
rabbitmq.conf的key = value格式,直观得多。
# 确认配置文件被正确解析(会列出当前生效配置)
$ sudo rabbitmqctl environment | grep -A2 vm_memory_high_watermark
预期输出:
{vm_memory_high_watermark,{relative,0.6}},
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了配置不生效 | 改完没重启 | sudo systemctl restart rabbitmq-server |
| 改完服务起不来 | 配置文件语法写错 | 看日志 /var/log/rabbitmq/ 定位行号,改回合法格式 |
一台刚装好的 RabbitMQ 要做三件事才算「可用且安全」:调好资源阈值、开管理控制台、把默认 guest 换成你自己的管理员账号。vhost(virtual host,虚拟主机)是 RabbitMQ 的隔离单元 —— 类似数据库实例里的 schema,不同业务的交换机和队列放在不同 vhost 里互不干扰。
第 1 步:写基础配置文件 /etc/rabbitmq/rabbitmq.conf。
$ sudo tee /etc/rabbitmq/rabbitmq.conf > /dev/null <<'EOF'
listeners.tcp.default = 5672
management.tcp.port = 15672
vm_memory_high_watermark.relative = 0.6
disk_free_limit.absolute = 2GB
EOF
| 参数 | 含义 | 为什么这么设 |
|---|---|---|
listeners.tcp.default |
AMQP 服务端口 | 5672 是标准端口,客户端都默认连它 |
management.tcp.port |
Web 控制台端口 | 15672 是管理界面标准端口 |
vm_memory_high_watermark.relative |
内存高水位(占系统内存比例) | 默认 0.4 偏保守,小内存机器可放到 0.6;超线后 RabbitMQ 会阻塞生产者自我保护 |
disk_free_limit.absolute |
磁盘剩余空间下限 | 低于 2GB 节点会自动停止写入,防止磁盘写满把整台机器拖垮 |
第 2 步:启用管理插件,重启服务。
# 开启 15672 Web 控制台
$ sudo rabbitmq-plugins enable rabbitmq_management
# 应用新配置
$ sudo systemctl restart rabbitmq-server
# 设置开机自启并立即启动
$ sudo systemctl enable --now rabbitmq-server
第 3 步:创建管理员账号,删除默认 guest。
[!IMPORTANT] 默认账号 guest/guest 禁止远程登录 —— 这是 RabbitMQ 的默认安全策略:只允许从本机 localhost 登录。所以装完第一件事就是建自己的管理员,并把 guest 删掉,别指望用 guest 远程连控制台。
# 创建管理员用户(密码里的单引号防止特殊字符被 shell 解析)
$ sudo rabbitmqctl add_user admin '你的强密码'
# 赋予 administrator 标签(控制台管理权限)
$ sudo rabbitmqctl set_user_tags admin administrator
# 给 admin 在根 vhost(/)上配置/读/写全部权限
$ sudo rabbitmqctl set_permissions -p / admin '.*' '.*' '.*'
# 删除默认 guest 账号
$ sudo rabbitmqctl delete_user guest
第 4 步:建业务用户与业务 vhost(应用连 RabbitMQ 时用它们,不要用 admin)。
# 创建业务 vhost
$ sudo rabbitmqctl add_vhost appvhost
# 创建业务用户
$ sudo rabbitmqctl add_user appuser '业务用户的强密码'
# 只给业务用户在 appvhost 上的权限,根 vhost 一概不可见
$ sudo rabbitmqctl set_permissions -p appvhost appuser '.*' '.*' '.*'
第 5 步:防火墙按需放行。
[!WARNING] 15672 管理控制台绝不能对公网开放。 5672 也只应放行给应用所在的网段。消息队列被公网扫到后,弱口令爆破和恶意建队列的事故非常多。放行方法见 防火墙。
# 只对应用网段放行 AMQP 端口(把网段换成你的实际值)
$ sudo ufw allow from 10.0.0.0/24 to any port 5672 proto tcp
# 只对运维 IP 放行管理控制台
$ sudo ufw allow from 203.0.113.10 to any port 15672 proto tcp
set_permissions -p <vhost> <user> '<conf>' '<write>' '<read>' 三个正则分别控制配置、写、读;'.*' 表示全部放行。生产上可以收紧,比如只允许写 app.* 开头的交换机。
逐项验证配置是否生效:
# 查看用户与标签
$ rabbitmqctl list_users
预期输出:
Listing users ...
user tags
admin [administrator]
appuser []
# 查看 vhost 列表
$ rabbitmqctl list_vhosts
预期输出:
Listing vhosts ...
name
/
appvhost
| 现象 | 根因 | 解决 |
|---|---|---|
| 超过内存水位后程序发消息卡住 | RabbitMQ 阻塞生产者是设计行为 | 消费跟上后自动恢复;长期卡说明消费端太慢 |
| 业务用户登录控制台看不到队列 | 控制台只展示该用户有权限的 vhost | 正常现象;或给它加对应 vhost 权限 |
1. 版本与运行状态:
# 版本
$ rabbitmqctl version
# 诊断探活
$ rabbitmq-diagnostics ping
预期输出:
Ping succeeded for node rabbit@myserver
# 状态总览
$ sudo rabbitmqctl status
重点看四段:Runtime 里的 Erlang 版本(应为 27.x)、Listeners 里的监听端口、Queues 里的队列数、Memory 里的内存占用。日常巡检看这四项就够。
2. 端口监听:
# 确认 AMQP 和管理端口都在监听
$ ss -lntp | grep -E '5672|15672'
预期输出:
LISTEN 0 1024 0.0.0.0:5672 0.0.0.0:* users:(("beam.smp",pid=1234,fd=40))
LISTEN 0 1024 0.0.0.0:15672 0.0.0.0:* users:(("beam.smp",pid=1234,fd=41))
3. 管理 API 连通性(用你的管理员账号):
# 用 REST API 查询集群概要
$ curl -s -u admin:'你的强密码' http://localhost:15672/api/overview | head -c 300; echo
预期输出:
{"management_version":"4.3.6","rates_mode":"basic",...,"rabbitmq_version":"4.3.6",...}
4. 浏览器访问 http://<服务器IP>:15672,用 admin 登录看到 Overview 仪表盘即成功。若打不开,按顺序排查:管理插件是否启用 → 防火墙是否放行你的 IP → 是否误用了 guest 账号。
apt 安装的 RabbitMQ 自带 systemd 服务单元,不需要你手写 unit 文件,直接用即可。
# 开机自启并立即启动
$ sudo systemctl enable --now rabbitmq-server
# 查看 / 重启 / 停止
$ systemctl status rabbitmq-server
$ sudo systemctl restart rabbitmq-server
$ sudo systemctl stop rabbitmq-server
systemd 的启停原理、日志查看(journalctl -u rabbitmq-server)见 systemd 服务管理。
$ systemctl is-active rabbitmq-server && systemctl is-enabled rabbitmq-server
预期输出:
active
enabled
| 现象 | 根因 | 解决 |
|---|---|---|
状态显示 activating (auto-restart) 反复重启 |
配置文件语法错误 | 看 /var/log/rabbitmq/ 日志修正 rabbitmq.conf |
下面汇总安装与运行阶段的高频故障。排查通用思路:先 systemctl status,再看 /var/log/rabbitmq/ 日志,最后对照本表。
| 现象 | 根因 | 解决 |
|---|---|---|
| 装 RabbitMQ 时报 Erlang 依赖错误 | 混用了系统源旧 Erlang,或 Erlang 版本超出对应表范围 | 卸载系统源 Erlang,按「安装顺序铁律」用官方 Erlang 源重装;对照版本对应表 |
| Web 控制台登录不了(远程) | guest 禁止远程登录 | 建独立管理员账号(见「最小必要配置」),删 guest |
| 控制台 15672 打不开 | 没启用 management 插件 / 防火墙没放行 | rabbitmq-plugins enable rabbitmq_management;检查 ufw 规则 |
rabbitmqctl 报 unable to connect to node |
服务没起;epmd 端口被占;主机名解析失败(经典坑) | 先 systemctl status;ping $(hostname) 不通就在 /etc/hosts 加一行 127.0.1.1 <主机名> |
| 改过主机名后节点启动失败 | hostname 变更导致 Erlang cookie / 节点名不匹配 | 把 /var/lib/rabbitmq/.erlang.cookie 权限保持 400 且与节点匹配;或改回主机名后重启 |
| 节点突然自动停止写入 | 磁盘剩余低于 disk_free_limit |
清理磁盘或下调阈值;rabbitmqctl start_app 恢复 |
[!WARNING] 删除 vhost 会连带删除其中所有交换机、队列和消息,不可恢复。 执行
rabbitmqctl delete_vhost前务必确认 vhost 里没有业务数据。
修复后统一跑一遍健康检查:
$ rabbitmq-diagnostics ping && sudo rabbitmqctl status | head -5
预期输出:
Ping succeeded for node rabbit@myserver
Status of node rabbit@myserver ...
Runtime
OS PID: 1234
OS: Linux
Uptime (seconds): 3600
本节本身就是问题汇总;若上面表格没覆盖你的报错,把 /var/log/rabbitmq/ 里最后 50 行日志带去搜索,基本都能找到同款。
# ── 安装 ──────────────────────────────
$ sudo apt install -y rabbitmq-server # 官方源装 4.3.x(自动带 Erlang 27)
$ rabbitmqctl version # 查看 RabbitMQ 版本
# ── 服务管理 ──────────────────────────
$ sudo systemctl enable --now rabbitmq-server
$ sudo systemctl restart rabbitmq-server
$ sudo rabbitmqctl status # 运行状态(Erlang 版本/端口/队列/内存)
$ rabbitmq-diagnostics ping # 节点探活
# ── 用户与 vhost ──────────────────────
$ sudo rabbitmq-plugins enable rabbitmq_management # 开 15672 控制台
$ sudo rabbitmqctl add_user admin '密码'
$ sudo rabbitmqctl set_user_tags admin administrator
$ sudo rabbitmqctl set_permissions -p / admin '.*' '.*' '.*'
$ sudo rabbitmqctl add_vhost appvhost
$ sudo rabbitmqctl delete_user guest # 删除默认账号
$ rabbitmqctl list_users # 用户列表
$ rabbitmqctl list_vhosts # vhost 列表
# ── 验证 ──────────────────────────────
$ ss -lntp | grep -E '5672|15672'
$ curl -s -u admin:'密码' http://localhost:15672/api/overview
https://ppa1.rabbitmq.io/rabbitmq/rabbitmq-erlang/rpm/el/9 与 .../rabbitmq-server/rpm/el/9,配 .repo 文件后 dnf install rabbitmq-server。firewall-cmd --permanent --add-port=5672/tcp 后 firewall-cmd --reload。/etc/rabbitmq/rabbitmq.conf)与 Ubuntu 一致。Kafka 是大数据与日志采集领域事实上的标准消息系统,擅长高吞吐、可回放的流式数据传输。本章从零装起:下二进制包 → KRaft 模式格式化存储 → 写 systemd 服务 → 建 topic 收发验证。全程只用 KRaft 模式。
[!WARNING] 网上大量教程还在教 ZooKeeper 模式 —— 出现
--zookeeper localhost:2181、zookeeper-server-start.sh这类命令的,全部是 Kafka 3.x 及更早时代的做法。Kafka 4.0 起已彻底移除 ZooKeeper,只能跑 KRaft 模式,照老教程敲会直接报参数错误。看到 ZooKeeper 相关步骤,直接关掉那个教程。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
Kafka 4.x 是「无 ZooKeeper」时代的第一个大版本线:4.0 彻底删掉了 ZooKeeper 相关代码,元数据管理完全交给 Kafka 自身的 KRaft(Kafka Raft)协议。选版本时只需要记住一条:4.x 只能 KRaft,不需要也不存在「装 ZooKeeper」这一步。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 4.2 / 4.3(4.x 线;具体补丁号以官网 downloads 页为准) | 截至 2026-09 的当前稳定线 |
| 运行环境 | Java 17+(17 / 21 / 25 受支持) | Java 11 仅部分模块可用,别用 |
| 发布形式 | 官方二进制包(Scala 2.13 构建) | Ubuntu 源里没有 Kafka,必须官网下载 |
| 安装包命名 | kafka_2.13-4.x.x.tgz |
2.13 是 Scala 版本,与你的 JDK 无关 |
[!NOTE] Kafka 4.x 对 ZooKeeper 模式零支持:
server.properties里写zookeeper.connect会直接被拒绝。找配置示例时认准带process.roles的 KRaft 写法。
新装机器无脑选 4.2+,唯一前置依赖是 JDK 17+,推荐 JDK 21:
# 确认 Java 版本满足 17+
$ java -version
预期输出:
openjdk version "21.0.x" 2026-xx-xx
OpenJDK Runtime Environment (build 21.0.x+xx-Ubuntu-...)
没装或版本低于 17,先按 JDK 安装 装好再回来。
| 场景 | 选哪个 | 为什么 |
|---|---|---|
| 新机器、新集群 | 4.2 / 4.3 | 当前稳定线,bug 修复和安全补丁都覆盖 |
| 跟着老教程学习 | 仍然 4.x + KRaft | 把老教程的 ZooKeeper 步骤跳过,命令按本章写法 |
确认准备就绪:Java 版本(见上)+ 能访问 Apache 下载站:
# 探测下载域名可达
$ curl -sI https://downloads.apache.org/kafka/ | head -1
预期输出:
HTTP/1.1 200 OK
| 现象 | 根因 | 解决 |
|---|---|---|
| 启动即报 Java 版本相关错误 | JDK 低于 17 | 换 JDK 21,见 20 JDK |
找不到 zookeeper-server-start.sh |
Kafka 4.x 已移除 | 正常现象,改走 KRaft 流程 |
Kafka 官方以 tar.gz 二进制包发布,解压即用。装到 /opt 下并做一个软链接指向它:路径固定为 /opt/kafka,将来升级只需换软链接指向,所有脚本和 systemd 配置都不用改(目录规划思路见 Shell 与文件系统)。
# 下载二进制包(版本号以 https://kafka.apache.org/downloads 为准;下文用 4.x.x 代表实际版本号)
$ wget -P /tmp https://downloads.apache.org/kafka/4.x.x/kafka_2.13-4.x.x.tgz
# 解压到 /opt
$ sudo tar -xzf /tmp/kafka_2.13-4.x.x.tgz -C /opt
# 建软链接 /opt/kafka 指向带版本号的目录
$ sudo ln -s /opt/kafka_2.13-4.x.x /opt/kafka
# 创建专用运行用户(systemd 托管时用,不建 Linux 登录)
$ sudo useradd -r -d /opt/kafka -s /usr/sbin/nologin kafka
# 把安装目录交给 kafka 用户
$ sudo chown -R kafka:kafka /opt/kafka_2.13-4.x.x
[!TIP] 把
/opt/kafka/bin加入 PATH 可以少敲路径。编辑~/.bashrc加一行export PATH=$PATH:/opt/kafka/bin,source ~/.bashrc生效。本章为清晰起见统一用绝对路径。
| 决策 | 理由 |
|---|---|
装 /opt |
第三方软件的标准位置,与系统目录隔离 |
软链接 /opt/kafka |
升级时换指向即可,PATH / systemd 配置零改动 |
专用 kafka 用户 |
服务以最小权限运行,被攻破也不波及系统 |
# 确认解压结果和脚本可执行
$ ls /opt/kafka/bin/kafka-server-start.sh && /opt/kafka/bin/kafka-topics.sh --version
预期输出:
/opt/kafka/bin/kafka-server-start.sh
4.x.x(实际版本号,以官网 downloads 为准)
| 现象 | 根因 | 解决 |
|---|---|---|
| 官网 404 或下载极慢 | 版本已更替 / 网络问题 | 以官网 downloads 页列出的链接为准;国内可配镜像(见 镜像加速) |
tar 报不是 gzip 文件 |
下载到的是 HTML 错误页 | file /tmp/kafka_*.tgz 确认,重新下载 |
Kafka 的启动脚本优先靠 JAVA_HOME 找 JDK(未设置时才退回 PATH 里的 java)。顺着 java 命令反查两层目录,即可拿到当前生效的 JAVA_HOME:
$ dirname $(dirname $(readlink -f $(which java)))
预期输出:
/usr/lib/jvm/java-21-openjdk-amd64
输出的就是 JAVA_HOME 的值。设置 JAVA_HOME、多版本切换的完整做法见 20 JDK;后面用 systemd 托管 Kafka 时,记得在 unit 里用 Environment=JAVA_HOME=... 显式声明(systemd 不读 .bashrc)。
Kafka 的全部行为都写在 config/server.properties 里。单机 KRaft 模式下,这个进程同时扮演两个角色:broker(存数据、服务客户端)和 controller(用 Raft 协议管理集群元数据),所以 process.roles 要同时写两个。
编辑 /opt/kafka/config/server.properties,关注以下几项(其余保持默认):
# 单机模式:同一进程兼任 broker 和 controller
process.roles=broker,controller
# 节点唯一 ID(集群内不得重复)
node.id=1
# 元数据仲裁地址(单机指向自己;4.x 新版也可用 controller.quorum.bootstrap.servers,以官方配置文档为准)
controller.quorum.voters=1@localhost:9093
# 对内监听与对外宣告地址
listeners=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
advertised.listeners=PLAINTEXT://<服务器IP或域名>:9092
# controller 与 broker 之间的安全协议映射
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
# 数据目录(日志段文件存放处)
log.dirs=/opt/kafka/data
# 每个 topic 默认分区数
num.partitions=3
# 消息保留时间(过期自动删除)
log.retention.hours=168
# 关闭「自动创建 topic」,所有 topic 显式创建
auto.create.topics.enable=false
先建数据目录:
$ sudo mkdir -p /opt/kafka/data && sudo chown kafka:kafka /opt/kafka/data
格式化存储(首次启动前必做一次):
[!IMPORTANT] KRaft 模式首次启动前必须用集群 UUID 格式化存储目录。跳过这一步直接启动,日志会报
INCONSISTENT_CLUSTER_ID或提示Format the cluster,broker 起不来。
# 生成集群 UUID
$ /opt/kafka/bin/kafka-storage.sh random-uuid
预期输出:
dV2kQ4m8R8yF3fN6xVwJ2A
# 用该 UUID 格式化存储目录(单机 standalone 模式)
$ sudo /opt/kafka/bin/kafka-storage.sh format --standalone \
-t dV2kQ4m8R8yF3fN6xVwJ2A -c /opt/kafka/config/server.properties
| 参数 | 含义 | 为什么重要 |
|---|---|---|
process.roles |
进程角色 | 单机合并 broker+controller,省一个进程 |
node.id |
节点编号 | 元数据仲裁和副本分配都靠它认节点 |
controller.quorum.voters |
谁来投票管理元数据 | 格式 节点id@主机:9093,单机就是 1@localhost:9093 |
listeners |
实际绑定的地址端口 | 0.0.0.0 表示监听所有网卡 |
advertised.listeners |
宣告给客户端的地址 | 客户端先连 bootstrap 地址拿元数据,再用宣告地址收发消息。这是 Kafka 最经典的坑:写 localhost 的话,远程客户端拿到元数据后会去连它自己的 localhost,表现就是连得上但拉不到数据、反复元数据失败。跨机/公网访问必须写真实 IP 或域名 |
log.dirs |
数据目录 | 放在容量足够、IO 好的盘上 |
num.partitions |
默认分区数 | 3 是常见起步值,分区是并行消费的单位 |
log.retention.hours |
保留时长 | 168 即 7 天,按磁盘容量和业务需求调 |
auto.create.topics.enable=false |
禁止隐式建 topic | 防止写错 topic 名悄悄建出一堆垃圾 topic |
# 确认格式化结果
$ sudo ls /opt/kafka/data
预期输出:
meta.properties
# 核对元数据里的集群 ID
$ sudo cat /opt/kafka/data/meta.properties | head -3
预期输出:
cluster.id=dV2kQ4m8R8yF3fN6xVwJ2A
node.id=1
version=1
| 现象 | 根因 | 解决 |
|---|---|---|
启动报 INCONSISTENT_CLUSTER_ID |
数据目录残留旧集群 ID,与新 UUID 不一致 | 清空 log.dirs 目录后重新 format(确认无数据可丢) |
启动报 Format the cluster 类错误 |
首次启动前没 format | 按上文执行 kafka-storage.sh format |
改了 server.properties 不生效 |
没重启 broker | sudo systemctl restart kafka |
Kafka 不像 RabbitMQ 有统一的「status」命令,验证分三层:进程在听端口 → broker 能响应协议 → 真正建 topic 收发消息。三层都过才算部署成功。
1. 端口监听(先启动服务,见下一节的 systemd 托管):
$ ss -lntp | grep 9092
预期输出:
LISTEN 0 50 *:9092 *:* users:(("java",pid=5678,fd=120))
2. broker 协议连通性:
# 列出 broker 支持的 API 版本,能输出即协议层连通
$ /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 | head
预期输出:
localhost:9092 (id: 1 rack: null) -> ...
Produce(0): 0 to 11 [usable: 11]
Fetch(1): 0 to 16 [usable: 16]
...
3. topic 收发闭环:
# 创建测试 topic:3 分区、单副本(单机只能 1 副本)
$ /opt/kafka/bin/kafka-topics.sh --create --topic test \
--bootstrap-server localhost:9092 --partitions 3 --replication-factor 1
# 列出所有 topic
$ /opt/kafka/bin/kafka-topics.sh --list --bootstrap-server localhost:9092
# 查看 topic 详情
$ /opt/kafka/bin/kafka-topics.sh --describe --topic test --bootstrap-server localhost:9092
预期输出:
test
Topic: test TopicId: x8... PartitionCount: 3 ReplicationFactor: 1
Topic: test Partition: 0 Leader: 1 Replicas: 1 Isr: 1
Topic: test Partition: 1 Leader: 1 Replicas: 1 Isr: 1
Topic: test Partition: 2 Leader: 1 Replicas: 1 Isr: 1
# 控制台生产者:输入几行文字后 Ctrl+C 退出
$ /opt/kafka/bin/kafka-console-producer.sh --topic test --bootstrap-server localhost:9092
预期输出:
>hello kafka
>message two
>
# 控制台消费者:能收到刚才的消息即成功,Ctrl+C 退出
$ /opt/kafka/bin/kafka-console-consumer.sh --topic test --from-beginning --bootstrap-server localhost:9092
预期输出:
hello kafka
message two
[!IMPORTANT] 所有客户端命令一律用
--bootstrap-server localhost:9092。老教程里的--zookeeper参数在 4.x 已被移除,使用会直接报错。
| 现象 | 根因 | 解决 |
|---|---|---|
--list 能通但远程程序连不上 |
advertised.listeners 配错(写了 localhost 或内网 IP) |
见「常见问题」一节的专项排查 |
| 消费者收不到消息 | topic 名打错 / 消费了别的 group offset | 用 --from-beginning 重试,核对 topic 名 |
Kafka 官方不带服务管理脚本,kafka-server-start.sh 只是前台启动进程,SSH 一断就死。生产环境必须交给 systemd 托管:开机自启、崩溃自动拉起、日志进 journal。systemd 基础见 systemd 服务管理。
创建 /etc/systemd/system/kafka.service:
$ sudo tee /etc/systemd/system/kafka.service > /dev/null <<'EOF'
[Unit]
Description=Apache Kafka (KRaft)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=kafka
Group=kafka
Environment=JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
ExecStart=/opt/kafka/bin/kafka-server-start.sh /opt/kafka/config/server.properties
ExecStop=/opt/kafka/bin/kafka-server-stop.sh
Restart=on-failure
RestartSec=10
LimitNOFILE=100000
[Install]
WantedBy=multi-user.target
EOF
# 重载 unit 定义并启动
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now kafka
| 指令 | 作用 | 为什么这么写 |
|---|---|---|
User=kafka |
以专用用户运行 | 最小权限原则,避免 root 跑 Java 服务 |
Environment=JAVA_HOME=... |
显式指定 JDK | systemd 不读 .bashrc,环境变量必须在 unit 里给全,否则脚本找不到 java |
ExecStart 绝对路径 |
不依赖 PATH | 同上,systemd 环境 PATH 很精简 |
Restart=on-failure |
崩溃自动拉起 | 异常退出 10 秒后重启,正常 stop 不重启 |
LimitNOFILE=100000 |
提高文件句柄上限 | Kafka 并发读写大量日志段文件,默认 1024 远远不够 |
$ systemctl status kafka
预期输出:
● kafka.service - Apache Kafka (KRaft)
Loaded: loaded (/etc/systemd/system/kafka.service; enabled)
Active: active (running) since ...
Main PID: 5678 (java)
| 现象 | 根因 | 解决 |
|---|---|---|
| systemd 起不来但手动能跑 | unit 里没设 JAVA_HOME,找不到 java | 加 Environment=JAVA_HOME=...(确认路径:readlink -f $(which java) 的父目录) |
status 显示 203/EXEC |
ExecStart 路径不对或脚本没执行权限 | 核对软链接路径;ls -l /opt/kafka/bin/*.sh 看权限 |
| 启动后几秒又退出 | storage 没 format 或 server.properties 语法错 |
journalctl -u kafka -n 50 看具体报错 |
汇总 KRaft 单机部署的高频故障。排查第一入口永远是:journalctl -u kafka -n 100。
| 现象 | 根因 | 解决 |
|---|---|---|
启动报 storage 未 format / INCONSISTENT_CLUSTER_ID |
首次启动没 format,或数据目录混了旧集群 ID | 执行 kafka-storage.sh format;有旧数据则先确认可丢再清目录 |
| 本机能连,远程客户端超时 / 反复拉元数据失败 | advertised.listeners 写了 localhost 或错 IP |
改成客户端能路由到的 IP/域名,重启后重连。排查步骤见下 |
| 启动直接失败 | Java 版本低于 17 | 升级 JDK 21(见 20 JDK) |
| 磁盘满后写入报错 | broker 触发磁盘保护,或日志没清理 | 清磁盘;核对 log.retention.hours 和磁盘余量 |
生产者报 NotEnoughReplicas |
副本同步数不足(单机 1 副本时通常是 min.insync.replicas 配大) | 单机保持默认;集群场景核对 ISR 与 min.insync.replicas |
| broker 起来但建 topic 失败 | controller 角色未生效 / node.id 与 voters 不一致 |
核对 process.roles 含 controller、controller.quorum.voters 与 node.id 匹配 |
| systemd 起不来,手动能跑 | JAVA_HOME 未在 unit 中设置 | 见上一节 |
advertised.listeners 专项排查(客户端连不上时按序执行):
# 1. 在服务器上先确认 broker 本机协议连通
$ /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 | head -1
# 2. 看宣告出去的地址是什么(客户端会拿这个地址连)
$ grep advertised.listeners /opt/kafka/config/server.properties
# 3. 在客户端机器上测试宣告地址是否可达
$ nc -zv <宣告的IP> 9092
预期输出(第 3 步):
Connection to 203.0.113.50 9092 port [tcp/*] succeeded!
三步中任何一步不通,对应修:第 1 步不通查 broker 日志;第 2 步是 localhost 就改成真实地址;第 3 步不通查防火墙与云安全组(见 防火墙)。
[!WARNING] 9092 端口不要对公网裸奔。 Kafka 默认无任何认证,公网可达等于数据对所有人开放读写。生产环境建议:防火墙只放行应用网段;跨网需求上 SASL/SSL 认证加密(配置见官方 Security 文档,初学阶段先把端口收好)。
[!WARNING] 重置消费者组 offset(
kafka-consumer-groups.sh --reset-offsets)会让组内消费者从指定位置重新读消息,可能造成重复消费或漏消息,执行前务必停掉消费组并确认偏移量目标。
生产者发消息,消费者以「组」为单位消费:同组内的消费者分摊分区,各组的消费进度(offset)独立记录。日常最常用的是看积压:
# 查看消费者组详情与积压
$ /opt/kafka/bin/kafka-consumer-groups.sh --describe --group mygroup --bootstrap-server localhost:9092
预期输出:
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
mygroup test 0 120 125 5
mygroup test 1 88 88 0
LAG 列就是积压量:LOG-END-OFFSET(已写入的最新位置)减 CURRENT-OFFSET(组消费到的位置)。LAG 长期增长说明消费速度跟不上生产速度,优先排查消费者性能或加分区。
做完一轮故障修复后,跑一遍完整闭环确认:
$ systemctl is-active kafka && /opt/kafka/bin/kafka-topics.sh --list --bootstrap-server localhost:9092
预期输出:
active
test
# ── 首次部署(4.x.x 代表实际版本号,以官网 downloads 为准)──
wget -P /tmp https://downloads.apache.org/kafka/4.x.x/kafka_2.13-4.x.x.tgz
sudo tar -xzf /tmp/kafka_2.13-4.x.x.tgz -C /opt
sudo ln -s /opt/kafka_2.13-4.x.x /opt/kafka
/opt/kafka/bin/kafka-storage.sh random-uuid # 生成集群 UUID
sudo /opt/kafka/bin/kafka-storage.sh format --standalone \
-t <UUID> -c /opt/kafka/config/server.properties # 格式化存储(首次必做)
sudo systemctl daemon-reload && sudo systemctl enable --now kafka
# ── topic 管理 ────────────────────────
/opt/kafka/bin/kafka-topics.sh --create --topic test \
--bootstrap-server localhost:9092 --partitions 3 --replication-factor 1
/opt/kafka/bin/kafka-topics.sh --list --bootstrap-server localhost:9092
/opt/kafka/bin/kafka-topics.sh --describe --topic test --bootstrap-server localhost:9092
# ── 收发验证 ──────────────────────────
/opt/kafka/bin/kafka-console-producer.sh --topic test --bootstrap-server localhost:9092
/opt/kafka/bin/kafka-console-consumer.sh --topic test --from-beginning --bootstrap-server localhost:9092
# ── 消费者组 ──────────────────────────
/opt/kafka/bin/kafka-consumer-groups.sh --describe --group mygroup --bootstrap-server localhost:9092
# ── 健康检查 ──────────────────────────
ss -lntp | grep 9092
/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 | head
systemctl status kafka
journalctl -u kafka -n 100
/opt 目录规划与软链接dnf install java-21-openjdk-headless)。firewall-cmd --permanent --add-port=9092/tcp 后 firewall-cmd --reload。Nginx 是最流行的 Web 服务器与反向代理。本章走完整链路:选版本 → 官方源安装 → 看懂配置结构 → 服务管理与平滑重载 → server/location 核心概念 → 静态站点与反向代理 → 日志与日常排错。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
Nginx 把版本线拆成两条:**stable(稳定版)**和 mainline(主线版)。stable 不是"老版本",而是从 mainline 分出来、只接收 bug 修复的分支;mainline 新特性多,但变化也快。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | 1.30 stable(截至 2026-09) | 生产环境首选,只修 bug 不加新特性 |
| 尝鲜可选 | 1.31 mainline(截至 2026-09) | 新特性先在这条线上出现,适合测试环境 |
| Ubuntu 22.04 系统源 | 1.18 | 太旧,且已停止功能更新,不建议新项目使用 |
| License | 类 BSD 开源协议 | 免费商用;另有商业版 Nginx Plus |
先看系统源里是什么版本,确认"源里只有 1.18"这个前提:
# 查看 apt 会装哪个版本的 nginx
$ apt policy nginx
预期输出:
nginx:
Installed: (none)
Candidate: 1.18.0-6ubuntu14.4
Version table:
1.18.0-6ubuntu14.4 500
500 http://cn.archive.ubuntu.com/ubuntu jammy/main amd64 Packages
Candidate 是 1.18,说明系统源太旧。要拿 1.30 stable,需要添加 nginx.org 官方源,见下一节。
| 场景 | 选哪条线 | 为什么 |
|---|---|---|
| 生产环境 | 1.30 stable | 只修 bug,行为可预期,升级风险最小 |
| 学习、本机实验 | 1.30 stable 或 1.31 mainline | 跟教程走用 stable,出问题最好搜 |
| 需要最新特性 | 1.31 mainline | 新模块先上线,但要承担不稳定风险 |
| 使用系统源的 1.18 | 不推荐 | 缺新特性与修复,且目录结构和官方源不同(见下文) |
装好之后确认版本符合基线:
# 查看 nginx 版本与编译参数
$ nginx -v
预期输出:
nginx version: nginx/1.30.x
只要大版本是 1.30,就符合本章的 stable 基线。
| 现象 | 根因 | 解决 |
|---|---|---|
| 装完版本还是 1.18 | 用了 Ubuntu 系统源 | 按下文添加 nginx.org 官方源后重装 |
apt policy 找不到 nginx |
源索引未更新 | sudo apt update 后重试 |
| stable 和 mainline 版本号一样大 | 版本号是平行线不是先后关系 | 看小版本和分支名,不要只比大小 |
nginx.org 提供自己的 apt 源,能拿到最新的 1.30 stable。添加第三方源的本质是两件事:放进一枚 GPG 公钥(用来验签软件包,防止包被篡改)和写一个 .list 源文件(告诉 apt 去哪下载)。
[!WARNING] 不要使用
apt-key add。 老教程里常见的curl ... | sudo apt-key add -已被 Ubuntu 22.04 废弃:用apt-key加的密钥会被当作全局信任,且升级系统时会报apt-key is deprecated警告。正确做法是把密钥放进/usr/share/keyrings/,并在源文件里用signed-by=指定,让信任范围只限于这一个源。
# 安装下载与验签所需的工具
$ sudo apt update
$ sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring
# 从 nginx 官方下载 GPG 公钥,放到受信任的密钥环目录
$ curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg > /dev/null
# 写入官方源,signed-by 把验签范围限定在这枚密钥上
$ echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu jammy nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list > /dev/null
# 更新源索引并安装
$ sudo apt update
$ sudo apt install nginx
| 步骤 | 关键点 | 为什么 |
|---|---|---|
gpg --dearmor |
把 ASCII 文本密钥转成二进制格式 | apt 的 signed-by 需要二进制 keyring |
/usr/share/keyrings/ |
系统级密钥环目录,root 管理 | 密钥集中存放、权限可控,替代废弃的 apt-key |
signed-by= |
声明本源只用这枚密钥验签 | 密钥泄露时影响范围只有这一个源 |
jammy |
Ubuntu 22.04 的代号 | nginx.org 按代号提供不同版本的包 |
nginx |
源里的组件名 | 官方源只提供 nginx 一个组件 |
# 确认包来自官方源且版本正确
$ apt policy nginx
预期输出:
nginx:
Installed: 1.30.x-1~jammy
Candidate: 1.30.x-1~jammy
Version table:
*** 1.30.x-1~jammy 500
500 http://nginx.org/packages/ubuntu jammy/nginx amd64 Packages
100 /var/lib/dpkg/status
1.18.0-6ubuntu14.4 500
500 http://cn.archive.ubuntu.com/ubuntu jammy/main amd64 Packages
Installed 显示 1.30.x 且来源是 nginx.org,说明官方源生效。
# 启动服务并设置开机自启
$ sudo systemctl enable --now nginx
# 本地请求一次首页
$ curl -I localhost
预期输出:
HTTP/1.1 200 OK
Server: nginx/1.30.x
Content-Type: text/html
[!NOTE] RHEL / CentOS 用户:nginx.org 同样提供 yum 源,安装
nginx.org/packages/rhel对应的 repo 文件即可;目录结构、配置语法与本章完全一致,差别只在包管理命令换成dnf。RHEL 对照
# 创建官方 yum 源文件后安装 $ sudo dnf install nginx $ sudo systemctl enable --now nginx
| 现象 | 根因 | 解决 |
|---|---|---|
NO_PUBKEY 报错 |
GPG 密钥没放对位置 | 检查 /usr/share/keyrings/nginx-archive-keyring.gpg 是否存在 |
| 源加了但还装 1.18 | 存在旧的 nginx 源文件或优先级设置 | grep -r nginx /etc/apt/sources.list.d/ 排查重复源 |
| 80 端口被占,nginx 起不来 | Apache 等服务先占了 80 | `ss -lntp |
curl: (7) Connection refused |
服务没启动 | systemctl status nginx 看状态与报错日志 |
官方源和 Ubuntu 源装出来的 Nginx,配置目录结构完全不同——这是新手最迷惑的一点,网上教程互相矛盾多半因为此。两者主配置 /etc/nginx/nginx.conf 都一样,差别在虚拟主机(server 块)放哪。
| 对比项 | nginx.org 官方源 | Ubuntu 系统源 |
|---|---|---|
| 虚拟主机目录 | /etc/nginx/conf.d/*.conf |
/etc/nginx/sites-available/ |
| 启用方式 | 直接把 .conf 文件放进 conf.d/ |
软链接到 sites-enabled/ |
| 默认站点 | 无 | 有一个 default 站点 |
| 本教程 | 以此为准 | 仅了解 |
软链机制本身值得理解:sites-available/ 存放所有站点配置,sites-enabled/ 里只放指向它们的软链接。想启用某站点就 ln -s 建一条软链,想停用就删软链——配置本体不丢。官方源的 conf.d/ 则靠 nginx.conf 里一行 include /etc/nginx/conf.d/*.conf; 把目录下所有 .conf 全部加载,放进去就生效,改名为 .conf.bak 才算停用。
先看主配置的关键部分:
# 查看主配置中 include 相关的行
$ grep include /etc/nginx/nginx.conf
预期输出:
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
推荐的做法:不要直接改 nginx.conf,每个站点在 conf.d/ 下建一个独立的 .conf 文件,出问题好定位、回滚也容易。
# 备份默认示例配置,避免默认 server 块和自己的站点抢 Host
$ sudo mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak
| 文件/目录 | 作用 | 要点 |
|---|---|---|
/etc/nginx/nginx.conf |
主配置,定义全局参数 | 负责 include conf.d/*.conf,一般不动 |
/etc/nginx/mime.types |
扩展名 → Content-Type 映射 | 让浏览器正确识别 css/js/图片类型 |
/etc/nginx/conf.d/*.conf |
每个站点一个文件 | 官方源的虚拟主机都放这里 |
/var/log/nginx/ |
access.log 与 error.log |
排错第一现场,见日志一节 |
改完任何配置文件后,先做语法检查再看实际加载情况:
# 检查配置语法
$ sudo nginx -t
预期输出:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了配置但站点没变 | 改到了不生效的文件 | 官方源下改 sites-available 根本不会被加载,应改 conf.d/*.conf |
nginx -t 报 unknown directive |
配置里有拼写错误或少了分号 | 按报错行号检查,nginx 配置每条指令以 ; 结尾 |
| 两个站点打开是同一个 | server_name 没写或写重了 | 每个 server 块写清各自的 server_name |
default.conf.bak 仍被加载 |
include 只匹配 *.conf 之外的通配写法被改过 |
确认 include 是 *.conf;.bak 后缀不会被加载 |
Nginx 由 systemd 托管,日常只有三个动作:启动、重载、重启。reload 与 restart 的区别是本章必须建立的直觉:
reload:主进程重新读配置,平滑换掉旧 worker 进程,已建立的连接不断开,秒级完成,失败还能回退旧配置。restart:整个进程杀掉再起,所有连接中断。只有改了 nginx.conf 里不可热更的全局项(如 listen 的某些底层参数)或 nginx 完全卡死时才需要。日常改配置一律 reload,这就是"为什么":生产环境一次 restart 会掐断所有正在处理的请求,而 reload 用户完全无感。
# 首次启动并设置开机自启
$ sudo systemctl enable --now nginx
# 改配置后的标准三步:检查 → 重载 → 确认
$ sudo nginx -t
$ sudo systemctl reload nginx
$ curl -I localhost
| 命令 | 效果 | 适用场景 |
|---|---|---|
sudo systemctl enable --now nginx |
设自启 + 立即启动 | 首次安装 |
sudo nginx -t |
只检查语法,不动进程 | 每次改配置后、reload 前必做 |
sudo systemctl reload nginx |
平滑重载,不断连接 | 日常改配置 |
sudo nginx -s reload |
效果同上,绕过 systemd | 也可用,推荐统一走 systemctl |
sudo systemctl restart nginx |
进程级重启,连接中断 | 仅限 reload 无效或全局参数变更 |
# 确认 nginx 正在监听 80 端口
$ ss -lntp | grep nginx
预期输出:
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1234,fd=6))
LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=1234,fd=7))
[!IMPORTANT] 改配置后必须先
sudo nginx -t再 reload。nginx -t不通过时 reload 会静默失败——旧配置继续生效,你却以为改成功了。把nginx -t && sudo systemctl reload nginx养成肌肉记忆。
| 现象 | 根因 | 解决 |
|---|---|---|
| reload 后行为没变 | 没先 nginx -t,reload 因语法错失败 |
先 -t 看报错,修好再 reload |
nginx: [emerg] bind() to 0.0.0.0:80 failed |
80 被占用 | `ss -lntp |
| restart 后站点 502 一小会儿 | 重启瞬间后端连接被掐 | 用 reload 代替 restart |
server 块 = 虚拟主机。一台服务器上跑多个网站,全靠 server 块区分:Nginx 收到请求后,按请求头里的 Host 字段(即浏览器访问的域名)去匹配 server_name,命中的 server 块处理这次请求。一个 .conf 文件里可以写多个 server 块。
location = URL 路由规则。同一个站点内,不同路径交给谁处理(静态文件?转发后端?),由 location 决定。location 有多种写法,匹配优先级不同,这是 Nginx 配置的第一大坑。
| 语法 | 含义 | 优先级 |
|---|---|---|
location = /exact |
精确匹配 | 最高,命中立即停止 |
location ^~ /prefix/ |
前缀匹配且不再查正则 | 第二 |
location ~ \.php$ |
正则匹配(区分大小写) | 第三,按出现顺序取先命中者 |
location /prefix |
普通前缀匹配 | 最低,取最长前缀兜底 |
一个同时体现优先级的例子:
server {
listen 80;
server_name demo.example.com;
# 优先级 1:精确匹配,只有访问 /health 才进来
location = /health {
return 200 "ok";
}
# 优先级 2:^~ 前缀,/static/ 下的一切都按静态文件处理
location ^~ /static/ {
root /var/www/demo;
}
# 优先级 3:正则,图片类后缀的请求直接按静态文件处理
location ~ \.(jpg|png)$ {
root /var/www/demo;
}
# 优先级 4:普通前缀,其余所有请求的兜底
location / {
root /var/www/demo;
index index.html;
}
}
判断顺序:先看有没有 = 精确命中;否则记下最长前缀,若它带 ^~ 就直接用它;否则按顺序试正则,正则都不中才回落到最长前缀。
| 指令 | 作用 | 常见取值 |
|---|---|---|
listen |
监听的端口 | 80(HTTP)、443 ssl(HTTPS) |
server_name |
匹配 Host 头的域名 | 可写多个,空格分隔;_ 作兜底 |
root |
站点文件根目录 | 如 /var/www/demo |
index |
目录默认文件 | 如 index.html index.htm |
return |
直接返回状态码/内容 | 常用于跳转和健康检查 |
把上面的配置放进 /etc/nginx/conf.d/demo.conf 后:
# 检查语法并重载
$ sudo nginx -t && sudo systemctl reload nginx
# 验证精确匹配与兜底路由
$ curl localhost/health
$ curl -I -H "Host: demo.example.com" localhost/
预期输出:
ok
HTTP/1.1 200 OK
Server: nginx/1.30.x
| 现象 | 根因 | 解决 |
|---|---|---|
| 访问域名却打开默认页 | 没有任何 server_name 匹配,落入默认 server | 检查 server_name 拼写,删掉或改掉默认 server 块 |
| 正则 location 抢了静态目录 | 忘了 ^~ |
静态目录前缀改用 location ^~ /static/ |
| 所有域名都打到同一站点 | server_name 写了 _ 或没写 |
每个站点写明确域名 |
Nginx 两大用途:直接发静态文件(HTML/CSS/JS,root 指到目录即可)和反向代理(站在后端应用前面,替它接客)。生产里后者更常见:你的 Java/Node/Python 应用监听 8080,Nginx 占住 80/443 对外服务,顺便做 TLS、压缩和限流。
反向代理里最容易被忽略的是转发头:请求经过 Nginx 后,后端看到的"来源"变成了 Nginx 本机(127.0.0.1)。如果不在代理时把原始信息带过去,应用拿到的 Host 是 127.0.0.1、客户端 IP 是 127.0.0.1——登录校验、日志统计、IP 限流全部失效。
最小静态站点:
server {
listen 80;
server_name www.example.com;
root /var/www/example;
index index.html;
}
反向代理(重点配置,逐行解释见下):
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
逐行解释:
proxy_pass:把请求转给本机 8080 端口的后端应用。proxy_set_header Host $host:把用户实际访问的域名传给后端。不配的话后端收到的 Host 是 127.0.0.1:8080,依赖域名做路由(多租户、子域分发)的应用会直接错乱。proxy_set_header X-Real-IP $remote_addr:传真实客户端 IP。不配的话日志和限流全错——所有请求看起来都来自 127.0.0.1。proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for:传完整代理链(客户端 IP + 之前每一跳代理的 IP 追加成列表),多层代理时的标准做法。proxy_set_header X-Forwarded-Proto $scheme:告诉后端用户走的是 http 还是 https。不配的话,https 用户也会被后端当成 http,生成的重定向链接可能错误。这四条就是"proxy_set_header 四件套",任何反代配置都应原样带上。
proxy_pass 结尾带不带 /,路径拼接结果完全不同(重点坑):
# 写法 A:结尾无斜杠,完整路径原样转发
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
# 请求 /api/users → 后端收到 /api/users
# 写法 B:结尾有斜杠,替换掉 location 匹配的部分
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
# 请求 /api/users → 后端收到 /users
后端接口本身定义在 /api/users 就用写法 A;后端不认识 /api 前缀就用写法 B。404 排查时先看这里。
WebSocket 需要额外两行(把 HTTP 升级为长连接协议):
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
| 指令 | 作用 | 默认值/建议 |
|---|---|---|
proxy_pass |
后端地址 | 一般指向 127.0.0.1:端口,不暴露到公网 |
proxy_read_timeout |
等后端响应的超时 | 默认 60s;长任务接口需调大 |
proxy_set_header |
覆盖/新增发给后端的请求头 | 四件套必配 |
client_max_body_size |
允许上传的请求体大小 | 默认 1m,文件上传场景要调大 |
# 本机模拟带 Host 的请求,验证代理链路
$ curl -I -H "Host: api.example.com" localhost/
预期输出:
HTTP/1.1 200 OK
Server: nginx/1.30.x
再在后端应用侧确认它收到了正确的转发头:应用日志里的客户端 IP 应该是你的真实 IP 而不是 127.0.0.1。
# 在 access.log 里看代理请求的来源
$ sudo tail -2 /var/log/nginx/access.log
预期输出:
203.0.113.7 - - [29/Sep/2026:10:00:00 +0800] "GET / HTTP/1.1" 200 612 "-" "curl/7.81.0"
| 现象 | 根因 | 解决 |
|---|---|---|
| 后端拿到 Host 是 127.0.0.1 | 没配 proxy_set_header Host $host |
补上四件套 |
| 日志/限流里全是 127.0.0.1 | 没配 X-Real-IP / X-Forwarded-For |
补上四件套 |
加了 / 后接口 404 |
proxy_pass 结尾斜杠改变了路径拼接 | 按"写法 A/B"对照修正 |
| WebSocket 连不上就断 | 没加 Upgrade 头 |
补 proxy_http_version 1.1 与 Upgrade/Connection 头 |
Nginx 日志分两种:access.log 记录每个请求(谁、何时、访问什么、返回码),error.log 记录错误与启动信息。排错顺序永远是先看 error.log 再看 access.log。自定义 log_format 可以把响应时间、转发头等字段记下来,配合 日志分析 做统计与告警。
# 实时跟踪错误日志
$ sudo tail -f /var/log/nginx/error.log
# 打印当前生效的完整配置(排错神器)
$ sudo nginx -T
自定义日志格式示例(加入响应耗时字段):
# 在 http {} 级别定义,server 里用 access_log 引用
log_format timed '$remote_addr - $status $request_time '
'"$request" "$http_user_agent"';
server {
listen 80;
server_name api.example.com;
access_log /var/log/nginx/api.access.log timed;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
| 命令/文件 | 作用 |
|---|---|
sudo nginx -T |
打印全部生效配置(含 include 展开后的结果),配置"到底加载了什么"一锤定音 |
sudo nginx -t |
只查语法,不展开 |
| `ss -lntp | grep nginx` |
curl -I localhost |
最快烟雾测试,看响应头 |
/var/log/nginx/access.log |
请求流水账 |
/var/log/nginx/error.log |
错误与启动失败原因 |
# 三连验证:配置、端口、响应
$ sudo nginx -t
$ ss -lntp | grep nginx
$ curl -I localhost
预期输出:
nginx: configuration file /etc/nginx/nginx.conf test is successful
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1234,fd=6))
HTTP/1.1 200 OK
Server: nginx/1.30.x
| 现象 | 根因 | 解决 |
|---|---|---|
| 403 Forbidden | 目录权限不足,或目录下没有 index 指定的文件 |
检查 root 目录的读/执行权限与索引文件是否存在;RHEL 系另查 SELinux(setsebool -P httpd_can_network_connect on) |
| 404 但文件明明存在 | root 与 alias 用混了 |
见下方重点说明 |
| 502 Bad Gateway | 后端没启动或端口写错 | curl 127.0.0.1:8080 直接探后端,不通就先修后端 |
| 504 Gateway Timeout | 后端太慢超过 60s | 调大 proxy_read_timeout,同时排查后端为何慢 |
| 改配置不生效 | 忘了 -t+reload,或改了不加载的文件 |
用 nginx -T 确认生效配置里有没有你的修改 |
| 80 端口起不来 | Apache 等先占 80 | `ss -lntp |
root 与 alias 的区别(404 的头号根因):root 把 location 路径拼在根目录后面;alias 把 location 匹配到的部分替换掉。
# root:请求 /static/a.css → 实际找 /var/www/static/a.css
location /static/ {
root /var/www;
}
# alias:请求 /static/a.css → 实际找 /var/www/files/a.css
location /static/ {
alias /var/www/files/;
}
记忆法:root 是"根 + 全路径",alias 是"换个目录名"。用 alias 时结尾的 / 要与 location 保持一致,否则同样会 404。
把本章散落各节的高频问题集中成一张表,排错时先对号入座,再跳回对应小节看细节:
| 现象 | 一句话排查方向 | 详见 |
|---|---|---|
| 502 Bad Gateway | 先 curl 127.0.0.1:8080 直接探后端:后端没起、端口写错、或 proxy_pass 结尾斜杠导致路径拼接错 |
「静态站点与反向代理」 |
| 504 Gateway Timeout | 后端处理超过 proxy_read_timeout(默认 60s);调大超时的同时排查后端为何慢 |
「静态站点与反向代理」 |
| 80/443 端口被占,nginx 起不来 | ss -lntp | grep :80 找占用者(常见是 Apache),停掉或换端口 |
「安装」「服务管理」 |
| 403 permission denied | root 目录缺少读/执行权限,或目录下没有 index 指定的文件;RHEL 系另查 SELinux |
「日志与日常运维」 |
nginx -t 通过但 reload 后行为没变 |
多半改了不被加载的文件(如 Ubuntu 源布局的 sites-available);用 nginx -T 确认生效配置里有没有你的修改 |
「配置文件结构」「服务管理」 |
reload 报 bind() to 0.0.0.0:80 failed |
端口被占或配置里 listen 冲突 | 「服务管理」 |
| 访问域名却打开默认页 / 别的站点 | server_name 拼写错误或缺失,请求落入默认 server 块 |
「核心概念:server 块」 |
| 404 但文件明明存在 | root 与 alias 语义用混,见本章末尾的重点说明 |
「日志与日常运维」 |
| 后端拿到的客户端 IP 全是 127.0.0.1 | 反代没带 proxy_set_header 四件套 |
「静态站点与反向代理」 |
Docker 把应用连同它的运行环境打包成容器,彻底解决"在我机器上是好的"问题。本章走完整链路:选版本 → 官方源安装 → 服务管理与非 root 使用 → daemon.json 与镜像加速 → 核心命令 → 数据卷 → Compose → 磁盘清理 → 避坑。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
Docker 分免费社区版 CE 和付费企业版 EE(一句话:个人与绝大多数公司用 CE 就够了,EE 多的是官方商业支持与安全加固镜像)。本章只讲 CE。此外要建立一个新习惯:Compose 已是 Docker 的子命令插件,版本基线为 Compose v5.x,命令是 docker compose(带空格);老的 docker-compose(带横杠)独立二进制已弃用,网上老教程请自动换算。
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | Docker Engine 29.x(截至 2026-09) | 官方 apt 源安装即为此线 |
| Compose | v5.x 插件,命令 docker compose |
老命令 docker-compose 已弃用 |
| Ubuntu 22.04 系统源 | docker.io 20.x | 太旧,不建议 |
| License | CE 为 Apache 2.0 | 免费商用 |
先确认系统源里的旧版本情况,做到心里有数:
# 查看 apt 会装到的 docker 版本
$ apt policy docker.io
预期输出:
docker.io:
Installed: (none)
Candidate: 20.10.x-0ubuntu1~22.04.2
| 场景 | 选什么 | 为什么 |
|---|---|---|
| 个人服务器、学习 | Docker CE(官方源) | 免费、最新、教程都基于它 |
| 需要官方 SLA 的大企业 | Docker EE | 花钱买支持,功能核心与 CE 相同 |
| 想用 Compose 编排 | 装官方源里的 docker-compose-plugin |
获得 docker compose 子命令 |
| 使用系统源 docker.io | 不推荐 | 20.x 缺新特性与新修复 |
装好后用两条命令确认版本基线:
# 查看 Engine 与 Compose 版本
$ docker --version
$ docker compose version
预期输出:
Docker version 29.x.x, build xxxxxxx
Docker Compose version v5.x.x
| 现象 | 根因 | 解决 |
|---|---|---|
docker compose 提示无此命令 |
没装 compose 插件 | 安装 docker-compose-plugin 包 |
教程里 docker-compose 能跑我的不能 |
老教程用的是弃用的独立二进制 | 换成 docker compose(空格) |
| 装完版本是 20.x | 误用了系统源 docker.io | 卸载后按官方源流程重装 |
和 Nginx 一样,拿新版本要加 Docker 官方 apt 源。流程同样是三步:卸旧 → 加 GPG 密钥 → 写源文件。Docker 官方把密钥放在 /etc/apt/keyrings/ 并用 signed-by= 声明,这是当前 Linux 发行版推荐的标准做法。
完整流程如下,逐块执行:
# 卸载旧版(系统源里的 docker.io 版本太旧,混装会冲突)
$ sudo apt remove docker docker-engine docker.io containerd runc
# 安装依赖
$ sudo apt update
$ sudo apt install ca-certificates curl gnupg
# 添加官方 GPG 密钥
$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
$ sudo chmod a+r /etc/apt/keyrings/docker.gpg
# 添加官方源(自动适配架构与系统代号)
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 更新索引并安装 Engine 与插件
$ sudo apt update
$ sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
| 包名 | 作用 |
|---|---|
docker-ce |
守护进程与核心,容器真正跑在这里 |
docker-ce-cli |
docker 命令行客户端 |
containerd.io |
底层容器运行时,Docker 依赖它 |
docker-buildx-plugin |
新一代镜像构建器 |
docker-compose-plugin |
提供 docker compose 子命令 |
# 启动服务并设置开机自启
$ sudo systemctl enable --now docker
# 跑一个最小容器做烟雾测试
$ sudo docker run --rm hello-world
预期输出:
Hello from Docker!
This message shows that your installation appears to be working correctly.
看到 Hello from Docker! 说明拉镜像、创建容器、运行、输出整条链路都通了。
| 现象 | 根因 | 解决 |
|---|---|---|
NO_PUBKEY 报错 |
GPG 密钥没生成成功 | 重跑 curl ... gpg --dearmor 那步 |
Unable to locate package docker-ce |
源没生效 | 确认 /etc/apt/sources.list.d/docker.list 内容与 apt update 无报错 |
Cannot connect to the Docker daemon |
守护进程没起 | sudo systemctl status docker 看状态与报错 |
Docker 守护进程默认只允许 root(或 sudo)访问,所以刚装完每条 docker 命令都要加 sudo。官方做法是把用户加入 docker 组,该组对守护进程的 socket 有读写权,之后即可免 sudo。
[!WARNING]
docker组的权限等同 root。 组内用户可以把宿主机任意文件挂载进容器随意读写,等于完全控制这台机器。只给真正需要跑容器的用户加组,不要为了让别人"试用一下"就随手加。
# 把当前用户加入 docker 组
$ sudo usermod -aG docker $USER
# 组变更需重新登录才生效,登出再登录,或临时用 newgrp 验证
$ newgrp docker
| 命令片段 | 含义 |
|---|---|
usermod -aG |
-a 追加(不覆盖已有组),-G 指定组列表 |
docker |
目标组名,安装 docker-ce 时自动创建 |
newgrp docker |
当前 shell 立即套用新组,仅供临时验证 |
重新登录后,不加 sudo 执行:
# 免 sudo 运行烟雾测试
$ docker run --rm hello-world
预期输出:
Hello from Docker!
This message shows that your installation appears to be working correctly.
| 现象 | 根因 | 解决 |
|---|---|---|
| 加组后仍报 permission denied | 没重新登录,组变更未生效 | 登出重登,或 newgrp docker |
usermod: group 'docker' does not exist |
docker 服务从未启动过,组未创建 | 先 sudo systemctl start docker |
/etc/docker/daemon.json 是 Docker 守护进程的总配置文件,管两件本章关心的事:从哪拉镜像(registry-mirrors 加速器)和容器日志怎么存(log-driver 与轮转)。国内服务器不配加速器,docker pull 经常超时甚至失败,所以这步几乎是必做。
{
"registry-mirrors": ["https://<你的加速器地址>"],
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
[!IMPORTANT] 改完 daemon.json 必须
sudo systemctl restart docker。 只执行systemctl daemon-reload是没用的——那只让 systemd 重读自己的单元文件,Docker 守护进程并不会重新加载 daemon.json。restart 后,未配置 restart 策略(默认为no)的容器会被停止且不会自动拉起;希望 daemon 重启后容器自动恢复的,创建容器时加--restart=always(Compose 里写restart: always)。请挑合适时间操作。这是新手最常见的坑。另注:2024 年之后不少公共镜像加速器已下线或改为仅登录可用,pull 失败先检查这里配的加速器还活着没有,可用加速器清单见 镜像与软件源加速。
[!NOTE] 环境变量方面,Docker CLI 主要认一个
DOCKER_HOST:它用于指向远程 Docker daemon(如export DOCKER_HOST=ssh://user@10.0.0.5),单机部署可忽略,保持默认的本地 socket 即可。
# 创建配置文件并写入上面的 JSON 内容
$ sudo nano /etc/docker/daemon.json
# 重启守护进程使配置生效
$ sudo systemctl restart docker
# 确认加速器已被读取
$ docker info
预期输出(docker info 中应出现):
Registry Mirrors:
https://<你的加速器地址>/
| 键 | 作用 | 建议 |
|---|---|---|
registry-mirrors |
镜像拉取加速地址列表 | 按顺序尝试,放可用的地址 |
log-driver |
容器日志驱动 | json-file 兼容 docker logs |
log-opts.max-size |
单个日志文件上限 | 10m 起步,量大可调 |
log-opts.max-file |
日志轮转保留份数 | 3 即单容器日志最多约 30m |
[!WARNING] 不配 log-opts,容器日志会无限增长撑爆磁盘。 某些应用每秒输出多行日志,几个月就能写满
/var/lib/docker所在分区,最终整机卡死。上面的max-size+max-file就是给日志套上"滚动覆盖"的缰绳。磁盘已被撑爆的处理见 磁盘空间排查。注意 log-opts 只对新创建的容器生效,存量容器需重建。
# 确认守护进程读到配置,且日志驱动对新建容器生效
$ docker info | grep -A1 "Registry Mirrors"
# 注意不能用 --rm:容器一退出就被删除,inspect 会报 No such object
$ docker run --name logtest alpine echo hi
$ docker inspect logtest --format '{{.HostConfig.LogConfig.Config}}'
$ docker rm logtest
预期输出:
Registry Mirrors:
https://<你的加速器地址>/
hi
map[max-file:3 max-size:10m]
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了 daemon.json 没生效 | 只 daemon-reload 没 restart | sudo systemctl restart docker |
json: cannot unmarshal 启动失败 |
JSON 语法错误(多余逗号等) | 用 `cat /etc/docker/daemon.json |
| pull 仍然超时 | 加速器已下线 | 换 镜像与软件源加速 里列的可用地址 |
Docker 日常命令就两组:镜像命令(管理"模板",只读)和容器命令(管理"运行实例",由镜像启动)。容器是镜像的一次运行,同一镜像可起任意多个容器。
镜像命令速览:
# 从仓库拉取镜像(不写 tag 默认 latest)
$ docker pull nginx
# 列出本地已有镜像
$ docker images
# 删除镜像(被容器占用的删不掉)
$ docker rmi nginx:latest
# 给镜像打别名(推送到自己仓库前常用)
$ docker tag nginx:latest myrepo/nginx:1.0
容器命令速览:
# 后台启动一个 nginx 容器:宿主 8080 映射容器 80,站点目录挂载进去
$ docker run -d --name web --restart=always -p 8080:80 -v /srv/web:/usr/share/nginx/html nginx
# 列出容器(-a 含已停止的)
$ docker ps -a
# 停止 / 启动 / 重启
$ docker stop web
$ docker start web
$ docker restart web
# 删除已停止的容器
$ docker rm web
# 进入容器内部排查
$ docker exec -it web bash
# 跟踪日志,只看最近 100 行
$ docker logs -f --tail 100 web
# 查看容器完整信息(IP、挂载、重启策略等)
$ docker inspect web
# 实时查看各容器 CPU/内存占用
$ docker stats
docker run 的关键参数,值得逐个记住:
| 参数 | 作用 | 说明 |
|---|---|---|
-d |
后台运行 | 不加则占住当前终端,Ctrl+C 即停 |
-p 8080:80 |
端口映射 宿主:容器 | 外部访问 8080 即达容器 80 |
-v /srv/web:/usr/share/nginx/html |
挂载 宿主目录:容器目录 | 数据落在宿主机,容器删除不丢 |
--name web |
指定容器名 | 免去记随机名;同名会冲突 |
--restart=always |
宕机/开机自动拉起 | 守护进程重启时也自动恢复 |
-e KEY=value |
注入环境变量 | 应用配置的标准入口 |
--rm |
退出后自动删除容器 | 适合一次性任务 |
-it |
分配交互式终端 | 配合进入容器执行命令 |
[!NOTE]
--restart=always与 systemd 的关系:容器内服务不需要再写 systemd 单元——Docker 自己负责拉起,开机自启交给systemctl enable docker即可。进程托管的三种层次对比见 进程守护。
| 命令 | 关键参数 | 用途 |
|---|---|---|
docker logs |
-f 跟踪、--tail N 最近 N 行、-t 带时间戳 |
看容器输出 |
docker exec |
-it 交互、容器后接命令 |
进容器排查;alpine 镜像用 sh 不是 bash |
docker inspect |
--format 取指定字段 |
脚本化取 IP、挂载等 |
docker ps |
-a 含已停止、-q 只列 ID |
配合批量清理 |
# 确认 web 容器在跑、端口映射正确、可访问
$ docker ps
$ curl -I localhost:8080
预期输出:
CONTAINER ID IMAGE ... NAMES
9f3c1e... nginx ... web
HTTP/1.1 200 OK
Server: nginx/1.30.x
| 现象 | 根因 | 解决 |
|---|---|---|
port is already allocated |
宿主端口被占用或被另一容器占用 | `ss -lntp |
exec: bash: not found |
容器是无 shell 的精简镜像 | 换 docker exec -it web sh |
docker logs 没输出 |
应用日志写进了文件而非 stdout | 进容器看应用自己的日志文件 |
| 容器起来立刻退出 | 主进程结束或配置错误 | docker logs 容器名 看退出原因 |
容器的文件系统是临时的:容器一删,里面所有写入全部蒸发。要保住数据(数据库文件、上传的图片、配置),必须在创建容器时用 -v 挂载出来。两种方式:
| 方式 | 写法 | 特点 |
|---|---|---|
| 绑定挂载 | -v /srv/web:/usr/share/nginx/html |
宿主机具体路径,直观、好备份,路径必须已存在 |
| 命名卷 | -v webdata:/var/lib/mysql |
Docker 统一管理(默认在 /var/lib/docker/volumes/),适合数据库类数据 |
[!WARNING] 删容器数据不会丢的前提是你用了
-v;不用-v就一定会丢。 容器是可抛弃的(升级 = 删旧起新),没挂载出来的数据随容器删除一起消失。给数据库容器配-v不是可选项,是保命项。
# 绑定挂载:站点目录放宿主机
$ docker run -d --name web -v /srv/web:/usr/share/nginx/html nginx
# 命名卷:数据库数据交给 Docker 管理(mysql 镜像必须给 root 密码,否则容器启动即退出)
$ docker volume create dbdata
$ docker run -d --name db -e MYSQL_ROOT_PASSWORD=<强密码> -v dbdata:/var/lib/mysql mysql
# 卷管理三件套
$ docker volume ls
$ docker volume inspect dbdata
$ docker volume prune
| 写法片段 | 含义 |
|---|---|
-v 宿主路径:容器路径 |
绑定挂载,以 / 开头的是宿主绝对路径 |
-v 卷名:容器路径 |
命名卷,不含 / 的名字由 Docker 创建管理 |
volume create |
显式创建命名卷(run 时写不存在卷名会自动建) |
volume inspect |
查看卷在宿主机的真实路径 |
volume prune |
清理未被引用的数据卷(执行前先 volume ls 核对,可能删掉重要数据) |
# 验证数据真的落在宿主机:写入 → 删容器 → 重建 → 数据还在
$ echo hello | sudo tee /srv/web/test.txt
$ docker rm -f web
$ docker run -d --name web -p 8080:80 -v /srv/web:/usr/share/nginx/html nginx
$ curl localhost:8080/test.txt
预期输出:
hello
| 现象 | 根因 | 解决 |
|---|---|---|
| 升级容器后数据全没了 | 当初没配 -v |
无解,重导数据;今后务必挂载 |
| 绑定挂载目录是空的 | 宿主路径不存在,Docker 自动建了空目录并覆盖 | 先建好目录再放入数据 |
权限报错 Permission denied |
容器内进程 uid 与宿主目录属主不匹配 | chown 对应 uid 或用命名卷 |
prune 后数据没了 |
被清的卷已无容器引用 | 依赖备份;prune 前先 volume ls 核对 |
Compose 把"一组容器 + 它们的参数"写进一个 YAML 文件,一条命令起停全家桶。多容器项目(应用 + 数据库 + 缓存)用 docker run 逐个敲又长又容易错,Compose 文件进版本库,团队与服务器之间一致可复现。当前基线为 v5.x 插件,命令 docker compose(带空格)。
项目目录下创建 compose.yaml(v5 的标准文件名):
services:
app:
image: myapp:1.0
ports:
- "8080:8080"
environment:
- DATABASE_URL=mysql://root:secret@db:3306/app
- REDIS_HOST=cache
volumes:
- ./uploads:/app/uploads
depends_on:
- db
- cache
restart: always
db:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=secret
volumes:
- dbdata:/var/lib/mysql
restart: always
cache:
image: redis:8
restart: always
volumes:
dbdata:
要点:services 下每个键就是容器名;db、cache 这类服务名同时是内部 DNS 名,app 容器里直接用 db:3306 访问数据库;顶层 volumes 声明命名卷;depends_on 只管启动顺序,不管"就绪"。
# 启动全部服务(-d 后台)
$ docker compose up -d
# 查看本项目容器状态
$ docker compose ps
# 跟踪全部服务日志
$ docker compose logs -f
# 停止并删除容器与网络
$ docker compose down
# 连同数据卷一起删(危险!)
$ docker compose down -v
| 字段/命令 | 作用 |
|---|---|
image |
使用哪个镜像;换成 build: . 则从当前目录 Dockerfile 构建 |
ports |
端口映射,格式同 -p |
environment |
环境变量,格式同 -e |
volumes |
挂载,格式同 -v |
depends_on |
启动依赖顺序 |
restart: always |
等价 run 的 --restart=always |
compose up -d |
创建并启动;文件有改动时自动重建受影响容器 |
compose down |
停止删除容器与网络,不动数据卷 |
compose down -v |
额外删除数据卷 |
[!WARNING]
docker compose down -v会删掉数据卷,数据库数据随之蒸发。 日常停服只用down;-v只在明确要清空数据重来时使用,执行前确认已有备份。
# 启动并确认三个服务都 Up
$ docker compose up -d
$ docker compose ps
预期输出:
NAME IMAGE STATUS PORTS
myapp-app-1 myapp:1.0 Up 10 seconds 0.0.0.0:8080->8080/tcp
myapp-db-1 mysql:8.0 Up 12 seconds 3306/tcp
myapp-cache-1 redis:8 Up 12 seconds 6379/tcp
| 现象 | 根因 | 解决 |
|---|---|---|
| app 连不上 db | 用了 localhost:3306 |
容器里 localhost 是自己,改用服务名 db:3306 |
no configuration file provided |
文件名不对或不在项目目录 | 文件名为 compose.yaml 且在项目根目录执行 |
| up 后 db 一直重启 | 忘了配 MYSQL_ROOT_PASSWORD 等必填变量 |
看 docker compose logs db 补环境变量 |
Docker 的镜像、容器、卷、构建缓存全部堆在 /var/lib/docker,只增不减:每次 pull 的新版本、构建的中间层、停掉的容器都会留存量。用 docker system df 看占用,用 prune 系列回收——这是服务器磁盘爆满的头号来源之一。
# 查看 Docker 各类资源的磁盘占用
$ docker system df
# 只清理悬空镜像(无标签的中间层,安全)
$ docker image prune
# 清理所有未被容器使用的镜像 + 停止容器 + 无用网络(危险)
$ docker system prune -a
[!WARNING]
docker system prune -a会删除所有未被任何容器使用的镜像。 下次启动这些服务时必须重新 pull 全部镜像——国内网络下可能很慢甚至失败。常规清理先用docker image prune,-a只在确认磁盘告急时使用。
| 命令 | 清理范围 | 风险 |
|---|---|---|
docker system df |
只读统计(加 --verbose 看明细) |
无 |
docker image prune |
悬空镜像(<none> 标签) |
基本无风险 |
docker container prune |
所有已停止容器 | 已停容器会被删 |
docker volume prune |
未被引用的数据卷 | 可能删掉重要数据 |
docker system prune -a |
上述大部分 + 所有无容器引用的镜像 | 高,见 WARNING |
# 查看各类资源占用与可回收量
$ docker system df
预期输出(RECLAIMABLE 明显下降):
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 8 2 2.1GB 1.6GB (76%)
Containers 3 2 120MB 60MB (50%)
| 现象 | 根因 | 解决 |
|---|---|---|
磁盘满但 df 找不到大文件 |
占用藏在 /var/lib/docker |
sudo du -sh /var/lib/docker/* 定位后 prune |
| prune 后空间没释放 | 删除的文件仍被运行中进程持有 | 重启对应容器或 systemctl restart docker |
| 构建缓存巨大 | buildx 缓存不在普通 prune 范围 | docker builder prune |
Docker 会绕过 ufw。 你用 ufw deny 8080 挡了端口,但只要容器以 -p 8080:80 发布过,公网照样能访问——因为 Docker 直接往 iptables 写 NAT 规则,在 ufw 的规则链之前生效,ufw 对 Docker 发布的端口形同虚设。这是"我明明关了防火墙端口为什么还能访问"的标准答案。详见 防火墙。
对策很简单:不需要暴露到公网的端口,发布时只绑本机回环地址。
# 只允许本机访问(配合 Nginx 反代最常用)
$ docker run -d --name api -p 127.0.0.1:8080:8080 myapp
# 对比:这样写会监听所有网卡,公网可直达
$ docker run -d --name api -p 8080:8080 myapp
| 写法 | 监听范围 | 公网可达 |
|---|---|---|
-p 8080:80 |
0.0.0.0 所有网卡 | 是,ufw 拦不住 |
-p 127.0.0.1:8080:80 |
仅本机回环 | 否,只有本机和反代可达 |
不写 -p,容器间走内部网络 |
不映射宿主端口 | 否 |
# 确认端口只绑在回环地址上
$ ss -lntp | grep 8080
预期输出:
LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("docker-proxy",pid=2345,fd=4))
关键看地址列是 127.0.0.1:8080 而不是 0.0.0.0:8080;从另一台机器 curl 服务器IP:8080 应连接失败。
| 现象 | 根因 | 解决 |
|---|---|---|
| ufw deny 了端口仍可访问 | Docker 直写 iptables 绕过 ufw | -p 改绑 127.0.0.1,外层用反代 |
| 想彻底关掉 Docker 的 iptables 干预 | 不推荐改 "iptables": false |
会破坏容器联网,用绑定回环替代 |
| 反代转发到容器 502 | 后端容器绑了回环但 Nginx 访问的是别的地址 | 反代指向 127.0.0.1:端口,与 -p 绑定一致 |
| 现象 | 根因 | 解决 |
|---|---|---|
permission denied ... docker.sock |
用户不在 docker 组,或加组后没重新登录 | sudo usermod -aG docker $USER 后重新登录 |
docker pull 超时/失败 |
无加速器或加速器已下线 | 检查 daemon.json 的 registry-mirrors,换可用地址(见 33 章) |
| 容器端口映射冲突 | 宿主端口已被占 | ss -lntp 查占用,换宿主端口 |
-p 发布的端口 ufw 拦不住 |
Docker 直写 iptables,绕过 ufw | 只绑回环 -p 127.0.0.1:8080:80,反代对外(见 16 章) |
/var/lib/docker 撑爆磁盘 |
镜像/日志/构建缓存堆积 | docker system df 定位,image prune、builder prune 回收(见 55 章) |
| 容器时间与宿主差 8 小时 | 容器默认 UTC 时区 | run 时加 -e TZ=Asia/Shanghai |
| daemon.json 改完没生效 | 只 daemon-reload 没重启 Docker |
sudo systemctl restart docker(注意容器会重启) |
| 容器日志无限膨胀 | 没配 log-opts 轮转 | daemon.json 配 max-size/max-file,重建存量容器 |
服务器上除了软件本身,还有两类「基础设施」必备:Git(把代码弄到服务器上)和构建工具链(把源码变成可运行的产物)。本章先讲 Git 与 SSH 密钥,再讲 build-essential、Maven、Gradle。
[!NOTE] 本章所有版本号截至 2026-09,版本信息以 版本速查表 为准。
Git 是版本控制系统。在服务器上它的角色很简单:把代码从仓库取下来(部署时)、看一下改动历史(排错时)。 版本选择上它有个特点:对版本几乎不敏感,新老版本在 clone/pull 上完全兼容,所以系统源里的 2.34+ 直接用,不需要加第三方源。
# 安装 git
$ sudo apt install -y git
# 设置提交者身份(换成你自己的)
$ git config --global user.name "Your Name"
$ git config --global user.email "you@example.com"
# 新建仓库时默认分支叫 main
$ git config --global init.defaultBranch main
# Windows 用户必备:提交时把 CRLF 转成 LF
$ git config --global core.autocrlf input
[!IMPORTANT]
core.autocrlf input是 Windows 用户必配项。 Windows 换行符是 CRLF(\r\n),Linux 是 LF(\n)。 带 CRLF 的 shell 脚本传到 Linux 执行会报/bin/bash^M: bad interpreter——^M就是回车符,排查极费时间。设成input后 Git 提交时统一转成 LF,从源头杜绝污染。
版本基准:
| 项目 | 值 | 说明 |
|---|---|---|
| 推荐版本 | Git 2.34+ | Ubuntu 22.04 系统源默认即可满足,不需要额外加源 |
| Ubuntu 22.04 源默认 | 2.34.x | 直接 apt 装 |
| 版本核对 | 版本速查表 | 全篇唯一权威来源 |
常用配置项:
| 配置项 | 作用 | 建议值 |
|---|---|---|
user.name / user.email |
提交记录的作者信息 | 与代码平台账号一致 |
init.defaultBranch |
git init 时的默认分支名 |
main(现代约定) |
core.autocrlf |
换行符自动转换 | Windows 设 input;纯 Linux 可不设 |
--global |
作用域:当前用户(~/.gitconfig) |
改为 --system 则对全机生效 |
$ git --version
预期输出:
git version 2.34.1
$ git config --global --list
预期输出:
user.name=Your Name
user.email=you@example.com
init.defaultBranch=main
core.autocrlf=input
| 现象 | 根因 | 解决 |
|---|---|---|
git: command not found |
没装 | sudo apt install -y git |
提交后作者显示 root <root@localhost> |
没配 user.name/email | 按上面的命令配置 |
脚本报 /bin/bash^M: bad interpreter |
文件是 CRLF 换行 | 设 core.autocrlf input;已污染的用 sed -i 's/\r$//' 文件 修 |
| 配置不生效 | 仓库自己的 .git/config 覆盖全局 |
git config --list --show-origin 看生效来源 |
HTTPS 拉私有仓库每次都要输密码,自动化脚本里根本没法输。SSH 密钥的做法是:生成一对密钥,私钥留在自己机器、公钥放到代码平台,之后平台靠密钥认机器。
# 生成 ed25519 密钥对(比 RSA 短且安全,当前推荐)
$ ssh-keygen -t ed25519 -C "you@example.com"
# 查看公钥,整行复制
$ cat ~/.ssh/id_ed25519.pub
# 测试连通性(先把公钥贴到平台的 SSH Keys 页面)
$ ssh -T git@github.com
[!NOTE]
ssh-keygen会问保存路径(回车用默认~/.ssh/id_ed25519)和 passphrase 口令(可留空)。私钥 绝不能外传,往平台贴的是.pub公钥。
[!IMPORTANT] 密钥文件权限不对时 SSH 会直接拒绝使用:
~/.ssh必须 700,私钥必须 600。见 SSH 加固。
| 命令 / 参数 | 含义 |
|---|---|
-t ed25519 |
密钥类型,ed25519 是当前推荐的椭圆曲线算法 |
-C "邮箱" |
备注,仅用于标识钥匙归属,不影响认证 |
-f <路径> |
指定保存路径;多平台各用一把钥匙时用它区分 |
ssh -T / ssh-add <私钥> |
只做认证测试不分配终端 / 把私钥交给 ssh-agent |
$ ssh -T git@github.com
预期输出(GitLab 提示 Welcome to GitLab, @username!):
Hi username! You've successfully authenticated, but GitHub does not provide shell access.
| 现象 | 根因 | 解决 |
|---|---|---|
Permission denied (publickey) |
公钥没加到平台,或私钥没被加载 | ssh -vT git@github.com 看它试了哪些 key;ssh-add -l 确认 |
UNPROTECTED PRIVATE KEY FILE |
私钥权限太开放 | chmod 600 ~/.ssh/id_ed25519、chmod 700 ~/.ssh |
| 有多把钥匙但用了错的 | 默认只试几个固定名字 | 在 ~/.ssh/config 为不同 Host 指定 IdentityFile |
服务器上 90% 的场景只用得到下面这些,分三层:看状态(status/diff/log)、记录改动(add/commit)、与远端同步(pull/push)。
# 取代码 / 同步 / 看状态与历史
$ git clone git@github.com:user/repo.git
$ git pull
$ git status
$ git diff
$ git log --oneline --graph
# 记录改动 / 分支 / 暂存 / 标签
$ git add .
$ git commit -m "说明这次改了什么"
$ git switch -c feature-x
$ git switch main
$ git stash
$ git tag v1.0.0
| 命令 | 作用 | 备注 |
|---|---|---|
clone / pull |
下载远端仓库 / 拉取并合并 | clone 加 --depth 1 只取最近一次提交 |
status / diff |
看哪些文件改了 / 看改动内容 | 不确定就先跑 status |
add . / commit -m "信息" |
放进暂存区 / 记录成一次提交 | 信息写「为什么改」 |
log --oneline --graph |
一行一条看历史与分支图 | 加 -10 只看最近 10 条 |
branch / switch -c |
列分支 / 新建并切换(老写法 checkout -b) |
switch main 切回主分支 |
stash / stash pop / tag |
临时收起改动 / 取回 / 打版本标签 | 发布时用 tag 标记提交 |
[!TIP] 推荐用
switch/restore而不是老写法checkout——checkout一个命令承担太多职责,容易误操作。
$ git log --oneline --graph -3
预期输出:
* 3f2a1c9 (HEAD -> main, origin/main) fix: 修正登录超时
* 8b7e2d1 feat: 新增导出功能
* 1a0c4b3 docs: 补充部署说明
| 现象 | 根因 | 解决 |
|---|---|---|
git pull 报 CONFLICT 冲突 |
本地与远端改了同一处 | git status 找冲突文件,手工去掉 <<<<<<< 标记后 git add 再 git commit |
git push 被拒(non-fast-forward) |
远端有新提交你没拉 | 先 git pull 合并再 push |
| 想撤销未提交的改动 / detached HEAD | 误改文件 / 直接切到了某提交 | git restore <文件>(改动不可恢复);git switch main 回分支 |
服务器上用 Git 的目标很单一:把某个版本的代码放到指定目录,然后构建、启动。不要在服务器上改代码 —— 改了下次 pull 就冲突。
# 首次部署:浅克隆,只取最近一次提交(省时省带宽)
$ git clone --depth 1 git@github.com:user/repo.git /opt/app
# 后续更新
$ cd /opt/app
$ git pull
[!TIP] deploy key 是服务器拉私有仓库的正确姿势:在仓库 Settings 里加一把只读专用密钥,只对该仓库有效,出事不波及你的其他仓库,还能单独吊销。别用个人密钥配服务器。
| 参数 / 做法 | 含义 |
|---|---|
--depth 1 |
浅克隆:只要最近一次提交,不要完整历史 |
--branch <名> |
指定要克隆的分支 |
| deploy key | 仓库级别的专用只读密钥;部署目录建议 /opt/<应用名> |
$ git log -1 --oneline
预期输出:
3f2a1c9 (HEAD -> main, origin/main) fix: 修正登录超时
| 现象 | 根因 | 解决 |
|---|---|---|
| 大仓库 clone 极慢 | 默认拉完整历史(含大文件) | 加 --depth 1 |
部署机 git pull 冲突 |
有人在服务器上直接改过代码 | 备份后 git checkout -- . 丢弃改动再 pull |
| 想回滚到上一个版本 | 新版本有问题 | git log 找版本号 → git switch --detach <版本号> → 重新构建 |
build-essential 是 Ubuntu 的元包(本身没内容,只依赖一堆包),装上它等于装齐编译 C/C++ 的基本工具:gcc、g++、make、libc 开发头文件。源码编译、Python 带 C 扩展的包、Node 的本地编译模块都依赖它。
# 基本编译工具链
$ sudo apt install -y build-essential
# 其他常见编译依赖(按需装,别一次全装)
$ sudo apt install -y libssl-dev zlib1g-dev libffi-dev pkg-config cmake
| 包 | 什么时候需要 |
|---|---|
build-essential |
任何源码编译前必装(gcc / g++ / make / libc6-dev) |
libssl-dev |
用到 OpenSSL 的程序(HTTPS、加密) |
zlib1g-dev |
用到压缩(Nginx、Python 的 zlib 模块) |
libffi-dev |
Python 的 ctypes / cffi 类库 |
pkg-config / cmake |
自动查找库位置与编译参数 / 项目用 CMake 构建时 |
[!NOTE] 只有带
-dev后缀的包才含头文件(.h):运行时装不带-dev的库包就够,编译时才需要-dev。
$ gcc --version | head -1
$ make --version | head -1
预期输出:
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
GNU Make 4.3
| 现象 | 根因 | 解决 |
|---|---|---|
make: command not found |
没装 build-essential | sudo apt install -y build-essential |
fatal error: xxx.h: No such file or directory |
缺对应的 -dev 开发包 |
按报错里的库名装 -dev 包,如 libssl-dev |
make: *** missing separator. Stop. |
Makefile 里该用 Tab 的行用了空格 | 把缩进换成真正的 Tab 字符 |
C compiler cannot create executables |
编译器异常或磁盘满 | 确认 gcc --version;df -h 看磁盘 |
Maven 是 Java 项目的构建与依赖管理工具,做三件事:按 pom.xml 下载依赖、编译打包成 jar、跑测试。产物在 target/,依赖缓存在 ~/.m2/repository。
[!IMPORTANT] Maven 依赖 JDK:必须先装好 JDK 并配好
JAVA_HOME,再装 Maven。见 JDK。
# 方式一:apt(最简单,但 22.04 源里装到的是 3.6.3,要 3.9.x 请走方式二)
$ sudo apt install -y maven
# 方式二:官网二进制包解压到 /opt(推荐,可控版本;版本号以官网 3.9.x 为准)
$ wget https://dlcdn.apache.org/maven/maven-3/3.9.x/binaries/apache-maven-3.9.x-bin.tar.gz
$ sudo tar -xzf apache-maven-3.9.x-bin.tar.gz -C /opt
$ sudo ln -s /opt/apache-maven-3.9.x /opt/maven
配置环境变量(为什么放 /etc/profile.d/ 见下一节):
# 一个软件一个文件,互不干扰
# Maven 3.x 只需把 bin 加进 PATH,不再需要 M2_HOME / MAVEN_HOME
$ sudo tee /etc/profile.d/maven.sh > /dev/null <<'EOF'
export PATH=$PATH:/opt/maven/bin
EOF
$ source /etc/profile.d/maven.sh
[!NOTE] 老教程常见的
M2_HOME/MAVEN_HOME在 Maven 3.x 已不再需要,仅当确有老脚本引用这两个变量时才补上(export M2_HOME=/opt/maven),纯兼容用途。
配置国内镜像(~/.m2/settings.xml,用户级):
<settings>
<mirrors>
<mirror>
<id>mirror-id</id>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>
[!NOTE] 镜像地址以镜像站官方帮助页为准(常见域名:
mirrors.aliyun.com、mirrors.tuna.tsinghua.edu.cn、mirrors.cloud.tencent.com)。更多见 国内镜像加速。
| 命令 / 配置 | 含义 |
|---|---|
mvn -v |
查看 Maven / JDK / 系统信息 |
mvn clean package |
清理后编译、测试、打包 |
-DskipTests |
跳过测试(部署提速用,别在 CI 里常态化) |
-U |
强制更新 SNAPSHOT 快照依赖 |
~/.m2/settings.xml |
用户级配置:镜像、仓库路径、认证 |
M2_HOME / MAVEN_HOME |
Maven 3.x 不再需要(仅兼容老脚本时保留);依赖缓存在 ~/.m2/repository |
$ mvn -v
预期输出:
Apache Maven 3.9.9 (…)
Maven home: /opt/maven
Java version: 21.0.8, vendor: Eclipse Adoptium, runtime: /opt/jdk-21
OS name: "linux", arch: "amd64", family: "unix"
重点看两行:Maven home 指向你解压的目录,且 Java version 是正常版本号(而不是报错)。
| 现象 | 根因 | 解决 |
|---|---|---|
Error: JAVA_HOME is not defined correctly |
没装 JDK 或 JAVA_HOME 没配 | 先按 JDK 装好 JDK 并配 JAVA_HOME |
mvn: command not found |
PATH 没生效 | source /etc/profile.d/maven.sh;新终端自动生效 |
| 下载依赖超时 / 极慢 | 默认连国外的 Maven Central | 配 ~/.m2/settings.xml 镜像 |
| 依赖下了一半构建失败 / 测试失败 | 缓存残缺 / 单测没过 | 删 ~/.m2/repository 对应目录后重跑;测试问题确认后再 -DskipTests |
Gradle 是另一个 Java 构建工具(Android 与很多新项目的默认选择),推荐版本 8.x(截至 2026-09)。安装方式与 Maven 同构:二进制包 → 解压 /opt → 软链 → 写 profile.d 脚本。
# 前置:下载用 wget、解压 zip 用 unzip
$ sudo apt install -y wget unzip
# 下载、解压到 /opt 并软链(版本号以 Gradle 官网给出的 8.x 为准)
$ wget https://services.gradle.org/distributions/gradle-8.x-bin.zip
$ sudo unzip -q gradle-8.x-bin.zip -d /opt
$ sudo ln -s /opt/gradle-8.x /opt/gradle
# 写 profile.d 脚本
$ sudo tee /etc/profile.d/gradle.sh > /dev/null <<'EOF'
export GRADLE_HOME=/opt/gradle
export PATH=$GRADLE_HOME/bin:$PATH
EOF
$ source /etc/profile.d/gradle.sh
[!IMPORTANT] 为什么用
/etc/profile.d/*.sh而不是直接改/etc/profile?/etc/profile是系统文件,升级可能被覆盖;profile.d是「一个软件一个文件」,互不干扰,卸载时删一个文件即可,排查时一眼看出谁配了什么。 注意:profile.d只在登录 shell 自动加载,systemd 与 crontab 里不生效 —— 那些场景写Environment=或用绝对路径。
| 命令 / 配置 | 含义 |
|---|---|
gradle -v / gradle build |
查看版本 / 构建(-x test 跳过测试) |
GRADLE_USER_HOME |
Gradle 缓存目录,默认 ~/.gradle |
source /etc/profile.d/<名字>.sh |
让脚本在当前终端立即生效 |
| 新开一个登录 shell | 另一种让 profile.d 生效的方式 |
$ gradle -v
预期输出:
Gradle 8.14.2
JVM: 21.0.8 (Eclipse Adoptium 21.0.8+9)
OS: Linux 5.15.0-… amd64
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了 profile.d 但当前终端没生效 | 只在登录时加载 | source /etc/profile.d/gradle.sh,或重开终端 |
systemd 服务里 mvn / gradle 找不到 |
profile.d 对非登录 shell 不生效 | unit 里写 Environment=PATH=... 或用绝对路径 |
| Gradle 也报 JDK 相关错误 | 没装 JDK | 同 Maven,先按 JDK 装好 |
| Maven 与 Gradle 会冲突吗 | 不会 | 各自一个 profile.d 脚本,PATH 追加即可 |
| 命令 | 作用 |
|---|---|
sudo apt install -y git |
安装 Git |
git config --global user.name/email "..." |
设置提交者身份 |
git config --global init.defaultBranch main |
默认分支设为 main |
git config --global core.autocrlf input |
提交时 CRLF 转 LF(Windows 必配) |
ssh-keygen -t ed25519 -C "邮箱" |
生成 SSH 密钥对 |
cat ~/.ssh/id_ed25519.pub |
查看公钥(贴到代码平台) |
ssh -T git@github.com |
测试 SSH 密钥是否配置成功 |
git clone --depth 1 <url> |
浅克隆,只取最近一次提交 |
git status / git diff / git log --oneline --graph |
看状态 / 看改动 / 看历史图 |
git add . / git commit -m "..." |
暂存 / 提交 |
git switch -c <分支> / git stash |
新建分支 / 临时收起改动 |
sudo apt install -y build-essential / gcc --version / make --version |
装编译工具 / 验证编译器与 make |
mvn -v / mvn clean package -DskipTests / mvn -U |
Maven 版本 / 打包 / 强制更新 |
gradle -v / gradle build |
Gradle 版本 / 构建 |
source /etc/profile.d/<名字>.sh |
让环境变量脚本在当前终端生效 |
HTTPS 给网站加上加密与身份证明,如今是标配而非可选项。本章走完整链路:确认前置条件 → 安装 certbot → 一键签发 → 验证 → 自动续期 → 证书文件与手动配置 → 过期排查与避坑。
[!IMPORTANT] 本章版本号截至 2026 年 9 月。版本信息的唯一权威来源是 版本速查表,冲突时以速查表为准。
HTTPS 证书是由 CA(证书颁发机构)签发的"服务器身份证明":浏览器通过它确认"我连的真是这个域名的主人,且通信被加密"。Let's Encrypt 是免费、自动化的公共 CA,唯一"代价"是证书有效期只有 90 天——这倒逼你配置自动续期,而自动续期正是 certbot 的核心价值。为什么免费还要 90 天一换?短周期降低密钥泄露的长期风险,也让"证书错了能快速作废重发"成为可能。
前置条件清单(缺一不可,签发前逐条核对):
| 条件 | 为什么需要 | 怎么确认 |
|---|---|---|
| 有域名且 A 记录已解析到本服务器 | 验证方式是"证明你控制该域名" | dig +short example.com 返回你的服务器 IP |
| 80 端口可从公网访问 | HTTP-01 验证走 HTTP | 公网机器 curl -I http://example.com 能通 |
| 服务器时间准确 | TLS 握手校验证书时间窗,时间错会直接失败 | timedatectl 看 synchronized |
| Nginx 已安装运行 | certbot 要改它的配置并重载 | Nginx 章节 完成安装 |
逐条自检:
# 确认域名解析到了本机公网 IP
$ dig +short example.com
# 确认 80 端口在监听且时间同步正常
$ ss -lntp | grep :80
$ timedatectl
预期输出:
203.0.113.10
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1234,fd=6))
Local time: Tue 2026-09-29 10:00:00 CST
NTP service: active
System clock synchronized: yes
云服务器还要检查安全组/防火墙放行了 80 端口——ss 显示监听只代表本机在听,云平台安全组是更外面的一道门(见 防火墙)。
| 术语 | 含义 |
|---|---|
| CA | 证书颁发机构,签发并背书证书的第三方 |
| HTTP-01 验证 | 在 http://域名/.well-known/acme-challenge/ 放一个临时文件供 CA 回访,证明你控制该域名 |
| 有效期 90 天 | Let's Encrypt 的强制周期,到期前 30 天可续 |
| 域名解析 A 记录 | 域名 → IP 的 DNS 记录 |
最硬的验证是从外网访问 80 端口(可用手机流量热点,不要在服务器本机测):
# 在外网环境执行
$ curl -I http://example.com
预期输出:
HTTP/1.1 200 OK
Server: nginx/1.30.x
| 现象 | 根因 | 解决 |
|---|---|---|
dig 返回的 IP 不对 |
A 记录没改或没生效 | 到域名服务商改 A 记录,等 DNS 生效 |
| 外网访问 80 超时 | 安全组/防火墙没放行 | 云控制台安全组 + ufw allow 80/tcp |
System clock synchronized: no |
没开时间同步 | sudo timedatectl set-ntp on |
certbot 是与 Let's Encrypt 交互的官方客户端,负责"申请验证文件 → 回应 CA 校验 → 下载证书 → 改 Nginx 配置 → 到期自动续"全套流程。安装方式两种:snap 安装(官方推荐),证书插件、依赖随 snap 自更新,不会被系统源版本卡住;apt 安装更符合习惯,但版本随 Ubuntu 仓库走。
推荐 snap 方式:
# 确认 snapd 正常(Ubuntu 22.04 自带)
$ sudo snap install core && sudo snap refresh core
# 安装 certbot,--classic 授予完整系统权限(改 nginx 配置需要)
$ sudo snap install --classic certbot
# 确认 certbot 命令可用
$ certbot --version
预期输出(4.x 或更新均正常,版本随官方更新):
certbot 4.x.x
若偏好 apt(两者选其一即可,不要混装):
# 从系统源安装 certbot 与 nginx 插件
$ sudo apt install certbot python3-certbot-nginx
| 方式 | 优点 | 注意 |
|---|---|---|
snap install --classic certbot |
官方推荐,自动更新,插件齐全 | --classic 是必须的,否则无权改配置 |
apt install certbot python3-certbot-nginx |
熟悉的包管理方式 | 版本可能落后;必须带 nginx 插件包 |
certbot --version |
确认安装成功 | 4.x 或更新均可(截至 2026-09) |
[!WARNING] snap 版与 apt 版不要同时安装。 两套 certbot 会互相覆盖续期配置,导致"看着都装了、续期却悄悄失败"。切换方式时先彻底卸载另一套(
sudo snap remove certbot或sudo apt remove certbot)。
# 确认 certbot 可执行且 nginx 插件在列
$ certbot --version
$ certbot plugins
预期输出(应能看到 nginx 插件):
* nginx
Description: Nginx Web Server plugin
Interfaces: IAuthenticator, IInstaller, IPlugin
| 现象 | 根因 | 解决 |
|---|---|---|
snap: command not found |
snapd 未安装 | sudo apt install snapd |
| snap 安装后命令找不到 | /snap/bin 不在 PATH |
重新登录 shell,或用全路径 /snap/bin/certbot |
certbot plugins 看不到 nginx |
只装了核心没装插件 | 补装 python3-certbot-nginx(apt 方式) |
certbot --nginx 是最省心的签发方式:certbot 自动找到 Nginx 配置里对应域名的 server 块,临时改配置完成 HTTP-01 验证,签发成功后再自动把证书写进配置(可选附带 HTTP→HTTPS 重定向)。整个过程你不用碰任何证书文件。
# 一键为两个域名签发证书并自动配置 nginx
$ sudo certbot --nginx -d example.com -d www.example.com
交互过程会依次询问(回答要点):
| 提问 | 怎么答 | 说明 |
|---|---|---|
| Enter email address | 输入你的邮箱 | 到期前收提醒邮件,强烈建议填真实邮箱 |
| Please read the Terms of Service | 输入 Y 同意 |
不同意无法签发 |
| Would you be willing to share your email... | Y 或 N 均可 |
是否订阅 EFF 新闻,随意 |
| 请选择重定向选项 | 选 2(Redirect) |
选 2 后 80 端口自动 301 跳转 HTTPS;不选的话 http 仍以明文可访问 |
成功后 certbot 会输出证书位置与到期日。它对 Nginx 配置的改动:在你原有 server 块的 listen 80 基础上新增 listen 443 ssl 与证书路径,并生成一个 80 跳转 443 的块。
两种特殊场景的替代方案(一句带过):
sudo certbot certonly --webroot -w /var/www/example -d example.com,验证文件落在你的站点目录,适合 Nginx 配置复杂、不想让 certbot 自动改文件的情况。sudo certbot certonly --dns-dnsprovider ...(需对应 DNS 插件与 API 凭据),通过 DNS TXT 记录验证,适合无法开放 80 端口或需要泛域名证书(*.example.com)的场景。| 参数 | 作用 |
|---|---|
--nginx |
使用 nginx 插件:自动验证 + 自动改配置 + 自动重载 |
-d |
指定域名,可写多个;每个都要能解析到本机 |
certonly |
只签证书不改配置,配合 --webroot/--dns-* 使用 |
-w |
webroot 模式的站点根目录 |
# 从外网验证 HTTPS 可访问且证书生效
$ curl -I https://example.com
预期输出:
HTTP/2 200
server: nginx/1.30.x
返回 HTTP/2 200 且无证书报错即为成功;http:// 版本应返回 301 跳转。
| 现象 | 根因 | 解决 |
|---|---|---|
Certbot doesn't know how to find the server block |
Nginx 里没有该域名的 server 块 | 先配好 80 端口带正确 server_name 的 server 块再跑 certbot |
Failed authorization procedure |
80 端口不通或 DNS 未生效 | 回看前置条件清单逐项排查 |
某个 -d 域名验证失败 |
多个域名中有一个解析不对 | 先只签主域名,其余修好再加签 |
| 签发成功但 http 没跳 https | 重定向选了 1(No redirect) |
重跑 sudo certbot --nginx -d 域名,选 2 |
签发完成要在三个层面确认:命令行可用(curl)、证书信息正确(certbot 自带查询)、浏览器可信(锁标)。certbot certificates 一条命令列出所有已签证书的域名、文件路径与到期日,是日常查得最多的命令。
# 列出本机所有证书与到期日
$ sudo certbot certificates
# 命令行验证 HTTPS 响应
$ curl -I https://example.com
浏览器层面:直接访问 https://example.com,地址栏应出现锁形图标,点开可查看证书详情(颁发者 Let's Encrypt、有效期、覆盖域名)。
| 命令 | 作用 |
|---|---|
sudo certbot certificates |
列出证书名、覆盖域名、到期日、文件路径 |
sudo certbot delete --cert-name example.com |
删除指定证书(含续期配置) |
curl -I |
只取响应头,-I 不会下载页面内容 |
# 查看证书清单
$ sudo certbot certificates
预期输出:
Saving debug log to /var/log/letsencrypt/letsencrypt.log
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Found the following certs:
Certificate Name: example.com
Serial Number: 4f8a2b1c9d0e1f2a3b4c5d6e7f8a9b0c1d2e
Key Type: ECDSA
Domains: example.com www.example.com
Expiry Date: 2026-12-28 08:00:00+00:00 (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/example.com/privkey.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
重点看 Expiry Date 是否约 90 天后、Domains 是否包含你要的所有域名。
| 现象 | 根因 | 解决 |
|---|---|---|
| curl 正常但证书只覆盖一个域名 | 签发时只写了一个 -d |
sudo certbot --nginx -d 主域 -d www域 扩签 |
certbot certificates 显示 0 个证书 |
签发实际失败或被误删 | 重新执行一键签发流程 |
| 锁标点了显示"部分不安全" | 页面里有 http:// 引用的资源 | 页面内资源全部改用 https 或协议相对路径 |
90 天有效期意味着没有自动续期就必然过期——这是本章反复强调的核心理由。好消息是续期基本零配置:certbot 安装时自带 systemd timer,每天两次检查是否有证书进入"到期前 30 天"窗口,有就自动续。你要做的只有两件事:确认 timer 在跑、用干跑演练验证续期链路真的通。
# 确认 certbot 的续期定时器在运行
$ systemctl list-timers | grep certbot
预期输出(snap 版安装的,timer 名带 snap 前缀):
snap.certbot.renew.timer Wed 2026-09-30 06:32:11 CST 20h left Tue 2026-09-29 03:14:02 CST 8h ago
[!NOTE] timer 名按安装方式不同:snap 版是
snap.certbot.renew.timer,apt 版才是certbot.timer。两种都能完成自动续期,别因为名字带snap.就以为装错了。
然后做一次干跑演练——用测试环境 CA 完整走一遍续期流程,不消耗正式签发额度:
# 干跑演练续期
$ sudo certbot renew --dry-run
预期输出:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)
续期后自动重载 Nginx:证书换新后 Nginx 仍持有旧证书在内存里,必须 reload。certbot 提供 hook 目录,放进 deploy 的脚本会在每次成功续期后自动执行:
# 创建部署钩子目录并写入自动 reload 脚本
$ sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/bash
systemctl reload nginx
EOF
$ sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
| 命令/机制 | 作用 |
|---|---|
certbot.timer / snap.certbot.renew.timer |
systemd 定时器(apt 版 / snap 版的名字),每天随机时间触发两次续期检查 |
certbot renew |
检查并续期所有临近到期的证书,不要手动 cron 重复跑 |
--dry-run |
对测试 CA 演练,不占正式签发额度 |
renewal-hooks/deploy/ |
续期成功后执行的钩子目录(另有 pre/ authentication/post 阶段目录) |
[!IMPORTANT] 签发成功后立即跑一次
sudo certbot renew --dry-run。 干跑能提前暴露续期链路的所有问题(80 端口不通、DNS 变更、hook 脚本没权限),而不是让证书在 90 天后的深夜静默过期。每次改动 DNS、防火墙或 Nginx 配置后,也建议再干跑一次。
# 确认 timer 活跃、干跑成功、钩子可执行
$ systemctl list-timers | grep certbot
$ sudo certbot renew --dry-run
$ sudo run-parts --test /etc/letsencrypt/renewal-hooks/deploy/
预期输出:
snap.certbot.renew.timer Wed 2026-09-30 06:32:11 CST 20h left ...
Congratulations, all simulated renewals succeeded.
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
第三条命令列出钩子脚本名即代表权限与命名无误。
| 现象 | 根因 | 解决 |
|---|---|---|
list-timers 里找不到 certbot |
timer 被禁或安装异常。注意 timer 名按安装方式不同:snap 版是 snap.certbot.renew.timer,apt 版才是 certbot.timer |
snap 版:sudo systemctl enable --now snap.certbot.renew.timer;apt 版:sudo systemctl enable --now certbot.timer |
dry-run 报 Failed authorization |
续期验证走 80 端口被挡 | 检查防火墙/安全组与 DNS,与首次签发的排查相同 |
| 续期成功但站点还是旧证书 | 没有 deploy 钩子,Nginx 没 reload | 按上文创建 reload-nginx.sh 钩子 |
| 续期静默失败没人知道 | 邮箱没填或填错 | sudo certbot update_account -m 新邮箱 |
certbot 把证书统一存放在 /etc/letsencrypt/live/<域名>/ 下。目录里是四个文件的软链,各有分工:
| 文件 | 内容 | 用途 |
|---|---|---|
fullchain.pem |
服务器证书 + 中间证书链 | Nginx ssl_certificate 用这个 |
privkey.pem |
私钥 | ssl_certificate_key 用;绝不能泄露 |
cert.pem |
仅服务器证书,无中间链 | 单独用会导致浏览器报不安全 |
chain.pem |
仅中间证书链 | 调试证书链时用 |
自动改配置之外,你也可以手写 443 的 server 块(webroot/certonly 方式签的证书就需要)。
server {
listen 443 ssl;
server_name example.com www.example.com;
# 证书用 fullchain(含中间链),不要用 cert.pem
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example;
index index.html;
}
# 80 端口只做一件事:301 跳转到 HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
改完走标准流程:sudo nginx -t && sudo systemctl reload nginx(Nginx 章节 的肌肉记忆)。
[!WARNING]
privkey.pem是私钥,泄露即身份被盗。 不要提交进 git、不要发到聊天工具、不要用cat贴到网上求助(发cert.pem或用openssl x509查看公开部分是安全的)。
| 指令 | 作用 | 要点 |
|---|---|---|
listen 443 ssl |
监听 HTTPS 端口 | 必须带 ssl 关键字 |
ssl_certificate |
证书文件路径 | 用 fullchain.pem,原因见常见问题表 |
ssl_certificate_key |
私钥路径 | 与证书成对 |
return 301 https://$host$request_uri |
保留原路径整页跳转 | 80 块里不需要 root/location |
# 语法检查并重载,然后验证跳转与 HTTPS
$ sudo nginx -t && sudo systemctl reload nginx
$ curl -I http://example.com
$ curl -I https://example.com
预期输出:
nginx: configuration file /etc/nginx/nginx.conf test is successful
HTTP/1.1 301 Moved Permanently
Location: https://example.com/
HTTP/2 200
| 现象 | 根因 | 解决 |
|---|---|---|
| 浏览器提示不安全,curl 却正常 | ssl_certificate 误用了 cert.pem,中间链不完整 |
改用 fullchain.pem 并 reload |
nginx: [emerg] cannot load certificate |
证书路径写错或域名目录名不符 | 用 certbot certificates 核对 Certificate Path |
| 访问 443 连接被拒绝 | 443 未放行 | 安全组与 ufw allow 443/tcp |
证书问题最常见的两类:快过期/已过期和续期/签发失败。前者一条 openssl 命令看穿真实生效的证书有效期;后者几乎都能归到"80 端口不通、DNS 变了、限频"三种根因上。
不依赖 certbot,直接问服务器上的真实证书(浏览器看到的也是它):
# 读取 443 端口实际返回的证书有效期
$ echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
预期输出:
notBefore=Sep 29 08:00:00 2026 GMT
notAfter=Dec 28 08:00:00 2026 GMT
notAfter 距今不足 30 天,说明自动续期没工作,回上一节排查 timer 与 dry-run。
| 命令片段 | 作用 |
|---|---|
openssl s_client -connect |
模拟 TLS 客户端连接,打印握手信息 |
-servername example.com |
发送 SNI,服务器按域名返回对应证书 |
openssl x509 -noout -dates |
从证书中只提取起止日期 |
综合排查路径:先 openssl 看真实有效期 → sudo certbot certificates 看 certbot 认知是否一致 → sudo certbot renew --dry-run 验证续期链路 → sudo tail -50 /var/log/letsencrypt/letsencrypt.log 看失败原因。
| 现象 | 根因 | 解决 |
|---|---|---|
--nginx 报找不到 server 块 |
没有该域名的 80 server 块 | 先配好 listen 80 + 正确 server_name,再跑 certbot |
验证失败 Timeout during connect |
防火墙/云安全组没放行 80 | 放行 80 端口后重试 |
验证失败 DNS problem |
DNS 未生效或解析到了别处 | dig +short 域名 核对,等生效再签 |
| 走 CDN 时验证失败 | CDN 拦截了 ACME 回访 | 签发期间临时切 DNS 直连,或改用 DNS-01 |
too many certificates 限频 |
同一域名一周签发超 5 张 | Let's Encrypt 限频;等一周窗口过去,平时多用 --dry-run 测试 |
| 续期失败 | 80 被其他服务占用,或域名解析已变更 | `ss -lntp |
| 浏览器不安全但 curl 正常 | Nginx 用了 cert.pem 而非 fullchain.pem |
换 fullchain.pem 并 reload |
| 证书已过期站点打不开 | timer 失效且无人发现 | 手动 sudo certbot renew,随后修 timer 并干跑验证 |
镜像站把国外软件源「复制」一份放在国内,让你下载时不用跨洋。本章讲 apt、pip、npm、Maven、Docker 五类源的加速配置,以及换源出问题后怎么快速恢复。
[!NOTE] 本章的配置全部是可选的。镜像地址以镜像站官方帮助页为准;文中只给出域名,具体路径请到官方帮助页复制(截至 2026-09)。
镜像加速是一种可选优化,不是部署的必做步骤。判断标准很简单:你的服务器下载官方源时慢不慢。海外服务器、或国内机器访问官方源本来就不慢时,完全没必要改 —— 多改一处就多一处出错的可能。
先测速再决定:
# 测下载官方源一个文件的耗时(秒)
$ curl -o /dev/null -s -w '%{time_total}\n' http://archive.ubuntu.com/ubuntu/ls-lR.gz
耗时超过几秒、或 apt update 明显卡住才换。换源遵循三条原则:一次只换一类源、改前先备份、以镜像站官方帮助页给出的配置为准。
[!IMPORTANT] 不要盲目替换系统默认的
/etc/apt/sources.list。 镜像站的 URL 会变(网上抄的教程常常已过期); 与 Docker、Nginx 等第三方源混用会导致 404 或签名错误;出问题时你还分不清是镜像没同步还是配错了。 正确做法:只替换 Ubuntu 官方那几行,第三方源保持原样,改之前先备份。
常见镜像站(到官方帮助页复制最新配置,不要照抄过期教程):清华大学 TUNA https://mirrors.tuna.tsinghua.edu.cn/、
阿里云 https://mirrors.aliyun.com/(开发者社区另有 developer.aliyun.com/mirror/)、腾讯云 https://mirrors.cloud.tencent.com/。
$ apt policy nginx
预期输出(换源后 500 后面那行会变成镜像域名):
1.18.0-6ubuntu14.4 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages
| 现象 | 根因 | 解决 |
|---|---|---|
| 不知道该不该换源 | 没测过实际速度 | 用上面的 curl 测一下,慢才换 |
| 换源后反而更慢 | 镜像站到你的机器链路不佳 | 换另一个镜像站,或用备份恢复默认源 |
| 换了源但速度没变 | 改错文件 / 没 apt update |
确认改的是生效的那个文件,改完必须 apt update |
apt 的软件源地址写在 /etc/apt/sources.list 和 /etc/apt/sources.list.d/*.list 里。所谓「换 apt 源」,就是把里面 archive.ubuntu.com 这类官方域名换成镜像站域名。
[!NOTE] Ubuntu 22.04 用
/etc/apt/sources.list(一行一条的传统格式);24.04 起改成/etc/apt/sources.list.d/ubuntu.sources的 deb822 格式,写法不同,改之前先ls /etc/apt/sources.list.d/确认。
# 1) 先备份(这步不能省)
$ sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak
# 2) 把官方域名替换成镜像域名(以官方帮助页给出的为准)
$ sudo sed -i 's|http://archive.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list
$ sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list
# 3) 改完必须更新索引
$ sudo apt update
替换后的内容形如(以 Ubuntu 22.04 jammy 为例):
deb https://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse
[!WARNING] 只替换 Ubuntu 官方那几行。
/etc/apt/sources.list.d/下 Docker、Nginx、MySQL 等第三方源有各自的域名和签名密钥, 把它们一起 sed 掉会直接导致apt update报 NO_PUBKEY 或 404。用grep -r deb /etc/apt/sources.list.d/先看看有哪些第三方源。
| 片段 | 含义 |
|---|---|
deb |
二进制包源(deb-src 是源码包源,一般不用开) |
jammy |
发行版代号;jammy-updates 更新、-backports 回溯、-security 安全更新 |
main / universe 等 |
组件:官方支持 / 社区维护 / 非自由软件 |
sources.list.bak |
备份文件,恢复时直接用它覆盖 |
$ apt policy nginx
预期输出(500 那行已变成镜像域名):
1.18.0-6ubuntu14.4 500
500 https://mirrors.aliyun.com/ubuntu jammy-updates/main amd64 Packages
| 现象 | 根因 | 解决 |
|---|---|---|
apt update 报 NO_PUBKEY |
第三方源被一起替换了,签名对不上 | 用备份恢复,只改官方那几行 |
报 Hash Sum mismatch / 散列不符 |
镜像同步到一半,索引与包不一致 | 等一会儿重试;不行就换回官方源 |
报 404 Not Found |
镜像站路径变了或该版本已下线 | 去官方帮助页复制最新配置 |
| 想恢复默认源 | —— | sudo cp /etc/apt/sources.list.bak /etc/apt/sources.list && sudo apt update |
Python 的 pip 默认从 pypi.org 下载包。换镜像有两种粒度:临时(只对这一条命令生效)和永久(写进配置文件)。
# 临时:只对这一次安装生效
$ pip install -i https://mirrors.aliyun.com/pypi/simple/ requests
# 永久:写进 pip 配置(用户级)
$ pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
等价的配置文件 ~/.config/pip/pip.conf(系统级是 /etc/pip.conf):
[global]
index-url = https://mirrors.aliyun.com/pypi/simple/
(示例用的是 https 镜像,不需要 trusted-host;只有镜像站走 http 或证书异常时才加 trusted-host = <域名>,见下表。)
[!WARNING] 不要用
sudo pip install往系统 Python 里装包,它可能覆盖 apt 管理的依赖,把系统工具搞坏。 正确做法是用虚拟环境或--user安装,详见 Python。
| 配置 / 参数 | 含义 |
|---|---|
-i <url> |
临时指定索引地址 |
index-url |
永久默认索引地址 |
trusted-host |
信任该主机(镜像站用 http 或证书异常时才需要) |
~/.config/pip/pip.conf / /etc/pip.conf |
用户级 / 系统级配置文件 |
常用镜像域名(路径一般为 /pypi/simple/):mirrors.aliyun.com、pypi.tuna.tsinghua.edu.cn、mirrors.cloud.tencent.com。
$ pip config list
预期输出:
global.index-url='https://mirrors.aliyun.com/pypi/simple/'
| 现象 | 根因 | 解决 |
|---|---|---|
装包报 Connection timed out |
默认源在国外 | 配 index-url |
报证书错误 SSL: CERTIFICATE_VERIFY_FAILED |
镜像站证书链问题 | 加 trusted-host,或改用 https 地址 |
| 配了但不生效 / 部分包找不到 | 多配置文件覆盖 / 镜像同步延迟 | pip config list -v 看读取了哪些文件;临时换回官方源 |
npm 的包下载地址由 registry 决定,默认连国外的 registry.npmjs.org。换镜像就是改这一项。
# 设置国内镜像
$ npm config set registry https://registry.npmmirror.com
# 查看当前值
$ npm config get registry
等价的用户级配置文件 ~/.npmrc:
registry=https://registry.npmmirror.com
[!TIP] 需要在多个源之间来回切时,可以装
nrm(npm i -g nrm),用nrm ls看可选源、nrm use <名字>一键切换。 项目级的做法是在项目根目录放一个.npmrc,只对该项目生效。详见 Node.js。
| 配置 | 含义 |
|---|---|
registry |
包下载地址 |
~/.npmrc |
用户级配置 |
项目内 .npmrc |
项目级配置,优先级高于用户级 |
@scope:registry |
为某个作用域单独指定源 |
$ npm config get registry
预期输出:
https://registry.npmmirror.com/
| 现象 | 根因 | 解决 |
|---|---|---|
| 换源后装包 404 | 镜像同步延迟 | 等一会儿,或临时换回官方源 |
| 私有包拉不到 / 换源没生效 | 镜像没有私有包 / 项目内 .npmrc 覆盖 |
配 @scope:registry=<私有源>;检查项目根目录 |
npm ci 仍然慢 |
lock 文件里写着旧的 resolved 地址 | 重新生成 lock 或加 --registry 参数 |
Maven 默认从国外的 Maven Central 下载依赖。换镜像要改 ~/.m2/settings.xml 的 <mirrors> 段,让对 central 的请求改走镜像站。
<settings>
<mirrors>
<mirror>
<id>cn-mirror</id>
<name>国内公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>
[!NOTE]
<url>以镜像站官方帮助页为准(常见域名:maven.aliyun.com、mirrors.tuna.tsinghua.edu.cn、mirrors.cloud.tencent.com)。详见 Git 与构建工具。
| 元素 | 含义 |
|---|---|
<id> |
镜像的唯一标识,随便起 |
<url> |
镜像仓库地址 |
<mirrorOf>central</mirrorOf> |
只代理 id 为 central 的仓库 |
<mirrorOf>*</mirrorOf> |
代理所有仓库(私有仓库也会被代理,慎用) |
<localRepository> |
本地依赖缓存路径,默认 ~/.m2/repository |
[!IMPORTANT] 优先用
<mirrorOf>central</mirrorOf>而不是*。用*会把公司私有仓库的请求也转发到公共镜像,导致私有依赖永远拉不到。
# 看生效后的镜像配置
$ mvn help:effective-settings | grep -A4 "<mirrors>"
预期输出:
<mirrors>
<mirror>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
| 现象 | 根因 | 解决 |
|---|---|---|
| 下载依赖超时 | 默认连国外 Central | 配置 <mirrors> |
| 私有依赖拉不到 | mirrorOf 写成了 * |
改成 central,或加 <mirrorOf>*,!私有仓库id</mirrorOf> |
| 改了 settings.xml 没生效 / 依赖残缺 | 文件放错位置 / 缓存下载了一半 | 必须在 ~/.m2/settings.xml;删 ~/.m2/repository 对应目录后 mvn -U |
Docker 拉镜像默认走国外的 Docker Hub。加速方式是往 /etc/docker/daemon.json 里配 registry-mirrors,让 Docker 先去镜像站找。
{
"registry-mirrors": [
"https://<镜像加速器地址>"
]
}
# 改完必须重启 Docker(reload 不会重新读取 daemon.json)
$ sudo systemctl restart docker
[!IMPORTANT] 改
daemon.json后必须sudo systemctl restart docker,reload无效。 这一点和 Nginx 相反(Nginx 是reload就够了),因为 Docker 只在守护进程启动时读取一次该配置文件。 详见 Docker。
[!WARNING] 2024 年之后多家公共加速器已下线或改为需登录。
docker pull失败时优先怀疑这一项:用docker info看Registry Mirrors是否为空。可靠做法是到云厂商控制台申请专属加速器地址,或给仓库配认证。
| 字段 / 命令 | 含义 |
|---|---|
registry-mirrors |
镜像加速器地址数组,按顺序尝试 |
/etc/docker/daemon.json |
Docker 守护进程配置文件 |
sudo systemctl restart docker |
重启守护进程使配置生效 |
docker pull <镜像> |
拉取镜像,可观察是否走了加速器 |
$ docker info | grep -A3 "Registry Mirrors"
预期输出:
Registry Mirrors:
https://<镜像加速器地址>/
若该项为空数组或没有输出,说明配置没生效 —— 检查 JSON 是否合法(多余逗号会导致 Docker 起不来)。
| 现象 | 根因 | 解决 |
|---|---|---|
配完 docker pull 依旧超时 |
没 restart,只是 reload | sudo systemctl restart docker |
| 重启后 Docker 起不来 | daemon.json 有语法错误(如多余逗号) | journalctl -u docker -n 50 看报错,修正 JSON |
Registry Mirrors 为空 |
配置文件路径或字段名写错 | 确认是 /etc/docker/daemon.json 的 registry-mirrors |
加速器地址失效 / docker info 报权限错误 |
公共加速器下线 / 用户不在 docker 组 | 换云厂商专属地址;用 sudo 或把用户加入 docker 组 |
其他语言的包管理器也有类似的「镜像地址」开关,思路完全一样:找到它读取源地址的配置项,改成镜像域名。
# Go:设置模块代理(goproxy.cn 是国内常用的公共代理,也可用镜像站提供的 Go 代理)
$ go env -w GOPROXY=https://goproxy.cn,direct
# 查看
$ go env GOPROXY
其他工具同理:先查它的「源/镜像」配置项(如 Ruby 的 gem sources、Rust 的 ~/.cargo/config.toml),再按官方帮助页给出的地址替换。
| 配置 | 含义 |
|---|---|
GOPROXY |
Go 模块代理地址 |
,direct |
代理找不到时直接连源站,作为兜底 |
$ go env GOPROXY
预期输出:
https://goproxy.cn,direct
| 现象 | 根因 | 解决 |
|---|---|---|
go mod download 超时 |
默认代理在国外 | 设 GOPROXY |
| 私有模块拉不到 / 改了没生效 | 代理转发私有请求 / 环境变量覆盖 | 设 GOPRIVATE=<私有域名>;检查是否 export 了同名 GOPROXY |
换源引入的问题有个共同特点:报错指向「源」本身(签名、散列、404、超时),而不是你的应用代码。掌握「快速恢复默认源」这一招,就能把风险降到最低。
# apt:一键恢复默认源(前提是改之前备份过)
$ sudo cp /etc/apt/sources.list.bak /etc/apt/sources.list
$ sudo apt update
# pip / npm:恢复官方源
$ pip config unset global.index-url
$ npm config set registry https://registry.npmjs.org
| 报错关键词 | 含义 | 处理 |
|---|---|---|
NO_PUBKEY / 签名无效 |
签名密钥不匹配,通常是第三方源被误改 | 恢复该第三方源原样 |
Hash Sum mismatch |
索引与包不一致,镜像同步中 | 稍后重试,或换回官方源 |
404 Not Found |
路径变了 / 版本下线 | 去官方帮助页复制新地址 |
Release file ... is not valid yet |
机器时间与源服务器差太多 | 校准时间后重试,见 网络配置 |
# 确认已回到官方源且索引正常
$ apt update && apt policy nginx
预期输出(末尾无报错,来源回到官方域名):
命中:1 http://archive.ubuntu.com/ubuntu jammy InRelease
1.18.0-6ubuntu14.4 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages
| 现象 | 根因 | 解决 |
|---|---|---|
| 忘了备份就想恢复 | 没留退路 | 手动把域名改回 archive.ubuntu.com / security.ubuntu.com |
| 换了源之后什么都装不上 | 改坏了整个 sources.list | 恢复备份;第三方源单独还原 |
| 只有一个包装不上 / 不确定是不是源的问题 | 镜像延迟 / 判断不清 | 等几小时;先恢复默认源再试,能装上就说明是源的问题 |
时间不对导致 not valid yet |
系统时钟漂移 | 校准时间后 apt update |
| 命令 | 作用 |
|---|---|
curl -o /dev/null -s -w '%{time_total}\n' <url> |
测下载耗时,判断是否需要换源 |
sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak |
备份 apt 源 |
sudo sed -i 's|archive.ubuntu.com/ubuntu|<镜像域名>/ubuntu|g' /etc/apt/sources.list |
替换 apt 源域名 |
sudo apt update |
改源后必须更新索引 |
apt policy <包> |
看候选版本与来源 URL(判断是否换成功) |
pip config set global.index-url <url> / pip config list |
设置 / 查看 pip 镜像 |
npm config set registry <url> / npm config get registry |
设置 / 查看 npm 镜像 |
mvn help:effective-settings | grep -A4 "<mirrors>" |
查看 Maven 生效的镜像配置 |
docker info | grep -A3 "Registry Mirrors" |
查看加速器是否生效 |
sudo systemctl restart docker |
让 daemon.json 生效(reload 无效) |
go env -w GOPROXY=<url> / go env GOPROXY |
设置 / 查看 Go 模块代理 |
pip config unset global.index-url |
恢复 pip 默认源 |
sudo cp /etc/apt/sources.list.bak /etc/apt/sources.list |
恢复 apt 默认源 |
signed-by 密钥配置settings.xml前面章节装好的 MySQL、PostgreSQL,以及后面要部署的每一个应用,在 Ubuntu 上最终都交给同一套机制来启动、守护和开机自启 —— 这就是 systemd。本章是全书骨架:所有部署章都会说「写一个 unit 文件、交给 systemd」,具体怎么做都在这里。
[!IMPORTANT] 本章命令适用于 Ubuntu 22.04 自带的 systemd v249,截至 2026-09 仍是 LTS 默认版本。systemd 命令在 Ubuntu 与 RHEL 系上几乎完全一致,可放心迁移。
开机时,内核只启动一个进程,就是 PID 1 —— 在 Ubuntu 22.04 上它是 systemd。其他所有进程都由它直接或间接拉起,所以它天然是「进程的总管家」。
systemd 把管理对象抽象成 unit(单元),每种 unit 一个用途:
.service:守护进程,如 mysql.service、nginx.service —— 本章主角;.timer:定时器,是 cron 的现代替代品;.socket / .mount / .path:按需启动、挂载、路径变化触发,部署中偶尔遇到;.target:一组 unit 的集合,相当于「运行级别」,如 multi-user.target 代表多用户命令行环境。为什么全书所有软件都教 systemctl?因为不管你装的是 MySQL 还是自研 Java 应用,在 systemd 眼里都只是一个 unit —— 一套命令管所有软件,不用再记每个软件自带的启停脚本。
先确认自己机器上的 PID 1 确实是 systemd,再看系统里有哪些服务:
# 查看 1 号进程的名字
$ ps -p 1 -o comm=
# 列出当前已加载的服务 unit
$ systemctl list-units --type=service
list-units 输出每行是一个 unit,各列含义:
| 列 | 常见值 | 含义 |
|---|---|---|
| UNIT | nginx.service | unit 名,systemctl 操作时写它 |
| LOAD | loaded / not-found | 配置是否成功加载 |
| ACTIVE | active (running) / failed / inactive | 运行状态,部署最关心这个 |
| SUB | running / dead / exited | 细分状态:running 常驻运行、exited 跑完就退 |
# 只看「正在运行」的服务,输出短得多
$ systemctl list-units --type=service --state=running
预期输出:
UNIT LOAD ACTIVE SUB DESCRIPTION
cron.service loaded active running Regular background program processing
mysql.service loaded active running MySQL 8.0 Database Server
ssh.service loaded active running OpenBSD Secure Shell server
| 现象 | 根因 | 解决 |
|---|---|---|
System has not been booted with systemd |
在 WSL1 或容器里没有真正的 systemd | WSL2 在 /etc/wsl.conf 加 [boot] systemd=true;容器交给宿主机管理 |
| 服务名提示不存在 | 名字拼错或软件没装 | systemctl list-unit-files | grep <关键词> 模糊查找 |
日常管服务就 9 个动词,分两组记:
start(启动)、stop(停止)、restart(重启)、reload(重读配置);enable(设为自启)、disable(取消自启)、is-enabled(查自启状态);加上查状态的 status 和 is-active,凑成九连。reload 和 restart 的区别必须搞清:
restart:杀掉进程重新拉起,已有连接全部断开;reload:优先执行 unit 里 ExecReload= 定义的命令(服务未定义 ExecReload= 时才退回给进程发 SIGHUP 信号),让它重读配置文件但不断开连接,适合改完 nginx、MySQL 配置后平滑生效;reload 会失败,只能 restart。# 启动 nginx
$ sudo systemctl start nginx
# 改完配置平滑重载,不断连接;不支持 reload 的服务改用 restart
$ sudo systemctl reload nginx
# 开机自启 + 立即启动,一步到位
$ sudo systemctl enable --now nginx
| 命令 | 作用 | 是否断开现有连接 |
|---|---|---|
systemctl start <服务> |
启动 | - |
systemctl stop <服务> |
停止 | 是 |
systemctl restart <服务> |
重启进程 | 是 |
systemctl reload <服务> |
发 SIGHUP 重读配置 | 否(需服务支持) |
systemctl status <服务> |
状态与最近日志 | - |
systemctl enable <服务> |
设置开机自启 | - |
systemctl disable <服务> |
取消开机自启 | - |
systemctl is-active <服务> |
只回答运行与否,适合写脚本 | - |
[!TIP]
enable只管「开机拉起」,不会立刻启动;start只管「现在拉起」,不会设自启。部署时用enable --now一步到位。
# 确认 nginx 运行中且已设自启
$ systemctl is-active nginx
$ systemctl is-enabled nginx
预期输出:
active
enabled
这两条命令只输出一个词、退出码可判断,是脚本里最常用的检查方式。
| 现象 | 根因 | 解决 |
|---|---|---|
reload 执行后报 Job 失败 |
该服务没实现 reload | 改用 restart |
start 卡很久后报 Job failed |
启动超时,常伴随端口占用或配置错误 | 看 systemctl status 与 journalctl -u |
stop 之后进程还在 |
有子进程没被杀干净,或被别的守护工具拉着 | systemctl kill <服务>;排查是否重复守护(见 42) |
systemctl status 是排错第一站:一屏给出服务状态、进程号、资源占用和最近几行日志。看懂它,80% 的「服务怎么挂了」当场就有答案。
# 查看 nginx 服务的完整状态
$ systemctl status nginx
以下是一次典型输出,逐段解读:
预期输出:
● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-09-29 10:00:03 CST; 2 days ago
Main PID: 1234 (nginx)
Tasks: 5 (limit: 4915)
Memory: 12.5M
CPU: 3.201s
CGroup: /system.slice/nginx.service
├─1234 "nginx: master process"
└─1235 "nginx: worker process"
Sep 29 10:00:03 web01 systemd[1]: Starting A high performance web server...
Sep 29 10:00:03 web01 systemd[1]: Started A high performance web server...
| 行 | 怎么读 |
|---|---|
● nginx.service - ... |
圆点颜色即状态:绿=运行、红=失败、白=未运行;后面是描述 |
Loaded: |
unit 文件路径 + 自启状态(enabled 表示开机自启已开) |
Active: |
当前状态 + 持续时间 ——「2 days ago」说明两天没重启过 |
Main PID: |
主进程号,top 定位、排查都用它 |
Tasks / Memory / CPU |
资源占用,判断内存泄漏先看这里 |
| 末尾几行 | 最近日志,只是预览,完整日志用 journalctl -u |
# 停掉 nginx 后再看状态,观察变化
$ sudo systemctl stop nginx
$ systemctl status nginx
预期输出:
● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: inactive (dead) since Tue 2026-09-29 15:00:00 CST; 3s ago
| 现象 | 根因 | 解决 |
|---|---|---|
Active: failed (Result: exit-code) |
启动进程退出码非 0 | 立刻 journalctl -u <服务> -n 50 看完整日志 |
Active: activating (auto-restart) |
起不来但配了 Restart,反复重试 | 查日志找根因,别等它自己好 |
| status 正常但功能不通 | 进程活着但监听端口或配置不对 | ss -tlnp 查端口,回查应用自身配置 |
systemctl enable 只做一件事:在 /etc/systemd/system/multi-user.target.wants/ 目录下,为你的 unit 文件建一个软链接。开机最后阶段,systemd 启动 multi-user.target(多用户命令行环境),并顺带拉起它的 .wants/ 目录里链接的所有 unit —— 所以「自启」=「把自己的 unit 链接进 wants 目录」。
[!NOTE] 传统运行级别(runlevel 0-6)在 systemd 里由 target 对应:
multi-user.target≈ 级别 3(命令行),graphical.target≈ 级别 5(图形界面)。服务器默认进入 multi-user.target。
# 设置开机自启
$ sudo systemctl enable nginx
# 开机自启 + 立即启动,部署最常用
$ sudo systemctl enable --now nginx
# 取消开机自启,正在运行的服务不会被停掉
$ sudo systemctl disable nginx
| 命令 | 动作 | 目录变化 |
|---|---|---|
enable |
建软链 | multi-user.target.wants/ 下出现该服务的链接 |
disable |
删软链 | 上述链接消失 |
enable --now |
建软链 + 启动 | 链接出现且服务 active |
--now 是在 enable/disable 之上追加一条 start/stop。
亲手看一眼软链,理解就扎实了:
# 查看 wants 目录里有没有 nginx 的软链,再确认自启状态
$ ls -l /etc/systemd/system/multi-user.target.wants/ | grep nginx
$ systemctl is-enabled nginx
预期输出:
lrwxrwxrwx 1 root root 35 Sep 29 10:00 nginx.service -> /lib/systemd/system/nginx.service
enabled
| 现象 | 根因 | 解决 |
|---|---|---|
| enable 了但重启后没起来 | unit 缺 [Install] 段或 WantedBy 写错 |
补 WantedBy=multi-user.target,改后 daemon-reload 再 enable |
enable 报 No such file or directory |
unit 文件路径不对 | 先 systemctl cat <服务> 确认实际加载路径 |
| 想让应用比数据库先起来 | 启动顺序由 After= 决定,与 enable 无关 |
unit 里写 After=mysql.service |
unit 文件就是服务的「说明书」:怎么启动、以谁的身份跑、挂了怎么办、要不要自启。systemd 按以下优先级查找,同名时上面的覆盖下面:
| 优先级 | 目录 | 用途 |
|---|---|---|
| 1(最高) | /etc/systemd/system/ |
管理员自己写的放这里 |
| 2 | /run/systemd/system/ |
运行时临时生成,重启即消失 |
| 3 | /usr/local/lib/systemd/system/ |
本机手动安装的软件(如 make install)自带的 unit 放这里 |
| 4 | /lib/systemd/system/ |
apt 装的软件自带,别直接改(升级会被覆盖) |
自己写的服务放 /etc/systemd/system/;改软件自带的 unit 用 systemctl edit 生成覆盖片段(见「排查工具箱」一节),不动原文件。
以一个自建 Java 应用为例,这是全书部署章的模板。先建专用用户,再写 unit 文件:
# 创建无登录权限的系统用户,专用于跑应用
$ sudo useradd -r -s /usr/sbin/nologin appuser
# 新建 unit 文件
$ sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Java App
After=network.target mysql.service
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java $JAVA_OPTS -jar /opt/myapp/app.jar
Restart=always
RestartSec=5
Environment="JAVA_OPTS=-Xms512m -Xmx1024m"
EnvironmentFile=-/opt/myapp/app.env
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
让 systemd 识别新文件并启动:
# 重载 unit 定义,新增或修改 unit 后必须执行
$ sudo systemctl daemon-reload
# 设置自启并立即启动
$ sudo systemctl enable --now myapp
逐行拆解这份模板,每一行都回答「为什么」:
[Unit] 段 —— 元信息与依赖
Description=:status 和日志里显示的名字,写清楚是什么;After=network.target mysql.service:排序依赖,等网络就绪、MySQL 启动后再启动本服务,应用连库不失败。注意 After 只管顺序,不管「MySQL 起不来我就不启动」——那是 Requires= 的职责,部署场景配 After 通常够用。[Service] 段 —— 怎么跑
Type=simple:ExecStart 直接前台运行,systemd 认定主进程就是它。五种 Type 怎么选:
simple:前台常驻,现代应用(Java/Go/Python/Node)的默认选择;exec:与 simple 类似,但 systemd 会等到 exec() 成功才标记启动,能捕捉「二进制不存在、无执行权限」等失败(v249+ 可用);forking:进程自己 fork 到后台、父进程退出(老式 daemon 脚本风格);notify:服务就绪后主动通知 systemd(需程序调用 sd_notify API,状态更精确);oneshot:跑一次就退出,如初始化脚本,常配 RemainAfterExit=yes。User= / Group=:以哪个用户跑。用 root 跑应用,一旦被攻破就是整机沦陷,专用低权用户是底线;WorkingDirectory=:进程的工作目录,应用读相对路径的配置和静态文件都基于它;ExecStart=:必须绝对路径,且不支持管道、重定向、通配符等 shell 特性;但支持 Environment= 定义的 $VAR/${VAR} 变量展开(上面的 $JAVA_OPTS 就是)。用 which java 确认真实路径再填;Restart=always 与 Restart=on-failure 的区别:
always:无论怎么退出都拉起,包括正常退出,适合「永远该活着」的常驻服务;on-failure:只在异常退出(非 0 退出码、被信号杀死)时拉起,正常退出不管;always,不会错。RestartSec=5:每次拉起间隔 5 秒,给依赖服务(如 MySQL)留恢复时间;Environment="JAVA_OPTS=-Xms512m -Xmx1024m":直接写死的环境变量。值含空格时必须整体加引号,否则 -Xmx1024m 会被当成第二条(非法的)赋值而静默忽略,堆参数只剩一半。写好后别忘了在 ExecStart= 里引用 $JAVA_OPTS 才会传给 java;若不想引入变量,60 章端到端实战的做法是把堆参数直接写进 ExecStart;EnvironmentFile=-/opt/myapp/app.env:从文件批量读环境变量(数据库密码等敏感信息放这里,该文件不要进 git)。开头的 - 表示文件不存在也不报错,首次部署友好;LimitNOFILE=65536:进程可打开的文件句柄数上限(每个 TCP 连接、每个打开的文件都占一个)。默认 1024,高并发应用必须调大,否则报 Too many open files。[Install] 段 —— 装进哪个 target
WantedBy=multi-user.target:enable 时把软链建到它的 .wants/ 目录,服务器标准写法。启动后用 status 确认,重点看 Loaded 路径和 Main PID:
$ systemctl status myapp
# 确认进程确实以 appuser 身份在跑
$ ps -o user= -p $(systemctl show -p MainPID --value myapp)
预期输出:
● myapp.service - My Java App
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-09-29 16:20:00 CST; 10s ago
Main PID: 5678 (java)
appuser
Loaded 应指向你写的 /etc/systemd/system/myapp.service,末行 appuser 确认进程没有以 root 运行。
| 现象 | 根因 | 解决 |
|---|---|---|
Failed with result 'exit-code' |
应用自己崩了,systemd 只是如实汇报 | journalctl -u myapp -n 50 看应用报错 |
| Loaded 显示 not-found | unit 文件名拼错或没 daemon-reload | 核对文件名,sudo systemctl daemon-reload |
| 应用读不了某些文件 | User= 指定的用户权限不足,或没设 WorkingDirectory | chown -R appuser:appuser <部署路径>;unit 里补 WorkingDirectory= |
除了 LimitNOFILE(文件句柄数),systemd 还能直接在 unit 里限制服务的内存和 CPU,被托管的进程超限时由 systemd 兜底,不需要额外装监控组件:
| 键 | 作用 | 超限行为 |
|---|---|---|
LimitNOFILE=65536 |
可打开的文件句柄上限 | 进程报 Too many open files |
MemoryMax= |
内存硬上限 | 超出的进程被 OOM kill |
MemoryHigh= |
内存软限(throttle 阈值) | 超限时内核回收页缓存并限流,不杀进程 |
CPUQuota= |
CPU 时间配额,如 100% 表示单核满载 |
超限时进程被节流,不会报错 |
在 [Service] 段追加,daemon-reload + restart 生效:
[Service]
# 内存硬顶 512M,超限直接杀;软限 400M 起开始回收
MemoryMax=512M
MemoryHigh=400M
# 最多用满一个核(200% = 两个核)
CPUQuota=100%
改完可用 systemctl status <服务> 看 Memory 是否被压住,或 systemctl show <服务> -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSec 核对实际生效值。
[!TIP] 生产上给应用设
MemoryMax时,记得比 JVM 的-Xmx留出余量(堆外内存、元空间、线程栈都要占内存),否则 java 本体没超限也会被 systemd 杀掉。
systemd 启动后会把所有 unit 文件缓存在内存里。你用编辑器改了磁盘上的文件,内存里的还是旧的 —— 这是新手最常踩的坑:改完直接 restart,要么报错,要么行为诡异。
[!IMPORTANT] 新增、修改、删除 unit 文件后,必须执行
sudo systemctl daemon-reload,让 systemd 重新读盘,然后再 restart。固定顺序:改文件 → daemon-reload → restart。
# 修改 unit 后的标准三连
$ sudo nano /etc/systemd/system/myapp.service
$ sudo systemctl daemon-reload
$ sudo systemctl restart myapp
| 命令 | 作用 | 何时需要 |
|---|---|---|
daemon-reload |
重读所有 unit 文件 | unit 文件有任何增删改 |
restart |
重启服务进程 | 改了影响运行行为的键 |
注意:daemon-reload 只是「重新理解说明书」,不会重启进程;要让新配置作用到进程还需 restart。
改完先看 systemd 实际加载的内容,确认是新的:
# 查看该 unit 实际生效的完整配置
$ systemctl cat myapp
预期输出:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Java App
...
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了 ExecStart,restart 后仍跑旧命令 | 忘了 daemon-reload | 三连:改文件 → daemon-reload → restart |
| restart 报 unit changed on disk | systemd 检测到文件与缓存不一致 | 先 daemon-reload 再 restart |
| 怎么改都不生效 | 改的是 /lib/systemd/system/ 里的原文件,被 /etc 里的同名文件覆盖 |
用 systemctl cat 看真正生效的是哪个文件 |
systemd 启动的服务,其 stdout/stderr 会被 journald 自动收走,统一存进二进制日志,用 journalctl 查询 —— 不用找每个软件自己的日志文件在哪,一条命令按服务过滤。完整用法见 51 journalctl。
# 看 myapp 最近 50 行日志
$ journalctl -u myapp -n 50
# 实时跟踪日志,排错时开着
$ journalctl -u myapp -f
| 参数 | 作用 |
|---|---|
-u <服务> |
只看指定 unit 的日志 |
-f |
持续跟踪新输出(类似 tail -f) |
-n <行数> |
只显示末尾 N 行 |
-b |
只看本次开机以来的 |
--since "10 min ago" |
按时间过滤 |
# 跟踪日志,另开终端 restart 服务,能看到启动日志滚出
$ journalctl -u myapp -f
预期输出:
Sep 29 16:20:00 web01 systemd[1]: Started My Java App.
Sep 29 16:20:01 web01 java[5678]: Started MyJavaApp in 2.3 seconds
[!TIP] 服务起不来时,第一反应永远是
journalctl -u <服务> -n 50,日志里往往直接写着原因。
| 现象 | 根因 | 解决 |
|---|---|---|
-u 查不到任何日志 |
unit 名拼错或服务从未启动过 | systemctl list-units --all | grep <关键词> 确认名字 |
| 日志占满磁盘 | journald 默认上限为磁盘 10%(封顶 4G),小磁盘上仍可能偏大 | 在 journald.conf 显式收紧 SystemMaxUse=,详见 51 |
除了 status 和 journalctl,还有四件趁手兵器:找失败的服务、找拖慢开机的服务、看实际生效的配置、无侵入修改配置。
# 一、列出所有启动失败的服务
$ systemctl list-units --type=service --state=failed
# 二、看各服务拖慢开机多少,启动耗时排行
$ systemd-analyze blame
# 三、看某服务实际生效的 unit 内容(含覆盖片段的合并结果)
$ systemctl cat myapp
# 四、无侵入修改:生成 override 覆盖片段
$ sudo systemctl edit myapp
systemctl edit 打开编辑器,写入的键值保存为 /etc/systemd/system/myapp.service.d/override.conf,只覆盖你写的键,其余沿用原文件。
为什么推荐 systemctl edit 而不是直接改原文件:
| 方式 | 软件升级后 | 可追溯性 |
|---|---|---|
直接改 /lib/systemd/system/xxx.service |
改动被包升级覆盖丢失 | 无痕迹,难回滚 |
systemctl edit 生成 override |
原文件更新不受影响,override 依然生效 | override 独立成文件,删掉即回滚 |
apt 装的软件,原 unit 属于包管理器不该手改;override 把「你的定制」和「官方原件」分离,升级互不踩。
# 看 override 是否已被加载
$ systemctl cat myapp
预期输出:
# /etc/systemd/system/myapp.service
...
# /etc/systemd/system/myapp.service.d/override.conf
[Service]
RestartSec=10
末尾出现 override.conf 段,说明覆盖已加载。改 override 同样要 daemon-reload + restart。
| 现象 | 根因 | 解决 |
|---|---|---|
Start request repeated too quickly |
短时间反复崩溃触发频率限制(默认 10 秒内 5 次) | 先修崩溃根因;临时解锁 sudo systemctl reset-failed <服务>(只清计数与失败状态,不解决崩溃);确需放宽就在 [Unit] 段加 StartLimitIntervalSec= 与 StartLimitBurst= |
| unit 里读了环境变量但程序拿不到 | systemd 启动的进程不走 .bashrc/.profile,shell 里 export 的变量一概不继承 |
用 Environment= 或 EnvironmentFile= 显式提供,见下方 WARNING |
日志含 Failed at step EXEC 或 203 EXEC |
ExecStart 路径不存在,或文件没有执行权限 | ls -l 核对路径与执行位;脚本确认 shebang 存在 |
Failed with result 'exit-code',手动跑却正常 |
systemd 与终端的运行环境不同:用户、工作目录、环境变量都不同 | 逐项核对 User / WorkingDirectory / Environment |
[!WARNING] 经典坑:systemd 不读
.bashrc。 你在终端export DB_PASSWORD=xxx后「明明配好了」,服务里依然读不到 —— systemd 启动的进程和你的登录 shell 是两个世界。服务所需的环境变量,必须写进 unit 的Environment=或EnvironmentFile=。
.timer 是 systemd 提供的定时器,配合同名 service unit 实现「到点跑任务」,是 cron 的现代替代:日志进 journald、依赖管理统一、错过的任务可在开机后补跑(Persistent=true)。与 cron 的取舍详见 44 备份与定时任务。
最简示例:每天凌晨 3 点跑备份。写两个文件:mybackup.timer(什么时候)和 mybackup.service(干什么)。
# 新建定时器文件
$ sudo nano /etc/systemd/system/mybackup.timer
# 只 enable timer 即可,service 由 timer 拉起
$ sudo systemctl enable --now mybackup.timer
[Unit]
Description=Run mybackup daily
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
| 键 | 作用 |
|---|---|
OnCalendar= |
日历时间表达式,*-*-* 03:00:00 即每天 3 点 |
Persistent=true |
错过的执行(如关机期间)开机后补跑一次 |
| timer 与 service 同名前缀 | mybackup.timer 自动触发 mybackup.service |
# 查看所有定时器及下次触发时间
$ systemctl list-timers
预期输出:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-09-30 03:00:00 CST 10h Tue 2026-09-29 03:00:00 CST 19h ago mybackup.timer mybackup.service
NEXT 列与设置的触发点一致,即配置正确。
| 现象 | 根因 | 解决 |
|---|---|---|
| timer 激活了但任务没跑 | 对应 .service 没写或名字对不上 |
两文件同名前缀,systemctl list-timers --all 检查 |
| 改了 OnCalendar 不生效 | 忘了 daemon-reload | daemon-reload 后 restart timer |
| 想立即手动跑一次 | timer 只按表触发 | 直接 sudo systemctl start mybackup.service |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
systemctl 全部命令 |
完全相同 | systemd 命令两家一致,本章内容通用 |
/lib/systemd/system/ |
/usr/lib/systemd/system/ |
发行版 unit 目录略异;/etc/systemd/system/ 相同 |
apache2.service |
httpd.service |
少数软件服务名不同,以 systemctl list-unit-files 实查为准 |
| 命令 | 作用 |
|---|---|
systemctl start|stop|restart <服务> |
启动 / 停止 / 重启(restart 断连接) |
systemctl reload <服务> |
平滑重读配置,不断连接(需服务支持) |
systemctl status <服务> |
看状态、主进程与最近日志 |
systemctl is-active <服务> |
脚本里判断是否运行 |
systemctl is-enabled <服务> |
判断是否开机自启 |
systemctl enable --now <服务> |
开机自启 + 立即启动 |
systemctl disable <服务> |
取消开机自启 |
sudo systemctl daemon-reload |
改 unit 文件后必须执行 |
systemctl cat <服务> |
看实际生效的 unit 内容 |
sudo systemctl edit <服务> |
生成 override 覆盖片段 |
systemctl list-units --state=failed |
列出失败的服务 |
systemd-analyze blame |
开机各服务耗时排行 |
journalctl -u <服务> -f |
实时跟踪服务日志 |
journalctl -u <服务> -n 50 |
看服务最近 50 行日志 |
sudo systemctl reset-failed <服务> |
清除失败状态与频率限制 |
systemctl list-timers |
查看定时器下次触发时间 |
apt 装不到的版本、要自定义模块的软件,只剩源码编译一条路。编译本身只有三步,真正让人栽跟头的是它特有的三类坑:动态库找不到、卸不掉、编一半失败。本章一次讲清:什么时候才值得编译、标准流程,以及每类坑的成因与解法。
[!IMPORTANT] 安装方式优先级:apt 官方源 > 软件官方维护的第三方源 > 官方二进制包 > 源码编译。能用包管理就别编译 —— 包管理自带依赖解决、自动安全更新、干净卸载,这些好处编译全都要自己扛。
源码编译 = 拿到软件的 C/C++ 源代码,在你自己的机器上把它「翻译」成可执行文件。只有三种场景值得这么做:
其余情况永远优先 apt。版本策略详见 17 包管理。
动手编译前,先花一分钟确认真的需要:
# 查 apt 源里有没有这个软件、候选版本是多少
$ apt policy <软件名>
版本满足需求就 sudo apt install <软件名>,本章到此结束;不满足再往下走。
四种获取方式对比,理解优先级为什么这么排:
| 获取方式 | 版本新鲜度 | 维护成本 | 适用场景 |
|---|---|---|---|
| apt 官方源 | 落后但稳定 | 最低 | 默认选择 |
| 软件官方第三方源(如 nodesource、pgdg) | 新 | 低 | 官方提供时优先于编译 |
| 官方二进制包(现成的可执行文件) | 新 | 中 | 编译太慢、机器环境太素 |
| 源码编译 | 任意、可定制 | 最高 | 前三条都走不通 |
$ apt policy nginx
预期输出:
nginx:
Installed: (none)
Candidate: 1.18.0-6ubuntu14.5
Version table:
1.18.0-6ubuntu14.5 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages
Candidate 是 apt install 会装上的版本。若它低于你的要求且找不到官方第三方源,才进入编译路线。
| 现象 | 根因 | 解决 |
|---|---|---|
| 源里的版本落后一大截 | Ubuntu LTS 冻结大版本,只推安全补丁 | 先找软件官方第三方源,再考虑编译 |
| 装了新版,apt upgrade 又被降回去 | 源优先级冲突 | 见 17 包管理 的 apt pinning |
| 「教程都说要编译」 | 多为过时经验 | 先确认该教程年代,新系统通常有官方源了 |
编译需要两类材料:
build-essential,一键装齐;.h)和链接文件。Ubuntu 把它们打成 libssl-dev 这类 -dev 包 —— -dev 包 = 头文件 + 链接库,专供编译用;对应的运行时包只含程序跑起来必需的 .so 文件。这就解释了常见困惑:「我明明装了 libssl,怎么还编译失败?」—— 你缺的不是「能运行」的部分,而是「能编译」的部分。
# 安装编译工具链,gcc/g++/make 一次到位
$ sudo apt install build-essential
# 安装最常见的几个开发库和构建辅助工具
$ sudo apt install libssl-dev zlib1g-dev libffi-dev pkg-config
| 包 | 提供什么 | 哪些软件编译需要 |
|---|---|---|
build-essential |
gcc、g++、make、libc 头文件 | 几乎所有 C/C++ 软件 |
libssl-dev |
OpenSSL 头文件与库 | 一切涉及 https/TLS 的软件 |
zlib1g-dev |
zlib 压缩库 | nginx、Python 等 |
libffi-dev |
外部函数接口库 | Python(CPython 硬依赖) |
pkg-config |
查询已装库的编译参数 | configure 阶段定位依赖库 |
# 确认工具链就位,顺便记下 gcc 版本
$ gcc --version | head -1
$ make --version | head -1
预期输出:
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
GNU Make 4.3
两条都有版本号输出,工具链可用。截至 2026-09,Ubuntu 22.04 默认 gcc 为 11.4。
| 现象 | 根因 | 解决 |
|---|---|---|
C compiler cannot create executables |
gcc 缺失或工具链不完整 | sudo apt install --reinstall build-essential |
报找不到某个 .h 文件 |
缺对应的 -dev 包 |
apt search <关键词> 找到 dev 包安装 |
| 装了 -dev 还是找不到库 | pkg-config 缺失或缓存未更新 | 装 pkg-config 后重新 ./configure |
几乎所有 C/C++ 开源软件都遵循同一套流程:
./configure —— 探测你的系统(编译器在吗、依赖库齐吗),按你给的选项生成 Makefile(编译清单);make —— 按 Makefile 真正编译,产物生成在源码目录里,此时还没装进系统;sudo make install —— 把编译好的文件复制到安装位置。# 解压源码包并进入目录(以 foo 1.2.3 为例,最新版本以官方发布页为准)
$ tar xf foo-1.2.3.tar.gz
$ cd foo-1.2.3
# 第一步:配置,指定独立安装目录
$ ./configure --prefix=/opt/<软件名>
# 第二步:按 CPU 核数并行编译,nproc 输出核数
$ make -j$(nproc)
# 第三步:把编译产物复制到 --prefix 指定的目录
$ sudo make install
--prefix=/opt/<软件名> 为什么重要:所有文件(bin、lib、share)都收进这一个独立目录,不散落进 /usr;卸载等于删目录;多版本还能并存(/opt/foo-1.2 与 /opt/foo-1.3)。这是编译路线唯一的「后悔药」,不写它才是真正的坑。
altinstall 的意义:以 Python 为例,sudo make install 会安装 python3 并可能覆盖系统默认链接,而 apt 的一堆系统工具依赖默认 Python,一旦被顶掉,系统工具集体报错。sudo make altinstall 安装为 python3.12 这种带版本号的名字,不碰系统默认链接,是编译 Python 的唯一正确姿势。完整教训见 21 Python。
| 命令 / 参数 | 作用 |
|---|---|
./configure --prefix=<路径> |
探测环境并生成 Makefile,指定安装根目录 |
make -j$(nproc) |
并行编译,任务数 = CPU 核数,大幅提速 |
sudo make install |
复制产物到 prefix,写 /opt 需要 root |
sudo make altinstall |
安装但不注册为默认版本名(Python 必用) |
./configure --help |
查看该软件支持的全部编译选项 |
安装前先验证产物可用(详见下一节),安装后确认:
# 直接执行 prefix 下的二进制验证
$ /opt/<软件名>/bin/<软件名> --version
预期输出:
<软件名> version 1.2.3
能正确输出版本号,安装成功。
| 现象 | 根因 | 解决 |
|---|---|---|
make 报 missing separator |
Makefile 损坏,常见于解压方式不对或手改过文件 | 删目录重新解压官方包 |
| configure 通过但 make 失败 | 某依赖库版本不满足 | 找第一条 error,通常是缺 -dev 包 |
make -j 机器卡死 |
并行任务太多,内存耗尽 | 减小 -j 数值(如 -j2),见「编译失败排查」 |
make 成功 ≠ 能用。编译产物在装进系统之前,就能直接在源码目录里运行验证 —— 这一步能提前暴露「编出来了但跑不起来」的问题(典型:动态库缺失),避免把坏产物装进 /opt 之后才发现要重来。
产物位置因软件而异,常见在源码根目录或 src/ 子目录:
# 两种常见位置,试运行验证
$ ./src/<软件名> --version
$ ./<软件名> --version
# 若要推倒重来,先清理上次产物
$ make clean
| 操作 | 作用 |
|---|---|
make clean |
清掉上次编译产物,从干净状态重编 |
make distclean |
连 configure 生成的 Makefile 一并清掉,回到解压原始态 |
find . -maxdepth 2 -type f -perm -u+x -newer configure |
找出 configure 之后新生成的可执行文件,即产物 |
# 在源码目录里直接跑产物
$ ./src/<软件名> --version
预期输出:
<软件名> version 1.2.3
能输出版本号 = 二进制完整可执行;若报 error while loading shared libraries,先别装,直接跳到下一节解决动态库问题。
| 现象 | 根因 | 解决 |
|---|---|---|
./src/ 下没有产物 |
该软件产物放在别的子目录 | 用上表 find 命令定位 |
| 运行时报库缺失,但 make 没报错 | 编译期链接通过、运行期找不到库,两者机制不同 | 见下一节三种解法 |
--version 报非法选项 |
老软件用 -V 或 -v |
看 ./<软件名> --help |
Linux 程序运行时要动态加载共享库(.so 文件)。加载器按固定顺序找库:环境变量 LD_LIBRARY_PATH → /etc/ld.so.cache 缓存(由 ldconfig 生成)→ 默认目录 /lib、/usr/lib。
--prefix 装到 /opt/<软件名>/lib 的库不在默认搜索路径里,于是运行时报:
error while loading shared libraries: libxxx.so.1: cannot open shared object file: No such file or directory
库明明装了,只是加载器不知道它在哪 —— 这是自编译软件最常见的报错。
三种解法,按推荐顺序:
解法一(首选):系统缺的库,直接用 apt 装。 系统级依赖交给包管理:
# 按报错里的库名搜索提供它的包
$ apt search libxxx
# 安装运行时包(通常叫 libxxx1 这类带数字的名字)
$ sudo apt install libxxx1
解法二(自编译的库):注册进系统库缓存。
# 告诉加载器额外去这个目录找库,一行一个路径
$ echo "/opt/<软件名>/lib" | sudo tee /etc/ld.so.conf.d/<软件名>.conf
# 重建库缓存,立即生效
$ sudo ldconfig
解法三(临时救急):设 LD_LIBRARY_PATH。
# 临时为当前会话追加库搜索路径
$ export LD_LIBRARY_PATH=/opt/<软件名>/lib:$LD_LIBRARY_PATH
为什么不推荐解法三:它只对当前 shell 生效 —— systemd、supervisor、cron 或别的用户启动程序时没有这个变量,「换个方式启动就又跑不起来」;写进全局配置又会影响系统上所有程序,等于给每台进程埋雷。生产环境只认解法一、二。
| 命令 | 作用 |
|---|---|
ldd <可执行文件> |
列出它依赖的所有 .so 及实际解析路径,not found 就是问题所在 |
ldconfig -p | grep <关键词> |
查缓存里注册了哪些库 |
/etc/ld.so.conf.d/*.conf |
额外库目录配置,一行一个路径,供 ldconfig 使用 |
# 检查所有依赖是否都能解析,无输出即全部就位
$ ldd /opt/<软件名>/bin/<软件名> | grep "not found"
预期输出:
(无任何输出,说明没有缺失的库)
看完整依赖与缓存:
$ ldd /opt/<软件名>/bin/<软件名> | head -5
预期输出:
libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3 (0x00007f...)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f...)
$ ldconfig -p | grep libssl
预期输出:
libssl.so.3 (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl.so.3
| 现象 | 根因 | 解决 |
|---|---|---|
cannot open shared object file |
库没装,或装了但不在搜索路径 | 按上文解法一 → 二 → 三依次处理 |
| 库文件明明存在,ldd 仍报 not found | 目录没注册进缓存 | 写 ld.so.conf.d 配置后 sudo ldconfig |
| 装的是 libxxx.so,程序要 libxxx.so.1 | 无版本号的 .so 是 -dev 包给编译用的;运行时加载带版本号的 |
安装对应运行时包(如 libxxx1) |
apt remove 依赖安装记录,卸得干净;make install 不留任何记录 —— 它只是把文件复制过去,系统不知道装了什么、装到了哪。这就是编译安装「装时一时爽,卸时火葬场」:
make uninstall,靠的是安装时生成的清单文件,前提是源码目录还在且 configure 时的参数没变;—— 这正是上一节强调 --prefix 隔离的价值:整个软件收在一个目录里,卸载就是删目录,干净利落。
按以下顺序尝试:
# 方案一:回到源码目录执行卸载目标,仅当 Makefile 支持时有效
$ cd <源码目录>
$ sudo make uninstall
# 方案二:prefix 隔离的,直接删目录,卸载完成
$ sudo rm -rf /opt/<软件名>
[!WARNING]
rm -rf不可逆。执行前先ls /opt/<软件名>确认目录内容没有误判,并核对完整路径后再回车 —— 手滑少一个字符可能删掉完全不同的目录。
| 方案 | 前提 | 干净程度 |
|---|---|---|
make uninstall |
源码目录还在,且 Makefile 支持 uninstall 目标 | 高 |
| 删除 prefix 目录 | 当初 configure 用了 --prefix |
高 |
| 手动 find 全盘找文件删 | 无 | 低,易留残渣、易误删 |
冷门但实用的技巧:源码目录已删、又想干净卸载时,重新解压同版本源码,用与当初相同的 --prefix 执行 configure + make install,系统就重新有了安装清单,再 make uninstall 即可。
# 卸载后确认系统里已无此软件
$ which <软件名>
$ ls /opt/<软件名>
预期输出:
which: no <软件名> in (/usr/local/sbin:...)
ls: cannot access '/opt/<软件名>': No such file or directory
两条都「找不到」,卸载完成。
| 现象 | 根因 | 解决 |
|---|---|---|
make uninstall 报 No rule to make target |
该软件 Makefile 不支持卸载 | 删 prefix 目录 |
| 源码目录早删了,uninstall 没法跑 | 卸载依赖安装清单 | 重新解压同版本源码,相同 prefix 装一遍再 uninstall |
| 卸载后别的程序坏了 | 那个程序依赖你卸掉的库 | ldd 排查依赖方,重装该库或改用 apt 版 |
编译失败千奇百怪,但 90% 落进三类:缺头文件、编译器太老、内存不足。认得这三类的报错长相,大部分失败能自己定位,不必逐条搜索。
套路一:缺头文件。 报错特征是 fatal error: xxx.h: No such file or directory:
# 按头文件所属的库名搜索并安装对应 dev 包
$ apt search libssl | grep dev
$ sudo apt install libssl-dev
套路二:GCC 太老。 软件要求 GCC 12+,而 Ubuntu 22.04 默认 11:
# 官方源里装新版编译器
$ sudo apt install gcc-12 g++-12
# configure 时临时指定编译器
$ CC=gcc-12 CXX=g++-12 ./configure --prefix=/opt/<软件名>
套路三:内存不足(OOM,out of memory)。 编译器进程被系统杀掉,日志里常见 Killed 或 internal compiler error。内存不足先降并行度,还不行就临时加 swap:
# 4GB 以下小内存机器,把并行度降到 2
$ make -j2
# 仍失败则临时加 2G swap,编译完即删
$ sudo fallocate -l 2G /swapfile
$ sudo chmod 600 /swapfile
$ sudo mkswap /swapfile
$ sudo swapon /swapfile
# 编译结束后回收临时 swap
$ sudo swapoff /swapfile
$ sudo rm /swapfile
| 报错信号 | 判断 | 处理 |
|---|---|---|
fatal error: xxx.h |
缺头文件 | 装对应 -dev 包 |
大量 C++ 模板报错、does not name a type |
GCC 版本过老 | 装新版 GCC,CC= 指定 |
Killed / virtual memory exhausted |
内存不足 | 降 -j,或临时加 swap |
C compiler cannot create executables |
工具链缺失或损坏 | 重装 build-essential 与 libc6-dev |
重编时把完整输出存档,失败时只看第一条 error —— 后面的报错多为连锁反应:
# 编译输出同时写入日志文件,便于回查第一条 error
$ make -j$(nproc) 2>&1 | tee /tmp/build.log
预期输出:
(编译过程滚动输出,末尾无 Error 1 字样并回到提示符即成功)
| 现象 | 根因 | 解决 |
|---|---|---|
C compiler cannot create executables |
gcc 缺失/损坏,或缺 libc 开发文件 | sudo apt install --reinstall build-essential libc6-dev |
Makefile:8: *** missing separator. Stop. |
Makefile 被破坏,或复制粘贴时制表符变空格 | 重新解压源码包,不要手改 Makefile |
装完执行 command not found |
prefix 的 bin 目录不在 PATH | export PATH=/opt/<软件名>/bin:$PATH 并写入 ~/.profile。注意两者加载时机不同(00 章有对照表):登录 shell 读 ~/.profile,非登录交互 shell 只读 ~/.bashrc;登录与非登录都覆盖的最稳做法是两处都写,或只写 ~/.bashrc(Ubuntu 22.04 默认的 ~/.profile 会 source 它) |
运行时 cannot open shared object file |
动态库路径问题 | 用上文三种解法 |
| Ubuntu (apt) | RHEL (dnf) | 说明 |
|---|---|---|
sudo apt install build-essential |
sudo dnf group install "Development Tools" |
编译工具链一组装齐 |
libssl-dev |
openssl-devel |
Ubuntu 叫 -dev,RHEL 叫 -devel |
zlib1g-dev |
zlib-devel |
同上 |
libffi-dev |
libffi-devel |
同上 |
apt search libxxx |
dnf provides "*/libxxx*" |
按文件/库名反查所属包 |
| 命令 | 作用 |
|---|---|
sudo apt install build-essential |
安装 gcc/g++/make 工具链 |
sudo apt install libssl-dev zlib1g-dev libffi-dev pkg-config |
安装常用开发库 |
apt policy <软件名> |
查源里的候选版本,判断是否需要编译 |
./configure --prefix=/opt/<软件名> |
配置并指定独立安装目录 |
./configure --help |
查看该软件全部编译选项 |
make -j$(nproc) |
按 CPU 核数并行编译 |
sudo make install |
安装到 prefix 目录 |
sudo make altinstall |
安装但不覆盖系统默认版本名(Python 必用) |
./src/<软件名> --version |
安装前先在源码目录验证产物 |
make clean / make distclean |
清理编译产物 / 连 Makefile 一起清 |
ldd <二进制> |
查看动态库依赖与缺失 |
ldconfig -p | grep <库> |
查库缓存 |
echo <路径> | sudo tee /etc/ld.so.conf.d/<软件名>.conf |
注册自编译库目录 |
sudo ldconfig |
重建库缓存 |
sudo make uninstall |
源码卸载(仅部分软件支持) |
CC=gcc-12 ./configure ... |
指定新版编译器 |
make -j2 |
低内存机器降低并行度 |
应用跑起来只是第一步,让它「一直活着」才是生产环境的要求。本章回答三个问题:进程为什么会丢、三种主流守护方案怎么选、以及 supervisor 和 PM2 的落地配置。systemd 的完整教程在上一章 40 systemd,本章不重复。
不配守护的进程,常见三种「死亡方式」:
nohup <命令> & 只解决场景 1 —— 它让进程「不随会话退出」,但程序崩了没人拉起、服务器重启后一切归零。nohup 让你「不立刻死」,守护方案让你「死了能复活、重启能回来」,这是两个层次的需求。
先认清目标进程的「裸奔」现状:
# 看进程的父进程是谁,PPID 是判断谁在管它的关键
$ ps -o pid,ppid,cmd -p <PID>
PPID 是 1(由 systemd 或 init 直接收养)且没有对应的守护配置,它挂了就没人管。
| 需求层级 | 手段 | 覆盖场景 |
|---|---|---|
| 不随会话退出 | nohup / & |
只防 SSH 退出 |
| 崩溃自动拉起 | 守护方案的 autorestart / Restart | 防崩溃 |
| 开机自动恢复 | 守护方案的 enable / autostart | 防重启丢失 |
| 日志收集、状态查看、批量管理 | 守护方案自带 | 生产可运维 |
亲手体会 nohup 的能力边界:
# 用 nohup 放一个测试进程到后台
$ nohup sleep 3600 &
预期输出:
[1] 12345
nohup: ignoring input and appending output to 'nohup.out'
退出 SSH 重连后 ps -p 12345 进程还在 —— 场景 1 被覆盖;然后:
# 手动杀掉它,观察有没有任何东西把它拉起来
$ kill -9 12345
$ ps -p 12345
预期输出:
PID TTY TIME CMD
[进程消失,无输出]
进程死了就死了,没有任何机制拉起 —— 场景 2、3 依然裸奔,这就是必须上守护方案的原因。
| 现象 | 根因 | 解决 |
|---|---|---|
| nohup 的进程重连后不见了 | 不是被杀,是程序自己崩了 | 查应用日志,交给守护方案托管 |
nohup.out 疯狂增长 |
程序 stdout 全写进了它 | 换守护方案,由它接管日志(记得配轮转) |
& 放后台,关终端还是被杀 |
shell 退出时向任务发 SIGHUP | 用 nohup,或直接上守护方案 |
三款主流守护方案,定位各不相同:
| 维度 | systemd | supervisor | PM2 |
|---|---|---|---|
| 维护方 | 系统内置,无额外依赖 | 第三方软件(Python 编写,apt 安装) | Node.js 生态,npm 安装 |
| 配置方式 | unit 文件(ini 风格) | conf.d/ 下的 ini 片段 |
命令行为主,可选 JS/JSON 配置文件 |
| 日志 | 自动进 journald,journalctl -u 查 |
写文件,需自配 logrotate | 写文件,pm2 logs 查,需自配轮转 |
| 开机自启 | enable 原生支持 |
supervisor 本身由 systemd 拉起 | pm2 startup 生成 systemd unit |
| 适用语言 | 任意可执行程序 | 任意可执行程序 | Node.js 为主(也能守护任意程序) |
| 学习成本 | 中(需理解 unit 语法) | 低,配置直观 | 低,Node 开发者零门槛 |
| 管理界面 | 无(有第三方 Webmin 等) | 内置 Web 界面(需手动开启) | PM2 Plus(云端,商业化) |
| 生产推荐度 | 系统级服务首选 | 多语言团队常选 | Node.js 项目标准方案 |
按场景决策:
pm2 reload 零停机重启,与 22 Node.js 的生态无缝衔接。[!WARNING] 同一台机器上,一个进程只允许一种守护方案管理。 若 systemd 的 unit 和 supervisor 的 program 配了同一个进程,两边都带自动拉起,就会出现「A 拉起 B 杀的、B 拉起 A 杀的」循环互殴:进程反复重启、日志交错混乱、端口冲突,极难排查。接管前先确认旧方案已
stop+disable。
不确定时按这张表对号入座:
| 你的情况 | 推荐 | 理由 |
|---|---|---|
| 单机少量服务,运维就是你自己 | systemd | 不装新东西,教程与生态最全 |
| 多语言应用统一纳管、要 Web 界面 | supervisor | 配置格式统一,supervisorctl 心智负担低 |
| Node.js 项目,要多核与零停机发布 | PM2 | cluster 与 reload 是独有能力 |
接手一台机器时,先查清现有的守护格局,避免混用:
# 是否已有 supervisor 在运行
$ systemctl is-active supervisor
预期输出:
inactive
# 是否已有 PM2 管理的进程(以部署用户身份执行)
$ pm2 list
预期输出:
[PM2] Spawning PM2 daemon
[PM2] PM2 Successfully daemonized
┌────┬──────────┬─────────┬─────────┬───────────┬──────────┐
│ id │ name │ status │ restart │ uptime │ memory │
└────┴──────────┴─────────┴─────────┴───────────┴──────────┘
空列表或 inactive,说明该方案尚未启用,新服务可自由选型;若已有进程在册,新增服务沿用同一方案。
| 现象 | 根因 | 解决 |
|---|---|---|
| 进程被反复重启,日志时间戳交错 | 两套守护同时管它 | 只保留一套,另一套停用并删配置 |
| 分不清进程归谁管 | 多方案并存 | ps -o ppid= -p <PID> 看父进程:1 通常是 systemd,supervisord 的子进程 PPID 是 supervisord 的 PID |
| 团队成员各用各的方案 | 无团队约定 | 部署文档写明「本机唯一守护栈」,新增服务先查现状 |
supervisor 由 Python 编写:一个 supervisord 守护进程统一管理 N 个子进程,supervisorctl 客户端负责指挥。配置直观、上手快。注意它的定位:supervisor 自己也是一个被 systemd 管理的普通服务(下一节验证),它是「包工头」,systemd 才是「老板」。
# 安装 supervisor,截至 2026-09,Ubuntu 22.04 源内为 4.x 系列
$ sudo apt install supervisor
# 为应用新建配置文件,conf.d 下所有 .conf 都会被加载
$ sudo nano /etc/supervisor/conf.d/myapp.conf
[program:myapp]
; 启动命令,可执行文件必须写绝对路径
command=/usr/bin/java -jar /opt/myapp/app.jar
; 工作目录,应用读相对路径的基准
directory=/opt/myapp
; 以哪个用户运行,不要用 root
user=appuser
; supervisord 启动时自动拉起该进程(开机自启的载体)
autostart=true
; 进程退出后自动重启
autorestart=true
; 启动后须存活满 1 秒才算启动成功(默认值,这里显式写出);存活不足 1 秒即退出视为启动失败
startsecs=1
; 启动失败最多重试 3 次,超次标记 FATAL
startretries=3
; stdout 日志文件,注意会无限增长,必须配 logrotate
stdout_logfile=/var/log/myapp/out.log
; stderr 日志文件
stderr_logfile=/var/log/myapp/err.log
; 环境变量,多个用逗号分隔
environment=JAVA_OPTS="-Xms512m -Xmx1024m",APP_ENV="prod"
# 让 supervisor 扫描配置变化并应用(两条都要执行)
$ sudo supervisorctl reread
$ sudo supervisorctl update
[!WARNING] supervisor 内置 Web 管理界面,在
/etc/supervisor/supervisord.conf中配置[inet_http_server]段即可开启。绝不要对公网开放 —— 它能启停任意被管进程,等于把服务器控制权暴露出去;只绑127.0.0.1或内网地址,并配好账号密码。
| 键 | 作用 | 注意事项 |
|---|---|---|
command |
启动命令 | 不要带 nohup、不要加 &,supervisor 自己管前台进程 |
directory |
工作目录 | 应用读相对路径全靠它 |
user |
运行身份 | 日志目录、应用目录都要给该用户权限 |
autostart |
supervisord 启动时拉起 | 开机自启依赖它(supervisor 由 systemd 拉起) |
autorestart |
挂了自动拉起 | 可设 unexpected(仅异常退出才拉) |
startretries |
连续失败几次后标记 FATAL | FATAL 后不再重试,需人工介入 |
stdout/stderr_logfile |
日志路径 | 须配 logrotate 轮转,见本章末节 |
# 查看所有被管进程状态
$ sudo supervisorctl status
预期输出:
myapp RUNNING pid 12345, uptime 0:00:30
RUNNING 且 uptime 持续增长即正常;显示 FATAL 说明反复启动失败,按下节方法排错。
| 现象 | 根因 | 解决 |
|---|---|---|
FATAL (exit status 1) |
command 本身跑不起来 | 以相同用户在相同目录手动执行 command,报错一目了然 |
| 配置改了不生效 | 忘了 reread + update | 两条命令缺一不可 |
| 日志写不进去 | user 指定的用户对日志目录无写权限 | sudo mkdir -p /var/log/myapp && sudo chown appuser /var/log/myapp |
所有日常操作通过 supervisorctl 完成,它连接本机的 supervisord 执行指令 —— 类比:supervisorctl 之于 supervisord,就像 systemctl 之于 systemd。reread/update 的关系也和 daemon-reload 类似:一个重读配置,一个应用变化。
# 查看所有被管进程状态
$ sudo supervisorctl status
# 启动 / 停止 / 重启指定程序
$ sudo supervisorctl start myapp
$ sudo supervisorctl stop myapp
$ sudo supervisorctl restart myapp
# 实时跟踪程序 stdout,相当于 tail -f
$ sudo supervisorctl tail -f myapp
# 改过配置文件后,重读并应用
$ sudo supervisorctl reread
$ sudo supervisorctl update
reread 只扫描磁盘配置找出差异(不动作);update 真正应用差异(新增/变更的程序会被启动或重启)。改配置后两条必须都执行。
| 命令 | 作用 |
|---|---|
status |
列出全部程序及状态(RUNNING / FATAL / STOPPED) |
start / stop / restart <程序名> |
生命周期操作 |
tail -f <程序名> |
跟踪 stdout 日志 |
reread |
扫描配置变化,只读不动作 |
update |
应用配置变化,会重启受影响程序 |
reload |
重启 supervisord 自身(会短暂中断全部被管进程,慎用) |
先确认 supervisor 自身归 systemd 管、处于运行中:
$ systemctl status supervisor
预期输出:
● supervisor.service - Supervisor process control system for UNIX
Loaded: loaded (/lib/systemd/system/supervisor.service; enabled; ...)
Active: active (running) since Tue 2026-09-29 14:00:00 CST; 3h ago
Main PID: 890 (supervisord)
再实测守护能力 —— 手动杀进程,看它几秒内被拉起:
# 杀掉 myapp 的进程
$ pkill -f app.jar
# 稍等几秒再看状态
$ sudo supervisorctl status
预期输出:
myapp RUNNING pid 12346, uptime 0:00:05
pid 从 12345 变成 12346、uptime 归零重新计时 —— 自动拉起生效,守护闭环。
| 现象 | 根因 | 解决 |
|---|---|---|
error: <class 'PermissionError'>, .../supervisor.sock |
没加 sudo,连不上管理 socket | 命令前加 sudo |
update 后程序毫无变化 |
reread 判定配置无差异,改错了文件 | 确认改的是 conf.d/ 下被加载的 .conf |
| stop 了进程又自己起来 | 它同时被 systemd、cron 或别的工具拉起 | 按本章开头 WARNING 排查并移除重复守护 |
PM2 是 Node.js 世界的进程管理器,npm i -g pm2 即装即用。对 Node 应用有独门能力:cluster 模式自动按 CPU 核数起多个进程并做负载均衡,pm2 reload 零停机滚动重启。安装与基础用法见 22 Node.js,本节只补守护链路的关键一环:开机自启的原理与操作。
# 生成开机自启配置,会输出一条需要你执行的 sudo 命令
$ pm2 startup
# 按提示原样执行输出的那行 sudo env PATH=... pm2 startup ... 命令
# 把当前进程列表存盘,作为开机恢复的依据
$ pm2 save
原理:pm2 startup 生成一个 systemd unit(命名如 pm2-<用户名>.service),开机时由 systemd 拉起 pm2 守护进程;pm2 save 把当前进程快照写入 ~/.pm2/dump.pm2,pm2 启动时按快照恢复全部应用。所以本质仍是 systemd 负责开机链路,PM2 是第二层管家 —— 这也再次说明:任何守护方案最终都落在 systemd 之上。
| 命令 | 作用 |
|---|---|
pm2 startup |
生成开机自启 systemd unit,输出一条待执行的 sudo 命令 |
pm2 save |
快照当前进程列表,供开机恢复 |
pm2 resurrect |
手动按快照恢复进程 |
pm2 list |
查看全部被管进程 |
pm2 logs <应用名> |
查看应用日志 |
pm2 restart <应用名> |
硬重启,有瞬断 |
pm2 reload <应用名> |
零停机滚动重启(cluster 模式下) |
# 确认开机自启 unit 已生成并启用
$ systemctl list-unit-files | grep pm2
预期输出:
pm2-root.service enabled enabled
服务名中的 root 换成你执行 pm2 startup 的用户名,enabled 即开机生效。
# 确认快照已保存
$ pm2 save
预期输出:
[PM2] Saving current process list...
[PM2] Successfully saved in /root/.pm2/dump.pm2
| 现象 | 根因 | 解决 |
|---|---|---|
| 重启服务器后应用没回来 | 配完进程忘了 pm2 save,快照是空的或旧的 |
每次增删改进程后都执行 pm2 save |
pm2 startup 后开机仍不自启 |
输出的 sudo 命令只是模板,没有实际执行 | 把提示的那行命令原样复制执行 |
pm2 logs 看不到应用输出 |
应用自己把日志写进了别的文件 | 检查应用日志配置,或统一交给 PM2 接管 stdout |
supervisor 的 stdout_logfile 和 PM2 的日志文件都会无限增长 —— 一个高流量应用几周就能写满几十 GB,最终撑爆磁盘拖垮整机。轮转(logrotate)按天/按大小把旧日志改名、压缩、超期删除,是生产必配项。logrotate 完整教程见 54 logrotate。
为 supervisor 应用的日志建一条轮转规则:
# 新建轮转规则文件,文件名随意,放对目录即生效
$ sudo nano /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 7
compress
missingok
notifempty
copytruncate
}
其中 copytruncate 对 supervisor 场景尤其关键:logrotate 的常规做法是「改名旧文件、让进程重开新文件」,但 supervisor 不会重开日志句柄,会继续往改名后的旧文件里写;copytruncate 改为「复制旧内容后把原文件清空」,进程的句柄始终有效,不丢日志。
PM2 一句带过:pm2 flush 手动清空全部日志;自动轮转安装模块 pm2 install pm2-logrotate 即可,无需写规则文件。
| 指令 | 作用 |
|---|---|
daily |
每天轮转一次 |
rotate 7 |
保留 7 份,更旧的自动删除 |
compress |
轮转出的旧日志 gzip 压缩 |
copytruncate |
复制后清空原文件,适配不重开日志句柄的进程 |
missingok |
日志文件不存在时不报错 |
notifempty |
空文件不轮转 |
# 强制立即轮转一次,验证规则
$ sudo logrotate -f /etc/logrotate.d/myapp
# 查看轮转结果
$ ls -lh /var/log/myapp/
预期输出:
-rw-r--r-- 1 appuser appuser 0 Sep 29 17:00 out.log
-rw-r--r-- 1 appuser appuser 120K Sep 28 23:59 out.log.1.gz
原文件被清空(大小 0)、旧内容进了 .1.gz 压缩包 —— 轮转生效,且进程日志未中断。
| 现象 | 根因 | 解决 |
|---|---|---|
| 轮转后日志仍写进旧文件 | 进程不重开日志句柄,规则没用 copytruncate | 规则里加 copytruncate |
| 磁盘还是被日志吃满 | 单日日志量太大,按天轮转来不及 | 加 maxsize 100M,与 daily 并用即可「时间或大小先到先切」。注意前提:logrotate 默认每天只运行一次,要让 maxsize 在当天内生效,需把规则挂到 cron.hourly 或配置更高频的 timer(与 54 logrotate 口径一致) |
| PM2 日志巨大无从下手 | 未配置轮转 | pm2 install pm2-logrotate,或 pm2 flush 应急清空 |
| 命令 | 作用 |
|---|---|
sudo apt install supervisor |
安装 supervisor |
sudo supervisorctl status |
查看被管进程状态 |
sudo supervisorctl start|stop|restart <程序> |
程序生命周期操作 |
sudo supervisorctl tail -f <程序> |
实时跟踪程序输出 |
sudo supervisorctl reread |
扫描配置变化(只读) |
sudo supervisorctl update |
应用配置变化(改配置后必做) |
systemctl status supervisor |
确认 supervisord 本身在运行 |
pm2 startup |
生成开机自启 systemd unit |
pm2 save |
快照进程列表供开机恢复 |
pm2 list |
查看 PM2 全部进程 |
pm2 logs <应用名> |
查看 PM2 应用日志 |
pm2 reload <应用名> |
零停机重启(cluster 模式) |
pm2 flush |
清空 PM2 日志 |
pm2 install pm2-logrotate |
安装 PM2 日志轮转模块 |
sudo logrotate -f /etc/logrotate.d/<应用名> |
手动触发一次日志轮转 |
ps -o ppid= -p <PID> |
判断进程归哪个守护方案管 |
前面章节讲了怎么「装」和怎么「跑」,这一章讲怎么「查」。服务器出问题时,你需要在几分钟内回答四个问题:CPU 谁在吃?内存谁在占?磁盘谁在写?端口谁在用? 本章的工具箱就是为这四个问题准备的,每个工具都围绕「什么时候用、怎么用、看到什么算正常」来讲。
[!IMPORTANT] 本章工具版本均以 Ubuntu 22.04 官方源为准,截至 2026-09。所有工具都可以
sudo apt install直接安装,不需要添加第三方源。
top 是 Linux 自带的资源监控,但界面原始、操作别扭。htop 是它的现代化替代:彩色显示、鼠标可点、按键排序、直接杀进程。btop 界面更华丽,还带网络和磁盘图表,二者选一个用顺手即可,新手推荐先学 htop。
为什么运维离不开它?因为「服务器卡了」的第一反应永远是打开 htop,30 秒内就能看出是 CPU、内存还是负载的问题,再决定下一步用什么工具深挖。
# 安装 htop(需要管理员权限)
$ sudo apt install htop
# 启动,直接全屏交互界面
$ htop
常用交互键(在 htop 界面内直接按,不用回车):
| 按键 | 作用 |
|---|---|
F6 或 > |
选择排序列(默认按 CPU) |
M |
立刻按内存排序 |
P |
立刻按 CPU 排序 |
F5 |
树形视图,看清谁是谁的子进程 |
/ |
搜索进程名 |
F9 |
杀进程(先选中,再选信号,一般选 15 SIGTERM) |
q |
退出 |
# 查看版本号,确认安装成功(交互界面不适合脚本,--version 适合)
$ htop --version
预期输出:
htop 3.0.5
[!TIP] 内存条区域的颜色有讲究:绿色是正常使用,蓝色是缓冲,黄色是缓存,红色越来越满才需要担心。只看百分比会误判,Linux 会把空闲内存拿去做缓存,「看起来满了」不等于「内存不足」。
CPU 和内存忙不忙看 htop 就行,但「磁盘在狂转、IO 等待飙高,却不知道是哪个进程干的」就得用 iotop。它把每个进程的实时读写速度列出来,和 htop 的关系是:htop 看 CPU/内存,iotop 看磁盘。
# 安装
$ sudo apt install iotop
# -o 只显示正在产生 IO 的进程,不刷一屏 0
# -P 按进程统计(默认按线程)
# -a 显示累计读写量,而不是瞬时速度
$ sudo iotop -oPa
| 参数 | 含义 |
|---|---|
-o |
只显示有 IO 的行,界面干净 |
-P |
按进程聚合(不加则按线程,行数爆炸) |
-a |
累计量模式,便于发现「总量惊人」的进程 |
-d 2 |
刷新间隔 2 秒 |
# 人为制造一点磁盘 IO,再开 iotop -o 就能看到 dd 这个进程
$ dd if=/dev/zero of=/tmp/testfile bs=1M count=500
预期输出(iotop -oPa 中出现类似行):
2234 be/4 root 0.00 B/s 0.00 B/s 0.00 % 0.36 % dd if=/dev/zero of=/tmp/testfile bs=1M count=500
磁盘满了,第一反应是 du -sh /* 一层层猜。ncdu 把这个过程变成交互式界面:自动扫描、按大小排序、目录可进入可展开,还能直接删除。磁盘排错的效率完全不是一个量级,详见 55 磁盘满排查。
# 安装
$ sudo apt install ncdu
# 从根目录扫描(全盘扫描需要几分钟,也可以只扫某个可疑目录)
$ sudo ncdu /
| 按键 | 作用 |
|---|---|
| 上下方向键 / 回车 | 进入/退出目录 |
d |
删除选中的文件或目录 |
n |
按名字排序(再按切回大小排序) |
g |
切换百分比/图形条显示 |
q |
退出 |
[!WARNING]
ncdu里的d键是直接删除,不进回收站,不可恢复。在扫描结果里手滑按到d,选中的目录就没了。生产机上建议只看不动手,确认清楚再删;或者改用「先扫描、记录、再单独 rm」的流程。
# 只扫描 /var,通常日志堆在这里,扫描范围小、速度快
$ sudo ncdu /var
预期输出:
ncdu 1.15.1 ~ Use the arrow keys to navigate, press ? for help
--- /var -----------------------------------------------------------------------
912.0 MiB [##########] /lib
483.7 MiB [##### ] /log
...
Total disk usage: 2.1 GiB Apparent size: 2.1 GiB Items: 23456
lsof(list open files)回答一个其他工具回答不了的问题:「这个文件/端口/目录,现在被哪个进程占着?」Linux 一切皆文件,所以端口、日志、甚至已删除的文件都能查。它是本章性价比最高的工具 —— 一条命令就能定位「Address already in use」「磁盘满但找不到大文件」这类经典问题。
# 谁占用了 8080 端口(最常用的场景:启动服务报端口被占)
$ sudo lsof -i:8080
# 某用户打开的所有文件
$ lsof -u www-data
# 哪个进程打开了这个日志文件(判断日志是否还在被写入)
$ sudo lsof /var/log/syslog
# 某个目录下被打开的所有文件(慢,只对小目录用)
$ sudo lsof +D /var/www
| 参数 | 含义 |
|---|---|
-i:端口 |
查占用该端口的进程,服务部署排错首选 |
-i tcp |
查所有 TCP 连接 |
-u 用户 |
按用户过滤 |
+D 目录 |
递归查目录,量大时慢 |
| ` | grep deleted` |
这是磁盘排查的经典悬案:df -h 显示磁盘满了,du -sh /* 加起来却小很多。根因通常是——某个大日志文件被删了,但写它的进程还没重启,文件句柄没释放,磁盘空间不归还。用下面的命令一锤定音:
# 找出所有「已删除但仍被进程打开」的文件
$ sudo lsof | grep deleted
预期输出:
java 1234 appuser 12w REG 8,1 21474836480 5242890 /var/log/app/app.log (deleted)
看到的 21474836480 就是 20 GB 空间悬空的元凶。解决办法是重启对应进程(这里是 systemctl restart 应用服务),空间立刻归还。
[!NOTE] 这个场景的完整排查流程(du 和 df 对不上的处理)在 55 磁盘满排查 有详细展开。
# 确认 8080 被占用时,能输出进程名和 PID 就说明命令生效
$ sudo lsof -i:8080
预期输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 1234 appuser 45u IPv6 45210 0t0 TCP *:8080 (LISTEN)
程序「卡住了」「启动就退出」「日志什么都不打」,常规手段无能为力时,strace 是终极手段。它能看到进程正在进行的每一次系统调用——读写文件、网络连接、锁等待,等于给进程装了行车记录仪,一句话定位「程序到底卡在哪」。
# 附着到一个正在卡住的进程,实时看它在干什么(Ctrl+C 停止观察)
$ sudo strace -p 1234
# 跟踪一个新命令,只关注打开文件和网络连接两类调用
$ strace -f -e trace=openat,connect ls /tmp
# 统计模式:跑完后给出每类系统调用的耗时排名
$ strace -c curl -s http://example.com > /dev/null
| 参数 | 含义 |
|---|---|
-p PID |
附着到已运行的进程 |
-f |
跟踪子进程(服务类程序必加) |
-e trace=xxx |
只看指定调用,减少噪音 |
-c |
统计汇总,不逐条输出 |
-T |
显示每次调用耗时,超过 1 秒的行就是卡点 |
看输出的经验法则:
connect(...) 不返回 → 网络问题(对端不通或防火墙丢包);futex(...) → 锁等待(程序内部死锁或资源竞争);read(...) 且 fd 指向 socket → 在等对端响应,问题多半在对方;EAGAIN/EACCES → 权限或资源限制问题。# 用一个必失败的连接验证 strace 能否抓住网络错误
$ strace -e trace=connect nc -zv 192.0.2.1 9999
预期输出(能看到 connect 返回错误):
connect(3, {sa_family=AF_INET, sin_port=htons(9999), sin_addr=inet_addr("192.0.2.1")}, 16) = -1 ETIMEDOUT (Connection timed out)
[!WARNING] strace 会显著拖慢被跟踪进程的速度(几十倍),只用于排错,绝不要长期附着在生产进程上。排错完记得 Ctrl+C 分离。
「端口是通的,但返回的内容不对」——ping 通、nc 通、应用层却异常,这时候要看网络里实际传输的字节。tcpdump 就是命令行抓包工具,把网卡上的数据包原样抓给你看。更复杂的图形化分析,可以先用 -w 存成 pcap 文件,再用 Wireshark 打开。
# -i any 监听所有网卡
# -nn 不解析主机名和端口名,输出快且不受 DNS 影响
# -c 100 抓满 100 个包自动退出
$ sudo tcpdump -i any port 8080 -nn -c 100
| 参数 | 含义 |
|---|---|
-i any |
监听所有网卡(不确定流量走哪块网卡时用它) |
-nn |
禁止 DNS/服务名反解,IP 和端口原样显示 |
-c N |
抓 N 个包后停止,避免刷屏 |
-w file.pcap |
写入文件供 Wireshark 分析 |
port 8080 |
抓包过滤条件,类似语法还有 host x.x.x.x、src/dst |
典型场景:服务监听正常(ss -lntp 能看到),客户端却收到奇怪响应,抓包看是「三次握手成功后,应用返回了 RST/错误内容」,就说明问题不在网络而在应用本身。
# 一次终端抓包,另一个终端 curl localhost:8080,能看到包就正常
$ sudo tcpdump -i any port 8080 -nn -c 4
预期输出:
tcpdump: data link type LINUX_SLL2
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
12:00:01.123456 lo In IP 127.0.0.1.52844 > 127.0.0.1.8080: Flags [S], seq 123456, win 65495, length 0
12:00:01.123478 lo Out IP 127.0.0.1.8080 > 127.0.0.1.52844: Flags [S.], seq 654321, ack 123457, win 65483, length 0
4 packets captured
[!TIP] 抓包文件会包含明文数据,可能含密码和 token,不要随手把 pcap 上传到公开平台。
18 SSH 加固 已经讲了 fail2ban 的安装与配置,本章只讲装好之后的日常:谁被 ban 了?误封怎么解?封禁动作去哪查?这三件事是运维阶段的高频操作。
# 看整体状态:启用了哪些 jail
$ sudo fail2ban-client status
# 看 sshd 这个 jail 的封禁情况
$ sudo fail2ban-client status sshd
# 误封了自己/同事的 IP,手动解封
$ sudo fail2ban-client set sshd unbanip 203.0.113.50
status sshd 输出里的 Banned IP list 就是当前封禁名单;所有历史封禁动作(时间、IP、规则)都记录在 /var/log/fail2ban.log,事后回溯「服务为什么连不上」先翻它。
$ sudo fail2ban-client status sshd
预期输出:
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 148
| |- File list: /var/log/auth.log
`- Actions
|- Currently banned: 3
|- Total banned: 3
`- Banned IP list: 198.51.100.7 203.0.113.99 192.0.2.140
SSH 断线(网络抖动、笔记本休眠、VPN 掉线)时,终端里正在跑的任务会跟着收到 SIGHUP 被杀掉。14 进程管理 讲过 nohup 可以让任务「死不了」,但它有个短板:你回不到那个任务跟前看进度了。
tmux 解决的是「既要活下来,又要回得去」:它把任务跑在一个独立会话里,SSH 断了会话照常运行,重连后 attach 回去,现场原样保留——输出还在滚动,交互程序还在等你输入。这就是「长任务必须放 tmux」的原因。
# 安装
$ sudo apt install tmux
# 新建一个名为 work 的会话
$ tmux new -s work
# 在会话里跑一个长任务(示例),然后按 Ctrl+b 再按 d 分离(任务继续跑)
$ sleep 600
# 列出所有会话
$ tmux ls
# 重新接入 work 会话
$ tmux attach -t work
常用按键(都是先按 Ctrl+b 松开,再按下一个键):
| 按键 | 作用 |
|---|---|
Ctrl+b d |
分离会话(任务后台继续跑) |
Ctrl+b c |
新建窗口 |
Ctrl+b n / p |
下一个 / 上一个窗口 |
Ctrl+b % |
左右分屏 |
Ctrl+b " |
上下分屏 |
Ctrl+b 方向键 |
在分屏间移动 |
Ctrl+b [ |
进入翻页模式(滚轮/方向键翻历史输出,q 退出) |
screen 是更古老的前辈,功能类似(screen -S work / Ctrl+a d / screen -r work),会话保留机制相同。没有特殊理由的话,新项目直接用 tmux。
[!TIP] 养成肌肉记忆:跑大迁移、编译、备份之前,先
tmux new -s xxx,再敲命令。 十秒钟的动作,断网不慌,睡个觉回来 attach 一下看结果。「临时跑一下很快的」是每个运维都吃过亏的错觉。
# 分离后列出会话,能看到 work 且状态 attached 变回 detached 即成功
$ tmux ls
预期输出:
work: 1 windows (created Tue Sep 29 12:00:00 2026)
htop、iotop 都只能看「现在」。但真实事故往往是这样反馈的:「昨天凌晨三点服务特别慢,现在好了,帮我看看当时发生了什么。」sysstat 套件的 sar 就是干这个的:后台定时采样 CPU、内存、磁盘数据,事后回溯任何时间点的系统状态。vmstat 和 iostat 则是同族的实时快照工具。
# 安装 sysstat 套件(含 sar、iostat、pidstat 等;vmstat 属于 procps 包,系统自带,无需在此安装)
$ sudo apt install sysstat
# 打开周期采集(Ubuntu 上默认关闭,把 ENABLED 改为 true)
$ sudo sed -i 's/false/true/' /etc/default/sysstat
# 重启采集服务
$ sudo systemctl restart sysstat
# 看今天 CPU 使用率的全天曲线(-u 表示 CPU)
$ sar -u
# 看内存使用(-r)
$ sar -r
# 看磁盘 IO(-d)
$ sar -d
实时观测的两个搭档:
# vmstat 每 1 秒刷新一次,看 si/so 列判断是否在换页(不为 0 说明内存吃紧)
$ vmstat 1
# iostat 每 1 秒刷新,-x 显示扩展统计,看 %util 判断哪块磁盘被打满
$ iostat -x 1
| 命令 | 看什么 |
|---|---|
sar -u |
历史 CPU(%iowait 高 = IO 瓶颈) |
sar -r |
历史内存 |
sar -d |
历史磁盘 |
sar -q |
历史负载(load average) |
vmstat 1 |
实时综合快照,si/so 看换页 |
iostat -x 1 |
实时磁盘,%util 看瓶颈盘 |
[!NOTE] sysstat 默认每 10 分钟采一次样,历史数据存放在
/var/log/sysstat/下按天分文件。排查「昨晚的问题」时,sar -u -f /var/log/sysstat/sa27可以指定看 27 号那天的数据。
$ sar -u | tail -3
预期输出:
12:00:01 all 3.52 0.00 1.75 0.10 0.00 0.00 0.00 0.00 0.00 94.63
12:10:01 all 2.98 0.00 1.40 0.05 0.00 0.00 0.00 0.00 0.00 95.57
Average: all 3.21 0.00 1.52 0.08 0.00 0.00 0.00 0.00 0.00 95.19
排查「服务连不上」时,要先把问题切成两半:是网络不通,还是服务本身的问题?nc(netcat)就是切分工具——端口能连通,问题在应用层;连不通,问题在网络/防火墙层。15 网络配置 讲的网络排错流程里,它是最常用的一环。telnet 是更老的同位工具,现在主要被 nc 取代。
# -z 只测端口不发数据,-v 显示过程
$ nc -zv 192.168.1.10 5432
预期输出(成功):
Connection to 192.168.1.10 5432 port [tcp/postgresql] succeeded!
预期输出(失败,超时一般是防火墙拦截;Connection refused 是端口没监听):
nc: connect to 192.168.1.10 port 5432 (tcp) failed: Connection refused
# 两种失败形态要分清:refused 找服务,timeout 找防火墙
$ nc -zv 127.0.0.1 22
| 现象 | 根因 | 解决 |
|---|---|---|
sudo apt install htop 提示 Unable to locate package |
软件源列表为空或没更新 | 先执行 sudo apt update;仍失败检查 /etc/apt/sources.list 源配置(参考 17 包管理) |
tcpdump 报错 You don't have permission to capture on that device |
抓包需要 root 权限 | 用 sudo tcpdump ...;或给二进制授权 sudo setcap cap_net_raw,cap_net_admin+eip /usr/bin/tcpdump |
strace 附着后输出几条就没动静 |
附加的进程本来就在正常等待(如空闲的守护进程) | 先用 htop 确认目标进程确实在忙;或改用 strace -p PID -e trace=network(只看网络调用)或 -e trace=file(只看文件调用)缩小范围再观察 |
strace -p 报 Operation not permitted |
目标进程属主不同或容器内权限隔离 | 加 sudo;容器内进程需 docker exec 进同一容器再执行 |
| tmux 里颜色丢失、方向键变成字母 | 远端 TERM 环境变量设置不当 | 确认 SSH 终端为 xterm 系;必要时在 shell 配置里 export TERM=xterm-256color 后重新 attach |
nc -zv 一律超时,本机测试也是 |
本机防火墙(ufw)拦截 | sudo ufw status 检查,放行对应端口(参考 16 防火墙) |
| iotop 满屏都是 0.00 B/s 的行,分不清谁在写盘 | 不加 -o 时会显示全部进程(含 IO 为 0 的行),这是正常的 |
加 -o 只显示有 IO 的进程;再配 -d 2 拉长刷新间隔观察 |
| 命令 | 作用 |
|---|---|
htop |
交互式看 CPU/内存/进程,M 排内存、P 排 CPU、F9 杀进程 |
sudo iotop -oPa |
按进程看磁盘 IO 的实时与累计量 |
sudo ncdu / |
交互式分析磁盘空间占用,d 删除(危险) |
sudo lsof -i:8080 |
查谁占用某端口 |
sudo lsof | grep deleted |
找已删除但被进程占用的文件(磁盘悬空空间) |
sudo strace -p PID |
跟踪进程系统调用,定位「卡在哪」 |
strace -c 命令 |
统计系统调用耗时分布 |
sudo tcpdump -i any port N -nn -c 100 |
抓指定端口的包 |
tcpdump -w a.pcap |
抓包存文件,供 Wireshark 分析 |
sudo fail2ban-client status sshd |
查看封禁名单 |
sudo fail2ban-client set sshd unbanip IP |
解封 IP |
tmux new -s work |
新建会话 |
tmux ls / tmux attach -t work |
列出/重新接入会话 |
sar -u / sar -r / sar -d |
回溯历史 CPU/内存/磁盘 |
vmstat 1 / iostat -x 1 |
实时综合快照/实时磁盘 |
nc -zv IP 端口 |
测端口连通性 |
定时任务是服务器自动化的起点,备份是运维的底线。这两件事天然绑在一起:备份要靠定时任务来自动跑。本章先讲清备份的思路,再讲 cron 和 systemd timer 两种定时机制,最后给出可直接抄走的数据备份脚本。数据库版本以 91 版本速查表 为准(安装见 23 MySQL ~ 26 Redis 各章),截至 2026-09。
[!IMPORTANT] 没有经过恢复演练的备份,等于没有备份。 备份文件存在,不代表它能恢复出可用的数据。本章最后会讲怎么演练,请把「定期恢复一次」当成备份工作的一半,而不是附加项。
动手写任何备份脚本之前,先回答三个问题:
对应的行业标准是 3-2-1 原则:至少 3 份副本,存放在 2 种不同介质上,其中 1 份异地(另一机房/另一可用区/云对象存储)。小项目至少做到「本机一份 + 异机/云端一份」。
# 盘点最大的数据目录,确定「备份什么」
$ sudo du -sh /var/lib/mysql /var/lib/postgresql /var/www /etc 2>/dev/null
预期输出:
1.8G /var/lib/mysql
620M /var/lib/postgresql
4.1G /var/www
14M /etc
cron 是 Linux 最老牌的定时任务系统:每分钟醒来一次,检查有没有到点要执行的任务。它不依赖你登录,也不依赖 systemd 服务编写,一行五段式表达式就能让命令每天/每小时自动执行,是备份脚本最经典的载体。
当前用户的定时任务用 crontab -e 管理(会用 vi 编辑器,基础操作见 10 shell 与文件系统):
# 编辑当前用户的定时任务(首次会让你选编辑器,选 vim 或 nano 均可)
$ crontab -e
# 列出当前用户的定时任务
$ crontab -l
五段语法:分 时 日 月 周,各段用空格隔开:
| 表达式 | 含义 |
|---|---|
0 3 * * * |
每天 3:00 |
*/5 * * * * |
每 5 分钟 |
0 8 * * 1 |
每周一 8:00 |
0 0 1 * * |
每月 1 号 0:00 |
0 */2 * * 1-5 |
工作日每 2 小时整点 |
30 4 * * 0 |
每周日 4:30 |
系统级定时任务不走 crontab,而是放文件:
/etc/cron.d/:系统级 cron 文件,语法多一段「用户名」字段,推荐系统级部署放这里;/etc/cron.daily/、/etc/cron.weekly/、/etc/cron.hourly/:把可执行脚本丢进目录,自动按天/周/时执行;/etc/crontab:系统全局 crontab。分工建议:当前用户自己的任务用 crontab -e;随软件部署、root 运行的系统任务放 /etc/cron.d/,一个任务一个文件,便于配置管理和卸载。
坑 1:PATH 不全。 cron 执行命令时 PATH 只有 /usr/bin:/bin,你平时能用的 /usr/local/bin 下命令全部找不到,报错 command not found。解决:命令写绝对路径,或在 crontab 首行显式设置:
# crontab 首行设置 PATH,后面任务就能直接用 mysql 等命令
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 也可以不设 PATH,直接用绝对路径调用
0 3 * * * /usr/bin/mysqldump --defaults-extra-file=/root/.my.cnf appdb > /backup/a.sql
坑 2:% 必须转义。 在 crontab 里,% 是换行符,后面内容会被当成下一条命令。所以 date +%F 必须写成 date +\%F:
# % 必须写成 \%,否则任务在第一个 % 处被截断,文件名丢失日期
0 3 * * * /opt/backup.sh $(date +\%F)
坑 3:没有环境变量。 cron 不读你的 .bashrc/.profile,00 前言 里配置的别名、代理、JAVA_HOME 等,在 cron 环境里统统不存在。解决:环境变量在脚本内部自己 source 或显式 export,不要指望 cron 继承。
cron 的任何输出(包括报错)默认会发邮件给本机用户,存进 /var/mail/<用户>——而大部分服务器根本没人看邮件。务必把输出重定向到日志文件:
# >> 追加写入日志,2>&1 把错误输出也合并进去,方便事后排查
# 注意:普通用户无权写 /var/log,这条任务应放在 root 的 crontab 里;非 root 用户可改写到自家目录(如 ~/backup.log)
0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
# 1. 确认 cron 服务在运行
$ systemctl status cron
# 2. 用一条「两分钟后执行」的任务验证整条链路,先看当前时间
$ date '+%F %T'
预期输出:
2026-09-29 12:04:30
在 crontab 里加一行(时间改成两分钟后,注意 \% 转义):
6 12 * * * echo "cron works $(date +\%T)" >> /tmp/cron-test.log
等 12:06 过后查看:
$ cat /tmp/cron-test.log
预期输出:
cron works 12:06:01
[!WARNING]
crontab -r会删除当前用户全部定时任务,且没有任何确认提示。执行前先备份:# 先导出备份,再编辑/删除 $ crontab -l > ~/crontab.bak $ crontab -r
systemd 自带定时器 .timer,是 cron 的现代替代。与 cron 相比优势明确:执行记录直接进 journal(不用自己重定向)、可以设置依赖关系(等网络就绪再跑)、同一任务不会重复叠加执行(防重入)。40 systemd 已讲过 unit 体系,timer 就是其中一种。
| 对比项 | cron | systemd timer |
|---|---|---|
| 日志 | 重定向自己管,默认发邮件 | 自动进 journal,journalctl -u 直接查 |
| 依赖管理 | 无 | After=/Requires= 等完整依赖 |
| 防重入 | 可能叠跑(上次没完又到点) | 天然防重入,上一轮没完下一轮跳过 |
| 编写成本 | 一行搞定 | 需要两个文件 |
一对文件实现「每天 3:00 执行备份脚本」:
# /etc/systemd/system/mybackup.service —— 定义「做什么」
[Unit]
Description=Daily backup job
[Service]
Type=oneshot
ExecStart=/opt/backup.sh
# /etc/systemd/system/mybackup.timer —— 定义「什么时候做」
[Unit]
Description=Run mybackup daily at 03:00
[Timer]
# OnCalendar 语法:年-月-日 时:分:秒,* 表示任意
OnCalendar=*-*-* 03:00:00
# 开机时错过的任务是否补跑
Persistent=true
[Install]
WantedBy=timers.target
# 启用并立刻启动 timer(注意 enable 的是 .timer,不是 .service)
$ sudo systemctl enable --now mybackup.timer
# 列出所有 timer,看下次触发时间
$ systemctl list-timers mybackup.timer
预期输出:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-09-30 03:00:00 CST 14h - - mybackup.timer mybackup.service
[!NOTE] 手动测试时可以不依赖定时,直接
sudo systemctl start mybackup.service立即执行一次,再用journalctl -u mybackup.service看输出——这是 timer 方案排错比 cron 舒服的地方。
备份的核心对象是数据库。四种主流数据库各有一套官方备份工具,用它们而不是直接拷数据目录——直接拷目录要求停库且版本敏感,工具导出则是热备份、跨版本可恢复。
MySQL / MariaDB:
# --single-transaction 用一致性快照导出,InnoDB 下不锁表,业务无感
# --routines --triggers 连存储过程和触发器一起导出,漏了会恢复不全
# 注意:-p'<密码>' 仅作演示,密码会暴露在 ps 与历史记录里;生产脚本按下方「密码安全」一节用 --defaults-extra-file / ~/.my.cnf
$ mysqldump -u root -p'<密码>' --single-transaction --routines --triggers appdb | gzip > /backup/appdb_$(date +%F).sql.gz
# 恢复:解压导入
$ gunzip < /backup/appdb_2026-09-29.sql.gz | mysql -u root -p appdb
--single-transaction 的原理:开启一个一致性读事务,导出全程读的是事务开始时的快照,写入照常进行,所以不锁表。前提是表引擎为 InnoDB(默认就是),MyISAM 表仍会锁。
PostgreSQL:
# -Fc 是自定义压缩格式,支持选择性恢复,优于纯 SQL
# Ubuntu 的 PostgreSQL 默认 peer 认证,必须以 postgres 系统用户身份执行,否则报 role "xxx" does not exist
$ sudo -u postgres pg_dump -Fc appdb > /backup/appdb_$(date +%F).dump
# 恢复
$ sudo -u postgres pg_restore -d appdb /backup/appdb_2026-09-29.dump
MongoDB:
# --gzip --archive 打包成单个压缩文件,便于传输和清理
$ mongodump --db appdb --gzip --archive=/backup/appdb_$(date +%F).gz
# 恢复
$ mongorestore --gzip --archive=/backup/appdb_2026-09-29.gz
Redis: 数据就是一个 dump.rdb 文件,先触发落盘再拷贝:
# BGSAVE 在后台生成 RDB 快照,不阻塞写入
$ redis-cli BGSAVE
# 用 LASTSAVE 前后对比确认快照已落盘(时间戳变化说明本次生成完成)
$ redis-cli LASTSAVE
# dump.rdb 只有 redis 用户可读,拷贝需要 sudo
$ sudo cp /var/lib/redis/dump.rdb /backup/redis_$(date +%F).rdb
[!WARNING] 数据库密码不要直接写在命令行里。 命令行参数在执行期间对全机用户可见(任何人都可用
ps aux看到-p'密码'),历史记录里也会留下明文。脚本中务必用配置文件方式。
MySQL 推荐用 ~/.my.cnf,权限收紧到 600:
# /root/.my.cnf —— mysqldump 自动读取,命令行不再需要 -p
[client]
user=root
password=你的密码
# 权限收紧到 600:全局可写(world-writable)的配置文件会被客户端忽略并告警,600 是最佳实践
$ chmod 600 /root/.my.cnf
# 之后命令行可以不带密码直接用
$ mysqldump --single-transaction appdb | gzip > /backup/appdb_$(date +%F).sql.gz
# 备份后第一件事:看文件大小是否正常,0 字节说明备份失败
$ ls -lh /backup/appdb_$(date +%F).sql.gz
预期输出:
-rw-r--r-- 1 root root 245M Sep 29 03:12 /backup/appdb_2026-09-29.sql.gz
数据库之外的配置文件、上传文件,用 tar 打包或 rsync 同步。11 文件操作 已讲过基础用法,这里聚焦备份场景。
# tar 打包:czf = 创建 gzip 压缩包,配合日期变量命名
$ tar czf /backup/www_$(date +%F).tar.gz /var/www
# rsync 到备份机:-a 保留权限时间戳,-v 显示过程,-z 传输压缩
# --delete 让备份端与源端完全一致,源端删了备份端也删(慎用,见下)
$ rsync -avz --delete /var/www/ backup@192.168.1.20:/data/www/
--link-dest 可以做增量快照:指向上一次的备份目录,未变化的文件只创建硬链接,不占新空间——每天一份完整目录,总磁盘占用却接近一份全量加每日增量。
# 增量快照核心逻辑:与上次快照做硬链接去重
$ rsync -avz --link-dest=/backup/www/2026-09-28 /var/www/ /backup/www/2026-09-29/
备份不清理,迟早把备份盘撑满(排查见 55 磁盘满排查)。常见策略是「保留最近 7 天」:
[!WARNING] 下面的命令会删除 7 天前的所有备份,无法恢复。首次执行前,先不带
-delete跑一遍find -mtime +7 -print预览要删的清单,并确认备份目录路径没有写错——路径写错就是灾难。
# 先预览会删除哪些文件(不带 -delete,只看列表)
$ find /backup/www -mtime +7 -print
# 确认无误后再真正执行删除
$ find /backup/www -mtime +7 -delete
把前面的内容组装成一个可直接抄的脚本,逐段说明:
#!/bin/bash
# /opt/backup.sh —— 每日数据库 + 文件备份骨架
# 任一命令失败(含管道中段)立即中止,避免「dump 失败但 gzip 继续产出 0 字节文件」
set -euo pipefail
# 统一的日期后缀和基础路径
DATE=$(date +%F)
BACKUP_DIR=/backup
KEEP_DAYS=7
# 建立备份目录,不存在则创建
mkdir -p "$BACKUP_DIR"
# 数据库备份:密码走 /root/.my.cnf,不出现在命令行
/usr/bin/mysqldump --single-transaction --routines --triggers appdb \
| gzip > "$BACKUP_DIR/appdb_${DATE}.sql.gz"
# 文件备份
tar czf "$BACKUP_DIR/www_${DATE}.tar.gz" /var/www
# 同步到备份机(3-2-1 中的异地一份,路径按实际环境修改)
rsync -az "$BACKUP_DIR" backup@192.168.1.20:/data/backup/
# 清理本地过期备份(保留 7 天)
find "$BACKUP_DIR" -mtime +"$KEEP_DAYS" -delete
# 跑到这里说明全部成功,写一行日志
echo "$(date '+%F %T') backup ok" >> /var/log/backup.log
# 赋予执行权限(cron 里直接跑,缺执行位会报 Permission denied)
$ sudo chmod 700 /opt/backup.sh
# 手动跑一次验证,再交给 cron/timer
$ sudo /opt/backup.sh
[!TIP]
set -euo pipefail三个开关各管一件事:-e出错即停、-u用到未定义变量报错、-o pipefail让管道里任何一段失败都算整体失败。备份脚本不写这行,失败会被静默吞掉,产出 0 字节文件而你毫无察觉。
# 演练示例:在测试机上恢复并抽查行数
$ gunzip < appdb_2026-09-29.sql.gz | mysql -u root -p appdb_restore_test
$ mysql -u root -p -e "SELECT COUNT(*) FROM orders" appdb_restore_test
预期输出:
COUNT(*)
38214
| 现象 | 根因 | 解决 |
|---|---|---|
| cron 任务到点没执行 | cron 服务未运行、时间未到、表达式写错 | systemctl status cron 确认服务;date 对比服务器时间;crontab -l 核对表达式 |
手动能跑,cron 里 command not found |
cron 的 PATH 只有 /usr/bin:/bin | 命令写绝对路径,或 crontab 首行设 PATH=(见坑 1) |
| cron 执行了但失败,不知道报什么 | 输出发去了没人看的本地邮件 | 重定向到日志:>> /var/log/xxx.log 2>&1;历史报错去 /var/mail/root 翻 |
| 备份文件是 0 字节 | mysqldump 失败但管道后续照常执行 | 脚本加 set -euo pipefail;先手动执行脚本看真实报错(常见是密码/权限) |
% 后面内容丢失,文件名没日期 |
crontab 中 % 被解析为换行符 |
写成 \% 转义(见坑 2) |
| 备份时间与预期差了 8 小时 | 服务器时区是 UTC,本地是 CST | timedatectl set-timezone Asia/Shanghai 后重启 cron(参考 15 网络配置) |
| 备份盘被撑满,服务器又挂了 | 只备份不清理,或保留策略过宽 | 加 find -mtime +N -delete 定期清理;备份目录单独分区,防止拖垮系统盘 |
crontab -r 后任务全没了 |
该命令删全部且无确认 | 平时 crontab -l > ~/crontab.bak;有 git 的把 crontab 内容纳入版本管理 |
| 命令 | 作用 |
|---|---|
crontab -e / -l / -r |
编辑 / 列出 / 删除(全部,无确认)当前用户定时任务 |
systemctl status cron |
确认 cron 服务在运行 |
systemctl enable --now mybackup.timer |
启用并启动 systemd 定时器 |
systemctl list-timers |
查看所有 timer 的下次触发时间 |
journalctl -u mybackup.service |
查看 timer 任务的执行日志 |
mysqldump --single-transaction appdb |
MySQL 不锁表导出 |
pg_dump -Fc appdb / pg_restore |
PostgreSQL 导出 / 恢复 |
mongodump --gzip --archive=xx.gz |
MongoDB 导出为压缩归档 |
redis-cli BGSAVE |
触发 Redis 后台落盘 RDB |
tar czf x.tar.gz 目录 |
打包压缩目录 |
rsync -avz --delete 源 目标 |
同步到备份机 |
find 目录 -mtime +7 -delete |
删除 7 天前的旧备份(先 -print 预览) |
chmod 600 /root/.my.cnf |
收紧数据库密码文件权限 |
从这一章开始进入 PART 4「日志」。前面几十章,你的服务器上跑起了 Nginx、MySQL、Redis,还有自己部署的应用 —— 它们出了问题去哪找线索?答案只有一个:日志。本章先建立全局认识:日志都在哪、谁在写、怎么分级,后面两章再学具体怎么查。
[!IMPORTANT] 排查服务器问题的第一动作永远是看日志,而不是重启。重启能掩盖问题,日志才能定位问题。养成习惯:任何异常,先看日志,再动手。本章内容适用于 Ubuntu 22.04 自带的 rsyslog 8.2110 与 systemd-journald v249,截至 2026-09 仍是 LTS 默认组件。
日志就是程序给自己写的「工作日记」:谁在什么时间做了什么、成功了还是失败了、报了什么错。服务器上的一切——登录、请求、数据库慢查询、进程崩溃——只要程序愿意说,日志里都有记录。
为什么它重要?因为服务器 7x24 小时运行,问题往往发生在你不在场的时候:半夜进程崩了、凌晨有人尝试爆破 SSH、请求突然变慢。只有日志忠实地记下了这一切。没有日志,排查等于蒙着眼睛修车。
先建立肌肉记忆:遇到问题,脑子里要立刻浮现「该看哪个日志」。常见问题与对应日志的映射,下一节的目录地图里完整给出。
看一眼你机器上的日志目录,感受一下「日志的存在感」:
# 列出 /var/log 下所有日志文件,按人类可读大小显示
$ ls -lh /var/log
预期输出:
total 3.2M
-rw-r--r-- 1 root root 0 Sep 28 08:00 alternatives.log
-rw-r--r-- 1 root adm 312K Sep 28 10:15 auth.log
-rw-r--r-- 1 root adm 1.2M Sep 28 10:20 syslog
drwxr-xr-x 2 root root 4.0K Sep 20 08:00 nginx/
drwxr-xr-x 2 root systemd-journal 4.0K Sep 20 08:00 journal/
...
只要 /var/log 不是空的,说明日志系统在工作。注意 journal/ 这一行:开启 journald 持久化后才会出现(见下文 Storage= 说明),默认装机没有它属正常。
| 现象 | 根因 | 解决 |
|---|---|---|
| 找不到日志 | 不知道该看哪个文件 | 对照下一节目录地图,按「问题类型」找文件 |
| 日志是空的 | 应用没启动,或日志写到了别处 | 先 systemctl status 确认服务状态 |
| 以为日志会无限留 | 日志会滚动清理 | 理解 logrotate 机制,见 54 logrotate |
/var/log 是 Linux 约定俗成的日志大本营。Ubuntu 上的布局如下,每一行都标注了「什么场景去看它」——这张表建议收藏:
| 文件/目录 | 记录什么 | 什么场景去看它 |
|---|---|---|
syslog |
Ubuntu 综合日志,大部分系统消息都汇聚于此 | 不确定看哪个文件时,先在这里 grep |
auth.log |
登录、sudo、SSH 认证记录 | 怀疑被入侵、SSH 爆破、查 sudo 操作,先看这个 |
kern.log |
内核消息 | 硬件异常、驱动问题、OOM(内存不足杀进程) |
dmesg |
内核环形缓冲区(本次开机) | 开机失败、磁盘 I/O 报错,sudo dmesg 直接看 |
ufw.log |
防火墙 UFW 拦截记录 | 端口不通,怀疑被防火墙拦了 |
nginx/ |
Nginx 的 access.log / error.log | 网站打不开、看请求状态码、502 排查 |
mysql/ |
MySQL 错误日志 error.log | MySQL 起不来、崩溃恢复记录 |
postgresql/ |
PostgreSQL 日志 | PG 启动失败、慢查询记录 |
mongod.log |
MongoDB 日志(单文件) | mongod 起不来、连接异常 |
redis/redis-server.log |
Redis 日志 | Redis 启动失败、持久化报错 |
journal/ |
journald 的二进制日志 | 不能直接 cat,必须用 journalctl 查(下一章) |
apt/ |
软件包安装/升级历史 | 「这机器上周装过什么?」history.log 一目了然 |
fail2ban.log |
fail2ban 封禁动作记录 | 查 IP 被谁封、封了多久 |
cron.log |
定时任务执行记录 | 定时任务没跑?默认需手动开启(见下) |
[!NOTE]
cron.log在 Ubuntu 22.04 默认不单独落盘,定时任务的输出混在syslog里。要单独开启,编辑/etc/rsyslog.d/50-default.conf,取消#cron.*那行的#注释后重启 rsyslog。
背不下整张表没关系,记住三个入口即可:
# 入口一:登录/入侵问题,看 auth.log
$ sudo tail -50 /var/log/auth.log
# 入口二:不确定哪的问题,在综合日志里搜关键词
$ sudo grep -i error /var/log/syslog
# 入口三:某软件的问题,进它的专属目录
$ sudo tail -50 /var/log/nginx/error.log
目录地图里的权限规律值得一提:系统日志(syslog、auth.log 等)属主是 root:adm 组。adm 组的普通用户可以免 sudo 读日志,这是给运维同事授权看日志的标准做法:
# 把用户 deploy 加进 adm 组,之后不用 sudo 也能读 /var/log 下的日志
$ sudo usermod -aG adm deploy
logger 命令可以手写一条测试日志,是验证日志链路通不通的利器:
# 手动向 syslog 写一条测试消息
$ logger "hello from cli"
# 去综合日志里找这条消息(系统日志普通用户无权读,需要 sudo)
$ sudo grep "hello from cli" /var/log/syslog
预期输出:
Sep 28 10:30:01 myserver deploy: hello from cli
能 grep 到,说明「应用 → rsyslog → /var/log/syslog」整条链路是通的。这个技巧在调试应用日志配置时非常实用。
| 现象 | 根因 | 解决 |
|---|---|---|
/var/log/syslog 不存在 |
rsyslog 没安装或没启动 | sudo apt install rsyslog && sudo systemctl enable --now rsyslog |
| 读取日志提示 Permission denied | 普通用户默认无权读系统日志 | 前面加 sudo,或把自己加入 adm 组 |
| 某应用日志不在 /var/log 下 | 应用自己配置了日志路径 | 查应用的配置文件,或用 journalctl -u 应用名 兜底 |
Ubuntu 22.04 上同时跑着两套日志系统,初学者最容易在这里犯迷糊。它们不是竞争关系,而是流水线的上下游:
/run/log/journal)或 /var/log/journal,不能用 cat 直接看,必须用 journalctl 查询——这是下一章的主角。/var/log/*.log(syslog、auth.log、kern.log……)。流程一句话:journald 先收,rsyslog 再落盘成文本。两份内容同源,只是格式和查询方式不同。
为什么两种都要会查?因为现实世界很分裂:系统服务短平快的问题,journalctl -u xxx 最快;而需要 grep、备份、导出分析,或者应用直接写文件不走 journald 时,文本日志才是主角。只会其一,总有一天会「找不到日志」。
确认两套系统都在正常工作:
# 查 journald 状态
$ systemctl status systemd-journald
# 查 rsyslog 状态
$ systemctl status rsyslog
两者都应显示 active (running)。
journald 的存储位置由 /etc/systemd/journald.conf 里的 Storage= 决定:
| 取值 | 行为 |
|---|---|
auto(默认) |
/var/log/journal 目录存在才持久化,否则只存内存,重启即丢 |
persistent |
日志持久化到 /var/log/journal,重启不丢 |
volatile |
只存内存,重启即丢 |
Ubuntu 22.04 默认装了 rsyslog 并已把 journald 消息转发给它,所以你两种日志都有。
# 用 journalctl 查 journald 里的 nginx 日志(二进制侧)
$ journalctl -u nginx -n 3 --no-pager
# 用 tail 查 rsyslog 落盘的文本(文本侧)
$ tail -3 /var/log/syslog
预期输出(两侧都能看到内容,只是格式不同):
-- Logs begin at Mon 2026-09-01 09:00:01 CST, end at Tue 2026-09-29 10:00:00 CST. --
Sep 28 10:00:01 myserver systemd[1]: nginx.service: Succeeded.
Sep 28 10:00:01 myserver systemd[1]: Stopped A high performance web server.
| 现象 | 根因 | 解决 |
|---|---|---|
journalctl 只有本次开机的日志 |
Storage=auto 且 /var/log/journal 不存在 |
sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald |
/var/log/syslog 不存在 |
rsyslog 未安装或未启动 | 安装并启动 rsyslog(见上节常见问题) |
journalctl 报错没有权限 |
普通用户默认只能看自己的日志 | 加 sudo,或加入 adm、systemd-journal 组 |
| 同一条消息在两处都有 | 正常现象,journald 和 rsyslog 同源各存一份 | 不是重复配置错误,不用处理 |
日志按严重程度分为八个级别(syslog 标准),从高到低:
| 级别 | 名称 | 含义 | 典型例子 |
|---|---|---|---|
| 0 | emerg | 系统不可用 | 内核 panic |
| 1 | alert | 必须立即处理 | 数据库损坏 |
| 2 | crit | 危急 | 硬件故障、磁盘满 |
| 3 | err | 错误 | 服务启动失败、请求报 500 |
| 4 | warning | 警告,暂不影响运行 | 磁盘使用率超 80% |
| 5 | notice | 正常但值得注意 | 服务重启完成 |
| 6 | info | 一般信息 | 收到一个请求 |
| 7 | debug | 调试细节 | 变量值、SQL 明细 |
记忆建议:排错时先看 err 及以上(0-3 级),这是「已经出事」的部分;warning 级(4)是「快出事」,值得扫一眼;info 及以下平时不用看,量大但通常无害。
用 journalctl 的 -p 参数按级别过滤(下一章详讲):
# 只看 err 及更严重的日志
$ sudo journalctl -p err -b
# 查看本次开机以来 err 级以上的日志条数
$ sudo journalctl -p err -b --no-pager | wc -l
预期输出:
23
数字不大是正常的——健康系统 err 以上日志很少。如果这个数在快速增长,说明有东西在持续报错。
| 现象 | 根因 | 解决 |
|---|---|---|
| 日志里全是 info 刷屏,找不到重点 | 没按级别过滤 | 用 -p err 或 grep ERROR 先收敛范围 |
| 明明出错了却没看到 err 日志 | 应用没按级别打日志 | 检查应用日志级别配置,见下节 |
| warning 大量重复,埋没了 err | 噪音太多 | 先修刷 warning 的来源,再用 -p err 过滤 |
日志还有一个「来源分类」维度叫 facility:auth(认证)、daemon(守护进程)、kern(内核)、mail(邮件)、cron(定时任务)等。它的作用是让 rsyslog 决定「哪类消息写进哪个文件」——auth 类进 auth.log,kern 类进 kern.log。日常排查只需要知道这个概念,用来理解「为什么这条日志会出现在这个文件里」,不必深记。
系统软件的日志位置是发行版定好的,而你自己部署的应用,日志放哪、什么格式,由你的配置决定。这里有一条全行业通行的约定,遵守它,后面章节的排查命令才能通用。
位置约定:放 /var/log/<应用名>/ 目录。为什么?所有运维工具(logrotate、监控、备份)都默认在 /var/log 下找日志,放别处等于自绝于生态。
属主约定:目录属主给运行该应用的用户(比如 appuser,与 40/42/60 章的部署用户一致),让应用自己能写,别人不能随便改。
格式约定:一行一条,按「时间戳 级别 线程/模块 消息」排列。为什么一行一条?因为 grep、wc -l、tail 这些工具都是按行处理日志的,一行一条事件是所有文本分析的前提。
创建规范的日志目录:
# 创建应用日志目录,属主给运行用户 appuser,权限 750(属主可写,组可读,其他人无权)
$ sudo mkdir -p /var/log/myapp
$ sudo chown appuser:appuser /var/log/myapp
$ sudo chmod 750 /var/log/myapp
先看一个坏日志长什么样——多行混杂、没有时间戳、没有级别:
2026-09-28 10:00:01 ERROR something failed
at com.example.Service.run(Service.java:42)
at com.example.Main.main(Main.java:10)
done, took 3s
ERROR something failed again
问题:第二段错误没时间戳没法定位、普通消息和错误混在一起没法 grep、堆栈断行导致一行一条被破坏。
再看好日志——一行一条,字段齐全:
2026-09-28 10:00:01 ERROR [worker-1] OrderService: 订单 1024 处理失败, 原因=超时
2026-09-28 10:00:03 INFO [http-8080] RequestFilter: GET /api/health 200 3ms
2026-09-28 10:00:05 ERROR [worker-1] OrderService: 订单 1025 处理失败, 原因=库存不足
每行都能独立被 grep 到、能按时间排序、能按级别统计。堆栈可以跨行,但每条日志的首行必须带全时间戳和级别。
[!NOTE] 更进一步的做法是结构化(JSON)日志:每行一个 JSON 对象,字段固定,便于日志系统(如 ELK)解析分析。文本日志够用之前不必上,知道有这回事即可。
应用写日志的两条路径,选其一:
| 方式 | 做法 | 适用 |
|---|---|---|
| 直接写文件 | 应用日志库配置输出到 /var/log/myapp/app.log |
应用有完善日志库(Logback、winston 等) |
| 交给 journald | 应用只往 stdout/stderr 打印,由 systemd 收进 journal,由 journald 自动限额滚动(见 51 journalctl;journal 是二进制,logrotate 管不到) | 简单脚本、容器化应用 |
42 进程守护方案选型 讲进程托管时会回到这个选择。
部署后验证日志确实按约定落盘:
# 触发一条业务请求后,看日志最新 3 行
$ sudo tail -3 /var/log/myapp/app.log
预期输出:
2026-09-28 10:00:01 INFO [main] Application: started on port 8080
2026-09-28 10:00:05 INFO [http-8080] RequestFilter: GET /api/health 200 3ms
2026-09-28 10:00:09 ERROR [worker-1] OrderService: 订单 1024 处理失败, 原因=超时
时间戳对、级别分明、一行一条,即符合约定。
| 现象 | 根因 | 解决 |
|---|---|---|
| 日志目录权限拒绝,应用启动失败 | 属主不是运行用户 | chown -R appuser:appuser /var/log/myapp |
| 日志全是重复同一条 | 应用 bug 或配置错误导致刷屏 | 先修应用,再谈查日志;临时可用 grep -v 排除 |
| 堆栈被打成一行或断成乱行 | 日志框架配置不当 | 调整布局(pattern)配置,保证首行带时间戳与级别 |
| 日志文件时间戳与应用行为对不上 | 应用用了 UTC 时区 | 统一时区,见 15 网络配置 |
日志配置永恒的两难:
debug,每个细节都打出来;info,否则日志几十 GB,又费磁盘又费眼睛。没有两全的静态配置,正确姿势是:平时 info + 磁盘滚动清理,出事时临时调 debug,查完改回来。而「滚动清理」靠 logrotate:把大日志切割成 app.log.1、app.log.2.gz 并定期删除旧的,防止磁盘被日志撑爆——这是 54 logrotate 的主题。
临时调高日志级别的思路(以你的应用支持动态配置为前提):
# 平时:日志级别 info
log.level = info
# 排查期间:临时改成 debug,复现问题
log.level = debug
# 查完:立刻改回 info,避免刷盘
log.level = info
排查前后各检查一次磁盘占用,确认没把磁盘吃满:
# 看应用日志目录占用
$ du -sh /var/log/myapp/
预期输出:
128M /var/log/myapp/
| 现象 | 根因 | 解决 |
|---|---|---|
| 日志把磁盘撑满,服务挂了 | 没配滚动清理 | 立即清理旧日志,并配置 logrotate(见 54 logrotate) |
| 排查时信息太少 | 平时级别太高 | 临时调 debug 复现,查完改回 |
| debug 全开后性能下降 | 日志量太大拖慢应用 | 尽量用低峰期排查,或只对单个模块开 debug |
| 命令 | 作用 |
|---|---|
ls -lh /var/log |
查看日志目录全景 |
tail -50 /var/log/syslog |
看综合日志最新 50 行 |
sudo tail -50 /var/log/auth.log |
查登录与 sudo 记录(查入侵第一站) |
sudo dmesg |
看本次开机内核消息 |
logger "消息" |
手写一条测试日志 |
grep 关键词 /var/log/syslog |
在综合日志中搜索 |
sudo usermod -aG adm 用户 |
授权普通用户免 sudo 读日志 |
journalctl -u 服务名 |
查 journald 中某服务日志(下一章) |
du -sh /var/log/应用名/ |
查应用日志磁盘占用 |
上一章说过:systemd 时代,每个服务的 stdout/stderr 都被 journald 收进了二进制日志库。想看这些日志,唯一入口就是 journalctl。它是排查「服务为什么起不来」「半夜为什么重启」的第一工具——本章把它一次讲透。
[!IMPORTANT] journalctl 默认会进入
less分页浏览模式,看完记得按q退出。所有 journalctl 查询都建议加sudo(或把用户加入systemd-journal、adm组),否则普通用户看不到系统级日志。本章命令适用于 Ubuntu 22.04 的 systemd v249,截至 2026-09 参数行为一致。
journalctl 是 systemd 的日志查询工具,从 journald 的二进制数据库里按条件取日志。它能做的,grep /var/log/syslog 也能做一部分,但 journalctl 有三大优势:
-u、-p、--since);为什么 systemd 时代必须会它?因为「服务起不来」的报错根本不会出现在任何文本文件里——服务连日志文件都没建立,journalctl 是唯一能看到它死前遗言的地方。
最朴素的用法,直接敲:
# 从最旧到最新浏览全部日志,自动进入 less 分页
$ sudo journalctl
进分页后,常用 less 操作:
| 按键 | 作用 |
|---|---|
空格 / b |
向下 / 向上翻一页 |
/关键词 |
向下搜索,回车确认 |
n / N |
跳到下一个 / 上一个匹配 |
G / g |
跳到末尾 / 开头 |
q |
退出 |
不加任何参数时,journalctl 从最旧的日志开始显示——这对查历史有用,但日常排错几乎总想看最新的,所以后面小节的参数才是主角。先给一张总览:
| 参数 | 作用 |
|---|---|
-n 50 |
只显示最近 50 行 |
-e |
直接跳到日志末尾 |
-f |
实时跟踪新日志(类似 tail -f) |
-r |
反序输出(最新在最前) |
--no-pager |
不进分页器,直接打印(适合脚本/管道) |
# 看最近 20 行系统日志,不分页
$ sudo journalctl -n 20 --no-pager
预期输出:
-- Logs begin at Mon 2026-09-01 09:00:01 CST, end at Tue 2026-09-29 10:00:00 CST. --
Sep 29 09:59:41 myserver systemd[1]: Starting Daily apt download activities...
Sep 29 09:59:41 myserver systemd[1]: apt-daily.service: Succeeded.
Sep 29 09:59:41 myserver CRON[4821]: (root) CMD (command -v debian-sa1 > /dev/null)
...
能正常输出且格式为「时间 主机 进程[PID]: 消息」,说明 journald 工作正常。
| 现象 | 根因 | 解决 |
|---|---|---|
| 输出一半卡住不动 | 进入了 less 分页器,这是正常行为 | 按 q 退出,或加 --no-pager |
| 提示 Permission denied / 看不到系统日志 | 普通用户无权读系统 journal | 前面加 sudo,或加入 adm 组 |
| 输出是乱码般的超长行 | 某条日志含二进制内容 | 加 -o cat 只看消息体,或 --no-pager 管道处理 |
排错的 90% 场景是「刚刚发生了什么」——不需要翻完整历史,只需要最新几行或实时盯着。journalctl 用 -n、-e、-f、-r 四个参数覆盖这些场景,其中 -f(follow)是监视服务时最常用的:新日志一到就滚出来,等价于 tail -f。
# 看最近 50 行日志
$ sudo journalctl -n 50
# 等价写法:直接跳到末尾看
$ sudo journalctl -e
# 反序:最新日志在最前面,适合扫一眼刚发生的
$ sudo journalctl -r -n 20
# 实时跟踪(Ctrl+C 退出),盯着系统正在发生什么
$ sudo journalctl -f
-n 后面的数字可换,-n 200 就是最近 200 行。
| 参数 | 说明 |
|---|---|
-n N |
显示最近 N 行,默认 10 |
-e |
end,跳到日志末尾再显示一屏 |
-r |
reverse,倒序,常与 -n 搭配 |
-f |
follow,持续跟踪,Ctrl+C 退出;可与 -u 等过滤条件叠加 |
# 反序看最近 3 行
$ sudo journalctl -r -n 3 --no-pager
预期输出:
Sep 29 10:00:00 myserver systemd[1]: Starting Cleanup of Temporary Directories...
Sep 29 10:00:00 myserver systemd[1]: systemd-tmpfile-clean.service: Deactivated successfully.
Sep 29 10:00:00 myserver CRON[4830]: (root) CMD (test -x /usr/sbin/anacron)
...
第一行就是最新的日志,说明 -r 生效。
| 现象 | 根因 | 解决 |
|---|---|---|
-f 挂着却一直没新输出 |
应用日志走了 stdout 缓冲,或没有新事件产生 | 触发一次请求制造日志;若是缓冲问题见本章末常见问题 |
-n 50 显示的还是旧的 |
误与 -r 组合理解反了 |
-r 只是反转顺序,取的仍是最近 N 行 |
| 终端被日志刷屏停不下来 | 忘了 -f 会持续输出 |
Ctrl+C 退出 |
「昨晚 10 点到 11 点之间发生了什么」——这是日志查询最常见的形状。journalctl 的 --since / --until 就是在时间轴上切一刀,只取这个窗口内的日志。文本日志里做同样的事,你要肉眼比对时间戳;journalctl 一条参数搞定,这就是结构化日志的好处。
支持绝对时间和相对时间两种写法:
# 绝对时间:看某天某小时的日志
$ sudo journalctl --since "2026-09-28 10:00:00" --until "2026-09-28 11:00:00"
# 只给日期,默认从当天 00:00:00 起
$ sudo journalctl --since "2026-09-28"
# 相对时间:最近 1 小时
$ sudo journalctl --since "1 hour ago"
# 今天零点以来的日志
$ sudo journalctl --since today
# systemd 支持的简写:最近 15 分钟
$ sudo journalctl --since -15min
# 组合过滤:最近 1 小时内 nginx 的日志
$ sudo journalctl -u nginx --since "1 hour ago"
| 写法 | 含义 |
|---|---|
"2026-09-28 10:00:00" |
绝对时间,精确到秒;秒可省略 |
"2026-09-28" |
当天零点起 |
"1 hour ago" / "2 days ago" |
相对当前时刻往回推 |
today / yesterday |
今天 / 昨天零点起 |
-15min / -2h |
最近 15 分钟 / 2 小时(systemd 简写) |
--until |
窗口右边界,常与 --since 成对出现 |
时间按系统本地时区解释。如果日志时间和你的手表对不上,先查服务器时区(见 15 网络配置)。
# 验证时间窗过滤:取最近 1 小时,只显示第一行和总行数
$ sudo journalctl --since "1 hour ago" --no-pager | head -1
$ sudo journalctl --since "1 hour ago" --no-pager | wc -l
预期输出:
Sep 29 09:01:02 myserver CRON[4102]: (root) CMD (cd / && run-parts --report /etc/cron.hourly)
137
首行时间落在 1 小时内、总行数远小于全天日志量,说明窗口生效。
| 现象 | 根因 | 解决 |
|---|---|---|
结果为 -- No entries -- |
时间窗内确实没有日志,或时区理解错 | 拉大窗口重试;核对 date 输出的时区 |
--since 报错无法解析 |
引号缺失或格式拼错 | 时间串必须加引号,格式参照参数说明表 |
| 查到的日志时间「超前/落后几小时」 | 服务器是 UTC 时区 | 统一时区,见 15 网络配置 |
journalctl -u 服务名 只看某一个 systemd 服务的日志。这是排查服务起不来的第一命令:服务启动失败时,它自己往 stdout/stderr 打的报错、systemd 替它记的死亡原因,全都能用 -u 一网打尽。它和 40 systemd 的 systemctl status 是黄金搭档——status 告诉你「挂了」,journalctl 告诉你「为什么挂」。
# 看 nginx 的全部日志
$ sudo journalctl -u nginx
# 看最近 20 行并直达末尾(最常用组合)
$ sudo journalctl -u nginx -n 20 -e
# 实时盯 nginx 日志(改配置反复重启调试时的标配)
$ sudo journalctl -u nginx -f
# 同时看多个服务,把关联服务的日志按时间排在一起
$ sudo journalctl -u mysql -u redis
# 查服务为什么起不来:限定本次开机,只看这个 unit
$ sudo journalctl -u myapp -b --no-pager
-u 可叠加多个,unit 名不带 .service 后缀也行。普通用户自己启动的 --user 服务用 journalctl --user -u 服务名 查看,服务器部署场景较少,知道即可。
| 参数 | 说明 |
|---|---|
-u 名称 |
按(unit)过滤;一个 -u 一个服务,可写多个 |
-u 名称 -f |
过滤 + 实时跟踪,调试服务时最常用 |
-u 名称 -b |
限定本次开机,便于聚焦「这次启动发生了什么」 |
--user |
查用户级(非系统级)服务的日志 |
# 不分页查看 nginx 最近 5 行
$ sudo journalctl -u nginx -n 5 --no-pager
预期输出:
-- Logs begin at Mon 2026-09-01 09:00:01 CST, end at Tue 2026-09-29 10:00:00 CST. --
Sep 29 09:30:01 myserver systemd[1]: Reloading A high performance web server and a reverse proxy server.
Sep 29 09:30:01 myserver systemd[1]: Reloaded A high performance web server and a reverse proxy server.
Sep 29 09:45:00 myserver systemd[1]: nginx.service: Deactivated successfully.
...
输出里只出现 nginx 相关条目,说明 -u 过滤生效。
| 现象 | 根因 | 解决 |
|---|---|---|
journalctl -u 应用名 输出 -- No entries -- |
应用写自己的日志文件,没往 stdout/stderr 打印——journalctl 只收 stdout/stderr 和 syslog,写文件的应用日志不会进 journal | 去 /var/log/应用名/ 看文件,查法见 52 日志查询技巧 |
| 服务明明在跑却查不到日志 | unit 名写错(如把 mysql 写成 mysqld) |
用 `systemctl list-units --type=service |
| 日志停在服务启动那一刻 | 服务把日志写到文件后不再输出 stdout | 同第一条,改看文件日志或调整应用日志输出 |
上一章讲过日志八级。journalctl 用 -p 直接按级别过滤,一行命令只留「值得看的」。排错先看 err 以上,这条经验在这里落地。
# 只看 err(3)及以上:emerg/alert/crit/err
$ sudo journalctl -p err
# 本次开机内的 err 及以上
$ sudo journalctl -p err -b
# 范围写法:warning 到 emerg 之间
$ sudo journalctl -p warning..emerg
# 组合:本次开机的 err 及以上,看 nginx 的
$ sudo journalctl -u nginx -p err -b --no-pager
-p 接受级别名(emerg、alert、crit、err、warning、notice、info、debug)或数字(0-7):
| 写法 | 含义 |
|---|---|
-p err |
err 及更严重的所有日志(即 0-3) |
-p warning..emerg |
区间,4 到 0 级 |
-p 3 |
与 -p err 等价 |
# 本次开机 err 及以上的日志条数
$ sudo journalctl -p err -b --no-pager | wc -l
预期输出:
23
输出远小于 journalctl -b | wc -l 的结果,说明过滤生效。
| 现象 | 根因 | 解决 |
|---|---|---|
-p err 结果为空但服务确实挂了 |
应用把报错打成了普通 info | 说明应用日志不规范,改用 -g 关键词搜(见下节) |
-p emerg..warning 报错 |
优先级区间写反了(必须从轻到重:warning..emerg) |
调换顺序,写成 -p warning..emerg |
服务器半夜重启过,你想知道重启前发生了什么。journald 给每次开机编了号(0 = 本次,-1 = 上一次……),-b 按批次取日志——这是文本日志做不到的,因为文本文件里两次开机的日志混在一起。
# 只看本次开机以来的日志(默认行为就是 -b 0)
$ sudo journalctl -b
# 上一次开机的日志:半夜重启后查原因必备
$ sudo journalctl -b -1
# 列出 journal 里保存的所有开机记录
$ sudo journalctl --list-boots
# 上次开机的最后 50 行:往往就是崩溃/重启前的现场
$ sudo journalctl -b -1 -n 50 --no-pager
| 写法 | 含义 |
|---|---|
-b / -b 0 |
本次开机 |
-b -1 |
上一次开机 |
-b -2 |
上上次,依此类推 |
--list-boots |
列出所有开机批次及时间范围 |
# 列出开机记录
$ sudo journalctl --list-boots
预期输出:
-2 8a3f... Mon 2026-09-15 09:00:01 CST—Wed 2026-09-24 03:12:44 CST
-1 c41d... Wed 2026-09-24 03:15:02 CST—Sun 2026-09-28 02:40:11 CST
0 97e2... Sun 2026-09-28 02:42:03 CST—Tue 2026-09-29 10:00:00 CST
每行一个开机批次:负号开头是历史开机,0 是当前。-1 行的结束时间与 0 行的开始时间之间的断档,就是上次停机时刻。
| 现象 | 根因 | 解决 |
|---|---|---|
--list-boots 只有一行 0 |
日志没持久化,历史开机记录丢了 | 开启持久化,见本章「磁盘占用与清理」节 |
-b -1 报错不存在 |
该批次已被清理或从未持久化 | 核对 --list-boots 输出中的有效编号 |
| 上次开机日志很少 | 上次开机太早,早于持久化开启时间 | 无解,今后开启持久化即可积累 |
journald 给每条日志打了结构化字段(进程、PID、用户……),journalctl 支持用 字段=值 精确定位,还有 -g 做关键词搜索。当「按服务、按时间」都框不住目标时,字段过滤是最后的手术刀。
# 按进程名过滤:所有 ssh 相关进程的日志
$ sudo journalctl _COMM=ssh
# 按 PID 过滤:某次崩溃进程的完整生命周期
$ sudo journalctl _PID=1234
# 按用户 ID 过滤:uid 1000 用户相关的日志
$ sudo journalctl _UID=1000
# 关键词搜索(grep 模式),大小写不敏感
$ sudo journalctl -g -i "out of memory"
# 组合:本次开机内含 error 字样的 nginx 日志
$ sudo journalctl -u nginx -b -g error --no-pager
| 写法 | 说明 |
|---|---|
_COMM=名 |
进程(可执行文件)名,如 ssh、sshd、nginx |
_PID=号 |
进程 ID,精确定位某次运行 |
_UID=号 |
用户 ID;id -u 用户名 可查 |
-g PATTERN |
在消息正文里做模式匹配(需 systemd 237+,v249 支持);配合 -i 忽略大小写 |
常用字段远不止这些(journalctl --fields 可列出全部),部署工作中以上四个最常用。
# 验证字段过滤:查 sshd 进程最近 5 行
$ sudo journalctl _COMM=sshd -n 5 --no-pager
预期输出:
Sep 29 09:12:33 myserver sshd[3901]: Accepted publickey for deploy from 203.0.113.5 port 51234 ssh2
Sep 29 09:12:33 myserver sshd[3901]: pam_unix(sshd:session): session opened for user deploy
...
每行进程名都是 sshd,说明 _COMM= 过滤生效。
| 现象 | 根因 | 解决 |
|---|---|---|
-g 报不支持的选项 |
systemd 版本过老(低于 237) | Ubuntu 22.04 不存在此问题;老系统用 journalctl 管道给 grep |
_PID= 查不到 |
PID 每次重启都变,旧 PID 已无意义 | 配合 -b 用,或改用 _COMM= |
_COMM=ssh 结果为空 |
服务进程名是 sshd 不是 ssh | 先 ps 或 systemctl status 确认进程真实名字 |
journalctl 默认输出是给人看的;但要做统计、喂给脚本、导出分析时,格式就得换。-o 控制输出格式,四个常用档位覆盖「人看」和「机读」。
# 紧凑单行时间戳(带时区偏移),日常推荐的机器友好格式
$ sudo journalctl -u nginx -n 5 -o short-iso --no-pager
# 只看消息体,不带时间主机等前缀(适合管道 grep)
$ sudo journalctl -u nginx -n 5 -o cat --no-pager
# 机读 JSON,给脚本和日志平台用
$ sudo journalctl -u nginx -n 1 -o json --no-pager
# JSON 美化缩进版,人工检查字段时用
$ sudo journalctl -u nginx -n 1 -o json-pretty --no-pager
| 格式 | 说明 | 适用 |
|---|---|---|
| (默认) | 类 syslog 的人类可读格式 | 人直接看 |
short-iso |
ISO 8601 时间戳(如 2026-09-29T10:00:00+08:00),带时区 |
推荐;跨时区对时间线 |
cat |
只输出消息正文 | 管道给 grep/awk |
json / json-pretty |
每条一个 JSON 对象 / 带缩进 | 程序处理、导出分析 |
# 对比同一查询的两种格式
$ sudo journalctl -u nginx -n 1 -o short-iso --no-pager
预期输出:
2026-09-29T09:30:01+08:00 myserver systemd[1]: Reloading A high performance web server.
$ sudo journalctl -u nginx -n 1 -o cat --no-pager
预期输出:
Reloading A high performance web server.
| 现象 | 根因 | 解决 |
|---|---|---|
-o json 输出挤成一行无法读 |
json 格式默认单行 | 换 json-pretty,或管道给 python3 -m json.tool |
| 想把全部日志导出成文本 | 用 -o 不够,还要指定范围 |
sudo journalctl -o short-iso > all.log |
journald 默认上限为磁盘大小的 10%(封顶 4G),并非无限增长;但小磁盘机器上这个默认值仍可能偏大——磁盘被 journal 挤占后,数据库写不进、服务起不来,全服务器瘫痪。所以「显式收紧 journal 上限」是每台服务器开局的推荐配置,不是可选项。
先看现状,再做限制:
# 查 journal 当前占了多少磁盘
$ sudo journalctl --disk-usage
方法一(推荐):配置文件设永久上限。 编辑 /etc/systemd/journald.conf,在 [Journal] 段设置:
[Journal]
# 日志最多占 500M,超出自动删最旧的
SystemMaxUse=500M
# 同时开启持久化:重启后日志不丢(否则默认只有本次开机的)
Storage=persistent
生效需要重启 journald:
$ sudo systemctl restart systemd-journald
方法二:一次性清理。 立刻把 journal 压缩到指定大小或时间范围:
# 只保留 200M 的最新日志,其余删掉
$ sudo journalctl --vacuum-size=200M
# 只保留最近 30 天
$ sudo journalctl --vacuum-time=30d
注意:方法二只清一次,不改上限,过几个月又会涨回来。所以方法二适合救急,方法一才是治本,两者常常配合用。
| 配置/命令 | 说明 |
|---|---|
SystemMaxUse=500M |
journal 持久化的磁盘上限,超出删最旧;500M、2G 均可 |
Storage=persistent |
日志持久化到 /var/log/journal,重启不丢;默认 auto(目录不存在就不持久) |
--disk-usage |
显示 journal 当前占用 |
--vacuum-size=200M |
清理到只剩 200M(一次性) |
--vacuum-time=30d |
清理掉 30 天前的日志(一次性) |
[!WARNING] journald 默认上限为磁盘 10%、封顶 4G,对小磁盘机器仍可能偏大。新装机建议显式设置
SystemMaxUse(如 500M-2G,按磁盘大小酌情),避免数月后 journal 挤占业务数据空间。
[!IMPORTANT] 默认
Storage=auto且/var/log/journal目录不存在时,日志只存内存,重启即全部丢失。服务器半夜重启后想用-b -1查原因,前提就是持久化已开启——这也是部署完成后第一天的功课。
改完配置后三步验证:
# 1. 确认 journald 正常运行
$ systemctl status systemd-journald --no-pager
# 2. 确认持久化目录已建立
$ ls -lh /var/log/journal/
# 3. 确认上限生效后的占用
$ sudo journalctl --disk-usage
预期输出(status):
* systemd-journald.service - Journal Service
Active: active (running) since Tue 2026-09-29 08:00:00 CST; 2h ago
预期输出(disk-usage):
Archived and active journals take up 412.0M in the file system.
占用值小于等于 SystemMaxUse 即符合预期。
| 现象 | 根因 | 解决 |
|---|---|---|
重启后 journalctl 只剩本次开机日志 |
Storage=auto 且 /var/log/journal 不存在 |
建目录并设 Storage=persistent,重启 journald |
| 磁盘被 journal 撑满 | 没设 SystemMaxUse |
先 --vacuum-size 救急,再配 SystemMaxUse 治本 |
| 改了 journald.conf 不生效 | 配置写错了段,或没重启 journald | 确认写在 [Journal] 段下,执行 sudo systemctl restart systemd-journald |
--vacuum-size 清完占用反而没降多少 |
日志文件被占用无法立即收缩,或活跃 journal 未归档 | 重启 journald 让其归档后再清理;属正常现象 |
| 命令 | 作用 |
|---|---|
journalctl |
分页浏览全部日志(less 中 / 搜索、G 末尾、q 退出) |
journalctl -n 50 / -e |
看最近 50 行 / 直达末尾 |
journalctl -f / -r |
实时跟踪 / 反序显示 |
journalctl --since "1 hour ago" |
按时间窗过滤 |
journalctl -u nginx / -u a -u b |
按服务过滤 / 多服务(排服务故障第一命令) |
journalctl -p err -b |
本次开机的 err 及以上 |
journalctl -b -1 |
上一次开机的日志(查重启原因) |
journalctl --list-boots |
列出所有开机批次 |
journalctl _COMM=sshd / _PID= / _UID= |
按字段精确过滤 |
journalctl -g 关键词 |
全文模式搜索 |
journalctl -o short-iso / -o cat / -o json |
切换输出格式 |
journalctl --disk-usage |
查看 journal 磁盘占用 |
journalctl --vacuum-size=200M / --vacuum-time=30d |
一次性清理(不改上限) |
systemctl restart systemd-journald |
修改 journald.conf 后重启生效 |
systemctl status 与 journalctl 是黄金搭档)系统日志有 journalctl 这把瑞士军刀,但应用日志(写文件的那些)只能靠基础文本工具硬查。好消息是:三板斧——tail、grep、less——就能覆盖 90% 的场景。本章是「场景 → 命令」式的速查手册,建议对照自己服务器上的日志边看边敲。
[!IMPORTANT] 本章所有示例的
app.log泛指应用日志文件,请替换成你自己的路径(如/var/log/myapp/app.log)。读别人目录下的日志需要权限:加sudo,或把自己加入adm组(见 50 日志体系基础)。
应用日志排查的固定套路,由三个工具分工完成:
tail —— 看最新:日志是追加写的,最新内容永远在文件末尾;grep —— 过滤:几百万行里只留含 ERROR 的那几百行;less —— 浏览:打开整份日志慢慢翻、搜、跳。这三板斧解决「单机、看」的问题。当需求升级到「统计今天每种错误出现了多少次」「跨几十个文件聚合分析」,就该上 53 日志统计分析 的统计手段了。
三者的基本姿势:
# tail:看日志最后 100 行(-n 指定行数)
$ tail -n 100 app.log
# grep:只留含 ERROR 的行
$ grep ERROR app.log
# less:分页浏览,后续节详讲
$ less app.log
| 命令 | 关键参数 | 说明 |
|---|---|---|
tail |
-n N |
显示末尾 N 行,-n 100 可简写为 -100 |
tail |
-f / -F |
跟踪新内容(两者区别见下节,重要) |
grep |
-i |
忽略大小写 |
grep |
-v |
反选,排除匹配行 |
grep |
-n |
显示行号 |
less |
/关键词、n、N |
搜索、下一个、上一个匹配 |
用 logger 或自己的应用造一条测试日志,确认工具链工作正常:
# 追加一条测试行到日志
$ echo "2026-09-29 10:00:00 ERROR [test] 手工造的测试日志" >> app.log
# 用 grep 能找到它
$ grep "手工造的测试日志" app.log
预期输出:
2026-09-29 10:00:00 ERROR [test] 手工造的测试日志
| 现象 | 根因 | 解决 |
|---|---|---|
grep 卡住半天没结果 |
日志文件太大(GB 级) | 先用 less + /搜索,或按时间段缩小范围(见后文) |
| 输出哗哗刷屏看不清 | 没过滤直接 cat/less 到末尾 | 用 grep 收敛,或 tail 只看末尾 |
| 提示 Permission denied | 无读权限 | sudo,或加入 adm 组 |
调试的黄金姿势:一边操作,一边盯着日志滚动。页面上点一下按钮,马上看日志里打出了什么。tail -f 是这里的主角,但它有个致命的坑——日志轮转,必须用 -F 才能躲过。
# 跟踪 app.log 的新增内容(Ctrl+C 退出)
$ tail -f app.log
# 跟踪会轮转的日志,必须用 -F(原因见参数说明)
$ tail -F app.log
# 同时跟踪两个文件,tail 会以 ==> 文件名 <== 分隔显示
$ tail -F app.log error.log
# 边跟踪边过滤:只看 ERROR,且实时输出
$ tail -f app.log | grep --line-buffered ERROR
[!TIP] 记住一条铁律:跟踪会轮转的日志,永远用
tail -F。logrotate 一旦切割日志,-f就会悄悄失联,你会误以为「应用没在打日志」,白白排查半天。用-F没有任何代价,养成习惯即可。
-f 与 -F 的区别(本章最重要的一处): 日志轮转后,app.log 会被改名成 app.log.1,再新建一个空的 app.log 继续写。-f(follow)跟踪的是文件描述符——也就是旧文件,改名后它照样盯着已经不再更新的 app.log.1,从此再无输出,这叫「失联」。-F(follow + retry)跟踪的是文件名——发现旧文件不更新就重试打开同名新文件,轮转后无缝衔接。
| 写法 | 跟踪对象 | 轮转后表现 |
|---|---|---|
tail -f app.log |
文件描述符(旧文件) | 失联,不再有新输出 |
tail -F app.log |
文件名 | 自动切换到新文件,继续跟踪 |
--line-buffered 的必要性: 管道中的 grep 为了效率会攒够一大块(几 KB)才输出一次。交互式跟踪时,这意味着日志要等一会儿才「跳」出来,看起来像卡顿。--line-buffered 强制 grep 收到一行就立刻输出,恢复实时感。
less +F app.log 是另一个好用的跟踪姿势:进入 less 并自动跟踪;中途按 Ctrl+C 停下来用 /关键词 搜索,看完按 F 恢复跟踪——比 tail -f 只能 Ctrl+C 重来灵活得多。多文件重度跟踪可装 multitail(多窗口同屏,一句带过,sudo apt install multitail)。
# 开两个终端:终端 A 跟踪,终端 B 造日志
$ tail -F app.log | grep --line-buffered ERROR
# 终端 B:写入一条测试行
$ echo "2026-09-29 10:01:00 ERROR [test] 实时跟踪验证" >> app.log
预期输出(终端 A 立刻滚出):
2026-09-29 10:01:00 ERROR [test] 实时跟踪验证
| 现象 | 根因 | 解决 |
|---|---|---|
tail -f 突然没输出了 |
日志被轮转,-f 跟着旧文件失联 |
改用 tail -F,从此不再失联 |
| 管道 grep 后输出一阵一阵的 | grep 块缓冲导致的延迟 | 加 --line-buffered |
| 跟踪时文件被删了报错 | 应用重建了日志文件 | -F 会自动重试;确认应用日志路径没配错 |
grep Exception 有个经典陷阱:你只 grep 到了「Exception 那一行」,而 Java/Python 的异常堆栈是后面的十几行——单独一行 Exception 几乎没有排查价值,有价值的是堆栈。所以要给 grep 加上下文:把它前后的行一起捞出来。
-A 取匹配行之后的 N 行(After),-B 取之前 N 行(Before),-C 前后各 N 行(Context):
# 抓 Exception 及其后 20 行堆栈(最常用组合),再交给 less 慢慢看
$ grep -A 20 -i "exception" app.log | less
# 报错前 3 行:看看出错前程序在干什么
$ grep -B 3 ERROR app.log
# 前后各 10 行,完整的现场
$ grep -C 10 "Connection refused" app.log
[!TIP] 遇到异常先条件反射:
grep -A 20 -i "exception" app.log | less。Java 堆栈动辄几十行,-A 20是经验起步值,不够就加大。加-i是因为有的框架打的是EXCEPTION或小写。
| 参数 | 全称 | 说明 |
|---|---|---|
-A N |
After | 每个匹配行后附 N 行 |
-B N |
Before | 每个匹配行前附 N 行 |
-C N |
Context | 前后各 N 行 |
-- |
— | 输出分隔符,不是参数:grep 在多次匹配的分组之间输出 -- 分隔行,便于区分每次匹配的上下文 |
构造一个带堆栈的假日志验证上下文参数:
# 向测试文件写入两行
$ printf 'line before\nERROR boom\nstack frame 1\n' > test.log
# 抓 ERROR 及其后 1 行
$ grep -A 1 ERROR test.log
预期输出:
ERROR boom
stack frame 1
ERROR 后的一行跟着出现,说明 -A 生效。
| 现象 | 根因 | 解决 |
|---|---|---|
| grep 到 Exception 却看不到堆栈 | 没加 -A,上下文被过滤掉了 |
grep -A 20 起步 |
| 同一个错误重复出现,上下文输出太长 | 高频报错,每组都带 20 行 | 管道给 less 翻看,或先 grep -c 看总数 |
| 堆栈行本身不含 ERROR,单独 grep 搜不到 | 这是正常的,堆栈靠上下文带出 | 不要单独搜堆栈行,搜首行加 -A |
日志大到几百 MB 甚至几 GB 时,cat app.log 会把终端刷成瀑布,grep 全量扫描也很慢。正确策略:先摸清规模,再用 less 定点浏览。less 只加载当前屏,再大的文件也是秒开。
# 先看规模:行数和大小,心里有数
$ wc -l app.log
$ du -h app.log
# 打开浏览
$ less app.log
进入 less 后的常用操作:
| 按键 | 作用 |
|---|---|
/ERROR |
向下搜索(回车确认) |
n / N |
下一个 / 上一个匹配 |
G / g |
跳到文件末尾 / 开头 |
F |
进入跟踪模式(等价 tail -f),Ctrl+C 退回浏览 |
-n 回车 |
关闭行号显示(超大文件更快) |
# 摸底:看行数与大小
$ wc -l app.log && du -h app.log
预期输出:
2385171 app.log
1.2G app.log
230 万行、1.2 GB——这个量级,绝不 cat,直接 less + / 搜索。
| 现象 | 根因 | 解决 |
|---|---|---|
less 打开超大文件卡顿 |
行号计算在大文件上开销大 | 打开后输入 -n 回车关闭行号;或用 sed 直接取段 |
| 误操作 cat 了 1 GB 日志 | 想看内容随手 cat 了 | Ctrl+C 中断;养成先 wc -l、du -h 的习惯 |
/ 搜索等很久 |
关键词太泛,全文反复扫描 | 换更精确的关键词,或先按时间定位到区域再搜 |
logrotate 轮转过的旧日志是 .gz 压缩包(app.log.2.gz……),不能直接 grep。为它们准备的 z 系列工具(zgrep、zcat、zless)用法与原版完全一样,只是先解压再处理——不需要手动 gunzip 出一堆临时文件。
# 在压缩日志里搜错误
$ zgrep ERROR app.log.2.gz
# 浏览压缩日志
$ zless app.log.2.gz
# 等价写法:解压到管道
$ zcat app.log.2.gz | less
# 跨多天的归档统计:所有 .gz 里 ERROR 出现了多少次
$ zgrep -h "ERROR" app.log.*.gz | wc -l
| 工具 | 说明 |
|---|---|
zgrep |
= gunzip + grep,参数与 grep 相通(-A、-i 都能用) |
zcat |
= gunzip + cat,常接管道 |
zless |
= gunzip + less,分页浏览 |
-h |
grep 选项:不显示文件名前缀,让多文件结果干净些 |
# 造一个压缩日志并搜索
$ echo "ERROR in old log" | gzip > app.log.2.gz
$ zgrep ERROR app.log.2.gz
预期输出:
ERROR in old log
| 现象 | 根因 | 解决 |
|---|---|---|
| grep 直接读 .gz 输出乱码 | grep 不认 gzip 二进制 | 换 zgrep |
| 想搜的错既不在当前文件也不在归档里 | 已超过保留期被 logrotate 删除 | 无解;重要日志应在轮转策略中延长保留或归档备份 |
文本日志没有索引,「看 10:00 到 10:01 之间的日志」不能像 journalctl 那样一条参数搞定。套路是两步:先用 grep 拿到目标时间出现在第几行,再按行号区间取出来。
# 第一步:找时间点对应的行号(-n 显示行号,head -1 取第一个)
$ grep -n "2026-09-28 10:0" app.log | head -1
预期输出:
50000:2026-09-28 10:00:01 INFO [http] request received
# 第二步(方式一):sed 按行号区间打印 50000 到 50200 行
$ sed -n '50000,50200p' app.log
# 第二步(方式二):less 直接跳到 50000 行(+Ng 跳行号)
$ less +50000g app.log
# 第二步(方式三):最简单——直接在 less 里搜时间串
$ less app.log
# 进入后输入:/2026-09-28 10:01 回车,按 n 逐个跳
| 写法 | 说明 |
|---|---|
grep -n "时间前缀" |
-n 输出「行号:内容」;用时间前缀而非完整秒,一次命中多条 |
sed -n 'a,bp' |
只打印第 a 到 b 行,p 是 print |
less +Ng |
启动时跳到第 N 行 |
head -1 / head -3 |
只取前 1 行 / 3 行,避免刷屏 |
# 造数据并走一遍完整流程
$ printf '2026-09-29 09:59:00 INFO ok\n2026-09-29 10:00:01 ERROR boom\n' > app.log
$ grep -n "2026-09-29 10:00" app.log | head -1
预期输出:
2:2026-09-29 10:00:01 ERROR boom
行号 2 正确命中,再 sed -n '2,2p' app.log 即可取出该行。
| 现象 | 根因 | 解决 |
|---|---|---|
| grep 时间串一个都搜不到 | 日志时间格式与写法不一致 | 先 tail -3 app.log 看真实的格式照着写 |
| 定位到的时间点不对 | 日志时间戳是 UTC,与本地差 8 小时 | 换算时差再搜;根治见 15 网络配置 |
| sed 取的段落不含目标时间 | 日志乱序写入(多线程) | 适当扩大区间,或在结果里再 grep 一次 |
真实日志里充满噪音:心跳、健康检查、成功请求刷屏,把真正的错误淹没。多条件组合——错误之间取并集、排除已知噪音、统计数量——让日志从瀑布变成清单。
# 取并集:ERROR 或 FATAL 都要
$ grep -E "ERROR|FATAL" app.log
# 串联排除:是 ERROR,但排除心跳相关的已知噪音
$ grep ERROR app.log | grep -v "Heartbeat"
# 计数:今天 ERROR 出现了多少次
$ grep -c ERROR app.log
# 组合拳:错误、去重统计按次数排序(简单统计,进阶见 53 章)
$ grep ERROR app.log | sort | uniq -c | sort -rn | head
| 写法 | 说明 |
|---|---|
| `grep -E "A | B"` |
grep -v |
反选:排除匹配行,可串联多层 |
grep -c |
只输出匹配行数,不显示内容 |
| `sort | uniq -c |
# 造含噪音的日志,验证过滤链
$ printf 'ERROR timeout\nERROR Heartbeat lost\nINFO ok\n' > app.log
$ grep ERROR app.log | grep -v "Heartbeat"
预期输出:
ERROR timeout
Heartbeat 那条被排除,只剩真正的错误。
| 现象 | 根因 | 解决 |
|---|---|---|
| 排除噪音后什么都不剩 | 过滤条件太狠,把错误也排掉了 | 逐个去掉 -v 条件二分排查 |
| `grep "ERROR | FATAL"` 匹配不到 | 默认 BRE 中 ` |
| 统计结果每次不一样 | 应用还在持续写日志 | 统计前明确时间范围,或先拷贝一份静态文件 |
偶尔遇到「日志」根本不是纯文本:二进制格式(某 SDK 的 trace 文件)或编码不是 UTF-8(GBK 的 Windows 日志)。硬 grep 只会输出乱码或报「binary file matches」。处理思路:二进制先用 strings 抽取可读字符串;编码不对用 iconv 转码。这两招够应付偶发场景,深入日志解析交给 53 日志统计分析。
# 从二进制文件中抽出可读字符串再 grep
$ strings trace.bin | grep -i error
# 把 GBK 编码的日志转成 UTF-8 再看
$ iconv -f GBK -t UTF-8 app_gbk.log | less
| 命令 | 说明 |
|---|---|
strings 文件 |
打印文件中所有长度 ≥ 4 的可打印字符串 |
iconv -f 源编码 -t 目标编码 |
字符编码转换;-c 可跳过无法转换的字符 |
# 造一个 GBK 编码文件验证转码(iconv 可用时)
$ printf '\xc4\xe3\xba\xc3' | iconv -f GBK -t UTF-8
预期输出:
你好
乱码字节变成了可读中文,说明转码链路可用。
| 现象 | 根因 | 解决 |
|---|---|---|
| grep 提示 binary file matches | 文件含二进制字节 | grep -a 强制按文本处理,或先 strings |
| 中文全显示成问号或乱码 | 文件是 GBK,终端是 UTF-8 | iconv -f GBK -t UTF-8 转码后查看 |
高频场景直接抄,不必现场推导:
| 场景 | 命令 |
|---|---|
| 看最新 100 行错误 | tail -100 app.log | grep ERROR |
| 实时盯错误(轮转安全) | tail -F app.log | grep --line-buffered ERROR |
| 找今天的异常堆栈 | grep -A 20 "2026-09-29" app.log | grep -A 20 -i exception | less |
| 看某时间段日志 | grep -n "2026-09-28 10:0" app.log | head -1 再 sed -n '起,止p' |
| 轮转前的错误还在吗 | zgrep ERROR app.log.2.gz |
| 两个日志按时间合并看 | cat a.log b.log | sort | less |
| 统计今天错误总数 | grep -c "2026-09-29.*ERROR" app.log |
| 错误去重排行 | grep ERROR app.log | sort | uniq -c | sort -rn | head |
| 排除健康检查噪音 | grep ERROR app.log | grep -v -E "health|heartbeat" |
| 在大文件里定位关键词 | less app.log 后 /关键词,n 跳下一个 |
| 监控同时能随时搜索 | less +F app.log,Ctrl+C 搜索,F 恢复 |
| 找 404 最多的 URL(nginx) | grep " 404 " /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn |
| 命令 | 作用 |
|---|---|
tail -n 100 app.log |
看最新 100 行 |
tail -F app.log |
跟踪日志(轮转安全,优先于 -f) |
tail -f a.log b.log |
同时跟踪多个文件 |
tail -f app.log | grep --line-buffered ERROR |
跟踪并实时过滤 |
less +F app.log |
可暂停搜索的跟踪模式 |
grep -A 20 -i "exception" app.log |
抓异常及堆栈 |
grep -C 10 关键词 app.log |
取前后各 10 行上下文 |
wc -l app.log / du -h app.log |
先摸行数与大小 |
zgrep ERROR app.log.2.gz |
在压缩归档里搜索 |
zgrep -h ERROR app.log.*.gz | wc -l |
跨归档统计错误数 |
grep -n "时间串" app.log | head -1 |
定位时间点行号 |
sed -n '50000,50200p' app.log |
按行号取时间段 |
grep -E "ERROR|FATAL" app.log |
多关键词并集 |
grep ERROR app.log | grep -v "Heartbeat" |
排除噪音 |
grep -c ERROR app.log |
错误计数 |
strings x.bin | grep -i error |
二进制文件抽取文本 |
iconv -f GBK -t UTF-8 x.log |
转码查看 |
-F 之所以重要的根源)上一章(52 应用日志查看与过滤技巧)解决的是「找到那几行日志」;本章解决的是「日志整体长什么样」——今天有多少请求?5xx 占比多少?哪个 IP 访问最猛?哪个接口最慢?这些问题的答案都藏在统计数字里,而不在某一行日志里。
[!IMPORTANT] 本章所有示例基于 Nginx 的 access.log。请把
access.log替换成你自己的路径(如/var/log/nginx/access.log)。读 Nginx 日志可能需要sudo(原因见 50 日志体系基础)。
awk 按「空白字符」把每行拆成字段,$1 是第 1 个字段、$2 是第 2 个……所有统计命令都靠字段编号取数。所以动手之前,先拿一行真实日志,弄清楚每个位置是什么。下面是 Nginx 默认 combined 格式再追加一个请求耗时($request_time)的样例行(截至 2026-09 的主流配置写法):
192.168.1.25 - - [29/Sep/2026:10:00:03 +0800] "GET /api/items?page=2 HTTP/1.1" 200 1876 "https://example.com/list" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" 0.058
把样例行按空格拆开,逐个编号对照:
| 字段 | 值 | 含义 |
|---|---|---|
$1 |
192.168.1.25 |
客户端 IP |
$2 |
- |
远端用户标识(通常为空) |
$3 |
- |
认证用户名(通常为空) |
$4 |
[29/Sep/2026:10:00:03 |
时间开头(带 [) |
$5 |
+0800] |
时区(带 ]) |
$6 |
"GET |
请求方法(带引号) |
$7 |
/api/items?page=2 |
请求路径与参数 |
$8 |
HTTP/1.1" |
协议版本(带引号) |
$9 |
200 |
状态码 |
$10 |
1876 |
响应字节数 |
$11 |
"https://example.com/list" |
Referer(仅当值不含空格时成立,含空格则其后字段整体偏移) |
$12 |
"Mozilla/5.0 ..." |
User-Agent(仅当值不含空格时才成立;真实 UA 几乎都含空格,完整取法见「User-Agent 与爬虫识别」一节的 -F'"') |
$NF |
0.058 |
请求耗时(秒)。UA 含空格时耗时不在固定编号上,只能用 $NF(最后一个字段)取 |
$NF 表示「最后一个字段」:日志行很长时数编号容易错,取最后一个字段直接用 $NF 更稳;$(NF-3) 表示「倒数第 4 个字段」,安全排查一节会用到;$http_x_forwarded_for),字段号会整体偏移,请先按自己的日志重新做一张对照表,再套用本章命令。不确定字段号对不对?用 awk 打印单个字段看看:
$ awk '{print $1, $9}' access.log
预期输出:
192.168.1.25 200
203.0.113.8 404
192.168.1.25 500
打印出来的正是「IP + 状态码」,说明字段编号没数错。
| 现象 | 根因 | 解决 |
|---|---|---|
| 取出的值和预期错位 | 你的日志格式不是本例格式,字段号偏移 | 用本节方法重新对照一遍字段编号 |
本章 80% 的统计都是同一条流水线的变形:
grep 过滤 | awk 取字段 | sort | uniq -c | sort -rn
五个环节分工明确:grep 只留需要的行 → awk 把每行缩成关心的那个字段 → sort 把相同值排到一起 → uniq -c 数个数 → sort -rn 按次数从高到低排。
最典型的问题:「哪个 URL 被请求得最多?」
$ awk '{print $7}' access.log | sort | uniq -c | sort -rn | head -10
预期输出:
3512 /api/items
2107 /api/items?page=2
1880 /
| 环节 | 作用 | 关键点 |
|---|---|---|
sort |
排序 | uniq 只会合并相邻的相同行,所以必须先 sort |
uniq -c |
相邻去重并计数 | 输出格式是「次数 + 值」 |
sort -rn |
按数值逆序 | -n 按数字比(否则 9 会排在 10 后面),-r 从大到小 |
head -10 |
只看前 10 名 | 大文件必加,否则全量输出刷屏 |
[!IMPORTANT] 「先 sort 再 uniq」是初学者最容易踩的坑:uniq 不做全量去重,只合并相邻行。漏掉中间的 sort,你会得到一堆重复的、零零散散的计数结果,而不是一张干净的排行榜。
拿样例日志里的几行手工核对:如果 /api/items 在日志里肉眼可见地出现最频繁,而命令的第一名也是它,流水线就是对的。
| 现象 | 根因 | 解决 |
|---|---|---|
| 结果里同一个值出现多次 | 忘了先 sort |
在 uniq -c 前补上 sort |
| 排序结果 9 在 10 前面 | sort 没加 -n,按字典序比数字 |
改用 sort -rn |
| 终端刷屏停不下来 | 没加 head,全量输出 |
管道末尾加 head -N |
运营和排障最常用的两组数字:每分钟请求数(流量曲线,看峰值、看是否被打)和状态码分布(健康度,5xx 多说明服务在报错)。
每分钟请求数。Nginx 时间字段 $4 形如 [29/Sep/2026:10:00:03,用 substr 从第 2 个字符开始截 17 个字符,正好得到 [29/Sep/2026:10:00(精确到分钟):
$ awk '{print substr($4,2,17)}' access.log | sort | uniq -c
预期输出:
812 29/Sep/2026:10:00
834 29/Sep/2026:10:01
3010 29/Sep/2026:10:02
状态码分布:
$ awk '{print $9}' access.log | sort | uniq -c | sort -rn
预期输出:
8631 200
712 301
402 404
88 500
21 502
只揪出 5xx,连出错的 URL 一起看:
$ awk '$9 >= 500 {print $9, $7}' access.log | sort | uniq -c | sort -rn | head
预期输出:
88 500 /api/items
21 502 /api/orders
4 503 /api/items?page=2
再进一步:每个状态码的每分钟曲线(把分钟和状态码一起取出来再计数),一眼定位「几点几分开始爆炸」:
$ awk '{print substr($4,2,17), $9}' access.log | sort | uniq -c
预期输出:
790 29/Sep/2026:10:00 200
22 29/Sep/2026:10:00 500
340 29/Sep/2026:10:02 500
substr($4,2,17):从 $4 的第 2 个字符起截 17 个字符。第 1 个字符是 [,跳过;17 个字符恰好是「日/月/年:时:分」;$9 >= 500:awk 允许在 {动作} 前写条件,只有满足条件的行才执行动作——相当于内置的 grep;29/Sep/2026:10:00:03 含秒共 20 个字符),但秒级输出行数会暴涨,一般加 head 控制。统计总数是否对得上,是最可靠的验证:所有状态码计数相加,应等于日志总行数:
$ wc -l access.log
预期输出:
9854 access.log
上面状态码分布示例中 8631+712+402+88+21 = 9854,对上了。
| 现象 | 根因 | 解决 |
|---|---|---|
| 每分钟统计的行数特别碎 | 忘了 sort,uniq 没合并相邻行 |
加上 sort |
| 5xx 过滤没结果但明明有 500 | $9 位置取错,取到了别的字段 |
先 awk '{print $9}' 抽查确认 |
两类排行榜:谁访问最多(TOP N 客户端 IP,找爬虫、找攻击源、看真实用户分布)和什么请求最慢(TOP N 慢请求,性能优化的靶子)。
访问量前 20 的 IP:
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20
预期输出:
5201 203.0.113.8
812 192.168.1.25
445 198.51.100.3
最慢的请求(耗时从高到低)。本例日志的最后一个字段 $NF 是请求耗时(纯数字秒):
$ awk '($NF+0 >= 1){print $NF, $7}' access.log | sort -rn | head
预期输出:
12.452 /api/report/export
8.310 /api/items?page=100000
3.092 /api/orders
$NF+0 >= 1:$NF 是字符串,+0 把它转成数字再比较,筛出耗时≥1 秒的请求;如果你的日志把耗时记成 0.058s 这种带单位的格式,可改用 $NF ~ /s$/ 匹配;sort -rn 对「耗时 IP」这种以数字开头的行同样适用,按行首数值逆序;head 默认取前 10 行,head -20 取前 20 行。抽排行榜第一名的 IP,回日志里数一数:
$ grep -c "^203.0.113.8 " access.log
预期输出:
5201
计数一致,说明 $1 取对了。
| 现象 | 根因 | 解决 |
|---|---|---|
| 第一名是本机或负载均衡 IP | 请求经过了反向代理,$1 是代理地址 |
改用 X-Forwarded-For 真实 IP 字段(需在 Nginx 日志格式里加 $http_x_forwarded_for) |
| 慢请求排行是空的 | 耗时字段位置不对,或所有请求都小于 1 秒 | `awk '{print $NF}' access.log |
出事故时,你只关心「10:00 到 10:05 之间发生了什么」。从上千万行日志里截出这个时间窗,是日志分析的高频动作。有两种方法。
方法一:awk 字符串比较,精确截取(推荐):
$ awk '$4 >= "[29/Sep/2026:10:00" && $4 < "[29/Sep/2026:10:05"' access.log > window.log
方法二:grep 粗取,先把范围收窄:
$ grep "29/Sep/2026:10:0" access.log > window.log
方法一为什么能用字符串比较比时间?因为这种日志格式是固定的:日/月/年:时:分:秒 每一位的字符宽度含义都固定,月永远是三个字母、数字都补齐到位——字符串的字典序就等于时间先后序。"[29/Sep/2026:10:03" >= "[29/Sep/2026:10:00" 和「10:03 晚于 10:00」是同一件事。前提是日志保持这个固定宽度格式;如果你的时间格式是 9/Sep 这种不补零的,字典序就不等于时间序,方法一会出错。
$4 是时间字段,左右都带 [,所以条件里也要写 [;grep 匹配子串,10:0 能同时命中 10:00~10:09 整十分钟,快但边界粗,适合先粗后细。[!TIP] 实战顺序:先用方法二粗取一个较小的时间段存成
window.log,后面所有统计都在这个小文件上跑——比每次都全量扫描大日志快得多。
截取完检查窗口两端是否正确:
$ head -1 window.log
$ tail -1 window.log
预期输出:
192.168.1.25 - - [29/Sep/2026:10:00:01 +0800] "GET / HTTP/1.1" 200 1876 "-" "Mozilla/5.0" 0.003
203.0.113.8 - - [29/Sep/2026:10:04:59 +0800] "GET /api/items HTTP/1.1" 500 320 "-" "python-requests/2.31" 1.412
首行是 10:00 出头、末行是 10:04 结尾,窗口截对了。
| 现象 | 根因 | 解决 |
|---|---|---|
| awk 截取结果为空或不全 | 日志时间不补零,字典序≠时间序 | 改用 grep 粗取法,或先确认格式固定 |
| 截到了前一天/后一天的数据 | 条件里没带日期,只写了 10:00 |
条件必须写全年月日:[29/Sep/2026:10:00 |
应用日志(如 Java 的异常堆栈)一行就是一个报错,成千上万行里到底有几种错、各出现多少次?这需要对「匹配到的片段」聚合,而不是对整行聚合。
统计出现最多的异常类型:
$ grep -oE "Exception: [A-Za-z.]+" app.log | sort | uniq -c | sort -rn | head
预期输出:
412 Exception: NullPointerException
187 Exception: TimeoutException
grep -o:只输出匹配到的部分,而不是包含匹配的整行——这是本命令的灵魂。没有 -o,每行日志整行都被输出,时间戳、堆栈全都不一样,uniq 根本合并不了;grep -E:扩展正则,[A-Za-z.]+ 表示「字母或点号,一个及以上」,正好吃下 java.lang.NullPointerException 这类全限定名。该模式假定日志形如「Exception: 类型名」(常见于自定义日志封装);Java 原生堆栈的异常行是 java.lang.NullPointerException: msg 开头,建议改用 [A-Za-z.]+Exception 类模式匹配;核对某一种异常的真实出现次数:
$ grep -c "NullPointerException" app.log
预期输出:
412
| 现象 | 根因 | 解决 |
|---|---|---|
| 统计不出类别,全是 1 | 没加 -o,整行参与 uniq |
grep 加 -o 只留匹配片段 |
| 类别名被截断 | 正则没覆盖数字或下划线 | 扩大字符集,如 [A-Za-z0-9_.]+ |
前面都是「数行数」,这一节是「算数值」:平均响应体多大?哪个接口累计吃掉了最多的耗时?这类需求用 awk 的累加变量和关联数组完成。
平均响应大小($10 是响应字节数):
$ awk '{sum+=$10} END {print sum/NR}' access.log
预期输出:
1523.7
按接口($7)累计总耗时,找出「耗时大户」:
$ awk '{a[$7]+=$NF} END {for(k in a) print a[k], k}' access.log | sort -rn | head
预期输出:
45210.3 /api/report/export
12890.5 /api/items
4102.2 /api/orders
sum+=$10:awk 里变量不用声明,+= 对每行累加 $10 的值;NR:awk 内置变量,当前已读的总行数,到 END 时就是全部行数,sum/NR 即平均值;a[$7]+=$NF:a 是关联数组(下标可以是字符串的数组),效果是「以 URL 为 key,把每行的耗时累加进去」;END {for(k in a) ...}:END 块在所有行读完之后执行一次;for(k in a) 遍历数组所有 key,打印「累计耗时 + URL」。用小样本验证:造一个两行的文件手算核对。
$ printf 'x - - [t] "GET /a HTTP/1.1" 200 100 "r" "ua" 1.0\nx - - [t] "GET /b HTTP/1.1" 200 300 "r" "ua" 3.0\n' > t.log
$ awk '{sum+=$10} END {print sum/NR}' t.log
预期输出:
200
(100+300)/2=200,公式正确,验证完删除测试文件 rm t.log。
| 现象 | 根因 | 解决 |
|---|---|---|
| 平均值算出 0 或除零错 | 字段号取到了非数字列 | 对照字段表确认 $10 确实是字节数 |
| 累计耗时排序乱 | 结果是「数值 + 文本」,忘了 -n |
用 sort -rn 按行首数字排 |
日志的第 12 个字段是 User-Agent(浏览器标识)。正常的浏览器 UA 千姿百态但都含 Mozilla;爬虫、扫描器、脚本则常常报出 python-requests、curl、Go-http-client 或干脆留空。按 UA 排行,能快速发现「谁在机器式地扫你」。
UA 字段内部含空格,普通空格分列取 $12 只能取到 Mozilla/5.0 一个词。改用 -F'"' 按双引号分列:整行被引号切成若干段,请求行、Referer、UA 都成了完整字段,UA 恰好是第 6 段($6):
$ awk -F'"' '{print $6}' access.log | sort | uniq -c | sort -rn | head
预期输出:
6210 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36
1402 python-requests/2.31.0
553 Go-http-client/2.0
402 -
187 sqlmap/1.8
python-requests 数千次、还有 sqlmap(知名注入扫描工具,截至 2026-09 仍是最常见的扫描器之一)——这两位就该去防火墙里拉黑了。
-F'"':指定 awk 的字段分隔符为双引号。按引号切分后:$1 是引号前的头、$2 是请求行(GET /xx HTTP/1.1)、$4 是 Referer、$6 是 UA;- 是「UA 为空」的请求,大多数客户端工具不发 UA 头,大量空 UA 通常不是真人;grep 该 UA 抽出具体请求路径和 IP 看行为。确认按引号分列的段号:
$ awk -F'"' '{print $2, $6}' access.log | head -3
预期输出:
GET / HTTP/1.1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36
GET /api/items HTTP/1.1 python-requests/2.31.0
第 2 段是请求行、第 6 段是 UA,分列正确。
| 现象 | 根因 | 解决 |
|---|---|---|
$6 取出来是请求行而不是 UA |
引号配对数量和预期不同(如 Referer 缺失) | awk -F'"' '{print $2,$4,$6}' 抽查每段内容再定段号 |
| 想过滤 UA 但引号转义报错 | shell 引号嵌套冲突 | 用 -F'"' 这种「外单内双」写法,避免外层也是双引号 |
/var/log/auth.log 记录了 SSH 登录。公网机器每天被成千上万次暴力破解尝试是常态(截至 2026-09 依然如此),统计「谁失败得最多」,是安全自查的基本功。
登录失败来源 IP 排行。auth.log 的失败行形如 ... Failed password for invalid user admin from 203.0.113.5 port 41024 ssh2,倒数第 4 个字段($(NF-3))正是来源 IP:
$ grep "Failed password" /var/log/auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head
预期输出:
8412 203.0.113.5
320 198.51.100.77
45 192.168.1.25
$(NF-3):括号必不可少。$NF-3 会被理解成「$NF 的值减 3」(对文本是 0),而 $(NF-3) 才是「先算 NF-3 再取那个字段」;for root / for invalid user admin)影响——这就是为什么不用固定的 $10。抽一个 IP 确认它真的在失败记录里:
$ grep "Failed password" /var/log/auth.log | grep "203.0.113.5" | head -2
预期输出:
Sep 29 03:11:02 web sshd[2881]: Failed password for invalid user admin from 203.0.113.5 port 41024 ssh2
Sep 29 03:11:05 web sshd[2917]: Failed password for root from 203.0.113.5 port 41038 ssh2
| 现象 | 根因 | 解决 |
|---|---|---|
| 输出的是端口数字而不是 IP | 你的系统日志格式不同,倒数位置偏移 | 手工看一行失败日志,重新数 NF 位置 |
| 排行第一名是自己的 IP | 自己忘了密码重试多次 | 正常现象,关注陌生公网 IP 即可 |
| 找不到 auth.log | rsyslog 未安装或未运行 | 22.04 默认装有 rsyslog,auth.log 应存在(24.04 起才默认不装);先 systemctl status rsyslog 确认在运行,或改查 journald:journalctl -u ssh(见 51 journalctl) |
发现大量失败尝试后的加固手段(改端口、禁密码登录、fail2ban)见 18 SSH 加固。
统计结果直接打在终端上是「看完即走」;把结果存进文件,才能留档和对比——比如今天的 TOP IP 和昨天比,新增面孔就是可疑信号。
用重定向 > 把结果写到文件:
$ awk '$9 >= 500 {print $9, $7}' access.log | sort | uniq -c | sort -rn > today_5xx.txt
对比两天的结果:
$ diff today_5xx.txt yesterday_5xx.txt
>:把标准输出写入文件,覆盖已有内容;追加用 >>;diff 文件1 文件2:逐行比较,< 开头是文件 1 独有,> 开头是文件 2 独有——新增的错误接口一目了然。$ cat today_5xx.txt | head -3
预期输出:
88 500 /api/items
| 现象 | 根因 | 解决 |
|---|---|---|
| 文件被覆盖,昨天的数据没了 | 用了 > |
留档用 >> 追加,或文件名带上日期 |
| diff 输出看不懂 | 不熟悉 </> 标记 |
< 是第一个文件的内容,> 是第二个文件的 |
本章的一行流命令适合临时、单机、事后的分析。如果你需要实时大盘、持续趋势、跨多台机器聚合,该换专业工具了,不必用 awk 硬扛。
按投入从低到高,三个台阶(截至 2026-09):
GoAccess 最常用的一条:
$ goaccess /var/log/nginx/access.log --log-format=COMBINED
$ goaccess --version
预期输出:
GoAccess - 1.5.5
(版本以 Ubuntu 22.04 官方源 apt install goaccess 安装的为准;官方源外自装的新版以 goaccess --version 实际输出为准,版本号参考 91 版本速查表 的核对方法。)
| 现象 | 根因 | 解决 |
|---|---|---|
| GoAccess 报格式不识别 | 日志不是标准 combined 格式 | 用 --log-format 手工描述各字段 |
| ELK 服务器内存吃紧 | Elasticsearch 默认堆太大 | 调低 JVM 堆,或降级用 Loki |
| 命令 | 作用 |
|---|---|
awk '{print $1}' f | sort | uniq -c | sort -rn | head |
统计三件套:TOP N 排行 |
awk '{print substr($4,2,17)}' f | sort | uniq -c |
每分钟请求数 |
awk '{print $9}' f | sort | uniq -c | sort -rn |
状态码分布 |
awk '$9 >= 500 {print $9, $7}' f | sort | uniq -c | sort -rn |
5xx 及出错 URL |
awk '{print substr($4,2,17), $9}' f | sort | uniq -c |
每分钟×状态码曲线 |
awk '$4 >= "[D/M/Y:H:M" && $4 < "[D/M/Y:H:M"' |
按时间窗截取日志 |
grep -oE "pattern" f | sort | uniq -c | sort -rn |
异常类型聚合(-o 是关键) |
awk '{sum+=$10} END {print sum/NR}' f |
求平均值 |
awk '{a[$7]+=$NF} END {for(k in a) print a[k],k}' f |
按字段分组累计 |
awk -F'"' '{print $6}' f | sort | uniq -c | sort -rn |
User-Agent 排行 |
grep "Failed password" /var/log/auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn |
SSH 登录失败 IP 排行 |
cmd > file / diff a b |
结果存档与对比 |
上一章(53 日志统计与分析)教你怎么读日志,本章解决一个更根本的问题:怎么让日志文件不把磁盘吃光。答案是 logrotate——Ubuntu 自带、几乎所有软件包都在用的日志轮转工具。学完本章,你能给任何应用配一条「自动切割、压缩、清理」的日志流水线。
日志是追加写的:服务每处理一个请求就往文件末尾加一行,只进不出。没有轮转机制的日志文件只有一个结局——无限增长。三大件随之而来:
app.log 定期改名成 app.log-20260929,再新建一个空的 app.log 接着写;先感受一下不轮转的后果——找找你机器上已经失控的日志:
$ sudo du -sh /var/log/* 2>/dev/null | sort -rh | head
预期输出:
14G /var/log/myapp
3.2G /var/log/journal
1.1G /var/log/syslog
(du -sh /var/log/* 的通配只匹配第一层,myapp 显示为目录本身;想看目录内部明细用 sudo du -ah --max-depth=2 /var/log。)一个 14G 的 myapp 目录(里面一个失控的 app.log 就占掉绝大部分),就是下一章(55 磁盘满排查)「磁盘占满」事故的经典起因。
轮转解决三个问题:
| 问题 | 无轮转的后果 | 轮转后的效果 |
|---|---|---|
| 单文件过大 | grep/less 打开卡死 | 每个文件都不大,按日期定位 |
| 磁盘被吃光 | 服务写日志失败、进程崩溃 | 旧文件压缩 + 过期删除,总量有上界 |
| 排查困难 | 混在一个巨文件里 | 按天切分,天然对应「哪天出的事」 |
确认系统里 logrotate 已就位(Ubuntu 22.04 默认自带,截至 2026-09):
$ logrotate --version
预期输出:
logrotate 3.19.0
| 现象 | 根因 | 解决 |
|---|---|---|
| 日志文件几十 GB 才发现 | 没人看磁盘,应用自己不轮转 | 按本章配置 logrotate,并加磁盘监控 |
| 删了大日志磁盘没释放 | 进程握着旧文件句柄 | 见 55 磁盘满排查 的 deleted 场景 |
logrotate 本身不是常驻进程,而是一个被定时任务周期性拉起的命令。每次运行时,它读取配置,检查哪些日志「该切了」,然后执行切割、压缩、清理。
它由两部分驱动:
# Ubuntu 22.04 的定时入口(systemd 系统用 timer 调度)
$ systemctl cat logrotate.timer
预期输出:
# /lib/systemd/system/logrotate.timer
[Unit]
Description=Daily rotation of log files
[Timer]
OnCalendar=daily
Persistent=true
配置则分两层:
# 主配置:全局默认值 + 收编分文件
/etc/logrotate.conf
# 分文件配置:每个软件一份(安装软件包时通常自带)
/etc/logrotate.d/
├── nginx
├── rsyslog
├── mysql-server
└── apt
/etc/cron.daily/logrotate:传统 cron 系统的每日触发脚本;systemd 系统(包括 Ubuntu 22.04)由 logrotate.timer 每天触发一次,效果等价;/etc/logrotate.conf 末尾有一句 include /etc/logrotate.d,把目录下所有分文件配置收编进来——所以日常你只改 /etc/logrotate.d/ 下的文件,不动主配置;nginx、rsyslog)开箱即用;你自己写的应用,需要自己放一份配置进去,这正是本章的重点。确认 timer 在跑、以及上次轮转的时间:
$ systemctl status logrotate.timer
预期输出:
● logrotate.timer - Daily rotation of log files
Active: active (waiting) since Mon 2026-09-28 06:25:03 CST
Trigger: Tue 2026-09-29 06:25:11 CST; 8h left
Active: active (waiting) 且有下次触发时间,机制正常。
| 现象 | 根因 | 解决 |
|---|---|---|
| 改了配置一直没生效 | timer 没跑或机器不到触发点 | 见本章「调试与验证」,用 -d/-f 手动验证 |
| 找不到 /etc/logrotate.d/ | logrotate 未安装 | sudo apt install logrotate |
一份 logrotate 配置 = 全局指令 + 针对某个日志文件的块。指令不多,但每条都值得逐个讲透——尤其 size 与 daily 的关系、delaycompress、copytruncate 这几个反直觉的点。
先看一个典型配置长什么样:
/var/log/myapp/app.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
dateext
create 0640 appuser appgroup
postrotate
systemctl reload myapp
endscript
}
逐条拆解(截至 2026-09,logrotate 3.19 的常用指令):
| 指令 | 含义 | 要点 |
|---|---|---|
daily / weekly |
每天/每周轮转一次 | 按时间触发 |
rotate 14 |
保留最近 14 份归档 | 第 15 份出现时最老的被删;daily + rotate 14 ≈ 保留两周 |
size 100M |
超过 100M 才轮转 | 只看大小:配置了 size 后,时间间隔(daily/weekly)失效,仅当超过尺寸才切 |
maxsize 100M |
与时间指令并用:时间或大小,先到先切 | 兼得「按天轮转」与「尺寸兜底」的正解 |
minsize 100M |
与时间指令并用:到时间且同时超过尺寸才切 | 与 maxsize 相反的「且」关系 |
missingok |
日志文件不存在就跳过 | 不报错。没有它,文件一缺失 logrotate 就返回错误 |
notifempty |
空文件不轮转 | 省得切出一堆无意义的空归档 |
compress |
归档用 gzip 压缩 | 文本日志压缩比极高,强烈建议 |
delaycompress |
最近一份归档暂不压缩 | 见下方专段解释 |
dateext |
归档后缀用日期 | app.log-20260929 而不是 app.log.1,一眼看出是哪天的 |
create 0640 appuser appgroup |
切割后新建文件并指定权限和属主 | 应用是以 appuser 跑的,新文件必须归它,否则它写不进 |
copytruncate |
复制再清空,不动原文件 | 与 create 模式二选一,见下节 |
postrotate / endscript |
轮转后执行的脚本 | 用来通知进程重开日志文件 |
[!IMPORTANT] logrotate 默认每天只被 timer 拉起检查一次——所以配了
size/maxsize也不是「一到 100M 立刻切」,而是「下次检查时发现超了才切」。要让尺寸条件在当天内及时生效,需把规则挂到 cron.hourly 或配置更高频的 timer。
用 -d(dry run)检查配置语法,后面专讲。这里先验证指令拼写:任何拼错的指令都会在 dry run 中报 unknown option。
$ sudo logrotate -d /etc/logrotate.d/myapp 2>&1 | tail -5
预期输出:
reading config info for /var/log/myapp/app.log
Handling 1 logs
rotating pattern: /var/log/myapp/app.log after 1 days (14 rotations)
empty log files are not rotated, old logs are removed
配置被正确解析、模式与预期一致,语法就没问题。
| 现象 | 根因 | 解决 |
|---|---|---|
| 报 unknown option 'daily' | 指令缩进或花括号位置错了 | 指令前用空格缩进,花括号独占一行,分号脚本用 endscript 收尾 |
| 轮转从不发生 | size 条件没达到,或 daily 还没到期 | cat /var/lib/logrotate/status 看上次轮转时间 |
| 新日志文件应用写不进 | create 的属主/权限与应用进程不匹配 | 改成运行应用的用户的可写权限 |
轮转有个绕不开的矛盾:进程打开日志文件后握着文件句柄(fd)一直写,你把文件改了名,进程照样往原名对应的 inode 里写——它不知道文件被换了。logrotate 有两种哲学解决这个问题,选错一种就出事故。
方式一(默认,create 模式):改名旧文件 → 新建同名空文件 → postrotate 里通知进程重新打开日志文件:
/var/log/nginx/access.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
dateext
create 0640 www-data adm
sharedscripts
postrotate
# 通知 nginx 重新打开日志文件(USR1 信号)
[ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
endscript
}
方式二(copytruncate 模式):先把原文件内容拷贝成归档,再把原文件原地清空。进程全程不知道发生了什么,继续往原 fd 写(只是文件长度归零了):
/var/log/myapp/app.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
dateext
copytruncate
}
两种方式逐项对比:
| create + postrotate(默认) | copytruncate | |
|---|---|---|
| 原理 | 改名旧文件、建新文件,通知进程切换 fd | 拷贝内容后原地清空,进程无感知 |
| 丢日志风险 | 无(切换瞬间日志也由旧 fd 落盘,不丢) | 拷贝到清空之间写入的那几条日志会随清空丢失 |
| 通用性 | 必须知道怎么通知进程(信号/reload 命令) | 完全通用,应用不用配合 |
| 适用 | nginx、rsyslog 等支持信号重开的成熟软件 | 自研应用、第三方闭源程序、不便 reload 的进程 |
| postrotate | 需要 | 不需要 |
delaycompress 为什么存在?create 模式下,轮转后到进程切换 fd 之前,可能还有最后几条日志写进旧文件;立刻压缩会导致这几条读不出来或压缩出错。delaycompress 让最近一份归档保持明文,下次轮转时才压缩它——多留一天,避开句柄窗口期。
sharedscripts:多个日志文件匹配同一个块时,postrotate 脚本只执行一次(而不是每个文件执行一次)。
强制轮转一次,亲眼看看两种方式各自的产物:
$ sudo logrotate -f /etc/logrotate.d/myapp
$ ls -lh /var/log/myapp/
预期输出:
total 12K
-rw-r----- 1 appuser appgroup 0 Sep 29 10:00 app.log
-rw-r----- 1 appuser appgroup 2.1M Sep 28 23:59 app.log-20260929
app.log 变成 0 字节、昨天的内容进了带日期的归档,轮转成功。
| 现象 | 根因 | 解决 |
|---|---|---|
| 轮转后应用还在往归档文件里写 | create 模式但没通知进程(缺 postrotate) | 补 postrotate,或改用 copytruncate |
| copytruncate 后日志内容错乱/丢条目 | 拷贝瞬间应用正在写 | 可接受就没问题;不可接受就换 create 模式 |
| nginx 轮转后新日志没进新文件 | USR1 信号没发出去(pid 文件路径不对) | 确认 cat /var/run/nginx.pid 拿到的是真实 pid |
把你自己的 Java/Python 应用接入 logrotate,只需往 /etc/logrotate.d/ 放一份配置文件。下面是一份可抄的模板,逐行注释。
新建 /etc/logrotate.d/myapp(内容如下,假设应用以 appuser 运行、无法重开日志文件,故用 copytruncate):
# 要轮转的日志路径,可以写多个,每行一个
/var/log/myapp/app.log
/var/log/myapp/error.log
{
# 每天轮转一次
daily
# 保留最近 14 份归档,更旧的自动删除
rotate 14
# 尺寸兜底:与 daily 并用,时间或大小先到先切
# (注意 logrotate 默认每天只检查一次,要让它在当天内及时生效需更高频触发,见上文 IMPORTANT)
maxsize 100M
# 文件不存在就跳过,不报错
missingok
# 空文件不轮转
notifempty
# 归档用 gzip 压缩
compress
# 最近一份归档延迟到下次轮转才压缩(避开写入窗口)
delaycompress
# 归档后缀用日期,如 app.log-20260929
dateext
# 拷贝再清空,应用无需配合;接受极小概率的丢日志窗口
copytruncate
}
[!WARNING] copytruncate 与 postrotate/create 不要混用:同一块里
copytruncate会让create失效,语义混乱。二选一,想清楚再写。
0644 root root,logrotate 对配置属主有安全要求,普通用户写的会被忽略;maxsize 100M 与 daily 并存时是「或」:要么到一天,要么到 100M,先到先切——上面模板用的就是它。若换成 size,时间条件会被覆盖、只剩大小维度;想要「按天且必须超过尺寸才切」的「且」关系,用 minsize 100M + daily。dry run 这份新配置,确认它被正确解析、且计划动作符合预期:
$ sudo logrotate -d /etc/logrotate.d/myapp
预期输出:
reading config file /etc/logrotate.d/myapp
Reading state from file: /var/lib/logrotate/status
Handling 2 logs
rotating pattern: /var/log/myapp/app.log after 1 days (14 rotations)
...
rotating pattern: /var/log/myapp/error.log after 1 days (14 rotations)
两个日志文件都被纳入、模式正确,配置即生效(真实轮转等 timer 明天执行,或用 -f 立即验证)。
| 现象 | 根因 | 解决 |
|---|---|---|
| 配置被无视 | 放错目录(不在 /etc/logrotate.d/)或权限不对 | 移到正确目录,chown root:root + chmod 644 |
| 归档越来越多没清理 | rotate 值太大或没写 rotate | 明确保留份数,如 rotate 14 |
| 应用重启后日志文件找不到了 | 应用自己会重建日志,与 create 模式冲突 | 改用 copytruncate,或让应用支持信号重开 |
logrotate 的配置改动一天只被真正执行一次,写错了要等到明天才发现——这是最难受的调试节奏。好在 logrotate 提供了 dry run 和强制执行,把反馈周期缩到几秒钟。
三步调试点:
# 第一步:dry run,只打印将要执行的动作,不改任何文件
$ sudo logrotate -d /etc/logrotate.d/myapp
# 第二步:确认无误后,强制立刻轮转一次,验证真实效果
$ sudo logrotate -f /etc/logrotate.d/myapp
# 第三步:查看轮转状态账本,确认时间和记录
$ cat /var/lib/logrotate/status
预期输出:
logrotate state -- version 2
"/var/log/myapp/app.log" 2026-9-29-6:25:11
"/var/log/nginx/access.log" 2026-9-29-6:25:11
-d(dry run):只打印不执行。每次改完配置必跑一遍,看输出的「rotating pattern」和计划动作是否符合预期;-f(force):无视时间条件,立即轮转一次。注意它会真的切割文件,生产上挑业务低峰执行;/var/lib/logrotate/status:logrotate 的记账本,记录每个日志文件上次轮转的时间。「为什么没轮转?」先查这里——时间没到,logrotate 就是不动手,这是设计行为。强制轮转后检查三件事:归档文件出现、原文件变空、应用仍能正常写入:
$ ls -lh /var/log/myapp/
预期输出:
total 2.2M
-rw-r----- 1 appuser appgroup 512 Sep 29 10:05 app.log
-rw-r----- 1 appuser appgroup 2.1M Sep 28 23:59 app.log-20260929
app.log 是新的小文件且有新内容写入(不是 0 字节挂着),说明应用写日志正常。
| 现象 | 根因 | 解决 |
|---|---|---|
-d 没报错但真实轮转不发生 |
dry run 只做语法与计划检查,不代表条件已满足 | 看 status 里的时间是否到期;不等到期可用 -f |
-f 之后立即又想再切一次没反应 |
正常,status 已记录新时间 | 这正是记账机制在工作,无需处理 |
| 日志里有 error: stat 失败 | 配置里的日志路径不存在 | 核对路径,或加 missingok |
很多日志框架(logback、log4j2、Python logging 的 RotatingFileHandler、Node 的 winston-daily-rotate-file)自带「按大小/按天滚动」能力。logrotate 和应用内置滚动是竞争关系,只能二选一。
选型判断:
nohup app > app.log):用 logrotate,即本章方案;maxHistory/totalSizeCap 清理旧归档,logrotate 不要碰这个文件。<!-- logback 示例:框架自己滚动并清理,无需 logrotate 介入 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>14</maxHistory>
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
</appender>
maxHistory:框架侧的「保留份数」,等价于 logrotate 的 rotate 14;totalSizeCap:归档总大小上限,logrotate 反而没有直接的等价物——这是框架滚动的一个优势。确认应用是否内置滚动,看它旁边有没有自动出现的归档:
$ ls -lh /var/log/myapp/
预期输出:
total 20M
-rw-r----- 1 appuser appgroup 2.1M Sep 29 10:00 app.2026-09-29.log
-rw-r----- 1 appuser appgroup 2.0M Sep 28 23:59 app.2026-09-28.log
日期后缀的归档在自动增长——应用在内置滚动,别再叠 logrotate。
| 现象 | 根因 | 解决 |
|---|---|---|
| 同一天的日志出现两套归档,内容交错 | logrotate 与框架在双重滚动 | 删掉其中一方(推荐留框架侧) |
| 框架滚出来的文件从不清理 | 没配 maxHistory/totalSizeCap | 补上清理策略 |
| logrotate 切割后框架报 IO 错误 | 框架握着 fd 不放,与 create 冲突 | 框架自滚的场景直接移除 logrotate 配置 |
[!WARNING] 双重滚动会互相打架:logrotate 把
app.log改名,框架却还按自己的命名规则滚动同名文件,轻则归档命名混乱、重则日志丢失。上线前确认只有一方在管这个文件。
| 命令 | 作用 |
|---|---|
sudo logrotate -d /etc/logrotate.d/myapp |
dry run:只打印计划动作,不执行(改配置后必做) |
sudo logrotate -f /etc/logrotate.d/myapp |
强制立刻轮转一次 |
cat /var/lib/logrotate/status |
查看各日志上次轮转时间 |
systemctl status logrotate.timer |
确认每日轮转定时器在运行 |
ls /etc/logrotate.d/ |
列出所有分文件配置 |
kill -USR1 $(cat /var/run/nginx.pid) |
通知 nginx 重开日志文件(postrotate 用) |
gzip -d app.log-20260929.gz |
解压查看归档日志 |
zcat app.log-20260929.gz | grep ERROR |
不解压直接查归档内容 |
磁盘 100% 是服务器上最高频的线上事故:日志写不进、数据库停止服务、应用莫名崩溃,而现象往往只是「网站变慢」或「接口 500」。这一章给你一条固定的排错路线——从发现到定位到处理,照着走就能解决绝大多数情况。
[!IMPORTANT] 排错路线图,请按顺序走: ①
df -h确认真的满了 → ②du逐级定位大目录 → ③ 分类处理(删/清/扩容)→ ④ 加轮转与监控防复发(见 54 logrotate)。
df(disk free)按分区汇报空间使用情况。排错第一步永远是它:先搞清楚「哪个挂载点满了」,再去那个文件系统里找元凶。
$ df -h
预期输出:
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 40G 0 100% /
tmpfs 909M 0 909M 0% /dev/shm
/dev/vdb1 100G 12G 83G 12% /data
根分区 / 用了 100%,数据盘 /data 还很空——元凶在 / 里。只想单看一个分区:
$ df -h /
预期输出:
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 40G 0 100% /
-h(human-readable):字节换算成 K/M/G,人能直接读;Size 总容量、Used 已用、Avail 可用、Use% 使用率、Mounted on 挂载点(分区在目录树里的入口);-i:看的是 inode 用量而不是空间。inode 是文件的「户口」,每个文件占一个,小文件再多也只占一个户口——大量小文件能把 inode 耗光而空间还空着;$ df -i /
预期输出:
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 2560000 42015 2517985 2% /
服务报 No space left on device 时,两种 df 都要跑:
$ df -h / ; df -i /
预期输出:
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 40G 0 100% /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 2560000 42015 2517985 2% /
空间满、inode 还有大量剩余——是普通的空间占满,走 du 路线;反之则是 inode 耗尽,跳到本章「inode 耗尽的定位」一节。
| 现象 | 根因 | 解决 |
|---|---|---|
| df -h 显示还有空间但报 No space left on device | inode 被海量小文件耗光 | df -i 确认,见本章 inode 一节 |
| 看不出哪个分区满 | 挂载点多,输出太长 | df -h | grep -E "100%|9[0-9]%" 只看高危行 |
[!WARNING] inode 耗尽是经典陷阱:
df -h看起来空间充足,但 inode 用光后,创建任何新文件(写日志、建临时文件)照样报No space left on device。两种 df 必须都看。
df 告诉你「哪个分区满」,du(disk usage)告诉你「空间被谁吃了」。思路是逐级下钻:从 / 开始,找出最大的子目录,钻进去,再找最大的,直到挖到具体的大文件。
核心命令链,在每一级目录重复执行:
$ sudo du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -20
预期输出:
17G /
6.4G /var
4.1G /home
3.2G /usr
1.9G /opt
...(略)
/var 最大,钻进去:
$ sudo du -xh --max-depth=1 /var 2>/dev/null | sort -rh | head -20
预期输出:
6.4G /var
4.8G /var/log
1.1G /var/lib
...(略)
/var/log 最大——八成是日志没轮转,处理方式见下一节。
| 参数 | 作用 | 为什么需要 |
|---|---|---|
-x |
不跨文件系统 | 限制在当前分区内统计。否则 /data、挂载的 U 盘等会被算进来,干扰定位 |
--max-depth=1 |
只汇总到下一级目录 | 不加的话会列出全部子目录,输出爆炸 |
-h |
人类可读单位 | 配合下一步的排序 |
2>/dev/null |
丢弃权限报错 | 注意:普通用户对 /var/lib/mysql 等目录不可读,叠加 2>/dev/null 会静默漏统计、结果偏小——排查磁盘务必整体加 sudo 再用 |
sort -rh |
按人类可读数值逆序 | -h 让 sort 识别 4.1G/680M 这类单位,-r 从大到小 |
钻到最底层后,直接看目录里最大的文件,确认元凶:
$ ls -lhS /var/log/ | head
预期输出:
total 4.8G
-rw-r----- 1 appuser appgroup 4.2G Sep 29 10:00 app.log
-rw-r----- 1 syslog adm 301M Sep 29 06:25 syslog
...
-S 按文件大小排序,-h 人类可读。4.2G 的 app.log 就是目标。
| 现象 | 根因 | 解决 |
|---|---|---|
| du 统计很慢 | 目录里文件太多 | 耐心等,或先在更小的范围(如 /var)开始 |
| du 总和远小于 df 显示的已用量 | 有已删除但未释放的文件,或挂载点下藏了文件 | 见本章「文件已删但空间没释放」一节 |
| 命令报一堆 Permission denied | 普通用户权限不足 | 前面加 sudo,或保留 2>/dev/null |
定位到大目录后,接下来的处理是有套路的。线上磁盘占满的元凶,90% 落在下面七类里,每类一条处理路径。
元凶一:journal 日志堆积(systemd 系统日志)。查看占用、按上限清理:
$ sudo journalctl --disk-usage
$ sudo journalctl --vacuum-size=200M
预期输出:
Archived and active journals take up 3.2G in the file system.
Vacuuming done, freed 3.0G of archived journals from /var/log/journal.
再在 /etc/systemd/journald.conf 里设 SystemMaxUse=200M 永久限额,详见 51 journalctl。
元凶二:Docker 镜像/容器/卷/构建缓存。先看四类资产各占多少:
$ docker system df
预期输出:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 18 4 9.21GB 7.05GB (76%)
Containers 6 2 1.2GB 890MB (74%)
Local Volumes 8 3 4.05GB 2.1GB (51%)
Build Cache 45 0 3.8GB 3.8GB (100%)
一条命令粗清理(只删已停止容器、悬空镜像、无用网络、构建缓存):
$ docker system prune
四类分别清理的精确命令:
# 删除所有未被容器使用的镜像(注意:会删掉你重启时才用的镜像)
$ docker image prune -a
# 删除已停止的容器
$ docker container prune
# 删除未被任何容器使用的卷(可能含数据,慎用)
$ docker volume prune
# 清空构建缓存
$ docker builder prune
[!WARNING]
docker system prune -a会删除所有未被运行中容器引用的镜像,下次重启某服务时需要重新拉取/构建。数据卷(volume)里可能有数据库数据,清理前务必确认。
详见 30 Docker。
元凶三:应用日志没轮转。长期方案是配 logrotate(见 54 logrotate);当下应急,用清空代替删除:
$ sudo truncate -s 0 /var/log/myapp/app.log
或等效的:
$ sudo sh -c '> /var/log/myapp/app.log'
元凶四:文件已删但空间没释放——高频且反直觉,专开一节,见下节。
元凶五:数据库大表与 binlog。MySQL 的二进制日志会无限堆积:
# root 经 auth_socket 免密直连(23 章的默认安装);普通用户需 mysql -u root -p
$ sudo mysql -e "PURGE BINARY LOGS BEFORE NOW() - INTERVAL 3 DAY;"
PostgreSQL 侧留意 WAL 归档堆积与 pg_wal 目录(归档失败时它会疯长)。细节见 23 MySQL、24 PostgreSQL 与各自官方文档(截至 2026-09 版本)。
元凶六:备份文件堆积。/backup 里躺着三个月的手工备份包?定保留策略(比如只留最近 7 天),自动化方案见 44 备份与定时任务。
元凶七:临时文件与 core dump。/tmp 里的旧文件、崩溃产生的 core 文件:
# 删除 /tmp 下 7 天前的文件(删除操作不可逆,先 ls 预览再 -delete)
$ find /tmp -mtime +7 -delete
# 找出遗留的 core dump 文件
$ find / -xdev -name "core*" -size +10M 2>/dev/null
[!WARNING]
find ... -delete直接删除且不可恢复。动手前先把-delete换成-ls预览一遍要删的清单,确认无误再删。
journalctl --vacuum-size=200M:把 journal 压缩到 200M 以内,从最旧的开始删;对应的 --vacuum-time=2weeks 按时间清理;truncate -s 0:把文件截断为 0 字节,但保留文件本身和 inode——正在写它的进程不受影响,这是和 rm 的本质区别(原因见下节);find -mtime +7:修改时间在 7 天以前。每处理完一类,立刻用 df 复查回收效果:
$ df -h /
预期输出:
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 31G 6.9G 82% /
Use% 从 100% 降到 82%,处理有效。
| 现象 | 根因 | 解决 |
|---|---|---|
| 清理后过几天又满了 | 只治了标,没设轮转/限额 | journal 限额 + logrotate + 监控,见「应急 vs 治本」 |
rm 大日志后空间没变 |
进程握着已删文件的 fd | 见下一节 |
| docker prune 后容器起不来 | 镜像被 -a 清掉 | 重新 pull/build 该镜像 |
最迷惑的场景:rm 掉了 10G 的日志,df -h 显示空间纹丝不动。原理:Linux 里删除文件只是删掉目录项(文件名到 inode 的映射),只要还有进程握着这个文件的 fd,inode 和数据就不会释放——进程还在往一个「已经没有名字的文件」里写。
完整排查链:
# 第一步:找出谁握着已删除的文件(deleted 状态)
$ sudo lsof | grep deleted | head
预期输出:
java 1234 appuser 15w REG 253,1 10485760000 671089 /var/log/myapp/app.log (deleted)
java 进程(pid 1234)握着一个已删除的、10G 的文件。窄化查找可用 lsof +L1(列出 link count < 1 的文件,即已删除的):
$ sudo lsof +L1
预期输出:
COMMAND PID USER FD TYPE DEVICE SIZE/NLK NODE NAME
java 1234 appuser 15w REG 253,1 10485760000 671089 /var/log/myapp/app.log
处理办法二选一:
# 办法一:截断文件,空间立即释放,进程不用动(应急首选)
$ sudo truncate -s 0 /proc/1234/fd/15
# 办法二:重启该进程,fd 关闭,空间随之释放(需要安排停机/重启)
$ sudo systemctl restart myapp
rm 没用而 >/truncate 有用:rm 只删目录项(名字),inode 的引用计数里还有那个进程的 fd 在撑着,内核不释放数据块;> file(全称 : > file)和 truncate -s 0 是把文件内容清零——数据块当场归还,文件名和 inode 都还在,进程继续写也只是从文件头追加;/proc/1234/fd/15:进程 1234 的第 15 号文件描述符,指向那个幽灵文件。对它执行 truncate 等于对幽灵文件本身截断;copytruncate 模式(见 54 logrotate),以后就不会再出现这个场景。截断后确认 fd 已不再是巨型、df 已回收:
$ sudo lsof +L1 | head
$ df -h /
预期输出:
COMMAND PID USER FD TYPE DEVICE SIZE/NLK NODE NAME
java 1234 appuser 15w REG 253,1 0 671089 /var/log/myapp/app.log
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 29G 8.9G 77% /
SIZE 变为 0、磁盘回收约 10G,处理成功。
| 现象 | 根因 | 解决 |
|---|---|---|
| lsof 输出里没有 deleted 但空间还是对不上 | 看下面的「du 与 df 数值差很大」 | 检查挂载点覆盖问题 |
| truncate 后进程又把文件写大了 | 源头在疯写,截断只是止血 | 排查应用为何狂打日志,并配轮转 |
| /proc/1234/fd/15 报权限错误 | fd 编号取错或非 root | 加 sudo,用 lsof 输出的真实 FD 列编号 |
df -i 显示 IUse% 接近 100%,就是 inode 耗尽:文件数量爆炸(通常是几百万个小文件),每个文件吃掉一个 inode。此时哪怕空间充足,新建文件也会报 No space left on device。
先确认(见第一步的 df -i),然后找小文件爆炸的目录——逐目录统计文件个数:
$ cd /var
$ for d in */; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head
预期输出:
1812342 lib/
20451 log/
1832 cache/
...(略)
/var/lib 下藏着一百八十万个文件,继续对 /var/lib 逐级重复这条命令,直到锁定具体目录。常见元凶:
定位后按业务清理 session/cache,或给队列限长。
find "$d" -xdev | wc -l:统计目录内文件总数,-xdev 不跨文件系统;for d in */:遍历当前目录下所有子目录;echo "$(find | wc -l) $d" 输出「数量 + 目录名」两列,方便 sort -rn 排序。清理后 inode 应明显回落:
$ df -i /
预期输出:
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 2560000 1823035 736965 72% /
IUse% 从 99% 降到 72%,新建文件恢复正常。
| 现象 | 根因 | 解决 |
|---|---|---|
| 找不到哪个目录文件多 | 只在顶层跑了 for 循环 | 对最大的目录逐级重复执行 |
| 清理后 IUse% 不降 | 文件删了但同样握着 deleted fd | 用 lsof +L1 查(见上一节) |
| session 清理后又积满 | 应用没配过期清理 | cron 定期删过期 session(见 44 备份与定时任务) |
应急是「今天先让服务活下来」,治本是「让它永远不再发生」。磁盘事故的处理必须两段都做,只做前者的人每周都会见到同一个警报。
应急动作(按见效速度):journal 清理 → 截断/清空大日志 → docker prune → 删过期备份。目标是把 Use% 降到 80% 以下,先恢复服务写入。
治本三件套:
SystemMaxUse,数据库设 binlog 过期时间,备份设保留策略;df 的 Use% 设告警,阈值 80%——留出处置时间,别等到 100% 才知道(监控体系搭建不在本教程范围,一句带过)。应急与治本对照:
| 阶段 | 动作 | 特点 |
|---|---|---|
| 应急 | 清 journal、截断日志、prune | 几分钟见效,不解决复发 |
| 治本 | 轮转 + 限额 + 监控 | 一次配置,长期生效 |
治本配置完成的标志:强制跑一轮 logrotate 无报错、journal 限额生效、监控能收到 df 指标。
$ sudo logrotate -d /etc/logrotate.d/myapp
预期输出:
rotating pattern: /var/log/myapp/app.log after 1 days (14 rotations)
| 现象 | 根因 | 解决 |
|---|---|---|
| 每周都磁盘告警 | 只做应急没做治本 | 补齐轮转与限额 |
| 扩了容量又满了 | 业务增长,监控阈值没跟上 | 容量规划 + 80% 告警前移 |
[!NOTE] 扩容一句话:云盘支持在线扩容,但扩完分区和文件系统不会自动变大,需要
growpart扩分区 +resize2fs(ext4)或xfs_growfs(xfs)扩文件系统。具体路径各云厂商控制台文档都有逐步指引,操作前快照一份。
| 命令 | 作用 |
|---|---|
df -h |
按分区看空间使用率(第一步) |
df -h / |
单看根分区 |
df -i |
看 inode 使用量(小文件陷阱) |
du -xh --max-depth=1 / | sort -rh | head |
逐级定位大目录(第二步) |
ls -lhS 目录 | head |
列出目录内最大的文件 |
journalctl --disk-usage |
查看 journal 日志占用 |
sudo journalctl --vacuum-size=200M |
把 journal 清理到 200M 以内 |
docker system df |
查看 Docker 四类资产占用 |
docker system prune |
清理已停止容器/悬空镜像/构建缓存 |
sudo truncate -s 0 文件 |
清空文件(保住 inode,进程无感) |
sudo lsof | grep deleted / sudo lsof +L1 |
找已删除但未释放空间的文件 |
sudo truncate -s 0 /proc/PID/fd/N |
截断幽灵文件,立即释放空间 |
find /tmp -mtime +7 -delete |
删除 /tmp 下 7 天前的文件(先 -ls 预览) |
for d in */; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head |
找文件数最多的目录(inode 排查) |
这是全书收官章。前面每一章都教「一个点」,这一章把所有点串成一条线:从一台全新的 Ubuntu 22.04 服务器出发,最终交付一个可公网 HTTPS 访问的 Java 后端服务。照着做到底,你将得到:跑在 systemd 下的 Java 应用(Spring Boot 风格 jar)+ MySQL 8.0 + Redis 8.x + Nginx 1.30 stable 反向代理 + certbot 签发的 HTTPS,外加日志、健康检查与每日备份(版本均截至 2026-09)。
每个大步骤末尾都有「验收检查」,确认本步全部通过再往下走。中途翻车时,直接跳到文末「常见翻车现场」对照排查。
一个提供 REST API 的 Java 应用(以「文章管理 API」为例):有健康检查接口 /api/health,能读写 MySQL,用 Redis 做缓存。应用本身怎么写、怎么打成 app.jar 不在本章范围内(见 31 Git 与构建工具),我们聚焦部署侧。
用户 ──HTTPS 443 / HTTP 80──> Nginx 1.30 ← 唯一对公网开放的组件
│ http://127.0.0.1:8080
▼
Java 应用 app.jar(systemd 托管,专用用户 appuser)
│ │
127.0.0.1:3306 ◄────┘ └────> 127.0.0.1:6379
▼ ▼
MySQL 8.0(库 appdb) Redis 8.x(requirepass)
[!IMPORTANT] 每个组件只监听 127.0.0.1,只有 Nginx 对外开放——这是本章安全设计的底座:应用即使有漏洞公网也摸不到,MySQL/Redis 不暴露端口就无从爆破,防火墙只放行 22/80/443。网上让 MySQL 开
0.0.0.0、Redis 裸奔的教程,是被扫去挖矿的标准姿势。
全部完成后,逐条核对:
dig +short <你的域名> 返回 <服务器IP>https://<你的域名>/api/health 返回 200,HTTP 自动 301 跳转 HTTPSappuser 运行,systemd 托管且开机自启ss -tlnp 里对公网监听的只有 nginx(80/443)和 sshdjournalctl -u myapp 可查应用日志certbot renew --dry-run 通过,证书自动续期/var/backups/mysql/ 下有每日备份文件,且恢复演练通过一台云服务器:Ubuntu 22.04 LTS、2 核 4G 起(Java + MySQL + Redis 同机,1G 内存会很痛苦)、40G+ 磁盘。云安全组先只放行 22,80/443 到第 4 步要用时再放行。安全组与系统内的 ufw 是两道独立的门,两边都要放行才通(三层防火墙模型见 16 防火墙)。
在域名服务商处添加一条 A 记录指向 <服务器IP>,然后在本地电脑验证:
# 验证域名是否已解析到服务器 IP
$ dig +short <你的域名>
预期输出就是 <服务器IP>。没输出说明解析未生效,等几分钟再试——域名解析不通,后面 HTTPS 一步都做不了。
先用云厂商给的初始方式(密码或临时密钥)能 SSH 登录即可,第一步会立刻做密钥加固(详见 18 SSH 加固)。
lsb_release -a 显示 22.04free -h 显示内存 ≥ 3.5G,df -h / 显示磁盘 ≥ 30G 可用dig +short <你的域名> 返回 <服务器IP>把「出厂状态」变成「有基本防护」。每条命令背后都有对应章节,这里只走主线。
# 刷新软件源索引并升级全部已装软件包
$ sudo apt update && sudo apt upgrade -y
包管理细节见 17 包管理;国内下载慢可换镜像源,见 33 镜像加速。
不要长期用 root 干活。建一个日常用户并赋予 sudo 权限(用户与权限体系见 13 用户与权限):
# 创建用户 deploy 并加入 sudo 组(adduser 会同时创建家目录)
$ sudo adduser deploy
$ sudo usermod -aG sudo deploy
在本地电脑上推送公钥,之后禁用密码登录:
# 在本地电脑执行:推送公钥到服务器
$ ssh-copy-id deploy@<服务器IP>
# 在服务器上执行:确认密钥能登录后,禁用密码认证
$ sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
# 22.04 云镜像常在 drop-in 里覆盖主配置,必须一并检查
$ grep -ri PasswordAuthentication /etc/ssh/sshd_config.d/ || echo "无 drop-in 覆盖"
# 若 50-cloud-init.conf 输出了 PasswordAuthentication yes,同步改掉它:
$ sudo sed -i 's/^PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config.d/50-cloud-init.conf
# 核对最终生效值(sshd -T 输出实际生效配置,必须是 passwordauthentication no)
$ sudo sshd -T | grep -i passwordauthentication
$ sudo systemctl reload ssh
[!WARNING] 禁用密码登录前,先开一个新终端确认密钥能登录。把自己锁在外面是新手第一大事故。完整加固(禁 root 直登、改端口、fail2ban)见 18 SSH 加固。
时间不对会导致日志混乱、HTTPS 校验失败:
# 设置时区并确认时间同步在运行
$ sudo timedatectl set-timezone Asia/Shanghai
$ timedatectl
NTP service: active 即正常(时间同步细节见 15 网络配置)。
云安全组之外,系统内再启用 ufw 这道门。铁律:先放行 22 再 enable,否则启用瞬间会把自己锁在外面:
# 先放行 SSH,再启用防火墙(顺序不能反)
$ sudo ufw allow 22/tcp
$ sudo ufw enable
# 确认状态:22/tcp 为 ALLOW,其余默认拒绝
$ sudo ufw status
启用后保持当前 SSH 会话不断开,新开一个终端确认还能正常登录,再继续往下走(「先放 22 再 enable」的完整铁律见 16 防火墙)。80/443 到第 4 步 Nginx 上线时再放行。
apt upgrade 完成,无报错sudo sshd -T | grep -i passwordauthentication 输出 passwordauthentication nodate 显示上海时区与正确时间timedatectl 显示 NTP service: activesudo ufw status 显示 active 且 22/tcp 为 ALLOW,新开终端仍能 SSH 登录三个组件装在同一台机器上,且都只监听本机。安装细节各有一整章,这里给最短可执行路径。
JDK 21 是当前最稳妥的 LTS(截至 2026-09),22.04 源里已包含:
# 安装 OpenJDK 21 并确认版本
$ sudo apt install -y openjdk-21-jdk
$ java -version
输出含 openjdk version "21.x" 即成功。生产建议另配 JAVA_HOME,多版本切换见 20 JDK。
安装与初始化的完整讲解(含 caching_sha2_password 兼容坑)见 23 MySQL:
# 安装 MySQL Server 8.0,然后运行安全初始化向导
$ sudo apt install -y mysql-server
$ sudo mysql_secure_installation
执行 sudo mysql 进入命令行,建业务库和专用账号——账号只授权 localhost,密码替换为强密码:
-- 创建 utf8mb4 字符集的业务库
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建应用专用账号,仅允许本机连接('localhost' 是关键)
CREATE USER 'appuser'@'localhost' IDENTIFIED BY '<强密码>';
-- 只授予 appdb 库的常规权限,不给全局权限
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, ALTER ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
[!IMPORTANT] 确认
bind-address保持 127.0.0.1(8.0 默认即如此,别照抄网上教程改成0.0.0.0):sudo ss -tlnp | grep 3306的输出必须是127.0.0.1:3306。
本机连通验证:
# 用应用账号从本机连接,验证账号密码与库权限
$ mysql -u appuser -p -h 127.0.0.1 appdb -e "SELECT 1;"
22.04 源里只有 6.0,需 Redis 官方源装 8.x(源配置见 26 Redis):
# 添加官方源后安装(过程见第 26 章)
$ sudo apt install -y redis
编辑 /etc/redis/redis.conf:
# 只监听本机回环地址
bind 127.0.0.1
# 设置访问密码(替换为强密码)
requirepass <强密码>
# 重启生效,然后验证:无密码 ping 应被拒,带密码应返回 PONG
$ sudo systemctl restart redis-server
$ redis-cli ping
$ redis-cli -a '<强密码>' ping
java -version 显示 21.xsystemctl is-active mysql redis-server 都输出 activesudo ss -tlnp | grep -E '3306|6379' 只显示 127.0.0.1mysql -u appuser -p -h 127.0.0.1 appdb -e "SELECT 1;" 连通redis-cli -a '<强密码>' ping 返回 PONG本章假设你已有构建好的 app.jar(Spring Boot 风格可执行 fat jar,换任何能跑的 jar 同理;如何构建见 31 Git 与构建工具)。
先传到 /tmp 中转,确认无误后再放到正式目录(scp 细节见 11 文件操作):
# 在本地电脑执行:上传 jar 到服务器临时目录
$ scp -P 22 target/app.jar deploy@<服务器IP>:/tmp/
为应用建一个系统用户:-r 表示系统账号(不生成家目录、无过期),-s /usr/sbin/nologin 禁止登录 shell——即使应用被攻破,攻击者也拿不到能登录的账号(原理见 13 用户与权限):
# 创建不可登录的系统用户,专门用于运行应用
$ sudo useradd -r -s /usr/sbin/nologin appuser
# 创建应用目录与日志目录,jar 移到正式目录,全部归属 appuser
$ sudo mkdir -p /opt/myapp /var/log/myapp
$ sudo mv /tmp/app.jar /opt/myapp/app.jar
$ sudo chown -R appuser:appuser /opt/myapp /var/log/myapp
数据库密码不写死在 jar 内的配置文件里,而是通过环境变量注入——换环境不用重新打包:
# 创建环境变量文件,写入敏感配置
$ sudo tee /opt/myapp/app.env > /dev/null <<'EOF'
SPRING_PROFILES_ACTIVE=prod
SPRING_DATASOURCE_URL=jdbc:mysql://127.0.0.1:3306/appdb?useSSL=true&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME=appuser
SPRING_DATASOURCE_PASSWORD=<强密码>
SPRING_DATA_REDIS_HOST=127.0.0.1
SPRING_DATA_REDIS_PASSWORD=<强密码>
EOF
# 收紧权限:只有 root 可读,由 systemd 以 root 读取后注入进程环境
$ sudo chmod 600 /opt/myapp/app.env
$ sudo chown root:root /opt/myapp/app.env
[!TIP]
chmod 600 + 属主 root看似与应用用户矛盾,但 systemd 读EnvironmentFile是以 root 身份读的,读完注入给 appuser 进程——敏感文件恰好做到「应用能用值、但读不到文件」。
创建 /etc/systemd/system/myapp.service(unit 逐行讲解、Type= 区别、Restart 策略见 40 systemd):
[Unit]
# 描述信息,systemctl status 里显示的第一行
Description=MyApp Java Service
# 网络与数据库就绪后再启动
After=network.target mysql.service redis-server.service
[Service]
# 以专用低权限用户运行
User=appuser
Group=appuser
# simple:ExecStart 的进程即主进程,Spring Boot 前台运行正合适
Type=simple
# 工作目录,应用内的相对路径都以此为基准
WorkingDirectory=/opt/myapp
# 启动命令:绝对路径的 java,避免 PATH 问题;堆内存按机器规格调整
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/myapp/app.jar
# 从环境文件注入变量(敏感配置都在这里)
EnvironmentFile=/opt/myapp/app.env
# 异常退出总是自动拉起,10 秒后重启
Restart=always
RestartSec=10
# Java 需要较多文件描述符(连接、jar 内资源等)
LimitNOFILE=65536
[Install]
# 开机自启,挂在多用户运行级别下
WantedBy=multi-user.target
# 重新加载 unit 定义,启动服务并设为开机自启
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now myapp
# 状态应为 active (running);再跟踪日志,看到 "Started ...Application" 即启动完成
$ systemctl status myapp
$ journalctl -u myapp -f
按 Ctrl+C 退出跟踪;journalctl 的过滤技巧见 51 journalctl。
# 请求健康检查接口
$ curl http://127.0.0.1:8080/api/health
预期输出 {"status":"UP"}。
ps -o user= -p $(pgrep -f app.jar) 输出 appusersystemctl is-enabled myapp 输出 enabledsystemctl is-active myapp 输出 activecurl http://127.0.0.1:8080/api/health 返回 {"status":"UP"}Restart=always 自动拉起(验证:sudo pkill -f app.jar,几秒后 systemctl is-active myapp 仍为 active)22.04 源默认是 1.18,需添加 nginx.org 官方源装 1.30 stable(源配置见 29 Nginx):
# 添加官方源后安装(过程见第 29 章),确认版本
$ nginx -v
输出应为 nginx version: nginx/1.30.x。
创建 /etc/nginx/conf.d/myapp.conf。先用 HTTP 跑通,下一步再上 HTTPS(conf.d/ 与 sites-available/ 软链到 sites-enabled/ 两种位置均可生效,29 Nginx 演示的是后者的发行版惯例,这里选 conf.d/ 是为了单文件自包含):
server {
# 监听 80 端口
listen 80;
# 匹配你的域名
server_name <你的域名>;
# 允许最大 20M 请求体(文件上传场景按需调整)
client_max_body_size 20m;
location / {
# 转发到本机的 Java 应用
proxy_pass http://127.0.0.1:8080;
# 四件套:让应用拿到真实协议、域名与客户端 IP
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
[!IMPORTANT]
proxy_set_header四件套缺一不可:没有它们,应用看到的客户端 IP 全是 127.0.0.1、生成的跳转链接会丢协议。日志统计、限流、鉴权都依赖真实 IP。
# 语法检查(必须显示 test is successful),然后平滑重载
$ sudo nginx -t
$ sudo systemctl reload nginx
nginx -t 是改配置后的肌肉记忆;reload 与 restart 的区别见 29 Nginx。
先去云控制台安全组放行 80/443,再放行系统防火墙。放行前务必确认 22 已放行,否则会把自己锁在外面(ufw 完整用法与「先放 22 再 enable」的铁律见 16 防火墙):
# 先确认 22 已放行(第 1 步已启用 ufw),再放行 Web 端口
# Nginx Full 是 ufw 内置 profile,同时涵盖 80 和 443;显式写法为 sudo ufw allow 80,443/tcp,二者等价、选一个执行即可
$ sudo ufw status
$ sudo ufw allow 'Nginx Full'
在本地电脑验证:
# 在本地电脑执行:验证公网 HTTP 访问
$ curl -I http://<你的域名>/api/health
预期 HTTP/1.1 200 OK。若 502,翻到文末翻车表。
sudo nginx -t 通过Nginx Full 均为 allowcurl -I http://<你的域名>/api/health 返回 200sudo ss -tlnp | grep nginx 监听 0.0.0.0:80(对公网,符合预期)证书申请、自动续期原理、过期排查全流程见 32 HTTPS 证书,这里一条命令搞定:
# 用 certbot 的 nginx 插件一键签发并自动改 Nginx 配置
$ sudo certbot --nginx -d <你的域名>
交互中问「是否把 HTTP 重定向到 HTTPS」时,选 Redirect(重定向)——certbot 会自动把 80 端口改成 301 跳转,Nginx 配置无需手改。
Let's Encrypt 证书有效期 90 天,certbot 自带 systemd timer 每天检查续期:
# 演练续期流程(不会真的换证书),通过即说明自动续期没问题
$ sudo certbot renew --dry-run
在本地电脑:
# 验证 HTTPS 直连,以及 HTTP 会 301 跳转
$ curl -I https://<你的域名>/api/health
$ curl -I http://<你的域名>/api/health
预期:第一条 HTTP/2 200,第二条 HTTP/1.1 301 且 Location 为 https 地址。
certbot --nginx 签发成功,选择了 Redirectcertbot renew --dry-run 显示 all simulated renewals succeededcurl -I https://<你的域名>/api/health 返回 200,HTTP 301 跳转上线只是开始,出问题时「能不能快速看到发生了什么」才是运维水平。
本章的 systemd 配置让应用日志全部进 journald:
# 查看应用最近日志(--since 支持人类可读时间),-p err 只看 err 及以上
$ journalctl -u myapp --since "1 hour ago"
$ journalctl -u myapp -p err
[!NOTE] 如果应用额外写日志文件(如
/var/log/myapp/app.log),必须配 logrotate,否则日志无限增长会撑爆磁盘。配置模板与copytruncate的坑见 54 logrotate。
访问日志在 /var/log/nginx/access.log,一条最常用的状态码分布统计(awk 统计的完整玩法见 53 日志分析):
# 统计各 HTTP 状态码出现次数,按数量倒序
$ awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
200 应占绝大多数;若 502 持续增长,先看 journalctl -u myapp 是不是应用挂了。
把日常巡检固化成脚本,存为 /opt/myapp/healthcheck.sh:
#!/bin/bash
# 一键健康检查:应用接口 + 服务状态 + 磁盘水位;异常项输出 [FAIL]
echo "== HTTP 健康接口 =="
curl -fsS -m 5 https://<你的域名>/api/health && echo || echo "[FAIL] health 接口异常"
echo "== systemd 服务状态 =="
for svc in myapp mysql redis-server nginx; do
systemctl is-active --quiet "$svc" && echo "[OK] $svc" || echo "[FAIL] $svc"
done
echo "== 磁盘使用率 =="
df -h / | tail -1
# 根分区使用率超 85% 提示排查
usage=$(df / | awk 'NR==2 {gsub("%",""); print $5}')
[ "$usage" -ge 85 ] && echo "[FAIL] 根分区使用率 ${usage}%,排查见 55 章"
# 赋予执行权限并运行一次
$ sudo chmod +x /opt/myapp/healthcheck.sh
$ sudo /opt/myapp/healthcheck.sh
journalctl -u myapp 能查到启动与应用日志[OK]logrotate -d 干跑无报错没有验证过恢复的备份等于没有备份。cron 语法、% 转义、3-2-1 策略见 44 备份与 cron。
cron 里不能交互输密码,把凭据放进 root 家目录的 /root/.my.cnf——下面 7.2 的备份任务跑在 root 的 crontab 里,mysqldump 以 root 身份执行、读的正是 /root/.my.cnf;写进 deploy 家目录的话 root 反而读不到(~/.my.cnf 只对各自的主人生效):
# 写入 /root/.my.cnf(普通用户无法在 /root 下直接建文件,用 sudo tee)
$ sudo tee /root/.my.cnf > /dev/null <<'EOF'
[client]
# 备份专用凭据(按最小权限原则自建 backup 账号更佳)
user=appuser
password=<强密码>
host=127.0.0.1
EOF
# 锁权限:组和其他用户不可读
$ sudo chmod 600 /root/.my.cnf
# 编辑 root 的 crontab(备份以 root 跑,避免目录权限问题)
$ sudo crontab -e
# 每天 3:00 备份 appdb 并 gzip 压缩,文件名带日期(--routines --triggers 连存储过程和触发器一起导出,漏了恢复不全)
0 3 * * * mysqldump --single-transaction --routines --triggers appdb | gzip > /var/backups/mysql/appdb_$(date +\%Y\%m\%d).sql.gz
# 每天 3:30 清理 7 天前的旧备份
30 3 * * * find /var/backups/mysql -name "appdb_*.sql.gz" -mtime +7 -delete
[!IMPORTANT] 注意
\%转义——cron 里%有特殊含义,不转义命令会被静默截断,这是 cron 第一大坑,见 44 备份与 cron。
先建目录并手动跑一次确认产物:
# 创建备份目录并手动执行一次验证
$ sudo mkdir -p /var/backups/mysql
# 目录属主是 root,而管道右侧的 gzip 和重定向由当前用户 shell 执行,
# 所以整条管道要放进 sudo sh -c,否则重定向一步会 Permission denied
$ sudo sh -c 'mysqldump --single-transaction --routines --triggers appdb | gzip > /var/backups/mysql/appdb_manual.sql.gz'
$ ls -lh /var/backups/mysql/
备份的价值只有在恢复成功那一刻才成立。恢复到临时库验证:
# 创建临时恢复库,解压导入备份文件(建库/导入是全局操作,appuser 只有 appdb.* 的权限,改用 root 执行)
$ sudo mysql -e "CREATE DATABASE appdb_restore CHARACTER SET utf8mb4;"
$ gunzip < /var/backups/mysql/appdb_manual.sql.gz | sudo mysql appdb_restore
# 对比两边行数是否一致(示例:articles 表;原库走 appuser,恢复库走 root)
$ mysql -u appuser -p appdb -e "SELECT COUNT(*) FROM articles;"
$ sudo mysql appdb_restore -e "SELECT COUNT(*) FROM articles;"
行数一致即演练通过,最后清理临时库:
# 演练完成后删除临时库(删库是全局操作,用 root 执行)
$ sudo mysql -e "DROP DATABASE appdb_restore;"
/root/.my.cnf 存在且权限 600,sudo mysqldump --single-transaction appdb 免密可用sudo crontab -l 能看到两条备份任务/var/backups/mysql/ 下有今天日期的 .sql.gz 且大小不为 0appdb_restore 成功,行数与原库一致排错方法论(现象 → 范围 → 证据 → 假设 → 验证)见 92 排错手册,下表是本章场景的快速索引:
| 现象 | 最可能原因 | 排查入口 |
|---|---|---|
| 访问域名返回 502 Bad Gateway | Java 应用挂了或没监听 8080 | systemctl status myapp + journalctl -u myapp -n 50;40 systemd、51 journalctl |
| 浏览器连接超时(80/443 都不通) | 安全组或 ufw 没放行 | 先查云控制台安全组,再 sudo ufw status;16 防火墙 |
| 应用日志报 Communications link failure / Access denied | 连接串、账号或密码错;MySQL 没监听 127.0.0.1 | sudo ss -tlnp | grep 3306 + 手动 mysql -u appuser -p 验证;23 MySQL |
页面白屏但本机 curl 127.0.0.1:8080 正常 |
Nginx 没转发、server_name 不匹配、忘了四件套 |
sudo nginx -t + 核对第 4 步配置;29 Nginx |
| HTTPS 证书续期失败 | 域名解析变了、80 被占、timer 没在跑 | 看 sudo certbot renew --dry-run 报错;32 HTTPS 证书 |
| 重启服务器后服务没起来 | 忘了 systemctl enable,或依赖顺序问题 |
systemctl is-enabled myapp;40 systemd |
| 磁盘一周就满 | 日志无轮转、备份堆积、journal 占用超限 | df -h + du 逐级定位;55 磁盘满排查、54 logrotate、51 journalctl |
[!TIP] 排错铁律:从现象所在的那一层开始查,一次只改一个变量。 502 先查应用活没活再查 Nginx;连接超时先查防火墙再查监听。
全部步骤完成后,用这六项做最终交付核对(92 章排错手册引用的正是这份清单):
| # | 验收项 | 怎么核对 |
|---|---|---|
| 1 | 应用可访问 | curl -I https://<你的域名>/api/health 返回 200(或本机 curl http://127.0.0.1:8080/api/health 返回 {"status":"UP"}) |
| 2 | HTTPS 有效 | 浏览器地址栏显示锁形图标,或 `openssl s_client -connect <你的域名>:443 -servername <你的域名> </dev/null 2>/dev/null |
| 3 | systemd 自启 | systemctl is-enabled myapp nginx mysql redis-server 全部输出 enabled |
| 4 | 日志在写 | journalctl -u myapp --since "5 min ago" 请求后能看到新日志滚动 |
| 5 | 备份在跑 | ls -lh /var/backups/mysql/ 有当日 .sql.gz 且大小不为 0,sudo crontab -l 能看到备份任务 |
| 6 | 防火墙最小开放 | sudo ufw status 只见 22/tcp 与 80/443(Nginx Full),无多余放行 |
六项全绿,这次端到端部署才算真正交付。
走到这里,你完成了一次合格的单机生产部署——这正是本教程的边界。业务长大之后的进阶方向:
upstream 做负载均衡,数据库拆到独立实例不管走哪条路,本章建立的安全底座(最小监听、专用用户、防火墙、备份演练)都不会过时。回头查阅请回到 目录与阅读路线。
本表是索引,不是教程:每条命令一行「命令 + 一句话作用 + 所属章节」,想看原理和完整用法请点进章节。命令写法约定($ 提示符、sudo、注释独立成行)见 00 前言与约定。
[!TIP] 记不住命令时:先来这张表找,再进对应章节看「为什么」。版本号问题去 91 版本速查,服务器出故障去 92 排错手册。
详见 10 Shell 与文件系统、11 文件操作、12 文本处理。
| 命令 | 作用 | 章节 |
|---|---|---|
$ ls -lah |
列出文件(含隐藏、人类可读大小) | 10 |
$ pwd |
显示当前所在目录绝对路径 | 10 |
$ cd /var/log |
切换目录(绝对路径) | 10 |
$ cd - |
回到上一次所在目录 | 10 |
$ cp -a src dst |
复制并保留权限/时间戳/软链接(备份首选) | 11 |
$ mv old new |
移动或重命名(同盘是改名,瞬间完成) | 11 |
$ rm -i file |
删除前逐个确认(比裸 rm 安全) |
11 |
$ mkdir -p a/b/c |
递归创建多级目录,已存在不报错 | 11 |
$ ln -s /real/path link |
创建软链接(Nginx 站点启用就靠它) | 10 |
$ find /var -name "*.log" -mtime +7 |
按名字/时间找文件 | 12 |
$ tar czf backup.tar.gz dir/ |
打包并 gzip 压缩(czf = 创建) | 11 |
$ tar xzf backup.tar.gz |
解压 .tar.gz(xzf = 解开) | 11 |
$ tar tf backup.tar.gz |
只看包内容不解压(解压前先确认) | 11 |
$ rsync -avz src/ dst/ |
增量同步(结尾斜杠=同步目录内容) | 11 |
$ scp file user@host:/tmp/ |
走 SSH 复制文件到远程 | 11 |
$ du -sh /var |
查目录总占用(一句话汇总) | 10 |
$ df -h |
查各挂载点磁盘使用率 | 10 |
$ stat file |
查看文件的权限/属主/时间戳细节 | 11 |
$ unzip app.zip -d /opt/ |
解压 zip 包 | 11 |
$ which nginx |
查命令的可执行文件在 PATH 的哪个位置 | 10 |
$ rsync -avz --delete src/ dst/ |
同步并删除目标端多余文件(镜像目录,慎用 --delete) | 11 |
| 命令 | 作用 | 章节 |
|---|---|---|
$ grep -rn "ERROR" /var/log/app/ |
递归搜索并显示行号 | 12 |
$ grep -C 5 "Exception" app.log |
显示匹配行前后各 5 行上下文 | 52 |
$ tail -f app.log |
实时跟踪日志(文件被轮转后跟错对象) | 52 |
$ tail -F app.log |
实时跟踪且自动跟随轮转后的新文件 | 52 |
$ less +F app.log |
交互式跟踪,Ctrl+C 可暂停回翻 | 52 |
$ zgrep "ERROR" app.log.2.gz |
直接搜压缩过的历史日志 | 52 |
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn |
统计第一列(IP)出现次数 TOP N | 53 |
$ sed -i 's/old/new/g' file |
原地替换文本(改配置常用) | 12 |
$ cat access.log | awk '{print $9}' | sort | uniq -c |
按状态码聚合 Nginx 访问日志 | 53 |
$ find . -name "*.tmp" | xargs rm |
把查找结果逐个交给下一条命令处理 | 12 |
$ sudo tee /etc/sysctl.d/99.conf < local.conf |
以普通用户身份写入 root 文件(> 重定向会权限不够) |
12 |
$ wc -l access.log |
数行数(快速估算请求量) | 12 |
$ grep -v "^#" config.conf |
反向匹配:过滤掉注释行 | 12 |
$ head -100 access.log |
看文件前 100 行 | 12 |
$ awk -F: '{print $1, $3}' /etc/passwd |
指定分隔符后取字段(冒号分隔取第 1、3 列) | 12 |
[!NOTE]
sort | uniq -c | sort -rn是日志统计三件套:先排序(uniq 只认相邻)、计数、再按次数倒序。原理见 53 日志分析。
详见 13 用户与权限。
| 命令 | 作用 | 章节 |
|---|---|---|
$ sudo useradd -m -s /bin/bash app |
建用户并创建家目录、指定 shell | 13 |
$ sudo usermod -aG docker app |
把用户追加进组(漏了 -a 会清空其他组) | 13 |
$ sudo passwd app |
设置/修改用户密码 | 13 |
$ chmod 755 script.sh |
所有者全权,其他人可读可执行(目录标配) | 13 |
$ chmod 644 config.conf |
所有者可写,其他人只读(配置文件标配) | 13 |
$ sudo chown -R app:app /opt/myapp |
递归改文件属主属组 | 13 |
$ sudo -l -U app |
查看用户有哪些 sudo 权限 | 13 |
$ sudo visudo |
安全编辑 sudoers(保存前自动查语法) | 13 |
$ umask |
查看新建文件的默认权限掩码 | 13 |
$ id app |
查用户 uid/gid 及所属组 | 13 |
$ groups app |
只列用户所属的组 | 13 |
$ su - app |
切换到 app 用户(带 - 加载其环境变量) | 13 |
$ sudo -i |
切换到 root 的完整环境(用完即退) | 13 |
$ getfacl /opt/myapp |
查看文件的 ACL 细粒度授权 | 13 |
[!WARNING] 永远不要
chmod -R 777。为什么 755 够用而 777 危险,见 13 章「为什么 755 不是 777」。
| 命令 | 作用 | 章节 |
|---|---|---|
$ ps aux | grep java |
快照式查看进程 | 14 |
$ top |
实时看 CPU/内存/负载(按 P 按CPU排、M 按内存排) | 14 |
$ htop |
更好用的 top(彩色、可滚动、可搜) | 43 |
$ kill 12345 |
发 SIGTERM 优雅终止(默认信号) | 14 |
$ pkill -f myapp.jar |
按完整命令行匹配杀进程 | 14 |
$ jobs / $ fg / $ bg |
管理当前终端的后台任务 | 14 |
$ nohup java -jar app.jar & |
挂断 SSH 后进程不死(临时跑一次用) | 14 |
$ sudo lsof -i:8080 |
查谁占用了 8080 端口 | 43 |
$ sudo strace -p 12345 |
追踪进程的系统调用(卡死时看它在等什么) | 43 |
$ vmstat 1 |
每秒刷新看 CPU/内存/IO 概览(重点看 wa 列) | 43 |
$ iostat -x 1 |
磁盘 IO 详细统计 | 43 |
$ free -h |
看内存(重点看 available,不是 free) | 14 |
$ uptime |
看 load average 三个数 | 14 |
$ ps -ef --forest |
树状显示进程父子关系 | 14 |
$ kill -l |
列出全部信号(SIGTERM=15,SIGKILL=9) | 14 |
$ sudo pkill -u app |
杀掉某用户的所有进程 | 14 |
$ iotop |
实时看哪个进程在读写磁盘 | 43 |
[!IMPORTANT]
kill -9是 SIGKILL,进程没有清理机会。数据库进程用 kill -9 可能损坏数据,先试默认的kill。信号详解见 14 章。
| 命令 | 作用 | 章节 |
|---|---|---|
$ ip a |
查网卡和 IP 地址(取代 ifconfig) | 15 |
$ ip r |
查路由表和默认网关 | 15 |
$ ss -lntp |
查监听中的端口及对应进程(取代 netstat) | 15 |
$ curl -I http://localhost |
只看响应头(快速验证 HTTP 通不通) | 15 |
$ nc -zv 10.0.0.5 3306 |
测试 TCP 端口连通性 | 15 |
$ ping -c 4 example.com |
发 4 个包测试连通(服务器别裸跑 ping) | 15 |
$ dig example.com |
查域名解析结果 | 15 |
$ sudo ufw status verbose |
查防火墙规则和开关状态 | 16 |
$ sudo ufw allow 22/tcp |
放行端口(先放 SSH 再 enable!) | 16 |
$ sudo ufw delete allow 8080/tcp |
删除一条规则 | 16 |
$ hostnamectl |
查看/修改主机名 | 15 |
$ timedatectl |
查看时区与时间同步状态 | 15 |
$ wget -c https://example.com/big.tar.gz |
断点续传下载大文件 | 15 |
$ sudo chronyc tracking |
查看时间同步精度与上游 NTP 源 | 15 |
$ resolvectl status |
查看当前 DNS 服务器配置 | 15 |
[!WARNING] 开 ufw 前必须先
ufw allow 22/tcp,否则 enable 的瞬间 SSH 断连。完整顺序见 16 章。
详见 17 包管理。
| 命令 | 作用 | 章节 |
|---|---|---|
$ sudo apt update |
刷新软件源索引(装东西前先跑) | 17 |
$ sudo apt install nginx |
安装软件包 | 17 |
$ sudo apt remove nginx |
卸载软件但保留配置 | 17 |
$ sudo apt purge nginx |
卸载并删除配置文件 | 17 |
$ apt policy nginx |
查已装版本和可装版本 | 17 |
$ apt-cache madison nginx |
列出源里所有可用版本 | 17 |
$ sudo apt-mark hold nginx |
锁定版本不被 apt upgrade 升级 | 17 |
$ dpkg -L nginx |
列出某包装到了哪些文件 | 17 |
$ dpkg -S /usr/sbin/nginx |
反查某文件属于哪个包 | 17 |
$ sudo apt -f install |
修复损坏的依赖关系 | 17 |
$ apt search redis |
按关键字搜索源里的软件包 | 17 |
$ apt list --installed |
列出本机已安装的全部软件包 | 17 |
$ apt show redis-server |
查看某包的版本、依赖、大小详情 | 17 |
[!TIP] 国内下载慢先看 33 镜像加速,里面有 apt / pip / npm / Maven / Docker 各家镜像源。
详见 18 SSH 加固。
| 命令 | 作用 | 章节 |
|---|---|---|
$ ssh -p 2222 user@host |
指定端口连接(改过端口必带 -p) | 18 |
$ ssh-keygen -t ed25519 |
生成密钥对(现代算法,比 rsa 短且快) | 18 |
$ ssh-copy-id user@host |
把公钥装到服务器,实现免密登录 | 18 |
$ sudo sshd -t |
校验 sshd 配置语法(restart 前必跑) | 18 |
$ sudo fail2ban-client status sshd |
查看爆破封禁情况 | 18 |
$ ssh -v user@host |
输出详细连接过程(排错第一命令) | 18 |
$ ssh user@host "df -h" |
远程执行一条命令后立即返回(批量巡检用) | 18 |
$ cat ~/.ssh/id_ed25519.pub |
查看本地公钥(往服务器 authorized_keys 手工添加时用) | 18 |
| 命令 | 作用 | 章节 |
|---|---|---|
$ sudo systemctl start myapp |
启动服务 | 40 |
$ sudo systemctl stop myapp |
停止服务 | 40 |
$ sudo systemctl restart myapp |
重启服务(进程全新起) | 40 |
$ sudo systemctl reload nginx |
重载配置不断连接(仅支持 reload 的服务) | 40 |
$ systemctl status myapp |
看服务状态和最近日志 | 40 |
$ sudo systemctl enable --now myapp |
设开机自启并立即启动(一步到位) | 40 |
$ sudo systemctl daemon-reload |
unit 文件改完后让 systemd 重新读取 | 40 |
$ journalctl -u myapp -f |
实时跟踪某服务日志 | 51 |
$ journalctl -u myapp --since "10 min ago" |
查最近 10 分钟的日志 | 51 |
$ systemctl cat myapp |
看 unit 文件实际内容(含自动生成的部分) | 40 |
$ systemctl list-units --failed |
列出所有启动失败的服务 | 40 |
$ systemd-analyze blame |
看各服务开机耗时(谁拖慢了启动) | 40 |
$ systemctl is-active myapp |
脚本化检查服务是否在跑(适合写进巡检) | 40 |
$ systemctl is-enabled myapp |
检查是否设置了开机自启 | 40 |
$ sudo systemctl reset-failed myapp |
清除失败状态,解除崩溃限流后可重新启动 | 40 |
安装与验证细节见 PART 2 各章:20 21 22 23 24 25 26 27 28 29 30 31 32。
| 命令 | 作用 | 章节 |
|---|---|---|
$ java -version |
验证 JRE 可用 | 20 |
$ javac -version |
验证 JDK 编译器(只有 JRE 的话这条会失败) | 20 |
$ python3 --version |
验证 Python 版本 | 21 |
$ node -v |
验证 Node.js 版本 | 22 |
$ mysql --version |
查看 MySQL 客户端版本 | 23 |
$ sudo mysql -e "SELECT 1;" |
免密验证 MySQL 服务可用(root 走 auth_socket) | 23 |
$ psql --version |
查看 PostgreSQL 客户端版本 | 24 |
$ sudo -u postgres psql -c "SELECT version();" |
验证 PostgreSQL 服务可用 | 24 |
$ mongosh --version |
验证 MongoDB Shell 可用 | 25 |
$ redis-cli ping |
应返回 PONG,验证 Redis 活着 | 26 |
$ sudo rabbitmqctl status |
查看 RabbitMQ 运行状态 | 27 |
$ kafka-topics.sh --list --bootstrap-server localhost:9092 |
列出 topic,验证 Kafka 可连(4.x 无 ZooKeeper 参数) | 28 |
$ sudo nginx -t |
校验 Nginx 配置语法(reload 前必跑) | 29 |
$ nginx -v |
查看 Nginx 版本 | 29 |
$ docker version |
验证 Docker Engine 可用 | 30 |
$ docker compose version |
验证 Compose v5 插件(注意 compose 是子命令,老写法 docker-compose 已弃用) |
30 |
$ sudo certbot certificates |
列出已托管证书及到期时间 | 32 |
$ mvn -v |
验证 Maven(含其使用的 JDK) | 31 |
$ git --version |
验证 git 可用 | 31 |
$ pip3 --version |
验证 pip 可用及其绑定的 Python | 21 |
$ npm -v |
验证 npm 可用 | 22 |
详见 44 备份与定时。
| 命令 | 作用 | 章节 |
|---|---|---|
$ sudo mysqldump db > db.sql |
逻辑备份 MySQL 库(root 默认 auth_socket,用 sudo 免密) | 44 |
$ pg_dump dbname > db.sql |
逻辑备份 PostgreSQL 库 | 44 |
$ mongodump --out /backup/mongo |
备份 MongoDB(BSON 目录形式) | 44 |
$ crontab -e |
编辑当前用户的定时任务 | 44 |
$ crontab -l |
列出当前用户的定时任务 | 44 |
$ systemctl list-timers |
列出所有 systemd timer(certbot 续期在这看) | 44 |
$ tar czf /backup/$(date +%F).tar.gz /opt/myapp |
按日期打包目录 | 44 |
$ sudo crontab -u app -l |
查看指定用户的定时任务 | 44 |
$ mysql -u root -p db < db.sql |
从备份恢复 MySQL 库 | 44 |
[!WARNING]
crontab -r会整表删除定时任务且无确认,请用crontab -e删对应行。更多 cron 坑(PATH 缺失、% 转义)见 44 章。
详见 55 磁盘满排错、51 journalctl、30 Docker、43 运维工具箱、17 包管理。
| 命令 | 作用 | 章节 |
|---|---|---|
$ df -h |
查磁盘空间使用率 | 55 |
$ df -i |
查 inode 使用率(小文件塞满时 df -h 却显示没满) | 55 |
$ sudo du -xh --max-depth=1 / | sort -rh | head -20 |
逐级定位大目录(-x 不跨文件系统) | 55 |
$ sudo journalctl --vacuum-size=200M |
把 journal 压到指定大小以内 | 51 |
$ docker system df |
查看 Docker 镜像/容器/卷占了多少磁盘 | 30 |
$ docker system prune |
清理无用镜像、停止的容器(在用镜像不会被删) | 30 |
$ sudo lsof +L1 |
找出「已删除但空间未释放」的文件 | 55 |
$ sudo ncdu / |
交互式磁盘分析(方向键钻目录) | 43 |
$ sudo apt clean |
清空 apt 下载缓存 | 17 |
$ sudo apt autoremove |
删除不再需要的依赖包 | 17 |
$ sudo journalctl --vacuum-time=7d |
删除 7 天以前的 journal 日志 | 51 |
$ docker volume ls |
列出所有数据卷(占空间大户) | 30 |
$ docker image prune -a |
删除所有未被容器引用的镜像 | 30 |
以下命令在错误场景下会让你失去数据或服务器控制权。逐条的风险说明见 00 前言「危险命令红名单」。
| 命令 | 危险点 | 安全做法 |
|---|---|---|
$ rm -rf / 或路径带变量 |
变量为空时变成删根,不可恢复 | rm 前先 echo 打印变量;危险路径用绝对路径 |
$ sudo chmod -R 777 /dir |
全目录开放写权限,等于裸奔 | 按需 755/644 + chown,见 13 章 |
$ crontab -r |
整表删除定时任务,无确认 | 用 crontab -e 删单行;改前 crontab -l > cron.bak |
$ kill -9 <数据库进程> |
数据库跳过落盘清理,可能损坏数据 | 先 kill(SIGTERM),等它优雅退出,见 14 章 |
$ git push --force |
覆盖远程他人提交 | 用 --force-with-lease,先沟通 |
$ sudo apt remove python3 |
Ubuntu 系统工具依赖它,可能带崩半个系统 | 绝不动系统 python3,新版本并行装,见 21 章 |
$ ufw enable(未放行 22 端口) |
当场把自己 SSH 踢下线 | 先 ufw allow 22/tcp 再 enable,见 16 章 |
$ dd if=... of=/dev/sda |
目标盘写错直接清空整块盘 | 写 of= 前用 lsblk 反复核对设备名 |
[!IMPORTANT] 执行任何「删除、覆盖、递归」类命令前,先停一秒:路径对吗?有备份吗?能回滚吗? 改前备份改后验证,见 92 排错手册三原则。
[!IMPORTANT] 本文件是全篇版本信息的唯一权威来源。各部署章节开头均引用本表。 如果发现某章写着的版本与本表不一致,以本表为准并修正该章。
所有版本号截至 2026 年 9 月。 软件版本持续变化,请务必学会自己核对(见文末「如何自己核对版本」)。
| 项目 | 值 | 说明 |
|---|---|---|
| 本教程基准 | Ubuntu 22.04 LTS (jammy) | apt 系,云服务器最常见的入门选择 |
| 标准支持到期 | 2027-04 | 标准支持到 2027-04(以官方页面为准);届时需 Ubuntu Pro/ESM 才能继续获得安全更新 |
| ESM(Ubuntu Pro) | 到 2032 年 | 个人和小企业可免费用于最多 5 台机器 |
| 新机器建议 | 24.04 LTS | 2026-09 起新购服务器建议优先考虑,支持周期更长 |
| 内核 | 5.15(22.04 HWE 可升至 6.x) | uname -r 查看 |
[!NOTE] 截至 2026-09,最新的 Ubuntu LTS 已是 26.04 LTS(2026-04 发布,默认 OpenJDK 25、Python 3.14、内核 7.0)。 本教程仍以 22.04 为基准,是因为它存量最大、且 22.04/24.04 上的命令绝大多数通用。
| 组件 | 推荐版本 | Ubuntu 22.04 源默认 | 获取方式 | 关键提醒 |
|---|---|---|---|---|
| Java (JDK) | 21 LTS(稳妥) 25 LTS(最新) |
openjdk-11/17/21 | apt(21 及以下是 apt;25 需 Temurin 源或手动安装) | Oracle 对 17 的 Premier Support 2026-09 到期;源里没有 JDK 8(需 Temurin 等外部源);必须配 JAVA_HOME;多版本用 update-alternatives |
| Python | 3.12 / 3.13(稳妥) 3.14(最新) |
3.10 | deadsnakes PPA / pyenv / 源码编译到 /opt |
绝不动系统自带 python3,apt 等系统工具依赖它 |
| Node.js | 24 LTS | 12.x(已 EOL,勿用) | NodeSource 官方源 / nvm | 长期服务用 PM2 守护;Node 20 已于 2026-04 停止支持 |
| Go | 1.25+ | 1.18 | 官网二进制包 | 解压到 /usr/local 即可;仅列本教程验证过的版本线,最新版以官网为准 |
| Erlang(RabbitMQ 依赖) | 27.x | 24.x | RabbitMQ 官方 Erlang 源 | 必须先于 RabbitMQ 安装;Erlang 26 已于 2026-05 EOL |
| 组件 | 推荐版本 | Ubuntu 22.04 源默认 | 获取方式 | 关键提醒 |
|---|---|---|---|---|
| MySQL | 8.0.x(生态最成熟) 8.4 LTS(新项目可选) |
8.0.x | 官方 apt 源 | 默认认证插件 caching_sha2_password,老客户端连不上;字符集统一 utf8mb4;Oracle Premier Support 2026-04 已到期(社区/发行版仍在维护);新项目可优先 8.4 LTS |
| PostgreSQL | 17 / 18(18.4 当前稳定) | 14 | PGDG 官方源 | postgresql.conf 与 pg_hba.conf 必须同时改才生效 |
| MongoDB | 8.0.x | 无 | 官方 apt 源 | 先建管理员用户,再开启 auth;License 为 SSPL |
| Redis | 8.x | 6.0 | 官方源 / 源码编译 | 8.0 起 License 改为 AGPLv3(另可选 RSALv2/SSPLv1);BSD 替代品是 Valkey |
| RabbitMQ | 4.3.x | 无 | 官方 apt 源(+ 官方 Erlang 源) | 4.3.6 起才支持 Erlang 28/29;Erlang 27.x 最稳妥 |
| 组件 | 推荐版本 | Ubuntu 22.04 源默认 | 获取方式 | 关键提醒 |
|---|---|---|---|---|
| Kafka | 4.2 / 4.3 | 无(官方二进制包) | 官方二进制包(Scala 2.13 构建) | 4.0 起彻底移除 ZooKeeper,只能跑 KRaft;需 Java 17+;所有 CLI 用 --bootstrap-server |
| Nginx | 1.30 stable(mainline 1.31) | 1.18 | nginx.org 官方源 | 改配置先 nginx -t,再 reload 而非 restart |
| Docker Engine | 29.x | 20.x | 官方 apt 源 | Compose 是 v5 插件,命令为 docker compose(带空格) |
| Docker Compose | v5.x | 随插件(无独立源版本) | 随 Engine 一起装的插件 | 老写法 docker-compose(带横杠)已弃用 |
| 组件 | 推荐版本 / 说明 | 获取方式 |
|---|---|---|
| Git | 2.34+(22.04 源默认即可) | apt |
| Maven | 3.9.x(仅列本教程验证过的版本线,最新版以官网为准) | 官网二进制包 / apt |
| Gradle | 8.x(仅列本教程验证过的版本线,最新版以官网为准) | 官网二进制包 |
build-essential |
含 gcc/g++/make | apt |
| certbot | 最新版 | 推荐 snap install certbot,或 apt |
LTS(Long Term Support) 的含义是厂商承诺在该版本上持续提供安全更新和问题修复数年。
[!WARNING] 非 LTS 版本只适合学习和尝鲜。 它们通常只有约 6 个月的支持期,到期后不再有安全补丁,生产环境用了等于裸奔。
最新 ≠ 最合适。选版本前问两个问题:
caching_sha2_password推荐做法:选上一个 LTS。它已经过一两年的社区验证,坑被踩完了,文档也齐全。
版本升级(尤其是数据库大版本)永远遵循:
备份 → 测试环境升级 → 跑通业务 → 观察一段时间 → 生产停机窗口升级 → 验证 → 回滚预案待命
[!IMPORTANT] 数据库大版本不要原地升级。 正确做法是:新版本实例 + 数据导入 + 业务切换。原地
apt upgrade把 MySQL 8.0 升到 8.4 可能直接起不来。
教程会过期,但方法不会。这是本节最重要的内容。
# 查看已装版本 + 候选版本 + 源里所有可用版本
$ apt policy nginx
预期输出(节选):
nginx:
Installed: (none)
Candidate: 1.18.0-6ubuntu14.4
Version table:
1.18.0-6ubuntu14.4 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages
Candidate 就是 apt install nginx 会装上的版本。
$ apt-cache madison postgresql
# Debian 系
$ dpkg -l | grep nginx
# 只看版本号
$ dpkg-query -W -f='${Version}\n' nginx
$ java -version
$ python3 --version
$ node -v
$ nginx -v # 小写 v,只输出版本号
$ nginx -V # 大写 V,输出版本号 + 编译参数 + 模块列表
$ mysql --version
$ psql --version
$ redis-server --version
$ docker --version
$ docker compose version
endoflife.date 这类站点可以查各软件的支持到期时间[!TIP] 养成习惯:装之前先
apt policy <包名>,确认源里的版本是不是你要的。 很多「装完发现版本不对」的问题,根源是没先看源里有什么,直接apt install装了系统默认的旧版。
| 现象 | 根因 | 解决 |
|---|---|---|
| 装完发现是 1.18,想要 1.30 | 用了系统默认源 | 添加 nginx.org 官方源,见 29-nginx |
apt install python3.12 说找不到包 |
22.04 源里没有 | 用 deadsnakes PPA 或 pyenv,见 21-python |
| RabbitMQ 装不上,报 Erlang 版本冲突 | 先装了系统源里的旧 Erlang | 卸载旧 Erlang,改用 RabbitMQ 官方 Erlang 源,见 27-rabbitmq |
Kafka 教程里的 --zookeeper 参数报错 |
Kafka 4.x 已移除 ZooKeeper | 改用 --bootstrap-server,见 28-kafka |
docker-compose 命令找不到 |
Compose v2+ 是插件形式 | 用 docker compose(空格),见 30-docker |
| 老程序连不上 MySQL 8 | 认证插件 caching_sha2_password 不兼容 |
建用户时指定 mysql_native_password,见 23-mysql |
apt install 装的 Redis 是 6.0 |
系统源太旧 | 用 Redis 官方源或源码编译,见 26-redis |
| 命令 | 作用 |
|---|---|
lsb_release -a |
查看系统发行版与版本号 |
uname -r |
查看内核版本 |
apt policy <包> |
查看该包已装版本、候选版本及来源 |
apt-cache madison <包> |
列出源里所有可用版本 |
dpkg -l | grep <关键字> |
查看已安装包及版本 |
dpkg-query -W -f='${Version}\n' <包> |
只输出已装包的版本号 |
<命令> --version / -v / -V |
查各组件版本 |
apt list --upgradable |
列出可升级的包 |
本手册分两部分:第一部分教思路(排错方法论,适用于任何故障),第二部分是症状索引(按症状查原因和排查命令,链接到各详细章节)。
[!TIP] 救火时直接跳到第二部分对症状。但强烈建议第一部分读一遍——方法论学会了,没有索引表的故障也能排。
| 步骤 | 做什么 | 关键问题 |
|---|---|---|
| ① 现象 | 搞清到底什么不对 | 报错原文是什么?有没有截图?从什么时间开始?是渐渐变差还是突然坏掉? |
| ② 范围 | 圈定问题边界 | 一台还是多台?一个服务还是全部服务?最近改了什么? |
| ③ 证据 | 收集客观数据 | 日志说了什么?状态命令输出什么?监控曲线哪个点开始异常? |
| ④ 假设 | 提出候选原因 | 按概率从高到低排序,一次只验证一个假设 |
| ⑤ 验证与记录 | 修复并沉淀 | 问题解决了吗?写复盘文档了吗? |
这是排错的第一问。运维经验中,绝大多数故障由最近一次变更引起:
# 找最近 3 天内被修改过的配置文件
$ sudo find /etc /opt -type f -mtime -3 -name "*.conf" 2>/dev/null
# 查最近登录与执行过的 sudo 命令
$ last -20
$ sudo grep sudo /var/log/auth.log | tail -50
同时改三个地方再重启,好了也不知道是哪个救的,坏了更排查不清。正确姿势:
复盘文档最小字段:
- 现象(报错原文 + 时间)
- 根因(一句话说清为什么)
- 修复动作(执行过的命令)
- 预防措施(怎么保证不再犯)
# 改前备份:带日期后缀,永不覆盖旧备份
$ sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date +%F-%H%M)
# 改后验证:Nginx 的例子
$ sudo nginx -t
$ sudo systemctl reload nginx
$ curl -I http://localhost
不确定从哪下手时,按故障类型直接复制这四组「第一条命令」。
# 一条链:状态 → 最近 50 行日志 → 端口在听吗
$ systemctl status myapp
$ journalctl -u myapp -n 50 --no-pager
$ sudo ss -lntp | grep <端口>
# 一条链:本机 IP → 监听端口 → 防火墙 → 本机 curl 自测
$ ip a
$ sudo ss -lntp
$ sudo ufw status verbose
$ curl -I http://localhost:<端口>
# 一条链:空间 → inode → 找大目录 → 找已删除未释放的文件
$ df -h
$ df -i
$ sudo du -xh --max-depth=1 / | sort -rh | head -10
$ sudo lsof +L1 | head
详细步骤见 55 磁盘满排错。
# 一条链:负载 → 内存 → CPU/IO 明细 → 谁在吃资源
$ uptime
$ free -h
$ vmstat 1 5
$ top
用法:找到症状 → 看「最可能原因」(按概率排序,最多 3 个)→ 跑「排查命令」→ 需要细节再点进章节。
[!IMPORTANT] SSH 已经断开时,
systemctl status ssh、ufw status这类命令无法再通过 SSH 执行——须经云厂商控制台的 VNC / 救援模式登录服务器后运行,然后再从本地重新连接。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
Connection refused |
① sshd 没起来 ② sshd 改了端口而你还连 22 ③ 机器根本没开机 | 经控制台 VNC 登录服务器后:sudo systemctl status ssh、sudo ss -lntp | grep sshd;确认端口后再从本地 ssh -p <端口> user@host |
18 |
Connection timed out |
① 云厂商安全组没放行 ② ufw 没放行 ③ IP 写错 / 实例已释放 | 经控制台 VNC 登录服务器后:sudo ufw status verbose;再到控制台核对安全组;从本地 ping -c 4 <IP> 看是否完全不通 |
16 |
Permission denied (publickey) |
① 服务器上没有你的公钥 ② 私钥权限太开放 ③ 禁用了密码登录而你没配密钥 | ssh -v user@host 看卡在哪一步;本地 ls -l ~/.ssh/,私钥须 600:chmod 600 ~/.ssh/id_ed25519 |
18 |
Too many authentication failures |
ssh-agent 里塞了太多密钥被服务器拒绝 | ssh -o IdentitiesOnly=yes -i ~/.ssh/指定私钥 user@host |
18 |
| 突然连不上、服务器其他服务正常 | ① fail2ban 把自己的 IP 误封(多次输错密码 / 爆破误判) | 经控制台 VNC 登录服务器后:sudo fail2ban-client status sshd 看封禁列表;解封 sudo fail2ban-client set sshd unbanip <被封IP> |
18 |
快速处置:
# SSH 已连不上时,以下命令须经云厂商控制台 VNC/救援模式登录服务器后执行
# 确认 sshd 活着、在听哪个端口
$ systemctl status ssh
$ sudo ss -lntp | grep sshd
# 确认防火墙是否放行 22
$ sudo ufw status verbose
详见 29 Nginx、40 systemd、16 防火墙。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
| 白屏 / 连接被拒绝 | ① 后端应用没起来 ② 端口没监听 ③ 反代 upstream 写错端口 | systemctl status myapp;sudo ss -lntp | grep <端口>;curl -I http://127.0.0.1:<后端端口> 直连后端验证 |
29 |
| 502 Bad Gateway | ① 后端挂了 ② Nginx proxy_pass 端口/地址错 ③ 后端返回了 Nginx 不认的响应 | journalctl -u myapp -n 50 --no-pager;sudo tail -20 /var/log/nginx/error.log;直连后端 curl -I 对比 |
29 |
| 504 Gateway Timeout | ① 后端处理太慢(慢 SQL、死锁) ② proxy_read_timeout 太短 | 看后端慢请求日志 53 章;临时调大 proxy_read_timeout 再查根因 |
29 |
| 403 Forbidden | ① Nginx 无权读目录(属主不是 www-data) ② 目录缺 x 权限 ③ autoindex 关闭且无 index 文件 | ls -l 看站点目录属主与权限;sudo tail -20 /var/log/nginx/error.log 找 permission denied |
29、13 |
| 404 Not Found | ① root/alias 路径写错 ② sites-enabled 没建软链 ③ 前端路由没配 try_files | ls -l /etc/nginx/sites-enabled/ 确认软链;curl -I 看是 Nginx 404 还是应用 404 |
29 |
快速处置:
# 判断是 Nginx 的问题还是后端的问题
$ curl -I http://127.0.0.1:<后端端口> # 直连后端
$ sudo nginx -t && sudo tail -20 /var/log/nginx/error.log
详见 40 systemd、50 日志基础、51 journalctl。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
status=203/EXEC |
① ExecStart 路径写错或不存在 ② 没用绝对路径 ③ 脚本没有执行权限 | systemctl cat myapp 看 ExecStart;ls -l <路径> 核对存在且可执行 |
40 |
status=1/FAILURE 立即退出 |
① 配置文件语法错 ② 依赖的数据库/端口连不上 ③ 工作目录或用户不对 | journalctl -u myapp -n 100 --no-pager,报错在日志里,不在 status 里 |
51 |
Start request repeated too quickly |
反复崩溃触发限流,systemd 拒绝再拉起 | sudo systemctl reset-failed myapp 后先手动 systemctl start 看真实报错 |
40 |
Address already in use |
端口被旧进程 / 别的服务占着 | sudo lsof -i:<端口> 找占用者;旧进程 kill 掉或换端口 |
43 |
| 改了 unit 文件没生效 | 忘了 daemon-reload | sudo systemctl daemon-reload && sudo systemctl restart myapp |
40 |
| 开机自启没生效 | 只 enable 了一个,或 enable 的本质没理解(建软链) | systemctl is-enabled myapp;systemctl list-units --failed |
40 |
快速处置:
# 服务起不来的标准三连
$ systemctl status myapp
$ journalctl -u myapp -n 100 --no-pager
$ systemctl cat myapp
详见 23 MySQL、24 PostgreSQL、25 MongoDB、26 Redis。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
MySQL Access denied |
① root 走 auth_socket,用密码连当然失败 ② 新用户建了但没 FLUSH PRIVILEGES/授权范围不对 ③ caching_sha2 与老客户端不兼容 |
$ sudo mysql(本机免密进)看用户表;核对授权语句见 23 章 |
23 |
| MySQL 远程连不上 | ① bind-address 还是 127.0.0.1 ② 云安全组/ufw 没放 3306 ③ 用户授权 host 不是 % |
sudo ss -lntp | grep 3306 看绑定地址;nc -zv <IP> 3306 测端口 |
23、16 |
PG Peer authentication failed |
本地连接走 peer 认证,系统用户名 ≠ 数据库用户名 | sudo -u postgres psql 以 postgres 系统用户进;或改 pg_hba 为 md5/scram,见 24 章 |
24 |
| PG 远程连不上 | ① 只改了 listen_addresses 没改 pg_hba ② 反之只改了 pg_hba | 两个文件必须同时改:postgresql.conf + pg_hba.conf,见 24 章 |
24 |
Mongo Authentication failed |
① 先开了 auth 才建用户,把自己锁外面 ② 连的库名不对(用户建在哪个库就 authenticate 到哪个库) | 先关 auth 进去核对用户:use admin; show users;;正确顺序见 25 章 |
25 |
Redis NOAUTH / WRONGPASS |
① 客户端没传 requirepass 的密码 ② 改过密码客户端还用旧的 | redis-cli 后 AUTH <密码>;redis-cli -a <密码> ping 应返回 PONG |
26 |
快速处置:
# 先确认服务本体活着,再谈认证
$ systemctl status mysql # 或 postgresql / mongod / redis
$ sudo ss -lntp | grep -E "3306|5432|27017|6379"
详见 55 磁盘满排错。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
No space left on device(df -h 满) |
① 日志暴涨 ② Docker 镜像/卷堆积 ③ 备份文件没清理 | df -h 确认哪个分区;sudo du -xh --max-depth=1 / | sort -rh | head 逐级钻 |
55 |
写文件仍报满但 df -h 显示没满 |
inode 耗尽(海量小文件) | df -i 确认 IUse%;find <目录> -type f | wc -l 数文件 |
55 |
| 删了大文件空间没释放 | 进程还握着已删除文件的句柄 | sudo lsof +L1 找到进程,重启该服务即释放 |
55 |
快速处置:
# 磁盘满标准四连(按顺序跑)
$ df -h
$ df -i
$ sudo du -xh --max-depth=1 / | sort -rh | head -10
$ sudo lsof +L1 | head
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
| 负载高但 CPU 不高 | ① IO 等待(wa 高)② 磁盘/网络卡 | vmstat 1 看 wa 列;iostat -x 1 看哪块盘 util 高 |
43 |
| CPU 100%,某个进程吃满 | ① 业务流量正常增长 ② 死循环/正则回溯 ③ 被挖矿程序入侵 | top 按 P 排序找元凶;陌生进程先 lsof -p <PID> 看它打开的文件 |
14 |
| 内存耗尽、进程莫名消失 | OOM killer 杀了它(内存不足时系统挑进程杀) | sudo dmesg -T | grep -i "killed process";journalctl -k --since today | grep -i oom |
14 |
free -h 显示 free 很少 |
可能是正常缓存占用,先看 available 列再决定 | free -h:available 够就不用管;不够再 top 按 M 排序找进程 |
14 |
快速处置:
# 性能四连
$ uptime # 负载几个 9?
$ free -h # available 还剩多少
$ vmstat 1 5 # wa 高不高
$ top # 谁在吃资源(P=CPU,M=内存)
详见 13 用户与权限。遇到 Permission denied,按顺序问四层:
| 层 | 检查什么 | 排查命令 |
|---|---|---|
| ① 文件权限 | 属主/属组/其他人,当前用户落在哪一档 | $ ls -l <文件>;$ id 看自己是谁、在哪个组 |
| ② 目录路径权限 | 路径上每一级目录都需要 x(进入)权限 | $ namei -l /opt/myapp/logs/app.log 逐级显示权限 |
| ③ 进程属主 | 服务以什么用户在跑?它有权限吗(而不是「我」) | $ ps aux | grep myapp 看第一列用户名 |
| ④ SELinux/AppArmor | Ubuntu 默认 AppArmor,可能限制服务行为 | $ sudo aa-status;journalctl -k | grep -i apparmor |
快速处置:
# 权限四连
$ id
$ ls -l <文件>
$ namei -l <完整路径>
$ ps aux | grep myapp
[!WARNING] 别用
chmod -R 777「一键解决」。正确做法是 chown 对属主 + 权限给到 755/644,见 13 章。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
| 能 ping 通但 curl 不通 | ① 服务没监听该端口 ② 防火墙拦了 TCP ③ 服务只绑了 127.0.0.1 | sudo ss -lntp(本地确认监听地址);nc -zv <IP> <端口>(远端测端口) |
15、16 |
| DNS 解析失败 | ① resolv.conf 配置错或被覆盖 ② 上游 DNS 挂了 | dig example.com、dig @8.8.8.8 example.com 对比;resolvectl status |
15 |
| 端口不通四层排查 | ① 应用没监听 → ② 本机 ufw → ③ 云安全组 → ④ 中间网络 | 逐层验证:ss -lntp → ufw status → 控制台安全组 → nc -zv |
16 |
| Docker 映射端口防火墙拦不住 / ufw 规则对 Docker 无效 | Docker 直接改 iptables,绕过了 ufw | sudo iptables -L DOCKER -n;要么信任内网要么绑定 127.0.0.1:端口 |
30、16 |
快速处置:
# 端口不通四连(从内到外)
$ sudo ss -lntp | grep <端口> # ① 在听吗,绑的哪个地址
$ sudo ufw status verbose # ② 本机防火墙
$ nc -zv <服务器IP> <端口> # ③ 从外部测
# ④ 还不通 → 去云控制台看安全组
详见 50 日志基础、51 journalctl、52 查询配方、54 logrotate。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
journalctl -u xxx 没输出 |
① unit 名写错 ② journald 没持久化(重启即丢) ③ 服务输出没进 journal | systemctl cat xxx 核对名字;ls /var/log/journal/ 是否存在(不存在=未持久化) |
51 |
| 应用日志文件没内容 | ① 应用写到别的路径 ② stdout 被 nohup 重定向到别处 ③ 日志级别过滤掉了 | ls -l /proc/<PID>/fd/1 看进程 stdout 实际指向哪 |
50 |
| 磁盘被日志撑爆 | ① 某应用 debug 级别狂刷 ② logrotate 没配 / 没生效 | sudo du -sh /var/log/* | sort -rh | head;sudo logrotate -d /etc/logrotate.conf 干跑验证 |
54、55 |
| journal 占用几十 GB | 默认无上限或上限太大 | journalctl --disk-usage;sudo journalctl --vacuum-size=200M 立即回收 |
51 |
| 历史日志被打包找不到内容 | logrotate 压成了 .gz | zgrep "ERROR" app.log.2.gz 直接搜压缩包 |
52 |
快速处置:
# 日志三连
$ journalctl --disk-usage
$ sudo du -sh /var/log/* | sort -rh | head
$ sudo journalctl --vacuum-size=200M # 确认要清理再执行
详见 32 HTTPS 证书、15 网络配置。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
| 浏览器提示证书过期(但 certbot 装了) | ① 自动续期 timer 没跑 ② Nginx reload 没执行,新证书没加载 ③ certbot 装的证书路径和 Nginx 配的不一致 | sudo certbot certificates 看到期时间;systemctl list-timers | grep certbot;sudo nginx -t && sudo systemctl reload nginx |
32 |
| 续期失败(domain 验证不过) | ① 80 端口被占/被防火墙拦 ② DNS 没指到本机 | curl -I http://<域名>;dig +short <域名> 对比本机公网 IP |
32、16 |
HTTPS 报 not yet valid / 时间诡异 |
服务器时间漂移(重启后丢时间、NTP 没同步) | timedatectl 看 System clock synchronized 是否 yes;sudo chronyc tracking |
15 |
| API 调用报签名/时间戳错误 | 本机时间与标准时间偏差过大 | timedatectl 对比手机时间;sudo chronyc makestep 立即校准 |
15 |
快速处置:
# 时间与证书三连
$ timedatectl
$ sudo certbot certificates
$ systemctl list-timers | grep certbot
详见 17 包管理、91 版本速查、27 RabbitMQ、28 Kafka。
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
apt 报 NO_PUBKEY |
第三方源的 GPG 密钥没装或过期 | 按报错补钥:signed-by 方式导入,步骤见 17 章 |
17 |
| 装到的版本和教程不一样 | Ubuntu 官方源自带旧版(如 PostgreSQL、Node) | apt policy <包名> 看候选版本来自哪个源;对照 91 版本速查 |
91 |
| RabbitMQ 起不来 / Erlang 崩溃 | Erlang 版本和 RabbitMQ 不匹配 | 先查 RabbitMQ 与 Erlang 版本对应表,先装匹配的 Erlang 再装 RabbitMQ | 27 |
| Kafka 命令报 ZooKeeper 参数错误 | 网上老教程是 3.x 的;4.0 起移除 ZooKeeper,只能 KRaft | 去掉 --zookeeper,改用 --bootstrap-server localhost:9092 |
28 |
| 升级后某软件突然行为异常 | apt upgrade 把锁外的包升了级 | apt policy <包名> 核对版本;需要锁版本用 apt-mark hold |
17 |
快速处置:
# 版本核对三连
$ apt policy <包名>
$ <软件> --version
# 不确定该装哪个版本 → 查版本速查表
| 症状 | 最可能原因(按概率) | 排查命令 | 章节 |
|---|---|---|---|
permission denied ... /var/run/docker.sock |
当前用户不在 docker 组(或还没重新登录) | sudo usermod -aG docker $USER 后退出重登再试;id 确认组已生效 |
30 |
docker pull 超时 / 很慢 |
国内访问 Docker Hub 受阻,没配镜像加速 | 配置 daemon.json 镜像源后 systemctl restart docker,见 30 章 |
30、33 |
| 容器把磁盘撑爆 | 容器 stdout 日志无上限(json-file 默认不轮转) | docker system df;sudo du -sh /var/lib/docker/containers/* 找巨大 json 日志 |
30、55 |
| 容器重启后数据丢了 | 数据写在容器层,没挂 volume | docker inspect <容器> 看 Mounts;有状态数据一律挂 -v 卷 |
30 |
| 改了 daemon.json 没生效 | 改完执行了 reload,但 Docker 需要 restart | sudo systemctl restart docker(注意会重启所有容器) |
30 |
快速处置:
# Docker 排查四连
$ docker system df
$ sudo du -sh /var/lib/docker/containers/* | sort -rh | head
$ docker inspect <容器> --format '{{json .Mounts}}'
$ docker logs --tail 50 <容器>
问题修好 ≠ 排错结束。做完这三件事,这次故障才有价值:
把「现象 → 根因 → 修复动作 → 预防措施」四行写下来。下次同样的问题(一定会再遇到),五分钟内解决。模板见本手册第一部分「⑤ 记录」。
这次是用户/巡检才发现的,下次要让它主动报:
df -h 并发通知,见 44 章)systemctl is-active 定时检查certbot certificates 把所有证书都看一遍[!IMPORTANT] 做完完整部署演练后,请用 60 端到端实战 各阶段的验收检查与末尾的汇总验收清单逐项核对:应用可访问、HTTPS 有效、systemd 自启、日志在写、备份在跑、防火墙最小开放。清单全绿,才算真正交付。
本教程共 39 个分章文件 + 本目录文件。所有版本号截至 2026 年 9 月,核对方法见 版本速查。
目标:从零到能独立部署一个项目。
00 前言与约定 → 10 Shell与文件系统 → 11 文件操作 → 12 文本处理
↓
13 用户与权限 → 17 包管理 → 40 systemd
↓
按需挑 PART 2 组件章(先 20-jdk / 21-python,再数据库、Nginx)
↓
29 Nginx 反代 → 32 HTTPS 证书 → 60 端到端实战
↓
50-55 日志(项目上线后必读)
直接跳到 PART 2 对应章节,然后回看 40-systemd.md 把它托管成服务、开机自启。
| 我要装 | 去这章 |
|---|---|
| Java | 20 |
| Python | 21 |
| Node.js | 22 |
| MySQL | 23 |
| PostgreSQL | 24 |
| MongoDB | 25 |
| Redis | 26 |
| RabbitMQ | 27 |
| Kafka | 28 |
| Nginx | 29 |
| Docker | 30 |
| Git / Maven | 31 |
| HTTPS 证书 | 32 |
| 下载太慢 | 33 |
| 症状 | 先去这章 |
|---|---|
| 连不上服务器 / SSH 断了 | 18、92 |
| 端口不通、服务访问不到 | 16、15 |
| 磁盘满了 | 55 |
| 服务起不来 | 40、92 |
| 要看报错在哪 | 50、51、52 |
| 要统计错误量 / 慢请求 | 53 |
| 日志文件太大撑爆磁盘 | 54 |
| 进程卡死 / CPU 飙高 | 14、43 |
| 权限不对、Permission denied | 13 |
| 不知道该装哪个版本 | 91 |
| 文件 | 主题 | 行数级别 |
|---|---|---|
| 00-preface.md | 阅读约定、安全基线、环境变量原理、PATH、危险命令红名单 | 大 |
[!IMPORTANT] 不要跳过 00 章。 它定义了全篇的命令写法约定、安全基线和「哪些命令会让你失去服务器控制权」。后面所有章节都依赖它。
| 文件 | 主题 |
|---|---|
| 10-shell-and-filesystem.md | FHS 目录地图、绝对路径/相对路径、软链接 vs 硬链接(inode 视角)、du / df |
| 11-file-operations.md | 增删改查、tar / zip、scp / rsync(结尾斜杠语义差异) |
| 12-text-processing.md | 重定向与管道、grep / find / sed / awk / xargs / sort / uniq |
| 13-users-and-permissions.md | 用户与组、rwx 与八进制换算、为什么 755 不是 777、umask、setuid/setgid/sticky、ACL |
| 14-process-management.md | ps / top / kill、信号表 SIGTERM vs SIGKILL、前后台、nohup、load average |
| 15-network-configuration.md | ip / ss(取代 netstat)、netplan 静态 IP、DNS、curl / wget、chrony 时间同步 |
| 16-firewall.md | 防火墙三层模型、ufw 完整用法、firewalld zone/service、iptables 四表五链、排错顺序 |
| 17-package-management.md | apt / dpkg、apt policy 查版本、signed-by 取代 apt-key、dnf 对照、镜像源 |
| 18-ssh-hardening.md | 密钥登录、禁 root 直登、改端口、fail2ban 防爆破、连接排错 |
每章统一结构:版本选择 → 安装 → 环境变量 → 最小必要配置 → 验证 → systemd 托管 → 常见问题(语言运行时与数据库类章节适用;Git/HTTPS/镜像加速等工具类与专题类章节按专题组织,不强制此模板)。
| 文件 | 主题 | 关键提醒 |
|---|---|---|
| 20-jdk.md | JDK 17/21/25 | Temurin 源、update-alternatives 多版本切换、JAVA_HOME |
| 21-python.md | Python 3.12/3.13 | 绝不动系统 python3;deadsnakes / pyenv / 源码三条路 |
| 22-nodejs.md | Node.js 24 LTS | nvm vs NodeSource、pnpm、PM2 |
| 23-mysql.md | MySQL 8.0 / 8.4 LTS | caching_sha2_password 兼容问题、utf8mb4、最小授权 |
| 24-postgresql.md | PostgreSQL 17/18 | PGDG 源;postgresql.conf 与 pg_hba.conf 必须同时改 |
| 25-mongodb.md | MongoDB 8.0 | 先建管理员用户,再开 auth —— 顺序反了会把自己锁在外面 |
| 26-redis.md | Redis 8.x | 8.0 起 AGPLv3,可用 Valkey(BSD) 替代;maxmemory 策略 |
| 27-rabbitmq.md | RabbitMQ 4.3.x | 先装匹配版本 Erlang,再装 RabbitMQ;版本对应表必看 |
| 28-kafka.md | Kafka 4.2/4.3 | 4.0 起移除 ZooKeeper,只能跑 KRaft;网上的 ZooKeeper 教程是 3.x 老做法 |
| 29-nginx.md | Nginx 1.30 stable | nginx.org 源、sites-available 软链机制、nginx -t、reload vs restart |
| 30-docker.md | Docker Engine 29.x | docker compose 带空格;daemon.json 改完要 restart 不是 reload |
| 31-git-and-build-tools.md | git / Maven / Gradle / make | SSH key、Maven 镜像、build-essential |
| 32-https-certbot.md | Let's Encrypt | certbot 申请、自动续期 timer、证书过期排查 |
| 33-mirrors-acceleration.md | 国内镜像加速 | apt / pip / npm / Maven / Docker 镜像源(可选,不替换默认源) |
| 文件 | 主题 |
|---|---|
| 40-systemd.md | unit 文件结构、Type= 区别、Restart=always 为什么这么配、enable 的本质是建软链、自定义 unit、资源限制 |
| 41-source-compile-install.md | ./configure --prefix → make -j → make install、动态库 ldconfig、源码安装的卸载困境 |
| 42-process-supervision.md | systemd / supervisor / PM2 三种守护方式对比与选型建议 |
| 43-ops-toolbox.md | htop / btop / iotop / ncdu / lsof / strace / tcpdump / fail2ban / tmux |
| 44-backup-and-cron.md | crontab 五段语法与常见坑(PATH 缺失、% 转义)、systemd timer、3-2-1 备份策略、恢复演练 |
| 文件 | 主题 |
|---|---|
| 50-log-fundamentals.md | /var/log 目录地图、rsyslog 与 journald 的分工与共存、应用日志放哪、日志格式约定 |
| 51-journalctl.md | --since/--until、-u、-p、-b、-f、持久化开启、--vacuum-size 清理 |
| 52-log-query-recipes.md | tail -f vs tail -F、less +F、grep -A/-B/-C 上下文、zgrep 查历史压缩包 |
| 53-log-analysis.md | awk 字段统计、错误 TOP N、按 IP / 状态码聚合、按分钟切时间窗、慢请求排序 |
| 54-logrotate.md | 配置项逐条讲解、copytruncate vs postrotate kill -USR1 的区别与坑、logrotate -d 干跑 |
| 55-disk-full-troubleshooting.md | df -h vs df -i、du 逐级定位、文件已删但空间没释放(`lsof |
| 文件 | 主题 |
|---|---|
| 60-hands-on-project.md | 端到端完整部署:准备服务器 → JDK → MySQL → Redis → 部署 Java 应用 → systemd 托管 → Nginx 反代 → HTTPS → 日志与备份验收 |
| 文件 | 主题 |
|---|---|
| 90-cheatsheet-commands.md | 跨章高频命令索引:命令 + 一句作用 + 所属章节链接 |
| 91-cheatsheet-versions.md | 版本速查表(全篇唯一权威来源):推荐版本、Ubuntu 22.04 源默认版本、获取方式、关键提醒 |
| 92-troubleshooting-playbook.md | 通用排错方法论(现象 → 范围 → 证据 → 假设 → 验证)+ 症状 → 章节索引表 |
明确划清边界,避免范围蔓延:
updated 字段,改动时请同步更新README.md、本文件、90 命令速查、91 版本速查linux-server/
README.md
docs/
00-preface.md
10-shell-and-filesystem.md
11-file-operations.md
12-text-processing.md
13-users-and-permissions.md
14-process-management.md
15-network-configuration.md
16-firewall.md
17-package-management.md
18-ssh-hardening.md
20-jdk.md
21-python.md
22-nodejs.md
23-mysql.md
24-postgresql.md
25-mongodb.md
26-redis.md
27-rabbitmq.md
28-kafka.md
29-nginx.md
30-docker.md
31-git-and-build-tools.md
32-https-certbot.md
33-mirrors-acceleration.md
40-systemd.md
41-source-compile-install.md
42-process-supervision.md
43-ops-toolbox.md
44-backup-and-cron.md
50-log-fundamentals.md
51-journalctl.md
52-log-query-recipes.md
53-log-analysis.md
54-logrotate.md
55-disk-full-troubleshooting.md
60-hands-on-project.md
90-cheatsheet-commands.md
91-cheatsheet-versions.md
92-troubleshooting-playbook.md
README.md完整源码 Gitee 仓库浏览 →