Continuously mirror the images your Kubernetes workloads already run into the registry you control.
This is an absolutely experimental WIP project. Do not use it in production environments.
- Overview
- Why k8s-copycat?
- Key Capabilities
- Getting Started
- Configuration
- Troubleshooting mirrors
- Inspiration
k8s-copycat monitors Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, and Pods to mirror their container images into AWS ECR or any other Docker-compatible registry. It keeps your recovery registry in sync with what is actively running—no image swaps, admission webhooks, or pod restarts required.
The controller runs inside your cluster and reacts to changes in workload specs and status. Once configured, it continuously copies referenced images into a registry that you own so they are available when upstream registries throttle, disappear, or delete content without notice.
Over the last years we repeatedly encountered scenarios where official registries:
- Serve different image versions
- Become overloaded
- Enforce strict pull limits
- Are taken down completely
- Delete images without notice
To guarantee access to the exact images already running in our cluster—without swapping them out—we built k8s-copycat. Unlike pull-through proxies such as Harbor, copycat maintains a dedicated mirror registry that only receives artifacts you choose to replicate.
We also needed a solution that does not rely on admission webhooks because of network restrictions in EKS/Cilium environments (see cilium/cilium#21959 for background). Copycat accomplishes this by watching workload resources directly through the Kubernetes API.
- Continuously mirrors workloads into ECR or any Docker-compatible registry
- Supports namespace allow/deny lists, workload skip lists, and registry exclusions
- Handles manifest lists, attestations, and multi-architecture images, mirroring the platforms your workloads actually use and any extras you list in
mirrorPlatforms - Provides templated repository prefixes to segregate mirrored content
- Exposes Prometheus metrics for observability
- Operates in dry-run modes to validate configuration before pushing
Clone the repository, build the controller, and apply the Kubernetes manifests under manifests/ tailored to your cluster. A sample configuration is available in example/ and the docs/ directory contains deep dives into mirroring logic, deployment options, and operational guidance.
At minimum you will need to:
- Choose a target registry (
TARGET_KIND=ecrorTARGET_KIND=docker). - Provide credentials that allow pulling from source registries and pushing to the destination.
- Deploy the controller with access to watch the workloads you want mirrored.
Once running, copycat begins replicating the images referenced by your workloads, respecting namespace and resource filters.
Spin up a throwaway kind cluster to see the controller in action without touching a shared environment:
-
Create a cluster and deploy the manifests:
kind create cluster --name copycat kubectl apply -f manifests/k8s.yaml
-
Wait for the deployment to become ready and inspect the logs:
kubectl wait --for=condition=available deployment/k8s-copycat -n k8s-copycat --timeout=120s kubectl logs deployment/k8s-copycat -n k8s-copycat -
Tear down the local cluster once finished:
kind delete cluster --name copycat
The sample manifest runs in dry-run mode so the controller never attempts to push to a registry. Update the configuration before deploying copycat to a persistent environment.
Use the published manifests rather than the main branch to avoid drift between your deployment and a tagged release. Each release builds and pushes a multi-arch controller image to GHCR (ghcr.io/matzegebbe/k8s-copycat:<tag>) and includes the matching Kubernetes resources under manifests/k8s.yaml.
- Choose a released version (for example
v0.6.3) and pin the manifest to that tag:VERSION=v0.6.3 kubectl apply -f https://raw.githubusercontent.com/matzegebbe/k8s-copycat/${VERSION}/manifests/k8s.yaml kubectl wait --for=condition=available deployment/k8s-copycat -n k8s-copycat --timeout=180s
- Update the image tag or environment variables in the manifest to align with your registry credentials and mirroring preferences before deploying to production.
- When testing local changes, build an image, push it to a registry you control, and override the
image:field in the Deployment.
Copycat is configured through a combination of environment variables and a YAML configuration file. Any values defined in the environment override what is present in the config file, allowing safe secret management in Kubernetes.
Target selection
TARGET_KIND:ecr(default) ordocker.AWS_REGION,ECR_ACCOUNT_ID,ECR_REPO_PREFIX,ECR_CREATE_REPO: configure AWS ECR mirroring.TARGET_REGISTRY,TARGET_REPO_PREFIX,TARGET_USERNAME,TARGET_PASSWORD,TARGET_INSECURE: configure other Docker registries.
Workload selection
INCLUDE_NAMESPACES:*or a comma-separated list (for exampledefault,prod).SKIP_NAMESPACES: namespaces that should never be mirrored.SKIP_DEPLOYMENTS,SKIP_STATEFULSETS,SKIP_DAEMONSETS,SKIP_JOBS,SKIP_CRONJOBS,SKIP_PODS: workload names to ignore.WATCH_RESOURCES: comma-separated resource types to watch (defaultdeployments,statefulsets,daemonsets,jobs,cronjobs,pods).
Registry routing
EXCLUDE_REGISTRIES: registry prefixes that should never be mirrored. Include the target registry (for example123456789.dkr.ecr.eu-central-1.amazonaws.com) to avoid loops.TARGET_REPO_PREFIXor configrepoPrefix: prepend names before pushing to the destination.- Optional
pathMapentries in the config file rewrite repository paths before pushing.
Mirroring behavior
DIGEST_PULL: resolve tags to digests before pulling (falseby default).CHECK_NODE_PLATFORM: consult node architecture/OS before mirroring multi-arch images (falseby default, requiresgetonnodes).ALLOW_DIFFERENT_DIGEST_REPUSH: permit overwriting tags with different digests (trueby default,latestis always protected).DRY_RUN: perform all operations except pushing to the target registry (falseby default).DRY_PULL: log which images would be fetched without contacting the source registry (falseby default).
Operations and observability
REGISTRY_REQUEST_TIMEOUT: timeout (in seconds) for individual pull/push operations (120by default).FAILURE_COOLDOWN_MINUTES: wait time before retrying a failed mirror (1440by default,0disables the cooldown).METRICS_ADDR: bind address for Prometheus metrics (:8080by default).MAX_CONCURRENT_RECONCILES: overrides the worker count per controller (defaults to2).
Whether digest resolution is enabled fundamentally changes how copycat interacts with multi-architecture images:
digestPull: true/DIGEST_PULL=true– copycat resolves the tag to its immutable digest. When reconciling Pods it prefers the digest reported by the kubelet in the containerImageID, guaranteeing that the mirrored artifact matches what actually runs on the node, even across architectures. When copycat only has a PodSpec (for example from a Deployment) it falls back to resolving the digest from the registry.digestPull: false/ default – copycat keeps the original tag reference. When it encounters a manifest list (for examplealpine:3.19), it downloads the entire multi-architecture index and uploads every referenced platform image to the target registry.
Assume a Pod references docker.io/library/alpine:3.19:
- With
digestPull=false, copycat mirrors the manifest list and pushes layers for all available architectures (currently386,amd64,arm64,ppc64le,riscv64, ands390x) so the target registry can serve any of them. - With
digestPull=true, copycat mirrors the exact digest reported in the Pod’s status (for exampledocker.io/library/alpine@sha256:...). If the Pod runs on anarm64node, copycat mirrors thearm64manifest even when the controller executes onamd64.
This distinction matters when sizing storage in the mirror registry or when you rely on the $arch prefix placeholder described below.
Copycat listens to the Kubernetes resources you select. By default it watches Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, and stand-alone Pods. You can narrow the scope through the WATCH_RESOURCES environment variable or the watchResources field in the configuration file. Unsupported entries are rejected at startup so you can catch typos early.
When a repoPrefix is configured (via config file or environment variables), the value can include placeholders that are replaced at runtime. The following tokens are available:
$namespace— Namespace of the workload or Pod referencing the image.$podname— Name of the owning resource (or Pod when available).$container_name— Container name that uses the image.$arch— Architecture of the mirrored image. WhendigestPullis enabled this is the architecture of the selected manifest (for exampleamd64). When mirroring a manifest list, the placeholder expands to a hyphen-separated list of all mirrored architectures (for example386-amd64-arm64-ppc64le-riscv64-s390x). If copycat cannot determine the architecture it leaves the segment blank.
For example, setting repoPrefix: "$namespace/$podname" keeps target repositories unique across namespaces even when multiple workloads reference the same source image. To separate images by architecture you can combine placeholders:
repoPrefix: "$arch/$namespace"With the alpine:3.19 example above this produces repositories such as amd64/default/alpine when digestPull=true, or 386-amd64-arm64-ppc64le-riscv64-s390x/default/alpine when digestPull=false and the manifest list exposes all those variants.
You can provide an ECR lifecycle policy in the configuration file. When a repository is created by k8s-copycat, the policy is applied automatically.
ecr:
lifecyclePolicy: |
{
"rules": [
{
"rulePriority": 1,
"description": "Retain only the five most recent images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 5
},
"action": { "type": "expire" }
}
]
}targetKind: ecr # target aws ecr or default docker registry
digestPull: true # resolve source tags to their immutable digest before pulling
checkNodePlatform: true # optional: ask the API for node architecture/OS before mirroring Pod images
mirrorPlatforms: # optional: always mirror these additional platforms when digestPull is enabled
- amd64 # shorthand for linux/amd64 also works
- linux/arm64
allowDifferentDigestRepush: false # optional: fail when the target tag already exists with a different digest (except for "latest")
watchResources:
- deployments # default: listen to all supported resource types
- statefulsets
- daemonsets
- jobs
- cronjobs
- pods
skipNamespaces: [] # default: allow all namespaces
skipNames:
deployments: [] # default: watch every Deployment
statefulSets: [] # default: watch every StatefulSet
daemonSets: [] # default: watch every DaemonSet
jobs: [] # default: watch every Job
cronJobs: [] # default: watch every CronJob
pods: [] # default: watch every stand-alone Pod
maxConcurrentReconciles: 2 # default: two workers per controller
pathMap:
- from: "group/project"
to: "prod/project"
- from: "^legacy/(.*)"
to: "modern/$1"
regex: true
requestTimeout: 120 # seconds; set to 0 to disable per-request deadlines
failureCooldownMinutes: 60 # retry failed pushes after one hour; set to 0 to disable the cooldown
forceReconcileMinutes: 30 # rescan all watched resources every 30 minutes; set to 0 to disable the periodic resync
registryCredentials:
- registry: registry-1.docker.io
registryAliases:
- index.docker.io
- docker.io
- "*.docker.io"
usernameEnv: DOCKERHUB_USERNAME
passwordEnv: DOCKERHUB_PASSWORD
- registry: ghcr.io
registryAliases:
- "*.ghcr.io"
- docker.pkg.github.com
tokenEnv: GHCR_TOKENRules are evaluated in order, with the first matching entry applied. Leaving pathMap empty keeps repository paths unchanged. When maxConcurrentReconciles is omitted, copycat defaults to two workers per controller. You can override the value at runtime via the MAX_CONCURRENT_RECONCILES environment variable.
The registryCredentials section (or matching environment variables) lets copycat authenticate against private registries while mirroring into your target. Credentials can be supplied directly in the configuration file via username, password, or token, but referencing secret values through environment variables (*Env fields) is recommended. When a token is provided it is sent as an authentication bearer token; otherwise basic authentication is used.
Copycat exposes Prometheus metrics on /metrics. The listener binds to the address configured via METRICS_ADDR (default :8080).
Add a scrape job similar to the following to pull metrics into your Prometheus stack:
scrape_configs:
- job_name: "k8s-copycat"
static_configs:
- targets: ["k8s-copycat.default.svc:8080"]Useful queries include:
sum by (image) (rate(k8s_copycat_registry_pull_success_total[5m]))
sum(rate(k8s_copycat_registry_push_success_total[5m]))
sum(rate(k8s_copycat_registry_push_error_total[5m]))
When you mirror or verify batches of image references—tags, digests, manifest lists, or attestations—transient errors should not block progress. If a particular reference fails to pull or push (missing credentials, non-runnable attestation, registry hiccup), skip it and continue. Copycat follows the same pattern internally: failures are recorded and retried later without preventing other objects from being mirrored. Emulate that workflow during manual checks by circling back once credentials or permissions have been corrected.
See docs/CONTRIBUTING.md for coding standards, linting requirements, and the Conventional Commits policy that keeps release automation happy.
