<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sandeep Varma</title>
    <description>The latest articles on DEV Community by Sandeep Varma (@sandeepvarma).</description>
    <link>https://dev.to/sandeepvarma</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059602%2F010aa8a4-3612-4709-919a-958b313f0450.png</url>
      <title>DEV Community: Sandeep Varma</title>
      <link>https://dev.to/sandeepvarma</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sandeepvarma"/>
    <language>en</language>
    <item>
      <title>The order to build MLOps in when you have one model, not fifty</title>
      <dc:creator>Sandeep Varma</dc:creator>
      <pubDate>Sun, 09 Aug 2026 04:30:54 +0000</pubDate>
      <link>https://dev.to/sandeepvarma/the-order-to-build-mlops-in-when-you-have-one-model-not-fifty-49c5</link>
      <guid>https://dev.to/sandeepvarma/the-order-to-build-mlops-in-when-you-have-one-model-not-fifty-49c5</guid>
      <description>&lt;p&gt;A five-person team brought me in last month to help ship their first production model, a churn scorer that flags accounts likely to cancel in the next 30 days. Before I looked at a single line of their training code, I found out they had already signed a contract for a feature store.&lt;/p&gt;

&lt;p&gt;One model. Zero other models sharing features. That contract was real money going toward a problem they did not have yet.&lt;/p&gt;

&lt;p&gt;This kept happening across projects this year, so I want to write down the order I actually build an MLOps stack in for a small team shipping its first model, and why the sequence most vendor content pushes gets it backwards. Feature stores, dedicated model monitoring platforms, and multi-cluster serving setups solve problems that show up once you have several models in production competing for the same data. A team with one model has a much narrower set of problems: get the model out, know when a new version is worse than the old one, and get back to the old one fast if it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the team had when I started
&lt;/h2&gt;

&lt;p&gt;Training happened in a notebook. Whoever trained the latest version emailed a pickle file to whoever was deploying that week. Deploys were a Slack thread: "which version is live right now?" followed by someone SSHing into a box and restarting a process. There was no record of which data a given model version had actually been trained on. Rolling back meant finding the previous pickle file in someone's downloads folder.&lt;/p&gt;

&lt;p&gt;None of this is unusual. It is close to the default state for a team whose first job is proving the model is worth building, not building infrastructure around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: experiment tracking and a model registry, before anything else
&lt;/h2&gt;

&lt;p&gt;The first thing I set up was MLflow, self-hosted on a single small VM rather than a managed offering, because at this stage the team did not need multi-user scale, just a shared source of truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;

&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracking_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://mlflow.internal:5000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_experiment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;churn-scorer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lightgbm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;training_data_snapshot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.891&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sklearn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registered_model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;churn-scorer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every run now logs the data snapshot it trained on, the hyperparameters, and the resulting metrics. The model registry gives each version a number and a stage, so "which version is live" became a query instead of a Slack search. This step took two days and cost nothing beyond the VM. It should come before a feature store, before a monitoring dashboard, before Kubernetes, because none of the later steps mean anything if you cannot answer "what is actually running and what was it trained on."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: a containerized inference service
&lt;/h2&gt;

&lt;p&gt;Next was wrapping the model in a small FastAPI service with one job, and health checks that check the thing that actually matters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.11-slim&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=5s \&lt;/span&gt;
  CMD python -c "import requests; requests.get('http://localhost:8000/healthz', timeout=3).raise_for_status()"

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "app:api", "--host", "0.0.0.0", "--port", "8000"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The /healthz endpoint loads the registered model from MLflow and runs one prediction against a fixed test row on startup, not just a check that the process is alive. Early on, this service had passed its liveness probe while quietly serving from a stale in-memory model because a deploy had not actually swapped the artifact. A health check that confirms the model loads and predicts catches that class of bug before it reaches users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: CI/CD that builds and deploys on merge
&lt;/h2&gt;

&lt;p&gt;With a containerized service, CI/CD is mostly plumbing, and there was no reason to reach for anything beyond GitHub Actions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push image&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker build -t registry.internal/churn-scorer:${{ github.sha }} .&lt;/span&gt;
          &lt;span class="s"&gt;docker push registry.internal/churn-scorer:${{ github.sha }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update rollout image&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;kubectl argo rollouts set image churn-scorer \&lt;/span&gt;
            &lt;span class="s"&gt;churn-scorer=registry.internal/churn-scorer:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This replaced the SSH-and-restart routine entirely. A merge to main now produces a tagged image and updates the rollout, and there is a record of exactly which commit is running.&lt;/p&gt;

&lt;p&gt;We added one more gate before the deploy step: a smoke test job that spins up the built image, hits /healthz and /predict with a handful of fixed inputs, and checks the outputs are within an expected range. It runs in about 40 seconds and catches the boring failures, a missing dependency in the image, a schema change in the input payload, before any of it reaches a real rollout. It is not a substitute for the canary. It is there so the canary is not spending its 10 percent traffic window catching mistakes a unit test should have caught for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step four: canary rollout with automatic rollback
&lt;/h2&gt;

&lt;p&gt;This is the step that actually mattered for confidence, because a model can be technically healthy, serving requests fine, and still be quietly worse than the version it replaced. A crash-only health check will not catch that. What catches it is comparing behavior against the previous version before fully committing to the new one.&lt;/p&gt;

&lt;p&gt;I used Argo Rollouts for the progressive delivery piece. Its canary strategy lets you define a sequence of steps, each shifting a percentage of traffic to the new version and pausing before continuing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rollout&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;churn-scorer&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30m&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;30m&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argo Rollouts can also query metrics during those pause windows and drive automated promotion or rollback based on what it sees, rather than requiring a person to eyeball a dashboard and decide. We fed it two signals: the service's own error rate, and a drift score computed by Evidently, an open source library that runs statistical tests, Kolmogorov-Smirnov for numeric features and a chi-squared test for categorical ones, comparing the distribution of incoming requests against the training data snapshot logged in MLflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;evidently.report&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Report&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;evidently.metric_preset&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DataDriftPreset&lt;/span&gt;

&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;DataDriftPreset&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;training_snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recent_requests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;drift_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_dict&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dataset_drift&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the new version's error rate climbed past a threshold, or the drift score flagged the incoming traffic as meaningfully different from what the model had trained on, the rollout stopped promoting and rolled back to the previous stable version on its own. No page, no 2am dashboard check. The one time this actually fired in the first month, it caught a training data join that had silently dropped a chunk of rows for one customer segment, producing a model that was worse specifically for that segment. A crash-only health check would have shown a perfectly healthy service the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the rollback thresholds
&lt;/h2&gt;

&lt;p&gt;The first version of the error rate threshold was too tight. We set it at half a percentage point above the current stable version's baseline, and the canary rolled itself back twice in the first week over noise, a couple of slow requests during a deploy window that coincided with a batch job hitting the same database. Widening it to two percentage points above baseline, measured over a rolling five-minute window rather than an instant snapshot, stopped the false rollbacks without losing the ability to catch a genuinely broken version.&lt;/p&gt;

&lt;p&gt;The drift threshold took longer to get right. Evidently's default dataset-level drift flag treats a dataset as drifted once a set share of individual features show statistically significant drift, and the default share is tuned for general use rather than this specific model. We narrowed it to the six features the model actually weights most heavily, based on feature importance from the training run logged in MLflow, and ignored drift on the rest. Watching all thirty input columns equally meant a harmless change in a rarely used field could trigger a rollback for a model that did not depend on that field in any meaningful way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the feature store actually fits
&lt;/h2&gt;

&lt;p&gt;None of this required a feature store, because there was one model and one training pipeline. A feature store earns its cost when a second or third model needs the same underlying features, and the team is maintaining that transformation logic in two or three places at once, with training and serving quietly drifting apart because someone updated one copy of the logic and not the other. That is a real, expensive problem. It just was not this team's problem in month one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Manual deploys before this work were a three-day process end to end: train, hand off the pickle file, coordinate a deploy window, SSH in, restart, watch logs for a while and hope. After the four steps above, shipping a new model version to production, canary included, took under 30 minutes of wall clock time, most of which was the rollout's own pause windows rather than anything a person had to do. The team went from their first production model to automated canary rollback in about a week and a half of work.&lt;/p&gt;

&lt;p&gt;The bigger change was not the speed. It was that a deploy stopped being an event that needed a person watching it. Before, every deploy meant someone staying near a laptop for an hour after pushing, refreshing a log stream. After, the on-call engineer found out about the one rollback that fired from a Slack notification the rollout posted on its own, after it had already happened and already recovered. Nobody had to be watching for it to work.&lt;/p&gt;

&lt;p&gt;That also changed what the team's next hire needed to know. The original plan had been to bring on a dedicated ML platform engineer once the model was live, largely to own exactly this kind of deploy babysitting. With the rollback automated, that hire got pushed back a couple of quarters, and the money went toward a second model instead, one that predicts which accounts are worth a retention call rather than just flagging churn risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order, restated
&lt;/h2&gt;

&lt;p&gt;Experiment tracking and a model registry first, because you need to know what is running before you can safely change it. A containerized service with a health check that actually exercises the model, second. CI/CD that removes the human from the deploy path, third. A canary rollout wired to real signals, so a bad version gets caught and reversed automatically, fourth. A feature store, a dedicated monitoring platform, and a multi-cluster serving layer come later, when there are enough models and enough shared data plumbing to justify the cost. Building them first solves a problem you do not have yet, and leaves the problem you do have, shipping this one model safely, unsolved.&lt;/p&gt;

</description>
      <category>mlops</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
