Java容器化实践:从Dockerfile构建到Kubernetes部署
第一部分:Dockerfile编写与基础JDK容器构建
1.1 基础JDK容器的构建
FROM alpine:3.19 RUN apk add --update --no-cache openjdk8 #1 ENV JAVA_HOME=/usr/lib/jvm/java-1.8-openjdk ENV PATH="$JAVA_HOME/bin:${PATH}" RUN addgroup -S nonroot \ #2 && adduser -S nonroot -G nonroot
1由于我们的业务应用还在使用JDK8,到dockerhub官方维护的镜像中看,大多的JDK8容器镜像已经很久没有维护了,所以这里选择使用容器环境最流行的alpine作为操作系统基础包,再通过包管理器添加较新的jdk环境的方式作为基础镜像,在较新的版本中JVM的cgroups视角问题已经修复,JVM已经可以获取到正确的可支配内存了,详见下面的dashboard部分,所以建议是使用自己维护的JDK8容器镜像
2添加非root用户,后续在业务DockerFile的entrypoint/cmd前再切为这个用户,这是容器安全的最佳实践,值得注意的是,如果要在容器中操作文件,或其他需要root才能做到操作,可能会提示权限问题,需要自己处理好权限问题。在我们原本的jar中,本来会默认在当前目录写logback日志,由于权限nonroot不能读写,就只能在标准流输出了,避免了过多读写unionfs的问题。
后续使用
docker build -t xxxx/yyyy/jdk:jdk1.8.392.08-r1-alpine3.19 . (不要漏了.)
docker push xxxx/yyyy/jdk:jdk1.8.392.08-r1-alpine3.19
的方式使其可以作为一个类似官方维护的jdk基础镜像然后放到自己的容器私服中
1.2 业务JVM容器的构建
参考的DockerFile,使用上一个阶段的基础镜像作为基础运行环境
FROM xxxx/yyyy/jdk:jdk1.8.392.08-r1-alpine3.19 WORKDIR /app COPY xxx/<domain-jar-name>c.jar yyy/application.yml . RUN wget -P /app/ <jmx-exporter-config-fileserver-url>/config.yml;wget -P /app/ <jmx-exporter-javaagent-jar-fileserver-url>/jmx_prometheus_javaagent.jar;wget -P /app/ <otel-javaagent-jar-fileserver-url>/opentelemetry.jar EXPOSE 7006 8080 #K8S环境会进行实际端口映射实际上可有可无 USER nonroot ENTRYPOINT ["java", "-javaagent:/app/jmx_prometheus_javaagent.jar=8080:/app/config.yml","-javaagent:/app/opentelemetry-javaagent.jar","-Dotel.service.name=<OTEL-trace-name>","-Dotel.exporter.otlp.endpoint=<otel-colletor-endpoint>","-Xms256m", "-Xmx256m","-jar", "<domain-jar-name>.jar"]
1.2.1构建说明
由于我们公司业务需要运维多个2B环境,存在一些部署环境上的差异,所以我选择在CI阶段的jenkins服务器上构建jar包归档和兼容产物,而非容器化构建推荐的两阶段构建,仅在Dockerfile中加入构建好的jar包和基础配置文件(不要这个也可以,因为后续会在K8S中重新挂载configmap,主要是环境测试方便)
1.2.2java agent说明
由于可观测性的需要,从本地服务器上获取到官方提供的JMX exporter的jar包和默认config文件(JMX exporter维护团队推荐的行为,作为javaagent一起运行),OTEL agent的jar包,读者可以自行到对应的官方连接下载或者去除掉对应的配置参数),读者可以自行修改为官方提供的url或者自己的文件服务器,或者改为后续在k8s环境的pod配置里挂载一个固定的PV
1.2.3 entrypoint说明
包含了两个 java-agent的参数指明,JVM堆参数设置,如果只在特定地区部署,也可以在构建阶段就使用插入时区配置TZ,如需定制JMX exporter的参数,则修改文件服务器上的的config或者后续configmap挂载覆盖。由于OTEL agent是push模式的,这里也是直接定义了OTEL采集器的端点,如果需要更加灵活可以删掉这部分,后续通过k8s的manifest添加env或者args
第二部分:K8S manifest编写与基础JDK容器构建
在实际使用时,建议把manifest放到git上管理,和源代码一样,在使用一些gitops如(argocd)方案时,还会要求根据命名空间和git路径对应,建议根据该实践来做,具体的内容这里先省略
2.1 deployment
apiVersion: apps/v1 kind: Deployment metadata: labels: app: domain-name name: domain-name spec: replicas: 1 #预期副本数,无状态可多设置,最后做好容量规划,也可以进一步配置HPA, selector: matchLabels: app: domain-name strategy: type: RollingUpdate #滚动更新策略,多副本时注意 rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: domain-name #这里的label,promtail可以采集,使用podmonitor需要特殊配置 spec: containers: - name: domain-name image: <仓库信息>/domain-name:<tags> #私服(要登陆的)要加拉取密钥,见最末,dockerhub等官方库可以不要 ports: - containerPort: 8080 - containerPort: 7006 volumeMounts: - name: domain-name-config #挂载配置文件,java默认不会监测最新的配置文件,修改后要重启,要和后面的卷对应上 mountPath: /app/application.yaml subPath: application.yaml resources: #资源限制,可以去了解一下qos机制和cgroups机制 requests: cpu: 0.5 memory: 512Mi limits: cpu: 1 memory: 1024Mi readinessProbe: httpGet: path: /actuator #利用spring boot actuator探活,如果有特殊需要可以自定义端点,就绪pod才会接收流量 port: 7006 periodSeconds: 60 timeoutSeconds: 20 # successThreshold: 1 # failureThreshold: 3 livenessProbe: httpGet: path: /actuator #利用spring boot actuator探活,如果有特殊需要可以自定义端点,失败达到阈值重启 port: 7006 periodSeconds: 60 timeoutSeconds: 20 lifecycle: preStop: #生命周期回调,如果要做一些清理或者启动前准备 exec: command: ["sh","-c","sleep 5;echo domain-name crash..."] postStart: exec: command: ["sh","-c","echo domain-name starting..."] volumes: - name: domain-name-config configMap: name: domain-name-configmap restartPolicy: Always #重启策略deployment默认也是always imagePullSecrets: - name: regcred #私服拉取密钥
2.2 configmap
configmap就是把java的application.yaml配置文件配置为一个挂载的文件,如果进行了修改,容器内的文件是会发生该改变的的,但jvm应用往往没有监测该文件的动态更新。需要redeploy控制器或者删除pod重新读取配置文件
2.3 service
apiVersion: v1 kind: Service metadata: labels: service: domain-name metrics: jmx #service monitor的服务发现标签 name: domain-name spec: #type: ClusterIP 默认值可以不写 ports: - port: 7006 targetPort: 7006 name: domain-name - port: 8080 targetPort: 8080 name: jmx #service monitor要求具名端口 selector: app: domain-name #注意和pod那里的对上 status: loadBalancer: {}
2.3.1服务类型选择
java服务主要还是把服务暴露为默认的clusterIP模式,主要是为了servicemonitor/手动配置的Prometheus有一个可以访问的端点,所以服务考虑一个明确给JMX service monitor的特殊label,如metrics:JMX。如果使用K8S/istio的服务发现,则应该定义好服务。
如果有后续直接访问监听端口,后续再通过LB或者ingress访问
如果真的有只能4层或者测试环境才考虑暴露为NodePort,生产环境还要考虑加edge LB给各个Node。
对于集群中的java注册中心如nacos,eureka等,即不使用K8S/istio的服务发现机制时,容器pod往往也会注册自己的被CNI分配到的endpoint IP,在集群中是默认可以通信的,所以不需要暴露的话不是为了需要metrics的话服务都不需要创建,spring应用会通过java自己的注册中心去路由。此时可能podmonitor反而更合适。
如果是云环境或者存在如metallb等的方案,还可以把服务设置为loadbalance类型让lb controller分配外部ip直接访问
2.4.ingress
如上面service中提到的,最常规的ingress流量导入方法,要求是集群内已部署nginx ingress controller
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: net namespace: default spec: ingressClassName: nginx rules: - host: test.test http: paths: - backend: service: name: domain-name port: number: 7006 path: / pathType: Prefix
然后如果是测试环境,本地host配置test.test,到ingress controller入口访问即可。如果要配置TLS中止和DNS服务器,建议去查看相应文档。
第三部分:可观测性使用
在云原生方案之中,和springcloud相比比较重要的一点就是无侵入,可以在研发不需要改动任何代码的情况下就可以使用到可观测性。
3.1 日志
3.1.1 promtail
由于JVM在前台运行会默认输出到容器标准流,我们采用日志代理agent默认收集即可。promtail的默认配置已经给我们做好了一定的根据K8S资源的relabel工作,如需一些进一步的定制化的label,如一些标识业务归类的label,可以考虑给pod打上新的标签,如上面的manifest注释。
一般情况下promtail的配置已经够用了,选好对于的标签,即可找到对应的pod的日志
简单使用说明
3.2 指标
我们在DockerFile打包时已经集成了JMX exporter,我们只需要对对应的端点进行指定的采集即可,如果是使用手动配置Prometheus。需要指定为对应的端点,即ip/k8s服务发现名:8080(我们在容器中指定的agent端口),笔者没有采用,可以自行查阅官方文档手动配置。
如果使用Prometheus operator方案,如上所述,我们只需要创建对应的serviceMonitor即可,需要注意的是serviceMonitor需要具名端口jmx,serviceMOnitor和Prometheus需要在特定的命名空间有合适的RBAC权限才能正常使用。
apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: jmx-service-monitor namespace: monitoring labels: app: jmx-monitor spec: endpoints: - port: jmx interval: 30s namespaceSelector: matchNames: - default selector: matchLabels: metrics: jmx
在dafault空间中的Java服务,需要有该标签即可metrics: jmx被servicemonitor获取到,当然也需要配置有jmx的具名端口,参见上面的K8S service manifest。
grafana的JMX exporter dashboard
下图是一个jdk8小版本为212的镜像其total RAM视角没有和limit一致。
读者也可以自己去看看该dashboard有没有自己需要去监控的指标和想要配置的告警项
可以看到,在低版本212时,JVM的cgroups视角的CPU是对的,而内存是node整体的视角,在较新的版本392之中,内存的视角已经修正为cgroups视角(total memory和swap和limit一致),这意味着我们并不需要做特殊的操作,JVM已经可以意识到容器环境了。
3.3 分布式追踪
我们在DockerFile打包时的entrypoint指定了相应的OTEL collector端点位置,这是优先级最高的,可以考虑去除掉后,在环境变量中设置会更加灵活(优先级比java启动参数低)。
单服务性能散点图
可以找到一些异常事件范围的调用,和筛选一些失败的4xx 5xx http码的链路(如提示的标签error=true),以及在压测等高性能场景要求场景时检查是否有因为压力造成的异常处理时间的调用
可以看到热点图(圆越大,越多trans)
可以看到生成的调用架构图
以及根据trans id span id等实现单条链路追踪等的trace功能