Linux服务器部署Java服务实战:从环境配置到稳定运行
我第一次独立在Linux服务器上部署Java服务的时候想法特别简单把jar包扔上去java -jar跑起来就完事。结果那天晚上十一点我坐在地上对着终端发呆MySQL连不上、8080端口被占、进程起来三秒就挂三个问题排着队来找我。后来我才意识到部署这件事从来不是“把包传上去启动”这么简单。它背后是一整套链路运行环境是否干净、网络是否可达、进程挂了谁能拉起、日志去哪看、升级怎么不停服、安全怎么守住。任何一个环节没想过线上就会用各种诡异的方式给你上课。这篇文章我会按自己实际部署的完整路径把Linux Java服务从零跑到稳定运行的整个流程拆开讲清楚。包括服务器基础准备、JDK选型、打包上传、systemd和Docker两种启动方式、部署后的体检清单、几个高频故障的真实排查链路以及升级回滚和安全加固的实操经验。不管你是刚接触部署的Java开发还是被临时拉去管服务器的运维新人这篇文章都可以当作一份可以直接照着做的操作手册。1. 先理清部署这件事的三层问题运行环境、可访问性、故障恢复很多人部署失败不是某个命令敲错了而是脑子里缺少一张完整的链路图。我习惯把一次部署拆成三个问题来看。1.1 环境问题你的服务依赖什么它们都在哪Java服务几乎不可能孤零零跑起来。一个典型的Spring Boot单体项目背后通常站着MySQL、Redis、Nginx这些基础设施。微服务项目就更复杂还有注册中心、配置中心、消息队列。第一步不是急着装JDK而是先盘清楚你的服务到底依赖哪些组件。我建议在动手之前画一张最朴素的依赖清单不用画花哨的架构图列个表格就行组件用途端口是否需要公网暴露JDKJava运行环境无否MySQL业务数据存储3306否仅内网Redis缓存/会话6379否仅内网Nginx反向代理/静态资源80/443是业务服务核心API8080按需这张表的作用是让你知道哪些组件需要从外部访问哪些只要内网能通就行。很多部署事故都是因为把数据库端口暴露到了公网或者反过来服务起了、端口也监听了但安全组没放行外部怎么都访问不到。1.2 可访问性问题进程活着不等于服务可用这是新手最容易混淆的一点。java -jar启动后终端显示“Started Application in 5.2 seconds”你以为大功告成结果浏览器访问不了。原因通常出在三个层面应用监听地址不对Spring Boot默认监听0.0.0.0但如果有人改了server.address127.0.0.1外部就永远访问不到。Linux防火墙拦截firewalld或ufw没有放行业务端口。云平台安全组没放行阿里云、腾讯云这些都有独立于系统防火墙的访问控制层Linux防火墙放行不够或者放行了但安全组没开都会被挡在外面。排查这类问题要按照“网线是否通→防火墙是否放行→系统端口是否监听→应用是否正常响应”的顺序一层层看不要一上来就怀疑代码。1.3 故障恢复进程挂了你知不知道谁来拉起裸奔的Java进程终端一关就没了或者OOM被系统杀掉就再也起不来。部署之前必须先定好这几点谁来拉起进程systemd、Docker的restart策略、还是手工脚本进程挂了你知不知道有没有健康检查脚本或监控告警日志写到哪journald、文件、还是容器stdout这三点就是服务自愈能力的地基。我在后面的章节会分别展开讲systemd和Docker的配置方法这里先把这个心智模型立起来部署不是一次性的动作而是一套让你的服务能持续对外提供服务的机制。2. 从零准备一台能跑Java服务的Linux主机2.1 系统选择与初始化操作大部分云厂商都提供Ubuntu、Debian、CentOS这些主流发行版镜像。我自己的倾向是新项目用Ubuntu 22.04 LTS或者Debian 12CentOS 7已经停止维护很久了CentOS Stream的定位也不太适合追求稳定的生产环境。用root登录后的第一件事是按顺序做基础初始化# 查看系统版本确认自己没登错机器 cat /etc/os-release # Debian/Ubuntu系 apt update apt upgrade -y # 安装基础工具 apt install -y curl wget vim git lsof net-tools unzip tree这里有个容易被忽略的细节lsof和net-tools提供netstat这些命令在最小化安装的系统里默认没有真到排查端口问题的时候再装就手忙脚乱了。所以提前装好备着垫底。2.2 创建部署用户别用root跑服务我一直坚持用独立的账号跑Java服务这个习惯救过我很多次。原因很直白应用一旦被攻破root权限意味着整个服务器沦陷而且不同项目混在root下文件权限、日志、环境变量全搅在一起维护成本极高。创建用户的命令很简单# 创建deploy用户并加入sudo组 useradd -m -s /bin/bash deploy usermod -aG sudo deploy # 设置密码 passwd deploy后面所有JDK安装、项目部署、服务启动都用deploy用户来做。目录所有权也要对应给好mkdir -p /opt/app chown -R deploy:deploy /opt/app2.3 SSH密钥登录配置密码登录在暴力破解面前就是一层纸特别是22端口暴露在公网上的情况。我之前看过服务器日志一天几万次失败登录尝试是常态。所以密钥登录不是可选项是必选项。在本地电脑上生成密钥并拷贝到服务器# 本地执行 ssh-keygen -t ed25519 -C deployyour-machine ssh-copy-id deploy你的服务器IP拷完之后建议先不要急着关密码登录等新会话确认密钥能登进去再改配置。改/etc/ssh/sshd_configPasswordAuthentication no PubkeyAuthentication yes然后systemctl restart sshd。注意一定要保留一个已登录的会话窗口再重启SSH防止配置写错把自己锁在外面。2.4 云安全组与系统防火墙云服务器的访问控制是两层Linux系统防火墙iptables/firewalld/ufw 云平台安全组。两层都要放行端口才能通。我见过太多人只改了一层另一层忘记改结果服务起在服务器上看不到任何问题外面就是连不上。在Ubuntu上推荐直接关闭ufw或明确放行端口二选一不要模棱两可# 方式一明确放行必要端口 ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw allow 8080/tcp ufw enable # 方式二如果暂时不想启用ufw至少确认状态 ufw status云平台安全组那边尽量只放行22、80、443和业务端口。数据库3306、Redis 6379这些内部端口永远不要直接暴露到公网至少加个来源IP白名单。3. JDK选型与环境变量为什么很多人卡在部署第一步3.1 JDK版本怎么选8、11、17还是21版本选择不是越新越好要看你项目的底层框架。JDK版本对Java服务的影响我的建议JDK 8老项目绝对主力Spring Boot 2.x兼容性最好老项目不要轻易升JDK 11已有部分机构使用但生态定位比较尴尬新项目可以避开的中间版本JDK 17当前Spring Boot 3.x的最佳搭配长期支持版本新项目首选JDK 21更新的LTS虚拟线程等特性亮眼技术栈较新的团队可以上最经典的翻车场景是本地用JDK 17编译服务器装的是JDK 8启动直接报UnsupportedClassVersionError。这个错误码的意思是“编译用的JDK比运行JDK版本高”不是代码问题是环境不一致。所以先问清楚项目是用什么版本编译的再决定服务器装什么或者反过来服务器装了17本地也统一用17。3.2 安装JDK的三种方式对比包管理器安装apt install openjdk-17-jdk-headless最省事适合大多数时候。但官方源里的JDK版本可能滞后。二进制包安装自己去网上下载tar.gz包放到/opt下解压即用版本完全可控。对需要精确定制JDK版本的场景推荐。容器镜像eclipse-temurin:17-jre这类镜像适合Docker部署后面第5章会讲。二进制包安装的完整操作tar -xzf jdk-17_linux-x64_bin.tar.gz -C /opt/ mv /opt/jdk-17.0.10 /opt/jdk17 # 写环境变量 cat /etc/profile.d/java.sh EOF export JAVA_HOME/opt/jdk17 export PATH$JAVA_HOME/bin:$PATH EOF # 让环境变量立即生效并验证 source /etc/profile.d/java.sh java -version写环境变量到/etc/profile.d/而不是直接改/etc/profile这个习惯很重要。前者按文件拆开管理升级JDK时只需要改一个文件也方便一眼看清这台机器装了什么。3.3 JVM启动参数不能随便给下面这套参数是我实测下来比较稳的基准配置适合大多数Spring Boot服务java -Xms512m -Xmx512m \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize256m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heapdump.hprof \ -Dfile.encodingUTF-8 \ -Dspring.profiles.activeprod \ -jar app.jar# 更推荐方式写成易读的列表格式 java \ -Xms512m \ -Xmx512m \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize256m \ -Dfile.encodingUTF-8 \ -Dspring.profiles.activeprod \ -jar /opt/app/demo.jar几个值得注意的点-Xms和-Xmx设置成相同值避免JVM运行中动态扩容缩容带来的性能抖动。-Xmx不是越大越好。服务器物理内存假设4G你给JVM 3G一旦发生内存尖峰系统可能触发OOM Killer直接把进程干掉。留30%-40%内存给操作系统和文件缓存才是合理的。-Dfile.encodingUTF-8能解决一大部分中文乱码问题尤其是日志里中文变???的情况。HeapDumpOnOutOfMemoryError这个参数平时不起眼但线上OOM时它生成的堆转储文件是你分析内存泄漏的唯一线索。4. 打包、上传与服务目录规划把代码放到正确的位置4.1 Maven打包为什么本地能跑服务器却不行先把本地打包流程跑标准mvn clean package -DskipTests打包完去target/目录找产物。Spring Boot项目会有两个jar一个以-plain.jar结尾一个以项目名结尾。要启动的是那个带完整Spring Boot插件重打包过的jar不是-plain.jar。这两个jar的区别在于是否包含内嵌Tomcat和所有依赖。如果你启动时频繁报no main manifest attribute基本就是选错了jar或者项目没有配置spring-boot-maven-plugin。!-- pom.xml 中必须包含 -- build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build4.2 上传方式scp、rsync还是Git拉取scp最常用。包不大、频率不高时用它就够。rsync大包或者频繁更新时推荐只传增量部分。Git拉取服务器上构建适合源码部署的场景但是生产环境不建议装JDK之外的编译工具链Java项目还是本地构建完传jar包更干净。# 本地构建后上传到服务器 scp target/demo.jar deploy你的服务器IP:/opt/app/demo.jar # 或者用rsync支持断点续传和增量 rsync -avz --progress target/demo.jar deploy你的服务器IP:/opt/app/demo.jar4.3 目录规划别把东西都堆在/root或者/home我自己的目录规范如下路径用途说明/opt/app/{项目名}/lib存放jar包按项目隔离/opt/app/{项目名}/config外部配置文件环境差异大的配置放这/data/logs/{项目名}/运行日志单独挂盘最好/data/backup/{项目名}/{日期}/升级前的备份至少保留3份为什么要分这么细两个原因第一配置和代码分离。jar包里的application.yml是给默认环境跑的生产环境的数据库密码、第三方密钥应该通过外部的application-prod.yml或环境变量注入。这样升级时只替换jar不需要重新修改配置。第二日志单独放盘。日志文件增长速度远超你的想象如果/根分区被日志打满整个系统都会出问题。单独分一个/data挂载点出问题时清理日志不影响系统分区。5. 启动方式对比nohup、systemd与Docker我建议怎么选5.1 nohup启动最快但不是终点最后层级的启动命令nohup java -jar /opt/app/demo.jar /data/logs/demo/console.log 21 拆开解释一下nohup忽略挂断信号终端关了进程不会跟着死。 file 21把标准输出和错误输出都重定向到日志文件。放到后台执行。这套命令能让你快速验证服务能否跑起来但它最大的问题是进程挂了没人管重启靠人工记忆。我自己的经验是它只适合本地调试不适合生产。5.2 systemd单机部署的首选方案在生产环境我个人最推荐systemd。它足够原生每一个Linux运维都看得懂而且自带守护、开机自启、日志统一管理。新建文件/etc/systemd/system/demo.service[Unit] DescriptionJava Demo Service Afternetwork.target [Service] Typesimple Userdeploy Groupdeploy WorkingDirectory/opt/app/demo EnvironmentFile/etc/demo.env ExecStart/opt/jdk17/bin/java -Xms512m -Xmx512m -Dspring.profiles.activeprod -jar /opt/app/demo/demo.jar Restartalways RestartSec5 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.target几个关键配置项的作用Restartalways进程崩溃、被kill、系统重启后都会自动拉起这是服务自愈的基础。RestartSec5重启前等5秒防止故障时疯狂重启刷日志。EnvironmentFile把数据库密码、第三方密钥放到/etc/demo.env这个独立文件不写死在unit文件里权限设成600。Userdeploy强制用普通用户运行不用root。操作命令systemctl daemon-reload systemctl enable --now demo systemctl status demo # 查看日志-u 指定服务-f 跟随 journalctl -u demo -f5.3 Docker部署环境隔离适合服务多的场景服务多起来之后systemd管理一堆进程也会乱。这时候Docker的价值就出来了环境一致性、依赖隔离、重启策略内置。Dockerfile示例FROM eclipse-temurin:17-jre WORKDIR /app RUN useradd -r -s /sbin/nologin appuser COPY target/demo.jar app.jar COPY app-prod.yml /app/config/app-prod.yml USER appuser ENV SPRING_PROFILES_ACTIVEprod ENTRYPOINT [java, -Xms512m, -Xmx512m, -jar, app.jar]docker-compose.yml示例version: 3.8 services: demo: build: . container_name: demo ports: - 8080:8080 environment: SPRING_PROFILES_ACTIVE: prod env_file: - demo.env volumes: - /data/logs/demo:/app/logs restart: unless-stoppedrestart: unless-stopped对应systemd的Restartalways是容器自愈的关键。三种方式的对比维度nohupsystemdDocker自动重启不支持内置内置开机自启不支持支持需要额外设置日志管理重定向到文件journald统一管理stdout收集环境隔离无无完全隔离学习成本最低中中高我的选择逻辑可以复制单机单服务用systemd服务数量多或者对运行环境要求差异大用DockerKubernetes是后面规模化的事不建议小项目一步到位。6. 部署后的第一轮体检进程、端口、日志与健康检查服务启动完别急着下班先做完下面这一轮体检。6.1 确认进程和端口状态# 查看Java进程 jps -l ps -ef | grep java | grep -v grep # 查看端口监听 ss -lntp | grep 8080 lsof -i:8080重点看两点进程是否还在很多服务启动失败后进程直接消失端口监听地址是0.0.0.0还是127.0.0.1后者意味着只能本机访问6.2 健康接口与错误日志Spring Boot项目推荐引入spring-boot-starter-actuator并暴露健康检查端点curl -s http://127.0.0.1:8080/actuator/health # 期望输出 {status:UP}用127.0.0.1而不是公网IP做探测是为了验证“本机服务正常”然后你再去安全组和防火墙排查外部网络的问题。这一步能把“服务本身故障”和“网络不通”分离开。日志检查# 实时查看日志 tail -f /data/logs/demo/app.log # 启动日志里的异常 grep -E ERROR|Exception|Caused by /data/logs/demo/app.log | head -506.3 一张可以直接照抄的体检清单我每部署一个服务都会把这个脚本跑一遍echo 1. 进程检查 ps -ef | grep demo.jar | grep -v grep || echo FAIL: 进程不存在 echo 2. 端口检查 ss -lntp | grep 8080 || echo FAIL: 8080未监听 echo 3. 健康检查 curl -s --max-time 5 http://127.0.0.1:8080/actuator/health || echo FAIL: 健康接口无响应 echo 4. 系统资源 free -h df -h /这四步跑完服务“活没活着、能不能接客、环境还剩多少余地”基本就清楚了。剩下的事情就是把这些命令存成healthcheck.sh以后每次部署完只跑一次这个脚本。7. 部署一定绕不开的坑数据库连接、内存不足与端口冲突7.1 MySQL连接不上的真相密码插件与网络白名单现象项目启动报Communications link failure或者Access denied for user。排查链路# 第一步确认网络通不通端口通不通 telnet 数据库内网IP 3306 # 如果telnet不通问题在防火墙/安全组/网络不关代码的事 # 第二步确认账号能不能连 mysql -h 数据库内网IP -u app_user -p根因最常见的是两个一是MySQL 8默认的caching_sha2_password认证插件和旧版本JDBC驱动不兼容二是数据库账号只允许localhost访问没有授权给应用服务器的内网IP。解决-- 在MySQL上执行为应用账号授权允许从应用服务器网段访问 CREATE USER app_user10.0.0.% IDENTIFIED BY 强密码; GRANT SELECT, INSERT, UPDATE, DELETE ON demo_db.* TO app_user10.0.0.%; FLUSH PRIVILEGES;如果是密码插件问题检查JDBC驱动版本MySQL 8.0.29之后的版本对caching_sha2_password支持已经很好优先升级驱动而不是回退认证方式。7.2 Java进程三秒就挂内存不足和OOM Killing现象systemctl status demo显示进程启动后立刻消失或者过一段时间消失。排查链路# 第一步看系统日志有没有OOM记录 dmesg | grep -i out of memory | tail -20 dmesg | egrep -i killed process | tail -20 # 第二步看服务器剩余的物理内存 free -h根因服务器物理内存不足Linux内核的OOM Killer会根据评分杀掉最“占内存”的进程Java服务往往是头号目标。我处理过一台4G内存的机器部署了3个Java服务每个都设置-Xmx2g结果服务轮流被杀就是典型的超卖。解决把-Xmx压到合理范围或者加物理内存。给Linux内核留出的余量至少30%。另外可以用/etc/systemd/system.conf里的DefaultLimitNOFILE等参数配合调优但不是关键路径。7.3 端口被占用启动日志让一切现形现象Web server failed to start. Port 8080 was already in use.排查链路lsof -i:8080 # 或 ss -lntp | grep 8080看到PID后用ps -ef | grep PID确认是哪个进程然后决定是杀掉它还是换端口kill -15 PID注意不要一上来就kill -9先给进程优雅退出的机会。实在不行再升级到-9。7.4 日志中文乱码编码问题不是玄学现象日志里的中文变成???或者乱码。排查链路# 查看系统语言环境 locale # 查看JVM文件编码 java -XshowSettings:property -version 21 | grep file.encoding解决JVM启动参数加-Dfile.encodingUTF-8同时确保日志配置文件Logback/Log4j2里指定了UTF-8字符集。数据库连接串也要加上characterEncodingutf8。7.5 一个通用的故障排查顺序踩了这么多坑我总结出最有效的排查套路是分层定位不要跳跃先看进程进程还在不在不在就去看dmesg或者systemd状态。再看端口在的话端口有没有监听监听在哪个网卡然后本机访问curl 127.0.0.1:端口通不通不通去看日志。最后外部访问本机通但外部不通问题在防火墙/安全组。这个顺序能帮你避免90%的无效排查。很多新手一上来就怀疑代码结果反过来查了半天最后发现是安全组端口没放行。8. 升级与重启从kill -9到优雅停机的正确姿势8.1 升级前先备份这是底线动作不管是修bug还是发新功能升级之前先备份# 备份旧包 mkdir -p /data/backup/demo/$(date %Y%m%d) cp /opt/app/demo/demo.jar /data/backup/demo/$(date %Y%m%d)/ # 备份数据库MySQL示例 mysqldump -u root -p demo_db /data/backup/demo/$(date %Y%m%d)/demo_db.sql备份的意义不是做做样子而是让你在升级出问题时可以毫无心理负担地回滚不用临时找同事要旧包。8.2 优雅停机kill -9是最后手段不少人重启服务直接用kill -9 PID这是最粗暴的方式。Java服务正在处理请求kill -9会直接中断进行中的事务可能导致数据不一致。正确的姿势是# systemd方式 systemctl stop demo # 或 systemctl restart demo # 手动方式先发TERM信号 kill -15 PIDSpring Boot 2.3支持优雅停机server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s这样配置后服务收到终止信号不会再接收新请求已有请求处理完最多等30秒再退出。MySQL连接池、Redis连接都会正常释放。8.3 不停服升级的简化版方案单机场景怎么做到尽量不停服用Nginx做代理配合多实例和软链接切换。一个简化的发布脚本思路#!/usr/bin/env bash # deploy.sh - 简化版蓝绿切换 APP_NAMEdemo JAR_DIR/opt/app/${APP_NAME} BACKUP_DIR/data/backup/${APP_NAME}/$(date %Y%m%d%H%M%S) # 1. 备份当前版本 mkdir -p ${BACKUP_DIR} cp ${JAR_DIR}/demo.jar ${BACKUP_DIR}/ # 2. 将新jar放到新目录 mkdir -p ${JAR_DIR}/release cp /tmp/demo.jar ${JAR_DIR}/release/demo.jar # 3. 切换软链接 ln -sfn ${JAR_DIR}/release ${JAR_DIR}/active # 4. 重启服务 systemctl restart ${APP_NAME} # 5. 等待并检查健康状态 sleep 15 if curl -s --max-time 5 http://127.0.0.1:8080/actuator/health | grep -q status:UP; then echo 部署成功 else echo 部署失败回滚到上一版本 systemctl stop ${APP_NAME} ln -sfn ${BACKUP_DIR} ${JAR_DIR}/active systemctl start ${APP_NAME} exit 1 fi多实例场景下用Nginx upstream管理节点权重先摘掉一台的流量weight0部署完再切回来一台一台滚动升级几乎不影响用户体验。这个思路比蓝绿发布简单但同样有效。9. 安全加固三板斧非root用户、防火墙与密钥登录9.1 应用服务不允许用root跑用root跑java -jar可能在开发时给你省事但生产环境风险极大应用如果被打穿攻击者直接拿到root权限你的服务器就完全沦陷了。正确做法是前面建好的deploy用户。同时注意文件权限# 应用目录deploy用户读写 chown -R deploy:deploy /opt/app/demo # 配置目录只读 chmod -R 750 /opt/app/demo/config # 环境变量文件只有deploy和root可读 chmod 600 /etc/demo.env9.2 防火墙最小开放原则只开放必要端口其余一律关闭。我在第2章已经给了ufw的配置示例这里补充一个常见误区很多人配置防火墙时只写了“开放80端口”却忘了给业务端口放行结果Nginx能访问后面的Java服务一直报502。放行端口时按实际需要来宁少勿多。数据库安全是重点MySQL的3306端口绑定地址改成内网IP不要监听0.0.0.0。云安全组层面来源IP只填应用服务器的内网IP。应用账号永远不要用root连接数据库只给必要的增删改查权限。9.3 SSH关闭密码登录并记录一份连接清单密钥登录配置我前面已经写过了。这里再补充一句改SSH配置之前先开一个新终端测试密钥能否登录。很多人改完直接断开当前会话发现密钥没拷好人就彻底进不去了。最后一个小建议把服务器上运行了哪些服务、端口是什么、对应哪个应用、用什么账号跑的整理成一份简单的文档放内网。平时感觉没用出故障的时候这份文档能帮你节省至少半小时的定位时间。我把这份清单叫做“服务器台账”每台机器维护一份越简单越好但一定要有。