-
Notifications
You must be signed in to change notification settings - Fork 226
registry pull retries #2443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
registry pull retries #2443
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ use std::collections::HashSet; | |
| use std::io::{BufRead, Write}; | ||
| use std::os::fd::AsFd; | ||
| use std::process::Command; | ||
| use std::time::Duration; | ||
|
|
||
| use anyhow::{Context, Result, anyhow}; | ||
| use bootc_utils::skopeo_bin; | ||
|
|
@@ -76,6 +77,13 @@ use crate::utils::async_task_with_spinner; | |
| // TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a | ||
| const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; | ||
|
|
||
| // Match the default attempt count and delay used by the Justfile's build-fetch | ||
| // retry helper. A failed attempt has to rebuild the importer, so retries are | ||
| // intentionally made at the whole-pull boundary instead of independently for | ||
| // every layer. | ||
| const PULL_MAX_ATTEMPTS: u32 = 3; | ||
| const PULL_RETRY_DELAY: Duration = Duration::from_secs(30); | ||
|
|
||
| /// Create an ImageProxyConfig with bootc's user agent prefix set. | ||
| /// | ||
| /// This allows registries to distinguish "image pulls for bootc client runs" | ||
|
|
@@ -769,8 +777,59 @@ pub(crate) async fn pull_from_prepared( | |
| Ok(Box::new((*import).into())) | ||
| } | ||
|
|
||
| /// Wrapper for pulling a container image, wiring up status output. | ||
| pub(crate) async fn pull( | ||
| fn is_retryable_pull_error(transport: &str, error: &anyhow::Error) -> bool { | ||
| if transport != "registry" { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'd be cleaner to skip the retry loop at a higher level |
||
| return false; | ||
| } | ||
|
|
||
| // The legacy GetBlob proxy method does not preserve a typed distinction | ||
| // between transient registry failures and errors such as a missing blob. | ||
| // Retry the opaque registry error at this top-level boundary, with the | ||
| // attempt limit above preventing an unbounded delay. | ||
| error.chain().any(|source| { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not too sure about this. What happens if the image itself doesn't exist? Will we still keep retrying?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I think this should only use https://github.com/bootc-dev/containers-image-proxy-rs/blob/154f4868c2fbf0ea60bad91651389260e5f1e948/src/imageproxy.rs#L91 |
||
| matches!( | ||
| source.downcast_ref::<ostree_ext::containers_image_proxy::Error>(), | ||
| Some( | ||
| ostree_ext::containers_image_proxy::Error::RequestInitiationFailure { | ||
| method, | ||
| .. | ||
| } | ||
| ) if method.as_ref() == "GetBlob" | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| async fn retry_pull_operation<F, Fut, T>( | ||
| transport: &str, | ||
| mut operation: F, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this argument? It's just |
||
| retry_delay: Duration, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ) -> Result<T> | ||
| where | ||
| F: FnMut() -> Fut, | ||
| Fut: std::future::Future<Output = Result<T>>, | ||
| { | ||
| for attempt in 1..=PULL_MAX_ATTEMPTS { | ||
| match operation().await { | ||
| Ok(value) => return Ok(value), | ||
| Err(error) | ||
| if attempt < PULL_MAX_ATTEMPTS && is_retryable_pull_error(transport, &error) => | ||
| { | ||
| tracing::warn!( | ||
| attempt, | ||
| max_attempts = PULL_MAX_ATTEMPTS, | ||
| retry_delay_seconds = retry_delay.as_secs(), | ||
| error = %error, | ||
| "Container image pull failed; retrying" | ||
| ); | ||
| tokio::time::sleep(retry_delay).await; | ||
| } | ||
| Err(error) => return Err(error), | ||
| } | ||
| } | ||
| unreachable!("the pull attempt range is non-empty") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's have this be an error instead |
||
| } | ||
|
|
||
| async fn pull_once( | ||
| repo: &ostree::Repo, | ||
| imgref: &ImageReference, | ||
| target_imgref: Option<&OstreeImageReference>, | ||
|
|
@@ -810,6 +869,32 @@ pub(crate) async fn pull( | |
| } | ||
| } | ||
|
|
||
| /// Wrapper for pulling a container image, wiring up status output. | ||
| pub(crate) async fn pull( | ||
| repo: &ostree::Repo, | ||
| imgref: &ImageReference, | ||
| target_imgref: Option<&OstreeImageReference>, | ||
| quiet: bool, | ||
| prog: ProgressWriter, | ||
| booted_deployment: Option<&ostree::Deployment>, | ||
| ) -> Result<Box<ImageState>> { | ||
| retry_pull_operation( | ||
| &imgref.transport, | ||
| || { | ||
| pull_once( | ||
| repo, | ||
| imgref, | ||
| target_imgref, | ||
| quiet, | ||
| prog.clone(), | ||
| booted_deployment, | ||
| ) | ||
| }, | ||
| PULL_RETRY_DELAY, | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> { | ||
| tokio::task::spawn_blocking(move || { | ||
| sysroot | ||
|
|
@@ -1403,6 +1488,99 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { | |
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn get_blob_failure(message: &str) -> anyhow::Error { | ||
| let error = ostree_ext::containers_image_proxy::Error::RequestInitiationFailure { | ||
| method: "GetBlob".into(), | ||
| error: message.into(), | ||
| }; | ||
| anyhow::Error::from(error).context("Unencapsulating base") | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_retry_pull_operation_succeeds() -> Result<()> { | ||
| let attempts = std::cell::Cell::new(0); | ||
| let value = retry_pull_operation( | ||
| "registry", | ||
| || { | ||
| let attempt = attempts.get() + 1; | ||
| attempts.set(attempt); | ||
| async move { | ||
| if attempt < PULL_MAX_ATTEMPTS { | ||
| Err(get_blob_failure("502 Bad Gateway")) | ||
| } else { | ||
| Ok(42) | ||
| } | ||
| } | ||
| }, | ||
| Duration::ZERO, | ||
| ) | ||
| .await?; | ||
|
|
||
| assert_eq!(value, 42); | ||
| assert_eq!(attempts.get(), PULL_MAX_ATTEMPTS); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_retry_pull_operation_stops_after_max_attempts() { | ||
| let attempts = std::cell::Cell::new(0); | ||
| let error = retry_pull_operation( | ||
| "registry", | ||
| || { | ||
| attempts.set(attempts.get() + 1); | ||
| async { Err::<(), _>(get_blob_failure("blob unknown")) } | ||
| }, | ||
| Duration::ZERO, | ||
| ) | ||
| .await | ||
| .unwrap_err(); | ||
|
|
||
| assert_eq!(attempts.get(), PULL_MAX_ATTEMPTS); | ||
| assert_eq!( | ||
| error.root_cause().to_string(), | ||
| "failed to invoke method GetBlob: blob unknown" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_retry_pull_operation_does_not_retry_other_errors() { | ||
| let attempts = std::cell::Cell::new(0); | ||
| let error = retry_pull_operation( | ||
| "registry", | ||
| || { | ||
| attempts.set(attempts.get() + 1); | ||
| async { Err::<(), _>(anyhow!("invalid image configuration")) } | ||
| }, | ||
| Duration::ZERO, | ||
| ) | ||
| .await | ||
| .unwrap_err(); | ||
|
|
||
| assert_eq!(attempts.get(), 1); | ||
| assert_eq!(error.to_string(), "invalid image configuration"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_retry_pull_operation_does_not_retry_local_storage() { | ||
| let attempts = std::cell::Cell::new(0); | ||
| let error = retry_pull_operation( | ||
| "containers-storage", | ||
| || { | ||
| attempts.set(attempts.get() + 1); | ||
| async { Err::<(), _>(get_blob_failure("local storage unavailable")) } | ||
| }, | ||
| Duration::ZERO, | ||
| ) | ||
| .await | ||
| .unwrap_err(); | ||
|
|
||
| assert_eq!(attempts.get(), 1); | ||
| assert_eq!( | ||
| error.root_cause().to_string(), | ||
| "failed to invoke method GetBlob: local storage unavailable" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_new_proxy_config_user_agent() { | ||
| let config = new_proxy_config(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -195,7 +195,7 @@ use crate::bootc_composefs::{ | |
| use crate::bootc_kargs::{INITRD_ARG_PREFIX, ROOTFLAGS_KEY}; | ||
| use crate::boundimage::{BoundImage, ResolvedBoundImage}; | ||
| use crate::containerenv::ContainerExecutionInfo; | ||
| use crate::deploy::{MergeState, PreparedPullResult, prepare_for_pull, pull_from_prepared}; | ||
| use crate::deploy::{MergeState, PreparedPullResult, pull, pull_from_prepared}; | ||
| use crate::install::config::Filesystem as FilesystemEnum; | ||
| use crate::lsm; | ||
| use crate::progress_jsonl::ProgressWriter; | ||
|
|
@@ -1073,26 +1073,34 @@ async fn install_container( | |
| // Auto-detection (None) is only appropriate for upgrade/switch on a running system. | ||
| let use_unified = state.target_opts.unified_storage_exp; | ||
|
|
||
| let prepared = if use_unified { | ||
| let pulled_image = if use_unified { | ||
| tracing::info!("Using unified storage path for installation"); | ||
| crate::deploy::prepare_for_pull_unified( | ||
| let prepared = crate::deploy::prepare_for_pull_unified( | ||
| repo, | ||
| &spec_imgref, | ||
| Some(&state.target_imgref), | ||
| storage, | ||
| None, | ||
| ) | ||
| .await? | ||
| } else { | ||
| prepare_for_pull(repo, &spec_imgref, Some(&state.target_imgref), None).await? | ||
| }; | ||
|
|
||
| let pulled_image = match prepared { | ||
| PreparedPullResult::AlreadyPresent(existing) => existing, | ||
| PreparedPullResult::Ready(image_meta) => { | ||
| crate::deploy::check_disk_space_ostree(repo, &image_meta, &spec_imgref)?; | ||
| pull_from_prepared(&spec_imgref, false, ProgressWriter::default(), *image_meta).await? | ||
| .await?; | ||
| match prepared { | ||
| PreparedPullResult::AlreadyPresent(existing) => existing, | ||
| PreparedPullResult::Ready(image_meta) => { | ||
| crate::deploy::check_disk_space_ostree(repo, &image_meta, &spec_imgref)?; | ||
| pull_from_prepared(&spec_imgref, false, ProgressWriter::default(), *image_meta) | ||
| .await? | ||
| } | ||
| } | ||
| } else { | ||
| pull( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't this skip the disk space check?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's also losing the separation between "prepare" and "pull" for the non-unified path. prepare is "fetch manifest to use for change detection", "pull" = "download whole image". There's a bigger picture issue here in that we're not doing retries for the first, but we need to in the general case as we can hit flakes there too (DNS, TCP etc). |
||
| repo, | ||
| &spec_imgref, | ||
| Some(&state.target_imgref), | ||
| false, | ||
| ProgressWriter::default(), | ||
| None, | ||
| ) | ||
| .await? | ||
| }; | ||
|
|
||
| repo.set_disable_fsync(false); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Conceptually I think we should be matching what e.g. podman does by default. What our build system happens to do is a different unrelated thing.
On that topic see podman-container-tools/container-libs#951
In the short term, we can just copy the same defaults and add a link to that issue as a TODO to allow having bootc be configurable in the same way.