This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new b10f3e08114 [feat](docker) run the 4.1 all-in-one image as multi-node 
and cloud compose clusters (#67875)
b10f3e08114 is described below

commit b10f3e08114803d1d724553320e2f356bb620910
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Sep 11 22:01:30 2026 +0800

    [feat](docker) run the 4.1 all-in-one image as multi-node and cloud compose 
clusters (#67875)
    
    ## Proposed changes
    
    #66983 added a single-container Doris for downstream CI. The next things
    one
    wants from the same box are a cluster with several FEs and BEs, and a
    compute-storage separated (cloud) cluster — for local feature work and
    demos,
    not for the regression pipelines. This PR makes the **same image** serve
    both,
    via two compose files under `docker/runtime/all-in-one/4.1/compose/`. No
    second image, no Python, no cloud credentials.
    
    ```shell
    cd docker/runtime/all-in-one/4.1/compose
    docker compose -f multi-node.yml up --wait            # 3 FE + 3 BE, ~35 s 
to healthy
    docker compose -f cloud.yml up --wait                 # fdb + ms + recycler 
+ minio + FE + 3 BE, ~50 s
    docker compose -f cloud.yml --profile ha up --wait    # ... with 3 FEs
    docker compose -f cloud.yml exec client mysql -uroot -hfe-1 -P9030
    ```
    
    ### One image, three ways to run it
    
    The entrypoint now dispatches on `DORIS_ROLE`: `all` (the single
    container,
    unchanged), `fe`, `be`, `ms`, `recycler`, `cloud-init` (one-shot,
    creates the
    instance) and `client` (waits for the expected nodes, then idles with
    `mysql`
    and `curl` inside the network). Each role is plain Doris under
    `--console`
    with the same start → probe → register → ready-flag → fail-fast shape as
    before, so `docker compose up --wait` blocks until the whole cluster is
    formed
    and a dead process still turns into a non-zero exit.
    
    Topology-dependent settings — `priority_networks`, replica count,
    balancing,
    the cloud keys — moved from the conf files baked into the image to the
    entrypoint, written per role at container start. The baked conf keeps
    only
    the size-related defaults.
    
    ### Cloud mode with nothing external
    
    `cloud.yml` is FoundationDB + meta-service + recycler + MinIO + one to
    three
    FEs + three BEs in two compute groups (`cg_a` × 2, `cg_b` × 1), so
    `use @cg_b`, `SHOW COMPUTE GROUPS`, storage vaults and FE failover in
    cloud
    mode all have something to run against.
    
    - Nodes are managed by SQL: the FE gets `cluster_id = <instance id>` and
      derives its `cloud_unique_id` itself; BEs are added with
      `ALTER SYSTEM ADD BACKEND ... ("tag.compute_group_name" = ...)`.
    - `cloud-init` creates the instance in storage-vault mode with MinIO as
    the
    vault — `use_path_style` in the `create_instance` payload is enough, no
    virtual-host aliasing — and the first FE marks `built_in_storage_vault`
    as
      default, so `CREATE TABLE` works as is. Both steps are idempotent.
    - The meta-service payload joins both tags. It is 664 MB as shipped, 456
    MB
    of which is an unstripped `libfdb_c.so`; `strip --strip-debug` brings
    the
    whole thing to 183 MB, so the tags grow from 2.46 / 2.99 GB to 2.68 /
    3.21 GB.
    
    ### Also in here
    
    - `FE_MASTER` may list several FEs (`fe-1,fe-2,fe-3`): the first one
    that
    answers is used, so a BE or follower restart does not wait on the one FE
      that is down. An FE that already has metadata rejoins on its own.
    - A local `./output` or an extracted release tarball is passed to the
    build
    as BuildKit named contexts. The previous `--tarball-dir` path could not
    work: `Dockerfile.dockerignore` only let `output/fe` and `output/be`
    into
      the context. This also means a tarball can build the image before the
      component images of a release reach Docker Hub.
    - Node probes use `SHOW FRONTENDS` / `SHOW BACKENDS` with header-based
    column
    lookup. The `frontends()` / `backends()` table functions look handier
    but
    are queries, and a query needs a live BE — which is what the FE probe
    runs
      before.
    
    ### Things a user should know (all in the README)
    
    - Nodes get fixed IPs on a private subnet so a restart keeps their
    identity.
    From the host, use the published ports; on Docker Desktop the container
    addresses are not routable, so a stream load from the host cannot follow
    FE's redirect to a BE — run it from the `client` service. The
    meta-service
      HTTP API is published on `15000`, since `5000` is AirPlay on macOS.
    - `foundationdb/foundationdb:7.1.x` upstream images are amd64 only; on
    Apple
      Silicon `fdb` runs under emulation, which is fine for this purpose.
    - A cloud cluster is ~9 GB; give Docker Desktop 12 GB or more.
    
    ## Testing
    
    All on 4.1.3 official component images, arm64 (Apple Silicon):
    
    - `resource/smoke-test.sh apache/doris:all-in-one-4.1.3 base` — the
    existing
      single-container test, unchanged and green.
    - `compose/smoke-test.sh multi-node` (1 min 40 s) and
    `compose/smoke-test.sh
      cloud` (2 min 7 s): bring the topology up under its own project name,
    subnet and host ports; assert 3 FE / 3 BE alive; create / insert / read;
      three replicas over three BEs, or two compute groups + default vault +
    objects in MinIO; stream load through the client; kill the master FE and
    write through the new one (elected in 3–9 s); restart a BE while the old
      master is down; bring the old master back; tear down.
    - `docker compose stop` / `up` on the cloud cluster keeps the data
    (instance
      creation, vault default and node registration are all idempotent).
    - `build.sh -s tarball --tarball-dir <dir>` from outside the repository,
      with fe/be/ms extracted from the component images.
    
    **Not verified here:** amd64 (same caveat as #66983 — the BE does not
    survive
    emulation on an Apple Silicon host).
    
    ## Notes
    
    - Publishing: the compose files default to
    `apache/doris:all-in-one-4.1.3`,
    which on Docker Hub still lacks the meta-service payload and the
    role-aware
    entrypoint. Once this is merged, the 4.1.3 tags need a rebuild and
    re-push
    (single-container behaviour is unchanged; the tags gain ~0.2 GB), or the
      default image moves to the next release's tag.
    - This is a demo / feature-work environment. The `docker()` suites under
      `regression-test/suites/cloud_p0` still need `doris-compose`.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    https://claude.ai/code/session_01CvUCjtaEi5tgy89ABkhgMh
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 docker/runtime/all-in-one/4.1/Dockerfile           |  70 ++--
 .../runtime/all-in-one/4.1/Dockerfile.dockerignore |   9 +-
 docker/runtime/all-in-one/4.1/README.md            | 138 ++++++-
 docker/runtime/all-in-one/4.1/build.sh             |  81 +++--
 docker/runtime/all-in-one/4.1/compose/cloud.yml    | 320 +++++++++++++++++
 .../runtime/all-in-one/4.1/compose/multi-node.yml  | 168 +++++++++
 .../runtime/all-in-one/4.1/compose/smoke-test.sh   | 154 ++++++++
 .../runtime/all-in-one/4.1/resource/cloud_init.sh  |  64 ++++
 .../all-in-one/4.1/resource/conf/be_ci.conf        |   1 -
 .../all-in-one/4.1/resource/conf/fe_ci.conf        |  16 +-
 .../runtime/all-in-one/4.1/resource/entrypoint.sh  | 396 ++++++++++++++-------
 .../all-in-one/4.1/resource/health_check.sh        |  37 +-
 docker/runtime/all-in-one/4.1/resource/lib.sh      | 148 ++++++++
 13 files changed, 1379 insertions(+), 223 deletions(-)

diff --git a/docker/runtime/all-in-one/4.1/Dockerfile 
b/docker/runtime/all-in-one/4.1/Dockerfile
index 31c3c829bf8..173d24e2e21 100644
--- a/docker/runtime/all-in-one/4.1/Dockerfile
+++ b/docker/runtime/all-in-one/4.1/Dockerfile
@@ -18,9 +18,10 @@
 # ============================================================================
 # Apache Doris all-in-one image for integration testing -- branch-4.1 only.
 #
-# A single container running one FE and one BE, sized for use as a test
-# fixture in downstream CI. Build it with ./build.sh; the commands below are
-# what that script ends up running.
+# One image, three ways to run it: a single container with FE and BE (the
+# default entrypoint role), the multi-node compose file, and the cloud
+# (compute-storage separated) compose file. See README.md. Build it with
+# ./build.sh; the commands below are what that script ends up running.
 #
 #   base flavor (default):
 #     docker buildx build --build-arg DORIS_VERSION=4.1.3 \
@@ -30,8 +31,10 @@
 #   full flavor (adds hudi / trino / maxcompute):
 #     ... --build-arg FLAVOR=full -t apache/doris:all-in-one-4.1.3-full .
 #
-# The build context is the repository root (so ARTIFACT_SOURCE=local can pick
-# up ./output); Dockerfile.dockerignore narrows it down to a few KB.
+# The build context is the repository root; Dockerfile.dockerignore narrows it
+# down to the resource directory. A local ./output or an extracted release
+# tarball comes in through named contexts (ARTIFACT_SOURCE=dir), which
+# build.sh sets up.
 # ============================================================================
 
 # ---- global args: must be declared before the first FROM ----
@@ -39,14 +42,16 @@ ARG DORIS_VERSION=4.1.3
 ARG ARTIFACT_SOURCE=image
 ARG FE_IMAGE=apache/doris:fe-${DORIS_VERSION}
 ARG BE_IMAGE=apache/doris:be-${DORIS_VERSION}
+ARG MS_IMAGE=apache/doris:ms-${DORIS_VERSION}
 ARG BASE_IMAGE=ubuntu:22.04
 
 # ============================ artifact sources ==============================
-# Three interchangeable ways to get fe/ and be/ into /artifacts. Everything
+# Two interchangeable ways to get fe/, be/ and ms/ into /artifacts. Everything
 # downstream only knows about /artifacts, never about where it came from.
 
 FROM ${FE_IMAGE} AS fe-src
 FROM ${BE_IMAGE} AS be-src
+FROM ${MS_IMAGE} AS ms-src
 
 # strip(1) lives here, so this is ubuntu rather than busybox.
 FROM ${BASE_IMAGE} AS artifacts-base
@@ -59,18 +64,16 @@ RUN apt-get update -y && \
 FROM artifacts-base AS artifacts-image
 COPY --from=fe-src /opt/apache-doris/fe /artifacts/fe
 COPY --from=be-src /opt/apache-doris/be /artifacts/be
+COPY --from=ms-src /opt/apache-doris/ms /artifacts/ms
 
-# B. a locally built ./output, for developers testing their own build
-FROM artifacts-base AS artifacts-local
-ARG LOCAL_OUTPUT=output
-COPY ${LOCAL_OUTPUT}/fe /artifacts/fe
-COPY ${LOCAL_OUTPUT}/be /artifacts/be
-
-# C. an extracted release tarball
-FROM artifacts-base AS artifacts-tarball
-ARG TARBALL_DIR
-COPY ${TARBALL_DIR}/fe /artifacts/fe
-COPY ${TARBALL_DIR}/be /artifacts/be
+# B. directories: a locally built ./output or an extracted release tarball.
+# Each part is a named build context (--build-context doris-fe=<dir>/fe ...),
+# which keeps them out of the main context and its dockerignore. A build
+# without a meta-service points doris-ms at an empty directory.
+FROM artifacts-base AS artifacts-dir
+COPY --from=doris-fe / /artifacts/fe
+COPY --from=doris-be / /artifacts/be
+COPY --from=doris-ms / /artifacts/ms
 
 # ========================= strip + flavor pruning ===========================
 # Both have to happen HERE, before the final COPY. Doing them in the runtime
@@ -79,28 +82,33 @@ COPY ${TARBALL_DIR}/be /artifacts/be
 FROM artifacts-${ARTIFACT_SOURCE} AS artifacts
 ARG CTX_PREFIX=docker/runtime/all-in-one/4.1
 ARG FLAVOR=base
-ARG STRIP_BE=debug
+ARG STRIP_MODE=debug
 
 COPY ${CTX_PREFIX}/resource/prune.txt /tmp/prune.txt
 
 RUN set -eux; \
     case "${FLAVOR}" in base|full) ;; *) echo "bad FLAVOR=${FLAVOR}" >&2; exit 
1 ;; esac; \
-    # 4.1.3 ships doris_be with debug info: 2213M -> 430M, and --strip-debug
+    # 4.1.3 ships doris_be with debug info: 2213M -> 430M, and the meta-service
+    # payload is 664M of which 456M is an unstripped libfdb_c.so. --strip-debug
     # keeps .symtab so crash backtraces still resolve function names.
-    case "${STRIP_BE}" in \
-      debug) strip --strip-debug /artifacts/be/lib/doris_be ;; \
-      full)  strip -s            /artifacts/be/lib/doris_be ;; \
+    bins="/artifacts/be/lib/doris_be"; \
+    if [ -f /artifacts/ms/lib/doris_cloud ]; then \
+        bins="${bins} /artifacts/ms/lib/doris_cloud $(find /artifacts/ms/lib 
-name 'libfdb_c.so')"; \
+    fi; \
+    case "${STRIP_MODE}" in \
+      debug) strip --strip-debug ${bins} ;; \
+      full)  strip -s            ${bins} ;; \
       none)  : ;; \
-      *) echo "bad STRIP_BE=${STRIP_BE}" >&2; exit 1 ;; \
+      *) echo "bad STRIP_MODE=${STRIP_MODE}" >&2; exit 1 ;; \
     esac; \
     # "always" rows apply to both flavors, "base" rows only to the base flavor.
     awk -v f="${FLAVOR}" '$1=="always" || $1==f {print $2}' /tmp/prune.txt \
       | while IFS= read -r p; do echo "prune: ${p}"; rm -rf "/artifacts/${p}"; 
done; \
     rm -f /tmp/prune.txt; \
-    rm -rf /artifacts/fe/log /artifacts/be/log; \
-    mkdir -p /artifacts/fe/log /artifacts/be/log \
+    rm -rf /artifacts/fe/log /artifacts/be/log /artifacts/ms/log; \
+    mkdir -p /artifacts/fe/log /artifacts/be/log /artifacts/ms \
              /artifacts/fe/doris-meta /artifacts/be/storage; \
-    echo "=== artifact size (${FLAVOR}) ==="; du -sh /artifacts/fe 
/artifacts/be
+    echo "=== artifact size (${FLAVOR}) ==="; du -sh /artifacts/fe 
/artifacts/be /artifacts/ms
 
 # ================================ runtime ===================================
 
@@ -111,7 +119,8 @@ ARG FLAVOR=base
 ARG CTX_PREFIX=docker/runtime/all-in-one/4.1
 ARG JDK_PKG=openjdk-17-jre-headless
 # FE ships -Xmx8192m, far more than a CI runner can spare; BE ships -Xmx2048m
-# for its JNI-side JVM. Both are rewritten in place below.
+# for its JNI-side JVM. Both are rewritten in place below, and again at
+# container start when FE_HEAP / BE_HEAP are set.
 ARG FE_HEAP=2048m
 ARG BE_HEAP=1024m
 
@@ -144,12 +153,14 @@ ENV JAVA_HOME=/usr/lib/jvm/java \
 
 COPY --from=artifacts /artifacts/fe ${DORIS_HOME}/fe
 COPY --from=artifacts /artifacts/be ${DORIS_HOME}/be
+COPY --from=artifacts /artifacts/ms ${DORIS_HOME}/ms
 COPY ${CTX_PREFIX}/resource/ ${CI_HOME}/
 
 RUN set -eux; \
     # Integration-test defaults are appended, never edited in: the upstream
     # values stay visible above them and the last assignment wins for both the
     # shell-sourced ALL_CAPS vars and the property-style lowercase keys.
+    # Topology keys are not in these files; entrypoint.sh adds them per role.
     cat "${CI_HOME}/conf/fe_ci.conf" >> "${DORIS_HOME}/fe/conf/fe.conf"; \
     cat "${CI_HOME}/conf/be_ci.conf" >> "${DORIS_HOME}/be/conf/be.conf"; \
     # The heap lives inside a long JAVA_OPTS_FOR_JDK_17 line that also carries
@@ -162,14 +173,15 @@ RUN set -eux; \
     chmod +x "${CI_HOME}"/*.sh
 
 LABEL org.opencontainers.image.title="Apache Doris all-in-one" \
-      org.opencontainers.image.description="Single-container Doris (FE+BE) for 
integration testing" \
+      org.opencontainers.image.description="Single-container Doris (FE+BE) for 
integration testing; also the node image of the multi-node and cloud compose 
files" \
       org.opencontainers.image.version="${DORIS_VERSION}" \
       org.opencontainers.image.source="https://github.com/apache/doris"; \
       org.apache.doris.allinone.flavor="${FLAVOR}" \
       org.apache.doris.allinone.branch="4.1"
 
 # 8030 FE http | 9030 FE mysql | 8040 BE http (stream load) | 9050 BE heartbeat
-EXPOSE 8030 9030 8040 9050
+# 5000 meta-service (DORIS_ROLE=ms / recycler)
+EXPOSE 8030 9030 8040 9050 5000
 
 WORKDIR ${DORIS_HOME}
 
diff --git a/docker/runtime/all-in-one/4.1/Dockerfile.dockerignore 
b/docker/runtime/all-in-one/4.1/Dockerfile.dockerignore
index 379b506fd84..8fb78e8c783 100644
--- a/docker/runtime/all-in-one/4.1/Dockerfile.dockerignore
+++ b/docker/runtime/all-in-one/4.1/Dockerfile.dockerignore
@@ -18,10 +18,9 @@
 # BuildKit reads this in preference to the repository root .dockerignore when
 # building with -f docker/runtime/all-in-one/4.1/Dockerfile.
 #
-# The context has to be the repository root so that ARTIFACT_SOURCE=local can
-# reach ./output, but nothing else in the tree is needed. Exclude everything,
-# then add back the two paths the build actually reads.
+# The context is the repository root, but the only thing the build reads from
+# it is the resource directory. Local builds and release tarballs arrive as
+# named build contexts (see build.sh), not through this context, so nothing
+# else needs to be let in.
 *
 !docker/runtime/all-in-one/4.1/resource
-!output/fe
-!output/be
diff --git a/docker/runtime/all-in-one/4.1/README.md 
b/docker/runtime/all-in-one/4.1/README.md
index 49d8d1d6888..b18e0fb9192 100644
--- a/docker/runtime/all-in-one/4.1/README.md
+++ b/docker/runtime/all-in-one/4.1/README.md
@@ -20,10 +20,15 @@ under the License.
 # Doris all-in-one image (branch-4.1)
 
 One FE and one BE in a single container, sized to be a test fixture in a
-downstream project's CI. It is assembled from the official `apache/doris:fe-*`
-and `apache/doris:be-*` images, so a new Doris release needs no repackaging of
+downstream project's CI. It is assembled from the official `apache/doris:fe-*`,
+`be-*` and `ms-*` images, so a new Doris release needs no repackaging of
 anything here.
 
+The same image also runs one Doris process per container, which is what the
+two compose files under `compose/` do: a three-FE / three-BE cluster, and a
+compute-storage separated (cloud) cluster with its meta-service, FoundationDB
+and MinIO. See [Multi-node and cloud clusters](#multi-node-and-cloud-clusters).
+
 This directory targets the **4.1 release line only**. Other lines differ enough
 in payload layout to deserve their own directory rather than a version switch.
 
@@ -31,14 +36,15 @@ in payload layout to deserve their own directory rather 
than a version switch.
 
 | tag | covers | size |
 |---|---|---|
-| `apache/doris:all-in-one-<version>` | internal tables, Hive, Iceberg 
(including system tables), Paimon, JDBC catalogs, external-table writeback, 
Java UDF | 2.46 GB |
-| `apache/doris:all-in-one-<version>-full` | the above plus Hudi, Trino 
connector, MaxCompute | 2.99 GB |
+| `apache/doris:all-in-one-<version>` | internal tables, Hive, Iceberg 
(including system tables), Paimon, JDBC catalogs, external-table writeback, 
Java UDF | 2.68 GB |
+| `apache/doris:all-in-one-<version>-full` | the above plus Hudi, Trino 
connector, MaxCompute | 3.21 GB |
 
 Pick `-full` only if the tests touch Hudi, the Trino connector or MaxCompute.
 
 Both come up `healthy` in under 20 seconds. Sizes are the uncompressed layer
-sum measured on 4.1.3/arm64, against 4.9 GB for the same payload untouched.
-`docker image inspect --format '{{.Size}}'` reports 1.61 / 2.06 GiB for them;
+sum measured on 4.1.3/arm64, against 5.6 GB for the same payload untouched;
+about 0.2 GB of each is the meta-service, which only the cloud compose file
+uses. `docker image inspect --format '{{.Size}}'` reports 1.73 / 2.18 GiB;
 `docker images` can print a much larger figure when the containerd image store
 is enabled, because it adds the compressed blobs to the unpacked snapshot.
 
@@ -54,12 +60,19 @@ A plain run builds for the host architecture only. 
Multi-arch needs an explicit
 # base tag only, then smoke test it
 ./build.sh -v 4.1.3 -f base -t
 
-# from a locally built ./output instead
+# from a locally built ./output instead (build.sh --fe --be --cloud; without
+# --cloud there is no ms/ and the image cannot serve the cloud compose file)
 ./build.sh -v dev -s local
+
+# from an extracted release tarball, e.g. while the component images for a
+# new release are not on Docker Hub yet
+./build.sh -v 4.1.4 -s tarball --tarball-dir ~/apache-doris-4.1.4-bin-arm64
 ```
 
 `./build.sh --help` lists the rest. The build context is the repository root,
-narrowed to a few KB by `Dockerfile.dockerignore`; run the script from 
anywhere.
+narrowed to a few KB by `Dockerfile.dockerignore`; a local `./output` or a
+tarball directory is passed as BuildKit named contexts instead, so it can live
+anywhere. Run the script from anywhere.
 
 ## Multi-architecture
 
@@ -208,6 +221,7 @@ deliberately omits them and says so.
 | | base | -full |
 |---|---|---|
 | `strip --strip-debug` on `doris_be` | 2213 MB → 430 MB (450 MB on arm64) | 
same |
+| `strip --strip-debug` on the meta-service (`doris_cloud` and its two 
`libfdb_c.so`) | 664 MB → 183 MB | same |
 | `be/lib/meta_tool`, `be/lib/cdc_client`, `fe/arthas` | removed | removed |
 | hudi / trino / maxcompute scanners | removed | kept |
 
@@ -235,17 +249,117 @@ It does **not** read a real external table. To check 
that end of things, point
 the built image at the fixtures under `docker/thirdparties` and run an Iceberg
 or Hive query by hand.
 
+## Multi-node and cloud clusters
+
+The image's entrypoint takes a `DORIS_ROLE`. The default, `all`, is the
+single container described above; the other roles run one process each and
+are what the compose files are built from:
+
+| `DORIS_ROLE` | runs | needs |
+|---|---|---|
+| `all` | FE + BE on loopback | nothing |
+| `fe` | one FE; bootstraps the cluster when `FE_MASTER` is empty, otherwise 
registers with it as `FE_ROLE` (`follower` \| `observer`) and joins | 
`FE_MASTER` |
+| `be` | one BE, registered with `FE_MASTER`; in cloud mode into 
`COMPUTE_GROUP` | `FE_MASTER` |
+| `ms`, `recycler` | the cloud meta-service / recycler | `FDB_CLUSTER` |
+| `cloud-init` | one-shot: creates the cloud instance on an S3-compatible 
store, then exits | `MS_ENDPOINT`, `INSTANCE_ID`, `S3_*` |
+| `client` | waits for `EXPECT_FE` / `EXPECT_BE` live nodes, then idles with 
`mysql` and `curl` | `FE_MASTER` |
+
+`DEPLOY_MODE=cloud` turns `fe` and `be` into cloud nodes (`deploy_mode`,
+`meta_service_endpoint`, file cache); the FE takes `INSTANCE_ID` as its
+`cluster_id` and manages nodes by SQL, so no `cloud_unique_id` has to be handed
+out. `FE_MASTER` may list several FEs (`fe-1,fe-2,fe-3`): the first one that
+answers is used, which keeps a node restart from waiting on the one FE that is
+down. An FE that already has metadata rejoins on its own. Every role honours
+`FE_CONFIG_EXTRA` / `BE_CONFIG_EXTRA` / `MS_CONFIG_EXTRA`, `FE_HEAP` / 
`BE_HEAP`
+and drops a ready flag that the image `HEALTHCHECK` keys on.
+
+Topology-dependent settings (`priority_networks`, replica count, balancing,
+cloud keys) are written by the entrypoint at container start; the conf files
+baked into the image only carry the size-related defaults.
+
+### `compose/multi-node.yml` — three FEs, three BEs
+
+```shell
+cd docker/runtime/all-in-one/4.1/compose
+docker compose -f multi-node.yml up --wait        # ~35 s to healthy
+docker compose -f multi-node.yml exec client mysql -uroot -hfe-1 -P9030
+docker compose -f multi-node.yml kill fe-1        # a new master in a few 
seconds
+docker compose -f multi-node.yml start fe-1       # rejoins as a follower
+docker compose -f multi-node.yml down             # nothing persists
+```
+
+Tables default to three replicas, one per BE. `fe-3` becomes an observer with
+`FE3_ROLE=observer`. Host ports: `9030`/`8030` for `fe-1`, `9031`/`8031` and
+`9032`/`8032` for the other two, `8040` for `be-1`.
+
+### `compose/cloud.yml` — compute-storage separated
+
+```shell
+docker compose -f cloud.yml up --wait             # ~50 s to healthy
+docker compose -f cloud.yml --profile ha up --wait   # ... plus two follower 
FEs
+docker compose -f cloud.yml exec client mysql -uroot -hfe-1 -P9030
+```
+
+```
+fdb ─ fdb-init ─┬─ ms ─ cloud-init ─ fe-1 ─┬─ be-1, be-2  (compute group cg_a)
+                └─ recycler                 ├─ be-3        (compute group cg_b)
+minio ─ minio-init ─┘                       └─ client
+```
+
+`cloud-init` creates the instance in storage-vault mode with MinIO as the
+vault (path-style, plain HTTP), and `fe-1` marks `built_in_storage_vault` as
+the default, so `CREATE TABLE` works as is. Both are idempotent, so
+`docker compose stop` / `up` keeps the data; `down` wipes everything. The
+MinIO console is on `9001` (`minioadmin` / `minioadmin`), the meta-service
+HTTP API on `15000` (`5000` is taken by AirPlay on macOS).
+
+What this is for: `use @cg_b`, `SHOW COMPUTE GROUPS`, `ALTER SYSTEM ADD
+BACKEND ... ("tag.compute_group_name" = ...)`, storage vaults, warm-up, FE
+failover in cloud mode, watching objects land in the bucket. What it is not:
+a stand-in for the cloud regression pipelines, and the `docker()` suites in
+`regression-test/suites/cloud_p0` still need `doris-compose`.
+
+### Things to know
+
+- **Addresses.** Nodes get fixed IPs on a private subnet (`SUBNET`, default
+  `172.31.80` for cloud and `172.31.81` for multi-node), so a restarted
+  container keeps the identity Doris knows it by. Both files can run at once
+  if one of them is given other host ports.
+- **From the host.** Use the published ports. On Docker Desktop the container
+  addresses are not routable from the host, so a stream load from the host
+  cannot follow FE's redirect to a BE; run it from the `client` service
+  instead, which is inside the network. Linux hosts can reach the nodes
+  directly.
+- **Memory.** Defaults are `FE_HEAP=1024m` and `mem_limit = 25%` per BE. A
+  full cloud cluster is around 9 GB; give Docker Desktop 12 GB or more, and do
+  not expect both topologies to fit side by side on a 16 GB VM.
+- **FoundationDB on arm64.** The upstream `foundationdb/foundationdb:7.1.x`
+  images are amd64 only, so on an Apple Silicon host `fdb` runs under
+  emulation. It is fine for this purpose; `FDB_IMAGE` / `FDB_PLATFORM` switch
+  it out. The meta-service links the 7.1 client, so stay on a 7.1 server.
+- **Smoke test.** `compose/smoke-test.sh <multi-node|cloud> [image:tag]`
+  brings the topology up under its own project name, subnet and host ports,
+  checks replicas or compute groups and the vault, stream loads through the
+  client, kills the master FE, restarts a BE while the old master is down,
+  brings the master back, and tears everything down. Two minutes each.
+
 ## Layout
 
 ```
 4.1/
-├── Dockerfile                 three artifact sources -> strip + prune -> 
runtime
+├── Dockerfile                 artifact sources -> strip + prune -> runtime
 ├── Dockerfile.dockerignore    keeps the repo-root context to a few KB
 ├── build.sh                   the only entry point you need
+├── compose/
+│   ├── multi-node.yml         3 FE + 3 BE, storage and compute together
+│   ├── cloud.yml              fdb + ms + recycler + minio + 1..3 FE + 3 BE in 
2 compute groups
+│   └── smoke-test.sh          guards the two compose files
 └── resource/
-    ├── entrypoint.sh          FE -> readiness -> BE -> register -> fail-fast 
wait
-    ├── health_check.sh        backs HEALTHCHECK
-    ├── smoke-test.sh          guards prune.txt
+    ├── entrypoint.sh          one entrypoint, dispatched on DORIS_ROLE
+    ├── lib.sh                 shared by entrypoint.sh, health_check.sh and 
cloud_init.sh
+    ├── cloud_init.sh          creates the cloud instance 
(DORIS_ROLE=cloud-init)
+    ├── health_check.sh        backs HEALTHCHECK, per role
+    ├── smoke-test.sh          guards prune.txt and the single-container role
     ├── prune.txt              what each flavor drops, and what must never be 
dropped
     └── conf/{fe_ci.conf,be_ci.conf}   appended to the upstream conf at build 
time
 ```
diff --git a/docker/runtime/all-in-one/4.1/build.sh 
b/docker/runtime/all-in-one/4.1/build.sh
index 55f509e1c85..c06eefb6f87 100755
--- a/docker/runtime/all-in-one/4.1/build.sh
+++ b/docker/runtime/all-in-one/4.1/build.sh
@@ -22,13 +22,13 @@ set -euo pipefail
 
 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 REPO_ROOT="$(cd "${HERE}/../../../.." && pwd)"
-DOCKERFILE="docker/runtime/all-in-one/4.1/Dockerfile"
+DOCKERFILE="${HERE}/Dockerfile"
 
 IMAGE="${IMAGE:-apache/doris}"
 VERSION=""
 SOURCE="image"
 FLAVORS=""
-STRIP_BE="debug"
+STRIP_MODE="debug"
 PLATFORM=""
 LOCAL_OUTPUT="output"
 TARBALL_DIR=""
@@ -51,16 +51,19 @@ Options:
   -v, --version <v>     Doris version, e.g. 4.1.3. Required.
   -f, --flavor <f>      base | full | both        (default: both)
   -s, --source <s>      image | local | tarball   (default: image)
-                          image   -> apache/doris:fe-<v> and :be-<v>
-                          local   -> ./output/{fe,be} from a local build
+                          image   -> apache/doris:fe-<v>, :be-<v> and :ms-<v>
+                          local   -> ./output/{fe,be,ms} from a local build
+                                     (ms is optional; without it the image
+                                     cannot serve the cloud compose file)
                           tarball -> --tarball-dir
-      --tarball-dir <d> Extracted release package holding fe/ and be/,
-                        as a path relative to the repository root.
+      --tarball-dir <d> Extracted release package holding fe/, be/ and ms/.
+                        Relative paths are tried against the repository root
+                        first, then the current directory.
       --local-output <d> Override the ./output path for --source local.
       --strip <mode>    debug | full | none       (default: debug)
                           debug -> strip --strip-debug, keeps .symtab
                           full  -> strip -s
-                          none  -> ship doris_be as-is (+1.8 GB)
+                          none  -> ship doris_be and doris_cloud as-is (+2.2 
GB)
       --platform <p>    Target platform(s), e.g. linux/amd64 or
                         linux/amd64,linux/arm64. Comma-separated values produce
                         one multi-arch tag (an OCI image index) that resolves 
to
@@ -76,7 +79,10 @@ Options:
 Examples:
   ./build.sh -v 4.1.3                     # both flavors from the official 
images
   ./build.sh -v 4.1.3 -f base -t          # base only, then smoke test it
-  ./build.sh -v dev -s local -f full      # from a local ./output
+  ./build.sh -v dev -s local -f full      # from a local ./output (build.sh 
--fe --be --cloud)
+  ./build.sh -v 4.1.4 -s tarball --tarball-dir ~/apache-doris-4.1.4-bin-arm64
+                                          # from a release tarball, e.g. 
before the
+                                          # component images are on Docker Hub
   ./build.sh -v 4.1.3 --platform linux/amd64,linux/arm64 --push
                                           # one multi-arch tag for both
 
@@ -102,7 +108,7 @@ while [[ $# -gt 0 ]]; do
         -s|--source)       SOURCE="$2"; shift 2 ;;
         --tarball-dir)     TARBALL_DIR="$2"; shift 2 ;;
         --local-output)    LOCAL_OUTPUT="$2"; shift 2 ;;
-        --strip)           STRIP_BE="$2"; shift 2 ;;
+        --strip)           STRIP_MODE="$2"; shift 2 ;;
         --platform)        PLATFORM="$2"; shift 2 ;;
         -i|--image)        IMAGE="$2"; shift 2 ;;
         --no-cache)        NO_CACHE=true; shift ;;
@@ -126,16 +132,34 @@ case "${SOURCE}" in
     image|local|tarball) ;;
     *) echo "error: bad --source '${SOURCE}'" >&2; exit 1 ;;
 esac
-case "${STRIP_BE}" in
+case "${STRIP_MODE}" in
     debug|full|none) ;;
-    *) echo "error: bad --strip '${STRIP_BE}'" >&2; exit 1 ;;
+    *) echo "error: bad --strip '${STRIP_MODE}'" >&2; exit 1 ;;
 esac
 
-if [[ "${SOURCE}" == tarball && -z "${TARBALL_DIR}" ]]; then
-    echo "error: --source tarball needs --tarball-dir" >&2; exit 1
+# A local build and a release tarball are the same thing to the Dockerfile: a
+# directory with fe/, be/ and (optionally) ms/, handed over as named build
+# contexts so that the main context can stay narrowed to a few KB.
+ARTIFACT_DIR=""
+if [[ "${SOURCE}" == tarball ]]; then
+    [[ -n "${TARBALL_DIR}" ]] || { echo "error: --source tarball needs 
--tarball-dir" >&2; exit 1; }
+    if [[ -d "${REPO_ROOT}/${TARBALL_DIR}" ]]; then
+        ARTIFACT_DIR="$(cd "${REPO_ROOT}/${TARBALL_DIR}" && pwd)"
+    elif [[ -d "${TARBALL_DIR}" ]]; then
+        ARTIFACT_DIR="$(cd "${TARBALL_DIR}" && pwd)"
+    else
+        echo "error: --tarball-dir ${TARBALL_DIR} not found" >&2; exit 1
+    fi
+elif [[ "${SOURCE}" == local ]]; then
+    ARTIFACT_DIR="${REPO_ROOT}/${LOCAL_OUTPUT}"
 fi
-if [[ "${SOURCE}" == local && ! -d "${REPO_ROOT}/${LOCAL_OUTPUT}/be" ]]; then
-    echo "error: ${REPO_ROOT}/${LOCAL_OUTPUT}/be not found; build Doris first" 
>&2; exit 1
+if [[ -n "${ARTIFACT_DIR}" ]]; then
+    for part in fe be; do
+        [[ -d "${ARTIFACT_DIR}/${part}" ]] || { echo "error: 
${ARTIFACT_DIR}/${part} not found; build Doris first" >&2; exit 1; }
+    done
+    if [[ ! -d "${ARTIFACT_DIR}/ms" ]]; then
+        echo "note: ${ARTIFACT_DIR}/ms not found (build.sh --cloud); the image 
will not serve the cloud compose file"
+    fi
 fi
 
 command -v docker >/dev/null || { echo "error: docker not found" >&2; exit 1; }
@@ -159,7 +183,12 @@ if [[ "${MULTI_PLATFORM}" == true && "${RUN_TEST}" == true 
]]; then
 fi
 
 builder="docker buildx build"
-docker buildx version >/dev/null 2>&1 || builder="docker build"
+if ! docker buildx version >/dev/null 2>&1; then
+    builder="docker build"
+    if [[ -n "${ARTIFACT_DIR}" ]]; then
+        echo "error: --source ${SOURCE} needs docker buildx (named build 
contexts)" >&2; exit 1
+    fi
+fi
 
 # Say which platforms are being built. Leaving --platform unset means the host
 # architecture only, which is easy to mistake for a multi-arch build.
@@ -173,7 +202,7 @@ fi
 echo "repository root : ${REPO_ROOT}"
 echo "doris version   : ${VERSION}"
 echo "artifact source : ${SOURCE}"
-echo "strip mode      : ${STRIP_BE}"
+echo "strip mode      : ${STRIP_MODE}"
 echo "flavors         : ${FLAVORS}"
 echo "platform(s)     : ${platform_note}"
 echo "output          : $([[ "${PUSH}" == true ]] && echo 'push to registry' 
|| echo 'load into local image store')"
@@ -188,12 +217,22 @@ for flavor in ${FLAVORS}; do
 
     args=(
         --build-arg "DORIS_VERSION=${VERSION}"
-        --build-arg "ARTIFACT_SOURCE=${SOURCE}"
         --build-arg "FLAVOR=${flavor}"
-        --build-arg "STRIP_BE=${STRIP_BE}"
-        --build-arg "LOCAL_OUTPUT=${LOCAL_OUTPUT}"
+        --build-arg "STRIP_MODE=${STRIP_MODE}"
     )
-    [[ -n "${TARBALL_DIR}" ]] && args+=(--build-arg 
"TARBALL_DIR=${TARBALL_DIR}")
+    if [[ -n "${ARTIFACT_DIR}" ]]; then
+        args+=(--build-arg "ARTIFACT_SOURCE=dir"
+               --build-context "doris-fe=${ARTIFACT_DIR}/fe"
+               --build-context "doris-be=${ARTIFACT_DIR}/be")
+        if [[ -d "${ARTIFACT_DIR}/ms" ]]; then
+            args+=(--build-context "doris-ms=${ARTIFACT_DIR}/ms")
+        else
+            empty_ms="$(mktemp -d)"
+            args+=(--build-context "doris-ms=${empty_ms}")
+        fi
+    else
+        args+=(--build-arg "ARTIFACT_SOURCE=image")
+    fi
     [[ -n "${PLATFORM}" ]] && args+=(--platform "${PLATFORM}")
     [[ "${NO_CACHE}" == true ]] && args+=(--no-cache)
     if [[ "${PUSH}" == true ]]; then
diff --git a/docker/runtime/all-in-one/4.1/compose/cloud.yml 
b/docker/runtime/all-in-one/4.1/compose/cloud.yml
new file mode 100644
index 00000000000..3d798437540
--- /dev/null
+++ b/docker/runtime/all-in-one/4.1/compose/cloud.yml
@@ -0,0 +1,320 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Doris 4.1 in compute-storage separated (cloud) mode, for local feature work
+# and demos. Every Doris process runs from the all-in-one image; FoundationDB
+# and MinIO come from their upstream images.
+#
+#   docker compose -f cloud.yml up --wait          # 1 FE, 3 BE in 2 compute 
groups
+#   docker compose -f cloud.yml --profile ha up --wait   # ... with 3 FE
+#   docker compose -f cloud.yml exec client mysql -uroot -hfe-1 -P9030
+#   docker compose -f cloud.yml down                # clean slate (nothing 
persists)
+#
+#   fdb ─ fdb-init ─┬─ ms ─ cloud-init ─ fe-1 ─┬─ be-1, be-2  (compute group 
cg_a)
+#                   └─ recycler                 ├─ be-3        (compute group 
cg_b)
+#   minio ─ minio-init ─┘                       └─ client      (healthy = 
cluster ready)
+#
+# Knobs, as environment variables or a .env file next to this file:
+#   DORIS_IMAGE   apache/doris:all-in-one-4.1.3     any tag built from 
../Dockerfile
+#   SUBNET        172.31.80                         first three octets; change 
it to run
+#                                                   two clusters side by side
+#   FE_PORT 9030  FE_HTTP_PORT 8030  BE_HTTP_PORT 8040  MS_PORT 15000 (5000 is 
AirPlay on macOS)
+#   MINIO_PORT 9000  MINIO_CONSOLE_PORT 9001         host ports
+#   FE_HEAP 1024m  BE_MEM_LIMIT 25%                 per-process memory
+#   CG_A cg_a  CG_B cg_b                            compute group names
+#   INSTANCE_ID 100001                              numeric; also the FEs' 
cluster_id
+#   S3_BUCKET doris  S3_AK / S3_SK minioadmin
+#   FDB_IMAGE foundationdb/foundationdb:7.1.23      amd64 only upstream: on an 
arm64
+#   FDB_PLATFORM linux/amd64                        host it runs under 
emulation
+#
+# Node addresses are pinned inside the private network, so a restarted
+# container keeps the identity Doris knows it by. From the host, use the
+# published ports; on Docker Desktop the container addresses themselves are
+# not routable, which is what the client service is for.
+
+name: doris-cloud
+
+x-node: &node
+  image: ${DORIS_IMAGE:-apache/doris:all-in-one-4.1.3}
+  stop_grace_period: 60s
+
+x-cloud-env: &cloud-env
+  DEPLOY_MODE: cloud
+  INSTANCE_ID: ${INSTANCE_ID:-100001}
+  MS_ENDPOINT: ms:5000
+  FE_MASTER: fe-1,fe-2,fe-3
+
+x-be: &be
+  <<: *node
+  depends_on:
+    fe-1:
+      condition: service_healthy
+
+services:
+  # ----------------------------------------------------- infrastructure ---
+  fdb:
+    image: ${FDB_IMAGE:-foundationdb/foundationdb:7.1.23}
+    platform: ${FDB_PLATFORM:-linux/amd64}
+    environment:
+      FDB_NETWORKING_MODE: container
+      FDB_PORT: "4500"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.10
+    healthcheck:
+      # "available" only once fdb-init has configured the database.
+      test: ["CMD-SHELL", "fdbcli -C /var/fdb/fdb.cluster --exec 'status 
minimal' | grep -q 'is available'"]
+      interval: 5s
+      timeout: 5s
+      retries: 60
+      start_period: 10s
+
+  fdb-init:
+    image: ${FDB_IMAGE:-foundationdb/foundationdb:7.1.23}
+    platform: ${FDB_PLATFORM:-linux/amd64}
+    depends_on:
+      fdb:
+        condition: service_started
+    environment:
+      FDB_CLUSTER_FILE_CONTENTS: docker:docker@${SUBNET:-172.31.80}.10:4500
+    networks:
+      doris:
+    entrypoint: ["bash", "-c"]
+    command:
+      - |
+        echo "$$FDB_CLUSTER_FILE_CONTENTS" > /tmp/fdb.cluster
+        for i in $$(seq 1 60); do
+          out=$$(fdbcli -C /tmp/fdb.cluster --exec 'configure new single ssd' 
2>&1)
+          case "$$out" in
+            *"Database created"*|*"already exists"*) echo "$$out"; exit 0 ;;
+          esac
+          sleep 2
+        done
+        echo "$$out"; exit 1
+
+  minio:
+    image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-01-20T14-49-07Z}
+    command: server /data --console-address :9001
+    environment:
+      MINIO_ROOT_USER: ${S3_AK:-minioadmin}
+      MINIO_ROOT_PASSWORD: ${S3_SK:-minioadmin}
+    ports:
+      - "${MINIO_PORT:-9000}:9000"
+      - "${MINIO_CONSOLE_PORT:-9001}:9001"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.20
+    healthcheck:
+      test: ["CMD", "mc", "ready", "local"]
+      interval: 5s
+      timeout: 5s
+      retries: 30
+
+  minio-init:
+    image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-01-20T14-49-07Z}
+    depends_on:
+      minio:
+        condition: service_healthy
+    environment:
+      MINIO_ROOT_USER: ${S3_AK:-minioadmin}
+      MINIO_ROOT_PASSWORD: ${S3_SK:-minioadmin}
+      S3_BUCKET: ${S3_BUCKET:-doris}
+    networks:
+      doris:
+    entrypoint: ["sh", "-c"]
+    command:
+      - mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" 
"$$MINIO_ROOT_PASSWORD" && mc mb --ignore-existing "local/$$S3_BUCKET"
+
+  # ----------------------------------------------------- doris cloud ------
+  ms:
+    <<: *node
+    environment:
+      DORIS_ROLE: ms
+      DEPLOY_MODE: cloud
+      FDB_CLUSTER: docker:docker@${SUBNET:-172.31.80}.10:4500
+    depends_on:
+      fdb:
+        condition: service_healthy
+      fdb-init:
+        condition: service_completed_successfully
+    ports:
+      - "${MS_PORT:-15000}:5000"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.30
+
+  recycler:
+    <<: *node
+    environment:
+      DORIS_ROLE: recycler
+      DEPLOY_MODE: cloud
+      FDB_CLUSTER: docker:docker@${SUBNET:-172.31.80}.10:4500
+    depends_on:
+      fdb:
+        condition: service_healthy
+      fdb-init:
+        condition: service_completed_successfully
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.31
+
+  # One-shot: creates the instance on the meta-service with MinIO as its
+  # storage vault, then exits. A second run finds it and leaves it alone.
+  cloud-init:
+    <<: *node
+    environment:
+      DORIS_ROLE: cloud-init
+      DEPLOY_MODE: cloud
+      MS_ENDPOINT: ms:5000
+      INSTANCE_ID: ${INSTANCE_ID:-100001}
+      S3_ENDPOINT: minio:9000
+      S3_BUCKET: ${S3_BUCKET:-doris}
+      S3_AK: ${S3_AK:-minioadmin}
+      S3_SK: ${S3_SK:-minioadmin}
+      S3_PROVIDER: S3
+      S3_REGION: us-east-1
+      S3_PREFIX: ${S3_PREFIX:-doris}
+      S3_PATH_STYLE: "true"
+    depends_on:
+      ms:
+        condition: service_healthy
+      minio-init:
+        condition: service_completed_successfully
+    healthcheck:
+      disable: true
+    networks:
+      doris:
+
+  # ----------------------------------------------------- frontends --------
+  fe-1:
+    <<: *node
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: fe
+      FE_MASTER: ""
+      FE_HEAP: ${FE_HEAP:-1024m}
+    depends_on:
+      cloud-init:
+        condition: service_completed_successfully
+    ports:
+      - "${FE_PORT:-9030}:9030"
+      - "${FE_HTTP_PORT:-8030}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.40
+
+  fe-2:
+    <<: *node
+    profiles: [ha]
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: fe
+      FE_ROLE: follower
+      FE_HEAP: ${FE_HEAP:-1024m}
+    depends_on:
+      fe-1:
+        condition: service_healthy
+    ports:
+      - "${FE2_PORT:-9031}:9030"
+      - "${FE2_HTTP_PORT:-8031}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.41
+
+  fe-3:
+    <<: *node
+    profiles: [ha]
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: fe
+      FE_ROLE: follower
+      FE_HEAP: ${FE_HEAP:-1024m}
+    depends_on:
+      fe-1:
+        condition: service_healthy
+    ports:
+      - "${FE3_PORT:-9032}:9030"
+      - "${FE3_HTTP_PORT:-8032}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.42
+
+  # ----------------------------------------------------- backends ---------
+  # BEs are compute nodes here: data lives in MinIO, the local disk is only a
+  # file cache. Two compute groups, so `use @cg_b` has somewhere to go.
+  be-1:
+    <<: *be
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: be
+      COMPUTE_GROUP: ${CG_A:-cg_a}
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    ports:
+      - "${BE_HTTP_PORT:-8040}:8040"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.50
+
+  be-2:
+    <<: *be
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: be
+      COMPUTE_GROUP: ${CG_A:-cg_a}
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.51
+
+  be-3:
+    <<: *be
+    environment:
+      <<: *cloud-env
+      DORIS_ROLE: be
+      COMPUTE_GROUP: ${CG_B:-cg_b}
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.80}.52
+
+  # ----------------------------------------------------- client -----------
+  # Turns healthy once every node above is alive, which is what
+  # `docker compose up --wait` blocks on; then idles inside the network with a
+  # mysql client and curl, so stream load redirects to BE addresses resolve.
+  client:
+    <<: *node
+    environment:
+      DORIS_ROLE: client
+      FE_MASTER: fe-1,fe-2,fe-3
+      EXPECT_FE: ${EXPECT_FE:-1}
+      EXPECT_BE: 3
+    depends_on:
+      be-1:
+        condition: service_healthy
+      be-2:
+        condition: service_healthy
+      be-3:
+        condition: service_healthy
+    networks:
+      doris:
+
+networks:
+  doris:
+    driver: bridge
+    ipam:
+      config:
+        - subnet: ${SUBNET:-172.31.80}.0/24
diff --git a/docker/runtime/all-in-one/4.1/compose/multi-node.yml 
b/docker/runtime/all-in-one/4.1/compose/multi-node.yml
new file mode 100644
index 00000000000..5f510c15329
--- /dev/null
+++ b/docker/runtime/all-in-one/4.1/compose/multi-node.yml
@@ -0,0 +1,168 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Doris 4.1 with storage and compute together on the BEs, three FEs and
+# three BEs, one container per node. For multi-node behaviour -- FE election
+# and failover, three replicas, balancing, decommission -- that the
+# single-container image cannot show.
+#
+#   docker compose -f multi-node.yml up --wait
+#   docker compose -f multi-node.yml exec client mysql -uroot -hfe-1 -P9030
+#   docker compose -f multi-node.yml kill fe-1        # watch the election
+#   docker compose -f multi-node.yml down             # clean slate (nothing 
persists)
+#
+#   fe-1 (bootstraps) ─┬─ fe-2, fe-3 (followers, or FE3_ROLE=observer)
+#                      ├─ be-1, be-2, be-3
+#                      └─ client (healthy = cluster ready)
+#
+# Knobs, as environment variables or a .env file next to this file:
+#   DORIS_IMAGE   apache/doris:all-in-one-4.1.3     any tag built from 
../Dockerfile
+#   SUBNET        172.31.81                         first three octets; 
differs from
+#                                                   cloud.yml so both can run 
at once
+#                                                   (give one of them other 
host ports)
+#   FE_PORT 9030  FE2_PORT 9031  FE3_PORT 9032      host ports, mysql protocol
+#   FE_HTTP_PORT 8030  FE2_HTTP_PORT 8031  FE3_HTTP_PORT 8032  BE_HTTP_PORT 
8040
+#   FE_HEAP 1024m  BE_MEM_LIMIT 25%                 per-process memory
+#   FE3_ROLE follower                               or observer
+#
+# Tables default to three replicas here, one per BE, as they would on a real
+# cluster. Node addresses are pinned inside the private network, so a
+# restarted container keeps the identity Doris knows it by. From the host,
+# use the published ports; on Docker Desktop the container addresses
+# themselves are not routable, which is what the client service is for.
+
+name: doris-multi
+
+x-node: &node
+  image: ${DORIS_IMAGE:-apache/doris:all-in-one-4.1.3}
+  stop_grace_period: 60s
+
+x-fe-follower: &fe-follower
+  <<: *node
+  depends_on:
+    fe-1:
+      condition: service_healthy
+
+x-be: &be
+  <<: *node
+  depends_on:
+    fe-1:
+      condition: service_healthy
+
+services:
+  fe-1:
+    <<: *node
+    environment:
+      DORIS_ROLE: fe
+      FE_HEAP: ${FE_HEAP:-1024m}
+    ports:
+      - "${FE_PORT:-9030}:9030"
+      - "${FE_HTTP_PORT:-8030}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.40
+
+  fe-2:
+    <<: *fe-follower
+    environment:
+      DORIS_ROLE: fe
+      FE_MASTER: fe-1,fe-2,fe-3
+      FE_ROLE: follower
+      FE_HEAP: ${FE_HEAP:-1024m}
+    ports:
+      - "${FE2_PORT:-9031}:9030"
+      - "${FE2_HTTP_PORT:-8031}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.41
+
+  fe-3:
+    <<: *fe-follower
+    environment:
+      DORIS_ROLE: fe
+      FE_MASTER: fe-1,fe-2,fe-3
+      FE_ROLE: ${FE3_ROLE:-follower}
+      FE_HEAP: ${FE_HEAP:-1024m}
+    ports:
+      - "${FE3_PORT:-9032}:9030"
+      - "${FE3_HTTP_PORT:-8032}:8030"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.42
+
+  be-1:
+    <<: *be
+    environment:
+      DORIS_ROLE: be
+      FE_MASTER: fe-1,fe-2,fe-3
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    ports:
+      - "${BE_HTTP_PORT:-8040}:8040"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.50
+
+  be-2:
+    <<: *be
+    environment:
+      DORIS_ROLE: be
+      FE_MASTER: fe-1,fe-2,fe-3
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.51
+
+  be-3:
+    <<: *be
+    environment:
+      DORIS_ROLE: be
+      FE_MASTER: fe-1,fe-2,fe-3
+      BE_CONFIG_EXTRA: "mem_limit = ${BE_MEM_LIMIT:-25%}"
+    networks:
+      doris:
+        ipv4_address: ${SUBNET:-172.31.81}.52
+
+  # Turns healthy once every node above is alive, which is what
+  # `docker compose up --wait` blocks on; then idles inside the network with a
+  # mysql client and curl, so stream load redirects to BE addresses resolve.
+  client:
+    <<: *node
+    environment:
+      DORIS_ROLE: client
+      FE_MASTER: fe-1,fe-2,fe-3
+      EXPECT_FE: 3
+      EXPECT_BE: 3
+    depends_on:
+      fe-2:
+        condition: service_healthy
+      fe-3:
+        condition: service_healthy
+      be-1:
+        condition: service_healthy
+      be-2:
+        condition: service_healthy
+      be-3:
+        condition: service_healthy
+    networks:
+      doris:
+
+networks:
+  doris:
+    driver: bridge
+    ipam:
+      config:
+        - subnet: ${SUBNET:-172.31.81}.0/24
diff --git a/docker/runtime/all-in-one/4.1/compose/smoke-test.sh 
b/docker/runtime/all-in-one/4.1/compose/smoke-test.sh
new file mode 100755
index 00000000000..051662a3b48
--- /dev/null
+++ b/docker/runtime/all-in-one/4.1/compose/smoke-test.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# Smoke test for the compose topologies: brings one up under its own project
+# name, subnet and host ports (so a cluster you are working with stays
+# untouched), exercises what the topology is for, tears it down.
+#
+#   ./smoke-test.sh multi-node [image:tag]
+#   ./smoke-test.sh cloud      [image:tag]
+#
+# Everything runs through the client service, so the host needs only docker.
+
+set -euo pipefail
+
+TOPOLOGY=${1:?usage: smoke-test.sh <multi-node|cloud> [image:tag]}
+export DORIS_IMAGE=${2:-${DORIS_IMAGE:-apache/doris:all-in-one-4.1.3}}
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WAIT_SECONDS=${WAIT_SECONDS:-600}
+
+case "${TOPOLOGY}" in
+    multi-node) export SUBNET=172.31.91 ;;
+    cloud)      export SUBNET=172.31.92 ;;
+    *) echo "unknown topology ${TOPOLOGY}, expected multi-node|cloud" >&2; 
exit 1 ;;
+esac
+export COMPOSE_PROJECT_NAME="doris-smoke-${TOPOLOGY}"
+export FE_PORT=29030 FE2_PORT=29031 FE3_PORT=29032 \
+       FE_HTTP_PORT=28030 FE2_HTTP_PORT=28031 FE3_HTTP_PORT=28032 \
+       BE_HTTP_PORT=28040 MS_PORT=25000 MINIO_PORT=29000 
MINIO_CONSOLE_PORT=29001
+
+compose() { docker compose -f "${HERE}/${TOPOLOGY}.yml" --profile ha "$@"; }
+cleanup() {
+    local rc=$?
+    if ((rc != 0)); then
+        echo "--- compose state ---" >&2
+        compose ps >&2 || true
+        echo "--- recent logs ---" >&2
+        compose logs --tail 40 --no-log-prefix fe-1 be-1 2>&1 | tail -80 >&2 
|| true
+    fi
+    compose down --remove-orphans >/dev/null 2>&1 || true
+    exit "${rc}"
+}
+trap cleanup EXIT
+
+step() { printf '\n== %s\n' "$*"; }
+fail() { echo "FAIL: $*" >&2; exit 1; }
+# q <fe host> <sql>: through the client container, batch output, no header
+q() { compose exec -T client mysql -uroot -h"$1" -P9030 -N --batch 
--connect-timeout=3 -e "$2"; }
+# column <fe host> <show statement> <column name> [<filter column> <value>]
+column() {
+    compose exec -T client mysql -uroot -h"$1" -P9030 --batch 
--connect-timeout=3 -e "$2" \
+        | awk -F'\t' -v want="$3" -v fc="${4:-}" -v fv="${5:-}" '
+            NR == 1 { for (i = 1; i <= NF; i++) col[$i] = i; next }
+            fc == "" || $col[fc] == fv { print $col[want] }'
+}
+master_fe() { column "$1" 'show frontends' Host IsMaster true; }
+wait_for() {   # wait_for <seconds> <description> <command...>
+    local timeout=$1 what=$2; shift 2
+    local deadline=$((SECONDS + timeout))
+    while ((SECONDS < deadline)); do
+        if "$@" >/dev/null 2>&1; then return 0; fi
+        sleep 2
+    done
+    fail "timed out waiting for ${what}"
+}
+healthy() { [[ "$(docker inspect -f '{{.State.Health.Status}}' 
"${COMPOSE_PROJECT_NAME}-$1-1" 2>/dev/null)" == healthy ]]; }
+stream_load() {   # stream_load <fe host> <db.table> <csv lines>
+    local target=${2/./\/}
+    compose exec -T client bash -c "printf '$3' | curl -sS --location-trusted 
-u root: \
+        -H 'column_separator:,' -H 'Expect:100-continue' -T - 
http://$1:8030/api/${target}/_stream_load"; \
+        | grep -q '"Status": *"Success"'
+}
+
+step "starting ${TOPOLOGY} from ${DORIS_IMAGE} (project 
${COMPOSE_PROJECT_NAME}, subnet ${SUBNET}.0/24)"
+compose up --wait --wait-timeout "${WAIT_SECONDS}" >/dev/null
+echo "  up after ${SECONDS}s"
+compose ps --format 'table {{.Service}}\t{{.Status}}' | sed 's/^/  /'
+
+step "three FEs and three BEs alive"
+[[ "$(column fe-1 'show frontends' Alive | grep -c true)" == 3 ]] || fail 
"expected 3 live FEs"
+[[ "$(column fe-1 'show backends' Alive | grep -c true)" == 3 ]] || fail 
"expected 3 live BEs"
+echo "  master is $(master_fe fe-1)"
+
+step "create, insert, read back"
+q fe-1 "create database if not exists smoke"
+q fe-1 "drop table if exists smoke.t"
+q fe-1 "create table smoke.t (k int, v varchar(32)) duplicate key(k) 
distributed by hash(k) buckets 4"
+q fe-1 "insert into smoke.t values (1,'a'),(2,'b'),(3,'c')"
+[[ "$(q fe-1 'select count(*) from smoke.t')" == 3 ]] || fail "insert/select 
mismatch"
+echo "  3 rows"
+
+if [[ "${TOPOLOGY}" == multi-node ]]; then
+    step "three replicas, one per backend"
+    q fe-1 "show create table smoke.t" | grep -q 'tag.location.default: 3' || 
fail "table is not 3-replica"
+    per_be=$(q fe-1 "show tablets from smoke.t" | awk -F'\t' '{n[$3]++} END 
{print length(n)}')
+    [[ "${per_be}" == 3 ]] || fail "replicas spread over ${per_be} backends, 
expected 3"
+    echo "  12 replicas over 3 backends"
+else
+    step "two compute groups, default storage vault on MinIO"
+    [[ "$(column fe-1 'show compute groups' BackendNum Name cg_a)" == 2 ]] || 
fail "cg_a should have 2 BEs"
+    [[ "$(column fe-1 'show compute groups' BackendNum Name cg_b)" == 1 ]] || 
fail "cg_b should have 1 BE"
+    [[ "$(column fe-1 'show storage vaults' IsDefault Name 
built_in_storage_vault)" == true ]] \
+        || fail "built_in_storage_vault is not the default"
+    [[ "$(q fe-1 'use @cg_b; select count(*) from smoke.t')" == 3 ]] || fail 
"read via cg_b failed"
+    echo "  cg_a=2 cg_b=1, reads work from cg_b"
+    objects=$(compose exec -T minio sh -c 'mc alias set local 
http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && 
mc ls -r local/doris | wc -l')
+    ((objects > 0)) || fail "no objects in MinIO after the insert"
+    echo "  ${objects} objects in MinIO"
+fi
+
+step "stream load through the client (redirect to a BE address)"
+stream_load fe-1 smoke.t '4,d\n5,e\n' || fail "stream load did not report 
Success"
+[[ "$(q fe-1 'select count(*) from smoke.t')" == 5 ]] || fail "row count after 
stream load"
+echo "  5 rows"
+
+step "kill the master FE, expect a new one"
+old_master=$(master_fe fe-1)
+compose kill fe-1 >/dev/null 2>&1
+new_master_elected() {
+    new_master=$(master_fe fe-2 2>/dev/null || true)
+    [[ -n "${new_master}" && "${new_master}" != "${old_master}" ]]
+}
+wait_for 120 "a new master" new_master_elected
+echo "  ${old_master} -> ${new_master}"
+q fe-2 "insert into smoke.t values (6,'f')"
+[[ "$(q fe-2 'select count(*) from smoke.t')" == 6 ]] || fail "write through 
the new master failed"
+echo "  write through fe-2 ok"
+
+step "restart a BE while the old master is down, then bring the old master 
back"
+compose restart be-2 >/dev/null 2>&1
+wait_for 180 "be-2 healthy" healthy be-2
+compose start fe-1 >/dev/null 2>&1
+wait_for 180 "fe-1 healthy" healthy fe-1
+[[ "$(column fe-1 'show frontends' Alive | grep -c true)" == 3 ]] || fail 
"expected 3 live FEs after the restart"
+[[ "$(column fe-1 'show backends' Alive | grep -c true)" == 3 ]] || fail 
"expected 3 live BEs after the restart"
+echo "  everything alive again, master is $(master_fe fe-1)"
+
+step "cleanup"
+q fe-1 "drop database smoke force" >/dev/null 2>&1 || q fe-1 "drop database 
smoke"
+printf '\nsmoke test passed: %s (%s)\n' "${TOPOLOGY}" "${DORIS_IMAGE}"
diff --git a/docker/runtime/all-in-one/4.1/resource/cloud_init.sh 
b/docker/runtime/all-in-one/4.1/resource/cloud_init.sh
new file mode 100644
index 00000000000..57860ce1bb1
--- /dev/null
+++ b/docker/runtime/all-in-one/4.1/resource/cloud_init.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# One-shot: create the cloud instance on the meta-service, backed by an
+# S3-compatible object store, then exit. Safe to run again -- an instance that
+# already exists is left alone.
+#
+#   MS_ENDPOINT   host:port of the meta-service
+#   INSTANCE_ID   numeric; the FEs use the same value as their cluster_id
+#   S3_ENDPOINT   host:port of the object store (MinIO in the compose file)
+#   S3_BUCKET, S3_AK, S3_SK, S3_REGION, S3_PREFIX, S3_PROVIDER (S3 | OSS | COS 
| OBS | BOS | GCP | AZURE)
+#   S3_PATH_STYLE true for MinIO, false for the public clouds
+#   VAULT_MODE    true (default) creates a storage-vault instance, false a 
legacy obj_info one
+
+set -Eeuo pipefail
+CI_HOME="${CI_HOME:-/opt/doris-ci}"
+# shellcheck source=lib.sh
+source "${CI_HOME}/lib.sh"
+
+: "${MS_ENDPOINT:?}" "${INSTANCE_ID:?}" "${S3_ENDPOINT:?}" "${S3_BUCKET:?}" 
"${S3_AK:?}" "${S3_SK:?}"
+S3_REGION="${S3_REGION:-us-east-1}"
+S3_PREFIX="${S3_PREFIX:-doris}"
+S3_PROVIDER="${S3_PROVIDER:-S3}"
+S3_PATH_STYLE="${S3_PATH_STYLE:-true}"
+VAULT_MODE="${VAULT_MODE:-true}"
+MS_TOKEN="${MS_TOKEN:-greedisgood9999}"
+INSTANCE_NAME="${INSTANCE_NAME:-doris_${INSTANCE_ID}}"
+
+ms_url() { echo "http://${MS_ENDPOINT}/MetaService/http/$1?token=${MS_TOKEN}";; 
}
+ms_up()  { curl -fsS --max-time 4 "http://${MS_ENDPOINT}/health"; >/dev/null 
2>&1; }
+
+wait_until "${START_TIMEOUT}" "meta-service at ${MS_ENDPOINT}" ms_up
+
+obj_info=$(printf 
'{"ak":"%s","sk":"%s","bucket":"%s","endpoint":"%s","external_endpoint":"%s","prefix":"%s","region":"%s","provider":"%s","use_path_style":%s}'
 \
+    "${S3_AK}" "${S3_SK}" "${S3_BUCKET}" "${S3_ENDPOINT}" "${S3_ENDPOINT}" 
"${S3_PREFIX}" "${S3_REGION}" "${S3_PROVIDER}" "${S3_PATH_STYLE}")
+if [[ "${VAULT_MODE}" == true ]]; then
+    body=$(printf 
'{"instance_id":"%s","name":"%s","user_id":"doris","vault":{"obj_info":%s}}' 
"${INSTANCE_ID}" "${INSTANCE_NAME}" "${obj_info}")
+else
+    body=$(printf 
'{"instance_id":"%s","name":"%s","user_id":"doris","obj_info":%s}' 
"${INSTANCE_ID}" "${INSTANCE_NAME}" "${obj_info}")
+fi
+
+info "creating instance ${INSTANCE_ID} on ${S3_PROVIDER} 
${S3_ENDPOINT}/${S3_BUCKET}/${S3_PREFIX} (vault=${VAULT_MODE}, 
path_style=${S3_PATH_STYLE})"
+out=$(curl -sS --max-time 30 "$(ms_url create_instance)" -d "${body}") || die 
"create_instance request failed"
+code=$(sed -nE 's/.*"code"[[:space:]]*:[[:space:]]*"([A-Z_]+)".*/\1/p' 
<<<"$(tr -d '\n' <<<"${out}")")
+case "${code}" in
+    OK)              info "instance ${INSTANCE_ID} created" ;;
+    ALREADY_EXISTED) info "instance ${INSTANCE_ID} already exists, leaving it 
alone" ;;
+    *)               die "create_instance failed: ${out}" ;;
+esac
diff --git a/docker/runtime/all-in-one/4.1/resource/conf/be_ci.conf 
b/docker/runtime/all-in-one/4.1/resource/conf/be_ci.conf
index 8e26b7cb095..b3cc7efe38b 100644
--- a/docker/runtime/all-in-one/4.1/resource/conf/be_ci.conf
+++ b/docker/runtime/all-in-one/4.1/resource/conf/be_ci.conf
@@ -21,7 +21,6 @@
 # The JNI-side heap is not set here; the Dockerfile rewrites -Xmx/-Xms inside
 # the upstream JAVA_OPTS_FOR_JDK_17 line.
 
-priority_networks = 127.0.0.1/32
 
 # Upstream default is 90%, measured against the cgroup limit when there is one
 # and against host memory when there is not. An unconstrained container on a
diff --git a/docker/runtime/all-in-one/4.1/resource/conf/fe_ci.conf 
b/docker/runtime/all-in-one/4.1/resource/conf/fe_ci.conf
index 374fe60d120..c07c0d9a1a7 100644
--- a/docker/runtime/all-in-one/4.1/resource/conf/fe_ci.conf
+++ b/docker/runtime/all-in-one/4.1/resource/conf/fe_ci.conf
@@ -24,18 +24,10 @@
 # The heap is not set here; the Dockerfile rewrites the -Xmx/-Xms tokens inside
 # the upstream JAVA_OPTS_FOR_JDK_17 line so the --add-opens flags stay intact.
 
-# One BE means one replica. Saves every downstream CREATE TABLE from having to
-# spell out replication_num=1. Config.java describes this as test-environment
-# only, which is exactly what this image is.
-force_olap_table_replication_num = 1
-
-# Everything lives on the container loopback. Pinning IP mode as well keeps a
-# restart under a different container hostname from invalidating the metadata.
-priority_networks = 127.0.0.1/32
-enable_fqdn_mode = false
-
-# Nothing to balance across a single backend.
-disable_balance = true
+# Keys that depend on the topology -- network identity, replica count,
+# balancing -- are not here: entrypoint.sh writes them per DORIS_ROLE at
+# container start, so that the same image serves the single-container image
+# and the multi-node and cloud compose files.
 
 # Keep the log directory bounded over a long CI run.
 sys_log_roll_num = 2
diff --git a/docker/runtime/all-in-one/4.1/resource/entrypoint.sh 
b/docker/runtime/all-in-one/4.1/resource/entrypoint.sh
index 1ce68ee0efa..4fe54c8a5be 100755
--- a/docker/runtime/all-in-one/4.1/resource/entrypoint.sh
+++ b/docker/runtime/all-in-one/4.1/resource/entrypoint.sh
@@ -16,93 +16,168 @@
 # specific language governing permissions and limitations
 # under the License.
 #
-# Brings up one FE and one BE inside a single container and keeps them there.
+# Entrypoint for every role of the all-in-one image. DORIS_ROLE selects what
+# the container runs; each role is plain Doris started with --console, so
+# `docker logs` carries the process output and the container exits non-zero
+# when the process does. `docker stop` shuts down gracefully and exits 0.
 #
-# Fail-fast by design: if either process exits, so does the container, with a
-# non-zero status. A test fixture that quietly restarts a dead FE turns a
-# two-second failure into a job timeout.
+#   all         one FE and one BE on loopback -- the single-container image 
(default)
+#   fe          an FE: bootstraps the cluster when FE_MASTER is unset, joins 
it otherwise
+#   be          a BE that registers with FE_MASTER
+#   ms          the cloud meta-service  } DEPLOY_MODE=cloud only; FDB_CLUSTER
+#   recycler    the cloud recycler      } names the FoundationDB to use
+#   cloud-init  one-shot: create the cloud instance on the object store, then 
exit 0
+#   client      waits until EXPECT_FE / EXPECT_BE nodes are alive, then idles
+#
+# Every role reads FE_CONFIG_EXTRA / BE_CONFIG_EXTRA / MS_CONFIG_EXTRA 
(appended
+# to the respective conf), FE_HEAP / BE_HEAP (JVM -Xmx, e.g. 1024m) and
+# START_TIMEOUT. The compose files under ../compose show the rest in use.
 
 set -Eeuo pipefail
-
-# Job control, so each child lands in its own process group. This is what makes
-# shutdown work: start_fe.sh / start_be.sh run the real java / doris_be process
-# in the foreground and do not forward signals, and neither writes a usable pid
-# file under --console, so stop_fe.sh / stop_be.sh cannot help us. Signalling
-# the whole group reaches the actual process.
 set -m
 
-DORIS_HOME="${DORIS_HOME:-/opt/apache-doris}"
-FE_HOME="${DORIS_HOME}/fe"
-BE_HOME="${DORIS_HOME}/be"
-READY_FLAG="${DORIS_HOME}/.ready"
-
-HOST=127.0.0.1
-FE_HTTP_PORT="${FE_HTTP_PORT:-8030}"
-FE_QUERY_PORT="${FE_QUERY_PORT:-9030}"
-BE_HEARTBEAT_PORT="${BE_HEARTBEAT_PORT:-9050}"
-START_TIMEOUT="${START_TIMEOUT:-300}"
-STOP_TIMEOUT="${STOP_TIMEOUT:-30}"
+CI_HOME="${CI_HOME:-/opt/doris-ci}"
+# shellcheck source=lib.sh
+source "${CI_HOME}/lib.sh"
 
+MY_IP="$(my_ip)"
+PRIORITY_NETWORKS="${PRIORITY_NETWORKS:-${MY_IP}/32}"
+MASTER_IP=
 FE_PID=
 BE_PID=
+MS_PID=
 
-log()  { printf '%s [%-5s] [entrypoint] %s\n' "$(date -Iseconds)" "$1" 
"${*:2}"; }
-info() { log INFO "$@"; }
-warn() { log WARN "$@" >&2; }
-die()  { log ERROR "$@" >&2; exit 1; }
-
-sql() {
-    mysql -uroot -h"${HOST}" -P"${FE_QUERY_PORT}" -N --batch 
--connect-timeout=2 -e "$1" 2>/dev/null
+require_env() {
+    local v
+    for v in "$@"; do
+        [[ -n "${!v:-}" ]] || die "${v} must be set for 
DORIS_ROLE=${DORIS_ROLE} DEPLOY_MODE=${DEPLOY_MODE}"
+    done
 }
 
-fe_health() {
-    # Public endpoint (HealthAction): 503 until FE is ready, otherwise a body
-    # carrying online_backend_num. curl -f turns the 503 into a non-zero exit.
-    curl -fsS --max-time 4 "http://${HOST}:${FE_HTTP_PORT}/api/health"; 
2>/dev/null
-}
+# ------------------------------------------------------------ conf blocks ---
+# What each role appends to the shipped conf. Topology keys live here rather
+# than in the image so that one image serves every topology.
 
-# ---------------------------------------------------------------- config ----
-# The image already carries the integration-test defaults; this is the runtime
-# escape hatch for downstream projects that need one knob changed.
-apply_env_overrides() {
-    if [[ -n "${FE_CONFIG_EXTRA:-}" ]]; then
-        info "appending FE_CONFIG_EXTRA to fe.conf"
-        printf '\n# --- FE_CONFIG_EXTRA ---\n%s\n' "${FE_CONFIG_EXTRA}" 
>>"${FE_HOME}/conf/fe.conf"
+fe_conf_block() {
+    printf 'priority_networks = %s\nenable_fqdn_mode = false\n' 
"${PRIORITY_NETWORKS}"
+    if [[ "${DORIS_ROLE}" == all ]]; then
+        # One BE means one replica: saves every downstream CREATE TABLE from
+        # spelling out replication_num=1 (Config.java calls this a
+        # test-environment knob, which is what this image is), and there is
+        # nothing to balance across a single backend.
+        printf 'force_olap_table_replication_num = 1\ndisable_balance = true\n'
     fi
-    if [[ -n "${BE_CONFIG_EXTRA:-}" ]]; then
-        info "appending BE_CONFIG_EXTRA to be.conf"
-        printf '\n# --- BE_CONFIG_EXTRA ---\n%s\n' "${BE_CONFIG_EXTRA}" 
>>"${BE_HOME}/conf/be.conf"
+    if [[ "${DEPLOY_MODE}" == cloud ]]; then
+        # SQL-managed cloud node: FE derives its cloud_unique_id from
+        # cluster_id, which therefore has to be the numeric instance id that
+        # cloud-init created on the meta-service.
+        printf 'deploy_mode = cloud\ncluster_id = %s\nmeta_service_endpoint = 
%s\n' \
+            "${INSTANCE_ID}" "${MS_ENDPOINT}"
     fi
+    [[ -z "${FE_CONFIG_EXTRA:-}" ]] || printf '# --- FE_CONFIG_EXTRA 
---\n%s\n' "${FE_CONFIG_EXTRA}"
 }
 
-# ------------------------------------------------------------------- FE -----
-start_fe() {
-    info "starting FE"
-    "${FE_HOME}/bin/start_fe.sh" --console &
-    FE_PID=$!
+be_conf_block() {
+    printf 'priority_networks = %s\n' "${PRIORITY_NETWORKS}"
+    if [[ "${DEPLOY_MODE}" == cloud ]]; then
+        printf 'deploy_mode = cloud\nmeta_service_endpoint = 
%s\nenable_file_cache = true\n' "${MS_ENDPOINT}"
+        printf 'file_cache_path = 
[{"path":"%s/storage/file_cache","total_size":%s,"query_limit":%s}]\n' \
+            "${BE_HOME}" "${BE_FILE_CACHE_BYTES:-2147483648}" 
"${BE_FILE_CACHE_QUERY_LIMIT_BYTES:-1073741824}"
+        printf 'tmp_file_dirs = 
[{"path":"%s/storage/tmp","max_cache_bytes":104857600,"max_upload_bytes":104857600}]\n'
 \
+            "${BE_HOME}"
+    fi
+    [[ -z "${BE_CONFIG_EXTRA:-}" ]] || printf '# --- BE_CONFIG_EXTRA 
---\n%s\n' "${BE_CONFIG_EXTRA}"
+}
+
+ms_conf_block() {
+    printf 'fdb_cluster = %s\nbrpc_listen_port = %s\n' "${FDB_CLUSTER}" 
"${MS_PORT}"
+    [[ -z "${MS_CONFIG_EXTRA:-}" ]] || printf '# --- MS_CONFIG_EXTRA 
---\n%s\n' "${MS_CONFIG_EXTRA}"
 }
 
-wait_fe_ready() {
-    local deadline=$((SECONDS + START_TIMEOUT))
+# ---------------------------------------------------------------- probes ---
+
+fe_up() { fe_health "$1" >/dev/null; }
+
+# Pick the FE to talk to: the first entry of FE_MASTER (comma-separated
+# hosts) that resolves and answers /api/health. Any live FE will do --
+# ALTER SYSTEM is forwarded to whichever FE is master -- so listing every FE
+# keeps a BE restart from waiting on the one FE that happens to be down.
+# Doris identifies nodes by IP here (enable_fqdn_mode is off), so
+# registration, --helper and the SQL probes all use the address.
+resolve_master() {
+    require_env FE_MASTER
+    local deadline=$((SECONDS + START_TIMEOUT)) host ip
     while ((SECONDS < deadline)); do
-        kill -0 "${FE_PID}" 2>/dev/null \
-            || die "FE exited during startup, see ${FE_HOME}/log/fe.log"
-        # Two gates: the HTTP endpoint reports FE readiness, and a metadata
-        # query proves the MySQL port is actually serving. It has to be a
-        # metadata query -- `select 1` goes through Nereids, which picks a
-        # backend as its scan node and fails with "No backend available" until
-        # one is registered. The BE is not started yet at this point, so using
-        # it here would deadlock the two waits against each other.
-        if fe_health >/dev/null && sql 'show frontends' | grep -q "${HOST}"; 
then
-            info "FE is ready after ${SECONDS}s"
-            return 0
-        fi
+        for host in ${FE_MASTER//,/ }; do
+            ip="$(getent hosts "${host}" | awk '{print $1; exit}')" || true
+            [[ -n "${ip}" ]] || continue
+            if fe_up "${ip}"; then
+                MASTER_IP="${ip}"
+                info "using FE ${host} (${ip})"
+                return 0
+            fi
+        done
         sleep 1
     done
-    die "FE did not become ready within ${START_TIMEOUT}s, see 
${FE_HOME}/log/fe.log"
+    die "no FE in FE_MASTER=${FE_MASTER} answered within ${START_TIMEOUT}s"
+}
+
+# The local FE is up: the HTTP endpoint reports ready, and a metadata query
+# proves the MySQL port serves. It has to be a metadata query -- `select 1`
+# goes through Nereids, which picks a backend as its scan node and fails with
+# "No backend available" until one is registered.
+fe_alive_here() {
+    kill -0 "${FE_PID}" 2>/dev/null || die "FE exited during startup, see 
${FE_HOME}/log/fe.log"
+    fe_up 127.0.0.1 && [[ "$(node_alive 127.0.0.1 frontends "${MY_IP}" 
"${FE_EDIT_LOG_PORT}")" == true ]]
+}
+
+# The local BE is up as far as the master FE is concerned, which is the only
+# opinion that matters to a client.
+be_alive_here() {
+    kill -0 "${BE_PID}" 2>/dev/null || die "BE exited during startup, see 
${BE_HOME}/log/be.INFO and ${BE_HOME}/log/be.out"
+    [[ "$(node_alive "${MASTER_IP}" backends "${MY_IP}" 
"${BE_HEARTBEAT_PORT}")" == true ]]
+}
+
+tcp_open() { (exec 3<>"/dev/tcp/$1/$2") 2>/dev/null; }
+
+ms_alive_here() {
+    kill -0 "${MS_PID}" 2>/dev/null || die "${DORIS_ROLE} exited during 
startup, see ${MS_HOME}/log/"
+    curl -fsS --max-time 4 "http://127.0.0.1:${MS_PORT}/health"; >/dev/null 2>&1
+}
+
+# A cloud instance created in storage-vault mode carries a 
built_in_storage_vault
+# that nothing marks as default, and CREATE TABLE refuses to pick one on its 
own.
+ensure_default_vault() {
+    local vaults
+    vaults="$(sql 127.0.0.1 'show storage vaults')" || return 0
+    if awk -F'\t' '$NF == "true" { found = 1 } END { exit !found }' 
<<<"${vaults}"; then
+        return 0
+    fi
+    if grep -q '^built_in_storage_vault' <<<"${vaults}"; then
+        info "setting built_in_storage_vault as the default storage vault"
+        sql 127.0.0.1 'set built_in_storage_vault as default storage vault' \
+            || warn "could not set the default storage vault; CREATE TABLE 
will need an explicit one"
+    fi
+}
+
+# park <name> <pid>: block until the process exits, then fail the container.
+# `wait` also returns when a trap fires; the trap exits on its own, so getting
+# past it means the process is really gone.
+park() {
+    local rc=0
+    wait "$2" || rc=$?
+    rm -f "${READY_FLAG}"
+    die "$1 exited (rc=${rc})"
+}
+
+# ------------------------------------------------------------------ roles ---
+
+start_fe() {
+    info "starting FE${*:+ $*}"
+    "${FE_HOME}/bin/start_fe.sh" "$@" --console &
+    FE_PID=$!
 }
 
-# ------------------------------------------------------------------- BE -----
 start_be() {
     info "starting BE"
     "${BE_HOME}/bin/start_be.sh" --console &
@@ -110,85 +185,158 @@ start_be() {
 }
 
 register_be() {
-    # Idempotent: a container restarted on a mounted doris-meta already has the
-    # backend in its metadata.
-    if sql 'show backends' | grep -qE 
"[[:space:]]${HOST}[[:space:]]+${BE_HEARTBEAT_PORT}[[:space:]]"; then
-        info "backend ${HOST}:${BE_HEARTBEAT_PORT} already registered"
+    local stmt="alter system add backend '${MY_IP}:${BE_HEARTBEAT_PORT}'"
+    if [[ "${DEPLOY_MODE}" == cloud ]]; then
+        stmt+=" properties ('tag.compute_group_name' = 
'${COMPUTE_GROUP:-default_compute_group}')"
+    fi
+    # Idempotent: a container restarted on a mounted doris-meta already has it.
+    if [[ -n "$(node_alive "${MASTER_IP}" backends "${MY_IP}" 
"${BE_HEARTBEAT_PORT}")" ]]; then
+        info "backend ${MY_IP}:${BE_HEARTBEAT_PORT} already registered"
     else
-        info "registering backend ${HOST}:${BE_HEARTBEAT_PORT}"
-        sql "alter system add backend '${HOST}:${BE_HEARTBEAT_PORT}'" \
-            || die "ALTER SYSTEM ADD BACKEND failed"
+        info "registering backend 
${MY_IP}:${BE_HEARTBEAT_PORT}${COMPUTE_GROUP:+ in compute group 
${COMPUTE_GROUP}}"
+        sql "${MASTER_IP}" "${stmt}" || die "ALTER SYSTEM ADD BACKEND failed"
     fi
 }
 
-wait_be_alive() {
-    local deadline=$((SECONDS + START_TIMEOUT))
-    while ((SECONDS < deadline)); do
-        kill -0 "${BE_PID}" 2>/dev/null \
-            || die "BE exited during startup, see ${BE_HOME}/log/be.INFO and 
${BE_HOME}/log/be.out"
-        # FE reports how many backends it considers alive, so one request
-        # answers both "is the BE up" and "did FE notice".
-        if fe_health | grep -qE 
'"online_backend_num"[[:space:]]*:[[:space:]]*[1-9]'; then
-            info "backend is alive after ${SECONDS}s"
-            return 0
+run_all() {
+    [[ "${DEPLOY_MODE}" == local ]] || die "DORIS_ROLE=all only supports 
DEPLOY_MODE=local"
+    MASTER_IP=127.0.0.1
+    render_conf "${FE_HOME}/conf/fe.conf" "$(fe_conf_block)"
+    render_conf "${BE_HOME}/conf/be.conf" "$(be_conf_block)"
+    [[ -z "${FE_HEAP:-}" ]] || set_heap "${FE_HOME}/conf/fe.conf" "${FE_HEAP}"
+    [[ -z "${BE_HEAP:-}" ]] || set_heap "${BE_HOME}/conf/be.conf" "${BE_HEAP}"
+    start_fe
+    wait_until "${START_TIMEOUT}" "FE to come up" fe_alive_here
+    start_be
+    register_be
+    wait_until "${START_TIMEOUT}" "the backend to come alive" be_alive_here
+    touch "${READY_FLAG}"
+    info "cluster is ready -- mysql -uroot -h127.0.0.1 -P${FE_QUERY_PORT}"
+    local rc=0
+    wait -n "${FE_PID}" "${BE_PID}" || rc=$?
+    rm -f "${READY_FLAG}"
+    if ! kill -0 "${FE_PID}" 2>/dev/null; then
+        die "FE exited (rc=${rc}), see ${FE_HOME}/log/fe.log"
+    fi
+    die "BE exited (rc=${rc}), see ${BE_HOME}/log/be.INFO and 
${BE_HOME}/log/be.out"
+}
+
+run_fe() {
+    [[ "${DEPLOY_MODE}" != cloud ]] || require_env INSTANCE_ID MS_ENDPOINT
+    render_conf "${FE_HOME}/conf/fe.conf" "$(fe_conf_block)"
+    [[ -z "${FE_HEAP:-}" ]] || set_heap "${FE_HOME}/conf/fe.conf" "${FE_HEAP}"
+    if [[ -z "${FE_MASTER}" ]]; then
+        start_fe
+        wait_until "${START_TIMEOUT}" "FE to come up" fe_alive_here
+        [[ "${DEPLOY_MODE}" != cloud ]] || ensure_default_vault
+    else
+        local role="${FE_ROLE:-follower}"
+        case "${role}" in follower|observer) ;; *) die "bad FE_ROLE=${role}, 
expected follower|observer" ;; esac
+        if [[ -d "${FE_HOME}/doris-meta/bdb" ]]; then
+            # Already a member: the metadata knows the peers, so rejoin
+            # without waiting on any other FE, which may well be down.
+            info "metadata present, rejoining the cluster"
+            start_fe
+        else
+            resolve_master
+            # A node has to be in the master's metadata before it may join
+            # with --helper.
+            if [[ -z "$(node_alive "${MASTER_IP}" frontends "${MY_IP}" 
"${FE_EDIT_LOG_PORT}")" ]]; then
+                info "registering as ${role} ${MY_IP}:${FE_EDIT_LOG_PORT}"
+                sql "${MASTER_IP}" "alter system add ${role} 
'${MY_IP}:${FE_EDIT_LOG_PORT}'" \
+                    || die "ALTER SYSTEM ADD ${role^^} failed"
+            fi
+            start_fe --helper "${MASTER_IP}:${FE_EDIT_LOG_PORT}"
         fi
-        sleep 1
-    done
-    die "backend did not come alive within ${START_TIMEOUT}s"
-}
-
-# -------------------------------------------------------------- shutdown ----
-stop_one() {
-    local name=$1 pid=$2
-    [[ -n "${pid}" ]] || return 0
-    kill -0 "${pid}" 2>/dev/null || return 0
-    info "stopping ${name}"
-    # Negative pid signals the whole process group, which is where the real
-    # java / doris_be process lives.
-    kill -TERM -"${pid}" 2>/dev/null || kill -TERM "${pid}" 2>/dev/null || true
-    local deadline=$((SECONDS + STOP_TIMEOUT))
-    while ((SECONDS < deadline)); do
-        kill -0 "${pid}" 2>/dev/null || { info "${name} stopped"; return 0; }
-        sleep 1
-    done
-    warn "${name} did not stop within ${STOP_TIMEOUT}s, killing"
-    kill -KILL -"${pid}" 2>/dev/null || kill -KILL "${pid}" 2>/dev/null || true
+        wait_until "${START_TIMEOUT}" "FE to join the cluster" fe_alive_here
+    fi
+    touch "${READY_FLAG}"
+    info "FE is ready -- mysql -uroot -h${MY_IP} -P${FE_QUERY_PORT}"
+    park FE "${FE_PID}"
+}
+
+run_be() {
+    [[ "${DEPLOY_MODE}" != cloud ]] || require_env MS_ENDPOINT
+    mkdir -p "${BE_HOME}/storage/file_cache" "${BE_HOME}/storage/tmp"
+    render_conf "${BE_HOME}/conf/be.conf" "$(be_conf_block)"
+    [[ -z "${BE_HEAP:-}" ]] || set_heap "${BE_HOME}/conf/be.conf" "${BE_HEAP}"
+    resolve_master
+    register_be
+    start_be
+    wait_until "${START_TIMEOUT}" "the backend to come alive" be_alive_here
+    touch "${READY_FLAG}"
+    info "BE is ready"
+    park BE "${BE_PID}"
+}
+
+run_ms() {
+    require_env FDB_CLUSTER
+    [[ -x "${MS_HOME}/bin/start.sh" ]] || die "this image carries no 
meta-service payload (${MS_HOME})"
+    local flag=--meta-service
+    [[ "${DORIS_ROLE}" == recycler ]] && flag=--recycler
+    render_conf "${MS_HOME}/conf/doris_cloud.conf" "$(ms_conf_block)"
+    mkdir -p "${MS_HOME}/log"
+    # doris_cloud gives up quickly when FoundationDB is not there yet; the
+    # compose files order it after fdb, this covers a hand-run container.
+    local fdb_addr="${FDB_CLUSTER##*@}"
+    wait_until "${START_TIMEOUT}" "FoundationDB at ${fdb_addr}" tcp_open 
"${fdb_addr%:*}" "${fdb_addr##*:}"
+    info "starting ${DORIS_ROLE}"
+    (cd "${MS_HOME}" && bash bin/start.sh ${flag} --console) &
+    MS_PID=$!
+    wait_until "${START_TIMEOUT}" "${DORIS_ROLE} to come up" ms_alive_here
+    touch "${READY_FLAG}"
+    info "${DORIS_ROLE} is ready on ${MY_IP}:${MS_PORT}"
+    park "${DORIS_ROLE}" "${MS_PID}"
 }
 
+run_cloud_init() {
+    exec "${CI_HOME}/cloud_init.sh"
+}
+
+# Sit in the network as a ready-made client: `docker compose up --wait`
+# returns once this container is healthy, i.e. once every expected node is
+# alive, and `docker compose exec client mysql ...` reaches the cluster.
+run_client() {
+    resolve_master
+    local want_fe="${EXPECT_FE:-1}" want_be="${EXPECT_BE:-1}"
+    count_alive() {
+        [[ "$(alive_count "${MASTER_IP}" frontends)" -ge "${want_fe}" ]] \
+            && [[ "$(alive_count "${MASTER_IP}" backends)" -ge "${want_be}" ]]
+    }
+    wait_until "${START_TIMEOUT}" "${want_fe} FE and ${want_be} BE to be 
alive" count_alive
+    touch "${READY_FLAG}"
+    info "cluster is ready: $(alive_count "${MASTER_IP}" frontends) FE, 
$(alive_count "${MASTER_IP}" backends) BE"
+    info "connect with: mysql -uroot -h${FE_MASTER%%,*} -P${FE_QUERY_PORT}"
+    sleep infinity &
+    wait $!
+}
+
+# --------------------------------------------------------------- lifecycle ---
+
 shutdown() {
     trap - SIGTERM SIGINT
     rm -f "${READY_FLAG}"
     # BE first, so it stops reporting to an FE that is about to go away.
     stop_one BE "${BE_PID}"
     stop_one FE "${FE_PID}"
+    stop_one "${DORIS_ROLE}" "${MS_PID}"
     exit 0
 }
 
-# ------------------------------------------------------------------ main ----
 main() {
     trap shutdown SIGTERM SIGINT
     rm -f "${READY_FLAG}"
-
-    apply_env_overrides
-    start_fe
-    wait_fe_ready
-    start_be
-    register_be
-    wait_be_alive
-
-    touch "${READY_FLAG}"
-    info "cluster is ready -- mysql -uroot -h127.0.0.1 -P${FE_QUERY_PORT}"
-
-    # Park here until something dies. `wait -n` also returns when a trap fires,
-    # so the explicit re-check below distinguishes the two cases.
-    local rc=0
-    wait -n "${FE_PID}" "${BE_PID}" || rc=$?
-    rm -f "${READY_FLAG}"
-
-    if ! kill -0 "${FE_PID}" 2>/dev/null; then
-        die "FE exited (rc=${rc}), see ${FE_HOME}/log/fe.log"
-    fi
-    die "BE exited (rc=${rc}), see ${BE_HOME}/log/be.INFO and 
${BE_HOME}/log/be.out"
+    case "${DEPLOY_MODE}" in local|cloud) ;; *) die "bad 
DEPLOY_MODE=${DEPLOY_MODE}, expected local|cloud" ;; esac
+    info "role=${DORIS_ROLE} mode=${DEPLOY_MODE} ip=${MY_IP}"
+    case "${DORIS_ROLE}" in
+        all)         run_all ;;
+        fe)          run_fe ;;
+        be)          run_be ;;
+        ms|recycler) run_ms ;;
+        cloud-init)  run_cloud_init ;;
+        client)      run_client ;;
+        *) die "unknown DORIS_ROLE=${DORIS_ROLE}" ;;
+    esac
 }
 
 main "$@"
diff --git a/docker/runtime/all-in-one/4.1/resource/health_check.sh 
b/docker/runtime/all-in-one/4.1/resource/health_check.sh
index 16eaeebc18f..86d881761a8 100755
--- a/docker/runtime/all-in-one/4.1/resource/health_check.sh
+++ b/docker/runtime/all-in-one/4.1/resource/health_check.sh
@@ -16,26 +16,25 @@
 # specific language governing permissions and limitations
 # under the License.
 #
-# Backs the image HEALTHCHECK. Downstream CI waits on the resulting docker
-# health status instead of sleeping.
+# Backs the image HEALTHCHECK. The entrypoint drops a ready flag once its
+# role has come up; from then on the role's own endpoint has to keep
+# answering, so a dead process turns the container unhealthy.
 
 set -uo pipefail
+CI_HOME="${CI_HOME:-/opt/doris-ci}"
+# shellcheck source=lib.sh
+source "${CI_HOME}/lib.sh"
 
-DORIS_HOME="${DORIS_HOME:-/opt/apache-doris}"
-HOST=127.0.0.1
-FE_HTTP_PORT="${FE_HTTP_PORT:-8030}"
-BE_HTTP_PORT="${BE_HTTP_PORT:-8040}"
-
-# Bootstrap not finished yet: FE may answer while the backend is still being
-# registered, and a test that connects then sees a cluster with no capacity.
-[[ -f "${DORIS_HOME}/.ready" ]] || exit 1
-
-# FE readiness and backend liveness in one request: HealthAction returns 503
-# until FE is ready, and online_backend_num once it is.
-curl -fsS --max-time 4 "http://${HOST}:${FE_HTTP_PORT}/api/health"; 2>/dev/null 
\
-    | grep -qE '"online_backend_num"[[:space:]]*:[[:space:]]*[1-9]' || exit 1
-
-# The BE http port also serves stream load, so check it directly.
-curl -fsS --max-time 4 "http://${HOST}:${BE_HTTP_PORT}/api/health"; >/dev/null 
2>&1 || exit 1
-
+[[ -f "${READY_FLAG}" ]] || exit 1
+case "${DORIS_ROLE}" in
+    all)
+        fe_health 127.0.0.1 | grep -qE 
'"online_backend_num"[[:space:]]*:[[:space:]]*[1-9]' || exit 1
+        curl -fsS --max-time 4 "http://127.0.0.1:${BE_HTTP_PORT}/api/health"; 
>/dev/null 2>&1 || exit 1
+        ;;
+    fe)          fe_health 127.0.0.1 >/dev/null || exit 1 ;;
+    be)          curl -fsS --max-time 4 
"http://127.0.0.1:${BE_HTTP_PORT}/api/health"; >/dev/null 2>&1 || exit 1 ;;
+    ms|recycler) curl -fsS --max-time 4 "http://127.0.0.1:${MS_PORT}/health"; 
>/dev/null 2>&1 || exit 1 ;;
+    client)      ;;
+    *)           exit 1 ;;
+esac
 exit 0
diff --git a/docker/runtime/all-in-one/4.1/resource/lib.sh 
b/docker/runtime/all-in-one/4.1/resource/lib.sh
new file mode 100644
index 00000000000..b9a1fba803f
--- /dev/null
+++ b/docker/runtime/all-in-one/4.1/resource/lib.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# Shared helpers for entrypoint.sh and health_check.sh. Sourced, not run.
+
+DORIS_HOME="${DORIS_HOME:-/opt/apache-doris}"
+FE_HOME="${DORIS_HOME}/fe"
+BE_HOME="${DORIS_HOME}/be"
+MS_HOME="${DORIS_HOME}/ms"
+READY_FLAG="${DORIS_HOME}/.ready"
+
+# What this container is. See the header of entrypoint.sh for the list.
+DORIS_ROLE="${DORIS_ROLE:-all}"
+# local = storage and compute on the BEs; cloud = BEs are compute nodes over a
+# meta-service and an object store.
+DEPLOY_MODE="${DEPLOY_MODE:-local}"
+
+# Ports are the Doris defaults. They only need changing when several nodes
+# share one network namespace, which none of the shipped topologies do.
+FE_HTTP_PORT="${FE_HTTP_PORT:-8030}"
+FE_QUERY_PORT="${FE_QUERY_PORT:-9030}"
+FE_EDIT_LOG_PORT="${FE_EDIT_LOG_PORT:-9010}"
+BE_HTTP_PORT="${BE_HTTP_PORT:-8040}"
+BE_HEARTBEAT_PORT="${BE_HEARTBEAT_PORT:-9050}"
+MS_PORT="${MS_PORT:-5000}"
+
+# The master FE other nodes register with. Empty on an FE means "I am the
+# first FE and bootstrap the cluster". A hostname resolved by docker DNS works
+# as well as an IP.
+FE_MASTER="${FE_MASTER:-}"
+
+START_TIMEOUT="${START_TIMEOUT:-300}"
+STOP_TIMEOUT="${STOP_TIMEOUT:-30}"
+
+log()  { printf '%s [%-5s] [%s] %s\n' "$(date -Iseconds)" "$1" "${DORIS_ROLE}" 
"${*:2}"; }
+info() { log INFO "$@"; }
+warn() { log WARN "$@" >&2; }
+die()  { log ERROR "$@" >&2; exit 1; }
+
+# The address this node is known by. Single-container mode binds everything
+# to loopback; every other role uses the container's first IP, which the
+# compose files pin so that a restarted container keeps its identity.
+my_ip() {
+    if [[ "${DORIS_ROLE}" == all ]]; then
+        echo 127.0.0.1
+    else
+        hostname -i | awk '{print $1}'
+    fi
+}
+
+# sql <host> <statement>: root, no password, batch output, no header.
+sql() {
+    mysql -uroot -h"$1" -P"${FE_QUERY_PORT}" -N --batch --connect-timeout=2 -e 
"$2" 2>/dev/null
+}
+
+# The node probes go through SHOW FRONTENDS / SHOW BACKENDS, which FE answers
+# on its own. The frontends() / backends() table functions look handier but
+# are queries, and a query needs a live BE to scan -- which is the very thing
+# being waited for. Columns are picked by header name, not position.
+
+# node_alive <fe host> <frontends|backends> <node ip> <port>: prints the
+# node's Alive column (true/false), nothing when the node is not listed.
+node_alive() {
+    local fe=$1 what=$2 ip=$3 port=$4 portcol
+    case "${what}" in
+        frontends) portcol=EditLogPort ;;
+        backends)  portcol=HeartbeatPort ;;
+    esac
+    mysql -uroot -h"${fe}" -P"${FE_QUERY_PORT}" --batch --connect-timeout=2 -e 
"show ${what}" 2>/dev/null \
+        | awk -F'\t' -v ip="${ip}" -v port="${port}" -v pc="${portcol}" '
+            NR == 1 { for (i = 1; i <= NF; i++) col[$i] = i; next }
+            $col["Host"] == ip && $col[pc] == port { print $col["Alive"]; exit 
}'
+}
+
+# alive_count <fe host> <frontends|backends>: how many nodes report Alive.
+alive_count() {
+    mysql -uroot -h"$1" -P"${FE_QUERY_PORT}" --batch --connect-timeout=2 -e 
"show $2" 2>/dev/null \
+        | awk -F'\t' 'NR == 1 { for (i = 1; i <= NF; i++) col[$i] = i; next }
+                      $col["Alive"] == "true" { n++ } END { print n + 0 }'
+}
+
+# Public FE endpoint (HealthAction): 503 until FE is ready, otherwise a body
+# carrying online_backend_num. curl -f turns the 503 into a non-zero exit.
+fe_health() {
+    curl -fsS --max-time 4 "http://$1:${FE_HTTP_PORT}/api/health"; 2>/dev/null
+}
+
+# wait_until <seconds> <description> <command...>: poll the command once a
+# second until it succeeds; die when the deadline passes.
+wait_until() {
+    local timeout=$1 what=$2; shift 2
+    local deadline=$((SECONDS + timeout))
+    while ((SECONDS < deadline)); do
+        if "$@"; then return 0; fi
+        sleep 1
+    done
+    die "timed out after ${timeout}s waiting for ${what}"
+}
+
+# render_conf <conf file> <block>: rewrite the file as the shipped copy plus
+# the block, so that a restarted container (same layer, entrypoint run again)
+# does not keep appending. The shipped copy is kept next to it on first run.
+# Doris takes the last assignment of a key, which is what lets the block win
+# over the upstream defaults above it.
+render_conf() {
+    local conf=$1 block=$2
+    [[ -f "${conf}.orig" ]] || cp "${conf}" "${conf}.orig"
+    { cat "${conf}.orig"; printf '\n# --- generated by %s at container start 
---\n%s\n' "$(basename "$0")" "${block}"; } >"${conf}"
+}
+
+# set_heap <conf file> <size>: the JVM heap lives inside a long JAVA_OPTS line
+# that also carries every --add-opens FE needs on JDK 17. Rewriting only the
+# -Xmx/-Xms tokens keeps the rest of that line as shipped.
+set_heap() {
+    sed -i -E "s/-Xmx[0-9]+[kKmMgG]/-Xmx$2/g; s/-Xms[0-9]+[kKmMgG]/-Xms$2/g" 
"$1"
+}
+
+# Kill a background job by pid, gracefully first. Negative pid signals the
+# whole process group, which is where the real java / doris_be process lives.
+stop_one() {
+    local name=$1 pid=$2
+    [[ -n "${pid}" ]] || return 0
+    kill -0 "${pid}" 2>/dev/null || return 0
+    info "stopping ${name}"
+    kill -TERM -"${pid}" 2>/dev/null || kill -TERM "${pid}" 2>/dev/null || true
+    local deadline=$((SECONDS + STOP_TIMEOUT))
+    while ((SECONDS < deadline)); do
+        kill -0 "${pid}" 2>/dev/null || { info "${name} stopped"; return 0; }
+        sleep 1
+    done
+    warn "${name} did not stop within ${STOP_TIMEOUT}s, killing"
+    kill -KILL -"${pid}" 2>/dev/null || kill -KILL "${pid}" 2>/dev/null || true
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to