4. Mini Compose¶
A minimal Flask-based API that launches, monitors, and controls
multi-container applications defined in a docker-compose.yml
.
It can run the services with Docker, Singularity, or Apptainer
(from a single Python process) and exposes a small HTTP interface for
starting, stopping, and reloading the stack. In an Apptainer container environment, PortShift enables the deployment of multiple container groups on the same host.
4.1. Key Features¶
- Compose-aware launcher – Parses
docker-compose.yml
, resolvesdepends_on
, builds images when necessary, and starts containers in a correct order. - Multi-runtime support – Switch between
docker | singularity | apptainer
(default =apptainer
) at runtime via the/set_apptainer
endpoint. - Health-check loop – Executes
healthcheck.test
commands from compose and sets container status to HEALTHY / UNHEALTHY. - Environment injection – POST
/container_settings/
with JSON to pass arbitrary environment variables (including bind-mount settings) to every launched container. - Graceful shutdown –
/stop/<id>/
stops all running containers,/stopServer
terminates the Flask server itself. - Minimal UI – Root path (
/
) serves a tiny HTML page with Start, Stop, Reload, and Shutdown buttons—handy for manual testing. - PortShift (Optional) - When launching servers that use the same TCP port within Apptainer, port collisions occur, making simultaneous operation impossible. By integrating with the PortShift framework (Please refer to the latter part of this page), however, port numbers can be automatically offset to avoid conflicts, allowing multiple services to run concurrently. These offsets are assigned per container group, and both server and client processes are automatically given their corresponding port offsets.
4.2. Prerequisites¶
Install Python deps:
pip install flask pyyaml pytimeparse asyncio
4.3. Project Layout¶
your-app/
├── app.py # mini-compose script
├── docker-compose.yml # services definition – **required**
└── working_dir/ # Apptainer(Singularity) image cache / build context
app.py can be copied from $MINICOMPOSE_LIB
directory, which will be available after invoking module load mini-compose
4.4. Overview of Usage¶
Mini-Compose involves three main stages:
- Preparing the container images in a private environment where Docker / Docker-Compose are available
- Transferring the container images to TSUBAME 4.0
- Running the images on the TSUBAME 4.0 cluster
4.4.1. Create the Apptainer container images in a private Docker environment¶
- Prepare a
docker-compose.yml
file in your private environment. - Perform thorough testing with Docker-Compose.
- Switch Mini-Compose to Docker mode and run additional tests locally.
Please switch
runtime = 'docker'
(Please disableruntime = 'singularity'
andruntime = 'apptainer'
) in app.py - Enable image export and run Mini-Compose once more in Docker mode.
Please switch
is_save = True
in app.py. Container images for each service will be generated under theworking_dir
directory.
4.4.2. Move the images to TSUBAME 4.0¶
- Compress the
your-app
directory you created in the private environment (e.g., withtar
). - Transfer the archive to TSUBAME 4.0.
- Decompress it on TSUBAME 4.0.
4.4.3. Execute on TSUBAME 4.0¶
Every subsequent step is performed on TSUBAME 4.0 cluster.
- Prepare a script that assigns IP addresses and apply any minor image fixes if necessary.
Please switch
runtime = 'apptainer'
in app.py. - Create a job script that launches Mini-Compose on TSUBAME 4.0.
4.5. Running the Server¶
Choose an IP address of the host that containers should use to reach each other. The script appends this IP with every service name into a generated container_hosts file and bind-mounts it at /etc/hosts inside each container.
export HOST_IP=192.168.1.100 # ← adjust
Start the API:
python app.py "$HOST_IP"
0.0.0.0:5000
by default.
4.6. HTTP Endpoints¶
Method & Path | Description |
---|---|
GET / |
Serves a simple HTML control panel |
GET /reload/ |
Re-reads docker-compose.yml and rebuilds the dependency graph |
GET /set_apptainer/ |
Switch backend to Apptainer (same effect for Singularity/Docker if you edit the code) |
POST /container_settings/ |
JSON body of key-value pairs → exported as --env to every container |
GET /start/<id>/ |
Launches all services once (the <id> value is ignored except for the response text) |
GET /stop/<id>/ |
Sends docker … |
GET /status/<id>/ |
Dummy endpoint that just tells whether <id> is in the jobs dict |
GET /stopServer |
Terminates the Flask process (SIGINT) |
4.7. PortShift : System Overview¶
PortShift lets you run many copies of the same network-centric application on a single machine without editing their source code or hard-coding unique port numbers. It consists of two lightweight, cooperating components:
Component | Role | Runs as | Key Responsibilities |
---|---|---|---|
Port Offset Server | Central authority that hands out port-offset windows (100 ports each) on demand. | Stand-alone daemon on one TCP port (default 25000). | • Tracks { APP_GRP → offset } in memory.• Guarantees that every group receives a disjoint window. • Re-issues the same offset to repeat callers from the same group. |
LD_PRELOAD Port-Offset Library | Transparent shim that adds the assigned offset to every bind() /connect() made by the process. |
Injected into target process via LD_PRELOAD . |
• Queries the Offset Server at startup using environment variable APP_GRP .• Offsets IPv4 & IPv6 ports on both server and client sides. • Respects EXCEPT_PORTS to leave privileged or shared ports untouched.• (Optional) Rewrites file paths in open() so that each group also gets its own log/config directory. |
4.7.1. Why Port Offsetting?¶
Traditional approaches (Docker networks, separate VMs, hand-edited configs) carry non-trivial overhead or maintenance cost. Port offsetting offers a zero-footprint alternative that is ideal when you need to:
- Spin up dozens of integration-test stacks on CI runners.
- Allow different teams to share a jump-box while developing micro-services that talk to fixed “well-known” ports.
- Replay production traffic against multiple binary versions on the same host.
Because the offset is injected below the application layer, any language, framework, or binary—even closed-source—works unmodified.
4.7.2. High-Level Flow¶
- Startup – The shim contacts the Offset Server, sending its
APP_GRP
. - Assignment – The server returns the group’s unique offset (e.g., 100).
- Runtime – Every subsequent
bind()
orconnect()
call has the offset added transparently. - Re-use – Additional processes with the same
APP_GRP
skip allocation and immediately receive the cached value.
4.7.3. Key Properties¶
- Stateless clients – If the server restarts, clients simply re-query and continue.
- 100-port windows – Large enough for typical service stacks (HTTP, gRPC, DB, cache…).
- Collision-free – Offsets are multiples of 100, avoiding accidental overlap.
- Opt-out list – Critical ports (22/80/443…) can be excluded per process.
- IPv4 & IPv6 – Works uniformly across address families.
4.8. Port Offset Server¶
A lightweight TCP service that assigns per-application port-offsets at runtime.
Client processes (typically instrumented via an LD_PRELOAD
shim) connect to this
server, submit their APP_GRP name, and receive a numeric offset that is later
added to every bind()
/connect()
port.
This enables multiple instances of identical software stacks to coexist on the
same node without hard-coding unique port numbers.
4.8.1. How It Works¶
client (APP_GRP = "groupA") ─┐
client (APP_GRP = "groupB") ─┤──► Port-Offset-Server (port 25000)
client (APP_GRP = "groupA") ─┘ │
├── maintains {groupName → offset}
└── replies with offset (100, 200 …)
4.8.2. Running¶
module load mini-compose
port_offset_server # listens on 0.0.0.0:25000
4.8.3. Configuration Constants¶
Macro / Variable | Purpose | Default |
---|---|---|
SERVER_PORT | Listen port for the offset service | 25000 |
MAX_GROUPS | Max group entries in memory | 100 |
g_nextOffset | Starting offset for the first group | 100 |
g_nextInc | Size of each port window | 100 |
4.9. LD_PRELOAD Port-Offset Library¶
libpreload_port_offset.so
transparently adds a per-application port
offset to all bind()
and connect()
calls (IPv4 & IPv6) and can
optionally rewrite file paths on open()
.
Pair it with the Port Offset Server to launch many copies of the same service stack on one host—each isolated to its own window of 100 ports—without modifying source code.
4.9.1. Features¶
Hooked Call | Behaviour |
---|---|
bind() |
Adds offset to the requested port unless it is listed in EXCEPT_PORTS . |
connect() |
Likewise applies the offset when dialing remote services (skipped while talking to the offset server itself). |
4.9.2. Quick Start¶
4.9.2.1. Start the offset server (one per host)¶
module load mini-compose
port_offset_server &
4.9.2.2. Launch your application with LD_PRELOAD¶
module load mini-compose
export APP_GRP=myteam # assigns offset 100
export EXCEPT_PORTS=22,80,443 # leave SSH/HTTP unchanged
export LD_PRELOAD=$MINICOMPOSE_LIB/libpreload_port_offset.so
./my_server_binary
Every bind(…, 8080)
made by my_server_binary will be translated to
bind(…, 8180)
; a teammate starting the same binary with
APP_GRP=yourteam
would receive offset 200, and so on.
4.9.3. Environment Variables¶
Variable | Default | Description |
---|---|---|
APP_GRP | (unset) | Logical group name. Required—otherwise offset 0 is used. |
OFFSET_SERVER_ADDR | 127.0.0.1 | Hostname/IP of the Port Offset Server. |
EXCEPT_PORTS | (none) | Comma-separated list of ports that must not receive an offset. |
EXTENSIONS | (none) | Comma-separated file extensions (txt,log) that trigger path rewriting in open(). |