← the light list
THE LIGHT LIST · NO. 002 Iso G 7s lit

The Great Un-monolithing

An event-driven microservices architecture built on Kubernetes. More buzzwords please.

ownership design to operations, solo
outcome 5 minute publishes to a few seconds
scope 29 microservices + infrastructure
established · 2024 tags · kubernetes · python · rabbitmq · varnish · docker
01 Some history and whining

The Post-Gazette switched from a home-build publishing stack and CMS (called Bridge) to Libercus in 2013. After some data migrations, templates, undocumented APIs, visits from CEOs, and a bit of workaholicism, it was finally done. Except not: Libercus was pretty terrible once you had any more than a handful of storylists on a single page. The homepage had a ton. So we decided, just a few years later: let's ditch Libercus as a frontend. We would still use it as a CMS and source of truth, but it would no longer be our publishing stack.

02 Queues and lost cities were born

rsweb1 / rsweb2 / rsweb3 (+ webNAPI) — served by api2.post-gazette.com/var/www/html_apis

apid.post-gazette.com (data/worker tier)

updateThirdPartyOnUpdate fan-out

updateStory.php → Mahout

Pompeii daemons (watch /var/Pompeii/.stories)

yes

no

yes

no

yes

yes

no

curl each rsweb → api2

curl each rsweb → api2

CMS / publish trigger
POST /article/1/updateArticle/

article_updateArticle()
html-secure/index.php:850
validate POST + API_KEY + itemID

exec updatePompeii.php
stories={itemID} (async, &)

host ==
apiserver?

curl POST → data1.post-gazette.com
legacy apid mirror

write trigger file
/var/Pompeii/.stories/pg_{id}[_trigger]

Pompeii.py / _priority / _retry_* / _backfill
dedup via ps aux, remove file

subprocess updateStory.php
stories={id} [comp=2 for TB]

load MahoutConfig_PG/TB (+backfill)
kill-switch check
log tblArticleTriggerLog

UserData::updateUserArticles()
Mahout.php:103

fetch story from Libercus API
GET_STORY_SINGLE

story
exists?

_removeArticleFromHBASE()

_processArticleCache()
Mahout.php:1752

fetch Libercus images / sections / tags

Status==9 &
not nate/devtest?

_removeArticleFromHBASE()
return -1

adjustBody + generate canonical
hero images + CDN upload (Rackspace)

canonical
changed?

updateThirdPartyOnDelete(old canon)

insert HBASE (Stargate) + MariaDB
write local JSON cache
retry once on failure

updateThirdPartyOnUpdate(storyID)
MahoutConfig_PG.php:1240

updateFeedSection.py — rebuild section packages from HBase

updateInfinite.py

Queryly articleInsert.py — search index

_updateWeb story → POST /article/1/updateArticle/

_updateWeb feed → POST /article/1/updateFeeds/

article_updateArticle()
html_apis/index.php:805

_getStoryCache active check
inactive → delete=1

exec app/createStoryHTML.php siteType=live

exec app/createStoryHTML.php siteType=dev

pagebuilder/build.php
pagetype=story storyid={id}

write cached/stories/.../index.html
(or unlink on delete)

StrangeCache::quickPurge(canonical)
+ prime via file_get_contents

STORY IS LIVE

pg_updateFeeds()
html_apis/index.php:944

exec app/updateFeeds.py

rand: updateRSSFeeds.py + updateInfiniteScroll.py

After quite a bit of tinkering, learning, and plenty of ice cream sandwiches, our new publishing stack was born.

from the log Then came the headaches. And sleepless nights. 4AM phone calls.

Launching this...thing, was very tough. We had a barely functioning Cloudera server. We had 3 web servers that had to run background tasks, serve HTML and also run nginx and varnish. We had a system that could take up to 15 minutes for a story to show up after publishing. We had an unhappy newsroom. But they dealt with it, all the way until 2024.

03 The changing point

The Post-Gazette newsroom had patience I could only dream of. Occasional issues would come up, but they'd deal with it with grace and acceptance. But I had had enough. I was tired of having to wake up at 4AM to fix an issue. I was tired of 50% of my day being spent fixing bugs. I built a broken system, and I needed to fix it.

Around this time, our systems team got kind of taken over by the shared services team at BCI. There were some growing pains, but they were also willing to expand us. My initial pitch was 3 web servers, 3 Varnish servers, 2 data servers. We'd keep Cloudera. We'd separate the web logic from the caching logic. It would be better, but would it really be great? So I started pursuing docker, containerization, trying to find ways to get things more modern. I was struggling building an API gateway, an orchestrator, etc. Tried docker swarm and wasn't a fan, and finally stumbled upon Kubernetes. It seemed to have everything I need as a solo dev: redundancy, self healing, easy deployment. The dream of no more sleepless nights was too good to pass up, so I went for it. I sent my request.

Hey guys,
Hey guys,
04 The shape of it

pub

sub

fetch

write

pub

sub

sub

pub

sub

Editor change

Beacon

Scribe

Libercus

Archivist

Illustrator
(images)

Anbu

story.trigger.received

story.processed

story.images.processed

note Publish workflow

GET post-gazette.com

SSL term

cache miss

build HTML

GET section

GET section data

GET stories

User

KEMP
load balancer

MetalLB

Varnish
cache

Ingress
(nginx)

Renderer

PageBuilder

Milize

Weaver

Archivist

note Consumption workflow

Is that actually better? Who knows

05 The design and the build

The rule that made 29 services survivable for one keeper: every service looks the same. Same repo layout, same base image, same health checks, same way onto and off the bus. The novelty budget goes to the problem, not the plumbing.

# Project: archivist
# File: main.py
# Description: Main entry point for the archivist service.

import os
import uvicorn # type: ignore
from app.archivist import app
from app.logging.config import logconfig_dict
from config import Settings # Changed from app.config import Settings

if __name__ == "__main__":
settings = Settings()

uvicorn.run(
app,
host=settings.HOST,
port=settings.PORT,
log_config=logconfig_dict
)

Every service gets an entry point, a main.py file that initializes it and starts the (mostly) FastAPI process. Every service gets an API, to check the health and for prometheus metrics. Every service scaffolded the same, same log output, same general structure throughout. Interface first and SOLID led the way.

# Description: MongoDB data access layer for story data.
# Standard imports
import datetime
import logging
from typing import Any, List, Optional, Tuple, Dict

# External imports
from motor.motor_asyncio import AsyncIOMotorClient # type: ignore
from bson.objectid import ObjectId # type: ignore
from bson.errors import InvalidId # type: ignore
from pymongo import ReadPreference # type: ignore
from pymongo.errors import ServerSelectionTimeoutError, NetworkTimeout # type: ignore

# Internal imports
from interfaces import StoryDataInterface
from app.domain_model import QueryParams, FilterCondition, SortCondition, Operator, RegexOptions

logger = logging.getLogger(__name__)


class MongoStoryData(StoryDataInterface):
STORY_COLLECTION = "stories"

# Class-level counter for concurrent aggregations - per instance
_concurrent_aggregations = 0
_MAX_CONCURRENT_AGGREGATIONS = 10 # Reduced from 10 to account for multiple workers

#
# Initialization and lifecycle methods
#
def __init__(self, db_url: str, db_name: str, read_preference: str = "secondaryPreferred"):
"""
Initializes the MongoDB client and database connection.
Args:
db_url (str): The MongoDB connection URL.
db_name (str): The name of the database to connect to.
read_preference (str): The read preference for the MongoDB client.
- "primary" for primary node
- "secondary" for secondary node
- "primaryPreferred" for primary node if available, otherwise secondary
- "secondaryPreferred" for secondary node if available, otherwise primary
- "nearest" for the nearest node
"""
read_pref_map = {
"primary": ReadPreference.PRIMARY,
"secondary": ReadPreference.SECONDARY,
"primaryPreferred": ReadPreference.PRIMARY_PREFERRED,
"secondaryPreferred": ReadPreference.SECONDARY_PREFERRED,
"nearest": ReadPreference.NEAREST
}

mongo_read_pref = read_pref_map.get(read_preference, ReadPreference.SECONDARY_PREFERRED)

self.client = AsyncIOMotorClient(
db_url,
read_preference=mongo_read_pref,
serverSelectionTimeoutMS=3000,
connectTimeoutMS=3000,
socketTimeoutMS=5000,
)
self.db = self.client[db_name]
self.collection = self.db[self.STORY_COLLECTION]

self._read_pref = mongo_read_pref
note Shhh, I definitely used protocols here, not ABC
06 Migrating while the paper ships (uh, digitally)

To migrate without interruption to our digital platform, I had to create this cluster while keeping the old servers running. This resulted in a lot of overtime (not recommended), a lot of working late into the night, a lot of missed time with my kid, regrettably. I was able to create the working microservices cluster, mirroring our website. Before I was ready to cutover, things started breaking. Feeds on the old platform were breaking, not working correctly. The infinite scrolls were no longer infinite. I had to adapt, so I started migrating parts of it one at a time. First, routing some of the story lists to the new cluster.

function pg_mostRecent($section, $page)
{
$app = \Slim\Slim::getInstance();
$app->contentType("application/json");

if ("local/breaking" == $section) {
$section = "all";
}

// Use milize.toledoblade.com/recent/1/$section/$page/
$dataURL = "http://milize.toledoblade.com/recent/1/{$section}/{$page}/";
$data = @file_get_contents($dataURL);

$dataDecoded = json_decode($data, true);
if (isset($dataDecoded["status"])) {
if ("ok" === $dataDecoded["status"]) {
echo $data;
exit;
}
}

$appName = "recent";
$appSection = "";
$apiName = $section;

$decoder = new RetrieveJSON($appName, $appSection, $apiName, $page);
$time = $decoder->getLastMod();
$app->lastModified($decoder->getLastMod());
header("Last-Modified: " . $app->response->headers->get("Last-Modified"));
if ($time === strtotime($app->request->headers->get('IF_MODIFIED_SINCE'))) {
http_response_code(304);
exit;
}

$decoder->getAndDecodePage();
$data = $decoder->getContents();

echo $data;
}
heads up Yes, I just forwarded the request like that...

Finally, the whole cluster was eventually cutover, and the results...well, they were...

29 services in production, uniform, one keeper
~2M visits/mo served through the platform
old stack: dark decommissioned 2025 · see light no. 014
< 5 seconds from edit to live on the site
07 What went wrong
from the log Consistency is tough to find

404s, stories missing (I am aware that's the same thing), sections not updating properly, sections with the wrong content, a random "Oh, we're moving the cluster to Ohio, you gotta do this all again".

Learning Kubernetes in 6 months was tough. There was a crazy learning curve, and even a year later, I'm not sure I still understand all of it. Certain services went crazy (Anbu blew up the cluster a few times, our cache invalidator and primer), certain services were undertuned, certain services weren't even my fault (looking at you Jenkins and your updates that break 80% of the plugins). But the newsroom stuck with me, patient as they were, and we reached stability.

08 What I'd change now
· Have more time please