To deploy (in Italian "rilascio" or "messa in servizio") means to take the model from the place where you trained it to the place where someone else, or some other program, can really use it. That place is called production: the real environment, with real users, where a mistake has consequences. So far you have versioned code (Git, GitHub), a reproducible environment (uv), tests (pytest, model tests), external configuration (environment variables), a packaged image (Dockerfile) and a model with a precise version (model registry). One question remains: what do I run it on, and how do I keep it alive? There is no single answer; there are four typical shapes and a rule for choosing.
The Python listings and the systemd-analyze commands in this article were run in a temporary folder outside the repository, with MLflow 3.18.0, FastAPI 0.162.5, scikit-learn 1.9.1 and Python 3.12, on the same registered models as the previous article. The systemd unit of the test service passed systemd-analyze verify (in the variant with an executable that exists on this machine). The real service, systemctl and journalctl were not run: whatever depends on a server is declared in the text as a schema to adapt.
The words of deployment
A service is a program that stays on waiting for requests, like an open shop waiting for customers; the opposite is the script, which starts, works and ends, like a craftsman who delivers the work and leaves. An endpoint is a precise address of the service that you can ask something of, like counter number 3 in an office: /predict is the "give me a prediction" counter. Latency is the time between request and response, in milliseconds. Inference is using an already trained model on new data: training is the studying, inference is the exam. A batch is processing in lots, a lot of data at once at a fixed time, like doing the laundry once a week instead of washing one shirt every evening.
On the infrastructure side, a VPS (virtual private server) is a virtual computer that you rent from a provider and that stays on day and night, and of which you are the administrator. systemd is the program that on Linux starts the others at power-on and restarts them if they stop, the server's "foreman". The cold start is the time a service takes to be ready after having been switched off, because it has to load the model and the libraries. The last three words are the ones that change the choice between the four shapes the most, as we will see.
The analogy: four ways of selling bread
Imagine having to get bread to customers. You can bake everything at night and leave the bags ready at six in the morning (batch). You can keep the oven on and bake when the customer walks in (API on a server). You can hand the recipe to a chain that already has shops, lorries and cashiers, and take care only of the bread (managed service). Or you can rent a shop and do everything yourself, from the oven to the shutter (VPS). None is "best": it depends on how many customers you have, how quickly they want the bread and how much time you want to spend running the shop.
Shape 1: batch, the script that runs at fixed times
The simplest shape, and often the right one. The model is used on a set of data that is already available (all of yesterday's customers), not on a request that is waiting. You need no server, no exposed network, and a failure stops no user: the batch is relaunched. The script reads a CSV, loads the registry's champion by alias and writes a second CSV with one more column:
# score_batch.py
import sys
import mlflow
import pandas as pd
from churn.data import FEATURES
mlflow.set_tracking_uri("sqlite:///mlflow.db")
model = mlflow.sklearn.load_model("models:/churn-classifier@champion")
src, dst = sys.argv[1], sys.argv[2]
df = pd.read_csv(src)
df["churn_probability"] = model.predict_proba(df[FEATURES])[:, 1].round(4)
df.to_csv(dst, index=False)
print(f"{len(df)} customers scored -> {dst}")
# python score_batch.py clienti.csv out.csv (clienti.csv = customers file)
# 3 customers scored -> out.csv
# out.csv: c1 -> 0.7993, c2 -> 0.1862, c3 -> 0.5547 (clienti.csv: c1 = 3 months and 4 complaints, c2 = 60 and 0, c3 = 24 and 2)
# with a file that does not exist: FileNotFoundError, exit=1
The model is loaded by alias, so the script does not know which version is in service: moving the alias changes the model the following night, without touching the code. sys.argv[1] and sys.argv[2] are the two command-line arguments, input and output file. With three test customers the script wrote the probabilities 0.7993, 0.1862 and 0.5547, and two runs gave the same file. With a file that does not exist it ends with exit code 1 and the FileNotFoundError at the end of the message: the failure is visible, not silent, and a scheduler that looks at the exit code will notice it.
To make it run every night you use a systemd timer or cron, Unix's historical scheduler. A weekly timer is written, for example, with OnCalendar=Mon *-*-* 03:00:00 UTC (every Monday at 03:00 UTC) and Persistent=true. According to the systemd.timer documentation this means that the time of the last activation is saved to disk and, if a run was "skipped" while the machine was off, the service starts immediately when the timer is activated. The batch is fine when the predictions serve a process (a report, an e-mail campaign), the daily update is enough and the data arrives in packets; it is not fine when the user is waiting for the answer, looking at the screen.
Shape 2: API on a server, the always-on process
It is the service of the article on the Dockerfile: a FastAPI program that stays listening and answers HTTP requests in a few milliseconds. The model is loaded once at startup and reused for every request, which is why startup is slow and the response fast. The price is responsibility: the service has to stay alive, has to say how it is, and a failure is seen immediately by the user. The rest of the article is about this.
Shape 3: a managed service in the cloud
A managed service is a cloud provider's platform to which you hand the container image (see registry, tags and digests) and which takes care of the rest: it starts the copies, multiplies them if more traffic arrives, switches them off if none arrives, and provides the address and the https certificate. Google's Cloud Run is the example of the category. The Cloud Run container contract says that the platform injects the PORT environment variable into the container and that the application must listen on that port: it is the configuration by environment variable of the article on secrets, applied by the platform. The commercial details (prices, limits) change and must be read on the provider's site; here the shape is what matters.
| What you gain | What you pay |
|---|---|
| No server to administer or update | A cost that grows with use, to be monitored |
| Automatic scaling with the traffic | The cold start: after a period of inactivity the first request is slow, because the container has to start and load the model |
| https, address and logging included | Dependence on the provider and its rules (lock-in) |
| Releases by image, with simple rollback | The data and the model leave your physical control: mind privacy and regulations |
The table has a single idea underneath: you trade administration time for control. For a heavy model the entry that weighs most is the cold start, because the "time to load the model" is exactly what scaling to zero repeats at every wake-up. If your model takes minutes to load, ask the provider how to keep at least one copy always warm, and put that cost in the bill.
Shape 4: a VPS with systemd
You rent a virtual server and run the service on it under systemd. It is worth reading the unit line by line, because each one is an operational decision. The one that follows is written for the article's FastAPI service (I call it churn-api, in /opt/churn-api) and is a model to adapt: I checked it with systemd-analyze verify, but the service was not started. The file is a single one, and I split it into blocks to comment on it. The first section says when to start and when to stop retrying:
[Unit]
Description=Churn API (FastAPI)
After=network.target
StartLimitIntervalSec=120
StartLimitBurst=3
After=network.target starts the service after the network is available; if your service depended on another (a database, a cache) you would also add Wants= with the name of that unit, which expresses a wish for its presence: it is the order in which you switch on the machines in a laboratory. StartLimitIntervalSec=120 and StartLimitBurst=3 have a precise effect, described in systemd.unit: a unit started more than three times in two minutes can no longer be started. It avoids the "death loop" that fills the logs and consumes the processor, but it has a downside: after three quick failures the service stays down until someone intervenes.
The [Service] section decides with what identity and what configuration the process runs:
[Service]
User=churn
Group=churn
WorkingDirectory=/opt/churn-api
EnvironmentFile=/opt/churn-api/.env.prod
Environment=MLFLOW_TRACKING_URI=sqlite:////opt/churn-api/mlflow.db
User=churn runs the service with a dedicated user and not with root, the same principle as the app user in the Dockerfile. EnvironmentFile= loads configuration and secrets from a file; the systemd.exec documentation specifies that, if the file does not exist or cannot be read, the service does not start. Environment= adds individual variables, here the address of the MLflow registry (four slashes after sqlite:, because the path is absolute). The practical consequence is that secrets are not in the unit, readable by many, but in a file with tight permissions.
The start command and the stop and restart rules are the heart of the unit:
ExecStart=/opt/churn-api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --workers 1 --timeout-keep-alive 30
TimeoutStopSec=20
TimeoutStartSec=300
Restart=always
RestartSec=10
ExecStart uses the uvicorn of the project's virtual environment (.venv/bin/uvicorn): no "system" Python by accident. app:app tells uvicorn to look for the app object in the file app.py, and --workers 1 asks for a single process, because every process loads its own model in memory. --host 127.0.0.1 makes it listen only on the computer itself, a topic I return to below. TimeoutStartSec=300 grants up to five minutes for startup: the manager's fallback value is DefaultTimeoutStartSec, which on this machine is 1 min 30 s (systemctl show -p DefaultTimeoutStartUSec), and with a heavy model to load a service still starting would be considered failed halfway. Restart=always and RestartSec=10 make the process start again after ten seconds if it stops for any reason: it is the line that makes a process a service.
On stopping, systemd sends SIGTERM by default (it is the value of KillSignal= in the systemd.kill documentation) and TimeoutStopSec=20 grants twenty seconds to close gracefully before systemd forces it: the same theme as the list-form CMD in the article on the Dockerfile.
Finally the security constraints, the equivalent of a container's isolation obtained with systemd's tools:
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/churn-api /var/lib/churn-api
PrivateTmp=true
[Install]
WantedBy=multi-user.target
The process cannot acquire new privileges (NoNewPrivileges), sees the system read-only (ProtectSystem=strict, ProtectHome=read-only) except for the two folders listed in ReadWritePaths, and has a private /tmp. If the service were compromised, the possible damage is limited to those folders. This unit does what the Dockerfile did: unprivileged user, configuration from a file, start command, isolation, automatic restart. What changes is where the isolation lives (in the kernel through the container, or in systemd's options) and where the dependencies live (in the image, or in the virtual environment on the server). The [Install] section at the end says to which target to hook the unit when you enable it with systemctl enable.
How to choose: five questions
| Question | If the answer is... | ...it leans toward |
|---|---|---|
| Is someone waiting for the answer in real time? | No | Batch |
| Is the traffic very variable or unpredictable? | Yes | Managed service (scales by itself) |
| Can the data leave your infrastructure? | No (privacy, regulation) | VPS or your own server |
| Do you have someone who administers a server? | No | Managed service |
| Does the fixed monthly cost matter more than flexibility? | Yes | VPS (predictable cost) |
The five questions do not give a score: they push in different directions, and the choice arises when the answers diverge. A project with one machine, one person who administers it, limited traffic and a fixed monthly cost has the set of answers that leads to the VPS, with a deploy that can stay manual (git pull and restart, as the section below shows). A good deploy is the one consistent with the project's constraints, not the most fashionable; and a project with very variable traffic and no administrator would have answered the opposite.
What a service must expose to be manageable
A service without tools to understand how it is doing is a black box: if something goes wrong, you find out about the problem from an angry user. Four simple things are needed. The first is a health check, an endpoint (/health) that says whether the service is alive. A good check has one notable detail: if it is called before the model is loaded it answers "status": "starting" instead of pretending to be ready (the test application further below is simpler, because it loads the model before accepting requests and always answers ok). It distinguishes "I am alive" from "I am ready to work", a distinction that orchestration systems call liveness and readiness. The second is the version: which code and which model are answering, because the first question about a reported error is "which version?". Add it to the health check, as in the log example below.
The third is the logs. Everything the service writes to standard output ends up, under systemd, in the journal, and is read with journalctl -u churn-api. The fourth is the metrics, numbers over time (requests per second, latency, errors): in Python they are exposed, for example, with the prometheus_client library, in the format that Prometheus collects. The side of metrics and model drift is the topic of the article on drift detection and retraining.
The release, command by command
The manual release of a service like churn-api can be written as a single line. Taking it apart teaches what "deploy" means in concrete terms. The schema below is to be adapted and was not run, because it requires the server:
cd /opt/churn-api \
&& git pull --ff-only \
&& uv sync --locked \
&& sudo systemctl restart churn-api
The thread is the && operator: each step starts only if the previous one succeeded, so a failed git pull installs and restarts nothing. It is the first defence against half-done deploys. git pull --ff-only refuses to create a merge commit: if the local branch is not a simple advance of the remote one it stops with an error (I tried it on two test repositories, and it ends with exit code 128 and the message "Not possible to fast-forward, aborting"), instead of merging silently. uv sync --locked installs exactly the lockfile from lesson 05 and fails if it is not up to date, so the service's environment is that of development, and not the one a pip install would resolve at every release. systemctl restart reloads code, model and configuration. The commit you have just pulled is the identifier of the release, and it must be noted in the release log described at the end.
After the restart the line does not end: a check is needed, because "the command has finished" does not mean "the service is ready". The time after which the health check answers depends on the machine and the model: measure it on your service, because with a large model it can be much more than the seconds expected by someone coming from the web, and use it to size the waiting attempts. The minimum check is to ask for the health and verify that it says what you expect, version included:
curl -s http://127.0.0.1:8000/health
# schema da adattare, non eseguito: serve il servizio in esecuzione sul server
If it answers "starting" wait and try again; if it does not answer, read the journal with journalctl -u churn-api. In the next article you see what happens when the release seems to have succeeded and has not.
When you change the model and when the code
With the registry the two releases separate. The code release (the line above) happens when the service changes. The model release happens when you move the champion alias, and the service picks it up at the next restart. Rule of thumb: treat both as events with an identifier and a line in the release log, even just a text file with date, code version, model version and who did it. The log is described at the end of the article.
The most common traps
Almost all of them arise from the same illusion, that the deploy ends when the command ends. The service may still be starting, or dead right after; a start timeout left at the default with a heavy model gets it killed halfway through loading and makes it look "unstable" (the two lines of the unit above, TimeoutStartSec and StartLimit..., exist for this). A single worker with a large model is right, while more workers multiply the memory: measure before scaling. Secrets in the unit remain readable in the system configuration, so they go in the environment file with tight permissions. And without a way to know the version in use, the first question of every incident remains without an answer. A batch that fails silently is the night-time version of the same problem: if nobody checks the outcome, the batch is skipped and nobody notices for weeks; a non-zero exit code and a warning are part of the batch.
After the release the real work begins
A common mistake of those who come from development is to consider the deploy the finish line. In an ML service it is the starting point: from that moment the model meets data it has never seen, users who do unexpected things, and an infrastructure that sometimes breaks. A service that does not tell you how it is doing is a service that you discover is broken from a customer. The difference between a service that "runs" and one that can be managed starts from the minimum: the record of what happens, observability, that is, the ability to understand from the outside what is happening inside a system.
Structured logs: lines a machine can read
A log is the service's diary. A diary in free prose ("Request received from customer 42, all fine") is readable by a person and almost useless for searching, counting, filtering. A structured log writes one line per event, in a regular format (here JSON), with fixed fields. The complete service below adds to the FastAPI application a middleware, a function that wraps every request, which records identifier, path, status, duration and model version. The model is loaded from the registry by version number, taken from the MODEL_VERSION variable:
# app.py
import json
import logging
import os
import sys
import time
import uuid
from contextlib import asynccontextmanager
import mlflow
import pandas as pd
from fastapi import FastAPI, Request
from pydantic import BaseModel, Field
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
log = logging.getLogger("churn")
MODEL_VERSION = os.environ["MODEL_VERSION"]
state: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
mlflow.set_tracking_uri(os.environ["MLFLOW_TRACKING_URI"])
state["model"] = mlflow.sklearn.load_model(f"models:/churn-classifier/{MODEL_VERSION}")
log.info(json.dumps({"event": "startup", "model_version": MODEL_VERSION}))
yield
app = FastAPI(lifespan=lifespan)
class Customer(BaseModel):
tenure: int = Field(ge=0, le=600)
complaints: int = Field(ge=0, le=100)
@app.middleware("http")
async def registra_richiesta(request: Request, call_next):
rid, t0 = uuid.uuid4().hex[:8], time.perf_counter()
response = await call_next(request)
log.info(json.dumps({"event": "request", "id": rid, "path": request.url.path,
"status": response.status_code,
"ms": round((time.perf_counter() - t0) * 1000, 2),
"model_version": MODEL_VERSION}))
return response
@app.get("/health")
def health():
return {"status": "ok", "model_version": MODEL_VERSION}
@app.post("/predict")
def predict(c: Customer):
p = state["model"].predict_proba(pd.DataFrame([c.model_dump()]))[0, 1]
return {"churn_probability": round(float(p), 4)}
The middleware takes every request; uuid generates a short identifier so that it can be found again; time.perf_counter() measures the time before and after; call_next(request) runs the real handler and returns the response; then a JSON line is written. The model version, read from the environment variable, ends up in every line and in /health. The startup is the only event written in the lifespan, after the model is loaded. I drove the service with FastAPI's TestClient, which calls it in memory without opening ports, with four requests: a health check, two identical predictions and an invalid one (tenure equal to −1). The driver is this one, eight lines long (the function name registra_richiesta, "log the request" in Italian, is an identifier kept as in the listing):
from fastapi.testclient import TestClient
from app import app
with TestClient(app) as client:
client.get("/health")
for _ in range(2):
client.post("/predict", json={"tenure": 3, "complaints": 4})
client.post("/predict", json={"tenure": -1, "complaints": 4})
The with is necessary: it is what starts and closes the lifespan, that is, loads the model and writes the startup line. Without it, the requests would reach a service with no model. I ran it five times with MODEL_VERSION=1 and MLFLOW_TRACKING_URI=sqlite:///mlflow.db, and here I report one (ids and times change at every run; the times shown are those of one of my five runs, and after translating the field names I re-ran the listing only to check that the lines keep the same structure):
# MODEL_VERSION=1 MLFLOW_TRACKING_URI=sqlite:///mlflow.db python prova_app.py
# (id and ms change at every run)
{"event": "startup", "model_version": "1"}
{"event": "request", "id": "563da2c9", "path": "/health", "status": 200, "ms": 7.58, "model_version": "1"}
{"event": "request", "id": "61e7127b", "path": "/predict", "status": 200, "ms": 28.93, "model_version": "1"}
{"event": "request", "id": "d2e2446f", "path": "/predict", "status": 200, "ms": 5.46, "model_version": "1"}
{"event": "request", "id": "d5f40531", "path": "/predict", "status": 422, "ms": 0.49, "model_version": "1"}
Three things to read. First: every line says which model version answered, so when a user reports a problem at 14:07, you find the line and know which model produced it. Second: the first prediction is slower than the second, because the model's code is run for the first time (initialisations, caches). In five runs repeated for the check the first lasted between 10.3 and 18.7 ms and the second between 1.5 and 1.8 ms; they are times of my machine and vary at every run. It is the warm-up: a latency test or a health check must take it into account, and a service with a heavy model sometimes makes a dummy prediction at startup precisely to warm up. Third: the request with invalid input appears in the log with status 422 and minimal duration (0.35-0.6 ms in the same five runs), because it is rejected before reaching the model; counting the 422s lets you notice that a client has changed format.
What not to write in the logs: input data in the clear if it contains personal information, secrets, the whole model responses if they are long or sensitive. A prudent practice is to record an identifier or a summary (the length, a hash) and not the content. With JSON lines you can then extract the fields with tools such as jq, and under systemd everything the service writes to stdout is searched with journalctl -u service-name.
A scheduled batch, with systemd timers
The batch in this article needs a schedule. With systemd you write two files: the service unit, which says what to run, and a timer unit, which says when. The pair below makes the batch script run every night. The service is of type oneshot: it starts, works and ends, unlike the always-on service of shape 2.
[Unit]
Description=Churn - nightly batch scoring
[Service]
Type=oneshot
User=churn
WorkingDirectory=/opt/churn
EnvironmentFile=-/opt/churn/.env.prod
ExecStart=/opt/churn/.venv/bin/python score_batch.py clienti.csv risultati.csv
TimeoutStartSec=1800
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
# file: churn-score.service
User=churn is a dedicated user, as always. EnvironmentFile=-/opt/churn/.env.prod has a leading dash, which according to the systemd.exec documentation makes the file optional and makes any error be silently ignored: convenient in development, dangerous in production, where a missing secrets file should stop the service and not let it start without configuration. TimeoutStartSec=1800 sets a limit of half an hour: for oneshot services the start timeout is disabled by default, so the limit is your choice. The timer says when:
[Unit]
Description=Churn - batch scoring, every night at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
Unit=churn-score.service
[Install]
WantedBy=timers.target
# file: churn-score.timer
OnCalendar uses a date in systemd's format, and Persistent=true makes it recover the run if the machine was off at the scheduled time. It is activated with systemctl enable --now churn-score.timer, a command I did not run because it modifies the system. Something that seems little and is worth a lot: you can verify the files before installing them. I ran systemd-analyze (systemd 259) on these two units, on a variant with an interpreter that exists on this machine and on a deliberately wrong calendar:
systemd-analyze verify ./churn-score.service ./churn-score.timer
# churn-score.service: Command /opt/churn/.venv/bin/python is not executable: No such file or directory
# exit=1
# v.service = churn-score.service with ExecStart=/usr/bin/python3 ...; v.timer points to v.service
systemd-analyze verify ./v.service ./v.timer
# exit=0
# bad.timer = v.timer with OnCalendar=*-*-* 25:99:00
systemd-analyze verify ./v.service ./bad.timer
# bad.timer:5: Failed to parse calendar specification, ignoring: *-*-* 25:99:00 (path shortened)
# bad.timer: Timer unit lacks value setting. Refusing.
# Unit bad.timer has a bad unit file setting.
# exit=1
systemd-analyze calendar "Mon *-*-* 03:00:00 UTC" # run on 2026-11-13 at 02:52, local time (CET)
# Normalized form: Mon *-*-* 03:00:00 UTC
# Next elapse: Mon 2026-11-16 04:00:00 CET
# (in UTC): Mon 2026-11-16 03:00:00 UTC
The first check fails with a real error: on my machine /opt/churn/.venv/bin/python does not exist, and verify also checks that the executable exists, so on the target server, once installed, it should pass. The second, with /usr/bin/python3 in place of the interpreter, exits with 0 and no messages. The third shows that an invalid calendar is rejected before reaching production. The calendar command translates an expression and says when it would fire: run on 13 November 2026 at 02:52 local time, an example expression (Monday at 03:00 UTC) gives Monday 16 November at 04:00 CET, that is 03:00 UTC. The "Next elapse" line depends on the day you run it; the messages above were obtained with the C locale, and the path in the third check is shortened. Verifying before installing avoids the silliest and most common class of timer errors: the one that fires at the wrong time, because of a time zone or a syntax that was not understood.
Designing for failure
An ML service depends on other things: a cache, a database, an external service. Sooner or later one does not answer, and the behaviour at that moment is a design decision to be taken beforehand, not in the heat of the moment. The options go from the most rigid to the softest. You can fail immediately, with a clear error, right when a wrong answer is worse than no answer (a financial decision, a medical one). You can degrade gracefully: return a poorer but honest result, flagging the limit (a value computed without the cache, a warning in the response). Or you can retry with patience, with growing waits and a maximum number of attempts, for transient failures: there are libraries designed for this, such as tenacity in Python and Resilience4j in Java, which also provides the automatic circuit switch (circuit breaker) for calls between services.
The common thread is to decide and document what the user sees when something goes wrong, and to verify it with a test. A handled failure is a feature; an unplanned one is an incident. Retrying has a limit that must not be forgotten: a failure that lasts minutes, while the service retries, turns into queues of waiting requests; this is why the attempts have a ceiling and the circuit opens.
Who can call the service: a principle and a line of the unit
An internal service has an assumption to write down: who is authorised to call it and where this is verified. If the verification is in another service in front of it, the internal service must not be reachable except through that one. The unit shown above says so with one line: --host 127.0.0.1, that is, the process listens only on the computer itself, and whoever calls it must be on the same machine. Changing that address to 0.0.0.0 would open it to every network interface, and at that point only the firewall would keep it away from the Internet. The lesson is general: the safe value must be in the versioned file and not in a local fix, which the next reinstallation of the unit would erase without warning.
The right value depends on who stands in front. With a managed platform the opposite applies: the Cloud Run contract asks the container to listen on 0.0.0.0, and says it must not listen on 127.0.0.1, because it is the platform that receives the requests and forwards them. For you: check that the verification cannot be bypassed by calling the service's port directly.
Releasing without stopping the service: the blue-green idea
With a single process and a startup that can last minutes, restarting the ML service means an interruption. For many projects, with rare releases and limited traffic, it is acceptable. When it is not, there is a conceptually simple technique, the blue-green release: you keep two copies of the service, on two different ports; the proxy sends traffic to the "blue" copy while the "green" one starts with the new version; when the green one answers the health check well, you move the traffic to it and stop the blue one. The rollback is the inverse move, immediate, because the old copy is still there. It costs double the memory during the release (two models loaded) and a more elaborate proxy configuration. It should be chosen when the cost of the interruption exceeds that of the complexity; the article on manual deploy and CI/CD discusses the criterion for deciding how much release complexity pays off, and it is the reason why a project with one machine and rare releases can stay on the simple restart.
The release log: the habit that is missing most often
It is a text file (or a table) in which, at every release, you write a line:
| Date and time | Service | Code version | Model version | Who | Outcome of the check |
|---|---|---|---|---|---|
| 2026-11-12 18:20 | churn-api | sha-a1b2c3d | champion v1 | name | health: version 1, test prediction 0.7993 |
The line is an example: date, service name and hash are invented, while the test prediction (0.7993) is that of the article on the registry. It costs thirty seconds and answers, months later, the question "what was in production on the day it happened?". It is what turns the rollback from a hypothesis into a procedure: you know which version to go back to. It requires no tools: a file in the repository, or a wiki page, is enough.
What you can do now
You can tell the four deployment shapes apart (batch, API on a server, managed service, VPS with systemd) and choose one with five questions: real-time waiting, traffic variability, where the data may live, who administers, fixed or flexible cost. You can read a systemd unit line by line as the operational equivalent of a Dockerfile, write a timer and verify it with systemd-analyze before installing it. You know that a manageable service exposes a health check with the alive/ready distinction, the version, structured logs and metrics, and that the release command ends before the service is ready, so it is always verified. The last step of the module is deciding how much to automate: manual deploy or CI/CD, and why sometimes the automatic pipeline is not worth it.
The same series also includes ML-Ops: containers, images and isolation.
Sources
- systemd.service:
Type=oneshot,TimeoutStartSec=(disabled by default foroneshot),Restart=, consulted on 2026-11-12. - systemd.timer:
OnCalendar=andPersistent=, consulted on 2026-11-12. - systemd.exec:
EnvironmentFile=and the "-" prefix,NoNewPrivileges=,ProtectSystem=,PrivateTmp=, consulted on 2026-11-12. - systemd.unit:
StartLimitIntervalSec=andStartLimitBurst=, consulted on 2026-11-12. - Cloud Run, container contract:
PORTenvironment variable injected into the container and the obligation to listen on0.0.0.0, consulted on 2026-11-13. - systemd.kill:
KillSignal=is SIGTERM by default, consulted on 2026-11-13.
How the listings were verified: score_batch.py and the application with the middleware were run with MLflow 3.18.0, FastAPI 0.162.5 (with httpx 0.28.1 for the TestClient) and Python 3.12 on a test registry; score_batch.py twice with the same output file, the application five times (ids and times vary). systemd-analyze is from systemd 259. The churn-api.service unit was checked with systemd-analyze verify, twice, in a variant with an existing executable. Not run, declared in the text: the release on a server, curl on the service, systemctl and journalctl. Customers and probabilities are invented synthetic data.
Commenti
Caricamento commenti...
Sign in to comment