1. 为什么要在 Ubuntu 18.04 上自己构建 JDK+Tomcat+Redis 镜像
先把结论摆出来:这个组合看起来像是“老派 Java Web 全家桶”,但在不少实际项目里依然是刚需。Ubuntu 18.04 作为长期支持版本,很多企业内网、边缘设备、教学实验环境还在跑;JDK 8 或 JDK 11 搭配 Tomcat 8.5/9.0 是大量存量 Java 应用的运行底座;Redis 则承担会话共享、缓存、分布式锁这些角色。把三者塞进同一个镜像,最直接的诉求就是部署单元收敛——一个镜像拉起一个容器,应用、运行时、缓存中间件全在里面,省去多容器编排的复杂度。
我见过不少团队的做法是:基础镜像用某个现成的 Tomcat 镜像,再往里装 Redis,结果镜像层数混乱、启动脚本互相打架、时区不对、日志路径冲突。自己写 Dockerfile 的好处是每一层都清清楚楚,出问题能定位到具体指令。这篇文章就是把这个构建过程完整拆开,从基础镜像选型、依赖安装顺序、环境变量设计,到启动脚本编排、常见报错排查,全部讲透。适合谁看?有一定 Linux 基础、写过简单 Dockerfile、但没系统梳理过“多组件单镜像”构建逻辑的后端或运维同学。哪怕你只是想搞清楚“为什么我的容器里 Redis 起不来”,也能从排查章节里找到答案。
需要提前说明的是,把有状态服务(Redis)和无状态应用(Tomcat)放进同一个容器,在云原生语境下并不算“最佳实践”,但在单机部署、离线环境、快速验证场景里非常实用。我们要做的是把这个方案做扎实,而不是盲目追新。下面所有操作都基于 Ubuntu 18.04 的官方基础镜像展开,命令和参数都可以直接复现。
2. 构建前的整体设计与关键取舍
2.1 单镜像多进程的架构逻辑
一个容器默认只跑一个前台进程,这是 Docker 的设计约定。但“约定”不等于“限制”,容器里完全可以跑多个进程,关键是要有一个前台守护进程把其他后台服务拉起来并保持存活。我们的方案是:Tomcat 作为前台进程(catalina.sh run),Redis 以守护模式在后台启动,JDK 只提供运行时环境不产生进程。这样容器的主进程就是 Tomcat,日志能正常输出到 stdout,docker stop时信号也能正确传递。
为什么不把 Redis 也放前台?因为一个容器只能有一个 PID 1,两个前台进程会互相抢信号处理,停止容器时容易出现“杀不干净”的情况。让 Redis 后台跑、Tomcat 前台跑,是权衡之后最稳的组合。这里有个细节:Redis 后台启动后如果崩溃,容器不会自动退出,所以启动脚本里要加健康检查或者至少做一次启动后验证,这个后面会讲。
2.2 基础镜像与组件版本的选择依据
基础镜像选ubuntu:18.04而不是ubuntu:latest,原因是版本锁定。18.04 的 apt 源里 OpenJDK 8 和 11 都能直接装,Tomcat 需要手动下载(apt 源里的版本偏旧且包结构不同),Redis 用 apt 装 4.0.x 版本足够稳定。如果选 latest,apt 源结构变化、依赖库版本漂移,构建结果不可控。
版本搭配上,我的建议是:JDK 8 + Tomcat 9.0.x + Redis 4.0.x或者JDK 11 + Tomcat 9.0.x + Redis 5.0.x。JDK 8 兼容性最好,存量应用多;JDK 11 是很多新项目的起点。Tomcat 9 对 Servlet 4.0 支持完整,且配置文件和 Tomcat 8.5 基本一致。Redis 版本跟着 Ubuntu 18.04 的 apt 源走即可,不必强求最新,稳定优先。
| 组件 | 推荐版本 | 选择理由 | 安装方式 |
|---|---|---|---|
| 基础镜像 | ubuntu:18.04 | 版本锁定,apt 源稳定 | docker pull |
| JDK | OpenJDK 8 或 11 | 兼容存量应用,apt 直装 | apt-get install |
| Tomcat | 9.0.x | Servlet 4.0,配置成熟 | 官方 tar.gz 解压 |
| Redis | 4.0.x / 5.0.x | apt 源稳定版,够用 | apt-get install |
2.3 分层策略与构建缓存利用
Dockerfile 的层顺序直接决定构建速度。原则是:变化频率低的放前面,变化频率高的放后面。基础镜像、apt 更新、JDK 和 Redis 安装这些几乎不变的操作放最前面;Tomcat 下载解压放中间;环境变量、启动脚本、应用包拷贝放最后。这样改一次应用配置,不需要重装 JDK 和 Redis,构建时间能从几分钟降到十几秒。
还有一个容易忽略的点:apt-get update和apt-get install要写在同一条 RUN 里,否则缓存失效时可能出现“update 用了旧缓存、install 找不到包”的问题。清理 apt 缓存也要在同一层做,避免镜像体积膨胀。这些细节在后面的实操章节会逐条对应。
3. Dockerfile 核心指令逐层拆解
3.1 基础环境与 apt 源优化
第一层从FROM ubuntu:18.04开始。紧接着设置DEBIAN_FRONTEND=noninteractive,这个环境变量很关键——不设置的话,apt 安装某些包时会弹出交互式配置界面(比如时区选择),在非交互的构建环境里会直接卡死或者报错。这是新手最常踩的坑之一。
apt 源方面,Ubuntu 18.04 默认源在国内访问速度一般,如果构建环境网络条件不好,可以换成国内镜像源。但要注意:换源属于环境适配操作,不同网络环境下最优源不同,我这里只给出通用写法,具体源地址按实际环境替换。换源后必须执行apt-get update刷新索引,否则安装会失败。
FROM ubuntu:18.04 ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Asia/Shanghai RUN apt-get update && \ apt-get install -y --no-install-recommends \ openjdk-8-jdk \ redis-server \ curl \ vim \ net-tools \ tzdata && \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ echo $TZ > /etc/timezone && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*这里有几个设计考量。--no-install-recommends避免安装一堆推荐但非必需的包,能显著减小镜像体积。tzdata单独装并配置时区,是因为 Java 应用和 Redis 日志都依赖系统时区,时区不对会导致日志时间错乱、定时任务偏移,排查起来非常隐蔽。curl和net-tools是运维调试用的,生产镜像可以去掉,但构建阶段留着方便验证。最后清理/var/lib/apt/lists/*是必须的,这层不清理能多出几十 MB。
3.2 JDK 环境变量与验证
OpenJDK 8 通过 apt 安装后,默认路径是/usr/lib/jvm/java-8-openjdk-amd64。设置JAVA_HOME和更新PATH是标准操作,但要注意ENV指令的写法——多个变量可以合并成一条,减少镜像层数。
ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 ENV PATH=$JAVA_HOME/bin:$PATH装完 JDK 后建议在构建时做一次验证,虽然多一层 RUN,但能提前暴露问题。验证命令就是java -version,如果输出正常说明 JDK 可用。有人会问:apt 装完不就能用吗,为什么还要验证?实际经验是,某些精简版基础镜像或者源配置异常时,apt 显示安装成功但实际文件缺失,构建时验证一次比运行时报错再回头查要省事得多。
3.3 Tomcat 下载、解压与目录规划
Tomcat 不走 apt,直接下载官方二进制包。版本号用ARG参数化,方便后续升级时只改一处。下载地址用 Apache 官方归档地址,curl -fSL的-f参数很重要——HTTP 返回 4xx/5xx 时直接失败,避免把错误页面当成压缩包解压出一堆乱码。
ARG TOMCAT_VERSION=9.0.85 ENV CATALINA_HOME=/opt/tomcat ENV PATH=$CATALINA_HOME/bin:$PATH RUN curl -fSL https://archive.apache.org/dist/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz -o /tmp/tomcat.tar.gz && \ mkdir -p $CATALINA_HOME && \ tar -xzf /tmp/tomcat.tar.gz -C $CATALINA_HOME --strip-components=1 && \ rm -f /tmp/tomcat.tar.gz && \ rm -rf $CATALINA_HOME/webapps/*--strip-components=1去掉压缩包顶层目录,让文件直接落在/opt/tomcat下,避免路径里多一层版本号。rm -rf webapps/*清空默认应用,生产环境通常只部署自己的 war 包,留着默认应用既占空间又有安全暴露面。如果确实需要管理界面,可以保留manager,但一定要改默认密码,这个后面注意事项里会提。
3.4 Redis 配置调整与后台启动准备
apt 安装的 Redis 默认配置在/etc/redis/redis.conf,默认是前台运行(daemonize no),且绑定127.0.0.1。在容器里我们要让它后台运行并监听所有地址(或者至少监听容器内网卡),否则 Tomcat 连不上。
RUN sed -i 's/^bind 127.0.0.1/bind 0.0.0.0/' /etc/redis/redis.conf && \ sed -i 's/^daemonize no/daemonize yes/' /etc/redis/redis.conf && \ sed -i 's/^protected-mode yes/protected-mode no/' /etc/redis/redis.conf这三条 sed 分别解决三个问题:绑定地址限制、前台/后台模式、保护模式。protected-mode no在容器内网环境可以接受,但如果容器端口映射到公网,必须配合密码使用,否则就是裸奔。更稳妥的做法是设置requirepass,这个在安全注意事项里展开。另外建议把 Redis 的dir指向一个明确目录,日志输出到文件,方便排查。
3.5 启动脚本编排与前台进程设计
启动脚本是整个镜像的“胶水层”。核心逻辑是:先启动 Redis 并确认它起来了,再启动 Tomcat 前台运行。脚本要处理信号传递,确保docker stop时 Tomcat 能优雅关闭。
#!/bin/bash set -e redis-server /etc/redis/redis.conf for i in $(seq 1 10); do if redis-cli ping 2>/dev/null | grep -q PONG; then echo "Redis is ready" break fi echo "Waiting for Redis... ($i)" sleep 1 done exec catalina.sh runset -e让脚本在任意命令失败时退出,避免“Redis 没起来但 Tomcat 照跑”的假象。循环探测redis-cli ping是最轻量的健康检查,最多等 10 秒。最后exec catalina.sh run用 exec 替换当前 shell 进程,让 Tomcat 成为 PID 1,这样 Docker 发的 SIGTERM 能直接到 Tomcat,触发它的 shutdown hook。如果不用 exec,信号会停在 bash 层,Tomcat 收不到停止信号,容器要等超时才被杀掉,日志和连接都来不及清理。
脚本写完记得chmod +x,并在 Dockerfile 里COPY进去。脚本的换行符必须是 LF,Windows 下编辑后带 CRLF 会导致bad interpreter报错,这个坑非常经典。
4. 完整构建与运行实操
4.1 Dockerfile 整合与构建命令
把前面各层按顺序拼起来,完整 Dockerfile 如下。注意层顺序:基础环境 → JDK → Tomcat → Redis 配置 → 启动脚本 → 暴露端口。应用 war 包如果构建时就确定,可以在最后 COPY 进webapps;如果希望运行时挂载,就留空。
FROM ubuntu:18.04 ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Asia/Shanghai RUN apt-get update && \ apt-get install -y --no-install-recommends \ openjdk-8-jdk redis-server curl vim net-tools tzdata && \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ echo $TZ > /etc/timezone && \ apt-get clean && rm -rf /var/lib/apt/lists/* ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 ENV PATH=$JAVA_HOME/bin:$PATH ARG TOMCAT_VERSION=9.0.85 ENV CATALINA_HOME=/opt/tomcat ENV PATH=$CATALINA_HOME/bin:$PATH RUN curl -fSL https://archive.apache.org/dist/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz -o /tmp/tomcat.tar.gz && \ mkdir -p $CATALINA_HOME && \ tar -xzf /tmp/tomcat.tar.gz -C $CATALINA_HOME --strip-components=1 && \ rm -f /tmp/tomcat.tar.gz && rm -rf $CATALINA_HOME/webapps/* RUN sed -i 's/^bind 127.0.0.1/bind 0.0.0.0/' /etc/redis/redis.conf && \ sed -i 's/^daemonize no/daemonize yes/' /etc/redis/redis.conf && \ sed -i 's/^protected-mode yes/protected-mode no/' /etc/redis/redis.conf COPY start.sh /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh EXPOSE 8080 6379 CMD ["/usr/local/bin/start.sh"]构建命令:docker build -t java-web-redis:1.0 .。第一次构建会下载基础镜像和 Tomcat 包,耗时取决于网络。构建过程中如果卡在 apt 阶段,多半是源的问题;卡在 curl 阶段,检查 Tomcat 版本号对应的 URL 是否存在——Apache 归档地址的版本号必须精确匹配,写错一个小版本号就是 404。
4.2 容器启动与端口映射验证
启动命令:docker run -d --name app -p 8080:8080 -p 6379:6379 java-web-redis:1.0。两个端口都映射出来方便验证,生产环境 Redis 端口不建议对外暴露。
验证分三步走。第一步看容器状态:docker ps确认没有反复重启。第二步看日志:docker logs app,正常应该能看到 Redis 启动信息、Redis is ready、Tomcat 的Server startup in xxx ms。第三步进容器验证:docker exec -it app bash,然后java -version、redis-cli ping、curl localhost:8080逐个确认。
如果curl localhost:8080返回 404,别慌,这是正常的——我们把webapps清空了,没有默认应用。返回 404 恰恰说明 Tomcat 在正常工作。要验证应用,部署一个 war 包或者往webapps/ROOT放个 index.html 即可。
4.3 应用部署与数据持久化挂载
应用部署有两种方式。构建时 COPY:在 Dockerfile 最后加COPY app.war $CATALINA_HOME/webapps/,镜像自带应用,适合版本固定的场景。运行时挂载:docker run -v /host/app.war:/opt/tomcat/webapps/app.war ...,适合频繁更新、不想重新构建镜像的场景。挂载方式要注意文件权限,容器内 Tomcat 用户(如果用 root 跑则无此问题)需要读权限。
Redis 数据持久化同样建议挂载:-v /host/redis-data:/var/lib/redis。apt 装的 Redis 默认dir是/var/lib/redis,RDB 文件会落在这里。不挂载的话,容器删了数据就没了。挂载目录的属主需要和容器内 redis 用户匹配,否则写入会报权限错误,这个在排查章节细说。
5. 常见问题与排查技巧实录
5.1 构建阶段典型报错对照表
| 报错现象 | 根因 | 解决方式 |
|---|---|---|
debconf: unable to initialize frontend | 未设非交互模式 | 加ENV DEBIAN_FRONTEND=noninteractive |
curl: (22) The requested URL returned error: 404 | Tomcat 版本号写错 | 核对归档地址中的精确版本 |
bad interpreter: /bin/bash^M | 脚本是 CRLF 换行 | dos2unix或编辑器改 LF |
Unable to locate package openjdk-8-jdk | apt 索引未更新或源异常 | 确认apt-get update与 install 同层 |
| 镜像体积异常大 | 未清理 apt 缓存 | 同层执行rm -rf /var/lib/apt/lists/* |
这些报错我几乎每个都踩过。最隐蔽的是 CRLF 那个,脚本内容看起来完全正常,报错信息却指向一个不存在的路径,新手很容易懵。判断方法:file start.sh看是否提示with CRLF line terminators,或者cat -A start.sh看行尾是否有^M。
5.2 运行阶段 Redis 连接失败排查
Tomcat 应用连不上 Redis,报Connection refused或NOAUTH Authentication required,按这个顺序查。先确认 Redis 进程在不在:docker exec app ps aux | grep redis。再看监听地址:netstat -tlnp | grep 6379,如果显示127.0.0.1:6379说明 bind 配置没生效,检查 sed 是否匹配到了正确的行——不同版本的 redis.conf 里 bind 那行可能带注释或者格式略有差异,sed匹配不上就静默失败,这是最容易忽略的点。
如果报NOAUTH,说明配置了密码但应用没带密码。反过来,如果没配密码却报这个,检查是不是配置文件里残留了requirepass。还有一种情况是protected-mode没关且 bind 是0.0.0.0,Redis 会拒绝非本机连接并给出明确提示,日志里能看到。
5.3 时区与日志时间错乱的定位方法
应用日志时间比实际时间差 8 小时,或者 Redis 日志时间不对,根因通常是时区没配好。检查三步:容器内date命令看系统时间;cat /etc/timezone看时区文件;Java 应用里System.getProperty("user.timezone")看 JVM 时区。系统时区对了但 JVM 不对,说明启动参数里没继承,可以在catalina.sh的JAVA_OPTS里加-Duser.timezone=Asia/Shanghai兜底。
注意:
tzdata装完后必须执行ln -snf和写/etc/timezone两步,只做一步在某些基础镜像里不生效。这个细节官方文档不会强调,但实测下来缺一不可。
5.4 容器停止缓慢与信号处理问题
docker stop要等 10 秒才停,或者日志里看不到 Tomcat 的 shutdown 信息,基本可以确定是启动脚本没用exec。没有 exec 时,PID 1 是 bash,SIGTERM 发给 bash 后 bash 不会转发给子进程,Docker 等超时后发 SIGKILL 强杀。改成exec catalina.sh run后,Tomcat 直接接管信号,停止通常在 1-2 秒内完成,连接池也能正常关闭。
另一个相关问题是 Redis 后台进程在容器停止时不会被优雅关闭,可能丢最后一次 RDB 快照。如果数据重要,可以在启动脚本里 trap 信号,先redis-cli shutdown再退出。这个属于进阶优化,单机验证场景可以先不做,但生产环境值得加上。
6. 安全加固与镜像瘦身的实操建议
6.1 非 root 运行与权限收敛
默认用 root 跑容器是方便但不安全的做法。改进方案是创建专用用户,把 Tomcat 和 Redis 的数据目录属主改过去。Redis 的 apt 包其实已经创建了redis用户,Tomcat 可以复用或者新建。切换用户后要注意端口权限——1024 以下端口需要特权,我们用的 8080 和 6379 都没问题。
具体操作:RUN useradd -r -m -d /opt/tomcat tomcat && chown -R tomcat:tomcat $CATALINA_HOME /var/lib/redis /var/log/redis,然后USER tomcat。改完之后要重新验证启动,因为权限问题往往在启动时才暴露,比如日志目录不可写、临时目录不可用。Tomcat 的temp目录和work目录都需要写权限,漏一个就启动失败。
6.2 Redis 访问控制与 Tomcat 管理界面处理
Redis 一定要设密码,哪怕只在内网。配置方式是在redis.conf里加requirepass your_strong_password,应用侧连接时带上密码。密码不要硬编码在镜像里,用运行时环境变量注入更稳妥,启动脚本里用sed动态替换或者通过--requirepass启动参数传入。
Tomcat 的manager和host-manager应用如果保留,必须改tomcat-users.xml里的默认凭据,并且限制访问来源 IP。更彻底的做法是直接删掉这两个目录,需要时再按需部署。默认应用目录webapps清空后,记得放一个自己的ROOT应用或者明确返回 404,避免暴露 Tomcat 版本信息——错误页面里的版本号对攻击者是有效情报。
6.3 多阶段构建与镜像体积优化
单阶段构建的镜像大概 600-800MB,主要体积在 JDK 和 apt 缓存。优化手段有几个:用openjdk-8-jdk-headless替代完整 JDK,省掉图形库依赖;apt 安装后彻底清理;Tomcat 的webapps/docs、examples删掉。如果追求极致,可以用多阶段构建——第一阶段下载解压 Tomcat,第二阶段只 COPY 必要文件,但 JDK 本身还是得装,收益有限。
实测下来,headless版本能省 100MB 左右,清理文档和示例再省几十 MB。对于单机部署场景,这个体积可以接受;如果镜像要频繁分发,值得花时间优化。另外注意不要把构建工具(maven、git)装进运行镜像,那是另一个常见的体积黑洞。
7. 我个人的几点实操体会
这套镜像我在几个离线环境和教学场景里反复构建过,最大的感受是:多组件单镜像的难点不在“装进去”,而在“管起来”。装 JDK、Tomcat、Redis 都是标准操作,真正花时间的是启动顺序、信号处理、时区一致、权限匹配这些“胶水”问题。每一个都对应过至少一次深夜排查。
另一个体会是版本锁定要彻底。基础镜像锁18.04,Tomcat 锁精确小版本,apt 包虽然没法锁到补丁号,但至少保证构建时源是一致的。有次没锁 Tomcat 版本,隔了两个月重新构建,默认版本变了,配置文件格式微调,启动直接失败。从那以后所有ARG都写死。
最后分享一个小技巧:构建完先别急着跑应用,进容器手动执行一遍启动脚本里的每条命令,确认 Redis 能起、Tomcat 能起、端口能通,再交给CMD自动跑。手动验证五分钟,能省掉后面半小时的日志排查。这个习惯在调试任何多进程容器时都适用。