- 容器运行时
- 云原生
【免费下载链接】docker-ce
:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:
Raft 是一种用于在分布式集群中维护**复制状态机(Replicated State Machine)**的一致性协议。本篇文章以 docker-ce 仓库内随附的etcd/raft库(即[raft/README.md](https://link.gitcode.com/i/25aa5013d8ba60788673c153d8fd5077)所述内容)为核心对象,先讲清楚它的极简设计哲学,再给出完整可运行的Node使用范例,最后结合仓库中的源码(raft.go、node.go、log.go、storage.go、progress.go等)逐层剖析其背后的状态机模型、选举与心跳、日志复制与流控、只读线性化查询等实现细节。读完本文,你将能够独立完成一个基于 etcd Raft 的最小集群应用,并理解这一经典共识算法在工业界的工程化落地方案。
本文所涉及代码均位于 components/engine/vendor/github.com/coreos/etcd/raft/ 目录下,该库作为第三方依赖随 docker-ce 源码树一同发布,是 Docker Swarm 等分布式系统底层一致性的重要支撑组件。
一、Raft 协议与库的设计哲学
Raft 是一种用于让集群中的多个节点共同维护一份复制状态机的一致性协议:每个节点都持有一份状态机的副本,通过**复制日志(Replicated Log)**来保证各副本最终一致。其理论来源是 Diego Ongaro 与 John Ousterhout 的论文In Search of an Understandable Consensus Algorithm,本文档亦建议参考 Raft 博士论文(Raft thesis)理解算法细节。
etcd/raft 库最大的特点是只实现 Raft 核心算法本身,不做任何存储与网络的假设:
- 网络传输由使用者自建:库只负责产出需要发给各对等节点的
Message,实际通过何种 RPC、TCP 或自定义通道发送,完全由应用层决定。 - 持久化存储由使用者自建:日志条目、HardState、Snapshot 如何落盘,由应用实现
Storage接口并注入。 - 模型化为确定性状态机:为了让库行为可被精确测试,整个库被建模为一个状态机。状态机以一条
Message作为输入(既可以是本地定时器更新,也可以是来自远端节点的网络消息),输出是一个三元组{[]Messages, []LogEntries, NextState}(待发送消息数组、日志条目、以及 Raft 状态变更)。对于状态相同的状态机,相同的输入永远产生相同的输出——这正是它可被广泛测试与信任的基础。
这种"极简 + 确定性"的设计换来的是灵活性、确定性与性能:网络 IO 与磁盘 IO 都被隔离在库外,任何传输层与存储方案都可以接入。
二、功能全景:完整 Raft 特性与可选增强
2.1 协议完整实现
该库是一套功能完整的 Raft 协议实现,覆盖以下核心能力:
| 特性 | 说明 |
|---|---|
| Leader 选举(Leader election) | 任期(Term)驱动的选举流程,支持随机化选举超时,避免多个节点同时竞选 |
| 日志复制(Log replication) | Leader 将日志条目复制到全部节点,并在多数派确认后提交(Commit) |
| 日志压缩(Log compaction) | 通过快照(Snapshot)机制压缩历史日志,避免日志无限增长 |
| 成员变更(Membership changes) | 支持向集群添加、删除节点,见文档中的ProposeConfChange/ApplyConfChange流程 |
| 领导权转移(Leadership transfer) | Leader 可以主动将领导权转移给指定节点,用于运维场景下的优雅切换 |
| 高效线性化只读查询 | 见 2.2 节的两种实现方式 |
2.2 线性化只读查询的两种方式
库同时提供两种可配置的只读查询方案(对应 raft.go 中的ReadOnlyOption):
ReadOnlySafe(默认,推荐):- Leader:先与法定多数(quorum)通信确认自己仍是合法 Leader(通过携带上下文的特殊心跳收集确认),绕过 Raft 日志即可安全处理只读请求;
- Follower:先向 Leader 请求一个安全读索引(read index),待本地 applied index 追上该索引后再本地处理只读请求。
ReadOnlyLeaseBased(基于租约,更高效):- Leader:完全本地处理只读请求,不再与 quorum 通信;
- Follower:同样向 Leader 请求安全读索引后本地处理。
- 前提条件:该方案依赖集群内所有机器的时钟,若存在无界的时钟漂移则可能读到过期数据。源码中强制约束
CheckQuorum必须开启(见 raft.go 的validate逻辑),以降低时钟漂移风险。
2.3 可选增强特性
在标准协议之外,该实现还包含若干工程化增强:
- 乐观流水线(Optimistic pipelining):处于 Replicate 状态的节点无需等待上一条 Append 的确认即可继续发送下一条,降低复制延迟;
- 日志复制流控(Flow control):通过
inflights滑动窗口限制在途 Append 消息数量,避免压垮应用层发送缓冲; - Raft 消息批量发送(Batching):减少同步网络 IO 调用次数;
- 日志条目批量落盘(Batching log entries):减少磁盘同步 IO 次数;
- Leader 磁盘并行写:按 Raft 论文 10.2.1 节的建议,Leader 可与其 followers并行执行磁盘写入,摊薄同步 IO 延迟;
- 提案转发(Internal proposal redirection):Follower 收到的提案自动转发给 Leader;
- 失 quorum 自动退位(Automatic step-down):
CheckQuorum开启时,Leader 在选举超时内未观测到 quorum 活跃会自动退位为 Follower。
三、核心对象与配置参数
3.1 Node 接口:一切的入口
库的主要操作对象是Node(接口定义见 node.go)。Node 的创建方式有两种:
raft.StartNode(c *Config, peers []Peer):从零启动新集群节点;raft.RestartNode(c *Config):从持久化状态(快照 + HardState + 日志条目)恢复一个既有节点。
两者底层都会启动一个独立 goroutine 运行事件循环(见newNode()与n.run(r)),Node通过Go channel与用户代码协作。Node接口的常用方法包括:
| 方法 | 作用 |
|---|---|
Tick() | 推进内部逻辑时钟一步,心跳与选举超时均以 tick 为单位 |
Propose(ctx, data) | 提交应用数据提案,被提交后以EntryNormal类型出现在CommittedEntries |
ProposeConfChange(ctx, cc) | 提交成员变更提案(添加/删除节点) |
Step(ctx, msg) | 将收到的网络消息喂给状态机 |
Ready() <-chan Ready | 获取当前时刻需要处理的状态批次 |
Advance() | 通知 Node 上一批Ready已处理完毕 |
ApplyConfChange(cc) | 应用已提交的成员变更条目 |
ReadIndex(ctx, rctx) | 发起线性化只读请求 |
ReportSnapshot/ReportUnreachable | 向库汇报快照发送结果 / 对端不可达 |
Stop() | 停止节点并释放资源 |
3.2 Config 核心参数
Config结构(raft.go)是启动前的全部配置。下表给出文档示例中出现的参数及源码注释中的取值范围与含义:
| 参数 | 含义与建议值 |
|---|---|
ID | 本节点唯一标识,不能为 0(0 保留为None)。ID 必须在集群存续期内唯一且不可复用——文档特别强调不要用 IP 地址作 ID(可能被复用),可选用随机生成的 64 位整数 |
ElectionTick | 连续多少个 tick 未收到当前任期 Leader 消息即发起选举。必须大于HeartbeatTick,官方建议ElectionTick = 10 * HeartbeatTick,以避免不必要的 Leader 切换 |
HeartbeatTick | Leader 每隔多少个 tick 发送一次心跳。示例取 1,即每个 tick 一次心跳 |
Storage | 实现Storage接口的持久化存储,必填。示例使用NewMemoryStorage() |
MaxSizePerMsg | 每条 Append 消息的最大字节数。越小则恢复成本越低(初次探测与消息丢失),但可能影响正常复制吞吐;math.MaxUint64表示不限制,0表示每条消息至多一条日志条目 |
MaxInflightMsgs | 乐观复制阶段允许的在途 Append 消息最大数量,用于防止打爆应用层传输缓冲(TCP/UDP 发送缓冲)。必须大于 0 |
Applied | 仅重启时设置:上次已应用到状态机的日志索引,避免重复返回已应用条目 |
CheckQuorum | 是否开启 quorum 活跃检查,开启后 Leader 失 quorum 会自动退位;ReadOnlyLeaseBased模式下必须开启 |
PreVote | 是否启用论文 9.6 节的 Pre-Vote 预投票算法,防止被网络分区隔离的旧节点回归时干扰集群 |
ReadOnlyOption | ReadOnlySafe(默认)或ReadOnlyLeaseBased |
Logger | 自定义日志器,默认为包内raftLogger |
DisableProposalForwarding | 为 true 时 Follower 丢弃而非转发提案(适用于 Leader 需为提案数据打混合逻辑时钟时间戳等场景) |
validate()(raft.go)会在启动时对以上约束做校验,包括ID != 0、HeartbeatTick > 0、ElectionTick > HeartbeatTick、Storage != nil、MaxInflightMsgs > 0以及 Lease 模式与 CheckQuorum 的绑定关系。
四、实战:搭建一个三节点集群
4.1 启动三节点集群
文档给出的三节点集群启动方式如下:每个节点持有一个本地Storage(示例为MemoryStorage),并通过peers参数声明集群中的其他节点:
storage := raft.NewMemoryStorage() c := &Config{ ID: 0x01, ElectionTick: 10, HeartbeatTick: 1, Storage: storage, MaxSizePerMsg: 4096, MaxInflightMsgs: 256, } // 设置 peer 列表为集群中其他节点(它们需要被单独启动)。 n := raft.StartNode(c, []raft.Peer{{ID: 0x02}, {ID: 0x03}})StartNode内部(node.go)会为每个 peer 追加一条EntryConfChange类型的引导日志条目,把它们标记为已提交并应用——因此调用StartNode后即可立即发起竞选(Campaign),这也是测试中常见的用法。
4.2 启动单节点集群
单节点集群让节点 0x01 自己成为 Leader:
// 创建 storage 与 config(如上)。 // peer 列表指向自己,这样本节点即可成为单节点集群的 Leader。 peers := []raft.Peer{{ID: 0x01}} n := raft.StartNode(c, peers)4.3 新节点加入既有集群
新节点不允许通过peers参数加入既有集群,正确姿势是两步走:
- 先在集群内任意既有节点上调用
ProposeConfChange把新节点加入成员配置; - 新节点以空 peer 列表启动:
// 创建 storage 与 config(如上)。 n := raft.StartNode(c, nil)4.4 从持久化状态重启节点
重启时必须先从持久层恢复快照、HardState 与日志条目到存储,再用RestartNode恢复节点(成员配置已包含在存储中,无需再传 peers):
storage := raft.NewMemoryStorage() // 从持久化快照、状态与日志条目恢复内存存储。 storage.ApplySnapshot(snapshot) storage.SetHardState(state) storage.Append(entries) c := &Config{ ID: 0x01, ElectionTick: 10, HeartbeatTick: 1, Storage: storage, MaxSizePerMsg: 4096, MaxInflightMsgs: 256, } // 不带 peer 信息重启 raft,peer 信息已包含在存储中。 n := raft.RestartNode(c)4.5 Ready 循环:使用者必须承担的职责
创建Node之后,用户有四大职责,顺序至关重要:
职责一:消费Ready()channel 并处理其中更新。其中步骤 1、2 可以并行,但步骤 2 依赖步骤 1 完成:
- 按顺序持久化
Entries、HardState、Snapshot:即先写 Entries,再写非空的 HardState 与 Snapshot。若持久层支持原子写可一次性写入。注意:写入 Index 为 i 的条目时,任何 Index >= i 的旧持久化条目必须被丢弃。 - 将全部
Messages发送到To字段指定的节点。关键约束:在最新 HardState 落盘、且上一批Ready写入的所有 Entries 完成之前,不得发送任何消息(同一批次内可以在持久化 Entries 的同时发送消息)。若消息类型为MsgSnap,发送完成后必须调用Node.ReportSnapshot()(快照消息可能很大)。注意消息序列化不是线程安全的,最稳妥的方式是在主 raft 循环内直接序列化发送。 - 将
Snapshot(若有)与CommittedEntries应用到状态机:若已提交条目类型为EntryConfChange,须调用Node.ApplyConfChange()应用之;成员变更可以在此时通过将NodeID置零来取消(但无论如何都必须调用ApplyConfChange,且取消决策只能基于状态机自身状态,不能依据外部信息如节点健康状况)。 - 调用
Node.Advance()表示已就绪接收下一批更新,可在步骤 1 之后的任意时刻调用,但所有更新必须按Ready返回的顺序处理。
职责二:让所有已持久化日志条目通过Storage接口可访问。可使用库自带的MemoryStorage(重启时重新填充),或自行实现磁盘存储。
职责三:收到网络消息后传给Node.Step:
func recvRaftRPC(ctx context.Context, m raftpb.Message) { n.Step(ctx, m) }职责四:周期性调用Node.Tick()(通常用time.Ticker)。Raft 有两大超时——心跳超时与选举超时,而在库内部时间以抽象的 "tick" 表示。
4.6 完整主循环
将上述职责组合起来,一个完整的处理循环形如:
for { select { case <-s.Ticker: n.Tick() case rd := <-s.Node.Ready(): saveToStorage(rd.State, rd.Entries, rd.Snapshot) send(rd.Messages) if !raft.IsEmptySnap(rd.Snapshot) { processSnapshot(rd.Snapshot) } for _, entry := range rd.CommittedEntries { process(entry) if entry.Type == raftpb.EntryConfChange { var cc raftpb.ConfChange cc.Unmarshal(entry.Data) s.Node.ApplyConfChange(cc) } } s.Node.Advance() case <-s.done: return } }Ready结构(node.go)中几个字段值得关注:MustSync提示应用本次HardState与Entries是否必须同步写盘(该标志由MustSync()函数依据是否有新条目、Term/Vote 是否变化计算而来,见 node.go);ReadStates则用于服务线性化只读请求(本地 applied index 追过ReadState.Index后即可安全返回)。
4.7 提交数据提案与成员变更
提交应用数据:将数据序列化为字节切片后调用Propose,提案被提交后数据会以EntryNormal类型出现在已提交条目中。注意:提案不保证一定被提交,超时后可能需要重新提案。
n.Propose(ctx, data)添加/移除节点:构建ConfChange结构体后调用ProposeConfChange;待成员变更提交后,会返回EntryConfChange类型的已提交条目,必须通过ApplyConfChange应用到节点:
n.ProposeConfChange(ctx, cc) // 提交后…… var cc raftpb.ConfChange cc.Unmarshal(data) n.ApplyConfChange(cc)ConfChange的类型在 raft.pb.go 中定义为ConfChangeAddNode/ConfChangeRemoveNode/ConfChangeUpdateNode。
⚠️ ID 使用约束:文档特别强调——一个 ID 在集群存续期内代表唯一节点,即使旧节点被移除,该 ID 也绝不能被再次使用。这解释了为何 IP 地址不适合做节点 ID(IP 可能被复用),且节点 ID 必须非零。
五、状态机模型与消息驱动源码剖析
5.1 角色状态与状态机输入输出
StateType定义了四种角色状态(raft.go):StateFollower、StateCandidate、StateLeader、StatePreCandidate。状态机由消息驱动,Step是所有消息的统一入口(raft.go),其内部先做任期(Term)处理,再按消息类型分发给对应角色的 step 函数:
- 消息 Term 更高:跟随高 Term,若为
MsgApp/MsgHeartbeat/MsgSnap则同时承认消息来源为 Leader(becomeFollower(m.Term, m.From));MsgPreVote特殊处理——不改变本地 Term; - 消息 Term 更低:开启
CheckQuorum时对MsgHeartbeat/MsgApp回复MsgAppResp以推动任期前进,否则忽略; - 本地消息(
MsgHup等):进入campaign流程发起选举。
节点内部通过step、tick两个函数指针随角色切换(becomeLeader时tick = r.tickHeartbeat,其余角色为tickElection),这正是"时间抽象为 tick"的落地方式。
5.2 选举机制:任期、随机超时与 Pre-Vote
随机化选举超时是避免选举风暴的关键:resetRandomizedElectionTimeout()将随机超时设置在[electionTimeout, 2*electionTimeout-1)区间内(raft.go)。tickElection()在超时触发时发送MsgHup开始竞选;campaign()中节点自投票后向所有对端发送MsgVote/MsgPreVote,携带自己的最后日志索引与任期(Index、LogTerm),对端通过raftLog.isUpToDate(log.go)比较日志新鲜度决定是否投票。
投票校验在Step的MsgVote/MsgPreVote分支(raft.go):仅在尚未投票或任期更高或已投给该请求者,且对方日志不落后于自己时才投赞成票;learner 节点不能投票。poll()统计票数达到quorum()(len(prs)/2 + 1,见 raft.go)即当选。
Pre-Vote 通过campaignPreElection阶段(先以Term+1试探、不递增本地 Term)避免分区节点回归时引起任期跳变与已提交数据丢失风险。
5.3 Leader 心跳、日志复制与提交
Leader 心跳与租约检查:Leader 每HeartbeatTick通过tickHeartbeat(raft.go)发送MsgBeat广播心跳;若开启CheckQuorum,每electionTimeout检查一次checkQuorumActive()——统计各节点RecentActive标记,若活跃数不足 quorum 则becomeFollower(r.Term, None)自动退位。
日志复制与确认:sendAppend()(raft.go)从pr.Next开始取日志条目发送MsgApp;若取条目失败(如已被压缩)则改发MsgSnap快照。Follower 收到MsgApp后调用handleAppendEntries(raft.go):通过raftLog.maybeAppend检查前一条日志的 Index/Term 是否匹配,匹配则追加并回复MsgAppResp{Index: mlastIndex},不匹配则回复带RejectHint(本节点最后日志索引)的拒绝,Leader 据此maybeDecrTo回退Next再补发——这就是日志冲突的"后退式(backoff)"修复机制。
提交推进:Leader 收到MsgAppResp后pr.maybeUpdate(m.Index)推进Match,然后maybeCommit()(raft.go)统计多数节点的Match值,若某索引在当前任期获得多数确认则提交(raftLog.commitTo)。Follower 侧则通过心跳消息中的Commit字段(sendHeartbeat中commit = min(Match, committed),防止越界推进)异步追赶提交索引(handleHeartbeat中raftLog.commitTo(m.Commit))。
5.4 复制状态机:Probe / Replicate / Snapshot 三态流控
每个对端节点在 Leader 侧对应一个Progress(progress.go),状态机三态如下:
| 状态 | 行为 | 触发条件 |
|---|---|---|
ProgressStateProbe | Leader 每次只发送一条Append 消息并暂停等待确认,确认后才推进Next | 节点刚加入 / 复制失败后回退 |
ProgressStateReplicate | 乐观流水线复制:发送时直接optimisticUpdate(last)推进Next,并记录在inflights窗口内;收到确认后freeTo(m.Index)释放窗口 | 首次确认成功 |
ProgressStateSnapshot | 进入快照发送流程,发送MsgSnap后暂停,等待ReportSnapshot汇报结果 | 日志已被压缩、需要发送快照 |
inflights是一个滑动窗口(progress.go):窗口满时(full())暂停发送,防止在途消息溢出应用层缓冲;窗口大小即Config.MaxInflightMsgs。这条"暂停-确认-推进"的反馈链路正是 2.3 节所述流控与乐观流水线两个增强的底层实现。
5.5 成员变更:一次一个、提交生效
成员变更是工程上最容易出错的部分。本实现遵循 Raft 论文第 4 章**"一次只变更一个节点"的关键不变量,但生效时机有所不同:变更在条目被应用(applied)时生效,而非追加到日志时生效——即成员变更条目在旧成员配置**下提交。这在安全性上等价(新旧配置必然存在重叠),实现上的约束是:
- 不安全的并发变更被直接禁止:Leader 日志中一旦存在未提交的成员变更(
pendingConf),后续MsgProp中的EntryConfChange会被降级为EntryNormal丢弃(见 raft.go); - 成为 Leader 时校验:
becomeLeader会检查未提交条目中配置变更数量,多于 1 个直接 panic(raft.go),防止不同 quorum 需求的变更同时提交; - 应用侧在
Ready.CommittedEntries中看到EntryConfChange后调用ApplyConfChange,该调用最终驱动addNode/removeNode更新prs集合(raft.go)。
⚠️ 两节点集群的移除陷阱:若两节点集群中某成员在另一成员收到 confchange 提交前死亡,集群将无法继续推进(quorum 不足),成员便无法被移除——因此文档强烈建议任何集群都应使用三个或以上节点。
5.6 日志与存储层:raftLog 的职责划分
raftLog(log.go)在稳定存储(Storage)与不稳定内存缓冲(unstable)之间划分职责:
unstable:保存尚未持久化的新条目与快照。应用通过Ready.Entries拿到它们并落盘,随后Advance()触发node.run中的stableTo/stableSnapTo将其移交稳定存储(node.go);Storage接口(storage.go)暴露InitialState、Entries(lo,hi,maxSize)、Term、LastIndex、FirstIndex、Snapshot六个方法。若任一方法返回错误,raft 实例将不可操作并拒绝参与选举,清理与恢复由应用负责;MemoryStorage是库自带的纯内存实现,文档示例均使用它;它内部以ents[i]对应日志位置i+snapshot.Metadata.Index,并预留 term 0 的占位条目(storage.go)。生产环境通常需要自行实现磁盘存储;nextEnts()(log.go)返回(applied, committed]区间内待应用到状态机的条目,由newReady填进Ready.CommittedEntries(node.go)。
5.7 只读查询的源码级流程
结合 read_only.go 与 raft.go,两种只读方案的内部流程如下:
ReadOnlySafe:Leader 收到MsgReadIndex后addRequest登记请求,并通过bcastHeartbeatWithCtx广播携带请求上下文的特殊心跳;对端回复MsgHeartbeatResp时携带该上下文,recvAck累计确认数,达到 quorum 后advance出队这批请求并返回ReadState(本地请求)或MsgReadIndexResp(远端请求)。用户使用Ready.ReadStates中的RequestCtx匹配自己的请求,本地 applied index 超过ReadState.Index后即可安全读本地状态机。ReadOnlyLeaseBased:Leader 收到读请求时直接生成ReadState{Index: committed}放回readStates,零额外网络往返;Follower 则向 Leader 请求读索引并等MsgReadIndexResp。该模式依赖集群时钟同步,源码在 raft.go 注释中明确警告:时钟漂移无界时读索引不再安全。
六、设计权衡与适用边界
6.1 极简设计的两面性
- 灵活性:存储与传输完全外包,可接入任意 RPC 框架、任意磁盘方案,甚至可以在单进程内模拟多节点(测试友好);
- 确定性:纯消息驱动、输入输出可复现,便于形式化测试与故障注入;
- 代价:应用层必须正确实现 4.5 节的全部职责,尤其是HardState 落盘先于消息发送这一安全不变量,一旦违反可能导致数据丢失或脑裂风险。
6.2 单 goroutine 事件循环
node.run()(node.go)以单 goroutine 串行处理全部提案、消息、tick 与 Ready/Advance,通过 channel 与外部通信(propc/recvc/confc/tickc/readyc/advancec等,其中tickc为容量 128 的缓冲 channel,忙时可缓冲 tick)。这一模型天然免除了 raft 内部状态的数据竞争——用户只需保证同一时刻只有一个 goroutine 读写Node。若希望完全掌控事件循环(例如嵌入已有单线程框架),可使用RawNode(rawnode.go)——一个线程不安全但更轻量的同步 API,HasReady()配合Ready()/Advance(rd)手动驱动。
6.3 使用场景
该库已在众多分布式系统中投入使用,本文档列举的代表性用户包括:CockroachDB(可扩展、可存活、强一致的 SQL 数据库)、Dgraph(分布式图数据库)、etcd 本身(分布式 KV)、TiKV(基于 Raft 的分布式事务 KV)、Docker SwarmKit(任意规模分布式系统编排工具包,docker-ce 生态的重要组成部分)、Chain Core(许可制多资产区块链)。这些系统形态迥异,却共享同一套由本库提供的共识核心,印证了其"只做算法、外包 IO"设计哲学的普适性。
七、学习与实践建议
- 从 MemoryStorage 起步:先在内存环境跑通 4.5 节 的完整 Ready 循环,再逐步替换为自定义磁盘存储与真实网络传输;
- 善用确定性模型:相同输入必得相同输出的性质意味着你可以把消息序列录制下来做离线重放调试;
- 遵循文档的安全警告:三节点起步、ID 不复用、HardState 先落盘、消息不晚发,是四道不可逾越的安全红线;
- 深入阅读源码:以 raft.go、node.go、log.go、progress.go 为顺序通读,可对论文中的抽象概念与工程代码形成一一对应,这是掌握 Raft 实现的最短路径。
- 容器运行时
- 云原生
【免费下载链接】docker-ce
:warning: This repository is deprecated and will be archived (Docker CE itself is NOT deprecated) see the https://github.com/docker/docker-ce/blob/master/README.md :warning:
相关推荐
深入SubtitleEdit核心库:libse架构与API使用
深入SubtitleEdit核心库:libse架构与API使用 本文详细解析了SubtitleEdit核心库libse的架构设计与API使用。libse采用分层
音视频桌面应用PostCSS源码解析:深入核心库架构设计与实现原理
PostCSS源码解析:深入核心库架构设计与实现原理 PostCSS是一个强大的CSS处理工具,它通过JavaScript插件系统实现了CSS的现代化转换和处理
前端前端构建深入LZ4核心库:API设计与实现架构
深入LZ4核心库:API设计与实现架构 本文深入分析了LZ4压缩算法库的核心架构设计,重点解析了其高度模块化的组织结构和API设计理念。LZ4库主要由四个核心模
存储系统编程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考