feat(devenv): MCP dev hot-reload with image-based approach (#145)

Add mcp-build.sh that compiles the MCP binary and packages it as an
OCI image layer on top of the base rootfs, imported directly into
containerd. Air triggers rebuild on code changes, cleaning stale
containers automatically.

Consolidate dev-only files (Dockerfiles, entrypoint, config, build
script) into devenv/ to separate dev tooling from production artifacts.
Skip image pull when already imported to speed up dev startup.
This commit is contained in:
BBQ
2026-03-02 14:59:48 +08:00
committed by GitHub
parent f9f968f13f
commit 04bce702b7
12 changed files with 131 additions and 26 deletions
+86
View File
@@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1
# ---- Stage 1: Assemble MCP image rootfs (runtime deps only, no Go binary) ----
FROM alpine:latest AS mcp-rootfs
RUN apk add --no-cache grep curl bash
RUN apk add --no-cache nodejs npm
RUN apk add --no-cache python3 && \
curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -sf /root/.local/bin/uv /usr/local/bin/uv && \
ln -sf /root/.local/bin/uvx /usr/local/bin/uvx
COPY cmd/mcp/template /opt/mcp-template
RUN printf '#!/bin/sh\n\
[ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }\n\
if [ -x /app/mcp ]; then exec /app/mcp "$@"; fi\n\
exec /opt/mcp "$@"\n' > /opt/entrypoint.sh && chmod +x /opt/entrypoint.sh
RUN tar -cf /tmp/rootfs.tar \
--exclude='./proc' --exclude='./sys' --exclude='./dev' \
--exclude='./tmp' --exclude='./run' \
-C / .
# ---- Stage 2: Dev server image ----
FROM golang:1.25-alpine
WORKDIR /workspace
RUN go install github.com/air-verse/air@latest
RUN apk add --no-cache \
bash \
ca-certificates \
cni-plugins \
containerd \
containerd-ctr \
git \
iptables \
make \
tzdata \
wget \
&& mkdir -p /opt/cni/bin \
&& (cp -a /usr/lib/cni/. /opt/cni/bin/ 2>/dev/null || true) \
&& (cp -a /usr/libexec/cni/. /opt/cni/bin/ 2>/dev/null || true) \
&& mkdir -p /etc/cni/net.d /var/lib/cni /run/containerd /var/lib/containerd /opt/memoh/data
RUN printf '%s\n' \
'{' \
' "cniVersion": "1.0.0",' \
' "name": "memoh-cni",' \
' "plugins": [' \
' {' \
' "type": "bridge",' \
' "bridge": "cni0",' \
' "isGateway": true,' \
' "ipMasq": true,' \
' "hairpinMode": true,' \
' "ipam": {' \
' "type": "host-local",' \
' "ranges": [[' \
' { "subnet": "10.88.0.0/16" }' \
' ]],' \
' "routes": [' \
' { "dst": "0.0.0.0/0" }' \
' ]' \
' }' \
' },' \
' {' \
' "type": "portmap",' \
' "capabilities": { "portMappings": true }' \
' }' \
' ]' \
'}' > /etc/cni/net.d/10-memoh.conflist
# Raw MCP rootfs for mcp-build.sh to package with compiled binary
COPY --from=mcp-rootfs /tmp/rootfs.tar /opt/images/memoh-mcp-rootfs.tar
COPY devenv/server-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
VOLUME ["/var/lib/containerd", "/opt/memoh/data"]
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
+3
View File
@@ -0,0 +1,3 @@
FROM node:25-alpine
RUN npm install -g pnpm@10
WORKDIR /workspace
+53
View File
@@ -0,0 +1,53 @@
# Memoh development configuration
# Connects to devenv/docker-compose.yml infrastructure
[log]
level = "debug"
format = "text"
[server]
addr = ":8080"
[admin]
username = "admin"
password = "admin123"
email = "dev@memoh.local"
[auth]
jwt_secret = "memoh-dev-secret-do-not-use-in-production"
jwt_expires_in = "168h"
[containerd]
socket_path = "/run/containerd/containerd.sock"
namespace = "default"
[mcp]
# registry = "memoh.cn" # Uncomment for China mainland mirror
image = "memohai/mcp:latest"
snapshotter = "overlayfs"
data_root = "/opt/memoh/data"
cni_bin_dir = "/opt/cni/bin"
cni_conf_dir = "/etc/cni/net.d"
[postgres]
host = "postgres"
port = 5432
user = "memoh"
password = "memoh123"
database = "memoh"
sslmode = "disable"
[qdrant]
base_url = "http://qdrant:6334"
api_key = ""
collection = "memory"
timeout_seconds = 10
[agent_gateway]
host = "agent"
port = 8081
server_addr = "server:8080"
[web]
host = "0.0.0.0"
port = 8082
+6 -6
View File
@@ -37,7 +37,7 @@ services:
deps:
build:
context: ..
dockerfile: docker/Dockerfile.web.dev
dockerfile: devenv/Dockerfile.web
container_name: memoh-dev-deps
working_dir: /workspace
command: ["pnpm", "install"]
@@ -50,13 +50,13 @@ services:
migrate:
build:
context: ..
dockerfile: docker/Dockerfile.server.dev
dockerfile: devenv/Dockerfile.server
container_name: memoh-dev-migrate
working_dir: /workspace
entrypoint: []
command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"]
environment:
CONFIG_PATH: /workspace/conf/app.dev.toml
CONFIG_PATH: /workspace/devenv/app.dev.toml
volumes:
- ..:/workspace
- go_mod_cache:/go/pkg/mod
@@ -71,14 +71,14 @@ services:
server:
build:
context: ..
dockerfile: docker/Dockerfile.server.dev
dockerfile: devenv/Dockerfile.server
container_name: memoh-dev-server
working_dir: /workspace
privileged: true
pid: host
command: ["air", "-c", ".air.toml"]
environment:
CONFIG_PATH: /workspace/conf/app.dev.toml
CONFIG_PATH: /workspace/devenv/app.dev.toml
volumes:
- ..:/workspace
- go_mod_cache:/go/pkg/mod
@@ -121,7 +121,7 @@ services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web.dev
dockerfile: devenv/Dockerfile.web
container_name: memoh-dev-web
working_dir: /workspace/packages/web
command: ["pnpm", "dev"]
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
# Build MCP binary, package as containerd image, and import.
# Called by air after server build — safe to skip outside dev container.
set -e
MCP_IMAGE="${MCP_IMAGE:-docker.io/memohai/mcp:latest}"
MCP_BINARY="/opt/memoh/data/.dev/mcp"
BASE_ROOTFS="/opt/images/memoh-mcp-rootfs.tar"
[ -f "$BASE_ROOTFS" ] || exit 0
command -v ctr >/dev/null 2>&1 || exit 0
mkdir -p "$(dirname "$MCP_BINARY")"
OLD_HASH=$(sha256sum "$MCP_BINARY" 2>/dev/null | cut -d' ' -f1)
go build -o "$MCP_BINARY" ./cmd/mcp || exit 0
NEW_HASH=$(sha256sum "$MCP_BINARY" | cut -d' ' -f1)
[ "$OLD_HASH" = "$NEW_HASH" ] && exit 0
echo "[mcp-dev] Binary changed, rebuilding MCP image..."
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Layer 1: base rootfs (symlink to avoid copying the large file)
LAYER1_SHA=$(sha256sum "$BASE_ROOTFS" | cut -d' ' -f1)
mkdir -p "$WORK/$LAYER1_SHA"
ln -s "$BASE_ROOTFS" "$WORK/$LAYER1_SHA/layer.tar"
# Layer 2: compiled binary overlay
mkdir -p "$WORK/overlay/opt"
cp "$MCP_BINARY" "$WORK/overlay/opt/mcp"
chmod +x "$WORK/overlay/opt/mcp"
tar -cf "$WORK/layer2.tar" -C "$WORK/overlay" opt
LAYER2_SHA=$(sha256sum "$WORK/layer2.tar" | cut -d' ' -f1)
mkdir -p "$WORK/$LAYER2_SHA"
mv "$WORK/layer2.tar" "$WORK/$LAYER2_SHA/layer.tar"
# OCI image config
ARCH=$(uname -m)
case "$ARCH" in aarch64|arm64) ARCH="arm64" ;; x86_64|amd64) ARCH="amd64" ;; esac
printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s","sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp rootfs"},{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp binary"}]}' \
"$ARCH" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/config.json"
CONFIG_SHA=$(sha256sum "$WORK/config.json" | cut -d' ' -f1)
mv "$WORK/config.json" "$WORK/$CONFIG_SHA.json"
printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar","%s/layer.tar"]}]' \
"$CONFIG_SHA" "$MCP_IMAGE" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/manifest.json"
# -h follows symlinks (layer 1 is symlinked to avoid copying)
tar -chf "$WORK/memoh-mcp.tar" -C "$WORK" manifest.json "$CONFIG_SHA.json" "$LAYER1_SHA/" "$LAYER2_SHA/"
# Replace image in containerd
ctr -n default images rm "$MCP_IMAGE" 2>/dev/null || true
ctr -n default images import --all-platforms "$WORK/memoh-mcp.tar" 2>&1 || true
# Clean old MCP containers so they recreate with new image
for c in $(ctr -n default containers ls -q 2>/dev/null | grep "^mcp-"); do
ctr -n default tasks kill "$c" 2>/dev/null || true
ctr -n default tasks delete "$c" 2>/dev/null || true
ctr -n default containers delete "$c" 2>/dev/null || true
done
echo "[mcp-dev] Done. Containers will auto-recreate with new image."
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -e
# Setup cgroup v2 delegation for nested containerd.
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
mkdir -p /sys/fs/cgroup/init
while read -r pid; do
echo "$pid" > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true
done < /sys/fs/cgroup/cgroup.procs
sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \
> /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
fi
mkdir -p /run/containerd
containerd &
CONTAINERD_PID=$!
echo "Waiting for containerd..."
for i in $(seq 1 30); do
if ctr version >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! ctr version >/dev/null 2>&1; then
echo "ERROR: containerd not responsive after 30s"
exit 1
fi
echo "containerd is running (pid $CONTAINERD_PID)"
# Build MCP binary and import as containerd image
echo "Building MCP image..."
(cd /workspace && sh devenv/mcp-build.sh)
echo "MCP image ready."
echo "Starting server..."
trap 'kill ${SERVER_PID:-0} 2>/dev/null || true; kill ${CONTAINERD_PID:-0} 2>/dev/null || true; wait' TERM INT
"$@" &
SERVER_PID=$!
wait $SERVER_PID
EXIT_CODE=$?
kill $CONTAINERD_PID 2>/dev/null || true
wait $CONTAINERD_PID 2>/dev/null || true
exit $EXIT_CODE