cinder RPC 分析
[TOC]
我們都知道在Cinder內部,各組件之間通訊是通過RPC api,比如cinder-api創建卷,會通過RPC通知scheduler,然后scheduler再選擇一個cinder-volume服務,rpc通知創建卷。
AMQP通信協議
Openstack 組件內部的 RPC(Remote Producer Call)機制的實現是基于 AMQP(Advanced Message Queuing Protocol)作為通訊模型,從而滿足組件內部的松耦合性。AMQP 是用于異步消息通訊的消息中間件協議,AMQP 模型有四個重要的角色:
- Exchange:根據 Routing key 轉發消息到對應的 Message Queue 中
- Routing key:用于 Exchange 判斷哪些消息需要發送對應的 Message Queue
- Publisher:消息發送者,將消息發送的 Exchange 并指明 Routing Key,以便 Message Queue 可以正確的收到消息
- Consumer:消息接受者,從 Message Queue 獲取消息
消息發布者 Publisher 將 Message 發送給 Exchange 并且說明 Routing Key。Exchange 負責根據 Message 的 Routing Key 進行路由,將 Message 正確地轉發給相應的 Message Queue。監聽在 Message Queue 上的 Consumer 將會從 Queue 中讀取消息。
Routing Key 是 Exchange 轉發信息的依據,因此每個消息都有一個 Routing Key 表明可以接受消息的目的地址,而每個 Message Queue 都可以通過將自己想要接收的 Routing Key 告訴 Exchange 進行 binding,這樣 Exchange 就可以將消息正確地轉發給相應的 Message Queue。圖 2 就是 AMQP 消息模型。
AMQP 定義了三種類型的 Exchange,不同類型 Exchange 實現不同的 routing 算法:
- Direct Exchange:Point-to-Point 消息模式,消息點對點的通信模式,Direct Exchange 根據 Routing Key 進行精確匹配,只有對應的 Message Queue 會接受到消息
- Topic Exchange:Publish-Subscribe(Pub-sub)消息模式,Topic Exchange 根據 Routing Key 進行模糊匹配,只要符合模式匹配的 Message Queue 都會收到消息。支持Routing-key用或#的模式,進行綁定。匹配一個單詞,#匹配0個或者多個單詞。例如,binding key *.user.# 匹配routing key為 usd.user和eur.user.db,但是不匹配user.hello。
- Fanout Exchange:廣播消息模式,Fanout Exchange 將消息轉發到所有綁定到它的 Message Queue,而不考慮routing key的值。
OpenStack 目前支持的基于 AMQP 模型的 RPC backend 有 RabbitMQ、QPid、ZeroMQ,對應的具體實現模塊在 lib\site-packages\oslo_messaging\ _drivers目錄下,impl_*.py 分別為對應的不同 backend 的實現。cinder默認使用RabbitMQ。
RabbitMQ 介紹
作為消息隊列(MQ),是一種應用程序對應用程序的通信方法。MQ是消費(consumer)-生產者(proceduer)模型的一個典型的代表,一端往消息隊列中不斷寫入消息,而另一端則可以讀取或者訂閱隊列中的消息。
MQ的用處是在項目中,將一些無需即時返回且耗時的操作提取出來,進行異步處理,而這種異步處理的方式大大的節省了服務器的請求響應時間,從而提高了系統的吞吐量。
常用指令
-
創建用戶:
rabbitmqctl add_user <username> <password>
[root@localhost ~]# rabbitmqctl add_user wyue wyue
Creating user "wyue" ...
-
查看所有用戶:
rabbitmqctl list_users
[root@localhost ~]# rabbitmqctl list_users
Listing users ...
guest [administrator]
wyue []
stackrabbit []
關閉rabbitmq:
rabbitmqctl stop_app
還原:
rabbitmqctl reset
啟動:
rabbitmqctl start_app
設置權限:
rabbitmqctl set_permissions -p / root ".*" ".*" ".*"
在創建用戶后,必須對用戶設置權限,否則連接會被拒絕!-
查看所有隊列信息:
rabbitmqctl list_queues
所列格式是 queue_name | queue_length ,
圖片.png -
查看所有Exchange信息:
rabbitmqctl list_exchanges
所列格式是 exchange_name | exchange_type , exchange是以name作為唯一的,如果你之前定義了一個exchange(name='task',type='topic'),下次又定義exchange(name='task',type='direct')是會報錯的!!
圖片.png -
查看所有consumer信息:
rabbitmqctl list_consumers
圖片.png
只有程序對queue添加consumer后,用list_consumers指令才能看到這個consumer的信息,如果程序運行結束,consumer就不再對queue做監聽了,用list_consumers指令便不能再查到之前consumer的信息。
我們了解下常用指令就行。python中已經有專門的工具庫kombu,接下來我們看看它是什么玩意。
Kombu
Kombu是一個為Python寫的消息庫,目標是為AMQ協議提供一個傻瓜式的高層接口,讓Python中的消息傳遞變得盡可能簡單,并且也提供一些常見消息傳遞問題的解決方案。可參考《Kombu:Python的消息庫》
消息隊列的使用過程
- 客戶端連接到消息隊列服務器,打開一個channel。
- 客戶端聲明一個exchange,并設置相關屬性。
- 客戶端聲明一個queue,并設置相關屬性。
- 客戶端使用routing key,在exchange和queue之間建立好綁定關系。
- 客戶端投遞消息到exchange。
- exchange接收到消息后,就根據消息的key和已經設置的binding,進行消息路由,將消息投遞到一個或多個隊列里。
范例
我們寫個rabbitmq的通信,producer通過direct類型的exchange發送‘hello’到rabbitmq的隊列,然后consumer從隊列中取出。
注意:
- kombu最好升級版本到4.1.0,之前我使用4.0,運行的時候打開AMQP channel的時候報異常,提示WinError 10042。
- 如果連接rabbitmq新用戶,請確認用戶已經賦權,否則會被rabbitmq拒絕連接。
entity.py:
from kombu import Exchange, Queue
# 定義direct類型的exchange,另外還有topic/fanout兩種類型
task_exchange = Exchange('tasks', type='direct')
# 創建一個隊列,定義routing_key,并且跟exchange做綁定
task_queue = Queue('wy_test_queue', task_exchange, routing_key='wy_test1')
send.py
from kombu import Connection
from kombu.messaging import Producer
from entity import task_exchange
from kombu.transport.base import Message
# 創建連接
connection = Connection('amqp://wyue:wyue@172.24.3.200:5672//')
# 在連接里建立一個通道
with connection.channel() as channel:
# 初始化消息
message = Message(channel=channel, body='Hello')
# 定義消息發布者,綁定exchange
producer = Producer(channel, exchange=task_exchange)
# 選用routing_key,發布消息
producer.publish(message.body, routing_key='wy_test1')
運行send.py后,檢查rabbitmq隊列里wy_test_queue有一條信息。
[root@localhost ~]# rabbitmqctl list_queues|grep wy
wy_test_queue 1
recv.py:
from kombu import Connection
from kombu.messaging import Consumer
from entity import task_queue
connection = Connection('amqp://wyue:wyue@172.24.3.200:5672//')
def process_media(body, message):
"""消息回調函數"""
print body
# 確認消息已經收到
message.ack()
if __name__ == '__main__':
with connection.channel() as channel:
# 對隊列創建一個消息消費者
consumer = Consumer(channel, task_queue)
# 注冊回調
consumer.register_callback(process_media)
consumer.consume()
# 一直循環,除非收到某個事件退出,比如socket超時
while True:
connection.drain_events()
運行send.py后,檢查rabbitmq隊列里wy_test_queue已經沒有信息。
而openstack,對底層MQ也做了封裝。openstack能夠支持多種MQ底層,其中也支持rabbitmq。
oslo_messaging
openstack 使用工具包來實現rpc調用。openstack社區把RPC相關的功能作為OpenStack的一個依賴庫。其實oslo.messaging庫就是把rabbitmq的python庫做了封裝,考慮到了編程友好、性能、可靠性、異常的捕獲等諸多因素。讓各個項目的開發者聚焦于業務代碼的編寫,而不用考慮消息如何發送和接收。我們上文討論的源碼,多屬于oslo_messaging。
Cinder RPC加載
在cinder-api啟動時候,代碼cinder.cmd.api.main里可以看到rpc.init(CONF)做RPC的加載。
cinder.rpc.init:
def init(conf):
global TRANSPORT, NOTIFICATION_TRANSPORT, NOTIFIER
exmods = get_allowed_exmods()
# 初始化RPC用的transport
TRANSPORT = messaging.get_transport(conf,
allowed_remote_exmods=exmods,
aliases=TRANSPORT_ALIASES)
# 初始化RPC notification用的transport
NOTIFICATION_TRANSPORT = messaging.get_notification_transport(
conf,
allowed_remote_exmods=exmods,
aliases=TRANSPORT_ALIASES)
# get_notification_transport has loaded oslo_messaging_notifications config
# group, so we can now check if notifications are actually enabled.
if utils.notifications_enabled(conf):
# 定義序列化工具
json_serializer = messaging.JsonPayloadSerializer()
serializer = RequestContextSerializer(json_serializer)
NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT,
serializer=serializer)
else:
NOTIFIER = utils.DO_NOTHING
調用oslo_messaging的get_transport方法。get_transport是一個工廠方法,可根據配置文件里的transport_url生成不同后端的TRANSPORT對象。
oslo_messaging.transport.get_transport:
def get_transport(conf, url=None, allowed_remote_exmods=None, aliases=None):
"""A factory method for Transport objects.
This method will construct a Transport object from transport configuration
gleaned from the user's configuration and, optionally, a transport URL.
If a transport URL is supplied as a parameter, any transport configuration
contained in it takes precedence. If no transport URL is supplied, but
there is a transport URL supplied in the user's configuration then that
URL will take the place of the URL parameter. In both cases, any
configuration not supplied in the transport URL may be taken from
individual configuration parameters in the user's configuration.
An example transport URL might be::
rabbit://me:passwd@host:5672/virtual_host
and can either be passed as a string or a TransportURL object.
:param conf: the user configuration
:type conf: cfg.ConfigOpts
:param url: a transport URL
:type url: str or TransportURL
:param allowed_remote_exmods: a list of modules which a client using this
transport will deserialize remote exceptions
from
:type allowed_remote_exmods: list
:param aliases: A map of transport alias to transport name
:type aliases: dict
"""
allowed_remote_exmods = allowed_remote_exmods or []
# 導入'transport_url','rpc_backend','control_exchange'三個配置,配置說明見下文分析
conf.register_opts(_transport_opts)
# 把url(cinder.conf:transport_url = rabbit://stackrabbit:secret@172.24.3.200:5672/)轉換成oslo_messaging.transport.TransportURL對象
if not isinstance(url, TransportURL):
url = TransportURL.parse(conf, url, aliases)
# url打印出: <TransportURL transport='rabbit', hosts=[<TransportHost hostname='172.24.2.218', port=5672, username='stackrabbit', password='secret'>]>
kwargs = dict(default_exchange=conf.control_exchange,
allowed_remote_exmods=allowed_remote_exmods)
try:
# 在oslo.messaging.driver這個命名空間下,綁定rabbitmq的驅動
mgr = driver.DriverManager('oslo.messaging.drivers',
url.transport.split('+')[0],
invoke_on_load=True,
invoke_args=[conf, url],
invoke_kwds=kwargs)
except RuntimeError as ex:
raise DriverLoadFailure(url.transport, ex)
return Transport(mgr.driver)
transport 配置項列表
配置項 | 默認值 | 說明 |
---|---|---|
transport_url | 無 | A URL representing the messaging driver to use and its full configuration. |
rabbit | The messaging driver to use, defaults to rabbit. Other drivers include amqp and zmq. 已經被棄用,被transport_url取代 | |
control_exchange | openstack | The default exchange under which topics are scoped. May be overridden by an exchange name specified in the transport_url option. |
serializer = RequestContextSerializer(serializer) 是消息的序列化處理,把cinder消息轉換成可以在網絡中傳送的格式。
總結
- cinder在啟動cinder-api服務的時候,把RPC相關環境加載好
- 加載的內容主要是rpc的transport和serializer
- transport是根據配置項transport_url和control_exchange創建的,可看做cinder和rpc后端(如rabbitmq)之間的消息中轉站。
- serializer是序列化工具,用于rpc發送消息的序列化轉換。
Cinder RPC 接口
scheduler和volume都有定義自己的RPC接口,我們以scheduler為例。
rpcapi.py文件開放了RPC api接口,manager.py則是RPC 方法的具體業務實現。
cinder.scheduler.rpcapi.SchedulerAPI繼承自cinder.rpc.RPCAPI
每一個RPC api初始化的時候,都要定義target、serializer、client等,__init__
定義在cinder.rpc.RPCAPI,其它子RPCAPI可直接繼承使用:
cinder.rpc.RPCAPI#init
def __init__(self):
target = messaging.Target(topic=self.TOPIC,
version=self.RPC_API_VERSION)
obj_version_cap = self.determine_obj_version_cap()
serializer = base.CinderObjectSerializer(obj_version_cap)
rpc_version_cap = self.determine_rpc_version_cap()
self.client = get_client(target, version_cap=rpc_version_cap,
serializer=serializer)
而一些特別的參數比如RPC_API_VERSION、TOPIC則由子RPCAPI自己定義:
cinder.scheduler.rpcapi.SchedulerAPI
RPC_API_VERSION = '3.5'
RPC_DEFAULT_VERSION = '3.0'
# cinder/common/constants.py:21定義的 SCHEDULER_BINARY = "cinder-scheduler"
TOPIC = constants.SCHEDULER_TOPIC
BINARY = 'cinder-scheduler'
如果我們要定義rpc api,直接在cinder.scheduler.rpcapi.SchedulerAPI里添加即可。比如我們寫一個say_hello的rpc api 的 demo:
def say_hello(self, ctxt):
version = '3.0'
cctxt = self.client.prepare(version=version)
cctxt.cast(ctxt, 'say_hello')
self.client 來自于cinder.rpc.get_client:
def get_client(target, version_cap=None, serializer=None):
# assert斷言是聲明其布爾值必須為真的判定,如果發生異常就說明表達示為假。
# 這里判斷TRANSPORT如果為空就斷言異常退出,TRANSPORT已經在cinder-api服務啟動時加載好了,可見上文。
assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer)
# 返回了oslo_messaging.rpc.client.RPCClient對象
return messaging.RPCClient(TRANSPORT,
target,
version_cap=version_cap,
serializer=serializer)
self.client.prepare(version=version) ,用于準備rpc環境的上下文,返回oslo_messaging.rpc.client._CallContext對象。
而oslo_messaging.rpc.client._CallContext繼承了oslo_messaging.rpc.client._BaseCallContext。_BaseCallContext有兩個重要的RPCClient方法,分別是call和cast。
oslo_messaging.rpc.client._BaseCallContext#cast:
def cast(self, ctxt, method, **kwargs):
"""Invoke a method and return immediately. See RPCClient.cast()."""
# 對request消息做格式序列化
msg = self._make_message(ctxt, method, kwargs)
# 對rpc上下文做格式序列化
msg_ctxt = self.serializer.serialize_context(ctxt)
# 檢查target的版本是否正確
self._check_version_cap(msg.get('version'))
try:
self.transport._send(self.target, msg_ctxt, msg, retry=self.retry)
except driver_base.TransportDriverError as ex:
raise ClientSendError(self.target, ex)
cast方法就是直接發出序列化的消息到target,不需要接收返回值。這屬于異步調用。
oslo_messaging.rpc.client._BaseCallContext#call:
def call(self, ctxt, method, **kwargs):
"""Invoke a method and wait for a reply. See RPCClient.call()."""
if self.target.fanout:
raise exceptions.InvalidTarget('A call cannot be used with fanout',
self.target)
msg = self._make_message(ctxt, method, kwargs)
msg_ctxt = self.serializer.serialize_context(ctxt)
timeout = self.timeout
if self.timeout is None:
timeout = self.conf.rpc_response_timeout
self._check_version_cap(msg.get('version'))
try:
result = self.transport._send(self.target, msg_ctxt, msg,
wait_for_reply=True, timeout=timeout,
retry=self.retry)
except driver_base.TransportDriverError as ex:
raise ClientSendError(self.target, ex)
return self.serializer.deserialize_entity(ctxt, result)
call發出了rpc消息,在timeout超時時間內,接收到響應信息并反序列化后返回。這是同步調用。
call和cast都是調用self.transport._send,我們來看看self.transport._send方法。
oslo_messaging.transport.Transport#_send
def _send(self, target, ctxt, message, wait_for_reply=None, timeout=None,
retry=None):
if not target.topic:
raise exceptions.InvalidTarget('A topic is required to send',
target)
return self._driver.send(target, ctxt, message,
wait_for_reply=wait_for_reply,
timeout=timeout, retry=retry)
而實際還是通過驅動的rabbitmq實現,即調用oslo_messaging._drivers.impl_rabbit.RabbitDriver的send方法,而這個方法繼承自oslo_messaging._drivers.amqpdriver.AMQPDriverBase的send方法,send又調用了_send。
oslo_messaging._drivers.amqpdriver.AMQPDriverBase#_send:
def _send(self, target, ctxt, message,
wait_for_reply=None, timeout=None,
envelope=True, notify=False, retry=None):
msg = message
# 如果wait_for_reply=True,等待回復。修改msg的數據結構。
if wait_for_reply:
# 生成msg_id。
# uuid.uuid4由偽隨機數得到,轉換16進制。可見參考文檔《Python使用UUID庫生成唯一ID》
msg_id = uuid.uuid4().hex
msg.update({'_msg_id': msg_id})
# _get_reply_q()設置回復信息的msgid,創建一個用于監聽回復消息的socket連接。具體見下文分析
msg.update({'_reply_q': self._get_reply_q()})
# msg結構體增加UNIQUE_ID,作為唯一性標識,避免重復msg
rpc_amqp._add_unique_id(msg)
unique_id = msg[rpc_amqp.UNIQUE_ID]
# 把ctxt上下文整合進msg
rpc_amqp.pack_context(msg, ctxt)
# 對msg做序列化
if envelope:
msg = rpc_common.serialize_msg(msg)
if wait_for_reply:
# msg_id加入監聽隊列,用于接受返回值,msg_id就是監聽的key
self._waiter.listen(msg_id)
log_msg = "CALL msg_id: %s " % msg_id
else:
log_msg = "CAST unique_id: %s " % unique_id
try:
# 創建一個用于發送消息的socket連接
with self._get_connection(rpc_common.PURPOSE_SEND) as conn:
if notify:
exchange = self._get_exchange(target)
log_msg += "NOTIFY exchange '%(exchange)s'" \
" topic '%(topic)s'" % {
'exchange': exchange,
'topic': target.topic}
LOG.debug(log_msg)
conn.notify_send(exchange, target.topic, msg, retry=retry)
elif target.fanout:
log_msg += "FANOUT topic '%(topic)s'" % {
'topic': target.topic}
LOG.debug(log_msg)
conn.fanout_send(target.topic, msg, retry=retry)
else:
topic = target.topic
exchange = self._get_exchange(target)
if target.server:
topic = '%s.%s' % (target.topic, target.server)
# 例如:exchange 'openstack' topic 'cinder-scheduler'
log_msg += "exchange '%(exchange)s'" \
" topic '%(topic)s'" % {
'exchange': exchange,
'topic': topic}
LOG.debug(log_msg)
# 創建exchange,發送給publisher。
conn.topic_send(exchange_name=exchange, topic=topic,
msg=msg, timeout=timeout, retry=retry)
if wait_for_reply:
# oslo_messaging._drivers.amqpdriver.ReplyWaiter#wait 等待rpc返回值
result = self._waiter.wait(msg_id, timeout)
# 如果返回值是個異常類型,則拋出
if isinstance(result, Exception):
raise result
return result
finally:
if wait_for_reply:
self._waiter.unlisten(msg_id)
def _get_reply_q(self):
# 其實是with threading.Lock(),加線程鎖
with self._reply_q_lock:
if self._reply_q is not None:
return self._reply_q
reply_q = 'reply_' + uuid.uuid4().hex
# 創建監聽模式的連接。oslo_messaging定義了兩種PURPOSE用于創建Connection,'listen'和 'send'。'listen'用于讀socket,'send'用于寫socket。
conn = self._get_connection(rpc_common.PURPOSE_LISTEN)
# 初始化回復監聽器
self._waiter = ReplyWaiter(reply_q, conn,
self._allowed_remote_exmods)
self._reply_q = reply_q
self._reply_q_conn = conn
return self._reply_q
oslo_messaging._drivers.impl_rabbit.Connection#topic_send
def topic_send(self, exchange_name, topic, msg, timeout=None, retry=None):
"""Send a 'topic' message."""
# 創建kombu.entity.Exchange對象
exchange = kombu.entity.Exchange(
name=exchange_name,
type='topic',
durable=self.amqp_durable_queues,
auto_delete=self.amqp_auto_delete)
# 其實調用的是oslo_messaging._drivers.impl_rabbit.Connection#_publish,發布消息
self._ensure_publishing(self._publish, exchange, msg,
routing_key=topic, timeout=timeout,
retry=retry)
oslo_messaging._drivers.impl_rabbit.Connection#_publish
def _publish(self, exchange, msg, routing_key=None, timeout=None):
"""Publish a message."""
# 檢查exchange有沒有在_declared_exchanges隊列,如果沒有,就加入。
# _declared_exchanges適用于存儲exchanges的隊列,避免不必要的exchange重新定義。如果connection被重置了,Connection._set_current_channel也會對_declared_exchanges做重置。
if not (exchange.passive or exchange.name in self._declared_exchanges):
exchange(self.channel).declare()
self._declared_exchanges.add(exchange.name)
# NOTE(sileht): no need to wait more, caller expects
# a answer before timeout is reached
with self._transport_socket_timeout(timeout):
# 調用kombu.messaging.Producer#publish,這里就不深入分析了。
self._producer.publish(msg,
exchange=exchange,
routing_key=routing_key,
expiration=timeout,
compression=self.kombu_compression)
oslo_messaging里除了topic_send,還定義了direct_send、fanout_send兩種發送方法。三者對應了AMQP協議中Exchange的3種類型:Direct, Topic, Fanout。通過代碼看,它們的實現都是先定義一個exchange,然后通過oslo_messaging._drivers.impl_rabbit.Connection#_ensure_publishing方法,最后交給kombu.messaging.Producer#publish發布消息。
cinder中有用到的exchange:
exchange_name | exchange_type |
---|---|
cinder-volume.localhost.localdomain@NetAppIscsiBackend_fanout | fanout |
cinder-volume.localhost.localdomain@ceph_fanout | fanout |
cinder-volume_fanout | fanout |
cinder-scheduler_fanout | fanout |
cinder-volume.localhost.localdomain@ceph-image_fanout | fanout |
openstack | topic |
def direct_send(self, msg_id, msg):
"""Send a 'direct' message."""
exchange = kombu.entity.Exchange(name=msg_id,
type='direct',
durable=False,
auto_delete=True,
passive=True)
self._ensure_publishing(self._publish_and_raises_on_missing_exchange,
exchange, msg, routing_key=msg_id)
def fanout_send(self, topic, msg, retry=None):
"""Send a 'fanout' message."""
exchange = kombu.entity.Exchange(name='%s_fanout' % topic,
type='fanout',
durable=False,
auto_delete=True)
self._ensure_publishing(self._publish, exchange, msg, retry=retry)
oslo_messaging._drivers.amqpdriver.ReplyWaiter#wait:
def wait(self, msg_id, timeout):
# NOTE(sileht): for each msg_id we receive two amqp message
# first one with the payload, a second one to ensure the other
# have finish to send the payload
# NOTE(viktors): We are going to remove this behavior in the N
# release, but we need to keep backward compatibility, so we should
# support both cases for now.
timer = rpc_common.DecayingTimer(duration=timeout)
timer.start()
final_reply = None
ending = False
while not ending:
timeout = timer.check_return(self._raise_timeout_exception, msg_id)
try:
message = self.waiters.get(msg_id, timeout=timeout)
except moves.queue.Empty:
self._raise_timeout_exception(msg_id)
reply, ending = self._process_reply(message)
if reply is not None:
# NOTE(viktors): This can be either first _send_reply() with an
# empty `result` field or a second _send_reply() with
# ending=True and no `result` field.
final_reply = reply
return final_reply
其它
oslo.versionedobjects
The oslo.versionedobjects library provides a generic versioned object model that is RPC-friendly, with inbuilt serialization, field typing, and remotable method calls. It can be used to define a data model within a project independent of external APIs or database schema for the purposes of providing upgrade compatibility across distributed services.