1. 为什么把老SpringMVC+JSP项目塞进Tomcat容器是个技术活
手里有一批跑了好几年的SpringMVC+JSP项目,代码能跑、业务稳定,但部署方式还停留在“传war包到服务器、手动重启Tomcat”的原始阶段。每次上线都像拆盲盒,环境差异、JDK版本、Tomcat配置、日志路径,任何一个环节对不上就是一堆500错误。这两年容器化是大趋势,把这套老架构迁移到Docker里,用Tomcat作为基础运行环境,是最稳妥、改动最小的路径。
这个方案的核心价值在于:不改一行业务代码,只调整部署方式和运行环境。SpringMVC和JSP这套组合对Servlet容器有强依赖,JSP需要容器编译成Servlet再执行,所以不能像SpringBoot那样打成fat jar直接跑。Tomcat作为Servlet容器,天然就是JSP的最佳搭档。把它做成Docker镜像,等于把“操作系统+JDK+Tomcat+应用配置”这一整套运行时环境固化下来,开发、测试、生产用同一个镜像,环境差异问题从根上被消除。
适合谁来参考?如果你手上有维护中的传统Java Web项目,团队还没全面转向SpringBoot,或者你正在做老系统的容器化改造,这篇内容就是为你准备的。我会从镜像选型、Dockerfile编写、JSP编译配置、日志与中文编码、数据卷挂载、健康检查这几个维度,把踩过的坑和验证过的方案完整拆开讲。整个过程不需要你精通Docker底层原理,但需要对Tomcat的目录结构和SpringMVC的启动流程有基本认知。
2. 镜像选型与基础环境搭建的取舍逻辑
2.1 Tomcat基础镜像怎么选才不踩坑
Docker Hub上Tomcat官方镜像有多个标签体系,选错了后面全是麻烦。常见的标签组合是tomcat:版本号-jdk版本,比如tomcat:9.0-jdk8、tomcat:9.0-jdk11、tomcat:10.1-jdk17。这里有几个关键决策点。
第一,Tomcat大版本必须和原项目匹配。SpringMVC+JSP项目通常跑在Tomcat 8或9上,Tomcat 10最大的变化是把javax.servlet包名改成了jakarta.servlet,老项目直接扔进去会报ClassNotFoundException。所以除非你打算改包名,否则老老实实选Tomcat 9。
第二,JDK版本要跟编译版本对齐。项目用JDK 8编译的,运行环境就用JDK 8;用JDK 11编译的,就用JDK 11。跨大版本运行虽然多数情况能跑,但反射、字节码、GC行为都可能有微妙差异,生产环境不要赌这个。
第三,基础镜像的底层系统。官方Tomcat镜像现在主流基于Debian或Alpine。Alpine体积小(几十MB),但用的是musl libc,某些依赖glibc的本地库(比如某些加密库、图像处理库)可能跑不起来。Debian系体积大一些(200MB+),但兼容性最好。我的建议是:如果项目里有JNI调用或者用了带本地库的第三方包,直接选Debian系;纯Java项目可以考虑Alpine。
# 推荐的基础镜像写法 FROM tomcat:9.0-jdk8 # 或者 FROM tomcat:9.0.85-jdk8-temurin提示:尽量锁定到具体的小版本号,比如
9.0.85,而不是只用9.0。否则某天官方镜像更新了,你的构建结果就变了,排查问题时会很痛苦。
2.2 目录结构规划与工作目录设定
官方Tomcat镜像的默认布局是这样的:CATALINA_HOME是/usr/local/tomcat,webapps目录在/usr/local/tomcat/webapps,日志在/usr/local/tomcat/logs,配置在/usr/local/tomcat/conf。默认的webapps目录里有一堆自带的管理应用(ROOT、docs、examples、manager、host-manager),生产环境这些都应该删掉,既省空间又减少攻击面。
我的做法是在Dockerfile里先清理默认应用,再把自己的war包放进去。同时把工作目录显式设定为/usr/local/tomcat,后续所有相对路径操作都基于这个目录,避免因为WORKDIR没设导致文件放错位置。
FROM tomcat:9.0.85-jdk8-temurin # 清理默认应用 RUN rm -rf /usr/local/tomcat/webapps/* # 设置工作目录 WORKDIR /usr/local/tomcat # 复制war包,注意war包名决定了访问路径 COPY target/myapp.war /usr/local/tomcat/webapps/这里有个细节:war包的名字就是应用的context path。myapp.war部署后访问路径是/myapp,ROOT.war部署后访问路径是根路径/。如果你的项目原来在Tomcat里配了server.xml的<Context path="...">,迁移到Docker后要么改war包名,要么在镜像里覆盖server.xml,前者更简单。
2.3 时区与字符编码的前置处理
老项目最容易翻车的地方就是时区和编码。容器默认是UTC时区,JSP页面显示的时间会比北京时间少8小时;默认字符集在某些镜像里不是UTF-8,中文会变乱码。这两个问题必须在构建镜像时就解决,不要等到运行时报错了再回头改。
时区处理有两种方式:一种是在Dockerfile里装tzdata并设置TZ环境变量,另一种是运行时通过-e TZ=Asia/Shanghai传入。我倾向于在Dockerfile里固化,因为这是应用运行的必要条件,不应该依赖运行时参数。
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone字符编码方面,Tomcat 9的server.xml里Connector默认URIEncoding是UTF-8,但JSP页面的pageEncoding和响应头的Content-Type需要应用自己保证。保险起见,在catalina.sh的启动参数里加上-Dfile.encoding=UTF-8,同时确保JSP文件本身保存为UTF-8无BOM格式。
ENV CATALINA_OPTS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"注意:
CATALINA_OPTS和JAVA_OPTS的区别在于,前者只作用于Tomcat启动进程本身,后者还会传递给其他Java进程。对于单纯跑Tomcat的场景,用CATALINA_OPTS更精准。
3. Dockerfile完整编写与JSP编译关键配置
3.1 一份可直接复用的Dockerfile拆解
把前面几节的决策串起来,下面是一份经过验证的完整Dockerfile。我把它拆成几个逻辑块,每一块都说明为什么这么写。
# 基础镜像:Tomcat 9 + JDK 8,Debian系保证兼容性 FROM tomcat:9.0.85-jdk8-temurin # 维护者信息(可选,但建议保留) LABEL maintainer="dev-team" # 时区设置 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # JVM与编码参数 ENV CATALINA_OPTS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m" # 清理默认webapps RUN rm -rf /usr/local/tomcat/webapps/* # 复制war包 COPY target/myapp.war /usr/local/tomcat/webapps/ROOT.war # 复制自定义配置(如果有) # COPY config/server.xml /usr/local/tomcat/conf/server.xml # COPY config/context.xml /usr/local/tomcat/conf/context.xml # 暴露端口 EXPOSE 8080 # 启动命令(官方镜像已有默认CMD,这里显式写出便于理解) CMD ["catalina.sh", "run"]关于JVM参数,-Xms和-Xmx设成一样可以避免堆动态扩容带来的性能抖动,但会占用固定内存。MetaspaceSize和MaxMetaspaceSize对老项目很重要,因为SpringMVC+JSP会加载大量类,Metaspace不够会报OutOfMemoryError: Metaspace。512m到1g的堆对大多数中小型Web应用够用,具体数值要根据实际压测调整。
3.2 JSP编译相关的隐藏配置项
JSP在Tomcat里是运行时编译的,第一次访问某个JSP页面时会触发编译,这会导致首次响应变慢。生产环境通常希望预编译JSP,或者至少让编译结果持久化,避免每次重启容器都重新编译。
Tomcat的JSP编译工作目录默认在work/Catalina/localhost/应用名/下。容器重启后这个目录如果没挂载出来,所有JSP都要重新编译。解决办法有两个:一是把work目录挂载为数据卷,二是用Maven插件在构建阶段预编译JSP。
预编译JSP的Maven配置大致如下,在pom.xml里加入jspc-maven-plugin,构建时会把JSP编译成class文件打进war包。这样运行时就不需要再编译了,启动速度和首次访问速度都会明显提升。
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>jspc-maven-plugin</artifactId> <version>2.0.0</version> <executions> <execution> <id>jspc</id> <goals> <goal>compile</goal> </goals> </execution> </executions> </plugin>如果不想改构建流程,那就用数据卷把work目录持久化:
docker run -d \ -v /data/myapp/work:/usr/local/tomcat/work \ -v /data/myapp/logs:/usr/local/tomcat/logs \ -p 8080:8080 \ myapp:1.0提示:JSP预编译有个坑,如果JSP里用了
<jsp:include>动态包含,预编译时可能找不到被包含的文件。这种情况要么改成静态包含<%@ include %>,要么放弃预编译,改用work目录持久化。
3.3 应用配置外置与数据卷挂载策略
老项目的配置文件通常散落在WEB-INF/classes下,比如jdbc.properties、log4j.properties、applicationContext.xml。这些配置在不同环境(开发、测试、生产)值不一样,如果打进war包,每次改配置都要重新构建镜像,很不灵活。
我的做法是把配置目录外置,通过数据卷挂载到容器里,应用启动时从挂载点读取。SpringMVC项目可以通过PropertyPlaceholderConfigurer的locations属性指定外部配置文件路径,或者用-Dconfig.path=/data/config这样的系统属性传入。
# 在Dockerfile里创建配置目录 RUN mkdir -p /data/config# 运行时挂载配置 docker run -d \ -v /data/myapp/config:/data/config \ -e JAVA_OPTS="-Dconfig.path=/data/config" \ myapp:1.0日志目录同样要挂载出来。Tomcat的日志分几类:catalina.out是标准输出和错误输出,localhost.log是应用日志,localhost_access_log是访问日志。容器里如果不挂载,容器一删日志就没了。挂载到宿主机后,既方便排查问题,也方便用日志采集工具统一收集。
| 容器内路径 | 宿主机建议路径 | 用途 |
|---|---|---|
| /usr/local/tomcat/logs | /data/myapp/logs | Tomcat各类日志 |
| /usr/local/tomcat/work | /data/myapp/work | JSP编译产物 |
| /data/config | /data/myapp/config | 应用外部配置 |
| /usr/local/tomcat/webapps | 不建议挂载 | 应用包,应固化在镜像里 |
4. 构建、运行与常见故障排查实录
4.1 从零到跑起来的完整操作流程
假设你已经有一个能打出war包的SpringMVC+JSP项目,下面是完整的容器化操作步骤。
第一步,确认war包能正常生成。在项目根目录执行mvn clean package,检查target/目录下是否生成了myapp.war。如果项目是多模块的,注意war包可能在子模块的target目录下。
第二步,把Dockerfile放在项目根目录,和pom.xml同级。执行构建命令:
docker build -t myapp:1.0 .构建过程中会下载基础镜像、执行RUN指令、复制war包。如果网络慢,可以配置镜像加速器。构建完成后用docker images确认镜像存在。
第三步,启动容器。首次启动建议前台运行,方便看日志:
docker run --rm -it \ -p 8080:8080 \ -v /data/myapp/logs:/usr/local/tomcat/logs \ -v /data/myapp/work:/usr/local/tomcat/work \ myapp:1.0看到Server startup in xxx ms就说明Tomcat启动成功了。浏览器访问http://宿主机IP:8080/,如果war包名是ROOT.war就直接是根路径,否则加上应用名。
第四步,验证JSP页面。访问一个包含中文的JSP页面,检查中文是否正常显示、时间是否正确。如果中文乱码,回头检查file.encoding和JSP的pageEncoding;如果时间差8小时,检查user.timezone和容器时区。
第五步,确认无误后改用后台运行,加上-d参数和重启策略:
docker run -d \ --name myapp \ --restart unless-stopped \ -p 8080:8080 \ -v /data/myapp/logs:/usr/local/tomcat/logs \ -v /data/myapp/work:/usr/local/tomcat/work \ myapp:1.04.2 启动失败与页面报错的排查路径
容器化之后报错信息不像传统部署那样直观,因为日志分散在docker logs和挂载的日志文件里。下面是我整理的一套排查路径。
容器起不来,docker ps看不到。先用docker ps -a找到退出的容器,然后docker logs 容器ID看输出。常见原因是war包损坏、端口被占用、JVM参数写错导致启动脚本报错。
容器起来了但访问404。检查war包是否真的在webapps目录下,docker exec -it 容器ID ls /usr/local/tomcat/webapps/。如果war包在但访问404,可能是context path不对,试试加上war包名作为路径。
访问500错误。看localhost.log和catalina.out,通常是Spring上下文初始化失败,比如数据库连不上、配置文件找不到、类冲突。这类问题在容器里排查和在物理机上一样,关键是日志要能看到。
JSP编译报错。看localhost.log里的Jasper相关异常,常见的是JSP语法错误、taglib找不到、JDK版本不匹配。如果报Unable to compile class for JSP,多半是JDK版本和编译版本不一致。
中文乱码。分三种情况:请求参数乱码、响应内容乱码、日志乱码。请求参数乱码检查Connector的URIEncoding;响应乱码检查JSP的contentType和pageEncoding;日志乱码检查file.encoding。
| 现象 | 可能原因 | 排查命令/位置 |
|---|---|---|
| 容器立即退出 | war包损坏、JVM参数错误 | docker logs |
| 访问404 | war包未部署、context path不对 | docker exec ls webapps |
| 访问500 | Spring初始化失败、DB连接失败 | localhost.log |
| JSP编译错误 | JDK版本不匹配、taglib缺失 | localhost.log Jasper异常 |
| 中文乱码 | 编码配置不一致 | catalina.out、JSP头 |
| 时间差8小时 | 时区未设置 | 容器内date命令 |
4.3 性能调优与内存溢出应对
老项目跑在容器里,最容易遇到的是内存问题。容器和JVM对内存的认知经常不一致:JVM看到的是宿主机内存,容器限制的是cgroup内存,结果JVM堆设得比容器限制还大,一跑就OOM被kill。
JDK 8u191之后的版本支持-XX:+UseContainerSupport,能自动识别容器内存限制。如果用的是较老的JDK 8版本,需要手动设置-Xmx,并且留出足够空间给Metaspace、线程栈、直接内存。经验值是:容器内存限制 = 堆内存 + 256MB到512MB。比如容器限制1GB,堆最多设768MB。
docker run -d \ --memory=1g \ --memory-swap=1g \ -e CATALINA_OPTS="-Xms512m -Xmx768m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m" \ myapp:1.0如果出现OutOfMemoryError: Metaspace,说明加载的类太多,调大MaxMetaspaceSize。如果出现OutOfMemoryError: Java heap space,要么调大堆,要么排查内存泄漏。老项目常见的内存泄漏点是静态集合不断增长、数据库连接未关闭、ThreadLocal未清理。
注意:
--memory-swap如果不设置,默认和--memory一样,意味着容器不能用swap。对延迟敏感的应用这是好事,但内存吃紧时容易直接被kill。如果希望有一定缓冲,可以把swap设成memory的两倍。
4.4 健康检查与优雅停机的配置细节
生产环境跑容器,健康检查和优雅停机是两个容易被忽略但很重要的点。健康检查让编排工具知道容器是否真的可用,优雅停机保证重启时正在处理的请求能完成。
Tomcat镜像本身没有HEALTHCHECK,需要自己加。最简单的健康检查是访问一个轻量级的页面或接口:
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1start-period要给足,因为SpringMVC项目启动可能比较慢,60秒是保守值。如果项目启动超过60秒,健康检查会在启动期间就报失败,导致容器被反复重启。
优雅停机方面,Tomcat收到SIGTERM信号后会执行catalina.sh stop,等待正在处理的请求完成。Docker默认的停止信号就是SIGTERM,停止超时默认10秒。如果请求处理时间较长,需要调大超时:
docker stop -t 30 myapp或者在docker-compose.yml里配置stop_grace_period: 30s。另外,Tomcat的server.xml里可以配置<Connector ... connectionTimeout="20000" />,控制连接超时,避免停机时长时间等待。
5. 多环境适配与持续集成的延伸思路
5.1 用Docker Compose编排完整运行环境
单跑一个Tomcat容器往往不够,项目通常还依赖数据库、缓存、消息队列。用Docker Compose可以把这些组件编排在一起,一条命令拉起整个环境。
version: '3.8' services: app: image: myapp:1.0 ports: - "8080:8080" environment: - TZ=Asia/Shanghai - CATALINA_OPTS=-Xms512m -Xmx768m -Dfile.encoding=UTF-8 volumes: - ./logs:/usr/local/tomcat/logs - ./work:/usr/local/tomcat/work - ./config:/data/config depends_on: - db restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 60s db: image: mysql:5.7 environment: - MYSQL_ROOT_PASSWORD=changeit - MYSQL_DATABASE=myapp volumes: - ./mysql-data:/var/lib/mysql restart: unless-stoppedCompose的好处是环境定义即代码,开发、测试、生产可以用同一份文件,只改镜像标签和环境变量。depends_on保证启动顺序,但注意它只保证容器启动顺序,不保证服务就绪。应用侧需要有重试机制,数据库没起来时能自动重连。
5.2 镜像分层优化与构建速度提升
Docker镜像构建是分层的,每条指令一层。把不常变的部分放前面,常变的部分放后面,能充分利用缓存。war包是每次构建都会变的,所以COPY war包应该放在Dockerfile靠后的位置。时区设置、JVM参数、清理webapps这些不常变的操作放前面。
FROM tomcat:9.0.85-jdk8-temurin # 不常变的部分放前面 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV CATALINA_OPTS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai" RUN rm -rf /usr/local/tomcat/webapps/* # 常变的部分放后面 COPY target/myapp.war /usr/local/tomcat/webapps/ROOT.war如果构建环境网络慢,基础镜像拉取会拖时间。可以在CI环境里预先拉取基础镜像,或者用私有镜像仓库做缓存。另外,.dockerignore文件要配好,把target/下的非war文件、.git、node_modules等排除掉,减少构建上下文大小。
5.3 从手动部署到流水线的过渡建议
如果团队还在手动构建和部署,可以先从半自动化开始:写一个build.sh脚本,把mvn package和docker build串起来;再写一个deploy.sh,把docker stop、docker rm、docker run串起来。等这套跑顺了,再接入CI/CD工具做全自动。
#!/bin/bash # build.sh set -e mvn clean package -DskipTests docker build -t myapp:$(date +%Y%m%d%H%M) .#!/bin/bash # deploy.sh set -e IMAGE=$1 docker stop myapp || true docker rm myapp || true docker run -d \ --name myapp \ --restart unless-stopped \ -p 8080:8080 \ -v /data/myapp/logs:/usr/local/tomcat/logs \ -v /data/myapp/work:/usr/local/tomcat/work \ $IMAGE镜像标签用时间戳而不是latest,好处是每次部署都有唯一标识,回滚时知道回滚到哪个版本。latest标签在多环境场景下容易混淆,生产环境应该避免使用。
我在实际迁移过程中最大的体会是:老项目容器化,难点不在Docker本身,而在对原项目运行环境的完整梳理。JDK版本、Tomcat版本、JSP编译方式、配置文件位置、日志路径、时区编码,这些平时散落在运维文档和运维人员脑子里的信息,必须全部显式化到Dockerfile和启动参数里。梳理清楚了,容器化就是水到渠成的事;梳理不清楚,就会陷入“本地能跑、容器里报错”的循环。建议动手之前,先在一台干净的虚拟机上用传统方式把项目跑起来,把所有依赖和配置记录下来,再照着这份记录写Dockerfile,成功率会高很多。