{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "个人技术文章分享",
  "home_page_url": "https://blog.shabbywu.cn/",
  "feed_url": "https://blog.shabbywu.cn/feed.json",
  "description": "这是一个简单的博客",
  "favicon": "https://blog.shabbywu.cn/img/avatar.png",
  "items": [
    {
      "title": "[Golang] 容易被 k8s 误导的 json:\",inline\"",
      "url": "https://blog.shabbywu.cn/posts/2023/09/28/golang-%E5%AE%B9%E6%98%93%E8%A2%AB-k8s-%E8%AF%AF%E5%AF%BC%E7%9A%84-json-%E5%BA%8F%E5%88%97%E5%8C%96%E6%B3%A8%E9%87%8A.html",
      "id": "https://blog.shabbywu.cn/posts/2023/09/28/golang-%E5%AE%B9%E6%98%93%E8%A2%AB-k8s-%E8%AF%AF%E5%AF%BC%E7%9A%84-json-%E5%BA%8F%E5%88%97%E5%8C%96%E6%B3%A8%E9%87%8A.html",
      "summary": "背景介绍 最近在开发 K8s Operator 时遇到一个奇怪的现象 -- 不管参数怎样传递, 请求发送到 Operator 时总会返回 422 Unprocessable Content。 Apiserver 返回的错误提示非常清晰，就是请求中缺了必须的字段(FieldValueRequired)。 排查下来发现这些参数都已经传递了的。具体的请求和返...",
      "content_html": "<h2>背景介绍</h2>\n<p>最近在开发 K8s Operator 时遇到一个奇怪的现象 -- 不管参数怎样传递, 请求发送到 Operator 时总会返回 <code>422 Unprocessable Content</code>。\nApiserver 返回的错误提示非常清晰，就是请求中缺了必须的字段(<code>FieldValueRequired</code>)。</p>\n<p>排查下来发现这些参数都已经传递了的。具体的请求和返回值如下所示(相关信息已脱敏):</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>curl -X POST -H \"Content-Type: application/json\" https://example.com/apis/paas.bk.tencent.com/v1alpha1/namespaces/bkapp-foo-prod/bkapps -d `{\n\t\"apiVersion\": \"paas.bk.tencent.com/v1alpha1\",\n\t\"kind\": \"BkApp\",\n\t\"metadata\": {\n\t\t\"name\": \"foo\"\n\t},\n\t\"spec\": {\n\t\t\"build\": null,\n\t\t\"processes\": [{\n\t\t\t\"name\": \"web\",\n\t\t\t\"replicas\": 1\n\t\t}, {\n\t\t\t\"name\": \"dev\",\n\t\t\t\"replicas\": 1\n\t\t}],\n\t\t\"envOverlay\": {\n\t\t\t\"autoscaling\": [{\n\t\t\t\t\"envName\": \"stag\",\n\t\t\t\t\"process\": \"web\",\n\t\t\t\t\"minReplicas\": 3,\n\t\t\t\t\"maxReplicas\": 5,\n\t\t\t\t\"policy\": \"default\"\n\t\t\t}]\n\t\t}\n\t},\n\t\"status\": {\n\t\t\"conditions\": []\n\t}\n}`\n\n</code></pre></div>",
      "date_published": "2023-09-28T00:00:00.000Z",
      "date_modified": "2026-03-20T13:21:38.000Z",
      "authors": [],
      "tags": [
        "golang"
      ]
    },
    {
      "title": "大模型 + 阿里云实例诊断: AI 赋能云运维的实践与心得",
      "url": "https://blog.shabbywu.cn/posts/2026/01/29/llm-%E5%A4%A7%E6%A8%A1%E5%9E%8B-%E9%98%BF%E9%87%8C%E4%BA%91%E5%AE%9E%E4%BE%8B%E8%AF%8A%E6%96%AD-ai-%E8%B5%8B%E8%83%BD%E4%BA%91%E8%BF%90%E7%BB%B4%E7%9A%84%E5%AE%9E%E8%B7%B5%E4%B8%8E%E5%BF%83%E5%BE%97.html",
      "id": "https://blog.shabbywu.cn/posts/2026/01/29/llm-%E5%A4%A7%E6%A8%A1%E5%9E%8B-%E9%98%BF%E9%87%8C%E4%BA%91%E5%AE%9E%E4%BE%8B%E8%AF%8A%E6%96%AD-ai-%E8%B5%8B%E8%83%BD%E4%BA%91%E8%BF%90%E7%BB%B4%E7%9A%84%E5%AE%9E%E8%B7%B5%E4%B8%8E%E5%BF%83%E5%BE%97.html",
      "summary": "大模型 + 阿里云实例诊断：AI 赋能云运维的实践与心得 本文介绍我们如何利用大模型（LLM）结合 MCP Server 技术构建智能化的阿里云实例诊断系统。 文章重点分享了 MCP 工具设计的三个核心原则：使用 TOON 格式优化 Token 消耗、通过显式标注单位消除大模型幻觉、对时序监控数据进行预计算统计摘要。 同时介绍了 Prompt 工程实践...",
      "content_html": "\n<blockquote>\n<p>本文介绍我们如何利用大模型（LLM）结合 MCP Server 技术构建智能化的阿里云实例诊断系统。</p>\n<p>文章重点分享了 MCP 工具设计的三个核心原则：使用 TOON 格式优化 Token 消耗、通过显式标注单位消除大模型幻觉、对时序监控数据进行预计算统计摘要。\n同时介绍了 Prompt 工程实践，通过提供完整的输出格式模板来保证诊断报告的一致性。</p>\n</blockquote>\n<h2>一、背景与动机</h2>\n<h3>传统云运维的痛点</h3>\n<p>在日常云资源运维中，运维工程师经常面临这些挑战：</p>\n<ol>\n<li><strong>信息分散</strong>：实例状态、监控数据、安全组规则、磁盘信息分布在不同的控制台页面</li>\n<li><strong>排查耗时</strong>：一个简单的\"实例连不上\"问题，可能需要检查十几个维度</li>\n<li><strong>经验依赖</strong>：新手难以快速定位问题，老手的经验难以沉淀和传承</li>\n<li><strong>重复劳动</strong>：相似问题反复排查，效率低下</li>\n</ol>\n<h3>我们的方案</h3>\n<p>我们希望打造一个<strong>极简的诊断入口</strong>：用户只需要提供一个实例 ID，系统就能自动完成全面诊断并输出专业报告。</p>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>输入：i-bp1xxxxxxxxxx（实例 ID）\n输出：结构化的诊断报告（问题定位 + 解决建议）\n</code></pre></div><p>在这个方案中，<strong>大模型承担两个核心职责</strong>：</p>\n<ol>\n<li>\n<p><strong>智能调度诊断流程</strong></p>\n<ul>\n<li>大模型根据内置的诊断 Prompt 按需调用 MCP 工具</li>\n<li>自主决定调用顺序和调用哪些工具（不是固定流程）</li>\n<li>遇到异常数据时，能够自动深挖相关维度</li>\n</ul>\n</li>\n<li>\n<p><strong>输出用户友好的诊断报告</strong></p>\n<ul>\n<li>将原始的 API 数据、监控指标转换为自然语言描述</li>\n<li>结构化呈现问题和建议，让非技术人员也能看懂</li>\n<li>提供可操作的解决方案，而不仅仅是数据罗列</li>\n</ul>\n</li>\n</ol>\n<p>这正是我们构建的——基于 <strong>Dify + MCP Server</strong> 的智能诊断系统。</p>\n<h2>二、技术架构</h2>\n<h3>整体架构</h3>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐\n│                 │     │                 │     │                 │\n│      Dify       │────▶│   MCP Server    │────▶│  GameCloud API  │\n│   （模型编排）    │ MCP │    （提供工具）   │HTTP │   (云资源API)    │\n│                 │◀────│                 │◀────│                 │\n└─────────────────┘     └─────────────────┘     └─────────────────┘\n        │                       │\n        │                       │\n        ▼                       ▼\n   自然语言报告           ┌────────────────────────────┐\n                        │   MCP Tools 工具集          │\n                        ├────────────────────────────┤\n                        │ • get_instance_detail      │\n                        │ • get_instance_status      │\n                        │ • get_monitor_data         │\n                        │ • get_disks_info           │\n                        │ • get_network_interfaces   │\n                        │ • check_security_group     │\n                        │ • get_console_log          │\n                        │ • create_diagnostic_report │\n                        └────────────────────────────┘\n</code></pre></div><h3>技术栈</h3>\n<p>| 层级 | 技术选型 | 说明 |\n|</p>\n",
      "date_published": "2026-01-29T00:00:00.000Z",
      "date_modified": "2026-03-20T13:19:03.000Z",
      "authors": [],
      "tags": [
        "llm"
      ]
    },
    {
      "title": "校准概率机器：SDD 与 Skill 如何重新定义 AI 时代的元编程边界",
      "url": "https://blog.shabbywu.cn/posts/2026/03/17/llm-%E6%A0%A1%E5%87%86%E6%A6%82%E7%8E%87%E6%9C%BA%E5%99%A8-sdd-%E4%B8%8E-skill-%E5%A6%82%E4%BD%95%E9%87%8D%E6%96%B0%E5%AE%9A%E4%B9%89-ai-%E6%97%B6%E4%BB%A3%E7%9A%84%E5%85%83%E7%BC%96%E7%A8%8B%E8%BE%B9%E7%95%8C.html",
      "id": "https://blog.shabbywu.cn/posts/2026/03/17/llm-%E6%A0%A1%E5%87%86%E6%A6%82%E7%8E%87%E6%9C%BA%E5%99%A8-sdd-%E4%B8%8E-skill-%E5%A6%82%E4%BD%95%E9%87%8D%E6%96%B0%E5%AE%9A%E4%B9%89-ai-%E6%97%B6%E4%BB%A3%E7%9A%84%E5%85%83%E7%BC%96%E7%A8%8B%E8%BE%B9%E7%95%8C.html",
      "summary": "校准概率机器：SDD 与 Skill 如何重新定义 AI 时代的元编程边界 从 Python 元类到 BDD Scenario，约束系统的设计者才是真正的架构师 元编程的古典定义及其天花板 元编程（Metaprogramming）在软件工程中有一个精准的定义：用代码操纵代码，用结构描述结构，让程序在运行时或编译时自动完成原本需要手工编写的工作。 Pyt...",
      "content_html": "\n<blockquote>\n<p>从 Python 元类到 BDD Scenario，约束系统的设计者才是真正的架构师</p>\n</blockquote>\n<h2>元编程的古典定义及其天花板</h2>\n<p>元编程（Metaprogramming）在软件工程中有一个精准的定义：用代码操纵代码，用结构描述结构，让程序在运行时或编译时自动完成原本需要手工编写的工作。</p>\n<p>Python 生态是古典元编程的最佳展示场。</p>\n<p>Django 的 <code>ModelBase</code> 元类是其中最具代表性的案例：当你定义一个继承自 <code>models.Model</code> 的类时，<code>ModelBase.__new__</code> 会在类对象被创建的瞬间介入——它扫描所有 <code>Field</code> 描述符，注册到 <code>_meta</code> 容器，生成数据库迁移所需的元信息，并将该类绑定到 ORM 的查询管理器上。开发者写下的十行类定义，在 Python 解释器的元类机制下，展开为数百行的运行时行为。</p>\n<p>Flask 的路由装饰器是另一种形态的元编程：<code>@app.route(\"/api/v1/users\")</code> 不是一个注释，不是一个标记，而是一次<strong>运行时的注册行为</strong>——它在函数对象被定义时立即将其写入应用的 URL 规则树，并附上 HTTP 方法约束、参数提取规则和视图函数引用。元编程将「声明意图」与「完成注册」合并成了一个动作。</p>\n<p>这些机制的工程价值是真实的：它们消除了大量重复的样板代码，将框架的使用成本压缩到极低的水平。然而，古典元编程有一条无法逾越的边界——</p>\n<p><strong>它理解语法，但不理解语义。</strong></p>\n<p><code>ModelBase</code> 知道你定义了 <code>CharField(max_length=100)</code>，但它不知道这个字段代表「用户真实姓名，须经实名认证系统二次校验，且不得与历史记录重复」。</p>\n<p>Flask 的路由系统知道 <code>/api/v1/users</code> 接受 <code>POST</code> 请求，但它不知道这个接口「仅限内部服务调用，须携带服务间认证令牌，且每个 IP 每分钟限流 100 次」。那些关于业务规则、安全约束、数据治理的核心知识，始终游离在元编程机制之外，只能由人工逐处填补。</p>\n<p>开发者，因此成为了一台永不停歇的<strong>肉身编译器</strong>：接收高层的业务语义，输出低层的语法结构，无休止地循环。</p>\n",
      "image": "https://blog.shabbywu.cn/img/古典元编程对比AI元编程.png",
      "date_published": "2026-03-17T00:00:00.000Z",
      "date_modified": "2026-03-17T08:07:00.000Z",
      "authors": [],
      "tags": [
        "llm"
      ]
    },
    {
      "title": "C++ 模板元编程实践总结(Template, SFINAE, Type Traits)",
      "url": "https://blog.shabbywu.cn/posts/2024/09/18/c-tmp-exercise.html",
      "id": "https://blog.shabbywu.cn/posts/2024/09/18/c-tmp-exercise.html",
      "summary": "本文分享了在编写脚本语言的 c++ binding 库时使用模板元编程时的经验与教训。\n",
      "content_html": "<h2>背景</h2>\n<p>在编写脚本语言的 c++ binding 库时, 最繁琐的工序是实现类型转换。具体来说就是将脚本语言中的某个类型转换成 c++ 等价类型。\n对于一般的数值类型而言，在不考虑精度损失的情况下，可能直接用 <code>static_cast</code> 就能完成类型转换。但是对于复杂类型如字符串或指针，类型转换将是一个复杂问题。</p>\n<p>以下以笔者开源的 <a href=\"https://github.com/shabbywu/sqbind17\" target=\"_blank\" rel=\"noopener noreferrer\">squirrel-lang c++ binding</a> 为例, 介绍在模板元编程(Template metaprogramming, TMP)遇到的一些经验与教训。</p>\n<blockquote>\n<p>注: 更系统化的介绍可以在 <a href=\"https://en.cppreference.com/w/cpp/language/templates\" target=\"_blank\" rel=\"noopener noreferrer\">cppreference</a> 中找到。</p>\n</blockquote>\n<h2>c++模板与问题</h2>\n<p>在编写 <a href=\"https://github.com/shabbywu/sqbind17\" target=\"_blank\" rel=\"noopener noreferrer\">sqbind17</a> 时遇到的第一个问题就是数据类型转换。\nsquirrel-lang 使用 union + type 来存储所有对象，而 c++ 是强类型语言，以最常见的数值类型 integer(整数) 而言, c++ 可以使用 char, short, int, long, long long 表示有符合整数，因此在将 squirrel-lang 对象转换成 c++ integer 时, 无可避免要使用模板。(否则将要写很多重复代码)。</p>\n<p>以下是实现将 squirrel-lang 对象转换成 integer 的模板代码:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>template&lt;typename To&gt;\nTo cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><p>接下来, 我们可以继续编写转换成 floating point(浮点数) 的模板:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>template&lt;typename To&gt;\nTo cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><p>恭喜, 接下来就会出现<strong>编译错误</strong>: <code>error: redefinition of 'cast'</code>。这是因为这两个模板在类型替换(Substitution)后获得的<strong>函数签名</strong>是完全一样的, 以 <code>int</code> 为例, 替换后将得到以下 2 个函数。</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>int cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (int)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n\nint cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (int)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><p>我们的本意是希望 integer 和 floating point 分别使用不同的模板, 但 c++ 代码生成并不能<strong>智能地</strong>分辨出我们的<strong>意图</strong>。</p>\n<p>所以, 我们需要一种协议来告诉编译器对于 integer 和 floating point 分别使用不同的模板。这个协议就是 <code>SFINAE(Substitution Failure Is Not An Error)</code>。</p>\n<blockquote>\n<p>p.s. 对于简单数值类型而言, 将这 2 个模板合并成一个并非不可行。但在处理复杂类型(如指针)则不能简单地合并, 否则极容易出现编译错误。\n同时, if/else 虽然能控制运行期不会执行个别分支逻辑的代码，但函数体是实打实占着空间的。</p>\n</blockquote>\n<h2>SFINAE - Substitution Failure Is Not An Error</h2>\n<p>SFINAE 是 Substitution Failure Is Not An Error 这句话的首字母缩写, 直译成中文是 <strong>替换失败不是错误</strong>。\n想要明白这句话，首先要弄懂 2 个概念，<strong>Substitution Failure</strong> 和 <strong>Error</strong>。</p>\n<ul>\n<li>\n<p><strong>Error</strong> 在这里特指的是<strong>编译失败</strong>, SFINAE 意味着当发生 <strong>Substitution Failure</strong> 时, 编译器不认为这是编译失败, 取而代之的是编译器会从模板重载中移除 <strong>Substitution Failure</strong> 的特化实例，简单点理解就是 <strong>编译器会忽略替换失败的模板, 而不是直接返回编译失败</strong>。</p>\n</li>\n<li>\n<p><strong>Substitution Failure</strong> 是指模板特化失败。这句话的重点是区分<strong>失败</strong>与<strong>错误</strong>的含义，笔者的个人见解是 <strong>失败是指某个行为的结果，错误是对某个行为的结果的定性判断。</strong>。</p>\n</li>\n</ul>\n<p>在进入实例前最后总结下笔者的理解，SFINAE 是指编译器并不会根据单个模板替换失败而直接判断编译失败。相反，判断编译成功的唯一条件是<strong>针对某个特化实例，在所有可能模板中，有且仅有一个模板替换成功</strong>。</p>\n<p>重新回到上面针对 integer 和 floating point 转换的模板, 我们的目标是实现:</p>\n<ul>\n<li>integer 模板只有在当 To 是 integer 类型如 short, int, long 等时才能替换成功。</li>\n<li>floating point 模板只有在当 To 是 floating point 类型如 float, double 等时才能替换成功。</li>\n</ul>\n<p>为了实现上述目标，我们需要使用 c++ 11 引入的元编程库 <code>type_traits</code>。</p>\n<p><code>type_traits</code> 提供了模板结构体 <code>std::enable_if&lt;bool condition, class T = void&gt;</code>, 当且仅当 condition 为 true 时, 这个 std::enable_if 才有 <code>type</code> 字段。</p>\n<p>最后，当编译器在特化模板时遇到无法访问的 <code>type</code> 字段时, 这就叫 <strong>替换失败(Substitution Failure)</strong>。</p>\n<p>综上所述, 我们将上面的代码改成符合 SFINAE 定义中的应用场景则可编译通过，例如</p>\n<h3>1. 函数类型中使用的所有类型（包括返回类型和所有形参的类型）</h3>\n<h4>1.1 在函数返回值类型中使用 SFINAE</h4>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\n// std::is_integral 是 `type_traits` 中的工具函数, 可用于判断类型是否整数类型\ntemplate&lt;typename To&gt;\nstd::enable_if_t&lt;std::is_integral_v&lt;To&gt;, To&gt; cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n\n// std::is_floating_point 是 `type_traits` 中的工具函数, 可用于判断类型是否浮点数类型\ntemplate&lt;typename To&gt;\nstd::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;, To&gt; cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><h4>1.2 在函数参数中使用 SFINAE</h4>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\n// std::is_integral 是 `type_traits` 中的工具函数, 可用于判断类型是否整数类型\ntemplate&lt;typename To&gt;\nTO cast(SQObjectPtr from, std::enable_if_t&lt;std::is_integral_v&lt;To&gt;, To&gt;* = nullptr) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n\n// std::is_floating_point 是 `type_traits` 中的工具函数, 可用于判断类型是否浮点数类型\ntemplate&lt;typename To&gt;\nTo cast(SQObjectPtr from, std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;, To&gt; = nullptr) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><h3>2.各个模板形参声明中使用的所有表达式</h3>\n<h4>2.1 在非类型模板参数中使用 SFINAE</h4>\n<p>在非类型模板参数中使用 SFINAE 的原理是让 SFINAE 发生在非类型模板参数的默认值。</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_integral_v&lt;To&gt;, To&gt;* = nullptr&gt;\nTo cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;, To&gt;* = nullptr&gt;\nTo cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n</code></pre></div><blockquote>\n<p><code>[type] [name] = [default value]</code> 是非类型模板参数的声明方式, 其中 <code>[name]</code> 可省略。\nSFINAE 的常见用法是设置成默认值为 nullptr 的指针类型</p>\n</blockquote>\n<h4>2.2 在类型模板参数中使用 SFINAE</h4>\n<p>在类型模板参数中使用 SFINAE 的原理是让 SFINAE 发生在类型模板参数的默认值。\n需要注意一点, <strong>c++ 不允许定义模板标识一致，但默认值不一样的模板</strong>，因此，在类型模板参数中使用 SFINAE 只能做到编译期的类型校验。</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To, typename SFINAE = std::enable_if_t&lt;std::is_integral_v&lt;To&gt;, To&gt;&gt;\nTo cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n}\n\n// 不能同时定义具有相同签名，但默认参数不一样的模板。\n// 以下模板编译时会报错: error: template parameter redefines default argument\n// template&lt;typename To, typename SFINAE = std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;, To&gt;&gt;\n// To cast(SQObjectPtr from) {\n//     if (from._type == tagSQObjectType::OT_FLOAT) {\n//         // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n//         return (To)_float(from);\n//     }\n//     // error-handling\n//     throw sqbind17::value_error(\"unsupported value\");\n// }\n</code></pre></div><blockquote>\n<p><code>typename [name] = [default value]</code> 是带有默认值的类型模板参数声明方式, 其中 <code>[name]</code> 可省略。\n在类型模板参数中使用 SFINAE 只能做到编译期的类型校验。</p>\n</blockquote>\n<h3>3. 部分特化的模板实参列表中使用的所有类型</h3>\n<p>模板函数不支持部分特化, 只有模板类支持部分特化。</p>\n<p>以下通过将上述函数声明成模板类中的静态方法来演示如何在部分特化中使用 SFINAE。</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To, typename Enable = void&gt;\nstruct Transformer;\n\ntemplate&lt;typename To&gt;\nstruct Transformer&lt;To, std::enable_if_t&lt;std::is_integral_v&lt;To&gt;&gt;&gt; {\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n\n\ntemplate&lt;typename To&gt;\nstruct Transformer&lt;To, std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;&gt;&gt; {\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n</code></pre></div><h2>常见的 SFINAE 错误使用方式(硬错误)</h2>\n<h3>1. 在偏特化模板声明中使用 SFINAE</h3>\n<p>这是一种常见 <strong>部分特化 SFINAE</strong> 错误写法:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To&gt;\nstruct Transformer;\n\n// error: default template argument in a class template partial specialization\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_integral_v&lt;To&gt;&gt;* = nullptr&gt;\nstruct Transformer&lt;To&gt; {\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n\n// error: default template argument in a class template partial specialization\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;&gt;* = nullptr&gt;\nstruct Transformer&lt;To&gt; {\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n</code></pre></div><p>编译错误的原因是<strong>偏特化模板不支持默认参数</strong>, 正确写法在 <strong>3. 部分特化的模板实参列表中使用的所有类型</strong> 中已展示。</p>\n<h3>2. 在类/结构体模板声明使用 SFINAE</h3>\n<p>在类/结构体模板声明使用 SFINAE 时有 2 种常见的 <strong>redefinition</strong> 错误。</p>\n<h4>2.1 <strong>非类型模板参数中使用 SFINAE</strong> 的错误用法:</h4>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_integral_v&lt;To&gt;&gt;* = nullptr&gt;\nstruct Transformer{\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n\n// error: template non-type parameter has a different type 'std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;&gt; *' (aka 'typename enable_if&lt;std::is_floating_point_v&lt;To&gt;, void&gt;::type *') in template redeclaration\ntemplate&lt;typename To, std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;&gt;* = nullptr&gt;\nstruct Transformer{\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n</code></pre></div><p>编译错误的原因是 c++ 不允许在 2 个相同顺位的形参使用不同的非类型参数类型。</p>\n<blockquote>\n<p>注: 该编译错误优先于模板替换, 此时仍未进行模板替换。换句话说这个错误的原因与 SFINAE 无关。即使将 std::enable_if_t 替换成其他类型, 也会编译出错。</p>\n</blockquote>\n<h4>2.2 <strong>类型模板参数中使用 SFINAE</strong> 的错误用法:</h4>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate&lt;typename To, typename = std::enable_if_t&lt;std::is_integral_v&lt;To&gt;&gt;&gt;\nstruct Transformer{\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_INTEGER) {\n        // _integer 是宏, 展开后是 from._unVal.nInteger; 其中 _unVal 是 union 联合体.\n        return (To)_integer(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n\n// error: redefinition of 'Transformer'\ntemplate&lt;typename To, typename = std::enable_if_t&lt;std::is_floating_point_v&lt;To&gt;&gt;&gt;\nstruct Transformer{\n    static inline To cast(SQObjectPtr from) {\n    if (from._type == tagSQObjectType::OT_FLOAT) {\n        // _float 是宏, 展开后是 from._unVal.fFloat; 其中 _unVal 是 union 联合体.\n        return (To)_float(from);\n    }\n    // error-handling\n    throw sqbind17::value_error(\"unsupported value\");\n    }\n};\n</code></pre></div><p>编译错误的原因是上述 2 个模板重复定义，因为 c++ 不以默认参数来区分不同的模板声明。</p>\n<h3>3. 在替换后发生的求值错误</h3>\n<p>函数体或类成员/方法中的求值错误是硬错误，例如:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\n\ntemplate &lt;typename T&gt;\nvoid sample(T*) {\n    std::enable_if_t&lt;std::is_integral_v&lt;T&gt;&gt;* error;\n}\n\nint main() {\n    sample&lt;int&gt;(nullptr);\n    // sample&lt;float&gt;(nullptr); // 硬错误,  error: no type named 'type' in 'std::enable_if&lt;false&gt;'; 'enable_if' cannot be used to disable this declaration\n}\n</code></pre></div><p>补充说明一点, 嵌套 SFINAE 是<strong>不支持</strong>的, 例如:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\ntemplate&lt;typename A&gt;\nstruct B { using type = typename A::type; };\n\n\ntemplate &lt;typename T&gt;\nvoid sample(typename B&lt;std::enable_if&lt;std::is_integral_v&lt;T&gt;&gt;&gt;::type* = nullptr) {}\n\n\nint main() {\n    sample&lt;int&gt;(nullptr);\n    // sample&lt;float&gt;(nullptr); // 硬错误, error: no type named 'type' in 'std::enable_if&lt;false&gt;'\n}\n</code></pre></div><p>同理, 以下模板也会编译错误:</p>\n<div class=\"language-c++\" data-ext=\"c++\" data-title=\"c++\"><pre class=\"language-c++\"><code>#include &lt;type_traits&gt;\ntemplate&lt;typename A&gt;\nstruct B { using type = typename A::type; };\n\n\ntemplate &lt;typename T&gt;\nvoid sample(typename B&lt;std::enable_if&lt;std::is_integral_v&lt;T&gt;&gt;&gt;::type* = nullptr) {}\n\n\n// error: no type named 'type' in 'std::enable_if&lt;false&gt;'\ntemplate &lt;typename T&gt;\nvoid sample(typename B&lt;std::enable_if&lt;std::is_floating_point_v&lt;T&gt;&gt;&gt;::type* = nullptr) {}\n\n\nint main() {\n    sample&lt;int&gt;(nullptr);\n}\n</code></pre></div><h2>小结</h2>\n<p>这篇文章以笔者在编写脚本语言 c++ binding 库时遇到的类型转换为引子, 先介绍了 c++ 模板的使用方式和遇到的问题，再以此引申出 SFINAE 的概念，SFINAE 的正确用法以及 SFINAE 的错误用法。\n模板编译错误的主要原因是 <strong>redefinition(重复定义)</strong>, 在定义同名模板时需严格保证模板实例化后的模板标识是不同的。\n总之，想要正确使用 SFINAE，关键要点是先定义出<strong>正确的模板</strong>，再在<strong>模板替换</strong>时应用 SFINAE。</p>\n",
      "date_published": "2024-09-18T00:00:00.000Z",
      "date_modified": "2024-09-24T08:05:05.000Z",
      "authors": [],
      "tags": [
        "c++"
      ]
    },
    {
      "title": "Git 合并代码的不同方式 - Merge Commit、Squash and merge、Cherry-pick、Rebase and merge",
      "url": "https://blog.shabbywu.cn/posts/2022/05/27/git-merge-method.html",
      "id": "https://blog.shabbywu.cn/posts/2022/05/27/git-merge-method.html",
      "summary": "前言 我们在日常开发中经常使用 Git 管理代码, 每个人在各自的分支开发代码, 开发完毕后在 Gitlab/Github 上提交 MR/PR, 最后点击 merge 按钮即将代码合并至主分支... 在稍微学习 Git 相关的知识后, 我们会发现 Git 代码合并的方法绝不仅此一种, 不同的代码合并方式之间有什么差异？各自又适用于什么样的场景？我将在这...",
      "content_html": "<h2>前言</h2>\n<p>我们在日常开发中经常使用 Git 管理代码, 每个人在各自的分支开发代码, 开发完毕后在 Gitlab/Github 上提交 MR/PR, 最后点击 merge 按钮即将代码合并至主分支...</p>\n<p>在稍微学习 Git 相关的知识后, 我们会发现 Git 代码合并的方法绝不仅此一种, 不同的代码合并方式之间有什么差异？各自又适用于什么样的场景？我将在这篇文章为大家展开聊聊这些话题。</p>\n<h2>不同的代码合并方式</h2>\n<h3>1. Merge</h3>\n<p>在常见的 Git 工作流中, 我们会有 2 个长期存在并且不会被删除的分支: master 和 develop。而在日常开发流程中, 我们使用的是特性分支，也叫功能分支。当需要开发一个新的功能的时候，可以新建一个 feature-xxx 的分支，在里边开发新功能，开发完成后，将之并入 develop 分支中，如下图:</p>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>                        H</code></pre></div>",
      "date_published": "2022-05-27T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "基础技术"
      ]
    },
    {
      "title": "Git Merge Conflicts",
      "url": "https://blog.shabbywu.cn/posts/2022/06/08/git-merge-conflicts.html",
      "id": "https://blog.shabbywu.cn/posts/2022/06/08/git-merge-conflicts.html",
      "summary": "前言 在上一篇文章中介绍了 Merge Commit、Squash Merge、Cherry-pick、Rebase Merge 等合并方法的差异和使用场景, 接下来我们继续讨论与 Git 密不可分的另一个话题 -- 代码合并冲突。 为什么会发生代码冲突？ 当多个开发者试图编辑相同的内容时, 那么就可能发送代码冲突。例如以下就是典型的代码冲突时的场景:...",
      "content_html": "<h2>前言</h2>\n<p>在上一篇文章<a href=\"/posts/2022/05/27/git-merge-method.html\" target=\"_blank\">Git 合并代码的不同方式</a>中介绍了 Merge Commit、Squash Merge、Cherry-pick、Rebase Merge 等合并方法的差异和使用场景, 接下来我们继续讨论与 Git 密不可分的另一个话题 -- 代码合并冲突。</p>\n<h2>为什么会发生代码冲突？</h2>\n<p>当多个开发者试图编辑相同的内容时, 那么就可能发送代码冲突。例如以下就是典型的代码冲突时的场景:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## 一开始, 我们实现了一个很简单的 a + b 的函数\ndef add(a, b):\n    try:\n        return 0, a + b\n    except Exception:\n        return -1, None\n</code></pre></div><div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## 然后, 有个开发者希望用 contextlib.suppress 替换这个 try-except\nfrom contextlib import suppress\n\n\ndef add(a, b):\n    with suppress(Exception):\n        return 0, a + b\n    return -1, None\n</code></pre></div><div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## 但是, 另一个开发者又提交了一个 bugfix, 希望修复当 a, b 为字符串时, 结果不符合预期的问题\ndef add(a, b):\n    try:\n        return 0, int(a) + int(b)\n    except Exception:\n        return -1, None\n</code></pre></div><p>当我们分别合并以上两份代码时, 就会出现代码冲突:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def add(a, b):\n&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD\n    with suppress(Exception):\n        return 0, a + b\n    return -1, None\n=======\n    try:\n        return 0, int(a) + int(b)\n    except Exception:\n        return -1, None\n&gt;&gt;&gt;&gt;&gt;&gt;&gt; bugfix\n</code></pre></div><h2>理解冲突的含义</h2>\n<p>合并和冲突是所有版本管理工具都会存在的正常情况, 在大多数情况下, Git 都可以智能地解决<strong>无歧义</strong>的合并方案, 但是如果合并有歧义(即冲突), 不像其他的版本控制系统, Git 绝对不会尝试智能解决它。我们必须解决所有合并冲突后, 才能真正完成 2 个分支的合并。</p>\n<h2>合并冲突的类型</h2>\n<p>一般情况下, 只有在两个开发者分别修改 1 个文件的相同行时, 或者是一个开发者在修改一份被另一个开发者删除的文件时, 才会出现代码冲突 -- 因为 Git 无法判断哪一个才是正确的。以上这些情况都已经在进行分支合并了, 事实上冲突还可能发生在合并启动之前。</p>\n<h3>Git 无法启动合并</h3>\n<p>当 Git 发现当前项目的工作目录或暂存区域发生更改时, 合并将无法启动。当发生以下情况时, 并不是意味着你的代码与其他开发者发生冲突, 反而是与你本地的其他(未保存的)变更发生冲突。想要解决这种状态也很简单, 以下是针对不同情况使用的指令:</p>\n<ul>\n<li><code>git stash</code> &gt;  贮藏（stash）会处理工作目录的脏的状态 —— 即跟踪文件的修改与暂存的改动 —— 然后将未完成的修改保存到一个栈上， 而你可以在任何时候重新应用这些改动（甚至在不同的分支上）。</li>\n<li><code>git checkout</code> &gt;  检出（checkout）会恢复工作目录中的文件至未修改的状态, 与 <strong>stash</strong> 不同, <strong>checkout</strong> 会直接丢弃当前未完成的修改, 如果你不希望工作内容被丢弃, 请使用 <code>git stash</code>。</li>\n<li><code>git commit</code> &gt;  提交（commit）会将当前暂存区域的所有变更保存至版本变更记录中。</li>\n<li><code>git reset</code> &gt;  重置（reset）会只丢弃当前缓存区域的状态, 而保留工作目录的状态。如果文件不存在于版本库, 那么使用 <code>git reset</code> 即可将该文件的状态设置为 <strong>untracked</strong>。</li>\n</ul>\n<figure><img src=\"/img/Git文件状态.png\" alt=\"Git文件状态\" tabindex=\"0\" loading=\"lazy\"><figcaption>Git文件状态</figcaption></figure>\n<blockquote>\n<p>只有所有文件都处于 unmodified 或 untracked 时, Git 才能启动合并。</p>\n</blockquote>\n<h3>Git 智能合并失败</h3>\n<p>当合并过程发生冲突时, 合并将会中止, 此时的代码处于 <code>合并中</code> 的状态。 当这种情况发生时, 代码库将无法进行 <code>pull</code>、<code>push</code> 等操作，直至开发者解决冲突完成合并后(或者中止合并)。</p>\n<p>典型的代码冲突例子在前文已有提及, 再次强调, 通常情况下只有在两个开发者分别修改 1 个文件的相同行时, 或者是一个开发者在修改一份被另一个开发者删除的文件时, 才会出现代码冲突。但总会有些我们预料之外的非典型冲突。</p>\n<h4>Git Squash Merge 冲突</h4>\n<p>在上一篇文章<a href=\"/posts/2022/05/27/git-merge-method.html\" target=\"_blank\">Git 合并代码的不同方式</a>中有提及, <code>Squash Merge</code> 会将代码提交记录压缩合并为 1个节点, 并追加至当前分支的末尾。使用 <code>Squash Merge</code> 会产生以下的拓扑结构:</p>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>                        H</code></pre></div>",
      "image": "https://blog.shabbywu.cn/img/Git文件状态.png",
      "date_published": "2022-06-08T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "基础技术"
      ]
    },
    {
      "title": "记一次时区异常问题排查思路和过程",
      "url": "https://blog.shabbywu.cn/posts/2023/08/18/%E8%AE%B0%E4%B8%80%E6%AC%A1%E6%97%B6%E5%8C%BA%E5%BC%82%E5%B8%B8%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5%E6%80%9D%E8%B7%AF%E5%92%8C%E8%BF%87%E7%A8%8B.html",
      "id": "https://blog.shabbywu.cn/posts/2023/08/18/%E8%AE%B0%E4%B8%80%E6%AC%A1%E6%97%B6%E5%8C%BA%E5%BC%82%E5%B8%B8%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5%E6%80%9D%E8%B7%AF%E5%92%8C%E8%BF%87%E7%A8%8B.html",
      "summary": "本文介绍了一次时区异常问题排查的思路和过程，并总结了一些经验教训。\n",
      "content_html": "<h2>背景介绍</h2>\n<p>最近有用户反馈线上运行的 nodejs 应用时区信息不对, 拿到的时间是 UTC 时区的时间。</p>\n<div class=\"language-node\" data-ext=\"node\" data-title=\"node\"><pre class=\"language-node\"><code>&gt; Date()\nFri Aug 11 2023 06:26:02 GMT+0000 (Coordinated Universal Time)\n</code></pre></div><p>登录容器后排查，发现容器内的时区应该是东八区，不应该获取到 UTC 时区的时间。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>❯ date\nFri Aug 11 14:23:51 CST 2023\n</code></pre></div><p>使用 python 交叉验证时区信息均是东八区。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>&gt;&gt;&gt; import datetime\n&gt;&gt;&gt; datetime.datetime.now()\ndatetime.datetime(2023, 8, 11, 14, 27, 42, 544771)\n</code></pre></div><blockquote>\n<p>通过交叉验证暂时可以判断是 nodejs 获取时区的逻辑与 linux <code>date</code> 命令和 python <code>datetime.now()</code> 函数不一致<br>\n所以需要先确认 <code>nodejs</code> 获取时区的逻辑，nodejs 的源码可以在 <a href=\"https://github.com/nodejs/node\" target=\"_blank\" rel=\"noopener noreferrer\">nodejs/node</a> 项目中找到。</p>\n</blockquote>\n<h2>排查历程</h2>\n<h3>深入源码分析</h3>\n<p>通过查阅 nodejs 的源码, 可以追踪到 nodejs 的时区选择是基于 <a href=\"https://github.com/nodejs/node/tree/main/deps/icu-small\" target=\"_blank\" rel=\"noopener noreferrer\">ICU, International Components for Unicode</a>(一个跨平台的字符和时间处理库), 而 python 的  <code>datetime.datetime.now()</code> 是通过 c 语言的 <a href=\"https://github.com/python/cpython/blob/main/Modules/_datetimemodule.c#L5120\" target=\"_blank\" rel=\"noopener noreferrer\">localtime_r</a> 获取系统时间。</p>\n<blockquote>\n<p><strong>显然，目前可以判断时区错误是 <code>ICU</code> 算法与 <code>localtime_r</code> 的行为不一致导致的。</strong></p>\n</blockquote>\n<p>因此需要再次深入 <code>ICU</code> 的算法排查, 以下是 <code>ICU</code> 时区探测主入口 <a href=\"https://github.com/nodejs/node/blob/main/deps/icu-small/source/i18n/timezone.cpp#L457\" target=\"_blank\" rel=\"noopener noreferrer\">TimeZone::detectHostTimeZone</a> 的部分代码摘录。</p>\n<div class=\"language-cpp\" data-ext=\"cpp\" data-title=\"cpp\"><pre class=\"language-cpp\"><code>TimeZone* U_EXPORT2\nTimeZone::detectHostTimeZone()\n{\n    ...\n    // Get the timezone ID from the host.  This function should do\n    // any required host-specific remapping; e.g., on Windows this\n    // function maps the Windows Time Zone name to an ICU timezone ID.\n    hostID = uprv_tzname(0);\n\n    UnicodeString hostStrID(hostID, -1, US_INV);\n\n    hostZone = createSystemTimeZone(hostStrID);\n    ...\n}\n</code></pre></div><p>显而易见, 时区探测的实现在 <a href=\"https://github.com/nodejs/node/blob/main/deps/icu-small/source/common/putil.cpp#L647\" target=\"_blank\" rel=\"noopener noreferrer\">uprv_timezone</a></p>\n<p><code>uprv_timezone</code> 实现十分复杂, 有大量的 if-else 分支, 以下是摘取出来的有效代码。</p>\n<div class=\"language-cpp\" data-ext=\"cpp\" data-title=\"cpp\"><pre class=\"language-cpp\"><code>U_CAPI const char* U_EXPORT2\nuprv_tzname(int n)\n{\n    // 获取软链指向的真实路径, 结果存在 gTimeZoneBuffer\n    // TZDEFAULT = \"/etc/localtime\"\n    char *ret = realpath(TZDEFAULT, gTimeZoneBuffer);\n    if (ret != nullptr &amp;&amp; uprv_strcmp(TZDEFAULT, gTimeZoneBuffer) != 0) {\n        // TZZONEINFOTAIL = \"/zoneinfo/\"\n        int32_t tzZoneInfoTailLen = uprv_strlen(TZZONEINFOTAIL);\n        // uprv_strstr 返回 TZZONEINFOTAIL 在 gTimeZoneBuffer 的起始位置\n        const char *tzZoneInfoTailPtr = uprv_strstr(gTimeZoneBuffer, TZZONEINFOTAIL);\n        if (tzZoneInfoTailPtr != nullptr) {\n            tzZoneInfoTailPtr += tzZoneInfoTailLen;\n            // 判断 /zoneinfo/ 后面的子串是否 Olson ID\n            if (isValidOlsonID(tzZoneInfoTailPtr)) {\n                return (gTimeZoneBufferPtr = tzZoneInfoTailPtr);\n            }\n        }\n    }\n    ...\n}\n</code></pre></div><p>终于排查到关键路径, 以上代码判断的<strong>核心逻辑</strong>是: 如果 <code>/etc/localtime</code> 是一个符号链接, 且链接指向的路径符合 <code>Olson ID</code> 的规则, 那么将会使用该 <code>Olson ID</code> 代表的时区, 否则将直接返回字符串, 代表未设置时区。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">延伸阅读: 什么是 Olson Time Zone IDs</p>\n<p>Olson ID（Olson Time Zone ID）是一个用于标识世界各个时区的唯一标识符。它通常以字符串的形式表示，例如：\"America/New_York\"、\"Europe/London\" 等。这些标识符是由 \"Olson Time Zone Database\"（也称为 \"tz database\" 或 \"IANA Time Zone Database\"）维护和分发的。</p>\n<p>完整的 <a href=\"https://docs.poly.com/bundle/trio-ag-5-9-3-AA/page/r2732735.html\" target=\"_blank\" rel=\"noopener noreferrer\">Olson Time Zone IDs</a> 列表看通过链接查看。</p>\n</div>\n<h3>重回案发现场</h3>\n<p>通过查阅源码终于找到了问题的蛛丝马迹, 接下来我们重新返回案发现场确认导致 Bug 的真正原因。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>root@******:~## ls -lah /etc/localtime\nlrwxrwxrwx 1 root root 27 Apr 21  2020 /etc/localtime -&gt; /usr/share/zoneinfo/Etc/UTC\n</code></pre></div><p>可以看到, <code>/etc/localtime</code> 的确是符号链接, 且链接的路径对应的是 <code>Etc/UTC</code>。但 <code>Etc/UTC</code> 并非 <code>Olson ID</code>, 因此 nodejs 无法正常判断系统的时区, 最后使用了 UTC 时区作为缺省值。</p>\n<p>修复方式也很简单，只需要将 <code>/etc/localtime</code> 的符号链接指向代表东八区的 Olson ID 文件即可。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 修复软连\nroot@******:~## rm /etc/localtime\nroot@******:~## ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime\n## 验证修复\nroot@******:~## ls -lah /etc/localtime\nlrwxrwxrwx 1 root root 27 Apr 21  2020 /etc/localtime -&gt; /usr/share/zoneinfo/Asia/Shanghai\n\n## nodejs 里执行\n&gt; Date()\nFri Aug 11 2023 14:46:22 GMT+0800 (China Standard Time)\n</code></pre></div><p>到此, 我们终于排查出 nodejs 获取到错误时区的原因是因为未正确设置 <code>/etc/localtime</code> 链接的路径。</p>\n<p>但令人费解的事情出现了, 明明 <code>/etc/localtime</code> 指向的是 <code>/usr/share/zoneinfo/Etc/UTC</code>, 为什么<strong>操作系统和 python 都认为是东八区</strong>呢？</p>\n<h2>案中案</h2>\n<p>显然问题不是那么简单, 我们再次将目光移到 python 时间的底层实现 <a href=\"https://linux.die.net/man/3/localtime_r\" target=\"_blank\" rel=\"noopener noreferrer\">localtime_r</a> 身上。\n<code>localtime_r</code> 依赖 <code>tzset</code> 函数设置时区, 以下是 <code>tzset</code> 的注释(部分摘要)的翻译。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">Linux man page - tzset(3)</p>\n<p><strong>tzset()</strong> 函数从环境变量 TZ 初始化 <code>tzname</code> 变量。<br>\n如果环境变量 TZ 不存在，<code>tzname</code> 变量将被初始化为本地时间的最佳近似值，该值由系统时区目录中的 <strong>tzfile(5)</strong> 格式文件 localtime 指定。(通常是 /etc/localtime)</p>\n</div>\n<p>我们不妨看下案发现场中, <code>/etc/localtime</code> 的内容究竟是什么。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>root@******:~## cat /etc/localtime\nTZif2\n     �Y^��      �p�ӽ����|@�;&gt;�Ӌ{��B���E\"�L���&lt;��fp���A|��R i�� ~��!I}�\"g� #)_�$G� %|&amp;'e &amp;�^(G (�@q�~�pLMTCDTCSTTZif2\n                                                                                                                    �����~6C)�����Y^������ �p�����ӽ������������|@�����;&gt;�����Ӌ{������B�������E\"�����L�������&lt;������fp�����������A|��R i�� ~��!I}�\"g� #)_�$G� %|&amp;'e &amp;�^(G (�@q�~�pLMTCDTCST\nCST-8\n</code></pre></div><p>虽然文件有一堆乱码, 但我们可以很清晰看到 <code>/etc/localtime</code> 指向的 <code>/usr/share/zoneinfo/Etc/UTC</code> 文件记录的时区是 <code>CST-8</code>, 也就是东八区。</p>\n<p>所以, 这个奇怪的案发现场是虽然 <code>/etc/localtime</code> 软链指向了 <code>/usr/share/zoneinfo/Etc/UTC</code>, 但实际上 <code>/usr/share/zoneinfo/Etc/UTC</code> 的内容是 <code>Asia/Shanghai</code>。</p>\n<blockquote>\n<p>通过 md5sum 计算的摘要进一步确认问题的确如此。</p>\n</blockquote>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>root@******:~## md5sum /usr/share/zoneinfo/Etc/UTC\n1d458654143678b18662d1b5b4b5de9d  /usr/share/zoneinfo/Etc/UTC\nroot@******:~## md5sum /usr/share/zoneinfo/Asia/Shanghai \n1d458654143678b18662d1b5b4b5de9d  /usr/share/zoneinfo/Asia/Shanghai\n</code></pre></div><h3>符号链接引发的乌龙事件</h3>\n<p>终于追查时区问题的根源 - <code>/usr/share/zoneinfo/Etc/UTC</code> 被意外覆盖了。<br>\n但是谁会闲着无事将 <code>Asia/Shanghai</code> 的内容覆盖到 <code>Etc/UTC</code> 呢？显然这不是正常行为。<br>\n带着最后的疑问我翻查了这个容器镜像的 <code>Dockerfile</code></p>\n<div class=\"language-docker\" data-ext=\"docker\" data-title=\"docker\"><pre class=\"language-docker\"><code>FROM heroku/heroku:18.v27\n...\n## 设置时区\nRUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime\n\n...\n</code></pre></div><p>如上所示, 这个容器镜像通过将 <code>/usr/share/zoneinfo/Asia/Shanghai</code> 复制到 <code>/etc/localtime</code> 来设置市区。<br>\n但很不巧, <code>/etc/localtime</code> 是一个符号链接, 因此这个操作实际上是将 <code>/usr/share/zoneinfo/Asia/Shanghai</code> 复制到了 <code>/usr/share/zoneinfo/Etc/UTC</code>。</p>\n<h2>总结</h2>\n<p>往往隐藏很深的问题, 根本原因就是这么简单 -- <strong>符号链接本质是一个指向另一个文件或目录的路径引用</strong>。<br>\n当访问符号链接时，操作系统会根据路径引用跳转到链接指向的实际文件。</p>\n<ul>\n<li>设置时区需谨慎, 最通用的方案是设置 <code>TZ</code> 环境变量。</li>\n<li>容器内覆盖文件需谨慎, 避免遇到符号链接导致出现预期外的行为。</li>\n<li>使用符号链接需谨慎，读写符号链接实际上操作的是引用的</li>\n</ul>\n",
      "date_published": "2023-08-18T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "基础技术"
      ]
    },
    {
      "title": "OAuth2.0 Client: 细说服务端应用如何使用 OAuth2.0 完成用户授权验证",
      "url": "https://blog.shabbywu.cn/posts/2023/09/04/oauth-client-server-side-apps.html",
      "id": "https://blog.shabbywu.cn/posts/2023/09/04/oauth-client-server-side-apps.html",
      "summary": "前言 OAuth2.0 是行业标准的用户授权框架，针对不同的使用场景提供了多种授权方式。关于 OAuth2.0 认证框架的所有细节都可以在 RFC 6749 找到对应的详细说明，然而阅读规范并不是了解 OAuth2.0 工作流程的最佳方式。 本文以接入 GitHub 授权作为使用场景，针对性介绍服务端应用(Server-Side Apps)如何使用 O...",
      "content_html": "<h2>前言</h2>\n<p>OAuth2.0 是行业标准的用户授权框架，针对不同的使用场景提供了多种授权方式。关于 OAuth2.0 认证框架的所有细节都可以在 <a href=\"https://datatracker.ietf.org/doc/html/rfc6749\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6749</a> 找到对应的详细说明，然而阅读规范并不是了解 OAuth2.0 工作流程的最佳方式。<br>\n本文以接入 GitHub 授权作为使用场景，针对性介绍服务端应用(<em>Server-Side Apps</em>)如何使用 OAuth2.0 完成用户授权验证。</p>\n<h2>服务端应用如何接入 GitHub？</h2>\n<p>想要集成 GitHub 到你的平台、或者获取用户在 GitHub 的数据甚至是实现自动化工作流程，就需要使用 GitHub API。在调用 API 前需要完成用户对第三方平台的授权，这个流程就是 OAuth2.0。</p>\n<h3>1. 创建 OAuth App</h3>\n<p>在开始所有流程之前, 我们首先需要在 GitHub 创建 <strong>OAuth App</strong>, 这也是 OAuth2.0 流程中的客户端(<em>client</em>)。</p>\n<details class=\"hint-container details\"><summary>延伸阅读: OAuth2.0 中的角色</summary>\n<p><strong>客户端(<em>client</em>)</strong> 是指在获得 <strong>资源所有者(<em>resource owner</em>)</strong> 授权后, 使用从 <strong>授权服务器(<em>authorization server</em>)</strong> 获取到的代表资源所有者身份的访问令牌访问存储在 <strong>资源服务器(<em>resource server</em>)</strong> 的受保护资源的应用程序。</p>\n<p>以接入 GitHub 为例:<br>\n客户端(<em>client</em>)即 GitHub OAuth App;<br>\n资源所有者(<em>resource owner</em>)即任一 GitHub 用户;<br>\n资源服务器(<em>resource server</em>)即 GitHub;<br>\n授权服务器(<em>authorization server</em>)即 GitHub;</p>\n</details>\n<p>创建 GitHub OAuth App 很简单, 首先在 <a href=\"https://github.com/settings\" target=\"_blank\" rel=\"noopener noreferrer\">「Settings」</a> 页找到 <a href=\"https://github.com/settings/developers\" target=\"_blank\" rel=\"noopener noreferrer\">「Developer Settings」</a>, 再点击 <a href=\"https://github.com/settings/applications/new\" target=\"_blank\" rel=\"noopener noreferrer\">「New OAuth App」</a> 即可创建。</p>\n<blockquote>\n<p>如果您之前没有创建过应用程序，此按钮会显示“Register a new application”。</p>\n</blockquote>\n<p>创建 OAuth App 需要填写应用的基本信息, 除了基本的应用名称(Application name), 应用主页(Homepage URL) 和应用描述(Application description)以外, 最重要的是一项是<strong>授权回调地址(<em>Authorization callback URL</em>)</strong>, 这也常被简称<strong>重定向地址(<em>Redirect URL</em>)</strong>。</p>\n<h3>2. 规划重定向地址(<em>Redirect URL</em>)</h3>\n<p><strong>重定向(<em>Redirect</em>)</strong> 是 授权服务器(<em>authorization server</em>) 对 客户端(<em>client</em>) 发起的 <strong>授权请求(<em>Authorization Request</em>)</strong> 进行鉴权的重要措施。主要用途是防止攻击者拦截授权代码或访问令牌的重定向攻击。</p>\n<p>为了安全起见, <strong>重定向地址(Redirect URL)</strong> 应当尽量使用 HTTPS 地址以防止在授权过程中遭受被中间人拦截泄露 <strong>授权码(<em>authorization code</em>)</strong>。</p>\n<details class=\"hint-container details\"><summary>延伸阅读: 什么是授权码(<em>authorization code</em>)</summary>\n<p>在 OAuth2.0 中 <strong>授权码(<em>authorization code</em>)</strong> 有两层含义:</p>\n<ol>\n<li>字面上的含义是由 授权服务器(<em>authorization server</em>) 签发给 客户端(<em>client</em>), 供其换取访问令牌的临时代码。</li>\n<li>另一层含义是指 OAuth 2.0 中定义的最主要的授权流程 - <em><strong>Authorization Code Grant</strong></em>。</li>\n</ol>\n</details>\n<p>设置重定向地址后即完成成功创建 GitHub OAuth App。</p>\n<h3>3. 发起授权请求(Authorization Request)</h3>\n<p>创建 OAuth 应用后, GitHub 会给每个 OAuth 应用分配唯一的客户端标识(Client ID) 和客户端密钥(Client secret), 接下来即可向授权服务器(<em>authorization server</em>)发起授权请求 -- 即<strong>将用户重定向到授权服务器</strong>。</p>\n<p>重定向到授权服务的 URL 需要按照一定规则构造, 以 GitHub 为例则需要构造如下的 URL:</p>\n<blockquote>\n<p>https://github.com/login/oauth/authorize?<br>\nclient_id=3934c6721961da9062bf<br>\n&amp;redirect_uri=https%3A%2F%2Fblog.shabbywu.cn<br>\n&amp;scope=public_repo\n&amp;state=WkZRRNbeEMZEpBxRLopS</p>\n</blockquote>\n<p>URL中涉及 4 个参数, 分别是:</p>\n<ul>\n<li>client_id: GitHub OAuth App 的客户端标识(Client ID)</li>\n<li>redirect_uri: 用户授权后将重定向到该页面</li>\n<li>scope: 需要用户授权的资源请求范围, public_repo 即需要授权访问 GitHub 中的公开仓库</li>\n<li>state: 描述重定向前应用状态的字符串, 授权服务器会将 state 原样返回到授权成功后跳转的 redirect_uri</li>\n</ul>\n<p>正确拼接 URL 后打开即可看到如下所示的界面:\n<img src=\"/img/GitHub-OAuth-Authrozation-Example.png\" alt=\"GitHub OAuth 授权示例\" loading=\"lazy\"></p>\n<h3>4. 获取资源访问令牌(Access Token)</h3>\n<p>点击绿色按钮确认授权后，将从 GitHub 重定向到 redirect_uri, 此时重定向的 URL 中的 params 部分会有 2 个重要的参数, 分别是:</p>\n<ul>\n<li>code: 授权码(<em>authorization code</em>), 客户端(<em>client</em>) 需要用 授权码(<em>authorization code</em>) 从 授权服务器(<em>authorization server</em>) 换取访问令牌</li>\n<li>state: 描述重定向前应用状态的字符串, 与构造的授权地址中的 state 一致</li>\n</ul>\n<p>以下是一个示例地址(其中的 code 已脱敏):</p>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>https://blog.shabbywu.cn/?   \ncode=ABCDEFG   \n&amp;state=WkZRRNbeEMZEpBxRLopS\n</code></pre></div><p>授权服务器(<em>authorization server</em>) 将浏览器重定向到重定向地址后, 客户端(<em>client</em>) 所在的服务器即可发起令牌交换请求, 以 GitHub 为例, 则是如下所示的 POST 请求(其中的 code 与 client_secret 已脱敏):</p>\n<div class=\"language-http\" data-ext=\"http\" data-title=\"http\"><pre class=\"language-http\"><code>POST /oauth/access_token HTTP/1.1\nHost: github.com\n \ncode=ABCDEFG\n&amp;client_id=3934c6721961da9062bf\n&amp;client_secret=ABCDEFG\n</code></pre></div><p>授权服务器(<em>authorization server</em>) 验证 <strong>客户端标识(<em>Client ID</em>)</strong> 和 <strong>客户端密钥(<em>Client secret</em>)</strong> 后将返回访问令牌(可能还有可选的刷新令牌, 但并非所有授权服务器都实现该特性), 以 GitHub 为例将返回如下所示的响应(其中的 access_token 已脱敏):</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"access_token\": \"gho_ABCDEFG\",\n    \"token_type\": \"bearer\",\n    \"scope\": \"public_repo\"\n}\n</code></pre></div><details class=\"hint-container details\"><summary>延伸阅读: <em>Authorization Code Grant</em> 的特点</summary>\n<p>与其他授权类型相比, <em>Authorization Code Grant</em> 具有以下优势:</p>\n<ul>\n<li>授权服务器(<em>authorization server</em>) 确认授权后将重定向到与 客户端(<em>client</em>) 事先约定的重定向地址, 这个步骤完成了授权码的交付。使用 HTTPS 作为重定向地址可以有效保护授权码不被恶意软件拦截。</li>\n<li>客户端(<em>client</em>) 只在服务器后台向 授权服务器(<em>authorization server</em>) 换取访问令牌, 可避免客户端密钥和访问令牌的泄露。</li>\n</ul>\n</details>\n<h3>5. 访问相关资源</h3>\n<p>完成以上步骤后, 客户端已成功获取到代表用户授权的访问令牌, 可以通过 <a href=\"https://docs.github.com/en/rest/overview/api-versions\" target=\"_blank\" rel=\"noopener noreferrer\">GitHub 接口文档</a> 中约定的方式访问授权范围(<em>scope</em>)内资源。</p>\n<h2>总结</h2>\n<p>本文以接入 GitHub 平台为例, 详细讲述了服务端应用接入 OAuth2.0 授权框架涉及到的知识要点，主要步骤可概括成 4 个步骤:</p>\n<ol>\n<li>在资源服务器中注册客户端(创建 OAuth App)</li>\n<li>引导资源所有者前往授权服务器授权客户端访问指定范围内的资源</li>\n<li>用户授权后, 客户端使用授权码向授权服务器交换访问令牌</li>\n<li>使用访问令牌从资源服务器获取相应的资源</li>\n</ol>\n",
      "image": "https://blog.shabbywu.cn/img/GitHub-OAuth-Authrozation-Example.png",
      "date_published": "2023-09-04T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "基础技术"
      ]
    },
    {
      "title": "OAuth2.0 Client: 细说客户端应用如何使用 OAuth2.0 完成用户授权验证",
      "url": "https://blog.shabbywu.cn/posts/2023/09/16/oauth-client-client-side-apps.html",
      "id": "https://blog.shabbywu.cn/posts/2023/09/16/oauth-client-client-side-apps.html",
      "summary": "前言 OAuth2.0 是行业标准的用户授权框架，针对不同的使用场景提供了多种授权方式。关于 OAuth2.0 认证框架的所有细节都可以在 RFC 6749 找到对应的详细说明，然而阅读规范并不是了解 OAuth2.0 工作流程的最佳方式。 本文以接入 GitHub 授权作为使用场景，针对性介绍 客户端应用(Client-Side Apps) 如何使用...",
      "content_html": "<h2>前言</h2>\n<p>OAuth2.0 是行业标准的用户授权框架，针对不同的使用场景提供了多种授权方式。关于 OAuth2.0 认证框架的所有细节都可以在 <a href=\"https://datatracker.ietf.org/doc/html/rfc6749\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6749</a> 找到对应的详细说明，然而阅读规范并不是了解 OAuth2.0 工作流程的最佳方式。<br>\n本文以接入 GitHub 授权作为使用场景，针对性介绍 客户端应用(<em>Client-Side Apps</em>) 如何使用 OAuth2.0 完成用户授权验证。</p>\n<h2>客户端应用如何接入 GitHub？</h2>\n<p>想要集成 GitHub 到你的平台、或者获取用户在 GitHub 的数据甚至是实现自动化工作流程，就需要使用 GitHub API。在调用 API 前需要完成用户对第三方平台的授权，这个流程就是 OAuth2.0。</p>\n<h3>1. 创建 OAuth App</h3>\n<p>在开始所有流程之前, 我们首先需要在 GitHub 创建 <strong>OAuth App</strong>, 这也是 OAuth2.0 流程中的客户端(<em>client</em>)。</p>\n<details class=\"hint-container details\"><summary>延伸阅读: OAuth2.0 中的角色</summary>\n<p><strong>客户端(<em>client</em>)</strong> 是指在获得 <strong>资源所有者(<em>resource owner</em>)</strong> 授权后, 使用从 <strong>授权服务器(<em>authorization server</em>)</strong> 获取到的代表资源所有者身份的访问令牌访问存储在 <strong>资源服务器(<em>resource server</em>)</strong> 的受保护资源的应用程序。</p>\n<p>以接入 GitHub 为例:<br>\n客户端(<em>client</em>)即 GitHub OAuth App;<br>\n资源所有者(<em>resource owner</em>)即任一 GitHub 用户;<br>\n资源服务器(<em>resource server</em>)即 GitHub;<br>\n授权服务器(<em>authorization server</em>)即 GitHub;</p>\n</details>\n<p>创建 GitHub OAuth App 很简单, 首先在 <a href=\"https://github.com/settings\" target=\"_blank\" rel=\"noopener noreferrer\">「Settings」</a> 页找到 <a href=\"https://github.com/settings/developers\" target=\"_blank\" rel=\"noopener noreferrer\">「Developer Settings」</a>, 再点击 <a href=\"https://github.com/settings/applications/new\" target=\"_blank\" rel=\"noopener noreferrer\">「New OAuth App」</a> 即可创建。</p>\n<blockquote>\n<p>如果您之前没有创建过应用程序，此按钮会显示“Register a new application”。</p>\n</blockquote>\n<p>创建 OAuth App 需要填写应用的基本信息, 除了基本的应用名称(Application name), 应用主页(Homepage URL) 和应用描述(Application description)以外, 最重要的是一项是<strong>授权回调地址(<em>Authorization callback URL</em>)</strong>, 这也常被简称<strong>重定向地址(<em>Redirect URL</em>)</strong>。</p>\n<h3>2. 重定向地址(<em>Redirect URL</em>)</h3>\n<p><strong>重定向(<em>Redirect</em>)</strong> 是 授权服务器(<em>authorization server</em>) 对 客户端(<em>client</em>) 发起的 <strong>授权请求(<em>Authorization Request</em>)</strong> 进行鉴权的重要措施。主要用途是防止攻击者拦截授权代码或访问令牌的重定向攻击。</p>\n<blockquote>\n<p>由于客户端应用一般<strong>无后台服务器</strong>, 无法提供固定的重定向地址。因此 OAuth 2.0 允许 客户端(<em>client</em>) 将本地回环地址(如 127.0.0.1)设为重定向地址, 用户授权后 授权服务器(<em>authorization server</em>) 将用户重定向到客户端(<em>client</em>)提前运行在用户本地的服务。</p>\n</blockquote>\n<p>将重定向地址设置成 <code>http://127.0.0.1</code> 后即完成成功创建 GitHub OAuth App。</p>\n<h3>3. 发起授权请求(Authorization Request) &amp; 获取资源访问令牌(Access Token)</h3>\n<p><strong>客户端应用(<em>Client-Side Apps</em>)</strong> 获取到 <strong>授权码(<em>authorization code</em>)</strong> 后, 余下流程与 <strong>服务端应用(<em>Server-Side Apps</em>)</strong> 完全一致, 读者可前往 <a href=\"/posts/2023/09/04/oauth-client-server-side-apps.html\" target=\"_blank\">OAuth2.0 Client: 细说服务端应用如何使用 OAuth2.0 完成用户授权验证</a> 阅读相关的流程，这里不再重复讲述。</p>\n",
      "image": "https://blog.shabbywu.cn/img/GitHub-OAuth-Device-Flow-Example.png",
      "date_published": "2023-09-16T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "基础技术"
      ]
    },
    {
      "title": "How To Build Images:Docker 镜像规范 v1.2",
      "url": "https://blog.shabbywu.cn/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html",
      "id": "https://blog.shabbywu.cn/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html",
      "summary": "前言 现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 docker run {your-image-name}, 而构建镜像也就是执行一句 docker build dockerfile .的事情。 也许正是由于 Docker 对实现细节封装得过于彻底，最近...",
      "content_html": "<h2>前言</h2>\n<p>现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 <code>docker run {your-image-name}</code>, 而构建镜像也就是执行一句 <code>docker build dockerfile .</code>的事情。\n也许正是由于 <strong>Docker</strong> 对实现细节封装得过于彻底，最近在学习 google 开源的镜像构建工具 <a href=\"https://github.com/GoogleContainerTools/kaniko\" target=\"_blank\" rel=\"noopener noreferrer\">kaniko</a> 时, 才发现我们也许只是学会了<strong>如何使用<code>Docker CLI</code></strong> , 而并非明白 Docker 是如何运行的。\n所以笔者决定开始写『How To Build Images』这一系列文章，这是本系列的第一篇，『Docker 镜像规范』。</p>\n<blockquote>\n<p>注: 本文假设读者了解如何使用 Docker, 包括但不限于懂得执行 <code>docker run</code> 和 <code>docker build</code> 以及编写 Dockerfile。</p>\n</blockquote>\n<h2><a class=\"header-anchor\" href=\"#docker镜像规范\"><span></span></a><a href=\"https://github.com/moby/moby/tree/master/image/spec\" target=\"_blank\" rel=\"noopener noreferrer\">Docker镜像规范</a></h2>\n<p>容器镜像存储了文件系统发生的变更，而容器镜像规范则描述了<strong>如何记录该变更历史和相应操作的参数</strong>以及<strong>如何将容器镜像转换成容器</strong>。</p>\n<blockquote>\n<p>简单点, 就是描述<strong>容器&gt;&gt;序列化&gt;&gt;镜像</strong>以及<strong>镜像&gt;&gt;反序列化&gt;&gt;容器</strong>的规范😯</p>\n</blockquote>\n<h3>版本历史</h3>\n<ul>\n<li><a href=\"https://github.com/moby/moby/blob/master/image/spec/v1.md\" target=\"_blank\" rel=\"noopener noreferrer\">v1</a>\n<ul>\n<li>初版</li>\n</ul>\n</li>\n<li><a href=\"https://github.com/moby/moby/blob/master/image/spec/v1.1.md\" target=\"_blank\" rel=\"noopener noreferrer\">v1.1</a>\n<ul>\n<li>由 Docker v1.10 实现 (February, 2016)</li>\n<li>确定使用 sha256 摘要作为各层(Layer)的 id (以前是随机值)</li>\n<li>新增 <strong>manifest.json</strong> 文件, 该文件负责记录镜像内容和依赖关系的元数据。</li>\n</ul>\n</li>\n<li><a href=\"https://github.com/moby/moby/blob/master/image/spec/v1.2.md\" target=\"_blank\" rel=\"noopener noreferrer\">v1.2</a>\n<ul>\n<li>由  Docker v1.12 实现 (July, 2016)</li>\n<li>将 Healthcheck 纳入镜像规范</li>\n</ul>\n</li>\n<li><a href=\"(https://github.com/opencontainers/image-spec)\">OCI v1 image</a>\n<ul>\n<li>由 Open Container Initiative (OCI) 提出的镜像规范</li>\n<li>不兼容 <a href=\"https://github.com/moby/moby/pull/33355\" target=\"_blank\" rel=\"noopener noreferrer\">Docker(moby)</a>, 但可以 push 至 Registry 然后再 pull 下来</li>\n</ul>\n</li>\n</ul>\n<p>为了统一容器格式和运行时创建的标准, Docker 联合 CoreOS 等组织在 linux 基金会的主持下成立了 Open Container Initiative (OCI)。\n目前 OCI 已经提出了两个规范:<a href=\"https://github.com/opencontainers/runtime-spec\" target=\"_blank\" rel=\"noopener noreferrer\">运行时规范(runtime-spec)</a>和<a href=\"https://github.com/opencontainers/image-spec\" target=\"_blank\" rel=\"noopener noreferrer\">镜像规范(image-spec)</a>, 但<strong>由于 docker 尚未兼容 OCI 镜像规范, 本文不涉及 OCI 镜像规范的内容。</strong> <s>(不排除以后会写😆)</s></p>\n<h3>一个🌰 : Docker 镜像的基本结构</h3>\n<p>我们以 busybox:latest 为例, 展示 Docker 镜像的基础结构。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── 036a82c6d65f2fa43a13599661490be3fca1c3d6790814668d4e8c0213153b12\n│&nbsp;&nbsp; ├── VERSION\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; └── layer.tar\n├── 6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30.json\n├── manifest.json\n└── repositories\n\n1 directory, 6 files\n</code></pre></div><p>接下来以该🌰 详细介绍 Docker 镜像中各组成部分的含义和内容。</p>\n<h4>directories (backward)</h4>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── VERSION\n├── json\n└── layer.tar\n\n0 directories, 3 files\n</code></pre></div><p>可以发现, 镜像中的每层(Layer)解压后可以对应到一个目录，这些目录的名称是根据该层(Layer)的相关信息使用一致性 hash 算法生成, (TIPS: v1版本规范是随机生成), 每个目录包括 3 个文件, 分别是:</p>\n<ul>\n<li>VERSION, <code>json</code> 文件内容个格式规范, 目前只能是 1.0。</li>\n<li>json, 在 v1 版本中定义的描述该层(Layer)信息的元数据，但由于 v1.2 版本中不需要依赖此文件，因此无需关注。</li>\n<li>layer.tar, 存储该层(Layer)文件系统的变更记录的归档包。</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 这些目录布局仅是为了向后兼容, 当前版本(v1.2)中每层(Layer)的归档包均在 <code>manifest.json</code> 指定。</p>\n</blockquote>\n<h4>repositories (backward)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n  \"busybox\": {\n    \"latest\": \"036a82c6d65f2fa43a13599661490be3fca1c3d6790814668d4e8c0213153b12\"\n  }\n}\n</code></pre></div><p>repositories 中存储了一个 json 对象, 该对象的每个 key 是镜像的名称, value 是<code>标签-镜像id映射表</code>。</p>\n<blockquote>\n<p>需要注意的是, 该文件同样是仅用于向后兼容, 当前版本(v1.2)中镜像与layer的关系均在 <code>manifest.json</code> 中指定。</p>\n</blockquote>\n<h4>manifest.json</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>[\n  {\n    \"Config\": \"6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30.json\",\n    \"RepoTags\": [\n      \"busybox:latest\"\n    ],\n    \"Layers\": [\n      \"036a82c6d65f2fa43a13599661490be3fca1c3d6790814668d4e8c0213153b12/layer.tar\"\n    ]\n  }\n]\n</code></pre></div><p><code>mainfest.json</code> 记录了一个列表, 该列表中每一项描述了一个镜像的内容清单以及该镜像的父镜像(可选的)。\n该列表中每一项由以下几个字段组成:</p>\n<ul>\n<li>Config: 引用启动容器的配置对象。</li>\n<li>RepoTags: 描述该镜像的引用关系。</li>\n<li>Layers: 指向描述该镜像文件系统各(Layer)的变更记录。</li>\n<li>Parent: (可选) 该镜像的父镜像的 imageID, 该父镜像必须记录在当前的 manifest.json。</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 该 manifest.json 与 Docker Register API 描述的 manifest.json 不是同一个文件。(详见附录部分)</p>\n</blockquote>\n<h4>Config (aka Image JSON)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n\t\"architecture\": \"amd64\",\n\t\"config\": {\n\t\t\"Hostname\": \"\",\n\t\t\"Domainname\": \"\",\n\t\t\"User\": \"\",\n\t\t\"AttachStdin\": false,\n\t\t\"AttachStdout\": false,\n\t\t\"AttachStderr\": false,\n\t\t\"Tty\": false,\n\t\t\"OpenStdin\": false,\n\t\t\"StdinOnce\": false,\n\t\t\"Env\": [\n\t\t\t\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n\t\t],\n\t\t\"Cmd\": [\n\t\t\t\"sh\"\n\t\t],\n\t\t\"ArgsEscaped\": true,\n\t\t\"Image\": \"sha256:7def3adf6786f772d2f02fc74c2d3f3334228416760aee45d3b6e561ce1c1dd3\",\n\t\t\"Volumes\": null,\n\t\t\"WorkingDir\": \"\",\n\t\t\"Entrypoint\": null,\n\t\t\"OnBuild\": null,\n\t\t\"Labels\": null\n\t},\n\t\"container\": \"3fbce8bb8947b036ee7ff05a86c0574159c04fc10a3db7485ab7bf4f56fd4020\",\n\t\"container_config\": {\n\t\t\"Hostname\": \"3fbce8bb8947\",\n\t\t\"Domainname\": \"\",\n\t\t\"User\": \"\",\n\t\t\"AttachStdin\": false,\n\t\t\"AttachStdout\": false,\n\t\t\"AttachStderr\": false,\n\t\t\"Tty\": false,\n\t\t\"OpenStdin\": false,\n\t\t\"StdinOnce\": false,\n\t\t\"Env\": [\n\t\t\t\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n\t\t],\n\t\t\"Cmd\": [\n\t\t\t\"/bin/sh\",\n\t\t\t\"-c\",\n\t\t\t\"#(nop) \",\n\t\t\t\"CMD [\\\"sh\\\"]\"\n\t\t],\n\t\t\"ArgsEscaped\": true,\n\t\t\"Image\": \"sha256:7def3adf6786f772d2f02fc74c2d3f3334228416760aee45d3b6e561ce1c1dd3\",\n\t\t\"Volumes\": null,\n\t\t\"WorkingDir\": \"\",\n\t\t\"Entrypoint\": null,\n\t\t\"OnBuild\": null,\n\t\t\"Labels\": {}\n\t},\n\t\"created\": \"2017-11-03T22:39:17.345892474Z\",\n\t\"docker_version\": \"17.06.2-ce\",\n\t\"history\": [{\n\t\t\t\"created\": \"2017-11-03T22:39:17.173629428Z\",\n\t\t\t\"created_by\": \"/bin/sh -c #(nop) ADD file:264af0c48e23e8b8fc57c2c70c7b5b08be20601d75f5efca07c5ace8748bcbcd in / \"\n\t\t},\n\t\t{\n\t\t\t\"created\": \"2017-11-03T22:39:17.345892474Z\",\n\t\t\t\"created_by\": \"/bin/sh -c #(nop)  CMD [\\\"sh\\\"]\",\n\t\t\t\"empty_layer\": true\n\t\t}\n\t],\n\t\"os\": \"linux\",\n\t\"rootfs\": {\n\t\t\"type\": \"layers\",\n\t\t\"diff_ids\": [\n\t\t\t\"sha256:0271b8eebde3fa9a6126b1f2335e170f902731ab4942f9f1914e77016540c7bb\"\n\t\t]\n\t}\n}\n</code></pre></div><h5>created <code>string</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"created\": \"2017-11-03T22:39:17.345892474Z\"\n}\n</code></pre></div><p>ISO-8601 格式的字符串, 描述了当前镜像创建的日期和时间。</p>\n<h5>author <code>string</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"author\": \"nobody\"\n}\n</code></pre></div><p>描述创建并维护这个镜像的个人或实体的名称和/或电子邮箱。</p>\n<h5>architecture <code>string</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"architecture\": \"amd64\"\n}\n</code></pre></div><p>描述该镜像中的二进制文件运行依赖的 CPU 架构，可能的值包括:</p>\n<ul>\n<li>386</li>\n<li>amd64</li>\n<li>arm</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 可选范围的值未来可能会添加或减少, 同时, 这里声明的值在不一定会被容器运行时实现(e.g. runc 或 rkt)所支持。</p>\n</blockquote>\n<h5>os <code>string</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"os\": \"linux\"\n}\n</code></pre></div><p>描述该镜像运行所基于的操作系统的名称, 可能的值包括:</p>\n<ul>\n<li>darwin</li>\n<li>freebsd</li>\n<li>linux</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 可选范围的值未来可能会添加或减少, 同时, 这里声明的值在不一定会被容器运行时实现(e.g. runc 或 rkt)所支持。</p>\n</blockquote>\n<h5>config (aka Container RunConfig) <code>object, optional</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"User\": \"\",\n        \"Tty\": false,\n        \"Env\": [\n            \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n        ],\n        \"Entrypoint\": null,\n        \"Cmd\": [\n            \"sh\"\n        ],\n        \"Volumes\": null,\n        \"WorkingDir\": \"\",\n        \"Labels\": null\n    }\n}\n</code></pre></div><p>描述容器运行时在实例化该镜像时, 所使用的默认参数。</p>\n<blockquote>\n<p>需要注意的是, 该字段可以为 null, 在这种情况下, 应在创建容器时指定运行所需要的任何参数。</p>\n</blockquote>\n<h6>User <code>string</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"User\": \"root\"\n    }\n}\n</code></pre></div><p>描述容器中应该使用的用户名或UID, 当创建容器时未指定该值时，该值将用作默认值。\n该字段支持以下格式:</p>\n<ul>\n<li>user</li>\n<li>uid</li>\n<li>user:group</li>\n<li>uid:gid</li>\n<li>uid:group</li>\n<li>user:gid</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 当不提供 <code>group/gid</code> 时, 默认行为会从容器中 /etc/passwd 中根据给定的 user/uid 配置默认组合补充组(supplementary groups)。</p>\n</blockquote>\n<h6>Memory <code>integer</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Memory\": 1024\n    }\n}\n</code></pre></div><p>描述容器实例的内存限制(以 bytes 为单位), 当创建容器时未指定该值时，该值将用作默认值。</p>\n<h6>MemorySwap <code>integer</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"MemorySwap\": -1\n    }\n}\n</code></pre></div><p>描述允许容器使用的总内存使用量(memory + swap), 当创建容器时未指定该值时，该值将用作默认值。</p>\n<blockquote>\n<p>需要注意的是, 设置该值为 -1 时, 表示关闭内存交换。</p>\n</blockquote>\n<h6>CpuShares <code>integer</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"CpuShares\": 4\n    }\n}\n</code></pre></div><p>CPU 份额(对于其他容器而言的相对值), 当创建容器时未指定该值时，该值将用作默认值。</p>\n<h6>WorkingDir <code>string</code></h6>\n<p>描述容器启动入口点进程时所在的工作目录, 当创建容器时未指定该值时，该值将用作默认值。</p>\n<h6>Env <code>array[string]</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Env\": [\n            \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n        ]\n    }\n}\n</code></pre></div><p>描述运行该镜像时的默认环境变量, 这些值将用作默认值, 并会在创建容器时指定的值进行合并。\n该列表的每一项的格式为: <code>VARNAME=\"VAR VALUE\"</code></p>\n<h6>Entrypoint <code>array[string]</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Entrypoint\": [\n            \"bash\",\n            \"-c\"\n        ]\n    }\n}\n</code></pre></div><p>描述启动容器时要执行的命令的参数列表, 当创建容器时未指定该值时，该值将用作默认值。</p>\n<h6>Cmd <code>array[string]</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Cmd\": [\n            \"ls\",\n        ]\n    }\n}\n</code></pre></div><p>描述容器入口(entry point) 的默认参数, 当创建容器时未指定该值时，该值将用作默认值。</p>\n<blockquote>\n<p>需要注意的是, 如果未指定 <code>Entrypoint</code>, 那么 cmd 数组中的第一项应当为要运行的可执行文件。</p>\n</blockquote>\n<h6>Healthcheck <code>object</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Healthcheck\": {\n            \"Test\": [\n                \"CMD-SHELL\",\n                \"/usr/bin/check-health localhost\"\n            ],\n            \"Interval\": 30000000000,\n            \"Timeout\": 10000000000,\n            \"Retries\": 3\n        }\n    }\n}\n</code></pre></div><p>描述确认容器是否健康的方法，该对象由 4 部分组成, 分别是:</p>\n<ul>\n<li>Test <code>array[string]</code>, 检查容器是否健康的测试方法, 可选项为:\n<ul>\n<li><code>[]</code>: 从父级镜像继承健康检查配置</li>\n<li><code>[\"None\"]</code>: 禁用健康检查</li>\n<li><code>[\"CMD\", arg1, arg2, ...]</code>: 直接执行参数</li>\n<li><code>[\"CMD-SHELL\", command]</code>: 使用镜像中的默认Shell运行命令</li>\n</ul>\n</li>\n<li>Interval <code>integer</code>: 两次探测之间等待的纳秒数。</li>\n<li>Timeout <code>integer</code>: 一次探测中等待的纳秒数。</li>\n<li>Retries <code>integer</code>: 认为容器不健康所需的连续失败次数。\n如果省略该字段, 则表示该值应从父级镜像中获取，同时，这些值将用作默认值, 并会在创建容器时指定的值进行合并。</li>\n</ul>\n<h6>ExposedPorts <code>object, optional</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"ExposedPorts\": {\n            \"8080\": {},\n            \"53/udp\": {},\n            \"80/tcp\": {}\n        }\n    }\n}\n</code></pre></div><p>一组端口, 描述运行该镜像的容器所需要对外暴露的端口组。存储结构为一个 json 对象, 该对象的每个 key 是需要暴露的端口和协议, value 必须是空对象 <code>{}</code>。\n该对象的键(key)可以是以下的几种格式:</p>\n<ul>\n<li>port/tcp</li>\n<li>port/udp</li>\n<li>port</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 该配置的结构之所以如此诡异, 是因为它是直接从 Go 类型 map[string]struct{} 序列化而成的, 因此在 json 中表现为 value 是空对象 <code>{}</code>。</p>\n</blockquote>\n<h6>Volumes <code>object, optional</code></h6>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"config\": {\n        \"Volumes\": {\n            \"/var/my-app-data/\": {},\n            \"/etc/some-config.d/\": {}\n        }\n    }\n}\n</code></pre></div><p>一组目录, 描述运行该镜像的容器应该被挂载卷覆盖的目录路径。存储结构为一个 json 对象, 该对象的每个 key 是应该被挂载卷覆盖的目录路径, value 必须是空对象 <code>{}</code>。</p>\n<blockquote>\n<p>需要注意的是, 该配置的结构之所以如此诡异, 是因为它是直接从 Go 类型 map[string]struct{} 序列化而成的, 因此在 json 中表现为 value 是空对象 <code>{}</code>。</p>\n</blockquote>\n<h5>rootfs <code>object</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"rootfs\": {\n        \"type\": \"layers\",\n        \"diff_ids\": [\n            \"sha256:0271b8eebde3fa9a6126b1f2335e170f902731ab4942f9f1914e77016540c7bb\"\n        ]\n    }\n}\n</code></pre></div><p><code>rootfs</code> 描述该镜像引用的 Layer DiffIDs (详情见附录-术语表), 在镜像配置(Config)存放该值, 可以使得计算镜像配置文件的hash值时, 会根据关联的文件系统的 hash 值的变化而变化。该对象包含两部分, 分别是:</p>\n<ul>\n<li>type: 通常将该值设置为 <code>layers</code>。</li>\n<li>diff_ids <code>(array[Layer DiffIDs])</code>: 按依赖顺序排序, 即从最底部的层(Layer)到最顶部的层(Layer)排序。</li>\n</ul>\n<h5>history <code>array</code></h5>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"history\": [{\n\t\t\t\"created\": \"2017-11-03T22:39:17.173629428Z\",\n\t\t\t\"created_by\": \"/bin/sh -c #(nop) ADD file:264af0c48e23e8b8fc57c2c70c7b5b08be20601d75f5efca07c5ace8748bcbcd in / \"\n\t\t},\n\t\t{\n\t\t\t\"created\": \"2017-11-03T22:39:17.345892474Z\",\n\t\t\t\"created_by\": \"/bin/sh -c #(nop)  CMD [\\\"sh\\\"]\",\n\t\t\t\"empty_layer\": true\n\t\t}\n\t]\n}\n</code></pre></div><p><code>history</code>描述了该镜像每层(Layer)的历史记录的对象数组，数组按照依赖关系排序，即从最底部的层(Layer)到最顶部的层(Layer)排序。数组中每个对象具有以下的字段:</p>\n<ul>\n<li>created: 该字段描述了该层(Layer)的创建的日期和时间, 要求为ISO-8601 格式的字符串。</li>\n<li>author: 该字段描述创建并维护该层(Layer)的个人或实体的名称和/或电子邮箱。</li>\n<li>created_by: 该字段描述创建该层(Layer)时调用的指令。</li>\n<li>comment: 该字段描述创建该层(Layer)时的自定义注解。</li>\n<li>empty_layer: 该字段用于标记历史记录项是否导致文件系统出现差异, 如果此历史记录项未对应到 <code>rootfs</code> 中实际的一项记录, 那么就应该将该项设置为 <code>true</code>(简单点, 就是如果 Dockerfile 里执行了类似 ENV, CMD 等指令, 由于这些指令不会导致文件系统的变更, empty_layer 就应该设置为 <code>true</code>)。</li>\n</ul>\n<h2>总结</h2>\n<p>本文主要先从梳理了Docker镜像规范的<strong>版本历史</strong>, 随后简单介绍了 OCI 组织和 OCI 镜像规范与 Docker 镜像规范之间的关系。接下来从一个简单但完整的 🌰 中展示了 <strong>Docker 镜像的目录结构</strong>, 再以此 🌰 介绍了现行镜像规范内容, 其中包括 <strong>manifest.json</strong> 和 <strong>Config</strong> 这两个重要文件的含义和内容。自 v1.1 版本的镜像规范开始, Docker 引入了 <strong>manifest.json</strong> 的概念, 从此就无需关心镜像的目录结构, 镜像中有效的信息都被记录在 manifest 中。</p>\n<p>当你看到这里的时候, 现行的 Docker 镜像规范已经完全介绍完毕, 从下一篇文章开始就进入<strong>实战</strong>内容。预期在下一章里, 我会为大家<strong>分享从 0 开始构建 Docker 镜像的经验</strong>, 以进一步探讨镜像中各 <code>Layer</code> 中记录的 <code>Filesystem Changeset</code> 的内容, 为最后介绍如何构建镜像打下铺垫。</p>\n<blockquote>\n<p>吐槽: 规范是很文绉绉的内容, 而事实上 Docker 自身的镜像规范的描述得很混乱, 会出现术语混乱的情形。例如 <code>Image JSON</code> 在 manifest.json 又被称之为 <code>Config</code>; 镜像分发规范和镜像规范又会同时出现 <code>manifest</code>。</p>\n</blockquote>\n",
      "date_published": "2021-01-31T00:00:00.000Z",
      "date_modified": "2024-03-09T16:14:15.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "How To Run Container:OCI 运行时规范",
      "url": "https://blog.shabbywu.cn/posts/2021/03/31/how-to-run-container-oci-%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A7%84%E8%8C%83.html",
      "id": "https://blog.shabbywu.cn/posts/2021/03/31/how-to-run-container-oci-%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A7%84%E8%8C%83.html",
      "summary": "前言 现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 docker run {your-image-name}，而构建镜像也就是执行一句 docker build dockerfile .的事情。 也许正是由于 Docker 对实现细节封装得过于彻底，有时候...",
      "content_html": "<h2>前言</h2>\n<p>现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 <code>docker run {your-image-name}</code>，而构建镜像也就是执行一句 <code>docker build dockerfile .</code>的事情。<br>\n也许正是由于 <strong>Docker</strong> 对实现细节封装得过于彻底，有时候会觉得我们也许只是学会了<strong>如何使用<code>Docker CLI</code></strong> , 而并非明白 Docker 是如何运行的。<br>\n笔者在编写『How To Build Images』时发现, 构建镜像和运行容器并非两条平行线。在介绍构建时或多或少会涉及运行时的内容，因此决定同时开展另一系列文章『How To Run Container』，而这是新系列的第一篇，『OCI 运行时规范』。</p>\n<h2><a class=\"header-anchor\" href=\"#oci运行时规范\"><span></span></a><a href=\"https://github.com/opencontainers/runtime-spec/blob/master/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">OCI运行时规范</a></h2>\n<p>OCI运行时规范旨在指定容器的<strong>配置</strong>，<strong>执行环境</strong>和<strong>生命周期</strong>。</p>\n<ul>\n<li>定义如何描述容器所支持的平台和创建容器实例时需要的配置信息(<code>config.json</code>)，避免各<code>运行时实现</code>提出不同标准</li>\n<li>定义容器的执行环节，确保容器内运行的应用程序在各<code>运行时实现</code>中具有一致的环境</li>\n<li>定义容器的生命周期，确保容器在各<code>运行时实现</code>中具有一致的表现</li>\n</ul>\n<h3><a class=\"header-anchor\" href=\"#容器格式-文件系统捆绑包-filesystem-bundle\"><span></span></a><a href=\"https://github.com/opencontainers/runtime-spec/blob/master/bundle.md\" target=\"_blank\" rel=\"noopener noreferrer\">容器格式 -- 文件系统捆绑包(Filesystem Bundle)</a></h3>\n<p>OCI运行时规范提出将容器编排为文件系统捆绑包(Filesystem Bundle)的形式, 即以某种方式组织一系列文件, 其中包含足以让合规的<code>运行时实现</code>能够启动容器的所有必要数据和元数据。<br>\n一个标准的容器捆绑包包含了加载和运行容器所需的所有信息, 其中包括以下内容:</p>\n<ul>\n<li><code>config.json</code>: 包含容器的配置信息。该文件必须存储在捆绑包的根目录, 且必须被命名为 <strong>config.json</strong>。文件的详细内容见后文。</li>\n<li>容器的根文件系统(<code>root filesystem</code>): 由 <code>root.path</code> 属性指定的目录（可选）。</li>\n</ul>\n<p>需要注意的是, 容器的运行时内容必须全部存储在本地文件系统上的单个目录中, 但该目录本身不属于捆绑包的一部分。<br>\n换而言之, 在使用 <code>tar</code> 归档容器捆绑包时, 这些内容应该存储在归档文件的根目录中, 而不是嵌套在其他目录之下:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── config.json\n└── $root.path\n</code></pre></div><h3><a class=\"header-anchor\" href=\"#运行时实现和容器生命周期\"><span></span></a><a href=\"https://github.com/opencontainers/runtime-spec/blob/master/runtime.md\" target=\"_blank\" rel=\"noopener noreferrer\">运行时实现和容器生命周期</a></h3>\n<h4>容器的作用域</h4>\n<p><code>运行时实现</code>的实例必须能够对其创建的容器执行本规范中定义的操作，且不可操作其他容器，不管其他容器是使用相同的<code>运行时实现</code>或不同的<code>运行时实现</code>创建的。</p>\n<h4>容器的状态</h4>\n<p>使用 <code>State</code> 对象描述容器的状态, 将该对象序列化成 JSON 时, 格式如下所示:</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"ociVersion\": \"0.2.0\",\n    \"id\": \"oci-container1\",\n    \"status\": \"running\",\n    \"pid\": 4422,\n    \"bundle\": \"/containers/redis\",\n    \"annotations\": {\n        \"myKey\": \"myValue\"\n    }\n}\n</code></pre></div><p>容器状态包含以下几个属性:</p>\n<ul>\n<li><strong>ociVersion</strong> (string, REQUIRED): 描述该运行时遵守的 「OCI运行时规范」 版本</li>\n<li><strong>id</strong> (string, REQUIRED): 描述容器的 ID。该值在该主机上的所有容器中必须是唯一的，但不需要在主机之间保证唯一性。</li>\n<li><strong>status</strong> (string, REQUIRED): 描述该容器的运行状态, 必须在以下各值中取值:\n<ul>\n<li><strong>creating</strong>: 容器正在创建 (生命周期中的第2步)</li>\n<li><strong>created</strong>: 运行时已执行完创建操作 (生命周期中的第2步), 此时容器进程处于运行状态但又未执行用户指定的程序</li>\n<li><strong>running</strong>: 容器进程已执行用户指定的程序, 且尚未推出 (生命周期中的第5步之后)</li>\n<li><strong>stopped</strong>: 容器进程已退出 (生命周期中的第7步)\n可由运行时实现定义额外的状态, 但不可与上述定义的运行时状态重复。</li>\n</ul>\n</li>\n<li><strong>pid</strong> (int, 对于 linux 以外的操作系统为可选值, 对于 linux 系统的 created, running 阶段是必须值): 描述容器进程的 ID。对于在运行时命名空间(namespace)中执行的钩子(hooks), 该值是运行时实现所感知的 pid。对于在容器命名空间(namespace)中执行的钩子(hooks), 该值是容器所感知到的 pid。</li>\n<li><strong>bundle</strong> (string, REQUIRED): 描述容器的捆绑包目录的绝对路径。(提供此信息是为了让使用者可以在主机上找到容器的配置和根文件系统。)</li>\n<li><strong>annotations</strong> (map, OPTIONAL): 描述与容器关联的注解。如果没有提供注解, 则该属性可以不存在或为空。\n除此之外, 运行时实现可以往 State 对象添加额外的属性。</li>\n</ul>\n<h4>容器的生命周期</h4>\n<p>容器的生命周期依据时间轴的先后顺序描述了从创建容器到销毁之间发生的事件。</p>\n<ol>\n<li>传递捆绑包位置和容器唯一标识作为参数, 调用由遵循 OCI 标准的 <code>运行时实现</code> 实现的 <strong>create</strong> 指令。</li>\n<li><code>运行时实现</code>根据 <code>config.json</code> 中的配置创建容器的运行时环境, 如果无法创建该环境, 则必须生成<strong>错误(Error)</strong>。该步骤仅负责创建 <code>config.json</code> 中请求的资源, 但并不运行用户指定的程序。在此不走之后, 任何对 <code>config.json</code> 的更新都不会影响到容器实例。</li>\n<li><code>运行时实现</code>调用 <strong>prestart</strong> 钩子, 如果调用时出现异常, 则必须生成<strong>错误(Error)</strong>, 并停止容器, 并直接跳转至步骤12继续执行。</li>\n<li><code>运行时实现</code>调用 <strong>createRuntime</strong> 钩子, 如果调用时出现异常, 则必须生成<strong>错误(Error)</strong>, 并停止容器, 并直接跳转至步骤12继续执行。</li>\n<li><code>运行时实现</code>调用 <strong>createContainer</strong> 钩子, 如果调用时出现异常, 则必须生成<strong>错误(Error)</strong>, 并停止容器, 并直接跳转至步骤12继续执行。</li>\n<li>传递步骤1中使用的容器唯一标识作为参数, 调用由遵循 OCI 标准的 <code>运行时实现</code> 提供的 <strong>start</strong> 指令。</li>\n<li><code>运行时实现</code>调用 <strong>startContainer</strong> 钩子, 如果调用时出现异常, 则必须生成<strong>错误(Error)</strong>, 并停止容器, 并直接跳转至步骤12继续执行。</li>\n<li><code>运行时实现</code>开始运行 <strong>process</strong> 指定的用户指定的程序。</li>\n<li><code>运行时实现</code>调用 <strong>poststart</strong> 钩子, 如果调用时出现异常, 则必须记录<strong>警告(Warning)</strong>, 但继续执行生命周期, 就像该钩子执行成功一样。</li>\n<li>容器进程退出。</li>\n<li>传递步骤1中使用的容器唯一标识作为参数, 调用由遵循 OCI 标准的 <code>运行时实现</code> 提供的 <strong>delete</strong> 指令。</li>\n<li>必须通过回退在创建阶段(步骤2)中执行的步骤来销毁容器。</li>\n<li><code>运行时实现</code>调用 <strong>poststop</strong> 钩子, 如果调用时出现异常, 则必须记录<strong>警告(Warning)</strong>, 但继续执行生命周期, 就像该钩子执行成功一样。</li>\n</ol>\n<h4>OCI运行时标准操作</h4>\n<p>OCI运行时规范定义了 5 个标准操作, 规范了与容器之间的交互流程。</p>\n<ul>\n<li>Query State: <code>state &lt;container-id&gt;</code> 根据指定的容器ID查询容器的状态。</li>\n<li>Create: <code>create &lt;container-id&gt; &lt;path-to-bundle&gt;</code> 根据容器捆绑包路径和容器ID创建容器实例</li>\n<li>Start: <code>start &lt;container-id&gt;</code> 执行用户指定的程序。</li>\n<li>Kill: <code>kill &lt;container-id&gt; &lt;signal&gt;</code> 将指定的信号发送到容器进程。</li>\n<li>Delete: <code>delete &lt;container-id&gt;</code> 删除容器以及在<strong>create</strong>步骤中创建的资源。</li>\n</ul>\n<h3><a class=\"header-anchor\" href=\"#容器的配置\"><span></span></a><a href=\"https://github.com/opencontainers/runtime-spec/blob/master/config.md\" target=\"_blank\" rel=\"noopener noreferrer\">容器的配置</a></h3>\n<p>配置文件必须包含对容器实施标准操作所需要的数据和元数据，其中包括容器要运行的进程，需要注入的环境变量，要使用的沙盒功能等等。\n大体上, 容器配置可划分为 8 个组成要素, 分别是: <code>规范版本(ociVersion)</code>, <code>根文件系统配置(root)</code>, <code>挂载点配置(mounts)</code>, <code>进程信息(process)</code>, <code>主机名(hostname)</code>, <code>钩子(hooks)</code> , <code>注解(annotations)</code> 和 <code>平台相关配置</code>。</p>\n<h4>规范版本 ociVersion (string, REQUIRED)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"ociVersion\": \"0.1.0\"\n}\n</code></pre></div><p>规范版本(ociVersion) 必须是 <a href=\"https://semver.org/spec/v2.0.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">SemVer v2.0.0</a> 格式的字符串, 表示当前容器捆绑包所支持的 OCI 运行时规范版本。</p>\n<h4>根文件系统配置 root (object, OPTIONAL)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    // For POSIX platforms\n    \"root\": {\n        \"path\": \"rootfs\",\n        \"readonly\": true\n    },\n    // For Windows\n    \"root\": {\n        \"path\": \"\\\\\\\\?\\\\Volume{ec84d99e-3f02-11e7-ac6c-00155d7682cf}\\\\\"\n    }\n}\n</code></pre></div><p>根文件系统配置 (root) 指定容器的根文件系统, 包含以下字段:</p>\n<ul>\n<li><strong>path</strong> (string, REQUIRED): 描述容器的根文件系统的路径(在宿主机的位置)。\n<ul>\n<li>对于 POSIX platforms 平台, <strong>path</strong> 可以是根文件系统的相对路径或绝对路径。例如, 容器捆绑包位于 <code>/to/bundel/</code> 以及根文件系统位于 <code>/to/bundel/rootfs</code>, 那么 path 的值可以为 <code>/to/bundel/rootfs</code> 或 <code>rootfs</code>。</li>\n<li>对于 Windows 平台, <code>path</code> 必须是数据卷的 GUID 路径。</li>\n</ul>\n</li>\n<li><strong>readonly</strong> (bool, OPTIONAL): 描述根文件系统在容器内是否可写的, 默认值是 false。\n<ul>\n<li>对于 Windows 平台, 该值必须缺省或为 false。</li>\n</ul>\n</li>\n</ul>\n<h4>挂载点配置 mounts (array of objects, OPTIONAL)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    // For POSIX platforms\n    \"mounts\": [\n        {\n            \"destination\": \"/tmp\",\n            \"type\": \"tmpfs\",\n            \"source\": \"tmpfs\",\n            \"options\": [\"nosuid\",\"strictatime\",\"mode=755\",\"size=65536k\"]\n        }\n    ],\n    // For Windows\n    \"mounts\": [\n        {\n            \"destination\": \"C:\\\\folder-inside-container\",\n            \"source\": \"C:\\\\folder-on-host\",\n            \"options\": [\"ro\"]\n        }\n    ],\n\n}\n</code></pre></div><p>挂载点配置 (mounts) 指定容器除了根目录以外的挂载点。<code>运行时实现</code> 必须按 <code>mounts</code> 的声明顺序进行挂载。<code>mounts</code> 对象包含以下字段:</p>\n<ul>\n<li><strong>destination</strong> (string, REQUIRED): 描述挂载点的目标位置(容器内的路径), 该值必须是绝对路径。</li>\n<li><strong>source</strong> (string, OPTIONAL): 一个设备名称，或者是需要挂载到容器的文件和目录的名称。对于 <code>bind</code> 类型的挂载, 该值必须是绝对路径或是相对容器捆绑包的相对路径(与 <strong>root.path</strong> 一样)。</li>\n<li><strong>options</strong> (array of strings, OPTIONAL): 挂载文件系统时的挂载参数。\n<ul>\n<li>对于 Linux 平台: 支持的 options 选项详见 <a href=\"https://man7.org/linux/man-pages/man8/mount.8.html\" target=\"_blank\" rel=\"noopener noreferrer\">mount(8)</a>。</li>\n<li>对于 Windows 平台: <code>运行时实现</code> 必须支持 <code>ro</code> 选项, 表示只读(read-only)。</li>\n<li>当 options 中包含 <code>bind</code> 或 <code>rbind</code> 时, 表示这是 <code>bind</code> 类型的挂载。</li>\n</ul>\n</li>\n<li><strong>type</strong> (string, OPTIONAL): 被挂载的文件系统的类型。\n<ul>\n<li>对于 Linux 平台: 内核支持的文件系统类型声明在 <code>/proc/filesystems</code>。对于 <code>bind</code> 类型的挂载, <strong>type</strong> 值将被忽略, 按照业界习惯, 该值常被设置为 <strong>none</strong>。</li>\n</ul>\n</li>\n</ul>\n<h4>进程信息 process (object, OPTIONAL)</h4>\n<p>进程信息 (process) 指定容器执行的进程，在 <code>运行时实现</code> 调用 <strong>start</strong> 操作时, 该值是必须提供的。<code>process</code> 对象包含以下字段:</p>\n<ul>\n<li><strong>terminal</strong> (bool, OPTIONAL): 描述能否将终端连接到进程, 默认值为 false。</li>\n<li><strong>consoleSize</strong> (object, OPTIONAL): 描述终端控制台的大小(以字符为单位)。\n<ul>\n<li><strong>height</strong> (uint, REQUIRED)</li>\n<li><strong>width</strong> (uint, REQUIRED)</li>\n</ul>\n</li>\n<li><strong>cwd</strong> (string, REQUIRED):</li>\n<li><strong>env</strong> (array of strings, OPTIONAL)</li>\n<li><strong>args</strong> (array of strings, OPTIONAL)</li>\n<li><strong>commandLine</strong>  (string, OPTIONAL)</li>\n<li><strong>user</strong> (object, REQUIRED): 描述执行进程的用户身份。\n<ul>\n<li><strong>uid</strong> (int, REQUIRED): 在容器命名空间中的用户ID。</li>\n<li><strong>gid</strong> (int, REQUIRED): 在容器命名空间中的组ID。</li>\n<li><strong>umask</strong> (int, OPTIONAL): 用户掩码。</li>\n<li><strong>additionalGids</strong> (array of ints, OPTIONAL): 额外给进程添加的在容器命名空间中的组ID。</li>\n</ul>\n</li>\n</ul>\n<h4>主机名 hostname (string, OPTIONAL)</h4>\n<p>主机名 (hostname) 指定容器内进程所能看到的主机名。</p>\n<h4>钩子 hooks (object, OPTIONAL)</h4>\n<p>钩子 (hooks) 允许用户指定在各生命周期事件前后运行特定的程序。<code>运行时实现</code> 必须按钩子 (hooks) 的声明顺序依序执行，同时在调用钩子时必须通过 <code>标准输入(stdin)</code> 传递容器的状态。OCI运行时规范共定义 6 个钩子，分别是:</p>\n<ul>\n<li><strong>prestart</strong> (DEPRECATED)：prestart 必须在 <strong>start</strong> 操作被调用之后, 但在用户指定的程序执行之前被调用(且需要在<code>运行时实现</code>的命名空间中杯调用)。<strong>prestart</strong> 定义的钩子包含以下参数:</li>\n<li><strong>createRuntime</strong>: createRuntime 必须在 <strong>create</strong> 操作被调用时, 但在 <code>pivot_root</code> 或等效的操作执行之前被调用(且需要在<code>运行时实现</code>的命名空间中被调用)。</li>\n<li><strong>createContainer</strong>: createContainer 必须在 <strong>create</strong> 操作被调用时, 且在<strong>createRuntime</strong>被调用后，但在 <code>pivot_root</code> 或等效的操作执行之前被调用(且需要在容器的命名空间中被调用)。</li>\n<li><strong>startContainer</strong>: startContainer 必须作为 <strong>start</strong> 操作的一部分被执行, 且需要在执行用户指定的进程时被调用。</li>\n<li><strong>poststart</strong>: poststart 必须在执行用户指定的进程之后, 但是 <strong>start</strong> 操作返回前被调用。</li>\n<li><strong>poststop</strong>: poststop 操作必须在容器被删除, 但是 <strong>delete</strong> 操作返回前被调用。</li>\n</ul>\n<p>以上所有钩子对象具有同样的结构定义:</p>\n<ul>\n<li><strong>path</strong> (string, REQUIRED): 同 <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/functions/exec.html\" target=\"_blank\" rel=\"noopener noreferrer\">IEEE Std 1003.1-2008 execv's <code>path</code></a>, 但必须为绝对路径。</li>\n<li><strong>args</strong> (array of strings, OPTIONAL): 同 <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/functions/exec.html\" target=\"_blank\" rel=\"noopener noreferrer\">IEEE Std 1003.1-2008 <code>execv's argv</code></a> 中的定义。</li>\n<li><strong>env</strong> (array of strings, OPTIONAL): 同 <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_01\" target=\"_blank\" rel=\"noopener noreferrer\">IEEE Std 1003.1-2008 <code>environ</code></a> 中的定义</li>\n<li><strong>timeout</strong> (int, OPTIONAL): 描述执行该钩子的等待超时时间，如果设置，则必须为正数。</li>\n</ul>\n<h4>注解 annotations (object, OPTIONAL)</h4>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"annotations\": {\n        \"com.example.gpu-cores\": \"2\"\n    }\n}\n</code></pre></div><p>注解 (annotations) 用于存储容器相关的元数据。注解的键必须为非空字符串，注解的键必须为字符串。</p>\n<h4>平台相关配置</h4>\n<p>目前OCI运行时规范主要针对 4 类平台做了差异化设定, 分别为: <a href=\"https://github.com/opencontainers/runtime-spec/blob/master/config-linux.md\" target=\"_blank\" rel=\"noopener noreferrer\">linux</a>, <a href=\"https://github.com/opencontainers/runtime-spec/blob/master/config-windows.md\" target=\"_blank\" rel=\"noopener noreferrer\">windows</a>, <a href=\"https://github.com/opencontainers/runtime-spec/blob/master/config-solaris.md\" target=\"_blank\" rel=\"noopener noreferrer\">solaris</a>, <a href=\"https://github.com/opencontainers/runtime-spec/blob/master/config-vm.md\" target=\"_blank\" rel=\"noopener noreferrer\">vm</a>。对于不同的平台, 直接使用平台名称为 Key 配置对应的配置即可。例如:</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"linux\": {\n        \"namespaces\": [\n            {\n                \"type\": \"pid\"\n            }\n        ]\n    }\n}\n</code></pre></div><p>由于篇幅有限, 暂时不再继续罗列平台之间的差异化配置，感兴趣的读者可以从传送门翻过去看规范原文，或者等我后继空闲继续翻译整理。</p>\n<h2>总结</h2>\n<p>本文主要翻译了OCI运行时规范的主要内容, 方便对容器技术感兴趣的童鞋快速了解OCI运行时规范涉及的领域。由于精力有限暂且整理到这里，后继有空再整理平台差异化相关的配置项。</p>\n",
      "date_published": "2021-03-31T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "How To Build Images:从 0 开始带你徒手构建 Docker 镜像",
      "url": "https://blog.shabbywu.cn/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html",
      "id": "https://blog.shabbywu.cn/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html",
      "summary": "前言 现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 docker run {your-image-name}，而构建镜像也就是执行一句 docker build dockerfile .的事情。 也许正是由于 Docker 对实现细节封装得过于彻底，有时候...",
      "content_html": "<h2>前言</h2>\n<p>现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 <code>docker run {your-image-name}</code>，而构建镜像也就是执行一句 <code>docker build dockerfile .</code>的事情。<br>\n也许正是由于 <strong>Docker</strong> 对实现细节封装得过于彻底，有时候会觉得我们也许只是学会了<strong>如何使用<code>Docker CLI</code></strong> , 而并非明白 Docker 是如何运行的。<br>\n笔者将在『How To Build Images』系列文章讲述 <code>Docker build dockerfile .</code>相关的实现细节，本文是本系列的第二篇文章，将为各位展示从 0 开始徒手构建 Docker 镜像的相关知识。</p>\n<blockquote>\n<p>注: 本文假设读者了解如何使用 Docker, 包括但不限于懂得执行 <code>docker run</code> 和 <code>docker build</code> 以及编写 Dockerfile，还需要懂得 <a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">Docker 镜像规范</a></p>\n</blockquote>\n<h2>剖玄析微-原来容器是这样运行的</h2>\n<h3>Docker 架构简述</h3>\n<p>在讲解构建镜像之前, 不得不先了解 Docker 是如何将镜像转换成容器的。在 Docker1.11 之后, Docker 的架构图迭代为下图的模式:\n<img src=\"/img/Docker1.11架构图.png\" alt=\"Docker1.11架构图\" loading=\"lazy\"></p>\n<p>如图所示，Docker 将运行时拆分成两个模块, 分别是 <strong>containerd</strong> 和 <strong>runc</strong>。这两者都是容器技术标准化之后的产物，其中 <strong>containerd</strong> 负责镜像管理和网络设施等上层建筑，而 <strong>runc</strong> 则专注于容器管理和容器化技术等底层设施。</p>\n<p>我们平常执行 <code>docker run</code> 指令需要经历: <em>Docker Cli</em> 与 <em>Docker Engine</em> 通信, <em>Docker Engine</em> 将请求解析后再转发至 <em>containerd</em>, 最后 <em>containerd</em> 借助 <em>containerd-shim</em> 这个转换器调用 <em>runc</em>, 容器才真正被创建和运行。</p>\n<p>照这么说，我们可不可以砍掉中间商, 直接调用 <code>runc</code> 来创建容器呢？答案是可以的。我们只需要根据 <strong>OCI 运行时规范</strong> 将容器编排为文件系统捆绑包(Filesystem Bundle)的形式，即可使用 <code>runc</code> 启动该容器。</p>\n<blockquote>\n<p>注1: 对于 linux 系统, 一般安装 Docker 即会同时安装 runc, 如本机未安装 runc, 可直接在 github 下载预编译好的 <a href=\"https://github.com/opencontainers/runc/releases\" target=\"_blank\" rel=\"noopener noreferrer\">runc 二进制文件</a>。<br>\n注2: 想进一步了解 <strong>OCI 运行时规范</strong> 的读者, 可以阅读笔者另一个系列文章<a href=\"/posts/2021/03/31/how-to-run-container-oci-%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『How To Run Container:OCI 运行时规范』</a></p>\n</blockquote>\n<h3>runc-如何运行一个容器镜像</h3>\n<p>根据 <strong>OCI 运行时规范</strong>, 一个基本的容器应该具备以下的文件目录结构:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── config.json\n└── $root.path\n</code></pre></div><blockquote>\n<p>其中, 通常将 <code>$root.path</code>  设置为 <strong>rootfs</strong>。</p>\n</blockquote>\n<p>首先我们创建一个空目录用于编排容器捆绑包。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>➜ cd /some-path\n## 创建捆绑包目录\n➜ mkdir mycontainer &amp;&amp; cd mycontainer\n\n## 创建 rootfs 目录\n➜ mkdir rootfs\n</code></pre></div><p>接下来的步骤则是生成 <code>config.json</code> 文件。由于OCI 运行时规范很复杂, 手工配置 config.json 需要花费很大的精力。不过幸好，runc 已经预留了特殊指令方便生成基本规范模板。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 生成基本模板\n➜ runc spec\n</code></pre></div><p>虽然我们还没往容器根文件系统里塞东西, 但是我们不妨试试容器能不能跑起来，万一能跑起来呢？</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>➜ runc run mycontainer\n## ERRO[0000] container_linux.go:349: starting container process caused \"exec: \\\"sh\\\": executable file not found in $PATH\"\n## container_linux.go:349: starting container process caused \"exec: \\\"sh\\\": executable file not found in $PATH\"\n</code></pre></div><p>预料之内地出现了报错..., 毕竟我们容器什么都没有，又怎么能跑起来呢。<br>\n现在到了最后但也是最重要的一步, 那就是<strong>创建容器根文件系统的内容</strong>。但是我们压根不知道容器内应该有什么内容...怎么办好呢？那就从 Docker 中导出一个容器来看看应该长什么样子呗！</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 导出 busybox 容器\n➜ docker export $(docker create --name busybox busybox) | tar -C rootfs -xvf - &amp;&amp; docker stop busybox &amp;&amp; docker rm busybox\n\n## 确定 rootfs 内容\n➜ ls rootfs\nbin  dev  etc  home  proc  root  sys  tmp  usr  var\n\n## 启动容器\n➜ runc run mycontainer\n\n## 在容器内执行 ls 、hostname 和 whoami\n➜ ls\nbin   dev   etc   home  proc  root  sys   tmp   usr   var\n\n➜ hostname\nrunc\n\n➜ whoami\nroot\n</code></pre></div><p>到这里我们成功用 runc 启动了容器, 我们先小结一下, 想要直接运行容器十分简单, 只需要:</p>\n<ol>\n<li>将容器编排为文件系统捆绑包(Filesystem Bundle)的形式</li>\n<li>往 <code>config.json</code> 编写正确的配置</li>\n<li>往 <code>$root.path</code> 填充合理和可用的文件</li>\n<li>执行 runc run $containerid 启动容器</li>\n</ol>\n<h2>照猫画虎-构建可运行的容器捆绑包</h2>\n<p>我们在上一节借助了 Docker 导出了可运行的容器的根文件系统，那如果不借助外力，我们有没有可能创建一个简单的可运行的容器呢？答案是可以的，但是在此之前需要先复习容器相关的知识。</p>\n<h3>容器和虚拟机的区别</h3>\n<p>虚拟机是一种运行在主机操作系统(HostOS)之上，可以访问底层硬件的客机操作系统(GuestOS)，例如 Linux 或 Windows；而容器是轻量级应用代码包，它还包含依赖项，例如编程语言运行时的特定版本和运行软件服务所需的库。与虚拟机相似，容器为用户提供了独立环境来运行应用程序，但两者具有本质上的差异:</p>\n<ul>\n<li>容器在操作系统级别进行虚拟, 而虚拟机在硬件级别进行虚拟化</li>\n<li>容器与主机操作系统(HostOS)共享操作系统内核, 而虚拟机使用客机操作系统(GuestOS)提供的操作系统内核。\n<img src=\"/img/容器与虚拟机架构对比.png\" alt=\"容器与虚拟机架构对比\" loading=\"lazy\"></li>\n</ul>\n<p>容器与虚拟机架构的差异带来的优势想必已经被说烂了，这里我也不会再说什么。我们将关注点放在<strong>启动</strong>这个流程。</p>\n<h4>虚拟机的启动流程</h4>\n<p>由于虚拟机是在硬件级别进行虚拟化，虚拟机的启动流程也即是计算机的启动流程。完整的计算机启动流程至少包括了 4 个阶段:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/9K-3RG1H68xnflXpGQq1ogZc16UxHbJRTjGB3a6jPVemHW_HpqzTuIGVJ4xP5WfDtV7uWBU15oVrQNroRaBZFHbSSP9NNGVbjqbzKdSTOCaZJSx9pGS-IF5M10kejhuEcZgWfsza0b3US7zld_Zh5ATVBCdmzsdjKCzu7LTSyDO3-_x6xozuunO_RUETCxosIXxUIbJ5fplw9Fkzfj5shQTBq5O1SmcqL3JL4ps9oCOhsLd7oHMzMi4V9_acX-2FVOIwP4VA8_q3\n\"></code></pre></div><h4>容器的启动流程</h4>\n<p>由于容器与主机共享操作系统内核, 启动容器只包含 2 个阶段:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/SoWkIImgAStDuIhEpimhI2nAp5L8J2x9BCiigGpEI2n8LSXFBabCpy_Z0igLP9PavkSfF5sty-dC5Kydh7_QjKAXcaj3IrD1rqxXQSVSfykxd_PCUx5_mek5FS-cRtlUj_xfecOke1n4xVCfAvvrR7_Mq_vqtQpdirgUxfe257c-ellfhdwG8g0vNBLS3gbvAK3d0000\n\"></code></pre></div><p>虚拟机与容器的启动流程最大的差异在于, 在<code>启动用户指定的应用程序</code>之前, 虚拟机需要先执行<code>计算机启动</code>这一流程, 而容器在<code>初始化运行时环境</code>后, 即马上启动用户指定的应用程序。这导致在容器中所看到的的 <strong>pid 1</strong> 进程, 即为 <strong>用户指定的应用程序</strong>。<br>\n另一方面, 由于容器启动无需经历<code>加载并初始化内核</code>和<code>启动init进程</code>这两个阶段, 因此容器镜像中无需包含操作系统内核和init进程等成分。<br>\n终上所述, 创建一个简单的可运行的容器只需要准备用户指定的应用程序和它的依赖项即可，无需任何多余成分。</p>\n<h3>一个极其简单的可执行程序</h3>\n<p>在这一小节里, 我们主要的任务是创建一个简单的可执行程序，那什么样的可执行程序是最简单的呢？正所谓万法归宗, 最简单的程序必然是汇编。这里预先准备了一段用 nasm 写的汇编程序，如下:</p>\n<div class=\"language-nasm\" data-ext=\"nasm\" data-title=\"nasm\"><pre class=\"language-nasm\"><code>section .data\n     msg:     db   \"Hello runc!\", 13, 10; 10 为ASCII码：\\n(LF)，13ASCII码：\\r(CR)\n     msglen:  equ  $ - msg;\n\nsection .text\n     global _start \n\n_start:\n     mov eax, 4        ; 4 对应 sys_write 系统调用\n     mov ebx, 1        ; sys_write 系统调用第一个参数: 文件描述符, 1 即标准输出\n     mov ecx, msg      ; sys_write 系统调用第二个参数: 要输出的字符串的偏移地址\n     mov edx, msglen   ; sys_write 系统调用第三个参数: 字符串长度\n     int 80h           ; 80h中断，触发系统调用\n\n     mov eax, 1   ; 1 对应 exit 系统调用\n     mov ebx, 0   ; exit 系统调用参数: 返回码\n     int 80h      ; 80h中断，触发系统调用\n</code></pre></div><p>nasm 汇编语言的组成成分这里就不展开介绍，现在只需要编译、链接即可生成可执行程序。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 我们目前还在 mycontainer 目录, 将上面的代码输出到 hello.nasm 文件\n## 使用 nasm 编译 hello.nasm, 注意设置输出格式为 ELF64 (x86-64) (Linux, most Unix variants)\n➜ nasm hello.nasm -f elf64 -o hello.o\n\n## 链接, 将目标文件连接为可执行程序\n➜ ld hello.o -o hello\n\n## 测试运行\n➜ ./hello\nHello runc!\n</code></pre></div><p>通过上述操作, 我们现在已经获得到一个可以在 x86-64 架构的 Linux 平台下执行的无依赖的可执行文件，我们现在尝试将其放到容器内执行。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 先清空 rootfs 目录\n➜ rm -r -f rootfs &amp;&amp; mkdir rootfs\n\n## 将 hello 文件复制至 rootfs 目录内\n➜ cp hello rootfs/\n\n## 确定 rootfs 目录仅有 hello 文件\n➜ ls -la rootfs\n总用量 12\ndrwxr-xr-x 2 root root 4096 4月   8 20:40 .\ndrwxr-xr-x 3 root root 4096 4月   8 20:40 ..\n-rwxr-xr-x 1 root root 1040 4月   8 20:40 hello\n\n## 启动容器!\n➜ runc run mycontainer\nERRO[0000] container_linux.go:367: starting container process caused: exec: \"sh\": executable file not found in $PATH\n</code></pre></div><p>然后就毫无意外地报错了...<br>\n根据错误信息, 默认的运行时配置模板指定的启动程序是 <code>sh</code> , 但是由于我们的容器极其简洁, 连 <code>sh</code> 程序都没有, 所以就报错了。我们只需要将 <strong>process.args</strong> 修改成 <code>/hello</code> 即可。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 将 \"sh\" 替换成 \"/hello\"\n➜ sed -i \"s/\\\"sh\\\"/\\\"\\/hello\\\"/\" config.json\n\n## 启动容器!\n➜ runc run mycontainer\nHello runc!\n</code></pre></div><p>到这里我们成功用 runc 启动了我们亲手填充内容的容器，我们先小结一下, 想要从 0 开始构建可运行的容器十分简单, 只需要:</p>\n<ol>\n<li>将容器编排为文件系统捆绑包(Filesystem Bundle)的形式</li>\n<li>往 <code>$root.path</code> 中添加用户指定的应用程序和它的依赖项</li>\n<li>保证 <code>config.json</code> 中的 <strong>process.args</strong> 指定的指令在容器中是可执行的</li>\n<li>执行 runc run $containerid</li>\n</ol>\n<h2>门前一脚-封装镜像归档包</h2>\n<p>我们在上一节中照猫画虎地构建了一个极其简单的可执行的容器，那有没有办法将这个容器打包成镜像, 并导入到 Docker 里呢？答案是可以的，而且凭借我们目前掌握的知识点，已足以实现这个需求。先让我们复习一下 Docker 镜像归档包的基本目录结构。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── 036a82c6d65f2fa43a13599661490be3fca1c3d6790814668d4e8c0213153b12\n│&nbsp;&nbsp; ├── VERSION\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; └── layer.tar\n├── 6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30.json\n├── manifest.json\n└── repositories\n\n1 directory, 6 files\n</code></pre></div><p>其中只有 <code>manifest.json</code> 中声明的要素是镜像归档包的必要成分，其他文件都无需关注。也就是说, 只需要关注 <code>layer.tar</code>, <code>config.json</code> 以及 <code>manifest.json</code> 三个文件。</p>\n<blockquote>\n<p>注1: config.json 即 Image JSON, 在归档包中常以自身的 sha256sum 命名, 在上述案例中即 <code>6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30.json</code>\n注2: 容器镜像归档包中包含的成分和含义详见本系列上一篇文章<a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『How To Build Images:Docker 镜像规范 v1.2』</a></p>\n</blockquote>\n<h3>构建 layer.tar</h3>\n<p>层归档包记录着镜像内容的变更历史, 我们这个镜像包有且只有一层, 因此只需要将 hello 文件打包进归档包即可。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 我们目前还在 mycontainer 目录\n## 创建层归档包 layer.tar\n➜ tar -cf layer.tar -C rootfs hello\n\n## 验证归档包内容\n➜ tar -tvf layer.tar\n-rwxr-xr-x root/root      1040 2021-04-08 20:40 hello\n\n## 计算 layer.tar 的 sha256sum 备用\n➜ sha256sum layer.tar\n45f29debe3c1db5d78d29583a12cb58208ca1942b23e281e9c5894182b5ffb97  layer.tar\n</code></pre></div><h3>构建 config.json</h3>\n<p>Image JSON 包含了与镜像相关的基本信息和运行时的相关配置等, 这导致构建 config.json 是最为复杂的步骤。为了减少这小节所占用篇幅, 这里直接展示构建结果, 并在注释中描述关键数据的来源。</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n\t\"architecture\": \"amd64\",\n    // 从 runc 生成的 config.json 的 process 字段里抄\n\t\"config\": {\n    // 镜像并没有创建用户, 所以这里留空\n\t\t\"User\": \"\",\n\t\t\"Tty\": false,\n\t\t\"Env\": [\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\n        // 启动指令, 这里设置为 /hello, 与 runc 的配置一致\n\t\t\"Cmd\": [\"/hello\"],\n\t\t\"Volumes\": null,\n\t\t\"WorkingDir\": \"/\",\n\t\t\"Entrypoint\": null,\n        \"Labels\": null\n\t},\n    // created 描述了当前镜像创建的日期和时间  \n\t\"created\": \"1970-01-01T00:00:00.0Z\",\n    // 构建镜像的 docker 版本号, 由于是手工构建, 我们执行 `docker version` 抄一下版本号\n\t\"docker_version\": \"20.10.5\",\n    // 没有构建 docker build 历史\n\t\"history\": [],\n\t\"os\": \"linux\",\n\t\"rootfs\": {\n\t\t\"type\": \"layers\",\n\t\t\"diff_ids\": \n        // 这里是 layer.tar 的 sha256sum\n        [\"sha256:45f29debe3c1db5d78d29583a12cb58208ca1942b23e281e9c5894182b5ffb97\"]\n\t}\n}\n</code></pre></div><p>将上述配置压缩(去掉注释)后, 计算压缩后的内容的 sha256sum, 并命名为对应的 ${sha256sum}.json</p>\n<blockquote>\n<p>上述配置对应的 sha256sum 为 112e38209f1b62794b83d25708b5ab354792a8155453d151aac8dadca11e2c48</p>\n</blockquote>\n<h3>构建 manifest.json</h3>\n<p><code>mainfest.json</code> 记录了一个列表, 该列表中每一项描述了一个镜像的内容清单以及该镜像的父镜像(可选的)。我们的镜像不存在父镜像, 因此只需要记录一个结构即可。具体的配置如下:</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>[\n  {\n    \"Config\": \"112e38209f1b62794b83d25708b5ab354792a8155453d151aac8dadca11e2c48.json\",\n    \"RepoTags\": [\n      \"hello-runc:nasm\"\n    ],\n    \"Layers\": [\n      \"layer.tar\"\n    ]\n  }\n]\n</code></pre></div><h3>归档封包</h3>\n<p>将上述的文件放置到同一层的目录进, 使用 tar 打包归档即可。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 我们目前还在 mycontainer 目录\n## 保证上述文件都在 mycontainer 目录内\n➜ ls \n112e38209f1b62794b83d25708b5ab354792a8155453d151aac8dadca11e2c48.json  config.json  layer.tar  manifest.json  rootfs\n\n## 打包归档\n➜ tar -cf image.tar 112e38209f1b62794b83d25708b5ab354792a8155453d151aac8dadca11e2c48.json layer.tar manifest.json\n\n## 验证归档包内容\n➜  tar -tvf image.tar\n-rw-r--r-- root/root       417 2021-04-08 22:14 112e38209f1b62794b83d25708b5ab354792a8155453d151aac8dadca11e2c48.json\n-rw-r--r-- root/root     10240 2021-04-08 22:14 layer.tar\n-rw-r--r-- root/root       188 2021-04-08 22:14 manifest.json\n</code></pre></div><h3>导入镜像</h3>\n<p>docker 导入镜像十分简单, 只需要执行 <code>docker load</code> 指令即可。接下来即演示导入镜像的操作。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 我们目前还在 mycontainer 目录\n## 保证容器原先不存在 hello-runc 镜像\n➜ docker images hello-runc\nREPOSITORY   TAG       IMAGE ID   CREATED   SIZE\n\n## 执行指令导入镜像\n➜ docker load -i image.tar\nLoaded image: hello-runc:nasm\n\n## 验证镜像已存在\n➜ docker images hello-runc\nREPOSITORY   TAG       IMAGE ID       CREATED        SIZE\nhello-runc   nasm      112e38209f1b   51 years ago   1.04kB\n</code></pre></div><p>到这里我们已徒手构建 Docker 镜像, 并成功导入到 Docker 镜像列表中。为了彰显成功的喜悦，将验证环节放到下一节进行。我们先小结一下，想要徒手构建 Docker 镜像十分简单, 只需要:</p>\n<ol>\n<li>将容器编排为文件系统捆绑包(Filesystem Bundle)的形式</li>\n<li>将容器文件系统捆绑包打包成 layer.tar 文件</li>\n<li>依照容器运行时配置内容(runc 的 config.json), 编写镜像配置信息(Image JSON, 镜像的 config.json)</li>\n<li>编写容器清单文件 (manifest.json)</li>\n<li>tar打包归档成镜像</li>\n</ol>\n<h2>大功告成-创建容器运行验证结果与总结</h2>\n<p>我们在上一节中徒手构建了 Docker 镜像并成功将其导入到 Docker 镜像列表中，我们将在这一环节浓重地使用该镜像创建容器😁</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 我们目前还在 mycontainer 目录, 但这并不重要。\n## 使用默认参数创建容器\n➜ docker run --rm hello-runc:nasm\nWARNING: IPv4 forwarding is disabled. Networking will not work.\nHello runc!\n\n## 通过指定启动指令的形式创建容器\n➜ docker run --rm hello-runc:nasm /hello\nWARNING: IPv4 forwarding is disabled. Networking will not work.\nHello runc!\n</code></pre></div><p>至此, 我们已经从 0 开始徒手构建 Docker 镜像, 并成功将其导入 Docker 镜像列表并正常运行。证实了徒手构建 Docker 镜像的可能性。<br>\n最后我们总结一下, 构建 Docker 镜像十分简单, 只需要:</p>\n<ol>\n<li>熟悉 Docker 镜像规范</li>\n<li>准备容器运行环境, 即应用程序及其依赖项，例如编程语言运行时的特定版本和运行软件服务所需的库</li>\n<li>整理镜像各层的归档包(layer.tar), 并计算 Layer DiffID</li>\n<li>依照规范编写镜像配置信息(Image JSON), 并计算 ImageID</li>\n<li>编写容器清单文件 (manifest.json)</li>\n<li>tar打包归档成镜像</li>\n</ol>\n<p>这篇文章是『How To Build Images』系列的第二篇，主要以 runc 如何运行容器为切入点, 深入介绍了构建 Docker 镜像的各个步骤和实现细节，到这里为止，我们已经初步掌握了 <strong>How To Build Images</strong> 的知识，本系列的下一篇文章将与大家深入探讨 <code>Docker Daemon</code> 与 <code>Docker Registry</code> 的交互流程, 为大家剖析隐藏在 <code>docker pull</code> 与 <code>docker push</code> 背后的细节。<sub><s>净听你吹牛逼</s></sub></p>\n",
      "image": "https://blog.shabbywu.cn/img/Docker1.11架构图.png",
      "date_published": "2021-04-01T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "How To Run Container-浅谈从镜像创建容器的实现细节",
      "url": "https://blog.shabbywu.cn/posts/2021/08/12/how-to-run-container-%E6%B5%85%E8%B0%88%E4%BB%8E%E9%95%9C%E5%83%8F%E5%88%9B%E5%BB%BA%E5%AE%B9%E5%99%A8%E7%9A%84%E5%AE%9E%E7%8E%B0%E7%BB%86%E8%8A%82.html",
      "id": "https://blog.shabbywu.cn/posts/2021/08/12/how-to-run-container-%E6%B5%85%E8%B0%88%E4%BB%8E%E9%95%9C%E5%83%8F%E5%88%9B%E5%BB%BA%E5%AE%B9%E5%99%A8%E7%9A%84%E5%AE%9E%E7%8E%B0%E7%BB%86%E8%8A%82.html",
      "summary": "前言 现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 docker run {your-image-name}，而构建镜像也就是执行一句 docker build dockerfile .的事情。 也许正是由于 Docker 对实现细节封装得过于彻底，有时候...",
      "content_html": "<h2>前言</h2>\n<p>现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 <code>docker run {your-image-name}</code>，而构建镜像也就是执行一句 <code>docker build dockerfile .</code>的事情。<br>\n也许正是由于 <strong>Docker</strong> 对实现细节封装得过于彻底，有时候会觉得我们也许只是学会了<strong>如何使用<code>Docker CLI</code></strong> , 而并非明白 Docker 是如何运行的。<br>\n笔者将在『How To Run Container』系列文章讲述 <code>docker run {your-image-name}</code> 相关的实现细节，本文是本系列的第二篇文章，将为各位介绍从镜像创建容器涉及到的实现细节。</p>\n<h2>什么是镜像和容器？</h2>\n<h3>1. 什么是镜像？</h3>\n<p>正如我上一篇文章<a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『Docker 镜像规范 v1.2』</a>中指出的, <code>镜像</code>是一个<strong>存储了文件系统发生的变更历史</strong>的归档包。一般而言，一个基本的镜像具有以下的目录结构:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── 036a82c6d65f2fa43a13599661490be3fca1c3d6790814668d4e8c0213153b12\n│&nbsp;&nbsp; ├── VERSION\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; └── layer.tar\n├── f578fecf2875c8c4e4f88d15b90949fa40c71a0f0231b831f1263c708c2d524d\n│&nbsp;&nbsp; ├── VERSION\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; └── layer.tar\n├── 6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30.json\n├── manifest.json\n└── repositories\n</code></pre></div><p>在存储文件系统发生的变更时, 镜像(Image) 将<strong>一组文件系统的变更历史</strong>定义为<code>「镜像层(Image Layer)」</code>, 每个镜像层负责记录该层镜像与上一层镜像的文件系统之间的差异, 而这些镜像层之间的关系则由<code>镜像清单(Image manifest)</code>负责维护。<br>\n综上所述, <strong>镜像可以简单地理解成由多个镜像层叠加起来的文件系统</strong>。(如下图)</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/SoWkIImgAStDuIf8JCvEJ4zLyCm5ajLS2a2GbfcJgl1owfodpVtFThG-xPsABlgojVVvtdLWKJ01D2epBJ6vHC4diJAro12Re14sGJy5Bf0eJ9kDdXvKiPN2YsLjpsTFGv8sGaZxmQLhkHnIyrA0tW00\n\"></code></pre></div><blockquote>\n<p>事实上, 镜像内还记录了该镜像的一些基本信息, 例如创建日期, 作者和其父镜像的ID, 以及运行时的相关配置, 关于镜像内容更详细的描述可参考我的另一篇文章<a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『Docker 镜像规范 v1.2』</a></p>\n</blockquote>\n<h3>2.什么是容器？</h3>\n<p>根据 OCI 的定义, <code>容器</code>是一个可配置<strong>资源限制</strong>和<strong>隔离性</strong>的, 用于<strong>执行进程的环境</strong>。我们知道, <code>Linux 容器</code>的<strong>资源限制</strong>和<strong>隔离性</strong>是分别基于 <code>Cgroup</code> 和 <code>Linux Namespace</code> 实现的, 两者都是 Linux 内核提供的功能, 其中 Cgroup 用于限制和隔离一组进程对系统资源的使用, 而 Linux Namespace 对内核资源(IPC、Network、Mount、PID、UTS 和 User)进行了封装, 使得不同进程在各自的 Namespace 下操作同一种资源时, 不会影响 Namespace 下的进程。<br>\n<code>容器</code>和<code>镜像</code>的关系就像是模板和实例, 镜像提供了<strong>运行容器的必要元素(文件系统和运行配置)</strong>，但不依赖镜像也可运行容器, 简而言之, 我们可以认为<strong>镜像是容器的充分不必要条件</strong>。</p>\n<blockquote>\n<p>关于“充分不必要条件”, 感兴趣的读者可以阅读我在上一篇文章<a href=\"/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html#%E7%85%A7%E7%8C%AB%E7%94%BB%E8%99%8E-%E6%9E%84%E5%BB%BA%E5%8F%AF%E8%BF%90%E8%A1%8C%E7%9A%84%E5%AE%B9%E5%99%A8%E6%8D%86%E7%BB%91%E5%8C%85\" target=\"_blank\">『从 0 开始带你徒手构建 Docker 镜像』</a>。在这篇文章中, 我先后为大家展示了<strong>如何使用镜像运行容器</strong>和<strong>如何在不依赖镜像的前提下, 构建容器运行要素并运行容器</strong>。</p>\n</blockquote>\n<h2>Docker 是如何从镜像创建容器？</h2>\n<p>正如前文所言, <code>镜像</code>是一个<strong>存储了文件系统发生的变更历史</strong>的归档包, 而<code>容器</code>是一个可配置<strong>资源限制</strong>和<strong>隔离性</strong>的, 用于<strong>执行进程的环境</strong>。从本质而言, 镜像为容器提供了文件系统和运行参数配置, 而容器则是从镜像创建出来的一个实例。<br>\n接下来, 我们将深入探讨 Docker 从镜像创建出容器的实现细节。</p>\n<h3>镜像存储和 UnionFS</h3>\n<p>Docker 镜像分层的存储设计借鉴自 UnionFS。UnionFS 是一种可以将多个独立的文件系统中的文件和目录联合挂载, 形成一个统一的, 屏蔽底层细节的文件系统的技术。<br>\nDocker 镜像中每个镜像层都是一个<strong>不完整</strong>的文件系统, 它记录该层镜像与上一层镜像的文件系统之间的差异。这种分层策略赋予了 Docker 更轻量的镜像(相对于虚拟机而言), 分发镜像时只需要下载对应的镜像层即可。<br>\n当然, 这种分层镜像设计也引入了一个难题, <strong>如何删除上层镜像的文件?</strong><br>\n对于这个问题, Docker 也是原封不动地引入了 UnionFS 的解决方案: <code>Whiteout</code> 和<code> Opaque</code>。</p>\n<h4>Whiteout</h4>\n<p>所谓的 <code>Whiteout</code> 和<code> Opaque Whiteout</code> 是借鉴自 UnionFS 协议, Docker 镜像通过约定的文件命名方式, 描述了下层文件系统需要屏蔽上层文件系统中的哪些文件或目录。例如, 以下是包含多个资源的基础层:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>❯ tree .\n.\n└── a\n    ├── b\n    │&nbsp;&nbsp; └── c\n    │&nbsp;&nbsp;     ├── bar\n    │&nbsp;&nbsp;     └── foo\n    └── baz\n\n3 directories, 3 files\n</code></pre></div><p>如果下层文件系统内需要删除 <code>a/b/c/foo</code> 这个文件, 那么下层文件系统则需要创建一个以 <code>.wh.&lt;filename&gt;</code> 为命名的隐藏文件, 即下层文件系统应当具有以下的文件系统结构:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre bash=\"\" class=\"language-bash\"><code>❯ tree . -a\n.\n└── a\n    └── b\n        └── c\n            └── .wh.foo\n\n3 directories, 1 file\n</code></pre></div><h4>Opaque Whiteout</h4>\n<p>除了通过 <code>Whiteout</code> 描述删除单个文件的协议外, 还可以通过 <code>Opaque Whiteout</code> 描述删除某个目录下的所有文件。<br>\n以上面提到的基础文件系统为例, 如果下层文件系统希望删除 <code>a</code> 目录下的所有文件, 那么下层文件系统则需要在 <code>a</code> 目录下创建命名为 <code>.wh..wh..opq</code> 的隐藏文件, 即下层文件系统应当具有以下的文件系统结构:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre bash=\"\" class=\"language-bash\"><code>❯ tree . -a\n.\n└── a\n    └── .wh..wh..opq\n\n1 directory, 1 file\n</code></pre></div><p>当然, 我们也可以通过 <code>Whiteout</code> 达到与 <code>Opaque Whiteout</code> 等价的效果, 例如以上面提到的基础文件系统为例, 我们希望删除 <code>a</code> 目录下的所有文件, 还可以采用以下的文件系统结构获得等价的结果:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre bash=\"\" class=\"language-bash\"><code>❯ tree . -a \n.\n└── a\n    ├── .wh.b\n    └── .wh.baz\n\n1 directory, 2 files\n</code></pre></div><blockquote>\n<p>值得注入的是: 如 <code>Opaque Whiteout</code> 不同的是, 如果 <code>a</code> 目录下新增一个新的文件或目录, 那么通过 <code>Whiteout</code> 删除 <code>a</code> 目录下的所有文件则需要为这个新的文件或目录创建新的 <code>Whiteout</code> 隐藏文件, 而使用 <code>Opaque Whiteout</code> 则不需要。</p>\n</blockquote>\n<h3>ReadOnly &amp; Copy on Write</h3>\n<h4>ReadOnly Layer</h4>\n<p>Docker 在 UnionFS 的基础上设计了镜像内容(文件系统变更历史)的存储方案, 同时又增加了一个限制: <strong>所有镜像层只读, 不允许更改镜像层内容</strong>。</p>\n<p>这个限制不但避免了容器内容在运行时出现意外变更<sup>注</sup>, 而且使得容器镜像比虚拟机而言更加轻量。</p>\n<blockquote>\n<p>注: 试想下, 如果上层文件系统内容变更后, 联合挂载的文件系统是否需要同步变更内容？</p>\n</blockquote>\n<figure><img src=\"/img/镜像层只读样例.png\" alt=\"镜像层只读样例(以 ubuntu 镜像为例)\" tabindex=\"0\" loading=\"lazy\"><figcaption>镜像层只读样例(以 ubuntu 镜像为例)</figcaption></figure>\n<p>基于 Docker 出色的镜像设计方案, 使得每台主机只需要为每个镜像层存储一个副本, 同时在分发镜像时也只需下载缺失的镜像层内容, 这大大节省了存储和网络带宽。</p>\n<p>接下来, 那么容器如何在只读的镜像层增删内容呢？这就不得不介绍另一个技术: <strong>Copy-on-Write</strong></p>\n<h4>Copy-on-Write</h4>\n<p><strong>Copy-on-Write(简称, Cow)</strong> 实际上是一种计算机程序设计领域的优化策略, 顾名思义, 如果有多个用户同时请求相同的资源时(如内存或磁盘上的数据), 他们首先会获得指向相同资源的地址, 直到某个用户视图修改资源的内容时, 系统才会真正复制一份专属副本给该用户, 而其他用户所访问的资源仍然保持不变。</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/X96rTMKn441pLJ7PhkdbPaigmHLyPZP3P6PcPsPcXceUTc7HPyhctA4xPteoVQvqNtyiYb3Kv--3lWIaGu5W7tX3QP-dBvI8eqma54-wqguO95sUY2lWw-qRZlg0frtnGDI7N9w-H1hwCxuqBeW9GhaIi5ycpSzL64K0_t43QF2VDF2OPkFnmcfiRPwRis_MnQUpyooQU9inTsSUNZU0AmCOIqRCnHbdVLOo8XajP5JrifsTdNnr6nZ7IyRigprpgLA4AGY7Dd0sVitb4yMfv3J7Ynr9FKTImP5KS2GKgMObWajCieAbXbJUH6ZXKLnIV8BbkiJXRM3VVHdJUmfh6iU4zwnSiamHfdcCk0XqQ9fyPevpZugOCiny55OxKOylxlqRC7K10000\n\"></code></pre></div><p>容器引入Cow(🐂)技术, 通过<strong>延迟拷贝</strong>的方式节省了创建多个完整副本时带来的空间和时间上的开销。该技术在容器上则表现为每个容器在 UnionFS 的基础上增加了各自的读写层(R/W Layer), 该层中的所有内容即是该容器的所有文件系统变更<sup>注</sup>。</p>\n<blockquote>\n<p>注: 借助 Cow 技术, 构建镜像时只需要将每层镜像的读写层归档成镜像层即可。</p>\n</blockquote>\n<figure><img src=\"/img/容器可读层.jpg\" alt=\"容器可读层(以 ubuntu 镜像为例)\" tabindex=\"0\" loading=\"lazy\"><figcaption>容器可读层(以 ubuntu 镜像为例)</figcaption></figure>\n<h2>实战: 基于 OverlayFS2, 徒手从镜像创建容器</h2>\n<p>使用 runc 启动容器的流程已经在上一篇文章<a href=\"/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html\" target=\"_blank\">『从 0 开始带你徒手构建 Docker 镜像』</a>充分演示, 这里重新回顾下流程, 想要直接运行容器十分简单, 只需要:</p>\n<ol>\n<li>将容器编排为文件系统捆绑包(Filesystem Bundle)的形式</li>\n<li>往 <code>config.json</code> 编写正确的配置</li>\n<li>往 <code>$root.path</code> 填充合理和可用的文件</li>\n<li>执行 runc run $containerid 启动容器</li>\n</ol>\n<p>但是如上一篇文章不同的是, 我们这次不再是徒手构建 Docker 镜像, 而是从 DockerHub 中获取镜像，充分模拟 <code>docker run {your-image-name}</code> 涉及的流程。</p>\n<h3>1. 获取镜像</h3>\n<p>我们知道, DockerHub 并不需要 Docker Engine 即可访问, 其接口规范遵循 Docker Registry API V2。也就是说, 我们只需要使用 REST API 即可从 DockerHub 获取镜像。这里使用到一个开源脚本<a href=\"https://raw.githubusercontent.com/moby/moby/master/contrib/download-frozen-image-v2.sh\" target=\"_blank\" rel=\"noopener noreferrer\">download-frozen-image-v2.sh</a>, 该脚本使用 curl, jq 等工具实现了<code>Token 认证</code>, <code>拉取镜像清单</code>, <code>拉取镜像层</code> 等流程, 下面演示如何使用该脚本拉取 alpine/git:v2.30.2 镜像</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre bash=\"\" class=\"language-bash\"><code>❯ ./download-frozen-image-v2.sh -h\nusage: ./download-frozen-image-v2.sh dir image[:tag][@digest] ...\n       ./download-frozen-image-v2.sh /tmp/old-hello-world hello-world:latest@sha256:8be990ef2aeb16dbcb9271ddfe2610fa6658d13f6dfb8bc72074cc1ca36966a7\n\n❯ ./download-frozen-image-v2.sh alpine alpine/git:v2.30.2\nDownloading 'alpine/git:v2.30.2@v2.30.2' (3 layers)...\n#=#=-  ##     #\n############################################################################################################################################################################ 100.0%\n#=#=-  ##     #\n############################################################################################################################################################################ 100.0%\n#=#=-  ##     #\n############################################################################################################################################################################ 100.0%\n\nDownload of images into 'alpine' complete.\nUse something like the following to load the result into a Docker daemon:\n  tar -cC 'alpine' . | docker load\n\n## 查看镜像结构\n❯ tree alpine/\nalpine/\n├── 09af0b97aec5975955488d528e8535d2678b75cb29adb6827abd85b52802d1b1\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; ├── layer.tar\n│&nbsp;&nbsp; └── VERSION\n├── 86f68eb8bb2057574a5385c9ce7528b70632e1c750fb36d5ac76c0a5460f5d95\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; ├── layer.tar\n│&nbsp;&nbsp; └── VERSION\n├── b86cef5f7cf032b9793fe2a4fb18ddf606df8ea9e41d4c2086749bf943c2985b.json\n├── d8aa90f099f0f17f3ad894f0909e6bfd026cc4c76eec03e3e50391af42f41976\n│&nbsp;&nbsp; ├── json\n│&nbsp;&nbsp; ├── layer.tar\n│&nbsp;&nbsp; └── VERSION\n├── manifest.json\n└── repositories\n\n3 directories, 12 files\n</code></pre></div><h3>2. 构建 Overlay 文件系统</h3>\n<p>在获取到镜像之后, 我们则可以开始将镜像内容编排为文件系统捆绑包(Filesystem Bundle)的形式, 这里根据 <a href=\"https://github.com/moby/moby/blob/master/daemon/graphdriver/overlay2/overlay.go\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Overlay2 Driver</a> 的流程来构建容器的 rootfs。</p>\n<blockquote>\n<p>OverlayFS 是一个与 AUFS 类似的但性能更快, 实现更简单的现代联合文件系统, 已集成至 linux 3.8 以上版本的内核，是 Docker 推荐使用在生产环境的文件系统。</p>\n</blockquote>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 解压缩镜像内容\n### 确定镜像层顺序\n❯ cat alpine/manifest.json\n[\n  {\n    \"Config\": \"b86cef5f7cf032b9793fe2a4fb18ddf606df8ea9e41d4c2086749bf943c2985b.json\",\n    \"RepoTags\": [\n      \"alpine/git:v2.30.2\"\n    ],\n    \"Layers\": [\n      \"86f68eb8bb2057574a5385c9ce7528b70632e1c750fb36d5ac76c0a5460f5d95/layer.tar\",\n      \"09af0b97aec5975955488d528e8535d2678b75cb29adb6827abd85b52802d1b1/layer.tar\",\n      \"d8aa90f099f0f17f3ad894f0909e6bfd026cc4c76eec03e3e50391af42f41976/layer.tar\"\n    ]\n  }\n]\n\n### 创建镜像层解压缩的目录\n❯ mkdir -p /tmp/overlay/image/1 /tmp/overlay/image/2 /tmp/overlay/image/3 \n\n### 解压镜像层内容, 并按顺序进行编排\n❯ tar -C /tmp/overlay/image/1 -xf alpine/86f68eb8bb2057574a5385c9ce7528b70632e1c750fb36d5ac76c0a5460f5d95/layer.tar\n❯ tar -C /tmp/overlay/image/2 -xf alpine/09af0b97aec5975955488d528e8535d2678b75cb29adb6827abd85b52802d1b1/layer.tar \n❯ tar -C /tmp/overlay/image/3 -xf alpine/d8aa90f099f0f17f3ad894f0909e6bfd026cc4c76eec03e3e50391af42f41976/layer.tar\n\n## 构建 OverlayFS\n### 创建挂载点(空目录)\n❯ mkdir -p /tmp/overlay/container-a/merged /tmp/overlay/container-a/upperdir /tmp/overlay/container-a/workdir\n\n### 挂载镜像文件系统至 /tmp/overlay/container-a/merged 目录, 其中镜像的读写层内容存储在 /tmp/overlay/container-a/upperdir\n❯ cd /tmp/overlay/ &amp;&amp; \\\n  mount -t overlay overlay \\\n  -o lowerdir=image/1:image/2:image/3,upperdir=container-a/upperdir,workdir=container-a/workdir \\\n  /tmp/overlay/container-a/merged\n\n## 验证挂载记录\n❯ mount |grep overlay\noverlay on /tmp/overlay/container-a/merged type overlay (rw,relatime,lowerdir=image/1:image/2:image/3,upperdir=container-a/upperdir,workdir=container-a/workdir)\n\n## 验证读写层不会影响底层文件系统\n❯ echo \"1\" &gt; /tmp/overlay/container-a/merged/a\n\n## 只有读写层(upperdir)会被写入\n❯ cat /tmp/overlay/container-a/upperdir/a\n1\n\n## 底层文件系统(lowerdir)不会被修改\n❯ cat image/1/a\ncat: image/1/a: No such file or directory\n❯ cat image/2/a\ncat: image/2/a: No such file or directory\n❯ cat image/3/a\ncat: image/3/a: No such file or directory\n\n## 但是, 挂载后修改底层文件系统则会体现到挂载的联合文件系统之中\n❯ echo \"2\" &gt; image/1/b\n❯ cat container-a/merged/b\n2\n\n## 然后在 merged 层中删除 b, 再查看读写层的内容\n❯ rm container-a/merged/b &amp;&amp; ls -ahl container-a/upperdir\n总用量 8.0K\ndrwxr-xr-x 2 root root 4.0K 8月  12 17:08 .\ndrwxr-xr-x 5 root root 4.0K 8月  12 12:05 ..\nc</code></pre></div>",
      "image": "https://blog.shabbywu.cn/img/镜像层只读样例.png",
      "date_published": "2021-08-12T00:00:00.000Z",
      "date_modified": "2024-03-10T10:48:22.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "How To Build Images:手把手教你如何访问 Docker Registry",
      "url": "https://blog.shabbywu.cn/posts/2021/12/05/how-to-build-image-%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%A6%82%E4%BD%95%E6%8E%A8%E9%95%9C%E5%83%8F%E8%87%B3-docker-registry.html",
      "id": "https://blog.shabbywu.cn/posts/2021/12/05/how-to-build-image-%E6%89%8B%E6%8A%8A%E6%89%8B%E6%95%99%E4%BD%A0%E5%A6%82%E4%BD%95%E6%8E%A8%E9%95%9C%E5%83%8F%E8%87%B3-docker-registry.html",
      "summary": "前言 现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 docker run {your-image-name}，而构建镜像也就是执行一句 docker build dockerfile .的事情。 也许正是由于 Docker 对实现细节封装得过于彻底，有时候...",
      "content_html": "<h2>前言</h2>\n<p>现在是容器化时代，不管是开发、测试还是运维，很少有人会不知道或不会用 Docker。使用 Docker 也很简单，很多时候启动容器无非就是执行 <code>docker run {your-image-name}</code>，而构建镜像也就是执行一句 <code>docker build dockerfile .</code>的事情。<br>\n也许正是由于 <strong>Docker</strong> 对实现细节封装得过于彻底，有时候会觉得我们也许只是学会了<strong>如何使用<code>Docker CLI</code></strong> , 而并非明白 Docker 是如何运行的。<br>\n笔者将在『How To Build Images』系列文章讲述 <code>Docker build dockerfile .</code>相关的实现细节，本文是本系列的第三篇文章，将为各位介绍 Docker Daemon 与 Docker Registry 的交互流程和实现细节。</p>\n<h2>Docker Daemon 与 Docker Registry 的关系</h2>\n<p>我们平时使用的 <code>docker</code> 命令称之为 <code>Docker Cli</code>。<code>Docker Cli</code> 为用户提供了在命令行中操作镜像、容器、网络和数据卷的相关指令, 但事实上真正操作相应资源实体的进程是 <code>Docker Daemon</code>。<br>\n<code>Docker</code> 使用的是典型的 C/S 架构, <code>Docker Daemon</code> 则是后台常驻运行的服务端组件, 负责管理宿主机中的所有 Docker 资源以及与其他 Daemon 进行通讯。<br>\n<code>Docker Registry</code> 负责存储和分发 Docker 镜像。当我们调用 <code>docker pull</code> 和 <code>docker push</code> 时, <code>Docker Daemon</code> 将从 <code>Docker Registry</code> 提取镜像或推送镜像至 <code>Docker Registry</code>。\n<img src=\"/img/DockerCS架构.png\" alt=\"Docker architecture\" loading=\"lazy\"></p>\n<h2>Docker Daemon 拉取镜像的流程</h2>\n<p>正如前言, 当在命令行执行 <code>docker pull</code> 时, 实际上是让 <code>Docker Daemon</code> 往 <code>Docker Registry</code> 拉取所需的镜像。在笔者上一篇文章<a href=\"/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html#%E5%BD%92%E6%A1%A3%E5%B0%81%E5%8C%85\" target=\"_blank\">『从 0 开始带你徒手构建 Docker 镜像』</a>曾经展示过构建镜像的过程, 那么镜像是否就是一个包含了 <code>config.json(镜像配置)</code>, <code>manifest.json(镜像清单)</code>, <code>layer.tar(镜像层内容)</code> 的 Tar 归档包呢？</p>\n<p>答案是否定的, Docker Registry 在分发镜像时是按镜像层为单元进行分发, 而并非直接分发镜像本身。<br>\n但是这又引入了另一个问题, 在笔者的另一篇文章<a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『Docker 镜像规范 v1.2』</a>描述的镜像都是基于一定的文件目录结构编排的, 如果需要按镜像层进行分发, 那 <code>Docker Daemon</code> 是如何知道从哪里下载哪个镜像层呢？</p>\n<p>为了解决这个问题, 需要引入另一个概念, <code>Docker Image Manifest</code>。</p>\n<h3>Docker Image Manifest</h3>\n<p><code>Docker Image Manifest</code> 不同于 <code>manifest.json</code>, 前者是用于描述 <code>Docker Registry</code> 中的镜像的清单文件, 而后者是描述导出镜像中内容的清单文件。</p>\n<p>目前 Docker Registry 共支持两个不同格式的 <code>Docker Image Manifest</code>, 分别为 <a href=\"https://github.com/distribution/distribution/blob/main/docs/spec/manifest-v2-1.md\" target=\"_blank\" rel=\"noopener noreferrer\">Image Manifest Version 2, Schema 1</a> 和 <a href=\"https://github.com/distribution/distribution/blob/main/docs/spec/manifest-v2-2.md\" target=\"_blank\" rel=\"noopener noreferrer\">Image Manifest Version 2, Schema 2</a>。</p>\n<p>以下是 Schema 2 的清单样例:</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"schemaVersion\": 2,\n    \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n    \"config\": {\n        \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n        \"size\": 7023,\n        \"digest\": \"sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7\"\n    },\n    \"layers\": [\n        {\n            \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n            \"size\": 32654,\n            \"digest\": \"sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f\"\n        },\n        {\n            \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n            \"size\": 16724,\n            \"digest\": \"sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b\"\n        },\n        {\n            \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n            \"size\": 73109,\n            \"digest\": \"sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736\"\n        }\n    ]\n}\n</code></pre></div><div class=\"hint-container tip\">\n<p class=\"hint-container-title\">提示</p>\n<p>值得注意的是, <strong>application/vnd.docker.container.image.v1+json</strong> 对应的类型即是在<a href=\"/posts/2021/01/31/how-to-build-images-docker-%E9%95%9C%E5%83%8F%E8%A7%84%E8%8C%83.html\" target=\"_blank\">『Docker 镜像规范 v1.2』</a>中介绍的 <code>Config</code>(又被称之为 <code>Image JSON</code>)。</p>\n</div>\n<h3>流程总结</h3>\n<p><code>Docker Registry</code> 使用 <strong>数字摘要(digest)</strong> 定位镜像层和镜像配置等镜像内容, 而 <code>Docker Image Manifest</code> 又描述了镜像配置和镜像层的数字摘要以及相应的文件类型, 最后 <code>Docker Daemon</code> 只需要根据清单逐一下载解析即可。<br>\n简而言之, <code>docker pull</code> 的流程可概况如下:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/V4yrOkj04Ett56Qs-u_12uHRas0DGcCPPcPcPkRaCfwLT8kOmwbsP-PXfHppIR5uD8Bo7_Gxo0V2bAGJuf68pkFn_46_QmkRMXyLvJbTvdv8LzpKKmoMOr9QH_fxONXAcro0zc5oqmc3atyGaYY1yiLqlyb0hxPw1tV3wlOwMHefeE2qBGl1jCv65WvWuGIkp-1m1XPsbFaR61bGvZhAk0gxBiPIN0wvfpoCPs5szDlX82ktPF185yGC9STqcPj-zV6np86kJZYwTJYowSdAghOtKCHbvo8lIWMnhp4jKBfiB1ZgBrFLxlDSahPiaYDup1QX8ZA5Z5LS38jngeN5CUftGnWBsGCNn7IfPfB54KfdKvzdo48lcR-0ZRaF9JW-bkrmjfgzP-wwxEZd4bhdeF9uP0lm78qPJqZcEVkNTGfPl-My3CCtm6ZEB-1o0BeT6ES0\n\"></code></pre></div><h2>Docker Daemon 推送镜像的流程</h2>\n<p>推送镜像的工作流程与拉取镜像完全相反。<code>Docker Daemon</code> 首先创建镜像清单, 再需要将所有镜像层推送至 <code>Docker Registry</code>, 只有当所有镜像层完全推送至镜像仓库后, 再将镜像配置上传至镜像仓库, 最后才推送镜像清单, <code>docker push</code> 的流程可概况如下:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/N4v3WWD15DtNAM8d-p4lCDwEkX0qmzNOjctRkan-TVeKOwy-7WkrQ9qQZKN2w7r1sOrIGqXGJN9CewP08923iai_FgLrGaGIJT0z1tUtniGyj0tnwmuOcCX1I1LaeIvgokRzFSRABTmEyS5jexdbWmKdhQyKXaskJwToWJ0jggPCVFe8XZVXwXGEfc5-Mv-xXk5-VRRzMrVWDaMf89fShpGtoavVzwRlbxzHwEo0mvjCrvdfdt4E4iAQWAn5OfGfCQ66igYGNH5YyYiWDZByrJT1MLWmkJ9Fqto-Xy-Tm_hiltHHYffS_7Jb5K_VThhHtmv6nu3-SEXga4JCI22eIQBxPTXSWz2423IiwefByWku781E1CO70000\n\"></code></pre></div><h2>流程之外但必不可少的步骤: 用户认证</h2>\n<p>到目前为止, 我们已经完整展示了拉取镜像和推送镜像的操作流程, 但是还有一个至关重要的步骤还未介绍, 那就是<strong>用户认证</strong>。<br>\nDocker Registry 采用中央认证服务实现用户身份认证, 具体的认证流程如下所示:\n<img src=\"/img/Docker-Registry-v2-auth-via-central-service.png\" alt=\"v2-auth-via-central-service\" loading=\"lazy\"></p>\n<ol>\n<li><code>Docker Daemon</code> 尝试进行 pull/push 操作</li>\n<li>如果 <code>Docker Registry</code> 需要进行用户认证, 那么就应该返回 <code>HTTP 401 Unauthorized</code> 的响应, 并在返回头里描述如何进行用户认证(基于 WWW-Authenticate 协议)</li>\n<li><code>Docker Daemon</code> 向中央认证服务进行用户认证</li>\n<li>中央认证服务向 <code>Docker Daemon</code> 返回一个 <code>Bearer token</code>, 代表用户的身份</li>\n<li><code>Docker Daemon</code> 重试 <strong>步骤1</strong> 中发送的请求, 并在请求头中带上 <strong>步骤4</strong> 中返回的 <code>Bearer token</code></li>\n<li><code>Docker Registry</code> 认证请求头中附带的 <code>Bearer token</code>, 验证通过后即可正常相应</li>\n</ol>\n<h2>小试牛刀</h2>\n<p>在笔者上一篇文章<a href=\"/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html#%E5%BD%92%E6%A1%A3%E5%B0%81%E5%8C%85\" target=\"_blank\">『从 0 开始带你徒手构建 Docker 镜像』</a>曾经构建了一个可运行的镜像, 现在我们尝试将该镜像推送至官方的 Docker Registry -- DockerHub。</p>\n<h3>1. 创建镜像清单(Docker Image Manifest)</h3>\n<p>重新根据上一篇文章记载的流程构建这个镜像, 并在计算 <code>镜像配置(config.json)</code> 与 <code>镜像层 (layer.tar)</code> 的 sha256 数字摘要后, 即可编写<code>镜像清单(Docker Image Manifest)</code>, 得如下所示的 JSON 文件:</p>\n<div class=\"language-json\" data-ext=\"json\" data-title=\"json\"><pre class=\"language-json\"><code>{\n    \"schemaVersion\": 2,\n    \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n    \"config\": {\n        \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n        \"size\": 546,\n        \"digest\": \"sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\"\n    },\n    \"layers\": [\n        {\n            \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n            \"size\": 10240,\n            \"digest\": \"sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\"\n        }\n    ]\n}\n</code></pre></div><h3>2. 上传镜像层与镜像配置至 Docker Registry</h3>\n<p>根据<a href=\"https://github.com/distribution/distribution/blob/main/docs/spec/api.md#monolithic-upload\" target=\"_blank\" rel=\"noopener noreferrer\">接口文档</a>, 我们采用整体上传的方式将镜像层与镜像配置推送至 Docker Registry。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 涉及的环境变量:\n## - your_username: DockerHub 的账号\n## - your_password: DockerHub 账号的密码\n## - your_token: 认证接口返回的 token 或 access_token\n\n## 【用户认证】发起上传镜像配置操作\n➜ curl -X POST \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/uploads/\" -v\n\n## 返回 401 Unauthorized\n## &lt; HTTP/1.1 401 Unauthorized\n## &lt; docker-distribution-api-version: registry/2.0\n## &lt; www-authenticate: Bearer realm=\"https://auth.docker.io/token\",service=\"registry.docker.io\",scope=\"repository:${your_username}/runc-hello:pull,push\"\n\n## 【用户认证】进行用户认证\n➜ curl -u \"${your_username}:${your_password}\" \"https://auth.docker.io/token?service=registry.docker.io&amp;scope=repository:${your_username}/runc-hello:pull,push\"\n## {\n##  \"token\": \"...\",\n##  \"access_token\": \"...\",\n##  \"expires_in\": 300,\n##  \"issued_at\": \"2021-12-07T01:50:05.654533932Z\"\n## }\n\n## 【上传镜像配置】重新发起上传镜像配置操作\n➜ curl -H \"Authorization: Bearer ${your_token}\" -X POST \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/uploads/\" -v\n## &lt; HTTP/1.1 202 Accepted\n## &lt; content-length: 0\n## &lt; docker-distribution-api-version: registry/2.0\n## &lt; docker-upload-uuid: 53231064-74b5-48d5-8cbd-5f810fa99a0c\n## &lt; location: https://registry.hub.docker.com/v2/435495971/runc-hello/blobs/uploads/53231064-74b5-48d5-8cbd-5f810fa99a0c?_state=S8Kt2Fx6i-CX-C7j4kS9RahBhxtS5BySKuJoaKup6QJ7Ik5hbWUiOiI0MzU0OTU5NzEvcnVuYy1oZWxsbyIsIlVVSUQiOiI1MzIzMTA2NC03NGI1LTQ4ZDUtOGNiZC01ZjgxMGZhOTlhMGMiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjEtMTItMDdUMDI6NDE6MjEuODgwMDcwOTI5WiJ9\n\n## 【上传镜像配置】开始上传镜像配置内容\n➜ curl -H \"Authorization: Bearer ${your_token}\" -X PUT \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/uploads/53231064-74b5-48d5-8cbd-5f810fa99a0c?_state=S8Kt2Fx6i-CX-C7j4kS9RahBhxtS5BySKuJoaKup6QJ7Ik5hbWUiOiI0MzU0OTU5NzEvcnVuYy1oZWxsbyIsIlVVSUQiOiI1MzIzMTA2NC03NGI1LTQ4ZDUtOGNiZC01ZjgxMGZhOTlhMGMiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjEtMTItMDdUMDI6NDE6MjEuODgwMDcwOTI5WiJ9&amp;digest=sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\" --upload-file config.json -v\n## 上传成功, 返回 201\n## &lt; HTTP/1.1 201 Created\n## &lt; content-length: 0\n## &lt; docker-content-digest: sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\n\n\n## 【上传镜像层】发起上传镜像层操作\n➜ curl -H \"Authorization: Bearer ${your_token}\" -X POST \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/uploads/\" -v\n## &lt; HTTP/1.1 202 Accepted\n## &lt; content-length: 0\n## &lt; docker-distribution-api-version: registry/2.0\n## &lt; docker-upload-uuid: 34efca43-27ed-4806-a74e-6cbea2d222f2\n## &lt; location: https://registry.hub.docker.com/v2/435495971/runc-hello/blobs/uploads/34efca43-27ed-4806-a74e-6cbea2d222f2?_state=O7lkfqKiEF-Ryqhms-_CnCsmd76kDtt_HjuprAebwJN7Ik5hbWUiOiI0MzU0OTU5NzEvcnVuYy1oZWxsbyIsIlVVSUQiOiIzNGVmY2E0My0yN2VkLTQ4MDYtYTc0ZS02Y2JlYTJkMjIyZjIiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjEtMTItMDdUMDI6NDY6MzEuNTY2ODMwNjI3WiJ9\n\n## 【上传镜像层】开始上传镜像层内容\n➜ curl -H \"Authorization: Bearer ${your_token}\" -X PUT \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/uploads/34efca43-27ed-4806-a74e-6cbea2d222f2?_state=O7lkfqKiEF-Ryqhms-_CnCsmd76kDtt_HjuprAebwJN7Ik5hbWUiOiI0MzU0OTU5NzEvcnVuYy1oZWxsbyIsIlVVSUQiOiIzNGVmY2E0My0yN2VkLTQ4MDYtYTc0ZS02Y2JlYTJkMjIyZjIiLCJPZmZzZXQiOjAsIlN0YXJ0ZWRBdCI6IjIwMjEtMTItMDdUMDI6NDY6MzEuNTY2ODMwNjI3WiJ9&amp;digest=sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\" --upload-file layer.tar -v\n## 上传成功, 返回 201\n## &lt; HTTP/1.1 201 Created\n## &lt; content-length: 0\n## &lt; docker-content-digest: sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\n## &lt; docker-distribution-api-version: registry/2.0\n## &lt; location: https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\n</code></pre></div><h3>3. 上传镜像清单</h3>\n<p>Docker 官方文档里的样例使用的是 Manifest Schema 1, 包含的内容很复杂, 但事实上用 Schema 2 也同样能创建镜像清单。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>➜ curl -H \"Authorization: Bearer ${your_token}\" -X PUT \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/manifests/latest\" -H \"Content-Type: application/vnd.docker.distribution.manifest.v2+json\" -v -d '{\n    \"schemaVersion\": 2,\n    \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n    \"config\": {\n        \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n        \"size\": 546,\n        \"digest\": \"sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\"\n    },\n    \"layers\": [\n        {\n            \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n            \"size\": 10240,\n            \"digest\": \"sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\"\n        }\n    ]\n}'\n## 上传成功, 返回 201\n## &lt; HTTP/1.1 201 Created\n## &lt; docker-content-digest: sha256:c4c42af74cf13c704100d9a7583d106d90f737ffb7dc12593022884986fc41dc\n## &lt; docker-distribution-api-version: registry/2.0\n## &lt; location: https://registry.hub.docker.com/v2/${your_username}/runc-hello/manifests/sha256:c4c42af74cf13c704100d9a7583d106d90f737ffb7dc12593022884986fc41dc\n</code></pre></div><h3>4. 验证</h3>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>➜ docker pull 435495971/runc-hello:latest\n## latest: Pulling from 435495971/runc-hello\n## cc668e407245: Pull complete\n## Digest: sha256:c4c42af74cf13c704100d9a7583d106d90f737ffb7dc12593022884986fc41dc\n## Status: Downloaded newer image for 435495971/runc-hello:latest\n## docker.io/435495971/runc-hello:latest\n\n➜ docker run --rm 435495971/runc-hello:latest\nHello runc!\n</code></pre></div><h2>总结</h2>\n<p>这篇文章是『How To Build Images』系列的第三篇，首先介绍了 Docker Daemon 与 Docker Registry 之间的关系, 再详细介绍了 <code>docker pull</code> 与 <code>docker push</code> 背后隐藏的操作细节, 最后以上一篇文章<a href=\"/posts/2021/04/01/how-to-build-image-%E4%BB%8E-0-%E5%BC%80%E5%A7%8B%E5%B8%A6%E4%BD%A0%E5%BE%92%E6%89%8B%E6%9E%84%E5%BB%BA-docker-%E9%95%9C%E5%83%8F.html#%E5%BD%92%E6%A1%A3%E5%B0%81%E5%8C%85\" target=\"_blank\">『从 0 开始带你徒手构建 Docker 镜像』</a>曾经构建了一个可运行的镜像为例子, 完整演示了推送镜像至 Dockerhub 的步骤。到目前为止, 我们已经掌握了镜像分发和上传的基本知识, 本系列的下一篇文章将为大家剖析 <code>docker build dockerfile .</code> 背后被隐藏的细节, 同时也将介绍 Google 提出的一个在容器内构建镜像的方案(kaniko)。</p>\n<h2>附录</h2>\n<h3>手把手教你从 Docker Registry 拉取镜像</h3>\n<p>由于篇幅问题, 正文的「小试牛刀」环节只展示了推送镜像的操作, 在这里继续介绍 ”拉取镜像“ 涉及的操作。</p>\n<h4>1. 下载镜像清单</h4>\n<p>在下载镜像清单时, Docker Registry 默认返回的是 <code>Schema 1</code>, 如果希望接收 <code>Schema 2</code> 版本的 Manifest, 则需要指定 <code>Accept: application/vnd.docker.distribution.manifest.v2+json</code>。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>➜ curl -H \"Accept: application/vnd.docker.distribution.manifest.v2+json\" -H \"Authorization: Bearer ${your_token}\" \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/manifests/latest\"\n## {\n##     \"schemaVersion\": 2,\n##     \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n##     \"config\": {\n##         \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n##         \"size\": 546,\n##         \"digest\": \"sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\"\n##     },\n##     \"layers\": [\n##         {\n##             \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n##             \"size\": 10240,\n##             \"digest\": \"sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\"\n##         }\n##     ]\n## }\n</code></pre></div><h4>2. 下载镜像配置与镜像层</h4>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 下载镜像配置\n➜ curl -H \"Authorization: Bearer ${your_token}\" \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\" -o 2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5 -L\n\n## 验证镜像配置\n➜ cat 2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\n{\"architecture\":\"amd64\",\"config\":{\"User\":\"\",\"Tty\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/hello\"],\"Volumes\":null,\"WorkingDir\":\"/\",\"Entrypoint\":null,\"Labels\":null},\"created\":\"1970-01-01T00:00:00.0Z\",\"docker_version\":\"20.10.5\",\"history\":[{\"created\":\"1970-01-01T00:00:00.0Z\",\"created_by\":\"nasm hello.nasm -f elf64 -o hello.o &amp;&amp; ld hello.o -o hello &amp;&amp; cp hello /hello\"}],\"os\":\"linux\",\"rootfs\":{\"type\":\"layers\",\"diff_ids\":[\"sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\"]}}\n\n## 下载镜像层\n➜ curl -H \"Authorization: Bearer ${your_token}\" \"https://registry.hub.docker.com/v2/${your_username}/runc-hello/blobs/sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\" -o cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed -L\n\n## 验证镜像层\n➜ tar -tf cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\nhello\n</code></pre></div><h4>3. 存储镜像至特定目录</h4>\n<p>虽然 Docker Registry 的接口简单, 但是 Docker Daemon 本身还需要将对应的文件存储到特定的目录, 具体的流程包括:</p>\n<ul>\n<li>存储镜像配置至 <code>graph</code> 目录下的 <code>image/${storage_driver}/imagedb/content/sha256/</code></li>\n<li>解压镜像层内容至 <code>graph</code> 目录下的 <code>${storage_driver}/${cache_id}</code> 和</li>\n<li>存储镜像层记录至 <code>graph</code> 目录下的 <code>image/${storage_driver}/layerdb/content/sha256/</code></li>\n<li>记录镜像与标签的关联关系至 <code>graph</code> 目录下的 <code>image/${storage_driver}/repositories.json</code></li>\n</ul>\n<p>以下演示对应的操作:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 提取当前的 graph 路径\n➜ graph=`docker info|grep -Eo \"Docker Root Dir: .*\" | sed -r \"s/Docker Root Dir: (.*)/\\1/g\"`\n\n## 提取 Storage Driver 类型\n➜ storage_driver=`docker info|grep -Eo \"Storage Driver: .*\" | sed -r \"s/Storage Driver: (.*)/\\1/g\"`\n\n## 存储镜像配置\n➜ cp 2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5 \"${graph}/image/${storage_driver}/imagedb/content/sha256/2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5\"\n\n## 构建镜像层内容\n## 1. 生成随机 cache-id\n➜ cache_id=`cat /proc/sys/kernel/random/uuid | md5sum | awk '{print $1}'`\n## 2. 创建映射目录\n➜ mkdir -p \"${graph}/${storage_driver}/${cache_id}\"\n➜ touch \"${graph}/${storage_driver}/${cache_id}/committed\"\n➜ mkdir \"${graph}/${storage_driver}/${cache_id}/diff\"\n## 3. 解压镜像层至 diff 目录\n➜ tar -xf cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed -C \"${graph}/${storage_driver}/${cache_id}/diff\"\n## 4. 生成短 ID (26位长)\n➜ lid=`cat /proc/sys/kernel/random/uuid | md5sum | awk '{print substr($1,0,27)}'`\n## 5. 创建层映射\n➜ ln -s \"../${cache_id}/diff\" \"${graph}/${storage_driver}/l/${lid}\" \n## 6. 记录短映射ID\n➜ echo -n \"$lid\" &gt; \"${graph}/${storage_driver}/${cache_id}/link\"\n\n\n## 构建镜像层内容(索引)\n➜ mkdir -p \"${graph}/image/${storage_driver}/layerdb/sha256/cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\"\n## 1. 记录 diff-id\n➜ echo -n \"sha256:cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed\" &gt; \"${graph}/image/${storage_driver}/layerdb/sha256/cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed/diff\"\n## 2. 记录镜像层大小\n➜ echo -n `stat \"${graph}/${storage_driver}/${cache_id}/diff/hello\" --printf '%s'` &gt; \"${graph}/image/${storage_driver}/layerdb/sha256/cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed/size\"\n## 3. 记录短映射 ID\n➜ echo -n \"${cache_id}\" &gt; \"${graph}/image/${storage_driver}/layerdb/sha256/cc668e407245ebdacbb7ac6d5ead798556adb5aebfcdd7fa2ca777bed3a83fed/cache-id\"\n\n## 记录镜像索引\n➜ python -c \"import json;fh=open('${graph}/image/${storage_driver}/repositories.json');repositories=json.load(fh);repositories['Repositories']['hello-runc']={'hello-runc:latest': 'sha256:2bd297f395ef7193402fbf58b1010655c7bf27b22c38545a63c71af402f73dc5'};print(repositories);fh=open('${graph}/image/${storage_driver}/repositories.json', mode='w');json.dump(repositories, fh);\"\n</code></pre></div><h4>4. 验证</h4>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 只有重启 Docker Daemon 进程, 镜像才会被正确识别。\n➜ docker images\nREPOSITORY   TAG       IMAGE ID   CREATED   SIZE\n\n## 重启 Docker Daemon\n➜ systemctl restart docker\n\n➜ docker images\nREPOSITORY   TAG       IMAGE ID       CREATED        SIZE\nhello-runc   latest    2bd297f395ef   51 years ago   1.02kB\n\n➜ docker run --rm hello-runc\nHello runc!\n</code></pre></div>",
      "image": "https://blog.shabbywu.cn/img/DockerCS架构.png",
      "date_published": "2021-12-05T00:00:00.000Z",
      "date_modified": "2024-03-10T11:33:17.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "Webassembly - 会是下一代的容器运行时吗?",
      "url": "https://blog.shabbywu.cn/posts/2023/01/08/wasm-or-container.html",
      "id": "https://blog.shabbywu.cn/posts/2023/01/08/wasm-or-container.html",
      "summary": "前言 2013年3月20日, DotCloud 发布了 Docker 的首个版本, 从此开启了容器化时代的序幕。现在是容器化时代, 不管是开发、测试还是运维, 很少有人会不知道或不会用 Docker。自 Docker 发布至今的 10年内, 开源和社区共建让容器化技术如日中天。尽管容器化产品迭代迅速, 但是容器技术的核心却一直围绕着 Linux, 每当...",
      "content_html": "<h2>前言</h2>\n<p>2013年3月20日, DotCloud 发布了 Docker 的首个版本, 从此开启了容器化时代的序幕。现在是容器化时代, 不管是开发、测试还是运维, 很少有人会不知道或不会用 Docker。自 Docker 发布至今的 10年内, 开源和社区共建让容器化技术如日中天。尽管容器化产品迭代迅速, 但是容器技术的核心却一直围绕着 Linux, 每当我们提及容器时, 实际上我们指代的往往是基于 Linux Kernel 的运行时实现。\n时至今日, 除了 Linux 容器以外还有很多容器运行时实现, 例如 <a href=\"https://github.com/kata-containers/kata-containers\" target=\"_blank\" rel=\"noopener noreferrer\">Kata Containers</a> 和 <a href=\"https://github.com/google/gvisor\" target=\"_blank\" rel=\"noopener noreferrer\">gVisor</a>, 那究竟谁会是下一代运行时实现呢？-- 很可能是 Webassembly。</p>\n<p>这篇文章会介绍什么是 WebAssembly, 为什么它有成为下一代运行时实现的潜力, 并演示 WebAssembly 容器与常规的 Linux 容器的差异。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">延伸阅读: 什么是容器？</p>\n<p><strong>容器镜像</strong>是一个轻量级的、独立的、可执行的<strong>软件包</strong>, 只要<strong>应用程序</strong>打包成容器镜像交付, 无论在何种基础架构(Linux 或 Windows; ARM 或 X86), 它们都将始终以相同的方式运行。</p>\n<p><strong>容器</strong>提供一种可以快速且可靠地将<strong>应用程序</strong>从一个计算环境运行到另一个计算环境的技术, 容器是软件即服务(Software as a service, SaaS)。</p>\n</div>\n<h2>什么是 Webassembly (aka Wasm)</h2>\n<p>WebAssembly 是一种安全的、可移植的、低级别的(类似于汇编)的编程语言(或者说是二进制指令格式, 类似于汇编), 需要在基于堆栈的虚拟机中执行。\nWasm 被设计为编程语言的可移植编译目标, 主要目标是在 Web 上实现高性能的应用。</p>\n<h2>Hello Wasm</h2>\n<p>我们通过简单的 Hello World Demo 快速认识什么是 Wasm 程序。</p>\n<h3>源语言 Rust</h3>\n<p>Wasm 是编程语言的可移植编译目标, 因此需要从另一种语言编译生成, 常见的源语言是 Rust, 以下是一个最简单的基于 Rust 的 Hello World 样例代码:</p>\n<div class=\"language-rust\" data-ext=\"rs\" data-title=\"rs\"><pre class=\"language-rust\"><code>// file: hello.rs\nfn main() {\n  println!(\"Hello Wasm\");\n}\n</code></pre></div><p>由于 Rust 的规则, 还需要编写 Cargo.toml 才能编译代码。</p>\n<div class=\"language-toml\" data-ext=\"toml\" data-title=\"toml\"><pre class=\"language-toml\"><code>## file: Cargo.toml\n[package]\nname = \"hello\"\nversion = \"0.0.1\"\n\n[[bin]]\nname = \"hello\"\npath = \"hello.rs\"\n\n[dependencies]\n</code></pre></div><p>测试运行 hello.rs</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>❯ cargo run\n   Compiling hello v0.0.1\n    Finished dev [unoptimized + debuginfo] target(s) in 0.26s\n     Running `target/debug/hello`\nHello Wasm\n</code></pre></div><h3>编译 Wasm</h3>\n<p>默认情况下, Rust 会被编译成可执行文件, 我们需要指定额外的编译参数才能编译得到 Wasm</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 安装编译依赖\n❯ rustup target add wasm32-wasi\ninfo: downloading component 'rust-std' for 'wasm32-wasi'\ninfo: installing component 'rust-std' for 'wasm32-wasi'\n## 编译成 Wasm\n❯ rustc hello.rs --target wasm32-wasi\n## 编译生成 hello.wasm\n❯ ls -lah hello.wasm\n-rwxr-xr-x  1 shabbywu  staff   2.1M  1  8 16:04 hello.wasm\n</code></pre></div><h3>执行</h3>\n<p>WebAssembly 是一种用于基于堆栈的虚拟机的二进制指令格式, 需要使用 WebAssembly 虚拟机才能执行 Wasm。常见的主要浏览器引擎(如 Chrome, Edge, Firefox 和 Safari)均支持执行 Wasm, 但想要在终端执行则需要先安装 Wasm 运行时, 以下是目前流行的 Wasm 运行时实现:</p>\n<ul>\n<li><a href=\"https://wasmtime.dev/\" target=\"_blank\" rel=\"noopener noreferrer\">Wasmtime</a>, 是由<a href=\"https://bytecodealliance.org/\" target=\"_blank\" rel=\"noopener noreferrer\">字节码联盟(Bytecode Alliance)</a>开发的快速, 安全的 WebAssembly 运行时。</li>\n<li><a href=\"https://github.com/bytecodealliance/wasm-micro-runtime\" target=\"_blank\" rel=\"noopener noreferrer\">WAMR</a>, 是由<a href=\"https://bytecodealliance.org/\" target=\"_blank\" rel=\"noopener noreferrer\">字节码联盟(Bytecode Alliance)</a>开发的 WebAssembly 轻量级运行时, 适用于嵌入式、物联网、边缘计算、智能设备等场景。</li>\n<li><a href=\"https://wasmer.io/\" target=\"_blank\" rel=\"noopener noreferrer\">Wasmer</a> 提供基于 WebAssembly 的超轻量级容器,其可以在任何地方运行：从桌面到云、以及 IoT 设备, 并且也能嵌入到 任何编程语言中。</li>\n<li><a href=\"https://github.com/wasm3/wasm3\" target=\"_blank\" rel=\"noopener noreferrer\">Wasm3</a> 是最快 WebAssembly <strong>解释器</strong>, 也是最通用的 Wasm 运行时。</li>\n<li><a href=\"https://wasmedge.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WasmEdge</a> 是一种轻量级、高性能且可扩展的 WebAssembly 运行时, 适用于云原生、边缘和去中心化应用程序。 它为无服务器应用程序、嵌入式功能、微服务、智能合约和物联网设备提供支持。</li>\n</ul>\n<p>我们选用 Star 数最多的 Wasmer 演示执行 Wasm:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 安装 Wasmer\n❯ curl https://get.wasmer.io -sSfL | sh\n## 执行 hello.wasm\n❯ wasmer run hello.wasm\nHello Wasm\n</code></pre></div><h2>为什么说 WebAssembly 具有成为下一代运行时实现的潜力？</h2>\n<p>Wasm 的特性让它充满无限可能:</p>\n<ul>\n<li><strong>标准</strong> —— Wasm 被设计成无版本、特性可测试、向后兼容的, 主流浏览器均已实现初版 Wasm 规范。</li>\n<li><strong>快速</strong> —— 它可以通过大多数运行时的 JIT/AOT 能力提供类似原生的速度。 与启动 VM 或启动容器不同的是, 它没有冷启动。</li>\n<li><strong>安全</strong> —— 默认情况下, Wasm 运行时是沙箱化的, 允许安全访问内存。基于能力的模型确保 Wasm 应用程序只能访问得到明确允许的内容。软件供应链更加安全。</li>\n<li><strong>可移植</strong> —— Wasm 的二进制格式是被设计成可在不同操作系统(目前支持 Linux、Windows、macOS、Android、甚至是嵌入式设备)与指令集（目前支持 x86、ARM、RISC-V等）上高效执行的。</li>\n<li><strong>高性能</strong> —— Wasm 只需极小的内存占用和超低的 CPU 门槛就能运行。</li>\n<li>️<strong>支持多语言</strong> —— <a href=\"https://github.com/appcypher/awesome-wasm-langs\" target=\"_blank\" rel=\"noopener noreferrer\">多种编程语言</a>可以编译成 Wasm。</li>\n</ul>\n<h3>WebAssembly 正从浏览器走向服务端</h3>\n<p>WebAssembly 起源于浏览器, 最初主要用于补齐 JavaScript 在执行性能方面的短板, 但 Wasm 并非为了取代 JavaScript, 而是希望提供一种在浏览器(沙盒环境)执行大型应用程序的能力。\nWasm 依赖虚拟机执行, 而浏览器引擎能运行 Wasm 程序是因为浏览器引起集成了 Wasm 虚拟机。如果将 Wasm 虚拟机剥离出来单独运行, 那我们就可以在浏览器之外的地方执行 Wasm 程序。与浏览器执行环境不同, 服务端程序需要与外部环境(如文件系统、网络等)交互, 由于 Wasm 设计上是在安全沙箱执行的语言, 与外部环境交互将引入潜在的安全风险，因此 Wasm 提出了 <a href=\"https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md\" target=\"_blank\" rel=\"noopener noreferrer\">WASI(WebAssembly System Interface)</a> 描述了 Wasm 程序支持的操作接口。</p>\n<blockquote>\n<p>WASI 由 Wasm 运行时实现, 例如 <a href=\"https://github.com/bytecodealliance/wasmtime/blob/main/crates/wasi-common/src/snapshots/preview_1.rs#L596\" target=\"_blank\" rel=\"noopener noreferrer\">fd_readdir</a> 是 <a href=\"https://wasmtime.dev/\" target=\"_blank\" rel=\"noopener noreferrer\">Wasmtime</a> 的读取目录接口的实现。</p>\n</blockquote>\n<p>作为开发者并不需要关心 Wasm 虚拟机的具体实现, 只需要将应用程序编译为 Wasm 二进制指令即可在任意服务器上执行。\n<img src=\"/img/Wasm-work-on-servers.png\" alt=\"Wasm执行在服务端的原理\" loading=\"lazy\"></p>\n<h3>WebAssembly 对软件交付的影响</h3>\n<p>在容器化时代, 容器已成为软件交付的事实标准，基本上所有软件均提供了「容器」部署的方案。\n为了统一容器的生命周期管理和交付介质，Open Container Initiative(OCI)提出了<a href=\"https://github.com/opencontainers/runtime-spec/blob/main/principles.md\" target=\"_blank\" rel=\"noopener noreferrer\">5点标准容器需要符合的原则</a>, 而 WebAssembly 基本符合这些原则:</p>\n<ul>\n<li>Standard operations(标准操作): Wasm 定义了 main 函数作为主入口, Wasm 虚拟机执行 main 函数即可启动 Wasm 程序。</li>\n<li>Content-agnostic(与内容无关): Wasm 编译后以二进制文件交付, 天然与内容无关。</li>\n<li>Infrastructure-agnostic(与基础设施无关): Wasm 依赖基于堆栈的虚拟机, 而虚拟机实现不依赖基础设施。</li>\n<li>Industrial-grade delivery(工业级交付): Wasm 一次编译, 到处执行。Wasm 无需关心软件交付的问题。</li>\n<li>❌ Designed for automation(为自动化而设计): Wasm 并不关心自动化部署的事宜, 但这不影响 Wasm 容器化，只是目前仍然缺乏标准流程和工具链(类似于 Dockerfile 和 Docker Cli)。</li>\n</ul>\n<p>WebAssembly 的特性让它天生支持容器化，<em>如果应用程序都编译成 Wasm 交付</em>, 那意味着我们只需要完成一系列的封装操作，即可将 Wasm 程序自动化部署至所有服务器。为此, Solomon Hykes(Docker创始人)甚至提出 WASM+WASI 将是服务器软件基础设施的下一个发展方向。</p>\n<div class=\"hint-container warning\">\n<p class=\"hint-container-title\">[Solomon Hykes(Docker创始人)的推文]((https://twitter.com/solomonstre/status/1111004913222324225))</p>\n<p>\"If WASM+WASI existed in 2008, we wouldn't have needed to created Docker. That's how important it is. Webassembly on the server is the future of computing. A standardized system interface was the missing link. Let's hope WASI is up to the task!\" -- Solomon Hykes, creator of Docker</p>\n</div>\n<p>确实, 如果操作系统集成了 Wasm 虚拟机(就像浏览器一样), 同时<em>如果应用程序都编译成 Wasm</em>, 那么我们根本不需要 \"Linux 容器\", 不需要虚拟一层完整的 Linux 操作系统, 只需要 Wasm 虚拟机, 即可完成 Wasm 程序的\"容器化部署\"。</p>\n<h2>容器化 WebAssembly</h2>\n<p>Docker 在 2022 年 10 月 24 日宣布将在 Docker Desktop 4.15 以 Beta 特性支持运行 Wasm 容器！正如前文所言, Wasm 是一个更快、更轻量的 Linux/Windows 容器的替代品。这一节将演示 Wasm 容器与常规的 Linux 容器的差异，包括构建 Wasm 镜像、运行 Wasm 容器和原生执行的对比。\n<img src=\"/img/Docker+Wasm.png\" alt=\"Docker+Wasm\" loading=\"lazy\"></p>\n<h3>构建并运行 Wasm 镜像</h3>\n<p>我们知道, 对于编译型语言最终生成的是 .wasm 文件, 编译镜像无任何技术含量。为了提高挑战性, 我们使用解释型语言 <a href=\"https://github.com/python/cpython\" target=\"_blank\" rel=\"noopener noreferrer\">CPython</a> 完成这一节的演示。</p>\n<p>与 C 和 Rust 等编译型语言不同, 对于 Python、Ruby 等解释型语言, 我们需要将它们的解释器编译成 Wasm。一旦将解释器编译为 Wasm, 任何 Wasm 虚拟机都能够运行这些解释型语言。</p>\n<p>理论如此, 但由于 WASI 并未提供完整的 POISX 兼容, 在编译 CPython 时需要修改部分源码, 开源项目 <a href=\"https://github.com/singlestore-labs/python-wasi.git\" target=\"_blank\" rel=\"noopener noreferrer\">python-wasi</a> 已完成了这个实验, 借助该项目即可将 CPython 编译成 Wasm。</p>\n<h4>0. 整理项目结构</h4>\n<p>为了方便描述, 我们假设项目结构符合以下目录树, 具体内容见上文。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── build.sh\n└── src\n    ├── main.py\n    |── [cpython](https://github.com/python/cpython/archive/refs/tags/v3.11.1.tar.gz)\n    └── [python-wasi](https://github.com/singlestore-labs/python-wasi)\n</code></pre></div><p>其中, main.py 内容如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import os\n## 打印环境变量, 测试安全性\nfor k, v in os.environ.items():\n  print(f\"{k}={v}\")\n\nprint(\"</code></pre></div>",
      "image": "https://blog.shabbywu.cn/img/Wasm-work-on-servers.png",
      "date_published": "2023-01-08T00:00:00.000Z",
      "date_modified": "2024-03-10T12:21:46.000Z",
      "authors": [],
      "tags": [
        "容器技术"
      ]
    },
    {
      "title": "Python异步初探-协程的定义",
      "url": "https://blog.shabbywu.cn/posts/2019/10/14/python%E5%BC%82%E6%AD%A5%E5%88%9D%E6%8E%A2-%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%AE%9A%E4%B9%89.html",
      "id": "https://blog.shabbywu.cn/posts/2019/10/14/python%E5%BC%82%E6%AD%A5%E5%88%9D%E6%8E%A2-%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%AE%9A%E4%B9%89.html",
      "summary": "序言 由于很多文章对协程的介绍都很精简，在探讨Python异步编程之前，我们先来明确协程的概念，避免以后混淆了异步和协程的概念。 什么是协程 协程，又称微线程，英文名为 Coroutine。 协程(Coroutine)的概念提出的很早，在操作系统层面上，与它关联的是线程(Thread), 进程(Process)。从设计理念出发，协程、线程和进程都是为了...",
      "content_html": "<h2>序言</h2>\n<p>由于很多文章对协程的介绍都很精简，在探讨Python异步编程之前，我们先来明确协程的概念，避免以后混淆了<strong>异步</strong>和<strong>协程</strong>的概念。</p>\n<h3>什么是协程</h3>\n<blockquote>\n<p>协程，又称微线程，英文名为 Coroutine。</p>\n</blockquote>\n<p><strong>协程(Coroutine)<strong>的概念提出的很早，在操作系统层面上，与它关联的是</strong>线程(Thread)</strong>, <strong>进程(Process)</strong>。从设计理念出发，<strong>协程</strong>、<strong>线程</strong>和<strong>进程</strong>都是为了更好的分配和利用CPU和内存资源。<br>\n在早期的操作系统中，<strong>进程</strong> 是程序执行的基本实体，随着支持多线程CPU的出现，程序执行的基本实体被 <strong>线程</strong> 所取代，但资源的分配单位仍然为<strong>进程</strong>。<br>\n一般而言，<strong>进程</strong> 是操作系统进行资源分配和调度的单位，<strong>线程</strong> 是操作系统进行CPU分配和调度的单位，而 <strong>协程</strong> 则隶属于 <strong>线程</strong> ，由用户自主控制任务的调度, 而非依赖操作系统的抢占式机制。</p>\n<div class=\"language-text\" data-ext=\"text\" data-title=\"text\"><pre class=\"language-text\"><code>+</code></pre></div>",
      "date_published": "2019-10-14T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "drf-yasg:一款自动生成API文档的工具介绍",
      "url": "https://blog.shabbywu.cn/posts/2020/04/15/python-auto-doc-for-drf.html",
      "id": "https://blog.shabbywu.cn/posts/2020/04/15/python-auto-doc-for-drf.html",
      "summary": "自动化生成文档的工具有很多, 这里介绍的是一款基于 Swagger/OpenAPI 2.0 规范的 API 文档自动化生成工具: drf-yasg。 提示 如果你不清楚什么是 Swagger/OpenAPI 2.0 规范, 没关系, 简单使用这个工具并不需要完全掌握这些规范。 drf-yasg - Yet another Swagger generat...",
      "content_html": "<p>自动化生成文档的工具有很多, 这里介绍的是一款基于 <strong>Swagger/OpenAPI 2.0</strong> 规范的 API 文档自动化生成工具: <strong>drf-yasg</strong>。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">提示</p>\n<p>如果你不清楚什么是 <strong>Swagger/OpenAPI 2.0</strong> 规范, 没关系, 简单使用这个工具并不需要完全掌握这些规范。</p>\n</div>\n<h2><a class=\"header-anchor\" href=\"#drf-yasg-yet-another-swagger-generator\"><span></span></a><a href=\"https://drf-yasg.readthedocs.io/en/stable/readme.html\" target=\"_blank\" rel=\"noopener noreferrer\">drf-yasg</a> - Yet another Swagger generator</h2>\n<p>API 文档自动化生成的工具有很多种, 其中大多数都是通过文档注释进行文档自动化生成的(如 APIDOC)。<br>\n然而 drf-yasg 选择了另辟蹊径, 它通过复用 <strong>Serializers</strong> 以及 <strong>Models</strong> 来自动化生成 API 文档。</p>\n<blockquote>\n<p>得益于 drf-yasg 的这项特性, 维护文档注释的工作量将会降低至微乎其微。<br>\n试想一下, 假若需要调整 API 请求参数或返回值结构, 在 coding 时必然会调整对应的 Serializers 或 Models, 这时候自动生成的文档也会同步更新, 这就避免了文档落后于代码的问题。</p>\n</blockquote>\n<h2><a class=\"header-anchor\" href=\"#drf-yasg-的使用方法\"><span></span></a><a href=\"https://drf-yasg.readthedocs.io/en/stable/readme.html#usage\" target=\"_blank\" rel=\"noopener noreferrer\">drf-yasg 的使用方法</a></h2>\n<p>该节参考官方文档编写, 同时针对部分细节做了更详细的解释。</p>\n<h3>0. 安装</h3>\n<p>在安装前, 建议先了解一下 drf-yasg 对 drf/django/python 各版本的兼容性。</p>\n<div class=\"language-yaml\" data-ext=\"yml\" data-title=\"yml\"><pre class=\"language-yaml\"><code>## drf-yasg 兼容性状况\n-   Django Rest Framework: 3.8, 3.9, 3.10, 3.11\n-   Django: 1.11, 2.2, 3.0\n-   Python: 2.7, 3.6, 3.7, 3.8\n</code></pre></div><p>对于兼容的项目, 直接安装 drf-yasg 即可</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>pip install -U drf-yasg\n</code></pre></div><h3>1. 快速开始</h3>\n<p>首先, 在 django settings 里的 <code>INSTALLED_APPS</code> 添加 <code>drf_yasg</code>。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## IN YOUR settings.py\nINSTALLED_APPS = [\n   ...\n   'drf_yasg',\n   ...\n]\n随后, 在 django urls 里添加对应的 url路由\n\n## IN YOUR urls.py\n...\nfrom rest_framework import permissions\nfrom drf_yasg.views import get_schema_view\nfrom drf_yasg import openapi\n\n...\n\nschema_view = get_schema_view(\n    ## 具体定义详见 [Swagger/OpenAPI 规范](https://swagger.io/specification/#infoObject)\n    openapi.Info(\n        title=\"Snippets API\",\n        default_version='v1',\n        description=\"Test description\",\n        terms_of_service=\"https://www.google.com/policies/terms/\",\n        contact=openapi.Contact(email=\"contact@snippets.local\"),\n        license=openapi.License(name=\"BSD License\"),\n    ),\n    ## public 表示文档完全公开, 无需针对用户鉴权\n    public=True,\n    ## 可以传递 drf 的 BasePermission\n    permission_classes=(permissions.AllowAny,),\n)\n\nurlpatterns = [\n    url(r'^swagger(?P&lt;format&gt;\\.json|\\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-spec'),\n    url(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),\n    url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),\n    ...\n]\n</code></pre></div><p>drf-yasg 提供 4 种默认路径(endpoints), 分别为:</p>\n<ul>\n<li><code>/swagger.json</code>, JSON 格式的 API 定义</li>\n<li><code>/swagger.yaml</code>, YAML 格式的 API 定义</li>\n<li><code>/swagger/</code>, 基于原生 swagger-ui 样式的前端页面</li>\n<li><code>/redoc/</code>, 基于 ReDoc 样式的前端页面</li>\n</ul>\n<h3>2. 常用配置</h3>\n<p>这一节简单介绍 <strong>drf-yasg</strong> 的配置参数，其他参数的详细解释建议阅读<a href=\"https://drf-yasg.readthedocs.io/en/stable/readme.html#configuration\" target=\"_blank\" rel=\"noopener noreferrer\">官方文档</a></p>\n<blockquote>\n<p>如果仅需简单应用, 参考 <code>1. 快速开始</code> 配置后即可通过对应的 <code>endpoints</code> 访问到自动生成的 API 文档。</p>\n</blockquote>\n<h4>a. <code>get_schema_view</code> 的配置</h4>\n<p>函数 <strong>get_schema_view</strong> 的作用是返回自动生成 API 文档的视图类, 该函数接受以下参数:</p>\n<ul>\n<li><strong>info</strong>: Swagger API Info 对象, 具体定义详见 <a href=\"https://swagger.io/specification/#infoObject\" target=\"_blank\" rel=\"noopener noreferrer\">Swagger/OpenAPI 规范</a>, 如果缺省, <strong>drf-yasg</strong> 默认会用 <code>DEFAULT_INFO</code> 进行填充。</li>\n<li><strong>url</strong>: 项目API的基础地址, 如果缺省, 则根据视图所在的位置进行推导。</li>\n<li><strong>patterns</strong>: 自定义的 urlpatterns, 该参数直接透传至 SchemaGenerator。</li>\n<li><strong>urlconf</strong>: 描述从哪个文件获取路由配置, 缺省值是 \"urls\", 该参数直接透传至 SchemaGenerator。</li>\n<li><strong>public</strong>: 描述API文档是否公开, 如果未 <code>False</code>, 则仅返回当前用户具有权限的接口(endpoints)的 API 文档。</li>\n<li><strong>validators</strong>: 用于校验自动生成的 Schema 的校验器, 目前仅支持 <code>ssv</code> 和 <code>flex</code>。</li>\n<li><strong>generator_class</strong>: 自定义 OpenAPI schema 生成器类, 该类应该继承自 <code>OpenAPISchemaGenerator</code></li>\n<li><strong>authentication_classes</strong>: 用于 schema view 进行登录认证的类</li>\n<li><strong>permission_classes</strong>: 用于 schema view 进行权限校验的类</li>\n</ul>\n<h4>b. <code>SchemaView</code> 的配置</h4>\n<p>通过函数 <strong>get_schema_view</strong> 可以获取对应的 <strong>SchemaView</strong>, 调用该类的 <strong>with_ui</strong> 或 <strong>without_ui</strong> 方法可生成对应的<strong>视图函数</strong>, 将其添加进 <strong>urlpatterns</strong> 即可访问到自动生成的 API 文档。</p>\n<ul>\n<li><strong>SchemaView.with_ui(renderer, cache_timeout, cache_kwargs)</strong>: 返回使用指定 UI 渲染器的视图函数, 可选的 UI 渲染器有: <code>swagger</code>, <code>redoc</code>。</li>\n<li><strong>SchemaView.without_ui(cache_timeout, cache_kwargs)</strong>: 返回无 UI 的视图函数, 该函数可以返回 json/yaml 格式的 swagger 文档。</li>\n</ul>\n<p>以上两个函数均支持通过 <code>cache_timeout</code> 或 <code>cache_kwargs</code> 配置缓存参数, 详见下一节。</p>\n<h3>3. 缓存</h3>\n<p>由于 schema 通常在服务运行期间不会发生改变, 因此 <strong>drf-yasg</strong> 使用 django 内置的 <code>cache_page</code> 实现开箱即用的缓存功能, 只需要配置对应的参数即可启用, 对应参数解释如下:</p>\n<ul>\n<li><strong>cache_timeout</strong>: 用于指定缓存的生存时间</li>\n<li><strong>cache_kwargs</strong>: 用于传递 <strong>cache_page</strong> 允许接受的非位置参数, 如 <code>cache</code>(指定 cache backend), <code>key_prefix</code>(缓存 key 的前缀) 等等, 详见 django 官方文档。</li>\n</ul>\n<blockquote>\n<p>需要注意的是, 由于 <strong>drf-yasg</strong> 支持针对不同用户返回不一样的 API 文档(通过<strong>public</strong>、<strong>authentication_classes</strong>、<strong>permission_classes</strong>等参数配置), 因此对于不同用户(通过HTTP 请求头中的 <strong>Cookie</strong> 和 <strong>Authorization</strong> 进行区分), 会在内存中分别进行缓存。</p>\n</blockquote>\n<h3>4. 校验文档有效性</h3>\n<p>为保证自动生成文档的有效性, 可以通过在 <strong>get_schema_view</strong> 中设置 <code>validators</code> 参数开启校验自动化生成文档是否符合 OpenAPI2.0 规范的功能。</p>\n<blockquote>\n<p>该功能可能会降低文档生成的效率, 鉴于 schema 通常在服务运行期间不会发生改变, 该选项可仅在本地开发期间开启。</p>\n</blockquote>\n<h3>5. 代码自动生成</h3>\n<p>使用 Swagger/OpenAPI 规范生成文档的好处之一, 就是能通过 API 文档自动生成 <strong>不同语言</strong> 的 SDK，该功能由 <strong><a href=\"https://github.com/swagger-api/swagger-codegen\" target=\"_blank\" rel=\"noopener noreferrer\">swagger-codegen</a></strong> 提供。</p>\n<h2>drf-yasg 自动生成 API 文档的流程</h2>\n<p>虽然在 Django Rest Framework 3.7 已经内置了自动生成 OpenAPI 2.0 Schema 的功能, 但是这个功能实际上是基于 <a href=\"https://www.coreapi.org/\" target=\"_blank\" rel=\"noopener noreferrer\">CoreAPI</a> 标准, 就功能和社区生态(周边工具)而言, 目前是远不如 <a href=\"https://djangoadventures.com/coreapi-vs-openapi/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI</a>。<br>\n因此, <strong>drf-yasg</strong> 基于 drf 的路由生成器(EndpointEnumerator), 用 OpenAPI 2.0 规范重新实现了一遍文档生成的流程。<br>\n鉴于文档生成的流程比较复杂, 这里笔者尝试将核心的流程用流程图记录如下。</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/bLJbUkCm4FplfzZZfCpCp8o_-Ydosj5NM_99SdFWU_TRHKxZCWKNPcT74oqPoxJDqoGeBh6nVBHT11a5X1LAmhOKzePY5m9Ry3S0-fk9qTuOtiIKtTnHUDP73Uex8UFPu5yGANqBmKnEeMNG-3D7OsTzbCLCI9zQwQPGXk3ImSnf6zTD6w0njNeHX6WPRpwNcH543cAKg9bLkXPYnFB4WZ3GqtEOCKcyVTTcfwQdfpuR13ES9KctSQ2xKAsar0TPtaW4f-hYIyGnmgbYLMayqegZCOLVMJXnY7kcXBI6FkNfSrJMouhrJfve311o5qFJ08sI8tqoAhb3RHv2d7GAJdpU8rGXk9B6mPjgCDCgpFbzyCkMiaT2tj89Ldb7MgKLyhws-8AZqNGK5jjfbc2AqZ9dY3MqJ2H-_rPJ90WrgI1L6Y7NISQiHIqNwuvrROCb1kltdW4cApMptJW5WQ9DS4JWOJvbP1gDINkuQQeC6cXRYD9tC3AEyo2mgj0zFRwRLcnpR7zvUdvLEQZxY01xUZo9ZZWUvzqQU0v7diFBAZn4zpOMqcJ8dtZdjCNY6FLoRfMtidEmRolzYw0or6rY8C8obp5jX8m9e3GqD5Ju6oZCKhIe3ItQ4hP91VU3UtgUD5Eh_dYNEvbAqvEAK75ZjVl438Hek1OPqQzQmhBOFBEAoSW42Fwt-XePPWocZGGfUTXHvvskyDRW8vo7GwKpJf7dMjZVQnW9AHoMNwfIw5XYfVI4P1UwxPck01fjhYKG8tSrmPhYkIkOxLwy0TN84FMJ6hzgwOR14oGDuV04IiITqyE3mUlb_JrtYwRC0kdhdap4JPlyRb-mmHAzv6do7m00\n\"></code></pre></div><p><strong>drf-yasg</strong> 自动生成文档的大致流程如上, 由于如何通过 <strong>inspector</strong> 从 <strong>Endpoint</strong> 解析出 <strong>RequestBodyParameters</strong>、<strong>QueryParameters</strong> 以及 <strong>ResponseSchema</strong> 的流程涉及到较多的 <strong>Swagger/OpenAPI</strong> 规范的知识, 这里的流程图省略了这些实现细节。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">提示</p>\n<p>建议感兴趣的读者先了解 OpenAPI2.0 规范, 再阅读对应的实现源码。</p>\n</div>\n<h2>结语</h2>\n<p>得益于 <strong>drf-yasg</strong> 选择了基于 drf-Serializer 和 Model 生成 API 文档, 不但复用了代码组件, 还降低了额外维护一份代码文档的开销成本，虽然文档生成流程中存在写瑕疵, 但瑕不掩瑜, <strong>drf-yasg</strong> 在目前还是 django/drf API 文档自动生成的最好用的工具库。</p>\n",
      "date_published": "2020-04-15T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "pydantic × drf-yasg, 使用 pydantic 简化 API 文档生成的思路",
      "url": "https://blog.shabbywu.cn/posts/2020/04/16/pydantic-with-drf-yasg.html",
      "id": "https://blog.shabbywu.cn/posts/2020/04/16/pydantic-with-drf-yasg.html",
      "summary": "本文分享了使用 pydantic 完成 drf 文档自动生成的思路和代码实现。\n",
      "content_html": "<h2>使用 pydantic 简化 API 文档生成的思路</h2>\n<h3>前言-Type Hints简介</h3>\n<p>前段时间介绍过 <strong>dry-yasg</strong> 这款 API 文档自动生成工具, 对于 <strong>Django Models</strong>, drf-yasg 能够自动探测并生成对应的 <strong>Schema</strong>, 但是随着项目功能的丰富, 返回值的对象结构往往会更加复杂, 这时候只能定义一大坨累赘用于描述数据结构的 <strong>Serializer</strong> 。<br>\n另一方面, 随着 <strong><a href=\"https://www.python.org/dev/peps/pep-0484/\" target=\"_blank\" rel=\"noopener noreferrer\">PEP 484 -- Type Hints</a></strong> 的提出, Python 已经支持通过简单的语法进行数据结构描述, 相对于累赘的 <strong>Serializer</strong> 而言, 完全就是解放生产力的工具, 两者的用法上的相似性可以从以下的代码看出。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## Type Hints 与 Serializer 的对比\nimport datetime\nfrom typing import List\nfrom dataclasses import dataclass\n\n\n## 使用 Type Hints\n@dataclass\nclass Comment:\n    title: str\n    content: str\n    author: str\n    created: datetime.datetime\n    updated: datetime.datetime\n\n\n@dataclass\nclass Post:\n    title: str\n    content: str\n    author: str\n    created: datetime.datetime\n    updated: datetime.datetime\n    visit: int\n    comments: List[Comment]\n\n\nfrom rest_framework.serializers import (\n    Serializer,\n    ListSerializer,\n    CharField,\n    IntegerField,\n    DateTimeField,\n)\n\n## 使用 drf-Serializer\nclass CommentSLZ(Serializer):\n    title = CharField()\n    content = CharField()\n    author = CharField()\n    created = DateTimeField()\n    updated = DateTimeField()\n\n\nclass PostSLZ(Serializer):\n    title = CharField()\n    content = CharField()\n    author = CharField()\n    created = DateTimeField()\n    updated = DateTimeField()\n    visit = IntegerField()\n    comments = ListSerializer(child=CommentSLZ())\n</code></pre></div><p>从上面的代码可以看出, 由于 <strong>Type Hints</strong> 有官方的支持, 语法比起 <strong>Serializer</strong> 而言要简单许多, 美中不足的只有官方实现的 <strong>dataclass</strong> 并不支持序列化。<br>\n如果项目里已经有使用 <strong>Type Hints</strong> 进行数据建模, 同时又需要使用 <strong>Serializer</strong> 做参数校验和序列化操作, 这个时候就可以尝试使用 <strong>pydantic</strong> 解放自己的双手。</p>\n<blockquote>\n<p>pydantic 在支持 <strong>Type Hints</strong> 进行类型注解的同时, 还具备了 <strong>Serializer</strong> 提供的 <strong>序列化</strong>, <strong>类型校验</strong> 功能, <a href=\"https://pydantic-docs.helpmanual.io/benchmarks/\" target=\"_blank\" rel=\"noopener noreferrer\">性能</a>也更加优秀, 更重要的是, 它原生支持了 <strong>Swagger/OpenAPI</strong> 规范, 能够用于 <strong>Swagger API</strong> 文档生成。</p>\n</blockquote>\n<h3>回顾初衷-为何选择 <strong>drf-yasg</strong></h3>\n<p>常见的文档自动化生成工具都是基于代码注释进行的, 而 <strong>drf-yasg</strong> 并没有走代码注释的老路, 另辟蹊径选择了基于 <strong>Serializer</strong> 和 <strong>Models</strong> 推导出对应的 API 文档的方案。<br>\n与 <em>apidoc</em> 等基于<strong>代码注释</strong>的文档自动化生成工具相比, <strong>drf-yasg</strong> 具有以下的优势:</p>\n<ul>\n<li>组件复用, 避免多处重复定义相同的数据结构\n<ul>\n<li>e.g. <strong>drf-yasg</strong> 复用了 <strong>Serializer</strong> 和 <strong>Models</strong> 中的类型描述符, 避免在代码注释中重复定义相同的数据结构</li>\n</ul>\n</li>\n<li>敏捷开发, 避免花大量精力在维护接口文档上\n<ul>\n<li>e.g. 使用 <strong>Serializer</strong> 和 <strong>Models</strong> 进行自动化文档生成, 可以在修改代码的同时, 自动更新相对于的接口文档</li>\n</ul>\n</li>\n<li>统一文档规范\n<ul>\n<li>e.g. 目前自动生成的文档符合 <strong>Swagger/OpenAPI</strong> 规范</li>\n</ul>\n</li>\n</ul>\n<h3><strong>dataclass</strong> 与 <strong>drf-Serializer</strong> 存在的不足</h3>\n<p>众所周知, Python语言是动态脚本语言, 为了应付日益复杂的工程项目, Python 引入了 <strong>Type Hints</strong> 等规范解决了返回值和变量类型的不确定性。<br>\n对于<em>成熟/工程化</em>的项目而言, 都不会推荐直接将 <strong>dict</strong> 用作函数的入参和返回值; 相对的, 一般都会将所需的数据结构进行抽象建模, 转换为具有一定语义性的 Python 对象, 常见的用法就是使用 <strong>dataclass</strong> 进行建模。</p>\n<blockquote>\n<p>对于简单的数据建模而言, 轻量级的 <strong>dataclass</strong> 能做到开箱即用, 但是正由于 PEP 过于保守, 过于轻量的 <strong>dataclass</strong> 不能满足 Web 项目开发的基础需求:</p>\n<ul>\n<li>dataclass 不支持序列化成 json</li>\n<li>dataclass 不支持类型校验</li>\n<li>dataclass 不支持文档生成</li>\n</ul>\n</blockquote>\n<p>我们知道, 定义 <strong>dataclass</strong> 时已经描述了数据结构的类型, 但是为了实现<strong>序列化</strong>、<strong>类型校验</strong>和<strong>文档生成</strong>等功能, 我们又要为此特地编写繁琐的 <strong>Serializer</strong>, 这显而易见的违背了我们的初衷: <strong>组件复用</strong>。<br>\n同时, 这个额外编写的 <strong>Serializer</strong> 用处很有限, 很可能会用于也仅用于做数据的序列化/类型校验操作, 转换输出的结果是 <strong>Dict</strong> 或 <strong>List</strong>, 处理产物无法用于 <strong>Type Hint</strong>, 注定了只能在视图函数中使用, 不能将其复用到后端的其他逻辑上去。<br>\n最后, 抛开 <strong>Serializer</strong> 繁琐累赘的语法不提, 将 <strong>dataclass</strong> 与 <strong>Serializer</strong> 结合使用, 虽然一定程度上弥补了 <strong>dataclass</strong> 的短板, 但是项目里往往会出现 <strong>成双成对</strong> 的 <strong>dataclass</strong> 和 <strong>Serializer</strong>。</p>\n<p>如果能将两者合二为一, 即能通过一个类就实现<strong>数据建模</strong>、<strong>序列化</strong>、<strong>类型校验</strong>和<strong>文档生成</strong>, 这将能节省很大的代码量。当我在机缘巧合之下接触到 <strong>pydantic</strong> 这个项目, 总有一种相见恨晚的感觉。</p>\n<blockquote>\n<p>虽然 <strong>dataclass</strong> 有所不足, 但是 <strong>drf-Serializer</strong> 也足以弥补起短板, 如果不想额外引入 <strong>pydantic</strong>, 事实上<strong>dataclass</strong> × <strong>Serializer</strong> 也能满足项目开发的需求。</p>\n</blockquote>\n<h3>pydantic 解放了生产力</h3>\n<p>pydantic 支持使用 <strong>Type Hints</strong> 进行数据建模, 同时其也实现了<strong>序列化</strong>和<strong>类型校验</strong>的功能, 更重要的是, 它原生支持了 <strong>Swagger/OpenAPI</strong> 规范。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import datetime\nfrom typing import List\nfrom pydantic import BaseModel\n\n\nclass Comment(BaseModel):\n    title: str\n    content: str\n    author: str\n    created: datetime.datetime\n    updated: datetime.datetime\n\n\nclass Post(BaseModel):\n    title: str\n    content: str\n    author: str\n    created: datetime.datetime\n    updated: datetime.datetime\n    visit: int\n    comments: List[Comment]\n\n\n## 数据校验\npost = Post(title=\"title\", \n            content=\"content\", \n            author=\"author\", \n            created=datetime.datetime.min,\n            updated=datetime.datetime.min,\n            visit=\"9\",\n            comments=[\n              dict(title=\"title\", content=\"content\", author=\"author\",\n                   created=datetime.datetime.min, updated=datetime.datetime.min,)\n            ])\n&gt;&gt;&gt; post\nPost(title='title', content='content', author='author', created=datetime.datetime(1, 1, 1, 0, 0), updated=datetime.datetime(1, 1, 1, 0, 0), visit=9, comments=[Comment(title='title', content='content', author='author', created=datetime.datetime(1, 1, 1, 0, 0), updated=datetime.datetime(1, 1, 1, 0, 0))])\n\n## 序列化\n&gt;&gt;&gt; post.json()\n'{\"title\": \"title\", \"content\": \"content\", \"author\": \"author\", \"created\": \"0001-01-01T00:00:00\", \"updated\": \"0001-01-01T00:00:00\", \"visit\": 9, \"comments\": [{\"title\": \"title\", \"content\": \"content\", \"author\": \"author\", \"created\": \"0001-01-01T00:00:00\", \"updated\": \"0001-01-01T00:00:00\"}]}'\n&gt;&gt;&gt; post.dict()\n{'title': 'title', 'content': 'content', 'author': 'author', 'created': datetime.datetime(1, 1, 1, 0, 0), 'updated': datetime.datetime(1, 1, 1, 0, 0), 'visit': 9, 'comments': [{'title': 'title', 'content': 'content', 'author': 'author', 'created': datetime.datetime(1, 1, 1, 0, 0), 'updated': datetime.datetime(1, 1, 1, 0, 0)}]}\n\n## 原生支持 **Swagger/OpenAPI** 规范\n&gt;&gt;&gt; post.schema()\n{'title': 'Post', 'type': 'object', 'properties': {'title': {'title': 'Title', 'type': 'string'}, 'content': {'title': 'Content', 'type': 'string'}, 'author': {'title': 'Author', 'type': 'string'}, 'created': {'title': 'Created', 'type': 'string', 'format': 'date-time'}, 'updated': {'title': 'Updated', 'type': 'string', 'format': 'date-time'}, 'visit': {'title': 'Visit', 'type': 'integer'}, 'comments': {'title': 'Comments', 'type': 'array', 'items': {'$ref': '#/definitions/Comment'}}}, 'required': ['title', 'content', 'author', 'created', 'updated', 'visit', 'comments'], 'definitions': {'Comment': {'title': 'Comment', 'type': 'object', 'properties': {'title': {'title': 'Title', 'type': 'string'}, 'content': {'title': 'Content', 'type': 'string'}, 'author': {'title': 'Author', 'type': 'string'}, 'created': {'title': 'Created', 'type': 'string', 'format': 'date-time'}, 'updated': {'title': 'Updated', 'type': 'string', 'format': 'date-time'}}, 'required': ['title', 'content', 'author', 'created', 'updated']}}}\n</code></pre></div><h3>当 pydantic 遇上了 drf-yasg</h3>\n<p>事实上 drf 框架本身就提供了基础的 API 文档生成能力, 但是 drf-yasg 在其基础上完善了对 Serializers 的支持, 使得可以通过 Serializers 输出 <strong>Swagger/OpenAPI 2.0</strong> 的文档。<br>\n既然 <strong>pydantic</strong> 原生支持 <strong>Swagger/OpenAPI</strong> 规范, 只需要经过恰当改造, <strong>drf-yasg</strong> 理论上是能直接<strong>复用 pydantic 模型</strong>来生成 API 文档的。恰好 <strong>drf-yasg</strong> 能在配置文件中自定义 <code>SwaggerAutoSchema</code>, 只需要重载对应的逻辑, 即可实现所需的功能。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## IN somewhere\n## -*- coding: utf-8 -*-\nfrom drf_yasg import openapi\nfrom drf_yasg.inspectors import SwaggerAutoSchema\nfrom pydantic import BaseModel\n\n\nclass ExtraDefinitionsInspectorMixin:\n    \"\"\"把自定义Responses中的schema definition添加到全局的Definitions\"\"\"\n\n    def get_response_serializers(self):\n        overrides_responses = self.overrides.get(\"responses\", None)\n        if overrides_responses:\n            for sc, resp in overrides_responses.items():\n                ## 判断是否继承自 BaseModel\n                if issubclass(resp, BaseModel):\n                    ## 得益于 pydantic 原生支持 Swagger/OpenAPI 规范, 这里的类型转换完全兼容\n                    schema = openapi.Schema(**resp.schema())\n                    overrides_responses[sc] = schema\n                    if \"definitions\" in schema:\n                        ## drf_yasg 目前只能获取 serializers 的 definitions\n                        ## 因此需要在这里补上 pydantic 的 definitions\n                        self.components[\"definitions\"].update(schema[\"definitions\"])\n\n        return super().get_response_serializers()\n\n\nclass BaseModelRequestBodyInspectorMixin:\n    \"\"\"将 swagger_auto_schema 中继承自 pydantic.BaseModel 的 request_body 转换成 drf_yasg.openapi.Schema\"\"\"\n\n    def _get_request_body_override(self):\n        body_override = self.overrides.get('request_body', None)\n        ## 判断是否继承自 BaseModel\n        if body_override and issubclass(body_override, BaseModel):\n            ## 得益于 pydantic 原生支持 Swagger/OpenAPI 规范, 这里的类型转换完全兼容\n            schema = openapi.Schema(**body_override.schema())\n            if \"definitions\" in schema:\n                ## drf_yasg 目前只能获取 serializers 的 definitions\n                ## 因此需要在这里补上 pydantic 的 definitions\n                self.components[\"definitions\"].update(schema[\"definitions\"])\n            return schema\n        return super()._get_request_body_override()\n\n\nclass ExtendedSwaggerAutoSchema(BaseModelRequestBodyInspectorMixin, ExtraDefinitionsInspectorMixin, SwaggerAutoSchema):\n    \"\"\"自定义的 schema 生成器\"\"\"\n\n...\n## IN settings.py\n## 自定义 drf-yasf 配置\nSWAGGER_SETTINGS = {\n  ...\n  'DEFAULT_AUTO_SCHEMA_CLASS': 'somewhere.ExtendedSwaggerAutoSchema',\n  ...\n}\n\n</code></pre></div><h3>项目场景</h3>\n<p>在实际开发中, 可以完全使用 <strong>pydantic</strong> 取代了 <strong>dataclass</strong> 和 <strong>drf-Serializer</strong>，也可以只使用 <strong>pydantic</strong> 进行取代 <strong>dataclass</strong> 进行数据建模, 省去了编写 <strong>Serializer</strong> 的开销。</p>\n<h4>经验分享</h4>\n<h5>自定义返回值结构[全局]</h5>\n<p>在 <strong>career</strong> 项目中, 整个项目定义了特地的返回值结构, 形如:</p>\n<div class=\"language-yaml\" data-ext=\"yml\" data-title=\"yml\"><pre class=\"language-yaml\"><code>Response:\n  result: bool\n  data: Any\n  code: Enum\n  message: str\n</code></pre></div><p>由于 career 项目是通过实现自定义Reponse类达到对返回值的再封装, 同时又由于具有 Any 类型, 针对每个接口的返回类型都需要定制的调整，因此项目里借助了 pydantic 优秀特性, 轻易实现了自定义Response结构的文档生成功能。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import copy\nfrom typing import Any\n\nfrom pydantic import BaseModel\nfrom drf_yasg.inspectors import SwaggerAutoSchema\nfrom drf_yasg import openapi\n\nfrom .errors import ErrorCode\n\n\nclass SpecificationResponse(BaseModel):\n    result: bool\n    data: Any\n    ## 能直接支持枚举类\n    code: ErrorCode\n    message: str\n\n\nclass SpecificationResponseInspector(SwaggerAutoSchema):\n    \"\"\"将 Response 的 schema 修改成规范中的格式\n    \"\"\"\n\n    def get_responses(self):\n        raw_responses = super().get_responses()\n        for sc, response in raw_responses.items():\n            if \"schema\" in response:\n                ## 保存原 Response 的 schema \n                data_schema = response[\"schema\"].as_odict()\n                ## 使用 `SpecificationResponse` 的 schema 覆盖\n                response[\"schema\"] = openapi.Schema(\n                    **copy.deepcopy(SpecificationResponse.schema())\n                )\n                ## 将原 Response 的 schema 直接放在 `data` 上\n                response[\"schema\"][\"properties\"][\"data\"] = data_schema\n                if \"definitions\" in response[\"schema\"]:\n                    self.components[\"definitions\"].update(\n                        response[\"schema\"][\"definitions\"]\n                    )\n            else:\n                response[\"schema\"] = openapi.Schema(**SpecificationResponse.schema())\n\n        return raw_responses\n\n</code></pre></div><h5>支持 <strong>多层级取值</strong> 和 <strong>函数动态取值</strong></h5>\n<p>我们知道, <strong>drf-Serializer</strong> 在设计之初是为了序列化 <strong>Models</strong>, 因此在<strong>对象取值</strong>上做了很大功夫, <strong>Serializer</strong>支持通过设置<code>source</code>属性来指定对应数据的来源, 数据来源可以是另外一个字段或者是从更深的层级取值, 甚至还支持从对象方法中取值。<br>\n然而, pydantic 在设计上并未考虑到层级对象或函数来源取值的问题, 对于需要<strong>多层级取值</strong>或<strong>函数动态取值</strong>的场景会明显吃力。<br>\n不过所幸的是, 由于 <strong>pydantic</strong> 对于在支持 orm 方面上是留了一手的, 目前来可以通过自定义 <code>GetterDict</code> 来实现</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>from typing import Any\nfrom pydantic.utils import GetterDict\nfrom pydantic import BaseModel, Field\nfrom rest_framework.fields import get_attribute\n\nclass ExtendedGetterDict(GetterDict):\n    def get(self, key: str, default: Any = None) -&gt; Any:\n        try:\n            return get_attribute(self._obj, key.split(\".\"))\n        except:\n            return default\n\n\nclass Test(BaseModel):\n    a: int\n    b: int = Field(..., alias=\"B\")\n    c: int = Field(..., alias=\"get_c\")\n    d: int = Field(..., alias=\"d.d\")\n\n    class Config:\n        orm_mode = True\n        getter_dict = ExtendedGetterDict\n\n\nclass D(BaseModel):\n    d: int = 4\n\n\nclass TestClass:\n    a: int = 1\n    B: int = 2\n    orther: int = 3\n    d: D = D()\n    def get_c(self):\n        return 3\n\n\n&gt;&gt;&gt; Test.from_orm(TestClass())\nTest(a=1, b=2, c=3, d=4)\n</code></pre></div><h4>短板和不足</h4>\n<h5>自定义返回值结构[局部]</h5>\n<p>由于不知道什么原因, PaaSNG 某些功能的接口具有自定义的返回值结构, 例如最近在重构的日志搜索功能, 所有接口都被封装成:</p>\n<div class=\"language-yaml\" data-ext=\"yml\" data-title=\"yml\"><pre class=\"language-yaml\"><code>Response:\n    code: int\n    data: Any\n</code></pre></div><p>虽然针对全局的返回值结构能够通过全局的中间件做统一处理, 但是若某些 API 接口由于某些原因需要定义不一样的结构时, 按照目前的方案就无法兼顾的, 目前有个想法是扩展 <strong>drf-yasg</strong> 的 <strong>overrides</strong> 协议, 使其支持局部的自定义返回值结构。</p>\n",
      "date_published": "2020-04-16T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "Python异步编程-探讨协程实现异步的细节",
      "url": "https://blog.shabbywu.cn/posts/2020/11/22/python%E5%BC%82%E6%AD%A5%E8%BF%9B%E9%98%B6-%E6%8E%A2%E8%AE%A8%E5%8D%8F%E7%A8%8B%E5%AE%9E%E7%8E%B0%E5%BC%82%E6%AD%A5%E7%9A%84%E7%BB%86%E8%8A%82.html",
      "id": "https://blog.shabbywu.cn/posts/2020/11/22/python%E5%BC%82%E6%AD%A5%E8%BF%9B%E9%98%B6-%E6%8E%A2%E8%AE%A8%E5%8D%8F%E7%A8%8B%E5%AE%9E%E7%8E%B0%E5%BC%82%E6%AD%A5%E7%9A%84%E7%BB%86%E8%8A%82.html",
      "summary": "序言 在上回 里，我们探讨了协程的本质，明确了协程本身并未解决异步编程的问题，而是提供了一套由用户主动分配CPU时钟的可行方案。 接下来，本文将会从任务调度入手，探讨 Python 实现协程异步编程的细节。 多线程的任务调度 每当我们需要进行异步编程，首先想到的必然是多线程模型，毕竟只需要几行代码，就能启动线程执行异步任务。 在探讨协程调度之前, 我们...",
      "content_html": "<h2>序言</h2>\n<p>在上回 <a href=\"/posts/2019/10/14/python%E5%BC%82%E6%AD%A5%E5%88%9D%E6%8E%A2-%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%AE%9A%E4%B9%89.html\" target=\"_blank\">Python异步初探-协程的定义</a> 里，我们探讨了协程的本质，明确了<strong>协程本身并未解决异步编程的问题</strong>，而是提供了一套<strong>由用户主动分配CPU时钟</strong>的可行方案。\n接下来，本文将会从任务调度入手，探讨 Python 实现协程异步编程的细节。</p>\n<h2>多线程的任务调度</h2>\n<p>每当我们需要进行异步编程，首先想到的必然是<strong>多线程模型</strong>，毕竟只需要几行代码，就能启动线程执行异步任务。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import threading\nimport time\n\n\ndef task(task_id):\n    start_clock = time.process_time()\n    print(f\"task&lt;{task_id}&gt; start\")\n    ## 模拟阻塞\n    time.sleep(10)\n    print(f\"task&lt;{task_id}&gt; end, cost cpu time: {time.process_time() - start_clock}\")\n\nfor i in range(5):\n    threading.Thread(target=task, args=(i, )).start()\n</code></pre></div><p>在探讨协程调度之前, 我们先以上面的多线程异步编程的样例，分析多线程任务的执行顺序。</p>\n<h3>操作系统调度</h3>\n<p>当启动一个线程时，线程并非立即执行，而是在等待操作系统的资源调度。当线程被分配到CPU时钟时，线程才真正开始执行。</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/TL53aaH14DtdAJ9wFVY8iHe76E_RjdTZsxPjem_pVOgsiQggpCSekS-lyVe33ZluT1gx0Ka2DJuawmXqMy86km_0wz86V7wKgRhwXUbtVd18ba34Nw1pENKMdo6vuZVUfUK3Rd9S6fjB0dhj5fq18Bv2Ym-5gszfuncImyGx0Tzu5QOc0EG9AI89-eu8bpDaC_8hS-pVhl3o8_oa9TcV7sxwI9nuIHgJTJaurOpSv0G_TnnVfYT0rHN8fSVyr8LuDClTp_8FQq3NvM9b46G10kIf2aHXNhAky4qLlUcYTwgpF4whRa-4Zz-QaVsSPZzd-FM9LFGqXifYg8eOEekXos3a1gS-_fSn\n\"></code></pre></div><blockquote>\n<p>CPU能执行那个线程，是通过操作系统设置的调度策略而决定的，如果感兴趣可以阅读操作系统相关的书籍，这里就不展开了。</p>\n</blockquote>\n<p>继续讨论回我们上面给的样例，如果我们重复执行几次这个样例, 就会发现, 多线程任务的调度策略和启动顺序是无关的，在重复执行多次还能发现，每次任务调度的顺序也是无关的。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 第一次执行的输出结果\ntask&lt;0&gt; start\ntask&lt;1&gt; start\ntask&lt;2&gt; start\ntask&lt;3&gt; start\ntask&lt;4&gt; start\n### 这里等待了 10 秒\ntask&lt;0&gt; end, cost cpu time: 0.000682000000000002\ntask&lt;4&gt; end, cost cpu time: 0.0003599999999999992\ntask&lt;3&gt; end, cost cpu time: 0.0005119999999999986\ntask&lt;2&gt; end, cost cpu time: 0.0005790000000000031\ntask&lt;1&gt; end, cost cpu time: 0.0006910000000000041\n\n## 第二次执行的输出结果\ntask&lt;0&gt; start\ntask&lt;1&gt; start\ntask&lt;2&gt; start\ntask&lt;3&gt; start\ntask&lt;4&gt; start\n### 这里等待了 10 秒\ntask&lt;0&gt; end, cost cpu time: 0.0005179999999999976\ntask&lt;2&gt; end, cost cpu time: 0.0005100000000000035\ntask&lt;3&gt; end, cost cpu time: 0.00044700000000000295\ntask&lt;1&gt; end, cost cpu time: 0.0006960000000000022\ntask&lt;4&gt; end, cost cpu time: 0.0003809999999999994\n</code></pre></div><p>由于多线程的任务调度是由操作系统底层的调度算法决定的，这导致用户无法感知和控制<strong>各线程被分配的CPU时间片大小以及执行顺序</strong>，而协程恰好就提供了一套<strong>由用户主动分配CPU时钟</strong>的可行方案。<br>\n换句话说, 如果我们能合理地控制协程的执行顺序, 那是不是就等同于实现了一个基于协程的异步框架呢？！</p>\n<h2>协程的任务调度</h2>\n<h3>任务队列-控制线程任务执行顺序的方案</h3>\n<p>所谓的<strong>控制协程执行顺序</strong>，实际上就是要在用户态中模拟操作系统针对线程的调度算法，最直观的实现方案就是用<strong>优先队列</strong>将异步任务缓冲起来，再由用户程序<strong>按任务优先级排序</strong>，调度器只需依序执行即可。这种类型的异步任务执行模型如下:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/N4t50S803FndYXi51i0PeEuQmUdgzLVpolbEr6Mrig28tJB4FU7G3V0bZESNjGLGLtBw4xKMscWOqPdJcbDzNsPcwzrly776HHWfj0QdSQ88KWvzJUjgrkOKkcwrZrjBkWgVqnnDD8RPwEf25OBvVY01k0g7rBmKe2S0\n\"></code></pre></div><p>虽然这个模型在设计上满足需求的，但是在编程实现时又引入了新问题: <strong>何时创建协程执行异步任务？</strong><br>\n如果是在创建异步任务时，马上创建新的协程执行任务，那么这个模型和原来的原地创建协程并未任何本质差异，因此不可能同时创建任务和创建协程。<br>\n通过分析这个模型可以发现，这是典型的生产者消费者模式，只要生产者(推送任务到队列)和消费者(创建新协程执行任务)分别在两个不同的协程运行，那就可以“避免”同时创建任务和创建协程的问题了，调整后的模型如下：</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/NKx5WK913BrF5MbWAjZ5ppW5V7UDuD0w4kRqSuazcnBxo59N9a6_Zc63RIHXZ70pyAcmQYM29ZuTV2hDmpZKecr8wW6A7QINM6Bu4hOw3J6pmRxIlOeJ1cu1ypHZY7ACGyxRHd_AWkVJmJyUtm7-hNKltLTwS5b_26l7UiWmzYCpZPj8wkeD\n\"></code></pre></div><p>对应的实现代码也很简单:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def consumer(queue):\n    while True:\n        task, args, kwargs = queue.get()\n        task.run(*args, **kwargs)\n        yield\n\n\ndef producer(queue):\n    while True:\n        queue.put(task, args, kwargs)\n        yield\n</code></pre></div><p>通过将原来的异步任务执行模型改造成上图所示的生产者消费者模式后，看似解决了创建协程的问题，但是再仔细分析新的模型，可以发现: 如果消费者执行完一个任务就马上让出CPU让生产者创建任务，那么本质上与同时创建和执行协程没有任何区别。</p>\n<p>虽然引入队列解决了任务执行优先级的问题，但是并未解决调度协程的时机，我们不妨从竞品(线程)中寻找实现调度算法的灵感，<s>了解业界做法总不会错</s>。</p>\n<h3>调度算法的核心职责</h3>\n<p>首先观察一下线程的生命周期，一个线程的生命周期可能会在5个状态中轮转， 分别是创建，就绪，运行，阻塞，终止，这几种关系的状态转移关系入下图所示:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/SYWkIImgAStD0L0vpWOM1M4OpQUAoTO5BFEDjgJListRDfkv_w2AQyOVpa6tu2l2yN27dpxmBSpcz3YuUy5ixIA766iCmUVXxZ0o4hN5hZ5M3yajUhZINb1kiTa_73h8I9nV9BlXsHNrUuc_AwSm2TcAIfFdZb2lA7zZFrpS-FCBasHOE9U8BqE75U7W9-MwSDmWe-dyD-loC_75d1Rqw2FZXkbgAf1f-X7BvZprGwQ_I9f3QbuAC3G0\n\"></code></pre></div><p>协程调度算法的本质其实就是解决一个问题：<strong>如何调度协程的执行</strong>？如果从任务(协程)的角度看待这个问题，可以将其拆解为以下几点:</p>\n<ul>\n<li>何时让出 CPU？</li>\n<li>CPU 让给了谁？</li>\n<li>何时继续执行？</li>\n</ul>\n<h4>何时让出 CPU？-- 控制权转移的时机</h4>\n<p>协程的最佳使用场景是IO密集型的应用，当需要等待 IO 操作执行时，协程可以主动让出 CPU 的使用权，避免浪费 CPU 资源。因此，协程应当在需要等待其他资源执行的地方主动让出 CPU，用伪代码描述如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def co():\n    result = yield one_blocking()\n    handle(result)\n    result = yield the_other_blocking()\n    handle(result)\n</code></pre></div><h4>CPU 让给了谁？-- 任务的调度器</h4>\n<p>在上回 <a href=\"/posts/2019/10/14/python%E5%BC%82%E6%AD%A5%E5%88%9D%E6%8E%A2-%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%AE%9A%E4%B9%89.html#%E5%8D%8F%E7%A8%8B%E7%9A%84%E5%AE%98%E6%96%B9%E5%AE%9E%E7%8E%B0%E4%BB%8B%E7%BB%8D\" target=\"_blank\">Python异步初探-协程的定义</a> 里，我们讲解了<strong>非对称式协程</strong>的执行顺序：非对称式协程通过 <code>send</code> 原语将控制权转移至子协程(被调用者)，再通过 <code>yield</code> 原语将控制权返回到其父协程(调用者)。<br>\n因此，协程的异步系统需要具备一个调度器负责接收子协程让渡出去的控制权，再从任务队列中挑选合适的协程继续执行。与此同时，这个调度器还需要保证只有当需要等待的 IO 操作执行完毕后，才继续执行上一个协程，用伪代码描述如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def scheduler():\n    while True:\n        ## 获取协程任务列表\n        cos = list(task_queue)\n        for co in cos:\n            ret = co.send()\n            if ret is some_blocking:\n                ret.callback = co\n                task_queue.put(ret)\n            elif hasattr(co, \"callback\"):\n                task_queue.put(co.callback)\n            else:\n                task_queue.pop(co)\n</code></pre></div><blockquote>\n<p>调度器既是生产者, 也是消费者。</p>\n</blockquote>\n<h4>何时继续执行？ -- 其一: 基于回调的事件处理机制</h4>\n<p>对于多线程而言，由于任务调度是委托至操作系统完成的，程序无需关心线程被阻塞后的执行情况。<br>\n但是对于协程调度系统而言，如果其中一个协程被阻塞，就意味着整个系统都被阻塞，只有当阻塞操作完成后，协程系统才会继续运行下去。\n阻塞对整个协程系统是毁灭性的，所有协程都应该只允许使用非阻塞 IO 操作，同时在调用 IO 操作后，需要马上让渡出 CPU 的使用权给调度器，当调度器判断 IO操作完成后，再继续执行该协程。\n单个协程的调度流程可以参考以下的时序图:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/SoWkIImgAStDuNgneVdfhcLFPwvGqBLJUDhR_tn5DrTYSabcMM99AfIleEpcvWG4N10kI25SbvM2fxz2Kav-SdPcNZgcHfV4ekpWn9pS_3o4HQZwOSpMRKzsJuFQhwUdnnMTZwhiPSEiv_ENWcnxDhdtoTuvJzVrFEkOWCtvLWhFToz_ldljiyxbBnRsl5Y_56fgIYgQyx3x1w8DPFpwgLZqEAJcfG333G00\n\"></code></pre></div><p>对于网络IO、磁盘IO等调用，操作系统均提供了异步读写的系统调用(system call)。一般而言，异步 IO 调用会返回文件描述符（句柄），用户程序只需要调用操作系统提供的 API，就可以知道 IO 操作是否完成。<br>\n简而言之，只需要在调度器中增加判断 IO 操作是否执行完毕的操作即可，用伪代码描述如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def scheduler():\n    while True:\n        ## 获取协程任务列表\n        cos = list(task_queue)\n        for co in cos:\n            ret = co.send()\n            if ret is some_blocking:\n                ret.callback = co\n                task_queue.put(ret)\n            elif ret is io_operation:\n                ret.callback = co\n                register_io_operation(ret)\n            elif hasattr(co, \"callback\"):\n                task_queue.put(co.callback)\n            else:\n                task_queue.pop(co)\n        \n        ## 获取已完成的 IO 操作\n        ops = get_completed_io_operations()\n        for op in ops:\n            task_queue.put(op.callback)\n</code></pre></div><h4>何时继续执行？ -- 其二: 处理与操作系统无关的阻塞</h4>\n<p>除了由于IO导致的阻塞以外, 很多时候我们只希望等待几秒钟再执行, 例如在标准库 <code>asyncio</code> 中提供了以下的语法。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import time\nimport asyncio\n\nasync def co():\n    start = time.time()\n    print(\"It's going to sleep 1s.\")\n    await asyncio.sleep(1)\n    end = time.time()\n    print(f\"{end - start}s passed.\")\n\n\nif __name__ == '__main__':\n    ## 输出: \n    ## It's going to sleep 1s.\n    ## 1.0028209686279297s passed.\n    asyncio.run(co())\n\n</code></pre></div><p>上述例子中关键的代码是: <strong>await asyncio.sleep(1)</strong>，其所描述的含义是: <strong>等待1秒, 随后继续执行</strong>。\n参考对于 <strong>io_operation</strong> 的处理, 这类型的阻塞可以抽象成 <strong>timeout_operation</strong>, 当满足继续执行的条件后, 再将相应的操作注册会任务队列之中，用伪代码描述如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def scheduler():\n    ...\n    ret = co.send()\n    if ret is timeout_operation:\n        ret.callback = co\n        register_timeout_operation(ret)\n    \n    ...\n\n    ops = get_timeout_operations()\n    for op in ops:\n        task_queue.put(op.callback)\n    ...\n</code></pre></div><p>至此, 我们已经具备实现一个简易的基于协程的异步系统的基本理论知识, 但仍然有一点是我们尚未讨论的, 那就是<strong>如何解决阻塞任务的返回值传递问题</strong>。</p>\n<h3>除了调度之外, 你还需要了解的一些细节</h3>\n<p>在 <strong>非对称式协程</strong> 中,  <code>send</code> 原语不仅仅是控制权转移至子协程(被调用者)，还充当了参数传递的作用。而 <code>yield</code> 原语也不仅仅是将控制权返回到其父协程(调用者)，还可以将返回值带回父协程，参考以下的代码:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def coroutine():\n    arg = yield 1\n    print(\"参数: \", arg)\n\nif __name__ == \"__main__\":\n    ## 输出:\n    ## 返回值:  1\n    ## 参数:  2\n    co = coroutine()\n    print(\"返回值: \", co.send(None))\n    co.send(2)\n\n</code></pre></div><p>将该逻辑整合至单个协程的调度流程时序图:</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/SoWkIImgAStDuNgneVdfhcLFPwvGqBLJUDhR_tn5DrTYSabcMM99AfIleEpcvWG4N10kI25SbvM2fxz2Kav-SdPcNZgcHfV4ekpWn9pS_3o4HQZwOSpMRKzsJuFQhwUdnnMTZwhiPSEiv_ENWcnxDhdtoTuvJzVrFEkOWCtvLWhFToz_ldljiyxbBnRsl5Y_52Aiew2bzNJdjYTx5hnicEJf3p7b-QoM5_kdF9qzxsd37YqjhfHKD1zWzpB4P90Bra_5eiSXDIy56BG0\n\"></code></pre></div><p>我们可以发现, 对于<strong>返回值</strong>的处理是属于<strong>调度器</strong>的任务, 因此想要解决阻塞任务的返回值传递问题，只需要调度器在处理回调时，将对应的返回值一并带上即可，用伪代码描述如下:</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">提示</p>\n<p>以下代码仅在于描述核心逻辑。\n在实现异步系统返回值处理时, 常见的机制为: Promise/Future。</p>\n</div>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>def scheduler():\n    while True:\n        cos = list(task_queue)\n        for co, args in cos:\n            ret = co.send(args)\n            if ret is some_blocking:\n                ret.callback = co\n                task_queue.put(ret)\n            elif ret is io_operation:\n                ret.callback = co\n                register_io_operation(ret)\n            elif ret is timeout_operation:\n                ret.callback = co\n                register_timeout_operation(ret)\n            elif hasattr(co, \"callback\"):\n                task_queue.put(co.callback)\n            else:\n                task_queue.pop(co)\n        \n        ops = get_completed_io_operations()\n        for op, result in ops:\n            task_queue.put((op.callback, result))\n\n        ops = get_timeout_operations()\n        for op in ops:\n            task_queue.put((op.callback, None))\n</code></pre></div><h2>总结: 协程异步系统的基本要素</h2>\n<p>在这篇文章中, 我们首先从<strong>异步编程</strong>这一话题展开, 通过分析操作系统调度多线程的原理, 引申出实现协程异步编程无非就是实现控制协程执行顺序是调度算法。<br>\n随后, 我们分别从<strong>任务执行顺序</strong>和<strong>任务生命周期</strong>两个维度探讨应该如何实现协程任务调度系统, 总结出实现协程任务调度系统所需的基本要素为以下几点:</p>\n<ul>\n<li>任务队列</li>\n<li>操作系统IO回调处理机制</li>\n<li>延迟执行机制</li>\n<li>返回值传递机制</li>\n</ul>\n<p>协程异步系统依赖于操作系统提供的异步IO回调(system call), 因此同一门编程语言的协程异步系统, 在不同的操作系统上可能会提供不同的实现方式。同时，由于协程异步系统的本质是在任务(协程)的调度算法, 因此不同的编程语言也有可能实现不同的调度算法。<br>\n如果想了解Python, JavaScript 和 Golang 在协程实现上有哪些差异, 不妨继续关注我未来可能会更新的文章。</p>\n",
      "date_published": "2020-11-22T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "FastAPI与依赖注入模式",
      "url": "https://blog.shabbywu.cn/posts/2020/11/24/fastapi%E4%B8%8E%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E6%A8%A1%E5%BC%8F.html",
      "id": "https://blog.shabbywu.cn/posts/2020/11/24/fastapi%E4%B8%8E%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E6%A8%A1%E5%BC%8F.html",
      "summary": "本文简单介绍了 FastAPI 的使用方式和依赖注入模式。\n",
      "content_html": "<h2>序言</h2>\n<blockquote>\n<p>如果说我看得比别人更远些，那是因为我站在巨人的肩膀上。   —— 牛顿</p>\n</blockquote>\n<p><a href=\"https://fastapi.tiangolo.com/\" target=\"_blank\" rel=\"noopener noreferrer\">FastAPI</a> 是一个完全兼容 OpenAPI 开放标准，主要用于编写 API 的高性能 web 框架。\n之所以说 FastAPI 站在巨人的肩膀之上，是因为虽然 FastAPI 是一个 web 框架，但实际上作为 Web 框架所需要的基础设施(Session、Cookie、CORS、ASGI...)完全由底层的 <a href=\"https://www.starlette.io/\" target=\"_blank\" rel=\"noopener noreferrer\">Starlette</a> 提供，而对于类型校验和文档生成又依托于 <a href=\"https://pydantic-docs.helpmanual.io/\" target=\"_blank\" rel=\"noopener noreferrer\">Pydantic</a> 来实现，自身则充当了两者的粘合剂。</p>\n<h2>场景演示</h2>\n<p>由于 FastAPI 设计精简，文档国际化完善，本文不会介绍 <a href=\"https://fastapi.tiangolo.com/zh/tutorial/\" target=\"_blank\" rel=\"noopener noreferrer\">如何使用 FastAPI</a>，而是从一个虚构的应用场景介绍 <strong>FastAPI</strong> 的特性。\n设想一个应用场景: 需要封装一个往 Elasticsearch 查询日志的接口，并需要支持部分 DSL 查询语法。</p>\n<h3>使用 Django REST framework</h3>\n<p>常见的 Django View 主体逻辑一般可以划分为 3 个部分：<strong>参数校验与类型转换</strong>、<strong>业务逻辑</strong>、<strong>返回值序列化</strong>。如果要实现虚构常见中的需求，我们需要一个 APIView、一个 DSLSerializer、一个 ResponseSerializer 和一个 ESClient。同时，为了启动项目，还需要一个 urlconf、django app......</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## serializers.py\nfrom rest_framework import serializers\n\n\nclass QuerySerializer(serializers.Serializer):\n    query_string = serializers.CharField(help_text=\"使用 `query_string` 语法进行搜索\")\n    terms = serializers.DictField(help_text=\"使用 `terms` 精准匹配\")\n    exclude = serializers.DictField(help_text=\"使用 `terms` 精准匹配, 但条件取反\")\n\n\nclass DSLSerializer(serializers.Serializer):\n    query = QuerySerializer()\n    sort = serializers.DictField(help=\"key为排序字段, value 为 desc 或 asc\")\n\n\nclass LogSerializer(serializers.Serializer):\n    ts = serializers.CharField(help=\"日志产生的时间戳\")\n    message = serializers.DictField(help=\"日志信息\")\n\n\nclass ResponseSerializer(serializers.Serializer):\n    logs = LogSerializer(many=True)\n\n\n## client.py\nfrom elasticsearch import Elasticsearch\nfrom django.conf import settings\n\nclass ESClient:\n    def __init__(self):\n        self.client = Elasticsearch(settings.ELASTICSEARCH_HOSTS)\n        self.indexes = settings.ELASTICSEARCH_INDEXES\n    \n    def query(self, dsl):\n        q = Search(using=self.client, index=self.indexes).query(dsl)\n        resp = q.execute()\n        return resp\n\n\n## view.py\nfrom rest_framework.views import APIView\nfrom rest_framework.response import Response\nfrom elasticsearch_dsl import Index, Search\n\n\nclass LogQueryView(APIView):\n    def post(self, request):\n        ## 参数校验\n        dsl_serializer = DSLSerializer(request.data)\n        dsl_serializer.is_valid(raise_exception=True)\n        dsl = dsl_serializer.data\n        ## 获取 ES client并查询\n        client = ESClient()\n        logs = client.query(dsl)\n        ## 返回值序列化\n        return Response(ResponseSerializer(logs=logs).data)\n</code></pre></div><h3>使用 FastAPI</h3>\n<p>FastAPI 的关键特性在于其<strong>通过不同的参数声明实现丰富功能，使代码重复最小化。</strong> 与 DRF 相比，FastAPI 可以省下较多累赘的代码，提高开发效率。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## schemas.py\nfrom typing import Dict, List, Optional\nfrom pydantic import BaseModel, Field\nfrom fastapi import Depends, FastAPI\n\n\nclass DSLQueryItem(BaseModel):\n    query_string: str = Field(None, description=\"使用 `query_string` 语法进行搜索\")\n    terms: Dict[str, List[str]] = Field({}, description=\"多值精准匹配\")\n    exclude: Dict[str, List[str]] = Field({}, description=\"terms取反, 非标准 DSL\")\n\n\nclass SimpleDomainSpecialLanguage(BaseModel):\n    query: DSLQueryItem\n    sort: Optional[Dict] = Field({}, description='排序，e.g. {\"response_time\": \"desc\", \"other\": \"asc\"}')\n\n\nclass LogLine(BaseModel):\n    ts: str = Field(..., description=\"日志产生的时间戳\")\n    message: str = Field(..., description=\"日志信息\")\n\n## client.py\nfrom elasticsearch import Elasticsearch\nfrom starlette.config import Config\n\n## 从环境变量或文件读取配置\nconfig = Config(env_file=None)\n\n\nclass ESClient:\n    def __init__(self):\n        self.client = Elasticsearch(config.ELASTICSEARCH_HOSTS)\n        self.indexes = config.ELASTICSEARCH_INDEXES\n    \n    def query(self, dsl) -&gt; List[LogLine]:\n        q = Search(using=self.client, index=self.indexes).query(dsl)\n        resp = q.execute()\n        return [LogLine.parse_obj(line) for line in resp]\n\n## view.py\napp = FastAPI()\n\n\n@app.post(\"/logs/\", response_model=List[LogLine])\nasync def query_log(dsl: SimpleDomainSpecialLanguage, client: ESClient = Depends(ESClient)):\n    return client.query(dsl)\n\n\nif __name__ == '__main__':\n    import uvicorn\n    uvicorn.run(app)\n</code></pre></div><p>与常见的 web 框架不同，FastAPI 的接口函数，给人一种写了没写的感觉，因为实在是太简单了。\n它不使用 <strong>request</strong> 对象来描述单次请求的上下文，而是接口需要什么参数，即声明具体的依赖，不冗余，不累赘。框架在启动前分析出具体接口的依赖关系，在接收请求时即构造对应的依赖对象，最后调用对应的API，完成整个请求调用流程。</p>\n<h2>FastAPI 是怎样做到的？</h2>\n<p>FastAPI 的这种\b\b行为模式被称之为<strong>依赖注入</strong>，依赖注入是依据<strong>控制反转</strong>原则而产生的一种设计模式。想了解依赖注入的原理，就需要先了解控制反转原则。</p>\n<h3>控制反转</h3>\n<blockquote>\n<p>不要打电话给我们，我们会打电话给您。  —— 好莱坞原则</p>\n</blockquote>\n<p>控制反转是设计框架中常见的设计模式，实际上，<strong>控制反转通常被视为框架的定义特征</strong>。\n一般而言，用户定义的函数应该被用户自身的代码所调用，而<strong>控制反转</strong>模式则提倡用户函数应该被开发框架本身调用，框架在整个应用中充当了<strong>主程序</strong>的角色，函数调用的控制权被反转了。\n例如，在编写 CLI 程序中，如果不使用<strong>控制反转</strong>原则，常见的流程如下:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import argparse\n\n\ndef summation():\n    \"\"\"输入一个整数列表并计算总和\"\"\"\n    parser = argparse.ArgumentParser(description='Process some integers.')\n    parser.add_argument('integers', metavar='N', type=int, nargs='+',\n                        help='an integer for the accumulator')\n    args = parser.parse_args()\n    print(sum(args.integers))\n\n\nif __name__ == '__main__':\n    summation()\n</code></pre></div><p>如果使用<strong>控制反转</strong>, 相关的代码就变成:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import typer  ## typer 是基于 type hints 的 CLI 程序框架\nfrom typing import List\n\n\napp = typer.Typer()\n\n@app.command()\ndef summation(intergers: List[int] = typer.Argument(..., help='an integer for the accumulator')):\n    \"\"\"输入一个整数列表并计算总和\"\"\"\n    print(sum(intergers))\n\n\nif __name__ == '__main__':\n    app()\n</code></pre></div><p>这两个程序之间的控制流程最大的差异在于，何时执行 <em>summation</em>，当使用框架编程时，调用 <em>summation</em> 的控制权被转移至框架手上，只有参数传递正确时，<em>summation</em> 才会被框架调用，这种现象就是所谓的<strong>控制反转</strong>。</p>\n<h3>依赖注入与组成根模式</h3>\n<p><strong>依赖注入</strong>和<strong>控制反转</strong>常被相提并论，实际上两则并非同样的概念。<strong>控制反转</strong>强调的是<strong>代码控制流程的反转</strong>，而<strong>依赖注入</strong>强调的是<strong>对象初始化的控制权反转</strong>。因此，我们可以认为依赖注入是控制转移的具体实现之一。<br>\n<strong>依赖注入</strong>的核心思想是由框架提供一种与类定义无关的构造依赖图的机制，由框架保证依赖的构造时机和顺序。一般而言，依赖注入分为两大步骤，分别是<strong>对象构造</strong>和<strong>对象注入</strong>。</p>\n<h4>对象构造</h4>\n<p>依据<strong>对象构造</strong>的实现方式可以将分成两大类型，分别是 <strong>Constructor Injection(基于构造函数注入)</strong>， <strong>Interface Injection(基于接口注入)</strong>。</p>\n<h5>Constructor Injection</h5>\n<p>在基于构造函数注入中，类所需的依赖项作为构造函数的参数提供（FastAPI 实现了该类型的依赖注入）。例如，我们可以如下声明一个接口和对应的依赖:</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>import datetime\nfrom pydantic import BaseModel\nfrom fastapi import Depends, FastAPI\nfrom typing import Optional, List\n\n\nclass LimitOffsetPagination:\n    def __init__(self, limit: Optional[int] = 20, offset: Optional[int] = 10):\n        self.limit = limit\n        self.offset = offset\n\n\napp = FastAPI()\n\n\n@app.get(\"/list_something/\")\nasync def list_something(pagination: LimitOffsetPagination = Depends(LimitOffsetPagination)):\n    ...\n\n\nif __name__ == '__main__':\n    import uvicorn\n    uvicorn.run(app)\n\n</code></pre></div><p>在 FastAPI 框架中，使用构造函数来决定如何注入一个依赖，在该案例中则是如何构造一个 LimitOffsetPagination 对象。<br>\n在接口调用的过程中，依赖的构造和传递均是由 FastAPI 框架完成的，简化了接口调用前置的初始化工作（参数校验和类型转换等), 保证了接口只会出现相应的业务逻辑代码，提高了开发效率，对此 FastAPI 号称能<strong>提高功能开发速度约 200％ 至 300％</strong>。</p>\n<h5>Interface Injection</h5>\n<p>在基于接口注入中，类所需的依赖项由预先定义的接口进行赋值。与基于构造函数注入最大的不同点在于，基于接口注入的类对象允许为该属性<strong>预设默认值</strong>。</p>\n<div class=\"hint-container tip\">\n<p class=\"hint-container-title\">提示</p>\n<p>在基于接口注入的具体实现中，常见的一类型是基于 <strong>setter</strong> 进行赋值，因而也会细分成 <strong>Setter Injection</strong> 或 <strong>Property Injection</strong>。<br>\n由于具体依赖注入的实现的差异，会有人将类似 <strong>obj.property = value</strong> 这样的属性注入认为是不同于 <strong>Interface Injection</strong> 的另一种实现方式。实际上两则的差异仅在于编程语言的具体实现细节之上，如果认为类属性也是对象，而对属性赋值是隐式调用该属性的 <strong>setter</strong> 方法时，那么在形式上两则是等同的。</p>\n</div>\n<p>在 Java 等静态语言中，基于接口的依赖注入最为常见，也由此诞生了一个专有名词：<strong>JavaBean</strong>。所谓 JavaBean，是指遵循以下规范定义的类：</p>\n<ul>\n<li>提供一个默认的无参构造函数</li>\n<li>包含若干属于 <code>private</code> 级别的实例字段</li>\n<li>包含若干属于 <code>public</code> 的 getter 或 setter 方法</li>\n<li>可被序列化并实现 Serializable 接口</li>\n</ul>\n<div class=\"language-java\" data-ext=\"java\" data-title=\"java\"><pre class=\"language-java\"><code>public class School {\n    private String name;\n    public String getName() {return this.name;}\n    public String setName(String name) {this.name = name;}\n}\n\npublic class Graduate {\n    private String name;\n    private School school;\n    private Date graduationDate;\n\n    public String getName() {return this.name;}\n    public void setName(String name) {this.name = name;}\n\n    public School getSchool() {return this.school;}\n    public void setSchool(School school) {this.school = school;}\n\n    public Date getGraduationDate() {return this.graduationDate;}\n    public void setGraduationDate(Date graduationDate) {this.graduationDate = graduationDate;}\n}\n</code></pre></div><p>在 Spring 框架中，最常见的依赖注入方式则是 <strong>Setter Injection</strong>。Spring 框架支持通过多种方式声明对象配置，常见的方案是使用 XML 文件进行配置，例如:</p>\n<div class=\"language-xml\" data-ext=\"xml\" data-title=\"xml\"><pre class=\"language-xml\"><code>&lt;beans&gt;\n    &lt;bean id=\"School\" class=\"School\"&gt;\n        &lt;property name=\"name\"&gt;\n            &lt;value&gt;some school name&lt;/value&gt;\n        &lt;/property&gt;\n    &lt;/bean&gt;\n    &lt;bean id=\"Graduate\" class=\"Graduate\"&gt;\n        &lt;property name=\"name\"&gt;\n            &lt;value&gt;some body name&lt;/value&gt;\n        &lt;/property&gt;\n        &lt;property name=\"school\"&gt;\n            &lt;ref local=\"School\"/&gt;\n        &lt;/property&gt;\n        &lt;property name=\"graduationDate\"&gt;\n            &lt;value&gt;yyyy-MM-dd&lt;/value&gt;\n        &lt;/property&gt;\n    &lt;/bean&gt;\n&lt;/beans&gt;\n</code></pre></div><h4>组成根模式</h4>\n<p>在<strong>对象构造</strong>流程中，每个类都通过构造函数或接口声明了其所需的依赖，但这却将注入具体依赖项的行为委托于第三方实现，那这应该由谁维护这些依赖关系呢？为了解决这个问题，<strong>依赖注入模式</strong>提出了<strong>The Composition Root Pattern(组成根模式)</strong>。<br>\n<strong>组成根模式</strong>认为，应当在最接近应用程序入口的地方提供唯一的组合各模块的切面，也就是说，组成根模式的核心思想在于在程序启动之初即维护一个依赖容器(也可称之为上下文)，<strong>该容器应当具有构建所有依赖项所需的配置</strong>。\n在实际实现中，每个类仅负责通过构造函数、接口或其他方式声明所需依赖，当程序启动时实例化依赖容器，借助依赖容器将依赖图“<strong>编译</strong>”成对象图。\n以 FastAPI 框架为例，在程序启动时，FastAPI 会依据接口声明的依赖(Depends)构造依赖图。当请求进入时，FastAPI 依据 OpenAPI 定义的接口规范，解析请求参数，构造出相应的依赖容器，并依据依赖图中的依赖关系构造相应的对象，再传递进接口函数，完成整个调用流程。</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code><img src=\"https://www.plantuml.com/plantuml/svg/R9D5UXmn48NtVGg5apRimypCMpEpvQ0PXydCp1vclaoNf5db2gcnskoPHi5xzVsl-a4xBMsZ7OqDIkVRqjgcDytDBPtA_fZIWfTQMjlSHFZO8ZCuc6qTxAjyPb3Bp8lm1Cnco5-5uE-Ms5nb2muHzK9cKCikipvl8M5s1zWdI4ifhI7SduG5C-u4UpyRzzq-lWj6qZZ4tHbmM0zsyTaGcaaRVJ7uXgMAr1VfFMPTmQ98o--AqYPQMrxyjhcYik5PRML3XRubrsS5sx1KoIt7KTnYLDMyYc5NLS6G5_oNia0yakjrGTe5QuFSCPoRFExTfTpYRYDaXaHs0OTlAvsN7g9RQqVbzvfQMjdvehIfeg6IVAh4aNRQgHJTGrX-yXa1DbHIMab4Q564LgNqRUMMy4QPlrzaPs5elP180kJ5boyV23EjiucHsyUeOwsf2U5rgkeXS9qt6EPXqmoM2Nt24uVfhUiLjUNqnW5RMnGvdasia6Z9THRrGs93Iqt1a85x9bbm70k1uU3XAbq4tvdLSnvXWfeo3d1PC3Y-wTEIDj8cCH1cc237IzDZFH4eCX3B5YaYqomakg6gFcvkQgTj7UMqkOsyRAghBCy3KLK4DYWi0snXCDUx1Dwuy2_hQZmPOgQruvvQp8r5GcgSZst1K907a_jf2jy03aWmilKm5KZOmJ29FB4NH7OCPXTk7_ySzDJaJ26H3Q75p5giLulj_1z4qfehDI-5om580FCI0000\n\"></code></pre></div><h2>总结</h2>\n<p>在实际编程中，使用依赖注入可以大大<strong>简化参数校验和类型转换的代码</strong>，使得接口的代码几乎完全是业务的核心逻辑，不仅能<strong>提高开发效率，还能降低代码的维护成本</strong>。<br>\n同时，对于接口的集成测试，也无需像一般的 Web 框架一样构造 request 对象，只需构造 API 所需的依赖对象，即<strong>可直接进行集成测试</strong>。<br>\n虽然引入依赖注入的好处明显，但是会<strong>使得调用链路复杂化</strong>。同时引入“<strong>图</strong>”的概念会提高业务模块划分的要求，需避免依赖图中出现“<strong>环</strong>”，否则将使得依赖关系无法解决，因此该类型框架<strong>不适合应用于复杂场景</strong>。</p>\n<h2>附录:</h2>\n<h3>基于 OpenAPI 的接口文档自动生成</h3>\n<p>在生成依赖图的过程中，FastAPI 将输入、输出参数转换为 <strong>Pydantic.ModelField</strong>。借助 Pydantic 将 ModelField 转换为 JSON Schema，而这恰好也是 OpenAPI 所兼容的一种类型描述，因此相对于依赖注入功能，基于 OpenAPI 的接口文档自动生成能力更像是一个附赠品，或者说是<strong>依赖图</strong>的一种可视化形式。</p>\n",
      "date_published": "2020-11-24T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "蓝鲸智云 PaaS 平台重构小结 - Django 项目合并心得",
      "url": "https://blog.shabbywu.cn/posts/2023/03/23/bkpaas-refactor-experience.html",
      "id": "https://blog.shabbywu.cn/posts/2023/03/23/bkpaas-refactor-experience.html",
      "summary": "本文分享了在对蓝鲸 PaaS（蓝鲸智云开发平台）进行重构过程中的经验和教训。\n",
      "content_html": "<h2>背景介绍</h2>\n<h3>重构前架构</h3>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>.\n├── apiserver &gt; apiserver(paas-ng) 承担与 webfe(UI)、workloads(反向代理) 交互的职责  \n├── workloads &gt; workloads(engine-ng) 承担与 k8s/operator 交互的职责  \n├── operator &gt; operator 承担云原生应用调度的职责   \n└── webfe &gt; UI\n</code></pre></div><p>由于部署相关功能需下沉至 operator, 旧架构中与 operator 交互的功能开发繁琐, 所以需要重构。</p>\n<h3>重构目的</h3>\n<ol>\n<li>减少无意义的内部接口开发(apiserver 与 workloads 之间通讯的接口)</li>\n<li>降低功能开发的复杂度(workloads 需回调 apiserver 查询应用信息)</li>\n</ol>\n<h2>项目合并方案</h2>\n<p>项目合并, 需要合并什么？</p>\n<ul>\n<li>代码</li>\n<li>数据</li>\n</ul>\n<h3>1. 代码合并</h3>\n<ol>\n<li>蚂蚁搬家, 逐个模块迁移?</li>\n</ol>\n<p>项目合并最大的痛点是要降低云原生应用功能的开发复杂度, 因此代码迁移时首先需要迁移的是云原生应用模块的代码。\n然而, 蚂蚁搬家的方案在实施后会发现<strong>实施难度大</strong>, 例如下面是云原生应用模块下的某个文件的 <code>import</code> 语句。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## https://github.com/TencentBlueKing/blueking-paas/blob/wl-refactor/workloads/paas_wl/paas_wl/cnative/specs/resource.py\nfrom paas_wl.cnative.specs import credentials\nfrom paas_wl.cnative.specs.addresses import AddrResourceManager, save_addresses\nfrom paas_wl.cnative.specs.constants import (\n    IMAGE_CREDENTIALS_REF_ANNO_KEY,\n    ConditionStatus,\n    DeployStatus,\n    MResConditionType,\n    MResPhaseType,\n)\nfrom paas_wl.cnative.specs.models import default_bkapp_name\nfrom paas_wl.cnative.specs.v1alpha1.bk_app import BkAppResource, MetaV1Condition\nfrom paas_wl.platform.applications.models import EngineApp\nfrom paas_wl.platform.applications.struct_models import ModuleEnv\nfrom paas_wl.resources.base import crd\nfrom paas_wl.resources.base.exceptions import ResourceMissing\nfrom paas_wl.resources.base.kres import KNamespace\nfrom paas_wl.resources.utils.basic import get_client_by_env\nfrom paas_wl.workloads.images.entities import ImageCredentials\n</code></pre></div><p>代码之间的互相引用简直是牵一发动全身，如果要完整迁移 <code>resource.py</code> 这个文件, 还需要处理间接引用的 <code>paas_wl.resources.base</code>、<code>paas_wl.resources.utils</code> 和 <code>paas_wl.workloads.images.entities</code>。\n然而, 间接引用的代码依然会依赖更多的其他代码。由于无法区分模块迁移的边界，以蚂蚁搬家方式的逐个模块迁移<strong>几乎不存在协同开发的可能性</strong>。</p>\n",
      "date_published": "2023-03-23T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "python"
      ]
    },
    {
      "title": "记一次-Nginx-Ingress-长连接异常断开问题复盘过程",
      "url": "https://blog.shabbywu.cn/posts/2021/02/06/%E8%AE%B0%E4%B8%80%E6%AC%A1-nginx-ingress-%E9%95%BF%E8%BF%9E%E6%8E%A5%E5%BC%82%E5%B8%B8%E6%96%AD%E5%BC%80%E9%97%AE%E9%A2%98%E5%A4%8D%E7%9B%98%E8%BF%87%E7%A8%8B.html",
      "id": "https://blog.shabbywu.cn/posts/2021/02/06/%E8%AE%B0%E4%B8%80%E6%AC%A1-nginx-ingress-%E9%95%BF%E8%BF%9E%E6%8E%A5%E5%BC%82%E5%B8%B8%E6%96%AD%E5%BC%80%E9%97%AE%E9%A2%98%E5%A4%8D%E7%9B%98%E8%BF%87%E7%A8%8B.html",
      "summary": "TLDR nginx reload 导致 worker 重启, 而 worker_shutdown_timeout 默认值是 10s (由 nginx-ingress-controller 配置), 导致出现长连接异常断开的问题。 问题背景 最近接到用户反馈, 在 IDC 通过域名访问应用接口时, 当请求耗时到 3 分钟的时候会出现服务端不返回数据直接...",
      "content_html": "<h2>TLDR</h2>\n<p><strong>nginx reload</strong> 导致 worker 重启, 而 <strong>worker_shutdown_timeout</strong> 默认值是 10s (由 nginx-ingress-controller 配置), 导致出现长连接异常断开的问题。</p>\n<h2>问题背景</h2>\n<p>最近接到用户反馈, 在 <strong>IDC</strong> 通过<strong>域名</strong>访问应用接口时, 当请求耗时到 <strong>3</strong> 分钟的时候会出现服务端不返回数据直接关闭连接的情况, 需要协助排查访问链路中是否有设置超时限制。</p>\n<h2>问题分析和排查复盘</h2>\n<h3>访问链路梳理</h3>\n<p>据了解, 目前集群的流量是依托公司基建(腾讯云CLB)做负载均衡, 将流量打散至 nginx-ingress 运行的节点上, 流量进入集群后, 即按照 k8s 的正常访问链路(<code>Ingress -&gt; Service -&gt; Pod</code>)路由至具体的容器。<br>\n为了排查是否由于 CLB 导致超时, 所以需要自查集群内的访问链路是否正常。我们知道, Service 的实现是通过 iptables 做路由规则转发, 如果 <code>Service -&gt; Pod</code> 能连通, 那么这层就不会出现超时的情况。所以集群内部的关注点主要在 <code>Ingress</code> 和 <code>Pod</code> 两个模块。</p>\n<h3>模拟复盘</h3>\n<p>由于用户无法提供可以测试的现场环境, 所以我们只能在集群内搭建一个模拟超时情况的现场。这里选择了使用 <code>fastapi</code> 快速搭建超时现场。</p>\n<div class=\"language-python\" data-ext=\"py\" data-title=\"py\"><pre class=\"language-python\"><code>## -*- coding: utf-8 -*-\nimport asyncio\nfrom fastapi import FastAPI\n\napp = FastAPI()\n\n\n@app.get(\"/test/timeout/{wait}\")\nasync def sleep(wait: int):\n    print(\"waiting\", wait)\n    await asyncio.sleep(wait)\n    return {\"timeout\": False}\n\n\nif __name__ == '__main__':\n    import uvicorn\n    uvicorn.run(app, port=5000)\n\n</code></pre></div><blockquote>\n<p>😁 如果想了解更多关于 FastAPI 的内容, 可以读我的另一篇文章<a href=\"/posts/2020/11/24/fastapi%E4%B8%8E%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E6%A8%A1%E5%BC%8F.html\" target=\"_blank\">fastapi与依赖注入模式</a></p>\n</blockquote>\n<p>模拟现场搭建好后, 分别在容器内和集群内两个环境下请求该现场:</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 域名信息以脱敏, 仅供演示, 应用监听 5000 端口\n~ Pod内 &gt; curl -H \"Host: ****.com\" -X GET 127.0.0.1:5000/test/timeout/180\n{\"timeout\": False}\n\n## 域名信息以脱敏, 仅供演示, nginx-ingress 使用 NodePort 模式监听 30080 端口\n~ ingress 节点上 &gt; curl -H \"Host: xxxx.com\" -X GET 127.0.0.1:30080/test/timeout/180\n{\"timeout\": False}\n\n~ ingress 节点上 &gt; curl -H \"Host: xxxx.com\" -X GET 127.0.0.1:30080/test/timeout/181\ncurl: (52) Empty reply from server\n\n~ ingress 节点上 &gt; curl -H \"Host: xxxx.com\" -X GET 127.0.0.1:30080/test/timeout/180\n{\"timeout\": False}\n\n~ ingress 节点上 &gt; curl -H \"Host: xxxx.com\" -X GET 127.0.0.1:30080/test/timeout/181\ncurl: (52) Empty reply from server\n</code></pre></div><p>意外情况如期而至, 容器内的访问能正常返回, 而使用 <code>nginx-ingress</code> 做代理转发的请求却被服务器中断了连接。<br>\n难道我们在 <code>nginx-ingress</code> 上某个地方配置了 <strong>180s</strong> 超时吗？ 现在的问题就转化成是由于哪里的配置不正确导致连接被中断。</p>\n<blockquote>\n<p>注: 在模拟复盘时遇上了 <strong>偶然</strong> 因素, 导致排查踩坑。</p>\n</blockquote>\n<h3>配置排查</h3>\n<p>\b首先进入 <code>nginx-ingress</code> 其中一个副本的容器中, 先确定配置文件的路径在哪里。</p>\n<div class=\"language-bash\" data-ext=\"sh\" data-title=\"sh\"><pre class=\"language-bash\"><code>## 执行以下指令查看 nginx 的启动参数\nps -ef |grep nginx\n</code></pre></div>",
      "date_published": "2021-02-06T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "运维"
      ]
    },
    {
      "title": "Let's Encrypt - 实现全自动免费 HTTPS 证书托管(自动签发、续期)",
      "url": "https://blog.shabbywu.cn/posts/2022/11/11/let-s-encrypt-%E5%AE%9E%E7%8E%B0%E5%85%A8%E8%87%AA%E5%8A%A8%E5%85%8D%E8%B4%B9-https-%E8%AF%81%E4%B9%A6%E6%89%98%E7%AE%A1-%E8%87%AA%E5%8A%A8%E7%AD%BE%E5%8F%91%E3%80%81%E7%BB%AD%E6%9C%9F.html",
      "id": "https://blog.shabbywu.cn/posts/2022/11/11/let-s-encrypt-%E5%AE%9E%E7%8E%B0%E5%85%A8%E8%87%AA%E5%8A%A8%E5%85%8D%E8%B4%B9-https-%E8%AF%81%E4%B9%A6%E6%89%98%E7%AE%A1-%E8%87%AA%E5%8A%A8%E7%AD%BE%E5%8F%91%E3%80%81%E7%BB%AD%E6%9C%9F.html",
      "summary": "前言 为了推动更安全的 HTTPS 加密协议普及全网，谷歌 Chrome 浏览器从 2017 年开始逐步对HTTP网站标记 “不安全” 警告，并在 2018年7月24日 发布的 Chrome 68 正式版本中将所有 HTTP 网站标记 “不安全”。 Chrome不安全演示图片Chrome不安全演示图片 随着 Chrome 68 版本的覆盖范围，HTTP...",
      "content_html": "<h2>前言</h2>\n<p>为了推动更安全的 HTTPS 加密协议普及全网，谷歌 Chrome 浏览器从 2017 年开始逐步对HTTP网站标记 <strong>“不安全”</strong> 警告，并在 2018年7月24日 发布的 Chrome 68 正式版本中将所有 HTTP 网站标记 <strong>“不安全”</strong>。</p>\n<figure><img src=\"/img/Chrome不安全演示.png\" alt=\"Chrome不安全演示图片\" tabindex=\"0\" loading=\"lazy\"><figcaption>Chrome不安全演示图片</figcaption></figure>\n<p>随着 Chrome 68 版本的覆盖范围，HTTP网站上的“不安全”警告将被越来越多的Chrome用户看到。因此，使用 HTTPS 加密协议提高网站安全性是每个网站所有者的义务。<br>\n但是，为了确保私钥安全，SSL/TLS 证书都设置了有效期限，最新的国际标准中SSL证书最长有效期为<strong>1年</strong>。如果网站使用的 SSL 证书已过期，那么 Chrome 反而会出现 <strong>红色“不安全”</strong> 警告。</p>\n<figure><img src=\"/img/Chrome不安全演示(红色).png\" alt=\"Chrome不安全演示(红色)图片\" tabindex=\"0\" loading=\"lazy\"><figcaption>Chrome不安全演示(红色)图片</figcaption></figure>\n<p>虽然推动 HTTPS 协议的初衷是好的，但是并非所有网站都需要 HTTPS 协议保护(例如你正在访问的静态网站 -- 博客)。为了避免网站被提示不安全，Chrome 68 的策略无疑大幅提高了网站运营人员的工作量 -- <strong>需要定期检查证书的有效性，避免被标记为更吓人的红色“不安全”警告</strong>。</p>\n<p>基于上述背景，本文介绍一种解放证书维护的工作量的方法 -- 全自动 Let's Encrypt 证书托管。</p>\n<h2>什么是 Let's Encrypt</h2>\n<p>Let’s Encrypt 是一家全球性非盈利的证书颁发机构（CA），在全球范围内提供了<strong>免费的域名验证型（DV）证书</strong>。网站所有者可以使用 Let's Encrypt 证书来启用安全的 HTTPS 连接。</p>\n<h2>如何申请 Let's Encrypt 证书</h2>\n<p>与其他常见的 CA 机构不同, Let's Encrypt 证书是基于 <a href=\"https://www.rfc-editor.org/rfc/rfc8555\" target=\"_blank\" rel=\"noopener noreferrer\"><strong>ACME(Automatic Certificate Management Environment) 协议</strong></a> 全自助颁发、续期或吊销的。<br>\n一般而言，申请 Let's Encrypt 证书可拆分成 2 个步骤。</p>\n<ul>\n<li>首先, 向 Let's Encrypt 证明 Web 服务域名的<strong>所有权</strong>。<em>(与其他 CA 机构一样, 颁发 DV 证书都需要证明域名所有权)</em></li>\n<li>然后, 调用 Let's Encrypt 提供的 API 颁发、续期或吊销该域名的证书。</li>\n</ul>\n<h3>Let's Encrypt 的工作原理</h3>\n<p>Let's Encrypt 通过公私密钥对验证和区分不同的 ACME 客户端的请求。为了认证域名的所有权, ACME 协议目前提供了 3 种认证的方式，分别是 <code>HTTP 01</code>、<code>DNS 01</code> 和 <code>TLS-ALPN-01</code>。我们可通过下面的流程图了解域名认证的大致流程。</p>\n<div class=\"language-plantuml\" data-ext=\"plantuml\" data-title=\"plantuml\"><pre class=\"language-plantuml\"><code></code></pre></div>",
      "image": "https://blog.shabbywu.cn/img/Chrome不安全演示.png",
      "date_published": "2022-11-11T00:00:00.000Z",
      "date_modified": "2024-02-24T07:10:47.000Z",
      "authors": [],
      "tags": [
        "运维"
      ]
    },
    {
      "title": "简历 - Jingtao Wu",
      "url": "https://blog.shabbywu.cn/resume/",
      "id": "https://blog.shabbywu.cn/resume/",
      "summary": "Resume for Jingtao Wu",
      "content_html": "",
      "date_modified": "2026-03-31T11:05:23.191Z",
      "authors": [],
      "tags": []
    }
  ]
}