fast failover
This commit is contained in:
20
client/dev.Dockerfile
Normal file
20
client/dev.Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
ENV USER="node"
|
||||||
|
|
||||||
|
RUN addgroup -g 1000 $USER && \
|
||||||
|
adduser -D -u 1000 -G $USER $USER || true
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["pnpm", "dev", "--host"]
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { UserInput } from "./game/UserInput";
|
import { UserInput } from "./game/UserInput";
|
||||||
import { BoardDisplay } from "./game/BoardDisplay";
|
import { BoardDisplay } from "./game/BoardDisplay";
|
||||||
import { ConnectionStatus } from "./game/ConnectionStatus";
|
import { ConnectionStatus } from "./game/ConnectionStatus";
|
||||||
|
import { NameInput } from "./game/NameInput";
|
||||||
|
|
||||||
|
const getPlayerNameFromUrl = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get("name") || null;
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [playerName, setPlayerName] = useState<string | null>(
|
||||||
|
getPlayerNameFromUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!playerName) {
|
||||||
|
return <NameInput onNameSubmit={setPlayerName} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserInput />
|
<UserInput playerName={playerName} />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "100vw",
|
width: "100vw",
|
||||||
@@ -17,7 +32,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
<BoardDisplay />
|
<BoardDisplay playerName={playerName} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface GameChannelContextValue {
|
|||||||
channel: Channel | null;
|
channel: Channel | null;
|
||||||
channelStatus: string;
|
channelStatus: string;
|
||||||
isJoined: boolean;
|
isJoined: boolean;
|
||||||
|
joinGame: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameChannelContext = createContext<GameChannelContextValue | undefined>(
|
const GameChannelContext = createContext<GameChannelContextValue | undefined>(
|
||||||
@@ -40,18 +41,15 @@ export function GameChannelProvider({
|
|||||||
.join()
|
.join()
|
||||||
.receive("ok", () => {
|
.receive("ok", () => {
|
||||||
console.log(`✓ Joined channel: ${channelName}`);
|
console.log(`✓ Joined channel: ${channelName}`);
|
||||||
setChannelStatus("joined");
|
setChannelStatus("connected");
|
||||||
setIsJoined(true);
|
|
||||||
})
|
})
|
||||||
.receive("error", (resp: unknown) => {
|
.receive("error", (resp: unknown) => {
|
||||||
console.log(`✗ Failed to join ${channelName}:`, resp);
|
console.log(`✗ Failed to join ${channelName}:`, resp);
|
||||||
setChannelStatus("join failed");
|
setChannelStatus("join failed");
|
||||||
setIsJoined(false);
|
|
||||||
})
|
})
|
||||||
.receive("timeout", () => {
|
.receive("timeout", () => {
|
||||||
console.log(`✗ Timeout joining ${channelName}`);
|
console.log(`✗ Timeout joining ${channelName}`);
|
||||||
setChannelStatus("timeout");
|
setChannelStatus("timeout");
|
||||||
setIsJoined(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set channel asynchronously to avoid cascading renders
|
// Set channel asynchronously to avoid cascading renders
|
||||||
@@ -62,15 +60,34 @@ export function GameChannelProvider({
|
|||||||
newChannel.leave();
|
newChannel.leave();
|
||||||
setChannel(null);
|
setChannel(null);
|
||||||
setIsJoined(false);
|
setIsJoined(false);
|
||||||
|
setChannelStatus("waiting");
|
||||||
};
|
};
|
||||||
}, [socket, isConnected, channelName, params]);
|
}, [socket, isConnected, channelName, params]);
|
||||||
|
|
||||||
|
const joinGame = (name: string) => {
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
channel
|
||||||
|
.push("join_game", { name })
|
||||||
|
.receive("ok", () => {
|
||||||
|
console.log(`✓ Joined game as: ${name}`);
|
||||||
|
setChannelStatus("joined");
|
||||||
|
setIsJoined(true);
|
||||||
|
})
|
||||||
|
.receive("error", (resp: unknown) => {
|
||||||
|
console.log(`✗ Failed to join game:`, resp);
|
||||||
|
setChannelStatus("join failed");
|
||||||
|
setIsJoined(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameChannelContext.Provider
|
<GameChannelContext.Provider
|
||||||
value={{
|
value={{
|
||||||
channel,
|
channel,
|
||||||
channelStatus,
|
channelStatus,
|
||||||
isJoined,
|
isJoined,
|
||||||
|
joinGame,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,34 +7,35 @@ interface Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
[playerId: string]: Player;
|
[playerName: string]: Player;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardDisplay = () => {
|
export const BoardDisplay = ({ playerName }: {
|
||||||
const { channel, isJoined } = useGameChannelContext();
|
playerName: string;
|
||||||
|
}) => {
|
||||||
|
const { channel, isJoined, joinGame } = useGameChannelContext();
|
||||||
const [players, setPlayers] = useState<GameState>({});
|
const [players, setPlayers] = useState<GameState>({});
|
||||||
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!channel || !isJoined) return;
|
if (channel && !isJoined) {
|
||||||
|
// Send join_game message with player name
|
||||||
|
joinGame(playerName);
|
||||||
|
}
|
||||||
|
}, [channel, isJoined, playerName, joinGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
// Listen for game state updates
|
// Listen for game state updates
|
||||||
const ref = channel.on("game_state", (payload: { players: GameState }) => {
|
const ref = channel.on("game_state", (payload: { players: GameState }) => {
|
||||||
setPlayers(payload.players);
|
setPlayers(payload.players);
|
||||||
|
|
||||||
if (!myPlayerId && Object.keys(payload.players).length > 0) {
|
|
||||||
const playerIds = Object.keys(payload.players);
|
|
||||||
if (playerIds.length > 0) {
|
|
||||||
setMyPlayerId(playerIds[playerIds.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup listener on unmount
|
// Cleanup listener on unmount
|
||||||
return () => {
|
return () => {
|
||||||
channel.off("game_state", ref);
|
channel.off("game_state", ref);
|
||||||
};
|
};
|
||||||
}, [channel, isJoined, myPlayerId]);
|
}, [channel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -48,9 +49,9 @@ export const BoardDisplay = () => {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(players).map(([id, player]) => (
|
{Object.entries(players).map(([name, player]) => (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={name}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: player.x,
|
left: player.x,
|
||||||
@@ -58,30 +59,28 @@ export const BoardDisplay = () => {
|
|||||||
width: "20px",
|
width: "20px",
|
||||||
height: "20px",
|
height: "20px",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: id === myPlayerId ? "#e94560" : "#53a8b6",
|
background: name === playerName ? "#e94560" : "#53a8b6",
|
||||||
border:
|
border:
|
||||||
id === myPlayerId ? "3px solid #ff6b6b" : "2px solid #48d6e0",
|
name === playerName ? "3px solid #ff6b6b" : "2px solid #48d6e0",
|
||||||
transition: "all 0.1s linear",
|
transition: "all 0.1s linear",
|
||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
boxShadow:
|
boxShadow:
|
||||||
id === myPlayerId ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
|
name === playerName ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{id === myPlayerId && (
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
position: "absolute",
|
||||||
position: "absolute",
|
top: "-25px",
|
||||||
top: "-25px",
|
left: "50%",
|
||||||
left: "50%",
|
transform: "translateX(-50%)",
|
||||||
transform: "translateX(-50%)",
|
color: "#fff",
|
||||||
color: "#fff",
|
fontSize: "10px",
|
||||||
fontSize: "10px",
|
whiteSpace: "nowrap",
|
||||||
whiteSpace: "nowrap",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{name}
|
||||||
You
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
105
client/src/game/NameInput.tsx
Normal file
105
client/src/game/NameInput.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface NameInputProps {
|
||||||
|
onNameSubmit: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNameFromUrl = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get("name") || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NameInput = ({ onNameSubmit }: NameInputProps) => {
|
||||||
|
const [name, setName] = useState(getNameFromUrl);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name.trim()) {
|
||||||
|
// Update URL with name
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set("name", name.trim());
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
onNameSubmit(name.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: "#1a1a2e",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#16213e",
|
||||||
|
padding: "40px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
border: "2px solid #0f3460",
|
||||||
|
maxWidth: "400px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: "#e94560",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "24px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enter Your Name
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name..."
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
fontSize: "16px",
|
||||||
|
background: "#0f3460",
|
||||||
|
border: "2px solid #53a8b6",
|
||||||
|
borderRadius: "5px",
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
marginBottom: "20px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
fontSize: "16px",
|
||||||
|
background: name.trim() ? "#e94560" : "#666",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "5px",
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
cursor: name.trim() ? "pointer" : "not-allowed",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join Game
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useGameChannelContext } from "../contexts/useGameChannelContext";
|
import { useGameChannelContext } from "../contexts/useGameChannelContext";
|
||||||
|
|
||||||
export const UserInput = () => {
|
export const UserInput = ({ playerName }: { playerName: string }) => {
|
||||||
const { channel } = useGameChannelContext();
|
const { channel } = useGameChannelContext();
|
||||||
const keysPressed = useRef<Set<string>>(new Set());
|
const keysPressed = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -12,16 +12,7 @@ export const UserInput = () => {
|
|||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
if (["w", "a", "s", "d"].includes(key)) {
|
if (["w", "a", "s", "d"].includes(key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
keysPressed.current.add(key);
|
||||||
// Only send if not already pressed (prevent repeat)
|
|
||||||
if (!keysPressed.current.has(key)) {
|
|
||||||
keysPressed.current.add(key);
|
|
||||||
|
|
||||||
// Send to active channel
|
|
||||||
if (channel.state === "joined") {
|
|
||||||
channel.push("move", { direction: key });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,14 +23,28 @@ export const UserInput = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
keysPressed.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (keysPressed.current.size > 0 && channel.state === "joined") {
|
||||||
|
const directions = Array.from(keysPressed.current);
|
||||||
|
channel.push("move", { directions, name: playerName });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
window.addEventListener("blur", handleBlur);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
window.removeEventListener("blur", handleBlur);
|
||||||
};
|
};
|
||||||
}, [channel]);
|
}, [channel, playerName]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
109
docker-compose.prod.yml
Normal file
109
docker-compose.prod.yml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
services:
|
||||||
|
phoenix1:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: phoenix1
|
||||||
|
hostname: phoenix1
|
||||||
|
environment:
|
||||||
|
- RELEASE_NODE=backend@phoenix1
|
||||||
|
- RELEASE_DISTRIBUTION=sname
|
||||||
|
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
||||||
|
- PHX_HOST=localhost
|
||||||
|
- PHX_SERVER=true
|
||||||
|
- PORT=4000
|
||||||
|
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
||||||
|
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
||||||
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||||
|
ports:
|
||||||
|
- "4001:4000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
phoenix2:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: phoenix2
|
||||||
|
hostname: phoenix2
|
||||||
|
environment:
|
||||||
|
- RELEASE_NODE=backend@phoenix2
|
||||||
|
- RELEASE_DISTRIBUTION=sname
|
||||||
|
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
||||||
|
- PHX_HOST=localhost
|
||||||
|
- PHX_SERVER=true
|
||||||
|
- PORT=4000
|
||||||
|
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
||||||
|
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
||||||
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||||
|
ports:
|
||||||
|
- "4002:4000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
phoenix3:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: phoenix3
|
||||||
|
hostname: phoenix3
|
||||||
|
environment:
|
||||||
|
- RELEASE_NODE=backend@phoenix3
|
||||||
|
- RELEASE_DISTRIBUTION=sname
|
||||||
|
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
||||||
|
- PHX_HOST=localhost
|
||||||
|
- PHX_SERVER=true
|
||||||
|
- PORT=4000
|
||||||
|
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
||||||
|
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
||||||
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||||
|
ports:
|
||||||
|
- "4003:4000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
nginx-lb:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nginx-lb
|
||||||
|
volumes:
|
||||||
|
- ./nginx-lb.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
ports:
|
||||||
|
- "4000:80"
|
||||||
|
depends_on:
|
||||||
|
- phoenix1
|
||||||
|
- phoenix2
|
||||||
|
- phoenix3
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: ./client
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: client
|
||||||
|
ports:
|
||||||
|
- "5173:80"
|
||||||
|
depends_on:
|
||||||
|
- nginx-lb
|
||||||
|
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
|
container_name: otel-collector
|
||||||
|
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||||
|
ports:
|
||||||
|
- "4318:4318" # OTLP HTTP receiver
|
||||||
|
- "4317:4317" # OTLP gRPC receiver
|
||||||
|
- "8888:8888" # Prometheus metrics
|
||||||
|
- "8889:8889" # Prometheus exporter metrics
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
phoenix1:
|
phoenix1:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: dev.Dockerfile
|
||||||
container_name: phoenix1
|
container_name: phoenix1
|
||||||
hostname: phoenix1
|
hostname: phoenix1
|
||||||
environment:
|
environment:
|
||||||
- RELEASE_NODE=backend@phoenix1
|
|
||||||
- RELEASE_DISTRIBUTION=sname
|
|
||||||
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
|
||||||
- PHX_HOST=localhost
|
|
||||||
- PHX_SERVER=true
|
- PHX_SERVER=true
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
- MIX_ENV=dev
|
||||||
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
- NODE_NAME=backend@phoenix1
|
||||||
|
- COOKIE=dev_cookie
|
||||||
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
user: root
|
||||||
ports:
|
command: |
|
||||||
- "4001:4000"
|
sh -c '
|
||||||
|
chown -R elixir:elixir /app/_build
|
||||||
|
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
|
||||||
|
'
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/_build
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -29,22 +30,25 @@ services:
|
|||||||
phoenix2:
|
phoenix2:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: dev.Dockerfile
|
||||||
container_name: phoenix2
|
container_name: phoenix2
|
||||||
hostname: phoenix2
|
hostname: phoenix2
|
||||||
environment:
|
environment:
|
||||||
- RELEASE_NODE=backend@phoenix2
|
|
||||||
- RELEASE_DISTRIBUTION=sname
|
|
||||||
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
|
||||||
- PHX_HOST=localhost
|
|
||||||
- PHX_SERVER=true
|
- PHX_SERVER=true
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
- MIX_ENV=dev
|
||||||
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
- NODE_NAME=backend@phoenix2
|
||||||
|
- COOKIE=dev_cookie
|
||||||
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
user: root
|
||||||
ports:
|
command: |
|
||||||
- "4002:4000"
|
sh -c '
|
||||||
|
chown -R elixir:elixir /app/_build
|
||||||
|
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
|
||||||
|
'
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/_build
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -54,22 +58,25 @@ services:
|
|||||||
phoenix3:
|
phoenix3:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: dev.Dockerfile
|
||||||
container_name: phoenix3
|
container_name: phoenix3
|
||||||
hostname: phoenix3
|
hostname: phoenix3
|
||||||
environment:
|
environment:
|
||||||
- RELEASE_NODE=backend@phoenix3
|
|
||||||
- RELEASE_DISTRIBUTION=sname
|
|
||||||
- RELEASE_COOKIE=super_secret_cookie_change_in_production
|
|
||||||
- PHX_HOST=localhost
|
|
||||||
- PHX_SERVER=true
|
- PHX_SERVER=true
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
|
- MIX_ENV=dev
|
||||||
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
|
- NODE_NAME=backend@phoenix3
|
||||||
|
- COOKIE=dev_cookie
|
||||||
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
user: root
|
||||||
ports:
|
command: |
|
||||||
- "4003:4000"
|
sh -c '
|
||||||
|
chown -R elixir:elixir /app/_build
|
||||||
|
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
|
||||||
|
'
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/_build
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -91,10 +98,12 @@ services:
|
|||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: ./client
|
||||||
dockerfile: Dockerfile
|
dockerfile: dev.Dockerfile
|
||||||
container_name: client
|
container_name: client
|
||||||
|
volumes:
|
||||||
|
- ./client:/app
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
- nginx-lb
|
- nginx-lb
|
||||||
|
|
||||||
@@ -104,8 +113,4 @@ services:
|
|||||||
command: ["--config=/etc/otel-collector-config.yaml"]
|
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||||
ports:
|
|
||||||
- "4318:4318" # OTLP HTTP receiver
|
|
||||||
- "4317:4317" # OTLP gRPC receiver
|
|
||||||
- "8888:8888" # Prometheus metrics
|
|
||||||
- "8889:8889" # Prometheus exporter metrics
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
upstream phoenix_backend {
|
upstream phoenix_backend {
|
||||||
# Hash based on WebSocket handshake key for sticky sessions
|
|
||||||
# Note: Each new connection gets a new key, so reconnections may route differently
|
|
||||||
hash $http_sec_websocket_key consistent;
|
hash $http_sec_websocket_key consistent;
|
||||||
|
|
||||||
server phoenix1:4000 max_fails=1 fail_timeout=10s;
|
server phoenix1:4000 max_fails=1 fail_timeout=10s;
|
||||||
@@ -22,6 +20,11 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
|
||||||
|
proxy_next_upstream_tries 1;
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api {
|
location /api {
|
||||||
@@ -31,6 +34,11 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
|
||||||
|
proxy_next_upstream_tries 3;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
Reference in New Issue
Block a user