1. 项目背景与整体部署思路拆解
把一个传统的 SpringMVC + JSP 项目塞进 Docker 里跑起来,这件事听起来像是“老技术遇上新工具”,但真到落地的时候,坑比想象中多得多。我最近刚把一个跑了七八年的老项目从物理机迁移到容器环境,中间踩了不少雷,也积累了一些可以直接复用的经验。这篇文章就围绕Docker 部署 SpringMVC + JSP 项目这个核心场景,把 Tomcat 篇的完整实操过程拆开来讲清楚。
先说清楚这个内容适合谁看。如果你手里有一个基于 SpringMVC 框架、前端用 JSP 渲染的老项目,想把它容器化但不知道从哪下手,那这篇内容就是给你准备的。如果你已经用过 Docker 部署 SpringBoot 项目,觉得“不就是写个 Dockerfile 嘛”,那我建议你也看看,因为 JSP 项目的容器化跟纯前后端分离的项目完全不是一回事,坑点集中在JSP 编译机制、Tomcat 目录结构、类加载顺序、静态资源路径这几个地方。
核心关键词先摆出来:Docker、SpringMVC、JSP、Tomcat、容器化部署、WAR 包、Dockerfile、镜像构建。这些词会贯穿全文,每一个我都会结合实际操作讲透。
整体思路其实不复杂:把项目打成 WAR 包,选一个合适的 Tomcat 基础镜像,把 WAR 包丢进 Tomcat 的 webapps 目录,构建镜像,启动容器,映射端口,验证访问。听起来五步搞定对吧?但每一步都有细节。比如 WAR 包打的时候 JSP 要不要预编译?Tomcat 基础镜像选哪个版本?webapps 目录下的默认应用要不要删?容器里的时区怎么处理?日志往哪写?这些细节决定了你的容器是“能跑”还是“跑得稳”。
我个人的习惯是,在动手写 Dockerfile 之前,先在本地用同样的 Tomcat 版本裸跑一遍 WAR 包,确认项目本身没问题。这一步很关键,因为一旦进了容器,排查问题的成本会翻倍。本地跑通了,再开始容器化,这样出问题的时候你能确定是容器配置的问题,而不是项目本身的代码问题。
提示:不要跳过本地验证这一步。我见过太多人直接把没验证过的 WAR 包丢进容器,结果花了几个小时排查,最后发现是项目本身的配置文件少了一个参数。
下面我会按照环境准备、WAR 包构建、Dockerfile 编写、镜像构建与容器启动、常见问题排查这个顺序,把每个环节的细节和坑点都讲清楚。每个部分都会附带可直接复制的配置和命令,你照着做就能跑起来。
2. 环境准备与基础镜像选型
2.1 本地环境与 Docker 环境确认
在开始之前,先把两边环境确认清楚。本地需要什么?一个能正常编译项目的 JDK,一个能打包的构建工具,再加上 Docker 环境本身。具体版本要求如下:
- JDK 版本:必须和项目原本使用的版本一致。SpringMVC 老项目大概率是 JDK 7 或 JDK 8,别想着升级到 JDK 11 或 17,JSP 和旧版 Spring 在高版本 JDK 上会有兼容性问题。我试过一个 JDK 8 的项目换成 JDK 11 后,JSP 页面直接报
java.lang.NoClassDefFoundError,排查了半天才发现是模块化系统导致的类加载差异。 - 构建工具:Maven 或 Gradle 都行,看项目原本用什么。Maven 的话确认
pom.xml里的packaging是war,不是jar。这个很基础但很容易忽略,打错了包后面全白搭。 - Docker 版本:建议用 20.10 以上的版本,支持
--platform参数和更好的资源限制功能。用docker version确认一下。
本地验证的命令很简单:
java -version mvn -version docker version三条命令的输出确认无误后,再往下走。
2.2 Tomcat 基础镜像选型对比
选基础镜像这件事,很多人随便docker pull tomcat就完事了,但其实有讲究。Tomcat 官方镜像有好几个标签,不同标签对应的 JDK 版本和 Tomcat 版本都不一样。选错了轻则跑不起来,重则出现诡异的字符编码问题。
我把常见的几个标签列出来对比一下:
| 镜像标签 | Tomcat 版本 | JDK 版本 | 适用场景 |
|---|---|---|---|
| tomcat:8.5-jdk8 | 8.5.x | JDK 8 | 老项目首选,兼容性最好 |
| tomcat:9.0-jdk8 | 9.0.x | JDK 8 | 需要 Servlet 4.0 特性的项目 |
| tomcat:9.0-jdk11 | 9.0.x | JDK 11 | 新项目,支持模块化 |
| tomcat:10.0-jdk11 | 10.0.x | JDK 11 | 仅支持 Servlet 5.0+,老项目慎用 |
对于 SpringMVC + JSP 的老项目,我的建议是优先选 tomcat:8.5-jdk8或tomcat:9.0-jdk8。原因很简单:这两个组合经过了大量生产环境的验证,JSP 编译器和 Spring 的兼容性最稳定。Tomcat 10 虽然新,但它把javax.servlet包名改成了jakarta.servlet,老项目的代码直接编译不过,除非你做全量包名替换,否则别碰。
还有一个细节:官方镜像默认的webapps目录里带了一堆示例应用(ROOT、docs、examples 等),这些在部署时应该删掉,不然会占用资源,还可能暴露不必要的页面。后面写 Dockerfile 的时候我会讲怎么处理。
注意:如果你所在的环境无法直接拉取官方镜像,可以提前在能访问的机器上
docker pull然后docker save成 tar 包,再docker load到目标机器。这个流程很常规,不涉及任何特殊工具。
2.3 项目依赖与配置文件梳理
在打包之前,把项目里的配置文件过一遍。SpringMVC 项目常见的配置文件有:
web.xml:配置 DispatcherServlet、字符编码过滤器、Session 超时等applicationContext.xml:Spring 容器配置spring-mvc.xml:MVC 相关配置,比如视图解析器、静态资源映射jdbc.properties或类似文件:数据库连接信息log4j.properties或logback.xml:日志配置
这些文件里,数据库连接信息和日志路径是最容易在容器化后出问题的。数据库地址不能写localhost,因为容器里的localhost指的是容器本身,不是宿主机。日志路径也不能写死成宿主机的绝对路径,要改成容器内可写的目录,或者挂载出来。
我一般会把这些可变配置抽成环境变量,在 Dockerfile 里用ENV声明默认值,启动容器时用-e覆盖。这样同一个镜像可以在不同环境(开发、测试、生产)复用,不用重新构建。
3. WAR 包构建与目录结构规划
3.1 Maven 打包配置与 JSP 预编译处理
打包这一步,Maven 的maven-war-plugin是核心。默认配置其实就能打出可用的 WAR 包,但有几个参数值得调整。
先看一个典型的pom.xml中 war 插件的配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.3.2</version> <configuration> <warName>myapp</warName> <failOnMissingWebXml>false</failOnMissingWebXml> <webResources> <resource> <directory>src/main/webapp</directory> <filtering>true</filtering> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> </resource> </webResources> </configuration> </plugin>这里几个参数解释一下:
warName:指定打出来的 WAR 包名字,不写的话默认是artifactId-version.war。建议写一个简短的名字,比如myapp.war,这样部署后访问路径就是/myapp,干净利落。failOnMissingWebXml:设为false,因为 Servlet 3.0 以后可以用注解替代web.xml,但老项目一般还是有web.xml的,这个参数看情况。filtering:开启资源过滤,可以把pom.xml里的属性替换到配置文件里。比如数据库地址可以用${db.url}这样的占位符,打包时自动替换。
关于 JSP 预编译,这是一个值得单独说的点。JSP 默认是在第一次访问时才编译成 Servlet 的,这意味着容器启动后第一次访问某个页面会特别慢,而且如果 JSP 里有语法错误,要到访问时才会暴露。有两种方式可以提前发现和解决这个问题:
第一种是用jspc-maven-plugin在打包阶段预编译所有 JSP:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>jspc-maven-plugin</artifactId> <version>2.0-alpha-3</version> <executions> <execution> <id>jspc</id> <goals> <goal>compile</goal> </goals> </execution> </executions> </plugin>第二种是在 Tomcat 的conf/web.xml里配置development模式为false,让 Tomcat 在启动时预编译。容器化场景下,我一般用第一种,因为打包阶段就能发现问题,不用等到容器启动。
提示:预编译 JSP 会增加打包时间,但能提前暴露语法错误,对于需要频繁部署的项目来说很值得。如果项目 JSP 文件不多,也可以跳过这一步,靠启动后的首次访问来触发编译。
3.2 WAR 包目录结构解析
打好的 WAR 包解压后,目录结构大概是这样的:
myapp/ ├── META-INF/ │ └── MANIFEST.MF ├── WEB-INF/ │ ├── web.xml │ ├── classes/ │ │ └── com/example/... │ ├── lib/ │ │ └── *.jar │ └── views/ │ └── *.jsp ├── static/ │ ├── css/ │ ├── js/ │ └── images/ └── index.jsp几个关键点:
WEB-INF/classes放的是编译后的 Java 类文件WEB-INF/lib放的是项目依赖的 jar 包WEB-INF/views通常是 SpringMVC 视图解析器配置的 JSP 存放位置static目录放静态资源,需要在spring-mvc.xml里配置<mvc:resources>映射
这个结构在容器里会原样保留,Tomcat 启动时会自动解压 WAR 包到webapps目录下。有一点要注意:Tomcat 默认会在启动时解压 WAR 包,如果容器重启频繁,这个解压过程会拖慢启动速度。可以在server.xml里配置unpackWARs="false"和autoDeploy="false"来跳过解压,直接从 WAR 包运行。但这样 JSP 的编译速度会受影响,因为 Tomcat 需要从 WAR 包里读取 JSP 文件。我实测下来,对于中小型项目,保持默认的解压行为更稳妥。
3.3 构建命令与产物验证
打包命令很简单:
mvn clean package -DskipTests-DskipTests跳过测试,加快打包速度。如果项目有集成测试需要跑,去掉这个参数就行。
打包完成后,在target目录下应该能看到myapp.war。验证一下包的内容:
unzip -l target/myapp.war | head -30确认WEB-INF/web.xml、WEB-INF/classes、WEB-INF/lib这些关键目录都存在。如果WEB-INF/lib是空的,说明依赖没有打进去,检查pom.xml里的依赖 scope 是不是设成了provided。Tomcat 本身提供了一些 jar 包(比如servlet-api.jar),这些依赖的 scope 应该是provided,不需要打进 WAR 包。但 Spring 相关的依赖必须是compilescope,否则运行时会报ClassNotFoundException。
我踩过的一个坑:项目里用了commons-lang3,但 scope 写成了provided,本地跑的时候因为 Tomcat 的 lib 目录里恰好有这个 jar 所以没报错,进了容器之后因为基础镜像的 Tomcat lib 目录里没有这个 jar,直接启动失败。所以打包后一定要检查WEB-INF/lib里的 jar 包是否完整。
4. Dockerfile 编写与镜像构建实操
4.1 Dockerfile 完整配置与逐行解析
下面是一个经过实战验证的 Dockerfile,我把它拆开逐行解释:
FROM tomcat:9.0-jdk8 LABEL maintainer="dev-team" \ description="SpringMVC JSP Application" ENV TZ=Asia/Shanghai \ JAVA_OPTS="-Xms512m -Xmx1024m -Dfile.encoding=UTF-8" \ CATALINA_OPTS="-Djava.security.egd=file:/dev/./urandom" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN rm -rf /usr/local/tomcat/webapps/* COPY target/myapp.war /usr/local/tomcat/webapps/myapp.war RUN unzip -q /usr/local/tomcat/webapps/myapp.war -d /usr/local/tomcat/webapps/myapp && \ rm -f /usr/local/tomcat/webapps/myapp.war EXPOSE 8080 CMD ["catalina.sh", "run"]逐行解释:
FROM tomcat:9.0-jdk8:基础镜像,前面已经分析过选型理由。如果你用的是 Tomcat 8.5,改成tomcat:8.5-jdk8即可。
ENV TZ=Asia/Shanghai:设置时区。这个非常重要,不设置的话容器默认是 UTC 时间,日志时间戳和数据库时间会对不上,排查问题时很头疼。JAVA_OPTS里设置了内存参数和编码格式,-Dfile.encoding=UTF-8确保 JSP 页面中文不乱码。CATALINA_OPTS里的urandom参数是优化 Tomcat 启动时的随机数生成速度,在容器环境里这个优化很明显,启动时间能缩短好几秒。
RUN ln -snf ...:把时区文件链接到上海时区,并写入/etc/timezone。这两步配合才能让容器内的系统时间和 Java 时间都正确。
RUN rm -rf /usr/local/tomcat/webapps/*:删掉默认的示例应用。官方镜像的webapps目录里有 ROOT、docs、examples 等,不删的话访问http://localhost:8080/会看到 Tomcat 的默认页面,而且这些应用会占用内存。
COPY target/myapp.war ...:把打好的 WAR 包复制到容器的webapps目录。
RUN unzip ... && rm -f ...:解压 WAR 包并删除原文件。为什么要手动解压?因为 Tomcat 默认会在启动时解压,但如果容器以只读文件系统运行,或者启动用户没有写权限,解压会失败。提前解压可以避免这个问题,而且启动速度更快。解压后删除 WAR 包是为了减小镜像体积,因为解压后的目录已经包含了所有内容。
EXPOSE 8080:声明容器暴露的端口。这只是一个声明,实际映射还是在docker run时用-p指定。
CMD ["catalina.sh", "run"]:启动 Tomcat。用catalina.sh run而不是startup.sh,因为run是前台运行,容器的主进程不会退出。如果用startup.sh,它会后台启动 Tomcat 然后退出,容器会立刻停止。
注意:
JAVA_OPTS里的内存参数要根据实际项目调整。-Xms512m -Xmx1024m适合中小型项目,如果项目比较大或者并发高,需要适当调大。但也不要设得太大,超过容器内存限制会导致容器被系统杀掉。
4.2 镜像构建命令与构建优化
构建命令:
docker build -t myapp:v1.0 .构建过程中,Docker 会按层缓存。如果只是改了 WAR 包,前面的FROM、ENV、RUN层都会命中缓存,只有COPY和后面的层会重新执行。所以把不常变的部分放在前面,常变的部分放在后面,这是 Dockerfile 的基本优化原则。
构建完成后,用docker images看一下镜像大小。基于tomcat:9.0-jdk8的镜像大概在 500MB 左右,加上你的 WAR 包,最终镜像可能在 600-700MB。如果想减小体积,可以考虑用 Alpine 版本的基础镜像,但 Alpine 用的是 musl libc,某些依赖 glibc 的库可能跑不起来,需要额外测试。
还有一个优化点:如果项目依赖的 jar 包很多,WEB-INF/lib目录会很大,每次改代码重新打包都会导致这一层缓存失效。可以把依赖 jar 包单独抽出来,先复制pom.xml然后mvn dependency:copy-dependencies,这样依赖不变的时候这一层就能复用缓存。不过对于老项目来说,依赖一般比较稳定,这个优化的收益有限,看情况决定要不要做。
4.3 容器启动与端口映射
启动容器的命令:
docker run -d \ --name myapp \ -p 8080:8080 \ -e JAVA_OPTS="-Xms512m -Xmx1024m -Dfile.encoding=UTF-8" \ -v /data/logs/myapp:/usr/local/tomcat/logs \ --restart unless-stopped \ myapp:v1.0参数解释:
-d:后台运行--name myapp:容器名字,方便后续管理-p 8080:8080:把宿主机的 8080 端口映射到容器的 8080 端口。如果宿主机 8080 被占用了,改成-p 9090:8080即可-e JAVA_OPTS=...:覆盖 Dockerfile 里的环境变量,可以针对不同环境调整内存参数-v /data/logs/myapp:/usr/local/tomcat/logs:把容器内的日志目录挂载到宿主机,这样容器重启或删除后日志不会丢失--restart unless-stopped:容器异常退出时自动重启,除非手动停止
启动后,用docker logs -f myapp查看启动日志。看到类似Server startup in [xxxx] milliseconds的输出,说明 Tomcat 启动成功了。
然后访问http://宿主机IP:8080/myapp,应该能看到项目的首页。如果访问的是http://宿主机IP:8080/,会看到 404,因为 ROOT 应用被删掉了,这是正常的。如果想直接通过根路径访问,可以把 WAR 包重命名为ROOT.war,或者在server.xml里配置 Context 路径。
5. 常见问题排查与避坑经验实录
5.1 启动失败与类加载问题排查
问题一:容器启动后立刻退出
用docker ps -a看到容器状态是Exited,用docker logs myapp查看日志。常见原因有:
web.xml配置错误导致 Tomcat 启动失败。日志里会有SEVERE级别的错误信息,根据提示定位问题。- 端口被占用。如果宿主机 8080 已经被其他程序占用,容器启动时会报
Address already in use。换成其他端口即可。 - 内存不足。如果
JAVA_OPTS里的-Xmx超过了容器可用内存,JVM 启动时会直接失败。用docker stats查看容器内存使用情况。
问题二:JSP 页面报java.lang.ClassNotFoundException
这个问题的根源通常是依赖 jar 包没有打全。排查步骤:
- 进入容器:
docker exec -it myapp bash - 查看
WEB-INF/lib目录:ls /usr/local/tomcat/webapps/myapp/WEB-INF/lib/ - 对比本地
target/myapp/WEB-INF/lib/目录,看少了哪些 jar 包 - 检查
pom.xml里对应依赖的 scope,如果是provided,改成compile或者删掉 scope 声明
我遇到过一个特殊情况:项目里用了jstl标签库,但依赖 scope 写成了provided,本地跑的时候因为 Tomcat 的 lib 目录里有jstl.jar所以没报错,进了容器之后基础镜像的 Tomcat lib 目录里没有这个 jar,JSP 页面直接报ClassNotFoundException。解决办法是把jstl的 scope 改成compile,让它打进 WAR 包。
问题三:中文乱码
中文乱码在容器化部署里特别常见,原因可能出在三个地方:
- JVM 编码:
JAVA_OPTS里要加-Dfile.encoding=UTF-8 - Tomcat 连接器编码:
server.xml里的Connector标签要加URIEncoding="UTF-8" - 应用层编码:
web.xml里配置的CharacterEncodingFilter要设置forceEncoding=true
三个地方都设置成 UTF-8,基本就能解决乱码问题。如果还有乱码,检查数据库连接的 URL 里有没有加useUnicode=true&characterEncoding=UTF-8。
5.2 日志与时间戳异常处理
问题四:日志时间戳比实际时间少 8 小时
这是时区没设置对。Dockerfile 里虽然设置了TZ=Asia/Shanghai,但有些基础镜像的/etc/localtime没有正确链接。排查方法:
docker exec -it myapp date如果输出的时间不是北京时间,说明时区配置有问题。解决办法是在 Dockerfile 里加这两行:
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone如果已经构建了镜像不想重新构建,可以在docker run时加-e TZ=Asia/Shanghai和-v /etc/localtime:/etc/localtime:ro来覆盖。
问题五:日志文件写入失败
如果项目配置了 log4j 或 logback 往文件写日志,容器里的路径可能是只读的,或者目录不存在。解决办法有两种:
- 在 Dockerfile 里创建日志目录并赋予写权限:
RUN mkdir -p /usr/local/tomcat/logs && chmod 777 /usr/local/tomcat/logs - 用
-v把日志目录挂载到宿主机,这样既解决了写权限问题,又方便查看日志
我一般用第二种,因为日志挂载出来之后,用tail -f直接看就行,不用每次都docker exec进容器。
5.3 性能调优与资源限制
问题六:容器启动慢
Tomcat 启动慢的原因可能有很多,常见的有:
- JSP 预编译耗时。如果项目 JSP 文件很多,首次启动会逐个编译,耗时较长。可以在打包阶段预编译 JSP 来缓解。
- 随机数生成阻塞。
CATALINA_OPTS里加-Djava.security.egd=file:/dev/./urandom可以加速。 - 依赖 jar 包太多。
WEB-INF/lib里的 jar 包越多,类加载时间越长。可以删掉不必要的依赖。
问题七:容器内存溢出被系统杀掉
用docker stats查看容器的内存使用情况。如果内存使用接近限制,JVM 会被系统杀掉,日志里会有Killed字样。解决办法:
- 调大容器的内存限制:
docker run --memory=2g ... - 调小 JVM 的堆内存:
-Xmx设置为容器内存的 70% 左右 - 检查是否有内存泄漏,用
jmap或jstack分析
下面是一个常见问题速查表,方便你快速定位:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 容器启动后立刻退出 | web.xml 配置错误 | docker logs 查看错误日志 | 根据日志修复配置 |
| JSP 报 ClassNotFoundException | 依赖 jar 包缺失 | 对比容器和本地 lib 目录 | 调整依赖 scope |
| 中文乱码 | 编码未统一设置 | 检查 JVM、Tomcat、应用层编码 | 三处都设为 UTF-8 |
| 日志时间少 8 小时 | 时区未设置 | docker exec date 查看时间 | 设置 TZ 环境变量 |
| 日志写入失败 | 目录权限不足 | 检查日志目录权限 | 挂载日志目录或修改权限 |
| 启动慢 | JSP 预编译或随机数阻塞 | 查看启动日志耗时 | 预编译 JSP、加 urandom 参数 |
| 内存溢出被杀 | JVM 堆内存超限 | docker stats 查看内存 | 调整 -Xmx 或容器内存限制 |
5.4 独家避坑技巧与实操心得
分享几个我在实际操作中总结的技巧,常规文档里不会写:
技巧一:用docker cp快速调试
如果容器启动失败,不想重新构建镜像来调试,可以用docker cp把修改后的文件复制进容器:
docker cp target/myapp.war myapp:/usr/local/tomcat/webapps/ docker restart myapp这样可以快速验证修改是否有效,确认后再更新 Dockerfile 重新构建。
技巧二:用docker commit保存调试状态
如果调试过程中找到了可用的配置组合,可以用docker commit把当前容器保存成新镜像:
docker commit myapp myapp:debug-v1这样就不用重新走一遍构建流程,直接基于调试好的容器继续开发。但注意,docker commit生成的镜像不可复现,正式环境还是要用 Dockerfile 构建。
技巧三:JVM 参数用JAVA_OPTS而不是CATALINA_OPTS
JAVA_OPTS和CATALINA_OPTS的区别在于,JAVA_OPTS在 Tomcat 启动和停止时都会生效,CATALINA_OPTS只在启动时生效。对于内存参数和编码参数,用JAVA_OPTS更合适。但-Djava.security.egd这种只影响启动的参数,放在CATALINA_OPTS里更干净。
技巧四:定期清理未使用的镜像和容器
Docker 用久了,磁盘上会积累大量未使用的镜像和停止的容器。定期清理:
docker system prune -a这个命令会删除所有未使用的镜像、容器、网络和构建缓存。执行前确认没有需要保留的资源。
技巧五:用.dockerignore排除不必要的文件
在项目根目录创建.dockerignore文件,排除target目录下的临时文件、.git目录、IDE 配置文件等:
.git .idea *.iml target/*.war.original这样可以减小构建上下文的大小,加快构建速度。
6. 多环境配置与持续部署扩展
6.1 多环境配置管理
实际项目中,开发、测试、生产环境的配置通常不一样。数据库地址、日志级别、缓存配置这些都需要区分。用 Docker 的话,有几种常见的做法:
做法一:环境变量覆盖
把可变配置抽成环境变量,在docker run时用-e传入。比如数据库地址:
docker run -d -e DB_URL=jdbc:mysql://192.168.1.100:3306/mydb myapp:v1.0然后在 Spring 的配置文件里用${DB_URL}引用。这种方式最简单,但环境变量多了之后管理起来比较麻烦。
做法二:挂载配置文件
把配置文件放在宿主机上,用-v挂载到容器里:
docker run -d -v /data/config/myapp:/usr/local/tomcat/webapps/myapp/WEB-INF/classes myapp:v1.0这样修改配置只需要改宿主机的文件,然后重启容器即可,不用重新构建镜像。但要注意,挂载会覆盖容器内的整个目录,所以宿主机上要放完整的配置文件。
做法三:多阶段构建
用 Maven 的多 profile 功能,在打包阶段就区分环境:
mvn clean package -Pprod然后在 Dockerfile 里根据不同的 WAR 包构建不同的镜像。这种方式适合环境差异比较大的项目。
我一般用第一种和第二种结合:核心配置用环境变量,复杂的配置文件用挂载。这样既灵活又不会太复杂。
6.2 容器编排与持续部署思路
如果项目规模不大,用docker run手动管理几个容器就够了。但如果容器数量多了,手动管理会变得很麻烦。这时候可以考虑用 Docker Compose 来编排:
version: '3.8' services: myapp: image: myapp:v1.0 ports: - "8080:8080" environment: - TZ=Asia/Shanghai - JAVA_OPTS=-Xms512m -Xmx1024m -Dfile.encoding=UTF-8 volumes: - /data/logs/myapp:/usr/local/tomcat/logs restart: unless-stopped用docker-compose up -d启动,docker-compose down停止。配置文件用 YAML 格式,比一长串docker run参数清晰得多。
如果项目需要和数据库、缓存等其他服务一起部署,Compose 的优势更明显。可以在同一个docker-compose.yml里定义多个服务,用depends_on控制启动顺序,用自定义网络让服务之间互相通信。
再往上走就是 Kubernetes 了,但对于一个 SpringMVC + JSP 的老项目来说,上 K8s 的收益可能不大,运维成本反而很高。我的建议是,如果团队没有 K8s 运维经验,用 Docker Compose 就够了,稳定可靠,学习成本低。
6.3 镜像版本管理与回滚策略
镜像打标签的时候,不要只用latest。latest标签在每次构建时都会被覆盖,出了问题想回滚都找不到旧版本。建议用版本号或日期来打标签:
docker build -t myapp:v1.0.0 . docker build -t myapp:20240115 .同时保留多个版本的镜像,出问题的时候可以快速回滚:
docker stop myapp docker rm myapp docker run -d --name myapp -p 8080:8080 myapp:v0.9.0如果用了镜像仓库,可以把构建好的镜像推送到仓库,部署时从仓库拉取。这样多台机器部署的时候不用每台都重新构建。
回滚策略的核心是:每次部署前记录当前使用的镜像版本,出问题时能快速切回旧版本。我一般会在部署脚本里加一行记录当前版本的操作,比如把当前镜像 ID 写到一个文件里,回滚的时候直接读这个文件。
6.4 安全加固与最佳实践
容器化部署的安全问题也值得注意。几个基本的安全实践:
- 不要用 root 用户运行容器:在 Dockerfile 里创建普通用户,用
USER指令切换。Tomcat 官方镜像默认是用 root 运行的,可以改成tomcat用户。 - 限制容器资源:用
--memory和--cpus限制容器的资源使用,防止单个容器耗尽宿主机资源。 - 不要挂载 Docker socket:除非确实需要,否则不要把
/var/run/docker.sock挂载到容器里,这相当于给了容器宿主机的 root 权限。 - 定期更新基础镜像:基础镜像里的 JDK 和 Tomcat 会定期发布安全补丁,定期重新构建镜像来获取这些补丁。
- 扫描镜像漏洞:用
docker scan或第三方工具扫描镜像里的已知漏洞,及时修复。
这些实践看起来简单,但真正落地的时候需要团队形成规范。我见过太多项目为了图方便,直接用 root 运行、不限制资源、不更新基础镜像,结果出了安全问题才后悔。
6.5 从 Tomcat 部署到其他方案的对比
最后聊一下 Tomcat 部署和其他方案的对比,帮你判断当前方案是否合适。
| 部署方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Tomcat + WAR | 兼容老项目,JSP 支持好 | 镜像大,启动慢 | SpringMVC + JSP 老项目 |
| 内嵌 Tomcat + JAR | 镜像小,启动快 | JSP 支持有限 | SpringBoot 新项目 |
| Nginx + 静态资源 | 静态资源性能好 | 需要额外配置 | 前后端分离项目 |
| 传统物理机部署 | 无容器开销 | 环境不一致,迁移麻烦 | 对性能要求极高的场景 |
对于 SpringMVC + JSP 的老项目,Tomcat + WAR 的容器化方案是最稳妥的选择。虽然镜像大一点、启动慢一点,但兼容性最好,迁移成本最低。等项目重构的时候,再考虑换成 SpringBoot 内嵌 Tomcat 的方案。
我个人在实际操作中的体会是,老项目容器化的核心价值不在于性能提升,而在于环境一致性和部署效率。以前部署一个老项目,要手动装 JDK、装 Tomcat、配环境变量、传 WAR 包,一套流程下来至少半小时,还容易出错。现在用 Docker,一条docker run命令就搞定,而且开发、测试、生产环境完全一致,省去了大量沟通和排查成本。这个价值对于维护老项目的团队来说,是非常实在的。