diff --git a/doc/source/admin/scheduling.rst b/doc/source/admin/scheduling.rst index b44fa0ea35..11fc8e0232 100644 --- a/doc/source/admin/scheduling.rst +++ b/doc/source/admin/scheduling.rst @@ -1073,6 +1073,19 @@ strategy) while a negative value will follow a spread strategy that will favor hosts with the lesser number of instances. +``ImagePropertiesWeigher`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 31.0.0 (Epoxy) + +This weigher compares hosts and orders them based on the existing instances +image properties respectively. By default the weigher is doing nothing but you +can change its behaviour by modifying the value of +:oslo.config:option:`filter_scheduler.image_props_weight_multiplier`. +A positive value will favor hosts with the same image properties (packing +strategy) while a negative value will follow a spread strategy that will +favor hosts not already having instances with those image properties. + Utilization-aware scheduling ---------------------------- diff --git a/nova/conf/scheduler.py b/nova/conf/scheduler.py index 8435c7d5fc..c0a3ec2118 100644 --- a/nova/conf/scheduler.py +++ b/nova/conf/scheduler.py @@ -544,6 +544,50 @@ Possible values: Related options: +* ``[filter_scheduler] weight_classes`` +"""), + cfg.FloatOpt("image_props_weight_multiplier", + default=0.0, + help=""" +Image Properties weight multiplier ratio. + +The multiplier is used for weighting hosts based on the reported +image properties for the instances they have. +A positive value will favor hosts with the same image properties (packing +strategy) while a negative value will follow a spread strategy that will favor +hosts not already having instances with those image properties. +The default value of the multiplier is 0, which disables the weigher. + +Possible values: + +* An integer or float value, where the value corresponds to the multiplier + ratio for this weigher. + +Example: + +* Strongly prefer to pack instances with related image properties. + + .. code-block:: ini + + [filter_scheduler] + image_props_weight_multiplier=1000 + +* Softly prefer to spread instances having same properties between hosts + + .. code-block:: ini + + [filter_scheduler] + image_props_weight_multiplier=-1.0 + +* Disable weigher influence + + .. code-block:: ini + + [filter_scheduler] + image_props_weight_multiplier=0 + +Related options: + * ``[filter_scheduler] weight_classes`` """), cfg.FloatOpt("pci_weight_multiplier", diff --git a/nova/objects/image_meta.py b/nova/objects/image_meta.py index 2029b274fd..c8764e9514 100644 --- a/nova/objects/image_meta.py +++ b/nova/objects/image_meta.py @@ -736,6 +736,10 @@ class ImageMetaProps(base.NovaObject): return obj + def to_dict(self): + """Returns a dictionary of image properties that are set.""" + return base.obj_to_primitive(self) + def get(self, name, defvalue=None): """Get the value of an attribute :param name: the attribute to request diff --git a/nova/scheduler/weights/image_props.py b/nova/scheduler/weights/image_props.py new file mode 100644 index 0000000000..856b681af9 --- /dev/null +++ b/nova/scheduler/weights/image_props.py @@ -0,0 +1,86 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Image Properties Weigher. Weigh hosts by the image metadata properties +related to the existing instances. + +A positive value will favor hosts with the same image properties (packing +strategy) while a negative value will follow a spread strategy that will favor +hosts not already having instances with those image properties. +The default value of the multiplier is 0, which disables the weigher. +""" + +import nova.conf +from nova import exception +from nova import objects +from nova.scheduler import utils +from nova.scheduler import weights + +CONF = nova.conf.CONF + + +class ImagePropertiesWeigher(weights.BaseHostWeigher): + + def weight_multiplier(self, host_state): + """Override the weight multiplier.""" + return utils.get_weight_multiplier( + host_state, 'image_props_weight_multiplier', + CONF.filter_scheduler.image_props_weight_multiplier) + + def _weigh_object(self, host_state, request_spec): + """Higher weights win. We want to choose hosts with the more common + existing image properties that are used by instances by default. + If you want to spread instances with the same properties between + hosts, change the multiplier value to a negative number. + """ + + weight = 0.0 + + # Disable this weigher if we don't use it as it's a bit costly. + if CONF.filter_scheduler.image_props_weight_multiplier == 0.0: + return weight + + # request_spec is a RequestSpec object which can have its image + # field set to None + if request_spec.image: + # List values aren't hashable so we need to stringify them. + requested_props = {(key, f"{value}") for key, value in + request_spec.image.properties.to_dict().items()} + else: + requested_props = set() + + existing_props = [] + + insts = objects.InstanceList(objects=host_state.instances.values()) + # system_metadata isn't loaded yet, let's do this. + insts.fill_metadata() + + for inst in insts: + try: + props = {(key, str(value)) for key, value in + inst.image_meta.properties.to_dict().items() + } if inst.image_meta else set() + except exception.InstanceNotFound: + # the host state can be a bit stale as the instance could no + # longer exist on the host if the instance deletion arrives + # before the scheduler gets the RPC message of the deletion + props = set() + # We want to unpack the set of tuples as items to the total list + # of properties we need to compare. + existing_props.extend(tup for tup in props) + + common_props = requested_props & set(existing_props) + + for prop in common_props: + weight += existing_props.count(prop) + return weight diff --git a/nova/tests/unit/objects/test_image_meta.py b/nova/tests/unit/objects/test_image_meta.py index 81b54a19d6..47226c9667 100644 --- a/nova/tests/unit/objects/test_image_meta.py +++ b/nova/tests/unit/objects/test_image_meta.py @@ -146,6 +146,12 @@ class TestImageMetaProps(test.NoDBTestCase): virtprops.get, "doesnotexist") + def test_to_dict(self): + props = {'os_type': 'windows'} + virtprops = objects.ImageMetaProps.from_dict(props) + self.assertEqual({'os_type': 'windows'}, + virtprops.to_dict()) + def test_legacy_compat(self): legacy_props = { 'architecture': 'x86_64', diff --git a/nova/tests/unit/scheduler/weights/test_weights_image_props.py b/nova/tests/unit/scheduler/weights/test_weights_image_props.py new file mode 100644 index 0000000000..9cbdd1adaa --- /dev/null +++ b/nova/tests/unit/scheduler/weights/test_weights_image_props.py @@ -0,0 +1,154 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +Tests For Scheduler image properties weights. +""" +from unittest import mock + +from oslo_utils.fixture import uuidsentinel as uuids + +from nova import objects +from nova.scheduler import weights +from nova.scheduler.weights import image_props +from nova import test +from nova.tests.unit.scheduler import fakes + +PROP_PC = objects.ImageMeta( + properties=objects.ImageMetaProps(hw_machine_type='pc')) + +PROP_WIN_PC = objects.ImageMeta( + properties=objects.ImageMetaProps(os_distro='windows', + hw_machine_type='pc')) + +PROP_LIN = objects.ImageMeta( + properties=objects.ImageMetaProps(os_distro='linux')) + +PROP_LIN_PC = objects.ImageMeta( + properties=objects.ImageMetaProps(os_distro='linux', hw_machine_type='pc')) + + +class ImagePropertiesWeigherTestCase(test.NoDBTestCase): + + def setUp(self): + super().setUp() + self.weight_handler = weights.HostWeightHandler() + self.weighers = [image_props.ImagePropertiesWeigher()] + + self.stub_out('nova.objects.ImageMeta.from_instance', + self._fake_image_meta_from_instance) + + @staticmethod + def _fake_image_meta_from_instance(self, inst): + # inst1 is on host2 + if inst.uuid == uuids.inst1: + return PROP_WIN_PC + # inst2 and inst3 are on host3 + elif inst.uuid == uuids.inst2: + return PROP_LIN + elif inst.uuid == uuids.inst3: + return PROP_LIN_PC + # inst4 is on host4 + elif inst.uuid == uuids.inst4: + return PROP_LIN + else: + return objects.ImageMeta(properties=objects.ImageMetaProps()) + + def _get_weighed_host(self, hosts, request_spec=None): + if request_spec is None: + request_spec = objects.RequestSpec(image=None) + return self.weight_handler.get_weighed_objects(self.weighers, + hosts, request_spec)[0] + + def _get_all_hosts(self): + + host_values = [ + # host1 has no instances + ('host1', 'node1', {'instances': {}}), + #  host2 has one instance with os_distro=win and hw_machine_type=pc + ('host2', 'node2', {'instances': {uuids.inst1: objects.Instance( + uuid=uuids.inst1)}}), + # host3 has two instances + # (os_distro=lin twice, hw_machine_type=pc only once) + ('host3', 'node3', {'instances': {uuids.inst2: objects.Instance( + uuid=uuids.inst2), + uuids.inst3: objects.Instance( + uuid=uuids.inst3)}}), + # host4 has one instance with os_distro=lin only + ('host4', 'node4', {'instances': {uuids.inst2: objects.Instance( + uuid=uuids.inst4)}}), + + ] + return [fakes.FakeHostState(host, node, values) + for host, node, values in host_values] + + @mock.patch('nova.objects.InstanceList.fill_metadata') + def test_multiplier_default(self, mock_fm): + hostinfo_list = self._get_all_hosts() + weighed_host = self._get_weighed_host(hostinfo_list) + self.assertEqual(0.0, weighed_host.weight) + # Nothing changes, we just returns the first in the list + self.assertEqual('host1', weighed_host.obj.host) + mock_fm.assert_not_called() + + @mock.patch('nova.objects.InstanceList.fill_metadata') + def test_multiplier_windows_and_pc(self, mock_fm): + self.flags(image_props_weight_multiplier=1.0, group='filter_scheduler') + hostinfo_list = self._get_all_hosts() + # we request for both windows and pc machine type + weighed_host = self._get_weighed_host( + hostinfo_list, + request_spec=objects.RequestSpec( + image=PROP_WIN_PC)) + self.assertEqual(1.0, weighed_host.weight) + # only host2 has instances with both os_distro=windows and + # hw_machine_type=pc + self.assertEqual('host2', weighed_host.obj.host) + mock_fm.assert_has_calls([mock.call(), mock.call(), + mock.call(), mock.call()]) + + @mock.patch('nova.objects.InstanceList.fill_metadata') + def test_multiplier_pc(self, mock_fm): + self.flags(image_props_weight_multiplier=1.0, group='filter_scheduler') + hostinfo_list = self._get_all_hosts() + weights = self.weight_handler.get_weighed_objects( + self.weighers, hostinfo_list, + weighing_properties=objects.RequestSpec(image=PROP_PC)) + # host2 and host3 have instances with pc machine type so are equally + # weighed so we return the first of both. + expected_weights = [{'weight': 1.0, 'host': 'host2'}, + {'weight': 1.0, 'host': 'host3'}, + {'weight': 0.0, 'host': 'host1'}, + {'weight': 0.0, 'host': 'host4'}] + self.assertEqual(expected_weights, [weigh.to_dict() + for weigh in weights]) + self.assertEqual('host2', weights[0].obj.host) + mock_fm.assert_has_calls([mock.call(), mock.call(), + mock.call(), mock.call()]) + + @mock.patch('nova.objects.InstanceList.fill_metadata') + def test_multiplier_linux(self, mock_fm): + self.flags(image_props_weight_multiplier=1.0, group='filter_scheduler') + hostinfo_list = self._get_all_hosts() + weights = self.weight_handler.get_weighed_objects( + self.weighers, hostinfo_list, + weighing_properties=objects.RequestSpec(image=PROP_LIN)) + # host3 and host4 have instances with linux distro but we favor + # host3 given he has more instances having the same requested property + expected_weights = [{'weight': 1.0, 'host': 'host3'}, + {'weight': 0.5, 'host': 'host4'}, + {'weight': 0.0, 'host': 'host1'}, + {'weight': 0.0, 'host': 'host2'}] + self.assertEqual(expected_weights, [weigh.to_dict() + for weigh in weights]) + self.assertEqual('host3', weights[0].obj.host) + mock_fm.assert_has_calls([mock.call(), mock.call(), + mock.call(), mock.call()]) diff --git a/releasenotes/notes/bp-image-metadata-props-weigher-b09125e1837428f5.yaml b/releasenotes/notes/bp-image-metadata-props-weigher-b09125e1837428f5.yaml new file mode 100644 index 0000000000..6f6aa4de41 --- /dev/null +++ b/releasenotes/notes/bp-image-metadata-props-weigher-b09125e1837428f5.yaml @@ -0,0 +1,14 @@ +--- +features: + - | + A new `ImagePropertiesWeigher` has been added. It will compare the number + of image properties of the image being booted for each of the host with how + many existing instances use them. By default this weigher is enabled but + with a value of 0.0 for + `[filter_scheduler]/image_props_weight_multiplier` which + won't modify the existing scheduling behavior. + If you want to pack instances having the same image properties on the same + hosts, modify `image_props_weight_multiplier` to a positive value. If you + want to spread instances with the same properties around all hosts, then + please modify `image_props_weight_multiplier` to a negative value. +