Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/three-runners-four-inventories && read --section="top" 0%
Microservices

Three Runners, Four Inventories

Trill ships a PM2 ecosystem file, a Tye manifest and a Docker Compose file - three ways to start the estate that disagree about which services exist, plus a Prometheus config that scrapes a service two of them never launch.

By Kumar Chandrachooda 09 Dec 2025 6 min read
Three parallel tracks of unequal length beside a fourth list that agrees with none of them

There is a specific kind of estate rot that nobody ever files a ticket for: the run scripts drift apart. Someone adds a service and updates the compose file. Someone else adds a monitoring target and updates Prometheus. A third person writes a new local-development runner and copies the list of services as it stood that afternoon. Nothing breaks loudly, so nothing gets fixed, and eighteen months later there is no single artefact that knows what the system is made of.

Part 1 established that the DevMentors Trill estate has no README and no bootstrap tooling. This part is about the four files that each partially substitute for one, and what happens when you line them up next to each other.

Runner one: an undocumented PM2 ecosystem file

Trill\services.yml is thirty-four lines and starts like this:

apps:
  - name: apigateway
    script: dotnet run
    cwd: ../Trill.APIGateway/src/Trill.APIGateway
    max_restarts: 3
  - name: pusher
    script: dotnet run
    cwd: ../Trill.Pusher/src/Trill.Pusher
    max_restarts: 3

The apps / script / cwd / max_restarts schema is PM2's — the Node.js process manager. Nothing in the estate says so. There is no README mentioning PM2, no comment in the file, no package.json, no npm dependency, no install instruction. If you do not already recognise the shape, services.yml is a YAML file with no owner: it is not Docker Compose, not Kubernetes, not Tye, not dotnet-* anything. It is a perfectly good runner, and identifying it is a piece of tacit knowledge the repository declines to share.

It lists seven applications. The Web UI is present but commented out (lines 30–33). The Saga is not present at all.

Runner two: Tye, which is the only manifest that names everything

Trill\tye.yaml is the estate's most complete inventory:

name: trill
services:
- name: API
  project: ../Trill.APIGateway/src/Trill.APIGateway/Trill.APIGateway.csproj
  bindings:
  - port: 5000

Nine services, each with an explicit port: API 5000, Pusher 5010, Web 5020, Ads 5030, Analytics 5040, Stories 5050, Timeline 5060, Users 5070, Saga 5080. It is the only file in the repository that names all nine runnable units. It also requires the tye global tool, which — like PM2 — is mentioned nowhere.

Runner three: Compose, which has never built

Trill\compose\services-local.yml declares eight services against Dockerfiles in the sibling repositories. It omits the Saga, includes the Web, and contains this on line 51:

  stories-service:
    build: ../../Trill.Services.stories
    container_name: trill-stories

Lowercase s on stories. On Windows and macOS that resolves; on any case-sensitive filesystem — Linux CI, a WSL2 Docker host, most build agents — that build context does not exist.

It is a moot typo, because the file cannot work anyway. Every one of the nine service Dockerfiles in this estate begins:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /app
COPY . .
RUN dotnet publish src/Trill.APIGateway -c release -o out

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1

And every one of the sixty-three .csproj files in the estate targets net5.0. The April 2021 net5.0 update commit changed the target framework in all ten repositories and did not touch a single Dockerfile. The 3.1 SDK cannot publish a net5.0 project, so every docker build in this estate has failed since 2021-04-22. The umbrella repository's own Docker compose update commit, three months later, did not notice.

The image paths are wrong in a second, independent way: mcr.microsoft.com/dotnet/core/sdk is the pre-5.0 repository path. From .NET 5 onwards it is mcr.microsoft.com/dotnet/sdk. Bumping the tag alone would not have fixed it.

Inventory four: Prometheus, which scrapes a service two runners never start

compose/prometheus/prometheus.yml declares nine scrape jobs. Put all four files in one table and the estate's inventory problem becomes a picture:

Unit services.yml (PM2) tye.yaml services-local.yml prometheus.yml
api-gateway yes, 5000 yes, 5000 yes, 5000:80 yes, 5000
pusher yes, 5010 yes, 5010 yes, 5010+5011 yes, 5010
saga no yes, 5080 no yes, 5080
web commented out yes, 5020 yes, 5020:80 no
ads / analytics / stories / timeline / users yes yes yes yes

Four manifests, four answers. Prometheus scrapes a Saga that two of the three runners never start, and the Web is started by two runners and scraped by none. Only tye.yaml describes the complete system, and nothing tells a newcomer that Tye is the authoritative one.

There is a second Prometheus file, prometheus-docker.yml, intended for the containerised path. It is never built into any image — compose/prometheus/Dockerfile copies prometheus.yml and only prometheus.yml:

FROM prom/prometheus
WORKDIR /app
COPY ./prometheus.yml /etc/prometheus/prometheus.yml

And it would mostly not work if it were, because its targets are hostnames like trill-ads-service and trill-stories-service, while services-local.yml sets container_name: trill-ads and names the compose service ads-service. Neither alias matches. Two of its eight targets happen to resolve — trill-api-gateway and trill-pusher are real container names — and six do not.

The live prometheus.yml takes the opposite view of the world: its targets are host.docker.internal:5000 through host.docker.internal:5080. That is coherent, and it tells you exactly how the author actually worked — services on the host under PM2 or Tye, infrastructure in Docker. It also makes the two paths mutually exclusive: containerise the services and Prometheus scrapes nothing.

Both Prometheus files carry the same small tell on lines 2–3:

global:
  scrape_interval:     5s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 5s # Evaluate rules every 15 seconds. The default is every 1 minute.

The value was changed; the comment was not. Twice, in two files.

The environment that does not exist

There is a fifth inventory of a different kind. Each of the nine service repositories carries scripts/start.sh, and all nine are this:

#!/bin/bash
export ASPNETCORE_ENVIRONMENT=local
cd src/Trill.APIGateway
dotnet run

There is no appsettings.local.json in any repository in the estate. I checked all eleven. So start.sh silently falls through to the base appsettings.json, which has vault.enabled: true, jaeger.enabled: true, seq.enabled: true and Consul enabled — the full infrastructure dependency set, not the trimmed profile. Meanwhile every launchSettings.json sets ASPNETCORE_ENVIRONMENT to development, which does exist and which disables Vault, Jaeger, Prometheus and file logging.

So the shell scripts and the IDE profiles start the same service in two different configurations, and only the IDE names one that exists. Run it from Rider and it works; run it from ./scripts/start.sh and it tries to reach Vault.

The dockerize.sh scripts complete the picture. Nine of them, near-identical:

case "$TRAVIS_BRANCH" in
  "master")  TAG=latest; VERSION_TAG=$TRAVIS_BUILD_NUMBER ;;
  "develop") TAG=dev;    VERSION_TAG=$TAG-$TRAVIS_BUILD_NUMBER ;;
esac

docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
docker build -t $REPOSITORY:$TAG -t $REPOSITORY:$VERSION_TAG .

There is no .travis.yml, no .github/workflows content, no azure-pipelines.yml and no .gitlab-ci.yml anywhere in the estate. Run locally, $TRAVIS_BRANCH is empty, both case arms miss, and the script builds and pushes images tagged with nothing at all. Note also docker login -p $DOCKER_PASSWORD, the password-on-the-command-line pattern that leaks into process listings and shell history — a habit worth naming even in a sample.

What this actually teaches

To be fair to the estate: having three runners is not itself a defect. PM2 for a fast inner loop, Tye for a coherent multi-service debug session and Compose for a container-parity check is a reasonable set of tools, and the author clearly used at least two of them in anger. The defect is that no artefact is marked authoritative, and no artefact is derived from another.

That is the durable lesson, and it generalises well past this repository. If your estate has more than one manifest listing its services, exactly one of them must be the source of truth and the rest must be generated from it — or, failing that, a five-line CI check must assert that the lists agree. Trill has neither, so the four lists drifted the moment a ninth unit appeared, and the drift is invisible until you put the files side by side.

Next, the same inversion one level deeper: the config is a promise the code never made, where twenty-one components are configured, packaged and never written.