在 Kubernetes 上运行 CFML 应用:实战操作全流程

CFML(ColdFusion 标记语言)至今仍支撑着大量企业软件,而其中很多都被困在一台臃肿的单体应用服务器上。这篇文章讲的是我把一个 CFML 应用——基于开源 CFML 引擎 Lucee 构建——容器化并跑在 Kubernetes 上的实际步骤,并把那些真正会坑到你的细节一并点出来。

这不是一篇从零开始的 Kubernetes 教程。它假设你能敲 kubectl get pods,并且手头有个可以连的集群(minikube、kind 或托管集群都行)。

心智模型

CFML 应用跑在 Kubernetes 上,形态和任何其他 Web 应用一样,只有一处特殊:引擎和你的代码是绑在一起、装进同一个镜像里的

text
你的 .cfm/.cfc 代码  →  Docker 镜像(Lucee + 代码)  →  Pod  →  Service  →  Ingress  →  用户
                         (构建一次)                    (运行 N 份副本)

凡是不属于代码的东西——数据库密码、数据源配置、会话设置——都在运行时通过 ConfigMap 和 Secret 注入,绝不烤进镜像里。守住这条界线,剩下的就顺理成章了。

第一步:容器化应用

从官方 Lucee 镜像出发,把你的 webroot 拷进去。一个最小化的 Dockerfile

dockerfile
FROM lucee/lucee:6.0-tomcat

# 应用代码放进 Tomcat 的 webroot
COPY ./webroot /var/www

# Lucee 管理密码与部署期设置来自环境变量,而非镜像
ENV LUCEE_ADMIN_ENABLED="false"

# Lucee 镜像里 Tomcat 监听 8888
EXPOSE 8888

在 Kubernetes 还没登场之前,先在本地构建并冒烟测试:

bash
docker build -t myregistry/cfml-app:1.0.0 .
docker run --rm -p 8888:8888 myregistry/cfml-app:1.0.0
# 访问 http://localhost:8888 —— 确认 .cfm 页面能正常渲染
docker push myregistry/cfml-app:1.0.0

用真实的版本号打 tag,永远别用 latest Kubernetes 会激进地缓存镜像;latest 会让发布变得模糊,回滚则无从谈起。

第二步:Deployment

Deployment 声明要跑几份 Pod 副本、用哪个镜像。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cfml-app
  labels:
    app: cfml-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cfml-app
  template:
    metadata:
      labels:
        app: cfml-app
    spec:
      containers:
        - name: cfml-app
          image: myregistry/cfml-app:1.0.0
          ports:
            - containerPort: 8888
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"

关于内存有一句要说:Lucee 跑在 JVM 上,而除非你明确告诉它,JVM 是看不到容器内存上限的。要把堆大小设在上限之内,否则内核会在请求处理到一半时把 Pod OOM 杀掉。通过 JVM 选项的环境变量传入:

yaml
env:
  - name: CATALINA_OPTS
    value: "-XX:MaxRAMPercentage=75.0"

MaxRAMPercentage 让 JVM 按容器上限的比例来设定堆大小——比硬编码一个 -Xmx 安全得多。

第三步:用 Service 暴露出去

Service 给这些 Pod 一个稳定的内部地址。Pod 来来去去,Service 的名字不变。

yaml
apiVersion: v1
kind: Service
metadata:
  name: cfml-app
spec:
  selector:
    app: cfml-app
  ports:
    - port: 80
      targetPort: 8888

集群内部现在任何东西都能通过 http://cfml-app 访问到这个应用。要让外部世界进来,加一个 Ingress:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cfml-app
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: cfml-app
                port:
                  number: 80

对于要处理文件上传的 CFML 应用来说,那个 proxy-body-size 注解很关键——Nginx 默认上限是 1 MB,超过就会被静默拒绝。

第四步:配置与密钥

千万别把数据库密码烤进镜像。非敏感配置放进 ConfigMap,凭据放进 Secret:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cfml-config
data:
  DB_HOST: "postgres.internal"
  DB_NAME: "appdb"
---
apiVersion: v1
kind: Secret
metadata:
  name: cfml-secrets
type: Opaque
stringData:
  DB_PASSWORD: "change-me-in-real-life"
  LUCEE_ADMIN_PASSWORD: "also-change-me"

envFrom 接到 Deployment 的容器里:

yaml
envFrom:
  - configMapRef:
      name: cfml-config
  - secretRef:
      name: cfml-secrets

然后在 Application.cfc 里读取它们,让数据源由环境决定,而不是硬编码:

cfml
component {
    this.name = "myapp";
    this.datasources["appdb"] = {
        class:    "org.postgresql.Driver",
        connectionString: "jdbc:postgresql://#server.system.environment.DB_HOST#/#server.system.environment.DB_NAME#",
        username: server.system.environment.DB_USER,
        password: server.system.environment.DB_PASSWORD
    };
}

同一个镜像现在不改一行就能跑在 dev、staging 和 prod——区别只在注入的配置。这正是整套做法的意义所在。

第五步:健康探针

Kubernetes 需要知道一个 Pod 何时存活、何时准备好对外服务。CFML 应用预热引擎可能要花 20–60 秒,所以探针配错是导致部署陷入 crash 循环的头号原因

往 webroot 里加一个最简单的 health.cfm,只输出 OK,然后:

yaml
readinessProbe:
  httpGet:
    path: /health.cfm
    port: 8888
  initialDelaySeconds: 20
  periodSeconds: 5
livenessProbe:
  httpGet:
    path: /health.cfm
    port: 8888
  initialDelaySeconds: 40
  periodSeconds: 15
  • Readiness(就绪) 决定是否把流量发给这个 Pod。在 /health.cfm 应答之前,Pod 会被排除在 Service 之外。
  • Liveness(存活) 会重启已经卡死的 Pod。initialDelaySeconds 要给得宽裕些——杀得太早,Lucee 永远启动不完。

第六步:应用并验证

bash
kubectl apply -f k8s/
kubectl rollout status deployment/cfml-app
kubectl get pods -l app=cfml-app
kubectl logs -f deploy/cfml-app          # 观察 Lucee 启动

如果某个 Pod 卡住了,kubectl describe pod <name> 会列出事件——拉镜像失败、OOM 被杀、探针失败都会在这里浮现。

第七步:扩缩容与发布

水平扩容只需改副本数,或者让 Kubernetes 按 CPU 自动扩:

bash
kubectl scale deployment/cfml-app --replicas=4

kubectl autoscale deployment/cfml-app --cpu-percent=70 --min=2 --max=8

有一个 CFML 有状态应用独有的坑:会话亲和性(session affinity)。如果你的应用把会话保存在 JVM 内存里,那同一个用户的请求就必须回到同一个 Pod。要么在 Ingress 上开启粘性会话:

yaml
nginx.ingress.kubernetes.io/affinity: "cookie"

……要么更好的做法是把会话彻底移出 JVM——存到 Redis 或数据库里,这样任何 Pod 都能服务任何请求。无状态的 Pod 才能让扩缩容和滚动更新毫无痛感;粘性会话是权宜之计,不是归宿。

发布新版本就是换一个镜像 tag:

bash
kubectl set image deployment/cfml-app cfml-app=myregistry/cfml-app:1.1.0
kubectl rollout undo deployment/cfml-app   # 出问题就秒回滚

检查清单

每次把 CFML 应用搬上 Kubernetes,真正决定成败的就是这几件事:

  1. 固定镜像 tag——永远别用 latest
  2. MaxRAMPercentage 把 JVM 堆大小框进容器上限。
  3. 所有配置都注入——通过 ConfigMap 和 Secret,敏感信息一律不进镜像。
  4. 给探针足够的预热时间——Lucee 启动很慢。
  5. 让 Pod 无状态——外置会话,或接受粘性会话的局限。
  6. 设置 resource requests,让调度器能合理安置 Pod。

把这六点做对,CFML 应用就和任何现代工作负载一样——可扩展、能自愈,并且以最好的方式变得"平平无奇"。CFML 那点"遗留技术"的名声,跟语言本身关系不大,更多是它一直以来的部署方式所致。而部署这件事,Kubernetes 给修好了。