cinder/doc/source/contributor/rolling.upgrades.rst
shangxiaobj 83dd1ddd68 Update and replace http with https for doc links in Cinder
1) Update doc links according to OpenStack document migration
2) Use https instead of http for docs links

Change-Id: I9b8473a439d15c741bc4d4809c4c4c122717f8a4
2017-07-27 09:58:23 +00:00

408 lines
20 KiB
ReStructuredText

..
Copyright (c) 2016 Intel Corporation
All Rights Reserved.
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.
Upgrades
========
Starting from Mitaka release Cinder gained the ability to be upgraded without
introducing downtime of control plane services. Operator can simply upgrade
Cinder services instances one-by-one. To achieve that, developers need to make
sure that any introduced change doesn't break older services running in the
same Cinder deployment.
In general there is a requirement that release N will keep backward
compatibility with release N-1 and in a deployment N's and N-1's services can
safely coexist. This means that when performing a live upgrade you cannot skip
any release (e.g. you cannot upgrade N to N+2 without upgrading it to N+1
first). Further in the document N will denote the current release, N-1 a
previous one, N+1 the next one, etc.
Having in mind that we only support compatibility with N-1, most of the
compatibility code written in N needs to exist just for one release and can be
removed in the beginning of N+1. A good practice here is to mark them with
:code:`TODO` or :code:`FIXME` comments to make them easy to find in the future.
Please note that proper upgrades solution should support both
release-to-release upgrades as well as upgrades of deployments following the
Cinder master more closely. We cannot just merge patches implementing
compatibility at the end of the release - we should keep things compatible
through the whole release.
To achieve compatibility, discipline is required from the developers. There are
several planes on which incompatibility may occur:
* **REST API changes** - these are prohibited by definition and this document
will not describe the subject. For further information one may use `API
Working Group guidelines
<https://specs.openstack.org/openstack/api-wg/guidelines/evaluating_api_changes.html>`_
for reference.
* **Database schema migrations** - e.g. if N-1 was relying on some column in
the DB being present, N's migrations cannot remove it. N+1's however can
(assuming N has no notion of the column).
* **Database data migrations** - if a migration requires big amount of data to
be transferred between columns or tables or converted, it will most likely
lock the tables. This may cause services to be unresponsive, causing the
downtime.
* **RPC API changes** - adding or removing RPC method parameter, or the method
itself, may lead to incompatibilities.
* **RPC payload changes** - adding, renaming or removing a field from the dict
passed over RPC may lead to incompatibilities.
Next sections of this document will focus on explaining last four points and
provide means to tackle required changes in these matters while maintaining
backward compatibility.
Database schema and data migrations
-----------------------------------
In general incompatible database schema migrations can be tracked to ALTER and
DROP SQL commands instruction issued either against a column or table. This is
why a unit test that blocks such migrations was introduced. We should try to
keep our DB modifications additive. Moreover we should aim not to introduce
migrations that cause the database tables to lock for a long period. Long lock
on whole table can block other queries and may make real requests to fail.
Adding a column
...............
This is the simplest case - we don't have any requirements when adding a new
column apart from the fact that it should be added as the last one in the
table. If that's covered, the DB engine will make sure the migration won't be
disruptive.
Dropping a column not referenced in SQLAlchemy code
...................................................
When we want to remove a column that wasn't present in any SQLAlchemy model or
it was in the model, but model was not referenced in any SQLAlchemy API
function (this basically means that N-1 wasn't depending on the presence of
that column in the DB), then the situation is simple. We should be able to
safely drop the column in N release.
Removal of unnecessary column
.............................
When we want to remove a used column without migrating any data out of it (for
example because what's kept in the column is obsolete), then we just need to
remove it from the SQLAlchemy model and API in N release. In N+1 or as a
post-upgrade migration in N we can merge a migration issuing DROP for this
column (we cannot do that earlier because N-1 will depend on the presence of
that column).
ALTER on a column
.................
A rule of thumb to judge which ALTER or DROP migrations should be allowed is to
look in the `MySQL documentation
<https://dev.mysql.com/doc/refman/5.7/en/innodb-create-index-overview.html#innodb-online-ddl-summary-grid>`_.
If operation has "yes" in all 4 columns besides "Copies Table?", then it
*probably* can be allowed. If operation doesn't allow concurrent DML it means
that table row modifications or additions will be blocked during the migration.
This sometimes isn't a problem - for example it's not the end of the world if a
service won't be able to report it's status one or two times (and
:code:`services` table is normally small). Please note that even if this does
apply to "rename a column" operation, we cannot simply do such ALTER, as N-1
will depend on the older name.
If an operation on column or table cannot be allowed, then it is required to
create a new column with desired properties and start moving the data (in a
live manner). In worst case old column can be removed in N+2. Whole procedure
is described in more details below.
In aforementioned case we need to make more complicated steps stretching through
3 releases - always keeping the backwards compatibility. In short when we want
to start to move data inside the DB, then in N we should:
* Add a new column for the data.
* Write data in both places (N-1 needs to read it).
* Read data from the old place (N-1 writes there).
* Prepare online data migration cinder-manage command to be run before
upgrading to N+1 (because N+1 will read from new place, so we need to make
sure all the records have new place populated).
In N+1 we should:
* Write data to both places (N reads from old one).
* Read data from the new place (N saves there).
In N+2
* Remove old place from SQLAlchemy.
* Read and write only to the new place.
* Remove the column as the post-upgrade migration (or as first migration in
N+3).
Please note that this is the most complicated case. If data in the column
cannot actually change (for example :code:`host` in :code:`services` table), in
N we can read from new place and fallback to the old place if data is missing.
This way we can skip one release from the process.
Of course real-world examples may be different. E.g. sometimes it may be
required to write some more compatibility code in the oslo.versionedobjects
layer to compensate for different versions of objects passed over RPC. This is
explained more in `RPC payload changes (oslo.versionedobjects)`_ section.
More details about that can be found in the `online-schema-upgrades spec
<http://specs.openstack.org/openstack/cinder-specs/specs/mitaka/online-schema-upgrades.html>`_.
RPC API changes
---------------
It can obviously break service communication if RPC interface changes. In
particular this applies to changes of the RPC method definitions. To avoid that
we assume N's RPC API compatibility with N-1 version (both ways -
:code:`rpcapi` module should be able to downgrade the message if needed and
:code:`manager` module should be able to tolerate receiving messages in older
version.
Below is an example RPC compatibility shim from Mitaka's
:code:`cinder.volume.manager`. This code allows us to tolerate older versions
of the messages::
def create_volume(self, context, volume_id, request_spec=None,
filter_properties=None, allow_reschedule=True,
volume=None):
"""Creates the volume."""
# FIXME(thangp): Remove this in v2.0 of RPC API.
if volume is None:
# For older clients, mimic the old behavior and look up the volume
# by its volume_id.
volume = objects.Volume.get_by_id(context, volume_id)
And here's a contrary shim in cinder.volume.rpcapi (RPC client) that downgrades
the message to make sure it will be understood by older instances of the
service::
def create_volume(self, ctxt, volume, host, request_spec,
filter_properties, allow_reschedule=True):
request_spec_p = jsonutils.to_primitive(request_spec)
msg_args = {'volume_id': volume.id, 'request_spec': request_spec_p,
'filter_properties': filter_properties,
'allow_reschedule': allow_reschedule}
if self.client.can_send_version('1.32'):
version = '1.32'
msg_args['volume'] = volume
else:
version = '1.24'
new_host = utils.extract_host(host)
cctxt = self.client.prepare(server=new_host, version=version)
request_spec_p = jsonutils.to_primitive(request_spec)
cctxt.cast(ctxt, 'create_volume', **msg_args)
As can be seen there's this magic :code:`self.client.can_send_version()` method
which detects if we're running in a version-heterogeneous environment and need
to downgrade the message. Detection is based on dynamic RPC version pinning. In
general all the services (managers) report supported RPC API version. RPC API
client gets all the versions from the DB, chooses the lowest one and starts to
downgrade messages to it.
To limit impact on the DB the pinned version of certain RPC API is cached.
After all the services in the deployment are updated, operator should restart
all the services or send them a SIGHUP signal to force reload of version pins.
As we need to support only N RPC API in N+1 release, we should be able to drop
all the compatibility shims in N+1. To be technically correct when doing so we
should also bump the major RPC API version. We do not need to do that in every
release (it may happen that through the release nothing will change in RPC API
or cost of technical debt of compatibility code is lower than the cost of
complicated procedure of increasing major version of RPC APIs).
The process of increasing the major version is explained in details in `Nova's
documentation <https://wiki.openstack.org/wiki/RpcMajorVersionUpdates>`_.
Please note that in case of Cinder we're accessing the DB from all of the
services, so we should follow the more complicated "Mixed version environments"
process for every of our services.
In case of removing whole RPC method we need to leave it there in N's manager
and can remove it in N+1 (because N-1 will be talking with N). When adding a
new one we need to make sure that when the RPC client is pinned to a too low
version any attempt to send new message should fail (because client will not
know if manager receiving the message will understand it) or ensure the manager
will get updated before clients by stating the recommended order of upgrades
for that release.
RPC payload changes (oslo.versionedobjects)
-------------------------------------------
`oslo.versionedobjects
<https://docs.openstack.org/oslo.versionedobjects/latest/>`_ is a library that
helps us to maintain compatibility of the payload sent over RPC. As during the
process of upgrades it is possible that a newer version of the service will
send an object to an older one, it may happen that newer object is incompatible
with older service.
Version of an object should be bumped every time we make a change that will
result in an incompatible change of the serialized object. Tests will inform
you when you need to bump the version of a versioned object, but rule of thumb
is that we should never bump the version when we modify/adding/removing a
method to the versioned object (unlike Nova we don't use remotable methods),
and should always bump it when we modify the fields dictionary.
There are exceptions to this rule, for example when we change a
``fields.StringField`` by a custom ``fields.BaseEnumField``. The reason why a
version bump is not required in this case it's because the actual data doesn't
change, we are just removing magic string by an enumerate, but the strings used
are exactly the same.
As mentioned before, you don't have to know all the rules, as we have a test
that calculates the hash of all objects taking all these rules into
consideration and will tell you exactly when you need to bump the version of a
versioned object.
You can run this test with
``tox -epy35 -- --path cinder/tests/unit/objects/test_objects.py``. But you
may need to run it multiple times until it passes since it may not detect all
required bumps at once.
Then you'll see which versioned object requires a bump and you need to bump
that version and update the object_data dictionary in the test file to reflect
the new version as well as the new hash.
There is a very common false positive on the version bump test, and that is
when we have modified a versioned object that is being used by other objects
using the ``fields.ObjectField`` class. Due to the backporting mechanism
implemented in Cinder we don't require bumping the version for these cases and
we'll just need to update the hash used in the test.
For example if we were to add a new field to the Volume object and then run the
test we may think that we need to bump Volume, Snapshot, Backup, RequestSpec,
and VolumeAttachment objects, but we really only need to bump the version of
the Volume object and update the hash for all the other objects.
Imagine that we (finally!) decide that :code:`request_spec` sent in
:code:`create_volume` RPC cast is duplicating data and we want to start to
remove redundant occurrences. When running in version-mixed environment older
services will still expect this redundant data. We need a way to somehow
downgrade the :code:`request_spec` before sending it over RPC. And this is were
o.vo come in handy. o.vo provide us the infrastructure to keep the changes in
object versioned and to be able to downgrade them to a particular version.
Let's take a step back - similarly to the RPC API situation we need a way to
tell if we need to send a backward-compatible version of the message. In this
case we need to know to what version to downgrade the object. We're using a
similar solution to the one used for RPC API for that. A problem here is that
we need a single identifier (that we will be reported to :code:`services` DB
table) to denote whole set of versions of all the objects. To do that we've
introduced a concept of :code:`CinderObjectVersionHistory` object, where we
keep sets of individual object versions aggregated into a single version
string. When making an incompatible change in a single object you need to bump
its version (we have a unit test enforcing that) *and* add a new version to
:code:`cinder.objects.base.CinderObjectVersionsHistory` (there's a unit test as
well). Example code doing that is below::
OBJ_VERSIONS.add('1.1', {'Service': '1.2', 'ServiceList': '1.1'})
This line adds a new 1.1 aggregated object version that is different from 1.0
by two objects - :code:`Service` in 1.2 and :code:`ServiceList` in 1.1. This
means that the commit which added this line bumped versions of these two
objects.
Now if we know that a service we're talking to is running 1.1 aggregated
version - we need to downgrade :code:`Service` and :code:`ServiceList` to 1.2
and 1.1 respectively before sending. Please note that of course other objects
are included in the 1.1 aggregated version, but you just need to specify what
changed (all the other versions of individual objects will be taken from the
last version - 1.0 in this case).
Getting back to :code:`request_spec` example. So let's assume we want to remove
:code:`volume_properties` from there (most of data in there is already
somewhere else inside the :code:`request_spec` object). We've made a change in
the object fields, we've bumped it's version (from 1.0 to 1.1), we've updated
hash in the :code:`cinder.tests.unit.test_objects` to synchronize it with the
current state of the object, making the unit test pass and we've added a new
aggregated object history version in :code:`cinder.objects.base`.
What else is required? We need to provide code that actually downgrades
RequestSpec object from 1.1 to 1.0 - to be used when sending the object to
older services. This is done by implementing :code:`obj_make_compatible` method
in the object::
from oslo_utils import versionutils
def obj_make_compatible(self, primitive, target_version):
super(RequestSpec, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
if target_version < (1, 1) and not 'volume_properties' in primitive:
volume_properties = {}
# TODO: Aggregate all the required information from primitive.
primitive['volume_properties'] = volume_properties
Please note that primitive is a dictionary representation of the object and not
an object itself. This is because o.vo are of course sent over RPC as dicts.
With these pieces in place Cinder will take care of sending
:code:`request_spec` with :code:`volume_properties` when running in mixed
environment and without when all services are upgraded and will understand
:code:`request_spec` without :code:`volume_properties` element.
Note that o.vo layer is able to recursively downgrade all of its fields, so
when `request_spec` will be used as a field in other object, it will be
correctly downgraded.
A more common case where we need backporting code is when we add new fields.
In such case the backporting consist on removing the newly added fields. For
example if we add 3 new fields to the Group object in version 1.1, then we need
to remove them if backporting to earlier versions::
from oslo_utils import versionutils
def obj_make_compatible(self, primitive, target_version):
super(Group, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
if target_version < (1, 1):
for key in ('group_snapshot_id', 'source_group_id',
'group_snapshots'):
primitive.pop(key, None)
As time goes on we will be adding more and more new fields to our objects, so
we may end up with a long series of if and for statements like in the Volume
object::
from oslo_utils import versionutils
def obj_make_compatible(self, primitive, target_version):
super(Volume, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
if target_version < (1, 4):
for key in ('cluster', 'cluster_name'):
primitive.pop(key, None)
if target_version < (1, 5):
for key in ('group', 'group_id'):
primitive.pop(key, None)
So a different pattern would be preferable as it will make the backporting
easier for future additions::
from oslo_utils import versionutils
def obj_make_compatible(self, primitive, target_version):
added_fields = (((1, 4), ('cluster', 'cluster_name')),
((1, 5), ('group', 'group_id')))
super(Volume, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
for version, remove_fields in added_fields:
if target_version < version:
for obj_field in remove_fields:
primitive.pop(obj_field, None)