fix: prevent oversized BEAM port tables

This commit is contained in:
SimpleTest 2026-07-20 04:01:31 +03:00
parent 673f94315c
commit fdaef2d685
21 changed files with 346 additions and 1 deletions

View File

@ -20,6 +20,7 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD
DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e
POOL_SIZE=10 POOL_SIZE=10
ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE
HANDOVER_SECRET=GENERATE_INDEPENDENT_E2E_HANDOVER_SECRET HANDOVER_SECRET=GENERATE_INDEPENDENT_E2E_HANDOVER_SECRET

View File

@ -63,6 +63,10 @@ POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret
DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help
POOL_SIZE=10 POOL_SIZE=10
# Maximum simultaneously existing Erlang ports (files, sockets and drivers).
# Keeping this explicit prevents a host's very large nofile ulimit from making
# every BEAM instance preallocate a multi-gigabyte port table.
ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=generate-with-mix-phx-gen-secret SECRET_KEY_BASE=generate-with-mix-phx-gen-secret
HANDOVER_SECRET=generate-an-independent-random-secret HANDOVER_SECRET=generate-an-independent-random-secret
RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie

View File

@ -21,6 +21,7 @@ POSTGRES_USER=wnh_load
POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD
DATABASE_URL=GENERATE_DATABASE_URL DATABASE_URL=GENERATE_DATABASE_URL
POOL_SIZE=10 POOL_SIZE=10
ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE
HANDOVER_SECRET=GENERATE_HANDOVER_SECRET HANDOVER_SECRET=GENERATE_HANDOVER_SECRET
RELEASE_COOKIE=GENERATE_RELEASE_COOKIE RELEASE_COOKIE=GENERATE_RELEASE_COOKIE

View File

@ -82,8 +82,16 @@ Inspect the exact state:
docker compose -p who_need_help ps -a docker compose -p who_need_help ps -a
docker compose -p who_need_help logs -f web worker docker compose -p who_need_help logs -f web worker
./scripts/verify-realtime-cluster.sh compose ./scripts/verify-realtime-cluster.sh compose
./scripts/verify-beam-runtime.sh compose
``` ```
`ERLANG_PORT_LIMIT` defaults to the normal OTP port limit of `65536`. Keeping
it explicit prevents a container runtime's unusually large `nofile` limit from
making every BEAM instance preallocate a multi-gigabyte port table. The runtime
check above reads every running web/worker VM and fails if its effective limit
differs from the configured value. Increase it only from measured concurrent
file, socket, and driver requirements.
Create and restore-test a database backup without restoring over the source: Create and restore-test a database backup without restoring over the source:
```bash ```bash
@ -345,7 +353,7 @@ traffic. Supported actions are listed in `docs/trust-safety.md`.
The kind script downloads checksum-verified kubectl, Helm, and kind binaries The kind script downloads checksum-verified kubectl, Helm, and kind binaries
into `.tools/`, creates a project-owned cluster, loads the local image, and into `.tools/`, creates a project-owned cluster, loads the local image, and
installs the Helm chart, waits for both Deployments, and verifies cross-node installs the Helm chart, waits for both Deployments, and verifies cross-node
PubSub: PubSub and each BEAM VM's effective port limit:
```bash ```bash
./scripts/kind-up.sh ./scripts/kind-up.sh

View File

@ -7,6 +7,10 @@ x-app-environment: &app-environment
HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env} HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env}
RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env} RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env}
METRICS_TOKEN: ${METRICS_TOKEN:?Set an independent METRICS_TOKEN in .env} METRICS_TOKEN: ${METRICS_TOKEN:?Set an independent METRICS_TOKEN in .env}
# Keep BEAM from deriving a massive port table from a host/container nofile
# limit. OTP's normal default is 65,536; deployments can override it after
# measuring their concurrent file/socket requirements.
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536}"
DNS_CLUSTER_QUERY: web DNS_CLUSTER_QUERY: web
PHX_HOST: ${PHX_HOST:?Set PHX_HOST in .env} PHX_HOST: ${PHX_HOST:?Set PHX_HOST in .env}
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}

View File

@ -47,6 +47,8 @@ spec:
env: env:
- name: APP_ROLE - name: APP_ROLE
value: migrate value: migrate
- name: ERL_ZFLAGS
value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@ -65,6 +67,8 @@ spec:
env: env:
- name: APP_ROLE - name: APP_ROLE
value: {{ $component }} value: {{ $component }}
- name: ERL_ZFLAGS
value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }}
- name: PHX_SERVER - name: PHX_SERVER
value: {{ if eq $component "web" }}"true"{{ else }}"false"{{ end }} value: {{ if eq $component "web" }}"true"{{ else }}"false"{{ end }}
- name: PHX_HOST - name: PHX_HOST

View File

@ -33,6 +33,8 @@ spec:
env: env:
- name: APP_ROLE - name: APP_ROLE
value: migrate value: migrate
- name: ERL_ZFLAGS
value: {{ printf "+Q %d" (int .Values.app.erlangPortLimit) | quote }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true

View File

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"app": {
"type": "object",
"properties": {
"erlangPortLimit": {
"type": "integer",
"minimum": 1024,
"maximum": 134217727
}
},
"required": [
"erlangPortLimit"
]
}
}
}

View File

@ -20,6 +20,10 @@ app:
urlPort: "443" urlPort: "443"
port: "4000" port: "4000"
poolSize: "10" poolSize: "10"
# OTP otherwise derives this from the container nofile ulimit. Some
# Kubernetes runtimes expose a value so large that each BEAM instance
# preallocates a multi-gigabyte port table.
erlangPortLimit: 65536
codexSessionId: not-configured codexSessionId: not-configured
# Shared limits are opt-in; set only after product policy thresholds are approved. # Shared limits are opt-in; set only after product policy thresholds are approved.
rateLimitPoliciesJson: "{}" rateLimitPoliciesJson: "{}"

View File

@ -284,6 +284,28 @@ The rollout timeout, probe interval/timeout/retry count, and cluster-join
timeout are experiment inputs. They are not production SLOs or resource timeout are experiment inputs. They are not production SLOs or resource
requirements. requirements.
## BEAM runtime memory guard
Compose sets `ERL_ZFLAGS="+Q ${ERLANG_PORT_LIMIT}"`; Helm renders the same flag
from `app.erlangPortLimit`. Both default to `65536`, OTP's normal port-table
limit. This makes the runtime independent of an unusually large host or nested
container `nofile` limit. Validate all live replicas after deployment:
```bash
./scripts/verify-beam-runtime.sh compose
./scripts/verify-beam-runtime.sh kind
```
The command records the effective port count/limit, allocated port-table bytes,
BEAM memory, process count, cgroup memory, and RSS for each web and worker
replica. It fails when a live VM does not use the configured limit; kind mode
also requires the desired number of Ready application pods.
`65536` is a concurrency ceiling for simultaneously existing Erlang ports
(files, sockets, and drivers), not a container memory limit. Do not lower or
raise it from a RAM estimate alone. A changed value must be validated against
measured peak port usage and the target environment.
## Protected Prometheus metrics ## Protected Prometheus metrics
The web role exposes Prometheus text format at `/metrics`. It requires the The web role exposes Prometheus text format at `/metrics`. It requires the

View File

@ -5,6 +5,38 @@ pool size, or autoscaling threshold is known yet. The repository therefore
contains a reproducible measurement profile, not a capacity claim or blocking contains a reproducible measurement profile, not a capacity claim or blocking
resource preflight. resource preflight.
## Kind port-table memory investigation
On 2026-07-20, the local kind control-plane container used 9.748 GiB while its
four otherwise idle application `beam.smp` processes each had roughly
2.3 GiB RSS. Read-only runtime probes found an effective Erlang port limit of
134,217,727 and an allocated port table of 1,610,612,736 bytes in every kind
BEAM VM. The nested runtime exposed `nofile=2,147,483,584`; OTP had derived the
maximum port limit from that value.
The same release image in ordinary Compose used the normal 65,536 port limit
and a 786,432-byte port table. A separate VM probe with
`ERL_ZFLAGS="+Q 65536"` reproduced those smaller values inside the kind pod
before any rollout. Compose and Helm now set that value explicitly, and the
startup and rolling-verification scripts check the effective value in every
running web and worker VM.
After the Helm rollout, the unchanged kind control-plane container measured
1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups
measured 188.5215.6 MiB in the first post-change sample. A subsequent
four-pod rolling replacement retained the 65,536 limit and 786,432-byte table;
the replacement cgroups measured 194.3211.4 MiB, all four replacement pods
were Ready, cross-node PubSub passed, 184 readiness samples had no failed
sample after retry handling, and the application-table count diff was empty.
Evidence is retained under
`output/memory/20260720-port-table-fix-005420` and
`output/resilience/beam-port-limit-final-20260720`.
In the ordinary 2-web/2-worker Compose profile, the immediate post-recreation
sample measured 190.1205.5 MiB per application container and 168.3 MiB for
PostgreSQL. These local observations do not include a target server's OS and do
not establish that a 1 GiB or 2 GiB machine is sufficient for production.
The profile uses a separate Compose project, generated independent secrets, and The profile uses a separate Compose project, generated independent secrets, and
a separate PostgreSQL volume. Its Traefik instance is constrained to that exact a separate PostgreSQL volume. Its Traefik instance is constrained to that exact
Compose project; its router, service, middleware, and Host rule are unique, so Compose project; its router, service, middleware, and Host rule are unique, so

View File

@ -268,6 +268,16 @@ fixture cleanup. Neither short run found a saturation point or represents
production traffic, so the Helm chart does not invent resource limits or an HPA production traffic, so the Helm chart does not invent resource limits or an HPA
policy. policy.
The 2026-07-20 memory investigation did identify and remove a development
runtime artifact: kind's nested container runtime exposed a huge `nofile`
limit, causing each OTP VM to allocate a roughly 1.5 GiB port table. Explicit
`+Q 65536` configuration reduced the entire kind container from 9.748 GiB to
1.796 GiB. A later rolling replacement retained that value in all four pods,
left the application-table count diff empty, and passed readiness and
cross-node PubSub. This fixes that specific over-allocation; it is not a
production minimum-RAM measurement. Detailed evidence and per-container values
are recorded in `docs/performance.md`.
## Public staging observation ## Public staging observation
On 2026-07-18, `whoneedhelp.imalto.site` was published through the existing On 2026-07-18, `whoneedhelp.imalto.site` was published through the existing

View File

@ -159,6 +159,7 @@ POSTGRES_USER=$postgres_user
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password
DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db
POOL_SIZE=10 POOL_SIZE=10
ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=$secret_key_base SECRET_KEY_BASE=$secret_key_base
HANDOVER_SECRET=$handover_secret HANDOVER_SECRET=$handover_secret
RELEASE_COOKIE=$release_cookie RELEASE_COOKIE=$release_cookie
@ -315,6 +316,12 @@ curl --fail-with-body --silent --show-error \
COMPOSE_PROJECT_NAME="$project" \ COMPOSE_PROJECT_NAME="$project" \
./scripts/verify-realtime-cluster.sh compose ./scripts/verify-realtime-cluster.sh compose
) >"$output_dir/pubsub-probe.txt" ) >"$output_dir/pubsub-probe.txt"
(
cd "$workspace"
COMPOSE_PROJECT_NAME="$project" \
./scripts/verify-beam-runtime.sh compose \
"$output_dir/beam-runtime.json"
) >/dev/null
"${compose[@]}" exec -T web /app/bin/who_need_help rpc ' "${compose[@]}" exec -T web /app/bin/who_need_help rpc '
nodes = [node() | Node.list()] |> Enum.sort() nodes = [node() | Node.list()] |> Enum.sort()

View File

@ -7,3 +7,4 @@ CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-not-configured}}
export CODEX_SESSION_ID export CODEX_SESSION_ID
docker compose -p who_need_help up -d --build --wait docker compose -p who_need_help up -d --build --wait
"$ROOT/scripts/verify-realtime-cluster.sh" compose "$ROOT/scripts/verify-realtime-cluster.sh" compose
"$ROOT/scripts/verify-beam-runtime.sh" compose

View File

@ -59,6 +59,7 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password
DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e
POOL_SIZE=10 POOL_SIZE=10
ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=$secret_key_base SECRET_KEY_BASE=$secret_key_base
HANDOVER_SECRET=$handover_secret HANDOVER_SECRET=$handover_secret
RELEASE_COOKIE=$release_cookie RELEASE_COOKIE=$release_cookie

View File

@ -396,6 +396,8 @@ fi
>"$output_dir/cluster-after.txt" >"$output_dir/cluster-after.txt"
"$ROOT/scripts/verify-realtime-cluster.sh" kind \ "$ROOT/scripts/verify-realtime-cluster.sh" kind \
>"$output_dir/pubsub-after.txt" >"$output_dir/pubsub-after.txt"
"$ROOT/scripts/verify-beam-runtime.sh" kind \
"$output_dir/beam-runtime-after.json" >/dev/null
stop_probe stop_probe
probe_pid= probe_pid=

View File

@ -160,6 +160,7 @@ kube --namespace "$NAMESPACE" rollout status deployment/who-need-help-who-need-h
kube --namespace "$NAMESPACE" rollout status deployment/who-need-help-who-need-help-worker kube --namespace "$NAMESPACE" rollout status deployment/who-need-help-who-need-help-worker
remove_legacy_inline_secrets remove_legacy_inline_secrets
"$ROOT/scripts/verify-realtime-cluster.sh" kind "$ROOT/scripts/verify-realtime-cluster.sh" kind
"$ROOT/scripts/verify-beam-runtime.sh" kind
echo "Who Need Help: http://localhost:4011" echo "Who Need Help: http://localhost:4011"
echo "Mailpit: http://localhost:8028" echo "Mailpit: http://localhost:8028"

View File

@ -46,6 +46,7 @@ docker compose \
--scale "worker=$LOAD_WORKER_REPLICAS" --scale "worker=$LOAD_WORKER_REPLICAS"
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-beam-runtime.sh" compose
status=$( status=$(
curl --silent --show-error \ curl --silent --show-error \
--header "Host: $LOAD_HOST" \ --header "Host: $LOAD_HOST" \

View File

@ -69,6 +69,11 @@ docker run --rm \
echo "Rendering every Docker Compose profile" echo "Rendering every Docker Compose profile"
docker compose --env-file .env.example -f compose.yaml config --quiet docker compose --env-file .env.example -f compose.yaml config --quiet
docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status '
[.services.migrate, .services.web, .services.worker]
| all(.environment.ERL_ZFLAGS == "+Q 65536")
' >/dev/null
PORTABILITY_IMAGE=who-need-help:portability-render \ PORTABILITY_IMAGE=who-need-help:portability-render \
docker compose --env-file .env.example \ docker compose --env-file .env.example \
-f compose.yaml -f compose.portability.yaml config --quiet -f compose.yaml -f compose.portability.yaml config --quiet
@ -157,6 +162,12 @@ tar --extract --file="$scan_tar" --directory "$scan_dir"
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \ --values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
"$ROOT/deploy/helm/who-need-help" \ "$ROOT/deploy/helm/who-need-help" \
>"$scan_dir/rendered-helm.yaml" >"$scan_dir/rendered-helm.yaml"
test "$(
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
)" -eq 5
test "$(
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
)" -eq 5
mkdir -p "$ROOT/.tools/trivy-cache" mkdir -p "$ROOT/.tools/trivy-cache"
docker run --rm \ docker run --rm \
--volume "$scan_dir:/scan:ro" \ --volume "$scan_dir:/scan:ro" \

View File

@ -410,6 +410,9 @@ docker exec "$web_container" /app/bin/who_need_help rpc '
COMPOSE_PROJECT_NAME="$project" \ COMPOSE_PROJECT_NAME="$project" \
"$ROOT/scripts/verify-realtime-cluster.sh" compose \ "$ROOT/scripts/verify-realtime-cluster.sh" compose \
>"$output_dir/pubsub.txt" >"$output_dir/pubsub.txt"
COMPOSE_PROJECT_NAME="$project" \
"$ROOT/scripts/verify-beam-runtime.sh" compose \
"$output_dir/beam-runtime.json" >/dev/null
image_id=$(docker image inspect --format '{{.Id}}' "$REHEARSAL_IMAGE") image_id=$(docker image inspect --format '{{.Id}}' "$REHEARSAL_IMAGE")
source_commit=$(git rev-parse HEAD) source_commit=$(git rev-parse HEAD)

207
scripts/verify-beam-runtime.sh Executable file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
MODE=${1:-compose}
OUTPUT=${2:-}
RPC_EXPRESSION='
IO.puts(
JSON.encode!(%{
node: to_string(node()),
port_count: :erlang.system_info(:port_count),
port_limit: :erlang.system_info(:port_limit),
process_count: :erlang.system_info(:process_count),
memory_total: :erlang.memory(:total),
memory_system: :erlang.memory(:system),
port_table:
:erlang.system_info(:allocated_areas)
|> Keyword.fetch!(:port_table)
})
)
'
temporary=$(mktemp "${TMPDIR:-/tmp}/wnh-beam-runtime.XXXXXX")
trap 'rm -f "$temporary"' EXIT HUP INT TERM
record_probe() {
local target=$1
local component=$2
local expected=$3
local runtime_json=$4
local cgroup_memory=$5
local rss_kb=$6
jq -cn \
--arg target "$target" \
--arg component "$component" \
--argjson expected_port_limit "$expected" \
--argjson cgroup_memory "$cgroup_memory" \
--argjson rss_kb "$rss_kb" \
--argjson runtime "$runtime_json" \
'{
target: $target,
component: $component,
expected_port_limit: $expected_port_limit,
cgroup_memory: $cgroup_memory,
rss_kb: $rss_kb
} + $runtime' >>"$temporary"
}
case "$MODE" in
compose)
mapfile -t targets < <(
docker compose -p "${COMPOSE_PROJECT_NAME:-who_need_help}" \
ps -q web worker |
sort -u
)
if [[ "${#targets[@]}" -eq 0 ]]; then
echo "No running Compose web or worker replicas were found." >&2
exit 1
fi
live_flags=$(
for target in "${targets[@]}"; do
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \
"$target" |
sed -n 's/^ERL_ZFLAGS=//p'
done |
sort -u
)
if [[ ! "$live_flags" =~ ^\+Q[[:space:]]+([0-9]+)$ ]]; then
echo "The live Compose replicas do not expose one unambiguous +Q limit." >&2
exit 1
fi
expected=${BASH_REMATCH[1]}
for target in "${targets[@]}"; do
component=$(
docker inspect --format \
'{{index .Config.Labels "com.docker.compose.service"}}' "$target"
)
runtime_json=$(
docker exec "$target" /app/bin/who_need_help rpc "$RPC_EXPRESSION" |
tail -n 1
)
cgroup_memory=$(docker exec "$target" cat /sys/fs/cgroup/memory.current)
rss_kb=$(
docker exec "$target" awk "/^VmRSS:/ {print \$2}" /proc/1/status
)
record_probe \
"$target" "$component" "$expected" "$runtime_json" \
"$cgroup_memory" "$rss_kb"
done
;;
kind)
KUBECTL="$ROOT/.tools/bin/kubectl"
CONTEXT=kind-who-need-help
NAMESPACE=who-need-help
if [[ ! -x "$KUBECTL" ]]; then
echo "The project-owned kubectl is missing." >&2
exit 1
fi
live_flags=$(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
get deployment \
who-need-help-who-need-help-web \
who-need-help-who-need-help-worker \
-o json |
jq -r '
[
.items[].spec.template.spec.containers[].env[]
| select(.name == "ERL_ZFLAGS")
| .value
]
| unique
| if length == 1 then .[0] else empty end
'
)
if [[ ! "$live_flags" =~ (^|[[:space:]])\+Q[[:space:]]+([0-9]+)($|[[:space:]]) ]]; then
echo "The live deployments do not expose one unambiguous +Q limit." >&2
exit 1
fi
expected=${BASH_REMATCH[2]}
mapfile -t pods < <(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
get pods \
-l app.kubernetes.io/name=who-need-help \
-o json |
jq -r '
.items[]
| .metadata.labels["app.kubernetes.io/component"] as $component
| select(
($component == "web" or $component == "worker") and
.status.phase == "Running" and
([.status.containerStatuses[]?.ready] | all)
)
| [.metadata.name, $component]
| @tsv
'
)
desired=$(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
get deployment \
who-need-help-who-need-help-web \
who-need-help-who-need-help-worker \
-o json |
jq '[.items[].spec.replicas] | add'
)
if [[ "${#pods[@]}" -ne "$desired" ]]; then
echo "Expected $desired Ready application pods; observed ${#pods[@]}." >&2
exit 1
fi
for entry in "${pods[@]}"; do
IFS=$'\t' read -r pod component <<<"$entry"
runtime_json=$(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
exec "$pod" --container "$component" -- \
/app/bin/who_need_help rpc "$RPC_EXPRESSION" |
tail -n 1
)
cgroup_memory=$(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
exec "$pod" --container "$component" -- \
cat /sys/fs/cgroup/memory.current
)
rss_kb=$(
"$KUBECTL" --context "$CONTEXT" --namespace "$NAMESPACE" \
exec "$pod" --container "$component" -- \
awk "/^VmRSS:/ {print \$2}" /proc/1/status
)
record_probe \
"$pod" "$component" "$expected" "$runtime_json" \
"$cgroup_memory" "$rss_kb"
done
;;
*)
echo "Usage: $0 [compose|kind] [output.json]" >&2
exit 1
;;
esac
summary=$(jq -s 'sort_by(.component, .target)' "$temporary")
if ! printf '%s\n' "$summary" |
jq -e \
--argjson expected "$expected" \
'length > 0 and all(.port_limit == $expected)' >/dev/null; then
echo "A running BEAM instance does not use the configured port limit." >&2
exit 1
fi
if [[ -n "$OUTPUT" ]]; then
printf '%s\n' "$summary" >"$OUTPUT"
fi
printf '%s\n' "$summary"