-
Notifications
You must be signed in to change notification settings - Fork 18.6k
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
c8d: Implement push #44963
c8d: Implement push #44963
Conversation
11b21c5
to
31376c9
Compare
Arghh, wanted to make the PushImage accept
|
7d7c0c3
to
b39819b
Compare
I'm not sure whether it is safe to push a manifest without its layer blobs.
|
return *s, nil | ||
} | ||
|
||
const labelDistributionSource = "containerd.io/distribution.source." |
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.
nit: The const should be publicized on containerd side: https://github.com/containerd/containerd/blob/97480afdac09c947d48f5e3a134db86c78f4bfa6/remotes/docker/handler.go#L35
Can be done later though
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.
Opened a PR: containerd/containerd#8224
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.
Maybe we could leave a breadcrumb in a comment? Something like this? (I'm sure it could be better, but just a hint as to how to update this in the future)
const labelDistributionSource = "containerd.io/distribution.source." | |
const labelDistributionSource = "containerd.io/distribution.source." // TODO https://github.com/containerd/containerd/pull/8224 in containerd 1.7+ |
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.
Does containerd append these labels on push, too? (or only pull?)
If I build something, push it somewhere, then build something else based on that first thing and push it to the same registry, it can use cross-repo blob mounts too. 😅
(see also the related #44757 😂 🙈)
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.
Nope, containerd only appends these on pull.
But I just made this code do it and it seems to work!
quick test:
$ docker run --name test -it alpine
# cat /dev/random >/file # ctr-c after a while
$ docker commit test 172.172.0.2:5000/big-image
$ docker push 172.172.0.2:5000/big-image
$ docker run --name test2 -it 172.172.0.2:5000/big-image
# apk add fish
...
$ docker commit test2 172.172.0.2:5000/big-image-with-small-change
$ docker push 172.172.0.2:5000/big-image-with-small-change
Using default tag: latest
f8d2deb24c5c: Pushed
c41833b44d91: Pushed
8b626a5feef1: Pushed
140adfd2fa47: Pushed
c76cc252a7ff: Pushed # <- pushes only this changed layer
The status is still Pushed
though because the containerd doesn't actually expose that information (I'm working on a containerd PR for that one though).
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.
That's really neat!! Thank you for testing/working on it 🙇
(For interested parties, I think containerd/containerd#8330 is the relevant containerd PR 👀)
Yes, registry (DockerHub at least) will reject the manifest because it references blobs not present in the repository. Removing that check for manifests wouldn't hurt though, but if there's a case in which the one-platform image has layers missing then probably there's something wrong going on. |
// Tag is empty only in case ImagePushOptions.All is true. | ||
if tag != "" { |
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.
I just recalled we still have an open ticket for this to change this at the API level as well;
- client.ImagePush(): default to ":latest" instead of "all tags" #40302 changed the client to require an explicit opt-in for "all tags"
- implement docker push -a/--all-tags docker/cli#2220 changed the CLI to use it
- Don't allow pulling all tags by default for the ImageCreate API endpoint #42053 has a proposal to change the API to have an explicit
all-tags
options
Maybe we should start working on making that API change 🤔 (we could mention the linked ticket here as a TODO)
3bed76b
to
e39998f
Compare
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.
Overall this looks pretty good to me -- in the maintainers call where we discussed this, I was concerned about having docker push
end up pulling content locally, but just doing as much cross-blob mounting as we can for objects we don't have locally seems 100% sane and honestly really, really clever 👍
I've got a few of what I think are minor nits and would love to hear more maintainer opinions about exposing platform
in the API for this endpoint before we've really ironed out the UX (because it's much harder to walk that external API back later if we decide we want something different than it is to just not introduce it for now), but I don't feel so strongly as to block it if I'm alone in my concerns. 🙇 ❤️
return err | ||
} | ||
if tag != "" { | ||
// Push by digest is not supported, so only tags are supported. |
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.
Ooooooh, I hadn't fully realized until just now that containerd integration means we could support push-by-digest now! 👀
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.
It probably requires an API change too though? I'm guessing we don't want the tag
to also be able to specify digests? 👀
Possibly, we could think about deprecating separate repo
and tag
arguments (but still support it), and have ref
instead as a preferred way to reference an image?
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.
Doh, yeah, I forgot the API separated those. 😭 Totally something to punt for now, but yeah, I guess we should probably reconsider that as we consider what we want the "better push" API to look like. 🤔
daemon/containerd/image_push.go
Outdated
if _, ok := targetRef.(reference.Tagged); !ok { | ||
return errdefs.NotImplemented(errors.New("push all tags is not implemented")) | ||
} |
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.
Longer term, we should probably implement some other means of telling this level of the code that we want to push "all" tags, right? (so we can support push-by-digest? I guess we could implement that by checking for reference.Digested
here too? future concerns IMO, but worth raising)
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.
I'm kinda wondering if push "all" tags is still useful with the multi-platform images. I can imagine it being useful when you had to do separate tags for multi-platform images (eg alpine:amd64
, alphine:arm64
etc), but with multi-platform images there might be less use cases for it?
Obviously, I might be missing some more context behind it, so happy to hear what others think 😅
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.
I still use it every now and then, but only for very small scale pushes where I've built a bunch of related things locally and want to batch pushing them all. 😅
I wouldn't be bothered one bit by it going away, but I'm sure there are some users who might have different opinions/use cases. 😂
})) | ||
defer finishProgress() | ||
|
||
var limiter *semaphore.Weighted = nil // TODO: Respect max concurrent downloads/uploads |
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.
👀 this one had some upstream blocker, right? could we maybe link to an upstream discussion somewhere about what's stopping us from implementing this?
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.
Actually we can support it for push just fine because we use the remotes.PushContent
directly. It's the pull that uses the c8d's client.Push
method that can't use the same limiter for multiple concurrent operations.
For pull, I expect that at some point we would have the need to do something more than the c8d client.Pull
provides and we will have to use the lower level functions anyway, which would allow us to support MaxConcurrentDownloads too.
So perhaps it would be fine (at least for now) to only have "MaxConcurrentUploads" implemented?
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.
Yeah, that seems reasonable to me, although IMO doesn't have to be part of this PR unless you think the implementation is really straightforward/quick. 🙇
Given this isn't blocked on upstream (as I'd mistakenly crossed the upload/download signals in my head), probably fine to leave the comment as-is too. 👍
err = errdefs.NotFound(fmt.Errorf( | ||
"missing content: %w\n"+ | ||
"Note: You're trying to push a manifest list/index which "+ | ||
"references multiple platform specific manifests, but not all of them are available locally "+ | ||
"or available to the remote repository.\n"+ | ||
"Make sure you have all the referenced content and try again.", | ||
err)) |
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.
This is really good, IMO 🙇
return *s, nil | ||
} | ||
|
||
const labelDistributionSource = "containerd.io/distribution.source." |
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.
Maybe we could leave a breadcrumb in a comment? Something like this? (I'm sure it could be better, but just a hint as to how to update this in the future)
const labelDistributionSource = "containerd.io/distribution.source." | |
const labelDistributionSource = "containerd.io/distribution.source." // TODO https://github.com/containerd/containerd/pull/8224 in containerd 1.7+ |
return *s, nil | ||
} | ||
|
||
const labelDistributionSource = "containerd.io/distribution.source." |
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.
Does containerd append these labels on push, too? (or only pull?)
If I build something, push it somewhere, then build something else based on that first thing and push it to the same registry, it can use cross-repo blob mounts too. 😅
(see also the related #44757 😂 🙈)
daemon/containerd/image_push.go
Outdated
if containerdimages.IsConfigType(mediaType) { | ||
return 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.
This one is a little over-zealous -- manifest objects are special (indexes and image manifests), but config objects are just regular blobs (like layers), so if they already exist, they're 100% fair game for cross-blob mounts 👀
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.
Ah, indeed. I was sure that configs are uploaded as manifests too, but this was probably some mistake when developing the initial fork version. Thanks!
api/swagger.yaml
Outdated
- name: "platform" | ||
in: "query" | ||
description: | | ||
Platform in the format `os[/arch[/variant]]` used to select | ||
a platform-specific manifest if the image is an index/manifest list. | ||
|
||
If the option is not set, then no specific manifest is select and the | ||
whole image index is pushed. | ||
|
||
When specified, the daemon checks if the image has any manifest that | ||
matches the requested platform and pushes it instead of the whole index. | ||
If there's no matching manifest it returns a `404` status. | ||
type: "string" |
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.
As I've noted in my other comment, IMO we should spend a bit more time thinking about the UX we want from docker push
WRT platforms before we implement this in the API ("no is temporary, yes is forever"), but I seem to recall being in a minority on that in the maintainers call where we discussed this, so I'm not going personally to block this if other maintainers don't agree. 🙇
Discussed in the maintainers meeting today, and we'd like to hold off on changes to the push API (specifically the addition of I think concretely that means pulling e39998f out for now (and the related code in the previous commit). 👀 |
e39998f
to
5e632df
Compare
Push the reference parsing from repo and tag names into the api and pass a reference object to the ImageService. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
5e632df
to
3d97a3d
Compare
Removed |
3d97a3d
to
8df1428
Compare
daemon/containerd/image_push.go
Outdated
|
||
// shouldDownload returns if the given descriptor can be cross-repo mounted | ||
// when pushing it to a remote reference ref. | ||
func canBeMounted(desc ocispec.Descriptor, targetRef reference.Named, isInsecureFunc func(string) bool, source distributionSource) bool { |
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.
func canBeMounted(desc ocispec.Descriptor, targetRef reference.Named, isInsecureFunc func(string) bool, source distributionSource) bool { | |
func canBeMounted(mediaType string, targetRef reference.Named, isInsecureFunc func(string) bool, source distributionSource) bool { |
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.
Thanks!
daemon/containerd/image_push.go
Outdated
return err | ||
} | ||
|
||
// prepareMissing will walk the target descriptor recursively and returns up |
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.
Looks like the docs doesn't match the function name
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.
Thanks! Fixed.
daemon/containerd/image_push.go
Outdated
return reference.WithDigest(source.registryRef, dgst) | ||
} | ||
|
||
// shouldDownload returns if the given descriptor can be cross-repo mounted |
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.
Same here
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.
Thanks! Fixed.
This implements `docker push` under containerd image store. When pushing manifest lists that reference a content which is not present in the local content store, it will attempt to perform the cross-repo mount the content if possible. Considering this scenario: ```bash $ docker pull docker.io/library/busybox ``` This will download manifest list and only host platform-specific manifest and blobs. Note, tagging to a different repository (but still the same registry) and pushing: ```bash $ docker tag docker.io/library/busybox docker.io/private-repo/mybusybox $ docker push docker.io/private-repo/mybusybox ``` will result in error, because the neither we nor the target repository doesn't have the manifests that the busybox manifest list references (because manifests can't be cross-repo mounted). If for some reason the manifests and configs for all other platforms would be present in the content store, but only layer blobs were missing, then the push would work, because the blobs can be cross-repo mounted (only if we push to the same registry). Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
8df1428
to
a75354c
Compare
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.
"LGTM" from a UX perspective (as discussed) I'm good with bringing this one in, but would appreciate a few eyes on the code
|
||
if status.Committed && status.Offset >= status.Total { | ||
if p.isMountable(j.Digest) { | ||
progress.Update(out, id, "Mounted") |
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.
I think the current output from docker push
on a cross-blob mount specifies where it was mounted from; is it possible to surface that data somehow here as well? (is that already in here and I missed it? 🙈)
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.
Aha, found the ref:
Line 379 in 0656059
progress.Updatef(progressOutput, pd.ID(), "Mounted from %s", err.From.Name()) |
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.
One minor comment, but probably fine for a follow-up 👍
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.
Looked a bit at the code; overall looks good, but left some nits, and a question about concurrency
if err != nil { | ||
return err | ||
} | ||
defer release(leasedCtx) |
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.
Looks like release()
can return an error; is this something we should log if it does?
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.
Technically, if it returned an error then the lease couldn't be deleted, so that might be something worthy of logging. However, looking at how containerd itself uses this, it's "common" (I haven't really seen any code that does it differently) to just call it and ignore the error.
Looking at the code, the only case where this could happen if the lease deletion fails. This can happen if the lease no longer exists (which is already the state we want) or the c8d connection is broken, which will pop out in some other place anyway.
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.
Gotcha; yeah, TBH, containerd ignoring things could sometimes be a guideline, but we should definitely check ourselves what makes sense (mistakes are made by everyone, so it's always possible containerd is on the wrong on these, in which case we could contribute there as well).
- "not found" errors; yup definitely we should ignore
- more curious; could "something else using the files / mounts / whatever" cause things to not be cleaned up? (in which case we may want to have a log to backtrace we potentially leaked things somewhere); of course that is if that wouldn't trigger "false positives" (race conditions, things being "eventually" cleaned up etc).
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.
This only deletes a lease - which is created only in this call-site and deleting it shouldn't fail unless the lease was already deleted, containerd connection is broken or containerd state is corrupted. In which case we will have loud errors in other places. Even if we fail to delete the lease and containerd is fully operational with the lease state still being present, the lease will expire after 24h.
It doesn't harm to log it though.. Except it's just bloating a simple one line defer to a deferred anonymous function call with a conditional log invocation 😅 But maybe it can save us from some trouble if the Lease manager has some major future changes from the current state, so I'm not that much against logging it.
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.
Thanks! Yes I didn't know if actual mounts and/or files were involved here. I'm mostly "fine" either way I guess
store := i.client.ContentStore() | ||
|
||
resolver, tracker := i.newResolverFromAuthConfig(authConfig) | ||
pushProgress := pushProgress{Tracker: tracker} |
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.
pushProgress
is shadowing the pushProgress
type here. Perhaps we could name it just progress
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.
Done in #45243
|
||
resolver, tracker := i.newResolverFromAuthConfig(authConfig) | ||
pushProgress := pushProgress{Tracker: tracker} | ||
jobs := newJobs() |
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.
Same for jobs
here (not sure what a good alternative name is. We could rename one of them to queue
perhaps, but 😅)
for _, c := range children { | ||
jobs.Add(c) | ||
} |
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.
heh, now would've been nice if
func (j *jobs) Add(desc ocispec.Descriptor)
Had a ...ocispec.Descriptor
as signature. That way we also wouldn't have to lock/unlock the mutex for each item;
// Add adds a descriptor to be tracked
func (j *jobs) Add(desc ...ocispec.Descriptor) {
j.mu.Lock()
defer j.mu.Unlock()
for _, d := range desc {
if _, ok := j.descs[d.Digest]; ok {
continue
}
j.descs[d.Digest] = d
}
}
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.
Done in #45243
|
||
for _, source := range sources { | ||
if canBeMounted(desc.MediaType, targetRef, i.registryService.IsInsecureRegistry, source) { | ||
mountableBlobs[desc.Digest] = source |
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.
Is it possible that this handler
runs concurrently? In that case, does mountableBlobs
need synchronisation?
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.
Yes, good catch! This is explicitly called concurrently due to Dispatch
instead of Walk
so there's a data race here.
registry := strings.TrimPrefix(k, labelDistributionSource) | ||
if registry != k { | ||
ref, err := reference.ParseNamed(registry + "/" + v) |
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.
nit: Looks like registry
collides with the "github.com/docker/docker/api/types/registry"
import. perhaps reg
is an option (perhaps also inline it, as it's only used inside the if
);
registry := strings.TrimPrefix(k, labelDistributionSource) | |
if registry != k { | |
ref, err := reference.ParseNamed(registry + "/" + v) | |
if reg := strings.TrimPrefix(k, labelDistributionSource); reg != k { | |
ref, err := reference.ParseNamed(reg + "/" + v) |
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.
Done in: #45243
return false | ||
} | ||
|
||
registry := reference.Domain(targetRef) |
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.
same here (registry
colliding with import)
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.
Done in: #45243
(those should be fine for a follow-up though 😂 because it's already in 😂) |
Implements
docker push
under containerd image store. Whenpushing manifest lists that reference a content which is not present in
the local content store, it will attempt to perform the cross-repo mount
the content if possible.
Considering this scenario:
This will download manifest list and only host platform-specific
manifest and blobs.
Note, tagging to a different repository (but still the same registry) and pushing:
will result in error, because the neither we nor the target repository
doesn't have the manifests that the busybox manifest list references
(because manifests can't be cross-repo mounted).
If for some reason the manifests and configs for all other platforms
would be present in the content store, but only layer blobs were
missing, then the push would work, because the blobs can be cross-repo
mounted (only if we push to the same registry).
The
container.io/distribution.source
label is taken only from the target blob we are pushing. It's a change from fork's behavior.In fork for each missing content we did search the entire content store for a manifest that includes it and took it's distribution source label:
moby/daemon/containerd/lazy_push.go
Lines 367 to 489 in 2670c94
This was mostly a workaround for buildkit image exporter not fetching all platform-specific content (only the host's platform). Eg. doing
FROM 'ubuntu' | docker buildx build --platform <host>,<other> -
would only fetch ubuntu rootfs for<host>
platform, but not for<other>
. Because the built image didn't have anydistribution.source
that would point to the originaldocker.io/library/ubuntu
- it was impossible to know where to obtain the blob from, based on the built image alone. This was only possible by traversing all manifests in content store, checking if they have adistribution.source
label and then traversing them to check if they happen to define this blob.Since this is no longer needed as buildkit already does
store=true
by default, I decided to change this.- What I did
Implemented push
- How I did it
Use containerd remote to push the content
Some missing content can be cross-repo mounted. In this case fake their existence by wrapping the
content.Store
to return fakecontent.Info
that includes thecontainerd.io/distribution.source
label with the source repository.That makes the push not end with NotFound error and allows it to perform cross-repo mount.
**How to verify it [click to expand]**
TODO: New examples
- A picture of a cute animal (not mandatory but encouraged)
![mobbunny](https://user-images.githubusercontent.com/5046555/217814826-56c699b1-ff48-4858-8e4c-7a11fc74f783.png)