Files
airflow/scripts/ci/docker_data_root_snapshot.sh
bf177e2948 Accelerate CI image materialization with same-run snapshots (#74173)
* Restore the CI image from a snapshot of Docker's data directory

Every job that prepares the CI image runs `docker image load` on the stash,
which unpacks and checksums every layer of an 8 GB image again, about two
minutes per job. Extracting a copy of the image store into a stopped daemon
gives the same image in about half a minute on the same runners. The image
stash stays as it is, for other consumers and as the fallback when a
snapshot does not fit the daemon a job runs on.

* Recover Docker safely when CI snapshot materialization fails

Snapshot failures must not prevent the authoritative stash from loading, and an older branch snapshot must not substitute an image for the current checkout.

* Scope CI snapshots to the current workflow run

Raw daemon snapshots are job handoffs, so publishing them into branch caches unnecessarily exposes later runs to arbitrary checkout refs.

* Publish authoritative caches before optional CI snapshot execution

The snapshot script runs checkout code and prunes Docker state. Existing cache publications must finish before that optional execution.

* Isolate CI snapshot creation from branch cache publishers

The optional snapshot producer executes checkout code without publishing branch caches; its artifacts are available only to consumers in the same workflow run.

* Bind optional snapshot jobs to the workflow commit

Snapshot producers must not accept arbitrary checkout refs in privileged workflow contexts, and a partially failed Docker stop must still trigger restart cleanup.

* Avoid reloading the CI image before snapshot creation

Preparing the authoritative image in a second runner adds nearly three minutes before consumers can start. All branch publications must finish before the optional snapshot code prunes Docker state.

* Execute the snapshot script from the immutable workflow commit

The builder can check out a configurable ref before publishing caches. Snapshot execution must read the workflow commit itself rather than trust files left in that working tree.

* Keep optional snapshots in an isolated read-only job

Executing snapshot code inside the configurable-ref builder introduces a cache-poisoning finding. A separate job preserves the security boundary; its latency cost must remain explicit when measuring the runner-time benefit.

* Preserve Docker state when snapshot inventory checks fail

An unavailable daemon must not be mistaken for an empty image store before destructive snapshot materialization.

* Retrigger CI after provider compatibility timeout

* Create CI snapshots in the builder after publishing caches

Reloading the freshly built image in a second job delays every consumer by nearly four minutes. Trusted workflow commands reuse the builder daemon without executing code from the configurable checkout.

* Write CI snapshots off the Docker data disk

The builder bind-mounts Docker from /mnt. Reading its image store and writing the compressed archive to that same disk competes for I/O; runner temp keeps the output on the root disk.

* Cache CI dependencies separately from workspace source

Ordinary source edits invalidate the dependency installation layer because it follows the full checkout copy. Reuse locked third-party dependencies while retaining the complete source installation and its resolution fallback.

* Make dependency manifest extraction portable

Use portable path-preserving shell commands for the BuildKit manifest stage and cover nested paths containing spaces.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Run dependency manifest extraction with Bash

Use the shell required by the manifest extraction stage so the BuildKit RUN command works with Docker's default shell.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden dependency manifest extraction

Restore the current UV and prek versions, propagate manifest discovery and copy failures, and cover failure injection in the extraction tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address CI snapshot review and separate dependency caching

* Name the CI image snapshot artifact v1

There has never been an earlier version of this artifact on main, so
the -v2 suffix left over from earlier branch revisions would puzzle the
next reader. Snapshot artifacts are scoped to the workflow run that
uploads them and kept for two days, so no consumer can see an artifact
under the old name and no compatibility shim is needed.

A test now checks that the upload and restore steps use the same
artifact name, so the two cannot drift apart silently.

* Keep CI image snapshots for one day

A snapshot is only downloaded by consumer jobs of the run that uploaded
it. Once it expires, a re-run restores the image stash instead, which
keeps its two-day retention. One day halves the storage the snapshot
adds on top of the stash.

* Drop snapshot-ci-images assertion per review

Co-authored-by: Yossi Eliaz <zozo123@users.noreply.github.com>

* Keep CI image snapshots faithful across reruns and check them before download

- Archive and extract xattrs in every namespace: GNU tar restores only
  user.* by default, which dropped overlay2's trusted.overlay.opaque and
  file capabilities, so a restored image could differ from the built one.
- Upload the snapshot with overwrite, plus a small metadata artifact that
  is uploaded even when the export fails; it reads "unavailable" until the
  archive is complete, so a rerun never restores an earlier attempt.
- Check the metadata against the checkout and daemon before downloading
  the snapshot, and bound extraction with a timeout that falls back to
  the stash after cleaning partial state.
- Load images for prepare_breeze_and_image and prepare_single_ci_image
  through one shared load_image action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Verify restored CI image snapshots and route them through breeze

- Record the image ID and a hash of its merged file tree when creating the
  snapshot and compare both after restoring: a restore that lost an opaque
  directory marker keeps the image ID but changes the tree, so it now falls
  back to the stash instead of passing `bash -c true`.
- Restore through `breeze ci-image load --snapshot-file`, keeping breeze's
  environment checks, image check, diagnostics and rebuild marker.
- Bound the daemon stop and start as well as the extraction.
- Download the snapshot metadata by pattern, which adds no error annotation
  when the builder made no snapshot.
- Limit snapshots to GitHub-hosted runners, where self-hosted runners
  already restore the stash from their own storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check that an unsupported image store skips pruning and publishes only the unavailable marker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sort the snapshot file-tree listing outside the container

Sorting the CI image's 265k-entry listing inside the container spilled
to temporary files in /tmp while find was still walking it, so every run
listed different files and every consumer rejected the snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Create CI image snapshots through breeze ci-image save

The builder now calls `breeze ci-image save --snapshot-file`, mirroring the
`breeze ci-image load --snapshot-file` that consumers restore with, so both
sides of the snapshot go through breeze and its environment checks.

* Retire the snapshot metadata when its archive upload fails and fingerprint ownership and size

The export writes metadata for a complete archive before the upload; if the upload then fails, that metadata would point consumers at an earlier attempt's archive. The tree fingerprint now also covers owner, group and size, and the artifact actions use the v7.0.2/v8.0.2 pins used elsewhere in the repository.

Generated-by: Claude Opus 5

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Shahar Epstein <60007259+shahar1@users.noreply.github.com>
Co-authored-by: Jarek Potiuk <jarek@potiuk.com>
2026-10-31 20:52:55 +00:00

208 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# Moves the CI image between jobs as a copy of the Docker data directory that holds it, so a job
# restores it with one extraction instead of `docker image load` unpacking and checksumming every
# layer again.
#
# docker_data_root_snapshot.sh create SNAPSHOT_FILE
# Creates the snapshot after the builder publishes its caches. SNAPSHOT_FILE.meta reads
# "unavailable" until the archive is complete, so a failed rerun retires an earlier snapshot.
# docker_data_root_snapshot.sh check SNAPSHOT_FILE
# Checks SNAPSHOT_FILE.meta against this checkout and daemon before the archive is downloaded.
# docker_data_root_snapshot.sh restore SNAPSHOT_FILE
# Restores the image. Incompatible snapshots leave the daemon untouched. Failed or overdue
# materialization cleans partial image state and restarts Docker for the normal stash path.
set -euo pipefail
DATA_ROOT="/var/lib/docker"
# overlay2 records opaque directories in trusted.* attributes and file capabilities in security.*;
# GNU tar extracts only user.* attributes unless told otherwise.
TAR_ATTRIBUTES=(--xattrs "--xattrs-include=*" --acls --numeric-owner)
# Every blocking step is bounded, so a stalled restore fails fast instead of holding the job.
RESTORE_TIMEOUT_SECONDS="${SNAPSHOT_RESTORE_TIMEOUT_SECONDS:-300}"
DAEMON_TIMEOUT_SECONDS=60
DAEMON_STOPPED=false
RESTORE_IN_PROGRESS=false
RESTORE_FILE=""
IMAGE_ID=""
EXPECTED_IMAGE=""
function cleanup_daemon() {
local result=$?
trap - EXIT
if [[ -n "${RESTORE_FILE}" ]]; then
rm -f "${RESTORE_FILE}" "${RESTORE_FILE}.meta"
fi
if [[ "${RESTORE_IN_PROGRESS}" == true ]]; then
stop_daemon
remove_image_store
start_daemon
elif [[ "${DAEMON_STOPPED}" == true ]]; then
start_daemon
fi
exit "${result}"
}
trap cleanup_daemon EXIT
function check_supported_daemon() {
local daemon=$1
local _ driver root
read -r _ driver _ root <<< "${daemon}"
if [[ "${driver}" != overlay2 || "${root}" != "${DATA_ROOT}" ]]; then
echo "::warning::Unsupported Docker image store; skipping snapshot: ${daemon}"
exit 3
fi
}
# A data directory is only readable by the daemon version and storage driver that wrote it.
function daemon_fingerprint() {
docker info --format '{{.ServerVersion}} {{.Driver}} {{.Architecture}} {{.DockerRootDir}}'
}
function stop_daemon() {
DAEMON_STOPPED=true
sudo timeout "${DAEMON_TIMEOUT_SECONDS}" systemctl stop docker.socket docker
}
function start_daemon() {
sudo timeout "${DAEMON_TIMEOUT_SECONDS}" systemctl start docker
DAEMON_STOPPED=false
}
# Identifies the image by its ID and by the file tree its layers combine into: a restore that loses
# a whiteout or an opaque-directory marker changes the tree while the ID stays the same. The listing
# is sorted on the host, since sorting it in the container would add sort's temporary files to it.
function image_fingerprint() {
local image=$1
local image_id tree
image_id="$(docker image inspect --format '{{.Id}}' "${image}")" || return 1
tree="$(timeout "${DAEMON_TIMEOUT_SECONDS}" docker run --rm --pull never --network none \
--entrypoint find "${image}" / -xdev -printf '%p %y %m %U %G %s\n' | LC_ALL=C sort | sha256sum)" \
|| return 1
echo "${image_id} ${tree%% *}"
}
function remove_image_store() {
sudo rm -rf "${DATA_ROOT}/image" "${DATA_ROOT}/overlay2"
}
function create_snapshot() {
local snapshot_file="${1}"
local daemon image_id revision fingerprint image_fingerprint_id image_tree
rm -f "${snapshot_file}"
echo "unavailable" > "${snapshot_file}.meta"
daemon="$(daemon_fingerprint)"
check_supported_daemon "${daemon}"
image_id="$(docker images --quiet --filter 'label=org.apache.airflow.image=airflow-ci' | sort -u)"
if [[ -z "${image_id}" || "${image_id}" == *$'\n'* ]]; then
echo "Expected exactly one CI image in the daemon, found: '${image_id}'" >&2
exit 1
fi
revision="$(git rev-parse HEAD)"
fingerprint="$(image_fingerprint "${image_id}")"
read -r image_fingerprint_id image_tree <<< "${fingerprint}"
docker ps --all --quiet | xargs --no-run-if-empty docker rm --force >/dev/null
docker images --quiet | sort -u | {
grep --invert-match --fixed-strings --line-regexp "${image_id}" || true
} | xargs --no-run-if-empty docker rmi --force >/dev/null
docker builder prune --all --force >/dev/null
stop_daemon
sudo tar --directory "${DATA_ROOT}" "${TAR_ATTRIBUTES[@]}" --create --file - image overlay2 \
| zstd -3 -T0 --quiet --force -o "${snapshot_file}"
printf '%s %s %s %s\n' "${daemon}" "${image_fingerprint_id}" "${revision}" "${image_tree}" \
> "${snapshot_file}.meta"
start_daemon
}
# Exits without touching the daemon unless the snapshot was made from this checkout by a daemon
# like this one, and this daemon holds nothing the restore would drop.
function check_snapshot() {
local snapshot_file="${1}"
local -a meta
local snapshot_fingerprint daemon existing_images existing_containers
if [[ ! -f "${snapshot_file}.meta" ]]; then
echo "No snapshot at ${snapshot_file}"
exit 2
fi
read -r -a meta < "${snapshot_file}.meta"
if [[ ${#meta[@]} != 7 ]]; then
echo "Snapshot unavailable or its metadata is invalid"
exit 3
fi
if [[ "${meta[5]}" != "$(git rev-parse HEAD)" ]]; then
echo "Snapshot belongs to a different checkout revision"
exit 3
fi
snapshot_fingerprint="${meta[*]:0:4}"
daemon="$(daemon_fingerprint)"
check_supported_daemon "${daemon}"
if [[ "${snapshot_fingerprint}" != "${daemon}" ]]; then
echo "::warning::Snapshot daemon fingerprint mismatch: '${snapshot_fingerprint}' != '${daemon}'"
exit 3
fi
existing_images="$(docker images --all --quiet)"
existing_containers="$(docker ps --all --quiet)"
if [[ -n "${existing_images}" || -n "${existing_containers}" ]]; then
echo "The daemon already holds images, which restoring the snapshot would drop"
exit 3
fi
IMAGE_ID="${meta[4]}"
EXPECTED_IMAGE="${meta[4]} ${meta[6]}"
}
function restore_snapshot() {
local snapshot_file="${1}"
local restored_image
RESTORE_FILE="${snapshot_file}"
if [[ ! -f "${snapshot_file}" ]]; then
echo "No snapshot at ${snapshot_file}"
exit 2
fi
check_snapshot "${snapshot_file}"
stop_daemon
RESTORE_IN_PROGRESS=true
remove_image_store
timeout "${RESTORE_TIMEOUT_SECONDS}" zstd -d -T0 --quiet --stdout "${snapshot_file}" \
| sudo timeout "${RESTORE_TIMEOUT_SECONDS}" \
tar --directory "${DATA_ROOT}" "${TAR_ATTRIBUTES[@]}" --extract --file -
start_daemon
if ! restored_image="$(image_fingerprint "${IMAGE_ID}")" \
|| [[ "${restored_image}" != "${EXPECTED_IMAGE}" ]]; then
echo "The restored image does not match the snapshot: '${restored_image:-}' != '${EXPECTED_IMAGE}'"
exit 4
fi
RESTORE_IN_PROGRESS=false
}
case "${1:-}" in
create)
create_snapshot "${2}"
;;
check)
check_snapshot "${2}"
;;
restore)
restore_snapshot "${2}"
;;
*)
echo "Usage: ${0} {create|check|restore} SNAPSHOT_FILE" >&2
exit 1
;;
esac