zhangguanzhang's Blog

[持续更新] - 为什么不应该为了实现需求而调用命令

字数统计: 4.1k阅读时长: 19 min
2025/12/18
loading

总结下一些案例场景,图快和方便调用命令在私有化的问题。

由来

在私有化的开发中,到现场客户反馈问题,聊一聊调用命令的后果和如何浪费了别人的时间。

案例

命令注入

有 dashboard 提供 url 检测,后端代码逻辑为:

  1. 取 post 的 ip
  2. 拼接 curl 命令

然后被安全部门注入了 ip; whoami; rm -rf / 被当作安全案例播放。以及另一个 ip ping 检测的调用 ping 命令拼接。url 检测完全可以调用 request 包,以及 icmp 的可以使用 socket 包。

拿 ping 来举例,某个版本有 ipv6 需求,你在自己的开发系统 ubuntu 上测试 ipv6 没问题:

1
2
3
4
$ ping ::1
PING ::1(::1) 56 data bytes
64 bytes from ::1: icmp_seq=1 ttl=64 time=0.505 ms
^C

然后提交后,测试人员出包测试后,来找你反馈说某些系统上有问题,你排查了一遍发现是在 centos7 上低版本 ping 测出问题了:

1
2
$ ping ::1
ping: ::1: Address family for hostname not supported

而如果你一开始使用 socket 编程,初期的 icmp 实现是:

1
2
sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, 
socket.getprotobyname("icmp"))

在接收到 ipv6 需求后,你根据传入的 ip 使用 IPy 库判断 socket 的第一个选项是使用 socket.AF_INET 还是 socket.AF_INET6,测试后续压根不会因为系统来找你,避免了一来一回和挤占了别人时间。golang 的 icmp 实现可以参考 https://github.com/go-ping/ping

僵尸进程

在 Python 中,常见的几种调用命令的方式有:os.system(), os.popen(), subprocess.Popen() 等。其中,如果使用 subprocess.Popen() 并且没有调用 wait() 或者没有使用 communicate() 方法,那么子进程在结束后可能会变成僵尸进程。

1
2
3
4
5
6
7
8
9
10
$ ps aux | grep Z
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 2214 0.0 0.0 0 0 ? Z 03:35 0:00 [docker] <defunct>
root 3727 0.0 0.0 0 0 ? Z 14:21 0:00 [docker] <defunct>
root 4908 0.0 0.0 0 0 ? Z 11:52 0:00 [docker] <defunct>
root 7979 0.0 0.0 0 0 ? Z 14:31 0:00 [docker] <defunct>
root 13301 0.0 0.0 0 0 ? Z 13:07 0:00 [docker] <defunct>
root 18508 0.0 0.0 0 0 ? Z 10:57 0:00 [docker] <defunct>
root 20993 0.0 0.0 0 0 ? Z 14:41 0:00 [docker] <defunct>
...

僵尸进程虽然不会占用 cpu mem,但是会占用 pid 资源:

1
2
3
4
5
6
# 默认的 pid max
$ cat /proc/sys/kernel/pid_max
32768
# 可以看到 pid 数字没释放
$ cat /proc/21190/cmdline

而私有化很多客户机器上有监控 agent,僵尸进程数量太多会告警,最后客户会反馈要求处理,而现场 pm 会先要求熟悉 linux 基础的同事 X 排查,最后发现是 daemon 类服务开发 Y 写的,给同事 X 增加定位时间。

命令实际上就是根据 cmdline、env 和命令的配置文件,最后去调用通过网络或者系统的 syscall 处理。例如之前看到一个文件处理服务,调用了 tar 命令最后产生很多僵尸进程的,压缩文件格式实际就是下面的流格式:

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
┌────────────────────────────────────────────────────────────────────┐
│ TAR 归档文件 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 文件1 - 头部 (512字节) │ │
│ │ name(100) mode(8) uid(8) gid(8) size(12) mtime(12) .. │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 文件1 - 内容 │ │
│ │ 实际数据 (例如: "Hello World!") │ │
│ │ │ │
│ │ 填充到 512 字节倍数 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 文件2 - 头部 (512字节) │ │
│ │ name(100) mode(8) uid(8) gid(8) size(12) ... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 文件2 - 内容 │ │
│ │ 实际数据 (例如: 二进制文件内容) │ │
│ │ │ │
│ │ 填充到 512 字节倍数 │ │
│ └─────────────────────────────────────────────────────────────┘ │
| .....

而每个编程语言都有对应的库的,完全可以使用库去处理,这里就不列举不通 rootfs 下 tar 行为和选项不一致的案例了,使用库在更换基础镜像就不会出现被动情况了。

客户监控

dashboard 有探测端口部分,然后调用的 nmap 命令实现,而很多政府单位机器上有 agent,针对 nmap 这种会认为是机器被黑成为肉鸡扫描其他机器,在护网期间这是非常严重的问题。然后又拉群和一堆人要求处理,最后动员一大堆人,给客户解释原因,客户要求整改。这个完全可以使用 socket 库实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def check_tcp_port(ip, port, timeout=1):
if is_ipv6(ip) is True:
sk = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
else:
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.settimeout(timeout)
try:
sk.connect((ip, int(port)))
return True
except Exception as e:
print(e)
return False
finally:
sk.close()

命令限制和禁止

某个银行客户,排查问题的时候发现删不掉 pod:

1
kubectl delete pod xxx

最后感觉客户机器有啥软件是不是阻拦了关键字的执行,让实施人员执行:

1
2
3
4
$ echo 111
111
$ echo 111 delete
$

发现确实,然后去 dashboard 上点击删除的(背后走 k8s api),除了命令限制以外,还有客户不允许执行某些命令,例如 ssh,如果一开始就使用 python 的 paramiko 库就没这种问题了。

行为控制和错误处理

这里以 ssh 命令举例,Linux 的 ssh 和 sshd 的配置都存放在 /etc/ssh/ 下的 ssh_configsshd_config ,而经常客户现场由于等保或者安全需求更改后,出问题了就在 ssh 调用上加 option:

1
2
3
4
5
6
7
8
9
10
11
$ find . -type f -name '*.py' -exec grep -P 'ssh .+-o' {} \;
cmd = "timeout 10 ssh -q -F /dev/null -o StrictHostKeyChecking=no {}@{} -p {} sudo LC_ALL=C {}".format(
cmd = "timeout 5 ssh {}@{} -o StrictHostKeyChecking=no -p {} ls".format(host_obj.username, host_obj.ip, host_obj.port)
cmd1 = "timeout 5 ssh {}@{} -o StrictHostKeyChecking=no -p {} {}".format(
"ssh -p %s -o PubkeyAuthentication=yes -o stricthostkeychecking=no %s@%s cat /etc/hosts 2>/dev/null | grep -m 1 ' xxx-init-job ' | awk '{print $1}'"
create_cmd = "ssh -p %s -o PubkeyAuthentication=yes -o stricthostkeychecking=no %s@%s 'docker exec -i %s %s'" % (
cmd = "ssh -p {} -o PubkeyAuthentication=yes -o stricthostkeychecking=no {}@{} 'docker exec -i {} ls /xxx/xxx/xxx-dc-main'".format(
ssh_cmd = 'ssh -q -F /dev/null -o StrictHostKeyChecking=no {}@{} -p {} "{}"'.format(
# 而且不按照 -o 选项 find 出来的行数居然更多
$ find . -type f -name '*.py' -exec grep -P 'ssh .+@' {} \; | wc -l
22

而每个部分选项又不一样,后续又可能有问题,以及上面的 timeout,这些实际 paramiko 库 都有选项可以设置。以及更精准的捕获,远端机器上执行一个命令,到底是 ssh 连接失败还是远端命令失败,库完全可以捕获到:

1
2
3
4
try:
ssh.connect(...)
except paramiko.SSHException:
# 连接层失败

甚至能完全区分 stdour 和 stderr:

1
2
3
4
5
6
7
8
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username="user", port=2222)
stdin, stdout, stderr = ssh.exec_command("x")

exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode()
err = stderr.read().decode()

文件权限

很多 cli 命令的执行需要授权信息,而 cmdline 调用会被客户扫到,非 cmdline 例如配置会在家目录下 ~/.xxx/xx.conf ,而在很多时候为了测试或者运行一个命令,需要产生文件让这个 cli 读取,然后因为容器 pid1 启动最后 gosu 启动切到非 root 运行,docker exec 进去是 root 用户,此刻调用产生的文件的 owner 是 root,后续 daemon 触发又报错权限问题。

例如之前的 mc 测试 minio 上传文件,实际可以使用 minio 的 python 库。以及 kubeconfig 文件权限,零星的有客户反馈要求权限问题,如果使用 kuberketes-api,授权信息存数据库,就能避免这种额外的整改需求。

后续扩展性

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

kubexxx_deploy_user = config_dict["HOSTS"][0]["username"]
kubexxx_deploy_ip = config_dict["HOSTS"][0]["ip"]
kubexxx_deploy_port = config_dict["HOSTS"][0]["ssh_port"]

ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(kubexxx_deploy_ip, kubexxx_deploy_port, kubexxx_deploy_user)

# 修改configmap和config.yaml
data_dir = config_dict["DATA_DIR"]
apisix_config_path = os.path.join(data_dir, "kube/apisix/config.yaml")
remote_cmd = f"sudo docker cp {apisix_config_path} kubexxx:/root/kubexxx;sudo docker exec kubexxx chown xxx:xxx /root/kubexxx/config.yaml"
_, stdout, stderr = ssh.exec_command(remote_cmd)
# 获取输出内容
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
logging.info("STDOUT:\n%s", out)
if err:
logging.error("STDERR:\n%s", err)

# 重新创建configmap,重启pod
configmap_cmd = "kubectl delete cm -n default apisix; kubectl create cm -n default apisix --from-file=/root/kubexxx/config.yaml; kubectl get pods -n default | grep apisix | awk '{print $1}' | xargs -I {} kubectl delete pod -n default {}"
retcode, res = exec_cmd(configmap_cmd)
if retcode != 0:
logging.error(res)
pod_cmd = "kubectl get pods -n default | grep apisix | awk '{print $1}' | xargs -I {} kubectl delete pod -n default {}"
retcode, res = exec_cmd(pod_cmd)
if retcode != 0:
logging.error(res)

同样修改 configmap,完全库实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def change_backend_type(mode):

config.load_kube_config()
api_instance = client.CoreV1Api()

cm_name = "kube-flannel-cfg"

cm = api_instance.read_namespaced_config_map(name="kube-flannel-cfg", namespace="kube-system")
cni_json_str = cm.data.get('net-conf.json', '{}')
net_conf = json.loads(cni_json_str)
net_conf['Backend']['Type'] = mode
cm.data['net-conf.json'] = json.dumps(net_conf)
result_cm = api_instance.patch_namespaced_config_map(cm_name, "kube-system", cm, pretty=True)
if get_backend_from_cm(result_cm) != mode:
logging.error("flannel backend Type修改为{0}失败: ".format(mode))
return False

logging.info("开始删除 flannel pod")
pods = api_instance.list_namespaced_pod("kube-system", label_selector='app=flannel').items
for pod in pods:
logging.info(f"删除 flannel Pod: {pod.metadata.name}")
api_instance.delete_namespaced_pod(name=pod.metadata.name, namespace="kube-system")

如果后续 dashboard 需要纳管多个 k8s 之类的,用库只需要修改 client 加载部分来操作具体集群,而且修改 configmap 不会产生临时文件,避免临时文件的权限问题,而命令形式的话 kubectl 拼一堆选项非常麻烦。

默认参数

很多命令有默认参数,再拿 ssh 举例,上面的 ssh,很多时候老是漏掉端口,很多客户的 ssh 端口不是默认的 22,如果基于 paramiko 库封装成方法,要求必须传入端口,能避免后续测试反馈以及客户现场暴漏。

tty

很多命令会通过 isatty(1),isatty(2) 改变行为,程序调用时默认是 非 tty,与人手敲命令完全不是一个世界,以及在管道里是非 tty 行为:

1
2
3
4
5
6
$ ls
Dockerfile Makefile README.md
$ ls | cat
Dockerfile
Makefile
README.md

如果不具备这些 Linux 知识,会浪费时间在这种问题上排查。

输出变更

很多时候调用命令是为了获取信息,而很多系统或者切换容器 os 后,命令的版本变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ openssl version
OpenSSL 1.0.2k-fips 26 Jan 2017
$ openssl x509 -noout -text -in ca.pem
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
77:b0:0a:e0:8c:5a:88:ee:89:9d:18:fa:48:94:c1:cf:28:f6:6d:d1
Signature Algorithm: ecdsa-with-SHA256


$ openssl version
OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022)
$ openssl x509 -in ca.pem -noout -text
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
77:b0:0a:e0:8c:5a:88:ee:89:9d:18:fa:48:94:c1:cf:28:f6:6d:d1
Signature Algorithm: ecdsa-with-SHA256

例如上面的 openssl 3 版本的 Signature 位置变化了,再例如 docker 新版本镜像输出变化:

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
# docker images | head
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
alpine:latest 706db57fb206 8.32MB 0B
busybox:glibc 08ef35a1c3f0 4.43MB 0B
busybox:latest 08ef35a1c3f0 4.43MB 0B
cr.loongnix.cn/kubernetes/etcd:3.5.14 dcb2aaf9fcc7 85.3MB 0B
debian:11 a20b5a7387bf 124MB 0B
debian:trixie-slim 58c1f2a9fa85 78.6MB 0B
envoyproxy/envoy:v1.21.2 2c32b8d45d47 115MB 0B
gcr.io/k8s-staging-dns/k8s-dns-dnsmasq-amd64:1.26.5-1-gcf293f8e 60f63d70918c 21.2MB 0B
gcr.io/k8s-staging-dns/k8s-dns-dnsmasq-amd64:1.26.5-1-gcf293f8e-dirty 60f63d70918c 21.2MB 0B
$ docker images | grep none
WARNING: This output is designed for human readability. For machine-readable output, please use --format.

$ docker images --format table -a | head
REPOSITORY TAG IMAGE ID CREATED SIZE
hub-mirror.xxx.xx/xxxx-run/python 3.12-amd64-oe-v1 8634b85409a9 2 hours ago 477MB
hub-mirror.xxx.xx/xxxx-run/python 3.12.11-amd64-oe-v1 8634b85409a9 2 hours ago 477MB
<none> <none> af0f5e291624 2 hours ago 477MB
<none> <none> 1b7d81e61c72 2 hours ago 467MB
<none> <none> 1affa42bade1 2 hours ago 467MB
<none> <none> 3353b43ef79a 2 hours ago 467MB
<none> <none> 789cff37497a 2 hours ago 467MB
<none> <none> 410eb1000652 2 hours ago 467MB
<none> <none> c38df4d4aa02 3 hours ago 426MB

如果使用库完全不限于被动情况。

以及调用 free -h 判断机器内存有多少 g 情况,然后做限制判断,然后客户机器内存 1t了,free -h 显示的数字部分就是 1 了,认为客户机器内存只有 1G。实际上 free 命令就是读取的 /proc 目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ strace free -h |& grep /proc/
openat(AT_FDCWD, "/proc/self/auxv", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/sys/kernel/osrelease", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/self/auxv", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/sys/kernel/osrelease", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/meminfo", O_RDONLY) = 3

$ free -h
total used free shared buff/cache available
Mem: 62G 9.8G 11G 3.0M 40G 52G
Swap: 0B 0B 0B
$ head /proc/meminfo
MemTotal: 65806560 kB
MemFree: 12365980 kB
MemAvailable: 54861520 kB
Buffers: 2104 kB
Cached: 40519256 kB

以及其他的 /proc/cpuinfo/proc/mounts/sys/ 目录。

引发事故

早期的环境检查单独的脚本里,为了检查某些 sysctl 参数调用了 sysctl -p ,而某天同事 A 去重要客户生产环境执行环境检查,执行完后环境崩了,被客户骂赶紧解决。最后排查到是客户修改了 /etc/sysctl.conf 内关闭 net.ipv4.ip_forward = 0 转发,本质这个环境检查就是看这些需要检查的内核参数,完全可以从 /proc 目录下获取和写入就行:

1
2
$ cat /proc/sys/net/ipv4/ip_forward
1

没考虑到的场景

因为网上很多 ssl证书生成都是用的 openssl rsa 生成证书,证书合法性使用 openssl rsa 判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
check_crt = subprocess.Popen(
"openssl x509 -pubkey -noout -in {0}".format(crt_path),
shell=True,
stdout=subprocess.PIPE,
)
check_crt.wait()
check_key = subprocess.Popen(
"openssl rsa -pubout -in {0}".format(key_path),
shell=True,
stdout=subprocess.PIPE,
)
check_key.wait()
if check_crt.returncode == 0:
check_crt_result = check_crt.stdout.read()
else:
check_crt_result = False
if check_key.returncode == 0:
check_key_result = check_key.stdout.read()
else:
check_key_result = False

然后客户的证书是使用 ecdsa 算法生成(生成效率快,体积更小),实施使用 nginx 起容器测试没问题,最后临时给证书检验的改为了 openssl pkey -pubout -in,如果调用命令,难道每个 openssl 子命令循环一边吗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa, ec

def cert_key_type(cert_path: str) -> str:
with open(cert_path, "rb") as f:
data = f.read()

try:
cert = x509.load_pem_x509_certificate(data)
except ValueError:
cert = x509.load_der_x509_certificate(data)

pubkey = cert.public_key()

if isinstance(pubkey, rsa.RSAPublicKey):
return "RSA"
elif isinstance(pubkey, ec.EllipticCurvePublicKey):
return "ECDSA"
else:
return type(pubkey).__name__

以及 cryptography 库还可以生成 ssl 证书,不需要像 openssl 那样产生临时文件调用多次命令。

参数废弃

命令选项的废弃只有运行时候才知道,而如果使用库的话,在代码 lint 层面或者 import 的时候就会报错,避免问题发生时间的滞后。

一些 python 替代

一些文件操作

1
2
3
shutil.copy
shutil.copyfile
shutil.rmtree

通配符文件:

1
glob.glob(f"{IPVS_DIRS}/*.ipvs.conf")

find 类似的路径匹配获取:

1
tag_dirs = [str(p) for p in Path(registry_dir).rglob("*/_manifests/tags/*")]

一些 sdk 经验

开源 sdk 单独的 logger,在 import 的里会有些输出,关闭的话可以在 import 之前关闭。

1
2
logging.getLogger('docker').setLevel(logging.CRITICAL)
logging.getLogger('salt').setLevel(logging.CRITICAL)

使用 ansible runner 默认会保留 tmp 目录,可以:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

import os
import ansible_runner
import logging
import shutil


# 默认的 artifacts 处理器
def default_artifacts_handler(artifacts_dir):
shutil.rmtree(artifacts_dir.split("artifacts/")[0], ignore_errors=True)

# 默认的 event 处理器
def default_event_handler(event):
line = event.get("stdout", "")
if line.strip():
logging.info(line.strip())

def run_ansible_playbook(
playbook,
inventory=None,
user=None,
private_key=None,
limit_hosts=None,
extra_cmdline="",
base_path=None,
become=True,
become_method="sudo",
suppress_output=True,
artifacts_handler=None,
event_handler=None,
):
"""
执行 Ansible playbook 的封装方法

参数:
playbook (str): playbook 文件路径
inventory (str|list): inventory 文件路径,可以是单个路径或路径列表
user (str): SSH 用户名,默认从环境变量 USER 获取
private_key (str): SSH 私钥路径
limit_hosts (list): 限制执行的主机列表
extra_cmdline (str): 额外的命令行参数
base_path (str): 基础路径
become (bool): 是否使用 become
become_method (str): become 方法
suppress_output (bool): 是否抑制 ansible 输出
artifacts_handler (callable): 自定义 artifacts 处理器
event_handler (callable): 自定义 event 处理器

返回:
ansible_runner 的运行结果
"""

artifacts_handler = artifacts_handler or default_artifacts_handler
event_handler = event_handler or default_event_handler
private_key = private_key or "/root/.ssh/id_rsa"

# 构建 ansible 命令行参数
cmdline_parts = []

# 用户名
user = user or os.getenv("USER")
if user:
cmdline_parts.append(f"-u {user}")

# 私钥
if private_key:
cmdline_parts.append(f"--private-key={private_key}")

# become
if become:
cmdline_parts.append("-b")
if become_method:
cmdline_parts.append(f"--become-method={become_method}")

# 限制主机
if limit_hosts:
if isinstance(limit_hosts, list):
cmdline_parts.append(f"--limit={','.join(limit_hosts)}")
else:
cmdline_parts.append(f"--limit={limit_hosts}")

if extra_cmdline:
cmdline_parts.append(extra_cmdline)

ansible_cmdline = " ".join(cmdline_parts)

if isinstance(inventory, str):
inventory_list = [inventory]
elif isinstance(inventory, list):
inventory_list = inventory
else:
inventory_list = []

# 执行 ansible
r = ansible_runner.run(
inventory=inventory_list,
playbook=playbook,
cmdline=ansible_cmdline,
artifacts_handler=artifacts_handler,
event_handler=event_handler,
settings={"suppress_ansible_output": suppress_output},
)

return r

salt -N xxx module.name args 实际上可以看 cat $(which salt) 找下源码,可以使用 salt.client

1
2
3
4
5
import salt.client
salt_client = salt.client.LocalClient()
# result = {'10.xx.xx.xxx': True, '10.xxx.xx.xxx': 'Minion did not return. xxxx'}
result = salt_client.cmd(tgt='*', fun='pillar.items', arg=["data_dir"], tgt_type="glob")
print(result)
CATALOG
  1. 1. 由来
  2. 2. 案例
    1. 2.1. 命令注入
    2. 2.2. 僵尸进程
    3. 2.3. 客户监控
    4. 2.4. 命令限制和禁止
    5. 2.5. 行为控制和错误处理
    6. 2.6. 文件权限
    7. 2.7. 后续扩展性
    8. 2.8. 默认参数
    9. 2.9. tty
    10. 2.10. 输出变更
    11. 2.11. 引发事故
    12. 2.12. 没考虑到的场景
    13. 2.13. 参数废弃
  3. 3. 一些 python 替代
    1. 3.1. 一些文件操作
    2. 3.2. 一些 sdk 经验