把宿主的程序和依赖动态库挂载进docker容器,解决一个诡异BUG

今天在一台 Redhat 7 服务器上启动一个 Kafka 3.7.2 容器,遇到一个很诡异的报错。经过排查发现,问题是由容器里新版 bash 引起的,应该是新 bash 或动态库可能跟老内核不兼容。

最终,我们把宿主上的旧版 bash 及其依赖动态库挂载到容器上解决问题。整个问题排查过程和解决思路挺有意思的,涉及不少基础知识,分享给大家。

报错背景

Kafka 容器启动方式平白无奇:

1
docker run --rm -it apache/kafka:3.7.2

按预期应该是日志哐哐刷一下,然后服务起来,接下来开始指定相关配置参数和数据目录。但结果出了个幺蛾子:

1
2
3
4
5
6
===> User
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
===> Setting default values of environment variables if not already set.
CLUSTER_ID not set. Setting it to default value: "5L6g3nShT-eMCtK--X86sw"
===> Configuring ...
/opt/kafka/config/ file not writable

报错有点诡异,显示 /opt/kafka/config/ 配置目录不可写。问题是我没把这个目录映射到宿主文件系统,不可能 Docker 镜像本身就有问题吧?

为什么 Kafka 容器启动后会写配置文件?

后台服务程序通常是通过配置文件来配置的,Kafka 也不例外。然而在容器时代,配置文件的维护不太方便,通过环境变量配置更加灵活,启动容器时即可通过命令行参数指定。

因此,很多容器镜像都会打包一个服务启动脚本,这个脚本可以检查当前环境变量,并据此生成对应的配置文件,以达到通过环境变量配置服务的目的。

定位过程

有点好奇,所以重新把容器跑起来,这次直接进去 sh ,看看配置目录是什么情况:

1
docker run --rm -it apache/kafka:3.7.2 sh

检查了配置目录所属用户和权限位,用户和用户组都是 appuser

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ ls -l /opt/kafka/
total 60644
-rw-r--r-- 1 appuser appuser    15260 Dec  4 18:50 LICENSE
-rw-r--r-- 1 appuser appuser    28359 Dec  4 18:50 NOTICE
drwxr-xr-x 3 appuser appuser     4096 Dec  4 19:55 bin
drwxr-xr-x 3 appuser appuser     4096 Dec  4 19:55 config
-r--r--r-- 1 root    root    45522944 Dec  4 19:55 kafka.jsa
drwxr-xr-x 2 appuser appuser     8192 Dec  4 19:55 libs
drwxr-xr-x 2 appuser appuser     4096 Dec  4 19:55 licenses
drwxr-xr-x 2 appuser appuser       44 Dec  4 19:55 site-docs
-r--r--r-- 1 root    root    16506880 Dec  4 19:55 storage.jsa

$ ls -l /opt/kafka/config/
total 72
-rw-r--r-- 1 appuser appuser  906 Dec  4 18:50 connect-console-sink.properties
-rw-r--r-- 1 appuser appuser  909 Dec  4 18:50 connect-console-source.properties
-rw-r--r-- 1 appuser appuser 5475 Dec  4 18:50 connect-distributed.properties
-rw-r--r-- 1 appuser appuser  883 Dec  4 18:50 connect-file-sink.properties
-rw-r--r-- 1 appuser appuser  881 Dec  4 18:50 connect-file-source.properties
-rw-r--r-- 1 appuser appuser 2063 Dec  4 18:50 connect-log4j.properties
-rw-r--r-- 1 appuser appuser 2540 Dec  4 18:50 connect-mirror-maker.properties
-rw-r--r-- 1 appuser appuser 2262 Dec  4 18:50 connect-standalone.properties
-rw-r--r-- 1 appuser appuser 1221 Dec  4 18:50 consumer.properties
drwxr-xr-x 2 appuser appuser   85 Dec  4 19:55 kraft
-rw-r--r-- 1 appuser appuser 4917 Dec  4 18:50 log4j.properties
-rw-r--r-- 1 appuser appuser 2065 Dec  4 18:50 producer.properties
-rw-r--r-- 1 appuser appuser 6896 Dec  4 18:50 server.properties
-rw-r--r-- 1 appuser appuser 1094 Dec  4 18:50 tools-log4j.properties
-rw-r--r-- 1 appuser appuser 1169 Dec  4 18:50 trogdor.conf
-rw-r--r-- 1 appuser appuser 1205 Dec  4 18:50 zookeeper.properties

$ echo abc > /opt/kafka/config/abc

回到上面的报错信息,可以看到 Kafka 也是以 appuser 用户执行的啊,怎么可能没有写入权限?我直接跑 echo 命令往配置目录写入一个新文件,也是可以的。诡异……

会不会是启动脚本有 BUG ,导致配置文件权限不对。因此,就想检查一下启动脚本,看看是哪个位置报错。那么问题来了,启动脚本在哪里?

这个问题等价于:一个容器镜像启动后,执行什么程序是怎么决定的?这是最基本的 Docker 基础知识,但我发现很多人并不会……扯远了,实际上这是由镜像配置里面的 EntrypointCmd 决定的。

先执行 docker inspect 查看镜像信息:

1
 docker inspect  apache/kafka:3.7.2

Config 里面就可以看到相关配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[
    {
        "Config": {
            "Cmd": [
                "/etc/kafka/docker/run"
            ],
            "Entrypoint": [
                "/__cacert_entrypoint.sh"
            ]
        }
    }
]

这个配置决定了,镜像起来后,默认执行的是:

1
/__cacert_entrypoint.sh /etc/kafka/docker/run

/__cacert_entrypoint.sh 这个入口脚本看名字跟 CA 证书设置有关,看了脚本内容,它根据环境变量设置证书,最后在执行参数指定的程序:

1
exec "$@"

由于我们没有指定相关环境变量,因此问题就出在其后执行的 Kafka 启动程序 /etc/kafka/docker/run 上。它应该是一个脚本,位于 etc 目录内更说明这一点,所以我直接把它 cat 出来看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
. /etc/kafka/docker/bash-config

# Set environment values if they exist as arguments
if [ $# -ne 0 ]; then
  echo "===> Overriding env params with args ..."
  for var in "$@"
  do
    export "$var"
  done
fi

echo "===> User"
id

echo "===> Setting default values of environment variables if not already set."
. /etc/kafka/docker/configureDefaults

echo "===> Configuring ..."
. /etc/kafka/docker/configure

echo "===> Launching ... "
. /etc/kafka/docker/launch

结合最上面的报错信息,===> Configuring ... 有输出,而===> Launching ... 没有,说明问题出在 /etc/kafka/docker/configure 这个脚本上。看名字,这个脚本应该是负责初始化配置文件,跟报错信息配置目录不可写也是吻合的。

直接在这个脚本里面搜报错关键字,很快就可以来到这个 path 这个 shell 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
path() {
  if [[ $2 == "writable" ]]; then
    if [[ ! -w "$1" ]]; then
      echo "$1 file not writable"
      exit 1
    fi
  elif [[ $2 == "existence" ]]; then
    if [[ ! -e "$1" ]]; then
      echo "$1 file does not exist"
      exit 1
    fi
  fi
}

这个子程序接收两个参数,$1 是待检查路径名,$2 是预期权限,里面通过 [[ -w /some/path ]] 这种 shell 语法进行权限检查。由于 shell 语法支持度历史上也是比较混乱,怀疑会不会是 shell 不支持。在容器 sh 又确认了一下,发现是可以的:

1
2
$ [[ -w /opt/kafka/config/ ]] && echo yes
yes

容器 sh 的执行身份跟 Kafka 的执行身份也是一致的:

1
2
$ id
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)

还是很诡异……又想到了启动脚本用的 shell 可能跟我启动的不一样,我执行容器启动的是 sh

1
2
3
$ ps u -p $$
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
appuser      1  0.1  0.0   1680   656 ?        Ss   02:12   0:00 sh

检查了启动脚本头,发现它用的是 bash ,讲道理 bash 应该比 sh 牛逼啊,怎么就有问题呢?仍然诡异:

1
2
3
4
$ cat /etc/kafka/docker/run | head -n 3
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with

直接在容器里面用 bash 执行检查逻辑,发现 bash 真的有问题!没有输出预期的 yes ,什么鬼❓

1
$ bash -c '[[ -w /opt/kafka/config/ ]] && echo yes'

但我回到宿主上执行 bash 检查 /tmp/ 目录权限结果发现宿主上的版本又是正常的:

1
2
$ bash -c '[[ -w /tmp/ ]] && echo yes'
yes

那问题应该出在 bash 版本上,宿主上的 bash 版本是 4

1
2
3
4
5
6
7
$ bash --version
GNU bash, version 4.2.46(2)-release (x86_64-redhat-linux-gnu)
Copyright (C) 2011 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

容器里面的 bash 版本是 5 ,相差一个大版本:

1
2
3
4
5
6
7
$ bash --version
GNU bash, version 5.2.26(1)-release (x86_64-alpine-linux-musl)
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

内核版本是 3 ,也算比较老了:

1
2
$ uname -a
Linux cb747a3b7a7d 3.10.0-1160.el7.x86_64 #1 SMP Mon Oct 19 16:18:59 UTC 2020 x86_64 GNU/Linux

至此,问题水落石出,容器里面的 bash 版本太新,跟旧的内核不兼容。

解决思路

那么,问题应该怎么解决呢?

系统升级

Redhat 7.9 确实太老了,能重装更新系统最后,但因其他因素无法实现。

用 sh 来执行启动脚本

sh[[ -w /some/path ]] 是正常的,就想到用它来跑启动脚本。这个路子我没抱什么希望,因为 sh 通常只是一个最小功能集,应该有很多语法不支持,要是启动脚本用到就跪了。不过,试一下嘛也没啥成本,在容器里面跑这个命令:

1
/__cacert_entrypoint.sh sh /etc/kafka/docker/run

不出意外抛锚了,因为 declare 不支持。我又看了一下,容器里面的 sh 是链到 busybox 上的:

1
2
3
4
 $ which sh
/bin/sh
$ ls -l /bin/sh
lrwxrwxrwx 1 root root 12 Sep  6 11:34 /bin/sh -> /bin/busybox

魔改启动脚本

权限没问题你不是瞎报错嘛,我就把报错去掉,闭上嘴:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
path() {
  if [[ $2 == "writable" ]]; then
    if [[ ! -w "$1" ]]; then
      echo "$1 file not writable"
      #exit 1
    fi
  elif [[ $2 == "existence" ]]; then
    if [[ ! -e "$1" ]]; then
      echo "$1 file does not exist"
      #exit 1
    fi
  fi
}

改完再重新跑一下:

1
/__cacert_entrypoint.sh /etc/kafka/docker/run

发现可以正常启动了!!!

宿主命令和动态库映射进容器

由于魔改启动脚本需要重新打镜像,而且可能有坑,所以并不是一个完美的解决方案。然后脑袋突然灵光一现,宿主上的 bash 不是正常嘛,能否把它映射进容器里用?由于容器和宿主是共用内核,理论上是可行的。由于可执行程序需要依赖动态库,所以也要映射进去,只是暂不确定是否有冲突。

先只把可执行程序 mount 进去,不出意外报错了,因为依赖的动态库还没映射进去:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ docker run -v /bin/bash:/bin/bash --rm -it apache/kafka:3.7.2 bash --version
Error loading shared library libtinfo.so.5: No such file or directory (needed by /bin/bash)
Error relocating /bin/bash: tputs: symbol not found
Error relocating /bin/bash: tgoto: symbol not found
Error relocating /bin/bash: tgetflag: symbol not found
Error relocating /bin/bash: tgetent: symbol not found
Error relocating /bin/bash: tgetnum: symbol not found
Error relocating /bin/bash: tgetstr: symbol not found
Error relocating /bin/bash: UP: symbol not found
Error relocating /bin/bash: PC: symbol not found
Error relocating /bin/bash: BC: symbol not found

先在宿主上看看 bash 都链接了哪些动态库:

1
2
3
4
5
6
$ ldd /usr/bin/bash
linux-vdso.so.1 =>  (0x00007ffc9b1d0000)
libtinfo.so.5 => /lib64/libtinfo.so.5 (0x00007fce0e4da000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007fce0e2d6000)
libc.so.6 => /lib64/libc.so.6 (0x00007fce0df08000)
/lib64/ld-linux-x86-64.so.2 (0x00007fce0e704000)

先映射 libtinfo.so.5 试试:

1
docker run -v /bin/bash:/bin/bash -v /lib64/libtinfo.so.5:/lib64/libtinfo.so.5 --rm -it apache/kafka:3.7.2 bash --version

发现还是报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Error loading shared library libtinfo.so.5: No such file or directory (needed by /bin/bash)
Error relocating /bin/bash: tputs: symbol not found
Error relocating /bin/bash: tgoto: symbol not found
Error relocating /bin/bash: tgetflag: symbol not found
Error relocating /bin/bash: tgetent: symbol not found
Error relocating /bin/bash: tgetnum: symbol not found
Error relocating /bin/bash: tgetstr: symbol not found
Error relocating /bin/bash: UP: symbol not found
Error relocating /bin/bash: PC: symbol not found
Error relocating /bin/bash: BC: symbol not found

还是找不到映射进去的库文件,有点奇怪……不过无妨,容器直接执行 ls 一下看 /lib64 里面有没有:

1
docker run -v /bin/bash:/bin/bash -v /lib64/libtinfo.so.5:/lib64/libtinfo.so.5 --rm -it apache/kafka:3.7.2 ls -al /lib64

发现 libtinfo.so.5 已经映射进来了:

1
2
lrwxrwxrwx 1 root root     27 Dec  4 19:55 ld-linux-x86-64.so.2 -> ../lib/ld-linux-x86-64.so.2
-rwxr-xr-x 1 root root 174576 Sep  6  2017 libtinfo.so.5

很有可能 /lib64 不是容器动态库的默认搜索路径,该目录原本只有 ld 链接器似乎也在暗示这一点。没关系,加个环境变量 LD_LIBRARY_PATH 显式指定一下呗,发现旧版的 bash 成功在容器里跑起来了!!!

1
2
3
4
5
6
7
$ docker run -e LD_LIBRARY_PATH=/lib64 -v /bin/bash:/bin/bash -v /lib64/libtinfo.so.5:/lib64/libtinfo.so.5 --rm -it apache/kafka:3.7.2 bash --version
GNU bash, version 4.2.46(2)-release (x86_64-redhat-linux-gnu)
Copyright (C) 2011 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

测了一下 -w 这个特性,发现高兴早了,没有出现预期的 yes ……

刚刚 ldd 看了宿主的 bash 是需要链接多个动态库的,包括 libc 等,都还没映射。容器里用的应该还是自带的新版动态库,所以问题依然存在。接下来,把宿主 ldd 输出的所有动态库都映射进去,发现可以了!!!

1
2
$ docker run -e LD_LIBRARY_PATH=/lib64 -v /bin/bash:/bin/bash -v /lib64/linux-vdso.so.1:/lib64/linux-vdso.so.1 -v /lib64/libdl.so.2:/lib64/libdl.so.2 -v /lib64/libtinfo.so.5:/lib64/libtinfo.so.5 -v /lib64/libc.so.6:/lib64/libc.so.6 -v /lib64/ld-linux-x86-64.so.2:/lib64/ld-linux-x86-64.so.2  --rm -it apache/kafka:3.7.2 bash -c '[[ -w /tmp/ ]] && echo yes'
yes

最后把自定义执行命令去掉,按照默认命令启动容器,发现 Kafka 可以正常启动了!!!

1
docker run -e LD_LIBRARY_PATH=/lib64 -v /bin/bash:/bin/bash -v /lib64/linux-vdso.so.1:/lib64/linux-vdso.so.1 -v /lib64/libdl.so.2:/lib64/libdl.so.2 -v /lib64/libtinfo.so.5:/lib64/libtinfo.so.5 -v /lib64/libc.so.6:/lib64/libc.so.6 -v /lib64/ld-linux-x86-64.so.2:/lib64/ld-linux-x86-64.so.2  --rm -it apache/kafka:3.7.2

订阅更新,获取更多学习资料,请关注我们的公众号:

【随笔】系列文章首发于公众号【小菜学编程】,敬请关注: