内网穿透

一般企业都有内部网络,访问内部服务时,必须使用VPN、远程桌面等手段先连接到内网,才能访问内部服务。

而内网穿透,即通过某些技术手段,将内部服务暴露到公网上,无需VPN即可访问内部服务。

基本原理

前提条件:

  1. 拥有一台可通过公网访问的服务器。记为 公网服务A
  2. 内部网络中有一台既可以访问公网,又可以访问目标内网服务的服务器。记为 内网服务B

如果是 Linux 环境,直接使用 SSH 即可。参见:SSH用于隧道代理的一些场景

如果是 Windows 环境,请继续。

内网穿透的基本做法如下:

  1. 内网服务B 主动访问 公网服务A,并建立连接。
  2. 公网服务A 接收客户端(用户)的访问,并将访问流量转发至 内网服务B,然后接收响应数据并返回给 客户端(用户)
  3. 内网服务B 将收到的从 公网服务A 来的流量转发至 内网目标服务,然后接收响应数据并返回给 公网服务A

如下图:

代码实现

代码地址:https://github.com/dytttf/inpe

关键点:

  • 使用select模块对socket进行轮询

  • 多线程

    如果不使用多线程处理流量的话,网速极差,且很容易超时

  • 心跳

    内网穿透一般会经过防火墙,而防火墙很容易会将空闲连接断开且无任何通知。然后大量连接处于断开状态但服务端确无任何感知,等到使用的时候才会发现断开,导致前期大量请求无效,直到将无效链接消耗完后才能正常访问。

扩展场景

如果你将内网服务B接收的流量转发到一个内网socks代理服务,那么你可以通过这个代理访问任何内网服务

背景

在用 openpyxl 读取 Excel 中的数据时,发现某些单元格读取到的是数字 44712,但打开 Excel 文件却显示的是时间:2022年6月2日

查看单元格格式会发现这个单元格属于自定义格式中的: yyyy”年”m”月”d”日”。这个可以理解,但为啥 openpyxl 读到的不是时间呢?

原因分析

通过查看 openpyxl 的源码,发现其在 openpyxl.styles.numbers.py 中定义了一组格式,如下

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
BUILTIN_FORMATS = {
0: 'General',
1: '0',
2: '0.00',
3: '#,##0',
4: '#,##0.00',
5: '"$"#,##0_);("$"#,##0)',
6: '"$"#,##0_);[Red]("$"#,##0)',
7: '"$"#,##0.00_);("$"#,##0.00)',
8: '"$"#,##0.00_);[Red]("$"#,##0.00)',
9: '0%',
10: '0.00%',
11: '0.00E+00',
12: '# ?/?',
13: '# ??/??',
14: 'mm-dd-yy',
15: 'd-mmm-yy',
16: 'd-mmm',
17: 'mmm-yy',
18: 'h:mm AM/PM',
19: 'h:mm:ss AM/PM',
20: 'h:mm',
21: 'h:mm:ss',
22: 'm/d/yy h:mm',

37: '#,##0_);(#,##0)',
38: '#,##0_);[Red](#,##0)',
39: '#,##0.00_);(#,##0.00)',
40: '#,##0.00_);[Red](#,##0.00)',

41: r'_(* #,##0_);_(* \(#,##0\);_(* "-"_);_(@_)',
42: r'_("$"* #,##0_);_("$"* \(#,##0\);_("$"* "-"_);_(@_)',
43: r'_(* #,##0.00_);_(* \(#,##0.00\);_(* "-"??_);_(@_)',

44: r'_("$"* #,##0.00_)_("$"* \(#,##0.00\)_("$"* "-"??_)_(@_)',
45: 'mm:ss',
46: '[h]:mm:ss',
47: 'mmss.0',
48: '##0.0E+0',
49: '@', }

其中并没有我们想要的 yyyy”年”m”月”d”日”。

然后通过查阅 Excel 的官方文档中关于 NumberingFormat Class 的解释发现 openpyxl 中定义的这些格式属于通用格式,是不区分语种的。

而我们想要找的这种格式,属于汉语中的特殊格式。对于这种特殊格式, Excel 中仅保存一个格式 ID ,但不保存具体格式的定义,格式的定义会随着所在国家发生变化。

解决方案

解决方案很简单,从文档中找到中文对应的格式 ID 和格式字符串的对应关系,然后采用 hook 的方式将其注入 openpyxl 模块中即可。

代码如下:(注意这些代码需要在导入 openpyxl 模块之前执行)

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
# 扩展openpyxl的数字格式
# 此处扩展的是中文格式
extra_formats = {
27: 'yyyy"年"m"月"',
28: 'm"月"d"日"',
29: 'm"月"d"日"',
30: "m-d-yy",
31: 'yyyy"年"m"月"d"日"',
32: 'h"时"mm"分"',
33: 'h"时"mm"分"ss"秒"',
34: '上午/下午h"时"mm"分"',
35: '上午/下午h"时"mm"分"ss"秒"',
36: 'yyyy"年"m"月"',
#
50: 'yyyy"年"m"月"',
51: 'm"月"d"日"',
52: 'yyyy"年"m"月"',
53: 'm"月"d"日"',
54: 'm"月"d"日"',
55: '上午/下午h"时"mm"分"',
56: '上午/下午h"时"mm"分"ss"秒"',
57: 'yyyy"年"m"月"',
58: 'm"月"d"日"',
}
from openpyxl.styles.numbers import BUILTIN_FORMATS

BUILTIN_FORMATS.update(extra_formats)

参考文档:

https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.numberingformat?view=openxml-2.8.1

背景

  最近居家办公,公司的电脑是 windows 环境,由于安全策略,需要远程连接公司内电脑进行办公。
  首先连接 VPN,然后登录网页堡垒机,通过堡垒机调用本地 mstsc 进行远程桌面

问题描述

  家里电脑是 mac ,由于堡垒机只能调用 mstsc ,所以需要 windows 环境来使用,一开始我用的是 Parallels Desktop ,连接我装的双系统中的 windows 环境。这样我可以同时两套环境使用,比较方便。但有两个问题使我不得不放弃了这种方式:

  1. 远程到公司电脑后,分辨率贼高。然后看到的字都非常小,由于公司内的办公电脑没有管理员权限,也无法修复这个问题。
  2. 组合键使用起来有问题,比如 Ctrl + C ,当第一次按下 Ctrl + C 时,远端接收到的只有 C,不松 Ctrl,再次按 C,才是复制功能。然后比如输入一些特殊符号,需要用到 Shift 组合键,比如 Shift + *,远端显示的是 8。临时解决办法是 Ctrl + Shift + * ,才能正确输入 *。

解决方案

  这些问题,坑了我好几天。当我开始使用 Virtual Box + windows 虚拟机后。问题 2,直接就没有了,组合键恢复。但问题 1 分辨率还是有问题,然后通过设置虚拟机的缩放率:控制 -> 显示 -> 缩放率 就可以了。根据自己的需求进行设置就可以,推荐150%。

注意事项

修改中转服务器上的 sshd 服务配置项:GatewayPorts yes 这样当使用 -R 参数进行转发时中转服务器会监听 0.0.0.0 而不是 127.0.0.1

基本用法

1、将 A 机器的某个端口映射到 B 机器的某个端口

例如:

1
ssh -f -N -R 0.0.0.0:80:0.0.0.0:80 root@remote_ip

此命令会将本地机器的 80 端口映射到远程服务器(1.1.1.1)的 80 端口。当别人访问远程服务器的 80 端口时,流量就会被转发到本地的 80端口。

参数解析:

  • -R remote_ip:remote_port:local_ip:local_port 当不指定本地IP和端口时,remote_ip:remote_port 将会表现为 socks4/5代理

  • -f 使ssh命令后台运行

  • -N Do not execute a remote command. This is useful for just forwarding ports.

2、启动 socks 代理服务

在本地启动 socks代理:

1
ssh -N -D 0.0.0.0:1080 root@127.0.0.1

3、SSH 关键参数:

  • -R:将远程的流量接过来

  • -L:将自己的流量转发出去

案例

案例1:云手机走本地 fiddler 代理

准备:

1、一台有公网 IP 的服务器

2、自己的电脑能够通过 ssh 访问 1 的服务器

步骤:

1、本地启动 fiddler

2、本地使用 ssh 将本地 8889 端口(fiddler 端口)映射到服务器的 8889 端口

1
ssh -N -R 0.0.0.0:8889:127.0.0.1:8889 root@remote_ip

3、云手机增加代理:ip 为服务器的公网 ip,端口为 8889

可能的问题:

1、使用不当会造成本地 tcp 连接过多,卡死代理

一句话总结:将有公网IP的服务器的端口流量转发到本地电脑

案例2:远程连接云手机的 frida 服务

准备:

1、云手机开启 frida-server, 端口为 27042

2、云手机可运行 ssh

3、一台有公网 ip 的服务器 A

步骤:

1、云手机 ssh 公钥放到服务器 A 上

2、将云手机的端口映射到服务器 A 上

例如:

1
ssh -p 22 -N -R 0.0.0.0:27042:127.0.0.1:27042 root@A

3、本地通过服务器 A 的 ip:27042 访问云手机的 frida-server

一句话总结:将访问具有公网IP的服务器的流量转发到云手机,转发行为发起人是云手机

案例3:远程连接云手机的 ssh 服务

准备:

1、云手机可运行 ssh

2、一台有公网 ip 的服务器 A

步骤:

1、云手机 ssh 公钥放到服务器 A 上

2、云手机将自己的 22 端口映射到服务器 A 的 8022 端口

例如:

1
ssh -p 22 -NR 0.0.0.0:8022:127.0.0.1:22 root@A_ip

3、本地通过服务器ip_port访问云手机

1
ssh -p 8022 root@A_ip

一句话总结:将访问具有公网IP的服务器的流量转发到云手机,转发行为发起人是云手机

隧道稳定性

SSH 隧道在网络波动的情况下会断掉,所以需要有守护进程来保证断掉重连,推荐使用 autossh

autossh 只能设置免密登录,否则 autossh 不能运行,因为自动重连的时候如果没有免密,是没办法输入密码的

用法参见 autossh 文档

1
autossh -f -M 0 [SSH OPTIONS]

某些时候会出现 ssh 链接仍在,但端口转发失败的情况,此时 autossh 不会自动重启 ssh,可配合定时任务,定时 kill ssh 进程,然后 autossh 会进行重启。

socks 代理应用

案例1:

当公司仅允许通过jumpserver访问服务器,并且禁止了 vpn 的 22 端口时:

  1. 在服务器上启动代理 ssh -p22 -fqND 0.0.0.0:6565 root@127.0.0.1

  2. 本地配合 ProxyCommand

1
ssh -o ProxyCommand='nc -X 5 -x 10.40.34.248:6565 %h %p' root@remote_ip

案例2:

云厂商的服务器公网 IP 都是非住宅类的,而某些应用或服务限制了此类IP,此时可以搭建代理配合 proxifier 使服务器流量走本地。

如下命令,可以在本地启动一个 socks5 代理 监听8880,并将远程机器的

1
ssh -N -R 0.0.0.0:8889 root@remote_ip

将本地端口映射到远程服务器端口, 使远程服务器可通过本地网络上网

前言

MinIO Gateway 是一款可以代理 S3、Azure、Nas、HDFS 等服务的软件。可以让用户以兼容 S3 的方式来访问所代理的服务。

具体介绍见:https://docs.min.io/docs/minio-gateway-for-s3.html

使用场景

一、一套代码支持不同对象存储产品。

当前市面常见的对象存储产品有:

  • 阿里云 OSS
  • 腾讯云 COS
  • 华为云 OBS
  • Amazon S3
  • 开源 MinIO

如果你的服务需要使用对象存储,但不同的场景下使用的对象存储服务并不一致,为了避免增加代码开发中适配多种产品的复杂性,

可以使用 MinIO Gateway 做一层代理,代码中仅需支持 MinIO 的访问方式即可。

二、避免大量开通云服务子帐号。

在公司内部,当我们使用云服务商的对象存储产品时,是必须开通云产品的子帐号才能访问的。而子帐号的开通和管理其实并不是

很方便,并且还没办法接入比如 LDAP 等帐号管理系统。此时也可以将对象存储服务用 MinIO Gateway 做一层代理,然后通过 Gateway

来管理对象存储服务的帐号,支持各种帐号管理方式。比如 Keycloak、 LDAP、内部用户等。

具体参见:https://docs.min.io/docs/minio-sts-quickstart-guide

MinIO Gateway 搭建

基本搭建步骤参考官方文档:https://docs.min.io/docs/minio-gateway-for-s3.html

可以使用 docker 或者二进制文件等方式启动 Gateway 服务。

下面说点不一样的

MinIO 编译

编译很简单,MinIO 使用 Go 语言开发,所以需要安装 Go,参见:https://go.dev/doc/install

然后源码中已经有写好的 Makefile,直接执行 make 即可。

适配腾讯云 COS

MinIO Gateway 在启动时会随机生成一个 Bucket 名称,然后利用检查 Bucket 是否存在的 API 确认 S3 服务是否可用,期待的返回状态码是 404,

但腾讯云 COS 强制每个 Bucket 的名字后缀为一串数字 ID,如果不符合格式,则响应 400,导致 MinIO Gateway 探测失败。

日志如下:

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
"""
---------START-HTTP---------
GET /probe-bucket-sign-99lrqve1qm4x/?location= HTTP/1.1
Host: cos.ap-beijing.myqcloud.com
User-Agent: MinIO (darwin; amd64) minio-go/v7.0.20
Authorization: AWS4-HMAC-SHA256 Credential=AKID2uWrwlJabKnzwd3CCwPbWBZhZBWZLr64/20220118/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=**REDACTED**
X-Amz-Content-Sha256: UNSIGNED-PAYLOAD
X-Amz-Date: 20220118T155622Z
Accept-Encoding: gzip

HTTP/1.1 400 Bad Request
Content-Length: 437
Connection: keep-alive
Content-Type: application/xml
Date: Tue, 18 Jan 2022 15:56:25 GMT
Server: tencent-cos

<?xml version='1.0' encoding='utf-8' ?>
<Error>
<Code>InvalidURI</Code>
<Message>Could not parse the specified URI.</Message>
<Resource>cos.ap-beijing.myqcloud.com/probe-bucket-sign-99lrqve1qm4x</Resource>
</Error>

---------END-HTTP---------
"""

在 MinIO 官方以及腾讯云官方都不做修改的情况下,只能通过修改源码重新编译来解决问题。

代码修改位置如下:

https://github.com/minio/minio/cmd/gateway/s3/gateway-s3.go

将其中的 randString 函数返回值修改为数字后缀,比如下图:

img

然后编译即可。

使用独立帐号体系

默认情况下,MinIO 自带一套帐号管理体系,不需要任何配置,但缺点是一旦服务重启则帐号信息丢失。

为了持久化存储帐号数据,需要配合 Etcd 服务。

Etcd部署参见:

https://github.com/etcd-io/etcd/releases

MinIO Gateway 启动方式如下:

1
2
3
4
5
6
7
#!/bin/sh
export MINIO_ROOT_USER="Access Key"
export MINIO_ROOT_PASSWORD="Access Secret"
export _MINIO_SERVER_DEBUG=off # 是否开启DEBUG 仅为了查看日志 on|off
export MINIO_ETCD_ENDPOINTS=http://localhost:2379 # 使用 ETCD 持久化存储内部用户
export MINIO_ETCD_PATH_PREFIX=minio/ # ETCD 中存储的数据的前缀
./minio gateway s3 https://cos.ap-beijing.myqcloud.com --console-address 0.0.0.0:9100

不过这种方式有个缺点,新创建的 MinIO Gateway 账密是明文存储在 Etcd 中的。。。

解决办法如下:

  1. 控制 Etcd 的访问权限,避免被其他人访问。

  2. 使用 MinIO 自己的加密方式,参考:https://docs.min.io/docs/minio-kms-quickstart-guide.html。但加密这个功能,加密的地方有点多,而且有点小BUG,比如展示出来的S3文件列表不全。。。所以我放弃了。

集成 LDAP

官方文档:https://github.com/minio/minio/blob/master/docs/sts/ldap.md

启动方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh
export MINIO_ROOT_USER="Access Key"
export MINIO_ROOT_PASSWORD="Access Secret"
export _MINIO_SERVER_DEBUG=off # 是否开启DEBUG 仅为了查看日志 on|off
export MINIO_IDENTITY_LDAP_SERVER_ADDR="LDAP服务器:LDAP服务端口"
export MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN="{LDAP 帐号}" # 例如 cn=readonly,dc=test,dc=com 仅用只读帐号即可
export MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD="{LDAP 密码}"
export MINIO_IDENTITY_LDAP_USER_DN_SEARCH_BASE_DN='ou=People,dc=test,dc=com' # 搜索域
export MINIO_IDENTITY_LDAP_USER_DN_SEARCH_FILTER='(uid=%s)' # 用来过滤登录的帐号 %s会被填充用户名
export MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY=on
export MINIO_IDENTITY_LDAP_SERVER_INSECURE=on
export MINIO_IDENTITY_LDAP_SERVER_STARTTLS=off
./minio gateway s3 https://cos.ap-beijing.myqcloud.com --console-address 0.0.0.0:9100

集成 LDAP 的好处就是不用单独开通帐号即可使用,但比较麻烦的是,只能通过命令行 mc 来对用户进行授权。而且当需要给公司外部人员开通帐号时,也必须得开通 LDAP 帐号,可能面临一定的风险。

集成 Audit Log 功能(审计日志)

默认情况下,当仅仅使用 MinIO 的 Gateway 功能时,Admin API 以及很多特性比如 Audit log 都是没有开放的。

这个时候就又到了改代码重新编译的时候。

首先是启用 Admin API,修改代码位置如下:

https://github.com/minio/minio/cmd/gateway-main.go

将下图所示地方改为 true:

然后重新编译即可。

下面是开启 Audit log。参见:https://github.com/minio/operator/tree/master/logsearchapi

按照文档步骤,启动 postgres 数据库,然后启动 logsearchapi 服务,这些都没有问题。

postgres启动:

1
2
3
4
5
6
7
docker run -d \
--name postgres \
-p 5432:5432 \
-e POSTGRES_PASSWORD="xxx" \
-e PGDATA=/var/lib/postgresql/data/pgdata \
-v /data/postgres:/var/lib/postgresql/data \
postgres:14.1 -c "log_statement=all"

logsearchapi启动(注意 logsearchapi 命令也是需要下载源码然后编译的)

1
2
3
4
5
export LOGSEARCH_PG_CONN_STR="postgres://postgres:xxx@localhost/postgres?sslmode=disable"
export LOGSEARCH_AUDIT_AUTH_TOKEN=logsearch_audit
export MINIO_LOG_QUERY_AUTH_TOKEN=logsearch_query
export LOGSEARCH_DISK_CAPACITY_GB=5
./logsearchapi

但当我尝试使用 mc admin 命令设置 aduit_webhook 参数时,却会出现莫名奇妙的错误,一番尝试后放弃,改用其他方式。

将如下代码加入启动脚本即可:

1
2
3
4
5
6
# audit log 功能
export MINIO_AUDIT_WEBHOOK_ENABLE_1="on" # audit log 功能
export MINIO_AUDIT_WEBHOOK_ENDPOINT_1="http://localhost:8080/api/ingest?token=logsearch_audit" # audit log 功能
export MINIO_LOG_QUERY_URL="http://localhost:8080"
export MINIO_LOG_QUERY_AUTH_TOKEN="logsearch_query"
export LOGSEARCH_QUERY_AUTH_TOKEN="logsearch_query"

背景

一个用来运行 Python 代码的 Jupyter 服务,由于某些原因,将 Python 的 pip 包安装目录使用 s3fs 挂载到了 MinIO。

然后就发生了一个很奇怪的现象,当使用

1
! pip install module

安装某个包,然后代码中使用

1
import module

来导入包时,总会报错显示找不到包。但当重启了 jupyter 服务后,import module 就会正常运行。

排查步骤及原因分析

一. 确认在 !pip install module 执行之后,在 import 执行前,对应库的安装文件确实存在。

通过如下代码确认包安装后的路径:

1
2
3
import module

print(module)

结果是文件确实存在。

二. 研究 Python 的导包机制,分析为何没有找到包。

具体源码参见 importlib 标准库。通过追踪如下代码的调用链

1
importlib.import_module("module")

发现有几个可疑点使用了缓存:

  1. sys.path_importer_cache

此对象数据结构就是一个字典。key 是路径,value 是查找器对象(比如 importlib._bootstrap_external.FileFinder )

每次对包的查找,都会使用这个缓存来记录查找过的路径。这样下次就可以很快的定位,主要是为了提高找包效率。

当导入一个包时,包所在的目录及其上级目录都会在 sys.path_importer_cache 里有记录。这样当第二次导入同目录下的包时,就不需要再次遍历子目录。

但同样的,如果缓存的 value 出了问题,那么就会影响此路径下包的导入

  1. importlib._bootstrap_external.FileFinder

此对象在构建时会缓存指定路径下的目录列表。避免多次调用操作系统接口来获取目录,也是为了提高效率。

但目录下的文件在安装包之后是会变化的,所以 FileFinder 本身也有一个机制来发现目录的变化,用的是目录的 mtime 修改时间

1
2
3
4
5
mtime = _path_stat(self.path or _os.getcwd()).st_mtime

// posix.stat(path).st_mtime

// nt.stat(path).st_mtime

当发现目录的 mtime 发生了变化时,会刷新缓存。

看起来很合理,也确实合理,但就是还是找不到包。

三. 重点锁定 FileFinder 的缓存机制,测试是否触发了缓存刷新。

通过输出 !pip install module 执行前后,对应目录的 mtime, 发现没有变化而且都是 0。

此时问题其实就定位到了 s3fs 挂载的目录无法修改 mtime 的问题。

正常的操作系统目录,当目录内有文件发生改变时,目录的 mtime 也会相应发生改变。而通过 s3fs 挂载的目录则不会。

也没有办法修改。

猜测一下原因:

&ensp;&ensp;S3本来是没有目录的概念的,所谓的目录仅仅是对同样前缀的文件的虚拟,并不是真实存在的一个东西。

但一般文件系统的目录是真实存在于磁盘上。所以如果要实现目录内文件修改的同时同步修改目录属性,则

s3fs 就需要额外的地方来存储这些信息,但这些信息无论是放在主机上,还是放在S3上都不太合适。一旦支

持了,就需要考虑多主机挂载同一目录时的同步问题。

解决方案

&ensp;&ensp;s3fs 这个问题无法解决,所以目录一旦缓存就无法自动刷新。

&ensp;&ensp;所能做的就是在每次 !pip install module 之后手动将 sys.path_importer_cache 置为空字典,强制触发磁盘扫描。

参考文档

https://docs.python.org/zh-cn/3/library/sys.html#sys.path_importer_cache

https://www.cnblogs.com/zhaojiedi1992/p/zhaojiedi_linux_031_linuxtime.html

背景

有一个很简单的爬虫项目,没啥反爬,代码也很简单,数据量也不大,就是每天都需要运行一遍,并且数据持久化存储。

解决方案参考

方案一(ECS)

阿里云 ECS:https://help.aliyun.com/document_detail/25398.html

方案说明:
买台服务器,想干啥干啥。根据自己的需求购买相应的配置。

优点:

  1. 非常简单易用,不需要多说了

缺点:

  1. 贵。最便宜的服务器也要一个月30+。

方案二(函数计算)

白嫖阿里云函数计算的免费额度:https://help.aliyun.com/document_detail/54301.html

  • 调用次数:每月前100万次函数调用免费。
  • 函数实例资源使用量:每月前400,000 GB-秒函数实例资源使用量免费。

方案说明:
代码部署有两种方式:

  1. 使用阿里云提供的函数计算环境来部署,阿里云提供了 python、node、php、java、go等运行时环境。详见文档:https://help.aliyun.com/document_detail/73338.html。
  2. 使用自定义的 docker 镜像提供服务
    推荐使用第 2 种方式,第一种方式仅适合非常简单的应用。因为运行时环境版本是固定的,而且依赖安装起来有不少问题。
    代码部署完成后,如果是定时任务,可选用定时器触发执行。其他类型,可选用 http 调用触发执行。

优点

  1. 按量计费,且有免费额度,一般的小应用免费额度就够用了, 一分钱不用花。

缺点

  1. 最大的缺点是:应用每次运行有时长限制,最大值 120 秒。所以如果你的代码每次运行会超过120秒,那就没办法用这个了。
  2. 调试不方便
  3. 以 python 为例,纯 python的第三方依赖安装没有问题,但一旦涉及到 C 扩展,则依赖无法安装成功。
  4. 需要 docker 使用经验

方案三(Serverless 容器服务 ASK

阿里云 Serverless 容器服务 ASK:

无需创建和管理 Master 节点及 Worker 节点,即可通过控制台或者命令配置容器实例的资源、指定应用容器镜像以及对外服务的方式,直接启动应用程序。

方案说明:
一个 k8s 集群,可以运行任何应用。定时任务可采用 k8s 的 CronJob 方式运行

优点:

  1. 可扩展性极强,一个 k8s 集群想干啥干啥
  2. 按量计费,没有固定节点费用。
  3. 简单易用

缺点:

  1. k8s 集群必须创建一个负载均衡用来提供k8s api服务。最低配的费用 72 元/月
  2. 如果你的应用需要访问公网,必须配置一个 NAT 网关。最低配的费用 165 元/月
  3. 需要 k8s 使用经验

方案四(Serverless 应用引擎 SAE

阿里云 Serverless 应用引擎 SAE:https://help.aliyun.com/document_detail/96732.html

  • CPU 0.0030864元/分钟/Core
  • 内存 0.0007716元/分钟/GiB

方案说明:

  1. 使用自定义 docker 镜像部署一个应用
  2. 设置定时启停规则

优点:

  1. 部署简单
  2. 按量计费
  3. 可以使用钉钉机器人接收应用启动和停止通知。

缺点:

  1. 只能使用定时的方式启动和停止应用,所以需要预估应用运行时间,避免造成不必要的时间浪费或者提前被停止
  2. 如果需要访问公网的话,必须提供购买弹性IP。按量付费的话:配置费用 14 元/月, 流量费用 0.8 元/G
  3. 需要提前预估应用耗费的 CPU 和 内存用来选择规格。

我的选择

方案四(Serverless 应用引擎 SAE)

  1. 爬虫代码运行时间是会超过120秒的
  2. k8s 太贵了,而且我也用不到那么多复杂的功能。
  3. ECS 也太贵了

具体实行方案:

  1. 代码打包成 docker 镜像,docker 镜像托管使用阿里云的容器镜像服务,个人版是免费的,而且同属内网,也会加快镜像拉取速度。
  2. 注意启动命令应该是先运行代码,然后阻塞住。如果不阻塞的话,容器在运行完代码后会退出,那么 SAE 服务会自动重启容器,然后你的代码就会再次运行,如此周而复始。。。
  3. 配置 SAE 时不需要选择健康探测,也不需要在容器里启动 HTTP 服务。
  4. 很重要的一个是数据持久化存储,我选择使用 OSS 服务,将采集后的数据存入 SQLite 数据库, 然后将数据库上传到 OSS 进行持久化存储。
  5. 每次修改代码只需要在本地打包成 docker 镜像,然后上传镜像即可。
  6. 可以使用钉钉机器人接收数据采集统计信息。

前言

记录一下很常见,但也很容易忽略的一些问题。主要是跟 Web 服务器相关的。

本文涉及到的名词有:

  • cgi、fastcgi、wsgi、uwsgi、gunicore、nginx、httpd

  • Web 服务器

  • WSGI 服务

  • Web 框架

Web服务演进

基本架构:

1
Client(用户) -> Web服务器 -> 网页源码
  1. 支持最基本的文件访问

    1
    2
    3
    """
    Client -> Web服务器(apache) -> 静态文件
    """
  2. 实现动态生成文件功能

    1
    2
    3
    4
    """
    Client -> Web服务器(apache) -> 静态文件
    | -> cgi -> 动态生成文件
    """
  3. 提升 cgi 效率

    1
    2
    3
    4
    """
    Client -> Web服务器(apache) -> 静态文件
    | -> fastcgi -> 动态生成文件
    """
  4. 提升静态文件效率效率

    1
    2
    3
    4
    """
    Client -> Web服务器(nginx / apache) -> 静态文件
    | -> fastcgi -> 动态生成文件
    """
  5. 某些语言的特定实现方式

    1
    2
    3
    4
    5
    6
    7
    8
    """
    Client -> Web服务器(nginx / apache) -> 静态文件
    | -> uwsgi -> flask、django -> 动态生成文件
    """
    """
    Client -> Web服务器(nginx / apache) -> 静态文件
    | -> php-fpm(fastcgi) -> php文件 -> 动态生成文件
    """

通用规范

CGI

即 Common Gateway Interface(通用网关接口) 见

为什么要有 CGI?

早期的 Web 服务器仅能访问静态网页。你所访问的所有网页内容都是提前生成好放到服务器的上的,所谓的网页访问就是读取服务器上的某个文件然后返回内容。

但CGI 使动态网页成为可能。

CGI 如何实现的?

通过定义一系列的通信规范来实现。

简而言之就是将 url、header、params 放入环境变量里,当你访问一个 CGI 文件时,Web 服务器会执行此 CGI 文件(脚本)获取输出然后返回给浏览器。你可以在 CGI 脚本中返回任意内容。

CGI 程序可以用任何脚本语言或者编程语言实现,只要该语言可以在系统上运行。

例如:http://www.example.com/wiki.cgi

以前比较常用的是 Perl 来实现 CGI 脚本。

CGI环境变量示例:https://www.runoob.com/python/python-cgi.html

CONTENT_TYPE 这个环境变量的值指示所传递来的信息的MIME类型。目前,环境变量CONTENT_TYPE一般都是:application/x-www-form-urlencoded,他表示数据来自于HTML表单。
CONTENT_LENGTH 如果服务器与CGI程序信息的传递方式是POST,这个环境变量即使从标准输入STDIN中可以读到的有效数据的字节数。这个环境变量在读取所输入的数据时必须使用。
HTTP_COOKIE 客户机内的 COOKIE 内容。
HTTP_USER_AGENT 提供包含了版本数或其他专有数据的客户浏览器信息。
PATH_INFO 这个环境变量的值表示紧接在CGI程序名之后的其他路径信息。它常常作为CGI程序的参数出现。
QUERY_STRING 如果服务器与CGI程序信息的传递方式是GET,这个环境变量的值即使所传递的信息。这个信息经常跟在CGI程序名的后面,两者中间用一个问号’?’分隔。
REMOTE_ADDR 这个环境变量的值是发送请求的客户机的IP地址,例如上面的192.168.1.67。这个值总是存在的。而且它是Web客户机需要提供给Web服务器的唯一标识,可以在CGI程序中用它来区分不同的Web客户机。
REMOTE_HOST 这个环境变量的值包含发送CGI请求的客户机的主机名。如果不支持你想查询,则无需定义此环境变量。
REQUEST_METHOD 提供脚本被调用的方法。对于使用 HTTP/1.0 协议的脚本,仅 GET 和 POST 有意义。
SCRIPT_FILENAME CGI脚本的完整路径
SCRIPT_NAME CGI脚本的的名称
SERVER_NAME 这是你的 WEB 服务器的主机名、别名或IP地址。
SERVER_SOFTWARE 这个环境变量的值包含了调用CGI程序的HTTP服务器的名称和版本号。例如,上面的值为Apache/2.2.14(Unix)

CGI 的缺点?

一般每次的 CGI 请求都需要新生成一个程序的副本来运行,这样大的工作量会很快将服务器压垮。

因此一些更有效的技术像 mod_perl,可以让脚本解释器直接作为模块集成在 Web 服务器(例如:Apache)中,这样就能避免重复加载和初始化解释器。

不过这只是就那些需要解释器的高级语言(即解释语言,例如python)而言的,使用诸如C一类的编译语言则可以避免这种额外负荷。

由于C及其他编译语言的程序与解释语言程序相比,前者的运行速度更快、对操作系统的负荷更小,使用编译语言程序是可能达到更高执行效率的.

然而因为开发效率等原因,在目前解释型语言还是最合适的。

FastCGI

即 FastCommon Gateway Interface(快速通用网关接口)见:https://zh.wikipedia.org/wiki/FastCGI

为了解决 CGI 效率低下的问题而进行的改进。

CGI 会对每个请求创建一个进程,在请求结束时销毁。开销太大。

而 FastCGI 会使用进程池来处理请求,避免频繁开启进程的开销。

特定语言实现

WSGI

即 Python Web Server Gateway Interface(Python Web 服务器网关接口)

起初是为 Python 定义的 Web 服务器与 Web 应用程序之间的接口。后来一些其他语言也参考实现了自己的 wsgi

为什么要有WSGI?

抄自PEP-3333:

Python 当前拥有各种各样的 Web 应用程序框架,例如 Zope,Quixote,Webware,SkunkWeb,PSO 和 Twisted Web-仅举几例。对于Python的新用户来说,各种各样的选择可能是个问题,因为通常来说,他们对 Web 框架的选择将限制他们对可用 Web 服务器的选择,反之亦然。

相比之下,尽管 Java 具有许多可用的 Web 应用程序框架,但是Java的 Servlet API 使得使用任何 Java Web 应用程序框架编写的应用程序都可以在支持 Servlet API 的任何 Web 服务器中运行。

WSGI 规范的实现?

定义了两类对象:

  1. Web 服务器:例如 uWSGI、gunicorn

  2. Web 应用程序:Flask、Django 的 application

Web 应用程序只需要实现一个函数即可:

1
2
3
def application(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/html')])
return [b'<h1>Hello, web!</h1>']

Web 服务器收到请求后会调用 application 函数

wsgi 的 environment示例:

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
{
'HTTP_ACCEPT': '*/*',
'HTTP_ACCEPT_ENCODING': 'gzip, deflate',
'HTTP_CONNECTION': 'keep-alive',
'HTTP_HOST': 'localhost:6666',
'HTTP_USER_AGENT': 'python-requests/2.18.4',
'PATH_INFO': '/hello',
'QUERY_STRING': '',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': 61415,
'REQUEST_METHOD': 'GET',
'SCRIPT_NAME': '',
'SERVER_NAME': '0.0.0.0',
'SERVER_PORT': '6666',
'SERVER_PROTOCOL': 'HTTP/1.1',
'SERVER_SOFTWARE': 'Werkzeug/0.14.1',
'werkzeug.server.shutdown': '<function WSGIRequestHandler.make_environ.<locals>.shutdown_server at 0x108a02378>',
'wsgi.errors': "<_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>",
'wsgi.input': '<_io.BufferedReader name=7>',
'wsgi.multiprocess': False,
'wsgi.multithread': False,
'wsgi.run_once': False,
'wsgi.url_scheme': 'http',
'wsgi.version': (1, 0)
}

WSGI 的好处?

  • 如果没有的话,Web 框架就只能通过 Socket 接收原始数据,自己实现 HTTP 协议的解析。但这些事情其实是不必要的,交给专业的人来做更好。
  • 一般 WSGI 服务器都是 C 写的,效率更高。
  • 对 Web 框架的使用者来说,他们并不关心如何接收 HTTP 请求,也不关心如何将请求路由到具体方法处理并将响应结果返回给用户。Web 框架的使用者在大部分情况下,只需要关心如何实现业务的逻辑。

Flask 干了啥?

flask.Flask 的

1
2
3
def __call__(self, environ, start_response):
"""Shortcut for :attr:`wsgi_app`."""
return self.wsgi_app(environ, start_response)

Django干了啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# django.core.handlers.wsgi.py
class WSGIHandler(base.BaseHandler):
request_class = WSGIRequest

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_middleware()

def __call__(self, environ, start_response):
...
status = '%d %s' % (response.status_code, response.reason_phrase)
response_headers = [
*response.items(),
*(('Set-Cookie', c.output(header='')) for c in response.cookies.values()),
]
start_response(status, response_headers)
if getattr(response, 'file_to_stream', None) is not None and environ.get('wsgi.file_wrapper'):
response = environ['wsgi.file_wrapper'](response.file_to_stream)
return response

uwsgi VS uWSGI

uwsgi 是一个 uWSGI 服务器的专有协议,参见

uWSGI 是一个Web 服务器,实现了 wsgi、uwsgi 两种协议

uWSGI 配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
[uwsgi]
http = :8000
socket = 127.0.0.1:3031
wsgi-file = backend/wsgi.py
callable = app
chdir = /opt/baelish/src
processes = 4
threads = 16
plugins = python3
max-requests = 100000
master = true
harakiri=30

uWSGI 很复杂 有兴趣的可以研究一下

混淆点:

  1. WSGI: Python Web Server Gateway Interface

  2. uWSGI: 实现了 WSGI、uwsgi 两种协议的一个Web服务器

  3. uwsgi: uWSGI 服务器自定义的一种协议

为啥我不用 uWSGI 也可以启动 Web 服务?

如果你看源码的话,会发现其实是启动了个 Python 内置的 WSGI 服务器。

gunicore

跟 uWSGI 同属实现了 WSGI 协议的 Web 服务器。前身是 ruby 的 unicore。

PHP

php 本质上是一个 CGI 脚本。所以 PHP 写的网站都是.php结尾的文件。

php-fpm 是一个实现了 fastcgi 规范的进程管理服务

Web服务器

Nginx

作用很多,不一一说明了。

1、静态文件访问功能(很优秀)

2、服务转发

3、负载均衡

4、虚拟主机:不同域名访问同一个IP、同一个主机上的不同服务

一般生产服务会使用 nginx + uwsgi + flask 部署,

静态文件请求使用 nginx 处理,动态请求直接转发到 uWSGI

那么为啥 nginx 不能直接接 flask?

  1. 因为 nginx 不会 python
  2. 如果在 nginx 中直接用 WSGI, 那么 nginx 线程中就要启动 python 解释器,效率太低。

Apache

感觉已经过时了,不过曾经很火,因为出现的比较早,所以还有很多老系统在用。

IIS

微软开发windows服务器上运行

其他语言

Java 现在流行内置 tomcat 了

php 最近也有一些内置 http server 的框架,性能远超跑 fpm 里的框架

ruby 版的 wsgi 叫 rack

go 语言则是标准库内置 http 服务

nodejs 也是内置 http

参考文档

https://www.python.org/dev/peps/pep-3333/

https://juejin.im/post/6844904202825646093

http://www.nowamagic.net/academy/detail/1330211

背景

在一次线上问题排查中,发现 uWSGI 在某些情况下,会出现假僵尸进程的现象。

问题描述

服务部署很简单,常规的 Django + uWSGI 模式。

uWSGI 配置如下:

1
2
3
4
5
6
[uwsgi]
http = :8888
processes = 8
threads = 1
master = true
lazy-apps = true

在 uWSGI 的访问日志中,会夹杂一些 500 的响应,但 500 并不是因为 Django 应用代码报错产生的。

而是:

1
2
--- no python application found, check your startup logs for errors ---
[pid: 1139|app: -1|req: -1/720133] 10.1.0.1 () {54 vars in 1184 bytes} [Tue Nov 16 13:47:19 2021] POST /api/v1/test => generated 21 bytes in 0 msecs (HTTP/1.0 500) 2 headers in 83 bytes (0 switches on core 0)

然后经过日志分析,发现 500 的请求都是由 uWSGI 的某两个固定子进程所处理的。

uWSGI服务一共启动了8个子进程,其中有两个子进程出现了问题,导致服务接口有 1/4的概率调用失败。

那么问题来了。

  1. 为什么会出现两个,而不是全部都有问题?

  2. 看现象是 uWSGI 的子进程的问题,为什么 uWSGI 没有自动处理这些有问题的子进程?

原因分析

针对第一个问题,经过日志分析,定位到在 500 响应第一次出现的时间点里,发生过内存不够的现象,

某个请求内存不够导致了其所在 worker 的重启,但重启的时候内存还是不太够,虽然 worker 进程启

动成功,但在加载 Django 应用代码的时候加载失败。如下图

此时就出现了,某些 worker 正常, 某些 worker 异常的情况。

针对第二个问题,就需要去了解一下 uWSGI 的运行机制了。首先看一下在 worker 启动的时候,uWSGI 会做什么。

以下代码出自 uWSGI 源码:https://github.com/unbit/uwsgi/

一个基本的调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
uwsgi_init <= uwsgi.main.c || core/uwsgi.c

-> uwsgi_setup <= uwsgi.c

-> uwsgi_worker_run

-> uwsgi_init_all_apps

-> init_apps(uwsgi_python_init_apps) <= plugins/python/python_plugin.c

-> init_uwsgi_app <= plugins/python/pyloader.c

-> uwsgi_file_loader
"""

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// plugins/python/pyloader.c 
// init_uwsgi_app
wi->callable = up.loaders[loader](arg1);
if (!wi->callable) {
uwsgi_log("unable to load app %d (mountpoint='%s') (callable not found or import error)\n", id, wi->mountpoint);
goto doh;
}
//
doh:
if (PyErr_Occurred())
PyErr_Print();
return -1;

当 uWSGI 尝试加载 python 代码时,如果加载失败,则返回 appid 为 -1 ;

然后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// core/uwsgi.c
// uwsgi_init_all_apps

// no app initialized and virtualhosting enabled
if (uwsgi_apps_cnt == 0 && uwsgi.numproc > 0 && !uwsgi.command_mode) {
if (uwsgi.need_app) {
if (!uwsgi.lazy)
uwsgi_log("*** no app loaded. GAME OVER ***\n");
if (uwsgi.lazy_apps) {
if (uwsgi.master_process) {
if (kill(uwsgi.workers[0].pid, SIGINT)) {
uwsgi_error("kill()");
}
}
}
exit(UWSGI_FAILED_APP_CODE);
}
else {
uwsgi_log("*** no app loaded. going in full dynamic mode ***\n");
}
}

当 worker 加载 app 失败时,会根据配置项决定是直接退出还是进入 dynamic 模式(默认配置)。

所以第二个问题的答案是: uWSGI 不认为这些进程是异常的,因此不作处理。

优化措施

最容易想到的一个优化措施是,在加载 app 失败的时候重试几次,但查了一下 uWSGI 的配置项后,

发现并没有相关选项,其实在分析代码过程中也能看出来,根本没有类似功能的代码。

那么只能从现有选项上着手尝试解决了。

先列一下涉及到的一些配置项:

  • master: 官方文档强烈推荐开启,但关于此选项的解释又很少

这里简单总结一下,启用 master 模式的话,uwsgi会启动 (1 + 1 + processes)个进程。其中第一个为 uWSGI主进程,第二个为 master 进程,专门用来管理 worker。之后会启动指定数量的 worker 进程。

至于为啥不用主进程来管理 worker, 这个问题我还不是很清楚。

master 进程的好处基本都在这里写完了:https://uwsgi-docs-zh.readthedocs.io/zh_CN/latest/articles/TheArtOfGracefulReloading.html

在 master 模式下,如果 worker 进程被 KILL,那么会自动被 master 进程重启,而非 master 模式则不会,并且 worker 进程会变成僵尸进程。

  • dynamic-apps: 所谓 dynamic 模式,就是指可以在每一个请求中附加参数来指定处理该请求的 app。

uWSGI 的多个 worker 之间是可以分别加载不同的应用代码的. 参见:https://uwsgi-docs.readthedocs.io/en/latest/DynamicApps.html

  • lazy-apps: 简单来说就是在 worker 进程启动完成后才开始加载 app 代码,

  • lazy: 同 lazy-apps,但已废弃,不推荐使用。

  • need-app: 如果 worker 没有加载到 app, 则直接退出。

  • max-requests: 设置 worker 处理的最大请求数量, 如果超过最大值则重启 worker.

  • max-requests-delta: 防止 worker 同时到达最大值而设置的一个差异值,会将每个 worker 的 max-requests 设置为 max-requests + ( worker_id * delta )

  • min-worker-lifetime: worker 重启的最小间隔描述,默认60

  • max-worker-lifetime: worker 将会在设置的秒数之后被重启, 默认不启用

  • max-worker-lifetime-delta: 类似于 max-requests-delta

  • worker-reload-mercy: worker 在被重启前默认的最大等待时间,worker 可以在这段时间内处理尚未完成的请求.

第一种解决方式:

&ensp;&ensp;暴力一些,设置 need-app 为 true, 这样当加载失败的时候直接退出 uWSGI, 不过这个依赖于服务重启策略,如果采用 docker 部署,可以采用自动重启来保证服务稳定性。

第二种解决方式:

&ensp;&ensp;优雅一些, 设置 max-worker-lifetime, 这样当出现异常 worker 时,不会让异常 worker 一直处于异常的状态。

不同的方式其实适用于不同的场景。

&ensp;&ensp;第一种方式的影响就是在重启期间完全不可用。

&ensp;&ensp;第二种方式的影响是在 worker 异常期间有一定概率服务请求失败。

单纯这么比较的话,貌似第二种稍微好一点,但第一种方式其实有很多办法做到用户完全无感知的。

举个例子:

&ensp;&ensp;在使用 k8s 进行应用部署的时候,将后端服务部署的 Deployment 中 Pod 格式设置为大于1, 并且使用 Service 来访问后端服务,

这样的话,当某个后端 Pod 重启,则完全不会影响服务可用性。

总结一下:没有绝对通用的策略,有的只是根据应用场景设计出来的适合的策略。

思考

uWSGI 为何不提供一种模式来处理随机加载失败的情况呢?

可能的原因是:

1、这个场景很少,没遇到过(概率极低)

2、没有很完美的处理方式,比如很简单的处理方式是加载失败时重试,可设置重试次数,重试间隔等。但这个也无法保证一定可以加载成功。

3、存在其他手段,例如设置 worker 生存时间来处理。

以前所不曾关注到的点

uWSGI 其实并不是一个简单易用的 Web 服务器,参考官方文档的定义:

The best definition for uWSGI is “Swiss Army Knife for your network applications”.

对 uWSGI 的最好形容是网络应用的瑞士军刀,可以应对任何情况,但前提是你得很熟悉它的用法。

uWSGI 并不是为 Python 而开发的,它最开始是在 Perl 语言中使用的, 而且也不叫 uWSGI.

参考

使用 uWSGI 前必读

&ensp;&ensp;https://uwsgi-docs.readthedocs.io/en/latest/Management.html

&ensp;&ensp;https://uwsgi-docs.readthedocs.io/en/latest/FAQ.html?highlight=ProcessManagement#why-should-i-choose-uwsgi

&ensp;&ensp;https://uwsgi-docs-zh.readthedocs.io/zh_CN/latest/articles/TheArtOfGracefulReloading.html

github issue:

&ensp;&ensp;https://github.com/unbit/uwsgi/issues/2374

前言

本文主要是记录一下在使用 IMAP 协议访问网易邮箱时遇到的一个很常见的问题以及之后我是如何解决并一步一步发现 QQ 邮箱 BUG 的过程。

发现问题

由于工作需要,开发的系统中需要访问用户设置的邮箱内的邮件列表。我们选用的方式是采用 IMAP 协议进行读取。简化后的代码如下:

1
2
3
4
5
import imaplib

client = imaplib.IMAP4_SSL(host="imap.163.com", port=993)
client.login("username", "password")
client.select()

一般邮箱都没有问题,但在访问网易邮箱时,select 命令返回的结果是:

1
# ('NO', [b'SELECT Unsafe Login. Please contact kefu@188.com for help'])

解决问题

原因分析

首先,百度一下很容易就知道答案,此处不赘述。可参考文章最后的引用链接。

简述一下就是:

网易邮箱服务器会检查 IMAP 客户端有没有发送 ID 命令来表明自己的身份,如果没有,则拒绝访问。

关于 ID 命令,ID 命令的设计初衷是为了让服务器可以统计不同客户端的使用情况,但并非强制的。

协议中明确表示,服务器不得以 ID 命令内的信息而对客户端拒绝提供服务。

详细内容可参见 RFC2971 的定义:https://datatracker.ietf.org/doc/html/rfc2971。

解决方案

原因知道之后,解决方案其实很简单,登陆之后再发送一个ID命令就好了。代码示例如下:

1
2
3
4
5
6
7
import imaplib
imaplib.Commands["ID"] = "NONAUTH"

client = imaplib.IMAP4_SSL(host="imap.163.com", port=993)
client.login("username", "password")
client._simple_command("ID",'("name" "test" "version" "0.0.1")')
client.select()

引申一下

解决方案很简单,但总感觉不够完美,少了点啥。

在实际应用中,用户会访问不同的邮箱的,那么就需要考虑以下两个问题:

  1. 是否有可能某些邮箱服务器不支持 ID 命令?

  2. 如果邮箱支持 ID 命令,但并不强制使用,会对其他正常功能有影响吗?

第一个问题,这个我觉得应该是有的,毕竟 ID 并非标准协议中的一部分,有可能某些邮箱服务器开发的比较早,并且无人维护了。所以针对这种情况是需要测试 IMAP 对于不认识的命令是如何处理的。

第二个问题,应该是不会有影响的,不过简单测试一下以求个安心。

于是,我先试了一下 QQ 邮箱。

先说第二个问题的测试结果,QQ 邮箱是没有啥影响的,这个也不重要,跳过就好。

但第一个问题有点出乎意料,测试方式很简单,分别向网易邮箱和 QQ 邮箱发送不支持的命令,看一下是否会影响接下来的使用即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
import imaplib

imaplib.Debug = 100
imaplib.Commands["XXX"] = "NONAUTH"

host = "imap.163.com"
# host = "imap.qq.com"

client = imaplib.IMAP4_SSL(host=host, port=993)
client._simple_command("XXX", '("name" "aaa")')
client.select()

其中网易邮箱在接收到未知命令时,返回结果是:

1
# b'PMGB1 BAD command not support' 

日志如图

这个符合预期,直接捕获一下异常即可。

但 QQ 邮箱在接收到未知命令时,返回的结果是:

1
# b'* BAD Command!'

日志如图

且代码在这里直接卡住了,等待几分钟后:

1
ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接

QQ 邮箱服务器主动断开了跟我的链接。

WTF ! ! !

网易邮箱的抛出异常,我可以直接捕获跳过即可,对程序主流程无影响。但 QQ 邮箱卡在这里是啥意思???

为了搞清楚这个事情,又开始了对代码卡住的分析。

代码分析过程不赘述,各种断点调试一通,最终发现,代码卡在了等待邮箱服务器返回数据的部分。

但从调试日志中可以看到,QQ 邮箱服务器已经返回了数据:

1
# b'* BAD Command!'

为啥还要等呢?难道数据有问题?

然后又是一通搜索,在 IMAP RFC3501 定义得到了答案,其中 2.2.1 节定义了邮箱服务端的返回数据格式规范:

返回数据的第一部分应该是 tag、*、+ 其中之一。

  • tag 是客户端在发送命令时携带的一个唯一标识,主要用于客户端区分收到的响应是那个命令的响应
  • * 代表此命令的相应内容尚未完结,应继续等待后续响应
  • + 代表服务端继续等待客户端发送尚未发送完的命令

因此,这里 QQ 邮箱返回的 b’* BAD Command!’ 是不对的,应该是 b’tag BAD Command!’。

至此,感觉已经没有什么可做的事情了。

不过,我还是给 QQ 邮箱发了封邮件,简单描述了一下问题,也不知道他们改不改,等结果吧。。。

然后我的应用代码改成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
import imaplib

client = imaplib.IMAP4_SSL(host="imap.163.com", port=993)
client.login("username", "password")
typ, dat = client.select()
if typ != "OK":
try:
client._simple_command("ID", '("name" "test" "version" "0.0.1")')
except Exception as e:
print(e)
typ, dat = client.select()
if typ != "OK":
raise Exception("邮箱登录失败: {} {}".format(typ, dat))

后记

待回复

不相关知识点整理

  • POP3 可以认为是只读的协议,客户端内的操作不会影响到服务端,只用来下载邮件

  • IMAP 是双向的协议,客户端的操作会反馈到服务端上,服务端也同步进行操作,不仅可以下载邮件,还可以删除,移动邮件,但不能发送邮件

  • SMTP 是用来发送邮件的

参考文档

IMAP4 ID 扩展

&ensp;&ensp; https://datatracker.ietf.org/doc/html/rfc2971

IMAP4

&ensp;&ensp;https://www.rfc-editor.org/rfc/inline-errata/rfc3501.html

第三方邮件客户端收取163邮件问题

&ensp;&ensp;https://www.cnblogs.com/chjbbs/p/9858672.html

IMAP 与 POP3 有什么不同?

&ensp;&ensp;http://help.163.com/10/0203/17/5UK7GVU100753VB9.html?servCode=6020251

什么是POP3、SMTP及IMAP?

&ensp;&ensp;https://help.mail.163.com/faqDetail.do?code=d7a5dc8471cd0c0e8b4b8f4f8e49998b374173cfe9171305fa1ce630d7f67ac21b87735d7227c217

imap连接提示Unsafe Login,被阻止的收信行为

&ensp;&ensp;https://help.mail.163.com/faqDetail.do?code=d7a5dc8471cd0c0e8b4b8f4f8e49998b374173cfe9171305fa1ce630d7f67ac211b1978002df8b23

Python解决的办法

&ensp;&ensp;https://blog.csdn.net/jony_online/article/details/108638571