Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
200 lines (176 loc) · 10.7 KB
/
Copy pathDockerfile
File metadata and controls
200 lines (176 loc) · 10.7 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
# Single build stage using pre-built deps image
# Contains: GHC, Node.js, bun, all npm/cabal deps pre-installed, chart-cli pre-built
ARG DEPS_IMAGE=ghcr.io/monoscope-tech/monoscope-deps:latest
ARG RUNTIME_IMAGE=debian:12-slim
FROM ${DEPS_IMAGE} AS dependencies
FROM dependencies AS builder
# Install system dependencies if not using deps image (no-ops if already installed)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates lsb-release gnupg \
libssl-dev librdkafka-dev libsnappy-dev libgrpc-dev \
libpq-dev libldap2-dev libsasl2-dev liblz4-dev libzstd-dev \
pkg-config git wget unzip \
|| true
# Install protoc if not present
RUN which protoc || ( \
PROTOC_VERSION=29.3 && \
ARCH=$(dpkg --print-architecture | sed 's/arm64/aarch_64/;s/amd64/x86_64/') && \
wget -q https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${ARCH}.zip && \
unzip -q protoc-${PROTOC_VERSION}-linux-${ARCH}.zip -d /usr/local && \
rm protoc-${PROTOC_VERSION}-linux-${ARCH}.zip \
)
# Install postgresql-client if not present
RUN which psql || ( \
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg && \
echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && apt-get install -y postgresql-client-16 \
)
RUN rm -rf /var/lib/apt/lists/* || true
WORKDIR /build
# Copy cabal files for dependency caching. cabal.project lists shared/ and cli/
# as packages, so their .cabal files have to be here too — cabal refuses to
# resolve anything if a listed package location is missing, and this stage runs
# before the source COPYs below.
COPY *.cabal cabal.project* Setup.hs LICENSE README.md auto-instrument-config.toml ./
# ...and the sources of the other two local packages. `--only-dependencies`
# below skips the target's own package but still *builds* local packages it
# depends on, so cabal files alone are not enough — it needs shared/src and
# cli/ to compile. Both are small and change rarely, and the dist-newstyle /
# cabal-store cache mounts mean invalidating this layer is cheap.
COPY shared ./shared
COPY cli ./cli
# Production profiling (Haskell counterpart of timefusion's --features profiling):
# prof-way build with LATE cost centres on monoscope code only — deps are built
# prof-way but with no cost centres, so runtime overhead (~2-5%) stays on our
# code. Enables +RTS -p CPU samples and -hc heap censuses (see GHCRTS below).
RUN printf 'package *\n profiling: True\n profiling-detail: none\npackage monoscope\n profiling-detail: late\n' >> cabal.project.local
# GHC outputs are mutable. Use a fresh namespace to avoid legacy shared mounts,
# and hold its lock through compilation and copying the executable.
# Build Haskell dependencies (fast - already cached in deps image)
RUN --mount=type=cache,from=dependencies,source=/root/.cabal/store,target=/root/.cabal/store \
--mount=type=cache,id=monoscope-production-dist-locked-v1,target=/build/dist-newstyle,sharing=locked \
cabal update && cabal build --only-dependencies exe:monoscope-server -j --semaphore
# The dependency image is a cache, not the authority for npm versions. Install
# from this checkout's lockfiles in a separately cached layer so new frontend
# dependencies are available without waiting for that image to be rebuilt.
COPY package.json package-lock.json ./
COPY web-components/package.json web-components/package-lock.json ./web-components/
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit && \
npm ci --prefix web-components --prefer-online --no-audit
# Copy source code
COPY package.yaml ./
COPY src ./src
COPY test ./test
COPY app ./app
COPY proto ./proto
# Build frontend assets and the PNG renderer from this checkout. The renderer
# in the dependency image is only a cache and may predate chart source changes.
# Pkg.DeriveUtils reads Vite's manifest through Template Haskell. Its object
# can survive in the persistent dist-newstyle cache even though Vite emitted a
# new entry hash, producing HTML that requests a chunk absent from the image.
# Generate a Haskell module whose source content includes the entry filename.
# GHC tracks ordinary imported modules reliably across the persistent cache.
COPY config ./config
COPY static ./static
COPY web-components ./web-components
RUN npx tailwindcss -i ./static/public/assets/css/tailwind.css -o ./static/public/assets/css/tailwind.min.css --minify && \
cd web-components && bun build --compile src/chart-cli.ts --outfile /usr/local/bin/chart-cli && \
NODE_ENV=production npx vite build --mode production --sourcemap false && \
cd .. && \
entry="$(node -p "require('./static/public/assets/web-components/dist/manifest.json')['index.html'].file")" && \
printf 'module Pkg.AssetManifestFingerprint (assetManifestFingerprint) where\n\nimport Relude (String)\n\nassetManifestFingerprint :: String\nassetManifestFingerprint = "%s"\n' "$entry" \
> src/Pkg/AssetManifestFingerprint.hs
# Build Haskell executable (dist-newstyle persisted via BuildKit cache mount).
# The binary is located with `cabal list-bin`, not `find … | head -1`: dist-newstyle is a
# persistent cache mount, so find's arbitrary ordering can pick a binary left by an earlier
# build — one compiled against a different static/ tree, which then serves pages referencing
# Vite chunks that aren't in the image (silent 404s for every web component).
# list-bin's stdout is the path, but source-repository-package sync chatter from the
# git SUBPROCESS lands on stdout too and -v0 cannot silence it (it broke the cp twice
# on 2026-09-08) — so keep only the line that is the binary path.
# BodyWrapper holds the viteAssetFile splice that embeds the entry path; its cached
# object can survive with a stale entry (2026-09-08 incident), so evict it and let the
# splice re-read the manifest this build just wrote. One module recompile is noise here.
RUN --mount=type=cache,from=dependencies,source=/root/.cabal/store,target=/root/.cabal/store \
--mount=type=cache,id=monoscope-production-dist-locked-v1,target=/build/dist-newstyle,sharing=locked \
(command -v hpack >/dev/null && hpack || echo "hpack not installed, using committed monoscope.cabal") && \
find /build/dist-newstyle -name 'BodyWrapper.*' -delete && \
cabal build exe:monoscope-server -j --semaphore --ghc-options="+RTS -A64m -n2m -RTS" && \
mkdir -p /build/dist && \
cp "$(cabal list-bin -v0 exe:monoscope-server | grep -a '/monoscope-server$' | tail -n1)" /build/dist/ && \
entry="$(node -p "require('./static/public/assets/web-components/dist/manifest.json')['index.html'].file")" && \
{ test -f "static/public/assets/web-components/dist/$entry" || \
{ echo "manifest entry $entry does not exist in dist" >&2; exit 1; }; } && \
{ grep -aFq "/public/assets/web-components/dist/$entry" /build/dist/monoscope-server || \
{ echo "server binary was compiled against a different Vite manifest entry" >&2; \
echo "manifest entry: $entry" >&2; \
echo "binary embeds:" >&2; \
grep -aoE '/public/assets/web-components/dist/[^"]{1,80}\.js' /build/dist/monoscope-server | sort -u >&2; \
exit 1; }; }
# Final runtime image
FROM ${RUNTIME_IMAGE}
# Install runtime dependencies
# Graphics libs needed for @napi-rs/canvas in chart-cli
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libgmp10 \
librdkafka1 \
libpq5 \
libsnappy1v5 \
liblz4-1 \
libzstd1 \
libsasl2-2 \
libldap-2.5-0 \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libjpeg62-turbo \
libgif7 \
fontconfig \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -U -s /bin/false monoscope
WORKDIR /opt/monoscope
# Declared after the apt layer: a per-commit value above it would rebuild that layer every time.
ARG GIT_HASH=dev
ARG GIT_COMMIT_DATE=dev
ENV GIT_HASH=$GIT_HASH
ENV GIT_COMMIT_DATE=$GIT_COMMIT_DATE
# --chown on COPY, not a later `chown -R`, which would duplicate every file into another layer.
RUN mkdir -p profiles && chown monoscope:monoscope . profiles
COPY --from=builder --chown=monoscope:monoscope --chmod=755 /build/dist/monoscope-server /usr/local/bin/chart-cli ./
COPY --from=builder --chown=monoscope:monoscope /build/static ./static
USER monoscope
EXPOSE 8080
# Production profiling (binary is built prof-way with late cost centres):
# heap census every 60s (-hc -i60) and CPU cost-centre samples (-p, duty-cycled
# 60s/15min by the cpu-profiler fiber in System.Server) stream to the eventlog,
# flushed every 30s so the samples leading up to an OOM kill survive. -l-a
# disables all other event classes to keep the file small.
# Starts DISABLED: enable after deployment with `docker kill -s USR2 <ctr>`
# (same signal toggles it back off, no restart). Mount ./profiles as a volume
# to keep artifacts across redeploys. Analyze off-host: eventlog2html (heap),
# hs-speedscope (CPU); a .prof is written on clean exit.
ENV GHCRTS="-p -poprofiles/monoscope -l-a --eventlog-flush-interval=30 -hc -i60"
# Liveness of the web listener itself (not just the process): a wedged warp
# accept-loop leaves the container "running" but refusing connections, so Swarm
# keeps routing 1/N of VIP traffic to a black-hole. Probe over bash /dev/tcp —
# the image has no curl/wget. ${PORT:-8080} matches prod (PORT=80) and the
# exposed default.
#
# /ping, NOT /status: this probe's failure action is "kill the container", so it
# must test liveness only. /status runs `select version()`, which makes every
# replica's liveness depend on one shared Postgres — a single DB hiccup fails all
# three probes at once and Swarm kills the whole service. /ping is a pure handler,
# so it still proves the accept loop and handler threads are alive (the thing this
# probe exists to catch) without coupling liveness to a dependency. Dependency
# health belongs in the InfraHealthCheck job, which alerts instead of killing.
HEALTHCHECK --interval=15s --timeout=5s --start-period=90s --retries=3 \
CMD ["bash","-c","exec 3<>/dev/tcp/127.0.0.1/${PORT:-8080}; printf 'GET /ping HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3; head -1 <&3 | grep -q '200 OK'"]
# Timestamped eventlog per boot: a fixed -ol path would be truncated by the
# restart right after an OOM kill, destroying exactly the samples we want.
# Prune to the newest 5 so a crash-loop can't fill the volume (cf. timefusion's
# prune_old). Command-line RTS opts take precedence over GHCRTS.
ENTRYPOINT ["bash","-c","ls -t profiles/monoscope-*.eventlog 2>/dev/null | tail -n +6 | xargs -r rm -f; exec ./monoscope-server +RTS -olprofiles/monoscope-$(date +%s).eventlog -RTS \"$@\"","--"]