> ## Documentation Index
> Fetch the complete documentation index at: https://ayakaleaf-pro.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 水平扩展

> 在使用会话保持的负载均衡器之后运行多个 Ayakaleaf Pro 副本。

一个副本的每次编译都只使用一个节点的 CPU：在我们配备 2 个 vCPU 的测试节点上，六个同时进行的编译所花的时间是单个编译的三倍，而其他节点却处于空闲状态。多个副本可以将用户及其编译分散到各个节点上。

本方案遵循 Overleaf 的[水平扩展](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/maintenance/horizontal-scaling.md)设计，并将其移植到 Kubernetes：

| 要求 | 本方案中的实现 |
| - | - |
| 共享的 MongoDB 和 Redis | 安装时的 `10-mongo.yaml` 和 `20-redis.yaml` |
| 共享的密钥 | [第 2 步](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/installation/using-kubernetes/2.-create-the-namespace-and-secrets.md)中的 Secret `overleaf-secrets` 已包含它们 |
| 项目文件和历史记录存放在 **S3** 中（而非 NFS） | 任意 S3 服务，参见 [S3](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/configuration/kubernetes/s3.md)；该页以 Garage 作为集群内示例 |
| 每个副本一块本地磁盘，用于编译和上传 | 每个节点上的 `hostPath` `/srv/overleaf-ha` |
| 持久路由、WebSocket、10 分钟超时、50 MB 上传 | 使用 Overleaf 示例配置的 HAProxy |
| git-bridge 作为单例，与一个副本相邻 | 以 `overleaf-0` 作为其相邻副本的 git-bridge |

> [!WARNING]
>
> 请从一开始就规划好 S3。已在其卷上保存文件的实例需要先进行 [S3 迁移](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/maintenance/s3-migration.md)。本方案将替代第 5 步中的 Deployment：在应用 StatefulSet 之前请先删除它（`kubectl delete -f 40-overleaf.yaml`）。

**1. S3 存储**

按照 [S3](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/configuration/kubernetes/s3.md) 中的说明，使用你的 S3 服务或集群中的 Garage 设置三个存储桶和两个用户，并将四个密钥放入 Secret `overleaf-s3`。下面的配置使用该页中的 Garage 示例。

**2. 配置**

此 ConfigMap 替代 `30-config.yaml`。它添加了 S3 和 git-bridge，并将 `OVERLEAF_SITE_URL` 指向负载均衡器：

```yaml title="60-config.yaml" wrap theme={null}
apiVersion: v1
kind: ConfigMap
metadata:
  name: overleaf-env
  namespace: overleaf
data:
  OVERLEAF_APP_NAME: Ayakaleaf Pro
  OVERLEAF_SITE_URL: http://192.0.2.10:30081      # the load balancer (HAProxy) URL
  OVERLEAF_ADMIN_EMAIL: admin@example.com
  # Emails: add the sender and the SMTP server together, see Email delivery
  OVERLEAF_MONGO_URL: mongodb://mongo-0.mongo:27017,mongo-1.mongo:27017,mongo-2.mongo:27017/sharelatex?replicaSet=overleaf
  OVERLEAF_REDIS_HOST: redis
  REDIS_HOST: redis
  ENABLED_LINKED_FILE_TYPES: project_file,project_output_file,url
  ENABLE_CONVERSIONS: "true"
  EMAIL_CONFIRMATION_DISABLED: "true"
  OVERLEAF_TRUSTED_PROXY_IPS: loopback,10.244.0.0/16   # HAProxy runs in the pod CIDR
  # Sandboxed compiles, node paths: each replica owns /srv/overleaf-ha on its node
  SANDBOXED_COMPILES: "true"
  SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
  DOCKER_RUNNER: "true"
  SANDBOXED_COMPILES_HOST_DIR_COMPILES: /srv/overleaf-ha/data/compiles
  SANDBOXED_COMPILES_HOST_DIR_OUTPUT: /srv/overleaf-ha/data/output
  SANDBOXED_COMPILES_HOST_DIR_CACHE: /srv/overleaf-ha/data/cache
  ALL_TEX_LIVE_DOCKER_IMAGES: ghcr.io/ayaka-notes/texlive-full:2026.1
  ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: TeX Live 2026
  TEX_LIVE_DOCKER_IMAGE: ghcr.io/ayaka-notes/texlive-full:2026.1
  # S3 (required for more than one replica); keys are in the Secret overleaf-s3
  OVERLEAF_FILESTORE_BACKEND: s3
  OVERLEAF_FILESTORE_TEMPLATE_FILES_BUCKET_NAME: overleaf-template-files
  OVERLEAF_FILESTORE_S3_ENDPOINT: http://garage:3900
  OVERLEAF_FILESTORE_S3_PATH_STYLE: "true"
  OVERLEAF_FILESTORE_S3_REGION: garage
  OVERLEAF_HISTORY_BACKEND: s3
  OVERLEAF_HISTORY_PROJECT_BLOBS_BUCKET: overleaf-project-blobs
  OVERLEAF_HISTORY_CHUNKS_BUCKET: overleaf-chunks
  OVERLEAF_HISTORY_S3_ENDPOINT: http://garage:3900
  OVERLEAF_HISTORY_S3_PATH_STYLE: "true"
  OVERLEAF_HISTORY_S3_REGION: garage
  # Git integration: one git-bridge, its sibling is overleaf-0
  GIT_BRIDGE_ENABLED: "true"
  GIT_BRIDGE_HOST: 10.96.100.80      # the git-bridge Service's fixed ClusterIP, see 75-git-bridge.yaml
  GIT_BRIDGE_PORT: "8000"
```

**3. 副本**

```yaml title="70-overleaf.yaml" wrap theme={null}
apiVersion: v1
kind: Service
metadata:
  name: overleaf-hl
  namespace: overleaf
spec:
  clusterIP: None            # stable DNS per replica: overleaf-0.overleaf-hl, ...
  selector:
    app: overleaf-ha
  ports:
    - {name: http, port: 80}
    - {name: web, port: 3000}       # git-bridge API (sibling only)
    - {name: history, port: 3100}   # history-v1, used by git-bridge via V1_HISTORY_URL
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: overleaf
  namespace: overleaf
spec:
  serviceName: overleaf-hl
  replicas: 3
  # OrderedReady: overleaf-0 runs the migrations alone before the next replica starts
  podManagementPolicy: OrderedReady
  selector:
    matchLabels:
      app: overleaf-ha
  template:
    metadata:
      labels:
        app: overleaf-ha
    spec:
      terminationGracePeriodSeconds: 60
      # Keep the environment to the ConfigMap and Secrets: no REDIS_PORT=tcp://..., GIT_BRIDGE_PORT=tcp://...
      enableServiceLinks: false
      affinity:
        podAntiAffinity:            # one replica per node: each uses that node's Docker and disk
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: overleaf-ha
              topologyKey: kubernetes.io/hostname
      containers:
        - name: sharelatex
          image: ghcr.io/ayaka-notes/overleaf-pro:6.3.2
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            # Each replica hands out history URLs with its own stable name, so git-bridge can reach them
            - name: V1_HISTORY_URL
              value: http://$(POD_NAME).overleaf-hl.overleaf.svc.cluster.local:3100/api
          envFrom:
            - configMapRef: {name: overleaf-env}
            - secretRef: {name: overleaf-secrets}
            - secretRef: {name: overleaf-s3}
          ports:
            - containerPort: 80
          readinessProbe:
            httpGet: {path: /status, port: 80}
            periodSeconds: 10
          resources:
            requests: {cpu: 500m, memory: 1536Mi}
            limits: {memory: 3Gi}
          volumeMounts:
            - {name: local, mountPath: /var/lib/overleaf}
            - {name: docker-sock, mountPath: /var/run/docker.sock}
      volumes:
        # Local disk for ephemeral files (compiles, output, uploads); the HOST_DIR_* paths are under it
        - name: local
          hostPath: {path: /srv/overleaf-ha, type: DirectoryOrCreate}
        - name: docker-sock
          hostPath: {path: /var/run/docker.sock, type: Socket}
```

* \*\*使用 StatefulSet，而非 Deployment。\*\*每个副本都会获得一个稳定的名称（`overleaf-0`、`overleaf-1`……），负载均衡器 Cookie 和 git-bridge 都引用该名称；`OrderedReady` 会先单独启动 `overleaf-0`，因此只有一个副本运行数据库迁移。
* \*\*每个节点一个副本。\*\*每个副本都通过其所在节点的 Docker 启动编译，并将编译内容保存在该节点的 `/srv/overleaf-ha` 中。
* \*\*每个副本各自的 `V1_HISTORY_URL`。\*\*每个副本都使用自己的名称分发历史记录链接，以便 git-bridge 能够访问它们。

**4. git-bridge**

```yaml title="75-git-bridge.yaml" wrap theme={null}
apiVersion: v1
kind: Service
metadata:
  name: git-bridge
  namespace: overleaf
spec:
  # Fixed ClusterIP (inside the service CIDR): the sharelatex image's nginx proxies /git/ with
  # `resolver 127.0.0.11` (Docker's DNS), which does not exist in Kubernetes, so GIT_BRIDGE_HOST is this IP
  clusterIP: 10.96.100.80
  selector:
    app: git-bridge
  ports:
    - {name: http, port: 8000}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: git-bridge
  namespace: overleaf
spec:
  serviceName: git-bridge
  replicas: 1
  selector:
    matchLabels:
      app: git-bridge
  template:
    metadata:
      labels:
        app: git-bridge
    spec:
      # Service "git-bridge" would inject GIT_BRIDGE_PORT=tcp://..., which git-bridge reads as its port
      enableServiceLinks: false
      containers:
        - name: git-bridge
          image: ghcr.io/ayaka-notes/overleaf-pro/git-bridge:6.3.2   # same version as the sharelatex image
          command: ["/server-pro-start.sh"]
          securityContext:
            runAsUser: 0
          env:
            - {name: GIT_BRIDGE_API_BASE_URL, value: "http://overleaf-0.overleaf-hl.overleaf.svc.cluster.local:3000/api/v0/"}
            - {name: GIT_BRIDGE_OAUTH2_SERVER, value: "http://overleaf-0.overleaf-hl.overleaf.svc.cluster.local"}
            - {name: GIT_BRIDGE_POSTBACK_BASE_URL, value: "http://git-bridge.overleaf.svc.cluster.local:8000"}
            - {name: GIT_BRIDGE_ROOT_DIR, value: /data/git-bridge}
          ports:
            - containerPort: 8000
          resources:
            requests: {cpu: 100m, memory: 256Mi}
            limits: {memory: 768Mi}
          volumeMounts:
            - {name: data, mountPath: /data/git-bridge}
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 5Gi
```

关于 `GIT_BRIDGE_HOST` 为什么是固定 IP 地址，请参见 [Git 集成](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/configuration/kubernetes/integrations/git-integration.md)。

**5. 负载均衡器**

该 HAProxy 配置基于 Overleaf 的示例，并做了五处修改：`/project/<id>/mcp` 与 `/git/` 一样路由到 `overleaf-0`（参见 [MCP 服务器](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/configuration/kubernetes/integrations/mcp-server.md#several-replicas)）；在端口 `8080` 上使用普通 HTTP（该镜像以非 root 用户运行，在 containerd 下无法绑定端口 80）；通过集群 DNS 查找服务器（Pod IP 在每次重启时都会变化）；使用 DaemonSet，使每个节点都能响应；以及使用 `balance leastconn` 代替默认的 `roundrobin`（参见下面的 Q\&A）。

```yaml title="80-haproxy.yaml" wrap theme={null}
apiVersion: v1
kind: ConfigMap
metadata:
  name: haproxy-config
  namespace: overleaf
data:
  haproxy.cfg: |
    global
      log stdout format raw local0 info

    resolvers kubedns
      parse-resolv-conf
      hold valid 10s

    defaults
      mode                    http
      # New sessions go to the replica with the fewest open connections (editors keep a WebSocket open)
      balance                 leastconn
      option                  httpchk HEAD /status
      http-check              expect status 200
      default-server          check resolvers kubedns init-addr none
      log                     global
      option                  httplog
      # Reroute to a different backend if the sticky one is down
      option                  redispatch 1
      retries                 3
      # Sticky session for 24h of inactivity -- compile output is deleted after 24h
      cookie                  server-pro-ha insert maxidle 24h
      timeout queue           1m
      timeout connect         15s
      timeout client          1m
      # Allow slow compiles -- hard-coded limit in clsi is 10min
      timeout server          10m
      # Disconnect the editor after 23h
      timeout tunnel          23h
      # Lower than NGINX_KEEPALIVE_TIMEOUT (65s) in the sharelatex container
      timeout http-keep-alive 60s

    listen overleaf
      bind :8080
      option forwardfor
      # When TLS ends in front of HAProxy, also pass:  http-request set-header X-Forwarded-Proto https
      # git requests go to the git-bridge sibling
      use-server overleaf-0 if { path_beg /git/ }
      # MCP clients send no cookie: keep compile, log and PDF download on one replica
      use-server overleaf-0 if { path_reg ^/project/[0-9a-f]{24}/mcp(/|$) }
      http-response add-header X-Served-By %s
      stats enable
      stats uri /haproxy
      server overleaf-0 overleaf-0.overleaf-hl.overleaf.svc.cluster.local:80 cookie overleaf-0
      server overleaf-1 overleaf-1.overleaf-hl.overleaf.svc.cluster.local:80 cookie overleaf-1
      server overleaf-2 overleaf-2.overleaf-hl.overleaf.svc.cluster.local:80 cookie overleaf-2
---
apiVersion: apps/v1
# DaemonSet: one HAProxy per node, so the NodePort answers on every node with externalTrafficPolicy
# Local. The cookie holds the replica name, so stickiness works whichever HAProxy a request hits.
kind: DaemonSet
metadata:
  name: haproxy
  namespace: overleaf
spec:
  selector:
    matchLabels:
      app: haproxy
  template:
    metadata:
      labels:
        app: haproxy
      annotations:
        config-hash: "1"   # bump to restart after editing the ConfigMap
    spec:
      containers:
        - name: haproxy
          image: haproxy:lts-alpine
          ports:
            - containerPort: 8080
          resources:
            requests: {cpu: 50m, memory: 32Mi}
            limits: {memory: 256Mi}
          volumeMounts:
            - {name: config, mountPath: /usr/local/etc/haproxy}
      volumes:
        - name: config
          configMap:
            name: haproxy-config
---
apiVersion: v1
kind: Service
metadata:
  name: haproxy
  namespace: overleaf
spec:
  type: NodePort
  externalTrafficPolicy: Local    # keep the client IP (no SNAT); only nodes running HAProxy answer
  selector:
    app: haproxy
  ports:
    - {name: http, port: 80, targetPort: 8080, nodePort: 30081}
```

```bash theme={null}
kubectl delete -f 40-overleaf.yaml --ignore-not-found
kubectl apply -f 60-config.yaml -f 70-overleaf.yaml -f 75-git-bridge.yaml -f 80-haproxy.yaml
kubectl -n overleaf rollout status statefulset/overleaf --timeout=20m
curl -sI http://192.0.2.10:30081/login | grep -i x-served-by
```

`externalTrafficPolicy: Local` 会在 `X-Forwarded-For` 中保留用户的 IP 地址。

**6. TLS**

按照 [TLS 代理](https://ayakaleaf-pro.mintlify.site/zh-CN/on-premises/configuration/kubernetes/tls-proxy.md)中的说明在 HAProxy 中终止 HTTPS，然后在 `60-config.yaml` 中将 `OVERLEAF_SITE_URL` 设置为 `https://` 地址，并设置 `OVERLEAF_SECURE_COOKIE: "true"`。

### 预期效果

我们在三个各配备 2 个 vCPU 和 4 GB 内存的节点上测试了三个副本，MongoDB、Redis、Garage、git-bridge 和 HAProxy 也运行在这些节点上：

* 12 名用户同时编译一篇 600 页的文档：全部 12 名用户都完成了编译并获取到 PDF，每个副本 4 名用户；一轮耗时 8 到 18 秒。每个节点至少保留了 1.3 GB 可用内存。
* 每个用户都停留在同一个副本上（Cookie `server-pro-ha`），包括必须来自执行编译的副本的 PDF 下载。在 HTTPS 下同样如此，包括编辑器的 WebSocket。
* 当某个副本停止时，其用户会在大约 15 到 20 秒内遇到错误，直到 HAProxy 将其标记为下线；之后用户会转移到另一个副本并保持登录状态，因为会话保存在 Redis 中。

### Q\&A

> [accordion] 为什么使用 `balance leastconn`？
>
> \*\*回答：\*\*在我们的测试中，使用 Overleaf 的示例配置（`roundrobin`，每个节点一个 HAProxy）时，12 名新用户在三个副本上的分布为 8、3 和 1，最繁忙的副本让最慢的用户等待了 29 秒。使用 `leastconn`（优先选择打开连接数最少的副本，每个编辑器都会保持一个 WebSocket 连接）时，同样的测试得到的分布为 4、4 和 4，耗时 18 秒。会话保持不受影响：它来自 Cookie。

> [accordion] 我可以使用自己的 Ingress 控制器代替 HAProxy 吗？
>
> \*\*回答：\*\*可以，前提是它能根据 Cookie 路由到固定的副本、支持 WebSocket、允许 10 分钟的请求时间和 50 MB 的上传、将其 keep-alive 超时保持在 65 秒以下、发送 `X-Forwarded-For` 和 `X-Forwarded-Proto`，并将 `/git/` 和 `/project/<id>/mcp` 发送到 `overleaf-0`。请路由到各副本自身的名称（`overleaf-N.overleaf-hl`），而不是路由到对每个请求进行负载均衡的 Service。

> [accordion] 如何添加第四个副本？
>
> \*\*回答：\*\*添加一个安装了 Docker 和 TeX Live 镜像的节点，在 `70-overleaf.yaml` 中设置 `replicas: 4`，并在 HAProxy 配置中添加一行 `server overleaf-3 ...`（同时递增 `config-hash`，以便 HAProxy 重启）。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.