半夜收到告警说服务挂了,登录服务器一看,进程不在了。这时候大多数人的第一反应是重启,重启成功了就当没发生,重启失败才开始慌。但"服务为什么启动不起来"这一类问题,其实有相当固定的排查路径,掌握之后大部分情况能在十分钟内定位到原因,不用靠猜。
这篇文章要教的就是这套路径。核心方法论只有一条:顺着启动过程逐层验证,每一步都要有证据,不要跳步。启动失败的原因按出现频率排,大致是环境变量缺失、端口被占用、依赖包缺失或版本不匹配、文件权限不对、配置文件语法或路径错误、内存不足、以及程序自身的逻辑错误。我会把每一类的原因、典型报错、验证命令和修复方式讲清楚。
命令给 RHEL/CentOS/Rocky 与 Debian/Ubuntu 两套。排查过程中会用到一些查看和终止进程的命令,涉及 kill 和删除文件的地方会标风险提示。
一、先建立正确的排查心态
有几个反直觉但非常重要的原则,先说在前面。
别急着重启。 重启会清掉现场:进程的内存状态没了、临时文件被删了、日志可能被轮转掉。如果服务是崩溃的,日志还在,重启一次新日志可能覆盖掉最关键的崩溃堆栈。正确顺序是:先收集证据,再动手。
先看日志,再看配置。 应用启动失败时,绝大多数情况日志里已经写了原因,只是很多人不看或者没找到正确的日志位置。花两分钟读日志,比花两小时猜配置高效得多。
一次只改一个变量。 排查时手忙脚乱地同时改了三处配置,然后服务起来了,你既不知道是哪一处起的作用,也不知道另外两处会不会在下次出问题。改一处、验证一次、记录一次。
用和线上完全一致的身份执行一次。 很多"服务启动失败但手工能跑"的情况,根源是身份不同导致的环境、权限、工作目录差异。用 sudo -u <服务用户> 手工执行一次启动命令,往往能立刻复现问题。
区分"起不来"和"起来又死"。 前者是启动阶段就报错退出,后者是启动成功运行一段时间后崩溃。两者的排查方向完全不同:前者看启动日志,后者要看崩溃前的运行日志、内存趋势、系统日志(可能是被 OOM Killer 杀的)。
二、第一现场:日志在哪里
先把"去哪儿找日志"这件事搞清楚,这是所有排查的前提。
如果是 systemd 托管的服务,日志进 journald:
# 看服务状态,这里会显示最近几行日志和退出码
sudo systemctl status myapp
# 看完整日志,-n 指定行数,--no-pager 避免卡在分页器里
sudo journalctl -u myapp -n 200 --no-pager
# 只看某次启动之后的日志,-b 是本次开机
sudo journalctl -u myapp -b
# 按时间过滤
sudo journalctl -u myapp --since "2024-01-01 10:00" --until "2024-01-01 10:10"
# 实时跟随(调试时用)
sudo journalctl -u myapp -f
# 看服务本次启动的退出状态和信号
systemctl show myapp -p ExecMainStatus -p ExecMainCode -p ExecMainExitTimestamp
journald 的日志有时会被限流,如果发现日志不完整(比如大量重复错误被丢弃),检查 /etc/systemd/journald.conf 里的 RateLimitBurst 和 RateLimitIntervalSec。在调试期间可以临时设成 RateLimitBurst=0 关闭限流,但不要长期保持。
日志不在 journald 里的情况,去这几个地方找:
# 应用自己写的日志目录
ls -lt /var/log/app/ | head
tail -n 200 /var/log/app/application.log
tail -f /var/log/app/application.log
# Nginx 的日志(如果问题出在入口层)
tail -n 200 /var/log/nginx/error.log
ls -lt /var/log/nginx/
# 内核日志,OOM Killer 和硬件层面的错误在这里
sudo dmesg -T | tail -n 50
sudo journalctl -k -n 100 --no-pager
dmesg -T 会把时间戳转成人类可读格式(-T 是 human readable,注意这个时间可能因时区显示有偏差)。查 OOM Killer 的典型命令:
sudo dmesg -T | grep -i -E "killed process|out of memory|oom"
sudo journalctl -k | grep -i oom
如果应用没有配置日志落盘,或者日志文件路径不对,那就得从服务定义里找线索:
# 看服务实际执行了什么命令、工作目录在哪、环境变量是什么
systemctl show myapp -p ExecStart -p WorkingDirectory -p Environment
# 或者直接看服务文件
systemctl cat myapp
三、环境变量缺失
这是最高频的原因之一,而且症状往往很奇怪——报错信息指向的地方和真正的原因无关。比如 Python 应用报 KeyError: 'DATABASE_URL',看起来是配置问题,根因是环境变量没注入。
根本原因在于环境是分层的。你在 SSH 登录后 export 的变量,属于交互式 shell 的环境;systemd 启动服务时用的是 systemd 自己的环境,它不读 /etc/profile、不读 ~/.bashrc、不读 ~/.bash_profile。所以"我明明在终端里 echo $JAVA_HOME 是有的"不能证明服务里也有。
验证方法就是切换到服务用户的视角看:
# 看服务用户能读到什么环境变量
sudo -u appuser bash -c 'env | sort'
# 看某个具体变量
sudo -u appuser bash -c 'echo "JAVA_HOME=$JAVA_HOME PATH=$PATH"'
# 最直接:用服务定义里的完整命令手工跑一次
sudo -u appuser /opt/app/venv/bin/gunicorn --bind 127.0.0.1:8000 app:app
常见的失败模式和修复方式:
# 在 systemd 服务文件里显式声明(最可靠)
[Service]
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
Environment="PATH=/usr/lib/jvm/java-17-openjdk/bin:/usr/local/bin:/usr/bin:/bin"
Environment="NODE_ENV=production"
# 从文件加载多个变量(文件格式为 KEY=value,一行一个)
EnvironmentFile=-/etc/app/app.env
EnvironmentFile 前面的减号表示"文件不存在也不报错",适合可选配置。但要注意这个文件里不要写 export,不要加引号(除非值里真的需要引号),也不要写 shell 变量引用,systemd 不解析这些。
修完之后必须 sudo systemctl daemon-reload 再重启服务,否则改的服务文件不会生效——这是另一个高频失误。
一个常见的陷阱是命令路径。当 PATH 不包含工具所在的目录时,会报 "command not found",看起来像程序没装,其实是 PATH 的问题。用绝对路径能绕过这个问题,也是生产脚本的推荐写法。
四、端口被占用
报错通常很直白:Address already in use、bind: address already in use、Port 8080 was already in use。但也有间接的表现,比如服务启动日志显示成功却访问不了,可能是起了两个实例,实际服务的是另一个。
# 看谁在监听某个端口,-l 监听中 -n 数字形式 -t TCP -p 显示进程
sudo ss -lntp | grep 8080
# 看某个进程占用了哪些端口
sudo ss -lntp | grep $(pgrep -f app.jar | head -1)
# 老系统可能只有 netstat
sudo netstat -lntp | grep 8080
# 看所有监听端口,检查有没有意料之外的暴露
sudo ss -lntp
查到占用者之后有三种处理方式,按推荐顺序:如果占用者是同一个应用的旧进程,说明上次没停干净,先停掉再启动;如果是另一个应用,给当前应用换端口并同步改 Nginx 配置;如果是系统服务(比如 80 被 Nginx 占了,你的应用也想用 80),那就必须换端口,这是设计问题。
# 正常停止一个 systemd 服务
sudo systemctl stop myapp
# 风险提示:kill 会强制中断进程,可能丢失正在处理的数据
# 优先尝试优雅终止(SIGTERM),给进程时间清理
sudo kill
# 等几秒确认是否退出
sudo ss -lntp | grep 8080
# 确实卡死了再用强制终止(SIGKILL),最后手段
sudo kill -9
风险提示:kill -9 会跳过进程的清理逻辑,对于有写操作的服务可能导致数据损坏或锁文件残留。用它之前先确认进程确实无响应,并且你清楚它当时在做什么。另外不要用 pkill -f java 这类模糊匹配的批量杀进程命令,在跑了多个 Java 应用的机器上很容易误伤。
还有一个特殊场景:TIME_WAIT 状态的连接会占用端口一小段时间(通常 60 秒)。如果你刚刚重启过服务,可能看到端口"被占用"但找不到进程。这种情况等一分钟或者确认连接状态:
sudo ss -antp | grep 8080 | head
# TIME_WAIT 是正常的回收状态,等待即可,不需要干预
五、依赖包缺失与版本不匹配
这一类在 Python 和 Node 项目里尤其常见,因为它们的依赖是分环境安装的。
Python 的典型症状是 ModuleNotFoundError: No module named 'xxx'。排查要点:
# 确认虚拟环境激活正确,用绝对路径最保险
/opt/app/venv/bin/python -c "import flask; print(flask.__version__)"
# 看虚拟环境里到底装了什么
/opt/app/venv/bin/pip list
# 确认 python 指向的是虚拟环境的解释器,而不是系统的
/opt/app/venv/bin/python -c "import sys; print(sys.executable)"
如果模块明明装了却导入失败,检查三件事:服务用的是不是这个虚拟环境(看服务文件里的 ExecStart 路径)、PYTHONPATH 有没有覆盖默认路径、有没有同名文件遮蔽(比如你自己建了个 json.py,就会导致标准库的 json 导不进来)。
Node.js 的典型症状是 Cannot find module 'xxx' 或者原生模块报 was compiled against a different Node.js version。后者是 node_modules 里的原生模块和当前 Node 版本不匹配,解决办法是重新 npm ci 一次,让原生模块按当前版本重新编译。
# 确认 node 版本
node -v
# 确认是哪个 node 在被用(nvm 场景下容易搞混)
which node
# 重新安装依赖,会重建原生模块
npm ci --omit=dev
Java 的依赖是全打在 fat jar 里的,缺依赖的情况少见,更常见的是版本问题:UnsupportedClassVersionError 说明 JDK 版本低于编译版本;NoSuchMethodError 或 ClassNotFoundException 说明依赖冲突(一般出现在手工拼 classpath 的部署方式里)。检查运行版本:
java -version
# 看 jar 是用哪个版本编译的(看 class 文件版本号)
unzip -p app.jar META-INF/MANIFEST.MF | head
javap -verbose -cp app.jar com.example.Main 2>/dev/null | grep "major version"
major version 和 JDK 的对应关系:52 对应 Java 8,55 对应 11,61 对应 17,65 对应 21。这个对照表很实用。
编译工具链缺失是另一类。Python 装原生扩展需要 python3-devel 和 gcc,Node 装原生模块需要 build-essential 和 python3。症状是安装依赖时报 Python.h: No such file or directory 或 gcc: command not found。
# RHEL / Rocky
sudo dnf groupinstall -y "Development Tools"
sudo dnf install -y python3-devel
# Debian / Ubuntu
sudo apt install -y build-essential python3-dev
六、文件与目录权限
权限问题的特点是报错信息通常很隐晦:Permission denied、EACCES、Read-only file system,有时候甚至直接是"什么都不报,进程就是起不来"。
排查顺序是:先确认服务运行的用户是谁,再看它需要访问的每个路径的权限。
# 1) 服务用哪个用户跑
systemctl show myapp -p User -p Group
# 2) 看关键目录的属主和权限
ls -ld /opt/app /opt/app/current /var/log/app /etc/app /run/gunicorn
# 3) 用服务用户实际测试读写
sudo -u appuser test -r /etc/app/app.env && echo "可读" || echo "不可读"
sudo -u appuser test -w /var/log/app && echo "可写" || echo "不可写"
# 4) 从根目录逐级检查父目录是否有 x 权限(这是最容易漏的一层)
namei -l /opt/app/current/app.jar
第四条的 namei -l 非常有用:Linux 里要访问一个文件,需要对路径上每一级目录都有执行权限(x)。很多人只改了目标文件的权限,却没注意某一级父目录缺少 x,导致依然访问不了。namei -l 会把整条路径的权限一次性列出来。
# 修复权限(示例)
sudo chown -R appuser:appuser /opt/app /var/log/app
sudo chmod 750 /opt/app
sudo chmod 600 /etc/app/app.env
风险提示:chown -R 和 chmod -R 的作用范围是整个目录树,在 / 或 /usr 这类系统目录上误用会造成严重问题(比如 sshd 拒绝加载密钥、sudo 完全失效)。执行前必须用 ls -ld 确认目标目录正确,并且明确知道递归会影响到哪些文件。改错之后的典型症状是 SSH 登录不上,那时只能通过控制台救援。
权限问题还有两个特殊形式。一是 SELinux(RHEL 系特有),文件权限看起来都正常,但就是访问不了,看 /var/log/audit/audit.log 会有 denied 记录:
# 看有没有被 SELinux 拦
sudo grep denied /var/log/audit/audit.log | tail
# 临时切换成宽容模式验证是不是 SELinux 的原因(重启后恢复)
sudo setenforce 0
# 验证完记得改回来
sudo setenforce 1
# 修复文件上下文(比关闭 SELinux 正确得多)
sudo restorecon -Rv /opt/app
风险提示:setenforce 0 只是临时关闭,用于验证假设。绝不建议作为长期方案,因为它把系统的安全机制整个关掉了。正确的做法是用 restorecon 修上下文或者用 semanage 添加策略。
二是文件被锁或者磁盘只读。磁盘写满时,部分文件系统会切换成只读模式,表现为"什么操作都报 Read-only file system"。先看磁盘:
df -h
df -i # inode 也会被用满,小文件特别多的场景要注意
七、配置文件语法与路径
配置文件的问题有两类:语法错误和路径错误。两者都会在启动时报错,但报错位置不同。
语法错误最典型的是 YAML 缩进问题。YAML 用缩进表示层级,多一个或少一个空格都会导致解析失败,而且报错信息经常指向你实际出错位置的下几行。检查方式:
# Python 环境里校验 YAML
/opt/app/venv/bin/python -c "import yaml,sys; yaml.safe_load(open('config.yml'))" && echo "语法正确"
# 用 yq、yamllint 等工具
yamllint config.yml
# JSON 文件
python3 -m json.tool config.json > /dev/null && echo "JSON 语法正确"
# Nginx 配置,这是必备的检查步骤
sudo nginx -t
nginx -t 是改 Nginx 配置后的强制动作,它能在不重载的情况下检查语法,避免"改坏了配置导致 Nginx 起不来、网站全挂"。
路径错误的表现是找不到配置文件,或者读到了错误的文件。排查要点:
# 服务的工作目录是什么(相对路径是相对它解析的)
systemctl show myapp -p WorkingDirectory
# 应用期望的配置文件位置在哪,实际存不存在
ls -l /etc/app/application-prod.yml
# 用 strace 看进程实际尝试打开了哪些文件(在有症状时很有用)
sudo strace -f -e trace=openat -p 2>&1 | grep -i config
另外一个高频问题是配置加载顺序。比如 Spring Boot 里外部配置和 jar 内配置的优先级、环境变量覆盖了 yml 里的值、多个 profile 文件叠加。这类问题的排查技巧是让应用把最终生效的配置打印出来:Spring Boot 加 --debug 参数会输出自动配置报告;很多框架支持启动时打印配置摘要(注意脱敏)。
八、内存不足
内存导致的启动失败有两个层次:应用层面和系统层面。
应用层面,Java 报 Error occurred during initialization of VM: Could not reserve enough space for object heap 或者 OutOfMemoryError: Java heap space;Node 报 JavaScript heap out of memory;Python 进程直接被杀掉。这类问题的排查:
# 看系统内存整体情况,重点看 available 不是 free
free -h
# 看具体进程的内存占用,RES 是常驻物理内存,%MEM 是百分比
ps aux --sort=-%mem | head -10
# 看进程详细的内存映射
cat /proc/$(pgrep -f app.jar | head -1)/status | grep -E "VmRSS|VmSwap"
# 看 JVM 各区域的内存情况
jcmd $(pgrep -f app.jar | head -1) GC.heap_info
jcmd $(pgrep -f app.jar | head -1) VM.native_memory summary
如果报的是"无法预留堆空间",说明机器上没有那么多连续可用内存,通常是 -Xmx 设得太大。这里的坑在于:JVM 需要的内存不只是 -Xmx,还包括元空间、线程栈、直接内存、代码缓存等。经验上 JVM 实际占用的物理内存大约是 -Xmx 的 1.3 到 1.5 倍。所以 8G 的机器上 -Xmx 设 7G 几乎必然失败或者跑不了多久就被系统杀掉。
系统层面的内存不足会触发 OOM Killer,这是最隐蔽的一种,因为应用日志里什么都没写,进程"凭空消失"。确认方式:
sudo dmesg -T | grep -i -E "out of memory|killed process"
sudo journalctl -k --since "1 hour ago" | grep -i oom
输出里会指明被杀进程的 PID、名字、总内存、以及一个 oom_score。这能明确告诉你"不是你的程序崩了,是被系统杀了"。
# 查看当前 OOM 相关配置
cat /proc/sys/vm/overcommit_memory
cat /proc/sys/vm/swappiness
# 查看系统内存和 swap
free -h
修复方向有三条:调小应用的内存参数(最直接);给机器加内存;改架构(比如把多个应用从一台机器拆开)。另外检查 swap 设置,如果机器开了 swap 且 swappiness 很高,Java 应用可能因为内存页被换出而出现极慢的假死状态,比直接被 kill 更难排查,生产环境通常把 swappiness 调到 0 到 10。
九、逐步排查清单
把上面的内容整理成一个可以照着走的顺序,遇到"服务起不来"从上到下逐项排除。
# ── 第一步:确认现象,不要跳过
sudo systemctl status myapp
sudo journalctl -u myapp -n 100 --no-pager
# 记录:退出码是什么?有没有明确的报错行?
# ── 第二步:排除系统层面
free -h # 内存是否耗尽
df -h # 磁盘是否写满
df -i # inode 是否耗尽
uptime # 负载是否异常高
sudo dmesg -T | grep -i -E "oom|killed process" # 是否被 OOM Killer 杀
# ── 第三步:环境与身份是否正确
systemctl show myapp -p User -p Group -p WorkingDirectory -p Environment
sudo -u appuser bash -c 'env | sort | head -30'
# ── 第四步:端口是否可用
sudo ss -lntp | grep -E "8080|3000|8000"
# ── 第五步:文件与权限
namei -l /opt/app/current/app.jar
ls -ld /var/log/app /etc/app /run
sudo -u appuser test -w /var/log/app && echo "可写" || echo "不可写"
# ── 第六步:依赖与运行时版本
java -version && echo "JAVA_HOME=$JAVA_HOME"
node -v && which node
/opt/app/venv/bin/python -V
/opt/app/venv/bin/pip list | head -20
# ── 第七步:配置语法
sudo nginx -t
/opt/app/venv/bin/python -c "import yaml; yaml.safe_load(open('/etc/app/config.yml'))"
# ── 第八步:用服务身份手工执行一次,看完整报错
sudo -u appuser bash -lc '/opt/app/venv/bin/gunicorn --bind 127.0.0.1:8000 app:app'
第八步是关键:它绕开了 systemd 的所有包装(自动重启、环境隔离、日志重定向),把最原始的报错直接打在终端上。前面七步都是在缩小范围,第八步通常直接给出答案。
补充两个实用技巧。一是在调试期间临时把服务改成前台运行,方便看日志:
# 编辑服务文件,把 Type 改成 simple 并去掉 SuccessExitStatus 等
# 更简单的方式是直接手工执行 ExecStart 里的命令
二是用 --dry-run 或配置检查模式。很多程序支持"只检查不启动",比如 nginx -t、httpd -t、systemctl --dry-run。这类检查不会影响现网,应该在改动配置后成为习惯动作。
常见问题
Q:手工执行命令能跑起来,systemd 启动就是不行,怎么办?
九成是环境差异。逐个对比:用户是谁、工作目录在哪、环境变量有哪些、PATH 是什么。用 sudo -u <用户> bash -lc '<完整命令>' 手工执行是最直接的对比方式。注意 bash -lc 会加载登录 shell 配置,而 systemd 不会,所以要留意这个区别本身也可能是问题的一部分。
Q:日志里什么错误都没有,进程就是起不来?
先看有没有日志:journalctl -u myapp 完全是空的,说明服务可能根本没执行到应用代码(比如 ExecStart 路径写错、用户不存在、权限不足)。看 systemctl status 输出的"Process exited with status"和内核日志。如果确定被 OOM Killer 杀了,应用侧确实不会留下任何日志。
Q:改完配置重启还是用旧的配置?
两类原因。一是没执行 systemctl daemon-reload,服务文件改动不会生效;二是应用自己的配置缓存了,比如某些框架会把配置写进临时文件,需要清掉。另外要确认改的配置文件是不是应用实际读的那个路径。
Q:服务能启动但很快就退出,Restart=always 一直重启?
看 systemctl status 里的重启计数,再用 journalctl -u myapp 找每次退出前的最后几行。这种"起来又死"的情况,通常是端口被别的东西占用、依赖的外部服务(数据库、Redis)连不上、或者健康检查失败导致的主动退出。Restart 配置要有频率上限,否则问题机器会陷入重启风暴。
Q:怎么快速判断是应用问题还是机器问题?
看同一台机器上其他服务是否正常。如果只有你的应用不行,问题在应用侧;如果所有服务都异常,问题在机器(内存、磁盘、内核、网络)。这是最省事的二分法。
总结
排查启动失败,方法论比技巧更重要:先用日志确定失败发生在哪个阶段,再顺着启动链路逐层验证,每一步拿到证据再往下走,不要跳步猜。
日志是第一现场,journalctl -u <服务> 和 dmesg -T 分别覆盖应用层和内核层。排在故障原因前三位的是环境变量缺失(尤其是 systemd 不加载 shell 配置导致的)、端口被占用、以及依赖和运行时版本不匹配。权限问题要用 namei -l 检查整条路径而不只是目标文件,内存问题要区分"应用报错"和"被 OOM Killer 杀掉"。
最有效的一招是用服务用户的身份、在正确的工作目录、手工执行一次启动命令,它能绕开 systemd 的封装直接暴露根因。配合 systemctl daemon-reload 这个容易被遗忘的动作,以及 nginx -t 这类配置检查习惯,大部分启动故障都能在十几分钟内定位。
企业QQ咨询




