-
Notifications
You must be signed in to change notification settings - Fork 5.3k
fix(proxycache): retry interrupted upstream blob fetches, verify digest before caching #23601
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
Open
oscrx
wants to merge
6
commits into
goharbor:main
Choose a base branch
from
oscrx:proxy-cache-retry-fix
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8ed7450
fix(proxycache): retry interrupted upstream blob fetches, verify dige…
oscrx 81c5628
fix(proxycache): validate ranged pulls, retry premature EOF, verify d…
oscrx 2a476c7
fix(proxycache): tighten chunk-range validation, cancel retry loop on…
oscrx 4dafa98
test(proxycache): fix typo in test name (AsSoon)
oscrx 638cb24
fix(proxycache): treat an error accompanying the final blob bytes as …
oscrx bbc3c78
fix(proxycache): don't retry a resume attempt that gets rate-limited
oscrx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| // Copyright Project Harbor Authors | ||
| // | ||
| // Licensed 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. | ||
|
|
||
| package proxy | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "time" | ||
|
|
||
| "github.com/opencontainers/go-digest" | ||
|
|
||
| libErrors "github.com/goharbor/harbor/src/lib/errors" | ||
| "github.com/goharbor/harbor/src/lib/log" | ||
| ) | ||
|
|
||
| const ( | ||
| // maxBlobFetchRetries bounds how many times an interrupted upstream blob | ||
| // fetch is resumed before the read error is returned to the caller. | ||
| maxBlobFetchRetries = 3 | ||
| ) | ||
|
|
||
| // blobFetchRetryBackoff is the delay before the first resume attempt; it | ||
| // doubles on each subsequent attempt (1s, 2s, 4s by default). It's a var | ||
| // rather than a const so tests can shrink it. | ||
| var blobFetchRetryBackoff = time.Second | ||
|
|
||
| // resumeBlobFunc opens a reader for the remaining bytes of a blob, starting | ||
| // at offset. It's used to resume a fetch that broke mid-stream. | ||
| type resumeBlobFunc func(offset int64) (io.ReadCloser, error) | ||
|
|
||
| // resumingBlobReader wraps an upstream blob body and transparently resumes | ||
| // the fetch, via resume, if the connection breaks mid-stream - instead of | ||
| // failing the whole blob on a single dropped connection, the way a plain | ||
| // docker/OCI client retries an interrupted layer pull. Resuming is bounded by | ||
| // maxBlobFetchRetries and disabled when size is unknown (<= 0), since the | ||
| // read offset can't be validated against the blob length in that case. | ||
| // | ||
| // resume itself isn't cancelable once a request is in flight - the | ||
| // underlying RemoteInterface doesn't accept a context - but the backoff wait | ||
| // between attempts, and any attempt not yet started, respect ctx, so a | ||
| // canceled caller (e.g. a disconnected pulling client) stops this reader | ||
| // from waiting out further backoffs and opening further upstream requests. | ||
| type resumingBlobReader struct { | ||
| ctx context.Context | ||
| reader io.ReadCloser | ||
| resume resumeBlobFunc | ||
| size int64 | ||
| offset int64 | ||
| retries int | ||
| completed bool | ||
| } | ||
|
|
||
| // newResumingBlobReader wraps reader so a mid-stream read failure is retried | ||
| // up to maxBlobFetchRetries times by resuming the fetch from the last | ||
| // successfully read byte offset (via an HTTP range request through resume). | ||
| // ctx bounds the retry loop itself (see resumingBlobReader); pass the | ||
| // pulling client's request context to give up promptly if it disconnects, | ||
| // or a detached context (e.g. context.Background()) for background work | ||
| // that should keep retrying regardless. | ||
| func newResumingBlobReader(ctx context.Context, reader io.ReadCloser, size int64, resume resumeBlobFunc) io.ReadCloser { | ||
| return &resumingBlobReader{ctx: ctx, reader: reader, size: size, resume: resume} | ||
| } | ||
|
|
||
| func (r *resumingBlobReader) Read(p []byte) (int, error) { | ||
| if r.completed { | ||
| // A prior call already reached the declared size; self-terminate | ||
| // instead of calling into a reader that may now be stale/exhausted. | ||
| return 0, io.EOF | ||
| } | ||
| for { | ||
| n, err := r.reader.Read(p) | ||
| r.offset += int64(n) | ||
|
|
||
| if r.size > 0 && r.offset >= r.size { | ||
| // We now have the full declared size, regardless of what error | ||
| // (if any) came with these final bytes - an io.Reader may | ||
| // legally pair its last bytes with a non-nil error instead of | ||
| // returning them cleanly and erroring only on a subsequent | ||
| // call. Report success once; further calls are handled by the | ||
| // completed check above rather than re-reading this reader. | ||
| r.completed = true | ||
| return n, nil | ||
| } | ||
|
|
||
| if err == nil { | ||
| return n, nil | ||
| } | ||
| if err == io.EOF { // nolint:errorlint | ||
| if r.size <= 0 { | ||
| // Nothing to validate the EOF against; trust it. | ||
| return n, err | ||
| } | ||
| // The stream ended cleanly but short of the expected size - | ||
| // e.g. a network intermediary that terminates a chunked | ||
| // response gracefully instead of resetting the connection. | ||
| // Treat it the same as a dropped connection so it consumes | ||
| // the retry budget instead of being accepted as complete. | ||
| err = io.ErrUnexpectedEOF | ||
| } | ||
| if !r.canRetry(err) { | ||
| return n, err | ||
| } | ||
| if rErr := r.reconnect(err); rErr != nil { | ||
| return n, rErr | ||
| } | ||
| if n > 0 { | ||
| return n, nil | ||
| } | ||
| // n == 0: loop and read immediately from the freshly resumed reader. | ||
| } | ||
| } | ||
|
|
||
| // reconnect closes the broken reader and resumes the fetch from the current | ||
| // offset, retrying the resume itself (bounded by the same retry budget) if | ||
| // establishing the new connection also fails. It gives up early, without | ||
| // waiting out the backoff or starting another attempt, if ctx is canceled. | ||
| func (r *resumingBlobReader) reconnect(cause error) error { | ||
| _ = r.reader.Close() | ||
| for { | ||
| if ctxErr := r.ctx.Err(); ctxErr != nil { | ||
| return fmt.Errorf("blob fetch canceled while resuming after %v: %w", cause, ctxErr) | ||
| } | ||
| r.retries++ | ||
| backoff := blobFetchRetryBackoff * time.Duration(uint64(1)<<uint(r.retries-1)) | ||
| log.Warningf("proxy cache: upstream blob read interrupted at byte %d/%d (%v), resuming attempt %d/%d in %s", | ||
| r.offset, r.size, cause, r.retries, maxBlobFetchRetries, backoff) | ||
| timer := time.NewTimer(backoff) | ||
| select { | ||
| case <-r.ctx.Done(): | ||
| timer.Stop() | ||
| return fmt.Errorf("blob fetch canceled while waiting to resume after %v: %w", cause, r.ctx.Err()) | ||
| case <-timer.C: | ||
| } | ||
| next, err := r.resume(r.offset) | ||
| if err == nil { | ||
| r.reader = next | ||
| return nil | ||
| } | ||
| if libErrors.IsRateLimitError(err) { | ||
| // A registry that's rate-limiting won't be helped by retrying | ||
| // within a few seconds of backoff - real rate limits typically | ||
| // need a much longer cool-down - so give up instead of burning | ||
| // through the retry budget on requests likely to fail the same | ||
| // way and adding more load to an already-throttled upstream. | ||
| return fmt.Errorf("upstream rate-limited the resume attempt after %v: %w", cause, err) | ||
| } | ||
| if r.retries >= maxBlobFetchRetries { | ||
| return fmt.Errorf("failed to resume interrupted blob fetch after %v: %w", cause, err) | ||
| } | ||
| cause = err | ||
| } | ||
| } | ||
|
|
||
| func (r *resumingBlobReader) canRetry(err error) bool { | ||
| return r.size > 0 && r.retries < maxBlobFetchRetries && r.offset < r.size && isRetryableBlobReadErr(err) | ||
| } | ||
|
|
||
| func (r *resumingBlobReader) Close() error { | ||
| return r.reader.Close() | ||
| } | ||
|
|
||
| // isRetryableBlobReadErr reports whether err looks like a transient failure | ||
| // of the upstream connection - dropped, reset, or timed out mid-transfer - | ||
| // worth retrying, as opposed to the pulling client itself having given up. | ||
| func isRetryableBlobReadErr(err error) bool { | ||
| return err != nil && !errors.Is(err, context.Canceled) | ||
| } | ||
|
|
||
| // verifyingReadCloser wraps a blob reader and verifies the bytes read so far | ||
| // match the expected digest as soon as the declared size has been read - not | ||
| // only when the wrapped reader happens to signal io.EOF on some later call. | ||
| // A consumer with its own framing (e.g. an HTTP request body capped by | ||
| // Content-Length) may never issue that extra call, since it can determine | ||
| // on its own that it has all the bytes it declared and stop reading. A | ||
| // mismatch is surfaced as a Read error, so a corrupted or truncated blob | ||
| // that still parses as a well-formed HTTP response is never mistaken for a | ||
| // complete, valid one by the caller (e.g. the local registry committing a | ||
| // blob push). | ||
| type verifyingReadCloser struct { | ||
| io.ReadCloser | ||
| verifier digest.Verifier | ||
| dig digest.Digest | ||
| size int64 | ||
| read int64 | ||
| verified bool | ||
| } | ||
|
|
||
| // newVerifyingReadCloser wraps reader so that, once size bytes have been | ||
| // read (or the stream ends, if size is unknown), content not matching dig | ||
| // surfaces as a Read error. | ||
| func newVerifyingReadCloser(reader io.ReadCloser, dig digest.Digest, size int64) io.ReadCloser { | ||
| return &verifyingReadCloser{ReadCloser: reader, verifier: dig.Verifier(), dig: dig, size: size} | ||
| } | ||
|
|
||
| func (v *verifyingReadCloser) Read(p []byte) (int, error) { | ||
| n, err := v.ReadCloser.Read(p) | ||
| if n > 0 { | ||
| _, _ = v.verifier.Write(p[:n]) | ||
| v.read += int64(n) | ||
| } | ||
| if !v.verified && ((v.size > 0 && v.read >= v.size) || err == io.EOF) { // nolint:errorlint | ||
| v.verified = true | ||
| if !v.verifier.Verified() { | ||
| return n, fmt.Errorf("blob content does not match expected digest %s after fetching from upstream", v.dig) | ||
| } | ||
| } | ||
| return n, err | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.