总结下一些案例场景,图快和方便调用命令在私有化的问题。
由来 在私有化的开发中,到现场客户反馈问题,聊一聊调用命令的后果和如何浪费了别人的时间。
案例 命令注入 有 dashboard 提供 url 检测,后端代码逻辑为:
取 post 的 ip
拼接 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_max32768 # 可以看到 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 2 3 4 $ echo 111111 $ echo 111 delete$
发现确实,然后去 dashboard 上点击删除的(背后走 k8s api),除了命令限制以外,还有客户不允许执行某些命令,例如 ssh,如果一开始就使用 python 的 paramiko 库就没这种问题了。
行为控制和错误处理 这里以 ssh 命令举例,Linux 的 ssh 和 sshd 的配置都存放在 /etc/ssh/ 下的 ssh_config 和 sshd_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) 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_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_forward1
没考虑到的场景 因为网上很多 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 x509from cryptography.hazmat.primitives.asymmetric import rsa, ecdef 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 osimport ansible_runnerimport loggingimport shutildef default_artifacts_handler (artifacts_dir ): shutil.rmtree(artifacts_dir.split("artifacts/" )[0 ], ignore_errors=True ) 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" 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} " ) 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 = [] 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.clientsalt_client = salt.client.LocalClient() result = salt_client.cmd(tgt='*' , fun='pillar.items' , arg=["data_dir" ], tgt_type="glob" ) print (result)