Files
posthog/Dockerfile.llm-analytics

153 lines
6.7 KiB
Docker

# syntax=docker/dockerfile:1
#
# Docker image for the LLM Analytics Temporal worker.
#
# Includes ML dependencies (transformers, ONNX Runtime, torch) that are too
# large (~2GB) for the shared PostHog image. These deps live in the `sentiment`
# dependency group in pyproject.toml and are installed via `uv sync --group sentiment`.
#
# Same as pyproject.toml so that uv can pick it up and doesn't need to download a different Python version.
FROM ghcr.io/astral-sh/uv:0.13.4 AS uv
FROM python:3.14.7-slim-bookworm@sha256:9ab8d9c8514b44f90cf0029dd42fdd7e9e211e639c8b995304cc04568dee900f
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
# Copy uv
COPY --from=uv /uv /uvx /bin/
# Set working directory
WORKDIR /code
# uv settings for Docker builds
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PROJECT_ENVIRONMENT=/python-runtime
# Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# Upgrades the CA bundle that the pinned base image freezes.
"ca-certificates" \
"build-essential" \
"git" \
"libpq-dev" \
"libxmlsec1=1.2.37-2" \
"libxmlsec1-dev=1.2.37-2" \
"libffi-dev" \
"zlib1g-dev" \
"pkg-config" \
&& \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies including sentiment ML deps
RUN --mount=type=cache,id=uv-libxmlsec1.2.37-2,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=tools/hogli,target=tools/hogli \
# uv sync validates workspace membership even with --no-dev, so every
# workspace member must be present in the build context. packages/owners-yaml is also a real install
# source here: owners-yaml is a runtime dependency (stamphog's digest reads owners.yaml
# through it, and posthog/urls.py imports that chain unconditionally), and --no-editable copies
# it into the venv so the image never depends on this bind mount's path surviving.
--mount=type=bind,source=packages/owners-yaml,target=packages/owners-yaml \
--mount=type=bind,source=packages/personhog-proto,target=packages/personhog-proto \
uv sync --locked --no-dev --no-editable --no-install-project --group sentiment --no-binary-package lxml --no-binary-package xmlsec
ENV PATH=/python-runtime/bin:$PATH \
PYTHONPATH=/python-runtime \
POSTHOG_SENTIMENT_MODEL_CACHE=/opt/posthog-sentiment-model \
TIKTOKEN_CACHE_DIR=/code/.tiktoken_cache
# owners-yaml is installed from a bind mount that does not survive into the image, so a missing
# --no-editable above leaves an editable install pointing at /code/packages/owners-yaml and every Django
# entrypoint dies on `import owners_yaml` at runtime instead of here. The root Dockerfile asserts
# the same invariant with `test -d packages/owners-yaml/owners_yaml`; this image never copies that source
# tree, so assert importability instead of presence. personhog-proto is installed from a bind mount
# the same way. Django loads posthog.models at startup, which imports posthog.personhog_client and
# through it the generated personhog package, so a stale or editable install breaks every Django
# entrypoint.
RUN python -c "import owners_yaml, personhog.service.v1.service_pb2_grpc"
# Pre-warm the tiktoken BPE encoding cache into the image so runtime token counting reads the
# blobs from disk instead of fetching them from OpenAI's blob host on first use, which fails under
# restricted egress or flaky DNS. Covers the two encodings our callers resolve to: o200k_base
# (gpt-4o) and cl100k_base (text-embedding-3-small).
RUN mkdir -p "$TIKTOKEN_CACHE_DIR" && \
python -c "import tiktoken; [tiktoken.get_encoding(e) for e in ('o200k_base', 'cl100k_base')]"
# Pre-bake the sentiment ONNX model so workers don't download from HuggingFace on startup.
# Downloads from a pinned HuggingFace revision, exports to ONNX, verifies file integrity
# via SHA256 checksums, and runs a smoke test to confirm correct inference output.
#
# To update the model:
# 1. Update MODEL_REVISION to the new commit SHA from HuggingFace
# 2. Run the export locally to compute new SHA256 hashes for config.json and tokenizer.json
# 3. Update EXPECTED_HASHES below
# 4. Rebuild the image
RUN <<'BAKE_MODEL'
python -c "
import hashlib, os, sys, torch
from transformers import AutoTokenizer, pipeline
from optimum.onnxruntime import ORTModelForSequenceClassification
MODEL_NAME = 'cardiffnlp/twitter-roberta-base-sentiment-latest'
MODEL_REVISION = '3216a57f2a0d9c45a2e6c20157c20c49fb4bf9c7'
EXPECTED_HASHES = {
'config.json': '6f9c837680f529dd63871255651ca177bc259fd7c7f64cd5a4d6fcf0c36b8863',
'tokenizer.json': '727009a8214ddfa5af1deedf1006d4d06e8e51e54aa5f03566263d4e19bfcdce',
}
cache_dir = os.environ['POSTHOG_SENTIMENT_MODEL_CACHE']
os.makedirs(cache_dir, exist_ok=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, revision=MODEL_REVISION)
# PyTorch 2.9+ defaults torch.onnx.export to dynamo=True which breaks optimum
original_export = torch.onnx.export
def export_with_dynamo_disabled(*args, **kwargs):
kwargs.setdefault('dynamo', False)
return original_export(*args, **kwargs)
torch.onnx.export = export_with_dynamo_disabled
model = ORTModelForSequenceClassification.from_pretrained(MODEL_NAME, revision=MODEL_REVISION, export=True)
torch.onnx.export = original_export
model.save_pretrained(cache_dir)
tokenizer.save_pretrained(cache_dir)
for filename, expected_sha in EXPECTED_HASHES.items():
filepath = os.path.join(cache_dir, filename)
actual_sha = hashlib.sha256(open(filepath, 'rb').read()).hexdigest()
if actual_sha != expected_sha:
print(f'HASH MISMATCH for {filename}:', file=sys.stderr)
print(f' expected: {expected_sha}', file=sys.stderr)
print(f' actual: {actual_sha}', file=sys.stderr)
sys.exit(1)
print(f' verified {filename}: {actual_sha}')
pipe = pipeline('sentiment-analysis', model=model, tokenizer=tokenizer, top_k=None, truncation=True, max_length=512)
result = pipe('I love this product')[0]
labels = {r['label']: r['score'] for r in result}
assert labels.get('positive', 0) > 0.5, f'Smoke test failed: expected positive > 0.5, got {labels}'
print(f' smoke test passed: positive={labels[\"positive\"]:.4f}')
print(f'Model baked into {cache_dir}')
"
BAKE_MODEL
# Copy project files. Must stay below the tiktoken pre-warm and the model bake, which read
# nothing from the repo; moving it above them invalidates both layers on every source-only commit.
COPY manage.py manage.py
COPY common/esbuilder common/esbuilder
COPY common/hogvm common/hogvm/
COPY common/migration_utils common/migration_utils/
COPY posthog posthog/
COPY products/ products/
COPY ee ee/
COPY bin/temporal-django-worker bin/temporal-django-worker
RUN chmod +x bin/temporal-django-worker
CMD ["bin/temporal-django-worker"]