❯ gitops · Running

Adopter 7 releases Helm dans ArgoCD sans redémarrer un seul pod Adopting 7 Helm Releases into ArgoCD Without Restarting a Single Pod

Sept composants étaient encore installés à la main avec Helm, hors GitOps — dont la couche de stockage et le controller de secrets. Voici la méthode d'adoption qui les a tous fait passer sous ArgoCD avec un diff de pré-adoption vide et zéro redémarrage de pod, et les pièges rencontrés en chemin. Seven components were still installed by hand with Helm, outside GitOps — including the storage layer and the secrets controller. Here is the adoption method that moved all of them under ArgoCD with an empty pre-adoption diff and zero pod restarts, and the traps found along the way.

· 11 min de lecture 11 min read ·
k3s-clusterargocdgitopshelmk8smigration
🇫🇷 Lire cet article en français

Background: the app-of-apps pattern and how GitOps works on this cluster are covered in GitOps with ArgoCD — App-of-Apps Pattern.

The symptom: documentation that opens with a warning

For months, the reference file for my infrastructure started with this sentence:

⚠️ Read the “Deployment method” section first: not everything is GitOps. Editing the repo for a component managed by plain Helm does nothing, and conversely a manual kubectl/helm on an ArgoCD component gets overwritten by selfHeal.

When a cluster’s documentation opens with a warning, that is not pedagogy — it is the symptom. On 5 September 2026, helm list -A on the control-plane node still returned nine releases:

NAME                 NAMESPACE        REVISION  CHART                        STATUS
traefik              kube-system      3         traefik-40.1.3+up40.1.0      deployed
traefik-crd          kube-system      5         traefik-crd-40.1.3+up40.1.0  deployed
grafana              monitoring       6         grafana-10.5.15              deployed
vmagent              monitoring       18        vmagent-0.41.2               deployed
vmsingle             monitoring       9         victoria-metrics-single-…    deployed
node-exporter        monitoring       4         prometheus-node-exporter-…   deployed
kube-state-metrics   monitoring       2         kube-state-metrics-7.5.2     deployed
sealed-secrets       kube-system      11        sealed-secrets-2.18.5        deployed
longhorn             longhorn-system  7         longhorn-1.12.0              deployed

Two of those releases are legitimate: traefik and traefik-crd are managed by k3s itself through its bundled manifests mechanism. The other seven were pure debt — components installed by hand “for now”, several of them dating back to the original build of the cluster.

The cost of that debt is not theoretical. It is paid in seconds of hesitation, several times a week: to change vmsingle’s retention, do I edit the repo or run a helm upgrade? Getting the answer wrong one way does nothing at all; the other way, it gets overwritten by selfHeal three minutes later. Two sources of truth in one cluster is not twice the flexibility — it is one more question to ask before every move.

The context: ArgoCD does not “adopt” anything, it applies

One ambiguity has to be cleared up first, because it drives the whole method.

ArgoCD can consume a Helm chart, but it does not behave like the Helm client. It does not install a release: it runs the equivalent of helm template — the chart rendered into plain manifests — then applies those manifests to the cluster like any other YAML. It does not read the sh.helm.release.v1.* secrets that hold a release’s state, it does not create them, it does not update them.

In practice, “adopting” an existing Helm release means: creating an ArgoCD Application whose render lands exactly on the objects already present in the cluster. There is no ownership transfer, no migration command. There are two processes targeting the same objects, and you want the second one to produce precisely what the first one produced.

If the render is identical, applying it is a no-op: Kubernetes receives objects it already has, nothing restarts. If the render differs by even one field — a chart default that moved between two minor versions, a missing annotation — then the sync triggers a rollout. On a monitoring Deployment, that is an annoyance. On the storage layer serving volumes to the whole cluster, it is an incident.

Hence the pattern chosen, known as multiple-sources: an Application pointing at two sources at once — the official chart pinned at its publisher, and the GitOps repository providing the values file.

spec:
  sources:
    - repoURL: https://prometheus-community.github.io/helm-charts
      chart: prometheus-node-exporter
      targetRevision: 4.53.1
      helm:
        releaseName: node-exporter
        valueFiles:
          - $values/monitoring/vm/node-exporter-values.yaml
    - repoURL: <internal-gitops-repo-url>
      targetRevision: actual-state
      ref: values

The second source deploys nothing: ref: values turns it into a plain file provider, referenced as $values by the first one. The upstream chart stays untouched — no vendoring, no fork — while the configuration lives versioned in the repository.

The method: five steps, three of them reversible

① Same version, no exceptions

The temptation is real: since you are touching the component anyway, why not move to the latest chart version? It has to be resisted completely. Adopting and upgrading are two different kinds of change, and mixing them makes diagnosis impossible — if a pod restarts, was it the adoption or the bump?

So the target version is the one helm list -A already shows, down to the patch: 4.53.1 for node-exporter, 7.5.2 for kube-state-metrics, 10.5.15 for grafana, 2.18.5 for sealed-secrets, 1.12.0 for the Longhorn chart. The bump comes later, as an ordinary commit — which is precisely the point of the exercise.

② The live values, not the README ones

The configuration to commit is the one actually applied to the running release, not the one you believe you applied at install time:

helm get values kube-state-metrics -n monitoring

On this cluster, three of the seven components already had their values in the repository, inherited from the initial install. The check was worth doing anyway: for grafana, vmagent and vmsingle, the repo file and the live release matched exactly, across eighteen vmagent revisions. For the others, the extraction produced short and honest files:

# kube-state-metrics values — consumed by the ArgoCD Application
# Source: helm get values kube-state-metrics -n monitoring (release rev 2)
resources:
  limits:
    cpu: 200m
    memory: 96Mi
  requests:
    cpu: 20m
    memory: 32Mi

③ The pre-adoption diff must be empty

This is the check that carries the entire safety of the operation, and it fits on one line:

helm template <release> <repo>/<chart> --version <X> \
  -f values.yaml -n <namespace> | kubectl diff -f -

You render the chart exactly as ArgoCD will render it, and compare against the live cluster. The expected output is empty. Not “short”, not “no significant change”: empty.

Any line appearing here is a change disguised as a migration. It has to be understood and made to disappear — by adjusting the values, by fixing the version — before going further. Across the seven components, the diff was empty on the first attempt every time, including on the bulky ones: ten resources for sealed-secrets, forty-two for the Longhorn chart (CRDs included), five for kube-state-metrics of which a ClusterRole and its binding.

Two adjustments were needed to get that result. The render must use --no-hooks on charts that ship lifecycle jobs, otherwise kubectl diff offers to create objects that have no reason to exist in steady state. And the Application must carry ServerSideApply=true: without it, ArgoCD compares partial objects on large resources such as the Longhorn CRDs and sees differences that are not real.

④ Manual sync, no automated

The Application is created without an automated block. The first sync is triggered by hand, and it is watched:

  # Adoption: manual sync first; automated/selfHeal enabled after the first
  # sync verified healthy (dedicated commit). prune deliberately absent.
  syncPolicy:
    syncOptions:
      - ServerSideApply=true

The pass criterion is not “ArgoCD shows Synced”. It is zero pod restarts: six node-exporter pods untouched, no restart; the sealed-secrets controller still alive and kubeseal --fetch-cert working; and for Longhorn, 14 volumes out of 14 still healthy throughout the operation, 6 managers and their instance-managers unchanged.

⑤ selfHeal, in a separate commit

automated: { selfHeal: true } only comes afterwards, in a dedicated commit whose message documents the validation:

  # Adoption validated: pre-adoption diff empty, first sync Succeeded
  # with no pod restart. prune deliberately absent (app adopted from Helm).
  syncPolicy:
    automated:
      selfHeal: true
    syncOptions:
      - ServerSideApply=true

Two absences are deliberate and permanent.

prune stays disabled on all seven applications. An app adopted from Helm must never be allowed to delete objects: if a values mistake makes a resource drop out of the render, you want a visible drift, not a silent deletion.

The finalizer is absent on sealed-secrets and on the Longhorn chart. By default you add resources-finalizer.argocd.argoproj.io so that deleting an Application cleans up its resources. On these two, that is exactly what you do not want: an accidental deletion of the Application must never cascade onto the encryption controller or onto the storage layer.

The traps: what was not in the plan

The chart repository that moved

The sealed-secrets adoption worked, then broke a few minutes later. The chart publisher had migrated its repository from bitnami-labs.github.io to bitnami.github.io in June 2026 — and GitHub Pages does not redirect: the old URL returns a flat 404.

The Helm client had never flagged it, because it was serving the chart from its local cache. ArgoCD, on the other hand, resolves the repository on every sync. A Helm release installed from a dead URL can live for months without anyone noticing; the GitOps adoption is what makes the breakage visible. That is an argument for the migration rather than against it.

Longhorn: the job that assumes a Helm cycle

The Longhorn chart ships a pre-upgrade-checker, a job that checks system state before a version bump. It assumes a classic Helm lifecycle and fails under ArgoCD — the publisher in fact documents disabling it as a prerequisite for GitOps installs:

preUpgradeChecker:
  jobEnabled: false

The chart’s other hooks cope with the ArgoCD model: the post-upgrade job is idempotent and runs as PostSync, the pre-delete hook is simply ignored. And one constraint survives the adoption intact: Longhorn version bumps must be strictly sequential, never skipping a minor version. Going through ArgoCD changes nothing about that rule — it has just become easier to break, since bumping targetRevision is a one-character edit.

The PVC that belongs to nobody

vmsingle is a StatefulSet, and its volume is created by a volumeClaimTemplate. Those PVCs are generated by the StatefulSet controller, not by the chart: they stay outside the scope ArgoCD manages. Not a problem in itself, but worth knowing before wondering why the metrics database’s storage appears nowhere in the Application tree.

A filename forbidden by its own safeguard

A trivial detail, but one that costs five minutes: the sealed-secrets Application manifest could not be named sealed-secrets.yaml. The repository carries a .gitignore rule on *secrets.yaml — an anti-leak safeguard installed much earlier, which did exactly its job on a perfectly harmless file. The file is called sealed-secrets-app.yaml.

The check that does not show up in the diff

The sealed-secrets controller’s private key — the one that decrypts every secret in the repository — is not a chart resource. It is generated by the controller on first start and lives in a cluster Secret. No diff, no Helm render, no command output would have flagged it.

Yet it is the one point in the operation where a mistake would have been irreversible: losing that key means losing the ability to decrypt every versioned secret. So the break-glass backup was verified in the password manager before touching the Application. An adoption checklist is not limited to what the tools know how to compare.

The result, and the point of no return

After the seven adoptions:

$ helm list -A
NAME         NAMESPACE    REVISION  CHART                        STATUS
traefik      kube-system  3         traefik-40.1.3+up40.1.0      deployed
traefik-crd  kube-system  5         traefik-crd-40.1.3+up40.1.0  deployed

Only the two k3s-managed releases remain. The cluster went from 24 to 31 ArgoCD Applications, all Synced and Healthy, and the warning at the top of the documentation has been replaced by an affirmative sentence.

It is worth being clear about what was lost along the way. The Helm release secrets are gone: helm list no longer knows about these components, helm upgrade will not work on them, and neither will helm rollback. The helm binary now serves only helm template for debugging. That is the point of no return in step ⑤ — accepted, but real: the only way to update these components is now to change targetRevision in the repository and push.

One last, less comfortable observation: the grafana/grafana chart is marked deprecated upstream. The 10.5.15 pin holds, but the GitOps adoption did not make that particular debt go away — it merely made it readable in a versioned file rather than in the memory of whoever ran the helm install. Which is about all you can ask of a migration: not to solve the underlying problems, but to stop hiding them.

Runbook — adopting a Helm release into ArgoCD

  1. Record what exists: helm list -A for the exact chart version, helm get values <rel> -n <ns> for the configuration actually applied.
  2. Check what the diff will not see: keys, data generated by the component itself, break-glass backups. Do it before, not after.
  3. Commit the values to the GitOps repository, with a header comment citing their provenance and date.
  4. Write the Application in multiple-sources form, targetRevision at the same version, ServerSideApply=true, no automated, no prune. No finalizer on components whose deletion would be catastrophic.
  5. Demand an empty diff: helm template … --no-hooks | kubectl diff -f -. Any output line is a stop.
  6. Sync by hand and check for the absence of pod restarts, not just the Synced status.
  7. Enable selfHeal in a separate commit, once the sync is validated, documenting the verification in the commit message.
  8. One component at a time. Seven adoptions in one session is fine; seven adoptions in one commit is not — when something moves, you need to know what.
k3s-clusterargocdgitopshelmk8smigration