|
| 1 | +volumes: |
| 2 | + cert-data: |
| 3 | + shared_conf: |
| 4 | + |
| 5 | +services: |
| 6 | + dstack-ingress: |
| 7 | + build: |
| 8 | + context: . |
| 9 | + dockerfile_inline: | |
| 10 | + FROM dstack-ingress:latest |
| 11 | + RUN apt-get update && \ |
| 12 | + apt-get install -y --no-install-recommends inotify-tools && \ |
| 13 | + rm -rf /var/lib/apt/lists/* |
| 14 | + restart: unless-stopped |
| 15 | + volumes: |
| 16 | + - /var/run/dstack.sock:/var/run/dstack.sock |
| 17 | + - cert-data:/etc/letsencrypt |
| 18 | + - shared_conf:/shared/conf.d |
| 19 | + ports: |
| 20 | + - 443:443 |
| 21 | + environment: |
| 22 | + DNS_PROVIDER: route53 |
| 23 | + AWS_REGION: ${ROUTE53_AWS_REGION} |
| 24 | + AWS_ROLE_ARN: ${ROUTE53_AWS_ROLE_ARN} |
| 25 | + AWS_ACCESS_KEY_ID: ${ROUTE53_AWS_ACCESS_KEY_ID} |
| 26 | + AWS_SECRET_ACCESS_KEY: ${ROUTE53_AWS_SECRET_ACCESS_KEY} |
| 27 | + GATEWAY_DOMAIN: npw.${DSTACK_GATEWAY_DOMAIN} |
| 28 | + CERTBOT_EMAIL: ${CERTBOT_EMAIL} |
| 29 | + CERTBOT_STAGING: 'false' |
| 30 | + SET_CAA: 'true' |
| 31 | + PROXY_BUFFER_SIZE: 128k |
| 32 | + PROXY_BUFFERS: 4 256k |
| 33 | + PROXY_BUSY_BUFFERS_SIZE: 256k |
| 34 | + DOMAIN: ${MACHINE_NAME}.example.com |
| 35 | + ALIAS_DOMAIN: app.example.com |
| 36 | + TARGET_ENDPOINT: http://dynamic_backends |
| 37 | + ROUTE53_INITIAL_WEIGHT: 0 |
| 38 | + configs: |
| 39 | + - source: nginx_master_config |
| 40 | + target: /etc/nginx/nginx.conf |
| 41 | + - source: nginx_fallback |
| 42 | + target: /etc/nginx/conf.d/fallback.conf |
| 43 | + - source: nginx_entrypoint |
| 44 | + target: /nginx_entrypoint.sh |
| 45 | + mode: 0755 |
| 46 | + - source: nginx_mainloop |
| 47 | + target: /nginx_mainloop.sh |
| 48 | + mode: 0755 |
| 49 | + networks: |
| 50 | + - default |
| 51 | + - my-tasks |
| 52 | + entrypoint: ["/nginx_entrypoint.sh"] |
| 53 | + command: [] |
| 54 | + |
| 55 | + watcher: |
| 56 | + build: |
| 57 | + context: . |
| 58 | + dockerfile_inline: | |
| 59 | + FROM python:3.11-alpine |
| 60 | + RUN pip install --no-cache-dir docker |
| 61 | + CMD ["python", "-u", "/watcher.py"] |
| 62 | + volumes: |
| 63 | + - /var/run/docker.sock:/var/run/docker.sock |
| 64 | + - shared_conf:/shared/conf.d |
| 65 | + configs: |
| 66 | + - source: watcher_py |
| 67 | + target: /watcher.py |
| 68 | + |
| 69 | +configs: |
| 70 | + |
| 71 | + nginx_master_config: |
| 72 | + content: | |
| 73 | + # /etc/nginx/nginx.conf |
| 74 | + worker_processes auto; |
| 75 | +
|
| 76 | + events { |
| 77 | + worker_connections 1024; |
| 78 | + } |
| 79 | +
|
| 80 | + http { |
| 81 | + include /etc/nginx/mime.types; |
| 82 | + default_type application/octet-stream; |
| 83 | +
|
| 84 | + # Pull in upstream definitions |
| 85 | + include /shared/conf.d/upstreams.conf; |
| 86 | + |
| 87 | + # Pull in server blocks |
| 88 | + include /etc/nginx/conf.d/*.conf; |
| 89 | + } |
| 90 | +
|
| 91 | +
|
| 92 | + nginx_fallback: |
| 93 | + content: | |
| 94 | + # Internal Maintenance Fallback Server |
| 95 | + server { |
| 96 | + listen 127.0.0.1:9998; |
| 97 | + location / { |
| 98 | + add_header Content-Type text/plain; |
| 99 | + return 503 "System is scaling or deploying. Please refresh in a few seconds.\n"; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + nginx_entrypoint: |
| 104 | + content: | |
| 105 | + #!/bin/sh |
| 106 | + echo "setting up upstreams.conf" >&2 |
| 107 | + /bin/ls -lah /shared/conf.d |
| 108 | +
|
| 109 | + # starting - so clear out any old upstreams |
| 110 | + echo 'upstream dynamic_backends { server 127.0.0.1:9998; }' > /shared/conf.d/upstreams.conf |
| 111 | + cat /shared/conf.d/upstreams.conf >&2 |
| 112 | +
|
| 113 | + echo "execute phalas container setup" >&2 |
| 114 | + exec /scripts/entrypoint.sh /nginx_mainloop.sh |
| 115 | +
|
| 116 | + nginx_mainloop: |
| 117 | + content: | |
| 118 | + #!/bin/sh |
| 119 | +
|
| 120 | + echo "phalas container setup COMPLETE!" >&2 |
| 121 | + echo "start Nginx in background with server config: " >&2 |
| 122 | + cat /shared/conf.d/upstreams.conf >&2 |
| 123 | + nginx -c /etc/nginx/nginx.conf -g "daemon off; error_log /dev/stderr info;" & |
| 124 | + NGINX_PID=$$! |
| 125 | +
|
| 126 | + echo "TEE Ingress started. Watching for upstream changes..." |
| 127 | + # Block and watch file for atomic modifications |
| 128 | + while inotifywait -q -e close_write,moved_to /shared/conf.d; do |
| 129 | + echo "[$(date)] Upstream configuration updated. Reloading Nginx..." |
| 130 | + nginx -s reload |
| 131 | + done |
| 132 | + wait $$NGINX_PID |
| 133 | +
|
| 134 | +
|
| 135 | + # --- PYTHON WATCHER SCRIPT --- |
| 136 | + watcher_py: |
| 137 | + content: | |
| 138 | + import docker |
| 139 | + import os |
| 140 | + import time |
| 141 | +
|
| 142 | + client = docker.from_env() |
| 143 | + SHARED_FILE = "/shared/conf.d/upstreams.conf" |
| 144 | + LABEL_KEY = "phala.ingress.target" |
| 145 | + LABEL_VAL = "true" |
| 146 | + TASK_NETWORK = "my-tasks" |
| 147 | +
|
| 148 | + def ensure_on_task_network(c): |
| 149 | + """Attach container to the shared my-tasks network if not already on it.""" |
| 150 | + networks = c.attrs.get("NetworkSettings", {}).get("Networks", {}) |
| 151 | + # Find the network by suffix in case compose prefixes the project name |
| 152 | + matched = next((k for k in networks if k.endswith(TASK_NETWORK)), None) |
| 153 | + if matched: |
| 154 | + print(f" [net] {c.name} already on {matched}") |
| 155 | + return matched |
| 156 | + try: |
| 157 | + net = client.networks.list(filters={"name": TASK_NETWORK}) |
| 158 | + if not net: |
| 159 | + print(f" [net] ERROR: network '{TASK_NETWORK}' not found") |
| 160 | + return None |
| 161 | + net[0].connect(c) |
| 162 | + c.reload() |
| 163 | + print(f" [net] attached {c.name} to {net[0].name}") |
| 164 | + # Return actual network name after attachment |
| 165 | + networks = c.attrs.get("NetworkSettings", {}).get("Networks", {}) |
| 166 | + return next((k for k in networks if k.endswith(TASK_NETWORK)), None) |
| 167 | + except Exception as e: |
| 168 | + print(f" [net] failed to attach {c.name}: {e}") |
| 169 | + return None |
| 170 | +
|
| 171 | + def get_task_network_ip(c): |
| 172 | + """Get the container's IP on the my-tasks network.""" |
| 173 | + networks = c.attrs.get("NetworkSettings", {}).get("Networks", {}) |
| 174 | + net_name = next((k for k in networks if k.endswith(TASK_NETWORK)), None) |
| 175 | + if net_name: |
| 176 | + return networks[net_name].get("IPAddress") |
| 177 | + return None |
| 178 | +
|
| 179 | + def update_upstreams(): |
| 180 | + print("Reconciling Nginx upstream state...") |
| 181 | + containers = client.containers.list( |
| 182 | + filters={"label": f"{LABEL_KEY}={LABEL_VAL}", "status": "running"} |
| 183 | + ) |
| 184 | + print(f"found {len(containers)} containers") |
| 185 | +
|
| 186 | + config = "upstream dynamic_backends {\n" |
| 187 | + for c in containers: |
| 188 | + net_name = ensure_on_task_network(c) |
| 189 | + ip = get_task_network_ip(c) if net_name else None |
| 190 | + if not ip: |
| 191 | + print(f" [warn] skipping {c.name} — no IP on {TASK_NETWORK}") |
| 192 | + continue |
| 193 | + port = c.labels.get("phala.ingress.port", "8080") |
| 194 | + print(f" -> Enrolled: {c.name.lstrip('/')} on {net_name} ({ip}:{port})") |
| 195 | + # Add passive health checks (fails 3 times in 15s = temporarily dead) |
| 196 | + config += f" server {ip}:{port} max_fails=3 fail_timeout=15s;\n" |
| 197 | + |
| 198 | + if containers: |
| 199 | + config += " server 127.0.0.1:9998 backup;\n}\n" |
| 200 | + else: |
| 201 | + config += " server 127.0.0.1:9998;\n}\n" |
| 202 | + |
| 203 | + # Atomic write to trigger inotify cleanly |
| 204 | + temp_file = f"{SHARED_FILE}.tmp" |
| 205 | + with open(temp_file, "w") as f: |
| 206 | + f.write(config) |
| 207 | + os.replace(temp_file, SHARED_FILE) |
| 208 | + print("Nginx state successfully written.\n") |
| 209 | + print(f"{config}\n") |
| 210 | +
|
| 211 | + def main(): |
| 212 | + print("Starting Phala Python Docker Watcher...") |
| 213 | + update_upstreams() |
| 214 | +
|
| 215 | + events = client.events(decode=True, filters={"type": "container", "event": ["start", "die"]}) |
| 216 | + for event in events: |
| 217 | + attrs = event.get("Actor", {}).get("Attributes", {}) |
| 218 | + if attrs.get(LABEL_KEY) == LABEL_VAL: |
| 219 | + action = event.get("status") |
| 220 | + print(f"App container {action} detected!") |
| 221 | + # Slight delay ensures Docker DNS has fully registered the new container IP |
| 222 | + time.sleep(1) |
| 223 | + update_upstreams() |
| 224 | +
|
| 225 | + if __name__ == "__main__": |
| 226 | + main() |
0 commit comments