Skip to content

Commit 906f57f

Browse files
authored
Merge pull request moby#43333 from pete-woods/20.10-backport-43291-schema-download-retry
[20.10 backport] distribution: retry downloading schema config on retryable error
2 parents c3dec60 + ce3b6d1 commit 906f57f

File tree

3 files changed

+210
-1
lines changed

3 files changed

+210
-1
lines changed

distribution/pull_v2.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"runtime"
1212
"strings"
13+
"time"
1314

1415
"github.com/containerd/containerd/log"
1516
"github.com/containerd/containerd/platforms"
@@ -899,9 +900,17 @@ func (p *v2Puller) pullManifestList(ctx context.Context, ref reference.Named, mf
899900
return id, manifestListDigest, err
900901
}
901902

903+
const (
904+
defaultSchemaPullBackoff = 250 * time.Millisecond
905+
defaultMaxSchemaPullAttempts = 5
906+
)
907+
902908
func (p *v2Puller) pullSchema2Config(ctx context.Context, dgst digest.Digest) (configJSON []byte, err error) {
903909
blobs := p.repo.Blobs(ctx)
904-
configJSON, err = blobs.Get(ctx, dgst)
910+
err = retry(ctx, defaultMaxSchemaPullAttempts, defaultSchemaPullBackoff, func(ctx context.Context) (err error) {
911+
configJSON, err = blobs.Get(ctx, dgst)
912+
return err
913+
})
905914
if err != nil {
906915
return nil, err
907916
}
@@ -920,6 +929,32 @@ func (p *v2Puller) pullSchema2Config(ctx context.Context, dgst digest.Digest) (c
920929
return configJSON, nil
921930
}
922931

932+
func retry(ctx context.Context, maxAttempts int, sleep time.Duration, f func(ctx context.Context) error) (err error) {
933+
attempt := 0
934+
for ; attempt < maxAttempts; attempt++ {
935+
err = retryOnError(f(ctx))
936+
if err == nil {
937+
return nil
938+
}
939+
if xfer.IsDoNotRetryError(err) {
940+
break
941+
}
942+
943+
if attempt+1 < maxAttempts {
944+
timer := time.NewTimer(sleep)
945+
select {
946+
case <-ctx.Done():
947+
timer.Stop()
948+
return ctx.Err()
949+
case <-timer.C:
950+
logrus.WithError(err).WithField("attempts", attempt+1).Debug("retrying after error")
951+
sleep *= 2
952+
}
953+
}
954+
}
955+
return errors.Wrapf(err, "download failed after attempts=%d", attempt+1)
956+
}
957+
923958
// schema2ManifestDigest computes the manifest digest, and, if pulling by
924959
// digest, ensures that it matches the requested digest.
925960
func schema2ManifestDigest(ref reference.Named, mfst distribution.Manifest) (digest.Digest, error) {

distribution/pull_v2_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
package distribution // import "github.com/docker/docker/distribution"
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"io/ioutil"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/url"
711
"reflect"
812
"regexp"
913
"runtime"
1014
"strings"
15+
"sync/atomic"
1116
"testing"
1217

1318
"github.com/docker/distribution/manifest/schema1"
1419
"github.com/docker/distribution/reference"
20+
"github.com/docker/docker/api/types"
21+
registrytypes "github.com/docker/docker/api/types/registry"
22+
"github.com/docker/docker/image"
23+
"github.com/docker/docker/registry"
1524
digest "github.com/opencontainers/go-digest"
1625
specs "github.com/opencontainers/image-spec/specs-go/v1"
1726
"gotest.tools/v3/assert"
@@ -205,3 +214,160 @@ func TestFormatPlatform(t *testing.T) {
205214
}
206215
}
207216
}
217+
218+
func TestPullSchema2Config(t *testing.T) {
219+
ctx := context.Background()
220+
221+
const imageJSON = `{
222+
"architecture": "amd64",
223+
"os": "linux",
224+
"config": {},
225+
"rootfs": {
226+
"type": "layers",
227+
"diff_ids": []
228+
}
229+
}`
230+
expectedDigest := digest.Digest("sha256:66ad98165d38f53ee73868f82bd4eed60556ddfee824810a4062c4f777b20a5b")
231+
232+
tests := []struct {
233+
name string
234+
handler func(callCount int, w http.ResponseWriter)
235+
expectError string
236+
expectAttempts int64
237+
}{
238+
{
239+
name: "success first time",
240+
handler: func(callCount int, w http.ResponseWriter) {
241+
w.WriteHeader(http.StatusOK)
242+
_, _ = w.Write([]byte(imageJSON))
243+
},
244+
expectAttempts: 1,
245+
},
246+
{
247+
name: "500 status",
248+
handler: func(callCount int, w http.ResponseWriter) {
249+
if callCount == 1 {
250+
w.WriteHeader(http.StatusInternalServerError)
251+
return
252+
}
253+
w.WriteHeader(http.StatusOK)
254+
_, _ = w.Write([]byte(imageJSON))
255+
},
256+
expectAttempts: 2,
257+
},
258+
{
259+
name: "EOF",
260+
handler: func(callCount int, w http.ResponseWriter) {
261+
if callCount == 1 {
262+
panic("intentional panic")
263+
}
264+
w.WriteHeader(http.StatusOK)
265+
_, _ = w.Write([]byte(imageJSON))
266+
},
267+
expectAttempts: 2,
268+
},
269+
{
270+
name: "unauthorized",
271+
handler: func(callCount int, w http.ResponseWriter) {
272+
w.WriteHeader(http.StatusUnauthorized)
273+
},
274+
expectError: "unauthorized: authentication required",
275+
expectAttempts: 1,
276+
},
277+
}
278+
279+
for _, tt := range tests {
280+
tt := tt
281+
t.Run(tt.name, func(t *testing.T) {
282+
var callCount int64
283+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284+
t.Logf("HTTP %s %s", r.Method, r.URL.Path)
285+
defer r.Body.Close()
286+
switch {
287+
case r.Method == "GET" && r.URL.Path == "/v2":
288+
w.WriteHeader(http.StatusOK)
289+
case r.Method == "GET" && r.URL.Path == "/v2/docker.io/library/testremotename/blobs/"+expectedDigest.String():
290+
tt.handler(int(atomic.AddInt64(&callCount, 1)), w)
291+
default:
292+
w.WriteHeader(http.StatusNotFound)
293+
}
294+
}))
295+
defer ts.Close()
296+
297+
p := testNewPuller(t, ts.URL)
298+
299+
config, err := p.pullSchema2Config(ctx, expectedDigest)
300+
if tt.expectError == "" {
301+
if err != nil {
302+
t.Fatal(err)
303+
}
304+
305+
_, err = image.NewFromJSON(config)
306+
if err != nil {
307+
t.Fatal(err)
308+
}
309+
} else {
310+
if err == nil {
311+
t.Fatalf("expected error to contain %q", tt.expectError)
312+
}
313+
if !strings.Contains(err.Error(), tt.expectError) {
314+
t.Fatalf("expected error=%q to contain %q", err, tt.expectError)
315+
}
316+
}
317+
318+
if callCount != tt.expectAttempts {
319+
t.Fatalf("got callCount=%d but expected=%d", callCount, tt.expectAttempts)
320+
}
321+
})
322+
}
323+
}
324+
325+
func testNewPuller(t *testing.T, rawurl string) *v2Puller {
326+
t.Helper()
327+
328+
uri, err := url.Parse(rawurl)
329+
if err != nil {
330+
t.Fatalf("could not parse url from test server: %v", err)
331+
}
332+
333+
endpoint := registry.APIEndpoint{
334+
Mirror: false,
335+
URL: uri,
336+
Version: 2,
337+
Official: false,
338+
TrimHostname: false,
339+
TLSConfig: nil,
340+
}
341+
n, _ := reference.ParseNormalizedNamed("testremotename")
342+
repoInfo := &registry.RepositoryInfo{
343+
Name: n,
344+
Index: &registrytypes.IndexInfo{
345+
Name: "testrepo",
346+
Mirrors: nil,
347+
Secure: false,
348+
Official: false,
349+
},
350+
Official: false,
351+
}
352+
imagePullConfig := &ImagePullConfig{
353+
Config: Config{
354+
MetaHeaders: http.Header{},
355+
AuthConfig: &types.AuthConfig{
356+
RegistryToken: secretRegistryToken,
357+
},
358+
},
359+
Schema2Types: ImageTypes,
360+
}
361+
362+
puller, err := newPuller(endpoint, repoInfo, imagePullConfig, nil)
363+
if err != nil {
364+
t.Fatal(err)
365+
}
366+
p := puller.(*v2Puller)
367+
368+
p.repo, _, err = NewV2Repository(context.Background(), p.repoInfo, p.endpoint, p.config.MetaHeaders, p.config.AuthConfig, "pull")
369+
if err != nil {
370+
t.Fatal(err)
371+
}
372+
return p
373+
}

distribution/xfer/transfer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"sync"
77

88
"github.com/docker/docker/pkg/progress"
9+
"github.com/pkg/errors"
910
)
1011

1112
// DoNotRetry is an error wrapper indicating that the error cannot be resolved
@@ -19,6 +20,13 @@ func (e DoNotRetry) Error() string {
1920
return e.Err.Error()
2021
}
2122

23+
// IsDoNotRetryError returns true if the error is caused by DoNotRetry error,
24+
// and the transfer should not be retried.
25+
func IsDoNotRetryError(err error) bool {
26+
var dnr DoNotRetry
27+
return errors.As(err, &dnr)
28+
}
29+
2230
// Watcher is returned by Watch and can be passed to Release to stop watching.
2331
type Watcher struct {
2432
// signalChan is used to signal to the watcher goroutine that

0 commit comments

Comments
 (0)