<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ray]]></title><description><![CDATA[Hi, I'm ray, nice to me you ! ]]></description><link>https://raymq.com</link><generator>Yohaku (https://github.com/Innei/Yohaku)</generator><lastBuildDate>Wed, 28 Oct 2026 13:03:59 GMT</lastBuildDate><atom:link href="https://raymq.com/feed" rel="self" type="application/rss+xml"/><pubDate>Wed, 28 Oct 2026 13:03:59 GMT</pubDate><language><![CDATA[zh-CN]]></language><item><title><![CDATA[导读]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/introduction">https://raymq.com/posts/Kubernetes/introduction</a></blockquote><div><p>本章为 Kubernetes 专栏的导读，用于讲解当前专栏中各个文章的一些简单内容，建议按照导读依次进行学习，希望你能有所收获</p><p>本专栏借鉴了 <a href="https://docs.whuanle.cn/zh/kubernetes">痴者工良</a> 的 Kubernetes 专栏内容，与其说借鉴，不如说照着专栏的内容自己进行了一些改编以及加入了自己的一点理解吧。当然，我还是希望大家能去看一下 <a href="https://docs.whuanle.cn/zh/kubernetes">痴者工良</a> 的文章，或许你会有别的收获</p><h1 id="">第一章：集群搭建</h1><ul><li><a href="https://raymq.com/posts/Kubernetes/master-slave">（一）master 和 slave 集群环境配置</a>：准备节点环境，并搭建一个完整的 Kubernetes 集群</li></ul><h1 id="pod-">第二章：Pod 与工作负载</h1><ul><li><a href="https://raymq.com/posts/Kubernetes/pod">（二）Pod</a>：了解 Kubernetes 中最基本的运行单元</li><li><a href="https://raymq.com/posts/Kubernetes/deployment">（三）Deployment</a>：使用 Deployment 声明和管理应用</li><li><a href="https://raymq.com/posts/Kubernetes/replicaset">（四）ReplicaSet</a>：理解 Pod 副本的维护方式</li><li><a href="https://raymq.com/posts/Kubernetes/pod-port-mapping">（五）Pod 端口映射</a>：了解容器端口和宿主机端口的映射</li><li><a href="https://raymq.com/posts/Kubernetes/pod-upgrade-rollback">（六）Pod 升级、回滚</a>：学习应用的滚动更新与版本回滚</li><li><a href="https://raymq.com/posts/Kubernetes/pod-scaling">（七）Pod 缩放</a>：手动或自动调整 Pod 副本数量</li></ul><h1 id="">第三章：标签、调度与任务</h1><ul><li><a href="https://raymq.com/posts/Kubernetes/label">（八）标签</a>：使用标签、选择器和注解组织资源</li><li><a href="https://raymq.com/posts/Kubernetes/pod-scheduling">（九）Pod 调度</a>：了解亲和性、污点和容忍度等调度机制</li><li><a href="https://raymq.com/posts/Kubernetes/jobs-cronJobs">（十）Jobs、CronJobs</a>：运行一次性任务和定时任务</li></ul><h1 id="">第四章：网络与服务访问</h1><ul><li><a href="https://raymq.com/posts/Kubernetes/kubernetes-network-model">（十一）Kubernetes 网络模型</a>：理解 Pod 网络、Service 和 kube-proxy</li><li><a href="https://raymq.com/posts/Kubernetes/endpointSlice">（十二）EndpointSlice</a>：了解 Service 如何发现和维护后端 Pod</li><li><a href="https://raymq.com/posts/Kubernetes/Ingress">（十三）Ingress</a>：通过 Ingress 对外暴露 HTTP 和 HTTPS 服务</li><li><a href="https://raymq.com/posts/Kubernetes/gateway-api">（十四）Gateway API</a>：使用 Gateway API 配置更灵活的流量入口</li><li><a href="https://raymq.com/posts/Kubernetes/service-discovery">（十五）服务发现</a>：通过 DNS、环境变量等方式发现 Service</li></ul><h1 id="">第五章：配置与存储</h1><ul><li><a href="https://raymq.com/posts/Kubernetes/volume">（十六）卷</a>：了解容器文件系统、临时卷和持久卷</li><li><a href="https://raymq.com/posts/Kubernetes/configmap-secret">（十七）ConfigMap 和 Secret</a>：管理应用配置和敏感信息</li><li><a href="https://raymq.com/posts/Kubernetes/nfs-volume">（十八）NFS 卷</a>：在多个 Pod 之间共享 NFS 存储</li><li><a href="https://raymq.com/posts/Kubernetes/pv-pvc-storageclass">（十九）PV、PVC、StorageClass</a>：理解 Kubernetes 的持久化存储体系</li></ul><h1 id="">后话</h1><p>从第（一）章 ～ 第（十九）章，从  2026 年 8 月 13 日 ～ 2026 年 9 月 14 日，我们终于将 Kubernetes 的基础学习完了</p><p>尽管这些文章的质量层次不齐，尽管作者对于文章的理解还有所欠缺，但经过这段时间的学习，我们至少从 0 -&gt; 1 ，至少我们不会因为听到别人谈论 nodeAffinity、Tolerations、IngressClass 等与 Kubernetes 有关的内容就心生忌惮，两眼空空，至少我们也能说一句：&quot;喔 ～ ，是这个啊&quot;</p><p>尽管在学习的过程中，我们会经历焦虑、迷茫、没有耐心、会认为看的内容没有作用、会因为自己看不懂而想要放弃...</p><p>但记住，只要看下来，就是你的，看不懂没有关系，至少你有印象了...我认为，这就足够了</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/introduction#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/introduction</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/introduction</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Mon, 14 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[（十九）PV、PVC、StorageClass]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/5ko8n2cxbputbt682b.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/4inlx5228jvgfn9wyu.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/q7gvkha2blyfgzjxt5.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/n0k7jpiql6b1dej80y.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/pv-pvc-storageclass">https://raymq.com/posts/Kubernetes/pv-pvc-storageclass</a></blockquote><div><p>前面学习 NFS 的时候，Pod 需要直接写 NFS Server 地址和共享目录</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">nfs:
  server: 192.168.64.2
  path: /srv/k8s-share
</code></pre><p>这种方式可以正常使用，但 Pod 自己知道底层使用的是 NFS，也知道 NFS Server 在哪里。如果以后服务器地址、共享目录或者存储类型发生变化，就需要修改 Pod 的 YAML</p><p>因此 Kubernetes 提供了 PV 和 PVC，把应用和具体存储隔开</p><ul><li>PV：描述真正的存储在哪里、容量多大、支持什么访问方式</li><li>PVC：描述应用需要一块什么样的存储</li><li>Pod：只使用 PVC，不需要知道后面是 NFS、云硬盘还是其他存储</li></ul><p>整体关系可以理解成：</p><ul><li>Pod -&gt; PVC -&gt; PV -&gt; NFS</li></ul><h1 id="pv--pvc">PV 和 PVC</h1><p><strong>PV 全称是 PersistentVolume，可以理解成 Kubernetes 中的一块持久化存储资源，是已经存在的、真实的存储资源，不需要额外申请</strong>，原来是直接写进 Pod，现在可以交给 PV 管理。比如前面使用的：</p><pre class="language-text lang-text"><code class="language-text lang-text">192.168.64.2:/srv/k8s-share
</code></pre>
<p><strong>PVC 全称是 PersistentVolumeClaim，可以理解成应用对存储的申请，需要申请</strong>，比如：</p><ul><li>我需要一块至少 2Gi，并且支持 ReadWriteMany，类型是 manual-nfs，的存储</li></ul><p>Kubernetes 会根据 PVC 的要求寻找合适的 PV，然后把它们绑定起来</p><pre class="language-text lang-text"><code class="language-text lang-text">PV
提供 5Gi、RWX、manual-nfs
          ↑
          │ 匹配
          ↓
PVC
申请 2Gi、RWX、manual-nfs
</code></pre>
<p>PV 是集群级资源，不属于某个 Namespace；而 PVC 属于 Namespace，并且 Pod 只能引用和自己位于同一个 Namespace 中的 PVC</p><h1 id="-nfs--pv--pvc">使用 NFS 创建 PV 和 PVC</h1><p>这里继续使用上一章已经搭建好的 NFS</p><pre class="language-text lang-text"><code class="language-text lang-text">NFS Server：192.168.64.2
共享目录：/srv/k8s-share
</code></pre>
<p>本章要将原有的路径从：</p><ul><li>Pod -&gt; NFS 
修改改成：</li><li>Pod -&gt; PVC -&gt; PV -&gt; NFS</li></ul><h2 id="-pv">创建 PV</h2><p>先创建一个 PV，把 NFS Server 和共享目录交给 PV 管理</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; nfs-pv.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv # PV 在集群中的 名称
spec:
  capacity:
    storage: 5Gi # PV 对 Kubernetes 声明自己提供 5Gi 存储

  volumeMode: Filesystem # 卷以 文件系统 方式挂载

  accessModes:
  - ReadWriteMany # 允许多个节点以读写方式使用

  persistentVolumeReclaimPolicy: Retain # PVC 删除以后保留这块存储的数据

  storageClassName: manual-nfs # 给这块 PV 标记一个存储类别

  mountOptions:
  - hard # NFS 请求失败时无限重试，直到服务器恢复
  - nfsvers=4.1

  nfs:
    server: 192.168.64.2 # NFS Server 主机 IP
    path: /srv/k8s-share # 共享目录
EOF

kubectl apply -f nfs-pv.yaml

kubectl get pv
</code></pre><p><img height="336" src="https://raymq.com/api/v3/objects/image/5ko8n2cxbputbt682b.png" width="1350"/>
这里需要了解的配置有：</p><ul><li><code>capacity.storage: 5Gi</code>：表示这个 PV 对 Kubernetes 声明自己提供 5Gi 存储</li><li><code>accessModes: ReadWriteMany</code>：允许多个节点以读写方式使用</li><li><code>persistentVolumeReclaimPolicy: Retain</code>：PVC 删除以后保留这块存储的数据</li><li><code>storageClassName: manual-nfs</code>：给这块 PV 标记一个存储类别</li><li><code>nfs.server</code> 和 <code>nfs.path</code>：真正的存储位置</li></ul><p>也就是说，这个 PV 可以理解成：</p><ul><li><strong>一个叫 nfs-pv 的 PV 声明了一块 5Gi、支持多节点读写（ReadWriteMany）、名为 manual-nfs 的存储资源，它的真实数据挂载在 NFS 服务器的 192.168.64.2:/srv/k8s-share 目录上</strong></li></ul><p>这里的 5Gi 主要提供给 Kubernetes 进行存储匹配，普通 NFS 共享目录不会因为这里写了 5Gi ，就真的只能存 5Gi。如果需要严格限制 NFS 目录容量，还需要在后端存储中另外配置配额</p><h2 id="-pvc">创建 PVC</h2><p>现在 PV 已经存在，但 Pod 还不会直接使用 PV，接下来创建一个 PVC，表示应用需要什么样的存储</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; nfs-pvc.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: web-data # PVC 在集群中的 名称
spec:
  accessModes:
  - ReadWriteMany  # 允许多个节点以读写方式使用

  storageClassName: manual-nfs # 指定要匹配的存储类别，必须与目标 PV 相同

  resources:
    requests:
      storage: 2Gi # 声明需要 2Gi 存储
EOF

kubectl apply -f nfs-pvc.yaml

kubectl get pvc

kubectl get pv
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/4inlx5228jvgfn9wyu.png"/></p><p>这个 PVC 表示的是：</p><ul><li><strong>一个叫 web-data 的 PVC 向 Kubernetes 申请一块至少 2Gi、支持多节点读写（ReadWriteMany）、类型为 manual-nfs 的存储资源</strong></li></ul><p>所以 Kubernetes 可以把它们绑定：</p><pre class="language-text lang-text"><code class="language-text lang-text">PVC web-data
     ↓
PV nfs-pv
</code></pre><p>查看时一般会看到 PVC 和 PV 都进入 <code>Bound</code></p><p>这里需要注意，PVC 虽然只申请了 2Gi，PV 提供了 5Gi，但绑定以后整个 PV 都会交给这个 PVC，多出来的 3Gi 不会再分给另一个 PVC。也就是说，一个 PV 与 一个 PVC，它们是一对一绑定的</p><h1 id="pod--pvc">Pod 怎么使用 PVC</h1><p>现在已经完成 PVC（web_data）-&gt; PV（nfs-pv）-&gt; NFS Server 的连接了，接下来创建一个 Pod 与他们连接</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; pvc-pod.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: pvc-demo
spec:
  containers:
  - name: nginx
    image: nginx:latest

    volumeMounts:
    - name: web-data # 引用下面 volumes 中名为 web-data 的卷
      mountPath: /usr/share/nginx/html # 挂载到容器内这个路径

  volumes:
  - name: web-data # 卷名称，提供给 volumeMounts 引用
    persistentVolumeClaim:
      claimName: web-data #  # 引用已存在的 PVC
EOF

kubectl apply -f pvc-pod.yaml

kubectl get pod pvc-demo -o wide

kubectl exec pvc-demo -- sh -c &#x27;echo &quot;hello pvc&quot; &gt; /usr/share/nginx/html/index.html&#x27;

kubectl exec pvc-demo -- cat /usr/share/nginx/html/index.html
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/q7gvkha2blyfgzjxt5.png"/>
这里和前面的 NFS Pod 最大的变化就是 volumes，以前需要明确写出 NFS Server 的 IP 以及 共享目录：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumes:
- name: web-data
  nfs:
    server: 192.168.64.2
    path: /srv/k8s-share
</code></pre><p>现在写成：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumes:
- name: web-data
  persistentVolumeClaim:
    claimName: web-data
</code></pre><p>Pod 只需要知道我要使用 web-data 这个 PVC 即可，后面的关系由 Kubernetes 自行处理：</p><pre class="language-text lang-text"><code class="language-text lang-text">Pod
 ↓
PVC：web-data
 ↓
PV：nfs-pv
 ↓
192.168.64.2:/srv/k8s-share
</code></pre><p>所以应用的 Pod 配置中已经没有 NFS Server 地址，也没有 NFS 共享目录。以后底层存储发生变化，只要 PVC 仍然能够获得符合要求的存储，Pod 这一层就不需要直接关心具体存储实现</p><h1 id="">静态制备和动态制备</h1><p>前面的实验是我们自己创建：</p><ul><li>PV -&gt; PVC</li></ul><p>这种方式叫静态制备，Kubernetes 还支持动态制备，两种方式主要区别就在于 PV 是谁创建的</p><h2 id="">静态制备</h2><p>静态制备就是管理员提前创建 PV</p><pre class="language-text lang-text"><code class="language-text lang-text">管理员创建 PV
      ↓
用户创建 PVC
      ↓
Kubernetes 匹配
      ↓
PV 和 PVC 绑定
</code></pre>
<p>我们前面的 NFS 实验就是静态制备，因为使用的命令是：</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl apply -f nfs-pv.yaml
</code></pre>
<p>我们以手动创建 PV 的方式很适合用来学习 PV 和 PVC，因为可以直接看到实际 存储、PV、PVC 和 Pod 之间的关系</p><h2 id="">动态制备</h2><p>如果每一个 PVC 都需要管理员提前创建 PV，实际使用起来会很麻烦，所以 Kubernetes 还可以配合 StorageClass 和 存储驱动 动态创建 PV，整体过程变成：</p><pre class="language-text lang-text"><code class="language-text lang-text">创建 PVC
   ↓
StorageClass
   ↓
存储驱动
   ↓
创建真实存储
   ↓
自动创建 PV
   ↓
与 PVC 绑定
</code></pre><p>应用只需要创建 PVC，PV 和后面的存储由集群自动准备，这种方式在现代 Kubernetes 集群和云环境中比较常见</p><h1 id="storageclass">StorageClass</h1><p>StorageClass 是 Kubernetes 中用于定义动态存储供给策略的资源对象，它算是一种规则，用于表示 哪种存储、怎么创建、怎么回收，例如一个集群中可能有：</p><ul><li>fast -&gt; SSD 云硬盘</li><li>normal -&gt; 普通云硬盘</li><li>nfs -&gt; NFS 存储</li></ul><p>我们可以看看当前集群的：</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl get storageclass

kubectl get sc
</code></pre><p>类似的输出比如：</p><pre class=""><code class="">root@master:~# get sc
NAME                      PROVISIONER                    RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path                rancher.io/local-path          Delete          WaitForFirstConsumer   false                  509d
local-storage (default)   kubernetes.io/no-provisioner   Delete          WaitForFirstConsumer   false                  509d
nfs-csi                   nfs.csi.k8s.io                 Retain          Immediate              true                   509d

root@master:~# get storageclass
NAME                      PROVISIONER                    RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path                rancher.io/local-path          Delete          WaitForFirstConsumer   false                  509d
local-storage (default)   kubernetes.io/no-provisioner   Delete          WaitForFirstConsumer   false                  509d
nfs-csi                   nfs.csi.k8s.io                 Retain          Immediate              true                   509d
</code></pre><p>可以看到，在输出中会存在这几个字段，也就是 StorageClass 的常见配置：</p><ul><li><code>provisioner</code>：指定由哪个存储驱动负责创建 PV，这是最核心的字段</li><li><code>parameters</code>：传递给存储驱动的参数，不同驱动支持的参数不同</li><li><code>volumeBindingMode</code>：决定 PVC 什么时候与 PV 绑定，其中常见的有两种
<ul><li><code>Immediate</code>：PVC 创建后立即进行 PV 创建或绑定</li><li><code>WaitForFirstConsumer</code>：等到真正有 Pod 使用这个 PVC 时，再结合 Pod 的调度位置创建或绑定 PV</li></ul></li><li><code>reclaimPolicy</code>：决定动态创建的 PV 在 PVC 删除后如何处理
<ul><li><code>Delete</code>：PVC 删除后，对应的动态 PV 和后端存储通常也会被删除</li><li><code>Retain</code>：PVC 删除后保留 PV 和底层数据，需要管理员后续处理</li></ul></li><li><code>allowVolumeExpansion</code>：决定是否允许通过修改 PVC 容量进行扩容</li></ul><p>一个 StorageClass 的结构大致如下，当然不同配置结构并不一致：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">apiVersion: storage.k8s.io/v1
kind: StorageClass

metadata:
  name: local-storage
  annotations:
    storageclass.kubernetes.io/is-default-class: &quot;true&quot;  # 设置为默认 StorageClass

provisioner: kubernetes.io/no-provisioner   # 不动态创建 PV，需要提前准备 Local PV
reclaimPolicy: Delete                       # PV 的回收策略
volumeBindingMode: WaitForFirstConsumer     # 等 Pod 使用 PVC 时再选择并绑定合适的 PV
</code></pre><p>这里的 <code>provisioner: kubernetes.io/no-provisioner</code> 表示这个 StorageClass 没有动态存储供给程序。也就是说，创建 PVC 时不会自动生成 PV，需要提前准备好符合条件的 Local PV，然后由 Kubernetes 完成匹配和绑定</p><pre class="language-text lang-text"><code class="language-text lang-text">提前准备 Local PV
        ↓
PVC 指定 local-storage
        ↓
Pod 使用 PVC
        ↓
Kubernetes 根据 Pod 的调度位置选择合适的 Local PV
        ↓
PVC 与 PV 绑定
</code></pre><p>而 <code>volumeBindingMode: WaitForFirstConsumer</code> 则表示不会在 PVC 创建后立即完成绑定，而是等到真正有 Pod 使用这个 PVC时，再结合 Pod 可以调度到哪个节点来选择 PV。这种方式尤其适合 Local PV，因为本地存储通常属于某一台具体节点</p><h2 id="-storageclass--pvc">使用 StorageClass 创建 PVC</h2><p>现在假设集群中已经存在：</p><pre class=""><code class="">StorageClass：fast
</code></pre>
<p>而且对应的存储驱动已经正常运行，这时候应用就不需要自己创建 PV，只需要创建 PVC</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; database-pvc.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: database-data
spec:
  storageClassName: fast

  accessModes:
  - ReadWriteOnce

  resources:
    requests:
      storage: 20Gi
EOF

kubectl apply -f database-pvc.yaml

kubectl get pvc database-data

kubectl get pv
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/n0k7jpiql6b1dej80y.png"/>
这个 PVC 的意思就是：</p><ul><li>我需要一块 20Gi、ReadWriteOnce、fast 类型 的存储</li></ul><p>接下来 Kubernetes 会根据：</p><pre class=""><code class="">storageClassName: fast
</code></pre><p>找到 fast StorageClass，然后：</p><pre class=""><code class="">PVC database-data
       ↓
StorageClass fast
       ↓
对应的 Provisioner
       ↓
创建真正的存储
       ↓
自动创建 PV
       ↓
绑定 database-data
</code></pre>
<p>所以这里和前面的 NFS 实验最大的区别就是：前面需要手动创建 PV、再创建 PVC；现在可以直接创建 PVC，然后自动得到 PV。这就是 StorageClass 在动态制备中的作用</p><h2 id="-storageclass">默认 StorageClass</h2><p>如果 PVC 没有写 <code>storageClassName:</code>，那么一个 StorageClass 就会被设置成默认 StorageClass 。通常会使用集群中的默认 StorageClass进行动态制备，比如</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">spec:
  accessModes:
  - ReadWriteOnce

  resources:
    requests:
      storage: 10Gi
</code></pre>
<p>这里还需要讲一下，不写 storageClassName 和 写了 storageClassName: &quot;&quot; 的含义是不一样的：</p><ul><li>不写 storageClassName 时，集群存在默认 StorageClass 的情况下，PVC 通常会使用默认 StorageClass</li><li>写成空字符串时，则表示这个 PVC不使用 StorageClass，通常用于绑定没有 StorageClass 的静态 PV</li></ul><h1 id="pv--pvc-">PV 和 PVC 扩展</h1><p>这里额外讲一下 PV 和 PVC 中几个经常遇到的配置</p><h2 id="">访问模式</h2><p>PV 和 PVC 中都会看到 accessModes 字段，常见有四种：</p><ul><li>ReadWriteOnce（RWO）：一个节点可以 以读写方式挂载</li><li>ReadOnlyMany（ROX）：多个节点可以 以只读方式挂载</li><li>ReadWriteMany（RWX）：多个节点可以 以读写方式挂载</li><li>ReadWriteOncePod（RWOP）：只允许集群中的一个 Pod 以读写方式挂载</li></ul><p>这里需要特别注意 ReadWriteOnce，<strong>它表示 一个节点读写，而不是 只能有一个 Pod 使用</strong>。同一个节点上的多个 Pod，在存储实现允许的情况下仍然可能共同使用这个卷</p><p>访问模式主要描述存储支持怎样的挂载方式，同时也是 PV 和 PVC 匹配时的重要条件。换句话说 访问模式说的是这个存储能被怎么挂载 —— 是只能挂到一个节点，还是能同时挂到多个节点。PVC 申请存储时，Kubernetes 会拿这个条件去筛 PV，对不上就绑不了</p><p>它也不能代替 Linux 文件权限或者应用自己的并发控制。换句话说就是它只是一种访问方式，如果你用 root 用户在 Pod A 写了一个 600 权限的文件、而在 Pod B 用普通用户去读，那 Pod B 就是读不了；以及 Pod A 与 Pod B 都能同时写文件 C 时，那这个文件 C 就会变得混乱不安全，这时候就需要进行并发控制加锁之类的操作...</p><h2 id="pv-">PV 状态</h2><p>查看：</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl get pv

kubectl get pvc -A

kubectl describe pv nfs-pv
</code></pre><p>PV 常见状态有：</p><ul><li><code>Available</code>：当前还没有绑定 PVC</li><li><code>Bound</code>：已经和 PVC 绑定</li><li><code>Released</code>：原来的 PVC 已删除，但 PV 还没有完成后续处理</li><li><code>Failed</code>：自动回收过程中发生错误</li></ul><p>我们前面的过程大概就是：</p><ul><li>创建 PV -&gt; Available</li><li>创建 PVC -&gt; Bound</li></ul><h2 id="">回收策略</h2><p>PV 中的 <code>persistentVolumeReclaimPolicy:</code> 字段，可以控制 PVC 删除以后，这块 PV 和后端存储怎么处理</p><p>常见的是 Retain 和 Delete</p><h3 id="retain">Retain</h3><p>前面的 NFS PV 使用：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">persistentVolumeReclaimPolicy: Retain
</code></pre><p>删除 PVC 后，PV 背后的数据会保留下来，PV 通常会进入：</p><pre class="language-text lang-text"><code class="language-text lang-text">Released
</code></pre>
<p>这时管理员需要自己检查、备份、清理数据，再决定后续怎么处理</p><p>对于手工管理的 NFS 实验或者重要数据，使用 Retain 可以降低误删除数据的风险</p><h3 id="delete">Delete</h3><p>如果使用：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">persistentVolumeReclaimPolicy: Delete
</code></pre>
<p>那 PVC 删除以后，Kubernetes 会尝试删除 PV，并让存储驱动删除真正的后端存储</p><p>动态创建的云硬盘、CSI Volume 经常使用这种策略，所以删除 PVC 之前需要先确认回收策略，否则真实数据可能跟着一起删除</p><p>以前 Kubernetes 还有一个：</p><pre class="language-text lang-text"><code class="language-text lang-text">Recycle
</code></pre>
<p>会简单清空卷中的文件然后重新使用，但已经废了，就不过多描述了</p><h2 id="pvc-">PVC 扩容</h2><p>如果 StorageClass 配置了：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">allowVolumeExpansion: true
</code></pre><p>并且对应存储驱动支持扩容，就可以增加 PVC 请求的容量，比如从原来的 20Gi 扩容到 30Gi，可以通过一下命令进行扩容</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl patch pvc database-data -p &#x27;{&quot;spec&quot;:{&quot;resources&quot;:{&quot;requests&quot;:{&quot;storage&quot;:&quot;30Gi&quot;}}}}&#x27;
</code></pre>
<p>虽然 PVC 支持增加容量，但不能通过把 <code>30Gi</code> 再改成 <code>20Gi</code> 的方式直接缩容。以及扩容以后文件系统是否需要额外处理，取决于具体卷类型和存储驱动</p><h1 id="">总结</h1><p>这篇主要学习的是 Kubernetes 怎么把 Pod 和 具体存储 进行隔离</p><p>前面直接使用 NFS 时 Pod 自己知道 NFS Server 地址和目录</p><ul><li>Pod -&gt; NFS</li></ul><p>在加入 PV 和 PVC 后 Pod 只需要知道自己要使用哪个 PVC，具体存储在哪里由 PV 管理</p><ul><li>Pod -&gt; PVC -&gt; PV -&gt; NFS</li></ul><p>静态制备时，PV 由管理员提前创建；动态制备时，StorageClass 和存储驱动会根据 PVC 自动创建真实存储和 PV</p><p>我认为，能了解这些就足够了。</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/pv-pvc-storageclass#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/pv-pvc-storageclass</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/pv-pvc-storageclass</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Sun, 13 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[（十八）NFS 卷]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/nfs-volume">https://raymq.com/posts/Kubernetes/nfs-volume</a></blockquote><div><p>在之前我们学过卷，emptyDir 会随着 Pod 删除，hostPath 又依赖某一个节点。这就存在一个问题，假设一个 Pod 当前运行在 worker-1，数据保存在这个节点</p><pre class="language-text lang-text"><code class="language-text lang-text">worker-1
└── /data/index.html
</code></pre><p>如果 Pod 后来被重新调度到 worker-2</p><pre class="language-text lang-text"><code class="language-text lang-text">worker-2
└── 没有 /data/index.html
</code></pre><p>这时就会出现问题</p><p>如果多个节点上的 Pod 都需要访问同一批文件，就需要一块独立于 Kubernetes 节点之外的共享存储。NFS 可以解决这个问题</p><pre class="language-text lang-text"><code class="language-text lang-text">worker-1 ─┐
          ├── NFS Server
worker-2 ─┘
</code></pre>
<p>NFS 全称是 Network File System ，它允许一台服务器把某个目录通过网络共享出去，其他主机再把这个远程目录挂载到本地，在 Kubernetes 中，可以理解成 <strong>Pod 通过所在节点的网络，访问 NFS 服务器，最终读写 NFS 上的共享目录</strong></p><pre class="language-text lang-text"><code class="language-text lang-text">Pod
 ↓
Pod 所在节点
 ↓
网络
 ↓
NFS Server
 ↓
共享目录
</code></pre>
<p>Pod 删除以后，只是挂载关系消失，真正的数据仍然保存在 NFS Server 上。Pod 被调度到其他节点后，只要新的节点也可以访问 NFS Server，就可以继续挂载原来的目录</p><p>普通单机 NFS Server 本身仍然可能是，这一点和 Kubernetes 没有直接关系</p><h1 id="-nfs">搭建并验证 NFS</h1><p>我这里就直接使用 master 主机作为 NFS Server ，使用 slave 作为 NFS Client 。如果有多台主机，可以替换成网段，实际操作时需要替换成自己的 IP 和网段</p><pre class="language-text lang-text"><code class="language-text lang-text">NFS Server: 192.168.64.2
NFS Client(slave): 192.168.64.3
Kubernetes 节点网段: 192.168.64.0/24
共享目录: /srv/k8s-share
</code></pre>
<h2 id="-nfs-server">配置 NFS Server</h2><p>NFS Server 在 master 上安装</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo apt-get update
sudo apt-get install -y nfs-kernel-server

# 创建共享目录
sudo mkdir -p /srv/k8s-share

sudo chown nobody:nogroup /srv/k8s-share

sudo chmod 0770 /srv/k8s-share

# 创建导出配置
mkdir -p /etc/exports.d

# 允许访问 192.168.64.3、权限 允许读写
cat &gt; /etc/exports.d/k8s-share.exports &lt;&lt;&#x27;EOF&#x27;
/srv/k8s-share 192.168.64.3(rw,sync,root_squash,no_subtree_check)
EOF

# 让配置生效
exportfs -rav

exportfs -v

systemctl enable --now nfs-kernel-server
</code></pre><ul><li>rw 允许客户端读写</li><li>sync 服务端写入数据后再返回成功</li><li>root_squash 把客户端 root 映射成低权限用户</li><li>no<em>subtree</em>check 关闭子目录检查
## 在 Kubernetes 节点测试挂载
每一个可能运行 NFS Pod 的 Kubernetes 节点，都需要安装 NFS Client：
```bash
sudo apt-get update</li></ul><p>sudo apt-get install -y nfs-common</p><pre class=""><code class="">先不创建 Pod，直接在 slave 写点东西然后测试 NFS 是否能正常访问
</code></pre><p>bash
sudo mkdir -p /mnt/nfs-test</p><p>sudo mount -t nfs -o nfsvers=4.1 192.168.64.2:/srv/k8s-share /mnt/nfs-test</p><h1 id="">写入文件</h1><p>echo &#x27;hello nfs&#x27; | sudo tee /mnt/nfs-test/hello.txt</p><h1 id="">查看</h1><p>cat /mnt/nfs-test/hello.txt</p><h1 id="">最后卸载</h1><p>sudo umount /mnt/nfs-test</p><h1 id="-master-">在 master 查看</h1><p>cat /srv/k8s-share/hello.txt</p><pre class=""><code class="">![](https://raymq.com/api/v3/objects/image/fzc35rxvy7mxt1w3vn.png)
![](https://raymq.com/api/v3/objects/image/5z2mfpqx0vhord4z0l.png)

可以看到，也是成功了。如果 Kubernetes 节点本身都无法挂载 NFS，那么后面的 Pod 也无法正常使用

# Pod 怎么使用 NFS
现在 NFS Server 已经共享了 `/srv/k8s-share`，Kubernetes 节点也确认能够正常访问，接下来就可以让 Pod 使用这块存储
## 挂载 NFS
创建一个 Pod，把 NFS Server 的 `/srv/k8s-share` 挂载到 Nginx 容器的 `/usr/share/nginx/html`
</code></pre><p>bash
cat &gt; nfs-pod.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: nfs-demo
spec:
  containers:</p><ul><li><p>name: nginx
image: nginx:latest</p><p>volumeMounts:</p><ul><li>name: web-data
mountPath: /usr/share/nginx/html # 把 web-data 这个 Volume 挂载到这个目录</li></ul><p>volumes:</p></li><li><p>name: web-data
nfs:
  server: 192.168.64.2 # NFS Server 地址
  path: /srv/k8s-share # NFS Server 上的共享目录
EOF</p></li></ul><p>kubectl apply -f nfs-pod.yaml</p><p>kubectl get pod nfs-demo -o wide</p><pre class=""><code class="">这里仍然是之前学习过的 Volume 结构，volumeMounts 表示把 web-data 这个 Volume 挂载到容器的 `/usr/share/nginx/html`，而下面的 volumes.nfs 则表示 web-data 这个 Volume 的数据来自 `192.168.64.2:/srv/k8s-share`

现在向 Pod 中写入一个文件，然后分别从 Pod 和 NFS Server 查看
</code></pre><p>bash</p><h1 id="-pod-">在 Pod 中写入</h1><p>kubectl exec nfs-demo -- sh -c &#x27;echo &quot;hello from pod&quot; &gt; /usr/share/nginx/html/index.html&#x27;</p><h1 id="-pod-">在 Pod 中读取</h1><p>kubectl exec nfs-demo -- cat /usr/share/nginx/html/index.html</p><h1 id="-nfs-server-">在 NFS Server 中读取</h1><p>cat /srv/k8s-share/index.html</p><pre class=""><code class="">可以看到两边都有 hello from pod，说明 Pod 中的 `/usr/share/nginx/html/index.html` 实际保存在 NFS Server 的 `/srv/k8s-share/index.html`
![](https://raymq.com/api/v3/objects/image/93pls7yzw2ovp3tihh.png)

现在把 Pod 删除，然后重建
</code></pre><p>bash
kubectl delete pod nfs-demo</p><p>cat /srv/k8s-share/index.html</p><h1 id="">重新创建</h1><p>kubectl apply -f nfs-pod.yaml</p><p>kubectl exec nfs-demo -- cat /usr/share/nginx/html/index.html</p><pre class=""><code class="">虽然 Pod 已经删除，但 index.html 仍然存在，因为这个文件真正保存在 NFS Server 中，
所以 NFS 和前面 emptyDir 最大的区别就在这里，Pod 的生命周期不会决定 NFS 中数据的生命周期
![](https://raymq.com/api/v3/objects/image/8scwk095rlezvz5t5m.png)

## 两个 Pod 共享文件
NFS 还有一个很重要的能力，就是多个 Pod 可以同时访问同一个共享目录。前面的 nfs-demo 已经向 `/srv/k8s-share/index.html` 写入了数据，现在再创建一个 Pod，把同一个 NFS 目录挂载到 `/data`
</code></pre><p>bash
cat &gt; nfs-reader.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: nfs-reader
spec:
  containers:</p><ul><li><p>name: reader
image: busybox:1.36
command: [&quot;sh&quot;, &quot;-c&quot;]
args: [&quot;cat /data/index.html &amp;&amp; sleep 3600&quot;]</p><p>volumeMounts:</p><ul><li>name: shared-data
mountPath: /data
readOnly: true</li></ul><p>volumes:</p></li><li><p>name: shared-data
nfs:
  server: 192.168.64.2
  path: /srv/k8s-share
  readOnly: true
EOF</p></li></ul><p>kubectl apply -f nfs-reader.yaml</p><p>kubectl get pod nfs-demo nfs-reader -o wide</p><p>kubectl logs nfs-reader</p><pre class=""><code class="">可以看到输出也是 hello from pod ，
![](https://raymq.com/api/v3/objects/image/26hwry7pgtcp1n3cot.png)

虽然两个 Pod 中的挂载目录不同，一个是 `/usr/share/nginx/html`，另一个是 `/data`，但它们最终都访问 NFS Server 上的 `/srv/k8s-share`
</code></pre><p>text</p><pre class=""><code class="">             /srv/k8s-share
            /             \
           /               \
          ↓                 ↓
       nfs-demo          nfs-reader
   /usr/share/nginx/html  /data</code></pre><pre class=""><code class="">
所以两个 Pod 可以看到同一个 index.html ，即使这两个 Pod 分别运行在不同 Kubernetes 节点，只要对应节点都能够访问 192.168.64.2 ，依然可以共享同一份数据

# NFS 与 PV、PVC
现在 Pod 的 YAML 中直接写了：
</code></pre><p>yaml
nfs:
  server: 192.168.64.3
  path: /srv/k8s-share</p><pre class=""><code class="">这意味着 Pod 自己知道 NFS Server 地址和共享目录，当前的关系是：
</code></pre><p>text
Pod
 ↓
NFS</p><pre class=""><code class="">这种写法很适合现在学习 NFS，因为可以直接看到 Volume 的数据到底来自哪里。但如果以后有很多 Pod 都需要使用 NFS，每一个 Pod 都要重复写 NFS Server 地址和目录，存储信息就会和 Pod 配置耦合在一起，因此 Kubernetes 还提供了 PV 和 PVC：
</code></pre><p>text
Pod
 ↓
PVC
 ↓
PV
 ↓
NFS</p><pre class=""><code class="">可以先简单理解成
- PV 用来描述实际存储，例如 `192.168.64.2:/srv/k8s-share`
- PVC 表示 Pod 希望使用一块存储
    
这样 Pod 后面只需要引用 PVC，NFS Server 地址和目录等信息可以交给 PV 管理。我们后面会学到

## ReadWriteMany
在学习 PV 和 PVC 时还会看到存储访问模式，其中一个叫 ReadWriteMany，简称 RWX，它表示存储可以被多个节点同时以读写方式挂载

NFS 就是比较常见的 ReadWriteMany 存储，例如 worker-1 上的 Pod 和 worker-2 上的 Pod，可以同时挂载同一个 NFS 共享目录

但需要注意，ReadWriteMany 只表示这块存储允许多个客户端同时读写，如果两个 Pod 同时修改同一个文件，文件锁、覆盖和并发一致性依然需要应用自己处理

# NFS 扩展
这里再额外讲一下 NFS 本身的一些问题
## 单点故障
单点故障就是 整个系统里有一个部件，它一挂，依赖它的东西全挂

虽然 NFS 解决了 hostPath 数据和节点绑定的问题，但普通单机 NFS Server 本身仍然可能成为单点故障。如果所有 Pod 都依赖同一台 NFS Server，那么 NFS Server 故障以后，这些 Pod 都可能无法继续访问存储。

这个问题属于 NFS 存储本身的可靠性问题，和 Kubernetes 调度没有直接关系

## 权限
配置 NFS 时，不建议为了方便直接长期使用：
</code></pre><p>text
*(rw,no<em>root</em>squash)</p><pre class=""><code class="">也不建议简单把共享目录设置成：
</code></pre><p>bash
chmod 777 /srv/k8s-share</p><pre class=""><code class="">`*` 会允许任意能够访问 NFS Server 的客户端尝试挂载，no_root_squash 会让客户端 root 在服务端仍然保留 root 权限，而 777 又会进一步扩大目录的读写权限

实验时可以适当简化权限，生产环境应该根据节点网段和应用需要限制访问范围

# 总结
这篇主要学习的是 NFS 怎么解决多节点 Kubernetes 中共享存储的问题

前面学习的 hostPath 会把数据绑定到某一个 Kubernetes 节点，而 NFS 把数据放到了独立的 NFS Server 上，所以 Pod 即使删除、重建或者被调度到其他节点，只要新的节点仍然能够访问 NFS Server，就可以重新挂载并继续使用原来的数据

当前记住 NFS 在 Kubernetes 中的关系就差不多了
- Pod → Volume → NFS Server → 共享目录

愿诸君顺遂

</code></pre></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/nfs-volume#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/nfs-volume</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/nfs-volume</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Sat, 12 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[2026年9月9日]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/notes/7">https://raymq.com/notes/7</a></blockquote><div><p>好久不写日记了，今天突然想记录一下，就不讲工作了，讲讲日常吧</p><p>我的一个好朋友，我单方面认为是好朋友吧，昨天面试通过一家非常草台班子的公司，位置正好在他家所在市，根据描述来看会非常压榨，工资不高并且听说月休一天半...</p><p>对于我来说，是完全没有诱惑力的，就算是当初我还没实习时，我也不会去的。我建议他只是去看看，仅仅是看看，从下周一开始，先干一星期，感觉不对了就跑路，毕竟是在自己家那边，而且就算有问题，马上也快中秋国庆了，如果情况不对请三天假，就能度过一个非常完美的时长半个月的假期</p><p>但是，我劝不动，可能我有些站着说话不腰疼了，他认为这是一个机会，认为可以学到东西，认为以后简历上有一个实习总比没有好，认为自己的父母很幸苦了。嗯，是，有些东西我反驳不了，所以我没有继续劝，而是转变方向想让他明白实习应该学到什么东西</p><p>他说：学到有人真实使用应用的时候，会遇到什么问题，然后我要如何去处理，项目的工作，部署流程是怎么样的...</p><p>诚然这些看着都对，但是有 ai 了，我只需要给 ai 提问，这些都能解决，但这些或多或少都属于这家公司自有的流程与经验，换一家公司，不同的团队组织，产品架构，这些流程经验或许就不灵了...后面越聊，我就感觉到他的情绪有些起伏不定，或者说有些被能实习洗脑了吧</p><p>我本来想直接告诉他答案的，但是 不撞南墙不回头，没有真实经历过，或许体会不到我所说的话</p><p>学会思考，这个词含义非常广，用于不同场合会有不同的意思，对于实习也一样，我希望他能学会去思考，而不是只会使用 ai ，ai 只是个工具罢了</p><p>我还想到，今天晨会的时候 mt 说让我讲一下组件出现的 bug 的原因是什么，是组件问题，还是环境问题，有没有进行过调研，我又一次才意识到，我的思考深度是远远不够的，还是非常浅显...</p><p>当然，这篇文章他或许会看到、或许看不到，但这篇文章的目的也仅仅是因为我比较的郁闷写出来抒发一下，我希望他能少走点弯路，但...</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/notes/7#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/notes/7</link><guid isPermaLink="true">https://raymq.com/notes/7</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Wed, 09 Sep 2026 15:41:00 GMT</pubDate></item><item><title><![CDATA[（一）初识 Prometheus]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/cae9uhhinl8rzo8np5.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/prometheus/prometheus-01">https://raymq.com/posts/prometheus/prometheus-01</a></blockquote><div><p>今天我们来学习一下什么是 Prometheus，从 0 开始理解的话，它会比较抽象，但不需要担心，因为我刚开始学习的时候也一样</p><h1 id="1prometheus-">1、Prometheus 是什么</h1><p>Prometheus 本质上是一个监控系统，但从我个人角度来看，它是一个工具，能定期采集系统和应用的指标数据，把这些时间序列数据保存下来，并提供查询能力的一个工具</p><p>比如我有一台服务器，我想知道这台服务器的 CPU 使用率、内容使用率、磁盘剩余空间...这些都属于 Prometheus 典型的监控数据，而这些数据别统一称为 Metrics（指标）</p><p>比如 <code>www.raymq.com</code> ，这是我的博客。有一天我突然发现，我登录我的博客特别慢，我想知道是什么问题导致的，是 CPU、内存、数据库 还是 请求量太大了。我需要上服务器使用 <code>top</code>、<code>free</code>、<code>df</code>...看看到底是什么问题，但是这些命令有局限性，就是我只能看到当前状态下，CPU、内存 之类的数据是怎么样的，我不知道 5 分钟前、10分钟前内存使用了多少、请求量有多少</p><p>这时候，Prometheus 就派上用场了，它能不断采集 CPU 的使用率，比如</p><pre class=""><code class="">10:00 CPU = 25%
10:01 CPU = 27%
10:02 CPU = 31%
10:03 CPU = 92%
</code></pre><p>于是，你就能得到一个 x-y 图形，随着时间变化，CPU使用率也在变化，这就是时间序列（Time Series），我们也可以称呼其为时序数据（以下都用此称呼描述）</p><pre class=""><code class="">CPU
100% |                    *
 80% |
 60% |
 40% |        *
 20% | *  *
     +-------------------------
       10:00 10:01 10:02 10:03
</code></pre><h1 id="2">2、时序数据（时间序列）</h1><p>从上面图我们可以看到，随着时间变化，CPU使用率也在变化。也就是说，时序数据最重要的就是 <strong>时间 + 数值</strong></p><pre class=""><code class="">时间                  CPU
10:00:00              20
10:00:15              24
10:00:30              35
10:00:45              80
</code></pre><p>因此，一个 指标 本质上会反映成 <strong>时间 -&gt; 数值</strong> 这么个形式</p><h1 id="3-metrics">3、什么是 Metrics</h1><p>我认为 Metrics 不需要额外去解释，因为这是一个抽象的概念，如果硬要解释，我认为应该是 <strong>对系统的某个状态使用数字进行描述</strong>，我们以 redis 指标举个例子，我想你就能明白了</p><pre class=""><code class=""># HELP redis_connected_clients 当前连接的客户端数量
# TYPE redis_connected_clients gauge
redis_connected_clients{instance=&quot;redis-prod-01&quot;} 2
</code></pre><p>这是一条标准的 <strong>Prometheus 文本格式数据</strong>，我们一起来看一下都是什么</p><h2 id="metric-name">Metric Name（指标名称）</h2><p><strong>redis<em>connected</em>clients</strong> 就是一个指标名称，它表达的意思是 我现在测量的东西是什么，从名称上我们可以直观的判断，这个指标想表达的意思</p><ul><li>redis、connected、clients</li><li>redis、已经连接的、客户端数量</li></ul><p>我认为，这是官方设计的一种小巧思吧。指标名称使用统一的应用前缀、基础单位，并通过 <code>_seconds</code>、<code>_bytes</code>、<code>_total</code> 等后缀去体现单位和语义</p><h2 id="label">Label（指标标签）</h2><p><strong>{instance=&quot;redis-prod-01&quot;}</strong> 就是一个指标标签，用于区分这个指标不同维度的数据特征</p><p>需要注意的是 {} 也是一个标签整体，它表示为一个集合，在这个 {} 里的都是筛选条件。而 {} 的内容，就是一个具体的标签匹配对，换句话说是一个键值对。比如这里的 <strong>instance 是 键、redis-prod-01 是 值</strong>。而 值 我们一般使用 &quot;&quot; 或者 &#x27;&#x27; 去进行引用，目的是防止语法解析报错</p><p>所以这个标签表示的意思就是，取，与 实例为 redis-prod-01 匹配的 redis 值</p><p>Label 同样可以支持多个 键值对 进行展示，比如</p><pre class=""><code class=""># HELP redis_up Redis 实例是否存活（1=存活，0=不可达）
# TYPE redis_up gauge
redis_up{instance=&quot;redis-prod-01&quot;, address=&quot;10.0.1.10:6379&quot;} 1
</code></pre>
<p>我们一般称呼 Label 为 <strong>维度</strong></p><h2 id="value-">Value (指标数值)</h2><ul><li><strong>2</strong> 指标数值，表示的是当前这个指标的数值是多少</li></ul><p>因此，这个指标整体的意思就可以表示为 <strong>实例为 redis-prod-01 的 redis 已经连接的客户端数量是 2</strong></p><h2 id="timestamp">TimeStamp（时间戳）</h2><p>上面我们了解了什么是 Prometheus 文本格式的数据，将 Metrics Name + Label + Value 进行组合成一条数据，而我们常见的还会有 TimeStamp 字段，比如</p><pre class=""><code class="">redis_connected_clients{instance=&quot;redis-prod-01&quot;} 2 1788872400
</code></pre><p>其中的 1788872400 属于 UNIX 时间戳，单位是 毫秒，换算成 UTC（世界标准时间）也就是 2026年9月8日13:00:00，再加上 8h 就变成了 UTC + 8 也就是北京时间</p><h1 id="4-metrics-">4、四种 Metrics 类型</h1><p>在上文提到的 redis 指标中有一个 <code># TYPE redis_up gauge</code>，不知道大家是否有注意到这里的 gauge。这就是一种 Metrics 类型，Prometheus 提供了 4 种指标类型，分别是 Counter、Gauge、Histogram、Summary</p><h2 id="counter">Counter</h2><p>Counter 表示 <strong>只能增加 或者 在进程重启等情况下归零的，累计值，一个单调递增、只能增加或者重置为 0 的累计指标</strong>，比如</p><pre class=""><code class=""># HELP redis_commands_processed_total 累计处理的命令总数
# TYPE redis_commands_processed_total counter
redis_commands_processed_total{instance=&quot;redis-prod-01&quot;} 78234156
</code></pre><p>但是，我希望大家能观察到这么个现象 78234156 这个数字，有意义吗？我想是没有的，因为它只能体现这个指标的总数非常大，但你告诉我除了大还有什么吗？所以，这时候就需要使用 PromQL 了，我们后面再聊</p><h2 id="gauge">Gauge</h2><p>Gauge 表示 <strong>当前状态，一个可以任意上升或下降的单一数值</strong>。比如上文提到的 <code>redis_connected_clients</code>，它就是 Gauge 类型的指标，它所呈现出的数值是一个随着时间可以上下波动的数据，如同所示
<img height="1086" src="https://raymq.com/api/v3/objects/image/cae9uhhinl8rzo8np5.png" width="1448"/></p><h2 id="histogram">Histogram</h2><p>Histogram <strong>用于观察一批数据的分布</strong>。这是一个比较抽象的概念，最典型且常见的例子是 HTTP 请求响应时间。</p><p>比如当前有 5 个请求，0.1s、0.2s、0.3s、0.8s、2.0s，我不只想知道 平均响应时间，我还想知道 90% 的请求能否在 xx 秒内完成？95% 呢？99% 呢？</p><p>而这就是 P90、P95、P99。Histogram 解决的就是这类型问题的指标</p><h2 id="bucket">Bucket</h2><p>这里我们额外扩展一个知识，Classic Histogram 也称呼为 经典 Histogram，它会把数据放到不同 Bucket 中。</p><p>比如当前有如下纬度 &lt;= 0.1s、&lt;= 0.5s、&lt;= 1s、&lt;= 2s、&lt;= +Inf；当前有一次请求耗时为 0.3s，那么这次耗时比 0.1s 大、0.5s 小。也就是 &lt;= 0.5s、&lt;= 1s、&lt;= 2s、&lt;= +Inf 数值会增加一次</p><p>我们多看点例子，总共 100 个请求</p><pre class=""><code class=""># 10 个 &lt;= 0.1s
http_request_duration_seconds_bucket{le=&quot;0.1&quot;}  10

# 80 个 &lt;= 0.5s
http_request_duration_seconds_bucket{le=&quot;0.5&quot;}  80

# 95 个 &lt;= 1s
http_request_duration_seconds_bucket{le=&quot;1&quot;}    95

# 99 个 &lt;= 2s
http_request_duration_seconds_bucket{le=&quot;2&quot;}    99

# 100 个 &lt;= +Inf
http_request_duration_seconds_bucket{le=&quot;+Inf&quot;} 100
</code></pre><p>Classic Histogram 还会产生类似于 xxx<em>bucket、xxx</em>sum、xxx_count 这种指标名称</p><p>官方当前提供了 Classic Histogram 和 Native Histogram；Native Histogram 使用复合样本保存 count、sum 和动态 buckets，而 Classic Histogram 会展开成多条普通时间序列，这里简单了解一下即可</p><h2 id="summary">Summary</h2><p>Summary 与 Histogram 类似，带有统计的含义，并且能统计 响应时间、请求大小，以及还可以进行计算 Quantile（分位数），比如</p><pre class=""><code class="">quantile=&quot;0.5&quot;
quantile=&quot;0.9&quot;
quantile=&quot;0.99&quot;
</code></pre>
<p>但 Summary 与 Histogram 的区别在于</p><ul><li>Histogram -&gt; Prometheus 根据 Bucket 数据计算分位数</li><li>Summary -&gt; 客户端程序提前计算分位数</li></ul><p>换句话说就是 Summary 的 Quantile 不方便跨实例聚合。假设我有 3 台机器 A、B、C，我想知道这三台机器的 P95 就不能直接使用 avg(A<em>P95, B</em>P95, C_P95) 得出整个系统的真正 P95</p><h2 id="">聚合</h2><p>这里的聚合的意思就是<strong>把多个数据点“合并”成一个更有意义的数据点</strong>，涉及到 PromQL 函数，比如 sum()、avg()、max()、min()，举个例子就是</p><pre class=""><code class="">全班 50 个学生的数学成绩 {85, 92, 78, 90, ...}，通过聚合操作得到

求 平均分（ avg ）→ 86.5 分
求 总分（ sum ）→ 4325 分
求 最高分（ max ）→ 100 分  
求 最低分（ min ）→ 52 分
</code></pre><h1 id="5prometheus-">5、Prometheus 数据模型</h1><p>Prometheus 数据模型，指的是 Prometheus 是如何组织、区分和存储这些 Metrics 数据</p><p>在前面我们知道了一条数据大概长什么样，再来回顾一下吧</p><pre class=""><code class="">redis_connected_clients{instance=&quot;redis-prod-01&quot;} 2 1788872400
</code></pre>
<p>而Prometheus 并不是单纯通过 Metric Name 来区分一条时序数据，而是通过 Metric Name + Labels 来确定一条 Time Series，我们再来回顾一下什么是 Time Series 时序数据</p><h2 id="time-series">Time Series</h2><p>比如</p><pre class=""><code class="">redis_connected_clients{instance=&quot;redis-prod-01&quot;} 2 1788872400
redis_connected_clients{instance=&quot;redis-prod-02&quot;} 5 1788872600
</code></pre><p>虽然它们的 Metric Name 都是 redis<em>connected</em>clients，但是由于它们的 Label 也就是纬度不同，所以在 Prometheus 看来，这是两条完全不同的 Time Series 时序数据</p><p>并且我们通过大量的 时序数据，得到了一个范围，借鉴上图</p><pre class=""><code class="">CPU
100% |                    *
 80% |
 60% |
 40% |        *
 20% | *  *
     +-------------------------
       10:00 10:01 10:02 10:03
       
10:00 -&gt; 25%
10:01 -&gt; 27%
10:02 -&gt; 31%
10:03 -&gt; 92%
</code></pre><p>我们可以将这个范围里每一条 时间 -&gt; 数值称为 <strong>Sample 样本</strong></p><p>由此，我们就能串联起来以上的知识</p><pre class=""><code class="">redis_connected_clients{instance=&quot;redis-prod-01&quot;} 2 1788872400
redis_connected_clients{instance=&quot;redis-prod-01&quot;} 3 1788874400
redis_connected_clients{instance=&quot;redis-prod-01&quot;} 5 1788876400
redis_connected_clients{instance=&quot;redis-prod-01&quot;} 7 1788878400
redis_connected_clients{instance=&quot;redis-prod-01&quot;} 4 1788880400
</code></pre><p>一条 Time Series 实际上是由很多 Sample 按照时间不断组成的</p><p>当 Label 发生了变化，也就是 redis-prod-01 变成了 redis-prod-11，那么这一条数据，就变成了一条 全新的时序数据，也就是维度只要变化，那就是新的数据</p><h2 id="cardinality">Cardinality</h2><p>既然不同的 Label 组合会产生不同的 Time Series，那么这里就会自然产生一个问题，当我的维度足够多，用户足够多，仅一条指标，就会产生非常庞大的时序数据条数</p><ul><li>比如有 100w 用户，使用 http<em>request{user</em>id=&quot;xxx&quot;} 展示，就会有 100w 条数据</li><li>如果维度再加上 2 种 method、10个 path、5个 status 那就会有 100w x 2 x 10 x 5 条数据</li></ul><p>理论上，这个数据会变的非常庞大，所以我们通常称这个现象为 Cardinality ，也就是基数，因此，在 Prometheus 中设计 Label 的时候，一般不会把一些变化范围非常大的值直接作为 Label，以此避免出现 <strong>高基数现象</strong></p><ul><li>user<em>id、email、request</em>id、trace_id、订单号、UUID 等...</li></ul><h1 id="">总结</h1><p>这一篇也算是花费了不少笔墨去讲解，也算是 Prometheus 中非常重要且非常基础的一章，尽管我的讲解多多少少还存在一些让人捉摸不透的地方</p><p>但记住我常说的话，有印象就足够了</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/prometheus/prometheus-01#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/prometheus/prometheus-01</link><guid isPermaLink="true">https://raymq.com/posts/prometheus/prometheus-01</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Tue, 08 Sep 2026 14:14:00 GMT</pubDate></item><item><title><![CDATA[（十七）ConfigMap 和 Secret]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/pyfzlwumc2tf4zjxxx.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/cvppdbrljmgqhttihq.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/f3azz019tx5rdiljhw.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/configmap-secret">https://raymq.com/posts/Kubernetes/configmap-secret</a></blockquote><div><p>应用通常需要数据库地址、日志级别、功能开关、账号密码和证书等配置，如果把这些内容直接写进镜像，每次修改配置都需要重新构建镜像，也容易把密码提交到代码仓库中</p><p>因此 Kubernetes 提供 ConfigMap 和 Secret，把配置与容器镜像分离</p><ul><li>ConfigMap 保存普通配置，比如 LOG<em>LEVEL=info、MYSQL</em>HOST=mysql</li><li>Secret 保存密码、令牌、证书等敏感数据，比如 MYSQL<em>PASSWORD=123456、JWT</em>SECRET=xxxx
# ConfigMap
ConfigMap 用来保存普通配置，本质上保存的是键值对，其中 Key 是配置名称，Value 可以是一行文本，也可以是一整个配置文件
## 使用参数进行创建
--from-literal 参数用于直接声明键值对
```bash
kubectl create configmap app-config --from-literal=APP<em>NAME=demo --from-literal=LOG</em>LEVEL=info</li></ul><p>kubectl get configmap app-config -o yaml</p><pre class=""><code class="">![](https://raymq.com/api/v3/objects/image/mpr6mglxxp231bdjm5.png)
这里有一个小细节就是 ConfigMap 的 Value 最终按照字符串处理，所以容易被 YAML 识别成数字、布尔值的内容最好加引号
## 使用文件创建
如果配置的参数会比较多，那么使用 --from-literal 进行创建就显得笨重了，这时候我们可以使用 文件 形式进行创建
### 使用 env 文件创建
我们创建一个配置文件，然后使用 --from-env-file，这样就会把文件中的每一行拆成独立的 Key 和 Value
</code></pre><p>bash
cat &gt; app.env &lt;&lt;&#x27;EOF&#x27;
APP<em>NAME=demo
LOG</em>LEVEL=info
FEATURE_X=true
EOF</p><p>kubectl create configmap app-env --from-env-file=app.env</p><p>kubectl get configmap app-env -o yaml</p><pre class=""><code class="">![](https://raymq.com/api/v3/objects/image/bz14t7y0jre7p1iyyl.png)

当前 kubectl 版本允许重复指定多个 env 文件进行创建，但需要注意，如果多个来源包含相同的 Key，需要避免互相覆盖或直接创建失败带来的混乱
</code></pre><p> bash
kubectl create configmap app-env --from-env-file=app.env --from-env-file=other.env</p><pre class=""><code class="">### 使用 .conf 文件创建
我们还可以使用 --from-file=nginx.conf 进行创建，这样文件名 nginx.conf 就是 Key，整个文件内容就是 Value，此时它不会再自动把文件内部的每一行再次拆成键值对
</code></pre><p>bash
cat &gt; nginx.conf &lt;&lt;&#x27;EOF&#x27;
server {</p><pre class=""><code class="">listen 8080;
location / {
    return 200 &quot;configmap demo\n&quot;;
}</code></pre><p>}
EOF</p><p>kubectl create configmap nginx-config --from-file=nginx.conf</p><p>kubectl get configmap nginx-config -o yaml</p><pre class=""><code class="">![](https://raymq.com/api/v3/objects/image/hxppqu5cuja7ovlolt.png)

虽然我们可以用上面三种方式创建出 ConfigMap ，但现在存在一个问题，那就是配置已经存进了 Kubernetes 中了，但是程序还没拿到，所以我们需要把 ConfigMap 交给 Pod 

# Pod 怎么使用 ConfigMap
ConfigMap 创建之后，只是存在 Kubernetes 中，Pod 需要主动去引用它，主要使用两种方式
- 环境变量：把配置以环境变量的形态注入容器。应用通过读环境变量来获取配置（如 os.Getenv()、System.getenv()、process.env ），适合本来就依赖环境变量读取配置的应用
- Volume 文件：把配置以文件形态挂载进容器。应用通过读配置文件来获取配置，适合本来就依赖配置文件读取配置的应用
## 环境变量
前面我们使用 --from-literal 创建了一个 ConfigMap，现在我们创建一个 Pod ，让 ConfigMap 把配置注入 Pod 容器环境变量里
</code></pre><p>bash
cat &gt; config-env-demo.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: config-env-demo
spec:
  containers:</p><ul><li><p>name: app
image: busybox:1.36
command: [&quot;sleep&quot;, &quot;3600&quot;]</p><p>env:</p><ul><li>name: APP<em>NAME # 创建一个环境变量名字是 APP</em>NAME
valueFrom:
  configMapKeyRef:
    name: app-config # 去 app-config 这个 ConfigMap 拿值
    key: APP_NAME # 读取 ConfigMap 的 key 拿到 demo 这个名字
EOF</li></ul></li></ul><p>kubectl apply -f config-env-demo.yaml</p><p>kubectl exec config-env-demo -- printenv APP_NAME</p><pre class=""><code class="">这个 Pod 配置文件的意思就是 **去 app-config 这个 ConfigMap，取 APP_NAME 这个 key 的值 demo ，把它作为容器里的环境变量 APP_NAME**
![](https://raymq.com/api/v3/objects/image/x5dtlqc0xk1gg81rlx.png)
### 导入多个配置
一个一个写 configMapKeyRef 的配置，太复杂了，所以我们可以将 spec.env 替换成 spec.envFrom，这样 ConfigMap 中的配置会整体作为环境变量交给容器

两种配置的区别是 configMapKeyRef 只拿一个配置，而 envFrom 一次拿多个配置
### 修改 ConfigMap 后会怎样
如果 Pod 启动时拿到 LOG_LEVEL=info，后面又将 ConfigMap 改成 LOG_LEVEL=debug 。这时候 Pod 是不会一起被修改成 debug 模式，因为环境变量是 Pod 在创建时被注入的

如果使用了 Deployment 创建的 Pod ，则可以通过 回滚 的方式进行重新配置
</code></pre><p>bash
kubectl rollout restart deployment {deployment-name}</p><pre class=""><code class="">如果不是，则只能重新创建一个 Pod
## Volume 文件
并非所有的应用都是使用环境变量进行配置的，比如 Nginx，依赖的是 `xx/xx/nginx.conf 。同样的，在前面我们使用 --from-file 创建了一个保存 nginx.conf 的 ConfigMap，现在我们创建一个 Pod ，让 ConfigMap 把这个配置文件挂载到 Pod 容器里
</code></pre><p>bash
cat &gt; config-volume-demo.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: config-volume-demo
spec:
  containers:</p><ul><li><p>name: nginx
image: nginx:latest</p><p>volumeMounts:</p><ul><li>name: config
mountPath: /etc/nginx/conf.d # 把 config 这个 Volume 挂载到这个目录
readOnly: true</li></ul><p>volumes:</p></li><li><p>name: config
configMap:
  name: nginx-config # 使用 nginx-config 这个 ConfigMap
EOF</p></li></ul><p>kubectl apply -f config-volume-demo.yaml</p><p>kubectl exec config-volume-demo -- cat /etc/nginx/conf.d/nginx.conf</p><pre class=""><code class="">这个 Pod 配置文件的意思就是 **去 nginx-config 这个 ConfigMap 拿到 nginx.conf，然后把 ConfigMap 作为一个 Volume 挂载到容器的 `/etc/nginx/conf.d` 目录中**
![](https://raymq.com/api/v3/objects/image/xk8uxl27a4084p7gob.png)
因为 ConfigMap 中的 Key 是 nginx.conf ，所以挂载到 `/etc/nginx/conf.d` 后，容器中就会出现 `/etc/nginx/conf.d/nginx.conf`
![](https://raymq.com/api/v3/objects/image/mvc5xrry8zphi33oze.png)

这里和前面学习的 Volume 是一样的，只是 Volume 的数据来源换成了 ConfigMap：
</code></pre><p>text
ConfigMap # ConfigMap 提供数据（配置文件）</p><pre class=""><code class="">↓</code></pre><p>Volume # Volume 中间载体，把这份数据包装成一块可挂载的存储</p><pre class=""><code class="">↓</code></pre><p>容器目录 # 容器目录 最终的挂载点，容器里的程序去这个目录读配置</p><pre class=""><code class="">### 修改文件名
默认情况下，ConfigMap 的 Key 会直接作为容器中的文件名，比如：
</code></pre><p>text
ConfigMap Key：nginx.conf</p><pre class=""><code class="">    ↓</code></pre><p>/etc/nginx/conf.d/nginx.conf</p><pre class=""><code class="">如果我们想把它改成 default.conf ，可以给 configMap 增加 items
</code></pre><p>yaml
volumes:</p><ul><li>name: config
configMap:
  name: nginx-config
  items:
<ul><li>key: nginx.conf # 读取 ConfigMap 中的 nginx.conf
path: default.conf # 挂载后文件名改成 default.conf
<code>
这时候容器中的文件就会变成：
</code>text
/etc/nginx/conf.d/default.conf
```
所以 items 可以理解为 <strong>指定要挂载 ConfigMap 中的哪个 Key，同时通过 path 指定这个 Key 在 Volume 中生成的文件名</strong></li></ul></li></ul><h3 id="-configmap-">修改 ConfigMap 后会怎样</h3><p>如果我们后面修改了 nginx-config 中 nginx.conf 的内容，普通的 ConfigMap Volume 会在一段时间后把新的内容同步到容器中的文件，也就是</p><pre class="language-text lang-text"><code class="language-text lang-text">修改 ConfigMap
    ↓
Volume 中的文件更新
    ↓
容器中的 nginx.conf 更新
</code></pre><p>但是容器中的文件更新了，不代表 Nginx 一定马上使用新的配置，因为 Nginx 是否重新读取配置，还要看 Nginx 自己有没有重新加载配置文件，所以这里需要区分</p><ul><li>Kubernetes 更新配置文件</li><li>应用重新读取配置文件
### subPath
上面的写法是把整个 Volume 挂载到：
<code>text
/etc/nginx/conf.d
</code>
如果只想把其中一个文件挂载到指定位置，可以使用 subPath ，意思就是 <strong>从 config 这个 Volume 中只拿 nginx.conf ，然后挂载成容器中的 <code>/etc/nginx/conf.d/default.conf</code></strong> 
```yaml
volumeMounts:</li><li>name: config
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
```
需要注意，使用 subPath 挂载以后，ConfigMap 后续发生修改时，这个文件不会自动同步更新，所以两种方式的区别就是</li><li>普通 ConfigMap Volume ，ConfigMap 修改后，文件会逐步同步更新</li><li>subPath ，ConfigMap 修改后，文件不会自动更新</li></ul><h1 id="secret">Secret</h1><p>Secret 用来保存密码、令牌、证书这类敏感配置，使用方式和 ConfigMap 基本一致，同样可以通过环境变量或者 Volume 文件交给 Pod 使用</p><h2 id="">使用参数进行创建</h2><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl create secret generic db-secret --from-literal=username=admin --from-literal=password=&#x27;t0p-Secret&#x27;

kubectl get secret db-secret -o yaml

echo &#x27;YWRtaW4=&#x27; | base64 -d

echo &#x27;dDBwLVNlY3JldA==&#x27; | base64 -d
</code></pre><p>查看以后会发现，Secret 中的数据长得奇形怪状的，因为这里的内容使用了 Base64 编码，通过解码后就能看到原始的值。所以需要注意的是 <strong>Base64 只是一种编码方式，并不等于加密方式，只要拿到了 Secret 中的 Base64 内容，就可以很容易还原出原始数据</strong>
<img src="https://raymq.com/api/v3/objects/image/pyfzlwumc2tf4zjxxx.png"/></p><h2 id="-yaml-">使用 YAML 创建</h2><p>如果自己编写 Secret YAML，可以使用 <code>stringData</code>，这样就不需要自己手动进行 Base64 编码</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; db-secret.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
type: Opaque
stringData:
  username: admin
  password: t0p-Secret
EOF

kubectl apply -f db-secret.yaml

kubectl get secret db-secret -o yaml
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/cvppdbrljmgqhttihq.png"/>
这里的 stringData 可以直接填写普通字符串，Secret 被提交给 Kubernetes 后，API Server 会将它转换成 data ，比如 <code>stringData.username: admin</code> 保存以后大致会变成 <code>data.username: YWRtaW4=</code> ，所以 stringData 主要就是为了方便我们编写 Secret</p><p>需要注意，如果里面是真实密码，那么这个 YAML 文件同样不能直接提交到 Git 仓库，因为 stringData 并没有提供额外的加密能力</p><p>和 ConfigMap 一样，现在 Secret 虽然已经存在 Kubernetes 中了，但是 Pod 还没有拿到，所以接下来同样需要让 Pod 去引用它</p><h1 id="pod--secret">Pod 怎么使用 Secret</h1><p>Secret 和 ConfigMap 一样，也是主要有两种使用方式</p><ul><li>环境变量：把 Secret 中的数据注入到容器环境变量中</li><li><p>Volume 文件：把 Secret 中的数据以文件形式挂载到容器中</p><h2 id="">环境变量</h2><p>前面我们创建了一个 db-secret ，其中有</p><pre class="language-text lang-text"><code class="language-text lang-text">username=admin
password=t0p-Secret
</code></pre><p>现在创建一个 Pod，把 Secret 中的用户名和密码注入到容器环境变量</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; secret-env-demo.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
name: secret-env-demo
spec:
containers:
- name: app
  image: busybox:1.36
  command: [&quot;sleep&quot;, &quot;3600&quot;]

  env:
  - name: DB_USERNAME
    valueFrom:
      secretKeyRef:
        name: db-secret # 去 db-secret 这个 Secret 拿值
        key: username # 读取 username 这个 Key

  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-secret
        key: password
EOF

</code></pre></li></ul><p>kubectl apply -f secret-env-demo.yaml</p><p>kubectl exec secret-env-demo -- printenv DB_USERNAME</p><pre class=""><code class="">这个 Pod 配置文件的意思就是 **去 db-secret 这个 Secret，读取 username 和 password，然后分别作为容器中的 DB_USERNAME 和 DB_PASSWORD 环境变量**
![](https://raymq.com/api/v3/objects/image/uajgg57wpkwxpikh9x.png)
这里和 ConfigMap 中的 `configMapKeyRef` 非常像
- ConfigMap → configMapKeyRef
- Secret    → secretKeyRef
### 导入多个配置
如果 Secret 中有很多配置，一个一个写 secretKeyRef 也比较麻烦，可以使用 envFrom
</code></pre><p>yaml
envFrom:</p><ul><li>secretRef:
  name: db-secret
```
这样 Secret 中的多个 Key 会整体作为环境变量交给容器</li></ul><p>所以两种方式的区别和 ConfigMap 一样</p><ul><li>secretKeyRef：读取某一个 Secret Key</li><li>envFrom：一次导入 Secret 中多个配置
### 修改 Secret 后会怎样
Secret 通过环境变量注入以后，更新规则和 ConfigMap 一样，中途修改不会改变 Pod 的环境变量</li></ul><p>由 Deployment 管理的 Pod ，可以重新启动 Deployment ，让新的 Pod 重新读取 Secret</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl rollout restart deployment {deployment-name}
</code></pre><p>如果是单独创建的 Pod，则需要重新创建 Pod</p><h2 id="volume-">Volume 文件</h2><p>有些应用支持从文件中读取密码、证书、Token 等敏感配置，这时候可以把 Secret 挂载成 Volume，前面的 db-secret 中有：</p><pre class="language-text lang-text"><code class="language-text lang-text">username=admin
password=t0p-Secret
</code></pre><p>现在创建一个 Pod，把 Secret 挂载到 <code>/etc/credentials</code> 目录</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">cat &gt; secret-volume-demo.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: secret-volume-demo
spec:
  containers:
  - name: app
    image: busybox:1.36
    command: [&quot;sleep&quot;, &quot;3600&quot;]

    volumeMounts:
    - name: credentials
      mountPath: /etc/credentials # 把 credentials Volume 挂载到这个目录
      readOnly: true

  volumes:
  - name: credentials
    secret:
      secretName: db-secret # 使用 db-secret 这个 Secret
EOF

kubectl apply -f secret-volume-demo.yaml

kubectl exec secret-volume-demo -- ls /etc/credentials

kubectl exec secret-volume-demo -- cat /etc/credentials/username
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/f3azz019tx5rdiljhw.png"/>
挂载以后，Secret 中每一个 Key 都会变成一个文件</p><ul><li>文件名 = Secret 的 Key</li><li>文件内容 = Secret 的 Value</li></ul><p>整体过程和 ConfigMap Volume 基本一致：</p><pre class="language-text lang-text"><code class="language-text lang-text">Secret # Secret 提供敏感配置
   ↓
Volume # Volume 作为中间载体
   ↓
容器目录 # 程序从目录中的文件读取配置
</code></pre><h3 id="">设置文件权限</h3><p>Secret 中保存的通常是密码、私钥等敏感内容，所以还可以通过 defaultMode 设置挂载文件的权限</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumes:
- name: credentials
  secret:
    secretName: db-secret
    defaultMode: 0400
</code></pre><p>0400 表示文件只有所有者具有读取权限
挂载以后类似：</p><pre class="language-text lang-text"><code class="language-text lang-text">-r-------- username
-r-------- password
</code></pre><h3 id="-secret-">修改 Secret 后会怎样</h3><p>普通 Secret Volume 的更新方式和 ConfigMap Volume 类似，如果 Secret 内容发生修改</p><pre class="language-text lang-text"><code class="language-text lang-text">修改 Secret
    ↓
Volume 中的文件更新
    ↓
容器中的文件逐步更新
</code></pre><p>但是容器中的文件发生变化以后，应用是否重新读取新的密码或者证书，还需要看应用本身，所以同样需要区分</p><ul><li>Kubernetes 更新 Secret 文件</li><li><p>应用重新读取 Secret 文件</p><p>如果应用只在启动的时候读取一次密码，那么即使文件已经更新，程序也可能继续使用之前读取到内存中的旧密码</p><h3 id="subpath">subPath</h3><p>Secret Volume 同样支持 subPath，比如只想把 <code>password</code> 这个文件挂载到指定位置</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumeMounts:

</code></pre></li><li><p>name: credentials
mountPath: /etc/app/db-password
subPath: password</p><pre class=""><code class="">意思就是 **从 credentials 这个 Volume 中只拿 password 文件，然后挂载成容器中的 `/etc/app/db-password`**

</code></pre></li></ul><p>需要注意，Secret 使用 subPath 以后，后续 Secret 发生修改时，这个文件也不会自动同步更新，所以</p><ul><li>普通 Secret Volume：Secret 修改后，文件会逐步同步更新</li><li>subPath：Secret 修改后，文件不会自动更新</li></ul><h1 id="secret-">Secret 扩展</h1><p>这里额外讲解一下 Secret 的一些东西</p><h2 id="-secret-">常见 Secret 类型</h2><p>我们前面使用的：</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">type: Opaque
</code></pre><p>是最普通的一种 Secret 类型，用来保存自定义敏感数据，比如用户名、密码、Token 等。除此之外，Kubernetes 还提供了一些常见类型</p><ul><li><code>kubernetes.io/tls</code>：保存 TLS 证书和私钥</li><li><code>kubernetes.io/dockerconfigjson</code>：保存私有镜像仓库认证信息</li><li><code>kubernetes.io/basic-auth</code>：保存用户名和密码</li><li><code>kubernetes.io/ssh-auth</code>：保存 SSH 私钥</li></ul><p>比如，创建一个 TLS Secret</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">kubectl create secret tls web-tls --cert=server.crt --key=server.key

kubectl get secret tls -o yaml
</code></pre><p>创建以后里面主要有两个字段：</p><pre class="language-text lang-text"><code class="language-text lang-text">tls.crt
tls.key
</code></pre><p>可以提供给 Ingress、Gateway 或应用程序使用</p><h2 id="secret-">Secret 是否安全</h2><p>Secret 的名字虽然叫 Secret，但不能简单理解成里面的数据已经被完全加密。首先，Secret 中常见的 Base64 内容可以直接解码：</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">echo &#x27;YWRtaW4=&#x27; | base64 -d
</code></pre><p>所以 Base64 本身不提供安全保护，Secret 的安全还依赖 Kubernetes 其他机制，比如</p><ul><li>使用 RBAC 限制谁可以读取 Secret</li><li>为 etcd 开启静态加密</li><li>不把真实密码写进 Git、日志和命令历史</li><li><p>限制哪些 Pod 可以使用 Secret</p><p>还有一个容易忽略的问题，如果某个用户有权限在一个 Namespace 中随意创建 Pod，那么他可能创建一个 Pod，把这个 Namespace 中的 Secret 挂载进去，然后进入容器读取</p></li></ul><p>所以 Pod 创建权限本身也是一种比较敏感的权限</p><h1 id="">总结</h1><p>这篇文章的内容也算是比较多的了，主要是 ConfigMap 和 Secret 两种配置方法操作方式都差不多，导致了大部分篇幅都是以操作为主</p><p>当然，看完这篇文章能依稀记得 ConfigMap 用来存普通配置，Secret 用了存敏感类配置就差不多了，后续忘了再回过头查就可以了</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/configmap-secret#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/configmap-secret</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/configmap-secret</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Fri, 04 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[（十六）卷]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/x7uiaa8c7e2bde0b9j.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/volume">https://raymq.com/posts/Kubernetes/volume</a></blockquote><div><p>由于容器中的文件默认写在容器可写层中，如果容器被删除了，那么这些文件也会随之消失，即使我们使用 kubelet 重启容器，新容器看到的也不一定还是原来的文件</p><p>但是应用运行时经常需要缓存文件、共享配置、保存日志，或者让同一个 Pod 中的多个容器交换数据，因此 Kubernetes 提供了 Volume，也就是卷</p><p><strong>卷是一个抽象的概念，本质上是一套 把不同存储挂载到容器中的一个机制</strong>，也就是说它不具有物理上的概念；它可以来自节点目录、临时目录、ConfigMap、Secret、NFS，也可以来自云硬盘和 CSI 存储系统</p><h1 id="">卷和容器文件系统</h1><p>Pod 中可以声明一个或多个卷，然后由容器通过 volumeMounts 决定把卷挂载到哪里</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">apiVersion: v1
kind: Pod
metadata:
  name: volume-demo
spec:
  containers:
  - name: nginx
    image: nginx:latest
    volumeMounts:
    - name: cache
      mountPath: /cache
  volumes:
  - name: cache
    emptyDir: {}
</code></pre><p>这里需要理解两个字段：</p><ul><li><strong><code>spec.volumes</code> 表示 Pod 有哪些卷，以及卷从哪里来</strong></li><li>**<code>spec.containers.volumeMounts</code> 表示 卷在容器中的挂载位置</li></ul><p>volumeMounts.name 必须与 volumes.name 对应，并且同一个卷可以挂载到 Pod 中的多个容器内，但不能跨 Pod 直接引用</p><p>挂载完成后，mountPath 原来能看到的文件会暂时被卷遮住，而不是合并到卷中，删除挂载以后，镜像中的原文件仍然存在</p><h1 id="emptydir-">emptyDir 卷</h1><p>emptyDir 是最简单的临时卷，Pod 被调度到某个节点时，kubelet 会为它创建一个空目录，emptyDir 的生命周期与 Pod 相同</p><ul><li>在容器崩溃、容器重启时，emptyDir 中的数据仍然存在</li><li>当 Pod 被删除或重新创建时，原来的 emptyDir 也会被删除</li></ul><p>因此当节点故障时，emptyDir 中的数据是无法恢复的，所以这就决定了 emptyDir 适合缓存、临时计算数据，以及在同一个 Pod 中多个容器共享文件，但不适合保存数据库等重要数据</p><h2 id="-emptydir">使用文件目录作为 emptyDir</h2><p>下面的 Pod 中，writer 容器向卷中写入时间，reader 容器读取同一份文件；别忘了 Pod 中可以存在多个容器</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">cat &gt; emptydir.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Pod
metadata:
  name: emptydir-demo
spec:
  containers:
  - name: writer
    image: busybox:1.36
    command: [&quot;sh&quot;, &quot;-c&quot;]
    args:
    - while true; do date &gt;&gt; /data/time.txt; sleep 5; done
    volumeMounts:
    - name: shared-data
      mountPath: /data
  - name: reader
    image: busybox:1.36
    command: [&quot;sh&quot;, &quot;-c&quot;]
    args:
    - tail -f /data/time.txt
    volumeMounts:
    - name: shared-data
      mountPath: /data
  volumes:
  - name: shared-data
    emptyDir: {}
EOF

kubectl apply -f emptydir.yaml

kubectl logs emptydir-demo -c reader

kubectl delete pod emptydir-demo
</code></pre><p>从输出可以看，两个容器各自独立的容器文件系统，但是通过共享一个卷，实现了 writer 写文件、reader 读文件
<img height="522" src="https://raymq.com/api/v3/objects/image/x7uiaa8c7e2bde0b9j.png" width="1016"/></p><h2 id="-emptydir">使用内存作为 emptyDir</h2><p>如果把 medium 设置为 Memory，emptyDir 就会使用 tmpfs，此时数据主要保存在内存中</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumes:
- name: cache
  emptyDir:
    medium: Memory
    sizeLimit: 128Mi
</code></pre><p>内存卷速度很快，适合保存少量临时数据；写入其中的数据会计入内存使用量，使用不当可能触发内存不足，因此最好设置 sizeLimit，并为容器配置合理的内存限制</p><h1 id="hostpath-">hostPath 卷</h1><p>hostPath 可以把节点上的文件或目录直接挂载到 Pod 中，效果与 Docker 的 bind mount</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">apiVersion: v1
kind: Pod
metadata:
  name: hostpath-demo
spec:
  containers:
  - name: nginx
    image: nginx:latest
    volumeMounts:
    - name: node-data
      mountPath: /data
      readOnly: true
  volumes:
  - name: node-data
    hostPath:
      path: /data/demo
      type: Directory
</code></pre><p>hostPath 依赖 Pod 所在节点：同样的 <code>/data/demo</code>，在不同节点上是不同的目录。如果 Pod 被重新调度到另一个节点，它看到的数据也会改变</p><p>常见的 type 有：</p><ul><li>Directory：目录必须已经存在</li><li>DirectoryOrCreate：目录不存在时由 kubelet 创建</li><li>File：文件必须已经存在</li><li>FileOrCreate：文件不存在时创建空文件，但不会自动创建父目录</li><li>Socket：必须是 UNIX Socket</li></ul><p>hostPath 能让容器接触节点文件系统、容器运行时 Socket 或 kubelet 凭据，具有较高的安全风险；普通业务 Pod 应尽量避免使用，确实需要时应限制路径并优先只读挂载</p><p>hostPath 比较常见于需要读取节点日志、设备或系统信息的 DaemonSet，而不是普通 Web 应用的数据存储</p><h2 id="-subpath">使用 subPath</h2><p>默认情况下，volumeMounts 会把整个卷挂载到 mountPath。如果只想挂载卷中的某个子目录或文件，可以使用 subPath</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">volumeMounts:
- name: app-data
  mountPath: /var/lib/app
  subPath: data
</code></pre><p>这表示把 app-data 卷中的 data 子目录挂载到 <code>/var/lib/app</code></p><p>需要注意，ConfigMap 或 Secret 使用 subPath 挂载时，后续更新不会自动同步到容器中；这部分会在下一章继续学习</p><h1 id="-git-">获取 Git 仓库中的文件</h1><p>早期 Kubernetes 曾经提供 gitRepo 卷，但它已经被弃用，并在新的 Kubernetes 版本中移除，所以下面这种配置</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml"># 已经过时，不要使用
volumes:
- name: source
  gitRepo:
    repository: https://example.com/demo.git
</code></pre><p>现在可以使用 init Container 把代码克隆到 emptyDir，再由业务容器读取</p><pre class="language-yaml lang-yaml"><code class="language-yaml lang-yaml">apiVersion: v1
kind: Pod
metadata:
  name: git-source-demo
spec:
  initContainers:
  - name: clone
    image: alpine/git:latest
    args:
    - clone
    - --depth=1
    - https://github.com/kubernetes/website.git
    - /work/repo
    volumeMounts:
    - name: source
      mountPath: /work
  containers:
  - name: reader
    image: busybox:1.36
    command: [&quot;sh&quot;, &quot;-c&quot;]
    args: [&quot;ls -lah /source &amp;&amp; sleep 3600&quot;]
    volumeMounts:
    - name: source
      mountPath: /source
      subPath: repo
  volumes:
  - name: source
    emptyDir: {}
</code></pre><p>init Container 必须成功完成，业务容器才会启动；这种方式只会在 Pod 创建时克隆一次，如果仓库发生变化，Pod 中的内容不会自动更新</p><h1 id="">临时卷和持久卷</h1><p>emptyDir 与 hostPath 都没有解决应用在不同节点之间长期保存数据的问题</p><pre class="language-text lang-text"><code class="language-text lang-text">emptyDir
  -&gt; 跟随 Pod 生命周期
  -&gt; Pod 删除后数据消失

hostPath
  -&gt; 跟随节点
  -&gt; Pod 换到其他节点后数据不同

持久存储
  -&gt; 独立于 Pod
  -&gt; 可以通过 PVC 交给 Pod 使用
</code></pre><p>对于数据库、用户上传文件等重要数据，通常需要 PersistentVolume、PersistentVolumeClaim、StorageClass 和 CSI 存储；后面的章节会继续学习</p><h1 id="">总结</h1><p>Kubernetes 的卷由 volumes 定义来源，由 volumeMounts 决定容器中的挂载位置</p><p>emptyDir 适合 Pod 内的临时共享数据，hostPath 适合少量确实需要访问节点文件的系统组件；它们都不能代替真正的持久存储</p><p>早期的 gitRepo 卷已经过时，需要改用 init Container 加 emptyDir；使用 hostPath 时，也应首先考虑节点依赖和安全风险</p><p>这一章说实话，我没怎么理解到位，因为生成的资料没有逻辑感，而我又是头一次看，所以文章质量不怎么高</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/volume#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/volume</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/volume</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Thu, 03 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[Curl 命令]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/lz9xsi3n8tj6cy5quw.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/v45mdr85vuptw331zr.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/hgxmh04e8mhentl0cr.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/w8cnj49cn40twldy87.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/s5vw6jrwmy84tkt2xl.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/wk2dtn1h0spymyf93r.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/j01jg3x50gi5msi39f.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/7lz3673sew4pvual4a.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/nzpruc5r605e54gnyk.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/yqn4mr6fdbvn908j5u.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/ojrgqcabz9egr55it9.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/ncpqsct21wnxfm8flm.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/compnet/curl-study">https://raymq.com/posts/compnet/curl-study</a></blockquote><div><p>今天我们来看一下与 curl 相关的知识，学习的原因是因为最近在做接口开发，需要了解一下 curl 相关的参数</p><p>curl 是一个命令行工具，可以通过 URL，向服务器发送请求并获取数据，常用于测试 HTTP/HTTPS 接口、排查网络问题、调试 Web 服务等...</p><h1 id="">运行程序</h1><p>还是使用 Go 写一份程序来进行学习，当然看不懂也没关系因为是 AI 写的</p><pre class=""><code class="">package main

import (
    &quot;compress/gzip&quot;
    &quot;crypto/rand&quot;
    &quot;crypto/rsa&quot;
    &quot;crypto/sha256&quot;
    &quot;crypto/tls&quot;
    &quot;crypto/x509&quot;
    &quot;crypto/x509/pkix&quot;
    &quot;encoding/hex&quot;
    &quot;encoding/json&quot;
    &quot;encoding/pem&quot;
    &quot;fmt&quot;
    &quot;io&quot;
    &quot;log&quot;
    &quot;math/big&quot;
    &quot;mime&quot;
    &quot;net&quot;
    &quot;net/http&quot;
    &quot;net/url&quot;
    &quot;os&quot;
    &quot;path/filepath&quot;
    &quot;strconv&quot;
    &quot;strings&quot;
    &quot;time&quot;
)

const (
    maxBodyBytes   = 1 &lt;&lt; 20  // 1 MiB
    maxUploadBytes = 10 &lt;&lt; 20 // 10 MiB
    defaultHTTP    = &quot;:8080&quot;
    defaultHTTPS   = &quot;:8443&quot;
)

type basicAuthInfo struct {
    Provided       bool   `json:&quot;provided&quot;`
    Username       string `json:&quot;username,omitempty&quot;`
    PasswordLength int    `json:&quot;password_length,omitempty&quot;`
}

type requestInfo struct {
    Method    string            `json:&quot;method&quot;`
    Path      string            `json:&quot;path&quot;`
    Host      string            `json:&quot;host&quot;`
    Query     url.Values        `json:&quot;query,omitempty&quot;`
    Headers   map[string]string `json:&quot;headers,omitempty&quot;`
    Cookies   map[string]string `json:&quot;cookies,omitempty&quot;`
    Body      string            `json:&quot;body,omitempty&quot;`
    JSONBody  interface{}       `json:&quot;json,omitempty&quot;`
    Form      url.Values        `json:&quot;form,omitempty&quot;`
    BasicAuth *basicAuthInfo    `json:&quot;basic_auth,omitempty&quot;`
}

type uploadedFile struct {
    Field       string `json:&quot;field&quot;`
    Filename    string `json:&quot;filename&quot;`
    Size        int64  `json:&quot;size&quot;`
    ContentType string `json:&quot;content_type&quot;`
    SHA256      string `json:&quot;sha256&quot;`
}

func main() {
    mux := http.NewServeMux()
    registerRoutes(mux)

    handler := requestLogMiddleware(commonHeadersMiddleware(mux))

    httpAddress := envOrDefault(&quot;HTTP_ADDR&quot;, defaultHTTP)
    httpsAddress := envOrDefault(&quot;HTTPS_ADDR&quot;, defaultHTTPS)

    httpListener, err := net.Listen(&quot;tcp&quot;, httpAddress)
    if err != nil {
        log.Fatalf(&quot;HTTP listen %s failed: %v&quot;, httpAddress, err)
    }

    httpServer := newServer(httpAddress, handler)
    errCh := make(chan error, 2)

    if os.Getenv(&quot;DISABLE_HTTPS&quot;) != &quot;1&quot; {
        certificate, certErr := newSelfSignedCertificate()
        if certErr != nil {
            log.Fatalf(&quot;generate HTTPS certificate: %v&quot;, certErr)
        }

        tlsConfig := &amp;tls.Config{
            Certificates: []tls.Certificate{certificate},
            MinVersion:   tls.VersionTLS12,
        }

        httpsListener, listenErr := tls.Listen(&quot;tcp&quot;, httpsAddress, tlsConfig)
        if listenErr != nil {
            log.Fatalf(&quot;HTTPS listen %s failed: %v&quot;, httpsAddress, listenErr)
        }

        httpsServer := newServer(httpsAddress, handler)

        log.Printf(
            &quot;HTTPS curl lab listening on %s (self-signed certificate)&quot;,
            httpsAddress,
        )

        go func() {
            errCh &lt;- httpsServer.Serve(httpsListener)
        }()
    }

    log.Printf(&quot;HTTP curl lab listening on %s&quot;, httpAddress)
    log.Printf(
        &quot;Try: curl -i http://127.0.0.1%s/&quot;,
        displayPort(httpAddress),
    )

    go func() {
        errCh &lt;- httpServer.Serve(httpListener)
    }()

    log.Fatal(&lt;-errCh)
}

func newServer(address string, handler http.Handler) *http.Server {
    return &amp;http.Server{
        Addr:              address,
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       15 * time.Second,
        WriteTimeout:      30 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
}

func registerRoutes(mux *http.ServeMux) {
    mux.HandleFunc(&quot;/help&quot;, helpHandler)
    mux.HandleFunc(&quot;/echo&quot;, echoHandler)
    mux.HandleFunc(&quot;/target&quot;, echoHandler)
    mux.HandleFunc(&quot;/upload&quot;, uploadHandler)
    mux.HandleFunc(&quot;/auth/basic&quot;, basicAuthHandler)
    mux.HandleFunc(&quot;/cookies/set&quot;, setCookieHandler)
    mux.HandleFunc(&quot;/cookies&quot;, echoHandler)
    mux.HandleFunc(&quot;/delay&quot;, delayHandler)
    mux.HandleFunc(&quot;/gzip&quot;, gzipHandler)
    mux.HandleFunc(&quot;/files/test.txt&quot;, downloadHandler)
    mux.HandleFunc(&quot;/206&quot;, rangeHandler)
    mux.HandleFunc(&quot;/304&quot;, cacheHandler)
    mux.HandleFunc(&quot;/status/&quot;, dynamicStatusHandler)

    for _, status := range []int{
        http.StatusOK,
        http.StatusNoContent,
        http.StatusBadRequest,
        http.StatusUnauthorized,
        http.StatusPaymentRequired,
        http.StatusForbidden,
        http.StatusNotFound,
        http.StatusTeapot,
        http.StatusTooManyRequests,
        http.StatusInternalServerError,
        http.StatusNotImplemented,
        http.StatusBadGateway,
        http.StatusServiceUnavailable,
        http.StatusGatewayTimeout,
    } {
        code := status

        mux.HandleFunc(
            fmt.Sprintf(&quot;/%d&quot;, code),
            func(w http.ResponseWriter, r *http.Request) {
                serveStatus(w, code)
            },
        )
    }

    for _, status := range []int{
        http.StatusMovedPermanently,
        http.StatusFound,
        http.StatusSeeOther,
        http.StatusTemporaryRedirect,
        http.StatusPermanentRedirect,
    } {
        code := status

        mux.HandleFunc(
            fmt.Sprintf(&quot;/%d&quot;, code),
            func(w http.ResponseWriter, r *http.Request) {
                http.Redirect(w, r, &quot;/target&quot;, code)
            },
        )
    }

    // Catch-all route.
    //
    // 这样 /api、/users、/login、/search 等任意路径
    // 都可以直接用于 curl 请求实验。
    mux.HandleFunc(&quot;/&quot;, echoHandler)
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
    request, err := inspectRequest(w, r)
    if err != nil {
        writeProblem(
            w,
            http.StatusRequestEntityTooLarge,
            err.Error(),
        )
        return
    }

    writeJSON(w, http.StatusOK, request)
}

func inspectRequest(
    w http.ResponseWriter,
    r *http.Request,
) (*requestInfo, error) {
    body, err := readBody(w, r, maxBodyBytes)
    if err != nil {
        return nil, fmt.Errorf(
            &quot;request body exceeds the 1 MiB echo limit&quot;,
        )
    }

    info := &amp;requestInfo{
        Method:  r.Method,
        Path:    r.URL.Path,
        Host:    r.Host,
        Query:   r.URL.Query(),
        Headers: safeHeaders(r.Header),
        Body:    string(body),
    }

    if len(r.Cookies()) &gt; 0 {
        info.Cookies = make(map[string]string)

        for _, cookie := range r.Cookies() {
            info.Cookies[cookie.Name] = cookie.Value
        }
    }

    if username, password, ok := r.BasicAuth(); ok {
        info.BasicAuth = &amp;basicAuthInfo{
            Provided:       true,
            Username:       username,
            PasswordLength: len(password),
        }
    }

    mediaType, _, _ := mime.ParseMediaType(
        r.Header.Get(&quot;Content-Type&quot;),
    )

    switch mediaType {
    case &quot;application/json&quot;:
        var value interface{}

        if len(body) &gt; 0 &amp;&amp; json.Unmarshal(body, &amp;value) == nil {
            info.JSONBody = value
            info.Body = &quot;&quot;
        }

    case &quot;application/x-www-form-urlencoded&quot;:
        form, parseErr := url.ParseQuery(string(body))
        if parseErr == nil {
            info.Form = form
            info.Body = &quot;&quot;
        }
    }

    return info, nil
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.Header().Set(&quot;Allow&quot;, http.MethodPost)

        writeProblem(
            w,
            http.StatusMethodNotAllowed,
            &quot;use POST with curl -F &#x27;file=@test.txt&#x27;&quot;,
        )
        return
    }

    r.Body = http.MaxBytesReader(
        w,
        r.Body,
        maxUploadBytes,
    )

    if err := r.ParseMultipartForm(2 &lt;&lt; 20); err != nil {
        writeProblem(
            w,
            http.StatusBadRequest,
            &quot;expected multipart/form-data; upload limit is 10 MiB&quot;,
        )
        return
    }

    if r.MultipartForm != nil {
        defer r.MultipartForm.RemoveAll()
    }

    files := make([]uploadedFile, 0)

    for field, fileHeaders := range r.MultipartForm.File {
        for _, fileHeader := range fileHeaders {
            file, err := fileHeader.Open()
            if err != nil {
                writeProblem(
                    w,
                    http.StatusBadRequest,
                    &quot;cannot read uploaded file&quot;,
                )
                return
            }

            hash := sha256.New()

            _, copyErr := io.Copy(hash, file)
            file.Close()

            if copyErr != nil {
                writeProblem(
                    w,
                    http.StatusBadRequest,
                    &quot;cannot hash uploaded file&quot;,
                )
                return
            }

            files = append(files, uploadedFile{
                Field:       field,
                Filename:    filepath.Base(fileHeader.Filename),
                Size:        fileHeader.Size,
                ContentType: fileHeader.Header.Get(&quot;Content-Type&quot;),
                SHA256:      hex.EncodeToString(hash.Sum(nil)),
            })
        }
    }

    writeJSON(
        w,
        http.StatusOK,
        map[string]interface{}{
            &quot;message&quot;: &quot;文件上传成功。&quot;,
            &quot;fields&quot;:  r.MultipartForm.Value,
            &quot;files&quot;:   files,
        },
    )
}

func basicAuthHandler(w http.ResponseWriter, r *http.Request) {
    username, password, ok := r.BasicAuth()
    if !ok {
        w.Header().Set(
            &quot;WWW-Authenticate&quot;,
            `Basic realm=&quot;Curl Lab&quot;`,
        )

        writeProblem(
            w,
            http.StatusUnauthorized,
            &quot;try: curl -u ray:password http://127.0.0.1:8080/auth/basic&quot;,
        )
        return
    }

    writeJSON(
        w,
        http.StatusOK,
        map[string]interface{}{
            &quot;message&quot;:         &quot;Basic Auth 凭据已收到（密码不会回显）。&quot;,
            &quot;username&quot;:        username,
            &quot;password_length&quot;: len(password),
        },
    )
}

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    http.SetCookie(
        w,
        &amp;http.Cookie{
            Name:     &quot;curl_demo&quot;,
            Value:    &quot;cookie_received&quot;,
            Path:     &quot;/&quot;,
            HttpOnly: true,
            SameSite: http.SameSiteLaxMode,
        },
    )

    writeJSON(
        w,
        http.StatusOK,
        map[string]string{
            &quot;message&quot;: &quot;cookie 已通过 Set-Cookie 响应头返回；可用 curl -c cookies.txt 保存。&quot;,
        },
    )
}

func delayHandler(w http.ResponseWriter, r *http.Request) {
    seconds := 3.0

    if raw := r.URL.Query().Get(&quot;seconds&quot;); raw != &quot;&quot; {
        parsed, err := strconv.ParseFloat(raw, 64)
        if err != nil || parsed &lt; 0 || parsed &gt; 15 {
            writeProblem(
                w,
                http.StatusBadRequest,
                &quot;seconds must be a number between 0 and 15&quot;,
            )
            return
        }

        seconds = parsed
    }

    delay := time.Duration(
        seconds * float64(time.Second),
    )

    select {
    case &lt;-time.After(delay):
        writeJSON(
            w,
            http.StatusOK,
            map[string]interface{}{
                &quot;message&quot;:       &quot;延迟响应完成。&quot;,
                &quot;delay_seconds&quot;: seconds,
            },
        )

    case &lt;-r.Context().Done():
        log.Printf(
            &quot;client canceled delayed request: %v&quot;,
            r.Context().Err(),
        )
    }
}

func downloadHandler(w http.ResponseWriter, r *http.Request) {
    content := `This file came from the Curl learning server.
Try curl -O http://127.0.0.1:8080/files/test.txt
`

    w.Header().Set(
        &quot;Content-Disposition&quot;,
        `attachment; filename=&quot;test.txt&quot;`,
    )

    w.Header().Set(
        &quot;Content-Type&quot;,
        &quot;text/plain; charset=utf-8&quot;,
    )

    http.ServeContent(
        w,
        r,
        &quot;test.txt&quot;,
        time.Date(
            2026,
            9,
            2,
            0,
            0,
            0,
            0,
            time.UTC,
        ),
        strings.NewReader(content),
    )
}

func rangeHandler(w http.ResponseWriter, r *http.Request) {
    content := &quot;0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n&quot;

    w.Header().Set(
        &quot;Content-Type&quot;,
        &quot;text/plain; charset=utf-8&quot;,
    )

    w.Header().Set(
        &quot;Accept-Ranges&quot;,
        &quot;bytes&quot;,
    )

    http.ServeContent(
        w,
        r,
        &quot;range-demo.txt&quot;,
        time.Date(
            2026,
            9,
            2,
            0,
            0,
            0,
            0,
            time.UTC,
        ),
        strings.NewReader(content),
    )
}

func cacheHandler(w http.ResponseWriter, r *http.Request) {
    const etag = `&quot;curl-lab-v1&quot;`

    w.Header().Set(&quot;ETag&quot;, etag)
    w.Header().Set(
        &quot;Cache-Control&quot;,
        &quot;private, max-age=60&quot;,
    )

    if r.Header.Get(&quot;If-None-Match&quot;) == etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }

    writeJSON(
        w,
        http.StatusOK,
        map[string]string{
            &quot;message&quot;: `再次请求时添加 If-None-Match: &quot;curl-lab-v1&quot;，即可看到 304。`,
        },
    )
}

func gzipHandler(w http.ResponseWriter, r *http.Request) {
    message := strings.Repeat(
        &quot;curl compression demo; &quot;,
        20,
    ) + &quot;\n&quot;

    w.Header().Set(
        &quot;Content-Type&quot;,
        &quot;text/plain; charset=utf-8&quot;,
    )

    w.Header().Set(
        &quot;Vary&quot;,
        &quot;Accept-Encoding&quot;,
    )

    if !strings.Contains(
        r.Header.Get(&quot;Accept-Encoding&quot;),
        &quot;gzip&quot;,
    ) {
        fmt.Fprint(w, message)
        return
    }

    w.Header().Set(
        &quot;Content-Encoding&quot;,
        &quot;gzip&quot;,
    )

    compressor := gzip.NewWriter(w)
    defer compressor.Close()

    fmt.Fprint(compressor, message)
}

func dynamicStatusHandler(
    w http.ResponseWriter,
    r *http.Request,
) {
    raw := strings.TrimPrefix(
        r.URL.Path,
        &quot;/status/&quot;,
    )

    code, err := strconv.Atoi(raw)
    if err != nil || code &lt; 200 || code &gt; 599 {
        writeProblem(
            w,
            http.StatusBadRequest,
            &quot;use /status/{code}, where code is between 200 and 599&quot;,
        )
        return
    }

    serveStatus(w, code)
}

func serveStatus(w http.ResponseWriter, code int) {
    switch code {
    case http.StatusUnauthorized:
        w.Header().Set(
            &quot;WWW-Authenticate&quot;,
            `Basic realm=&quot;Curl Lab&quot;`,
        )

    case http.StatusTooManyRequests:
        w.Header().Set(
            &quot;Retry-After&quot;,
            &quot;10&quot;,
        )

    case http.StatusServiceUnavailable:
        w.Header().Set(
            &quot;Retry-After&quot;,
            &quot;60&quot;,
        )
    }

    if code == http.StatusNoContent ||
        code == http.StatusNotModified {
        w.WriteHeader(code)
        return
    }

    statusText := http.StatusText(code)
    if statusText == &quot;&quot; {
        statusText = &quot;Custom Status&quot;
    }

    writeJSON(
        w,
        code,
        map[string]interface{}{
            &quot;status&quot;:  code,
            &quot;message&quot;: statusText,
        },
    )
}

func helpHandler(w http.ResponseWriter, r *http.Request) {
    writeJSON(
        w,
        http.StatusOK,
        map[string]interface{}{
            &quot;message&quot;: &quot;Curl 学习服务器端点&quot;,

            &quot;endpoints&quot;: map[string]string{
                &quot;any path&quot;: &quot;回显 method、URL、query、headers、body、form、JSON 和 Basic Auth 信息&quot;,

                &quot;/help&quot;:
                    &quot;查看本帮助&quot;,

                &quot;/301 /302 /303 /307 /308&quot;:
                    &quot;重定向到 /target，用 -L 观察 method 是否变化&quot;,

                &quot;/200 /204 /400 ...&quot;:
                    &quot;返回对应状态码，也可使用 /status/{code}&quot;,

                &quot;/206&quot;:
                    &quot;配合 -H &#x27;Range: bytes=0-9&#x27; 观察 206&quot;,

                &quot;/304&quot;:
                    `配合 -H &#x27;If-None-Match: &quot;curl-lab-v1&quot;&#x27; 观察缓存`,

                &quot;/upload&quot;:
                    &quot;配合 -F &#x27;file=@test.txt&#x27; 上传文件&quot;,

                &quot;/auth/basic&quot;:
                    &quot;配合 -u username:password 发送 Basic Auth&quot;,

                &quot;/cookies/set&quot;:
                    &quot;设置 cookie；/cookies 用于观察客户端发回的 cookie&quot;,

                &quot;/delay?seconds=3&quot;:
                    &quot;延迟响应，用于 --max-time 练习&quot;,

                &quot;/files/test.txt&quot;:
                    &quot;配合 -o 或 -O 下载文件&quot;,

                &quot;/gzip&quot;:
                    &quot;配合 --compressed 观察 gzip&quot;,

                &quot;https://127.0.0.1:8443&quot;:
                    &quot;自签名 HTTPS，配合 -k 使用&quot;,
            },
        },
    )
}

func readBody(
    w http.ResponseWriter,
    r *http.Request,
    limit int64,
) ([]byte, error) {
    if r.Body == nil || r.Body == http.NoBody {
        return nil, nil
    }

    r.Body = http.MaxBytesReader(
        w,
        r.Body,
        limit,
    )

    defer r.Body.Close()

    return io.ReadAll(r.Body)
}

func safeHeaders(headers http.Header) map[string]string {
    result := make(
        map[string]string,
        len(headers),
    )

    for key, values := range headers {
        canonical := http.CanonicalHeaderKey(key)

        switch canonical {
        case &quot;Authorization&quot;, &quot;Proxy-Authorization&quot;:
            result[canonical] = &quot;&lt;redacted&gt;&quot;

        case &quot;Cookie&quot;:
            result[canonical] = &quot;&lt;redacted&gt;&quot;

        default:
            result[canonical] = strings.Join(values, &quot;, &quot;)
        }
    }

    return result
}

func commonHeadersMiddleware(
    next http.Handler,
) http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set(
                &quot;X-Curl-Lab&quot;,
                &quot;true&quot;,
            )

            w.Header().Set(
                &quot;X-Request-Method&quot;,
                r.Method,
            )

            next.ServeHTTP(w, r)
        },
    )
}

func requestLogMiddleware(
    next http.Handler,
) http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            started := time.Now()

            next.ServeHTTP(w, r)

            log.Printf(
                &quot;%s %s host=%q remote=%s duration=%s&quot;,
                r.Method,
                r.URL.RequestURI(),
                r.Host,
                r.RemoteAddr,
                time.Since(started).Round(time.Millisecond),
            )
        },
    )
}

func writeJSON(
    w http.ResponseWriter,
    status int,
    value interface{},
) {
    w.Header().Set(
        &quot;Content-Type&quot;,
        &quot;application/json; charset=utf-8&quot;,
    )

    w.WriteHeader(status)

    encoder := json.NewEncoder(w)
    encoder.SetEscapeHTML(false)
    encoder.SetIndent(&quot;&quot;, &quot;  &quot;)

    if err := encoder.Encode(value); err != nil {
        log.Printf(
            &quot;encode response: %v&quot;,
            err,
        )
    }
}

func writeProblem(
    w http.ResponseWriter,
    status int,
    message string,
) {
    writeJSON(
        w,
        status,
        map[string]interface{}{
            &quot;status&quot;:  status,
            &quot;error&quot;:   http.StatusText(status),
            &quot;message&quot;: message,
        },
    )
}

func envOrDefault(
    name string,
    fallback string,
) string {
    if value := strings.TrimSpace(
        os.Getenv(name),
    ); value != &quot;&quot; {
        return value
    }

    return fallback
}

func displayPort(address string) string {
    if _, port, err := net.SplitHostPort(address); err == nil {
        return &quot;:&quot; + port
    }

    if strings.HasPrefix(address, &quot;:&quot;) {
        return address
    }

    return &quot;:8080&quot;
}

func newSelfSignedCertificate() (
    tls.Certificate,
    error,
) {
    privateKey, err := rsa.GenerateKey(
        rand.Reader,
        2048,
    )
    if err != nil {
        return tls.Certificate{}, err
    }

    serialLimit := new(big.Int).Lsh(
        big.NewInt(1),
        128,
    )

    serialNumber, err := rand.Int(
        rand.Reader,
        serialLimit,
    )
    if err != nil {
        return tls.Certificate{}, err
    }

    now := time.Now()

    template := x509.Certificate{
        SerialNumber: serialNumber,

        Subject: pkix.Name{
            CommonName: &quot;Curl Learning Server&quot;,

            Organization: []string{
                &quot;Local Curl Lab&quot;,
            },
        },

        NotBefore: now.Add(-time.Hour),
        NotAfter:  now.Add(24 * time.Hour),

        KeyUsage: x509.KeyUsageKeyEncipherment |
            x509.KeyUsageDigitalSignature,

        ExtKeyUsage: []x509.ExtKeyUsage{
            x509.ExtKeyUsageServerAuth,
        },

        BasicConstraintsValid: true,

        DNSNames: []string{
            &quot;localhost&quot;,
        },

        IPAddresses: []net.IP{
            net.ParseIP(&quot;127.0.0.1&quot;),
            net.ParseIP(&quot;::1&quot;),
        },
    }

    certificateDER, err := x509.CreateCertificate(
        rand.Reader,
        &amp;template,
        &amp;template,
        &amp;privateKey.PublicKey,
        privateKey,
    )
    if err != nil {
        return tls.Certificate{}, err
    }

    certificatePEM := pem.EncodeToMemory(
        &amp;pem.Block{
            Type:  &quot;CERTIFICATE&quot;,
            Bytes: certificateDER,
        },
    )

    privateKeyPEM := pem.EncodeToMemory(
        &amp;pem.Block{
            Type: &quot;RSA PRIVATE KEY&quot;,
            Bytes: x509.MarshalPKCS1PrivateKey(
                privateKey,
            ),
        },
    )

    return tls.X509KeyPair(
        certificatePEM,
        privateKeyPEM,
    )
}
</code></pre><h1 id="1--get-">1. 最基本的 GET 请求</h1><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl http://127.0.0.1:8080/echo
</code></pre><p>意思就是，像指定的 URL 发送 GET 请求，服务器默认只输出响应体
<img height="476" src="https://raymq.com/api/v3/objects/image/lz9xsi3n8tj6cy5quw.png" width="726"/></p><h1 id="2--http-">2. 查看 HTTP 响应头</h1><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i http://127.0.0.1:8080/echo
</code></pre><ul><li>-i 参数是 include 的意思，表示同时显示 响应头 + 响应体
可以看到，输出包含了 响应头 与 响应体，但仍然是 GET 请求
<img src="https://raymq.com/api/v3/objects/image/v45mdr85vuptw331zr.png"/>
# 3. 只查看响应头
<code>bash
curl -I http://127.0.0.1:8080/echo
</code></li><li>-I 参数表示 只发送 HEAD 请求
可以看到，服务器只返回响应头，不返回响应体
<img src="https://raymq.com/api/v3/objects/image/hgxmh04e8mhentl0cr.png"/>
# 4. 查看完整请求过程
<code>bash
curl -v http://127.0.0.1:8080/echo
</code></li><li>-v 参数是 verbose 的意思，会显示 连接过程、请求头、响应头、连接复用或关闭
其中 &gt; 表示 curl 发出的 HTTP 数据；&lt; 表示服务器返回的数据
<img src="https://raymq.com/api/v3/objects/image/w8cnj49cn40twldy87.png"/>
# 5. 指定 HTTP 方法
GET
<code>bash
curl -X GET http://127.0.0.1:8080/echo
</code>
POST
<code>bash
curl -X POST http://127.0.0.1:8080/echo
</code>
PUT
<code>bash
curl -X PUT http://127.0.0.1:8080/echo
</code>
DELETE
<code>bash
curl -X DELETE http://127.0.0.1:8080/echo
</code></li><li>-X 参数用于指定 HTTP 请求方法
这里就不继续展示了，因为响应基本都差不多，这里程序没有特别要求
<img src="https://raymq.com/api/v3/objects/image/s5vw6jrwmy84tkt2xl.png"/>
很多时候不需要显示写 -X GET，因为直接 curl 默认使用的 HTTP 方法就是 GET
# 6. 添加请求头
<code>bash
curl -H &#x27;Accept: application/json&#x27; http://127.0.0.1:8080/echo
</code>
添加自定义 Header
<code>bash
curl -H &#x27;X-Test: hello&#x27; http://127.0.0.1:8080/echo
</code></li><li>-H 参数用于添加请求头，比如请求头的格式、Token等...
<img src="https://raymq.com/api/v3/objects/image/wk2dtn1h0spymyf93r.png"/>
当然有多个 Header 可以写多个 -H
<code>bash
curl -H &#x27;Accept: application/json&#x27; -H &#x27;X-Test: hello&#x27; -H &#x27;X-Request-ID: 123456&#x27; http://127.0.0.1:8080/echo
</code>
<img src="https://raymq.com/api/v3/objects/image/j01jg3x50gi5msi39f.png"/>
# 7. Content-Type
Content-Type 的意思是发送数据时，需要告诉服务器 请求体是什么格式，比如</li><li>application/json 发送的是 JSON 格式的数据</li><li>application/x-www-form-urlencoded 发送的是表单数据，使用 URL 编码格式（ key1=value1&amp;key2=value2 形式）</li><li>multipart/form-data 发送的是多部分表单数据，用于<strong>文件上传</strong> 或 混合文本 + 文件
## 使用 -d 发送数据</li><li>-d 参数是 --data 的意思，也就是发送数据；使用 -d 会默认使用 POST
## 发送表单数据
<code>bash
curl -d &#x27;username=ray&amp;password=123456&#x27; http://127.0.0.1:8080/echo
</code>
通过响应体我们可以看到 HTTP 方法、响应头、响应体，以及可以看到数据格式，因为我们使用的是 URL 编码格式的数据，所以这里的输出就是传统 HTML 表单数据；当然这里是我对输出进行了清洗，原本输出可能不长这样，但是意思还是那么个意思
<img src="https://raymq.com/api/v3/objects/image/7lz3673sew4pvual4a.png"/>
## JSON 数据
<code>bash
curl -H &#x27;Content-Type: application/json&#x27; -d &#x27;{&quot;username&quot;:&quot;ray&quot;,&quot;password&quot;:&quot;123456&quot;}&#x27; http://127.0.0.1:8080/echo
</code>
可以看到，响应体是 json 的格式
<img src="https://raymq.com/api/v3/objects/image/nzpruc5r605e54gnyk.png"/>
# 8. GET Query 参数
## 普通写法
<code>bash
curl &#x27;http://127.0.0.1:8080/echo?page=1&amp;size=20&#x27;
</code>
通过响应体，我们可以看到，Query 的参数位于 <code>?</code> 后面，以及多个参数可以通过 <code>&amp;</code> 连接
<img src="https://raymq.com/api/v3/objects/image/yqn4mr6fdbvn908j5u.png"/>
## 使用 -G 构造 Query
当参数复杂时，可以使用 —G 参数进行请求</li><li>-G 参数的意思是 把 -d、--data-urlencode 等数据放到 URL Query 中；而不是放到请求体中
<code>bash
curl -G &#x27;http://127.0.0.1:8080/users&#x27; --data-urlencode &#x27;page=1&#x27; --data-urlencode &#x27;keyword=hello world&#x27;
</code>
<img src="https://raymq.com/api/v3/objects/image/ojrgqcabz9egr55it9.png"/>
而使用普通写法的话，就是以下的写法；20% 是  空格字符编码
<code>bash
curl &#x27;http://127.0.0.1:8080/users?page=1&amp;keyword=hello%20world&#x27;
</code>
# 9. 自动 URL 编码</li><li>--data-urlencode 参数的意思是 <strong>自动对数据进行 URL 编码</strong>后再发送；因为 curl 会自动处理空格和特殊字符，这样会比手动拼 URL 更安全
<code>bash
curl -G &#x27;http://127.0.0.1:8080/search&#x27; --data-urlencode &#x27;keyword=hello world&#x27;
</code>
<img src="https://raymq.com/api/v3/objects/image/ncpqsct21wnxfm8flm.png"/>
# 11. 静默模式</li><li>-s 参数的意思是 silent mode，意思是不显示进度条和错误信息</li><li>-S 参数的意思是 显示错误，也就是<strong>即使是静默模式，出错了也要显示错误信息</strong>
使用是通常是 -sS 一起，一起来看看区别吧
```bash 
curl -s <a href="http://127.0.0.1:9999/echo">http://127.0.0.1:9999/echo</a></li></ul><p>curl -sS <a href="http://127.0.0.1:9999/echo">http://127.0.0.1:9999/echo</a></p><pre class=""><code class="">可以看到，使用 -s 时，即使发生错误了，也不会有错误信息输出
![](https://raymq.com/api/v3/objects/image/hzrkiv9do9c9co6b5d.png)
# 12. 重定向
这个我们很熟悉了，HTTP 响应是 302 ，而自动跟随重定向的参数是 -L
- -L 参数表示的意思是 自动跟随重定向到指定的 URL 中
这里我们使用 -i 查看完整请求过程，使用 -L 进行重定向
</code></pre><p>bash
curl -i -L <a href="http://127.0.0.1:8080/302">http://127.0.0.1:8080/302</a></p><pre class=""><code class="">可以看到，响应头从 302 -&gt; 200
![](https://raymq.com/api/v3/objects/image/0ixnuj3lb7m7x8i2at.png)
# 13. 代理
## 系统代理
在使用 curl 的时候，会读取部分代理环境变量；当然并不只有以下代理
- HTTP_PROXY
- HTTPS_PROXY
- NO_PROXY
查看当前代理
</code></pre><p>bash
env | grep -i proxy</p><pre class=""><code class="">这是我虚拟机中的代理，走的是主机的梯子；所以在 curl 请求某些地址时可能会经过代理 导致出现一些问题
![](https://raymq.com/api/v3/objects/image/bay22u42r222s6j7kt.png)
## 绕过代理
这里也一并学习超时吧
</code></pre><p>bash
curl --noproxy &#x27;*&#x27; -sS -i --connect-timeout 5 --max-time 10 <a href="https://google.com">https://google.com</a></p><pre class=""><code class="">- `*` 参数表示 所有地址都不使用代理
- --connect-timeout 参数表示 TCP 建立连接最多等待几秒，这里就是 5s 的意思
- --max-time 参数表示 限制整个 curl 请求的时间，这里就是 curl 这个命令最多等 10s
可以看到，超时了，因为没走代理
![](https://raymq.com/api/v3/objects/image/akx1gw3thx6ns3393p.png)
## 指定代理
</code></pre><p>bash
curl -x <a href="http://127.0.0.1:7890">http://127.0.0.1:7890</a> <a href="https://example.com">https://example.com</a></p><p>curl --proxy <a href="http://127.0.0.1:7890">http://127.0.0.1:7890</a> <a href="https://example.com">https://example.com</a></p><pre class=""><code class="">- -x 参数就是指定代理进行访问，也可以使用 --porxy 参数，一样的效果；这里的 `www.google.com` 与 `goole.com` 其实都是可以的，后者会响应 301 永久重定向，加上 -L 参数跟随即可
![](https://raymq.com/api/v3/objects/image/wdlhby3k5ehvsr8kp0.png)
# 14. 设置 Host
在 HTTP 请求中包含 Host
</code></pre><p>bash
curl -H &#x27;Host: web.example.com&#x27; <a href="http://127.0.0.1:8080/echo">http://127.0.0.1:8080/echo</a></p><pre class=""><code class="">这个请求可能并不规范，但表达的意思是相同的，连接 127.0.0.1 这个 IP，然后请求这个 IP 上的 web.example.com 站点
![](https://raymq.com/api/v3/objects/image/iuz3uprq85x686oea9.png)
如果一个 IP 通过 Nginx 或者使用 k8s 进行 IP 代理后，可能会出现，一个 IP 存在多个战点，这个时候就需要加 Host 用于访问指定站点
# 15. Basic Auth
</code></pre><p>bash
curl -i -u ray:password <a href="http://127.0.0.1:8080/auth/basic">http://127.0.0.1:8080/auth/basic</a></p><pre class=""><code class="">- -u 参数用于发送 HTTP Basic Authentication，格式为 username:password
通过添加 -i 参数观察响应头和响应体，可以看到发送了 Authorization: Basic ...
![](https://raymq.com/api/v3/objects/image/emo7l46hvfnt2qjdf0.png)
# 16. Cookie
cookie 是服务器让浏览器保存的一小段数据，之后浏览器访问同一个网站时，会自动把这段数据带回去；人话就是，一个登陆缓存，保存你登陆的证据
</code></pre><p>bash
curl -i <a href="http://127.0.0.1:8080/cookies/set">http://127.0.0.1:8080/cookies/set</a></p><pre class=""><code class="">可以看到，响应头中有 Set-Cookie
![](https://raymq.com/api/v3/objects/image/chwyd3cwnx7ikbpgt8.png)
使用 -c 参数，对 cookie 进行保存、然后使用 -b 参数，读取 Cookie 并发送给服务器
</code></pre><p>bash
curl -c cookies.txt <a href="http://127.0.0.1:8080/cookies/set">http://127.0.0.1:8080/cookies/set</a></p><p>curl -b cookies.txt <a href="http://127.0.0.1:8080/cookies">http://127.0.0.1:8080/cookies</a></p><pre class=""><code class="">在日常中，我们使用的 cookie 一般来自于浏览器登录时产生的，可以使用 F12 进行查看
![](https://raymq.com/api/v3/objects/image/929g25zwzcdn8249rq.png)
# 17. 耗时
</code></pre><p>bash
time curl -s <a href="http://localhost:8080/echo">http://localhost:8080/echo</a></p><pre class=""><code class="">这个属于 Linux、Mac 自带的 time 命令，其实不能算是 curl 命令中的，但也很常见，会输出三个耗时
- real 实际耗时
- user 用户态 CPU 时间
- sys 内核态 CPU 时间
![](https://raymq.com/api/v3/objects/image/sqa8y0smrmcxwjxgaa.png)

# 18. 一些常见的参数 
| 参数                  | 作用                |
| ------------------- | ----------------- |
| `-i`                | 显示响应头和响应体         |
| `-I`                | 发送 HEAD 请求        |
| `-v`                | 显示详细请求和连接过程       |
| `-s`                | 静默模式              |
| `-S`                | 配合 `-s`，失败时显示错误   |
| `-sS`               | Shell 脚本常用组合      |
| `-X`                | 指定 HTTP Method    |
| `-H`                | 添加 HTTP Header    |
| `-d`                | 发送请求数据            |
| `-G`                | 将数据放入 URL Query   |
| `--data-urlencode`  | 对参数进行 URL 编码      |
| `-L`                | 跟随 HTTP 重定向       |
| `-u`                | Basic Auth        |
| `-F`                | multipart 表单或文件上传 |
| `-o`                | 保存为指定文件名          |
| `-O`                | 使用远程文件名保存         |
| `-k`                | 忽略 HTTPS 证书校验     |
| `-x`                | 指定代理              |
| `--proxy`           | 指定代理              |
| `--noproxy`         | 指定不经过代理的地址        |
| `--connect-timeout` | 设置连接阶段超时          |
| `--max-time`        | 设置整个请求最大时间        |
| `-w`                | 输出状态码、耗时等请求信息     |
| `-c`                | 保存 Cookie         |
| `-b`                | 发送 Cookie         |
| `--compressed`      | 请求压缩响应并自动解压       |
# 总结
到这里，curl 命令常见的就讲解完了，当然是否真的是常见的，也不一定，因为我也会遗漏很多的信息

所以，我常说的，有印象就足够了

愿诸君顺遂

</code></pre></div><p style="text-align:right"><a href="https://raymq.com/posts/compnet/curl-study#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/compnet/curl-study</link><guid isPermaLink="true">https://raymq.com/posts/compnet/curl-study</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Tue, 01 Sep 2026 01:00:00 GMT</pubDate></item><item><title><![CDATA[HTTP 状态码]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/5j6gd6ga7bbm1boylj.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/epikb5xvr05wnv0jf9.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/2oz05x2cnxc1b53e55.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/9ly7xuze0rqppmj126.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/kndi8g99l8gqbj0ble.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/bm3ka2231knov3p6c3.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/blhx49z6qccm7vn5c1.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/axhitwfnjyo5al140e.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/hqmzpw46jm2r6uu2gf.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/ekbodrj8nltal3szgc.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/1qnudvwy0564o879cf.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/qv2yw2prtm4i40ktye.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/eg8dte45shyhaoqlcm.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/dczbs6g2keypch5ita.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/o2v3krvime4y114wm6.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/hin0fdkyqvn9f0xauj.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/1ps4cwjwt1dxj88on3.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/ehivwpe12l22384jv6.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/r954y45dmyfxr4soz1.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/g2uixirnuqdstfabim.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/yss0hxjq0ax1bdnpka.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/compnet/http-study">https://raymq.com/posts/compnet/http-study</a></blockquote><div><p>这篇文章主要来讲讲 HTTP 状态码这个东西，因为在工作中，经常出现 429、404、503... 这些数字，但是我看不懂...</p><p>所以写这篇文章用于学习一些常见的状态码，想表示的是什么</p><p>先写一份文件用于 HTTP 请求吧，这里使用 Go 语言进行编写，因为路由非常方便</p><h1 id="">请求程序</h1><pre class=""><code class="">cat &gt; http_status.go &lt;&lt; &#x27;EOF&#x27;

package main

import (
    &quot;fmt&quot;
    &quot;log&quot;
    &quot;net/http&quot;
    &quot;strings&quot;
    &quot;time&quot;
)

func main() {
    // 200
    http.HandleFunc(&quot;/200&quot;, func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, &quot;200 OK&quot;)
    })

    // 204
    http.HandleFunc(&quot;/204&quot;, func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(&quot;X-Demo&quot;, &quot;204-has-header-but-no-body&quot;)
        w.WriteHeader(http.StatusNoContent)
    })

    // 206
    http.HandleFunc(&quot;/206&quot;, func(w http.ResponseWriter, r *http.Request) {
        content := &quot;0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ&quot;

        w.Header().Set(&quot;Content-Type&quot;, &quot;text/plain&quot;)
        w.Header().Set(&quot;Accept-Ranges&quot;, &quot;bytes&quot;)

        http.ServeContent(
            w,
            r,
            &quot;demo.txt&quot;,
            time.Now(),
            strings.NewReader(content),
        )
    })

    // 最终重定向目标
    http.HandleFunc(&quot;/target&quot;, func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, &quot;method=%s\n&quot;, r.Method)
    })

    // 301
    http.HandleFunc(&quot;/301&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, &quot;/target&quot;, http.StatusMovedPermanently)
    })

    // 302
    http.HandleFunc(&quot;/302&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, &quot;/target&quot;, http.StatusFound)
    })

    // 303
    http.HandleFunc(&quot;/303&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, &quot;/target&quot;, http.StatusSeeOther)
    })

    // 307
    http.HandleFunc(&quot;/307&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, &quot;/target&quot;, http.StatusTemporaryRedirect)
    })

    // 304
    http.HandleFunc(&quot;/304&quot;, func(w http.ResponseWriter, r *http.Request) {
        const etag = `&quot;article-v1&quot;`

        w.Header().Set(&quot;ETag&quot;, etag)

        if r.Header.Get(&quot;If-None-Match&quot;) == etag {
            w.WriteHeader(http.StatusNotModified)
            return
        }

        fmt.Fprintln(w, &quot;This is cached content.&quot;)
    })

    // 400
    http.HandleFunc(&quot;/400&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, &quot;bad request&quot;, http.StatusBadRequest)
    })

    // 401
    http.HandleFunc(&quot;/401&quot;, func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(&quot;WWW-Authenticate&quot;, `Basic realm=&quot;HTTP Demo&quot;`)
        http.Error(w, &quot;authentication required&quot;, http.StatusUnauthorized)
    })

    // 402
    http.HandleFunc(&quot;/402&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, &quot;payment required&quot;, http.StatusPaymentRequired)
    })

    // 403
    http.HandleFunc(&quot;/403&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, &quot;forbidden&quot;, http.StatusForbidden)
    })

    // 404
    http.HandleFunc(&quot;/404&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.NotFound(w, r)
    })

    // 500
    http.HandleFunc(&quot;/500&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, &quot;internal server error&quot;, http.StatusInternalServerError)
    })

    // 501
    http.HandleFunc(&quot;/501&quot;, func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, &quot;not implemented&quot;, http.StatusNotImplemented)
    })

    // 503
    http.HandleFunc(&quot;/503&quot;, func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set(&quot;Retry-After&quot;, &quot;60&quot;)
        http.Error(w, &quot;service unavailable&quot;, http.StatusServiceUnavailable)
    })

    log.Println(&quot;HTTP demo server listening on :8080&quot;)
    log.Fatal(http.ListenAndServe(&quot;:8080&quot;, nil))
}
EOF

go run http_status.go

# 导入环境变量
export SERVER_IP=xx.xx.xx.xx

# 输出 IP 
echo $SERVER_IP
</code></pre><h1 id="-1xx-">信息响应（ 1xx ）</h1><p>1xx 状态码好像没有什么常见的，就跳过了</p><h1 id="-2xx-">成功响应（ 2xx ）</h1><h2 id="200-ok">200 OK</h2><p>200 状态码表示 <strong>请求成功</strong>，常见于 HTTP 的请求，比如 GET、PUT、POST 等方法</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -v &quot;http://${SERVER_IP}:8080/200&quot;
</code></pre><p><img height="332" src="https://raymq.com/api/v3/objects/image/5j6gd6ga7bbm1boylj.png" width="836"/></p><h2 id="204-no-content">204 No Content</h2><p>204 状态码表示服务器成功处理了请求，但不需要返回响应正文</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/204&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/epikb5xvr05wnv0jf9.png"/></p><h2 id="206-partial-content">206 Partial Content</h2><p>这里我们通过两种方式请求 206 这个路由，返回的结果非常的直观</p><ul><li>第一个请求，正常返回完整的资源，但是并没有体现出 206 这个状态码</li><li>第二个请求，我们通过 Range 请求头表示&quot;只要第 0 到第 9 个字节&quot;
客户端通过 Range 请求资源的，一部分；然后服务器成功返回指定范围，所以返回状态码是 206 Partial Content
<code>bash
curl -i -H &#x27;Range: bytes=0-9&#x27; &quot;http://${SERVER_IP}:8080/206&quot;
</code>
<img src="https://raymq.com/api/v3/objects/image/2oz05x2cnxc1b53e55.png"/></li></ul><h1 id="-3xx-">重定向响应（ 3xx ）</h1><h2 id="301-moved-permanently">301 Moved Permanently</h2><p>301 状态码表示 <strong>永久重定向</strong>，也就是当前请求的资源已经永久移动到了另一个地址</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/301&quot;
</code></pre><p>这里可以看到 Location ，表示这个资源已经移动到了 /target
<img src="https://raymq.com/api/v3/objects/image/9ly7xuze0rqppmj126.png"/>
因为 curl 不会自动跟随重定向，所以我们可以增加 <code>-L</code> 参数</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i -L &quot;http://${SERVER_IP}:8080/301&quot;
</code></pre><p>这时候整个过程就变成了 访问 /301 ，发现重定向到了 /target，然后跟随到 /target，响应成功，返回 200 OK
<img src="https://raymq.com/api/v3/objects/image/kndi8g99l8gqbj0ble.png"/></p><h2 id="302-found">302 Found</h2><p>302 表示 <strong>临时重定向</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/302&quot;
</code></pre><p>同样可以看到 Location 以及 /target，它和 301 其实非常相似，唯一的不同是，302 是临时性的重定向
<img src="https://raymq.com/api/v3/objects/image/bm3ka2231knov3p6c3.png"/>
比如，当前使用 /302 进行访问日志，公司今天临时决定，/302 策略不开发，需要临时重定向到了 /target，也就是使用 /target 进行访问日志，但这只是今天使用 /target，今天一过，就可以继续用 /302 进行访问日志</p><p>当然，同样可以通过 <code>-L</code> 自动跟随</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i -L &quot;http://${SERVER_IP}:8080/302&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/blhx49z6qccm7vn5c1.png"/></p><h2 id="303-see-other">303 See Other</h2><p>303 也是一种重定向，但是它有一个比较明确的意思：当前请求已经处理，接下来使用 GET 请求访问 <code>Location</code> 指定的资源</p><p>-d &#x27;xx=xx&#x27; 表示发送 POST 数据，数据为 &#x27;xx=xx&#x27;</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i -L -d &#x27;name=ray&#x27; &quot;http://${SERVER_IP}:8080/303&quot;
</code></pre><p>可以看到，我们 使用 POST 发送数据后，响应为 303，并且重定向到了 /target，然后自动跟随重定向，响应 200 OK
<img src="https://raymq.com/api/v3/objects/image/axhitwfnjyo5al140e.png"/></p><h2 id="304-not-modified">304 Not Modified</h2><p>304 表示 <strong>资源没有发生修改</strong>，这个状态码和前面的重定向不太一样，它主要用于 HTTP 缓存，表示客户端已经有资源了，服务器只需要告诉客户端资源有没有变化，没有变化就继续使用缓存，避免重复传输资源</p><p>根据响应我们可以看到 ETag 以及一句话 <code>This is cached content.</code>；ETag 可以当作这个资源的版本标识，客户端拿到资源以后，可以把资源和它的 ETag 一起缓存下来，下一次再访问的时候，客户端可以告诉服务器，当前已经有 ETag 这个版本了，如果资源一致，就不需要重复发资源给我了</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/304&quot;
</code></pre><p>通过 <code>If-None-Match</code> 请求头进行条件判断，当客户端、服务端发现版本一致，就返回 304 Not Modified</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i -H &#x27;If-None-Match: &quot;article-v1&quot;&#x27; &quot;http://${SERVER_IP}:8080/304&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/hqmzpw46jm2r6uu2gf.png"/>
总结来看就是，第一次请求时，客户端缓存 ETag 和 Body；第二次请求，服务端检查 ETag，无变化则响应 304</p><h2 id="307-temporary-redirect">307 Temporary Redirect</h2><p>307 也表示 <strong>临时重定向</strong>，但是与 303 不太一样，它重定向之后必须保留原来的 HTTP Method 和请求体</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/307&quot;
</code></pre><p>使用 POST 请求</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i -L -d &#x27;name=ray&#x27; &quot;http://${SERVER_IP}:8080/307&quot;
</code></pre><p>可以看到 method=POST，这是与 303 很明显的区别
<img src="https://raymq.com/api/v3/objects/image/ekbodrj8nltal3szgc.png"/></p><ul><li>303 → 明确让客户端使用 GET 去访问另一个资源</li><li>307 → 临时换个地址，但是原来的请求方法不能变</li></ul><h1 id="-4xx-">客户端错误响应（ 4xx ）</h1><p>4xx 表示客户端发送的请求存在问题，这里的&quot;客户端问题&quot;范围比较大，比如：</p><ul><li>请求格式错误</li><li>没有进行身份认证</li><li>没有权限</li><li>请求的资源不存在</li><li>请求过于频繁</li></ul><p>这些情况都可能返回 4xx</p><h2 id="400-bad-request">400 Bad Request</h2><p>400 表示 <strong>服务器认为客户端发送的请求存在问题，无法按照预期进行处理</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/400&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/1qnudvwy0564o879cf.png"/>
在实际开发中比较常见的情况，比如接口要求发送 JSON</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;name&quot;: &quot;ray&quot;
}
</code></pre><p>但客户端发送了一个错误的 JSON：</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;name&quot;:
}
</code></pre><p>服务器解析失败，就可能返回 400 Bad Request</p><h2 id="401-unauthorized">401 Unauthorized</h2><p>401 表示 <strong>当前请求需要进行身份认证，或者提供的认证信息无效</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/401&quot;
</code></pre><p>可以看到 401 Unauthorized 响应以及 WWW-Authenticate，它用于告诉客户端应该使用什么认证方式，比如这里是 Basic 表示使用 用户名密码；如果是 Bearer 则是 JWT Token 之类的
<img src="https://raymq.com/api/v3/objects/image/qv2yw2prtm4i40ktye.png"/></p><h2 id="402-payment-required">402 Payment Required</h2><p>402 最初是为未来的数字支付系统预留的状态码，目前并没有一个统一、标准化的实际使用方式，但一些 Web API 会自己使用 402 Payment Required，表示&quot;需要付款&quot;；比如 余额不足、套餐到期等...</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/402&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/eg8dte45shyhaoqlcm.png"/></p><h2 id="403-forbidden">403 Forbidden</h2><p>403 表示 <strong>服务器接受客户端的请求，但拒绝执行</strong>，一般就是 <strong>权限不足</strong> 的问题</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/403&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/dczbs6g2keypch5ita.png"/></p><h2 id="404-not-found">404 Not Found</h2><p>404 应该是平时见得最多的 HTTP 状态码之一，表示 <strong>服务器找不到客户端请求的资源</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/404&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/o2v3krvime4y114wm6.png"/>
比如我当前只暴露了 /login、/register ，但是你 GET /skip，就会响应 404 Not Found，代表没有这个资源</p><h2 id="429-too-many-requests">429 Too Many Requests</h2><p>429 在调用 API 的时候也比较常见，它表示 <strong>客户端在一段时间内发送了太多请求，被服务器限流了</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/429&quot;
</code></pre><p>这里可以看到一个参数 Retry-After: 10 表示 可以等待 10 秒之后再尝试请求
<img src="https://raymq.com/api/v3/objects/image/hin0fdkyqvn9f0xauj.png"/>
常见于客户端 短时间发送大量请求，导致服务端限流，就会响应 429 Too Many Requests</p><p>它和 5xx 这种服务端错误是不同的，429 并不一定发生了故障，可能只是主动拒绝过多的请求罢了</p><h1 id="-5xx-">服务端错误响应（ 5xx ）</h1><p>5xx 表示 <strong>服务器已经接收到了请求，但是服务器在处理请求的过程中出现了问题</strong></p><h2 id="500-internal-server-error">500 Internal Server Error</h2><p>500 表示 <strong>服务器内部发生错误，导致请求无法正常完成</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/500&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/1ps4cwjwt1dxj88on3.png"/>
500 是一个比较通用的服务器错误，比如 Web 服务处理一个请求，我去查询 Mysql，但触发了 500 响应，这只能告诉我们 服务器内部处理失败了，而具体失败的原因，需要去看日志、堆栈、数据库连接什么的</p><h2 id="501-not-implemented">501 Not Implemented</h2><p>501 表示 <strong>服务器不支持完成当前请求所需要的功能</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/501&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/ehivwpe12l22384jv6.png"/>
这里需要注意 501 和 500 的区别，比如 /login</p><ul><li>500 表示 我实现了这个功能，但是服务器处理有问题导致错误</li><li>501 表示 我没有实现这个功能
## 502 Bad Gateway
502 表示 <strong>网关或代理从上游服务器收到了无效响应</strong>，这里就不用代码展示了，看个真实的响应吧；也就是上游给的响应是有问题的，然后进行了拦截
<img src="https://raymq.com/api/v3/objects/image/r954y45dmyfxr4soz1.png"/>
## 503 Service Unavailable
503 表示 <strong>服务器当前暂时无法处理请求</strong>
<code>bash
curl -i &quot;http://${SERVER_IP}:8080/503&quot;
</code>
可以看到一个熟悉的参数 Retry-After: 60，表示当前暂时无法提供服务，可以在 60 秒后尝试
<img src="https://raymq.com/api/v3/objects/image/g2uixirnuqdstfabim.png"/>
503 常见于 服务器正在维护、服务暂时不可用、服务器过载、上游服务暂时不可用等</li></ul><p>与 500 不同，503 表示的是 服务端没有能力处理而不是处理有问题</p><h2 id="504-gateway-timeout">504 Gateway Timeout</h2><p>504 表示 <strong>网关或代理等待上游服务器响应超时</strong></p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">curl -i &quot;http://${SERVER_IP}:8080/504&quot;
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/yss0hxjq0ax1bdnpka.png"/></p><h1 id="">总结</h1><p>当然，这篇教程并不是非常的规范，因为使用的路由都是这些状态码，并且有些输出可能看不出这些响应具体想表达什么，以及可能不经常使用会忘记</p><p>但我认为，有印象就足够了</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/compnet/http-study#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/compnet/http-study</link><guid isPermaLink="true">https://raymq.com/posts/compnet/http-study</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Mon, 31 Aug 2026 13:11:00 GMT</pubDate></item><item><title><![CDATA[（十五）服务发现]]></title><description><![CDATA[<link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/jq9sbxeriwdzhp9cd6.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/8utp4oeo0ae7h348qi.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/ko7yhiicmtdf130cim.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/xwic1twhip57lay43u.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/0cj3u2dgd1apul6whw.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/m8jesc363w433rc217.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/oew81229l511smqxbb.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/1gzpdmksabpfigf8j7.png"/><link rel="preload" as="image" href="https://raymq.com/api/v3/objects/image/m5pnl7whomsrqxs0jq.png"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://raymq.com/posts/Kubernetes/service-discovery">https://raymq.com/posts/Kubernetes/service-discovery</a></blockquote><div><p>前面我们学习了外部用户怎样通过域名和路径进入集群，今天我们来看看集群内部的应用是怎样找到彼此的</p><p>Pod IP 会随着重建而变化，所以应用不能把另一个 Pod 的 IP 写死在配置里；Service 虽然有稳定的 ClusterIP，但 ClusterIP 也是 Kubernetes 分配的，应用同样不应该提前知道它是多少</p><p>应用真正需要记住的应该是一个稳定名称，例如 <code>mysql</code>、<code>redis</code>、<code>user-api</code>，再由 Kubernetes 把这个名称解析成当前可以访问的地址，这个过程就是服务发现</p><p>比如你知道我叫 Ray，你想找我但不知道我住哪；你去公安局询问，然后找到了我（bushi —— 这就是服务发现</p><p>Kubernetes 主要提供两种发现 Service 的方式</p><ul><li>DNS</li><li>环境变量</li></ul><p>实际使用中以 DNS 为主，先从它开始</p><h1 id="-dns--service">使用 DNS 发现 Service</h1><p>创建两个命名空间，<code>discovery-b</code> 中运行服务，<code>discovery-a</code> 中运行客户端</p><pre class=""><code class="">kubectl create namespace discovery-a

kubectl create namespace discovery-b

kubectl create deployment discovery-web -n discovery-b --image=nginx:latest --replicas=2

kubectl rollout status deployment discovery-web -n discovery-b

kubectl expose deployment discovery-web -n discovery-b --port=8080 --target-port=80

# 查看
kubectl get service discovery-web -n discovery-b -o wide

kubectl get endpointslices -n discovery-b -l kubernetes.io/service-name=discovery-web -o wide

kubectl get pods -n discovery-b -l app=discovery-web -o wide
</code></pre><p>Service 有一个稳定的 ClusterIP，EndpointSlice（电话簿）中是两个 Nginx Pod 的 IP；客户端最后访问哪一个 Pod 仍然由前面学过的 Service 转发规则决定
<img height="674" src="https://raymq.com/api/v3/objects/image/jq9sbxeriwdzhp9cd6.png" width="1648"/></p><p>现在在另一个命名空间创建一个专门查询 DNS 的 Pod</p><pre class=""><code class="">kubectl run dns-client -n discovery-a --image=busybox:1.36 --restart=Never --command -- sleep 3600

kubectl wait pod dns-client -n discovery-a --for=condition=Ready --timeout=120s

# 只查询 Service 的短名称
kubectl exec -n discovery-a dns-client -- nslookup discovery-web

# 带上 Service 所在的命名空间查询
kubectl exec -n discovery-a dns-client -- nslookup discovery-web.discovery-b

# 使用完整名称查询
kubectl exec -n discovery-a dns-client -- nslookup discovery-web.discovery-b.svc.cluster.local
</code></pre><p>第一项查询，不出意外的话会出现 NXDOMAIN；第二项查询... 可能是 BusyBox 的问题，BusyBox 自带的 <code>nslookup</code> 遇到名称中已经包含点号时，会直接向 DNS 服务器查询 <code>discovery-web.discovery-b</code>，没有按照 Pod 的搜索域补成 Kubernetes 中真正存在的完整名称</p><p>但是使用完整名称查询则可以查询到与 Service 相同的 ClusterIP，这说明，说明 Service 的 DNS 记录确实存在
<img src="https://raymq.com/api/v3/objects/image/8utp4oeo0ae7h348qi.png"/>
完整的 Service DNS 名称是</p><ul><li><code>{Service名称}.{命名空间}.svc.cluster.local</code>
其中 <code>cluster.local</code> 是常见的集群域名，也可以在安装 Kubernetes 时改成别的值；排查 DNS 时直接使用完整名称最明确，不会受到不同命令怎样处理搜索域的影响</li></ul><p>普通应用通常会使用 Pod 中的系统解析器，它会读取 <code>/etc/resolv.conf</code> 中的搜索域，因此跨命名空间访问时可以简写成 <code>Service名称.命名空间</code></p><p>仍然在同一个 BusyBox Pod 中，使用 <code>wget</code> 访问 Nginx</p><pre class=""><code class="">kubectl exec -n discovery-a dns-client -- wget -qO- http://discovery-web.discovery-b:8080
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/ko7yhiicmtdf130cim.png"/>
这次能够看到 Nginx 页面；<code>wget</code> 使用的解析过程会尝试补全搜索域，最终把名称补成 <code>discovery-web.discovery-b.svc.cluster.local</code>；所以同一个短名称可能在 BusyBox 的 <code>nslookup</code> 中失败，却能被应用正常使用</p><p>现在来梳理一下请求经过</p><pre class=""><code class="">discovery-web.discovery-b  
  -&gt; Pod 中的解析器补全 .svc.cluster.local  
  -&gt; CoreDNS 解析出 Service ClusterIP  
  -&gt; kube-proxy 规则选择 EndpointSlice 中的一个 Pod  
  -&gt; Nginx Pod:80
</code></pre><p>从上述请求就可以知道，DNS 负责找到 Service 的稳定入口，EndpointSlice 和 kube-proxy 负责从 Service 到 Pod，它们解决的问题是不同的</p><h1 id="coredns-">CoreDNS 做了什么</h1><pre class=""><code class=""># 查看集群中的 DNS 组件
kubectl get deployment coredns -n kube-system

kubectl get pods -n kube-system -l k8s-app=kube-dns -o wide

kubectl get service kube-dns -n kube-system -o wide

kubectl get endpointslices -n kube-system -l kubernetes.io/service-name=kube-dns -o wide
</code></pre><p>可以看到，现在的 Kubernetes 通常运行 CoreDNS Pod，但它的 Service 为了兼容仍然叫 <code>kube-dns</code>
<img src="https://raymq.com/api/v3/objects/image/xwic1twhip57lay43u.png"/>以及，当前两个 CoreDNS Pod 都已经就绪，每个普通 Pod 会把 DNS 查询发到 <code>10.96.0.10</code>，然后 CoreDNS 持续观察 Kubernetes API 中的 Service 和 EndpointSlice，再回答对应的集群内名称</p><pre class=""><code class=""># 查看客户端 Pod 的 DNS 配置
kubectl exec -n discovery-a dns-client -- cat /etc/resolv.conf
</code></pre><p><img src="https://raymq.com/api/v3/objects/image/0cj3u2dgd1apul6whw.png"/>
<code>nameserver</code> 是集群 DNS Service 的地址，<code>search</code> 是系统解析器补全名称时依次尝试的域，<code>ndots:5</code> 表示少于 5 个点号的名称通常会先尝试搜索域</p><p>比如，应用查询 <code>discovery-web.discovery-b</code> 时，系统解析器会依次尝试搜索域，其中 <code>svc.cluster.local</code> 可以把它补成 <code>discovery-web.discovery-b.svc.cluster.local</code>；只查询 <code>discovery-web</code> 时，则会先按客户端自己的命名空间补成 <code>discovery-web.discovery-a.svc.cluster.local</code>，不会自动猜测 Service 位于哪个其他命名空间</p><p>搜索域是否生效还取决于具体程序使用哪一种解析实现，当前 BusyBox 的 <code>wget</code> 会使用这些搜索域，但它自带的 <code>nslookup</code> 对带点号名称的处理不同，因此查询完整名称才不会出现 NXDOMAIN</p><h1 id="headless-service">Headless Service</h1><p>普通 Service 的 DNS 名称解析成一个 ClusterIP，客户端只看到 Service，再由 kube-proxy 选择 Pod</p><p>有些应用不希望中间存在 ClusterIP，例如数据库集群的客户端需要直接知道所有成员的地址，这时可以创建 Headless Service</p><pre class=""><code class="">cat &gt; discovery-headless.yaml &lt;&lt;&#x27;EOF&#x27;
apiVersion: v1
kind: Service
metadata:
  name: discovery-headless
  namespace: discovery-b
spec:
  clusterIP: None
  selector:
    app: discovery-web
  ports:
  - name: http
    protocol: TCP
    port: 8080
    targetPort: 80
EOF

kubectl apply -f discovery-headless.yaml

kubectl get service discovery-headless -n discovery-b

kubectl exec -n discovery-a dns-client -- nslookup discovery-headless.discovery-b.svc.cluster.local

kubectl get pods -n discovery-b -l app=discovery-web -o wide
</code></pre><p>在 get service 中，我们可以看到 CLUSTER-IP 是 None，说明这个 Service 没有虚拟 IP，而后我们通过 DNS 查询，看到了两个 Address 正好与 Pod 的中的 IP 是相同的
<img src="https://raymq.com/api/v3/objects/image/m8jesc363w433rc217.png"/>
这里，我们就可以讲讲，普通 Service 和 Headless Service 的区别</p><p>一个普通 Service 名称，包含一个 CLUSTER-IP 通过 kube-proxy 选择 Pod；而 Headless Service 包含一个或多个后端 Pod IP，客户端自己去选择并连接 Pod</p><p>Headless 并不是 没有 Service，它仍然使用 selector 和 EndpointSlice，只是 DNS 不再返回 ClusterIP</p><p>StatefulSet、数据库集群和需要客户端自己发现成员的系统经常使用这种方式</p><p>Service 端口有名称时，CoreDNS 还会提供 SRV 记录；SRV 记录是 DNS 中的一个 &quot;服务定位&quot; 记录，它不仅告诉客户端服务器的地址，还精准的指出应该连接哪个端口以及如何选择服务器</p><pre class=""><code class="">kubectl exec -n discovery-a dns-client --   nslookup -type=SRV   _http._tcp.discovery-headless.discovery-b.svc.cluster.local
</code></pre><p><code>_http</code> 来自端口名称，<code>_tcp</code> 来自协议，可以看到，SRV 不只告诉客户端地址，还能告诉它服务端口，Headless Service 的 SRV 记录还可以返回各个后端的名称
<img src="https://raymq.com/api/v3/objects/image/oew81229l511smqxbb.png"/></p><h1 id="-service">使用环境变量发现 Service</h1><p>除了 DNS，kubelet 在创建 Pod 时，还会把同一命名空间中已经存在的 Service 写成环境变量</p><p>先创建 Service，再创建客户端 Pod</p><pre class=""><code class="">kubectl create service clusterip env-db -n discovery-b --tcp=5432:5432  
​  
kubectl run env-client -n discovery-b --image=busybox:1.36 --restart=Never --command -- sleep 3600  
​  
kubectl wait pod env-client -n discovery-b --for=condition=Ready --timeout=120s

# 查看和 `env-db` 有关的变量
kubectl exec -n discovery-b env-client -- sh -c &quot;env | sort | grep &#x27;^ENV_DB&#x27;&quot;
</code></pre><p>可以看到什么 DB<em>SERVICE</em>HOSG、xx<em>PORT 之类的环境变变量
<img src="https://raymq.com/api/v3/objects/image/1gzpdmksabpfigf8j7.png"/>
Service 名称会转成大写，连字符变成下划线；应用可以从 <code>ENV_DB_SERVICE_HOST</code> 和 `ENV</em>DB<em>SERVICE</em>PORT` 取得 ClusterIP 和端口</p><p>这种方式有一个很大的限制：环境变量只在 Pod 创建时注入，所以 Service 必须先于客户端 Pod 存在；之后新建 Service，已经运行的 Pod 不会自动出现新的变量，Service 地址发生变化时，旧变量也不会动态更新</p><p>DNS 没有这个创建顺序问题，应用只保存名称，每次解析时由 CoreDNS 返回当前地址，所以普通服务发现一般优先使用 DNS</p><h1 id="kubectl-proxy-">kubectl proxy 不是服务发现</h1><pre class=""><code class="">kubectl proxy --port=8001

# 额外开一个执行
curl http://127.0.0.1:8001/api/v1/namespaces/discovery-b/services/discovery-web
</code></pre><p>返回的是 API Server 中保存的 Service 对象；<code>kubectl proxy</code> 只是让当前电脑上的客户端通过 kubeconfig 安全地访问 Kubernetes API，默认只监听 <code>127.0.0.1</code>，它既不会创建 NodePort，也不是给集群内应用发现服务用的，更不参与 <code>kubeadm join</code>
<img src="https://raymq.com/api/v3/objects/image/m5pnl7whomsrqxs0jq.png"/></p><h1 id="dns-">DNS 排查</h1><p>Service 名称无法访问时，先在 Pod 内查询 Kubernetes 自带的 Service</p><pre class=""><code class="">kubectl exec -n discovery-a dns-client -- nslookup kubernetes.default.svc.cluster.local
</code></pre><p>如果这个名称也无法解析，再检查 Pod 的 DNS 配置和 CoreDNS</p><pre class=""><code class="">kubectl exec -n discovery-a dns-client -- cat /etc/resolv.conf  
​  
kubectl get service kube-dns -n kube-system  
​  
kubectl get endpointslices -n kube-system -l kubernetes.io/service-name=kube-dns -o wide  
​  
kubectl get pods -n kube-system -l k8s-app=kube-dns  
​  
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=100
</code></pre><p>如果 DNS 能解析出 Service ClusterIP，但访问仍然失败，就不应该继续盯着 CoreDNS，而要回到 Service 和 EndpointSlice</p><pre class=""><code class="">kubectl get service discovery-web -n discovery-b -o yaml  
​  
kubectl get endpointslices -n discovery-b -l kubernetes.io/service-name=discovery-web -o yaml
</code></pre><p>重点检查 Service 端口、selector、EndpointSlice 地址和就绪状态；还要注意，节点自己的 <code>/etc/resolv.conf</code> 和 Pod 的 DNS 配置不一定相同，应该从真正发起请求的 Pod 内测试，不能用节点上的 <code>nslookup</code> 结果直接判断 CoreDNS 是否正常</p><pre class=""><code class=""># 清理环境
kubectl delete namespace discovery-a discovery-b
</code></pre>
<h1 id="">总结</h1><p>今天这章主要讲的就是，应用怎样找到一个会变化的服务，将服务名称通过 CoreDNS 解析成 Service ClusterIP，Service 再通过 EndpointSlice 和 kube-proxy 找到后端 Pod</p><p>以及普通 Service 的 DNS 返回 ClusterIP，Headless Service 的 DNS 直接返回后端地址；环境变量也能提供 Service 地址，但受 Pod 创建顺序限制，实际使用通常以 DNS 为主</p><p>到这，Kubernetes 的网络部分，我们算是初步讲解完成了，当然，你会发现这些文章的质量不太高，没错，我也是个小白，我搜索资料也是靠各种网站和AI，质量参差不齐，很难产出一篇说让你从头明白到尾的博客</p><p>当然，我对这些文章，其实也不是很满意，因为他始终没让我明白，知识是具有连贯性的...</p><p>愿诸君顺遂</p></div><p style="text-align:right"><a href="https://raymq.com/posts/Kubernetes/service-discovery#comments">览毕，何不一言？</a></p></div>]]></description><link>https://raymq.com/posts/Kubernetes/service-discovery</link><guid isPermaLink="true">https://raymq.com/posts/Kubernetes/service-discovery</guid><dc:creator><![CDATA[ray]]></dc:creator><pubDate>Wed, 26 Aug 2026 01:00:00 GMT</pubDate></item></channel></rss>