Helm3 Chart 多依赖微服务构建案例
文章目录一、创建1.1 创建父Chart1.2 创建子Chart二、调试2.1 调试父Chart2.1.1 创建命名空间`namespace`模版文件2.1.2 创建镜像仓库`secrets`模版文件2.1.2 创建工作负载`deployment`模版文件2.1.3 创建服务发现`service`模版文件2.2 调试子Chart2.1.1 创建配置映射`configmap`模版文件2.1.2 创建
文章目录
本文将从头构建一个前后端分离的微服务
chart
,前端
Vue
,后端
Spring Boot
。需要提前了解
Helm3
、
Chart
相关知识。
相关文章:
一、创建
1.1 创建父Chart
创建一个前端Vue
作为父Chart
。
helm create vue
当前的 Chart
结构如下:
vue/
├── charts
├── Chart.yaml
├── templates
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── NOTES.txt
│ ├── serviceaccount.yaml
│ ├── service.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
charts
:目录用于存放其他依赖的chart
(我们称之为子chart)。Chart.yaml
:文件包含chart
的说明。templates
:目录用于放置模板文件。是所有资源的位置,我们可以看到很多kubernetes
的资源文件都在这里存放。values.yaml
:文件对模板也很重要。该文件包含chart
默认值。这些值可能在用户在helm install
或helm upgrade
期间被覆盖。
templates
目录中的文件:
deployment.yaml
:创建Kubernetes
工作负载的基本文件。_helpers.tpl
:放置模板的地方,该文件中定义模版可以在整个chart
中重复使用。hpa.yaml
:HPA
配置文件。ingress.yaml
:负载均衡配置文件。NOTES.txt
:chart
的 “帮助文本”。这会在用户运行helm install
时显示给用户。serviceaccount.yaml
:serviceaccount
配置文件。service.yaml
:服务发现的配置文件。tests/test-connection.yaml
:用于测试chart
安装后的测试pod
。
1.2 创建子Chart
创建eureka
注册中心、oauth2
鉴权中心和一个MS
微服务,作为子Chart
。
helm create eureka \
&& helm create oauth2 \
&& helm create ms \
&& helm create ws
现在当前目录我们一共有五个待调试的Chart
。
vue ---父chart
eureka ---子chart
oauth2 ---子chart
ms ---子chart
ws ---子chart
二、调试
2.1 调试父Chart
首先删除Chart
中无用的文件,所有文件根据自己需求创建。
rm -rf vue/values.yaml vue/templates/*
2.1.1 创建命名空间namespace
模版文件
values.yaml
文件中定义如下(每个值后续都会用到,这里统一写完):
vim vue/values.yaml
nameSpace: microservice
secretsName: mysecret
imageCredentials:
registry: http://192.168.1.40
username: admin
password: Yidongjituan123
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/vue-saas
tag: 95e740c61bd7f9b4b3be62f819d00f90fe10ac24
service:
port: 40099
创建namespace
模版文件。
vim vue/templates/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.nameSpace }}
labels:
name: {{ .Values.nameSpace }}
2.1.2 创建镜像仓库secrets
模版文件
镜像拉的secrets
实质上是注册,用户名和密码的组合。在正在部署的应用程序中可能需要它们,但要创建它们需要多次运行 base64
。我们可以编写一个模板来组合Docker
配置文件,以用作Secret
的有效载体。这里是一个例子:
我们定义我们的帮助模板如下:
vim vue/templates/_helpers.tpl
{{- define "imagePullSecret"}}
{{- printf "{\"auths\": {\"%s\": {\"auth\": \"%s\"}}}" .Values.imageCredentials.registry (printf "%s:%s" .Values.imageCredentials.username .Values.imageCredentials.password | b64enc) | b64enc }}
{{- end}}
创建secrets
模版文件。
vim vue/templates/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.secretsName }}
namespace: {{ .Values.nameSpace }}
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: {{ template "imagePullSecret" . }}
2.1.2 创建工作负载deployment
模版文件
vim vue/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
restartPolicy: Always
imagePullSecrets:
- name: {{ .Values.secretsName }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
startupProbe:
httpGet:
path: /
port: 8080
failureThreshold: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
failureThreshold: 3
periodSeconds: 5
2.1.3 创建服务发现service
模版文件
vim vue/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
labels:
name: {{ .Chart.Name }}
spec:
type: NodePort
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
nodePort: {{ .Values.service.port }}
protocol: TCP
selector:
app: {{ .Chart.Name }}
使用helm install --dry-run demo vue/
可对模版进行渲染预览,如渲染不成功,根据提示修改即可。
渲染后的样式:
NAME: demo
LAST DEPLOYED: Thu Jul 23 16:06:25 2020
NAMESPACE: default
STATUS: pending-install
REVISION: 1
TEST SUITE: None
HOOKS:
MANIFEST:
---
# Source: vue/templates/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: microservice
labels:
name: microservice
---
# Source: vue/templates/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: mysecret
namespace: microservice
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: eyJhdXRocyI6IHsiaHR0cDovLzE5Mi4xNjguMS40MCI6IHsiYXV0aCI6ICJZV1J0YVc0NldXbGtiMjVuYW1sMGRXRnVNVEl6In19fQ==
---
# Source: vue/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vue
namespace: microservice
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: vue
template:
metadata:
labels:
app: vue
spec:
restartPolicy: Always
imagePullSecrets:
- name: mysecret
containers:
- name: vue
image: "192.168.1.40/xzzyy/vue-saas:95e740c61bd7f9b4b3be62f819d00f90fe10ac24"
imagePullPolicy: Always
ports:
- name: http
containerPort: 40099
protocol: TCP
startupProbe:
httpGet:
path: /
port: 8080
failureThreshold: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
failureThreshold: 3
periodSeconds: 5
2.2 调试子Chart
由于父Chart
已经创建完成namespace
、secrets
,子Chart
无需再次创建。删除无用文件。
rm -rf eureka/values.yaml eureka/templates/* \
&& rm -rf oauth2/values.yaml oauth2/templates/* \
&& rm -rf ms/values.yaml ms/templates/* \
&& rm -rf ws/values.yaml ws/templates/*
2.1.1 创建配置映射configmap
模版文件
使用configmap
统一管理Spring Boot
的配置文件。只需要创建一个configmap
,然后将四个配置文件以键值对的方式存入该configmap
,以便引用。
values.yaml
文件中定义如下(每个值后续都会用到,这里统一写完):
vim eureka/values.yaml
configmapName: demo-config
nameSpace: microservice
secretsName: mysecret
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/eureka
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40000
创建configmap
模版文件。
vim eureka/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.configmapName }}
namespace: {{ .Values.nameSpace }}
data:
{{ (.Files.Glob "config/*").AsConfig | indent 2 }}
使用引用静态文件的方式,直接引入配置文件内容,经过这种方式会创建四个键值对,键为配置名称,值为配置文件内容。不过需要提前将配置文件放入Chart
的根目录,如下:
eureka/
├── charts
├── Chart.yaml
├── config
│ ├── eureka.yml
│ ├── ms.yml
│ ├── oauth2.yml
│ └── ws.yml
├── templates
│ ├── configmap.yaml
│ ├── deployment.yaml
│ └── service.yaml
└── values.yaml
编写完之后可以通过helm install --dry-run confimap eureka/
验证模版正确性。
2.1.2 创建工作负载deployment
模版文件
- eureka
vim eureka/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
restartPolicy: Always
imagePullSecrets:
- name: {{ .Values.secretsName }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
startupProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
failureThreshold: 10
periodSeconds: 5
readinessProbe:
tcpSocket:
port: http
failureThreshold: 3
periodSeconds: 5
livenessProbe:
tcpSocket:
port: http
failureThreshold: 1
periodSeconds: 10
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: {{ .Values.configmapName }}
items:
- key: {{ .Chart.Name }}.yml
path: bootstrap.yml
- oauth2
values.yaml
内容。
vim oauth2/values.yaml
secretsName: mysecret
configmapName: demo-config
nameSpace: microservice
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/oauth2
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40001
deployment.yaml
内容
vim oauth2/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
restartPolicy: Always
imagePullSecrets:
- name: {{ .Values.secretsName }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
startupProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
failureThreshold: 10
periodSeconds: 5
readinessProbe:
tcpSocket:
port: http
failureThreshold: 3
periodSeconds: 5
livenessProbe:
tcpSocket:
port: http
failureThreshold: 1
periodSeconds: 10
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: {{ .Values.configmapName }}
items:
- key: {{ .Chart.Name }}.yml
path: bootstrap.yml
- ws
values.yaml
内容
vim ws/values.yaml
secretsName: mysecret
configmapName: demo-config
nameSpace: microservice
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/doctor-ws
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40002
deployment.yaml
内容
vim ws/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
restartPolicy: Always
imagePullSecrets:
- name: {{ .Values.secretsName }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
startupProbe:
httpGet:
path: /doc.html
port: http
initialDelaySeconds: 5
failureThreshold: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /doc.html
port: http
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /doc.html
port: http
failureThreshold: 3
periodSeconds: 5
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: {{ .Values.configmapName }}
items:
- key: {{ .Chart.Name }}.yml
path: bootstrap.yml
- ms
values.yaml
内容
vim ms/values.yaml
secretsName: mysecret
configmapName: demo-config
nameSpace: microservice
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/online-registration-ms
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40003
deployment.yaml
内容
vim ms/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
restartPolicy: Always
imagePullSecrets:
- name: {{ .Values.secretsName }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
startupProbe:
httpGet:
path: /doc.html
port: http
initialDelaySeconds: 5
failureThreshold: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /doc.html
port: http
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /doc.html
port: http
failureThreshold: 3
periodSeconds: 5
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 1
memory: 1Gi
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: {{ .Values.configmapName }}
items:
- key: {{ .Chart.Name }}.yml
path: bootstrap.yml
2.1.3 创建服务发现service
模版文件
- eureka
vim eureka/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
labels:
name: {{ .Chart.Name }}
spec:
type: NodePort
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
nodePort: {{ .Values.service.port }}
protocol: TCP
selector:
app: {{ .Chart.Name }}
- oauth2
vim oauth2/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
labels:
name: {{ .Chart.Name }}
spec:
type: NodePort
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
nodePort: {{ .Values.service.port }}
protocol: TCP
selector:
app: {{ .Chart.Name }}
- ws
vim ws/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
labels:
name: {{ .Chart.Name }}
spec:
type: NodePort
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
nodePort: {{ .Values.service.port }}
protocol: TCP
selector:
app: {{ .Chart.Name }}
- ms
vim ms/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.nameSpace }}
labels:
name: {{ .Chart.Name }}
spec:
type: NodePort
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
nodePort: {{ .Values.service.port }}
protocol: TCP
selector:
app: {{ .Chart.Name }}
通过helm install --dry-run demo xxxx/
验证每个Chart
的正确性。
2.3 联合调试父子Chart
保证每个Chart都可以--dry-run
渲染成功,然后就可以开始进行联调试
首先修改父Chart
的Chart.yaml
文件,添加依赖关系。
vim vue/Chart.yaml
追加如下内容,file://
是针对Chart.yaml
的相对路径,也可以写远程repo
库的地址,这里是本地调试没有用到。
dependencies:
- name: eureka
repository: file://../eureka/
version: 0.1.0
- name: oauth2
repository: file://../oauth2/
version: 0.1.0
- name: ws
repository: file://../ws/
version: 0.1.0
- name: ms
repository: file://../ms/
version: 0.1.0
然后使用dependency update
参数,构建chart
依赖。
[root@harbor ~]# helm dependency update vue/
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "myrepo" chart repository
Update Complete. ⎈Happy Helming!⎈
Saving 4 charts
Deleting outdated charts
最终父Chart
目录结构
vue/
├── Chart.lock
├── charts
│ ├── eureka-0.1.0.tgz
│ ├── ms-0.1.0.tgz
│ ├── oauth2-0.1.0.tgz
│ └── ws-0.1.0.tgz
├── Chart.yaml
├── templates
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ ├── namespace.yaml
│ ├── secrets.yaml
│ └── service.yaml
└── values.yaml
三、安装
直接使用helm
本地安装父Chart
即可
helm install demo vue/
查看安装的chart
[root@harbor ~]# helm list
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
demo default 1 2020-07-23 17:18:14.746382646 +0800 CST deployed vue-0.1.0 1.16.0
查看部署的deployment
[root@harbor ~]# kubectl get deployments.apps -n microservice
NAME READY UP-TO-DATE AVAILABLE AGE
eureka 1/1 1 1 43s
ms 1/1 1 1 43s
oauth2 1/1 1 1 43s
vue 1/1 1 1 43s
ws 1/1 1 1 43s
四、优化
上述步骤完成的多依赖构建,已经满足一键部署多个服务的需求了,但是通过实际操作下来,会发现一些问题:
- 每个子
chart
中都有相同的values
值。例如:secretsName
、configmapName
、nameSpace
。 - 有很多模版都是相同的。例如:
deployment.yaml
、service.yaml
。
如果有二三十个微服务后端的话,会有一大部分时间在做重复工作。所以我们就在想能不能复用这些常量和模版。
4.1 全局常量
可以修改父Chart
的values.yaml
,追加一个global
值,将所有子Chart
会用到的值设置为全局常量。
imageCredentials:
registry: http://192.168.1.40
username: admin
password: Yidongjituan123
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/vue-saas
tag: 95e740c61bd7f9b4b3be62f819d00f90fe10ac24
service:
port: 40099
global:
nameSpace: microservice
secretsName: mysecret
configmapName: demo-config
此值可供所有chart
使用,使用方式:.Values.global.nameSpace
、.Values.global.secretsName
等等。
这样我们就无需在每个子Chart
的values.yaml
中重复定义这些值了,只需修改一下模版中的使用方式即可。
4.2 共享常量
父Chart
的values.yaml
,增加以下内容,注意节点的名字必须是子chart
名。
imageCredentials:
registry: http://192.168.1.40
username: admin
password: Yidongjituan123
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/vue-saas
tag: 95e740c61bd7f9b4b3be62f819d00f90fe10ac24
service:
port: 40099
global:
nameSpace: microservice
secretsName: mysecret
configmapName: demo-config
eureka:
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/eureka
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40000
oauth2:
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/oauth2
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40001
ws:
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/doctor-ws
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40002
ms:
replicaCount: 1
image:
repository: 192.168.1.40/xzzyy/online-registration-ms
tag: bff976dda6786a2b593ccf5a76bd2e1cf670e615
service:
port: 40003
在eureka
的模板里就可以通过{{ .Values.replicaCount }}
来引用。当Helm
发现节点名是子chart
名时,它会自动引用这个常量到子chart
的values.yaml
中。
因此,在oauth2
中,你也可以通过{{ .Values.image.repository }}
来访问这个常量。
如果出现父Chart
与子Chart
中都定义了相同的values
值,那么helm
会以父Chart
为准,覆盖子Chart
中相同的值。
4.3 if-else
如何复用我们的模版呢,这就需要在编写模版文件时候加入复杂的判断条件,来完成一个模版的多个chart
复用。
if-else
语法
{{if PIPELINE}}
# Do something
{{else if OTHER PIPELINE}}
# Do something else
{{else}}
# Default case
{{end}}
- 一个布尔型的假
- 一个数字零
- 一个空的字符串
- 一个 nil(空或 null)
- 一个空的集合(map,slice,tuple,dict,array)
在其他情况下, 条件值为 true 此管道被执行。
if-else
使用案例
values.yaml
内容:
favorite:
drink: coffee
food: pizza
configmap.yaml
内容:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{.Release.Name }}-configmap
data:
myvalue: "Hello World"
drink: {{.Values.favorite.drink }}
food: {{.Values.favorite.food }}
{{- if eq .Values.favorite.drink "coffee" }}
mug: true
{{- end}}
渲染后的模版:
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: clunky-cat-configmap
data:
myvalue: "Hello World"
drink: "coffee"
food: "PIZZA"
mug: true
更多推荐
所有评论(0)