☁️CSP Related

Managed Network Topology

The two k3s clusters have different trust boundaries and service roles. zjlab is the private company-network workload plane; 72602 is the public service plane. ecs-99 is the shared public relay and SSH jump host. The diagram shows stable roles and paths, not a live health status.

%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 30, "rankSpacing": 45}}}%%
flowchart TB
    users["Public users"]
    operators["Operators"]

    subgraph relay["Aliyun ECS ecs-99 · public relay · 47.110.67.161"]
        direction LR
        web["HAProxy Web\n80 / 443"]
        mail["HAProxy Mail\n25 / 465 / 587 / 993"]
        sshPublic["72602 SSH\n10021 primary · 10022 backup"]
        sshPrivate["ZJLAB ProxyJump targets\n10023 primary · 10024 backup\nloopback only"]
        monitor["Tunnel monitors\nDingTalk alerts"]
    end

    subgraph servicePlane["72602-minipc · public k3s service plane"]
        direction LR
        ingress["ingress-nginx\n32080 / 32443"]
        mailu["Mailu\n25 / 465 / 587 / 993"]
        ssh72602["sshd\n22"]
    end

    subgraph privatePlane["zjlab-ubuntu · private k3s workload plane"]
        direction LR
        sshZJLAB["sshd\n22"]
        workloads["Private workloads\nand internal data"]
    end

    users -->|"HTTP(S)"| web
    users -->|"SMTP / IMAP"| mail
    operators -->|"source-restricted"| sshPublic
    operators -->|"ECS SSH 22 + ProxyJump"| sshPrivate
    web -->|"WireGuard UDP 51820"| ingress
    mail -->|"loopback backends over 10022"| mailu
    sshPublic -->|"reverse SSH tunnels"| ssh72602
    sshPrivate -->|"reverse SSH tunnels"| sshZJLAB
    sshZJLAB --> workloads
    monitor -.-> sshPublic
    monitor -.-> sshPrivate

    classDef private fill:#e9f5ee,stroke:#1f7a4d,color:#123b27;
    classDef public fill:#eaf2ff,stroke:#2f63a8,color:#142b4a;
    classDef relay fill:#fff4df,stroke:#b87916,color:#4d3208;
    classDef service fill:#f4efff,stroke:#7650a8,color:#2d1e4a;
    class sshZJLAB,workloads private;
    class ingress,mailu,ssh72602 public;
    class web,mail,sshPublic,sshPrivate,monitor relay;
    class users,operators service;

The data-path arrows point from the client-facing listener to the destination. The reverse SSH sessions themselves are initiated outbound by 72602-minipc and zjlab-ubuntu toward ECS.

SSH Alias Convention

Use the alias matching the machine where the command runs. local aliases are direct paths from the matching host; proxy aliases use the approved ECS forwarding path. These are SSH configuration aliases, not DNS names.

Command runs onZJLAB72602ECS
zjlab-ubuntuzjlab-ubuntu-local72602-minipc-proxyecs-99
72602-minipczjlab-ubuntu-proxy72602-minipc-localecs-99

Validate an alias with ssh -G and an SSH connection. Do not use the old unqualified names zjlab, zjlab-backup, or minipc, and do not test an SSH alias with a DNS lookup.

Stable Port Map

ECS portDestination or functionExposure
10021/tcp72602 SSH primary reverse tunnelPublic, source-restricted
10022/tcp72602 SSH backup reverse tunnel; independent Mailu loopback forwardsPublic, source-restricted
10023/tcpZJLAB SSH primary listener used through ECS ProxyJumpECS loopback only
10024/tcpZJLAB SSH backup listener used through ECS ProxyJumpECS loopback only
51820/udpWireGuard Web transport between ECS and 72602Public, source-restricted

The four SSH tunnel paths are monitored independently and notify through DingTalk. A simultaneous ssh_banner_failed alert for ZJLAB primary and backup should first trigger checks of the shared ECS SSH prerequisite and the current ZJLAB egress-IP allowlist; it is not by itself evidence that the stable port map changed. Never publish or add public security-group rules for 10023/10024.

Mar 7, 2024

Subsections of ☁️CSP Related

72602

Scope

This section is the single source of truth for 72602 cluster operations.

Topology

  • Public ECS: 47.110.67.161 (2C4G, cn-hangzhou, zone cn-hangzhou-i)
  • Active ingress domain: 72602.space; legacy .72602.online routes are retired
  • ArgoCD host: argocd.72602.space
  • k3s node: 72602-minipc (192.168.0.25, MiniPC N100 28G+1TB NVMe)
  • SSH reverse tunnel: :10021 (main), :10022 (backup and Mailu loopback)
  • Web tunnel: WireGuard UDP 51820 between ECS and minipc
  • Ingress NodePort: 32080 (HTTP), 32443 (HTTPS)
  • Ingress class: nginx
  • Ingress namespace: basic-components
  • cert-manager issuer: lets-encrypt
  • Storage class: local-path (default, RWO)
  • OS: Ubuntu 26.04 LTS (minipc)
  • k3s version: v1.34.6+k3s1 (installed via install.sh)

Traffic Path

Web:

Internet -> ECS HAProxy TCP passthrough -> WireGuard -> 72602-minipc ingress-nginx NodePort

Mail:

Internet -> ECS HAProxy TCP passthrough -> ECS loopback sshd forwards -> 72602-minipc Mailu front

ECS Port Forwarding

Web traffic crosses WireGuard; SSH and mail continue to use independent SSH reverse tunnels. ECS side ports:

  • 10021 -> minipc:22 (72602 main SSH)
  • 10022 -> minipc:22 (72602 backup SSH and Mailu loopback forwards)
  • 80 -> 10.77.0.2:32080 (HAProxy TCP passthrough over WireGuard)
  • 443 -> 10.77.0.2:32443 (HAProxy TCP passthrough over WireGuard)
  • 127.0.0.1:10225 -> minipc:25 (SMTP via SSH reverse tunnel on 10022)
  • 127.0.0.1:10465 -> minipc:465 (SMTPS via SSH reverse tunnel on 10022)
  • 127.0.0.1:10587 -> minipc:587 (submission via SSH reverse tunnel on 10022)
  • 127.0.0.1:10993 -> minipc:993 (IMAPS via SSH reverse tunnel on 10022)

ZJLAB NewAPI public route

The isolated ZJLAB NewAPI-compatible service is published at https://newapi.zjlab.72602.space. Its relay, credentials, tunnel, and rollback procedure are maintained in the private operations repository. The existing MaaS route at https://llm.72602.space remains an independent service.

ECS HAProxy listens on public IPv4 and IPv6 25, 465, 587, and 993, and on public IPv4 80 and 443. Web is plain TCP passthrough without PROXY protocol or TLS termination. Mail passes TCP with PROXY v2 to the four ECS loopback backends. TLS remains terminated by ingress-nginx for Web and Mailu for the implicit-TLS mail protocols.

DNS Setup

Active service records use 72602.space and point to 47.110.67.161:

HostTypeValueService
argocd.72602.spaceA47.110.67.161ArgoCD UI
ops.docs.72602.spaceA47.110.67.161Hugo Docs
token.72602.spaceA47.110.67.161AI API proxy
port.72602.spaceA47.110.67.161Homepage dashboard
n8n.72602.spaceA47.110.67.161N8N workflow
webhook.n8n.72602.spaceA47.110.67.161N8N webhook receiver
ops.agent.72602.spaceA47.110.67.161OpenCode operations agent
grafana.72602.spaceA47.110.67.161Grafana observability UI
otlp.72602.spaceA47.110.67.161OTLP ingest endpoint
prometheus-write.72602.spaceA47.110.67.161Prometheus remote-write endpoint
uptime.72602.spaceA47.110.67.161Uptime Kuma
clash.72602.spaceA47.110.67.161Clash/mihomo panel
api.minio.72602.spaceA47.110.67.161MinIO S3 API
console.minio.72602.spaceA47.110.67.161MinIO Console

ACME CAA policy

The AliDNS zone 72602.space is delegated to dns15.hichina.com and dns16.hichina.com. AliDNS reports DNSSEC OFF; the parent delegation has no DS record, and both authoritative servers match the delegated nameservers. The zone has one enabled apex CAA record:

RecordIdRRTypeValueTTLStatus
2084917630338801664@CAA0 issue "letsencrypt.org"600ENABLE

This record was created after confirming that no enabled equivalent or conflicting CAA existed. It is scoped to ACME issuance and did not change any address, alias, delegation, DNSSEC, or unrelated record. Verify the record with the AliDNS API, both authoritative servers, and public resolvers 1.1.1.1 and 8.8.8.8; each must return NOERROR and the exact value. Roll back only this change with the official AliDNS SDK DeleteDomainRecord call for 2084917630338801664, then repeat the same resolver checks for an empty CAA answer. Do not delete other records.

The Clash route is basic-components/clash-ui-ingress using class nginx, ClusterIssuer lets-encrypt, and Secret clash.72602.space-tls. After the CAA addition, cert-manager retained the failed Order and automatically retried at its scheduled backoff time. The new Order became valid and the Certificate became Ready. Keep failed Orders and Challenges until normal cert-manager cleanup; do not repeatedly delete them.

The existing Prometheus receiver also accepts ZJLAB Kubernetes metrics through a dedicated HTTPS write-only Ingress at prometheus-write.72602.space. The Ingress exposes only the exact /api/v1/write path and requires a runtime Basic Auth Secret; it does not expose Prometheus query, status, or admin APIs. The TLS certificate is issued by lets-encrypt. ZJLAB sends metrics with the external label cluster=zjlab, so the existing Grafana Prometheus datasource can query ZJLAB without a second Grafana datasource. Credentials remain in runtime/private secret stores and must not be added to public manifests.

The Git-provisioned Grafana dashboard Kubernetes Resources (UID kubernetes-resources-multicluster) is in the Kubernetes folder and is defined by manifests/grafana-kubernetes-dashboard.yaml. It uses the existing Prometheus datasource UID prometheus; its Cluster selector maps 72602 to the empty-label matcher (^$), ZJLAB to cluster="zjlab", and All to both. Node and namespace selectors are dependent query variables. Node resource panels use the kubernetes-service-endpoints scrape job to avoid counting the ZJLAB node-exporter series twice. Verification returned one, two, and three unique nodes for 72602, ZJLAB, and All respectively; Pod CPU, Deployment, and scrape-target queries were non-empty. The previous runtime dashboard is not part of the operating path.

On 2026-08-14, the user-authorized reset deleted the 30Gi Prometheus TSDB PVCs monitor/prometheus-server in 72602 and monitoring/zjlab-prometheus-server in ZJLAB. ArgoCD recreated both PVCs and the existing scrape and remote-write configuration was retained. No backup was made; all prior metrics history is intentionally unrecoverable. Loki, Tempo, Grafana users, and Grafana datasource configuration were not cleared.

Mail records are managed in the 72602.space zone with TTL 600:

RRTypeValuePriority
mailA47.110.67.161
@MXmail.72602.space.10
@TXTv=spf1 mx ip4:<current-72602-egress-ip> -all (maintained dynamically)
_dmarcTXTv=DMARC1; p=none; rua=mailto:admin@72602.space
dkim._domainkeyTXTv=DKIM1; k=rsa; p=<derived-public-key>

The mail records were verified through AliDNS, both authoritative nameservers, and public resolvers 1.1.1.1 and 8.8.8.8. Mailu is configured with DKIM selector dkim for 72602.space, using /dkim/{domain}.{selector}.key. The current admin /dkim file is 72602.space.dkim.key; the front /dkim directory is not used for this admin-owned key.

Mailu DKIM and PTR readiness

  • The domain key was generated idempotently in the admin Pod with the official update import path. The input contained only the domain name and dkim_key: -generate-; -u -q was used and the default replace mode was not used:

    kubectl -n mailu exec deploy/mailu-admin -- sh -c \
      'printf "%s\\n" "domain:" "  - name: 72602.space" \
        "    dkim_key: -generate-" | flask mailu config-import -u -q -'
  • The admin image does not contain openssl. A temporary mailu-dkim-openssl Pod using the already deployed Mailu front image mounted only the mailu-storage PVC dkim subPath and ran container openssl to derive RSA public DER base64. It mounted no Secret, was deleted after derivation, and emitted no private-key content. The derived public value is intentionally not stored in this handbook.

  • AliDNS was queried first from aaron@72602-minipc with the official SDK v4.6.0 in /home/aaron/.local/venvs/alidns, sourcing only the mode-0600 /home/aaron/.aliyun-keys credential file inside that SDK process. The exact dkim._domainkey / TXT record was absent, so one record was created with TTL 600 and value shape v=DKIM1; k=rsa; p=<derived-public-key>. Its RecordId is 2082130099188750336.

  • The authoritative servers dns15.hichina.com and dns16.hichina.com, and public resolvers 1.1.1.1 and 8.8.8.8, all returned the complete matching TXT value with TTL 600 (410 characters). SPF, DMARC, and MX retained their existing values. Mailu admin/front were Ready, all Mailu Pod restart counts were zero, and no Warning events were present after the operation.

  • Roll back this newly created DNS record only with the official AliDNS SDK DeleteDomainRecord call for RecordId 2082130099188750336. Do not delete or regenerate the Mailu key during DNS rollback. If a future operation updates an existing record instead of creating one, restore that same RecordId’s pre-change value and TTL rather than deleting it.

  • Reverse DNS for 47.110.67.161 currently returns NXDOMAIN. A fresh read-only ECS API check found the address as the primary public IP of running instance i-bp1caavgd1twh7wb3n63 in cn-hangzhou (zone cn-hangzhou-i); its EipAddress.AllocationId and EipAddress.IpAddress are empty. The installed official aliyun-python-sdk-ecs is 4.24.83: DescribeEipAddresses found no EIP allocation for this address in the 32 discovered regions. The installed DescribeNatGateways model returned zero gateways in 31 regions; sa-east-1 returned 503 ServiceUnavailable twice and remains unconfirmed. The SDK contains ModifyEipAddressAttributeRequest, but its request model exposes AllocationId and Bandwidth (plus common owner parameters), not ResourceId, RegionId, or ReverseDnsName; it contains no ModifyReverseDns, ReverseDnsName, DescribeNatGatewayEipAddresses, or DescribeNatGatewayAttribute model. RegionId is supplied to AcsClient, not as a PTR parameter in that request. Do not treat ModifyEipAddressAttribute as a PTR operation or change PTR automatically; identify the owning Alibaba product/resource and use its documented console reverse-DNS action or Alibaba support path to request mail.72602.space if that product permits it. This verification made no PTR or DNS change.

Safe checks:

kubectl -n mailu exec deploy/mailu-admin -- sh -c 'ls -1 /dkim'
kubectl -n mailu exec deploy/mailu-front -- sh -c 'ls -1 /dkim'
dig @dns15.hichina.com dkim._domainkey.72602.space TXT +noall +answer +authority
dig @dns16.hichina.com dkim._domainkey.72602.space TXT +noall +answer +authority
dig -x 47.110.67.161 +noall +answer +authority

The AliDNS credential file must be sourced only inside the official SDK process; never print, log, copy, or commit its values. Never print Mailu Secret data, passwords, or DKIM private-key contents during these checks.

txt2img.agent.72602.online is retired. Its DNS record, certificate, TLS Secret, and unreferenced ai data claims have been removed.

DNS for 72602.space is managed in AliDNS. Active service records should point to the ECS public address only when the corresponding Ingress and certificate are Ready; do not describe this zone as dual-managed by Cloudflare.

Deployed ArgoCD Apps

AppNamespaceTypeSourceIngress
argocdargocdHelm (argo-cd)argo-cd 9.5.4argocd.72602.space
cert-managerbasic-componentsHelm (Jetstack)cert-manager 1.20.2internal
ingress-nginxbasic-componentsHelmingress-nginx 4.15.1shared ingress controller
ops-docsapplicationmanifests (Git)docs.git/mainops.docs.72602.space
ops-agentapplicationmanifests (Git)docs.git/mainops.agent.72602.space
mailumailuHelm (Mailu)mailu 2.7.3mail.72602.space
prometheusmonitorHelm (Prometheus Community)prometheus 29.18.0prometheus-write.72602.space
grafanamonitorHelm (Grafana)grafana 10.5.15grafana.72602.space
lokimonitorHelm (Grafana)loki 6.55.0internal
tempomonitorHelm (Grafana)tempo 1.24.4otlp.72602.space
alloymonitorHelm (Grafana)alloy 1.10.1otlp.72602.space
homepagemonitormanifests (Git)docs.git/mainport.72602.space
uptime-kumamonitormanifests (Git)docs.git/mainuptime.72602.space
sub2apiapplicationArgoCD (Git → OCI Helm)sub2api 0.1.6 / ghcr.io/wei-shaw/sub2api:0.1.168token.72602.space
postgresqldatabaseHelm (Bitnami)postgresql 18.1.8internal
redis-sharedstorageHelm (Bitnami)redis 18.16.0internal
miniostorageHelmminio 16.0.10console.minio.72602.space, api.minio.72602.space
n8nn8nHelm (community)n8n 1.16.36n8n.72602.space, webhook.n8n.72602.space

filing-site is uninstalled. Commit 0c250db869ae45c6c6a5a850876728783f1b08dd removed its manifest from the ops-docs source. Detailed filing-site deployment checks are intentionally omitted from this current-state page; use Git history when an older incident record is required.

argocd/ops-docs reconciles the repository’s manifests path and owns the application workloads and their child Applications, including sub2api and mailu. The same source also defines the observability Applications listed above. Sub2API uses the application namespace, nginx Ingress, a Ready TLS certificate, a 10Gi local-path RWO application PVC, and an 8Gi local-path RWO Redis PVC with AOF enabled.

The alloy Application is also live in monitor (Grafana Alloy chart 1.10.1) and receives OTLP traffic at otlp.72602.space; it forwards traces, metrics, and logs to Tempo, Prometheus, and Loki. Confirm the Application and its endpoints before changing the observability pipeline.

Ops Docs Publishing

argocd/ops-docs compares only the repository’s manifests path. A commit that changes only content can advance .status.sync.revision while remaining Synced; it does not create a sync operation, so the ops-docs-build Sync hook does not run.

Publish a reviewed content commit by setting its full SHA in manifests/configmap.yaml as PUBLISH_REVISION, then commit and push that single manifest change. Automatic sync configures ops-docs-config and runs the hook. The hook fetches and verifies that exact SHA, builds Hugo into hugo-docs-pvc, and writes the SHA to /usr/share/nginx/html/.ops-docs-revision. Its fixed Job name is safe because the delete policy is BeforeHookCreation,HookSucceeded; the successful Job is normally absent after the operation.

Verify the source, operation, build marker, rollout, and public route:

git -C /home/aaron/Ops/docs ls-remote origin refs/heads/main

kubectl -n application exec deployment/ops-agent -c ops-agent -- \
  argocd app get ops-docs --hard-refresh --insecure --grpc-web
kubectl -n application exec deployment/ops-agent -c ops-agent -- \
  argocd app history ops-docs --insecure --grpc-web

kubectl -n application rollout status deployment/ops-docs --timeout=300s
kubectl -n application exec deployment/ops-docs -- sh -c \
  'tr -d "\n" < /usr/share/nginx/html/.ops-docs-revision; printf "\n"'
curl -fsS -o /dev/null -w '%{http_code}\n' https://ops.docs.72602.space/

The Application revision and published content revision can differ by the manifest-only trigger commit; both must match their reviewed Git commits. To roll back the generated site, set PUBLISH_REVISION to the previous reviewed content SHA in Git and push a new trigger commit. Let automatic sync rebuild the PVC. Do not copy HTML directly or patch the Deployment, ConfigMap, or PVC.

Non-ArgoCD (手动部署)

DeploymentNamespaceImageIngress
ops-agentapplicationay-dev/ops-agent:0.2.3ops.agent.72602.space

2026-08-14: ops-agent 0.2.3 deployed

  • Live checks confirmed hostname=72602-minipc, kubeconfig context default, and node 72602-minipc Ready at 192.168.0.25 on v1.34.6+k3s1.
  • The live owner is argocd/ops-agent, sourced from https://github.com/AaronYang0628/docs.git at manifests/ops-agent, target main.
  • The local 0.2.3 image built successfully with OpenCode 1.18.16 and image ID 2e8de824615d5cfc8b3cb887bff21bad974985fe88ad24beda1c9082760c0f7c. The OCI manifest digest was sha256:f1e5e79d570cdec0ce375e264f3c83291912180591f43308d7fe2b66dc326bff. The first attempt hit a transient direct dl.k8s.io curl timeout; retrying completed the build.
  • The image was pushed to the Aliyun personal registry and imported into local k3s containerd under tag 0.2.3. Registry credentials were used through a temporary authfile and were not written to Git or the host’s persistent container configuration.
  • The GitOps manifest was committed and pushed to main; ArgoCD reconciled the updated Deployment. The replacement Pod became Ready with the 0.2.3 image, and the rollout completed successfully.
  • Authenticated /global/health returned healthy, DNS resolved ops.agent.72602.space to 47.110.67.161, its nginx Ingress and Ready TLS remained healthy, and the anonymous public health request returned the expected 401.
  • Roll back by restoring the prior 0.2.2 source revision and reconciling; do not delete Secrets or PVCs.

Network Proxy

For host-level command loading and the standard read-only preflight, use the shared Clash/Mihomo runbook. In particular, clashctl is a shell function and must be sourced explicitly in non-interactive agent shells; do not probe guessed ports when clashctl status and the runtime configuration provide the answer directly.

Egress Proxy Architecture

k8s Pod (10.42.x.x) --HTTP_PROXY--> 192.168.0.25:17890 (socat) --forward--> 127.0.0.1:7890 (mihomo/clash) --tunnel--> upstream proxies
  • mihomo (clash): listens on 127.0.0.1:7890 (HTTP), 127.0.0.1:7891 (SOCKS5)
    • Config: /home/aaron/clashctl/resources/runtime.yaml
    • Key setting: allow-lan: false (只监听 localhost)
  • socat bridge: 0.0.0.0:17890127.0.0.1:7890 (桥接使 k8s Pod 可达)
    • 进程: socat -d -d TCP-LISTEN:17890,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:7890
  • k8s Service: argocd-egress-proxy.argocd.svc.cluster.local:17890 → Host 192.168.0.25:17890; use the Service DNS name rather than a mutable ClusterIP.
  • App proxy env: 应统一使用 http://192.168.0.25:17890不是 192.168.0.25:7890,因为 mihomo 仅绑定 127.0.0.1

关键约束

  • mihomo allow-lan: false 意味着 不能 直接用 192.168.0.25:7890 作为代理地址
  • 必须通过 socat 桥接 (192.168.0.25:17890) 或 argocd-egress-proxy Service 访问
  • GitHub acceleration: ghfast.top URL rewrite + NO_PROXY bypass
  • Image mirror: m.daocloud.io/docker.io, m.daocloud.io/ghcr.io

Known Incident Pattern

  • Historical pattern (legacy .72602.online hostname): HTTPS handshake fails with tls alert internal error.

  • Root cause: ECS Docker/derper occupies public 443, traffic never reaches k3s ingress.

  • Fix baseline: derper must expose 8443:443, keep public 443 for ingress NodePort 32443.

  • Symptom: n8n 所有 workflow 报 connect ECONNREFUSED 192.168.0.25:7890

  • Root cause: HTTP_PROXY 指向 192.168.0.25:7890,但 mihomo 只监听 127.0.0.1:7890allow-lan: false)。Pod 无法直连 mihomo 的 LAN IP。

  • Fix baseline: HTTP_PROXY/HTTPS_PROXY 必须使用 socat 桥接端口 192.168.0.25:17890(或 Service DNS argocd-egress-proxy.argocd.svc.cluster.local:17890),该端口由 socat 转发至 127.0.0.1:7890

Host-Level Services

ServicePortBindDescription
mihomo (clash) HTTP proxy7890127.0.0.1Egress proxy, allow-lan: false
mihomo (clash) SOCKS57891127.0.0.1SOCKS5 proxy
mihomo external controller90900.0.0.0Clash API/UI, exposed via clash.72602.space
socat bridge178900.0.0.0Forwards to 127.0.0.1:7890, k8s pod accessible
autossh tunnel (main)10021→ECS-Reverse tunnel to ECS
autossh tunnel (backup)10022→ECS-Reverse SSH + Mailu loopback forwarding
WireGuard51820/udpECS 0.0.0.0, minipc dynamic UDPEncrypted Web path between ECS and minipc
ECS HAProxy Web80,4430.0.0.0TCP passthrough to minipc WireGuard IP
ECS HAProxy Mail25,465,587,9930.0.0.0 and [::]TCP passthrough with PROXY v2
ECS mail tunnel backends10225,10465,10587,10993127.0.0.1sshd reverse forwards to minipc Mailu front
Mailu front host ports25,465,587,993minipc hostPortSMTP, SMTPS, submission, IMAPS
k3s ingress HTTP320800.0.0.0NodePort for ingress HTTP
k3s ingress HTTPS324430.0.0.0NodePort for ingress HTTPS

Notes

  • Keep derper away from public 443 (use 8443).
  • Keep app ingress aligned with ArgoCD ingress pattern:
    • ingressClassName: nginx
    • cert-manager.io/cluster-issuer: lets-encrypt
    • TLS secret per host.
  • argocd-egress-proxyops-docs ArgoCD Application 管理,并为 repo-server 提供 Git/Helm 出站代理。
  • mihomo allow-lan: false 意味着 Pod 代理地址必须是 socat 桥接端口 17890,不能用 7890
  • 72602 的 SSH 隧道当前由两个独立的用户级 systemd 服务维护,并依赖 loginctl enable-linger 在登出和重启后继续运行;ZJLAB 的对应 loopback 隧道采用独立的系统级服务,不能把两套服务模型混写。
  • ECS must allow inbound TCP 25,465,587,993 from 0.0.0.0/0 and UFW must allow the same ports before testing public mail delivery. Preserve the existing default firewall policies and unrelated rules.

Mailu Public Mail Path

  • Inbound mail remains Internet -> ECS 47.110.67.161:25/465/587/993 -> HAProxy TCP passthrough with PROXY v2 -> ECS loopback backends 127.0.0.1:10225/10465/10587/10993 -> independent 10022 reverse tunnel -> 72602-minipc Mailu front hostPort.
  • ECS public mail ports are owned by HAProxy; the four loopback backends are owned by sshd. The 10021 SSH service is independent and must not be restarted during mail changes.
  • The current Mailu source trusts the k3s CNI gateway 10.42.0.1/32 for PROXY traffic and exposes PROXY ports 993, 25, 465, and 587. Read-only verification on 2026-08-13 confirmed the Mailu workloads were Ready and the 465/993 TLS and 587 STARTTLS handshakes completed. The old 127.0.0.1/32 trust mismatch and EOF observations are historical incident records, not the current baseline.

Mailu outbound delivery and SPF

  • Mailu outbound delivery is direct from Postfix through the 72602 home egress. It does not traverse ECS 10022 or ECS HAProxy; those are inbound mail paths. The egress address is dynamic, so update-mailu-spf.timer refreshes the AliDNS SPF record hourly. Query the current record and current egress IP during an incident rather than treating an observed historical IP as a permanent value.
  • The 2026-08-13 audit observed home egress and authoritative/public SPF as 36.24.58.213; the single SPF record is maintained by update-mailu-spf.timer. Treat that address, and the 36.24.59.216 and 125.121.102.50 values in the dated 2026-07-31 delivery record, as dated observations rather than permanent allowlist values. A successful SMTP queue response still does not prove final inbox placement or reputation.
  • Rspamd logged DKIM_SIGNED for 72602.space with selector dkim. The corresponding DNS record is 2082130099188750336; DMARC is record 2082063800085560320 with p=none. PTR lookups for both the dynamic egress IP and ECS 47.110.67.161 returned NXDOMAIN. A fixed outbound SMTP relay remains the reliable solution; no relay was configured.
  • SPF automation still has a one-hour polling interval and cannot guarantee delivery immediately after a home IP change. Preserve the timer and inspect its last run before making any manual DNS change; do not hard-code a transient egress address in this current-state section.

Useful checks:

sudo ss -lntp | grep -E ':(25|465|587|993|10022)$'
kubectl -n mailu get deploy,pod,svc,certificate,order -o wide
kubectl -n mailu get endpoints mailu-front -o wide

Rollback for this mail proxy change is: systemctl stop haproxy; restore the saved reverse-tunnel-ecs-10022.service backup; run systemctl --user daemon-reload; restart only reverse-tunnel-ecs-10022.service. If reverting the required ECS sshd binding change, restore /var/backups/sshd_config.20260728T142924Z.before-haproxy, run sshd -t, reload sshd, then restore/restart the 10022 tunnel as needed. Do not restore an old HAProxy configuration or uninstall the package as part of this rollback, and do not delete Mailu Secrets or PVCs.

Recent Operations

2026-08-02: application namespace prune incident

  • Trigger and time: commit 0c250db869ae45c6c6a5a850876728783f1b08dd deleted manifests/filing-site.yaml. The file’s first object was the shared Namespace/application. At 2026-08-02 08:25:53 +08, the ops-docs Application’s automated prune deleted that Namespace along with the intended filing-site resources.
  • Root cause: the lifecycle of a shared Namespace was coupled to one removable workload manifest while automated prune was enabled. Once the Namespace disappeared from Git, Argo CD treated it as stale. Kubernetes then cascade-deleted namespaced resources; sync options on an individual PVC or Secret cannot protect it from deletion through its parent Namespace.
  • Impact: runtime Secrets and the application local-path PVCs were deleted, so Ops Agent, Sub2API, and its Redis could not start. Argo CD recreated declarative resources, but not runtime Secret values or deleted volume contents. PostgreSQL remained intact because it runs in the separate database namespace.
  • Unrecovered data: the pre-incident local-path directories for opencode-data, Sub2API Redis AOF, sub2api-data, and filing-site photos were deleted and had no snapshot. Those contents were not recovered; replacement PVCs do not contain the former data.
  • Secret recovery: the 2026-08-02 00:00 +08 etcd snapshot was restored only into an isolated same-version temporary k3s. The approved whitelist was the aliyun-registry Secret, five opencode-* Secrets, and three sub2api-* Secrets. All nine were still absent immediately before create-only restoration. No Secret value was printed, logged, committed, or allowed to overwrite a newer object. The production server never ran cluster-reset; the procedure is documented in Runtime Secret Recovery.
  • Result: kubelet recovered the existing post-prune Pods without manual deletion or restart. Ops Agent reached 2/2 Ready; Sub2API and its Redis reached 1/1; application health checks passed; and ops-docs, ops-agent, and sub2api were Synced/Healthy. filing-site-upload-auth, 72602.space-tls, filing-site, and deleted local-path data were not restored.
  • Immediate guard: the live Namespace/application was merge-patched only with argocd.argoproj.io/sync-options=Prune=false,Delete=false. This protects an Argo CD-tracked Namespace from prune and Application deletion, but it is not an admission policy and cannot block direct deletion by another actor.
  • Durable guard: during the incident response, manifests/application-namespace.yaml was prepared as a dedicated Namespace manifest but was not included in a commit or pushed at that response checkpoint; remote main and ops-docs were still at 0c250db8. This is a historical checkpoint, not a claim about the current repository state. Before relying on the guard, verify that the reviewed file is in remote Git, Argo CD tracks the Namespace with both sync options, no removable workload manifest defines the shared Namespace, and recoverable off-volume backups exist for required local-path data. Do not delete or recreate the Namespace as rollback.

2026-07-30: fix MinIO S3 upload HTTP 413

  • Symptom: Sub2API backup uploads to api.minio.72602.space failed with S3 PutObject HTTP 413 for an approximately 2.45 MB request. The default ingress-nginx client_max_body_size 1m rejected the request before MinIO.

  • Fix: commit 2338bc6 added this annotation under the MinIO apiIngress.annotations in Git:

    nginx.ingress.kubernetes.io/proxy-body-size: "0"

    This change is scoped only to the MinIO API Ingress; it does not change the MinIO console or the shared/global ingress configuration. ArgoCD and MinIO became Synced/Healthy; generated nginx reported client_max_body_size 0. An authenticated S3 PutObject/Stat/Delete smoke test passed and its temporary object was removed. Sub2API backup upload was then manually confirmed successful.

  • Roll back by reverting 2338bc6 in Git and allowing ArgoCD to reconcile. If an emergency live reversal is required first, remove only the annotation from storage/minio-api, then verify ArgoCD convergence. Do not delete MinIO Secrets or PVCs. Do not globally disable request body limits without explicit scope and security review.

2026-07-29: rotate Ops Agent provider credentials

  • Confirmed the operation on 72602-minipc with context default; the only node was Ready at 192.168.0.25 on v1.34.6+k3s1. Before the change, application/opencode-model contained only the api-key key and the live Deployment injected only OPENAI_API_KEY. No Secret value was read or printed.
  • Commit ddb69f1 on main routes both the OpenAI and Grok providers to the then-current endpoint https://sub2api.72602.space/v1. The current public endpoint is https://token.72602.space/v1; this dated record is retained as history, not as a current endpoint instruction. The Secret was merge-patched through stdin to update api-key and add grok-api-key, preserving its other fields, and manifests/ops-agent/deployment.yaml was applied. No credential was written to Git or a temporary file, and no sub2api or unrelated resource was changed.
  • The live Deployment now injects OPENAI_API_KEY from opencode-model/api-key and GROK_API_KEY from opencode-model/grok-api-key. Rollout completed with Deployment generation and observed generation 16; Pod ops-agent-68556dc7f5-6jd84 was 2/2 Ready with zero restarts, and the Service endpoint was 10.42.0.207:8080.
  • The authenticated internal /global/health check returned healthy=true. Separate, read-only /v1/models requests from the Pod returned HTTP 200 with the OpenAI credential and HTTP 200 with the Grok credential. Filtered live merged configuration at that time showed both provider base URLs as https://sub2api.72602.space/v1; new-Pod logs contained no Invalid API key message.
  • DNS for ops.agent.72602.space resolved to 47.110.67.161; Ingress ops-agent used class nginx and that host, and Certificate ops.agent.72602.space-tls was Ready. The unauthenticated public health URL returned the expected HTTP 401. An Argo CD hard refresh from the Ops Agent Pod with --insecure --grpc-web reported ops-agent Synced to ddb69f1 and Healthy.
  • Roll back by restoring the prior approved model credential with the same non-output stdin merge-patch method. Remove grok-api-key only when reverting to the previous single-provider state, and restore/sync source revision a7e434b for the prior Deployment. Do not delete the Secret, expose its values, or modify sub2api during rollback.

2026-07-28: read-only Mailu verification after c5d1e0a

  • Checks ran from the Ops Agent Pod (hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but in-cluster credentials reached the only node 72602-minipc, Ready at 192.168.0.25 on v1.34.6+k3s1.
  • Automatic sync completed at 2026-07-28T12:50:54Z. Read-only ArgoCD checks reported ops-docs Synced/Healthy at full revision c5d1e0adddaace9ad0ba2d3c57c3ef27eb0e6802 (c5d1e0a, history ID 24), and child Application mailu targeting chart 2.7.3 as Synced/Healthy. No manual apply, sync, delete, rollback, or restart was run.
  • Read-only observations from 12:53:05Z through approximately 13:00Z kept all eight Mailu Deployments and both StatefulSets at 1/1 Ready. The mailu-front rollout status completed. mailu-front (10.43.76.102) and mailu-front-ext (10.43.108.81, ClusterIP) both had endpoint 10.42.0.198. The mailu-envvars ConfigMap reported PORTS=80,443,4190,995,993,25,465,587,4190.
  • Certificate mail.72602.space-tls was Ready=True; its Order was valid, with validity 2026-07-28T10:22:57Z through 2026-10-26T10:22:56Z, and no active Challenge. Public DNS returned mail.72602.space A 47.110.67.161, 72602.space MX 10 mail.72602.space., SPF v=spf1 mx -all, and DMARC v=DMARC1; p=none; rua=mailto:admin@72602.space, all with TTL 600. The three PVCs were Bound on local-path with RWO: 2Gi ClamAV, 100Gi Mailu storage, and 8Gi Redis.
  • Following the HTTPS redirect, https://mail.72602.space/ returned final HTTP 200 at /sso/login?url=/webmail/?homepage. Port 25 returned 220 mail.72602.space ESMTP ready; ports 465 and 993 completed verified TLS 1.3 handshakes for mail.72602.space.
  • Port 587 did not provide STARTTLS: openssl s_client reported no STARTTLS and an unexpected EOF. Connections to port 587 on both front ClusterIP Services and the postfix ClusterIP were refused, and the front container listener list had no 587. The ConfigMap update was not followed by a new front Pod template rollout: the running Pod started at 2026-07-28T12:32:36Z, before the automatic sync, while the Deployment remained 1/1 with generation and observed generation 3. Fix the rollout through Git and automatic ArgoCD reconciliation before treating submission as ready.
  • A single non-authenticated relay probe sent only EHLO, MAIL FROM, RCPT TO:<nobody@example.net>, and QUIT; it sent no DATA, credentials, or message. The external RCPT received 250 2.1.5 Ok instead of a rejection. This does not prove actual delivery or an open relay, but it fails the required black-box rejection check and requires immediate relay-policy review.
  • No Secret data, password, or DKIM private key was read. Remaining manual actions are to fix and reverify the front submission rollout, review relay policy, obtain the initial admin password through an approved secure process, publish the generated DKIM public record, and confirm ECS/UFW/cloud policy for outbound TCP 25 before any real delivery test. Do not delete Mailu Secrets or PVCs during correction or rollback.

2026-07-28: read-only Mailu deployment verification

  • Read-only checks ran from the Ops Agent Pod (hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but the in-cluster credentials reached the only node 72602-minipc, which was Ready at 192.168.0.25 on v1.34.6+k3s1.
  • Ran argocd app get ops-docs --hard-refresh --insecure --grpc-web: revision 2773ef5, Synced, Healthy. Ran argocd app get mailu --refresh --insecure --grpc-web: target 2.7.3, Synced, Degraded; only the mailu-front Deployment was Degraded.
  • Over approximately eight minutes, 17 read-only observations at 30-second intervals showed mailu-admin, mailu-dovecot, mailu-oletools, mailu-postfix, mailu-rspamd, mailu-tika, and mailu-webmail at 1/1 Ready, with mailu-clamav and mailu-redis-master at 1/1; all three PVCs were Bound on local-path (2Gi, 100Gi, and 8Gi). mailu-front remained 0/1 Ready with ProgressDeadlineExceeded, and mailu-front had no Endpoints. The old Pod mailu-front-85d9b6d7d4-6bd9s used m.daocloud.io/ghcr.io/mailu/nginx:2024.06.57 and remained in ImagePullBackOff after the mirror returned 403 Forbidden. The replacement Pod mailu-front-5cbbf9bc99-2qc7v used the desired ghcr.nju.edu.cn/mailu/nginx:2024.06.57 but remained Pending because the single node had no free requested host ports. Both ReplicaSets requested host ports 110,995,143,993,25,465,587.
  • Ingress mailu uses class nginx for mail.72602.space. Certificate mail.72602.space-tls is Ready=True, its Order is valid, and the certificate is valid from 2026-07-28T10:22:57Z through 2026-10-26T10:22:56Z; there is no active Challenge.
  • getent ahostsv4 mail.72602.space resolved the host to 47.110.67.161. Direct https://mail.72602.space/ returned 503. ECS TCP ports 25, 465, 587, and 993 accepted connections, but SMTP ports closed before returning a banner and SMTPS/IMAPS TLS handshakes were reset. No authentication or real mail delivery was attempted.
  • The exact blocker is the mailu-front image-pull 403 Forbidden in the old ReplicaSet combined with host-port contention during the single-node rolling update. DNS, the issued certificate, and public ECS port reachability are not the blocker. Webmail/Admin and the mail protocols are therefore not usable yet. No delete, rollback, manual apply, or ArgoCD sync was run, and no Secret values were read. A future fix must correct the image/rollout through the Git source, then verify the front Endpoints and protocol handshakes; preserve all Mailu Secrets and PVCs. Any rollback should restore the reviewed source revision through ArgoCD and must not delete Mailu Secrets or PVCs.

2026-07-28: diagnose MinIO Console slowness and port TLS fix

  • Read-only identity checks from the Ops Agent Pod reported hostname=ops-agent-5d6878f6c-xwdb; the kubeconfig context was unset, but in-cluster credentials reached the only node 72602-minipc, Ready at 192.168.0.25 on v1.34.6+k3s1. No Secret values, credentials, object names, or object data were read.
  • The pending manifests/ingress-port.yaml change has ClusterIssuer lets-encrypt, nginx.ingress.kubernetes.io/ssl-redirect: "true", and TLS host port.72602.space, but no matching port.72602.space rule. It belongs to the application/ops-docs source, while the live owner is monitor/homepage from manifests/homepage/ingress.yaml. Therefore it was invalid for the requested route and was not committed, pushed, applied, synced, or rolled back. Before correction, http://port.72602.space/ returned 200 without redirect; HTTPS returned 200 only with the Kubernetes Ingress Controller fake certificate. The correct future fix must update the owning Homepage ingress source, then sync ops-docs, wait for Certificate Ready, and verify strict HTTPS.
  • storage/minio was 1/1 Ready (minio-7f776484df-sslgs), with four restarts 58 days ago, 2m CPU and 662Mi memory observed, requests 250m/512Mi, limits 512m/1Gi, Service endpoints 10.42.0.21:9000,9001, and an 8Gi Bound local-path PVC. The mounted filesystem reported 937G total, 269G used, and 621G free. Node MemoryPressure, DiskPressure, and PIDPressure were False; node usage was 389m CPU (2%) and 11737Mi memory (40%).
  • From 72602, public Console root/API timings were 0.125s/0.241s; from ecs-99, 0.772s/0.783s. Direct Service/Pod representative requests were approximately 0.00030.0006s; direct ingress was approximately 0.1070.179s. The 3,259,800-byte main JavaScript asset took 16.802s locally and 13.893s from ECS; the 3,835,591-byte login video took 21.337s and 17.413s. The 663,820-byte background SVG took 2.144s and 5.437s. API responses remained fast.
  • Ingress-nginx was at 3m CPU and 253Mi memory with five restarts 58 days ago, 16 workers, and 16384 worker connections. In the last 24 hours, 165 Console access requests had no observed 499/502/504 or upstream errors; MinIO logs had no warning/error lines. /ws/objectManager returned HTTP 101 and several websocket connections ended at 60.00060.002s, matching the generated proxy-read-timeout 60s; this is a long-lived websocket timeout/reconnect concern, not a slow initial API response.
  • Root cause assessment: initial Console slowness is dominated by large static/media transfers through the ECS reverse tunnel and its network variability; MinIO, ingress CPU/concurrency, storage capacity, readiness, and API/backend response time were not limiting. No performance mutation was justified, so no resource, timeout, keepalive, scaling, DNS, or broad configuration change was made. No rollback is needed. A future websocket timeout change must be committed through manifests/minio-argocd.yaml and synced through ArgoCD; any port TLS correction must first update the owning Homepage ingress source.

2026-07-28: observe automatic sync for d639a46

  • Ran the read-only checks from the Ops Agent Pod (hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but in-cluster credentials reached the only node 72602-minipc, which was Ready at 192.168.0.25 on v1.34.6+k3s1. The expected Git manifest route is mail.72602.space, with Ingress class nginx, issuer lets-encrypt, local-path storage, Mailu chart 2.7.3, and global.security.allowInsecureImages: true.
  • Ran argocd app get ops-docs --hard-refresh --insecure --grpc-web. The first observation was OutOfSync from main (d639a46) with Healthy; automatic sync then completed without a manual sync, and the final state was Synced to main (d639a46) and Healthy. The output showed child argocd/mailu configured. No argocd app sync, permission bypass, Kubernetes apply, create, patch, or delete was run.
  • Ran argocd app get mailu --refresh --insecure --grpc-web. Automatic sync removed the previous ComparisonError; Mailu was Synced to 2.7.3 but Progressing. Twenty read-only observations over approximately five minutes (10:05:1710:10:07 UTC) remained Synced / Progressing with no ComparisonError condition.
  • The required kubectl -n mailu get deploy,statefulset,pod,pvc,svc,ingress,certificate,order,challenge -o wide check found all eight Mailu Deployments at 0/1; mailu-clamav StatefulSet at 0/1 and mailu-redis-master at 1/1. The Redis Pod and ACME solver Pod were Running and ready; admin, dovecot, and postfix were ImagePullBackOff; oletools, rspamd, and webmail were ErrImagePull; front, tika, and clamav were ContainerCreating. The three PVCs were Bound (2Gi, 100Gi, and 8Gi, all local-path/RWO). Ingress mailu used nginx for mail.72602.space at 10.43.13.156 on ports 80,443. Certificate mail.72602.space-tls was not Ready, its Order was pending, and its Challenge was pending.
  • The redacted ACME error was: Waiting for HTTP-01 challenge propagation: failed to perform self check GET request 'http://mail.72602.space/.well-known/acme-challenge/...': Get "http://mail.72602.space/.well-known/acme-challenge/...": dial tcp: lookup mail.72602.space on 10.43.0.10:53: no such host. Kubelet also reported, for Mailu components including admin, webmail, oletools, rspamd, postfix, and dovecot: failed to pull and unpack image "m.daocloud.io/ghcr.io/mailu/<component>:2024.06.57": failed to resolve reference ...: unexpected status from HEAD request to https://m.daocloud.io/v2/ghcr.io/mailu/<component>/manifests/2024.06.57: 403 Forbidden. The front Pod additionally reported MountVolume.SetUp failed for volume "certs" : secret "mail.72602.space-tls" not found. No Secret data was read.
  • The Helm comparison blocker was cleared by the committed configuration, and ArgoCD automatically created the Mailu resources. The rollout remained blocked by the image-mirror 403 Forbidden responses and missing DNS record, which prevented cert-manager HTTP-01 validation and TLS Secret issuance. No resource deletion or rollback was performed. Do not delete the Mailu Secret or PVCs; any Git revert, ArgoCD sync, DNS change, or resource mutation requires explicit authorization and should first correct the image source and DNS.

2026-07-28: Mailu deployment blocked before resource sync

  • Ran the checks from the Ops Agent Pod (hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but in-cluster credentials reached 72602-minipc, which is Ready at 192.168.0.25 on v1.34.6+k3s1. The existing mailu namespace and Secret metadata were left unchanged; Secret values were not read.
  • GitHub main contains 91986de with manifests/mailu-argocd.yaml. The manifest declares the Mailu route mail.72602.space, using the active 72602.space domain, nginx, lets-encrypt, and local-path.
  • Ran argocd app get ops-docs --hard-refresh --insecure --grpc-web; argocd/ops-docs reported Synced, Healthy, and revision 91986de, and its output confirmed that argocd/mailu was created. The required argocd app sync ops-docs --revision main --assumeYes --insecure --grpc-web then failed before applying changes with PermissionDenied: applications, sync, default/ops-docs, sub: readonly.
  • No mailu sync was attempted, and no Helm install, deletion, or automatic rollback was performed. argocd app get mailu --insecure --grpc-web reported Sync Unknown, Health Healthy, and a ComparisonError: Helm rejected the substituted Daocloud Bitnami Redis images because global.security.allowInsecureImages=true is not enabled.
  • Read-only checks found no Mailu Deployment, Pod, PVC, Service, Ingress, Certificate, CertificateRequest, Order, Challenge, or namespace events. mail.72602.space had no DNS result, and the HTTPS probe timed out because no Mailu endpoint was deployed.
  • Rollback boundary: stop further sync; after explicit authorization, restore or revert the Git source to the pre-change known-good revision and sync through ArgoCD. Do not delete the mailu Secret or any PVC, and do not auto-rollback.
  • Next actions: obtain an ArgoCD identity permitted to sync ops-docs, review and update the Git manifest with the chart-supported insecure-image setting if the image mirror is retained, then sync ops-docs and mailu through ArgoCD. Add mail.72602.space DNS A record to 47.110.67.161 before validating the public endpoint.

2026-07-28: create Mailu namespace and bootstrap Secret

  • Ran the checks from the Ops Agent Pod (hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but the in-cluster Kubernetes credentials reached the live node 72602-minipc, which is Ready at 192.168.0.25 on v1.34.6+k3s1.
  • The mailu namespace did not exist, so kubectl create namespace mailu created it. A second check confirmed that mailu/mailu-secrets did not exist before creation.
  • Generated secret-key with openssl rand -hex 32 and initial-account-password with openssl rand -base64 24 in shell memory, then ran kubectl create secret generic mailu-secrets -n mailu --from-literal=secret-key="$secret_key" --from-literal=initial-account-password="$initial_account_password". No credential value was written to a file, command output, logs, Git, or this documentation.
  • Safe verification confirmed namespace mailu is Active; Secret metadata is name=mailu-secrets, namespace=mailu, type=Opaque, and keys initial-account-password and secret-key. Secret data values were not read or output.
  • No ArgoCD sync was run, and no DNS, security-group, or reverse-tunnel changes were made. Rollback, only with explicit authorization: kubectl -n mailu delete secret mailu-secrets. The Secret was not deleted.

2026-07-28: black-box verification of filing-site upload policy

  • Ran the checks with curl on 72602-minipc (hostname=72602-minipc, context default). The live route is https://72602.space/; GET http://72602.space/ returned 308 with Location: https://72602.space, and the HTTPS home returned 200 text/html (19,881 bytes) containing data-upload="aaron", data-upload="licorice", and data-upload="yakult".
  • Anonymous GET https://72602.space/photos/{aaron,licorice,yakult}/ each returned 200 application/json with []; the corresponding HEAD requests each returned 200 application/json.
  • The one temporary test object was https://72602.space/photos/aaron/verification-1785228022571821377.png, a valid 1x1 PNG of 68 bytes. Anonymous PUT and wrong-credential PUT each returned 401 text/html; PUT with the temporary uploader credential held in shell memory since Secret creation returned 201. The follow-up Aaron listing returned 200 application/json and showed the file as type=file, size=68; the image GET returned 200 image/png with 68 bytes.
  • Authenticated DELETE on the temporary object and authenticated MKCOL, MOVE, COPY, and POST on the Aaron directory were all rejected with 403 text/html; no authenticated DELETE succeeded. Cleanup used kubectl -n application exec filing-site-55cff975bf-z67xw -- rm -f -- /data/files/aaron/verification-1785228022571821377.png. The post-cleanup Aaron listing returned 200 application/json with [], and the exact path was absent in the Pod.
  • Live resource checks passed: Deployment filing-site is 1/1 available, Pod filing-site-55cff975bf-z67xw is Running and ready with zero restarts, Ingress filing-site uses class nginx for 72602.space, and 72602.space-tls is Ready. PVC filing-site-photos is Bound to a 5Gi local-path PV.
  • In the nginx container (uid=101, gid=101), /data/files/aaron, /data/files/licorice, and /data/files/yakult exist and are writable with mode 775; /data/.tmp exists and is writable with mode 770. No Kubernetes Secret value was read, no manifest was changed, no ArgoCD sync was run, and no test object was retained. Recheck with the same curl method matrix and kubectl exec path test; rollback is limited to deleting the exact temporary path if a test object remains. Do not delete the PVC or Secret.

2026-07-28: sync filing-site photo albums from ops-docs

  • Confirmed 72602-minipc, context default, node 72602-minipc Ready at 192.168.0.25 (v1.34.6+k3s1); live route is https://72602.space/ through the nginx ingress class.
  • Hard-refreshed argocd/ops-docs with argocd app get ops-docs --hard-refresh --insecure --grpc-web (the installed CLI is v3.3.8 and does not support argocd app refresh --hard), then ran argocd app sync ops-docs --revision main --assumeYes --insecure --grpc-web.
  • The requested baseline was 98dbe94, but origin/main advanced during the operation to 07f0e515feb7379ca79516a6c31f0e41be5a04b4 (fix: increase sub2api ingress body timeout); the final sync used that remote main revision. ArgoCD finished Succeeded, Synced, and Healthy from 16:30:08 to 16:30:47 (+0800), with message successfully synced (no more tasks).
  • ops-docs-build briefly remained in Init:0/1 while clone-repo fetched the repository. Both clone-repo and hugo exited 0; the hook Job reached the expected succeeded pods. No manifest fix was necessary.
  • application/filing-site rollout completed. Pod filing-site-55cff975bf-z67xw is 1/1 Running; init-albums completed with exit 0, and nginx is ready with zero restarts. Deployment conditions Available=True and Progressing=True are present.
  • PVC filing-site-photos is Bound to a 5Gi local-path PV. Ingress annotations remain issuer lets-encrypt, SSL redirect enabled, body size 25m, request buffering off, and read/send timeouts 120; TLS Secret is 72602.space-tls.
  • Read-only verification: nginx -t reported syntax ok and test successful inside the Pod; HTTPS GET https://72602.space/ returned HTTP 200 with text/html from 47.110.67.161. Recent events show successful local-path provisioning, old ReplicaSet scale-down/new ReplicaSet scale-up, and Ingress scheduled for sync.
  • No Kubernetes Secret value was read, and no PUT/upload request or resource deletion was performed. Rollback requires explicit authorization and review of the current main: syncing the captured pre-sync revision 98dbe94 would also roll back later commits such as 07f0e51; do not delete the PVC or Secret.

2026-07-28: create filing-site upload authentication Secret

  • Confirmed 72602-minipc as the active node and found no existing application/filing-site-upload-auth Secret.
  • Generated the uploader password in shell memory with openssl rand -hex 18, generated an nginx-compatible apr1 hash with openssl passwd -apr1, and applied application/filing-site-upload-auth with key htpasswd. No credential material was written to disk or Git.
  • manifests/filing-site.yaml references this Secret for the filing-site Deployment, but does not define the Secret; no ArgoCD sync was required.
  • Safe verification confirmed metadata name=filing-site-upload-auth, namespace=application, type=Opaque, and key htpasswd without reading its value. The Deployment rollout succeeded with its Pod 1/1 Running.
  • Ingress remains nginx at https://72602.space/; certificate 72602.space-tls is Ready under lets-encrypt, and the public HTTPS check returned HTTP 200.
  • Rollback, only with explicit authorization: kubectl -n application delete secret filing-site-upload-auth.

2026-07-16: reset csst and update N8N webhook host

  • Deleted and recreated the csst namespace. Only the namespace default ServiceAccount and kube-root-ca.crt ConfigMap remain.
  • Changed N8N WEBHOOK_URL, webhook worker URL, Ingress rule, and TLS DNS name from webhook.72602.online to webhook.n8n.72602.online.
  • Synced ArgoCD application argocd/n8n; main, webhook, MCP webhook, and worker rollouts completed.
  • cert-manager completed HTTP-01 validation and issued the updated certificate.

2026-07-16: migrate OpenCode web to k3s

  • Replaced the host systemd process and static EndpointSlice with the application/ops-agent workload.
  • The Pod mounts /home/aaron/Ops/docs at /workspace, loads the project .opencode/opencode.json, and persists sessions in the opencode-data PVC.
  • Image ay-dev/ops-agent:0.2.3 uses glibc and contains OpenCode 1.18.16, kubectl 1.34.6, Argo CD CLI 3.3.8, VibeGuard, DCP, and Goal Mode.
  • OpenCode native Basic Auth protects both Ingress and cluster-internal access. Anonymous HTTPS returns 401; authenticated HTTPS returns 200.
  • An Nginx sidecar publishes the Ops Agent browser title and proxies Terminal WebSocket and event streams.

2026-07-16: remove Langfuse and refresh Homepage

  • Permanently removed the seven unused Langfuse PVCs (56 GiB) and six residual Secrets from monitor.
  • Removed Langfuse and pgAdmin from Homepage and added the OpenCode operations agent.
  • Restored the ArgoCD Homepage widget by binding the readonly API account to role:readonly.

2026-07-17: align Ops resource names

  • Renamed the Hugo workload and its Service, ConfigMap, build Job, and Ingress resources to ops-docs; retained hugo-docs-pvc to preserve generated content.
  • Renamed the OpenCode-based workload, Service, Ingress, proxy ConfigMap, manifest directory, and Dockerfile to ops-agent; retained existing opencode-* PVC and Secrets to preserve sessions and credentials.
  • Replaced the manual local-proxy-bridge with the GitOps-managed argocd-egress-proxy; ArgoCD repo-server uses its cluster Service while applications can continue using host port 17890.

Default Verification Commands

# 公网入口
curl -vI http://argocd.72602.space
curl -vkI https://argocd.72602.space

# k8s 资源
kubectl get ingress -A -o wide
kubectl get svc -A -o wide
kubectl get pods -A -o wide
kubectl get certificate,certificaterequest,order,challenge -A

# 主机端口
sudo ss -lntp | grep -E ':80|:443|:8443|:32080|:32443|:7890|:17890|:9090'

# ECS 转发
sudo iptables -t nat -L PREROUTING -n -v --line-numbers
sudo iptables -t nat -L DOCKER -n -v --line-numbers

# Egress proxy 完整性:先读实际端口,再做一次已知 204 检查
CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"
clashctl status
proxy_port="$("$CLASH_HOME/bin/yq" '."mixed-port" // .port // 7890' \
  "$CLASH_HOME/resources/runtime.yaml")"
curl --proxy "http://127.0.0.1:${proxy_port}" \
  --connect-timeout 5 --max-time 12 --silent --show-error \
  --output /dev/null --write-out 'proxy_http_code=%{http_code}\n' \
  https://www.gstatic.com/generate_204
kubectl exec -n n8n deploy/n8n -- sh -c \
  'test -n "$HTTP_PROXY" && test -n "$HTTPS_PROXY"'

# SSH 隧道
journalctl --user -u reverse-tunnel-ecs-10021.service --since "1 hour ago" --no-pager
journalctl --user -u reverse-tunnel-ecs-10022.service --since "1 hour ago" --no-pager
Mar 7, 2024

Subsections of 72602

Runtime Secret Recovery

This runbook documents the narrowly scoped 2026-08-02 recovery of a small, approved whitelist of missing runtime Secrets from a pre-incident k3s etcd snapshot. It does not restore the production cluster, databases, PVC contents, or application data. Do not reuse the old snapshot or whitelist as a generic current recovery recipe: later deployments added dependencies such as sub2api-mcp, and any future recovery must use a newly approved snapshot and an explicitly reviewed object list.

Safety boundary

Never reset production

Never run k3s server --cluster-reset with the production data directory, service, ports, network namespace, or kubeconfig. Restore the snapshot only in an isolated, same-version temporary k3s environment with no physical network interface or external route.

  • Obtain explicit approval to read the snapshot and create the named production Secrets.
  • Stop before production writes if the snapshot, version, token, isolation, or target object state is uncertain.
  • Disable shell tracing. Do not print Secret JSON/YAML, base64 data, hashes, tokens, passwords, private keys, or connection strings.
  • Use a root-owned mode-0700 temporary directory and mode-0600 files. Do not use the repository or an ordinary shared /tmp directory.
  • Use kubectl create, not apply, replace, or patch. A concurrent object creation must fail instead of being overwritten.
  • Do not delete Pods to accelerate recovery. Let kubelet and controllers retry the existing Pods after the required Secrets exist.
  • Do not modify PVCs, restore PostgreSQL, copy Redis data, or include filing-site resources in this procedure.
  • Run the production write steps only from 72602-minipc with kubeconfig context default. Recheck both immediately before creation; do not rely on an earlier terminal prompt or context check.

The approved 2026-08-02 whitelist was:

aliyun-registry
opencode-model
opencode-basic-auth
opencode-argocd
opencode-ssh
opencode-git-credentials
sub2api-auth
sub2api-external-postgresql
sub2api-redis

filing-site-upload-auth and 72602.space-tls were explicitly excluded. The list is historical. In particular, do not silently append newly created Secrets such as sub2api-mcp to this old snapshot procedure; validate each dependency against the selected snapshot and obtain approval for a new list.

Preflight

Confirm the live identity and exact k3s version before reading the snapshot:

hostname
kubectl config current-context
kubectl get nodes -o wide
kubectl version
sudo k3s etcd-snapshot ls --output json
sudo k3s secrets-encrypt status

Set the approved snapshot and whitelist without adding credential values to the shell history:

set +x
set -o pipefail
SNAPSHOT="/var/lib/rancher/k3s/server/db/snapshots/<approved-snapshot>"
NAMESPACE=application
NAMES=(
  aliyun-registry
  opencode-model
  opencode-basic-auth
  opencode-argocd
  opencode-ssh
  opencode-git-credentials
  sub2api-auth
  sub2api-external-postgresql
  sub2api-redis
)
sudo test -r "$SNAPSHOT"

Perform the first production absence check. If any object is present, stop the batch before extraction or writing and determine whether a newer value has already been created:

for name in "${NAMES[@]}"; do
  found="$(kubectl -n "$NAMESPACE" get secret "$name" \
    --ignore-not-found -o name)"
  test -z "$found" || {
    printf '%s already exists; stop before write\n' "$name" >&2
    exit 1
  }
done

Record the affected workloads, storage, route, and certificate state without reading Secret values:

kubectl -n application get deploy,statefulset,pod,pvc -o wide
kubectl -n application get ingress,certificate -o wide
kubectl -n application get events --sort-by=.lastTimestamp

Isolated extraction

Use the installed k3s binary so the temporary server exactly matches the live version. The temporary network has only loopback and a dummy sink interface. The dummy default route satisfies k3s node-address discovery, but cannot send a packet through a host or physical interface.

Create the protected operation directory:

RECOVERY_ROOT="$(sudo mktemp -d -p /var/lib \
  k3s-secret-recovery.XXXXXXXX)"
sudo chmod 0700 "$RECOVERY_ROOT"
printf 'recovery directory created with mode 0700\n'

Run the reset and export inside one private mount, network, and PID namespace. The production k3s and configuration directories are remounted read-only only inside that private mount namespace. The production server token is required to decrypt the snapshot bootstrap data; copy it only to ${RECOVERY_ROOT}/data/server/token with mode 0600 and never print it.

sudo tee "$RECOVERY_ROOT/extract.sh" >/dev/null <<'RECOVERY_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
set +x
umask 077
REC="$RECOVERY_ROOT"
PROD=/var/lib/rancher/k3s

case "$REC" in
  /var/lib/k3s-secret-recovery.*) ;;
  *) exit 20 ;;
esac
test -r "$SNAPSHOT"

mount --make-rprivate /
mount --bind "$PROD" "$PROD"
mount -o remount,bind,ro "$PROD"
if test -d /etc/rancher/k3s; then
  mount --bind /etc/rancher/k3s /etc/rancher/k3s
  mount -o remount,bind,ro /etc/rancher/k3s
fi

ip link set lo up
ip link add recovery0 type dummy
ip addr add 198.18.0.1/32 dev recovery0
ip link set recovery0 up
ip route add default dev recovery0
test "$(ip -o link show | wc -l)" -eq 2
test "$(ip route show default dev recovery0 | wc -l)" -eq 1

install -d -m 0700 "$REC/data/server" "$REC/export"
install -m 0600 "$PROD/server/token" "$REC/data/server/token"

common=(
  server
  --config /dev/null
  --data-dir "$REC/data"
  --token-file "$REC/data/server/token"
  --node-name secret-recovery
  --node-ip 198.18.0.1
  --bind-address 127.0.0.1
  --advertise-address 127.0.0.1
  --https-listen-port 16443
  --lb-server-port 16444
  --write-kubeconfig "$REC/recovery.kubeconfig"
  --write-kubeconfig-mode 0600
  --disable-agent
  --flannel-backend none
  --egress-selector-mode disabled
  --disable coredns
  --disable servicelb
  --disable traefik
  --disable local-storage
  --disable metrics-server
  --disable-scheduler
  --disable-cloud-controller
  --disable-kube-proxy
  --disable-network-policy
  --disable-helm-controller
  --etcd-disable-snapshots
)

/usr/local/bin/k3s "${common[@]}" \
  --cluster-reset \
  --cluster-reset-restore-path "$SNAPSHOT" \
  >"$REC/restore.log" 2>&1

/usr/local/bin/k3s "${common[@]}" >"$REC/server.log" 2>&1 &
server_pid=$!
cleanup_server() {
  kill -TERM "$server_pid" >/dev/null 2>&1 || true
  wait "$server_pid" >/dev/null 2>&1 || true
}
trap cleanup_server EXIT

ready=0
for _ in $(seq 1 180); do
  kill -0 "$server_pid" >/dev/null 2>&1 || exit 21
  if /usr/local/bin/k3s kubectl \
    --kubeconfig "$REC/recovery.kubeconfig" \
    get --raw=/readyz >/dev/null 2>&1; then
    ready=1
    break
  fi
  sleep 1
done
test "$ready" -eq 1

names=(
  aliyun-registry
  opencode-model
  opencode-basic-auth
  opencode-argocd
  opencode-ssh
  opencode-git-credentials
  sub2api-auth
  sub2api-external-postgresql
  sub2api-redis
)

for name in "${names[@]}"; do
  /usr/local/bin/k3s kubectl \
    --kubeconfig "$REC/recovery.kubeconfig" \
    -n application get secret "$name" -o json | \
    jq -ce --arg name "$name" '
      select(
        .apiVersion == "v1" and
        .kind == "Secret" and
        .metadata.namespace == "application" and
        .metadata.name == $name and
        (.data | type == "object") and
        (.data | length > 0)
      ) |
      {
        apiVersion: "v1",
        kind: "Secret",
        metadata: {
          name: .metadata.name,
          namespace: "application"
        },
        type: .type,
        data: .data
      } +
      (if has("immutable") then {immutable: .immutable} else {} end)
    ' >"$REC/export/$name.json"
  chmod 0600 "$REC/export/$name.json"
done

test "$(printf "%s\n" "$REC"/export/*.json | wc -l)" -eq 9
printf 'isolated whitelist export complete: 9 objects\n'
RECOVERY_SCRIPT

sudo chmod 0700 "$RECOVERY_ROOT/extract.sh"
sudo env -i \
  PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
  HOME=/root \
  RECOVERY_ROOT="$RECOVERY_ROOT" \
  SNAPSHOT="$SNAPSHOT" \
  unshare --mount --net --pid --fork --mount-proc \
  "$RECOVERY_ROOT/extract.sh"

Do not continue if the reset or temporary API fails. Inspect only redacted error lines from the root-owned logs; do not dump those logs or exported objects to a terminal. A missing default route must be solved only with the isolated dummy sink above. A restore requires the token at the temporary data-directory path; never satisfy that requirement by changing the production data directory.

Create production objects

Before the first mutation, record this rollback: delete only the objects that this operation successfully creates and that were proven absent immediately before creation. Never include a PVC, database, TLS Secret, or filing-site resource in that rollback.

Repeat the absence check immediately before creation:

test "$(hostname)" = 72602-minipc || {
  printf 'wrong host; abort before production write\n' >&2
  exit 1
}
test "$(kubectl config current-context)" = default || {
  printf 'wrong kubeconfig context; abort before production write\n' >&2
  exit 1
}
test "$NAMESPACE" = application || {
  printf 'wrong target namespace; abort before production write\n' >&2
  exit 1
}
kubectl get node 72602-minipc -o name | grep -qx 'node/72602-minipc'

for name in "${NAMES[@]}"; do
  found="$(kubectl -n "$NAMESPACE" get secret "$name" \
    --ignore-not-found -o name)"
  test -z "$found" || {
    printf '%s appeared after preflight; abort the batch\n' "$name" >&2
    exit 1
  }
done

Create each object through a protected pipe. kubectl create provides the required no-overwrite behavior:

CREATED=()
CREATE_FAILED=
for name in "${NAMES[@]}"; do
  if sudo dd if="$RECOVERY_ROOT/export/$name.json" status=none | \
    kubectl create -f - >/dev/null; then
    CREATED+=("$name")
    printf '%s created\n' "$name"
  else
    printf '%s failed; stop and assess\n' "$name" >&2
    CREATE_FAILED="$name"
    break
  fi
done

if test -n "$CREATE_FAILED"; then
  printf 'created before failure:' >&2
  printf ' %s' "${CREATED[@]}" >&2
  printf '\n' >&2
  false
fi

Do not automatically roll back a healthy recovery. If rollback is explicitly approved, use the same shell and delete only the names captured in CREATED. If that shell state is unavailable, stop and reconstruct the list from the recorded create results and Secret metadata before requesting new approval:

if test "${#CREATED[@]}" -eq 0; then
  printf 'CREATED is empty; refuse rollback\n' >&2
  false
else
  kubectl -n "$NAMESPACE" delete secret "${CREATED[@]}"
fi

Verification

Verify object metadata without requesting .data or .stringData:

for name in "${NAMES[@]}"; do
  kubectl -n application get secret "$name" \
    -o custom-columns='NAME:.metadata.name,TYPE:.type,CREATED:.metadata.creationTimestamp' \
    --no-headers
done

Wait for the existing workloads and endpoints. A Deployment can briefly retain the incident’s old ProgressDeadlineExceeded condition before its Pod becomes Ready; verify the current replicas and repeat rollout status after the controller updates the condition.

kubectl -n application rollout status \
  statefulset/sub2api-redis-master --timeout=300s
kubectl -n application rollout status deployment/sub2api --timeout=300s
kubectl -n application rollout status deployment/ops-agent --timeout=300s
kubectl -n application get deploy,statefulset,pod -o wide
kubectl -n application get endpointslice \
  -l kubernetes.io/service-name=sub2api -o wide
kubectl -n application get endpointslice \
  -l kubernetes.io/service-name=ops-agent -o wide

Inspect logs only after applying a credential and connection-string redactor. Treat any unrecognized output format as unsafe and stop before printing it. Verify that restart counts remain unchanged during an observation window.

Use the credential already injected into the Ops Agent Pod to verify health without reading it into terminal output or a command argument:

kubectl -n application exec deployment/ops-agent -c ops-agent -- sh -ceu '
set +x
{
  printf "user = \"%s:%s\"\n" \
    "$OPENCODE_SERVER_USERNAME" "$OPENCODE_SERVER_PASSWORD"
  printf "url = \"http://127.0.0.1:4000/global/health\"\n"
} | curl --silent --show-error --fail --config - | \
  jq -e ".healthy == true" >/dev/null
printf "ops-agent health is healthy\n"
'

Verify public routing, DNS, and TLS:

getent ahostsv4 ops.agent.72602.space
getent ahostsv4 token.72602.space
kubectl -n application get ingress ops-agent sub2api-token -o wide
kubectl -n application get certificate \
  ops.agent.72602.space-tls token.72602.space-tls
curl -fsS -o /dev/null -w '%{http_code}\n' \
  https://token.72602.space/health
curl -fsS -o /dev/null -w '%{http_code}\n' \
  https://token.72602.space/api/v1/settings/public
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://ops.agent.72602.space/

Run ArgoCD read-only checks from the Ops Agent Pod, where ARGOCD_SERVER and the readonly ARGOCD_AUTH_TOKEN are injected:

kubectl -n application exec -i deployment/ops-agent -c ops-agent -- \
  sh -seu <<'ARGOCD_CHECK'
for app in ops-docs ops-agent sub2api; do
  argocd app get "$app" --hard-refresh --insecure --grpc-web -o json | \
    jq -r '[
      .metadata.name,
      .status.sync.status,
      .status.health.status,
      (.status.sync.revision // "")
    ] | @tsv'
done
ARGOCD_CHECK

Cleanup

After the production create has completed or the operation has stopped before write, remove the entire temporary directory. First prove that no process or listener still uses it, then validate the path before deletion:

RECOVERY_CANON="$(sudo realpath -e -- "$RECOVERY_ROOT")" || {
  printf 'cannot resolve recovery path; refuse cleanup\n' >&2
  exit 1
}
test "$RECOVERY_CANON" = "$RECOVERY_ROOT" || {
  printf 'recovery path is not canonical; refuse cleanup\n' >&2
  exit 1
}
test "$(dirname -- "$RECOVERY_CANON")" = /var/lib || {
  printf 'unexpected recovery parent; refuse cleanup\n' >&2
  exit 1
}
case "$(basename -- "$RECOVERY_CANON")" in
  k3s-secret-recovery.????????) ;;
  *) printf 'unexpected recovery path; refuse cleanup\n' >&2; exit 1 ;;
esac
test "$(sudo stat -c '%u:%a' -- "$RECOVERY_CANON")" = 0:700 || {
  printf 'unexpected recovery owner or mode; refuse cleanup\n' >&2
  exit 1
}

if ps -eo args= | grep -F "$RECOVERY_ROOT" | grep -v grep >/dev/null; then
  printf 'temporary process still present; stop before cleanup\n' >&2
  exit 1
fi

TEMP_LISTENERS="$(sudo ss -H -lntp \
  '( sport = :16443 or sport = :16444 )')" || exit 1
test -z "$TEMP_LISTENERS" || {
  printf 'temporary listener still present; stop before cleanup\n' >&2
  exit 1
}
if ip link show recovery0 >/dev/null 2>&1; then
  printf 'temporary link still present; stop before cleanup\n' >&2
  exit 1
fi

sudo rm -r -- "$RECOVERY_CANON"
sudo test ! -e "$RECOVERY_CANON"
test "$(kubectl get --raw=/readyz)" = ok

The listener and link gates must both pass before deletion. The private namespace exit normally removes them automatically. Keep the approved etcd snapshot in the normal k3s snapshot directory; cleanup removes only the isolated restore environment and exported Secret material.

2026-08-02 recovery record

  • Live identity was 72602-minipc, context default, node 72602-minipc Ready at 192.168.0.25, k3s v1.34.6+k3s1.
  • Snapshot etcd-snapshot-72602-minipc-1785600004 was readyToUse=true, size 20164640, created at 2026-08-01T16:00:04Z. Secret encryption at rest was disabled.
  • Both production absence checks passed. All nine whitelist objects were created with create-only semantics; no object was overwritten and no credential was written to Git.
  • Kubelet recovered the existing Pods without deletion or a manual restart. Ops Agent reached 2/2 Ready, Sub2API and its Redis reached 1/1, and a 90-second stability observation showed no additional restart.
  • PostgreSQL remained 1/1 on its existing 95-day Pod in the separate database namespace. The post-prune replacement Ops Agent, Sub2API, and Redis PVCs remained Bound during Secret restoration; the pre-incident opencode-data, Redis AOF, sub2api-data, and filing-site photos local-path contents had no snapshot and were not recovered. Filing-site and its excluded Secrets were not restored.
  • Ops Agent health was healthy; OpenAI and Grok model checks returned HTTP 200. Sub2API cluster and public health returned 200; its public settings endpoint returned 200. Ops Agent public access returned authenticated 200 and expected anonymous 401.
  • Both active hostnames resolved to 47.110.67.161. Their nginx Ingresses and lets-encrypt certificates were Ready. ArgoCD reported ops-docs, ops-agent, and sub2api as Synced and Healthy.
  • The only remaining application warning was a missing pricing record for gemma4:31b; it did not block model requests or service health.
  • Cleanup removed the temporary data, token copy, exports, and logs. No temporary process, listener, or dummy link remained, and production k3s was still active and ready.

ECS Security Group

安全组 IP 自动更新

背景

72602-minipc 的 ISP 不定期更换公网 IP,而阿里云 ECS (ecs-99) 安全组限制了 SSH 端口只能从特定 IP 访问。

当公网 IP 变化时:

  • SSH 反向隧道断开
  • 无法通过 ssh aaron@47.110.67.161 -p 10022 访问
  • 无法直接 ssh root@47.110.67.161

解决方案

定时检测公网 IP,变化时统一协调两处 consumer:阿里云 ECS 安全组的 TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820 规则,以及 ECS 本机 UFW 的 51820/udp(comment wg 72602-minipc)与 3128/tcp (comment squid 72602-minipc)规则。所有云端写操作统一从 72602-minipc 上的同一个 5 分钟 systemd timer 发起;ECS 上只放一个最小化、root-only 的 forced-command 助手负责 UFW 这一侧。

ECS UFW 助手只固定管理两条规则:51820/udp(WireGuard)和 3128/tcp (Squid 公共前向代理),分别通过 UFW comment wg 72602-minipcsquid 72602-minipc 识别。云端 56396/tcp(mihomo/clash 外网面板) 当前只纳入 update-sg-ip-72602-minipc 的云安全组维护范围;ECS UFW 仍为 broad allow,暂未对 56396 做 IP 收窄。后续如要把 56396 真正转为 IP 白名单, 需要单独变更并评估与现网 0.0.0.0/0 行为之间的差异,本页不主张在 update-sg-ip 内一并实现。

工作原理

统一的动态 IP 协调器只跑在 72602-minipc 上的一个 5 分钟 systemd timer (update-sg-ip.timer / update-sg-ip.service)。每次执行都用 flock 串行化,保证同一时刻只有一个进程推进状态:

每 5 分钟 ──> flock 串行化
                  │
                  └── 获取公网 IPv4 (curl -4,按固定顺序逐个 fallback)
                          │
                          ├── 全部失败 ──> 写入 journald,不推进状态
                          │
                          └── 获取成功 ──> 校验返回内容是合法 IPv4
                                               │
                                               ├── 校验失败 ──> 写入 journald,不推进状态
                                               │
                                               └── 通过校验 ──> 仅与已记录的「两端都已核实」结果对比
                                                                  │
                                                                  ├── 未变化 ──> 退出,不动 SG / UFW / 状态
                                                                  └── 已变化 ──> 进入「先建新、再验证、后清理」
                                                                                    │
                                                                                     1. 通过 Aliyun SDK 在安全组内写入新 /32
                                                                                        (TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820)
                                                                                     2. 通过专用受限 SSH key(路径仅运行时存在)
                                                                                        以 root 身份调用 ECS 端的 forced-command 助手
                                                                                        /usr/local/sbin/72602-wireguard-ufw-reconcile
                                                                                        仅调整 `51820/udp`(comment `wg 72602-minipc`)
                                                                                        与 `3128/tcp`(comment `squid 72602-minipc`)
                                                                                        的 UFW 规则
                                                                                     3. 两个 consumer(Aliyun SG 与 ECS UFW)都验证生效后
                                                                                        才写入持久状态并清理旧的 updater-owned 规则
                                                                                    │
                                                                                    └── 任意一步失败 ──> 新规则保留,
                                                                                                        旧 managed 规则不删除;
                                                                                                        下一次重试幂等

公网 IP 探测使用 curl -4、有限重试和五个固定 endpoint:保留的 ifconfig.meip.sbicanhazip.com,以及现场验证通过的 ifconfig.co/ipipinfo.io/ip。脚本会收集全部结果;至少两个 endpoint 返回 相同的合法 IPv4 才会被采纳。单个结果或全失败都只写入 journald,不修改云 安全组、不推进缓存,并记录各 endpoint 的阶段性错误或结果。

探测失败使用持久的连续失败计数:达到三次才尝试一次 [ZJLAB] public IPv4 detection failed 通知,失败期间不重复刷屏;quorum 恢复 后只尝试一次恢复通知。通知失败不改变任务退出判断。成功 heartbeat 仍保持三天 一次,失败计数与告警状态文件位于现有持久状态目录且权限为 0600

受影响的端口

端口协调器写入的位置由谁验证
TCP 22Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 10021Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 10022Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 3128Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment squid 72602-minipcAliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地
TCP 56396Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证(仅云端;ECS UFW 当前仍 broad allow,暂未收窄)
UDP 51820Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment wg 72602-minipcAliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地

51820/udp3128/tcp 是「双 consumer」:Aliyun 安全组由 update-sg-ip.service 写入;ECS 本机的 UFW 规则由 update-sg-ip.service 通过专用受限 SSH key 调用 ECS 上 root-only 的 forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile 调整。两个 consumer 都验证 生效后,协调器才清理旧的 updater-owned 规则并落盘持久状态。任意一侧失败都会 让新规则保留、旧 managed 规则保留到下一次重试,重试本身幂等。

56396/tcp 是「单 consumer」:只写 Aliyun 安全组;ECS UFW 仍维持当前 broad allow。协调器不会去收窄 UFW 这一侧;任何把 56396 真正变成 IP 白名单 的变更都需要单独评审,并不会通过 update-sg-ip 顺带实现。

双 consumer 协调与安全的部分失败

51820/udp(WireGuard)和 3128/tcp(Squid 公共前向代理)在公网路径上各 有两层入口:阿里云安全组的 /32 规则(云端边界)和 ECS 本机 UFW 的 /32 规则(实例边界)。只更新其中一层,目标端口的流量仍可能在另一层被丢包, 因此协调器把两者视作一个事务来推进:

  1. 先建新规则:协调器先按 Description update-sg-ip-72602-minipc 在 Aliyun 安全组中写入新 /32,并通过 SSH 调用 ECS 上的 72602-wireguard-ufw-reconcile 在 UFW 中加入新 /3251820/udp 使用 comment wg 72602-minipc3128/tcp 使用 comment squid 72602-minipc
  2. 两边都验证:协调器再次描述安全组、再次触发 UFW helper 的 status 输出,确认所有新规则都已经落地并匹配当前探测到的公网 IP。
  3. 再清理旧规则:两侧验证都通过后才删除旧 updater-owned 规则,再把 「最近已知 IP + 上一次双 consumer 已核实时间戳」写入持久状态目录。
  4. 失败回退:只要任意一侧验证失败,协调器就立刻退出,不删除旧规则、 不推进持久状态。新规则保留,下一个 5 分钟周期由协调器幂等重试。

协调器在「IP 未变化」的周期也会跑一次只读的 3128/tcp51820/udp UFW 一致性检查:发现 UFW 与持久状态不符(例如上一次 IP 变化期间 ECS 侧 helper 调用被中断,或 UFW 被人手改回旧 IP)时,会把当前持久 IP 重新作为 新规则写回 UFW、验证后再清理旧的 managed 规则;幂等性保证重复执行不会 产生重复条目。这条「无变化补齐」逻辑正是 2026-08-27 那次旧 UFW 3128 白名 单未及时刷新故障的直接修复。

这一顺序保证了三件事:

  • 新 IP 在协调器认为「完成」之前已经可以同时被云端与本机接受,新规则一旦 落地就不会再被回滚。
  • 旧 IP 的访问能力在协调失败时仍然保留,下一次重试会先再次验证新规则、 再尝试清理旧规则,不会出现「只删了旧规则、新规则又没建好」的窗口。
  • 整个流程不依赖任何单一调用方的成功响应;任意一次调用失败都不会破坏 协调器与两个 consumer 之间的一致性。

协调器本身是幂等的:Aliyun SDK 写入同 Description / 同 /32 是修改语义, 不会复制规则;UFW 助手按 comment 识别自己负责的规则,重复调用也是修改或 去重,不会复制条目。因此 systemctl start update-sg-ip.service 在五分钟 周期之外被手动触发不会引入脏状态。

文件位置

协调器的所有持久组件都落在 72602-minipcaaron 用户下;ECS 上只放一个 最小化、root-only 的 forced-command 助手。本页不复述任何运行时密钥或 AccessKey 的实际路径。

文件 / 资源说明
/home/aaron/bin/update-sg-ip.sh协调器主脚本(0755,仅属主可写);使用 flock 串行化并调用 /home/aaron/.local/venvs/alidns/bin/python 跑官方 Aliyun ECS / VPC SDK
/home/aaron/.local/venvs/alidns/bin/python官方 Aliyun ECS / VPC SDK 的 approved virtualenv;当前脚本通过它发请求,不再使用系统 Python
/home/aaron/.aliyun-keys阿里云 AccessKey(0600,仅属主可读写),在 SDK 进程内被 source;当前同时具备 ECS 安全组和 AliDNS 权限,后续应拆分为最小权限 RAM 身份
/etc/systemd/system/update-sg-ip.service72602 系统级 systemd service(User=aaron
/etc/systemd/system/update-sg-ip.timer72602 系统级 systemd timer(OnBootSec=30OnUnitActiveSec=5minPersistent=true);唯一调度源
/home/aaron/.config/systemd/user/update-sg-ip.serviceZJLAB 用户级 systemd service
/home/aaron/.config/systemd/user/update-sg-ip.timerZJLAB 用户级 systemd timer(OnUnitActiveSec=5minPersistent=true,链接位于 timers.target.wants
/home/aaron/.local/state/update-sg-ip/持久状态目录:最近已知 IP、上一次「两端都已核实」的时间戳、连续探测失败计数与告警状态;状态文件 0600,仅属主可读写
update-sg-ip.service / update-sg-ip.timerjournald失败原因、API 退出码、是否推进状态等副作用日志
/usr/local/sbin/72602-wireguard-ufw-reconcileECS 上 root-only forced-command 助手;只接受来自专用受限 SSH key 的连接,仅调整 51820/udp(comment wg 72602-minipc)和 3128/tcp(comment squid 72602-minipc)两条 UFW 规则,不开放 shell / port forwarding;源 IP 取自 ECS 上看到的实际 SSH_CONNECTION
ECS 端的专用受限 SSH key路径与权限仅在运行时存在;本页面与版本控制都不复述绝对路径

/tmp 下不再保留持久状态;断电或重启后历史只在持久目录里。安全组的旧 Description 归属(auto-updated-ip)早已下线,新的 updater-owned 规则一律 按 update-sg-ip-72602-minipc / update-sg-ip-zjlab 描述字段识别。

Live 部署快照(已核实事实;2026-08-13 审计,动态状态需 live verify)

72602 live(2026-08-16 21:29 +08 观察点):

  • update-sg-ip.serviceupdate-sg-ip.timer 已部署在 /etc/systemd/system/User=aaron
  • timer 配置:OnBootSec=30OnUnitActiveSec=5minPersistent=true, 当前唯一调度源。
  • 已执行 daemon-reloadsystemctl status update-sg-ip.timer 处于 active (waiting);手动运行 update-sg-ip.service 一次成功。
  • 脚本 /home/aaron/bin/update-sg-ip.sh 权限 0755,使用 flock 串行化; 当前通过 approved virtualenv /home/aaron/.local/venvs/alidns/bin/python 调起官方 Aliyun ECS / VPC SDK,不再走系统 Python。
  • 持久状态目录 /home/aaron/.local/state/update-sg-ip/,文件 0600
  • ECS 上的 root-only forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile 已部署,固定管理两条 UFW 规则:51820/udp(comment wg 72602-minipc)和 3128/tcp(comment squid 72602-minipc);仅接受来自专用受限 SSH key 的 forced-command 调用,不开放 shell / port forwarding / Agent forwarding;该 SSH key 的私钥 路径与权限仅运行时存在,文档不公开。
  • 当前观察到的 72602-minipc 公网 IPv4 为 122.231.144.126,但这只是某一次 观察点,不应作为永久期望值;任何「当前公网 IP 应为 X」的判断都要重新探测 后再写。
  • WireGuard handshake fresh,HAProxy 正常服务 minipc_wg,SSH Web 备份路径 健康;公网 port.72602.spaceops.docs.72602.space 均为 HTTP 200、 TLS 校验通过。

ZJLAB 与仓库模板:ZJLAB 上的同名用户级 updater,以及本仓库私有模板,当前 只维护 TCP 221002110022,不维护 UDP 51820。两套脚本虽然文件名 相同,但端口集合、部署层级和运行时凭据来源不同;同步或恢复前必须按目标主机 逐项审阅,不能互相替换。

ZJLAB live:

  • 用户级 ~/.config/systemd/user/update-sg-ip.service.timer 已写入;timers.target.wants/ 下存在 enabled 链接。
  • timer 调度与 72602 相同(OnUnitActiveSec=5minPersistent=true)。
  • 脚本 /home/aaron/bin/update-sg-ip.sh 权限 0755;持久状态目录 /home/aaron/.local/state/update-sg-ip/
  • 脚本通过 bash -n;两个 user unit 通过 systemd-analyze verify。本批次变更 未修改 user unit 文件,也未对其执行 daemon-reload
  • 当前 user timer 为 enabledactive (waiting),仍按五分钟调度;非交互 SSH 需要设置用户运行时目录后才能连接 user manager。

安全组规则 Description 归属(已核实)

本次仅按 ModifySecurityGroupRule 在 ECS 控制台原地改写 Description 字段;协议、端口、CIDR、优先级、Policy、Direction 均未变更,未使用 RevokeSecurityGroup + AuthorizeSecurityGroup 组合。本节不出现 RuleId、真实 IP 或备份绝对路径。

当前自动更新器在 ECS 上以如下 Description 识别自己负责的规则:

脚本Description负责的协议/端口
update-sg-ip-72602-minipcupdate-sg-ip-72602-minipc72602-minipc 当前自动更新的 TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820(其中 312851820 在 ECS UFW 也有对端规则,56396 仅云端维护)
update-sg-ip-zjlabupdate-sg-ip-zjlabZJLAB 出口来源的 TCP 22 / 10021 / 10022 白名单;ZJLAB 的 10023 / 10024 仍为 ECS loopback-only listener,不应有公网规则

迁移与兼容要点(已核实):

  • 旧的 zjlab-ubuntu-SSH 目标规则当前已迁移为新 Description(update-sg-ip-zjlab),新写入按新 Description 归属。
  • 此前遗留的 8 条 auto-updated-ip 规则已经逐条审计并清理:无法证明仍在使用的历史来源已删除;仍有连接证据的 72602/ZJLAB 来源规则改为对应的新 Description;仅绑定 ECS loopback 的 10023 / 10024 规则也已删除。当前 live 安全组中 auto-updated-ip 为 0 条。当前 10023 / 10024 不属于公网访问面。
  • 72602 live 脚本后续新增规则统一使用新 Description 写入,再按 Description 归属做替换与去重。

排障/审计提示:

  • 描述归属是审计依据,但不是访问控制字段;脚本仍只按来源 IP 维度做替换与去重,不依赖 Description 进行授权判定。
  • 调整 Description 不会改变 ModifySecurityGroupRule 调用语义;若要恢复旧描述或回退历史规则,请先在 ECS 控制台人工确认目标 RuleId 当前的协议/端口/CIDR/优先级/Policy/Direction,再按原值 ModifySecurityGroupRule 回写 Description。
  • 后续如再次出现未知来源或旧 Description 规则,不应仅凭名称自动删除;必须先核对协议、端口、CIDR、优先级、Policy、Direction,以及 ECS 监听和连接日志,再按 RuleId 原地迁移或删除。

可恢复备份

脱敏后的可恢复备份位于私有仓库 ops-private 的隐私目录,包含 scripts/update-sg-ip.sh、两套 systemd unit 的样例、env.example 及对应的 Markdown runbook;提交不包含 AccessKey、钉钉 token 或真实安全组 ID。绝对路径仅保留在 ops-private 内部,本页不复述。

备份操作不会触碰现网脚本、systemd units 或 timer;只有在显式授权下才把备份还原回现网路径。

AliDNS 环境

  • 官方 AliDNS SDK 使用独立虚拟环境 /home/aaron/.local/venvs/alidns
  • SDK 或依赖需要下载时,使用 HTTP 代理 http://192.168.0.25:17890
  • 当前凭证具备 72602.space 区域的 AliDNS 记录管理能力,也具备 ECS 安全组变更能力。
  • DNS 变更前应限定目标区域和记录,并在变更后分别执行权威 DNS 与公共 DNS 验证。

常用命令

72602 系统级(从 72602-minipc 执行):

# 查看定时器状态
systemctl status update-sg-ip.timer

# 手动触发一次更新
sudo systemctl start update-sg-ip.service

# 查看执行日志
journalctl -u update-sg-ip.service -f

# 手动运行脚本
~/bin/update-sg-ip.sh

# 查看持久状态(最近已知 IP、上一次「两端都已核实」的时间戳等)
ls -l /home/aaron/.local/state/update-sg-ip/

# 检查 approved virtualenv
ls -l /home/aaron/.local/venvs/alidns/bin/python

# 验证 ECS 端的 UFW 规则与 helper 一致(只读)
ssh root@47.110.67.161 'sudo ufw status | grep -E "51820/udp|3128/tcp|wg 72602-minipc|squid 72602-minipc"'

ZJLAB 用户级(从非交互通道;enable-linger 状态需另行确认):

# 查看用户级定时器
systemctl --user status update-sg-ip.timer

# 手动触发一次更新
systemctl --user start update-sg-ip.service

# 查看执行日志
journalctl --user -u update-sg-ip.service -f

钉钉通知

脚本支持钉钉通知。ops-private 恢复模板要求从 mode 0600 的运行时凭据 文件或环境占位符读取通知配置,变量值不应出现在版本控制、日志或本页面里。 2026-08-13 审计发现 72602 live /home/aaron/bin/update-sg-ip.sh 与仓库模板 不一致并包含内嵌的钉钉运行时配置/凭据;本次未输出、复制或修改这些值。后续 应在维护窗口迁移到受权限保护的凭据文件并轮换旧凭据,不能把 live 脚本直接 复制回仓库。

通知端到端送达(钉钉服务器 → 群)无法从主机单独证明。已核实的层面仅是「脚本进入了成功发送路径」:

  • 72602:HTTP 层返回成功。
  • ZJLAB:当前仅能确认请求进入了成功发送路径,无法断言到达对端。

只有在「两端都已核实」之后,通知才会被发出;单边成功(SG 写入成功但 UFW 助手失败,或反之)不构成成功完成。

凭证安全

阿里云 AccessKey 存储在 /home/aaron/.aliyun-keys,权限 0600,仅在官方 Aliyun SDK 进程内被 source,绝不打字、复制或写入 Git。当前同一 AccessKey 同时具备 ECS 安全组和 AliDNS 变更权限;后续应拆分为两个最小权限 RAM 身份。AccessKey 获取:阿里云控制台 → 头像 → AccessKey 管理。建议定期轮换。

脚本 /home/aaron/bin/update-sg-ip.sh 权限已为 0755,仅属主可写。当前 通过 approved virtualenv /home/aaron/.local/venvs/alidns/bin/python 调起 官方 SDK(ECS / VPC / AliDNS),不再走系统 Python;依赖下载时仍可走 http://192.168.0.25:17890 代理。

ECS 上用于调用 UFW 助手的 SSH key 只授权单一 forced-command (/usr/local/sbin/72602-wireguard-ufw-reconcile),不携带 shell、不支持 端口转发 / Agent forwarding;其私钥路径与权限仅在运行时存在,本页面不复述。

排障与验证

依次用下面命令定位问题,每一步都不会改动远端:

# 1. systemd 单元是否加载、timer 是否 active/waiting
systemctl status update-sg-ip.timer
# 2. 最近一次运行的输出与退出码
sudo journalctl -u update-sg-ip.service -n 200 --no-pager
# 3. 公网 IPv4 获取是否仍正常(独立于 systemd)
curl -4 --max-time 5 -s https://ifconfig.me; echo
curl -4 --max-time 5 -s https://ip.sb; echo
curl -4 --max-time 5 -s https://icanhazip.com; echo
# 4. 当前持久状态与上次「两端都已核实」的时间戳
stat -c '%n %y' /home/aaron/.local/state/update-sg-ip/*
# 5. Aliyun 安全组中 updater-owned 规则的实际状态(只读)
#    仅按 Description 过滤,不打印真实 IP/RuleId
ssh root@47.110.67.161 'sudo /usr/local/sbin/72602-wireguard-ufw-reconcile status || true'
# 6. ECS UFW 中 helper 管理的两条规则的实际状态(只读):
#    `51820/udp` 走 comment `wg 72602-minipc`,`3128/tcp` 走 comment `squid 72602-minipc`
ssh root@47.110.67.161 'sudo ufw status | grep -E "wg 72602-minipc|squid 72602-minipc"'
# 7. Squid 自身监听(只读),确认 `:3128` 仍在 ECS 上服务
ssh root@47.110.67.161 'sudo ss -ltnp | grep ":3128 "'
# 8. 走 ECS 公网 `47.110.67.161:3128` 的代理连通性(明确不经过集群内任何代理)
ssh root@47.110.67.161 'curl --noproxy "*" -sS -o /dev/null -w "%{http_code}\n" \
    --max-time 8 -x http://127.0.0.1:3128 https://ifconfig.me/ip'

补充说明:

  • TCP 56396 当前只纳入 72602 云安全组 update-sg-ip-72602-minipc 的 updater 范围,ECS UFW 仍维持 broad allow;协调器不会去收窄 UFW 这一 侧,因此排障时不要把它和 wg 72602-minipc / squid 72602-minipc 放在同 一条 UFW 命令里期待出现匹配条目。如要把 56396 真正变成 IP 白名单,需要 在 helper 与协调器之间单独追加变更,不在本页说明。
  • 步骤 8 的 curl --noproxy '*' 是为了直接验证 ECS 自己的 Squid 是否真的在 接受流量,不等同于独立公网观测点。本环境目前没有独立公网探测机,不能声 称独立公网访问已验证;任何对该结论的主张都需要外部观测数据。

判定要点:

  • 如果定时器未处于 active (waiting),先看 journalctl 里是否含单元语法/路径错误;不要直接重写 unit,先核对 systemd-analyze verify
  • 如果 IPv4 全部失败,证实问题在出网路径而非本脚本;优先检查 ISP 与 192.168.0.25:17890 代理。
  • 如果 journald 显示「安全组写入成功但 UFW 助手失败」或反之,说明只完成了一半 consumer;此时新规则已经生效、旧 managed 规则不会删除,等待下一个 5 分钟周期由协调器重试,不需要立刻手工调整。
  • 如果发现 3128/tcp51820/udp 的 ECS UFW 中存在两条 updater-owned /32(旧的 IP 与当前 IP),说明上一次 IP 变化期间 UFW 这一侧未完成「先 建新 + 验证 + 清理旧」;当前协调器在「IP 未变化」的周期也会做幂等补齐, 请等待一个 5 分钟周期并复查;如果持续多条 managed /32 共存,请先按 helper 的 status 输出确认实际状态再决定是否人工介入,不要直接删除。
  • 不要在「两端都已核实」之前手工删除旧 managed 规则;否则下一次 IP 变化会同时出现旧规则缺失 + 新规则被建,造成双 consumer 一致性窗口被绕过。
  • 协调器本身是幂等的:每 5 分钟周期都会重新评估,新规则重复写入会被 Aliyun SDK / UFW 助手去重。

回滚原则

回滚的目标只是把协调器本身(unit、timer、脚本、approved virtualenv、持久 状态目录、ECS UFW 助手)恢复到上一份已审核版本,不应回滚阿里云安全 组或 ECS UFW 远端规则,也不应暴露任何备份的绝对路径或 SSH key 路径。 在确认新的协调路径能跑通之前,不要主动删除当前 ECS 22/10021/100223128/tcp51820/udp 的允许 IP,否则可能把自己从 ECS 端断掉。

一般顺序:

  1. systemctl stop update-sg-ip.timer(必要时连同 .service),冻结调度。
  2. ops-private 隐私目录取出对应文件原样覆盖到现网路径,并恢复属主 aaron 与原权限(脚本 0755,虚拟环境与持久状态目录 0600 / 0700)。
  3. systemctl daemon-reload(系统级)或 systemctl --user daemon-reload(用户级)。
  4. 重启 timer 并通过 systemctl statusjournalctl 复核 unit 已被识别。
  5. 在 ECS 上复核 /usr/local/sbin/72602-wireguard-ufw-reconcile 的版本与权限(root-only、0700),确认其 authorized_keys 条目仍指向专用受限 key 而非 root 登录 key。
  6. 安全组 / UFW 规则若需手动恢复到旧的允许 IP,应通过 ops-private 内的官方 AliDNS / 阿里云 SDK 流程,不在本页复述参数。

如果回滚过程中发现协调逻辑本身可疑,先保留旧的 systemd unit 与旧脚本,把 问题记到新 issue 而不是就地修改 live 协调路径。

紧急临时访问设计(尚未部署)

当前安全组只允许已登记的 72602 与 ZJLAB 出口来源访问 ECS 的 TCP 221002110022;端口用途仍分别由 72602 主/备入口和 ECS sshd 决定。ZJLAB 的 10023 / 10024 监听器仅在 ECS loopback 上提供 ProxyJump 目标,不应通过 安全组公开。紧急访问不建议使用无认证的传统端口敲门序列;序列可被监听、重放 或扫描。推荐使用一个独立的、仅密钥认证的 SSH gate:

  1. ECS 单独监听一个 gate 端口,例如 TCP 2222;该端口只允许专用用户 sg-gate,不提供 shell、PTY、端口转发或 Agent forwarding。
  2. sg-gate 只接受一把独立的、带密码短语的 emergency key。认证成功后由 forced command 读取 SSH_CONNECTION 的实际来源 IP,不接受用户自行传入任意 IP。
  3. 默认只为该来源 IP 添加 TCP 22/32 临时规则,Description 使用 emergency-ssh-<request-id>;如确实要访问 72602 反向入口,必须显式选择只包含 10021 / 10022 的 tunnel profile,不默认开放,也不得选择 10023 / 10024
  4. 临时授权最大有效期固定为 3600 秒。授权器保存 RuleId、来源、端口和 UTC 到期时间;root-only 的过期任务每分钟扫描并按 RuleId 删除,重启后先执行一次过期清理。删除失败必须重试并告警,不能只依赖启动授权的 SSH 会话。
  5. 授权、续期和删除都要记录审计日志;重复请求不得创建重复规则。用户 IP 发生变化时,必须从新 IP 重新执行 gate。

用户侧操作流程(部署后):

# 1. 用独立 emergency key 认证 gate;源 IP 由 ECS 自动识别
ssh -p 2222 -i ~/.ssh/ecs-emergency-gate sg-gate@47.110.67.161 grant

# 2. 使用原来的 ECS 管理 key 连接真正的 SSH 服务
ssh -i ~/.ssh/ecs-admin root@47.110.67.161

这个流程需要一个独立的云端控制路径。若授权器放在 ECS 上,必须使用只允许目标安全组读取、添加和删除规则的独立 RAM 身份,凭据仅由 root 读取,不能复用当前同时拥有 AliDNS 权限的主密钥。若坚持所有云变更只从 72602-minipc 发起,则 gate 可以通过现有反向隧道请求 minipc 执行,但 72602 与 ECS 的桥梁同时中断时紧急入口也会失效,不能满足真正的灾备目标。

传统 knockd 端口序列可以作为低成本触发器,但不应作为唯一认证。若不开放独立 SSH gate,可改用带时间戳、随机数和 MAC 的 SPA(例如 fwknop)触发同一个授权器;无论采用哪种触发方式,云端规则都必须由持久过期任务按 RuleId 删除。

该设计目前仅记录方案,尚未开放 gate 端口、创建 emergency key、创建 RAM 身份或部署授权器。

Recent Operations

2026-08-27: 72602 出口 IP 变化导致 ECS UFW 3128 白名单失同步

  • 现象:72602-minipc 出口公网 IP 变化后,Aliyun ECS 安全组的 TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820 已由 update-sg-ip-72602-minipc 按 Description 全部刷新到新 IP;但 ECS 本机 UFW 中 3128/tcp(旧 squid 72602-minipc 注释的 /32)未同步刷新, 新 IP 通过 ECS 公共 Squid 47.110.67.161:3128 出网时出现连接阶段超时。 旧 51820/udp UFW 规则在本次 IP 变化中由协调器按时刷新,未受影响。
  • 修复:协调器在「IP 未变化」的周期补做一次 51820/udp + 3128/tcp UFW 一致性检查:以持久状态目录记录的最近已知 IP 与 ECS UFW 中 wg 72602-minipc / squid 72602-minipc 注释的 /32 做比对,发现差异时 把当前持久 IP 重新作为新规则写回 UFW、验证后再清理旧 managed 规则;调 用逻辑仍然走 ECS 上 root-only 的 forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile,保留「先建新 + 验证 + 清理 旧」顺序,并依赖 Aliyun SDK / UFW 助手自身去重保证幂等。
  • 已核实(2026-08-27):
    • Aliyun ECS 安全组按 Description update-sg-ip-72602-minipc 描述验证: 新 IP /32 已落地,旧 IP /32 已清理;
    • ECS UFW 中 51820/udp(comment wg 72602-minipc)与 3128/tcp (comment squid 72602-minipc)的 /32 均与持久状态目录中的最近 已知 IP 一致;
    • Squid 在 ECS :3128 上仍正常监听(root-only ss 验证),与故障前 行为相同;
    • 通过 ECS 上 curl --noproxy '*' -x http://127.0.0.1:3128 https://ifconfig.me/ip 确认 Squid 仍能向出口出网并取得与持久状态一 致的公网 IPv4。
  • 未核实:本环境当前没有独立公网探测机,不能把上述验证等同于独立公网访 问已通过;任何对外部独立访问性的主张都需要外部观测数据。
  • 未触碰:Kubernetes、安全组旧 auto-updated-ip 历史规则、SSH 反向隧道、 HAProxy、Mailu、wg-quick@wg0、凭据、AccessKey 或 token。回滚保持通用 顺序,不要先于新协调路径验证就主动删除 ECS 22/10021/100223128/tcp51820/udp 的允许 IP。

2026-08-27: ZJLAB IPv4 探测多 endpoint curl 28 风暴后自愈

  • 当前 ZJLAB 用户级 update-sg-ip.service 在一个观测窗口内出现三个或多个 endpoint 同时返回连接阶段 curl 28 超时,随后自动恢复;该窗口内 ZJLAB 出口公网 IP 未发生实际变化,未触发任何云端写操作,未推进持久状态。
  • 已部署行为:
    • 5 个 endpoint 参与探测(保留 ifconfig.me / ip.sb / icanhazip.com, 新增现场验证通过的 ifconfig.co/ip / ipinfo.io/ip);
    • 至少两个 endpoint 返回同一合法 IPv4 才会被采纳;
    • 连续三次探测失败才发送一次 [ZJLAB] public IPv4 detection failed 通知,恢复后只发送一次恢复通知;
    • 失败 / 恢复通知在 DingTalk 上若有 pending 状态,会在下一个健康周期 重试,不会在日志里重复刷屏。
  • 当前事实:ZJLAB 出口 IP 未变化,云端 AliDNS / 安全组 / UFW / WireGuard / SSH 反向隧道均未触发更新;通知凭据与 endpoint 凭据不在本页面复述。 后续若窗口再次扩大或某个 endpoint 持续不可达,先看 journald 与 dingtalk 通知侧的实际状态,再决定是否调整 endpoint 列表或告警阈值。

2026-08-20: ZJLAB public IPv4 detection quorum and alert debounce

  • 通过批准的 zjlab-ubuntu-local 路径修复了 ZJLAB 用户级 updater 的诊断和告警质量。 旧逻辑按 endpoint 顺序采纳首个成功结果;本次观测到既有 endpoint 在一段时间内 同时出现连接阶段 curl 28 超时,恢复后无需云端动作。
  • 保留 ifconfig.meip.sbicanhazip.com,新增并现场验证 ifconfig.co/ipipinfo.io/ip。现在至少两个 endpoint 返回同一合法 IPv4 才会推进后续协调;单个或全失败不修改安全组、不推进缓存,并记录 endpoint 阶段性结果。
  • 持久失败计数在连续第三次失败时才尝试一次失败通知,恢复后只尝试一次恢复 通知;通知失败不改变任务退出判断。成功 heartbeat 仍为三天一次。
  • 未修改 Kubernetes、安全组、SSH 隧道、凭据、端口列表或 Description 逻辑。 脚本保持 0755;脚本与两个 user unit 的 0600 回滚备份保存在 ZJLAB 用户 的持久状态目录下,实际备份位置不在本页复述。
  • bash -n、两个 user unit 的 systemd-analyze verify 以及不触发云写 API 的 受控测试均通过,覆盖 quorum、单结果不足 quorum、全失败、三次失败单告警和 恢复单通知。

2026-08-16: unified 72602 dynamic-IP reconciliation deployed

  • 单一 5 分钟 systemd timer(update-sg-ip.timer / update-sg-ip.service) 现在统一协调两处 consumer:Aliyun ECS 安全组的 TCP 22 / 10021 / 10022 与 UDP 51820 规则,以及 ECS 本机 UFW 的 51820/udp 规则 (comment wg 72602-minipc)。ZJLAB 上的同名 user timer 仍是各自 environment 内的独立调度源。
  • 协调器使用 flock 串行化;通过 approved virtualenv /home/aaron/.local/venvs/alidns/bin/python 调起官方 Aliyun ECS / VPC SDK,不再走系统 Python。脚本本身仍位于 /home/aaron/bin/update-sg-ip.sh0755)。
  • ECS UFW 这一侧由一个 root-only forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile 负责;该助手仅调整 51820/udp UFW 规则,仅接受来自专用受限 SSH key 的调用,不开放 shell / port forwarding / Agent forwarding,源 IP 取自 ECS 上看到的实际 SSH_CONNECTION。专用 SSH key 的私钥路径与权限仅运行时存在,本页面与 版本控制都不公开其绝对路径。
  • 协调器采用「先建新规则 → 两边分别验证生效 → 再清理旧 updater-owned 规则并落盘持久状态」的顺序。任意一侧验证失败都会让新规则保留、旧 managed 规则保留到下一次重试,重试本身幂等;持久状态目录 /home/aaron/.local/state/update-sg-ip/ 只在「两端都已核实」之后才推进。
  • 验证点(2026-08-16 21:29 +08):
    • update-sg-ip.timer 仍处 active (waiting)
    • 公网 IPv4 探测从 ifconfig.me / ip.sb / icanhazip.com 都返回 122.231.144.126(仅作为该观察点的快照,不作为永久期望值);
    • ECS UFW 中 51820/udp 规则带有 comment wg 72602-minipc
    • WireGuard handshake fresh;HAProxy 正常服务 minipc_wg;SSH Web backup 路径健康;
    • 公网 https://port.72602.space/https://ops.docs.72602.space/ 均 HTTP 200 且 TLS 校验通过。
  • 回滚保持通用顺序(停 timer / 从 ops-private 取备份覆盖 / daemon-reload / 重启 timer),不要先于新路径验证就主动删除 ECS 22/10021/1002251820/udp 的允许 IP。

2026-08-11: approved ECS security-group cleanup

  • The operation ran through the approved 72602-minipc SSH path. Both ECS reverse-tunnel entry points (10021 and 10022) authenticated successfully and returned 72602-minipc. The official ECS SDK was run on that host in cn-hangzhou; no credential value was printed. A host-local redacted rollback record was created with mode 0600 before mutation.
  • ModifySecurityGroupRule changed only the Description of the three 39.170.58.206/32 TCP rules for 22, 10021, and 10022 from auto-updated-ip to update-sg-ip-zjlab. Protocol, port, source, policy, priority, and direction were unchanged.
  • By exact RuleId, with a fresh Describe verification after each deletion, the following were removed: the 36.24.59.216/32 TCP 22/10021/10022 rules; the 39.170.58.206/32 TCP 10023/10024 rules; the 47.110.67.161/32 TCP 10021/10022 rules owned by update-sg-ip-zjlab; and the 0.0.0.0/0 TCP 22 system-created rule. The live Description of the final rule included a trailing period and was matched by its RuleId after Describe. No unrelated port rule was changed.
  • Final Describe confirmed that TCP 22, 10021, and 10022 have only 36.24.58.213/32 (update-sg-ip-72602-minipc) and 39.170.58.206/32 (update-sg-ip-zjlab). TCP 10023 and 10024 have no public security-group rule. The final ingress rule count was 17.
  • IPv4 checks through ifconfig.me, ip.sb, and icanhazip.com all returned 36.24.58.213. The 72602 update-sg-ip.timer remained enabled and active (waiting). The rollback is to review the current fields and restore only the recorded deleted rules or revert the three descriptions through the official ECS SDK; do not restore public 10023/10024 rules.

Network Tunnels

72602 使用两类独立隧道;ZJLAB 的 SSH 入口是另一组 ECS loopback-only 监听器,不能与本页的公网 72602 入口混用:

  • SSH 主入口:10021
  • SSH 备入口:10022
  • Web 数据通道:WireGuard UDP 51820
  • 目标 ECS:47.110.67.161 (ecs-99)

ZJLAB 使用 ECS ProxyJump 访问 10023(primary)和 10024(backup)。这 两个端口只绑定 ECS loopback,由独立监控进程检查并通过 DingTalk 告警;它们 没有公网安全组规则。

公网 Web 80/443 固定由 ECS HAProxy 监听,经 WireGuard 转发到 72602-minipc 的 ingress NodePort。SSH 不再承载 Web;10022 仍保留 Mailu loopback forwards。

快速连接命令:

以下命令仅适用于当前登记在 ECS 安全组白名单中的来源客户端,并使用 SSH 密钥认证;10021/10022 不是面向任意公网客户端的开放入口。

ssh -p 10021 aaron@47.110.67.161
ssh -p 10022 aaron@47.110.67.161

上线顺序建议:

  1. 先在 72602-minipc 创建并启动 10022(备入口)
  2. 验证 ECS 已监听 10022
  3. 再创建并启动 10021(主入口)
  4. 最后做外网双端口连通性验证

完整步骤、故障恢复与运维命令见子页面。

Mar 7, 2024

Subsections of Network Tunnels

72602-minipc → ecs-99

SSH 反向隧道:72602-minipc → ecs-99(双入口)

本文档是 72602-minipc 的当前参考方案;执行前应核对 live unit、ECS 安全组、 SSH banner 和监控状态,目标是避免单端口掉线导致完全失联。

  • 主入口:10021
  • 备入口:10022
  • 两个端口由两个独立 service 维护

一、架构

已登记且获安全组授权的来源客户端        ecs-99 (47.110.67.161)                     72602-minipc (192.168.0.25)
ssh -p 10021 aaron@47.110.67.161  ->   0.0.0.0:10021 (sshd) --SSH reverse-->      localhost:22
ssh -p 10022 aaron@47.110.67.161  ->   0.0.0.0:10022 (sshd) --SSH reverse-->      localhost:22
ECS HAProxy :25/:465/:587/:993 -> 127.0.0.1:10225/:10465/:10587/:10993 (sshd)
                                      --SSH reverse--> minipc hostPort :25/:465/:587/:993

说明:反向隧道必须由 72602-minipc 主动发起。0.0.0.0 是 ECS 上 sshd 的 reverse-bind,并不等于对任意公网来源开放;安全组来源限制、SSH key 认证、已建立的 SSH child/session、banner 和监控恢复都必须分别验证。

二、上线前检查

2.1 在 72602-minipc 检查基础条件

# 1) 本机 SSH 服务
sudo systemctl is-active ssh

# 2) autossh 是否安装
autossh -V

# 3) 本机到 ECS 网络与认证
ssh -o ConnectTimeout=5 root@47.110.67.161 hostname
# 期望输出: ecs-99

2.2 在 ECS 检查前置配置

/etc/ssh/sshd_config 至少包含:

GatewayPorts clientspecified

重载:

sudo systemctl reload sshd

安全组仅应按当前 72602 出口地址来源受限地放行 10021/tcp10022/tcp。 不要为 ZJLAB 的 10023/tcp10024/tcp 添加公网规则;那两条 listener 属于 ECS loopback-only 的 ProxyJump 路径。

同时确认 ECS 本机防火墙(UFW)放行这两个端口:

sudo ufw status numbered
# 应包含 10021/tcp 和 10022/tcp 的 ALLOW 规则;公网来源边界由云安全组控制

三、创建双 service(72602-minipc 上执行)

下面步骤全部在 72602-minipc 上执行。

3.1 统一 SSH 客户端配置(可选但推荐)

编辑 ~/.ssh/config

Host ecs-99
    HostName 47.110.67.161
    User root
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
    TCPKeepAlive yes
    ConnectTimeout 10

3.2 创建 systemd 用户服务目录

mkdir -p ~/.config/systemd/user

3.3 新建 service(10021 主)

文件:~/.config/systemd/user/reverse-tunnel-ecs-10021.service

[Unit]
Description=Reverse SSH tunnel to ecs-99 (port 10021 -> local SSH)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment="AUTOSSH_GATETIME=0"
Environment="AUTOSSH_POLL=60"
Environment="AUTOSSH_FIRST_POLL=30"
ExecStart=/usr/bin/autossh -M 0 -N -R 0.0.0.0:10021:localhost:22 ecs-99
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target

3.4 10022 备入口(含 Mailu)

文件:~/.config/systemd/user/reverse-tunnel-ecs-10022.service

[Unit]
Description=Reverse SSH tunnel to ecs-99 (port 10022 -> local SSH)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment="AUTOSSH_GATETIME=0"
Environment="AUTOSSH_POLL=60"
Environment="AUTOSSH_FIRST_POLL=30"
ExecStart=/usr/bin/autossh -M 0 -N \
   -R 0.0.0.0:10022:localhost:22 \
  -R 127.0.0.1:10225:127.0.0.1:25 \
  -R 127.0.0.1:10465:127.0.0.1:465 \
  -R 127.0.0.1:10587:127.0.0.1:587 \
  -R 127.0.0.1:10993:127.0.0.1:993 \
  ecs-99
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target

说明:10022 只承载备用 SSH 和 Mailu 四个 hostPort。Web 80/443 已迁移 到 HAProxy + WireGuard,不得重新加入该 service,除非按 WireGuard 回滚步骤 临时恢复旧路径。ECS 上的四个 Mailu入口必须是 loopback-only;公网绑定由 HAProxy 完成。

3.5 启用并启动

export XDG_RUNTIME_DIR=/run/user/$(id -u)

systemctl --user daemon-reload
systemctl --user enable --now reverse-tunnel-ecs-10021.service
systemctl --user enable --now reverse-tunnel-ecs-10022.service

systemctl --user status reverse-tunnel-ecs-10021.service --no-pager
systemctl --user status reverse-tunnel-ecs-10022.service --no-pager

3.6 防登出失效(强烈建议)

sudo loginctl enable-linger aaron
loginctl show-user aaron | grep Linger
# 期望: Linger=yes

四、连通性验证

4.1 在 ECS 上看监听

ssh root@47.110.67.161 "ss -tlnp | grep -E '10021|10022'"

期望看到(监听归属示例;地址、来源限制和健康状态需按 2026-08-13 之后的 live 状态复核):

  • 0.0.0.0:10021
  • 0.0.0.0:10022
  • 127.0.0.1:10225, 127.0.0.1:10465, 127.0.0.1:10587, 127.0.0.1:10993 (sshd)
  • 0.0.0.0:80, 0.0.0.0:443 (HAProxy)
  • 0.0.0.0:51820/udp (WireGuard)

4.2 在外网验证

nc -zv 47.110.67.161 10021
nc -zv 47.110.67.161 10022

ssh -p 10021 aaron@47.110.67.161
ssh -p 10022 aaron@47.110.67.161

10021/10022 外网超时,但 ECS 上已监听,优先检查:

  • 云安全组来源网段是否覆盖当前出口 IP
  • ECS UFW 是否放行对应端口

4.3 Mailu 入口验证

在 ECS 上确认公网 Web/Mail 端口归 HAProxy;10021/10022 SSH listener 和 10225/10465/10587/10993 Mailu loopback backend 归 sshd:

sudo systemctl is-active haproxy
haproxy -c -f /etc/haproxy/haproxy.cfg
sudo ss -ltnp

从不发送邮件数据的测试客户端验证 banner、TLS 和 STARTTLS。relay 检查最多 发送 EHLOMAIL FROMRCPT TOQUIT,不要发送 DATA,不要认证。 同时检查 ArgoCD 与 front rollout:

argocd app get ops-docs --refresh --insecure --grpc-web
argocd app get mailu --refresh --insecure --grpc-web
kubectl -n mailu rollout status deployment/mailu-front
kubectl -n mailu get endpoints mailu-front mailu-front-ext

若 Dovecot 报 Client not trusted,先检查 Mailu realIpFrom 与 hostPort/CNI 实际传输源是否一致。修正必须提交到 Git 并等待 ArgoCD 自动同步,不能手动 patch、apply、sync 或重启 Mailu。

五、故障恢复(按顺序)

场景 A:ECS 本机 ssh localhost -p 10022 失败

这说明 ECS 上没有监听 10022,问题几乎总在源机器(72602-minipc)侧。

在 72602-minipc 执行:

export XDG_RUNTIME_DIR=/run/user/$(id -u)

# 1) 看 service
systemctl --user status reverse-tunnel-ecs-10021.service --no-pager
systemctl --user status reverse-tunnel-ecs-10022.service --no-pager

# 2) 一次只处理一条;先处理 backup,验证 listener/banner/监控恢复后再决定是否处理 primary
TUNNEL_SERVICE=reverse-tunnel-ecs-10022.service
systemctl --user restart "$TUNNEL_SERVICE"

# 3) 看日志
journalctl --user -u "$TUNNEL_SERVICE" --since "10 min ago" --no-pager

# 4) 验证到 ECS 的基础连通
ssh -o ConnectTimeout=5 root@47.110.67.161 echo ok

场景 B:外网超时但 ECS 本机可通

问题在安全组或 ECS 防火墙,不在隧道本身。

ssh root@47.110.67.161 "ss -tlnp | grep -E '10021|10022'"
ssh root@47.110.67.161 "iptables -L INPUT -n | grep -E '10021|10022' || true"

场景 C:重启后隧道没起来

export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user is-enabled reverse-tunnel-ecs-10021.service
systemctl --user is-enabled reverse-tunnel-ecs-10022.service
loginctl show-user aaron | grep Linger

六、常用运维命令

export XDG_RUNTIME_DIR=/run/user/$(id -u)

# 重载;重启时一次只选择一个入口,验证 listener/banner/监控后再处理另一个
systemctl --user daemon-reload
TUNNEL_SERVICE=reverse-tunnel-ecs-10021.service
systemctl --user restart "$TUNNEL_SERVICE"

# 停止(仅在明确授权的维护窗口内;一次只停止一个入口)
TUNNEL_SERVICE=reverse-tunnel-ecs-10021.service
systemctl --user stop "$TUNNEL_SERVICE"

# 日志实时跟踪
journalctl --user -u "$TUNNEL_SERVICE" -f

七、和现网监控的关系(72602;2026-08-13 审计快照,动态状态需 live verify)

ECS 上原有巡检继续只监控 72602-minipc 的两个公开入口;其端口列表、脚本、 unit、timer 和状态文件保持不变。

当 72602-minipc 双入口上线后,只需确认:

  • 安全组已放行 10021/10022
  • /etc/tunnel-healthcheck-ports.conf 包含 1002110022
  • /etc/tunnel-healthcheck.env 企业应用参数可用(DINGTALK_CLIENT_ID/SECRET/AGENT_ID/USER_IDS

按该审计快照记录的 72602 线上实现:

  • 告警通道为钉钉企业应用 API(非 webhook)
  • 告警消息为 Markdown 格式(标题:🚨 ECS Tunnel Alert
  • 连续失败 3 次才发送告警(防抖)

ZJLAB 的两个私有 loopback listener 不加入上述端口列表,而由独立的 check-only 巡检按 primarybackup 标签检查。该巡检要求每个标签恰有一个 loopback-only listener、唯一 owner 为 sshd、两个 owner 相互独立、短超时 SSH banner 正常且 owner 签名稳定;连续失败 3 次才告警。日志和告警不包含 endpoint、端口、账户、内网拓扑或签名值。巡检不会 restart/kill 隧道,也不会 修改 sshd、防火墙、安全组、DNS 或 endpoint。真实配置和恢复步骤只保存在 私有 SOPS inventory 与 root-only host 配置中。

八、Mailu 代理回滚

按以下顺序回滚,不恢复旧 HAProxy 配置,也不卸载 HAProxy:

# ECS
sudo systemctl stop haproxy

# 72602-minipc(使用实际保存的备份路径)
cp /home/aaron/Ops/ops-private/backups/reverse-tunnel-ecs-10022.service.<UTC>.before-haproxy \
  ~/.config/systemd/user/reverse-tunnel-ecs-10022.service
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user daemon-reload
systemctl --user restart reverse-tunnel-ecs-10022.service

若还原 ECS 的 GatewayPorts 修正,恢复对应的 /var/backups/sshd_config.<UTC>.before-haproxy,执行 sshd -t 后 reload sshd,再按上面步骤恢复并重启 10022。10021 不在本次回滚范围内。

WireGuard Web Tunnel

WireGuard Web Tunnel

公网 Web 入口固定由 ECS HAProxy 持有,HAProxy 通过 WireGuard 访问 72602-minipc 的 ingress-nginx NodePort。TLS 仍由 ingress-nginx 和 cert-manager 管理;ECS 不复制证书,不启用 PROXY protocol,也不终止 TLS。

Internet TCP 80/443
  -> ECS HAProxy
       -> primary: WireGuard 10.77.0.1 <-> 10.77.0.2 over UDP 51820
                    -> minipc TCP 32080/32443 -> ingress-nginx
       -> backup:  ECS-loopback SSH Web path 127.0.0.1:18080/18443
                    -> minipc TCP 32080/32443 -> ingress-nginx

The WireGuard and SSH Web paths are alternative HAProxy backends, not a serial chain. The SSH Web fallback is an independent ECS-loopback service; its host-local unit and credentials are kept in private host state/ ops-private, not reproduced in this repository.

Reference Configuration (verify live state; audit snapshot 2026-08-13)

ItemECS72602-minipc
WireGuard address10.77.0.1/3010.77.0.2/30
Servicewg-quick@wg0wg-quick@wg0
Config/etc/wireguard/wg0.conf/etc/wireguard/wg0.conf
Public UDPlistens on 51820initiates to ECS with keepalive
Web roleHAProxy 80/443ingress NodePort 32080/32443
SSH Web backupECS loopback 18080/18443independent fallback service (private host state)

Both WireGuard configs and private keys are root-only mode 0600. Never print, copy, commit, or place private keys in a ticket. Public keys are identifiers but do not need to be published in this handbook.

Firewall

  • UDP 51820 在公网路径上有两层入口:阿里云安全组的 /32 规则(云端边界) 和 ECS 本机 UFW 的 /32 规则(实例边界)。两者都由 72602-minipc 上同一个 5 分钟 systemd timer (update-sg-ip.timer / update-sg-ip.service)协调:安全组由协调器 通过官方 Aliyun ECS / VPC SDK 写入;ECS UFW 规则(comment wg 72602-minipc)由协调器通过专用受限 SSH key 调用 ECS 上 root-only 的 forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile 调整。两条规则在协调器「两端都已核实」之前都会被保留。
  • 协调器只在 Aliyun 安全组与 ECS UFW 这两个 consumer 都验证生效后才清理旧 的 updater-owned 规则并落盘持久状态;任意一侧失败都会让新规则保留、旧 managed 规则保留到下一次重试,重试本身幂等。协调器在 /home/aaron/.local/state/update-sg-ip/ 写入的最近已知 IP 与上一次 「两端都已核实」时间戳只描述成功的协调结果,不描述 IP 探测成功本身。
  • minipc UFW 允许 WireGuard 子网访问 TCP 3208032443
  • Do not expose 32080/32443 through the Aliyun security group.

Health Checks

Run these checks without displaying key material:

# Both hosts
systemctl is-active wg-quick@wg0
systemctl is-enabled wg-quick@wg0
sudo wg show wg0

# ECS listener ownership
sudo ss -ltnup | grep -E ':(80|443|51820) '
sudo haproxy -c -f /etc/haproxy/haproxy.cfg

# ECS -> ingress over WireGuard
curl --resolve port.72602.space:32443:10.77.0.2 \
  https://port.72602.space:32443/

# Public strict TLS
curl -fsS -o /dev/null https://port.72602.space/
curl -fsS -o /dev/null https://ops.docs.72602.space/

Expected listener ownership on ECS:

  • 80/443: HAProxy
  • 51820/udp: WireGuard kernel interface
  • 10021/10022: sshd reverse listeners

The WireGuard handshake alone is not sufficient. A valid handshake with a failed NodePort or HAProxy backend still breaks Web traffic, so monitor both the public HTTPS URL and the direct ECS-to-NodePort path.

The ECS host-local Web/tunnel monitor (private runtime configuration; verify it live before relying on its path or unit name) checks the HAProxy frontend, WireGuard primary, SSH Web backup, 72602 SSH listeners, and Mail public/loopback ports. DingTalk receives a message only when the state changes. The ZJLAB 10023/10024 check-only monitor is independent and is not managed by this Web monitor:

  • PRIMARY: WireGuard serves Web and the SSH backup is ready.
  • BACKUP: WireGuard failed and HAProxy automatically uses SSH.
  • PRIMARY_BACKUP_FAILED: Web remains healthy through WireGuard but redundancy is unavailable.
  • DOWN: no usable Web backend remains or the HAProxy frontend failed.
  • _SSH_PORT_FAILURE / _MAIL_FAILURE: the corresponding critical listener checks failed.

Notifications include the diagnosed layer, active path, whether automatic service recovery succeeded, timestamp, and host. They use DingTalk plain-text messages with real line breaks, not escaped \n text.

Runtime Recovery

ECS uses wg-quick@wg0 and /etc/wireguard/wg0.conf as the sole owner of the WireGuard interface address and peer configuration. Do not add a competing /etc/systemd/network/10-wg0.network file.

The ECS runtime repair timer 72602-wireguard-runtime-repair.timer runs every 30 seconds after boot. Its root-owned check restarts wg-quick@wg0 only when the service is inactive, wg0 lacks 10.77.0.1/30, or the route to 10.77.0.2 does not use wg0. A healthy interface is not restarted. Verify it with:

systemctl list-timers 72602-wireguard-runtime-repair.timer --all
sudo journalctl -t 72602-wireguard-repair --since "-15 min"

systemd-networkd also retries failures with a five-second delay and a 60-second, 12-start limit. The previous outage was not automatically repaired because networkd’s watchdog restart loop hit its default five-start limit after repeated 203/EXEC failures, while wg-quick@wg0 is a successful Type=oneshot unit with RemainAfterExit=yes and no restart policy. That unit therefore remained active (exited) after its address was lost and had no runtime address/route check.

Failure Handling

If public Web fails, inspect in this order:

  1. HAProxy owns 80/443 and its configuration validates.
  2. Both wg-quick@wg0 services are active and have a recent handshake.
  3. ECS can reach 10.77.0.2:32080 and 10.77.0.2:32443.
  4. ingress-nginx Service, Pod, Endpoint, Host routing, and certificate are healthy.
  5. The Aliyun security group and ECS UFW still allow UDP 51820 from the current home public IP.

Do not restart reverse-tunnel-ecs-10021.service during Web troubleshooting; it is the independent 72602 SSH access/rescue path. Keep the 72602 primary and backup SSH services operationally separate, and do not touch the ZJLAB system-level 10023/10024 path from this runbook. Do not enable PROXY protocol on HAProxy unless ingress-nginx is changed in the same reviewed operation.

Automatic Failover

HAProxy marks WireGuard as the primary backend and the independent SSH loopback path as backup. Checks run every two seconds with fall 2 and rise 2. The dated controlled test record measured automatic failover in approximately 8-9 seconds and automatic return to WireGuard after recovery. Treat that as a test observation, not an SLO; existing connections may fail and must reconnect, while new connections use the healthy path.

The approved SSH Web backup service is referenced as reverse-tunnel-ecs-web-backup.service in private host state. It must remain independent from 10021 (72602 SSH) and 10022 (SSH + Mailu). Never bind its 18080/18443 listeners publicly; they are ECS loopback-only.

Emergency Web Rollback (historical pre-migration path)

Use this only when WireGuard cannot be restored promptly and the independent 10021 72602 SSH access path has been authenticated first. This is not the ZJLAB 10023/10024 ProxyJump path.

  1. Keep 10021 authenticated and do not stop Mail HAProxy frontends.
  2. Restore the saved HAProxy configuration or the independent SSH Web backup service from ops-private; validate with haproxy -c before reload.
  3. If HAProxy itself cannot be restored, only then remove the Web frontends and restore the pre-migration 10022 unit containing -R 80 and -R 443.
  4. Confirm listener ownership, both 72602 SSH entries, Mail loopbacks, and strict public TLS.

Do not delete WireGuard keys, uninstall packages, change DNS, or alter ingress/cert-manager during an emergency Web rollback. The host-local migration backup is root/private state and must not be copied into Git.

Subsections of Aliyun

OSSutil

download ossutil

first, you need to download ossutil first

OS:
curl https://gosspublic.alicdn.com/ossutil/install.sh  | sudo bash
curl -o ossutil-v1.7.19-windows-386.zip https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-windows-386.zip

config ossutil

./ossutil config
ParamsDescriptionInstruction
endpointthe Endpoint of the region where the Bucket is located
accessKeyIDOSS AccessKeyget from user info panel
accessKeySecretOSS AccessKeySecretget from user info panel
stsTokentoken for sts servicecould be empty
Info

you can also modify /home/<$user>/.ossutilconfig file directly to change the configuration.

list files

ossutil ls oss://<$PATH>
For exmaple
ossutil ls oss://csst-data/CSST-20240312/dfs/

download file/dir

you can use cp to download or upload file

ossutil cp -r oss://<$PATH> <$PTHER_PATH>
For exmaple
ossutil cp -r oss://csst-data/CSST-20240312/dfs/ /data/nfs/data/pvc...

upload file/dir

ossutil cp -r <$SOURCE_PATH> oss://<$PATH>
For exmaple
ossutil cp -r /data/nfs/data/pvc/a.txt  oss://csst-data/CSST-20240312/dfs/b.txt
Mar 24, 2024

ECS DNS

ZJADC (Aliyun Directed Cloud)

Append content in /etc/resolv.conf

options timeout:2 attempts:3 rotate
nameserver 10.255.9.2
nameserver 10.200.12.5

And then you probably need to modify yum.repo.d as well, check link


YQGCY (Aliyun Directed Cloud)

Append content in /etc/resolv.conf

nameserver 172.27.205.79

And then restart kube-system.coredns-xxxx


Google DNS

nameserver 8.8.8.8
nameserver 4.4.4.4
nameserver 223.5.5.5
nameserver 223.6.6.6

Restart DNS

OS:
vim /etc/NetworkManager/NetworkManager.conf
vim /etc/NetworkManager/NetworkManager.conf
sudo systemctl is-active systemd-resolved
sudo resolvectl flush-caches
# or sudo systemd-resolve --flush-caches

add "dns=none" under '[main]' part

systemctl restart NetworkManager

Modify ifcfg-ethX [Optional]

if you cannot get ipv4 address, you can try to modify ifcfg-ethX

vim /etc/sysconfig/network-scripts/ifcfg-ens33

set ONBOOT=yes

Mar 14, 2024

Shared Clash/Mihomo Operations

zjlab-ubuntu and 72602-minipc both use nelvko/clash-for-linux-install from /home/aaron/clashctl. This page is the shared host-level proxy runbook; cluster-specific bridges and application proxy variables remain documented on their respective cluster pages.

Command Loading

clashctl is a Bash function loaded by /home/aaron/.bashrc, not an executable installed in PATH. Non-interactive SSH and agent shells do not automatically load it, so command -v clashctl or a direct clashctl status can incorrectly report that the command is missing.

Prefer explicit loading in automation:

CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"
clashctl --help
"$CLASH_HOME/bin/mihomo" -v
clashctl status

bash -ic 'clashctl status' is acceptable for a quick remote check, but it can emit harmless job-control warnings when SSH has no TTY. Explicit sourcing gives cleaner, more predictable automation output.

Read-Only Preflight

Run this once on the target host before trying alternate ports, editing proxy variables, or restarting anything:

CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"

hostname
"$CLASH_HOME/bin/mihomo" -v
clashctl status

proxy_port="$("$CLASH_HOME/bin/yq" \
  '."mixed-port" // .port // 7890' \
  "$CLASH_HOME/resources/runtime.yaml")"
printf 'proxy_port=%s\n' "$proxy_port"

curl --proxy "http://127.0.0.1:${proxy_port}" \
  --connect-timeout 5 --max-time 12 \
  --silent --show-error --output /dev/null \
  --write-out 'proxy_http_code=%{http_code}\n' \
  https://www.gstatic.com/generate_204

The expected result is one active mihomo process and HTTP 204. This test uses the configured local port and proves an end-to-end HTTPS request through the proxy; it is more useful than probing a sequence of guessed ports.

If it fails, inspect the existing log without opening an interactive pager:

tail -n 100 /home/aaron/clashctl/resources/mihomo.log

Do not use clashctl log in unattended work because it invokes less.

Three Independent States

Do not collapse these into one “proxy is enabled” judgment:

  1. clashctl status checks whether the mihomo core process exists.
  2. ._custom.system-proxy.enable is only the saved proxy preference in mixin.yaml.
  3. HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY are environment variables inherited by the current shell or process.

Inspect the latter two without printing proxy URLs or authentication:

CLASH_HOME=/home/aaron/clashctl

"$CLASH_HOME/bin/yq" -r \
  '"saved_proxy_flag=" + ((._custom.system-proxy.enable // false) | tostring)' \
  "$CLASH_HOME/resources/mixin.yaml"

env | awk -F= \
  'BEGIN { IGNORECASE=1 } /^(http|https|all|no)_proxy=/{ print $1 }' | \
  sort -fu

It is valid for the core and explicit curl --proxy request to be healthy while a fresh non-interactive shell has no proxy variables. Do not call clashctl proxy on merely to make a diagnostic shell look enabled; that command changes saved state. Use an explicit per-command proxy unless the requested operation requires persistent shell proxy configuration.

Systemd Boundary

clashctl has no version subcommand. Query the installed core directly:

/home/aaron/clashctl/bin/mihomo -v

Do not use a systemd unit state as a substitute for clashctl status and the end-to-end request. ZJLAB currently has no clashctl-on.service. On 72602, clashctl-on.service is an enabled user-level Type=oneshot bootstrap; active (exited) means only that the bootstrap command completed. It does not supervise or prove the health of the current mihomo process.

Path Boundaries

  • Host-local tools on both machines use 127.0.0.1:<runtime-port>.
  • On 72602, Pods use http://192.168.0.25:17890 or http://argocd-egress-proxy.argocd.svc.cluster.local:17890. The socat bridge forwards to host-local mihomo.
  • Do not configure a 72602 Pod with 192.168.0.25:7890 while allow-lan is false.
  • The 72602 bridge is not evidence that the same Pod path exists in ZJLAB. Verify the target cluster’s live proxy path separately.

Command Safety

CommandBehaviorAgent policy
clashctl --helpShows locally installed command setRead-only
/home/aaron/clashctl/bin/mihomo -vShows the installed core versionRead-only
clashctl statusChecks the mihomo processRead-only; first diagnostic
clashctl logOpens less on the core logAvoid in automation; use tail
clashctl proxyShows proxy environment stateDo not capture raw output; it may include authentication
clashctl on / offStarts or force-stops the shared core and changes shell proxy stateRequires explicit authorization
clashctl uiShows the panel and starts the core if it is downNot read-only; requires authorization
clashctl proxy on / offChanges saved proxy state and current shell variablesRequires authorization
clashctl tunReads or changes host routing/TUN stateChanges require explicit authorization
clashctl mixinReads or edits merged configuration and can restart the coreDo not print full config; changes require authorization
clashctl subManages subscription sources and updatesURLs are sensitive; changes require authorization
clashctl upgradeUpgrades the running coreRequires reviewed maintenance window
clashctl secretReads or changes the controller secretNever print; changes require secure rotation approval

Do not stop or restart the proxy merely because an unrelated external endpoint is slow. Confirm core status, the configured port, one known generate_204 request, and the recent log first.

2026-08-14 Audit Snapshot

Read-only checks on both zjlab-ubuntu and 72602-minipc found:

  • the installed mihomo process active;
  • Mihomo Meta v1.19.17 on linux amd64;
  • HTTP/mixed port 7890, SOCKS port 7891, and controller port 9090;
  • allow-lan: false and TUN disabled;
  • the dynamic local-port preflight above returned HTTP 204 on each host.

ZJLAB had no clashctl-on.service. The 72602 user unit was enabled and active (exited), but was a Type=oneshot bootstrap rather than the supervisor of the live core. Fresh non-interactive shells did not inherit proxy environment variables, which did not prevent the explicit local proxy checks from passing.

Treat these as dated health observations. The runtime configuration remains authoritative for current ports and modes.

Huawei

Tencent

    Mar 7, 2024

    Zhejianglab

    Scope

    ZJLAB operational inventory and network details are private. Public pages contain reusable application guidance only; verify dynamic state against the live cluster before applying a runbook.

    Access

    ssh zjlab-ubuntu-local hostname       # when running on ZJLAB
    ssh zjlab-ubuntu-local 'kubectl get nodes'
    ssh zjlab-ubuntu-proxy hostname       # when running on 72602

    Use zjlab-ubuntu-local for direct access from ZJLAB and zjlab-ubuntu-proxy for the forwarded path from 72602. These are SSH config aliases, not DNS names: validate configuration with ssh -G and reachability with SSH, not with getent hosts. The aliases are provisioned from private inventory and use an ECS ProxyJump to loopback-only reverse SSH listeners. Do not publish their resolved endpoints, ports, users, internal topology, or service names.

    Detailed inventory and tunnel recovery procedures are maintained in the private ops-private repository with SOPS-encrypted values.

    Tunnel Health

    ECS runs an independent check-and-alert-only monitor for the approved primary and backup loopback listeners. For each label it requires exactly one listener, loopback-only binding, sole sshd ownership, an independent owner, a short-timeout SSH banner, and a stable hashed owner signature. Alerts start only after ten consecutive failures.

    The monitor stores root-only state and emits labels and fixed reason codes only. It never restarts or kills a tunnel and never changes sshd, firewall, cloud network policy, DNS, keys, or endpoints. Real configuration and rollback details remain in the private SOPS inventory; public pages must not reproduce them.

    The ZJLAB tunnel initiators use enabled system-level services so they recover after a host reboot. User-level legacy tunnel services must remain stopped and disabled; Linger=yes starts the user manager but does not start disabled services. If both labels fail together, inspect host boot and service-manager state first, then recover backup before primary. Verify each alias and its loopback listener before proceeding to the next label.

    For external availability history, report the existing primary and backup check results to separate Uptime Kuma Push monitors. Do not expose the relay’s loopback listeners just to make them reachable by Kuma. The Push URLs and the ECS monitor configuration belong in the private SOPS inventory; see the Uptime Kuma setup for the public setup requirements.

    The ECS-side integration is active: ECS listener 10023 reports the primary check and listener 10024 reports the backup check. This is separate from the 72602 public tunnel listeners on 10021 and 10022, which are not Kuma Push sources for this monitor.

    The stable access map is:

    • 10023 is the ZJLAB primary SSH reverse listener.
    • 10024 is the backup listener.
    • 10025 is a restricted break-glass maintenance listener.

    All three are loopback-only on the ECS host and are reached through the ECS ProxyJump path. They must never be added to the ECS security group or exposed publicly. The maintenance listener is provided by zjlab-loopback-maintenance.service and uses a separate restricted SSH account and key; use the local zjlab-maintenance alias only for recovery. Client configurations should use HostName 127.0.0.1 for these ECS loopback targets rather than relying on localhost address-family resolution.

    ECS applies ClientAliveInterval 30 and ClientAliveCountMax 10 to the zjlab-tunnel and zjlab-maintenance accounts. This releases stale remote forward listeners quickly after a broken connection without changing the global SSH policy.

    The ZJLAB tunnel initiators are the system-level services located at /etc/systemd/system/zjlab-loopback-reverse-primary.service and /etc/systemd/system/zjlab-loopback-reverse-backup.service. These units invoke /usr/bin/ssh directly (not autossh) with Restart=on-failure, RestartSec=30s for the primary, RestartSec=45s for the backup, and KillMode=control-group. Legacy user-level services reverse-tunnel-ecs.service and reverse-tunnel-ecs-10024.service must remain stopped and disabled; running them alongside the system units causes listener ownership conflicts and reconnect loops.

    When both monitors report ssh_banner_failed, first verify the current ZJLAB egress IPv4 and the ECS security-group allowlist for TCP 22, then check ECS sshd and the established SSH child/session. A systemd active state alone only proves that the supervisor is running; it does not prove that the reverse listener has been established.

    The private checker keeps the Push request as a best-effort reporting path. Healthy checks send up; failed checks send down with a fixed reason. Push HTTP failures do not change listener judgment, failure counters, DingTalk debounce, or tunnel lifecycle. The checker merges existing query parameters when constructing the request and does not log the Push value. A normal tunnel reconnect changes the sshd PID and socket identity; those changes are not treated as failures when the listener, owner, bind address, and SSH banner are healthy. The checker completes an SSH key exchange with ssh-keyscan before closing its probe connection; do not replace it with a raw TCP banner read, which can leave CLOSE-WAIT channels on the reverse listener.

    DingTalk sends one failure notification after ten consecutive failures for a label. The message includes a fixed, redacted recovery action. After a notified failure becomes healthy, the checker sends one recovery notification; if that send fails, the alert state is retained and the next healthy check retries it. Short failures that never cross the alert threshold do not generate a recovery message.

    Deployment verification on 2026-09-05 confirmed the primary, backup, and maintenance aliases returned zjlab-ubuntu; the ECS listeners were unique, loopback-only, and returned valid SSH banners. The checker dry-run and live service run were healthy for both labels across more than two complete 60-second timer cycles. Rollback restores the root-only ECS backup and encrypted private inventory backup, then restarts only zjlab-tunnel-healthcheck.service; tunnel units are not restarted as part of monitoring rollback.

    Preflight

    For host-level proxy checks, follow the shared Clash/Mihomo runbook before trying ports or changing proxy variables. The installed clashctl is a shell function, so non-interactive sessions must source it explicitly.

    # On ZJLAB, use zjlab-ubuntu-local; on 72602, use zjlab-ubuntu-proxy.
    ZJLAB_SSH_ALIAS=zjlab-ubuntu-local
    ssh "$ZJLAB_SSH_ALIAS" 'kubectl config current-context'
    ssh "$ZJLAB_SSH_ALIAS" 'kubectl get nodes'
    ssh "$ZJLAB_SSH_ALIAS" 'kubectl get namespace'
    ssh "$ZJLAB_SSH_ALIAS" 'kubectl get applications.argoproj.io -A'
    ssh "$ZJLAB_SSH_ALIAS" 'kubectl get ingress,certificate -A'

    Independent Prometheus Deployment Attempt

    An independent Prometheus deployment was prepared for ZJLAB with the public Prometheus Community prometheus chart version 29.18.0, ArgoCD Application zjlab-prometheus, and destination namespace monitoring. Helm rendering and Kubernetes server-side dry-run succeeded, including the password-file based remote-write mount and explicit kube-state-metrics and node-exporter scrape jobs.

    The Application reached Synced but remained Healthy: Progressing. The 30Gi local-path PVC stayed Pending: the local-path provisioner timed out while creating its helper Pod and did not bind the volume. Consequently the Prometheus server, kube-state-metrics, and node-exporter workloads did not reach the verification gate, so readiness, targets, remote-write delivery, and the receiving-cluster queries were not claimed as successful.

    The attempt was rolled back by deleting only the newly created zjlab-prometheus Application, its monitoring/zjlab-prometheus-server PVC, the runtime remote-write Secret, and the empty monitoring namespace. Existing applications, tunnels, network policies, and metrics-server were not changed. Before retrying, verify local-path provisioning on the selected node and repeat the full readiness, target, remote-write, and receiving-cluster query checks.

    Prometheus Retry (2026-08-05)

    The retry used a private node selector for the previously diagnosed healthy local-path node. The 30Gi PVC bound immediately, and the Prometheus server was scheduled on the same node. Kube-state-metrics and both node-exporter Pods reached Running.

    The Prometheus server then exited with code 2 and entered CrashLoopBackOff. This is a stop condition, so readiness, targets, remote-write queue health, and receiving-cluster queries were not claimed as successful. The Application, PVC, runtime Secret, and monitoring namespace created for this retry were rolled back. Existing applications, tunnels, network policies, and metrics-server were not changed. The next retry must capture the Prometheus startup error before cleanup and correct the rendered configuration without publishing credentials.

    Prometheus Deployment (2026-08-05)

    The deployment was recreated as ArgoCD Application zjlab-prometheus using Prometheus Community chart 29.18.0. The rendered server arguments contain the chart-default web.enable-lifecycle flag exactly once; no custom server.extraArgs was configured. The runtime remote-write Secret contains only the password file and is mounted read-only; credentials are not part of the Application values.

    ArgoCD is Synced and Healthy at revision 29.18.0. The 30Gi local-path PVC is Bound, the Prometheus server is co-located with that volume on the private selected node, the Prometheus and kube-state-metrics Deployments are 1/1 Ready, and both node-exporter DaemonSet Pods are Ready with zero restarts. The server Service and the KSM/node-exporter Services are ClusterIP; no Ingress, NodePort, or admin API was enabled.

    Prometheus /-/ready returned HTTP 200. The targets API showed the Prometheus, kube-state-metrics, node-exporter, Kubernetes API server, nodes, cadvisor, and Kubernetes pod/service discovery jobs up with no scrape errors. The rendered configuration contains external_labels.cluster=zjlab, the remote-write password-file path, and no password value. Across two complete 30-second cycles, remote-write samples increased while failed and retried samples stayed at zero.

    The receiving 72602 Prometheus queries were completed after deployment: up{cluster="zjlab"} returned 18 samples, kube_node_status_condition{cluster="zjlab"} returned 54 samples, and kube_pod_info{cluster="zjlab"} returned 72 samples. The newest ZJLAB sample was approximately five seconds old at verification time, and all three queries were served by the existing 72602 Prometheus datasource used by Grafana. No Grafana datasource change was required.

    The receiving Prometheus reported zero failed and retried remote-write samples. After the user-authorized 2026-08-14 metrics reset, the local monitoring/zjlab-prometheus-server TSDB PVC was recreated while this remote-write configuration remained unchanged. The old local history was not backed up and is intentionally unrecoverable. The sender queue drained and the 72602 receiver again exposed fresh cluster="zjlab" samples; continue watching the pending queue if its delay grows.

    Prometheus Storage Diagnosis (2026-08-05)

    The read-only follow-up found two Ready nodes with no taints and with MemoryPressure, DiskPressure, and PIDPressure all false. The default local-path StorageClass uses rancher.io/local-path, WaitForFirstConsumer, and Delete reclaim policy. Its Rancher v0.0.35 provisioner is 1/1 Ready. The provisioner configuration has only the default root /var/lib/rancher/k3s/storage; its helper image is BusyBox 1.37.0, and the configured setup creates the volume directory and restricts its parent.

    Existing Bound local-path volumes cover both nodes, and the node filesystem and inode checks showed substantial headroom. Existing local-path mounts were also writable from their consuming workloads. This does not support a root capacity or permission failure as the cause of the Prometheus incident.

    The retained helper events use the helper-pod-create-pvc-<PVC-UID> naming pattern. The image was already available, but helper container creation/startup and retries exceeded the provisioner’s 120-second create-process timeout. Later retries included ContainerCreating and failed log-stream reads before the Prometheus resources were removed. No helper Pod, PVC, PV, or Prometheus Application is currently left behind. The evidence points first to helper scheduling/runtime latency or a local-path/K3s compatibility issue on the selected worker; the retained evidence does not prove a filesystem fault.

    nfs-data is currently 1/1 Ready with the v4.0.2 provisioner (Helm chart 4.0.18), an active leader endpoint, Immediate binding, Retain reclaim policy, and archiveOnDelete=true. Multiple existing nfs-data PVCs are Bound, and the NFS mount is currently read-write with healthy capacity and inode headroom. It remains a single-backend/single-node dependency, so NFS is an acceptable fallback for Prometheus only after confirming its latency and failure policy; it is not automatic high availability.

    The minimum reliable retry is to repair and verify helper startup on a deliberately selected, healthy local-path node, and to make the Prometheus workload’s node selection explicit so the local volume and workload remain co-located. Rollback is to remove only the new ArgoCD Application and its new resources; do not remove existing local-path data. NFS is the secondary option, and ephemeral storage is not recommended for Prometheus. Direct worker-host journal and host-permission inspection remains a verification gap because this diagnostic session was restricted to the approved canonical ZJLAB SSH entry point and did not create a debug Pod.

    Prometheus Storage Follow-up (2026-08-05)

    The approved node proxy exposed host system.journal and container-log directories for both nodes. The historical helper had already been removed, and no matching kubelet, containerd, CRI, mount, image, or sandbox error was retained for the helper timeout window. The control-plane journal did contain repeated PartialObjectMetadata watch errors, but no evidence connected them to the local-path helper failure. The Kubernetes node image inventory no longer advertises the helper image; the historical kubelet event is the available evidence that it was already cached during the incident.

    The strongest retry candidate is the control-plane node that already hosts the local-path provisioner and several successfully Bound local-path volumes. It has the standard Linux and control-plane/etcd labels, no taint, and healthy Ready/pressure conditions. The trade-off is that Prometheus would share the node with control-plane workloads, and loss of that node makes its local data unavailable. Use the private value of its kubernetes.io/hostname label in the deployment; do not publish that value.

    For a retry, the server values should include the following shape, with <selected-local-path-node> replaced only in the private manifest:

    server:
      nodeSelector:
        kubernetes.io/hostname: <selected-local-path-node>
      persistentVolume:
        enabled: true
        storageClass: local-path
        accessModes:
          - ReadWriteOnce
        size: 30Gi

    The retry gate is: the PVC must reach Bound and receive node affinity for the selected node before 120 seconds; the helper must reach container started/completed without ContainerCreating, mount, image, sandbox, or CRI errors; and the Prometheus server must schedule on the same node and become Ready. Any repeated runtime error, a helper still in ContainerCreating at 60 seconds, or another ProvisioningFailed at 120 seconds is a stop condition. Roll back the new Application and its new resources, then use nfs-data only after accepting its single-backend failure risk. No runtime or storage change was made during this follow-up.

    Mar 7, 2024

    Subsections of Zhejianglab

    👨‍💻Schedmd Slurm

    The Slurm Workload Manager, formerly known as Simple Linux Utility for Resource Management (SLURM), or simply Slurm, is a free and open-source job scheduler for Linux and Unix-like kernels, used by many of the world’s supercomputers and computer clusters.

    It provides three key functions:

    • allocating exclusive and/or non-exclusive access to resources (computer nodes) to users for some duration of time so they can perform work,
    • providing a framework for starting, executing, and monitoring work, typically a parallel job such as Message Passing Interface (MPI) on a set of allocated nodes, and
    • arbitrating contention for resources by managing a queue of pending jobs.

    func1 func1

    Content

    Aug 7, 2024

    Subsections of 👨‍💻Schedmd Slurm

    Build & Install

    Aug 7, 2024

    Subsections of Build & Install

    Install On Debian

    Cluster Setting

    • 1 Manager
    • 1 Login Node
    • 2 Compute nodes
    hostnameIProlequota
    manage01 (slurmctld, slurmdbd)192.168.56.115manager2C4G
    login01 (login)192.168.56.116login2C4G
    compute01 (slurmd)192.168.56.117compute2C4G
    compute02 (slurmd)192.168.56.118compute2C4G

    Software Version:

    softwareversion
    osDebian 12 bookworm
    slurm24.05.2

    Important

    when you see (All Nodes), you need to run the following command on all nodes

    when you see (Manager Node), you only need to run the following command on manager node

    when you see (Login Node), you only need to run the following command on login node

    Prepare Steps (All Nodes)

    1. Modify the /etc/apt/sources.list file Using tuna mirror
    cat > /etc/apt/sources.list << EOF
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware
    deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware
    
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware
    deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware
    
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware
    deb-src https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware
    
    deb https://mirrors.tuna.tsinghua.edu.cn/debian-security/ bookworm-security main contrib non-free non-free-firmware
    deb-src https://mirrors.tuna.tsinghua.edu.cn/debian-security/ bookworm-security main contrib non-free non-free-firmware
    EOF
    if you cannot get ipv4 address

    Modify the /etc/network/interfaces

    allow-hotplug enps08
    iface enps08 inet dhcp

    restart the network

    systemctl restart networking
    1. Update apt cache
    apt clean all && apt update
    1. Set hostname on each node
    Node:
    hostnamectl set-hostname manage01
    hostnamectl set-hostname login01
    hostnamectl set-hostname compute01
    hostnamectl set-hostname compute02
    1. Set hosts file
    cat >> /etc/hosts << EOF
    192.168.56.115 manage01
    192.168.56.116 login01
    192.168.56.117 compute01
    192.168.56.118 compute02
    EOF
    1. Disable firewall
    systemctl stop nftables && systemctl disable nftables
    1. Install packages ntpdate
    apt-get -y install ntpdate
    1. Sync server time
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    echo 'Asia/Shanghai' >/etc/timezone
    ntpdate time.windows.com
    1. Add cron job to sync time
    crontab -e
    */5 * * * * /usr/sbin/ntpdate time.windows.com
    1. Create ssh key pair on each node
    ssh-keygen -t rsa -b 4096 -C $HOSTNAME
    1. Test ssh login other nodes without password
    Node:
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@login01
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@compute01
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@compute02
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@manage01
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@compute01
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@compute02

    Install Components

    1. Install NFS server (Manager Node)

    there are many ways to install NFS server

    create shared folder

    mkdir /data
    chmod 755 /data

    modify vim /etc/exports

    /data *(rw,sync,insecure,no_subtree_check,no_root_squash)

    start nfs server

    systemctl start rpcbind 
    systemctl start nfs-server 
    
    systemctl enable rpcbind 
    systemctl enable nfs-server

    check nfs server

    showmount -e localhost
    
    # Output
    Export list for localhost:
    /data *
    1. Install munge service
    • add user munge (All Nodes)
    groupadd -g 1108 munge
    useradd -m -c "Munge Uid 'N' Gid Emporium" -d /var/lib/munge -u 1108 -g munge -s /sbin/nologin munge
    • Install rng-tools-debian (Manager Nodes)
    apt-get install -y rng-tools-debian
    # modify service script
    vim /usr/lib/systemd/system/rngd.service
    [Service]
    ExecStart=/usr/sbin/rngd -f -r /dev/urandom
    systemctl daemon-reload
    systemctl start rngd
    systemctl enable rngd
    apt-get install -y libmunge-dev libmunge2 munge
    • generate secret key (Manager Nodes)
    dd if=/dev/urandom bs=1 count=1024 > /etc/munge/munge.key
    • copy munge.key from manager node to the rest node (All Nodes)
    scp -p /etc/munge/munge.key root@login01:/etc/munge/
    scp -p /etc/munge/munge.key root@compute01:/etc/munge/
    scp -p /etc/munge/munge.key root@compute02:/etc/munge/
    • grant privilege on munge.key (All Nodes)
    chown munge: /etc/munge/munge.key
    chmod 400 /etc/munge/munge.key
    
    systemctl start munge
    systemctl enable munge

    Using systemctl status munge to check if the service is running

    • test munge
    munge -n | ssh compute01 unmunge
    1. Install Mariadb (Manager Nodes)
    apt-get install -y mariadb-server
    • create database and user
    systemctl start mariadb
    systemctl enable mariadb
    
    ROOT_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16) 
    mysql -e "CREATE USER root IDENTIFIED BY '${ROOT_PASS}'"
    mysql -uroot -p$ROOT_PASS -e 'create database slurm_acct_db'
    • create user slurm,and grant all privileges on database slurm_acct_db
    mysql -uroot -p$ROOT_PASS
    create user slurm;
    
    grant all on slurm_acct_db.* TO 'slurm'@'localhost' identified by '123456' with grant option;
    
    flush privileges;
    • create Slurm user
    groupadd -g 1109 slurm
    useradd -m -c "Slurm manager" -d /var/lib/slurm -u 1109 -g slurm -s /bin/bash slurm

    Install Slurm (All Nodes)

    • Install basic Debian package build requirements:
    apt-get install -y build-essential fakeroot devscripts equivs
    • Unpack the distributed tarball:
    wget https://download.schedmd.com/slurm/slurm-24.05.2.tar.bz2 -O slurm-24.05.2.tar.bz2 &&
    tar -xaf slurm*tar.bz2
    • cd to the directory containing the Slurm source:
    cd slurm-24.05.2 &&   mkdir -p /etc/slurm && ./configure 
    • compile slurm
    make install
    • modify configuration files (Manager Nodes)

      cp /root/slurm-24.05.2/etc/slurm.conf.example /etc/slurm/slurm.conf
      vim /etc/slurm/slurm.conf

      focus on these options:

      SlurmctldHost=manage
      
      AccountingStorageEnforce=associations,limits,qos
      AccountingStorageHost=manage
      AccountingStoragePass=/var/run/munge/munge.socket.2
      AccountingStoragePort=6819  
      AccountingStorageType=accounting_storage/slurmdbd  
      
      JobCompHost=localhost
      JobCompLoc=slurm_acct_db
      JobCompPass=123456
      JobCompPort=3306
      JobCompType=jobcomp/mysql
      JobCompUser=slurm
      JobContainerType=job_container/none
      JobAcctGatherType=jobacct_gather/linux
      cp /root/slurm-24.05.2/etc/slurmdbd.conf.example /etc/slurm/slurmdbd.conf
      vim /etc/slurm/slurmdbd.conf
      • modify /etc/slurm/cgroup.conf
      cp /root/slurm-24.05.2/etc/cgroup.conf.example /etc/slurm/cgroup.conf
      • send configuration files to other nodes
      scp -r /etc/slurm/*.conf  root@login01:/etc/slurm/
      scp -r /etc/slurm/*.conf  root@compute01:/etc/slurm/
      scp -r /etc/slurm/*.conf  root@compute02:/etc/slurm/
    • grant privilege on some directories (All Nodes)

    mkdir /var/spool/slurmd
    chown slurm: /var/spool/slurmd
    mkdir /var/log/slurm
    chown slurm: /var/log/slurm
    
    mkdir /var/spool/slurmctld
    chown slurm: /var/spool/slurmctld
    
    chown slurm: /etc/slurm/slurmdbd.conf
    chmod 600 /etc/slurm/slurmdbd.conf
    • start slurm services on each node
    Node:
    systemctl start slurmdbd
    systemctl enable slurmdbd
    
    systemctl start slurmctld
    systemctl enable slurmctld
    
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status xxxx` to check if the `xxxx` service is running
    Example slurmdbd.server
    ```text
    # vim /usr/lib/systemd/system/slurmdbd.service
    
    
    [Unit]
    Description=Slurm DBD accounting daemon
    After=network-online.target remote-fs.target munge.service mysql.service mysqld.service mariadb.service sssd.service
    Wants=network-online.target
    ConditionPathExists=/etc/slurm/slurmdbd.conf
    
    [Service]
    Type=simple
    EnvironmentFile=-/etc/sysconfig/slurmdbd
    EnvironmentFile=-/etc/default/slurmdbd
    User=slurm
    Group=slurm
    RuntimeDirectory=slurmdbd
    RuntimeDirectoryMode=0755
    ExecStart=/usr/local/sbin/slurmdbd -D -s $SLURMDBD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    LimitNOFILE=65536
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    Example slumctld.server
    ```text
    # vim /usr/lib/systemd/system/slurmctld.service
    
    
    [Unit]
    Description=Slurm controller daemon
    After=network-online.target remote-fs.target munge.service sssd.service
    Wants=network-online.target
    ConditionPathExists=/etc/slurm/slurm.conf
    
    [Service]
    Type=notify
    EnvironmentFile=-/etc/sysconfig/slurmctld
    EnvironmentFile=-/etc/default/slurmctld
    User=slurm
    Group=slurm
    RuntimeDirectory=slurmctld
    RuntimeDirectoryMode=0755
    ExecStart=/usr/local/sbin/slurmctld --systemd $SLURMCTLD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    LimitNOFILE=65536
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    Example slumd.server
    ```text
    # vim /usr/lib/systemd/system/slurmd.service
    
    
    [Unit]
    Description=Slurm node daemon
    After=munge.service network-online.target remote-fs.target sssd.service
    Wants=network-online.target
    #ConditionPathExists=/etc/slurm/slurm.conf
    
    [Service]
    Type=notify
    EnvironmentFile=-/etc/sysconfig/slurmd
    EnvironmentFile=-/etc/default/slurmd
    RuntimeDirectory=slurm
    RuntimeDirectoryMode=0755
    ExecStart=/usr/local/sbin/slurmd --systemd $SLURMD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    KillMode=process
    LimitNOFILE=131072
    LimitMEMLOCK=infinity
    LimitSTACK=infinity
    Delegate=yes
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running

    Test Your Slurm Cluster (Login Node)

    • check cluster configuration
    scontrol show config
    • check cluster status
    sinfo
    scontrol show partition
    scontrol show node
    • submit job
    srun -N2 hostname
    scontrol show jobs
    • check job status
    check job status
    squeue -a
    Aug 7, 2024

    Install On Ubuntu

    Cluster Setting

    • 1 Manager
    • 1 Login Node
    • 2 Compute nodes
    hostnameIProlequota
    manage01 (slurmctld, slurmdbd)192.168.56.115manager2C4G
    login01 (login)192.168.56.116login2C4G
    compute01 (slurmd)192.168.56.117compute2C4G
    compute02 (slurmd)192.168.56.118compute2C4G

    Software Version:

    softwareversion
    osUbuntu 22.04
    slurm24.05.2

    Important

    when you see (All Nodes), you need to run the following command on all nodes

    when you see (Manager Node), you only need to run the following command on manager node

    when you see (Login Node), you only need to run the following command on login node

    Prepare Steps (All Nodes)

    1. Modify the /etc/apt/sources.list file Using tuna mirror
    cat > /etc/apt/sources.list << EOF
    
    EOF
    if you cannot get ipv4 address

    Modify the /etc/network/interfaces

    allow-hotplug enps08
    iface enps08 inet dhcp

    restart the network

    systemctl restart networking
    1. Update apt cache
    apt clean all && apt update
    1. Set hosts file
    cat >> /etc/hosts << EOF
    10.119.2.36 juice-036
    10.119.2.37 juice-037
    10.119.2.38 juice-038
    EOF
    1. Install packages ntpdate
    apt-get -y install ntpdate
    1. Sync server time
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    echo 'Asia/Shanghai' >/etc/timezone
    ntpdate ntp.aliyun.com
    1. Add cron job to sync time
    crontab -e
    */5 * * * * /usr/sbin/ntpdate ntp.aliyun.com
    1. Create ssh key pair on each node
    ssh-keygen -t rsa -b 4096 -C $HOSTNAME
    1. Test ssh login other nodes without password
    Node:
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@juice-036
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@juice-037
    ssh-copy-id -i ~/.ssh/id_rsa.pub root@juice-038

    Install Components

    1. Install NFS server (Manager Node)

    there are many ways to install NFS server

    create shared folder

    mkdir /data
    chmod 755 /data

    modify vim /etc/exports

    /data *(rw,sync,insecure,no_subtree_check,no_root_squash)

    start nfs server

    systemctl start rpcbind 
    systemctl start nfs-server 
    
    systemctl enable rpcbind 
    systemctl enable nfs-server

    check nfs server

    showmount -e localhost
    
    # Output
    Export list for localhost:
    /data *
    1. Install munge service
    • add user munge (All Nodes)
    sudo apt install -y build-essential git wget munge libmunge-dev libmunge2 \
        mariadb-server libmariadb-dev libssl-dev libpam0g-dev \
        libhwloc-dev liblua5.3-dev libreadline-dev libncurses-dev \
        libjson-c-dev libyaml-dev libhttp-parser-dev libjwt-dev libdbus-glib-1-dev libbpf-dev libdbus-1-dev
    
    
    which mungekey
    
    # 如果有,使用它生成 key
    sudo systemctl stop munge
    sudo mungekey -c
    sudo chown munge:munge /etc/munge/munge.key
    sudo chmod 400 /etc/munge/munge.key
    sudo systemctl start munge
    • copy munge.key from manager node to the rest node (All Nodes)
    sudo scp /etc/munge/munge.key juice-036:/tmp/munge.key
    sudo scp /etc/munge/munge.key juice-037:/tmp/munge.key
    sudo scp /etc/munge/munge.key juice-038:/tmp/munge.key
    • grant privilege on munge.key (All Nodes)
    systemctl stop munge
    
    sudo mv /tmp/munge.key /etc/munge/munge.key
    chown munge: /etc/munge/munge.key
    chmod 400 /etc/munge/munge.key
    
    systemctl start munge
    systemctl status munge
    systemctl enable munge

    Using systemctl status munge to check if the service is running

    • test munge
    munge -n | ssh juice-036 unmunge
    munge -n | ssh juice-037 unmunge
    munge -n | ssh juice-038 unmunge
    1. Install Mariadb (Manager Nodes)
    apt-get install -y mariadb-server
    • create database and user
    systemctl start mariadb
    systemctl enable mariadb
    
    ROOT_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16) 
    mysql -e "CREATE USER root IDENTIFIED BY '${ROOT_PASS}'"
    mysql -uroot -p$ROOT_PASS -e 'create database slurm_acct_db'
    • create user slurm,and grant all privileges on database slurm_acct_db
    mysql -uroot -p$ROOT_PASS
    create user slurm;
    
    grant all on slurm_acct_db.* TO 'slurm'@'localhost' identified by '123456' with grant option;
    
    flush privileges;
    • create Slurm user
    groupadd -g 1109 slurm
    useradd -m -c "Slurm manager" -d /var/lib/slurm -u 1109 -g slurm -s /bin/bash slurm

    Install Slurm (All Nodes)

    • Install basic Debian package build requirements:
    apt-get install -y build-essential fakeroot devscripts equivs
    • Unpack the distributed tarball:
    wget https://download.schedmd.com/slurm/slurm-25.05.2.tar.bz2 -O slurm-25.05.2.tar.bz2 &&
    tar -xaf slurm*tar.bz2
    • cd to the directory containing the Slurm source:
    cd slurm-25.05.2 &&   mkdir -p /etc/slurm && ./configure --prefix=/usr --sysconfdir=/etc/slurm  --enable-cgroupv2
    • compile slurm
    make install
    • modify configuration files (Manager Nodes)

      cp /root/slurm-25.05.2/etc/slurm.conf.example /etc/slurm/slurm.conf
      vim /etc/slurm/slurm.conf

      focus on these options:

      SlurmctldHost=manage
      
      AccountingStorageEnforce=associations,limits,qos
      AccountingStorageHost=manage
      AccountingStoragePass=/var/run/munge/munge.socket.2
      AccountingStoragePort=6819  
      AccountingStorageType=accounting_storage/slurmdbd  
      
      JobCompHost=localhost
      JobCompLoc=slurm_acct_db
      JobCompPass=123456
      JobCompPort=3306
      JobCompType=jobcomp/mysql
      JobCompUser=slurm
      JobContainerType=job_container/none
      JobAcctGatherType=jobacct_gather/linux
      cp /root/slurm-25.05.2/etc/slurmdbd.conf.example /etc/slurm/slurmdbd.conf
      vim /etc/slurm/slurmdbd.conf
      • modify /etc/slurm/cgroup.conf
      cp /root/slurm-25.05.2/etc/cgroup.conf.example /etc/slurm/cgroup.conf
      • send configuration files to other nodes
      scp -r /etc/slurm/*.conf  root@juice-037:/etc/slurm/
      scp -r /etc/slurm/*.conf  root@juice-038:/etc/slurm/
    • grant privilege on some directories (All Nodes)

    mkdir /var/spool/slurmd
    chown slurm: /var/spool/slurmd
    mkdir /var/log/slurm
    chown slurm: /var/log/slurm
    
    mkdir /var/spool/slurmctld
    chown slurm: /var/spool/slurmctld
    
    chown slurm: /etc/slurm/slurmdbd.conf
    chmod 600 /etc/slurm/slurmdbd.conf
    • start slurm services on each node
    Node:
    systemctl start slurmdbd
    systemctl enable slurmdbd
    
    systemctl start slurmctld
    systemctl enable slurmctld
    
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status xxxx` to check if the `xxxx` service is running
    Example slurmdbd.server
    ```text
    # vim /usr/lib/systemd/system/slurmdbd.service
    
    
    [Unit]
    Description=Slurm DBD accounting daemon
    After=network-online.target remote-fs.target munge.service mysql.service mysqld.service mariadb.service sssd.service
    Wants=network-online.target
    ConditionPathExists=/etc/slurm/slurmdbd.conf
    
    [Service]
    Type=simple
    EnvironmentFile=-/etc/sysconfig/slurmdbd
    EnvironmentFile=-/etc/default/slurmdbd
    User=slurm
    Group=slurm
    RuntimeDirectory=slurmdbd
    RuntimeDirectoryMode=0755
    ExecStart=/usr/sbin/slurmdbd -D -s $SLURMDBD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    LimitNOFILE=65536
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    Example slumctld.server
    ```text
    # vim /usr/lib/systemd/system/slurmctld.service
    
    
    [Unit]
    Description=Slurm controller daemon
    After=network-online.target remote-fs.target munge.service sssd.service
    Wants=network-online.target
    ConditionPathExists=/etc/slurm/slurm.conf
    
    [Service]
    Type=notify
    EnvironmentFile=-/etc/sysconfig/slurmctld
    EnvironmentFile=-/etc/default/slurmctld
    User=slurm
    Group=slurm
    RuntimeDirectory=slurmctld
    RuntimeDirectoryMode=0755
    ExecStart=/usr/sbin/slurmctld --systemd $SLURMCTLD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    LimitNOFILE=65536
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    Example slumd.server
    ```text
    # vim /usr/lib/systemd/system/slurmd.service
    
    
    [Unit]
    Description=Slurm node daemon
    After=munge.service network-online.target remote-fs.target sssd.service
    Wants=network-online.target
    #ConditionPathExists=/etc/slurm/slurm.conf
    
    [Service]
    Type=notify
    EnvironmentFile=-/etc/sysconfig/slurmd
    EnvironmentFile=-/etc/default/slurmd
    RuntimeDirectory=slurm
    RuntimeDirectoryMode=0755
    ExecStart=/usr/sbin/slurmd --systemd $SLURMD_OPTIONS
    ExecReload=/bin/kill -HUP $MAINPID
    KillMode=process
    LimitNOFILE=131072
    LimitMEMLOCK=infinity
    LimitSTACK=infinity
    Delegate=yes
    
    
    # Uncomment the following lines to disable logging through journald.
    # NOTE: It may be preferable to set these through an override file instead.
    #StandardOutput=null
    #StandardError=null
    
    [Install]
    WantedBy=multi-user.target
    ```
    
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running
    systemctl start slurmd
    systemctl enable slurmd
    Using `systemctl status slurmd` to check if the `slurmd` service is running

    Test Your Slurm Cluster (Login Node)

    • check cluster configuration
    scontrol show config
    • check cluster status
    sinfo
    scontrol show partition
    scontrol show node
    • submit job
    srun -N2 hostname
    scontrol show jobs
    • check job status
    check job status
    squeue -a
    Aug 7, 2024

    Install From Binary

    Important

    (All Nodes) means all type nodes should install this component.

    (Manager Node) means only the manager node should install this component.

    (Login Node) means only the Auth node should install this component.

    (Cmp) means only the Compute node should install this component.

    Typically, there are three nodes are required to run Slurm.

    1 Manage(Manager Node), 1 Login Node and N Compute(Cmp).

    but you can choose to install all service in single node. check

    Prequisites

    1. change hostname (All Nodes)
      hostnamectl set-hostname (manager|auth|computeXX)
    2. modify /etc/hosts (All Nodes)
      echo "192.aa.bb.cc (manager|auth|computeXX)" >> /etc/hosts
    3. disable firewall, selinux, dnsmasq, swap (All Nodes). more detail here
    4. NFS Server (Manager Node). NFS is used as the default file system for the Slurm accounting database.
    5. [NFS Client] (All Nodes). all node should mount the NFS share
      Install NFS Client
      mount <$nfs_server>:/data /data -o proto=tcp -o nolock
    6. Munge (All Nodes). The auth/munge plugin will be built if the MUNGE authentication development library is installed. MUNGE is used as the default authentication mechanism.
      Install Munge

      All node need to have the munge user and group.

      groupadd -g 1108 munge
      useradd -m -c "Munge Uid 'N' Gid Emporium" -d /var/lib/munge -u 1108 -g munge -s /sbin/nologin munge
      yum install epel-release -y
      yum install munge munge-libs munge-devel -y

      Create global secret key

      /usr/sbin/create-munge-key -r
      dd if=/dev/urandom bs=1 count=1024 > /etc/munge/munge.key

      sync secret to the rest of nodes

      scp -p /etc/munge/munge.key root@<$rest_node>:/etc/munge/
      ssh root@<$rest_node> "chown munge: /etc/munge/munge.key && chmod 400 /etc/munge/munge.key"
      ssh root@<$rest_node> "systemctl start munge && systemctl enable munge"

      test munge if it works

      munge -n | unmunge
    7. Database (Manager Node). MySQL support for accounting will be built if the MySQL or MariaDB development library is present. A currently supported version of MySQL or MariaDB should be used.
      Install MariaDB

      install mariadb

      yum -y install mariadb-server
      systemctl start mariadb && systemctl enable mariadb
      ROOT_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16) 
      mysql -e "CREATE USER root IDENTIFIED BY '${ROOT_PASS}'"

      login mysql

      mysql -u root -p${ROOT_PASS}
      create database slurm_acct_db;
      create user slurm;
      grant all on slurm_acct_db.* TO 'slurm'@'localhost' identified by '123456' with grant option;
      flush privileges;
      quit

    Install Slurm

    1. create slurm user (All Nodes)
      groupadd -g 1109 slurm
      useradd -m -c "slurm manager" -d /var/lib/slurm -u 1109 -g slurm -s /bin/bash slurm
    Install Slurm from

    Build RPM package

    1. install depeendencies (Manager Node)

      yum -y install gcc gcc-c++ readline-devel perl-ExtUtils-MakeMaker pam-devel rpm-build mysql-devel python3
    2. build rpm package (Manager Node)

      wget https://download.schedmd.com/slurm/slurm-24.05.2.tar.bz2 -O slurm-24.05.2.tar.bz2
      rpmbuild -ta --nodeps slurm-24.05.2.tar.bz2

      The rpm files will be installed under the $(HOME)/rpmbuild directory of the user building them.

    3. send rpm to rest nodes (Manager Node)

      ssh root@<$rest_node> "mkdir -p /root/rpmbuild/RPMS/"
      scp -p $(HOME)/rpmbuild/RPMS/x86_64 root@<$rest_node>:/root/rpmbuild/RPMS/x86_64
    4. install rpm (Manager Node)

      ssh root@<$rest_node> "yum localinstall /root/rpmbuild/RPMS/x86_64/slurm-*"
    5. modify configuration file (Manager Node)

      cp /etc/slurm/cgroup.conf.example /etc/slurm/cgroup.conf
      cp /etc/slurm/slurm.conf.example /etc/slurm/slurm.conf
      cp /etc/slurm/slurmdbd.conf.example /etc/slurm/slurmdbd.conf
      chmod 600 /etc/slurm/slurmdbd.conf
      chown slurm: /etc/slurm/slurmdbd.conf

      cgroup.conf doesnt need to change.

      edit /etc/slurm/slurm.conf, you can use this link as a reference

      edit /etc/slurm/slurmdbd.conf, you can use this link as a reference

    Install yum repo directly

    1. install slurm (All Nodes)

      yum -y slurm-wlm slurmdbd
    2. modify configuration file (All Nodes)

      vim /etc/slurm-llnl/slurm.conf
      vim /etc/slurm-llnl/slurmdbd.conf

      cgroup.conf doesnt need to change.

      edit /etc/slurm/slurm.conf, you can use this link as a reference

      edit /etc/slurm/slurmdbd.conf, you can use this link as a reference

    1. send configuration (Manager Node)
       scp -r /etc/slurm/*.conf  root@<$rest_node>:/etc/slurm/
       ssh rootroot@<$rest_node> "mkdir /var/spool/slurmd && chown slurm: /var/spool/slurmd"
       ssh rootroot@<$rest_node> "mkdir /var/log/slurm && chown slurm: /var/log/slurm"
       ssh rootroot@<$rest_node> "mkdir /var/spool/slurmctld && chown slurm: /var/spool/slurmctld"
    2. start service (Manager Node)
      ssh rootroot@<$rest_node> "systemctl start slurmdbd && systemctl enable slurmdbd"
      ssh rootroot@<$rest_node> "systemctl start slurmctld && systemctl enable slurmctld"
    3. start service (All Nodes)
      ssh rootroot@<$rest_node> "systemctl start slurmd && systemctl enable slurmd"

    Test

    1. show cluster status
    scontrol show config
    sinfo
    scontrol show partition
    scontrol show node
    1. submit job
    srun -N2 hostname
    scontrol show jobs
    1. check job status
    squeue -a

    Reference:

    1. https://slurm.schedmd.com/documentation.html
    2. https://wiki.fysik.dtu.dk/Niflheim_system/Slurm_installation/
    3. https://github.com/Artlands/Install-Slurm
    Aug 7, 2024

    Install From Helm Chart

    Despite the complex binary installation, helm chart is a better way to install slurm.

    Source code could be found from https://github.com/AaronYang0628/slurm-on-k8s

    Prequisites

    1. Kubernetes has installed, if not check 🔗link
    2. Helm binary has installed, if not check 🔗link

    Installation

    1. get helm repo and update

      helm repo add ay-helm-mirror https://aaronyang0628.github.io/helm-chart-mirror/charts
    2. install slurm chart

      # wget -O slurm.values.yaml https://raw.githubusercontent.com/AaronYang0628/slurm-on-k8s/refs/heads/main/chart/values.yaml
      helm install slurm ay-helm-mirror/chart -f slurm.values.yaml --version 1.0.10

      Or you can get template values.yaml from https://raw.githubusercontent.com/AaronYang0628/helm-chart-mirror/refs/heads/main/templates/slurm/slurm.values.yaml

    3. check chart status

      helm -n slurm list
    Aug 7, 2024

    Install From K8s Operator

    Despite the complex binary installation, using k8s operator is a better way to install slurm.

    Source code could be found from https://github.com/AaronYang0628/slurm-on-k8s

    Prequisites

    1. Kubernetes has installed, if not check 🔗link
    2. Helm binary has installed, if not check 🔗link

    Installation

    1. deploy slurm operator

      kubectl apply -f https://raw.githubusercontent.com/AaronYang0628/helm-chart-mirror/refs/heads/main/templates/slurm/operator_install.yaml
      Expectd Output
      [root@ay-zj-ecs operator]# kubectl apply -f https://raw.githubusercontent.com/AaronYang0628/helm-chart-mirror/refs/heads/main/templates/slurm/operator_install.yaml
      namespace/slurm created
      customresourcedefinition.apiextensions.k8s.io/slurmdeployments.slurm.ay.dev created
      serviceaccount/slurm-operator-controller-manager created
      role.rbac.authorization.k8s.io/slurm-operator-leader-election-role created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-manager-role created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-metrics-auth-role created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-metrics-reader created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-slurmdeployment-admin-role created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-slurmdeployment-editor-role created
      clusterrole.rbac.authorization.k8s.io/slurm-operator-slurmdeployment-viewer-role created
      rolebinding.rbac.authorization.k8s.io/slurm-operator-leader-election-rolebinding created
      clusterrolebinding.rbac.authorization.k8s.io/slurm-operator-manager-rolebinding created
      clusterrolebinding.rbac.authorization.k8s.io/slurm-operator-metrics-auth-rolebinding created
      service/slurm-operator-controller-manager-metrics-service created
      deployment.apps/slurm-operator-controller-manager created
    2. check operator status

      kubectl -n slurm get pod
      Expectd Output
      [root@ay-zj-ecs operator]# kubectl -n slurm get pod
      NAME                                READY   STATUS    RESTARTS   AGE
      slurm-operator-controller-manager   1/1     Running   0          27s
    3. apply CRD slurmdeployment

      kubectl apply -f https://raw.githubusercontent.com/AaronYang0628/helm-chart-mirror/refs/heads/main/templates/slurm/slurmdeployment.zj.values.yaml
      Expectd Output
      [root@ay-zj-ecs operator]# kubectl apply -f https://raw.githubusercontent.com/AaronYang0628/helm-chart-mirror/refs/heads/main/templates/slurm/slurmdeployment.zj.values.yaml
      slurmdeployment.slurm.ay.dev/lensing created
    4. check operator status

      kubectl get slurmdeployment
      kubectl -n slurm logs -f deploy/slurm-operator-controller-manager
      # kubectl get slurmdep
      # kubectl -n test get pods
      Expectd Output
      [root@ay-zj-ecs ~]# kubectl get slurmdep -w
      NAME      CPU   GPU   LOGIN   CTLD   DBD   DBSVC   JOB COMMAND                     STATUS
      lensing   0/1   0/0   0/1     0/1    0/1   0/1     sh -c srun -N 2 /bin/hostname   
      lensing   1/2   0/0   1/1     1/1    1/1   1/1     sh -c srun -N 2 /bin/hostname   
      lensing   2/2   0/0   1/1     1/1    1/1   1/1     sh -c srun -N 2 /bin/hostname   
    5. upgrade slurmdep

      kubectl edit slurmdep lensing
      # set SlurmCPU.replicas = 3
      Expectd Output
      [root@ay-zj-ecs ~]# kubectl edit slurmdep lensing
      slurmdeployment.slurm.ay.dev/lensing edited
      
      [root@ay-zj-ecs ~]# kubectl get slurmdep -w
      NAME      CPU   GPU   LOGIN   CTLD   DBD   DBSVC   JOB COMMAND                     STATUS
      lensing   2/2   0/0   1/1     1/1    1/1   1/1     sh -c srun -N 2 /bin/hostname   
      lensing   2/3   0/0   1/1     1/1    1/1   1/1     sh -c srun -N 2 /bin/hostname   
      lensing   3/3   0/0   1/1     1/1    1/1   1/1     sh -c srun -N 2 /bin/hostname   
    Aug 7, 2024

    Try OpenSCOW

    What is SCOW?

    SCOW is a HPC cluster management system built by PKU.

    SCOW used four virtual machines to run slurm cluster. It is a good choice for you to learn how to use slurm.

    You should check https://pkuhpc.github.io/OpenSCOW/docs/hpccluster, it works well.

    Aug 7, 2024

    Subsections of CheatSheet

    Common Environment Variables

    VariableDescription
    $SLURM_JOB_IDThe Job ID.
    $SLURM_JOBIDDeprecated. Same as $SLURM_JOB_ID
    $SLURM_SUBMIT_HOSTThe hostname of the node used for job submission.
    $SLURM_JOB_NODELISTContains the definition (list) of the nodes that is assigned to the job.
    $SLURM_NODELISTDeprecated. Same as SLURM_JOB_NODELIST.
    $SLURM_CPUS_PER_TASKNumber of CPUs per task.
    $SLURM_CPUS_ON_NODENumber of CPUs on the allocated node.
    $SLURM_JOB_CPUS_PER_NODECount of processors available to the job on this node.
    $SLURM_CPUS_PER_GPUNumber of CPUs requested per allocated GPU.
    $SLURM_MEM_PER_CPUMemory per CPU. Same as –mem-per-cpu .
    $SLURM_MEM_PER_GPUMemory per GPU.
    $SLURM_MEM_PER_NODEMemory per node. Same as –mem .
    $SLURM_GPUSNumber of GPUs requested.
    $SLURM_NTASKSSame as -n, –ntasks. The number of tasks.
    $SLURM_NTASKS_PER_NODENumber of tasks requested per node.
    $SLURM_NTASKS_PER_SOCKETNumber of tasks requested per socket.
    $SLURM_NTASKS_PER_CORENumber of tasks requested per core.
    $SLURM_NTASKS_PER_GPUNumber of tasks requested per GPU.
    $SLURM_NPROCSSame as -n, –ntasks. See $SLURM_NTASKS.
    $SLURM_TASKS_PER_NODENumber of tasks to be initiated on each node.
    $SLURM_ARRAY_JOB_IDJob array’s master job ID number.
    $SLURM_ARRAY_TASK_IDJob array ID (index) number.
    $SLURM_ARRAY_TASK_COUNTTotal number of tasks in a job array.
    $SLURM_ARRAY_TASK_MAXJob array’s maximum ID (index) number.
    $SLURM_ARRAY_TASK_MINJob array’s minimum ID (index) number.

    A full list of environment variables for SLURM can be found by visiting the SLURM page on environment variables.

    Aug 7, 2024

    File Operations

    File Distribution

    • sbcast is used to transfer a file from local disk to local disk on the nodes allocated to a job. This can be used to effectively use diskless compute nodes or provide improved performance relative to a shared file system.
      • Feature
        1. distribute file:Quickly copy files to all compute nodes assigned to the job, avoiding the hassle of manually distributing files. Faster than traditional scp or rsync, especially when distributing to multiple nodes。
        2. simplify script:one command to distribute files to all nodes assigned to the job。
        3. imrpove performance:Improve file distribution speed by parallelizing transfers, especially for large or multiple files。
      • Usage
        1. Alone
        sbcast <source_file> <destination_path>
        1. Embedded in a job script
        #!/bin/bash
        #SBATCH --job-name=example_job
        #SBATCH --output=example_job.out
        #SBATCH --error=example_job.err
        #SBATCH --partition=compute
        #SBATCH --nodes=4
        
        # Use sbcast to distribute the file to the /tmp directory of each node
        sbcast data.txt /tmp/data.txt
        
        # Run your program using the distributed files
        srun my_program /tmp/data.txt

    File Collection

    1. File Redirection When submitting a job, you can use the #SBATCH –output and #SBATCH –error directives to redirect standard output and standard error to specified files.

       #SBATCH --output=output.txt
       #SBATCH --error=error.txt

      Or

      sbatch -N2 -w "compute[01-02]" -o result/file/path xxx.slurm
    2. Send the destination address manually Using scp or rsync in the job to copy the files from the compute nodes to the submit node

    3. Using NFS If a shared file system (such as NFS, Lustre, or GPFS) is configured in the computing cluster, the result files can be written directly to the shared directory. In this way, the result files generated by all nodes are automatically stored in the same location.

    4. Using sbcast

    Aug 7, 2024

    Submit Jobs

    3 Type Jobs

    • srun is used to submit a job for execution or initiate job steps in real time.

      • Example
        1. run shell
        srun -N2 bin/hostname
        1. run script
        srun -N1 test.sh
        1. exec into slurmd node
        srun -w slurm-lensing-slurm-slurmd-cpu-2 --pty /bin/bash
    • sbatch is used to submit a job script for later execution. The script will typically contain one or more srun commands to launch parallel tasks.

      • Example

        1. submit a batch job
        sbatch -N2 -w "compute[01-02]" -o job.stdout /data/jobs/batch-job.slurm
        batch-job.slurm
        #!/bin/bash
        
        #SBATCH -N 1
        #SBATCH --job-name=cpu-N1-batch
        #SBATCH --partition=compute
        #SBATCH --mail-type=end
        #SBATCH --mail-user=xxx@email.com
        #SBATCH --output=%j.out
        #SBATCH --error=%j.err
        
        srun -l /bin/hostname #you can still write srun <command> in here
        srun -l pwd
        
        1. submit a parallel task to process differnt data partition
        sbatch /data/jobs/parallel.slurm
        parallel.slurm
        #!/bin/bash
        #SBATCH -N 2 
        #SBATCH --job-name=cpu-N2-parallel
        #SBATCH --partition=compute
        #SBATCH --time=01:00:00
        #SBATCH --array=1-4  # 定义任务数组,假设有4个分片
        #SBATCH --ntasks-per-node=1 # 每个节点只运行一个任务
        #SBATCH --output=process_data_%A_%a.out
        #SBATCH --error=process_data_%A_%a.err
        
        TASK_ID=${SLURM_ARRAY_TASK_ID}
        
        DATA_PART="data_part_${TASK_ID}.txt" #make sure you have that file
        
        if [ -f ${DATA_PART} ]; then
            echo "Processing ${DATA_PART} on node $(hostname)"
            # python process_data.py --input ${DATA_PART}
        else
            echo "File ${DATA_PART} does not exist!"
        fi
        
        how to split file
        split -l 1000 data.txt data_part_ 
        && mv data_part_aa data_part_1 
        && mv data_part_ab data_part_2
        
    • salloc is used to allocate resources for a job in real time. Typically this is used to allocate resources and spawn a shell. The shell is then used to execute srun commands to launch parallel tasks.

      • Example
        1. allocate resources (more like create an virtual machine)
        salloc -N2 bash
        This command will create a job which allocates 2 nodes and spawn a bash shell on each node. and you can execute srun commands in that environment. After your computing task is finsihs, remember to shutdown your job.
        scancel <$job_id>
        when you exit the job, the resources will be released.
    Aug 7, 2024

    Configuration Files

    Aug 7, 2024

    Subsections of MPI Libs

    Test Intel MPI Jobs

    在SLURM集群中使用MPI(Message Passing Interface)进行并行计算,通常需要以下几个步骤:

    1. 安装MPI库

    确保你的集群节点已经安装了MPI库,常见的MPI实现包括:

    • OpenMPI
    • Intel MPI
    • MPICH 可以通过以下命令检查集群是否安装了MPI:
    mpicc --version  # 检查MPI编译器
    mpirun --version # 检查MPI运行时环境

    2. 测试MPI性能

    mpirun -n 2 IMB-MPI1 pingpong

    3. 编译MPI程序

    你可以用mpicc(C语言)或mpic++(C++语言)来编译MPI程序。例如:

    以下是一个简单的MPI “Hello, World!” 示例程序,假设文件名为 hello_mpi.c, 还有一个进行矩阵计算的示例程序,文件名为dot_product.c,任意挑选一个即可:

    #include <stdio.h>
    #include <mpi.h>
    
    int main(int argc, char *argv[]) {
        int rank, size;
        
        // 初始化MPI环境
        MPI_Init(&argc, &argv);
    
        // 获取当前进程的rank和总进程数
        MPI_Comm_rank(MPI_COMM_WORLD, &rank);
        MPI_Comm_size(MPI_COMM_WORLD, &size);
    
        // 输出进程的信息
        printf("Hello, World! I am process %d out of %d processes.\n", rank, size);
    
        // 退出MPI环境
        MPI_Finalize();
    
        return 0;
    }
    #include <stdio.h>
    #include <stdlib.h>
    #include <mpi.h>
    
    #define N 8  // 向量大小
    
    // 计算向量的局部点积
    double compute_local_dot_product(double *A, double *B, int start, int end) {
        double local_dot = 0.0;
        for (int i = start; i < end; i++) {
            local_dot += A[i] * B[i];
        }
        return local_dot;
    }
    
    void print_vector(double *Vector) {
        for (int i = 0; i < N; i++) {
            printf("%f ", Vector[i]);   
        }
        printf("\n");
    }
    
    int main(int argc, char *argv[]) {
        int rank, size;
    
        // 初始化MPI环境
        MPI_Init(&argc, &argv);
        MPI_Comm_rank(MPI_COMM_WORLD, &rank);
        MPI_Comm_size(MPI_COMM_WORLD, &size);
    
        // 向量A和B
        double A[N], B[N];
    
        // 进程0初始化向量A和B
        if (rank == 0) {
            for (int i = 0; i < N; i++) {
                A[i] = i + 1;  // 示例数据
                B[i] = (i + 1) * 2;  // 示例数据
            }
        }
    
        // 广播向量A和B到所有进程
        MPI_Bcast(A, N, MPI_DOUBLE, 0, MPI_COMM_WORLD);
        MPI_Bcast(B, N, MPI_DOUBLE, 0, MPI_COMM_WORLD);
    
        // 每个进程计算自己负责的部分
        int local_n = N / size;  // 每个进程处理的元素个数
        int start = rank * local_n;
        int end = (rank + 1) * local_n;
        
        // 如果是最后一个进程,确保处理所有剩余的元素(处理N % size)
        if (rank == size - 1) {
            end = N;
        }
    
        double local_dot_product = compute_local_dot_product(A, B, start, end);
    
        // 使用MPI_Reduce将所有进程的局部点积结果汇总到进程0
        double global_dot_product = 0.0;
        MPI_Reduce(&local_dot_product, &global_dot_product, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD);
    
        // 进程0输出最终结果
        if (rank == 0) {
            printf("Vector A is\n");
            print_vector(A);
            printf("Vector B is\n");
            print_vector(B);
            printf("Dot Product of A and B: %f\n", global_dot_product);
        }
    
        // 结束MPI环境
        MPI_Finalize();
        return 0;
    }

    3. 创建Slurm作业脚本

    创建一个SLURM作业脚本来运行该MPI程序。以下是一个基本的SLURM作业脚本,假设文件名为 mpi_test.slurm:

    #!/bin/bash
    #SBATCH --job-name=mpi_job       # Job name
    #SBATCH --nodes=2                # Number of nodes to use
    #SBATCH --ntasks-per-node=1      # Number of tasks per node
    #SBATCH --time=00:10:00          # Time limit
    #SBATCH --output=mpi_test_output_%j.log     # Standard output file
    #SBATCH --error=mpi_test_output_%j.err     # Standard error file
    
    # Manually set Intel OneAPI MPI and Compiler environment
    export I_MPI_PMI=pmi2
    export I_MPI_PMI_LIBRARY=/usr/lib/x86_64-linux-gnu/slurm/mpi_pmi2.so
    export I_MPI_ROOT=/opt/intel/oneapi/mpi/2021.14
    export INTEL_COMPILER_ROOT=/opt/intel/oneapi/compiler/2025.0
    export PATH=$I_MPI_ROOT/bin:$INTEL_COMPILER_ROOT/bin:$PATH
    export LD_LIBRARY_PATH=$I_MPI_ROOT/lib:$INTEL_COMPILER_ROOT/lib:$LD_LIBRARY_PATH
    export MANPATH=$I_MPI_ROOT/man:$INTEL_COMPILER_ROOT/man:$MANPATH
    
    # Compile the MPI program
    icx-cc -I$I_MPI_ROOT/include  hello_mpi.c -o hello_mpi -L$I_MPI_ROOT/lib -lmpi
    
    # Run the MPI job
    
    mpirun -np 2 ./hello_mpi
    #!/bin/bash
    #SBATCH --job-name=mpi_job       # Job name
    #SBATCH --nodes=2                # Number of nodes to use
    #SBATCH --ntasks-per-node=1      # Number of tasks per node
    #SBATCH --time=00:10:00          # Time limit
    #SBATCH --output=mpi_test_output_%j.log     # Standard output file
    #SBATCH --error=mpi_test_output_%j.err     # Standard error file
    
    # Manually set Intel OneAPI MPI and Compiler environment
    export I_MPI_PMI=pmi2
    export I_MPI_PMI_LIBRARY=/usr/lib/x86_64-linux-gnu/slurm/mpi_pmi2.so
    export I_MPI_ROOT=/opt/intel/oneapi/mpi/2021.14
    export INTEL_COMPILER_ROOT=/opt/intel/oneapi/compiler/2025.0
    export PATH=$I_MPI_ROOT/bin:$INTEL_COMPILER_ROOT/bin:$PATH
    export LD_LIBRARY_PATH=$I_MPI_ROOT/lib:$INTEL_COMPILER_ROOT/lib:$LD_LIBRARY_PATH
    export MANPATH=$I_MPI_ROOT/man:$INTEL_COMPILER_ROOT/man:$MANPATH
    
    # Compile the MPI program
    icx-cc -I$I_MPI_ROOT/include  dot_product.c -o dot_product -L$I_MPI_ROOT/lib -lmpi
    
    # Run the MPI job
    
    mpirun -np 2 ./dot_product

    4. 编译MPI程序

    在运行作业之前,你需要编译MPI程序。在集群上使用mpicc来编译该程序。假设你将程序保存在 hello_mpi.c 文件中,使用以下命令进行编译:

    mpicc -o hello_mpi hello_mpi.c
    mpicc -o dot_product dot_product.c

    5. 提交Slurm作业

    保存上述作业脚本(mpi_test.slurm)并使用以下命令提交作业:

    sbatch mpi_test.slurm

    6. 查看作业状态

    你可以使用以下命令查看作业的状态:

    squeue -u <your_username>

    7. 检查输出

    作业完成后,输出将保存在你作业脚本中指定的文件中(例如 mpi_test_output_<job_id>.log)。你可以使用 cat 或任何文本编辑器查看输出:

    cat mpi_test_output_*.log

    示例输出 如果一切正常,输出会类似于:

    Hello, World! I am process 0 out of 2 processes.
    Hello, World! I am process 1 out of 2 processes.
    Result Matrix C (A * B):
    14 8 2 -4 
    20 10 0 -10 
    -1189958655 1552515295 21949 -1552471397 
    0 0 0 0 
    Aug 7, 2024

    Test Open MPI Jobs

    在SLURM集群中使用MPI(Message Passing Interface)进行并行计算,通常需要以下几个步骤:

    1. 安装MPI库

    确保你的集群节点已经安装了MPI库,常见的MPI实现包括:

    • OpenMPI
    • Intel MPI
    • MPICH 可以通过以下命令检查集群是否安装了MPI:
    mpicc --version  # 检查MPI编译器
    mpirun --version # 检查MPI运行时环境

    2. 编译MPI程序

    你可以用mpicc(C语言)或mpic++(C++语言)来编译MPI程序。例如:

    以下是一个简单的MPI “Hello, World!” 示例程序,假设文件名为 hello_mpi.c, 还有一个进行矩阵计算的示例程序,文件名为dot_product.c,任意挑选一个即可:

    #include <stdio.h>
    #include <mpi.h>
    
    int main(int argc, char *argv[]) {
        int rank, size;
        
        // 初始化MPI环境
        MPI_Init(&argc, &argv);
    
        // 获取当前进程的rank和总进程数
        MPI_Comm_rank(MPI_COMM_WORLD, &rank);
        MPI_Comm_size(MPI_COMM_WORLD, &size);
    
        // 输出进程的信息
        printf("Hello, World! I am process %d out of %d processes.\n", rank, size);
    
        // 退出MPI环境
        MPI_Finalize();
    
        return 0;
    }
    #include <stdio.h>
    #include <stdlib.h>
    #include <mpi.h>
    
    #define N 8  // 向量大小
    
    // 计算向量的局部点积
    double compute_local_dot_product(double *A, double *B, int start, int end) {
        double local_dot = 0.0;
        for (int i = start; i < end; i++) {
            local_dot += A[i] * B[i];
        }
        return local_dot;
    }
    
    void print_vector(double *Vector) {
        for (int i = 0; i < N; i++) {
            printf("%f ", Vector[i]);   
        }
        printf("\n");
    }
    
    int main(int argc, char *argv[]) {
        int rank, size;
    
        // 初始化MPI环境
        MPI_Init(&argc, &argv);
        MPI_Comm_rank(MPI_COMM_WORLD, &rank);
        MPI_Comm_size(MPI_COMM_WORLD, &size);
    
        // 向量A和B
        double A[N], B[N];
    
        // 进程0初始化向量A和B
        if (rank == 0) {
            for (int i = 0; i < N; i++) {
                A[i] = i + 1;  // 示例数据
                B[i] = (i + 1) * 2;  // 示例数据
            }
        }
    
        // 广播向量A和B到所有进程
        MPI_Bcast(A, N, MPI_DOUBLE, 0, MPI_COMM_WORLD);
        MPI_Bcast(B, N, MPI_DOUBLE, 0, MPI_COMM_WORLD);
    
        // 每个进程计算自己负责的部分
        int local_n = N / size;  // 每个进程处理的元素个数
        int start = rank * local_n;
        int end = (rank + 1) * local_n;
        
        // 如果是最后一个进程,确保处理所有剩余的元素(处理N % size)
        if (rank == size - 1) {
            end = N;
        }
    
        double local_dot_product = compute_local_dot_product(A, B, start, end);
    
        // 使用MPI_Reduce将所有进程的局部点积结果汇总到进程0
        double global_dot_product = 0.0;
        MPI_Reduce(&local_dot_product, &global_dot_product, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD);
    
        // 进程0输出最终结果
        if (rank == 0) {
            printf("Vector A is\n");
            print_vector(A);
            printf("Vector B is\n");
            print_vector(B);
            printf("Dot Product of A and B: %f\n", global_dot_product);
        }
    
        // 结束MPI环境
        MPI_Finalize();
        return 0;
    }

    3. 创建Slurm作业脚本

    创建一个SLURM作业脚本来运行该MPI程序。以下是一个基本的SLURM作业脚本,假设文件名为 mpi_test.slurm:

    #!/bin/bash
    #SBATCH --job-name=mpi_test                 # 作业名称
    #SBATCH --nodes=2                           # 请求节点数
    #SBATCH --ntasks-per-node=1                 # 每个节点上的任务数
    #SBATCH --time=00:10:00                     # 最大运行时间
    #SBATCH --output=mpi_test_output_%j.log     # 输出日志文件
    
    # 加载MPI模块(如果使用模块化环境)
    module load openmpi
    
    # 运行MPI程序
    mpirun --allow-run-as-root -np 2 ./hello_mpi
    #!/bin/bash
    #SBATCH --job-name=mpi_test                 # 作业名称
    #SBATCH --nodes=2                           # 请求节点数
    #SBATCH --ntasks-per-node=1                 # 每个节点上的任务数
    #SBATCH --time=00:10:00                     # 最大运行时间
    #SBATCH --output=mpi_test_output_%j.log     # 输出日志文件
    
    # 加载MPI模块(如果使用模块化环境)
    module load openmpi
    
    # 运行MPI程序
    mpirun --allow-run-as-root -np 2 ./dot_product

    4. 编译MPI程序

    在运行作业之前,你需要编译MPI程序。在集群上使用mpicc来编译该程序。假设你将程序保存在 hello_mpi.c 文件中,使用以下命令进行编译:

    mpicc -o hello_mpi hello_mpi.c
    mpicc -o dot_product dot_product.c

    5. 提交Slurm作业

    保存上述作业脚本(mpi_test.slurm)并使用以下命令提交作业:

    sbatch mpi_test.slurm

    6. 查看作业状态

    你可以使用以下命令查看作业的状态:

    squeue -u <your_username>

    7. 检查输出

    作业完成后,输出将保存在你作业脚本中指定的文件中(例如 mpi_test_output_<job_id>.log)。你可以使用 cat 或任何文本编辑器查看输出:

    cat mpi_test_output_*.log

    示例输出 如果一切正常,输出会类似于:

    Hello, World! I am process 0 out of 2 processes.
    Hello, World! I am process 1 out of 2 processes.
    Result Matrix C (A * B):
    14 8 2 -4 
    20 10 0 -10 
    -1189958655 1552515295 21949 -1552471397 
    0 0 0 0 
    Aug 7, 2024

    Astro Code

      Mar 7, 2024

      Data Warehouse