分类 Linux 相关 下的文章

linux 上如何测试一个网页能不能用

今天有个同事说, 她在生产环境某个container里面的代码访问某个网页页面, 返回的结果不是期望的. 能不能一起看看到底是哪里出错了? 竟然有这么有趣的问题, 于是满口答应, 一块去探个究竟.

当然, 第一步就是去重现一下场景. 于是kubectl exec <pod_id> -n <ns> bash 登录container, 想着使用curl去模拟访问, 结果很沮丧. 为啥, 这个 container 真干净, 上面的代码是Java写的, 只有jdk, 没有curl, 没有wget, 让我怎么复现? 难道要自己写个Java Client, 打包编译, 然后去复现?

除了使用Java代码去实现访问网页http请求, 其他真没办法了吗? 让我们整理一下在Linux上常见的使用工具访问web网页的办法.

使用 curl

curl 是最方便的测试网页访问的工具, 很多Linux发布版都带curl, 支持很多协议.

$ curl -vvv 'https://www.tianxiaohui.com/index.php/about.html'

使用 wget

wget 支持 http, https, ftp, ftps这些协议去访问某个页面或资源, 也很方便使用, 很多Linux 发行版也自带.

$ wget 'https://www.tianxiaohui.com/index.php/about.html'

使用 netcat | ncat

netcat 是 GNU 一个网络实用工具, 不过根据官方网站在2004年发完0.7.1版本之后, 就没更新过了, 我发现我 Ubuntu 使用的是openBSD 版本的(https://packages.debian.org/sid/netcat-openbsd). 它最常用的是打开端口, 建立连接, 至于http协议, 那要自己去实现. 实现http协议还好, 如果实现证书认证https, 那可是有点难度.

所以, 你看我只能拿到一个400的错误. 不过要是访问非https的, 还是很可行的.

$ printf “GET /index.php/about.html HTTP/1.0\r\nHost: www.tianxiaohui.com\r\n\r\n” | nc www.tianxiaohui.com 443
F<html><head><title>400 Bad Request</title></head><body>
<h2>HTTPS is required</h2>
<p>This is an SSL protected page, please use the HTTPS scheme instead of the plain HTTP scheme to access this URL.<br />
<blockquote>Hint: The URL should starts with <b>https</b>://</blockquote> </p>
<hr />
Powered By LiteSpeed Web Server<br />
<a href='http://www.litespeedtech.com'><i>http://www.litespeedtech.com</i></a>
</body></html>

有人称它为网络debug的瑞士军刀, 没有继续开发, 是不是可惜了. 所以鼎鼎大名的nmap工具下面, 发扬了netcat的功能, 开了一ncat, 比netcat 更好用. 还支持 ssl. 那必须测试一把

$ printf "GET /index.php/about.html HTTP/1.0\r\nHost: www.tianxiaohui.com\r\n\r\n" | ncat -v --ssl www.tianxiaohui.com 443

Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: SSL connection to 154.213.16.200:443.
Ncat: SHA-1 fingerprint: 8461 369A 0DE4 7401 AE26 4259 B0C2 CC9F FE0B 66F5
HTTP/1.0 200 OK
Connection: close
content-type: text/html; charset=UTF-8
x-pingback: https://www.tianxiaohui.com/index.php/action/xmlrpc
date: Thu, 16 Feb 2023 15:08:37 GMT
server: LiteSpeed
vary: User-Agent,User-Agent

<!DOCTYPE HTML>
<html class="no-js">
<head>
...省略
</html>

完美拿到数据

使用 openssl

openssl 是一个开源的, 通用的加密安全的工具软件, 很多Linux 发行版都带了它, 使用它进行网页访问, 也不在话下.

$printf "GET /index.php/about.html HTTP/1.0\r\nHost: www.tianxiaohui.com\r\n\r\n" | openssl s_client -quiet -state -connect www.tianxiaohui.com:443
SSL_connect:before SSL initialization
SSL_connect:SSLv3/TLS write client hello
SSL_connect:SSLv3/TLS write client hello
SSL_connect:SSLv3/TLS read server hello
SSL_connect:TLSv1.3 read encrypted extensions
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R3
verify return:1
depth=0 CN = www.tianxiaohui.com
verify return:1
SSL_connect:SSLv3/TLS read server certificate
SSL_connect:TLSv1.3 read server certificate verify
SSL_connect:SSLv3/TLS read finished
SSL_connect:SSLv3/TLS write change cipher spec
SSL_connect:SSLv3/TLS write finished
HTTP/1.0 200 OK

仅仅使用 bash, 你没看错

如果你的container里面非常干净, 上面的软件全没有, 但恰巧你安装的是bash, 那么这个任务还是能完成. 仔细看:

supra@suprabox:~$ exec 4<>/dev/tcp/www.tianxiaohui.com/443
supra@suprabox:~$ echo -e "GET /index.php/about.html HTTP/1.0\r\nHost: www.tianxiaohui.com\r\n\r\n" >&4
supra@suprabox:~$ cat  <&4
<html><head><title>400 Bad Request</title></head><body>
<h2>HTTPS is required</h2>
<p>This is an SSL protected page, please use the HTTPS scheme instead of the plain HTTP scheme to access this URL.<br />
<blockquote>Hint: The URL should starts with <b>https</b>://</blockquote> </p>
<hr />
Powered By LiteSpeed Web Server<br />
<a href='http://www.litespeedtech.com'><i>http://www.litespeedtech.com</i></a>
</body></html>

虽然, 能拿到结果, 但是没有ssl协商, 所以网站只给了一个400. 所以 bash 这种方式, 对于https只能验证连接成功, 不能验证拿到的结果.

那么我们就拿一个http的网站来练习一下, 可是如今到哪里去找一个还支持http的网站呢, 哈哈, 还真找到一个. 如下:

supra@suprabox:~$ exec 5<>/dev/tcp/www.henu.edu.cn/80
supra@suprabox:~$ echo -e "GET /index.htm HTTP/1.0\r\nHost: www.henu.edu.cn\r\n\r\n" >&5
supra@suprabox:~$ less  <&5

完美拿到结果, 自己处理ssl 太难了, 不过对于仅仅http, 还是很管用的.

对于上面3步的解释:

  1. /dev/protocol/host/port 是bash的一个重定向文件, 它帮你建立一个连接, 协议可以是tcp或udp, host可以是主机名或ip, 所以第一行通过exec建立一个文件描述符是5的重定向.
  2. 第二步是把 http 协议的request 发送到这个连接
  3. 第三步读出返回的结果网页.

总结

上面的很多工具都很好用, 有些还支持设置代理, 设置timeout 等, 据有相当丰富的功能. 在container 里面受限的情况下, 还有哪些好用的网页测试工具? 当然, 你说我container 里面有 python, 有 perl, wow, 那你太幸福了.

查看一个socket的开始时间

问题起源

有一个服务分布在3个数据中心, 有一个统一的FQDN(svc1.vip.tianxioahui.com)让别人去访问它, 同时每个数据中心都有一个单独的IP来访问该服务, 也就是说这个域名能解析到3个不同的IP. 有一次某个数据中心的该服务出现了点问题, 于是对应的IP被从DNS服务器上临时去掉了. 当问题修复之后, DNS服务器上恢复对应的IP, 该数据中心的流量却没有恢复, 还是流向了其它2个数据中心. 问题到底出现在哪呢?

初步调查

该服务的使用方是我们内部的一个服务, 于是我们首先检查客户端的DNS解析. 同时, 我们的DNS服务器会根据查询者所在的数据中心首先返回本数据中心的IP, 当没有对应数据中心的IP之后, 才会返回不同的数据中心的IP.

正常情况下的服务访问情况:
Screen Shot 2023-02-10 at 20.47.20.png

当 DC-C 的服务出问题的时候:
Screen Shot 2023-02-10 at 20.49.26.png

于是我们登录之前出问题数据中心某个客户端, 查询 DNS 解析情况, 发现查询的结果就是已经恢复的数据中心的IP, 也就是DNS解析已经完全恢复.

于是通过 ss 去查看现存的tcp连接, 发现确实是连接到其它数据中心的.

为什么恢复后还连接到其它数据中心?

首先经过连续多次测试, DNS解析的结果仍然是当前数据中心的IP, 所以DNS解析完全是正常的. 所以怀疑这个连接是之前早就建立好的, 并且一直 keep-alive, 所以一直还是正常在用的.

如何确定这个连接已经存活多久了?

我们想通过最简单的方式: ss 命令, 看看它是否提供了一个tcp连接的开始时间man ss答案是, 并没有.

于是, 我们找到这篇: https://superuser.com/questions/565991/how-to-determine-the-socket-connection-up-time-on-linux

这个问题的最高评答案里面给的过程是:

  1. 找到这个tcp连接所在的进程<pid>和proc文件系统的inode
  2. 然后使用stat命令去查看文件的最后访问时间(回答里用的是: query the file access time of the symbolic link)

然而这个并不是socket uptime, 而是socket最后的访问时间, 可是那个shell函数又命名为suptime, 真是对不上.

但是, 他使用stat访问文件的创建时间是对的

文件创建时间

通常情况下,使用stat都能查看文件的创建时间, 比如:

supra@suprabox:~$ stat ./bootstrap.sh
  File: ./bootstrap.sh
  Size: 4162          Blocks: 16         IO Block: 4096   regular file
Device: fd01h/64769d    Inode: 10763859    Links: 1
Access: (0775/-rwxrwxr-x)  Uid: ( 1000/   supra)   Gid: ( 1000/   supra)
Access: 2022-11-22 19:06:03.507882931 -0800
Modify: 2022-11-22 19:05:59.487695031 -0800
Change: 2022-11-22 19:05:59.491695212 -0800
 Birth: 2022-11-22 19:05:59.487695031 -0800

上面最后一行 Birth 就是它的创建时间.

查看socket inode的创建时间

其实上面输出中的 Birth 是在 inode 的元数据里的, 所以, 如果我们按照那个答案的方法去访问socket虚拟文件的inode是不是就找到了socket 的创建时间?

sudo ss -t -p -e
State    Recv-Q  Send-Q   Local Address:Port     Peer Address:Port      Process
ESTAB    0       0        10.249.64.103:ssh      10.226.199.52:61340    users:(("sshd",pid=812231,fd=4),("sshd",pid=812101,fd=4)) timer:(keepalive,54min,0) ino:2211652 sk:5063 cgroup:/system.slice/ssh.service <->

ss命令通过制定 -p 参数, 现实进程号和fd信息(pid=812101,fd=4), 通过-e参数, 显示inode 信息(ino:2211652). 于是, 我们可以查看这个虚拟文件的inode 信息了

supra@suprabox:~$ sudo stat /proc/812101/fd/4
  File: /proc/812101/fd/4 -> socket:[2211652]
  Size: 64            Blocks: 0          IO Block: 1024   symbolic link
Device: 17h/23d    Inode: 2228948     Links: 1
Access: (0700/lrwx------)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2023-02-11 01:51:35.510079439 -0800
Modify: 2023-02-11 01:51:26.442196746 -0800
Change: 2023-02-11 01:51:26.442196746 -0800
 Birth: -

可是Birth 这行是空.
文件的符号链接指向 socket:[2211652], 这个方括号里面的值就是我们通过ss查看得到的 inode id.

同时, 我们去查看 cat /proc/net/tcp 也能看到一样的行

最终, 也没找到一个可以查看socket创建时间的命令.

虚拟以太网卡 veth

这是一块真实的台式机网卡, 左边是一个插网线的接口, 右边是电路板, 电路板上哪些金色的地方就插入主板的接口. 于是外部流量就通过网线, 进入网卡, 然后流入计算机, 计算机内部数据则通过相反的方向流出到外部.
网卡.png

虚拟以太网卡, 就是一个通过软件模拟的网卡设备, 它能模拟网卡的功能把数据传出去, 接收进来的数据, 在操作系统看来, 它就是一块功能完备的网卡. 这个虚拟的网卡一头连着操作系统的kernel, 另外一头呢? 它有没有网线可以插拔, 所以它只能连向另外一个网卡, 所以虚拟出来的网卡都是成对出现的, 它们不能插网线, 所以只能2个网卡之间相互通信, 就好比2个机器之间通过一条直连的网线连在了一起. 就好比下图这样:
vethPair.png

但是这个 veth0 和 veth1 对必然在同一台机器上, 不可能跨域两台机器还能连着. 不过 veth0 和 veth1 可以属于同一台机器上的不同VM, container 或者不同的 netns.

下面演示如何创建虚拟网卡:

# 新建之前查看已有的
$ ip link show

$ sudo ip link add  name veth1 type veth #新建link

$ ip link show # 再次查看
1994: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether fe:99:a3:6e:f7:b0 brd ff:ff:ff:ff:ff:ff
1995: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 56:64:02:ac:de:26 brd ff:ff:ff:ff:ff:ff

可以看到新加出来的 veth0 和 veth1 这对. 我们给的veth 的名字是 veth1, 而系统默认给出了配对的名字是 veth0. 最前面的数字 1994 和 1995 是系统的一个索引值, 称之为 IDX. @后面的名字是成对的另外一边点名字. <> 里面是支持的一些功能, 后边是一些相关参数, 这些参数可以在新建时设置, 也可以使用默认值. 下面一行代表 link 类型: ether(以太网), link 层地址(MAC 地址) 和 广播地址.

当然, 我们可以新建的时候, 直接给出这一对的名字, 比如:

$ sudo ip link add  name veth2 type veth peer name veth3

$ ip link show
1996: veth3@veth2: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 12:39:5b:12:84:49 brd ff:ff:ff:ff:ff:ff
1997: veth2@veth3: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 8a:f0:21:23:20:52 brd ff:ff:ff:ff:ff:ff

因为它们是默认一对出现的, 如果一方下线(down) 则另外一方默认就下线了:

$ sudo ip link set veth3  down

$ ip link
1996: veth3@veth2: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 12:39:5b:12:84:49 brd ff:ff:ff:ff:ff:ff
1997: veth2@veth3: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 8a:f0:21:23:20:52 brd ff:ff:ff:ff:ff:ff

可以看到, 我们这里新建的 veth 都只有link 层地址, 没有 IP 地址, 我们可以给它们赋予IP 地址.

$ ip addr show #设置 IP 地址之前先查看一下

$ sudo ip address add 192.168.88.1/16 dev veth3
$ sudo ip address add 192.168.88.2/16 dev veth2

$ ip addr show #设置完再次查看 
1996: veth3@veth2: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 12:39:5b:12:84:49 brd ff:ff:ff:ff:ff:ff
    inet 192.168.88.1/16 scope global veth3
       valid_lft forever preferred_lft forever
1997: veth2@veth3: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 8a:f0:21:23:20:52 brd ff:ff:ff:ff:ff:ff
    inet 192.168.88.2/16 scope global veth2
       valid_lft forever preferred_lft forever

$ sudo ip link set veth3 up #up

通过ldd找到依赖的共享库

经常做docker image, 就会考虑到底用哪个base image 最好? 是用 scratch, 还是用 busybox? 还是用 alpine? 这不仅仅关乎image 的大小, 还关乎带了哪些软件? 是不是经常要打漏洞的patch 等. 在一次尝试中, 忘记这些最精简的 image 中可能连最基本的glibc 都没有, 直接复制可执行文件上去, 发现无法运行, 于是开始研究一下到底缺少了哪些运行时库, 于是开始了研究ldd.


一开始, 我们做了一个最简单打印 hello world 的 c 代码, hello.c 如下:

#include<stdio.h>

int main(int argc, char* argv[]) {
    printf("hello  %s\n", "world");
}

然后编译 并本地运行:

$ gcc -o hello hello.c
$ ./hello
hello  world

开始制作 docker image, Dockerfile 内容:

FROM scratch
COPY ./hello /
CMD ["/hello"]

制作 docker image 的命令:

$ docker build -t helloimage .
Sending build context to Docker daemon  25.09kB
Step 1/3 : FROM scratch
 --->
Step 2/3 : COPY ./hello /
 ---> d86014f25f94
Step 3/3 : CMD ["/hello"]
 ---> Running in ca6e160bbde4
Removing intermediate container ca6e160bbde4
 ---> 51874d5170b3
Successfully built 51874d5170b3
Successfully tagged helloimage:latest

然后运行这个新的image:

docker run --rm helloimage
exec /hello: no such file or directory

为啥我明明已经复制 hello 这个可执行文件到了跟目录, 为啥还说没这个文件? 于是用 dive 命令去查看image的文件结构:
dive.png
从这个图中, 也明显看到: 该文件就是明明在那里.

这时候突然想到, 也许是它的动态链接库文件不存在, 导致它无法加载, 最终报出这个错误. 那么看看它到底需要什么动态链接库吧:

$ docker run --rm helloimage ldd /hello
docker: Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "ldd": executable file not found in $PATH: unknown.

奥, 原来这个scratch 啥都没有, ldd 当然也不存在了. 只好本地看看:

$ ldd ./hello
    linux-vdso.so.1 (0x00007ffec9f73000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5b7a06b000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f5b7a276000)

本地 ldd 给出了, hello 其实需要3个动态链接库. 第一个 vdso 其实是一个虚拟的动态链接库, 它不 map 到任何磁盘文件, 主要用来给 clib 使用并加速系统性能的. 第二个 libc.so.6 是 c 的lib, 它mapping 到磁盘文件 /lib/x86_64-linux-gnu/libc.so.6. 第三个 ld-linux-x86-64.so.2 其实是链接器本身.

$ ls -lah /lib/x86_64-linux-gnu/libc.so.6
lrwxrwxrwx 1 root root 12 Apr  6  2022 /lib/x86_64-linux-gnu/libc.so.6 -> libc-2.31.so
$ /lib64/ld-linux-x86-64.so.2
Usage: ld.so [OPTION]... EXECUTABLE-FILE [ARGS-FOR-PROGRAM...]

所以, 我们复制过去的 hello 文件缺少2个动态库文件, 导致它无法运行. 那么如何解决呢?
方案一: 静态编译:
先静态编译hello.c 到 staticHello

$ gcc --static -o staticHello hello.c
$ ls
Dockerfile  hello  hello.c  staticHello

然后修改 Dockerfile:

FROM scratch
COPY ./staticHello /
CMD ["/staticHello"]

然后build image, 并运行:

$ docker build -t helloimage .
$ docker run --rm helloimage
hello  world

完美运行.

方案二: 复制这些缺少的动态库文件过去:
首先复制需要的2个动态库到当前目录 (因为build 的context 是当前目录, 其它目录文件它看不到)

cp /lib/x86_64-linux-gnu/libc.so.6 ./
cp /lib64/ld-linux-x86-64.so.2 ./

然后修改 Dockerfile:

FROM scratch
COPY libc.so.6 /lib/x86_64-linux-gnu/
COPY ld-linux-x86-64.so.2 /lib64/
COPY ./hello /
CMD ["/hello"]

接着 build image 并且运行:

docker build -t helloimage .
Sending build context to Docker daemon  3.121MB
Step 1/5 : FROM scratch
 --->
Step 2/5 : COPY libc.so.6 /lib/x86_64-linux-gnu/
 ---> d72d728c639a
Step 3/5 : COPY ld-linux-x86-64.so.2 /lib64/
 ---> dafed808d94e
Step 4/5 : COPY ./hello /
 ---> d90b74b43ead
Step 5/5 : CMD ["/hello"]
 ---> Running in 27d2b82c8d52
Removing intermediate container 27d2b82c8d52
 ---> 1c419a965e87
Successfully built 1c419a965e87
Successfully tagged helloimage:latest
$ docker run --rm helloimage
hello  world

完美运行.

所以, 2种方案都可以解决这个问题.

关于ldd

回过头来, 我们关注一下 ldd. ldd(List Dynamic Dependencies) 显示依赖的共享对象. 比如 ls 命令依赖这些动态库:

$ ldd /bin/ls
    linux-vdso.so.1 (0x00007fff7834a000)
    libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007fb672c18000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fb672a26000)
    libpcre2-8.so.0 => /usr/lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007fb672995000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fb67298f000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fb672c7c000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fb67296c000)

ldd 是怎么工作的? ldd 调用标准的链接器 ld.so, 同时设置环境变量 LD_TRACE_LOADED_OBJECTS=1, 这样 ld 就会检查所依赖的所有动态库, 并且找到合适的库并且加载到内存. 这样 ldd 就能记录这些被加载到库以及它们在内存的地址. vdso 和 ld.so 是2个特殊的库.

其实 ldd 是一个shell 脚本, 我们能查看它的源文件:

$ which ldd
/usr/bin/ldd
$ file /usr/bin/ldd
/usr/bin/ldd: Bourne-Again shell script, ASCII text executable
$ less /usr/bin/ldd

另外, 可执行文件的依赖库可以通过 objdump 找到:

$ objdump -p hello | grep NEEDED

GDB

GNU Debugger (GDB) 是 Linux & Unix 上广泛使用的 debugger, 可以对 C, C++, Objective-C, go 等程序 debug.

GDB 可以 1) 对正在运行的程序通过 attach 的方式进行 debug, 2) 也可以通过 gdb 新运行一个程序进行 debug, 3) 还可以直接对 core dump 进行有线的 debug.

GDB 命令 cheatsheet

  1. https://darkdust.net/files/GDB%20Cheat%20Sheet.pdf
  2. https://gist.github.com/rkubik/b96c23bd8ed58333de37f2b8cd052c30

GDB 内部实现

GDB 使用 ptrace 系统调用去观察和控制其它程序的运行. 断点是通过替换原程序的某地址的指令为特殊的指令(int03)来实现的, 执行断点程序产生SIGTRAP中断.
关于 ptrace 特定的操作, 参看: https://man7.org/linux/man-pages/man2/ptrace.2.html