A large part of OpenStack Infrastructure teams recent efforts has been
focused on moving towards more stable and maintainable CI environments
for testing.
OpenStack CI Overview
Before getting into details, it's a good idea to get a basic
big-picture conceptual model of how OpenStack CI testing works. If
you look at the following diagram and follow the numbers with the
explanation below, hopefully you'll have all the context you need.
The developer uploads their code to gerrit via the
git-review tool. They wait.
Gerrit provides a JSON-encoded "firehose" output of everything
happening to it. New reviews, votes, updates and more all get sent
out over this pipe. Zuul is the overall scheduler
that subscribes itself to this information and is responsible for
managing the CI jobs appropriate for each change.
Zuul has a configuration
that tells it what jobs to run for what projects. Zuul can do lots
of interesting things, but for the purposes of this discussion we
just consider that it puts the jobs it wants run into gearman
for a Jenkins host to consume. gearman
is a job-server; as they explain it "[gearman] provides a generic
application framework to farm out work to other machines or
processes that are better suited to do the work".
A group of Jenkins hosts are subscribed
to gearman as workers. It is these Jenkins hosts that will
consume the job requests from the queue and actually get the tests
running. Jenkins needs two things to be able to run a job -- a job
definition (what to actually do) and a slave node (somewhere to do
it).
The first part -- what to do -- is provided by job-definitions
stored in external YAML files and processed by Jenkins Job Builder (jjb)
in to job configurations for Jenkins. Thus each Jenkins instance
knows about all the jobs it might need to run. Zuul also knows
about these job definitions, so you can see how we now have a
mapping where Zuul can put a job into gearman saying "run test
foo-bar-baz" and a Jenkins host can consume that request and know
what to do.
The second part -- somewhere to run the test -- takes some more
explaining. To the next point...
Several cloud companies donate capacity in their clouds for
OpenStack to run CI tests. Overall, this capacity is managed by
nodepool --
a customised orchestration tool. Nodepool watches the gearman
queue and sees what requests are coming out of Zuul, and decides
what type of capacity to provide and in what clouds to satisfy the
outstanding job queue. Nodepool will start-up virtual-machines as
required, and register those nodes to the Jenkins instances.
At this point, Jenkins has what it needs to actually get jobs
started. When nodepool registers a host to Jenkins as a slave, the
Jenkins host can now advertise its ability to consume jobs. For
example, if a ubuntu-trusty node is provided to the Jenkins
instance by nodepool, Jenkins can now consume a job from the
queue intended to run on an ubuntu-trusty host. It will run
the job as defined in the job-definition -- ssh-ing into the host,
running scripts, copying the logs and waiting for the result. (It
is a gross oversimplification, but but Jenkins is pretty much a
glorified ssh/scp wrapper to OpenStack CI. Zuul Version 3, under
development, is working to remove the need for Jenkins to be
involved at all).
Eventually, the test will finish. Jenkins will put the result back
into gearman, which Zuul will consume. The slave will be
released back to nodepool, which destroys it and starts all over
again (slaves are not reused and also have no sensitive details on
them, as they are essentially publicly accessible). Zuul will wait
for the results of all jobs and post the result back to Gerrit and
give either a positive vote or the dreaded negative vote if
required jobs failed (it also handles merges to git, but we'll
ignore that bit for now).
In a nutshell, that is the CI work-flow that happens
thousands-upon-thousands of times a day keeping OpenStack humming
along.
Image builds
There is, however, another more asynchronous part of the process that
hides a lot of details the rest of the system relies on. Illustrated
in step
8 above, this is the management of the images that tests are
being run upon. Above we we said that a test runs on a
ubuntu-trusty,
centos,
fedora or some other type of node,
but glossed over where these images come from.
Firstly, what are these images, and why build them at all? These
images are where the "rubber hits the road" -- where DevStack,
functional testing or whatever else someone might want to test is
actually run for real. Caching is a big part of the role of these
images. With thousands of jobs going on every day, an occasional
network blip is not a minor annoyance, but creates constant and
difficult to debug CI failures. We want the images that CI runs on to
rely on as few external resources as possible so test runs are as
stable as possible. This means caching all the git trees tests might
use, VM images consumed by various tests and other common bits and
pieces. Obviously a cache is only as useful as the data in it, so we
build these images up every day to keep them fresh.
Provider images
If you log into almost any cloud-provider's interface, they almost
certainly have a range of pre-canned images of common distributions
for you to use. At first, the base images for OpenStack CI testing
came from what the cloud-providers had as their public image types.
However, over time, there are a number of issues that emerge:
- No two images, even for the same distribution or platform, are the
same. Every provider seems to do something "helpful" to the images
which requires some sort of workaround.
- Providers rarely leave these images alone. One day you would boot
the image to find a bunch of Python libraries pip-installed, or a
mount-point moved, or base packages removed (all happened).
- Even if the changes are helpful, it does not make for consistent
and reproducible testing if every time you run, you're on a
slightly different base system.
- Providers don't have some images you want (like a latest Fedora),
or have different versions, or different point releases. All
update asynchronously whenever they get around to it.
So the original incarnations of building images was that nodepool
would start one of these provider images, run a bunch of scripts on it
to make a base-image (do the caching, setup keys, etc), snapshot it
and then start putting VM's based on these images into the pool for
testing (if you hear someone talking about a "snapshot image" in
OpenStack CI context, that's what it means). The first problem you
hit here is that the number of images being built starts to explode
when you take into account multiple providers and multiple regions.
Even with just Rackspace and the (now defunct) HP Cloud we would end
up snapshot images for 4 or 5 platforms across a total of about 8
regions -- meaning anywhere up to 40 separate image builds happening.
It was almost a
fait accompli that some images would fail every day
-- nodepool can deal with this by reusing old snapshots; but this
leads to a inconsistent and heterogeneous testing environment.
OpenStack is like a gigantic Jenga tower, with a full DevStack
deployment resulting in hundreds of libraries and millions of lines of
code all being exercised at once. The testing images are right at the
bottom of all this, and it doesn't take much to make the whole thing
fall over (see points about providers not leaving images alone). This
leads to constant fire-firefighting and everyone annoyed as all CI
stops. Naturally there was a desire for something much more
consistent -- a single image that could run across multiple providers
in a much more tightly controlled manner.
Upstream-based builds
Upstream distributions do provide their "cloud-images", which are
usually pre-canned
.qcow2 format files suitable for uploading to
your average cloud. So the
diskimage-builder tool was
put into use creating images for nodepool, based on these
upstream-provided images. In essence,
diskimage-builder uses a
series of elements (each, as the name suggests, designed to do one
thing) that allow you to build a completely customised image. It
handles all the messy bits of laying out the image file, tries to be
smart about caching large downloads and final things like conversion
to
qcow2 or
vhd or whatever your cloud requires.
nodepool has used
diskimage-builder to create customised images
based upon the upstream releases for some time. These are better, but
still have some issues for the CI environment:
- You still really have no control over what does or does not go into
the upstream base images. You don't notice a change until you
deploy a new image based on an updated version and things break.
- The images still have a fair amount of "stuff" on them. For
example cloud-init is a rather large Python program and has a
fair few dependencies. These dependencies can both conflict with
parts of OpenStack or end up tacitly hiding real test requirements
(the test doesn't specify it, but the package is there as part of
another base dependency -- things break when the base dependencies
change). The whole idea of the CI is that (as much as possible)
you're not making any assumptions about what is required to run
your tests -- you want everything explicitly included. (If you
were starting this whole thing again, things like Docker to keep
"systems within systems" might come into play. Indeed they may be
in the future. But don't forget that DevStack, the major CI
deployment mechanism, was started before Docker existed. And
there's tricky stuff with networking and Neutron etc going on).
- An image that "works everywhere" across multiple cloud-providers is
quite a chore. cloud-init hasn't always had support for
config-drive and Rackspace's DHCP-less environment, for
example. Providers all have their various different networking
schemes or configuration methods which needs to be handled
consistently.
Minimal builds
To this end,
diskimage-builder now has a serial of "minimal"
builds that are really that -- systems with essentially nothing on
them. For Debian and Ubuntu, this is achieved via
debootstrap,
for Fedora and CentOS we replicate this with manual installs of base
packages into a clean
chroot environment. We add-on a range of
important elements that make the image useful; for example, for
networking, we have
simple-init
which brings up the network consistently across all our providers but
has no dependencies to mess with the base system. If you check the
elements
provided by
project-config you can see a range of specific elements
that OpenStack Infra runs at each image build (these are actually
specified by in arguments to nodepool, see the
config file,
particularly
diskimages section). These custom elements do things
like caching, using puppet to install the right
authorized_keys
files and setup a few needed things to connect to the host. In
general, you can see the logs of an image build provided by
nodepool for each daily build.
So now, each day at 14:00 UTC nodepool builds the daily images that
will be used for CI testing. We have
one image of each type that
(theoretically) works across all our providers. After it finishes
building, nodepool uploads the image to all providers (p.s. the
process of doing this is so insanely terrible it spawned
shade; this deserves many posts
of its own) at which point it will start being used for CI jobs. If
you wish to replicate this entire process, the
build-image.sh
script, run on an Ubuntu Trusty host in a virtualenv with
diskimage-builder will get you pretty close (let us know of any
issues!).
Dependencies
But guess what, there's more! Along the way, OpenStack CI ended up
with the concept of
bare nodes and
devstack nodes. A
bare node
was one that was used for functional testing; i.e. what you do when
you type
tox -e py27 in basically any OpenStack project. The
problem here is that
tox has plenty of information about
installing required Python packages into the
virtualenv for
testing; but it doesn't know anything about the
system packages
required to build the Python libraries. This means things like
gcc and
-devel packages which many Python libraries use to
build library bindings.
In contrast to this, DevStack has always been able to bootstrap itself
from a blank system to a working OpenStack deployment, ensuring it has
the right libraries, etc, installed to get everything working. If you
look at all the job definitions, anything running DevStack has a
dsvm in the job name; which referred to to "DevStack Virtual
Machine"; or basically specifying what was installed on the host (and
yes, the
bare node had
more installed, so it wasn't that bare at
all!). Thus we don't want packages pre-installed for DevStack,
because it hides
actual devstack dependencies that we want
explicitly defined. But the
bare nodes, used for functional
testing, were different -- there was an every-growing and not
well-defined list of packages that were pre-installed on those nodes
to make sure functional testing worked. In general, you don't want
jobs relying on something like this; we want to be sure if jobs have a
dependency, they require it explicitly.
This is where a tool called
bindep comes in. OpenStack has
the concept of
global requirements -- those Python dependencies that
are common across all projects so version skew becomes somewhat
manageable. This now has some extra information in the
other-requirements.txt
file, which lists the system-packages required to build the
Python-packages in the requirements list.
bindep knows how to
look at this and get the right packages for the platform it is running
on. Indeed -- remember how it was previously mentioned we want to
minimise dependencies on external resources at runtime? Well we can
pre-cache all of these packages onto the images, knowing that they are
likely to be required by packages. How do we get the packages
installed? The way we really
should be doing it -- as part of the
CI job. There is a macro called
install-distro-packages
which uses
bindep to install those packages as required by the
global-requirements list. The result -- no more need for the
bare
node type to run functional tests! In all cases we can start with
essentially a blank image and all the dependencies to run the job are
expressed
by and
within the job -- leading to a consistent and
reproducible environment. Several things have broken as part of
removing
bare nodes -- this is actually a
good thing because it
means we have revealed areas where we were making assumptions in jobs
about what the underlying platform provides; issues that get fixed by
thinking about and ensuring we have correct dependencies bringing up
jobs. There's a few other macros there that do things like provide
MySQL/Postgres instances or setup other common job requirements. By
splitting these out we also improve the performance of jobs who now
only bring in the dependencies they need -- we don't waste time doing
things like setting up databases for jobs that don't need it.
Conclusion
While dealing with multiple providers, image-types and dependency
chains has been a great effort for the infra team, to everyone's
credit I don't think the project has really noticed much going on
underneath.
OpenStack CI has transitioned to a situation where there is a single
image type for each platform we test that deploys unmodified across
all our providers. We have better insight into our dependencies and
better tools to manage them. This leads to greatly decreased
maintenance burden, better consistency and better performance; all
great things to bring to OpenStack CI!