Skip to content

Honeywatch: debugging a slow page

This is the same project as the honeypot writeup - Honeywatch (overview). The parts that matter here: an ingestor writes Cowrie's logs into Postgres and a Flask API reads them. nginx sits in front of the API as a cache, and a Vue dashboard polls it. Everything except the dashboard runs on one box.

The Origins page - attacks by country - took 17-20 seconds to load, with the API box CPU usage % burst while it ran. On load it fetches /stats/countries, /stats/asns, /stats/ssh-clients and /stats/fingerprints, then re-polls them every 120 seconds.

The box is a small Hetzner VPS with 4 GB of RAM. I had/have no plans to resize. The goal was to squeeze what I could out of it.

Measuring timings by hand would take a long time. Thankfully, I could leave most of this work to Claude - it measured the baseline, wrote it down and looked for improvements iteratively.

TL;DR
# API box CPU on production:
# Idle: <5%
# API call: 60-80% during bursts, still for most requests <50%
#
# Local timings:
# Origins 1.5s -> 0.41s,
# Top Credentials 133ms -> 65ms.
#
# random_page_cost=1.1 was the only Postgres setting that visibly helped.
# The initdb args *look* applied, but don't change a running cluster;
# the other runtime settings turned out to be noise or had detrimental impact.

# docker-compose.prod.yml
# initdb args are read once, when the volume is empty
POSTGRES_INITDB_ARGS: >-
  ... -c shared_buffers=128MB -c work_mem=4MB -c max_connections=50 ...
# runtime settings; applied when the container is (re)created (`up -d`)
command:
  - postgres
  - ...
  - -c
  - random_page_cost=1.1

# proxy/nginx.conf
# an entry stays fresh up until each endpoint's own Cache-Control max-age
# (30s default, 120s for the stats endpoints, 300s for country detail)
#
# an entry is deleted after `inactive` with no requests, fresh or not
# (was 60s, so the 120s and 300s entries never survived to the next poll)
#   proxy_cache_path ... inactive=60s   ->   inactive=10m

# api/src/routes/stats.py
# endpoints set Cache-Control on every response, errors included
# (nginx then caches a 500 and serves it to every viewer until it expires)
#   set the header only when status < 400

initdb args

The Postgres service set shared_buffers=128MB, work_mem=4MB and max_connections=50 - in POSTGRES_INITDB_ARGS.

Those args go to initdb, which runs once, when the data directory is empty. Whatever it writes into postgresql.conf at that moment is what the cluster keeps. Editing the line later changes nothing on a running instance.

shared_buffers=128MB and work_mem=4MB are the Postgres defaults. Only max_connections=50 (default 100) would ever have mattered, and only if the volume was created after the line was added.

Runtime settings belong in command:. It's applied whenever the container is created and overrides postgresql.conf. I checked that precedence on a test container, with the two deliberately disagreeing, via SELECT name, setting, source FROM pg_settings:

FRESH CLUSTER, INITDB_ARGS says 128MB/4MB, command says 256MB/8MB:
  shared_buffers  = 32768   (8kB pages = 256MB, source: command line)
  work_mem        = 8192    (kB = 8MB,          source: command line)
  shared_preload_libraries = pg_stat_statements  (source: configuration file)

Command line wins for anything it sets. Everything else still comes from the file. The initdb line stays, since it still sets max_connections for a fresh install. But nothing in it retunes a running cluster, and nothing was checking which values were in effect.

While checking which values were in effect, I also realised that db queries aren't properly measured at all. This started out as a quick and small project. I was complacent and didn't pay pay much attention in the beginning.

Turns out pg_stat_statements library was already preloaded by that same initdb line, but the extension was never created, so nothing was recording.

I put CREATE EXTENSION pg_stat_statements into postgres/init.sql and since it wouldn't apply on a running container - I updated it by hand after the merge. Next iteration of query optimizations will have proper support in data.

proxy/nginx.conf also is is a bind mount, and compose hashes the service definition, not the contents of mounted files.

up -d recreated Postgres, since command: had changed, and left the proxy running with the old config in memory. The nginx change also needed --force-recreate:

docker compose -f docker-compose.prod.yml up -d --force-recreate proxy

Restart wouldn't work, since it reuses existing container, instead of creating a new one (which is required).

random_page_cost

random_page_cost - what the planner charges for reading a page out of order, relative to `seq_page_cost=1.0. Defaults to 4.0.

The Postgres docs say 4.0 already assumes most random reads hit cache, and that magnetic disks would want a value higher than 4. Once more more reliable measurements come from the cluster - it might be worthwhile to test out lower value for this one.

Reason is that - almost entire data we have fits within the memory, especially if we did some optimizations around ssh_clients:

database              1063 MB    (container cap 1024M, host RAM 3.47 GB)
  ssh_clients          786 MB    client fingerprints
  sessions             141 MB
  auth_attempts         92 MB
  geo_locations         <2 MB
hot set               ~235 MB
heap cache hit         95.9%    heap_blks_hit / (hit + read); measured locally

The three tables the dashboard queries fit in the container's 1 GB. A random read of a page already in RAM costs about the same as a sequential one.

The hit ratio was measured on my machine with no memory limit. Production caps the container at 1024M, so this holds only while the hot tables keep fitting.

The top-credentials query, GROUP BY username, password over auth_attempts, same data, timed on my machine:

random_page_cost=4.0:  Seq Scan (est. cost 10827) -> HashAggregate         162ms
random_page_cost=1.1:  Index Only Scan (est. cost 9628) -> GroupAggregate   89ms   Heap Fetches: 0

At 4.0 the planner priced the index path above the sequential scan and chose the scan. At 1.1 the index path came in cheaper and won. Nothing about caching or I/O changed - only the constant the planner multiplies by.

The index it started using, ix_auth_attempts_username_password, already stores rows sorted by the GROUP BY key. Postgres walks it in order and accumulates groups as it goes, instead of building a hash table of every group. Heap Fetches: 0 means it never touched the table - every column it needed is in the index.

That index, and the others built for these queries in named migrations, all showed idx_scan = 0 in pg_stat_user_indexes. The honeypot maintained them on every insert - it ingests continuously - and never read from them. Planner constant determined they weren't worth using.

The other settings

Other settings were also considered:

SettingResult
random_page_cost=1.1kept - the entire win
work_mem=8MB (with jit=off, not separated)2.3x worse on one endpoint
jit=off3ms apart - noise
shared_buffers=256MB1ms apart - noise
effective_cache_size=1GBno change
effective_io_concurrency=200never measured
maintenance_work_mem=64MBalready the default

I had also added effective_io_concurrency=200, because I saw quite a bit of recommendations for machines with SSD drives. Not yet measured. Default for Postgres 16 is 1.

The interesting one is work_mem. I had bundled it with random_page_cost and jit=off and measured the bundle, which looked like a solid improvement.

Split apart - a query bench against the restored snapshot, after the /stats/countries change in the next section, so countries starts at 185ms here rather than 810:

query bench, local.  countries = /stats/countries
                     detail = /stats/countries/<a2>   creds = top-credentials

stock (rpc=4, jit=on, wm=4MB)    countries=185ms detail=432ms  creds=131ms
rpc=1.1 ONLY (jit=on, wm=4MB)    countries=185ms detail=438ms  creds= 63ms
rpc=1.1 + jit=off + wm=8MB       countries=180ms detail=1000ms creds= 62ms

The credentials win is entirely random_page_cost. The country-detail endpoint more than doubles once work_mem=8MB and jit=off are added. jit=off on its own was noise, so work_mem is the suspect - though I measured the two together, so at the moment I can't pin it with certainty.

The bundled measurement hid it. The two endpoints move in opposite directions and I was only watching the one that improved. I would have shipped that - it passed the tests and the linters.

/stats/countries

Separately, /stats/countries was taking 810ms. The plan showed where:

GroupAggregate  (actual time=387.137..745.857 rows=126)
  Group Key: COALESCE(geo_locations.country_code, '??')
  ->  GroupAggregate  (rows=236985)
        Group Key: COALESCE(geo_locations.country_code, '??'), auth_attempts.username, auth_attempts.password
        ...
        Sort Method: external merge  Disk: 4984kB

Nearly all of it was the inner aggregate. It grouped every auth attempt by (country, username, password) into 236,985 groups, spilling to disk on the way, and only then collapsed those into 126 countries. The inner stage existed to compute two output columns: distinct_usernames and distinct_passwords.

Grepping the dashboard for either field hits the generated types and one test fixture. No component renders them. The Credentials page does show "Distinct passwords" and "Distinct usernames" tiles, but those come from auth-outcomes, a different endpoint entirely.

Removing them collapses a two-stage aggregation into a single GROUP BY country_code:

query alone, psql, local:
  with distinct user/pass counts   589ms
  without                          150ms

End to end the endpoint went from 810ms to 203ms.

Cache-Control on errors

The API sets Cache-Control per endpoint through a small @after_this_request block. There, I moved the Credentials endpoints from the 30-second default to 120. The ingestor never stops writing, and the dashboard doesn't need sub-minute freshness.

There is also an app-wide handler in api/src/security_headers.py:

if request.path.endswith("/openapi.json") or response.status_code >= 400:
    response.headers.setdefault("Cache-Control", "no-store")
else:
    response.headers.setdefault("Cache-Control", "public, max-age=30")

The status_code >= 400 branch ensures that errors won't be cached. It uses setdefault. The per-endpoint blocks used plain assignment, and Flask runs after_this_request callbacks before app-level after_request handlers. So the endpoint's header was already in place, setdefault did nothing, and the no-store never happened.

An error response carried public, max-age=120. I never saw it in production, but it's reachable. With the service monkeypatched to raise:

500-path status       : 500
500-path Cache-Control: 'public, max-age=120'

nginx sits in front of the API and caches what it returns, takes that header at its word. During testing, a throwaway nginx config was pointed at a fake upstream that always answers 500 with max-age=120, with an X-Cache debug header added so the cache state is visible (production doesn't emit one):

req1: status=500  X-Cache=MISS
req2: status=500  X-Cache=HIT
req3: status=500  X-Cache=HIT

The first request stored the error; the next two were served from the cache without touching the API. That is shared by every viewer, so a single database blip would turn into two minutes of 500s for everyone, including people who open the page after the database has recovered.

Six endpoints worked this way, and five of them had the bug. The sixth, /stats/countries/<a2> (a2 being the ISO country code), was fine by accident: its abort(404) comes before the callback is registered, so on the error path there is no callback to run. Same bug, saved by statement order.

As a fix - helper was added that sets the header on success:

def _cache_for(seconds: int) -> None:
    @after_this_request
    def _set(response: Response) -> Response:
        if response.status_code < 400:
            response.headers["Cache-Control"] = f"public, max-age={seconds}"
        return response

nginx inactive

The nginx cache zone was declared like this:

# ... a 10s cache collapses that to at most one upstream query per endpoint
# per interval, regardless of viewer count.
proxy_cache_path /var/cache/nginx/api keys_zone=api_cache:4m max_size=32m inactive=60s;
...
proxy_cache_valid 200 10s;

The comment describes a 10s micro-cache. nginx honours the upstream Cache-Control unless proxy_ignore_headers tells it not to, and nothing here does. So the real TTL is whatever the endpoint sends - 30 to 300 seconds, not 10. Verified against a fake upstream sending max-age=120 while proxy_cache_valid said 10s:

1st:       X-Cache: MISS
after 15s: X-Cache: HIT

The upstream header wins.

inactive=60s is a different clock from freshness. Freshness decides whether a cached copy may be served. inactive decides whether the file is kept at all, and it resets on every read. Whichever fires first wins.

The Origins page re-fetches /stats/countries every 120 seconds, and that endpoint sends max-age=120:

t=0     poll -> MISS -> fetch, store (fresh until t=120)
t=60    nginx DELETES it - untouched for 60s, despite 60s of validity left
t=120   poll -> MISS again

Every poll missed. The cache did nothing for a steady viewer, and had done nothing since I moved those endpoints to max-age=120 a few days earlier. Before that they sent 30 seconds and inactive=60s outlived it by accident. Shrinking inactive to 5s against an entry with max-age=120 shows the eviction:

1st:            X-Cache: MISS
immediate:      X-Cache: HIT
after 9s idle:  X-Cache: MISS   <- still valid, evicted anyway

Raising inactive past the longest max-age costs nothing. The entries are at most 24 KB and max_size=32m still bounds the total. It's 10m now, which is nginx's own default; the 60s had been an explicit shortening.

Dashboard poll interval

Once the Credentials endpoints moved to max-age=120 in the same change, the page's own constant was stale. It has a few views - the code calls them lenses - and the fast ones still had:

// Fast lenses (matrix rows/cols/pairs): 30s (matches endpoint Cache-Control);
const POLL_MS = 30_000

The comment was true right up to that commit. Left as it was, the browser's own HTTP cache would have answered the 30-second poll - the generated API client sets no cache option on its fetches. Three polls in four would never reach the server. The effective refresh would be 120s, with no in-app way to force one short of a hard reload.

Collapsed it to a single POLL_MS = 120_000 matching the cache; the two slower lenses moved from 60s to 120s with it.

Resolution

Through the API, locally, against the restored snapshot:

/stats/countries              810ms  -> 203ms   (distinct fields removed)
Origins, 4 requests serial     1.5s  -> 0.41s   (same change)
top-credentials               133ms  ->  65ms   (random_page_cost)
password-composition          111ms  ->  77ms   (random_page_cost)
/stats/countries/<a2>         426ms  -> 462ms   (+8%, accepted)

The country-detail drawer got slightly slower with random_page_cost=1.1 - a real regression, kept because Credentials was the page pinning the CPU.

On production, the API box had sat at around 60-80% CPU whenever Origins loaded. After the first change it mostly stayed under 50%, with occasional spikes to 70%, and after the second deploy the map stays under 40% even on a fresh load. I did not re-measure the Origins page load time there yet. The Credentials page was the other heavy consumer, so the CPU drop is not only the Origins change.

Those numbers are also softer than the local ones. There was no node exporter on the box at the time; I was reading btop while clicking through the dashboard, which is an eye test, not a measurement. The box has one now, so the next round gets compared on scraped CPU over time rather than on what I happened to notice.

What's left is ssh_clients, about three quarters of the database:

ssh_clients           786 MB   ~74% of the database, zero dead tuples
  rows                582,737
  avg row             1414 B
  hassh_algorithms    453 B avg, 149 distinct values
after normalising    ~300 MB

Five of its six algorithm columns account for nearly all of it, and hassh_algorithms - the SSH client fingerprint's algorithm list - is the widest. The same few lists are stored hundreds of thousands of times. Normalising them into a lookup table would let the whole database sit in page cache instead of just the hot part. It needs a migration - not yet done.pg_stat_statements is now running on production, so when I come back to it the slow queries will be in a table rather than reconstructed from a snapshot.

S.D.G