本文试图解决在 k8s 环境下 java 内存溢出时候 dump 文件的存储问题。
问题
在容器中运行 java 应用,通过类似如下命令行启动程序:
代码语言:txt复制java -Xms1536m -Xmx1536m
-XX: HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumper
-jar /app/oom-sims-1.0-SNAPSHOT.jar 2000
应用运行过程中,如果内存超过 1536M,会触发 java 的内存溢出,这个时候 java 会把内存 dump 成为文件 /dumper/java_pid1.hprof。过程完成之后,java 进程退出,容器会被 k8s 重启。
在这个过程中,会有如下几个“棘手”的问题:
- 在 yaml 配置中 dump 的文件名无法修改,当 再次 dump 的时候,会发现文件已经存在,dump 会直接报错。
- dump 文件存储问题,这个文件不能存在容器中,因为重启之后会丢,只能想办法存到主机上,但集群服务器多了,想拿到这个文件也不太容易。
- 使用分布式的网络存储,通过 PV 绑定到集群可以解决文件寻找的问题,但文件很大,网络存储较慢,有时候没有存完,容器被 liveness 等探针重启。通过网络存储亦有文件名重复问题。
方案
下述方案使用腾讯云产品实现。
1、 将cos 作为存储介质,直接绑定到集群。当发现 java_pid1.hprof 生成后,使用 scf 触发器修改文件名即可。
2、 写一个脚本,监视 java_pid1.hprof 文件,并进行操作。此脚本部署在同 pod,作为应用的 sidecar 运行。
下面重点讨论第二种方案。
实现步骤:
- 绑定 node 的临时文件夹到容器的 /dumper 目录
- 监视 /dumper 文件夹,发现 java_pid1.hprof 保存完成后进行 改名,压缩,上传 cos,删除操作。
这个脚本的完整实现如下:
代码语言:txt复制package main
import (
"bufio"
"compress/gzip"
"context"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
"github.com/tencentyun/cos-go-sdk-v5"
)
func main() {
COS_SECRETID := os.Getenv("COS_SECRETID")
COS_SECRETKEY := os.Getenv("COS_SECRETKEY")
COS_BUCKETURL := os.Getenv("COS_BUCKETURL")
COS_DUMPER_ROOT := os.Getenv("COS_DUMPER_ROOT")
APP_NAME := os.Getenv("APP_NAME")
DUMPER_ROOT := os.Getenv("DUMPER_ROOT")
dumpFileName := DUMPER_ROOT "java_pid1.hprof"
u, _ := url.Parse(COS_BUCKETURL)
b := &cos.BaseURL{BucketURL: u}
c := cos.NewClient(b, &http.Client{
//设置超时时间
Timeout: 3600 * time.Second,
Transport: &cos.AuthorizationTransport{
SecretID: COS_SECRETID,
SecretKey: COS_SECRETKEY,
},
})
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
done := make(chan bool)
log.Println("COS 地址:", COS_BUCKETURL)
log.Println("COS 存储目录:", COS_DUMPER_ROOT)
log.Println("进程开启,开始监控:", DUMPER_ROOT)
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create {
if event.Name == dumpFileName {
log.Println("检测到 dump 文件:", event.Name)
current_time := time.Now().Local()
newFileName := dumpFileName "-" current_time.Format("2006-01-02-15-04-05") "." APP_NAME
zipFileName := newFileName ".gz"
//死循环,根据文件大小判断是否 dump 完成
var postSize int64 = 0
for {
time.Sleep(2 * time.Second)
fileinfo, _ := os.Stat(event.Name)
currSize := fileinfo.Size()
log.Println("当前文件大小:", currSize)
if currSize == postSize {
break
}
postSize = currSize
}
log.Println("最终文件大小:", postSize)
// 改名
os.Rename(dumpFileName, newFileName)
log.Println("文件 dump 完成,改名,开始压缩:", newFileName)
//压缩
compress(newFileName, zipFileName)
log.Println("文件压缩完成,准备上传:", zipFileName)
//上传
upload(zipFileName, COS_DUMPER_ROOT, c)
//删除
os.Remove(newFileName)
log.Println("文件删除:", newFileName)
os.Remove(zipFileName)
log.Println("文件删除:", zipFileName)
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
err = watcher.Add(DUMPER_ROOT)
if err != nil {
log.Fatal(err)
}
<-done
}
func upload(fPath string, cosRoot string, client *cos.Client) {
key := cosRoot filepath.Base(fPath)
_, err := client.Object.PutFromFile(context.Background(), key, fPath, nil)
if err != nil {
panic(err)
}
log.Println("上传完成:", key)
}
func compress(fPath string, fPathZip string) {
f, _ := os.Open(fPath)
reader := bufio.NewReader(f)
content, _ := ioutil.ReadAll(reader)
f, _ = os.Create(fPathZip)
w := gzip.NewWriter(f)
w.Write(content)
w.Close()
}
将此文件打包镜像,并进行部署,部署 yaml 如下:
代码语言:txt复制apiVersion: apps/v1
kind: Deployment
metadata:
name: oom-sims
namespace: oom-test
labels:
app: oom-sims
spec:
replicas: 1
selector:
matchLabels:
app: oom-sims
template:
metadata:
labels:
app: oom-sims
spec:
volumes:
- name: heap-dumps
emptyDir: {}
containers:
- name: oom-sims-container
image: cloudbeer/oom-sims:1.0
command:
- java
- -Xms512m
- -Xmx1536m
- -XX: HeapDumpOnOutOfMemoryError
- -XX:HeapDumpPath=/dumper
- -jar
- /app/oom-sims-1.0-SNAPSHOT.jar
args: ["2000"]
resources:
requests:
memory: "3Gi"
cpu: "500m"
limits:
memory: "3Gi"
cpu: "500m"
volumeMounts:
- name: heap-dumps
mountPath: /dumper
- name: dumper
image: cloudbeer/dumper:1.5
env:
- name: COS_SECRETID
value: "YOUR_COS_SECRETID" # 这个是你的 API 密钥
- name: COS_SECRETKEY
value: "YOUR_COS_SECRETKEY" # 这个是你的 API 密钥
- name: COS_BUCKETURL
value: "YOUR_COS_BUCKETURL" # 这个是 cos bucket 地址
- name: COS_DUMPER_ROOT
value: "dumper/" # 这个是 cos 的 key 前缀
- name: APP_NAME
value: "oom-tester" # 应用名称,这个名称会附加在文件名后面
- name: DUMPER_ROOT
value: "/dumper/" # 会监视这个文件夹下面的 java_pid1.hprof
volumeMounts:
- name: heap-dumps
mountPath: /dumper
上面的脚本说明:
- 在 pod 内部署了 2 个容器,一个是应用,一个是 dump 文件处理脚本。
- 应用模拟了一个 OOM 的场景,调整 args 参数,会得到不同的 JVM 内存占用。
- dump 脚本可以通过环境变量将腾讯云的 cos 参数指定进去。
- cloudbeer/oom-sims:1.0 和 cloudbeer/dumper:1.5 这俩镜像均可直接测试使用。
- dump 1.5G 的时间大约需要 3s 钟,如果你配置了 liveness 探针,请给 dump 留下足够的时间。
- 使用此脚本的优点:本地存储速度会比较快,改名,压缩,上传过程也是异步进行,比较清爽。缺点:每个应用都要多配一个伴生容器。
其他
在配置容器的资源的时候,需要保证容器的 limits 内存配置要大于 jvm 的内存,如果配小了,会引发容器的 OOM,从而直接被杀死。
另外 java 的老版本会不支持容器的内存统计,实际占用内存会超过 Xmx 的设置,引发 容器的 OOM。需要保证 java 版本不低于如下版本:Java SE 8u131 和 openjdk 8u181
本文的源代码地址:https://github.com/cloudbeer/oom-sims
参考:
- How to do a Java/JVM heap dump in Kubernetes
- Java SE support for Docker CPU and memory limits
- Sizing Kubernetes pods for JVM apps without fearing the OOM Killer