SpringBoot微服务编写Dockerfile流程及问题汇总
背景
跟 Docker 磕了两天,将一个包含 N 个微服务的应用部署包改造,使其能够生成 Docker 镜像,并在 Docker 容器中运行。几年前玩过 Docker,隐约记得几个命令「Dockerfile 命令:黑卡饮料、山楂果费、哦SUV,机器学习」,项目中用不到,早忘光了。
开着 metaso,一路追问了两天,终于搞定了这个应用的 Dockerfile 编写,卡住的点:
- 多层级命令结构下,启动脚本中的
.
的相对路径问题,它相对的是WORKDIR
而不是当前执行脚本路径。 - 如何查看容器中运行程序的 logs 日志?非 Docker 命令输出到控制台的日志,而是 SpringBoot 应用通过 logback 组件写入到文件系统的日志。
- SpringBoot 应用配置文件中的变量,如何通过 Docker 运行命令传递并接收?基于 Nacos Config 的应用,容器运行时需要传递 Nacos 的信息。
- 外部宿主机怎么访问容器中的 Web 应用?
- 包含验证码的应用使用了 sun.awt.X11FontManager 绘图异常,怎么调整 JDK镜像?
- 一个容器中启动 N 个微服务 VS 每个容器启动一个微服务?
- 保证容器运行时不出现 Exited 状态的关键语句
tail -f /dev/null
。
需求描述
我有一个应用目录 myApp,下面有一个 appA,包含 bin/start.sh bin/stop.sh,Dockerfile。appA 需要引用到 myApp 目录的文件,现在需要对这个目录打镜像包含父级目录,而Dockerfile 位于子目录 appA中,怎么实现呢?
向秘搜AI输入上面的提问,它梳理出应用的目录结构如下:
myApp/├── commonJar├── bin/│ ├── startAll.sh│ └── stopAll.sh ├── appA/│ ├── bin/│ │ ├── start.sh│ │ └── stop.sh│ └── Dockerfile│ └── bootstrap.yml │ └── appA.jar ├── appB/│ ├── bin/│ │ ├── start.sh│ │ └── stop.sh│ └── Dockerfile│ └── appB.jar├── appC/│ ├── bin/│ │ ├── start.sh│ │ └── stop.sh│ └── Dockerfile│ └── appC.jar └── Dockerfile
要实现将 myApp 目录及其子目录 appA 打包成 Docker 镜像,并且 appA 中的 Dockerfile 需要引用 myApp 目录下的文件,可以按照以下步骤进行操作:
- 构建上下文:Docker 构建时需要一个构建上下文(context),这个上下文是一个目录,Dockerfile 中的所有 COPY 和 ADD 指令都会相对于这个目录来查找文件。因此,我们需要将 myApp 目录作为构建上下文。
- 指定 Dockerfile 路径:由于 Dockerfile 位于 appA 子目录中,我们需要使用 -f 参数来指定 Dockerfile 的路径。
- 编写 Dockerfile:在 Dockerfile 中,使用相对路径来引用 myApp 目录下的文件。
编写Dockerfile
单独为每个模块编写 Dockerfile,以模块 appA 为例,编写如下内容:
#use jdk
FROM eclipse-temurin:8-jre-alpine#work dir in docker.
RUN mkdir /opt/myApp
WORKDIR /opt#copy all content in myApp/* to /apps.
COPY . myApp#grant start.sh and stop.sh
RUN chmod +x /opt/myApp/appA/bin/start.sh#expose all ports for appA
EXPOSE 8080ENTRYPOINT ["/opt/myApp/appA/bin/start.sh"]
CMD ["in"]
调整 appA/bin/start.sh
启动脚本,之前定位 appA.jar 是通过相对路径,直接在 Linux 运行正常,使用 Docker 容器运行时,由于工作目录设置的是父级 myApp
,相对路径也是相对工作目录的,所以直接启动会出现 appA.jar
文件不存在。
此外,启动时由于服务内部使用日志框架将日志输出到 appA/logs 目录了,所以忽略了控制台日志,要想保证容器不退出,必须让启动脚本处于挂起状态,通过一个启动参数控制。
调整启动脚本如下:
#!/bin/sh
basePath=$(cd `dirname $0`; pwd)
echo "basepath is $basePath"#change dir to appA ,which is .. of start.sh path
cd $basePath/..# start appA.jar use nohup & and ignore console log
loadPath=../commonJar
nohup java -Xmx512m -Dloader.path=$loadPath -jar -Dlogging.config=./logback-spring.xml appA.jar >/dev/null 2>&1 &#hold on if has parameter
if [ -n "$1" ]; thenecho '$1 is not empty, holding on for container'tail -f /dev/null
fi
此外,应用的 Nacos 参数需要容器通过设置环境变量的方式接收,因此修改应用的配置文件 bootstrap.yml
,使用环境变量接收:
spring:cloud:nacos:# nacos 服务器地址server-addr: ${address}# nacos 配置中心config:enabled: trueusername: ${username}password: ${password}# 引用的配置文件所属的命名空间,public时必须注掉,非public可以放开并修改为目标名称namespace: ${namespace}
这就编写好了模块 appA 的 Dockerfile 文件了,进入根目录 myApp 下依次创建镜像、运行容器、停止容器、删除容器、删除镜像。启动 DockerDesktop,
第一步,进入应用根目录 cd /xxx/myApp
。
第二步,运行构建命令:docker build -t appa -f appA/Dockerfile .
。构建完成后,执行 docker images
查看镜像:
第三步,执行容器启动:docker run -d -e address=IP:port -e username=xxx -e password=xxx -e namespace=nonPublic -p 8080:8080 -v /Applications/dockerlogs:/opt/myApp/appA/logs --name appa appa
等待容器启动后,使用 docker ps -a
查看容器状态,正常是 Up
:
第四步,停止容器:docker stop d8ec9fbf8f49
。
第五步,删除容器:docker rm d8ec9fbf8f49
,只能针对 Exited
状态的容器进行删除。
第六步,删除镜像:docker rmi 616b785f371e
,只能针对没有容器运行的镜像进行删除。
Docker 操作汇总
针对每个应用提供一个 docker 操作脚本,方便操作,汇总 docker 脚本如下
- 根目录下创建镜像:
docker build -t myapp .
,针对当前文件目录下的 Dockerfile进行编译,且镜像名称必须小写。 - 在父级别目录中对子模块构建:
docker build -t appa -f appA/Dockerfile .
- 后台进程方式运行容器:
docker run -d -e address=IP:port -e username=xxx -e password=xxx -e namespace=nonPublic -p 8080:8080 -v /Applications/dockerlogs:/opt/myApp/appA/logs --name appa appa
,容器环境变量、挂载日志文件,开放宿主机端口和容器端口一致。 - 查看容器:
docker ps -a
- 删除容器:
docker rm containId
- 删除镜像:
docker rmi imageId
- 查看镜像:
docker images
- 查看日志:
docker logs appa
【容器名称】仅shell 执行时的控制台日志。 - 针对运行状态的容器,可以打印容器中日志目录下的文件:
docker exec -it containId /opt/myApp/appA/logs/a.log
9存储镜像:docker save -o appA.tar appA:latest
启示录
第一点,纠结一个问题 「一个容器中启动 N 个微服务 VS 每个容器启动一个微服务?」根据 Docker容器的设计思想:
- 一次构建,到处运行"的可移植性
- 轻量高效的资源利用
- 微服务友好的架构哲学,单一职责原则:每个Container仅运行一个主进程(如Nginx/MySQL),通过组合多个Container完成复杂应用,天然契合微服务拆分。
- 开发即生产的生命周期管理
- 开放生态的扩展性
比较好的实践方式是一个微服务一个镜像,独立容器启动。那么之前的部署方式一台服务器上三个服务运行的方式就不可行了。但是如果硬是这么实践,也是没问题的吧,毕竟通过瘦身包方式部署的,微服务共用了通用 jar 包。
一个容器里面运行 N 个微服务的 Dockerfile 文件就编写在根目录 myApp 中,然后逐次调用子模块的 start.sh 脚本进行启动,只用对根目录的应用打一个镜像就可以了。小公司的单体多模块的应用,就没必要打 N 个镜像了。
但是需要注意在一个容器中同时启动的微服务的个数,如果过多的话,可能 Docker 容器运行有资源约束,可能有些服务启动会失败。
第二点,JDK 选择上面,本来所有模块统一用轻量级 eclipse-temurin:8-jre-alpine
可以的,单有一个包含验证码的模块,这个精简镜像存在字体缺失问题,所以对该模块使用 openjdk:17-jdk
镜像。
最后一点,docker run
命令的参数 -v
必须在 --name
之前。用了 Docker 容器管理后,程序就不需要提供 stop 脚本了,直接通过容器的 stop 命令就可以停止应用。