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.
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.
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.
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.
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 layerThe 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.
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.
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-tagsoptions
Maybe we should start working on making that API change 🤔 (we could mention the linked ticket here as a TODO)
3bed76b to
e39998f
Compare
tianon
left a comment
There was a problem hiding this comment.
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.
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.
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.
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. 🤔
| if _, ok := targetRef.(reference.Tagged); !ok { | ||
| return errdefs.NotImplemented(errors.New("push all tags is not implemented")) | ||
| } |
There was a problem hiding this comment.
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.
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.
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.
👀 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.
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.
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)) |
| return *s, nil | ||
| } | ||
|
|
||
| const labelDistributionSource = "containerd.io/distribution.source." |
There was a problem hiding this comment.
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.
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 😂 🙈)
| if containerdimages.IsConfigType(mediaType) { | ||
| return false | ||
| } |
There was a problem hiding this comment.
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.
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!
| - 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.
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 <[email protected]>
5e632df to
3d97a3d
Compare
|
Removed |
3d97a3d to
8df1428
Compare
|
|
||
| // 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.
| 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 { |
| return err | ||
| } | ||
|
|
||
| // prepareMissing will walk the target descriptor recursively and returns up |
There was a problem hiding this comment.
Looks like the docs doesn't match the function name
| return reference.WithDigest(source.registryRef, dgst) | ||
| } | ||
|
|
||
| // shouldDownload returns if the given descriptor can be cross-repo mounted |
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 <[email protected]>
8df1428 to
a75354c
Compare
thaJeztah
left a comment
There was a problem hiding this comment.
"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.
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? 🙈)
tianon
left a comment
There was a problem hiding this comment.
One minor comment, but probably fine for a follow-up 👍
thaJeztah
left a comment
There was a problem hiding this comment.
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.
Looks like release() can return an error; is this something we should log if it does?
There was a problem hiding this comment.
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.
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.
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.
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.
pushProgress is shadowing the pushProgress type here. Perhaps we could name it just progress
|
|
||
| resolver, tracker := i.newResolverFromAuthConfig(authConfig) | ||
| pushProgress := pushProgress{Tracker: tracker} | ||
| jobs := newJobs() |
There was a problem hiding this comment.
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.
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
}
}|
|
||
| for _, source := range sources { | ||
| if canBeMounted(desc.MediaType, targetRef, i.registryService.IsInsecureRegistry, source) { | ||
| mountableBlobs[desc.Digest] = source |
There was a problem hiding this comment.
Is it possible that this handler runs concurrently? In that case, does mountableBlobs need synchronisation?
There was a problem hiding this comment.
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.
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) |
| return false | ||
| } | ||
|
|
||
| registry := reference.Domain(targetRef) |
There was a problem hiding this comment.
same here (registry colliding with import)
|
(those should be fine for a follow-up though 😂 because it's already in 😂) |
Implements
docker pushunder 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.sourcelabel 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.sourcethat 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.sourcelabel and then traversing them to check if they happen to define this blob.Since this is no longer needed as buildkit already does
store=trueby 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.Storeto return fakecontent.Infothat includes thecontainerd.io/distribution.sourcelabel 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)
