diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-14 20:59:14 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-09 03:32:52 +0000 |
| commit | 929d1609efefd3189b650facaaeb3d2a13ffbe1d (patch) | |
| tree | fcbbf69ec29c7a45c3ed31ddb353c4927650f2fa /classes | |
| parent | 4fd9190b7f2f7260b90c7de1609944c96fcf6f64 (diff) | |
| download | meta-virtualization-929d1609efefd3189b650facaaeb3d2a13ffbe1d.tar.gz | |
image-oci: add layer caching for multi-layer OCI builds
Add layer caching to speed up multi-layer OCI image rebuilds. When
enabled, pre-installed package layers are cached to disk and restored
on subsequent builds, avoiding repeated package installation.
New variables:
- OCI_LAYER_CACHE: Enable/disable caching (default "1")
- OCI_LAYER_CACHE_DIR: Cache location (default ${TOPDIR}/oci-layer-cache/${MACHINE})
Cache key is computed from:
- Layer name and type
- Sorted package list
- Package versions from PKGDATA_DIR
- MACHINE and TUNE_PKGARCH
Cache automatically invalidates when:
- Package versions change
- Layer definition changes
- Architecture changes
Benefits:
- First build: ~10-30s per layer (cache miss, packages installed)
- Subsequent builds: ~1s per layer (cache hit, files copied)
- Shared across recipes with identical layer definitions
Build log shows cache status:
NOTE: OCI Cache HIT: Layer 'base' (be88c180f651416b)
NOTE: OCI: Pre-installed packages for 3 layers (cache: 3 hits, 0 misses)
Also adds comprehensive pytest suite for multi-layer OCI functionality
including tests for 1/2/3 layer modes and cache behavior.
Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'classes')
| -rw-r--r-- | classes/image-oci-umoci.inc | 177 | ||||
| -rw-r--r-- | classes/image-oci.bbclass | 26 |
2 files changed, 201 insertions, 2 deletions
diff --git a/classes/image-oci-umoci.inc b/classes/image-oci-umoci.inc index 1ff36718..fbb77cd0 100644 --- a/classes/image-oci-umoci.inc +++ b/classes/image-oci-umoci.inc | |||
| @@ -5,12 +5,152 @@ | |||
| 5 | # directories using Yocto's package manager classes. The shell code then copies | 5 | # directories using Yocto's package manager classes. The shell code then copies |
| 6 | # from these pre-installed directories. | 6 | # from these pre-installed directories. |
| 7 | 7 | ||
| 8 | def oci_compute_layer_cache_key(d, layer_name, layer_type, layer_packages): | ||
| 9 | """ | ||
| 10 | Compute a cache key for a layer based on its definition and package versions. | ||
| 11 | |||
| 12 | The cache key is a SHA256 hash of: | ||
| 13 | - Layer name and type | ||
| 14 | - Sorted package list | ||
| 15 | - Package versions from PKGDATA_DIR | ||
| 16 | - Machine and architecture info | ||
| 17 | |||
| 18 | Returns: (cache_key, cache_info) tuple where cache_info is human-readable | ||
| 19 | """ | ||
| 20 | import hashlib | ||
| 21 | import os | ||
| 22 | import json | ||
| 23 | |||
| 24 | packages = sorted(layer_packages.split()) | ||
| 25 | pkgdata_dir = d.getVar('PKGDATA_DIR') | ||
| 26 | machine = d.getVar('MACHINE') | ||
| 27 | tune_pkgarch = d.getVar('TUNE_PKGARCH') | ||
| 28 | |||
| 29 | # Build cache key components | ||
| 30 | cache_components = { | ||
| 31 | 'layer_name': layer_name, | ||
| 32 | 'layer_type': layer_type, | ||
| 33 | 'packages': packages, | ||
| 34 | 'machine': machine, | ||
| 35 | 'tune_pkgarch': tune_pkgarch, | ||
| 36 | 'pkg_versions': {} | ||
| 37 | } | ||
| 38 | |||
| 39 | # Get package versions from pkgdata | ||
| 40 | for pkg in packages: | ||
| 41 | pkg_info_file = os.path.join(pkgdata_dir, 'runtime', pkg) | ||
| 42 | if os.path.exists(pkg_info_file): | ||
| 43 | try: | ||
| 44 | with open(pkg_info_file, 'r') as f: | ||
| 45 | for line in f: | ||
| 46 | if line.startswith('PKGV:'): | ||
| 47 | cache_components['pkg_versions'][pkg] = line.split(':', 1)[1].strip() | ||
| 48 | break | ||
| 49 | elif line.startswith(f'PKGV_{pkg}:'): | ||
| 50 | cache_components['pkg_versions'][pkg] = line.split(':', 1)[1].strip() | ||
| 51 | break | ||
| 52 | except Exception: | ||
| 53 | pass | ||
| 54 | |||
| 55 | # Create deterministic JSON for hashing | ||
| 56 | cache_json = json.dumps(cache_components, sort_keys=True) | ||
| 57 | cache_key = hashlib.sha256(cache_json.encode()).hexdigest()[:16] | ||
| 58 | |||
| 59 | # Human-readable info for logging | ||
| 60 | pkg_vers = [f"{p}={cache_components['pkg_versions'].get(p, '?')}" for p in packages[:3]] | ||
| 61 | if len(packages) > 3: | ||
| 62 | pkg_vers.append(f"...+{len(packages)-3} more") | ||
| 63 | cache_info = f"{layer_name}:{' '.join(pkg_vers)}" | ||
| 64 | |||
| 65 | return cache_key, cache_info | ||
| 66 | |||
| 67 | |||
| 68 | def oci_check_layer_cache(d, cache_key, layer_name): | ||
| 69 | """ | ||
| 70 | Check if a cached layer exists. | ||
| 71 | |||
| 72 | Returns: path to cached layer rootfs if found, None otherwise | ||
| 73 | """ | ||
| 74 | import os | ||
| 75 | |||
| 76 | cache_dir = d.getVar('OCI_LAYER_CACHE_DIR') | ||
| 77 | if not cache_dir: | ||
| 78 | return None | ||
| 79 | |||
| 80 | cached_path = os.path.join(cache_dir, f'{cache_key}-{layer_name}') | ||
| 81 | marker_file = os.path.join(cached_path, '.oci-layer-cache') | ||
| 82 | |||
| 83 | if os.path.isdir(cached_path) and os.path.exists(marker_file): | ||
| 84 | return cached_path | ||
| 85 | |||
| 86 | return None | ||
| 87 | |||
| 88 | |||
| 89 | def oci_cache_layer(d, cache_key, layer_name, layer_rootfs): | ||
| 90 | """ | ||
| 91 | Save a layer rootfs to the cache. | ||
| 92 | """ | ||
| 93 | import os | ||
| 94 | import shutil | ||
| 95 | import time | ||
| 96 | |||
| 97 | cache_dir = d.getVar('OCI_LAYER_CACHE_DIR') | ||
| 98 | if not cache_dir: | ||
| 99 | return | ||
| 100 | |||
| 101 | bb.utils.mkdirhier(cache_dir) | ||
| 102 | |||
| 103 | cached_path = os.path.join(cache_dir, f'{cache_key}-{layer_name}') | ||
| 104 | |||
| 105 | # Remove any existing cache for this key | ||
| 106 | if os.path.exists(cached_path): | ||
| 107 | shutil.rmtree(cached_path) | ||
| 108 | |||
| 109 | # Copy layer rootfs to cache | ||
| 110 | bb.note(f"OCI Cache: Saving layer '{layer_name}' to cache ({cache_key})") | ||
| 111 | shutil.copytree(layer_rootfs, cached_path, symlinks=True) | ||
| 112 | |||
| 113 | # Write cache marker with metadata | ||
| 114 | marker_file = os.path.join(cached_path, '.oci-layer-cache') | ||
| 115 | with open(marker_file, 'w') as f: | ||
| 116 | f.write(f"cache_key={cache_key}\n") | ||
| 117 | f.write(f"layer_name={layer_name}\n") | ||
| 118 | f.write(f"created={time.strftime('%Y-%m-%dT%H:%M:%SZ')}\n") | ||
| 119 | f.write(f"machine={d.getVar('MACHINE')}\n") | ||
| 120 | |||
| 121 | |||
| 122 | def oci_restore_layer_from_cache(d, cached_path, layer_rootfs): | ||
| 123 | """ | ||
| 124 | Restore a layer rootfs from the cache. | ||
| 125 | """ | ||
| 126 | import os | ||
| 127 | import shutil | ||
| 128 | |||
| 129 | # Ensure target directory exists and is empty | ||
| 130 | if os.path.exists(layer_rootfs): | ||
| 131 | shutil.rmtree(layer_rootfs) | ||
| 132 | bb.utils.mkdirhier(layer_rootfs) | ||
| 133 | |||
| 134 | # Copy cached content (excluding the cache marker) | ||
| 135 | for item in os.listdir(cached_path): | ||
| 136 | if item == '.oci-layer-cache': | ||
| 137 | continue | ||
| 138 | src = os.path.join(cached_path, item) | ||
| 139 | dst = os.path.join(layer_rootfs, item) | ||
| 140 | if os.path.isdir(src): | ||
| 141 | shutil.copytree(src, dst, symlinks=True) | ||
| 142 | else: | ||
| 143 | shutil.copy2(src, dst) | ||
| 144 | |||
| 145 | |||
| 8 | python oci_multilayer_install_packages() { | 146 | python oci_multilayer_install_packages() { |
| 9 | """ | 147 | """ |
| 10 | Pre-install packages for each packages layer in OCI_LAYERS. | 148 | Pre-install packages for each packages layer in OCI_LAYERS. |
| 11 | 149 | ||
| 12 | Creates temp rootfs directories with packages installed using Yocto's PM. | 150 | Creates temp rootfs directories with packages installed using Yocto's PM. |
| 13 | The shell IMAGE_CMD:oci then copies from these directories. | 151 | The shell IMAGE_CMD:oci then copies from these directories. |
| 152 | |||
| 153 | Supports layer caching when OCI_LAYER_CACHE = "1" to speed up rebuilds. | ||
| 14 | """ | 154 | """ |
| 15 | import os | 155 | import os |
| 16 | import shutil | 156 | import shutil |
| @@ -32,7 +172,15 @@ python oci_multilayer_install_packages() { | |||
| 32 | shutil.rmtree(layer_rootfs_base) | 172 | shutil.rmtree(layer_rootfs_base) |
| 33 | bb.utils.mkdirhier(layer_rootfs_base) | 173 | bb.utils.mkdirhier(layer_rootfs_base) |
| 34 | 174 | ||
| 35 | bb.note("OCI: Pre-installing packages for multi-layer mode") | 175 | # Check if caching is enabled |
| 176 | cache_enabled = d.getVar('OCI_LAYER_CACHE') == '1' | ||
| 177 | if cache_enabled: | ||
| 178 | bb.note("OCI: Pre-installing packages for multi-layer mode (caching enabled)") | ||
| 179 | else: | ||
| 180 | bb.note("OCI: Pre-installing packages for multi-layer mode (caching disabled)") | ||
| 181 | |||
| 182 | cache_hits = 0 | ||
| 183 | cache_misses = 0 | ||
| 36 | 184 | ||
| 37 | # Parse OCI_LAYERS and install packages for each packages layer | 185 | # Parse OCI_LAYERS and install packages for each packages layer |
| 38 | layer_num = 0 | 186 | layer_num = 0 |
| @@ -48,17 +196,42 @@ python oci_multilayer_install_packages() { | |||
| 48 | layer_num += 1 | 196 | layer_num += 1 |
| 49 | layer_rootfs = os.path.join(layer_rootfs_base, f'layer-{layer_num}-{layer_name}') | 197 | layer_rootfs = os.path.join(layer_rootfs_base, f'layer-{layer_num}-{layer_name}') |
| 50 | 198 | ||
| 199 | # Check cache if enabled | ||
| 200 | if cache_enabled: | ||
| 201 | cache_key, cache_info = oci_compute_layer_cache_key(d, layer_name, layer_type, layer_content) | ||
| 202 | cached_path = oci_check_layer_cache(d, cache_key, layer_name) | ||
| 203 | |||
| 204 | if cached_path: | ||
| 205 | bb.note(f"OCI Cache HIT: Layer '{layer_name}' ({cache_key})") | ||
| 206 | oci_restore_layer_from_cache(d, cached_path, layer_rootfs) | ||
| 207 | cache_hits += 1 | ||
| 208 | # Store the path for the shell code to use | ||
| 209 | d.setVar(f'OCI_LAYER_{layer_num}_ROOTFS', layer_rootfs) | ||
| 210 | d.setVar(f'OCI_LAYER_{layer_num}_NAME', layer_name) | ||
| 211 | continue | ||
| 212 | else: | ||
| 213 | bb.note(f"OCI Cache MISS: Layer '{layer_name}' ({cache_info})") | ||
| 214 | cache_misses += 1 | ||
| 215 | |||
| 51 | bb.note(f"OCI: Pre-installing layer {layer_num} '{layer_name}' to {layer_rootfs}") | 216 | bb.note(f"OCI: Pre-installing layer {layer_num} '{layer_name}' to {layer_rootfs}") |
| 52 | 217 | ||
| 53 | # Call the package installation function | 218 | # Call the package installation function |
| 54 | oci_install_layer_packages(d, layer_rootfs, layer_content, layer_name) | 219 | oci_install_layer_packages(d, layer_rootfs, layer_content, layer_name) |
| 55 | 220 | ||
| 221 | # Cache the installed layer if caching is enabled | ||
| 222 | if cache_enabled: | ||
| 223 | oci_cache_layer(d, cache_key, layer_name, layer_rootfs) | ||
| 224 | |||
| 56 | # Store the path for the shell code to use | 225 | # Store the path for the shell code to use |
| 57 | d.setVar(f'OCI_LAYER_{layer_num}_ROOTFS', layer_rootfs) | 226 | d.setVar(f'OCI_LAYER_{layer_num}_ROOTFS', layer_rootfs) |
| 58 | d.setVar(f'OCI_LAYER_{layer_num}_NAME', layer_name) | 227 | d.setVar(f'OCI_LAYER_{layer_num}_NAME', layer_name) |
| 59 | 228 | ||
| 60 | d.setVar('OCI_LAYER_COUNT', str(layer_num)) | 229 | d.setVar('OCI_LAYER_COUNT', str(layer_num)) |
| 61 | bb.note(f"OCI: Pre-installed packages for {layer_num} layers") | 230 | |
| 231 | if cache_enabled: | ||
| 232 | bb.note(f"OCI: Pre-installed packages for {layer_num} layers (cache: {cache_hits} hits, {cache_misses} misses)") | ||
| 233 | else: | ||
| 234 | bb.note(f"OCI: Pre-installed packages for {layer_num} layers") | ||
| 62 | } | 235 | } |
| 63 | 236 | ||
| 64 | # Run the Python function before IMAGE_CMD:oci | 237 | # Run the Python function before IMAGE_CMD:oci |
diff --git a/classes/image-oci.bbclass b/classes/image-oci.bbclass index 64b17d97..41f7b3d1 100644 --- a/classes/image-oci.bbclass +++ b/classes/image-oci.bbclass | |||
| @@ -195,6 +195,32 @@ OCI_LAYER_MODE ?= "single" | |||
| 195 | # | 195 | # |
| 196 | OCI_LAYERS ?= "" | 196 | OCI_LAYERS ?= "" |
| 197 | 197 | ||
| 198 | # ============================================================================= | ||
| 199 | # Layer Caching (for multi-layer mode) | ||
| 200 | # ============================================================================= | ||
| 201 | # | ||
| 202 | # OCI_LAYER_CACHE: Enable/disable layer caching ("1" or "0") | ||
| 203 | # When enabled, pre-installed package layers are cached to avoid | ||
| 204 | # reinstalling packages on subsequent builds. | ||
| 205 | # | ||
| 206 | # OCI_LAYER_CACHE_DIR: Directory for storing cached layers | ||
| 207 | # Default: ${TOPDIR}/oci-layer-cache/${MACHINE} | ||
| 208 | # Cache is keyed by: layer definition + package versions + architecture | ||
| 209 | # | ||
| 210 | # Cache key components: | ||
| 211 | # - Layer name and type | ||
| 212 | # - Sorted package list | ||
| 213 | # - Package versions (from PKGDATA_DIR) | ||
| 214 | # - MACHINE and TUNE_PKGARCH | ||
| 215 | # | ||
| 216 | # Cache invalidation: | ||
| 217 | # - Any package version change invalidates layers containing that package | ||
| 218 | # - Layer definition changes invalidate that specific layer | ||
| 219 | # - MACHINE/arch changes use separate cache directories | ||
| 220 | # | ||
| 221 | OCI_LAYER_CACHE ?= "1" | ||
| 222 | OCI_LAYER_CACHE_DIR ?= "${TOPDIR}/oci-layer-cache/${MACHINE}" | ||
| 223 | |||
| 198 | # whether the oci image dir should be left as a directory, or | 224 | # whether the oci image dir should be left as a directory, or |
| 199 | # bundled into a tarball. | 225 | # bundled into a tarball. |
| 200 | OCI_IMAGE_TAR_OUTPUT ?= "true" | 226 | OCI_IMAGE_TAR_OUTPUT ?= "true" |
