从零搭建一个本地ollama

先说一下整个架构

本次用的云服务器是火山云的内部开发机,它的内网IP固定但是外网IP在不断变化,可见这里字节使用了“动态NAT”或“负载均衡器出口IP池”技术。

整个的架构是:“用一个ingress来作为对外的通道,让调用方可以通过浏览器域名的方式来访问后端的service,然后service绑定一个deployment。这个deployment对应的backend pod会调用ollama的pod,询问对应的问题”。

这里的k8s用的是k3s。

再补一点名次解释:

  1. Ollama是一个大模型的运行框架。它可以把复杂的模型参数、配置文件打包成一个简单文件、然后开一个REST API的接口(也就是下面的11434接口),可以通过requests.post方法来请求。
  2. Llama3 8B 是一个开源大语言模型本体,后面我们会使用70B的大模型。

搭建过程

我们先搞一个ollama,ollama-deployment.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ollama-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: docker.m.daocloud.io/ollama/ollama:latest # 如果这里很慢的话,可以用国内镜像 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/ollama/ollama:latest-linuxarm64
ports:
- containerPort: 11434
resources:
requests:
cpu: "4" # 给它4个核起步
memory: "8Gi" # 给8G内存
limits:
cpu: "16" # 最高允许用到16核,如果还是回话很慢,一秒钟一个字的话就适量加大这里。
memory: "32Gi"
---
apiVersion: v1
kind: Service
metadata:
name: ollama-svc
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434 # 这里暴露11434接口

然后进入容器里下载模型,因为我这里没有GPU,就用一个轻量的Llama3 8B,它搭配CPU的效果不错:

1
2
3
4
5
# 找到 pod 名字
export OLLAMA_POD=$(kubectl get pods -l app=ollama -o jsonpath='{.items[0].metadata.name}')

# 让他开始下载模型(字节内网拉模型通常很快)
kubectl exec -it $OLLAMA_POD -- ollama run llama3 "你好,请自我介绍" #这里它应该会给你返回内容了

整个过程是这样的:

  1. Deployment启动了Ollama引擎。
  2. 执行了ollama run llama3,引擎就去下载Llama3 8B 的权重文件。
  3. 引擎将权重加载进内存,然后在11434接口等指令。

先写一个backend.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from flask import Flask, jsonify
import requests

app = Flask(__name__)


@app.route('/aigpt')
def aigpt():
# 直接请求集群内的 Service 名字
response = requests.post(
"http://ollama-svc:11434/api/generate",
json={
"model": "llama3",
"prompt": "Analyze the sentiment of: '请告诉我梅西一共有几个冠军?'", # 这里把问题写死了。
"stream": False
}
)
return response.json()

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

Dockerfile如下:

1
2
3
4
5
FROM docker.m.daocloud.io/library/python:3.9-slim
RUN pip install flask requests
RUN pip install --upgrade pip
COPY backend.py /app.py
CMD ["python", "/app.py"] # 容器就是直接启动这个backend.py

有了Dockerfile之后,我们来打包一个镜像ai-backend:docker build -t ai-backend:v4 . ,然后通过docker images |grep v4里看本地的确有了docker images库里的确有了ai-backend:v4这个镜像,但是要注意这个镜像还不在k3s里,所以还要执行docker save ai-backend:v4 |sudo k3s ctr images import - , 这样k3s的镜像库才也有了这个ai-backend:v4的镜像,可以用k3s ctr images list|grep ai-backend这个命令来确认。

backend-deployment.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-backend
spec:
replicas: 3
selector:
matchLabels:
app: ai-backend
template:
metadata:
labels:
app: ai-backend
spec:
containers:
- name: backend
image: ai-backend:v4
imagePullPolicy: Never # 这里说明只用本地镜像, IfNotPresent是“优先用本地,没有就去docker hub拉”,Always是“不管本地有没有,每次都去网上对比版本”
ports:
- containerPort: 5000 #py文件里写了5000,这里也让pod暴露5000这个接口
resources:
requests:
cpu: "500m" # 因为它本质的工作就是接受请求,转发给oolama-svc所以不用很高的配置。
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"


---
apiVersion: v1
kind: Service
metadata:
name: ai-backend-svc
spec:
selector:
app: ai-backend
ports:
- protocol: TCP
port: 80
targetPort: 5000 # 后端Pod的5000对应这个service的80

然后可以通过kubectl apply -f backend-deployment.yaml 或者 kubectl rollout restart deployment ai-backend来滚动发布。

后端搞定了之后,ingress.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ai-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ai-backend-ingress
spec:
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ai-backend-svc # 这里看出来
port:
number: 80

把这些都启动了之后,检查一下状态:

上面说了,这个开发机是没有一个固定的外网IP,不过本身就是测试,我这里就在我的macos上手动将开发机的ip写死到/etc/hosts里,对应的域名先暂定ai.demo.com。最后在浏览器测试一下:

最后再总结一下整个流程:

  1. 浏览器通过写死域名和ip发送请求。
  2. 通过ai.demo.com域名,流量请求到开发机的80接口。
  3. k3s内部的ingress接客,看到路径是/aigpt,就把请求转发给ai-backend-svc这个service。
  4. 后端backend Pod接收请求,执行对应的python代码,发起一个requests请求ollama svc,这俩pod是互通的。
  5. 返回结果到浏览器。整个过程闭环在macos和开发机里,哪怕开发机没有公网,一样请求OK,而且现在调用是不花token的。
  6. 做pv/pvc持久化,重启后就不用再去公网上下载模型了,避免了pod先活了,但是还要下载模型,其实服务没准备好,但是流量先进来的尴尬。

排错过程

如果发现pod起不来,可以先通过describe命令粗略看一下原因,如果看不出来啥,可以再用kubectl logs pod名称 --previous ,来查看具体的一个pods的日志,能看出来为啥“镜像顺利拉到手,但是程序为啥启动不起来”。

使用pvc做持久化

虽然上面的效果达到了目标,但是有一个致命的问题,就是ai模型动辄几十GB,容器的镜像拉取和启动太慢了,怎么缓解?

所以这里要解耦一下,镜像只包括运行环境ollama,而巨大的模型权重用过pvc挂载,这样镜像只有只百MB,可快速分发,POD重启的时候直接挂在一下卷,实现秒级就绪。

首先先创建一个ollama-pvc.yaml,做存储声明

1
2
3
4
5
6
7
8
9
10
11
12
13
# ollama-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ollama-storage-pvc
spec:
# K3s 默认提供的存储类,会自动在宿主机 /var/lib/rancher/k3s/storage 下创建目录
storageClassName: local-path
accessModes:
- ReadWriteOnce # 单节点读写,适合这种模型推理场景
resources:
requests:
storage: 50Gi # 给够 50G,以后下个 70B 模型也放得下,

这里要注意!storage: 50Gi 这个数字将来只能往上加,不能往下减(会报错Forbidden: field can not be less than status.capacity),这么设计的原因是往下减的话,会破坏文件系统,如果非要缩,只能删了重建,里面之前的东西都白下载了。

然后修改ollama-deployment.yaml让它挂载到这个“pvc”上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# ollama-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: docker.m.daocloud.io/ollama/ollama:latest
ports:
- containerPort: 11434
volumeMounts:
- name: ollama-models
mountPath: /root/.ollama # 这里是容器里的路径,不是宿主机的路径
resources:
requests:
cpu: "4"
memory: "8Gi"
limits:
cpu: "16"
memory: "32Gi"
volumes:
- name: ollama-models
persistentVolumeClaim:
claimName: ollama-storage-pvc # 这个名称就是刚才pvc里写的名称
---
apiVersion: v1
kind: Service
metadata:
name: ollama-svc
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434

然后重新部署一下,先用-it的方式来测试一个问题:

同样,此时用浏览器发起请求也是报错:

这里说明一下PVC → PV → 宿主机路径的关系链:

1
2
3
4
5
6
7
容器内路径 (/root/.ollama) 

PVC (ollama-storage-pvc)

PV (pvc-163ae8fb-4051-44f6-a7cc-3e1b4d3b6a9c)

宿主机实际路径 (由StorageClass决定,如:/var/lib/rancher/k3s/storage)

如图,这里可以看到的Path就是宿主机上的路径:

PVC状态说明:

  • Pending:等待中,存储卷还未创建(有问题)
  • Bound:已绑定,存储卷已成功创建并绑定到PVC(正常状态)
  • Released:已释放,Pod已删除但PV还未清理
  • Failed:失败,存储卷创建失败

现在你如果删除llama的pod,重建一个就会发现很快这个pod可以使用并且正确返回内容了。

改用70B的大模型

如果觉得8B模型不咋地,想升级用70B的大模型,就用这个:

1
2
3
4
5
# 找到 pod 名
export OLLAMA_POD=$(kubectl get pods -l app=ollama -o jsonpath='{.items[0].metadata.name}')

# 开始拉取并运行(这个命令会卡住下载一段时间)
kubectl exec -it $OLLAMA_POD -- ollama run llama3:70b

用了70B对8B是有了质的飞跃,而且即使想搞RAG,70B也比8B的效果更好,但是大小更大,要39G。但是它回答的时候需要先把这39G都加载到内存里,所以这里注意一下backend.py调用ollama的post请求要加上timeout=300,这样不至于模型还没算完,链接就断了。

39G虽然大,但是PVC是支持断点续传的,即使中间断网了,下一次启动会去看临时文件的:

感谢你请我喝咖啡~

Welcome to my other publishing channels