Iro# Ironic-部署物理机流程代码分析
部署物理机跟部署虚拟机的概念在nova来看是一样,都是nova通过创建虚拟机的方式来触发,只是底层nova-scheduler和nova-compute的驱动不一样。虚拟机的底层驱动采用的libvirt的虚拟化技术,而物理机是采用Ironic技术,ironic可以看成一组Hypervisor API的集合,其功能与libvirt类似。
部署流程
此图是Liberty版的官方裸机部署过程图,部署过程描述如下:
- 通过Nova API给出一个启动实例的请求,Nova API将此消息发送到消息队列Message Queue。
- Nova Schedule从消息队列拿到消息,并应用过滤条件确定目标物理节点,并指定计算节点。Nova Schedule会使用flavor的信息来匹配目标物理节点,比如‘cpu_arch’、‘baremetal:deploy_kernel_id’、‘baremetal:deploy_ramdisk_id’。
- Nova Computer从消息队列获取消息,调用驱动的spawn()函数,执行以下操作:
- 调用ironic API从Ironic Database获取目标物理节点
- 配置用于启动的相关信息,比如镜像id、虚拟网络接口(VIF)、驱动信息
- 调用ironic API 设置ironic node的provision_state为ACTIVE,调用set_provision_state触发部署动作,然后等待部署完成。
- Ironic API接受到set_provision_state请求,发送RPC请求调用Ironic Conductor的do_node_deploy
- Ironic conductor执行do_node_deploy,
从Glance下载镜像到本地磁盘。其中,pxe为前缀的驱动包含deploy ramdisk和user instance images;而agent为前缀的驱动只下载deploy ramdisk镜像,用户实例镜像作为一个对象存储的临时链接被给出。
- 配置虚拟接口VIFs,同时 Neutron API更新DHCP端口用于支持PXE/TFTP选项。
- Nova的ironic驱动通过Ironic API向Ironic conductor发出一个部署请求。
- PXE驱动准备TFTP bootloader。
- IPMI驱动发出从网络启动节点命令并且打开电源。
- 启动deploy ramdisk进行部署。部署完成后Ironic Conductor将pxe配置修改为server模式,并通知ramdisk中的agent部署成功。
- IPMI驱动重启bare metal node。
- 更新bare metal 节点状态信息,节点实例变成可用状态。
废话不多说,下面我们从liberty版的代码入手分析。
部署流程代码分析
配置
在/etc/nova/nova.conf中修改manager和driver
[DEFAULT]
scheduler_host_manager = nova.scheduler.ironic_host_manager.IronicHostManager
compute_driver = nova.virt.ironic.driver.IronicDriver
compute_manager = ironic.nova.compute.manager.ClusteredComputeManager
[ironic]
admin_username = ironic
admin_password = unset
admin_url = http://127.0.0.1:35357/v2.0
admin_tenant_name = service
compute_manager的代码实现是在ironic项目里面。
启动流程
第一步, nova-api接收到nova boot的请求,通过消息队列到达nova-scheduler
第二步, nova-scheduler收到请求后,在scheduler_host_manager里面处理。nova-scheduler会使用flavor里面的额外属性extra_specs,像cpu_arch,baremetal:deploy_kernel_id,baremetal:deploy_ramdisk_id等过滤条件找到相匹配的物理节点,然后发送RPC消息到nova-computer。
第三步,nova-computer拿到消息调用指定的driver的spawn方法进行部署,即调用nova.virt.ironic.driver.IronicDriver.spawn(), 该方法做了什么操作呢?我们来对代码进行分析(下面的代码只保留了主要的调用)。
def spawn(self, context, instance, image_meta, injected_files,
admin_password, network_info=None, block_device_info=None):
#获取镜像信息
image_meta = objects.ImageMeta.from_dict(image_meta)
......
#调用ironic的node.get方法查询node的详细信息,锁定物理机,获取该物理机的套餐信息
node = self.ironicclient.call("node.get", node_uuid)
flavor = instance.flavor
#将套餐里面的baremetal:deploy_kernel_id和baremetal:deploy_ramdisk_id信息
#更新到driver_info,将image_source、root_gb、swap_mb、ephemeral_gb、
#ephemeral_format、preserve_ephemeral信息更新到instance_info中,
#然后将driver_info和instance_info更新到ironic的node节点对应的属性上。
self._add_driver_fields(node, instance, image_meta, flavor)
.......
# 验证是否可以部署,只有当deply和power都准备好了才能部署
validate_chk = self.ironicclient.call("node.validate", node_uuid)
.....
# 准备部署
try:
#将节点的虚拟网络接口和物理网络接口连接起来并调用ironic API进行更新,以便neutron可以连接
self._plug_vifs(node, instance, network_info)
self._start_firewall(instance, network_info)
except Exception:
....
# 配置驱动
onfigdrive_value = self._generate_configdrive(
instance, node, network_info, extra_md=extra_md,
files=injected_files)
# 触发部署
try:
#调用ironic API,设置provision_state的状态ACTIVE
self.ironicclient.call("node.set_provision_state", node_uuid,
ironic_states.ACTIVE,
configdrive=configdrive_value)
except Exception as e:
....
#等待node provision_state为ATCTIVE
timer = loopingcall.FixedIntervalLoopingCall(self._wait_for_active,
self.ironicclient,
instance)
try:
timer.start(interval=CONF.ironic.api_retry_interval).wait()
except Exception:
...
nova-compute的spawn的步骤包括:
- 获取节点
- 配置网络信息
- 配置驱动信息
- 触发部署,设置ironic的provision_state为ACTIVE
- 然后等待ironic的node provision_state为ACTIVE就结束了。
第四步 ironic-api接收到了provision_state的设置请求,然后返回202的异步请求,那我们下来看下ironic在做什么?
首先,设置ironic node的provision_stat为ACTIVE相当于发了一个POST请求:PUT /v1/nodes/(node_uuid)/states/provision。那根据openstack的wsgi的框架,注册了app为ironic.api.app.VersionSelectorApplication的类为ironic的消息处理接口,那PUT /v1/nodes/(node_uuid)/states/provision的消息处理就在ironic.api.controllers.v1.node.NodeStatesController的provision方法。
@expose.expose(None, types.uuid_or_name, wtypes.text,
wtypes.text, status_code=http_client.ACCEPTED)
def provision(self, node_ident, target, configdrive=None):
....
if target == ir_states.ACTIVE:
#RPC调用do_node_deploy方法
pecan.request.rpcapi.do_node_deploy(pecan.request.context,
rpc_node.uuid, False,
configdrive, topic)
...
然后RPC调用的ironic.condutor.manager.ConductorManager.do_node_deploy方法,在方法中会先检查电源和部署信息,其中部署信息检查指定的节点的属性是否包含驱动的要求,包括检查boot、镜像大小是否大于内存大小、解析根设备。检查完之后调用ironic.condutor.manager.do_node_deploy方法
def do_node_deploy(task, conductor_id, configdrive=None):
"""Prepare the environment and deploy a node."""
node = task.node
...
try:
try:
if configdrive:
_store_configdrive(node, configdrive)
except exception.SwiftOperationError as e:
with excutils.save_and_reraise_exception():
handle_failure(
e, task,
_LE('Error while uploading the configdrive for '
'%(node)s to Swift'),
_('Failed to upload the configdrive to Swift. '
'Error: %s'))
try:
#调用驱动的部署模块的prepare方法,不同驱动的动作不一样
#1. pxe_* 驱动使用的是iscsi_deploy.ISCSIDeploy.prepare,
#然后调用pxe.PXEBoot.prepare_instance()进行cache images、 update DHCP、
#switch pxe_config、set_boot_device等操作
#cache images 是从glance上取镜像缓存到condutor本地,
#update DHCP指定bootfile文件地址为condutor
#switch pxe_config将deploy mode设置成service mode
#set_boot_device设置节点pxe启动
#2. agent_* 因为AgentDploy从硬盘启动,所以这里什么也不做
task.driver.deploy.prepare(task)
except Exception as e:
...
try:
#调用驱动的deploy方法,不同驱动动作不一样
#1. pxe_* 驱动调用iscsi_deploy.ISCSIDeploy.deploy()
#进行拉取instance image,创建keystone token,为next boot 更新DHCP 端口
#然后重启物理机
#2. agent_* 驱动什么事也不干,直接重启
new_state = task.driver.deploy.deploy(task)
except Exception as e:
...
# NOTE(deva): Some drivers may return states.DEPLOYWAIT
# eg. if they are waiting for a callback
if new_state == states.DEPLOYDONE:
task.process_event('done')
elif new_state == states.DEPLOYWAIT:
task.process_event('wait')
finally:
node.save()
至此,ironic-conductor的动作完成,等待物理机进行上电。
值得说明的是,task是task_manager.TaskManager的一个对象,这个对象在初始化的时候将self.driver初始化了
self.driver = driver_factory.get_driver(driver_name or self.node.driver)
driver_name是传入的参数,默认为空;这个self.node.driver指物理机使用的驱动,不同物理机使用的驱动可能不同,这是在注册物理机时指定的。
第五步
在上一步中已经设置好了启动方式和相关网络信和给机器上电了,那么下一步就是机器启动,进行部署了。
PXE
我们知道安装操作系统的通用流程是:首先,bios启动,选择操作系统的启动(安装)模式(此时,内存是空白的),然后根据相关的安装模式,寻找操作系统的引导程序(不同的模式,对应不同的引导程序当然也对应着不同的引导程序存在的位置),引导程序加载文件系统初始化程序(initrd)和内核初始镜像(vmlinuz),完成操作系统安装前的初始化;接着,操作系统开始安装相关的系统和应用程序。
PXE启动方式的过程为:
- 物理机上电后,BIOS把PXE client调入内存执行,客户端广播DHCP请求
- DHCP服务器(neutron)给客户机分配IP并给定bootstrap文件的放置位置
- 客户机向本网络中的TFTP服务器索取bootstrap文件
- 客户机取得bootstrap文件后之执行该文件
- 根据bootstrap的执行结果,通过TFTP服务器(conductor)加载内核和文件系统
- 在内存中启动安装
启动后运行init启动脚本,那么init启动脚本是什么样子的。
首先,我们需要知道当前创建deploy-ironic的镜像,使用的diskimage-build命令,参考diskimage-builder/elements/deploy-ironic这个元素,最重要的是init.d/80-deploy-ironic这个脚本,这个脚本主要其实就是做以下几个步骤:
- 找到磁盘,以该磁盘启动iSCSI设备
- Tftp获取到ironic准备的token文件
- 调用ironic的api接(POST v1/nodes/{node-id}/vendor_passthru/pass_deploy_info)
- 启动iSCSI设备, 开启socket端口 10000等待通知PXE结束
- 结束口停止iSCSI设备。
function install_bootloader {
# We need to run partprobe to ensure all partitions are visible
partprobe $target_disk
# root partition is always the last partition of the disk
readonly root_part=$(ls $target_disk* | tr " " "\n" | tail -n1)
readonly root_part_mount=/mnt/rootfs
mkdir -p $root_part_mount
mkdir -p $root_part_mount/dev
mkdir -p $root_part_mount/sys
mkdir -p $root_part_mount/proc
mount $root_part $root_part_mount 2>&1
if [ $? != "0" ]; then
echo "Failed to mount root partition $root_part on $root_part_mount"
return 1
fi
mount -o bind /dev $root_part_mount/dev
mount -o bind /sys $root_part_mount/sys
mount -o bind /proc $root_part_mount/proc
# If boot mode is uefi, then mount the system partition in /boot/efi.
# Grub expects the efi system partition to be mounted here.
if [ "$IRONIC_BOOT_MODE" = "uefi" ]; then
# efi system partition is labelled as "efi-part" by Ironic.
# lsblk output looks like this:
# NAME="sda1" LABEL="efi-part"
readonly efi_system_part=$(lsblk -Pio NAME,LABEL $target_disk | \
awk -F'"' '/"efi-part"/{print $2}')
readonly efi_system_part_dev_file="/dev/$efi_system_part"
readonly efi_system_part_mount="$root_part_mount/boot/efi"
mkdir -p $efi_system_part_mount
mount $efi_system_part_dev_file $efi_system_part_mount 2>&1
if [ $? != "0" ]; then
echo "Failed to mount efi system partition \
$efi_system_part_dev_file on $efi_system_part_mount"
return 1
fi
fi
# TODO(lucasagomes): Add extlinux as a fallback
# Find grub version
V=
if [ -x $root_part_mount/usr/sbin/grub2-install ]; then
V=2
fi
# Install grub
ret=1
if chroot $root_part_mount /bin/bash -c "/usr/sbin/grub$V-install ${target_disk}"; then
echo "Generating the grub configuration file"
# tell GRUB2 to preload its "lvm" module to gain LVM booting on direct-attached disks
if [ "$V" = "2" ]; then
echo "GRUB_PRELOAD_MODULES=lvm" >> $root_part_mount/etc/default/grub
fi
chroot $root_part_mount /bin/bash -c "/usr/sbin/grub$V-mkconfig -o /boot/grub$V/grub.cfg"
ret=$?
fi
# If we had mounted efi system partition, umount it.
if [ "$IRONIC_BOOT_MODE" = "uefi" ]; then
umount $efi_system_part_mount
fi
umount $root_part_mount/dev
umount $root_part_mount/sys
umount $root_part_mount/proc
umount $root_part_mount
if [ $ret != "0" ]; then
echo "Installing grub bootloader failed"
fi
return $ret
}
#向Ironic Condutor发送消息,开启socket端口10000等待通知PXE结束
function do_vendor_passthru_and_wait {
local data=$1
local vendor_passthru_name=$2
eval curl -i -X POST \
"$TOKEN_HEADER" \
"-H 'Accept: application/json'" \
"-H 'Content-Type: application/json'" \
-d "$data" \
"$IRONIC_API_URL/v1/nodes/$DEPLOYMENT_ID/vendor_passthru/$vendor_passthru_name"
echo "Waiting for notice of complete"
nc -l -p 10000
}
readonly IRONIC_API_URL=$(get_kernel_parameter ironic_api_url)
readonly IRONIC_BOOT_OPTION=$(get_kernel_parameter boot_option)
readonly IRONIC_BOOT_MODE=$(get_kernel_parameter boot_mode)
readonly ROOT_DEVICE=$(get_kernel_parameter root_device)
if [ -z "$ISCSI_TARGET_IQN" ]; then
err_msg "iscsi_target_iqn is not defined"
troubleshoot
fi
#获取当前linux的本地硬盘
target_disk=
if [[ $ROOT_DEVICE ]]; then
target_disk="$(get_root_device)"
else
t=0
while ! target_disk=$(find_disk "$DISK"); do
if [ $t -eq 60 ]; then
break
fi
t=$(($t + 1))
sleep 1
done
fi
if [ -z "$target_disk" ]; then
err_msg "Could not find disk to use."
troubleshoot
fi
#将找到的本地磁盘作为iSCSI磁盘启动,暴露给Ironic Condutor
echo "start iSCSI target on $target_disk"
start_iscsi_target "$ISCSI_TARGET_IQN" "$target_disk" ALL
if [ $? -ne 0 ]; then
err_msg "Failed to start iscsi target."
troubleshoot
fi
#获取到相关的token文件,从tftp服务器上获取,token文件在ironic在prepare阶段就生成好的。
if [ "$BOOT_METHOD" = "$VMEDIA_BOOT_TAG" ]; then
TOKEN_FILE="$VMEDIA_DIR/token"
if [ -f "$TOKEN_FILE" ]; then
TOKEN_HEADER="-H 'X-Auth-Token: $(cat $TOKEN_FILE)'"
else TOKEN_HEADER=""
fi
else
TOKEN_FILE=token-$DEPLOYMENT_ID
# Allow multiple versions of the tftp client
if tftp -r $TOKEN_FILE -g $BOOT_SERVER || tftp $BOOT_SERVER -c get $TOKEN_FILE; then
TOKEN_HEADER="-H 'X-Auth-Token: $(cat $TOKEN_FILE)'"
else
TOKEN_HEADER=""
fi
fi
#向Ironic请求部署镜像,POST node的/vendor_passthru/pass_deploy_info请求
echo "Requesting Ironic API to deploy image"
deploy_data="'{\"address\":\"$BOOT_IP_ADDRESS\",\"key\":\"$DEPLOYMENT_KEY\",\"iqn\":\"$ISCSI_TARGET_IQN\",\"error\":\"$FIRST_ERR_MSG\"}'"
do_vendor_passthru_and_wait "$deploy_data" "pass_deploy_info"
#部署镜像下载结束,停止iSCSI设备
echo "Stopping iSCSI target on $target_disk"
stop_iscsi_target
#如果是本地启动,安装bootloarder
# If localboot is set, install a bootloader
if [ "$IRONIC_BOOT_OPTION" = "local" ]; then
echo "Installing bootloader"
error_msg=$(install_bootloader)
if [ $? -eq 0 ]; then
status=SUCCEEDED
else
status=FAILED
fi
echo "Requesting Ironic API to complete the deploy"
bootloader_install_data="'{\"address\":\"$BOOT_IP_ADDRESS\",\"status\":\"$status\",\"key\":\"$DEPLOYMENT_KEY\",\"error\":\"$error_msg\"}'"
do_vendor_passthru_and_wait "$bootloader_install_data" "pass_bootloader_install_info"
fi
下面我们来看一下node的/vendor_passthru/pass_deploy_info都干了什么?Ironic-api在接受到请求后,是在ironic.api.controllers.v1.node.NodeVendorPassthruController._default()方法处理的,这个方法将调用的方法转发到ironic.condutor.manager.CondutorManager.vendor_passthro()去处理,进而调用相应task.driver.vendor.pass_deploy_info()去处理,这里不同驱动不一样,可以根据源码查看到,比如使用pxe_ipmptoos驱动, 则是转发给ironic.drivers.modules.iscsi_deploy.VendorPassthru.pass_deploy_info()处理,其代码是
@base.passthru(['POST'])
@task_manager.require_exclusive_lock
def pass_deploy_info(self, task, **kwargs):
"""Continues the deployment of baremetal node over iSCSI.
This method continues the deployment of the baremetal node over iSCSI
from where the deployment ramdisk has left off.
:param task: a TaskManager instance containing the node to act on.
:param kwargs: kwargs for performing iscsi deployment.
:raises: InvalidState
"""
node = task.node
LOG.warning(_LW("The node %s is using the bash deploy ramdisk for "
"its deployment. This deploy ramdisk has been "
"deprecated. Please use the ironic-python-agent "
"(IPA) ramdisk instead."), node.uuid)
task.process_event('resume') #设置任务状态
LOG.debug('Continuing the deployment on node %s', node.uuid)
is_whole_disk_image = node.driver_internal_info['is_whole_disk_image']
#继续部署的函数,连接到iSCSI设备,将用户镜像写到iSCSI设备上,退出删除iSCSI设备,
#然后在Condutor上删除镜像文件
uuid_dict_returned = continue_deploy(task, **kwargs)
root_uuid_or_disk_id = uuid_dict_returned.get(
'root uuid', uuid_dict_returned.get('disk identifier'))
# save the node's root disk UUID so that another conductor could
# rebuild the PXE config file. Due to a shortcoming in Nova objects,
# we have to assign to node.driver_internal_info so the node knows it
# has changed.
driver_internal_info = node.driver_internal_info
driver_internal_info['root_uuid_or_disk_id'] = root_uuid_or_disk_id
node.driver_internal_info = driver_internal_info
node.save()
try:
#再一次设置PXE引导,为准备进入用户系统做准备
task.driver.boot.prepare_instance(task)
if deploy_utils.get_boot_option(node) == "local":
if not is_whole_disk_image:
LOG.debug('Installing the bootloader on node %s',
node.uuid)
deploy_utils.notify_ramdisk_to_proceed(kwargs['address'])
task.process_event('wait')
return
except Exception as e:
LOG.error(_LE('Deploy failed for instance %(instance)s. '
'Error: %(error)s'),
{'instance': node.instance_uuid, 'error': e})
msg = _('Failed to continue iSCSI deployment.')
deploy_utils.set_failed_state(task, msg)
else:
#结束部署,通知ramdisk重启,将物理机设置为ative
finish_deploy(task, kwargs.get('address'))
在continue_deploy函数中,先解析iscsi部署的信息,然后在进行分区、格式化、写入镜像到磁盘。 然后调用prepare_instance在设置一遍PXE环境,为进入系统做准备,我们知道在instance_info上设置了ramdisk、kernel、image_source 3个镜像,其实就是内核、根文件系统、磁盘镜像。这里就是设置了ramdisk和kernel,磁盘镜像上面已经写到磁盘中去了,调用switch_pxe_config方法将当前的操作系统的启动项设置为ramdisk和kernel作为引导程序。 最后向节点的10000发送一个‘done’通知节点关闭iSCSI设备,最后节点重启安装用户操作系统,至此部署结束。
在部署过程中,节点和驱动的信息都会被存入ironic数据库,以便后续管理。
Ironic-Python-Agent(IPA,agent)
我们知道当前ironic的deploy模块是通过打开一个iSCSI设备,ironic-conductro来OS的镜像文件写到iSCSI的设备 Ironic-Python-Agent认为deploy模块还可以直接访问硬件,提供以下功能:
- 磁盘格式化
- 磁盘分区
- 安装OS( Bootloaders, OS)
- 固件升级
- raid配置 在Condutor端使用agent驱动,物理机端使用IPA,IPA通过暴露API给Condutor调用,则可完成相应功能。IPA启动时通过发送lookup()请求给Condutor获取UUID,相当于注册自己,并且每隔一段时间给Condutor发送心跳包进行连接。 下面我们来分析一下使用IPA来部署系统的过程。
使用iscsi部署流程:
使用IPA部署流程:
在部署阶段的前一部分与PXE一样,但是由于创建的ramdisk不一样所以部署方式则不一样,在PXE中,开机执行的是一段init脚本,而在Agent开机执行的是IPA。
机器上电后,ramdisk在内存中执行,然后启动IPA,入口为cmd.agent.run(),然后调用ironic-python-agent.agent.run(),其代码如下
def run(self):
"""Run the Ironic Python Agent."""
# Get the UUID so we can heartbeat to Ironic. Raises LookupNodeError
# if there is an issue (uncaught, restart agent)
self.started_at = _time()
#加载hardware manager
# Cached hw managers at runtime, not load time. See bug 1490008.
hardware.load_managers()
if not self.standalone:
# Inspection should be started before call to lookup, otherwise
# lookup will fail due to unknown MAC.
uuid = inspector.inspect()
#利用Ironic API给Condutor发送lookup()请求,用户获取UUID,相当于自发现
content = self.api_client.lookup_node(
hardware_info=hardware.dispatch_to_managers(
'list_hardware_info'),
timeout=self.lookup_timeout,
starting_interval=self.lookup_interval,
node_uuid=uuid)
self.node = content['node']
self.heartbeat_timeout = content['heartbeat_timeout']
wsgi = simple_server.make_server(
self.listen_address[0],
self.listen_address[1],
self.api,
server_class=simple_server.WSGIServer)
#发送心跳包
if not self.standalone:
# Don't start heartbeating until the server is listening
self.heartbeater.start()
try:
wsgi.serve_forever()
except BaseException:
self.log.exception('shutting down')
#部署完成后停止心跳包
if not self.standalone:
self.heartbeater.stop()
其中self.api_client.lookup_node调用到ironic-python-api._do_lookup(),然后GET /v1/lookup请求。 Condutor API在接受到/v1/lookup请求在后再
驱动总结
驱动都有几个属性
- 核心接口(core),最基本的功能,是其他服务的依赖,所有驱动都必须实现的,包括power,deploy
- 标准接口(standard),实现通用功能,包括 management, console, boot, inspect, , raid.
- 厂商接口(vendor),提供个性化功能,
驱动分类
- pxe_ deploy 用的是iscsi_deploy.ISCSIDeploy(), boot用的是pxe.PXEBoot(), power根据后缀不同使用的不同
- agent_ deploy用的是agent.AgentDeploy(), boot 用的是pxe.PXEBoot(), power根据后缀不同而不同
- iscis_ deploy用的iscsi_deploy.ISCSIDeploy(),power根据后缀不同而不同
下面显示两个部署实例
- 使用pxe_* 为前缀的驱动的部署过程
- 使用agent_* 为前缀的驱动的部署过程