Coverage for nova/virt/libvirt/imagecache.py: 92%
220 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
1# Copyright 2012 Michael Still and Canonical Inc
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16"""Image cache manager.
18The cache manager implements the specification at
19http://wiki.openstack.org/nova-image-cache-management.
21"""
23import hashlib
24import os
25import re
26import time
28from oslo_concurrency import lockutils
29from oslo_concurrency import processutils
30from oslo_log import log as logging
31from oslo_utils import encodeutils
33import nova.conf
34import nova.privsep.path
35from nova import utils
36from nova.virt import imagecache
37from nova.virt.libvirt import utils as libvirt_utils
39LOG = logging.getLogger(__name__)
41CONF = nova.conf.CONF
44def get_cache_fname(image_id):
45 """Return a filename based on the SHA1 hash of a given image ID.
47 Image files stored in the _base directory that match this pattern
48 are considered for cleanup by the image cache manager. The cache
49 manager considers the file to be in use if it matches an instance's
50 image_ref, kernel_id or ramdisk_id property.
51 """
52 return hashlib.sha1(image_id.encode('utf-8')).hexdigest()
55class ImageCacheManager(imagecache.ImageCacheManager):
56 def __init__(self):
57 super(ImageCacheManager, self).__init__()
58 self.lock_path = os.path.join(CONF.instances_path, 'locks')
59 self._reset_state()
61 def _reset_state(self):
62 """Reset state variables used for each pass."""
64 self.used_images = {}
65 self.instance_names = set()
67 self.back_swap_images = set()
68 self.used_swap_images = set()
70 self.back_ephemeral_images = set()
71 self.used_ephemeral_images = set()
73 self.active_base_files = []
74 self.originals = []
75 self.removable_base_files = []
76 self.unexplained_images = []
78 def _store_image(self, base_dir, ent, original=False):
79 """Store a base image for later examination."""
80 entpath = os.path.join(base_dir, ent)
81 if os.path.isfile(entpath): 81 ↛ exitline 81 didn't return from function '_store_image' because the condition on line 81 was always true
82 self.unexplained_images.append(entpath)
83 if original:
84 self.originals.append(entpath)
86 def _store_swap_image(self, ent):
87 """Store base swap images for later examination."""
88 names = ent.split('_')
89 if len(names) == 2 and names[0] == 'swap':
90 if len(names[1]) > 0 and names[1].isdigit():
91 LOG.debug('Adding %s into backend swap images', ent)
92 self.back_swap_images.add(ent)
94 def _store_ephemeral_image(self, ent):
95 """Store base ephemeral images for later examination."""
96 names = ent.split('_')
97 if len(names) == 3 and names[0] == 'ephemeral':
98 if len(names[1]) > 0 and names[1].isdigit():
99 if len(names[2]) == 7 and isinstance(names[2], str):
100 LOG.debug('Adding %s into backend ephemeral images', ent)
101 self.back_ephemeral_images.add(ent)
103 def _scan_base_images(self, base_dir):
104 """Scan base images in base_dir and call _store_image or
105 _store_swap_image on each as appropriate. These methods populate
106 self.unexplained_images, self.originals, and self.back_swap_images.
107 """
109 digest_size = hashlib.sha1().digest_size * 2
110 for ent in os.listdir(base_dir):
111 if len(ent) == digest_size:
112 self._store_image(base_dir, ent, original=True)
114 elif len(ent) > digest_size + 2 and ent[digest_size] == '_':
115 self._store_image(base_dir, ent, original=False)
117 else:
118 self._store_swap_image(ent)
119 self._store_ephemeral_image(ent)
121 def _list_backing_images(self):
122 """List the backing images currently in use."""
123 inuse_images = []
124 for ent in os.listdir(CONF.instances_path):
125 if ent in self.instance_names:
126 LOG.debug('%s is a valid instance name', ent)
127 disk_path = os.path.join(CONF.instances_path, ent, 'disk')
128 if os.path.exists(disk_path): 128 ↛ 124line 128 didn't jump to line 124 because the condition on line 128 was always true
129 LOG.debug('%s has a disk file', ent)
130 try:
131 backing_file = libvirt_utils.get_disk_backing_file(
132 disk_path)
133 except processutils.ProcessExecutionError:
134 # (for bug 1261442)
135 if not os.path.exists(disk_path): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 LOG.debug('Failed to get disk backing file: %s',
137 disk_path)
138 continue
139 else:
140 raise
141 LOG.debug('Instance %(instance)s is backed by '
142 '%(backing)s',
143 {'instance': ent,
144 'backing': backing_file})
146 if backing_file: 146 ↛ 124line 146 didn't jump to line 124 because the condition on line 146 was always true
147 backing_path = os.path.join(
148 CONF.instances_path,
149 CONF.image_cache.subdirectory_name,
150 backing_file)
151 if backing_path not in inuse_images:
152 inuse_images.append(backing_path)
154 if backing_path in self.unexplained_images:
155 LOG.warning('Instance %(instance)s is using a '
156 'backing file %(backing)s which '
157 'does not appear in the image service',
158 {'instance': ent,
159 'backing': backing_file})
160 self.unexplained_images.remove(backing_path)
161 return inuse_images
163 def _find_base_file(self, base_dir, fingerprint):
164 """Find the base file matching this fingerprint.
166 Yields the name of a base file which exists.
167 Note that it is possible for more than one yield to result from this
168 check.
170 If no base file is found, then nothing is yielded.
171 """
172 # The original file from glance
173 base_file = os.path.join(base_dir, fingerprint)
174 if os.path.exists(base_file):
175 yield base_file
177 # An older naming style which can be removed sometime after Folsom
178 base_file = os.path.join(base_dir, fingerprint + '_sm')
179 if os.path.exists(base_file):
180 yield base_file
182 # Resized images (also legacy)
183 resize_re = re.compile('.*/%s_[0-9]+$' % fingerprint)
184 for img in self.unexplained_images:
185 m = resize_re.match(img)
186 if m:
187 yield img
189 @staticmethod
190 def _get_age_of_file(base_file):
191 if not os.path.exists(base_file):
192 LOG.debug('Cannot remove %s, it does not exist', base_file)
193 return (False, 0)
195 mtime = os.path.getmtime(base_file)
196 age = time.time() - mtime
198 return (True, age)
200 def _remove_old_enough_file(self, base_file, maxage, remove_lock=True):
201 """Remove a single swap, base or ephemeral file if it is old enough."""
202 exists, age = self._get_age_of_file(base_file)
203 if not exists:
204 return
206 lock_file = os.path.split(base_file)[-1]
208 @utils.synchronized(lock_file, external=True,
209 lock_path=self.lock_path)
210 def _inner_remove_old_enough_file():
211 # NOTE(mikal): recheck that the file is old enough, as a new
212 # user of the file might have come along while we were waiting
213 # for the lock
214 exists, age = self._get_age_of_file(base_file)
215 if not exists or age < maxage: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true
216 return
218 LOG.info('Removing base, swap or ephemeral file: %s', base_file)
219 try:
220 os.remove(base_file)
221 except OSError as e:
222 LOG.error('Failed to remove %(base_file)s, '
223 'error was %(error)s',
224 {'base_file': base_file,
225 'error': e})
227 if age < maxage:
228 LOG.info('Base, swap or ephemeral file too young to remove: %s',
229 base_file)
230 else:
231 _inner_remove_old_enough_file()
232 if remove_lock:
233 try:
234 # NOTE(jichenjc) The lock file will be constructed first
235 # time the image file was accessed. the lock file looks
236 # like nova-9e881789030568a317fad9daae82c5b1c65e0d4a
237 # or nova-03d8e206-6500-4d91-b47d-ee74897f9b4e
238 # according to the original file name
239 lockutils.remove_external_lock_file(lock_file,
240 lock_file_prefix='nova-', lock_path=self.lock_path)
241 except OSError as e:
242 LOG.debug('Failed to remove %(lock_file)s, '
243 'error was %(error)s',
244 {'lock_file': lock_file,
245 'error': e})
247 def _remove_ephemeral_file(self, base_file):
248 """Remove a single ephemeral base file if it is old enough."""
249 maxage = CONF.image_cache.remove_unused_original_minimum_age_seconds
251 self._remove_old_enough_file(base_file, maxage, remove_lock=False)
253 def _remove_swap_file(self, base_file):
254 """Remove a single swap base file if it is old enough."""
255 maxage = CONF.image_cache.remove_unused_original_minimum_age_seconds
257 self._remove_old_enough_file(base_file, maxage, remove_lock=False)
259 def _remove_base_file(self, base_file):
260 """Remove a single base file if it is old enough."""
261 maxage = CONF.image_cache.remove_unused_resized_minimum_age_seconds
262 if base_file in self.originals:
263 maxage = (
264 CONF.image_cache.remove_unused_original_minimum_age_seconds)
266 self._remove_old_enough_file(base_file, maxage)
268 def _mark_in_use(self, img_id, base_file):
269 """Mark a single base image as in use."""
271 LOG.info('image %(id)s at (%(base_file)s): checking',
272 {'id': img_id, 'base_file': base_file})
274 if base_file in self.unexplained_images: 274 ↛ 277line 274 didn't jump to line 277 because the condition on line 274 was always true
275 self.unexplained_images.remove(base_file)
277 self.active_base_files.append(base_file)
279 LOG.debug('image %(id)s at (%(base_file)s): image is in use',
280 {'id': img_id, 'base_file': base_file})
281 nova.privsep.path.utime(base_file)
283 def _age_and_verify_ephemeral_images(self, context, base_dir):
284 LOG.debug('Verify ephemeral images')
286 for ent in self.back_ephemeral_images:
287 base_file = os.path.join(base_dir, ent)
288 if ent in self.used_ephemeral_images and os.path.exists(base_file):
289 nova.privsep.path.utime(base_file)
290 elif self.remove_unused_base_images: 290 ↛ 286line 290 didn't jump to line 286 because the condition on line 290 was always true
291 self._remove_ephemeral_file(base_file)
293 error_images = self.used_ephemeral_images - self.back_ephemeral_images
294 for error_image in error_images: 294 ↛ 295line 294 didn't jump to line 295 because the loop on line 294 never started
295 LOG.warning('%s ephemeral image was used by instance'
296 ' but no back files existing!', error_image)
298 def _age_and_verify_swap_images(self, context, base_dir):
299 LOG.debug('Verify swap images')
301 for ent in self.back_swap_images:
302 base_file = os.path.join(base_dir, ent)
303 if ent in self.used_swap_images and os.path.exists(base_file):
304 nova.privsep.path.utime(base_file)
305 elif self.remove_unused_base_images: 305 ↛ 301line 305 didn't jump to line 301 because the condition on line 305 was always true
306 self._remove_swap_file(base_file)
308 error_images = self.used_swap_images - self.back_swap_images
309 for error_image in error_images: 309 ↛ 310line 309 didn't jump to line 310 because the loop on line 309 never started
310 LOG.warning('%s swap image was used by instance'
311 ' but no back files existing!', error_image)
313 def _age_and_verify_cached_images(self, context, all_instances, base_dir):
314 LOG.debug('Verify base images')
315 # Determine what images are on disk because they're in use
316 for img in self.used_images:
317 fingerprint = hashlib.sha1(
318 encodeutils.safe_encode(img)).hexdigest()
319 LOG.debug('Image id %(id)s yields fingerprint %(fingerprint)s',
320 {'id': img,
321 'fingerprint': fingerprint})
322 for base_file in self._find_base_file(base_dir, fingerprint):
323 self._mark_in_use(img, base_file)
325 # Elements remaining in unexplained_images might be in use
326 inuse_backing_images = self._list_backing_images()
327 for backing_path in inuse_backing_images: 327 ↛ 328line 327 didn't jump to line 328 because the loop on line 327 never started
328 if backing_path not in self.active_base_files:
329 self.active_base_files.append(backing_path)
331 # Anything left is an unknown base image
332 for img in self.unexplained_images:
333 LOG.warning('Unknown base file: %s', img)
334 self.removable_base_files.append(img)
336 # Dump these lists
337 if self.active_base_files: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was always true
338 LOG.info('Active base files: %s',
339 ' '.join(self.active_base_files))
341 if self.removable_base_files: 341 ↛ 350line 341 didn't jump to line 350 because the condition on line 341 was always true
342 LOG.info('Removable base files: %s',
343 ' '.join(self.removable_base_files))
345 if self.remove_unused_base_images: 345 ↛ 350line 345 didn't jump to line 350 because the condition on line 345 was always true
346 for base_file in self.removable_base_files:
347 self._remove_base_file(base_file)
349 # That's it
350 LOG.debug('Verification complete')
352 def _get_base(self):
354 # NOTE(mikal): The new scheme for base images is as follows -- an
355 # image is streamed from the image service to _base (filename is the
356 # sha1 hash of the image id). If CoW is enabled, that file is then
357 # resized to be the correct size for the instance (filename is the
358 # same as the original, but with an underscore and the resized size
359 # in bytes). This second file is then CoW'd to the instance disk. If
360 # CoW is disabled, the resize occurs as part of the copy from the
361 # cache to the instance directory. Files ending in _sm are no longer
362 # created, but may remain from previous versions.
364 base_dir = os.path.join(CONF.instances_path,
365 CONF.image_cache.subdirectory_name)
366 if not os.path.exists(base_dir):
367 LOG.debug('Skipping verification, no base directory at %s',
368 base_dir)
369 return
370 return base_dir
372 def update(self, context, all_instances):
373 base_dir = self._get_base()
374 if not base_dir:
375 return
376 # reset the local statistics
377 self._reset_state()
378 # read the cached images
379 self._scan_base_images(base_dir)
380 # read running instances data
381 running = self._list_running_instances(context, all_instances)
382 self.used_images = running['used_images']
383 self.instance_names = running['instance_names']
384 self.used_swap_images = running['used_swap_images']
385 self.used_ephemeral_images = running['used_ephemeral_images']
386 # perform the aging and image verification
387 self._age_and_verify_cached_images(context, all_instances, base_dir)
388 self._age_and_verify_swap_images(context, base_dir)
389 self._age_and_verify_ephemeral_images(context, base_dir)
391 def get_disk_usage(self):
392 try:
393 # If the cache is on a different device than the instance dir then
394 # it does not consume the same disk resource as instances.
395 if (os.stat(CONF.instances_path).st_dev !=
396 os.stat(self.cache_dir).st_dev):
397 return 0
399 # NOTE(gibi): we need to use the disk size occupied from the file
400 # system as images in the cache will not grow to their virtual
401 # size.
402 # NOTE(gibi): st.blocks is always measured in 512 byte blocks see
403 # man fstat
404 return sum(
405 os.stat(os.path.join(self.cache_dir, f)).st_blocks * 512
406 for f in os.listdir(self.cache_dir)
407 if os.path.isfile(os.path.join(self.cache_dir, f)))
408 except OSError:
409 # NOTE(gibi): An error here can mean many things. E.g. the cache
410 # dir does not exists yet, the cache dir is deleted between the
411 # listdir() and the stat() calls, or a file is deleted between
412 # the listdir() and the stat() calls.
413 return 0
415 @property
416 def cache_dir(self):
417 return os.path.join(
418 CONF.instances_path, CONF.image_cache.subdirectory_name)