Python指定网卡发送HTTP请求的实现方法
本文介绍在Python多网卡环境中,如何通过修改socket的source_address参数来指定特定网卡发送HTTP请求。文章详细解析了urllib和requests库的底层实现,提供了针对urllib3的适配方案及解决ConnectionError异常的代码示例,帮助开发者在多IP场景下精确控制网络出口。
多网卡环境下指定HTTP请求出口的需求
在多网卡服务器或主机上,有时需要确保特定的HTTP请求通过指定的物理网卡发出,以实现流量隔离或特定IP出口。本文将探讨如何在Python中实现这一需求。
$ curl --interface eth0 www.baidu.com # curl interface 可以指定网卡
基于urllib的底层实现原理
首先分析Python标准库urllib的源码,追踪HTTP请求的创建流程,发现其最终会调用httplib模块,进而实例化HTTPConnection对象。
在HTTPConnection对象的初始化过程中,可以观察到其接受一个名为source_address的参数,该参数用于指定绑定的源地址。
当建立连接时,HTTPConnection会调用内部的_create_connection方法,该方法底层依赖于socket.create_connection函数。
# 先看一下本地网卡信息
$ ifconfig
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=3<RXCSUM,TXCSUM>
inet6 ::1 prefixlen 128
inet 127.0.0.1 netmask 0xff000000
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
nd6 options=1<PERFORMNUD>
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether c8:e0:eb:17:3a:73
inet6 fe80::cae0:ebff:fe17:3a73%en0 prefixlen 64 scopeid 0x4
inet 192.168.20.2 netmask 0xffffff00 broadcast 192.168.20.255
nd6 options=1<PERFORMNUD>
media: autoselect
status: active
en1: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=4<VLAN_MTU>
ether 0c:5b:8f:27:9a:64
inet6 fe80::e5b:8fff:fe27:9a64%en8 prefixlen 64 scopeid 0xa
inet 192.168.8.100 netmask 0xffffff00 broadcast 192.168.8.255
nd6 options=1<PERFORMNUD>
media: autoselect (100baseTX <full-duplex>)
status: active
通过查看本地网络配置,可以看到如en0、en1等网卡均具备访问公网的能力,而lo0为本地回环接口。
Socket绑定测试与验证
为了验证原理,可以直接对socket.py模块进行修改测试。
def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT,
source_address=None):
"""If *source_address* is set it must be a tuple of (host, port)
for the socket to bind as a source address before making the connection.
An host of '' or port 0 tells the OS to use the default.
source_address 如果设置, 必须是传递元组 (host, port), 默认是 ("", 0)
"""
host, port = address
err = None
for res in getaddrinfo(host, port, 0, SOCK_STREAM):
af, socktype, proto, canonname, sa = res
sock = None
try:
sock = socket(af, socktype, proto)
# sock.bind(("192.168.20.2", 0)) # en0
# sock.bind(("192.168.8.100", 0)) # en1
# sock.bind(("127.0.0.1", 0)) # lo0
if timeout is not _GLOBAL_DEFAULT_TIMEOUT:
sock.settimeout(timeout)
if source_address:
print "socket bind source_address: %s" % source_address
sock.bind(source_address)
sock.connect(sa)
return sock
except error as _:
err = _
if sock is not None:
sock.close()
if err is not None:
raise err
else:
raise error("getaddrinfo returns an empty list")
参考官方文档说明,可以在创建Socket时分别绑定不同网卡的IP地址,并将端口号设置为0,由操作系统自动分配。
# 测试 en0
$ python -c 'import urllib as u;print u.urlopen("http://ip.haschek.at").read()'
.148.245.16
# 测试 en1
$ python -c 'import urllib as u;print u.urlopen("http://ip.haschek.at").read()'
.94.115.227
# 测试 lo0
$ python -c 'import urllib as u;print u.urlopen("http://ip.haschek.at").read()'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 87, in urlopen
return opener.open(url)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 213, in open
return getattr(self, name)(url)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 350, in open_http
h.endheaders(data)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 1049, in endheaders
self._send_output(message_body)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 893, in _send_output
self.send(msg)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 855, in send
self.connect()
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 832, in connect
self.timeout, self.source_address)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/socket.py", line 578, in create_connection
raise err
IOError: [Errno socket error] [Errno 49] Can't assign requested address
测试结果显示,在多网卡环境中,只要在创建Socket时绑定特定网卡的IP且端口设为0,即可实现指定网卡通信。若端口未设为0,第二次发起请求时可能会因端口被占用而抛出异常。
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 87, in urlopen
return opener.open(url)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 213, in open
return getattr(self, name)(url)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/urllib.py", line 350, in open_http
h.endheaders(data)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 1049, in endheaders
self._send_output(message_body)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 893, in _send_output
self.send(msg)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 855, in send
self.connect()
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/httplib.py", line 832, in connect
self.timeout, self.source_address)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/socket.py", line 577, in create_connection
raise err
IOError: [Errno socket error] [Errno 48] Address already in use
在实际项目中应用时,只需将socket.create_connection函数的source_address参数设置为对应网卡的(IP, 0)元组即可。
# test-interface_urllib.py
import socket
import urllib, urllib2
_create_socket = socket.create_connection
SOURCE_ADDRESS = ("127.0.0.1", 0)
#SOURCE_ADDRESS = ("172.28.153.121", 0)
#SOURCE_ADDRESS = ("172.16.30.41", 0)
def create_connection(*args, **kwargs):
in_args = False
if len(args) >=3:
args = list(args)
args[2] = SOURCE_ADDRESS
args = tuple(args)
in_args = True
if not in_args:
kwargs["source_address"] = SOURCE_ADDRESS
print "args", args
print "kwargs", str(kwargs)
return _create_socket(*args, **kwargs)
socket.create_connection = create_connection
print urllib.urlopen("http://ip.haschek.at").read()
经过测试验证,数据确实通过指定的网卡发出,且源IP地址与所绑定网卡的IP一致。
Requests库的适配与异常处理
然而,许多爬虫项目依赖requests库,需确认其是否支持此功能。测试发现,requests并未直接使用Python内置的socket模块,而是通过其他封装层处理连接。
进一步查看requests的源码,其创建Socket连接的方式与urllib类似,具体实现细节此处省略。
由于测试环境使用Python 2.7,可以定位到requests底层实际调用的socket模块为urllib3.utils.connection中的相关代码。
修改urllib3的方法与修改urllib差异不大,主要涉及对连接创建函数的替换。
import urllib3.connection
_create_socket = urllib3.connection.connection.create_connection
# pass
urllib3.connection.connection.create_connection = create_connection
# pass
运行修改后的代码时,可能会遇到requests.exceptions.ConnectionError: Max retries exceeded with .. Invalid argument异常。
该异常并非每次必现,通常与IP段及跳转递归层数过多有关。解决方法是移除kwargs中的socket_options参数,注意127.0.0.1地址可能会触发此异常。
import socket
import urllib
import urllib2
import urllib3.connection
import requests as req
_default_create_socket = socket.create_connection
_urllib3_create_socket = urllib3.connection.connection.create_connection
SOURCE_ADDRESS = ("127.0.0.1", 0)
#SOURCE_ADDRESS = ("172.28.153.121", 0)
#SOURCE_ADDRESS = ("172.16.30.41", 0)
def default_create_connection(*args, **kwargs):
try:
del kwargs["socket_options"]
except:
pass
in_args = False
if len(args) >=3:
args = list(args)
args[2] = SOURCE_ADDRESS
args = tuple(args)
in_args = True
if not in_args:
kwargs["source_address"] = SOURCE_ADDRESS
print "args", args
print "kwargs", str(kwargs)
return _default_create_socket(*args, **kwargs)
def urllib3_create_connection(*args, **kwargs):
in_args = False
if len(args) >=3:
args = list(args)
args[2] = SOURCE_ADDRESS
in_args = True
args = tuple(args)
if not in_args:
kwargs["source_address"] = SOURCE_ADDRESS
print "args", args
print "kwargs", str(kwargs)
return _urllib3_create_socket(*args, **kwargs)
socket.create_connection = default_create_connection
# 因为偶尔会出问题, 所以使用默认的 socket.create_connection
# urllib3.connection.connection.create_connection = urllib3_create_connection
urllib3.connection.connection.create_connection = default_create_connection
print " *** test requests: " + req.get("http://ip.haschek.at").content
print " *** test urllib: " + urllib.urlopen("http://ip.haschek.at").read()
print " *** test urllib2: " + urllib2.urlopen("http://ip.haschek.at").read()
需要注意的是,直接修改urllib3.utils.connection可能无效,需采用其他手段。
自动化获取网卡IP的优化方案
为进一步完善方案,可以编写代码根据网卡名称自动获取对应的IP地址。
import subprocess
def get_all_net_devices():
sub = subprocess.Popen("ls /sys/class/net", shell=True, stdout=subprocess.PIPE)
sub.wait()
net_devices = sub.stdout.read().strip().splitlines()
# ['eth0', 'eth1', 'lo']
# 这里简单过滤一下网卡名字, 根据需求改动
net_devices = [i for i in net_devices if "ppp" in i]
return net_devices
ALL_DEVICES = get_all_net_devices()
def get_local_ip(device_name):
sub = subprocess.Popen("/sbin/ifconfig en0 | grep '%s ' | awk '{print $2}'" % device_name, shell=True, stdout=subprocess.PIPE)
sub.wait()
ip = sub.stdout.read().strip()
return ip
def random_local_ip():
return get_local_ip(random.choice(ALL_DEVICES))
# code ...
实现方式是将args[2]和kwargs["source_address"]的值替换为调用random_local_ip()或get_local_ip("eth0")函数的结果。
该技术的实际应用场景广泛,具体用途取决于开发者的创意和需求。
以上即为Python中使用指定网卡发送HTTP请求的完整实现方案,供读者参考与实践。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









