
1. 部署前的整体规划与版本选型先把话说在前头这篇东西不是从零教你写 Java Web而是讲清楚一件事一个已经在本地跑起来的 Java Web 项目怎么安全、稳当地搬到 Linux 服务器上让别人也能通过域名或者 IP 访问到。我按自己实际部署的流程来写尽量把每一步为什么要这么做讲明白而不是丢给你一串命令就完事。先说版本选型。很多新手一上来就问“用什么版本好”其实版本选择的关键是看项目本身用什么编译的。我自己习惯的原则是能用 JDK 1.8 就用 1.8能不用最新的就不用最新的。不是守旧而是 Java 生态里大量第三方依赖、老项目的代码都是在 JDK 8 时代写死的。你拿 JDK 17 去跑一个基于旧版 Spring 的项目分分钟遇到反射报错、模块化限制排查起来痛不欲生。如果你是新项目、新框架那用 JDK 11 或 17 完全没问题但务必保证本地开发环境和服务器环境大版本一致。类似地Tomcat 版本也要跟着 JDK 走。Tomcat 9 对应 JDK 8Tomcat 10 对应 JDK 8 但 Jakarta EE 命名空间换了如果你的项目还在用javax.servlet直接上 Tomcat 10 会找不到类。这里踩过坑的人应该不少。数据库方面MySQL 5.7 和 8.0 是最常见的如果项目用了比较老的驱动或 SQL 语法优先选 5.7 兼容性更好。甲骨文官方虽然已经停止 5.7 的维护更新但存量项目还是很多相关经验也容易搜到。部署架构上我推荐一个非常传统但极其稳定的组合Nginx Tomcat MySQL。Nginx 负责接收外部请求、做反向代理和静态资源处理Tomcat 只跑 Java 业务MySQL 管数据。这样做的好处是把 Web 服务的“入口”和“应用”解耦以后要加负载均衡、要配 HTTPS都是在 Nginx 层操作完全不用动 Java 应用的代码。2. 服务器初始化与基础环境准备拿到一台全新的 Linux 服务器我的习惯是先做几件小事再开始装环境。这些准备工作看起来不起眼但能帮你省掉后面一大堆麻烦。2.1 创建专用用户与目录规划不要用 root 直接跑应用这是第一条铁律。root 权限太大万一应用被入侵或者你自己误操作代价是灾难性的。我用某个项目的代号“某商城系统”举例部署时先建一个用户useradd -m -s /bin/bash shopapp passwd shopapp然后把应用目录规划好。我的目录习惯是/data/apps放 Tomcat、Nginx 这类中间件/data/www放项目文件、静态资源、上传文件/data/logs统一放所有日志/data/backup放数据库备份和配置备份别把东西乱丢在/root或者/home/shopapp下面后期维护、写备份脚本的时候目录越规整越省心。我见过不少同事把 Tomcat 解压到 home 目录里结果换人维护时找半天找不到配置在哪。2.2 基础工具安装与防火墙策略装环境前先更新一下软件源再把常用的工具装上# Debian/Ubuntu 系 apt update apt install -y wget curl tar vim lrzsz net-tools # CentOS/Rocky 系 yum install -y wget curl tar vim lrzsz net-tools这里提一下lrzsz它提供rz和sz命令配合 Xshell 或 SecureCRT 可以直接上传下载小文件。如果你用 Windows 连服务器这个工具非常实用不用每次开 SFTP 窗口。防火墙策略要提前想好。我测试时图省事直接把防火墙关了但生产环境千万别这样干。正确的姿势是放行指定端口# 以 firewalld 为例CentOS/Rocky systemctl start firewalld firewall-cmd --permanent --add-port22/tcp # SSH 端口 firewall-cmd --permanent --add-port80/tcp # Nginx 端口 firewall-cmd --permanent --add-port3306/tcp # MySQL 端口如需远程连接才加 firewall-cmd --reloadUbuntu 的 ufw 类似注意千万别把 SSH 端口默认 22忘了放行不然改完防火墙规则你就和服务器失联了。这种事我干过一次最后只能找云厂商网页控制台去救非常狼狈。3. JDK 与 Tomcat 的安装配置3.1 JDK 安装与环境变量配置JDK 的安装方式主要有两种用包管理器安装或者下载压缩包手动部署。我强烈推荐压缩包手动部署的方式因为这样可以精确控制版本号不受系统源和默认版本的影响。先用java -version检查一下系统是否自带过 OpenJDK。如果有但版本不对先卸载干净再装不然PATH环境变量会打架。# 以 JDK 8 为例下载 tar.gz 包放到 /data/apps mkdir -p /data/apps cd /data/apps wget https://xxx/jdk-8u381-linux-x64.tar.gz tar -zxvf jdk-8u381-linux-x64.tar.gz mv jdk1.8.0_381 jdk8然后配置环境变量。编辑/etc/profile在末尾加上export JAVA_HOME/data/apps/jdk8 export PATH$JAVA_HOME/bin:$PATH export CLASSPATH.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar运行source /etc/profile让配置生效然后验证java -version输出里有正确的版本号就算成功。这里有个细节CLASSPATH 前面的.号别漏掉它代表当前目录。虽然现代开发基本靠 Maven 管理依赖CLASSPATH 这个变量在实际中用到的地方不多但旧版本的某些工具仍然会读取它添上无害。3.2 Tomcat 安装与目录结构认知Tomcat 的安装和解压 JDK 类似去官网下载对应版本解压到/data/apps然后改个名字方便管理cd /data/apps tar -zxvf apache-tomcat-9.0.85.tar.gz mv apache-tomcat-9.0.85 tomcat9解压完先不急着启动先认清 Tomcat 的几个关键目录目录/文件作用bin/存放启动和关闭脚本conf/server.xml核心配置文件端口、连接池参数都在这conf/tomcat-users.xml管理后台用户配置webapps/项目部署目录WAR 包丢这里自动解压logs/日常运行日志、访问日志work/JSP 编译后的临时文件新手最容易犯的错误是直接在默认配置下启动然后发现端口被占用、日志看不懂。我建议启动前先做两件事一是改server.xml里的端口二是设置 JVM 内存参数。server.xml里重点看三处端口8080HTTP 请求端口、8005管理端口、8009AJP 端口。很多人只改 8080以为就安全了其实如果不做集群8009 端口直接注释掉最干净。很多安全扫描工具会拿这些默认端口试探减少暴露面总没错。JVM 参数在bin/catalina.sh头部JAVA_OPTS位置设置或者更优雅的方式是在bin目录下新建一个setenv.shJAVA_OPTS-Xms512m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -Dfile.encodingUTF-8-Xms是初始堆内存-Xmx是最大堆内存。服务器物理内存如果是 2G堆最大给 1024m 比较合适如果是 4G给 2048m 也行。别贪心把内存全给 JVM操作系统本身和其他进程也要留余量。-Dfile.encodingUTF-8这个参数非常重要不设的话项目处理中文文件名或GET请求乱码时你会排查到怀疑人生。4. MySQL 安装与业务库初始化4.1 MySQL 安装步骤数据库我一般用二进制包安装这样所有文件都在一个目录下卸载和升级都很清爽。MySQL 官方提供了通用的二进制包但很多云厂商的镜像源里也有现成的版本装起来更省事。以 Ubuntu 为例apt install mysql-server是最快的装法。但这里有个坑系统源里的 MySQL 版本可能比较新如果项目用的连接驱动很老连不上数据库会报Public Key Retrieval is not allowed之类的错。所以我还是坚持下载官方指定版本。# 创建 MySQL 用户 groupadd mysql useradd -r -g mysql -s /bin/false mysql # 解压到 /data/apps cd /data/apps tar -zxvf mysql-5.7.43-linux-glibc2.12-x86_64.tar.gz mv mysql-5.7.43-linux-glibc2.12-x86_64 mysql初始化数据目录注意是最小化初始化cd /data/apps/mysql mkdir -p /data/mysql/data chown -R mysql:mysql /data/mysql bin/mysqld --initialize-insecure --usermysql --basedir/data/apps/mysql --datadir/data/mysql/data--initialize-insecure表示初始 root 密码为空方便我们后面自己设置。用--initialize会生成一个随机密码在日志里还得去翻日志找麻烦。初始化完把 MySQL 注册成系统服务cp support-files/mysql.server /etc/init.d/mysql chmod x /etc/init.d/mysql然后手动启动并设置密码service mysql start /data/apps/mysql/bin/mysql -uroot进入 MySQL 后执行ALTER USER rootlocalhost IDENTIFIED BY YourStrongPassword; CREATE USER shopuserlocalhost IDENTIFIED BY ShopDbPass123; GRANT ALL PRIVILEGES ON shopdb.* TO shopuserlocalhost; FLUSH PRIVILEGES;这里要解释一下为什么要单独创建应用账号而不是直接用 root 跑业务因为业务账号只需要shopdb这一个库的权限万一被 SQL 注入或者代码里面有逻辑漏洞影响的也只是这一个库而不是整个数据库实例。最小权限原则不只用在 Linux 上数据库同样适用。4.2 初始化业务数据拿到项目方的 SQL 脚本后导入的命令非常简单/data/apps/mysql/bin/mysql -ushopuser -p shopdb /data/backup/init.sql但导入前一定要检查几点这些检查比导入本身更重要SQL 文件编码必须是UTF-8。用file命令查看编码如果是UTF-16或者 GBK提前转码。存储引擎确认建表语句里没有混用 MyISAM 和 InnoDB避免事务支持不一致。字符集查看表是否统一为utf8mb4而不是utf8。utf8在 MySQL 里并不是真正完整的四字节 UTF-8存储不了表情符号而且某些中文生僻字也存不进去。utf8mb4才是完整实现。导入后做一个简单的数据量验证USE shopdb; SHOW TABLES; SELECT COUNT(*) FROM t_product;如果表数量和行数跟项目方给的数据一致说明导入成功。不一致的话最可能是 SQL 脚本中间有报错但你没看终端输出重新用mysql -vvv导入一遍它会输出每一条语句的执行结果。5. 项目构建、上传与上线部署5.1 Maven 构建打包这一步在本地做还是服务器做理论上都可以但我建议在本地做构建只上传构建产物。原因很简单服务器上没必要装 Maven 和 JDK 开发环境减少攻击面而且本地构建出问题本地环境调试方便得多。在本地执行mvn clean package -Dmaven.test.skiptrue -Pprod这里重点说下-Pprod。很多项目的application.yml或.properties里有多个 profile比如 dev、test、prod不同 profile 对应不同的数据库地址、Redis 地址。如果你漏掉-Pprod默认用了 dev 配置打出来的包连的是本地数据库部署到服务器后一百个不可能跑起来而且报错会很诡异。构建产物有两种形式WAR 包或 JAR 包。如果项目是传统前后端不分离的 Spring MVC或者用了 JSP那构建出来的是xxx.war部署到 Tomcat 的webapps。如果项目用了 Spring Boot构建出来的通常是可执行xxx.jar那就根本不需要 Tomcat直接java -jar运行即可或者在目录里放一个Dockerfile容器化。这个区分非常重要决定了后面接 5.2 还是 5.3。我见过有人拿着一个 Spring Boot 的 JAR 包硬往 Tomcat 里塞Tomcat 报错Invalid or unreadable WAR file完全没意识到项目类型不对。5.2 WAR 包部署到 Tomcatcd /data/apps/tomcat9/webapps # 如果已经有解压出来的同名项目先备份再清除 mv shop ## 如果不存在直接上传 rz shop.war上传完 WAR 包启动 Tomcat/data/apps/tomcat9/bin/startup.sh启动后用tail -f /data/apps/tomcat9/logs/catalina.out观察日志。看到Deployment of web application archive [shop.war] has finished就说明部署成功。然后浏览器访问http://服务器IP:8080/shop。这里有个常见问题有时候webapps下面已经存在一个同名目录WAR 包更新后Tomcat 认为目录已经存在就不重新解压导致你明明传了新包跑的还是旧代码。解决方法是先停 Tomcat把webapps/shop整个删掉再把 WAR 传上去重启。提示规范的做法是项目打 WAR 包时配置finalName这样压缩包名和目录名保持一致如shop.war对应目录shop。5.3 JAR 包部署与 systemd 服务如果你的项目是 Spring Boot 的单体 JAR那我建议用systemd来守护进程而不是傻乎乎的nohup java -jar。用systemd的好处是开机自启、崩溃自动重启、日志统一由journalctl管理。新建/etc/systemd/system/shop.service[Unit] DescriptionShop Application Afternetwork.target mysql.service [Service] Usershopapp Groupshopapp WorkingDirectory/data/www/shop EnvironmentJAVA_HOME/data/apps/jdk8 ExecStart/data/apps/jdk8/bin/java -Xms512m -Xmx1024m -jar /data/www/shop/shop.jar SuccessExitStatus143 Restartalways RestartSec10 [Install] WantedBymulti-user.target注意SuccessExitStatus143Java 应用被systemctl stop正常停止时JVM 退出信号是 143不标注这个值的话 systemd 会认为这是异常退出。光这一行当年我排查了整整一个下午。之后的操作systemctl daemon-reload systemctl enable shop systemctl start shop systemctl status shop日志查看journalctl -u shop -f相比nohup ... logs/app.log 21journalctl 的过滤、时间线、PID 对应要方便得多而且不会因为日志文件过大把磁盘撑爆。如果你还是要用 nohup 方式记得加-Djava.security.egdfile:/dev/urandom不然启动时SecureRandom初始化会非常慢有时候半个多小时还没起来。6. Nginx 反向代理配置6.1 安装与基本配置Tomcat 直接暴露 8080 端口不是不行但不推荐。一是 Tomcat 对静态文件和高并发连接的处理能力不如 Nginx二是你要配 HTTPS 证书、要做请求大小限制、要按路径转发多个服务Nginx 是更合适的一层。apt install -y nginx # 或 yum install -y nginx安装完默认站点配置文件在/etc/nginx/conf.d/default.conf或者/etc/nginx/sites-available/default不同发行版路径不一样。我直接新建一个/etc/nginx/conf.d/shop.confserver { listen 80; server_name shop.example.com; client_max_body_size 50m; 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; } location /static/ { alias /data/www/shop/static/; expires 7d; } }proxy_set_header Host $host尤为重要。如果不加这句后端 Tomcat 接受到的Host头是127.0.0.1:8080如果项目里有基于request.getServerName()生成绝对路径的地方会生成一个错误域名。反正这三行X-Real-IP、X-Forwarded-For、X-Forwarded-Proto我都建议加上因为项目拿不到真实客户端 IP 时排查起来极其痛苦。配好后测试并重载nginx -t systemctl reload nginx6.2 动静分离与代理转发细节如果项目里有大量静态资源比如图片、CSS、JS我不想让 Tomcat 处理。Tomcat 处理静态资源的能力比 Nginx 差一个量级让 Nginx 直接读磁盘文件返回内部网络延迟低到可以忽略不计。配置里我写了location /static/的 alias 这种形式。需要注意alias和root的区别root会把匹配到的路径拼在 root 目录后面比如root /data/www;请求/static/a.jpgNginx 会去/data/www/static/a.jpg找文件而alias /data/www/shop/static/;请求/static/a.jpg直接去找/data/www/shop/static/a.jpg完全忽略/static前缀。匹配规则上/static/前面的location如果改成~* \.(gif|jpg|js|css|png)$这类正则匹配会更容易覆盖项目里来自不同路径的静态资源。但这种正则写在前面会劫持掉动态接口如果接口返回 JSON 的 URL 里恰好带着.do或.json正则就要小心写。稳妥起见新手我建议直接用前缀匹配location /static/让开发把静态资源统一放到这个前缀下。还有一个容易忽略的点大文件上传。默认 Nginx 对请求体的大小限制是 1MB如果你项目里有图片上传功能不改这个配置前端传图片到 1MB 之后就会收到 413 错误。所以我在配置里加了client_max_body_size 50m;具体多大看业务需求但不要设置成 0即无限大容易被恶意请求发包打崩。7. 常见问题排查与故障处理实录这部分挑几个我实际排查过的高频问题来讲每个都对应一段真实教训。7.1 数据库连接失败Access denied 与 Host 限制启动项目后日志里报Access denied for user shopuserlocalhost最常见的原因是密码不对或者 MySQL 里根本没有这个用户。另一个隐蔽原因是 MySQL 的用户表里host字段限制得死。MySQL 建用户时可以指定shopuserlocalhost和shopuser%是两种完全不同的授权。Linux 本机连 MySQL 会匹配localhost但如果你在服务器上用 Docker 起 MySQL容器 IP 不是 localhost程序连接会匹配不到用户。所以项目部署前先确认数据库连接配置里的 IP 和建用户时的 host 匹配SELECT user, host FROM mysql.user WHERE user shopuser;如果业务需要允许任意 IP 连接就创建shopuser%但实际生产环境为了安全我建议只给应用服务器 IP 开权限其他一律拒绝。7.2 端口无法访问防火墙与安全组部署完成Tomcat 明明启动了浏览器就是打不开。排查思路按顺序来先在服务器本机执行curl http://127.0.0.1:8080/shop如果本机能通说明应用没问题。再执行netstat -tlnp | grep 8080确认端口在监听。然后ss -tlnp看监听地址如果显示127.0.0.1:8080而不是0.0.0.0:8080说明 Tomcat 只监听了环回地址外部网络自然进不来。最后检查云服务器控制台的安全组规则很多云厂商的安全组默认只放行少数端口。我自己犯过的低级错误是云厂商的安全组和服务器内防火墙两边都开了端口但安全组里源IP写成了单个 IP 而不是0.0.0.0/0导致只有那一个 IP 能访问。这类问题用排除法最快一层层缩小范围。7.3 Tomcat 启动很快但访问 404上下文路径问题启动日志都正常浏览器访问http://IP:8080/看到的是 Tomcat 默认首页但访问http://IP:8080/shop/404。可能原因是 WAR 包解压目录名和请求路径不匹配。举个例子你的 WAR 包叫shop-v2.0.warTomcat 解压出的目录名是shop-v2.0那访问路径就是http://IP:8080/shop-v2.0/不是/shop/。要么改 WAR 包名要么在server.xml的Host标签下配Context path/shop docBaseshop-v2.0 reloadabletrue /但这种配置方式不推荐因为它是硬编码在 server.xml 里的以后迁移环境很容易漏掉。最省心的方法是构建时配置finalName保证 WAR 包名字就是你想让别人访问的路径。7.4 日志排查法catalina.out、app.log、journalctl日志是运维排查问题最重要的手段但新手往往不知道先看哪个日志。我养成的查日志顺序Tomcat 的话先看catalina.out这是主日志应用启动异常、Spring 容器加载错误都会在这里。Spring Boot 只要日志配置没瞎搞直接看journalctl -u shop -f或者项目里指定的logback输出文件。数据库问题先去 MySQL 的error.log看路径一般在/data/mysql/data/xxx.err。最容易被忽略的是dmesg或message日志如果 Java 进程被杀掉很可能是内存不足被 OOM Killer 杀了dmesg | tail会看到Out of memory: Kill process。有一次项目半夜挂掉catalina.out 里没有任何异常日志百思不得其解。后来查dmesg才发现是服务器内存被别的进程吃满内核直接把 Java 进程 Kill 了。这类问题光看应用日志是完全排查不出来的。7.5 文件上传失败权限问题项目跑通了但用户上传的头像保存失败Tomcat 日志里报Permission denied。这个基本可以断定是上传目录的属主不对。我遇到过的情况是Nginx 跑在www-data用户下Tomcat 跑在shopapp用户下上传目录是 root 创建的两边都没权限写。解决方法是明确上传目录属于谁chown -R shopapp:shopapp /data/www/shop/upload/ chmod -R 755 /data/www/shop/upload/这里还有一个细节Java 应用在 Linux 上写文件时如果目录有粘滞位或者ACL特殊权限也会产生奇怪的行为。比如很多共享目录会设置chmod 1777新手误以为这样可以但实际上 Java 的临时文件机制在这种目录下反而容易出问题。第一个教训就是写文件目录权限要给对不要贪省事全是777攻击者能往里放东西不说日志一堆安全告警也烦。8. 上线前的最终检查清单项目部署完、浏览器打开首页一切正常不代表可以高枕无忧了。我整理一个上线前检查清单每条都是踩坑踩出来的照着过一遍能避免绝大多数“上线当天被叫回公司”的尴尬。时区检查服务器date命令输出是否是东八区。如果不是执行timedatectl set-timezone Asia/Shanghai。Java 应用默认读系统时区数据库时间如果存的是 UTC导出的报表会差 8 小时。编码检查locale输出是否有LANGen_US.UTF-8。乱码问题十有八九是系统和应用编码不一致。开机自启检查Tomcat 如果用startup.sh方式要额外写 systemd service 或者 rc.local不然服务器一重启网站就 forever offline 了。推荐直接把 Tomcat 的启动方式也改成 systemd 管理和 JAR 包类似。数据库备份策略至少写一个 crontab 定时备份比如每天凌晨 3 点全量备份0 3 * * * mysqldump -ushopuser -pxxxx shopdb | gzip /data/backup/shopdb_$(date \%Y\%m\%d).sql.gzdate命令里的%号在 crontab 里要转义成\%这个细节我当年漏了结果备份文件每次都是同一个名字直接把前一天的备份覆盖了等于没有备份。日志清理策略日志文件不会自己变小必须设置 logrotate。Tomcat 的catalina.out如果不做轮转三个月就能把磁盘写满。简单粗暴的做法是 crontab 里定期清理0 0 * * * find /data/logs /data/apps/tomcat9/logs -name *.log -type f -mtime 30 -delete或者用 logrotate 服务统一管理配置文件写在/etc/logrotate.d/tomcat里更正规。常见安全检查项目里如果有默认管理员密码上线前必须改。数据库端口如果不能公网访问就不要暴露公网尽量只在内网监听。Nginx 里的server_tokens off;可以隐藏版本号减少针对性攻击概率。这些看上去是散点但每一个都对应生产环境真实发生过的故障。我自己的习惯是每次部署完重新走一遍清单花十分钟能省未来十小时的折腾。最后分享一个我自己的部署习惯每次上线前我都会在本地整理一份“部署确认单”把本次改动的配置、上传的包名、数据库脚本的文件名、备份文件的路径全部列出来。确认单一目了然出问题排查时会快很多。这套流程算是笨办法但用了很多年一直没出过大乱子。你在实际操作时也可以照这个思路建立自己的部署模板以后部署任何项目都能套用。