如何做一个最小的docker image?

当我们想做一个docker image 的时候, 经常想把它做的足够小, 不仅能够节省磁盘空间, 还能减少不必要的依赖, 加快启动, 减少可能出现的漏洞. 那么有哪些最精简的base image 呢?


  1. 如果你的app的所有依赖都是静态编译的, 那么你可以使用 scratch, 顾名思义, 它是一张白纸, 我们只能使用kernel 提供的服务. 它没有shell, 没有libc, 没有各种实用命令, 用户/组, 没有包管理器, 啥都没有.
  2. 如果你 busybox 提供的实用工具已经能满足, 那么你可以使用 busybox. busybox 是嵌入式linux上的瑞士军刀, 它把其它Linux 上常见的一些实用工具在一个很小的可执行文件内全部实现, 虽然选项更少, 但是基本能完成大部分常见功能.
  3. 如果你在 busybox的基础上还需要 libc库和包管理工具(package repo), 那么可以使用 alpine. alpine 在busybox的基础上, 增加了 musl libc 和 包管理器.
  4. 或者你可以尝试一下 https://github.com/GoogleContainerTools/distroless.

Java Metaspace 造成的full GC

老司机 Kyle 又一次问我有没有兴趣查一个问题, 直接扔过来一个app的监控 dashboard, 又是 GC overhead 100%. 不过这次有点曲折.


监控数据如下:
dashboard.png

解读:

  1. GC overhead 100%, 也就是Java 进程占用的CPU 全部用来做GC 了;
  2. JVM CPU usage 从20% 升到了35%, 也就是Java 进程使用的CPU 量增加了很多;
  3. OOM 的数量从0变成了每秒几个;
  4. 有意思的是 Java 的可用heap 从2G 突然变成了5.1G.

背景相关:
这个app 设置的Java heap 是5.5G.

从上面信息大致可以猜测, 可能遇到如下问题:

  1. 大量调用 System.gc() 或 Runtime.gc();
  2. Metaspace (或Java8之前永久代) 用尽;
  3. 或者 Native 内存被用尽;

于是使用 jstat 命令查看 gccause:

$ jdk/bin/jstat -gccause 1075  2000 5
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT    LGCC                 GCC
  0.00 100.00   0.00   4.14  69.08  32.72  48945 2441.067  1848  806.736 3247.803 Metadata GC Threshold Metadata GC Threshold
  0.00   0.00   0.00   4.55  69.08  32.72  48947 2441.076  1853  808.914 3249.990 Metadata GC Threshold Last ditch collection
  0.00 100.00   0.00   5.07  69.08  32.72  48950 2441.092  1858  811.101 3252.193 Metadata GC Threshold Metadata GC Threshold
  0.00 100.00   0.00   4.82  69.08  32.72  48952 2441.102  1862  812.887 3253.989 Metadata GC Threshold Metadata GC Threshold
  0.00   0.00   0.00   3.93  69.08  32.72  48954 2441.111  1867  815.072 3256.183 Metadata GC Threshold Last ditch collection

上面各列名的解释:
S0: Survive 0; S1: Survive 1; E: Eden; O: Old; M: Metaspace; CCS: Compressed class; YGC: Young GC Count; YGCT: Young GC Time; FGC: Full GC Count; FGCT: Full GC Time; GCT: GC Time; LGCC: Last GC Cause; GCC: GC Cause this time.

从上面统计数据可以看到: Full GC(FGC) 的数量在持续的10s 内持续增加, 造成的原因就是: Metadata GC Threshold 和 Last ditch collection.

在解释上面2个原因之前, 我们可以看看 Metaspace 的使用策略. Metaspace 是用来替换之前的永久代的, 并且从Native 内存申请, 用来存放类的元数据. 从操作系统申请的native 内存被分成 chunks, 每个chunk 被分配给每个 ClassLoader, 这个chunk里面的class 如果全部被回收的时候, 这个 chunk 也会被回收. 另外这些Native 内存使用mmap分配, 不是使用malloc.

从网上找来一张好图:
concepts.png

上面图上有几个概念:

  1. reserved: Java 应用虚拟内存里面保留的大小, 这个这是虚拟内存申请量, 和实际使用量没关系;
  2. committed: 从操作系统(OS)里面申请的 native 内存的大小, 被分为若干个不同大小的 chunks;
  3. capacity: 被分配给 ClassLoader 的所有 chunk 内存之和, 不包含没被分配的 free chunk;
  4. chunk: 分成2种: 被分配给classLoader的和未被分配的;
  5. used: 所有被分配的 chunk 里面的使用量之和. 一个被分配的chunk 也是慢慢被使用完的.
    上面图里面没有涉及到的概念:
  6. Metaspace occupied threshold(high watermark), 它一般是committed 的一个百分比, 由 -XX:MaxMetaspaceFreeRatio 和 -XX:MinMetaspaceFreeRatio 这个2个参数决定.

当 ClassLoader 要分配一个新的class空间的时候, 它检查自己拥有的chunk, 如果当前拥有的chunk内部空间不足的时候, 就会去free chunk 里申请一个新的chunk. JVM 内部维护一个metaspace 已经被占用的 threshold (high watermark), 当已经占用的量(capacity) 触及这个 threshold 的时候, 就会触发一个 Full GC (Metadata GC Threshold) GC. 当这个Full GC 还不能回收到足够的空间时, 就会看到 Full GC (Last ditch collection).

verbose GC log 里面的日志:

2022-10-01T18:11:27.974-0700: 407174.877: [Full GC (Metadata GC Threshold) 2022-10-01T18:11:27.974-0700: 407174.877: [GC concurrent-root-region-scan-end, 0.0001638 secs]
2022-10-01T18:11:27.974-0700: 407174.877: [GC concurrent-mark-start]
 189M->189M(5120M), 0.4585377 secs]
   [Eden: 0.0B(390.0M)->0.0B(390.0M) Survivors: 2048.0K->0.0B Heap: 189.4M(5120.0M)->189.4M(5120.0M)], [Metaspace: 362167K->362167K(1525760K)]
Heap after GC invocations=50066 (full 1356):
 garbage-first heap   total 5242880K, used 193982K [0x00000006a0800000, 0x00000006a0a05000, 0x00000007e0800000)
  region size 2048K, 0 young (0K), 0 survivors (0K)
 Metaspace       used 362167K, capacity 367544K, committed 524288K, reserved 1525760K
  class space    used 15775K, capacity 16721K, committed 48228K, reserved 1048576K
}
 [Times: user=0.65 sys=0.01, real=0.46 secs]
{Heap before GC invocations=50066 (full 1356):
 garbage-first heap   total 5242880K, used 193982K [0x00000006a0800000, 0x00000006a0a05000, 0x00000007e0800000)
  region size 2048K, 0 young (0K), 0 survivors (0K)
 Metaspace       used 362167K, capacity 367544K, committed 524288K, reserved 1525760K
  class space    used 15775K, capacity 16721K, committed 48228K, reserved 1048576K
2022-10-01T18:11:28.433-0700: 407175.336: [Full GC (Last ditch collection)  189M->189M(5120M), 0.4538734 secs]

-- 未完待续

mongo express MongoError: command listCollections requires authentication

为了连接一个MongoDB server 省事, 不想装本地app, 于是想使用docker 装一个 Web 版本的 Mongo express. 在启动的时候, 总是报这个错: MongoError: command listCollections requires authentication

我的连接URL是: mongodb://user1:[email protected]:27017/test_db. 可是根据官方的说明, 不论怎么写 docker command 都不行.

$docker run --rm -e ME_CONFIG_MONGODB_SERVER=mymongo.tianxioahui.com \
                 -e ME_CONFIG_BASICAUTH_USERNAME=user1 \
                 -e ME_CONFIG_BASICAUTH_PASSWORD=pwd1 \
                 -e ME_CONFIG_MONGODB_ENABLE_ADMIN=false \
                 -e ME_CONFIG_MONGODB_AUTH_DATABASE=test_db
                 -p 8083:8081 --name myMongo  mongo-express

(node:6) UnhandledPromiseRejectionWarning: MongoError: command listCollections requires authentication
    at Connection.<anonymous> (/node_modules/mongodb/lib/core/connection/pool.js:453:61)
    at Connection.emit (events.js:314:20)
    at processMessage (/node_modules/mongodb/lib/core/connection/connection.js:456:10)
    at Socket.<anonymous> (/node_modules/mongodb/lib/core/connection/connection.js:625:15)
    at Socket.emit (events.js:314:20)
    at addChunk (_stream_readable.js:297:12)
    at readableAddChunk (_stream_readable.js:272:9)
    at Socket.Readable.push (_stream_readable.js:213:10)
    at TCP.onStreamRead (internal/stream_base_commons.js:188:23)

可是不论怎么调可用的参数, 总是报这个错. Google 了一下, 发现2021年6月就有人报这个错: https://github.com/mongo-express/mongo-express/issues/720

解决方式也很简单, 直接用一个连接URL 替换其他环境变量:

sudo docker run --rm -e ME_CONFIG_MONGODB_URL=mongodb://user1:[email protected]:27017/test_db  -p 8083:8081 --name myMongo mongo-express

可是, 可是, 这个环境变量ME_CONFIG_MONGODB_URL在 https://hub.docker.com/_/mongo-express 竟然没有, 可是能用, 还很管用.

解密 Java 的 https 流量

最近有个同事发现生产环境中有些服务比较慢, 检查一番后, 发现一个服务调用在tcp 3次握手和 ssl client hello 包之间竟然有200ms 左右的延迟. 接下来, 该同事就想弄清楚: 为什么在3次握手和 client hello之间有200ms 延迟. 他知道有些语言是直接调用的 OpenSSL 的库, 于是问我 Java 里面是不是也是使用 OpenSSL的封装?

我之前写过2篇 Windows 上解密 chrome 的 https 流量 & MAC 上解密 chrome 的 https 流量, 当时就想: 是不是Java 也可以自动把 ssl key 劫持下来, 然后后边自动揭秘. 当时就找到了这个已经持续好多年的项目: jsslkeylog, 这个项目就是通过 Java Agent的方式, 注入相关的代码, 把ssl key 劫持下来.

所以, 当同事问我是不是 OpenSSL 封装的时候, 我立马想到这个项目. 在这个项目里面, 它通过注入的方式把 ssl key 记录下来, 那么肯定是Java 自己原生写的 SSL 的代码处理.

一些核心的代码类: https://github.com/jsslkeylog/jsslkeylog/tree/master/src/main/java/net/sf/jsslkeylog

关于 NSS(Network Security Services)

NSS (Network Security Services) 是由 Mozilla 项目开发的一个模块化、可复用的网络安全服务库,它提供了一系列的安全协议、密钥管理、证书管理、加密算法等安全功能的实现。NSS 被广泛地应用于多种网络应用程序中,包括 Web 浏览器、电子邮件客户端、虚拟专用网络 (VPN) 和移动设备等。

NSS 的功能涵盖了多种协议和技术,包括 SSL、TLS、PKCS#11、S/MIME、X.509 证书、公钥基础设施 (PKI)、安全套接字层 (SSL) 和传输层安全 (TLS) 等。NSS 在这些领域是具有广泛应用的库之一,是开源社区中信任和可靠的安全库。

NSS 还为应用程序提供了一些基本的加密功能,包括随机数生成器、哈希函数、密钥交换、数字签名、加密和解密、证书签名和认证等。这些功能被设计成易于使用和可扩展的,帮助应用程序开发者更快速、更容易地构建安全的网络应用程序。

需要注意的是,NSS 是一个独立的项目,并不仅限于 Mozilla 项目中。它在其他开源项目中也被广泛使用,例如 Red Hat、Fedora、OpenSSL 和 OpenSSH 等。
项目地址: https://github.com/nss-dev/nss
wiki: https://en.wikipedia.org/wiki/Network_Security_Services

关于 NSS 的 key log 格式

官方文档已经找不到了, 这是一个缓存: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
你可以认为这是 NSS 暴露出的一个接口, 这样使用 Wireshark 就能够解密加密传输.
NSS_Key_Log_Format.png

key log 的最新格式

这是 Mozilla 提交给 IEEE 的最新格式, 可以认为之前都不是经过认证, 只是 NSS 和 wireshark 之间商量的一个格式.
https://datatracker.ietf.org/doc/draft-thomson-tls-keylogfile/

wireshark 支持的格式

https://github.com/boundary/wireshark/blob/d029f48e4fd74b09848fc309630e5dfdc5d602f2/epan/dissectors/packet-ssl-utils.c#L4164-L4182
https://github.com/boundary/wireshark/blob/07eade8124fd1d5386161591b52e177ee6ea849f/epan/dissectors/packet-ssl-utils.c#L4204-L4207

两个揭秘 Java TLS 的工具

https://github.com/neykov/extract-tls-secrets/
https://github.com/jsslkeylog/jsslkeylog/tree/master/src/main/java/net/sf/jsslkeylog

使用 -Djavax.net.debug=all 生成的日志直接提取key的工具(TLS 1.3不适用):
https://dimosr.github.io/decrypting-tls-traffic-with-wireshark/
https://gist.github.com/tsaarni/14cc3341d0996e25671f5ca894842ec9

JDK 关于 javax.net.debug 的说明

https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/ReadDebug.html

JDK 使用到的关于TLS 版本的启动参数

  1. -Djdk.tls.client.protocols=TLSv1.2,TLSv1.3
    jdk.tls.client.protocols 是一个 JDK (Java Development Kit) 的系统属性,这个属性可以用来指定与客户端建立安全连接时所支持的 TLS 版本的范围。具体来讲,这个属性可以用来限制客户端连接到服务端时可用的 TLS 协议版本。jdk.tls.client.protocols 属性的值是一个以逗号分隔的 TLS 协议版本列表.
  2. -Dhttps.protocols=TLSv1.2,TLSv1.3
    https.protocols 是一个 Java 系统属性, 可以用来指定在执行 HTTPS 请求时支持的协议版本。特别是当我们使用 Java 编写的程序向使用 HTTPS 存储在远程服务器上的资源发出请求时,这个属性就非常有用了。参数的值表示 Java 程序只会使用 TLS 1.2 和 TLS 1.3 协议版本来发起 HTTPS 请求。如果远程服务器不支持这些协议,则连接将失败。
  3. -Djavax.net.debug=ssl,keygen,handshake

exec user process caused: exec format error

今天开发的app 做了一个新的docker image, 发布到K8S之后, 就报下面的错:

standard_init_linux.go:228: exec user process caused: exec format error

还以为自己写的代码配置出错了, 想登上去看看, 发现进程压根就没起来.

Google 一把, 有人说是entrypoint的shell 文件的 shebang 不对, 我这里没有这个问题.

后来看到有人说是可执行格式的错误, 发现还真是.
首先, 我在本地Mac Pro (ARM) 上去执行这个image, 很正常的执行起来了, 然后我在另外一个Ubuntu 上面执行这个image, 就给我报下面的错误了:

WARNING: The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64) and no specific platform was requested

所以, 很明显, 这个image 是给 ARM64/v8 用的. 用 docker image inspect <img>去查看, 能看到这个image 是给什么平台机构的.
arch.png

我的 docker build 环境是 MAC ARM 机器, 之前一直没有错, 今天为啥出错了, 原因很有可能是我今天重启了 Docker Desktop.

去看 docker 官方文档: https://docs.docker.com/engine/reference/commandline/build/ 发现他们最近(20220914) 对于 docker build 新加了一个参数 (--platform)
platform.png

到它 change 去看, 发现最新的 v141
https://docs.docker.com/engine/api/version-history/#v140-api-changes

所以, 要在 Mac Pro ARM 芯片上做 x86_64/amd64 的image, 要给一个新参数 --platform linux/amd64