第 10 章 信创云原生:鲲鹏 K8s 上的 PHP 容器化与弹性伸缩

10.1 先讲清楚 ARM64 容器化最容易翻车的一件事

大白话: 你在自己的 x86 笔记本上 docker build 出来的镜像,在鲲鹏服务器上跑不起来,报错长这样:

exec /usr/local/bin/docker-php-entrypoint: exec format error

这句话的意思是「这个二进制文件的架构和 CPU 不匹配」。因为镜像里的 PHP 是 x86_64 编译的,鲲鹏是 aarch64。

解决办法有两条,优先选第 1 条

  1. 直接在鲲鹏机器上构建(最省事、最可靠,本章走这条);
  2. 在 x86 上用 buildx 交叉构建 arm64 镜像(构建慢 5-10 倍,因为走 QEMU 模拟,但适合 CI 统一出多架构镜像)。

先确认你的基础镜像有 arm64 版本:

# 在任意机器上查某个镜像支持哪些架构
docker manifest inspect php:8.3-fpm-alpine | grep -A2 platform
# 期望能看到 "architecture": "arm64"

# 在鲲鹏机器上拉下来后必须核对
docker run --rm php:8.3-fpm-alpine uname -m   # 期望 aarch64

10.2 Dockerfile(鲲鹏 ARM64 + 达梦 + Swoole)

信创环境的镜像有三个额外要求:基础镜像要能拉到(内网通常有 Harbor 私仓)、要装达梦 ODBC 驱动、要装 Swoole。

# 文件名:Dockerfile
# 说明:多阶段构建。第一阶段编译扩展,第二阶段只留运行时,镜像能小 60%。
# 构建命令(在鲲鹏机器上执行):
#   docker build -t harbor.gov.local/php/hyperf-app:1.0.0 .

# ---------- 第一阶段:构建 ----------
FROM php:8.3-cli-bookworm AS builder

# 换成国内源,否则 apt 慢到超时
RUN sed -i 's@deb.debian.org@mirrors.aliyun.com@g' /etc/apt/sources.list.d/debian.sources

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential autoconf pkg-config \
      libssl-dev libcurl4-openssl-dev libzip-dev \
      unixodbc unixodbc-dev \
      libonig-dev \
    && rm -rf /var/lib/apt/lists/*

# 装 PHP 官方扩展(docker-php-ext-install 是官方镜像自带的脚本)
RUN docker-php-ext-install -j"$(nproc)" \
      pdo pdo_odbc bcmath sockets pcntl opcache zip

# 装 Swoole(ARM64 上要显式开 cares,见第 3 章)
RUN pecl install swoole-5.1.3 \
    && docker-php-ext-enable swoole

# 装 Redis 扩展(连 TongRDS 用)
RUN pecl install redis-6.0.2 \
    && docker-php-ext-enable redis

# ---------- 第二阶段:运行时 ----------
FROM php:8.3-cli-bookworm

RUN sed -i 's@deb.debian.org@mirrors.aliyun.com@g' /etc/apt/sources.list.d/debian.sources

# 运行时只装动态库,不装编译工具
RUN apt-get update && apt-get install -y --no-install-recommends \
      unixodbc libzip4 curl \
    && rm -rf /var/lib/apt/lists/*

# 从构建阶段拷贝编译好的扩展
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/

# 达梦 ODBC 驱动:把宿主机上解压好的客户端目录放进构建上下文再 COPY
# (达梦客户端要官网下载,注意下 aarch64 版本,不是 x86_64 版本)
COPY dm8_client/ /opt/dm8/
COPY odbc.ini /etc/odbc.ini
COPY odbcinst.ini /etc/odbcinst.ini
ENV LD_LIBRARY_PATH=/opt/dm8/drivers:$LD_LIBRARY_PATH

# PHP 配置
COPY php.ini /usr/local/etc/php/php.ini

WORKDIR /app
COPY --chown=www-data:www-data . /app

# 装依赖(--no-dev 去掉开发包,体积和攻击面都小)
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN composer install --no-dev --optimize-autoloader --no-interaction \
    && rm -rf ~/.composer

# 不要用 root 跑业务进程(等保测评会查这一条)
USER www-data

EXPOSE 9501

# 健康检查:K8s 的 probe 之外再加一层,docker 单机跑也能看状态
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD curl -fsS http://127.0.0.1:9501/health || exit 1

CMD ["php", "bin/hyperf.php", "start"]

配套的 odbcinst.ini(告诉 unixODBC 达梦驱动在哪):

[DM8 ODBC DRIVER]
Description = DaMeng ODBC Driver
Driver      = /opt/dm8/drivers/libdodbc.so
Setup       = /opt/dm8/drivers/libdodbc.so
FileUsage   = 1

配套的 php.ini 关键项(容器里和裸机不同的地方):

; 容器里日志要走 stdout/stderr,K8s 才能采集到
display_errors = Off
log_errors = On
error_log = /dev/stderr

memory_limit = 512M
date.timezone = Asia/Shanghai

[opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
; 容器镜像不可变,代码不会变,关掉时间戳检查省一次 stat 系统调用
opcache.validate_timestamps = 0
; ARM64 上按第 3 章的结论关掉 JIT
opcache.jit = disable

构建并推到内网 Harbor:

# 在鲲鹏机器上构建(架构天然匹配)
docker build -t harbor.gov.local/php/hyperf-app:1.0.0 .

# 关键验证:确认镜像是 arm64,别推错了
docker inspect harbor.gov.local/php/hyperf-app:1.0.0 --format '{{.Architecture}}'
# 期望输出:arm64

docker login harbor.gov.local
docker push harbor.gov.local/php/hyperf-app:1.0.0

10.3 如果必须在 x86 CI 上出 ARM 镜像(buildx 方案)

# 1. 装 QEMU 模拟器(让 x86 内核能执行 arm64 二进制)
docker run --privileged --rm tonistiigi/binfmt --install arm64

# 2. 建一个支持多架构的 builder
docker buildx create --name xc-builder --use
docker buildx inspect --bootstrap

# 3. 同时出 amd64 和 arm64,直接推仓库(多架构镜像必须 --push,不能 --load)
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t harbor.gov.local/php/hyperf-app:1.0.0 \
  --push .

这样鲲鹏和海光两种服务器可以拉同一个 tag,Docker 会自动选对应架构。代价是构建时间长(arm64 那一半走模拟),pecl 编译 Swoole 可能要 10 分钟以上。

10.4 K8s 部署清单(Deployment + Service + 探针)

先给应用加一个健康检查接口,探针要用:

<?php
// app/Controller/HealthController.php
namespace App\Controller;

use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\GetMapping;
use Hyperf\DbConnection\Db;

#[Controller]
class HealthController
{
    // 存活探针:只证明进程没死,不查外部依赖
    // 这里要是查了数据库,数据库一抖 K8s 就会把好好的 Pod 全杀掉重启
    #[GetMapping("/health")]
    public function health()
    {
        return ['status' => 'ok'];
    }

    // 就绪探针:查依赖,依赖不通就摘流量,但不重启
    #[GetMapping("/ready")]
    public function ready()
    {
        $checks = [];

        try {
            Db::connection('dm8')->select('SELECT 1 FROM DUAL');
            $checks['dameng'] = 'ok';
        } catch (\Throwable $e) {
            $checks['dameng'] = 'fail';
        }

        try {
            $redis = \Hyperf\Context\ApplicationContext::getContainer()
                ->get(\Hyperf\Redis\Redis::class);
            $redis->ping();
            $checks['tongrds'] = 'ok';
        } catch (\Throwable $e) {
            $checks['tongrds'] = 'fail';
        }

        $healthy = !in_array('fail', $checks, true);

        return $this->response
            ->withStatus($healthy ? 200 : 503)
            ->json(['status' => $healthy ? 'ready' : 'not_ready', 'checks' => $checks]);
    }
}

Deployment:

# 文件名:k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hyperf-app
  namespace: gov-app
spec:
  replicas: 3
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: hyperf-app
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0        # 保证升级过程中容量不下降
  template:
    metadata:
      labels:
        app: hyperf-app
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9501"
        prometheus.io/path: "/metrics"
    spec:
      # 关键:把 Pod 钉在 arm64 节点上。混合集群里少了这段会随机调度到海光节点然后崩
      nodeSelector:
        kubernetes.io/arch: arm64
      # 把副本尽量打散到不同物理节点,单机宕机不会全挂
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: hyperf-app
      securityContext:
        runAsNonRoot: true
        runAsUser: 33            # www-data
        fsGroup: 33
      containers:
        - name: app
          image: harbor.gov.local/php/hyperf-app:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 9501
              name: http
          env:
            - name: APP_ENV
              value: "prod"
            - name: DB_HOST
              value: "dm8.gov-db.svc.cluster.local"
            - name: DB_PORT
              value: "5236"
            - name: DB_DATABASE
              value: "MYAPP"
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: db-username
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: db-password
            - name: REDIS_HOST
              value: "tongrds.gov-db.svc.cluster.local"
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: redis-password
          resources:
            requests:
              cpu: "500m"
              memory: "256Mi"
            limits:
              cpu: "2000m"
              memory: "1Gi"
          startupProbe:                # 给启动留足时间,避免慢启动被误杀
            httpGet:
              path: /health
              port: 9501
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /health
              port: 9501
            initialDelaySeconds: 10
            periodSeconds: 15
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /ready
              port: 9501
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 2
          lifecycle:
            preStop:
              # 先睡 5 秒,等 Service 把自己从 endpoints 摘掉,再让进程退出
              # 不加这段,滚动更新时会有几百个请求打到正在关闭的 Pod 上
              exec:
                command: ["sh", "-c", "sleep 5"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: false   # Hyperf 要写 runtime/,设 true 需挂 emptyDir
            capabilities:
              drop: ["ALL"]
      terminationGracePeriodSeconds: 30
      imagePullSecrets:
        - name: harbor-cred

Secret 和 Service:

# 文件名:k8s/secret.yaml
# 生产环境别把 Secret 提交到 git,用 kubectl create secret 或密钥管理系统注入
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
  namespace: gov-app
type: Opaque
stringData:
  db-username: "myapp"
  db-password: "MyApp@2024"
  redis-password: "TongRDS@2024"
---
apiVersion: v1
kind: Service
metadata:
  name: hyperf-app
  namespace: gov-app
spec:
  type: ClusterIP
  selector:
    app: hyperf-app
  ports:
    - name: http
      port: 80
      targetPort: 9501

用命令行创建 Secret(推荐,避免明文进版本库):

kubectl create secret generic app-secrets -n gov-app \
  --from-literal=db-username='myapp' \
  --from-literal=db-password='MyApp@2024' \
  --from-literal=redis-password='TongRDS@2024'

kubectl create secret docker-registry harbor-cred -n gov-app \
  --docker-server=harbor.gov.local \
  --docker-username='deployer' \
  --docker-password='<password>'

部署并验证:

kubectl create namespace gov-app
kubectl apply -f k8s/deployment.yaml -f k8s/secret.yaml

kubectl -n gov-app rollout status deploy/hyperf-app
kubectl -n gov-app get pods -o wide

# 排错三连(Pod 起不来时按顺序看)
kubectl -n gov-app describe pod <pod-name>     # 看事件:镜像拉不到?调度失败?探针失败?
kubectl -n gov-app logs <pod-name> --tail=100  # 看应用日志
kubectl -n gov-app logs <pod-name> --previous  # 看上一次崩溃前的日志

10.5 弹性伸缩(HPA)

HPA 要能读到 Pod 的 CPU 用量,前提是集群装了 metrics-server:

kubectl top nodes    # 有输出说明 metrics-server 正常
kubectl top pods -n gov-app

基于 CPU 和内存的基础 HPA:

# 文件名:k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: hyperf-app
  namespace: gov-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: hyperf-app
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60      # 超过 request 的 60% 就扩
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75
  behavior:
    scaleUp:
      # 政务系统常见早高峰突增,允许快速扩容
      stabilizationWindowSeconds: 30
      policies:
        - type: Percent
          value: 100                  # 每次最多翻倍
          periodSeconds: 30
        - type: Pods
          value: 4                    # 或每 30 秒最多加 4 个
          periodSeconds: 30
      selectPolicy: Max
    scaleDown:
      # 缩容要慢,防止流量抖动导致反复扩缩(抖动会让长连接反复断开)
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60

为什么 Swoole 应用的 HPA 要特别小心 CPU 阈值: Swoole 是常驻进程,空闲时 CPU 接近 0,但一旦压上来 CPU 会瞬间冲高。如果阈值设到 80%,等 HPA 反应过来(默认 15 秒采集周期 + 稳定窗口),请求已经堆积了。建议设 50%-60%,用容量换响应速度。

对长连接服务(第 5 章的 WebSocket),CPU 不是好指标——连接数才是。用 Prometheus Adapter 暴露自定义指标:

# 文件名:k8s/hpa-websocket.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: gov-chat
  namespace: gov-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: gov-chat
  minReplicas: 2
  maxReplicas: 30
  metrics:
    - type: Pods
      pods:
        metric:
          name: swoole_connection_num     # 第 8 章 /metrics 暴露的指标
        target:
          type: AverageValue
          averageValue: "3000"            # 每个 Pod 平均超过 3000 连接就扩
  behavior:
    scaleDown:
      # 长连接缩容更要慢:缩一个 Pod 就断 3000 个用户,他们会同时重连打爆剩下的 Pod
      stabilizationWindowSeconds: 900
      policies:
        - type: Pods
          value: 1
          periodSeconds: 300

验证伸缩是否生效:

kubectl apply -f k8s/hpa.yaml
kubectl -n gov-app get hpa -w

# 压测触发扩容(在集群内起个临时 Pod 压)
kubectl -n gov-app run bench --rm -it --restart=Never \
  --image=williamyeh/wrk -- -t4 -c400 -d120s http://hyperf-app/api/users/1

# 另一个终端观察副本数变化
kubectl -n gov-app get hpa hyperf-app -w
# READY 列的副本数应从 3 逐步涨上去

10.6 长连接服务在 K8s 上的额外配置

WebSocket 走 Ingress 需要显式加超时和会话保持,否则连接会被 60 秒切断:

# 文件名:k8s/ingress-ws.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gov-chat
  namespace: gov-app
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    # 会话保持:让同一个客户端始终连到同一个 Pod
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "chat-route"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
spec:
  ingressClassName: nginx
  rules:
    - host: chat.example.gov.cn
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: gov-chat
                port:
                  number: 80

Service 侧也要延长会话亲和时间:

apiVersion: v1
kind: Service
metadata:
  name: gov-chat
  namespace: gov-app
spec:
  type: ClusterIP
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600
  selector:
    app: gov-chat
  ports:
    - port: 80
      targetPort: 8282

10.7 K8s 上的信创合规要点(测评会查的四条)

# 1. 禁止容器以 root 运行 —— 用 Pod Security Standards 在命名空间级别强制
apiVersion: v1
kind: Namespace
metadata:
  name: gov-app
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
---
# 2. 网络隔离 —— 默认拒绝,只放行必要流量
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: gov-app
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-from-ingress-and-db
  namespace: gov-app
spec:
  podSelector:
    matchLabels:
      app: hyperf-app
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 9501
  egress:
    # 只允许访问数据库命名空间和 DNS
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: gov-db
    - to:
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP

剩下两条是运维动作,不是 YAML:

  1. 镜像来源可控:所有镜像必须来自内网 Harbor,禁止从 docker.io 直拉。用 ImagePolicyWebhook 或 Kyverno 策略拦截非法仓库。
  2. 审计日志开启:kube-apiserver 要加 --audit-log-path 和审计策略文件,记录谁在什么时候改了什么资源。这条测评一定会要证据截图。

10.8 排错手册(容器化阶段的高频问题)

报错 / 现象 真实原因 怎么修
exec format error 镜像架构和节点架构不匹配 在鲲鹏上重建镜像,或用 buildx 出 arm64;加 nodeSelector
ImagePullBackOff Harbor 凭据没配 / 镜像名写错 kubectl describe pod 看具体信息,检查 imagePullSecrets
Pod 反复重启,日志无异常 livenessProbe 查了数据库,数据库慢就被杀 存活探针只返回静态 ok,依赖检查挪到 readinessProbe
滚动更新时有 502 旧 Pod 收到 SIGTERM 时还有流量进来 preStop: sleep 5 + maxUnavailable: 0
SQLSTATE[IM002] data source name not found 容器里没有 /etc/odbc.ini 或驱动路径错 确认 COPY 了 odbc.ini/odbcinst.ini,LD_LIBRARY_PATH 指向 /opt/dm8/drivers
WebSocket 每 60 秒断一次 Ingress 默认读超时 60s proxy-read-timeout: 3600
HPA 一直显示 <unknown> metrics-server 没装或没就绪 kubectl top pods 验证;容器必须设 resources.requests
副本扩了但 QPS 没涨 达梦连接池被打满,瓶颈在数据库 算总连接数:副本数 × max_connections 不能超过达梦的 max_sessions
容器内存缓慢上涨被 OOMKilled Swoole 常驻进程有内存泄漏 max_request: 100000 让 worker 定期重启;用 memory_get_usage 定位

关于最后一条特别提醒: 容器里的 memory limit 是硬限制,超了直接 SIGKILL(日志里看不到任何优雅退出信息,只有 Reason: OOMKilled)。查这个要看:

kubectl -n gov-app describe pod <pod-name> | grep -A3 "Last State"
# 期望能看到 Reason: OOMKilled 和 Exit Code: 137

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐