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

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. 

15 

16"""Image cache manager. 

17 

18The cache manager implements the specification at 

19http://wiki.openstack.org/nova-image-cache-management. 

20 

21""" 

22 

23import hashlib 

24import os 

25import re 

26import time 

27 

28from oslo_concurrency import lockutils 

29from oslo_concurrency import processutils 

30from oslo_log import log as logging 

31from oslo_utils import encodeutils 

32 

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 

38 

39LOG = logging.getLogger(__name__) 

40 

41CONF = nova.conf.CONF 

42 

43 

44def get_cache_fname(image_id): 

45 """Return a filename based on the SHA1 hash of a given image ID. 

46 

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() 

53 

54 

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() 

60 

61 def _reset_state(self): 

62 """Reset state variables used for each pass.""" 

63 

64 self.used_images = {} 

65 self.instance_names = set() 

66 

67 self.back_swap_images = set() 

68 self.used_swap_images = set() 

69 

70 self.back_ephemeral_images = set() 

71 self.used_ephemeral_images = set() 

72 

73 self.active_base_files = [] 

74 self.originals = [] 

75 self.removable_base_files = [] 

76 self.unexplained_images = [] 

77 

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) 

85 

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) 

93 

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) 

102 

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 """ 

108 

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) 

113 

114 elif len(ent) > digest_size + 2 and ent[digest_size] == '_': 

115 self._store_image(base_dir, ent, original=False) 

116 

117 else: 

118 self._store_swap_image(ent) 

119 self._store_ephemeral_image(ent) 

120 

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}) 

145 

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) 

153 

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 

162 

163 def _find_base_file(self, base_dir, fingerprint): 

164 """Find the base file matching this fingerprint. 

165 

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. 

169 

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 

176 

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 

181 

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 

188 

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) 

194 

195 mtime = os.path.getmtime(base_file) 

196 age = time.time() - mtime 

197 

198 return (True, age) 

199 

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 

205 

206 lock_file = os.path.split(base_file)[-1] 

207 

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 

217 

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}) 

226 

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}) 

246 

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 

250 

251 self._remove_old_enough_file(base_file, maxage, remove_lock=False) 

252 

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 

256 

257 self._remove_old_enough_file(base_file, maxage, remove_lock=False) 

258 

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) 

265 

266 self._remove_old_enough_file(base_file, maxage) 

267 

268 def _mark_in_use(self, img_id, base_file): 

269 """Mark a single base image as in use.""" 

270 

271 LOG.info('image %(id)s at (%(base_file)s): checking', 

272 {'id': img_id, 'base_file': base_file}) 

273 

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) 

276 

277 self.active_base_files.append(base_file) 

278 

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) 

282 

283 def _age_and_verify_ephemeral_images(self, context, base_dir): 

284 LOG.debug('Verify ephemeral images') 

285 

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) 

292 

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) 

297 

298 def _age_and_verify_swap_images(self, context, base_dir): 

299 LOG.debug('Verify swap images') 

300 

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) 

307 

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) 

312 

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) 

324 

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) 

330 

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) 

335 

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)) 

340 

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)) 

344 

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) 

348 

349 # That's it 

350 LOG.debug('Verification complete') 

351 

352 def _get_base(self): 

353 

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. 

363 

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 

371 

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) 

390 

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 

398 

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 

414 

415 @property 

416 def cache_dir(self): 

417 return os.path.join( 

418 CONF.instances_path, CONF.image_cache.subdirectory_name)