Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
255 lines (248 loc) · 11.3 KB
/
Copy pathdocker-compose.yml
File metadata and controls
255 lines (248 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# Fixed, not derived from the checkout's folder name, so images, networks and
# volumes are called orthonym-web-* wherever the repository is cloned.
name: orthonym-web
# Docker's default json-file driver keeps logs forever. The workers log a
# line per molecule at INFO, so an unattended public deployment fills the
# host disk in weeks and takes the whole box down with it -- including the
# containers that were healthy. 30 MB per service is the cap.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
redis:
image: redis:7-alpine
container_name: orthonym-redis
# volatile-lru, NOT allkeys-lru: Celery broker messages carry no TTL, so
# allkeys-lru would evict queued work and silently lose jobs. Only the
# TTL'd keys (results, cache, counters) are eligible for eviction.
command: >
redis-server
--maxmemory ${REDIS_MAXMEMORY:-2gb}
--maxmemory-policy volatile-lru
ports:
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Must stay ABOVE REDIS_MAXMEMORY, never equal to it: maxmemory bounds
# the dataset, while the process also holds client buffers, replication
# backlog and fragmentation on top. A limit at the maxmemory figure gets
# redis OOM-killed by the kernel instead of letting its own volatile-lru
# eviction run, which is the whole point of the policy above.
mem_limit: ${REDIS_MEM_LIMIT:-3g}
logging: *default-logging
networks:
- orthonym-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: orthonym-backend
ports:
# 127.0.0.1, NOT 0.0.0.0. This binding and TRUST_PROXY_HEADERS below
# are a PAIR and neither is safe alone -- an adversarial review of the
# rate limiter measured both halves:
# * published on 0.0.0.0 with TRUST_PROXY_HEADERS=true, an attacker
# talks to :8000 directly, bypasses nginx, and sets X-Real-IP to a
# fresh value per request -> unlimited per-IP buckets -> every cap
# in ratelimit.py is void.
# * with TRUST_PROXY_HEADERS=false, request.client.host is the nginx
# container's address for EVERY request, so the whole internet
# shares one bucket: 2 concurrent jobs and 60 requests/min for all
# visitors combined, and one script 429s the entire site.
# Loopback-only publishing makes nginx the only route in, which is what
# makes trusting its X-Real-IP correct (nginx.conf sets it from
# $remote_addr, overwriting any client-sent value).
- "127.0.0.1:8000:8000"
environment:
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- DEPLOYMENT_PROFILE=${DEPLOYMENT_PROFILE:-medium}
# true because nginx is the only way in (see the port binding above).
# Set it false ONLY if the backend is ever exposed directly, in which
# case per-IP limits stop working correctly -- read ratelimit.py first.
- TRUST_PROXY_HEADERS=${TRUST_PROXY_HEADERS:-true}
- PYTHONUNBUFFERED=1
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# Liveness of the WEB PROCESS only. /api/health answers 200 in both
# states -- "OK" with a worker's live JVM behind it, "DEGRADED" without
# -- and that is the right thing to check here: a backend with no worker
# is correctly refusing names, not broken, and marking it unhealthy would
# stop the frontend from ever starting and hide the honest DEGRADED
# message the operator needs to see. python, not curl: curl is not in the
# slim image and adding it for a health check is a package to patch
# forever.
# interval and retries are omitted: Docker's own defaults are 30s and 3,
# which is what this wants. start_period is not (its default is 0s).
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5)"]
timeout: 10s
start_period: 30s
mem_limit: ${BACKEND_MEM_LIMIT:-1500m}
logging: *default-logging
networks:
- orthonym-network
# Interactive requests: single molecules, explain, name->SMILES. A
# dedicated worker is the only thing that guarantees a long batch cannot
# occupy the last slot someone naming ethanol needs.
worker-fast:
build:
context: ./backend
dockerfile: Dockerfile
container_name: orthonym-worker-fast
command: >
celery -A app.celery_app worker
--loglevel=${CELERY_LOG_LEVEL:-info}
--concurrency=${CELERY_WORKERS_FAST:-2}
-Q fast -n worker-fast@%h
environment:
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- DEPLOYMENT_PROFILE=${DEPLOYMENT_PROFILE:-medium}
# Each forked child holds its own JVM. 512 MB x concurrency, so this
# is a memory dial, not a performance one.
- ORTHONYM_JVM_XMX=${ORTHONYM_JVM_XMX:-512m}
- PYTHONUNBUFFERED=1
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# What this proves and what it does NOT, because the difference matters:
# it proves this worker's main process is alive and still answering on
# the broker, which is the one failure `restart: unless-stopped` cannot
# see (a hung process never exits). It does NOT prove the forked children
# still hold a live JVM -- ping is answered by the parent.
#
# JVM liveness is deliberately left to the app, which already does it
# better: celery_app._start_status_heartbeat re-stamps each child in
# Redis only after _opsin_liveness_probe actually round-trips a name, and
# a child that stops passing drops out of any_worker_has_opsin() within
# one TTL, so /api/health goes DEGRADED and jvm_guard 503s every naming
# endpoint. That is a quarantine, and restarting the container on top of
# it would just restart-loop a worker the app has already handled.
#
# -d and not a bare ping: a bare ping is answered by ANY worker on the
# broker, so worker-batch alone would hold this container healthy after
# its own process died. The node name is worker-fast@%h with %h the
# container hostname, hence CMD-SHELL for the $$HOSTNAME expansion.
#
# start_period is 120s because a child boots a JVM, loads the OPSIN and
# centres jars and runs cdk_bridge.self_check() before it answers
# anything; a shorter window restart-loops a worker that was starting
# normally, and each restart pays the same cost again.
healthcheck:
test: ["CMD-SHELL", "celery -A app.celery_app inspect ping -d worker-fast@$$HOSTNAME --timeout 10"]
interval: 60s
timeout: 15s
retries: 3
start_period: 120s
# Sized from the JVM dial above, not guessed: each forked child holds its
# own JVM at ORTHONYM_JVM_XMX heap plus roughly as much again in
# non-heap (metaspace, code cache, the loaded jars), and the parent
# carries python and RDKit. At the defaults -- 512m x 2 children -- that
# is about 2.3 GB, and 3g leaves the headroom. RAISE THIS whenever
# CELERY_WORKERS_FAST or ORTHONYM_JVM_XMX goes up, or the kernel kills
# the worker mid-job instead of the job failing honestly.
mem_limit: ${WORKER_MEM_LIMIT:-3g}
logging: *default-logging
networks:
- orthonym-network
worker-batch:
build:
context: ./backend
dockerfile: Dockerfile
container_name: orthonym-worker-batch
command: >
celery -A app.celery_app worker
--loglevel=${CELERY_LOG_LEVEL:-info}
--concurrency=${CELERY_WORKERS_BATCH:-2}
-Q batch -n worker-batch@%h
environment:
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- DEPLOYMENT_PROFILE=${DEPLOYMENT_PROFILE:-medium}
- ORTHONYM_JVM_XMX=${ORTHONYM_JVM_XMX:-512m}
- PYTHONUNBUFFERED=1
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# See worker-fast for why -d is not optional and why start_period is 120s.
healthcheck:
test: ["CMD-SHELL", "celery -A app.celery_app inspect ping -d worker-batch@$$HOSTNAME --timeout 10"]
interval: 60s
timeout: 15s
retries: 3
start_period: 120s
mem_limit: ${WORKER_MEM_LIMIT:-3g}
logging: *default-logging
networks:
- orthonym-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
# A BUILD arg, not an `environment:` entry, and the distinction is the
# whole point: vite bakes `import.meta.env.VITE_*` into the bundle at
# build time, so a runtime variable is ignored silently. The ARG in
# frontend/Dockerfile carries the full reasoning.
#
# It also sets where "Report SMILES on GitHub" opens issues; `none`
# removes that link too (frontend/src/lib/github.js, .env.example).
#
# Unset now ships THIS SITE's own repository -- lib/github.js defaults
# to github.com/Steinbeck-Lab/Orthonym-Web, which is public. It does NOT link
# the naming engine: the Orthonym engine's repository is still private,
# and the prose in Terms.jsx and About.jsx that says so is still true.
#
# Set VITE_GITHUB_URL to point a fork at its own source, or to the
# literal `none` to ship no link at all. An empty value is NOT "off":
# an unset build arg reaches vite as an empty string, so empty has to
# mean "use the default" or an unset variable would silently remove the
# link. frontend/Dockerfile carries the default.
args:
VITE_GITHUB_URL: ${VITE_GITHUB_URL:-}
# Matomo page counting, OFF unless both are set (frontend/Dockerfile).
VITE_MATOMO_URL: ${VITE_MATOMO_URL:-}
VITE_MATOMO_SITE_ID: ${VITE_MATOMO_SITE_ID:-}
container_name: orthonym-frontend
ports:
# LOOPBACK BY DEFAULT, and still plain HTTP: this speaks no TLS, so a
# terminator holding the real certificate belongs in front of it on any
# public host. Published on 0.0.0.0 it would serve the whole site over
# unencrypted HTTP on :8080 alongside whatever the terminator serves on
# 443 -- the same mistake the backend's own binding avoids two services
# up, for the same reason.
#
# frontend/nginx.conf's set_real_ip_from block is the other half of the
# arrangement: without it every visitor collapses into ONE rate-limit
# bucket the moment a terminator is added. It trusts RFC1918 and
# loopback peers, which is what a co-located Caddy/nginx reaches it as.
#
# Override FRONTEND_BIND=0.0.0.0 only when nothing sits in front and
# you accept plain HTTP -- an internal network behind a VPN, say.
- "${FRONTEND_BIND:-127.0.0.1}:8080:80"
depends_on:
backend:
# service_healthy, not a bare list entry: a bare `- backend` waits
# only for the container to be created, so nginx could start, fail to
# resolve `backend` in its proxy_pass, and cache that failure.
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
timeout: 5s
start_period: 10s
mem_limit: ${FRONTEND_MEM_LIMIT:-256m}
logging: *default-logging
networks:
- orthonym-network
networks:
orthonym-network:
name: orthonym-network
driver: bridge