简介:在无外网、yum源不可用的服务器环境下,离线交付是保证Java应用与反向代理正常运行的关键手段。容器化技术通过将Docker引擎、Nginx服务及OpenJDK8基础镜像封装为可搬运的文件包,让实施人员能在目标机器上快速完成环境还原。其核心原理是利用docker save/load实现镜像的完整迁移,以rpm包离线安装Docker组件,并通过启动脚本串联容器生命周期。这种方式不仅屏蔽了网络依赖,还通过配置挂载和资源限制提升了运维可控性。常见于企业内部系统交付、政企项目部署及黑匣子服务器场景。本文从基础概念出发,结合工程实践,梳理离线部署中镜像导入、容器启动、时区与日志配置的完整路径,帮助运维与交付工程师规避典型坑点,最终自然落到以Docker、Nginx、OpenJDK8镜像为核心的离线交付解决方案上。
1. 一套离线交付物:Docker、Nginx与OpenJDK 8镜像为什么总被打包在一起
服务器的交付场景往往比开发环境粗暴得多:目标机器没有外网,yum源指向内网却一片空白,SSH 上去敲一句docker pull直接超时,这时候你才知道什么叫做“黑匣子服务器”。标题里这一串东西——Linux 环境下的 Docker 安装包、Nginx 安装包、Docker 容器里的 Nginx 启动脚本、OpenJDK 8 镜像安装包——本质上就是一套完整的离线交付物,解决的是“在一台干净的 Linux 机器上,把 Java 8 应用和反向代理跑起来”这件事。适合谁?部署实施工程师、运维,以及要给客户交付单体 Java 应用的技术负责人。核心思路只有一条:所有依赖的东西,必须在到达现场之前全部变成文件,然后带到目标机器上直接落盘。
整套交付物的依赖关系很清晰:Docker 是底座,OpenJDK 8 镜像负责运行 Java 应用,Nginx 负责反向代理外部流量,而启动脚本则把这些组件串成一个可重复执行的操作。我从实际交付经验出发,把每一块的安装包准备、落地方式和配置脚本拆开讲,最后落到一组验证命令上。整个过程不复杂,但坑不少,尤其是离线环境下的镜像导入和容器启动顺序,这两处最容易翻车。
2. 把Docker安装包落地到Linux:在线源与离线rpm包两种路径
2.1 在线安装docker-ce:最小命令与两个必调参数
如果目标机器能访问外网,Docker 的安装是最简单的一步。常见做法是直接配置 Docker 官方仓库,然后安装docker-ce。先卸载掉系统自带的旧版本,再装新包,避免出现dockerd和containerd版本不匹配的问题。
sudo yum remove -y docker docker-client docker-common docker-engine sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin sudo systemctl enable --now docker这段命令的顺序是有讲究的。yum-utils提供yum-config-manager命令,没有它你没法添加 Docker 官方源。安装docker-ce时必须把containerd.io一起装上,因为新版本的dockerd依赖它来做容器生命周期管理。docker-compose-plugin是给后续用docker compose编排多容器用的,不是必须,但建议一同安装。装完后systemctl enable --now一次搞定开机自启和立即启动,不用再单独start。
装完第一件事不是拉镜像,而是检查两个参数。第一,docker info看Cgroup Driver是不是systemd,如果不是,后面的容器日志采集和资源限制会有一堆玄学问题;第二,编辑/etc/docker/daemon.json,把镜像加速器和日志驱动设置好:
{ "registry-mirrors": ["https://docker.m.daocloud.io"], "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "3" }, "exec-opts": ["native.cgroupdriver=systemd"] }registry-mirrors只对 Docker Hub 的拉取生效,内网私有仓库用insecure-registries字段单独配置。log-opts限制单个容器日志文件的上限是 20MB,保留 3 个文件,这一步能避免 Java 应用疯狂打日志把磁盘塞满。改完配置必须重启 Docker:sudo systemctl restart docker。注意,exec-opts里的native.cgroupdriver=systemd要和宿主机的 systemd 保持一致,否则 kubelet 之类的组件会报错。
2.2 离线环境装Docker:准备好rpm包再进场
离线环境是这套交付物真正的用武之地。你需要在有外网的机器上把 Docker 相关的 rpm 包全部下载好,带到目标机器上安装。不要天真地只下载一个docker-cerpm,它有一堆依赖,少了containerd.io会直接报错。用--downloadonly参数可以一次性把所有依赖拉下来:
# 在有外网的机器上执行 sudo yum install --downloadonly --downloaddir=/opt/docker-rpms \ docker-ce docker-ce-cli containerd.io docker-compose-plugin # 把整个目录打包带走 tar -czf docker-offline.tar.gz -C /opt docker-rpms--downloadonly不会安装软件包,只是把 rpm 文件下载到指定目录。执行完后检查/opt/docker-rpms目录,里面除了 docker-ce 之外,应该还有container-selinux、libseccomp、audit-libs之类的依赖包。目标机器上的安装命令很简单:
# 在离线目标机器上执行 tar -xzf docker-offline.tar.gz -C /opt cd /opt/docker-rpms sudo rpm -ivh *.rpm --nodeps sudo systemctl enable --now docker--nodeps在这里是个双刃剑。离线环境下依赖可能不完整,加上它可以强制安装,但如果真缺了关键的库,dockerd启动时会闪退。所以加--nodeps之后必须紧接着验证:sudo docker info,能正常输出版本信息才说明安装成功。如果你不想用--nodeps,就老老实实按rpm -ivh报错提示一个个补齐依赖,这通常意味着你要回有外网的机器上再拉一轮包。从交付效率看,先加--nodeps试装,出问题再补依赖,是更快的路径。
离线安装有一点容易被忽略:装完后/etc/docker/daemon.json是空的,不会自动写入上面的镜像加速和日志配置。记得把在线环境那份配置手动复制过去,不然容器日志会无限增长。这个文件我每次都会放进交付物里,让启动脚本检查它的存在。
3. OpenJDK 8镜像安装包:从下载到离线导入的完整链路
3.1 选openjdk:8-jdk-alpine还是eclipse-temurin:8-jdk
OpenJDK 8 镜像是这套交付物里 Java 应用的地基。很多人直接docker pull openjdk:8-jdk-alpine,但这只适合拿来跑个不依赖 glibc 的小工具。线上 Java 应用我一般推荐eclipse-temurin:8-jdk,因为它基于 Ubuntu,底层是 glibc,和绝大多数 Java 生产环境兼容。两者的差别很实际:
| 镜像 | 体积 | 基础系统 | 适用场景 |
|---|---|---|---|
| openjdk:8-jdk-alpine | 约 150MB | Alpine Linux / musl libc | 轻量工具、简单Java程序 |
| eclipse-temurin:8-jdk | 约 400MB | Ubuntu / glibc | 生产Java应用、需依赖本地库 |
| openjdk:8-jre-alpine | 约 120MB | Alpine Linux | 只跑运行时不编译 |
体积差三倍,但生产环境千万别为了省这 250MB 牺牲兼容性。Alpine 镜像里libstdc++、fontconfig这类库经常缺失,Java 应用如果调用了 JNI 或者生成验证码图片,就会在运行时突然抛ClassNotFoundException或UnsatisfiedLinkError,这类问题最折磨人。所以离线交付包我一般打好两个镜像:一个eclipse-temurin:8-jdk给业务应用用,一个openjdk:8-jdk-alpine给排查网络问题时的临时容器用。两三百兆的存储成本换来现场排查的底气,值。
3.2 本地导出、压缩与离线导入的完整命令
在有外网的机器上拉取镜像后,要用docker save把镜像导出成 tar 文件,而不是直接拷贝镜像目录。docker save会把镜像的所有层完整保留,到目标机器上用docker load能精确还原镜像的 ID 和 Tag,这是官方支持的标准搬运方式。我一般会先打 tag,再导出,免得镜像迁移到目标机器后 tag 指向不明确:
# 1. 在有外网的机器上拉取镜像 docker pull eclipse-temurin:8-jdk docker pull openjdk:8-jdk-alpine # 2. 重新打tag,带上应用标识 docker tag eclipse-temurin:8-jdk registry.internal/java8:base docker tag openjdk:8-jdk-alpine registry.internal/java8:alpine # 3. 导出为tar包并压缩 docker save registry.internal/java8:base -o java8-base.tar docker save registry.internal/java8:alpine -o java8-alpine.tar gzip java8-base.tar java8-alpine.tar第二步打 tag 不是多此一举。离线交付的镜像来源可能五花八门,统一 tag 到内网命名空间下,启动脚本里引用镜像名就不会因为来源不同而出错。docker save默认不压缩,所以导出后一定要gzip,400MB 的镜像能压到 180MB 左右,传文件时能省不少时间。
到目标机器后的导入操作:
# 在离线目标机器上执行 docker load -i java8-base.tar.gz docker load -i java8-alpine.tar.gz # 验证镜像可用 docker run --rm registry.internal/java8:base java -versiondocker run --rm最关键的是--rm,表示验证完立即删除容器,不留垃圾。java -version会输出到标准错误,终端确实能看到openjdk version "1.8.0_xxx"之类的结果,看到这个才代表镜像里的 JDK 真的能跑。有个细节:docker load一个 400MB 的 tar 包可能需要一两分钟,期间没有任何进度条输出,终端看起来像卡住了,别急着 Ctrl+C,耐心等。导入完成后docker images确认两个镜像的 tag 和大小都正常,再继续下一步。如果目标机器的磁盘空间紧张,可以先删掉java8-alpine.tar.gz再导入下一个包,避免同时占用两份空间。
4. Nginx容器化启动脚本:从镜像拉取到一条命令跑起来
4.1 宿主机的Nginx首选方案:用容器跑,不用yum装
标题里把“Nginx安装包”和“docker容器的nginx启动脚本”并列,很多人会纠结到底走哪条路。我的建议:生产环境优先把 Nginx 跑在 Docker 容器里,宿主机的 Nginx 安装包只作为一个后备方案。原因很实际:Nginx 的配置随应用版本迭代,容器化之后,回滚就是docker rm加docker run两条命令,而宿主机安装的 Nginx 配置文件散落在/etc/nginx下,改错了还要手工备份恢复,这是最原始的“后悔药”成本。
离线交付物里 Nginx 镜像的选择,我推荐nginx:1.24-alpine。Alpine 版 Nginx 体积小(约 20MB),且 Nginx 不像 JDK 那样依赖 glibc 生态,Alpine 的 musl libc 不会造成兼容性问题。镜像拉取和导出方式与 OpenJDK 8 完全一致:
# 在有外网的机器上 docker pull nginx:1.24-alpine docker tag nginx:1.24-alpine registry.internal/nginx:base docker save registry.internal/nginx:base -o nginx-base.tar gzip nginx-base.tar宿主机上照常用 yum 装一个nginx包的做法我也保留在交付物里,作为启动脚本的前置依赖检测项。因为启动脚本需要用到curl做健康检查,而curl在最小化安装的系统里可能不存在,这时候一个顺手装上的nginx包会连带把curl依赖装上。不过重要的事情说三遍:Nginx 配置和服务跑在容器里,宿主机的 nginx 包只是备胎,装好后不要systemctl start nginx,免得 80 端口被它抢占,容器启动时端口冲突。
4.2 启动脚本逐行拆解:docker run参数和端口映射逻辑
下面这份start-nginx.sh是我离线交付物里最核心的文件。它的设计目标很简单:幂等、可重复执行、出错可见。脚本目录我固定放在/opt/deploy/nginx/,配置文件挂载目录和日志目录提前创建好。
#!/usr/bin/env bash # Nginx容器启动脚本 - 幂等执行 set -e NGINX_IMAGE="registry.internal/nginx:base" CONTAINER_NAME="app-nginx" CONF_DIR="/opt/deploy/nginx/conf.d" LOG_DIR="/opt/deploy/nginx/logs" PORT_HTTP="80" PORT_HTTPS="443" # 1. 检查镜像是否存在 if ! docker image inspect "$NGINX_IMAGE" >/dev/null 2>&1; then echo "ERROR: nginx镜像不存在,请先执行 docker load 导入" exit 1 fi # 2. 检查端口占用 for port in "$PORT_HTTP" "$PORT_HTTPS"; do if ss -lntp | grep -q ":$port "; then echo "ERROR: 端口 $port 已被占用,先释放再执行脚本" exit 1 fi done # 3. 清理已存在的旧容器(保留配置,删除容器本体) docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true # 4. 创建配置目录并写入default.conf mkdir -p "$CONF_DIR" "$LOG_DIR" cat > "$CONF_DIR/default.conf" <<'EOF' server { listen 80; server_name _; client_max_body_size 100m; location /api/ { proxy_pass http://172.17.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; } location / { root /usr/share/nginx/html; index index.html; } } EOF # 5. 启动Nginx容器 docker run -d \ --name "$CONTAINER_NAME" \ --restart=always \ -p "$PORT_HTTP":80 \ -p "$PORT_HTTPS":443 \ -v "$CONF_DIR":/etc/nginx/conf.d:ro \ -v "$LOG_DIR":/var/log/nginx \ -e TZ=Asia/Shanghai \ "$NGINX_IMAGE" # 6. 健康检查:等待容器启动并检测80端口 sleep 3 if docker exec "$CONTAINER_NAME" nginx -t >/dev/null 2>&1; then echo "Nginx配置合法,容器已启动" else echo "ERROR: Nginx配置有误,查看容器日志: docker logs $CONTAINER_NAME" exit 1 fi # 7. 输出验证结果 curl -sI http://127.0.0.1:80 | head -n 3脚本的第一个关键点是set -e,任何一行命令出错立即退出,不会出现“容器没起来但脚本继续跑完”的假成功。第二步检查端口用的是ss -lntp,比netstat更通用,且带-p能看到占用端口的进程 PID,方便排查是谁抢占了 80/443 端口。第三步docker rm -f加|| true是幂等的核心:第一次执行时没有旧容器,rm会报错但被true吞掉,脚本继续;第二次执行时旧容器被强制删除再重建,配置目录里的default.conf因为挂载在宿主机所以不受影响,这是 Nginx 配置能独立迭代的关键。
docker run -d之后的参数逐个解释:
--name app-nginx:固定容器名,保证关闭和删除命令不需要查 ID。--restart=always:容器崩溃后 Docker 自动重启,宿主机开机时也会自动拉起,这条对无人值守的服务器极重要。-p 80:80和-p 443:443:真实场景下宿主机上多个站点需要不同端口,改成-p 8080:80 -p 8443:443即可,脚本里用变量PORT_HTTP和PORT_HTTPS可以在顶部改。-v $CONF_DIR:/etc/nginx/conf.d:ro:只读挂载宿主机配置目录到容器内,容器内无法篡改配置,本机改文件后执行docker exec app-nginx nginx -s reload即可热加载新配置。-e TZ=Asia/Shanghai:Nginx 容器默认时区是 UTC,加了这条之后访问日志里的时间戳才是北京时间,排查问题时不用再手工加 8 小时。
第 7 步的curl -sI是最终验证,输出HTTP/1.1 200 OK就说明整个链路通了。这里我用-I只取响应头,不下载页面内容,省流量也快。如果返回的是000状态码,基本可以断定容器没起来,直接跳去docker logs看报错。
5. 启动脚本与镜像部署避坑:常见问题排查
5.1 容器启动三秒就退出,docker logs只看到exit code 1
遇到这个现象先别慌,这是 Nginx 容器化部署最常见的翻车现场。docker run后容器状态立刻变成Exited (1),即使配置看起来没什么问题。原因是配置文件里有语法错误,或者容器内 Nginx 进程没有以前台模式运行。官方 Nginx 镜像默认用nginx -g 'daemon off;'保持前台运行,只要配置能通过nginx -t,进程就不会自己退出。
排查时先执行docker logs --tail 20 app-nginx,看到nginx: [emerg] listen() to 0.0.0.0:80 failed (98: Address already in use)基本就是端口被抢占了;看到nginx: [emerg] unexpected "}" in /etc/nginx/conf.d/default.conf则是配置语法错误,需要手动修正default.conf后重新执行启动脚本。这个问题的解决路径很固定:先在宿主机执行nginx -t -c /etc/nginx/nginx.conf验证全部配置,确认无误后再跑启动脚本,从根上避免反复试错。
5.2 配置目录挂载不生效,容器内看到的是默认nginx页面
挂载了conf.d目录,容器内也确实是这个路径,但访问时返回的是 Nginx 默认欢迎页。这个坑的原因往往是配置文件的后缀名不对。Nginx 镜像的主配置/etc/nginx/nginx.conf里有include /etc/nginx/conf.d/*.conf;这一行,它只加载.conf结尾的文件。如果你放进去的是default.txt或者改了一行留下的default.conf~临时文件,Nginx 出于安全考虑不会加载它们。
解决:确认配置文件名严格以.conf结尾,然后执行docker exec app-nginx nginx -t和docker exec app-nginx nginx -s reload。顺便检查宿主机挂载目录的权限,chmod -R 644配置文件,chmod 755目录。权限不足时容器内 Nginx 进程可能读不了配置,现象同样是“配置改了但没生效”,只有docker logs能看到permission denied报错。
5.3 镜像导入成功,docker run时却报invalid reference format
离线导入registry.internal/java8:base后执行docker run registry.internal/java8:base,结果报docker: invalid reference format。问题出在宿主机 Docker 对镜像仓库地址的解析上:如果镜像 tag 没有带版本号,或者直接用了/开头的路径,部分 Docker 版本会解析失败。另一个常见原因是执行docker load后镜像 tag 变成了registry.internal/java8:base之外的名字,比如<none>,这种情况是导出时用了docker save imageID而不是docker save repo:tag。
解决:执行docker images查看实际镜像名和 tag,如果为空先用docker tag <IMAGE_ID> registry.internal/java8:base手动打 tag,再执行启动脚本。这类问题本质是镜像的 repo 标识链断了,docker tag就是给它重新接上。
5.4 容器里的时间比宿主机快8小时,日志里全是UTC时间
eclipse-temurin系列镜像基于 Ubuntu,默认时区是 UTC。Java 应用打印的日志、Nginx 的访问日志都会比北京时间慢 8 个小时,排查问题时对不上时间线,容易误判故障发生顺序。这个问题在 OpenJDK 8 镜像里尤其容易踩,因为 JVM 的默认时区读取的是/etc/localtime,而这个文件在容器里指向 UTC 时区。
解决:启动 Java 容器时加环境变量-e TZ=Asia/Shanghai,或者在启动脚本里挂载宿主机的时区文件:
docker run -d \ --name java-app \ -e TZ=Asia/Shanghai \ -v /etc/localtime:/etc/localtime:ro \ registry.internal/java8:base \ java -jar your-app.jar这两条是一个保险组合:TZ环境变量对大多数 JVM 实现生效,挂载/etc/localtime则直接改掉镜像里的默认时区配置。线上我一般两个都加,避免某些 JVM 版本只认TZ不认/etc/localtime的差异,这类兼容性问题属于典型的“看着是同一个镜像,跑起来却不一致”的黑匣子现象。
5.5 离线镜像包传完了,docker load时报tar包损坏
服务器上docker load -i java8-base.tar.gz执行到一半报unexpected EOF,crc 对不上。离线交付最痛的坑:scp传大文件时网络闪断,文件看似传完,实际校验失败。用scp传输 200MB 以上的文件我遇到过好几次这个问题,血泪经验总结下来就是三步:传到目标机后先对比 md5 再执行导入、用分段压缩减小单包体积、校验通过前不删本地源包。
# 在有外网的机器上计算哈希 md5sum java8-base.tar.gz > java8-base.tar.gz.md5 # 在目标机器上验证 md5sum -c java8-base.tar.gz.md5把 md5 文件一起放到交付物里,导入镜像前先跑md5sum -c,输出OK才说明文件完整。这一步也是目录清单里必须有的内容,别省。集齐镜像、脚本、校验文件之后,整套交付物才算真正满足“到了现场就能装,装了就能跑”的要求。
6. 验证脚本与进阶操作:把启动命令升级成自检工具
交付完最后一步不是留一句“重启一下试试”,而是给现场的人一套验证脚本。我把启动和验证拆开,check.sh专门做健康检查,逻辑简单到新手也能照着输出判断问题:
#!/usr/bin/env bash # 交付自检脚本 - 验证Docker、Nginx容器、Java镜像三部分 set -e echo "== 1. Docker进程状态 ==" systemctl is-active docker echo "== 2. Nginx容器运行状态 ==" docker ps --filter name=app-nginx --format "{{.Status}}" echo "== 3. Nginx配置合法性 ==" docker exec app-nginx nginx -t echo "== 4. HTTP响应码 ==" curl -s -o /dev/null -w "HTTP_STATUS: %{http_code}\n" http://127.0.0.1:80 echo "== 5. Java镜像可用性 ==" docker run --rm registry.internal/java8:base java -version 2>&1 | head -n 1 echo "== 6. 磁盘剩余空间 ==" df -h / | tail -n 1这段脚本里第 4 步用curl -s -o /dev/null -w只输出 HTTP 状态码,比curl -sI更干净,适合放进自动化工具里按状态码判断。第 6 步的磁盘检查是我自己加的,因为 Java 应用跑一段时间后日志可能挤爆磁盘,查一下能提前预防故障。这套脚本的价值在于把“看起来正常”变成“验证过正常”。
进阶操作里值得做的是给 Nginx 容器加资源限制。Java 8 应用和 Nginx 共用 Docker 底座,如果不限制容器内存,某个应用出问题会把整个宿主机的内存吃光,连 Docker 守护进程都连不上。在启动脚本的docker run参数里加两行:
--memory=512m --cpus=1 \ --log-opt max-size=10m --log-opt max-file=3 \--memory=512m限制 Nginx 容器最大使用 512MB 内存,--cpus=1限制它最多占满一个核心。这个限额对静态资源转发和反向代理场景足够,又能防止异常进程失控。日志参数再次限制单容器日志大小,和daemon.json的双层保险防止/var/lib/docker目录被日志撑满。
最后提醒一句习惯性做法:交付后把整套启动脚本的default.conf里server_name改成实际域名,并且做完一次“冷启动测试”——关掉服务器电源重新开机,确认 Nginx 容器和 Java 容器都能自动恢复。这一点比任何文档都管用。整套方案下来的体会是,离线部署的问题从来都不是命令有多难,而是每一个依赖项有没有被转化成文件、被校验、被串联。希望帮到你,少踩一次现场交付的坑。
本文还有配套的精品资源,点击获取