Performance Tuning#
Settings for each layer of a production deployment: Gunicorn, Kvrocks, Valkey/Redis, PostgreSQL, and optionally PgBouncer.
Production Reference Architecture#
The topology of the CIRCL/GCVE production instances, a good reference for high traffic:
flowchart LR
Client(["Client<br/>HTTP/2 + TLS"])
HA["HAProxy<br/>TLS termination<br/>L4/L7 load balancing"]
V["Varnish<br/>HTTP reverse-proxy cache"]
NGX_D["Nginx<br/>open-data dumps"]
NGX_S["Nginx<br/>static-asset CDN"]
VL["Vulnerability-Lookup<br/>Gunicorn + Flask"]
PGB["PgBouncer<br/>connection pooling"]
PG[("PostgreSQL")]
KV[("Kvrocks<br/>primary storage")]
VAL[("Valkey")]
DUMPS[("Daily Kvrocks<br/>NDJSON dumps")]
STATIC[("Flask /static/<br/>CSS · JS · images")]
Client -->|HTTPS| HA
HA -->|HTTP/2| V
HA -->|"HTTP/2<br/>/dumps/"| NGX_D
HA -->|"HTTP/2<br/>/static"| NGX_S
V -->|HTTP/1.1| VL
NGX_D --> DUMPS
NGX_S --> STATIC
VL -->|SQL| PGB
PGB --> PG
VL -->|RESP| KV
KV -.->|replication| VAL
Compared to the Nginx-only setup, it adds HAProxy, Varnish and
two Nginx instances (dumps under /dumps/, static assets under /static).
HAProxy (TLS termination and load balancing)#
HAProxy is the only component exposed to clients. It:
terminates TLS and speaks HTTP/2 to clients and to Varnish;
balances across several Varnish or application instances, with health checks;
keeps long-lived connections to the backends and absorbs connection bursts;
can pass the client IP with the PROXY protocol (Gunicorn runs with
--proxy-protocol).
Example configuration#
Three backends: the cache (Varnish, the application as backup), the dumps, and the static assets.
frontend web
mode http
bind [::]:80 accept-proxy
bind [::]:443 ssl crt /etc/haproxy/certs/ strict-sni accept-proxy
# clean header
http-request del-header X-Forwarded-Proto
http-request del-header X-Forwarded-For
# redirect to https
http-request redirect scheme https code 308 unless { ssl_fc }
http-request add-header X-Forwarded-Proto https if { ssl_fc } # tell the backend we are using https
default_backend cache
acl vulnerability hdr(host) -i vulnerability.circl.lu
acl vulnerability-dump path_beg /dumps/
acl vulnerability-cdn path_beg /static/
use_backend vulnerability-cdn if vulnerability-cdn
use_backend vulnerability-dump if vulnerability-dump
use_backend cache if vulnerability
backend cache
option httpchk
http-check send meth GET uri /api/system/configInfo hdr Host vulnerability.circl.lu
option forwardfor
server varnish localhost:6081 check proto h2
server vulnerability [2001:db8::1]:10001 check backup
backend vulnerability-dump
option httpchk
http-check send meth GET uri / hdr Host vulnerability.circl.lu
http-request set-path "%[path,regsub(^/dumps/,/)]"
option forwardfor
server vulnerability [2001:db8::2]:80 check
backend vulnerability-cdn
option httpchk
http-check send meth GET uri / hdr Host vulnerability.circl.lu
option forwardfor
server vulnerability [2001:db8::3]:80 check
Notes:
The application server is a
backupof Varnish: if Varnish fails, traffic still flows, only slower.set-path "%[path,regsub(^/dumps/,/)]"strips/dumps/, so the files sit at the root of that Nginx.The health check uses
/api/system/configInfo.
Varnish (HTTP reverse-proxy cache)#
Varnish serves repeated GET requests from memory, without reaching Gunicorn, Kvrocks or PostgreSQL. Vulnerability records change rarely and are read often, so even short TTLs absorb most of the load and traffic spikes.
Cache stable reads:
/vuln/<id>,/api/vulnerability/<id>, static assets.Bypass the cache for POST/PUT/DELETE, authenticated requests and personal pages.
Prefer
Cache-Controlheaders set by the application over VCL rules.
Example configuration#
A minimal VCL, as on the CIRCL instance. It passes requests with x-api-key, strips
every cookie except the session cookie and the SSO flow cookie (without it, SSO sign-ins
fail as expired), gzips text, and caches for 15 minutes with a 2-hour grace.
backend default {
.host = "2001:db8::1";
.port = "10001";
.first_byte_timeout = 30s;
.between_bytes_timeout = 20s;
}
sub vcl_recv {
# don't cache authenticated api requests
if (req.http.x-api-key) {
return (pass);
}
# remove cookie to force cache for special bootstrap content
if (req.url ~ "^/bootstrap($|/.*)") {
unset req.http.cookie;
}
# remove unwanted cookie to cache more requests; keep the session cookie and
# the single sign-on flow cookie (docs/sso.md)
if (req.http.Cookie) {
set req.http.Cookie = ";" + req.http.Cookie;
set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
set req.http.Cookie = regsuball(req.http.Cookie, ";(vulnerability-lookup|__Host-vl_sso_flow|vl_sso_flow)=", "; \1=");
set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");
if (req.http.Cookie == "") {
unset req.http.Cookie;
}
}
}
sub vcl_backend_response {
set beresp.ttl = 15m;
set beresp.grace = 2h;
# Allow browser to cache requests longer
unset beresp.http.Cache-Control;
set beresp.http.cache-control = "max-age=900";
# compress text content
if (beresp.http.content-type ~ "(application/json|text/html|image/svg|text/css|text/javascript)") {
set beresp.do_gzip = true;
}
# increase cache time
if (!beresp.http.last-modified) {
set beresp.http.last-modified = now;
}
}
Nginx (static-file backends)#
HAProxy routes /dumps/ and /static to two Nginx instances, so file traffic never
reaches Varnish or Gunicorn.
/dumps/: the daily NDJSON exports written bybin/dump.py(see Command-Line Interface). Served from disk withsendfileandautoindex on. Thelocation /dumps/block is in Reverse Proxy (Nginx)./static: the application’s CSS, JavaScript and images, kept apart from dynamic requests.
Storage and database backends#
PgBouncer → PostgreSQL: users, comments, bundles, sightings, watchlists. See PgBouncer and PostgreSQL.
Kvrocks → Valkey: Kvrocks (TCP
10002, Redis protocol) replicates to Valkey for a hot standby and read capacity. See Kvrocks.The local cache (Valkey/Redis on a Unix socket) is not drawn. See Valkey/Redis.
Gunicorn (Web Server)#
Set in config/generic.json. bin/start_website.py starts Gunicorn, and bin/gunicorn_conf.py
reads the four website_* settings below. A setting set to null keeps Gunicorn’s own default.
website_workers: size against the web tier’s memory budget and measured load. Each gevent worker handles many clients; increasing workers also multiplies database pools and private memory. The sample’s 49 workers is not a sizing rule.website_worker_connections: maximum simultaneous clients per gevent worker, including idle keep-alive and SSE clients (default 100).website_max_requests: recycle a worker after 1,000 requests by default; set to 0 to disable.website_max_requests_jitteradds up to 100 random requests to stagger restarts. Recycling contains accumulated memory growth, but cannot protect against a single oversized request.website_graceful_timeout: allow active requests 30 seconds to drain when a worker exits, including recycling, and when the website is stopped or restarted:stopandrestart_websitewait for it before forcing the stop. Long exports and SSE connections can be interrupted; streaming clients must reconnect. Increase this for your workload.Worker silence timeout: 300 seconds. With gevent, this is not a per-request deadline while the worker’s event loop continues sending heartbeats.
The shared bin/gunicorn_conf.py reads these settings for the website launcher
and the separate website Docker image. Existing instance configurations fall back
to sample values for missing keys. Gunicorn CLI options override them. Restart the
website after changing the settings.
When sizing the web tier, reserve memory for Kvrocks, Redis and PostgreSQL if they share the container. Compare container memory and worker PSS/private memory rather than summing RSS, which counts preloaded shared pages in each worker. Load-test large searches and streams as well as typical requests, and monitor latency and errors when reducing concurrency.
Search score lookups are processed in batches of at most 1,000 commands, without a transaction. This bounds pipeline queues, not complete searches: candidate lists and sorting still grow with the number of matches, even for a small response page.
Flags passed to Gunicorn:
--worker-class gevent # async worker for concurrent I/O
--timeout 300 # worker silence timeout
--reuse-port # SO_REUSEPORT for load balancing across workers
--proxy-protocol # preserve client IPs behind a reverse proxy
--preload # load the application once, before forking
--forwarded-allow-ips=* # trust X-Forwarded-* from any proxy
Kvrocks (Primary Storage)#
Put your tuning in
storage/kvrocks.local.conf (copy storage/kvrocks.conf first). It is gitignored and
used instead of kvrocks.conf when present. Do not edit kvrocks.conf itself:
git pull overwrites it.
workers 8 # I/O threads: about the number of CPU cores
maxclients 10000 # keep below the OS file descriptor limit (ulimit -n)
tcp-backlog 511 # keep net.core.somaxconn >= this value
RocksDB:
rocksdb.block_cache_size 4096 # MB; 25-50% of RAM on a dedicated server
rocksdb.max_open_files 8096 # -1 keeps all files open
rocksdb.write_buffer_size 64 # MB; raise for bulk imports
rocksdb.max_write_buffer_number 4
rocksdb.max_background_jobs 4 # raise on many cores for faster compaction
rocksdb.target_file_size_base 128 # MB
rocksdb.max_total_wal_size 512 # MB
Disk I/O:
max-io-mb 0 # set a limit if compaction slows down requests
max-replication-mb 0
Valkey/Redis (Cache Layer)#
Configured in cache/cache.conf, on a Unix socket only:
port 0 # TCP disabled — Unix socket only
unixsocket cache.sock
unixsocketperm 700
tcp-keepalive 300
Only memory needs watching: redis-cli -s cache/cache.sock INFO memory. Set maxmemory
and an eviction policy (e.g. allkeys-lru) to bound it.
PostgreSQL#
# Connection / memory
max_connections = 200 # keep moderate when using PgBouncer
shared_buffers = 64GB # ~25% of RAM
work_mem = 64MB # per query memory; adjust if heavy queries
maintenance_work_mem = 4GB
# WAL / checkpoints
wal_buffers = 16MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB
# Autovacuum
autovacuum = on
autovacuum_max_workers = 10
autovacuum_naptime = 10s
autovacuum_vacuum_cost_limit = 2000
# Logging
log_connections = on
log_disconnections = on
log_min_duration_statement = 2000 # log slow queries > 2s
PgBouncer (Optional)#
Recommended with many Gunicorn workers: it pools connections to PostgreSQL.
[databases]
vulnlookup = host=127.0.0.1 port=5432 dbname=vulnlookup
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
admin_users = vuln-lookup
pool_mode = transaction # best for web apps
default_pool_size = 200 # number of server connections per DB
min_pool_size = 50
reserve_pool_size = 50
reserve_pool_timeout = 5.0 # seconds to wait for reserve
max_client_conn = 5000
# Logging / stats
logfile = /var/log/pgbouncer/pgbouncer.log
log_connections = 1
log_disconnections = 1
Point
DB_CONFIG_DICTinconfig/website.pyto PgBouncer (6432), not PostgreSQL (5432).Here
default_pool_sizeequalsmax_connections(200). Keep a few connections free for admin access.
SQLAlchemy Connection Pool#
SQLALCHEMY_ENGINE_OPTIONS in config/website.py.
Without PgBouncer (default):
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 100, # persistent connections in the pool
"max_overflow": 50, # extra connections during traffic spikes
"pool_timeout": 30, # seconds to wait for a connection
"pool_recycle": 1800, # recycle connections every 30 minutes
"pool_pre_ping": True, # verify connection liveness before use
}
Warning
Each worker has its own pool: up to pool_size × website_workers connections
(100 × 49 = 4,900). Without PgBouncer, keep that well below max_connections.
With PgBouncer (let PgBouncer manage the pool):
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 0, # no persistent connections; rely on PgBouncer
"max_overflow": 0,
"pool_pre_ping": True,
"pool_timeout": 30,
"pool_recycle": 3600,
}
Logging#
Lower log levels in production:
config/generic.json: setlogleveltoWARNINGorERRORconfig/website.py: setLOG_LEVELtoWARNINGstorage/kvrocks.local.conf:log-level warning(default)config/modules.cfg:level = WARNINGper feeder
config/logging.json rotates files (1 MB, 5 backups).
Operating System Tuning#
File Descriptors#
# /etc/security/limits.conf (or systemd unit override)
* soft nofile 65536
* hard nofile 65536
Network Stack#
sysctl -w net.core.somaxconn=4096
sysctl -w net.ipv4.tcp_max_syn_backlog=4096
sysctl -w net.core.netdev_max_backlog=4096
sysctl -w vm.overcommit_memory=1 # recommended for Redis/Kvrocks
Transparent Huge Pages (THP)#
Disable it for Kvrocks and Valkey (latency spikes):
echo never > /sys/kernel/mm/transparent_hugepage/enabled
Reverse Proxy (Nginx)#
A minimal configuration:
upstream vulnlookup {
server 127.0.0.1:10001;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name vulnerability.example.org;
client_max_body_size 10m;
location / {
proxy_pass http://vulnlookup;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10;
proxy_read_timeout 300; # match Gunicorn timeout
proxy_send_timeout 300;
}
# Open-data NDJSON dumps produced daily by ``bin/dump.py``.
# Served as static files; never proxied to Gunicorn.
location /dumps/ {
alias /path/to/vulnerability-lookup/dumps/; # $VULNERABILITY_LOOKUP_HOME/dumps/
autoindex on; # public directory listing
autoindex_exact_size off;
autoindex_localtime on;
# Serve .ndjson with a useful content type
types {
application/x-ndjson ndjson;
}
default_type application/octet-stream;
sendfile on; # zero-copy for large dumps
tcp_nopush on;
gzip_static on; # serve pre-compressed .ndjson.gz if present
# Dumps are regenerated daily; allow downstream caches to keep them briefly.
expires 1h;
add_header Cache-Control "public";
# Only GET/HEAD make sense for a static dump tree.
limit_except GET HEAD { deny all; }
}
}
Gunicorn runs with
--proxy-protocol. Enable the PROXY protocol on your proxy, or rely onX-Forwarded-Forand remove the flag frombin/start_website.py.The
aliasmust point to thedumps/directory of the Vulnerability-Lookup home, readable by Nginx.
Summary#
Layer |
Key Settings |
Tune When |
|---|---|---|
Gunicorn |
|
High concurrent users, slow responses |
Kvrocks ( |
|
Large dataset, slow queries, high write load |
Valkey/Redis |
|
Cache memory growing unbounded |
PostgreSQL |
|
Slow user/account queries, high concurrency |
PgBouncer |
|
Many workers exhausting PostgreSQL connections |
SQLAlchemy |
|
Connection errors, stale connections |
OS |
|
Connection refused errors, latency spikes |