第一章:Go调用C代码的底层机制与安全边界
Go 通过 cgo 实现与 C 代码的互操作,其本质是在 Go 运行时(runtime)与 C 运行时(如 libc)之间建立受控的桥梁。该机制并非简单链接,而是依赖于一套精密的协作协议:Go 编译器将含 import "C" 的源文件预处理为 C 和 Go 双重目标代码;cgo 工具生成 glue code,封装 C 函数调用为 Go 可调用的包装函数,并自动处理内存所有权移交、栈切换及 goroutine 与 C 线程的调度隔离。
C 与 Go 的内存边界
- Go 的堆由 GC 管理,C 的堆由
malloc/free管理,二者不可混用 C.CString()分配的内存必须显式调用C.free()释放,否则泄漏- Go 字符串和切片传入 C 前需转换为
*C.char或unsafe.Pointer,且需确保底层数据在调用期间不被 GC 回收(可使用runtime.KeepAlive()或复制到 C 内存)
调用 C 函数的典型流程
/*
#cgo LDFLAGS: -lm
#include <math.h>
*/
import "C"
import "unsafe"
func Sqrt(x float64) float64 {
// Go float64 → C double → C library call → C double → Go float64
return float64(C.sqrt(C.double(x)))
}
执行逻辑:cgo 将 C.sqrt 解析为动态链接符号,生成汇编 stub 完成 ABI 转换(如寄存器映射、浮点调用约定),并在必要时暂停当前 goroutine 的 GC 扫描,防止 C 栈帧中引用的 Go 对象被误回收。
安全约束与风险清单
| 风险类型 | 后果 | 规避方式 |
|---|---|---|
C 中调用 Go 函数未加 //export |
编译失败或符号未定义 | 所有导出函数前必须添加 //export FuncName 注释 |
| 在 C 线程中直接调用 Go 代码 | 运行时 panic(”not allowed in C code”) | 使用 runtime.LockOSThread() + C.go_func() 模式,或改用 channel 异步通信 |
长时间阻塞 C 函数(如 read()) |
阻塞整个 OS 线程,降低并发吞吐 | 用 runtime.SetFinalizer 或 C.siginterrupt 配合非阻塞 I/O 替代 |
任何跨语言调用都隐含 ABI、内存模型与生命周期语义的错位,cgo 提供的是“受监管的越界通行”,而非无界融合。
第二章:内存管理失控——最隐蔽的崩溃源头
2.1 C内存分配与Go GC生命周期的冲突原理与实测案例
当 Go 程序通过 C.malloc 分配内存并交由 C 代码长期持有时,Go 的垃圾收集器(GC)无法感知该内存的存活状态——因其位于 Go 堆外,且无指针引用路径。
冲突本质
- Go GC 仅扫描 Go 堆及栈上的指针;
C.malloc返回的*C.void是纯数值地址,不参与写屏障跟踪;- 若 Go 变量(如
unsafe.Pointer)未被显式保留,可能被 GC 回收后仍被 C 侧解引用 → use-after-free。
实测崩溃片段
// cgo_test.c
#include <stdlib.h>
char* create_buffer() {
char* buf = (char*)malloc(1024);
for(int i=0; i<10; i++) buf[i] = 'A' + i;
return buf; // 持有所有权
}
// main.go
/*
#cgo LDFLAGS: -L. -ltest
#include "cgo_test.h"
*/
import "C"
import "unsafe"
func crashDemo() {
ptr := C.create_buffer()
// ⚠️ 此处无 Go 变量持有 ptr,GC 可能在任意时刻触发
C.free(ptr) // 若 GC 已回收关联的 Go runtime 元数据,free 可能失败
}
逻辑分析:
C.create_buffer()返回裸指针,Go 运行时无引用计数或 finalizer 关联;若ptr未赋值给全局变量或传入runtime.SetFinalizer,其生命周期完全脱离 GC 控制。参数ptr本质是uintptr,不触发写屏障,导致 GC 误判为“不可达”。
| 场景 | GC 是否感知 | 风险等级 |
|---|---|---|
C.malloc + C.free 在单函数内 |
否 | 中 |
C.malloc 后转为 []byte(via C.GoBytes) |
是(副本) | 低 |
C.malloc 后用 runtime.SetFinalizer 绑定释放 |
是(需手动管理) | 中高 |
graph TD
A[Go 调用 C.malloc] --> B[内存位于 C 堆]
B --> C[Go GC 扫描堆/栈]
C --> D{发现无 Go 指针指向该地址?}
D -->|是| E[标记为可回收]
D -->|否| F[保留]
E --> G[调用 C.free 时内存已失效]
2.2 字符串传参中CString泄漏与非法释放的调试复现
常见错误模式
当 CString 以值传递方式进入函数,且内部调用 GetBuffer() 后未配对 ReleaseBuffer(),易触发引用计数异常与堆块重复释放。
复现代码示例
void DangerousFunc(CString str) {
LPTSTR p = str.GetBuffer(256); // 引用计数未增,p指向共享缓冲区
_tcscpy_s(p, 256, _T("modified"));
str.ReleaseBuffer(); // ✅ 正确配对
// 但str在函数退出时析构,再次释放同一缓冲区 → 非法释放!
}
逻辑分析:CString 值参导致内部 CStringData 引用计数未增加;GetBuffer() 不复制数据,仅返回原始指针;函数末尾 str 析构时二次释放已释放内存。参数 str 是副本,但共享底层 CStringData。
调试验证要点
- 使用 Application Verifier 启用
Heap选项捕获非法释放 - 在
CString::~CString()中设置条件断点(this->m_pchData != nullptr)
| 场景 | 是否泄漏 | 是否非法释放 |
|---|---|---|
| 值传参 + GetBuffer/ReleaseBuffer配对 | 否 | 是(析构重释放) |
| 引用传参 + GetBuffer未ReleaseBuffer | 是(缓冲区残留) | 否 |
graph TD
A[调用DangerousFunc\lCString str] --> B[拷贝构造:m_pchData共享]
B --> C[GetBuffer:返回原始指针]
C --> D[ReleaseBuffer:标记长度]
D --> E[函数返回:str析构→delete m_pchData]
E --> F[同一内存被二次释放]
2.3 Go切片与C数组双向传递时的底层数组逃逸陷阱
Go切片与C数组互操作时,unsafe.Slice() 和 C.GoBytes() 等桥接操作易触发底层数组意外逃逸至堆,破坏零拷贝预期。
数据同步机制
当使用 (*[1<<30]byte)(unsafe.Pointer(&cArray[0]))[:len:] 转换C数组为Go切片时,若该切片被闭包捕获或传入非内联函数,编译器将判定其生命周期超出栈帧——底层数组被迫逃逸。
// ❌ 危险:cArray可能被释放,而goSlice仍持有其地址
func badBridge(cArray *C.char, n C.int) []byte {
return unsafe.Slice((*byte)(unsafe.Pointer(cArray)), int(n))
}
此处
unsafe.Slice返回的切片直接引用C堆内存,但Go运行时无法追踪C端生命周期;若后续调用runtime.KeepAlive(cArray)缺失,GC可能提前回收关联资源。
逃逸分析对照表
| 场景 | 是否逃逸 | 原因 |
|---|---|---|
| 切片仅在函数内使用并返回副本 | 否 | 栈上分配,无跨帧引用 |
| 切片作为参数传入接口值或闭包 | 是 | 编译器无法证明其存活期 ≤ 调用栈 |
graph TD
A[C数组] -->|unsafe.Slice| B[Go切片]
B --> C{是否逃逸?}
C -->|被接口/全局变量捕获| D[底层数组逃逸到堆]
C -->|纯局部使用| E[保持栈分配]
2.4 C回调函数中访问已回收Go变量的竞态复现与Valgrind验证
竞态触发场景
当Go通过C.register_callback(cb)向C注册回调,且cb捕获了局部Go变量(如&data)后,若Go侧函数返回、变量被GC回收,而C线程仍调用该回调——即发生use-after-free。
复现代码片段
// C side: callback invoked after Go stack unwind
void go_callback(void* ptr) {
int* p = (int*)ptr;
printf("Value: %d\n", *p); // ❗ Dangling dereference
}
ptr指向已被GC回收的Go堆内存;*p触发未定义行为,Valgrind标记为Invalid read of size 4。
Valgrind关键输出
| 错误类型 | 地址 | 上下文 |
|---|---|---|
| Invalid read | 0x…a120 | in go_callback at cb.c:5 |
| Address not stack/malloc | — | Freed by Go runtime |
内存生命周期图
graph TD
A[Go函数分配data] --> B[传&data给C]
B --> C[C保存ptr]
A --> D[函数返回→data可回收]
D --> E[GC回收data内存]
C --> F[C线程调用go_callback]
F --> G[读已释放地址→Valgrind报错]
2.5 使用cgo -godebug=gcflags精准定位内存越界访问点
当 C 代码通过 cgo 调用时发生内存越界(如 buffer[10] 访问长度为 5 的数组),Go 运行时默认不捕获此类错误。启用 -godebug=gcflags=-d=checkptr 可激活指针有效性检查:
GODEBUG=gctrace=1,cgocheck=2 go run -gcflags="-d=checkptr" main.go
cgocheck=2启用严格模式(检查跨 Go/C 边界的指针转换),-d=checkptr强制在每次指针解引用前验证内存归属。
关键检查维度
- 检查 C 指针是否指向 Go 分配的内存(反之亦然)
- 验证偏移量未超出原始分配边界
- 拦截
C.CString()后未C.free()导致的悬垂引用
典型报错示例
| 条件 | 触发场景 | 错误信息片段 |
|---|---|---|
| 越界读 | &slice[10](len=5) |
checkptr: pointer arithmetic on go string |
| 跨域写 | (*C.int)(unsafe.Pointer(&x)) 写入 Go struct 字段 |
checkptr: unsafe.Pointer conversion |
// 示例:触发 checkptr 失败的 cgo 调用
func badAccess() {
s := []byte("hello")
// ⚠️ 越界:s[10] 不存在,且 C 代码可能直接解引用
C.process_bytes((*C.uchar)(unsafe.Pointer(&s[0])), C.size_t(10))
}
该调用会立即 panic 并打印精确的源码位置与越界偏移量,无需依赖 ASan 或 Valgrind。
第三章:线程与运行时模型错配
3.1 C库依赖主线程(如OpenGL/X11)在Goroutine中调用的死锁实操分析
C语言绑定的图形库(如GLX、X11)要求所有API调用必须发生在创建上下文的原始线程中。Go runtime 的 goroutine 调度器不保证线程亲和性,跨 goroutine 直接调用 glClear() 或 XSync() 将触发未定义行为,常见表现为静默挂起。
线程绑定失效场景
- Go 运行时可能将 goroutine 迁移至任意 OS 线程
- X11 客户端连接句柄(
Display*)非线程安全 - OpenGL 上下文仅对创建它的
pthread_t有效
典型死锁复现代码
// cgo_export.go 中导出的 C 函数
/*
#include <GL/gl.h>
#include <X11/Xlib.h>
void unsafe_gl_call(Display *d, GLXContext ctx) {
XLockDisplay(d); // 阻塞:若 d 在另一线程初始化,此处永久等待
glXMakeCurrent(d, 0, ctx);
glClear(GL_COLOR_BUFFER_BIT);
XUnlockDisplay(d);
}
*/
XLockDisplay()内部使用线程局部锁(TLS-based mutex),仅对初始化Display*的线程释放;goroutine 若被调度到其他线程,将永远阻塞在XLockDisplay。
正确实践对照表
| 方案 | 是否线程安全 | Goroutine 友好 | 备注 |
|---|---|---|---|
runtime.LockOSThread() + 主线程调用 |
✅ | ❌(需手动管理) | 必须配对 UnlockOSThread |
| 专用 OS 线程+消息队列 | ✅ | ✅ | 推荐:通过 channel 向固定线程投递渲染任务 |
| CGO_CFLAGS=”-DGLX_THREAD_SAFE” | ⚠️(仅部分驱动支持) | ❌ | 不解决 X11 锁问题 |
graph TD
A[Goroutine 调用 OpenGL] --> B{是否绑定到创建上下文的 OS 线程?}
B -->|否| C[XLockDisplay 阻塞]
B -->|是| D[正常执行]
C --> E[死锁]
3.2 CGO_CALLING_THREAD标志误用导致的调度器阻塞现场还原
当 Go 运行时在 runtime.cgocall 中错误地将 CGO_CALLING_THREAD 标志持续置位(而非仅在 C 函数调用入口/出口瞬时设置),会导致 m(OS 线程)被永久标记为“正在执行 C 代码”,从而跳过调度器的抢占检查与 Goroutine 抢占逻辑。
数据同步机制
该标志本应配合 m.lockedg 和 g.status == _Gsyscall 原子协同更新,但误用后引发状态不一致:
// 错误示例:在C函数返回后未清除标志
runtime.cgocall(fn, arg)
// ❌ 遗漏:atomic.Store(&mp.cgoCallers, 0)
mp.cgoCallers 非零会抑制 checkpreemptMSpan 调用,使 m 无法响应 sysmon 的抢占信号。
调度阻塞链路
graph TD
A[sysmon 检测长时间运行 m] --> B{mp.cgoCallers == 0?}
B -- 否 --> C[跳过 preemptM]
C --> D[该 m 永远不被抢占]
D --> E[Goroutine 饿死]
关键修复点
- 必须在
cgocall返回路径中严格配对cgoCallers++/-- - 使用
runtime·asmcgocall汇编桩确保标志清理的原子性与不可绕过性
3.3 在C线程中调用Go函数时runtime.LockOSThread的必要性验证
当C线程(非Go runtime创建)直接调用导出的Go函数时,Go运行时默认可能将该goroutine调度到其他OS线程,导致:
- CGO调用栈不一致
- TLS(线程局部存储)错位,如
errno、pthread_getspecific失效 C.current_thread_id()与Go中runtime.GoroutineID()归属失配
关键验证逻辑
// export goHandler
func goHandler() {
runtime.LockOSThread() // 绑定当前OS线程至本goroutine
defer runtime.UnlockOSThread()
// 此后所有C调用均稳定运行于同一OS线程
}
runtime.LockOSThread()使当前goroutine与调用它的OS线程永久绑定,避免被M:P调度器迁移;defer UnlockOSThread()确保资源可释放(但通常C线程生命周期长,常省略解锁)。
典型错误行为对比
| 场景 | 是否调用LockOSThread | C线程内多次调用Go函数结果 |
|---|---|---|
| 否 | ❌ | goroutine跳转OS线程,TLS丢失、信号处理异常 |
| 是 | ✅ | 线程上下文稳定,C.pthread_self()始终一致 |
graph TD
A[C线程调用goHandler] --> B{runtime.LockOSThread?}
B -->|否| C[goroutine被调度到其他OS线程]
B -->|是| D[绑定成功,OS线程ID恒定]
C --> E[errno覆盖、cgo回调失败]
D --> F[安全访问TLS/信号/FFI资源]
第四章:符号链接与构建环境陷阱
4.1 动态库版本不匹配导致undefined symbol的ldd+readelf联合诊断流程
当程序运行报 undefined symbol: foo_bar,常因运行时加载的 .so 版本缺失该符号——而非编译期问题。
核心诊断双工具链
ldd ./app:定位实际加载的动态库路径与版本readelf -sW /path/to/libxxx.so | grep foo_bar:验证目标符号是否存在及绑定状态
典型误配场景
- 应用链接
libnet.so.2(含net_init@GLIBC_2.34) - 系统仅提供
libnet.so.1(无该符号,且无向后兼容SONAME重定向)
# 步骤1:确认运行时加载库版本
$ ldd ./client | grep libnet
libnet.so.2 => /usr/local/lib/libnet.so.2 (0x00007f8a1c200000)
ldd显示运行时解析路径;若显示not found或指向旧版路径(如/lib64/libnet.so.1),即为版本错配起点。
# 步骤2:检查符号定义与可见性
$ readelf -sW /usr/local/lib/libnet.so.2 | grep net_init
123: 00000000000045a0 42 FUNC GLOBAL DEFAULT 13 net_init@LIBNET_1.2
-sW输出全符号表;GLOBAL DEFAULT表明导出有效;末尾@LIBNET_1.2是版本节点——若应用期望@LIBNET_2.0则仍会失败。
关键判断依据
| 工具 | 关注点 | 异常信号 |
|---|---|---|
ldd |
实际加载路径与 SONAME | 显示 not found 或旧版路径 |
readelf -s |
符号存在性、绑定类型、版本节点 | 缺失条目、UND 类型、版本不匹配 |
graph TD
A[运行时报 undefined symbol] --> B{ldd ./app}
B -->|定位 libxxx.so.N| C[/readelf -sW libxxx.so.N/]
C --> D{符号存在?}
D -->|否| E[库未升级/安装错误]
D -->|是| F{版本节点匹配?}
F -->|否| G[需更新应用或启用 version script]
4.2 #cgo LDFLAGS中-rpath与$ORIGIN混用引发的运行时加载失败复现
当在 #cgo LDFLAGS 中混合使用 -rpath 和 $ORIGIN 时,动态链接器可能因路径解析顺序冲突而跳过预期路径:
#cgo LDFLAGS: -Wl,-rpath,'$ORIGIN/../lib' -Wl,-rpath,/usr/local/lib
逻辑分析:
$ORIGIN是相对路径占位符,需由链接器在运行时展开;但若-rpath出现多次,glibc 会按命令行顺序优先匹配(非覆盖),导致/usr/local/lib被先查,而目标.so实际位于./lib/下,引发dlopen: file not found。
常见错误链路如下:
graph TD
A[Go 程序启动] --> B[ld.so 加载 main]
B --> C[解析 DT_RPATH/DT_RUNPATH]
C --> D[按 LDFLAGS 顺序遍历 rpath 条目]
D --> E[首条 /usr/local/lib 无目标 so]
E --> F[跳过 $ORIGIN/../lib,直接报错]
关键参数说明:
$ORIGIN:代表可执行文件所在目录,不可被单引号包裹(否则失效);-Wl,:将参数透传给 linker;- 多
-rpath条目不会合并,而是形成有序搜索队列。
| 场景 | 是否生效 | 原因 |
|---|---|---|
-rpath '$ORIGIN/lib' |
❌ | 单引号阻止 shell 展开,$ORIGIN 字面传递 |
-rpath "$ORIGIN/lib" |
✅ | 双引号允许变量展开(需构建时环境支持) |
-rpath $ORIGIN/lib |
✅ | 无引号,由 shell 展开后传入 linker |
4.3 静态链接musl libc时cgo禁用与CGO_ENABLED=0的兼容性破坏实验
当使用 musl-gcc 静态链接 musl libc 时,CGO_ENABLED=0 会意外导致构建失败——因 Go 编译器在该模式下彻底剥离 cgo 调用路径,而某些 musl 静态链接场景(如 net 包 DNS 解析)仍隐式依赖 cgo 符号解析逻辑。
失败复现命令
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-linkmode external -extldflags '-static'" main.go
⚠️ 错误:
undefined reference to 'getaddrinfo'——CGO_ENABLED=0禁用 cgo 后,Go 使用纯 Go net 实现,但-linkmode external -static强制调用 musl 的静态符号,产生 ABI 冲突。
兼容性矩阵
| CGO_ENABLED | -ldflags | 结果 |
|---|---|---|
| 1 | -linkmode external -extldflags '-static' |
✅ 成功 |
| 0 | (任意) | ❌ 缺失符号 |
graph TD
A[CGO_ENABLED=0] --> B[跳过 cgo 初始化]
B --> C[net.LookupIP 使用纯 Go DNS]
C --> D[但 -extldflags '-static' 触发链接器查找 musl 符号]
D --> E[符号未定义:链接失败]
4.4 macOS上dylib签名失效与codesign –deep绕过方案的生产级验证
当嵌套动态库(如 App.app/Contents/Frameworks/Helper.framework/Versions/A/Helper)被第三方工具修改后,其签名链断裂导致 dlopen 失败。典型报错:code signature invalid for '/path/to/dylib'。
根本原因分析
macOS Gatekeeper 验证采用递归签名校验,--deep 并非“绕过”,而是强制重建完整签名树:
# 重建全路径签名(含嵌套 dylib、framework、plugins)
codesign --force --sign "Developer ID Application: XXX" \
--options runtime \
--timestamp \
--deep \
MyApp.app
--deep启用递归签名遍历;--options runtime启用 hardened runtime;--timestamp确保离线有效性。缺一将导致公证(Notarization)失败。
生产验证关键项
| 检查项 | 命令 | 期望输出 |
|---|---|---|
| 签名完整性 | codesign -v MyApp.app |
valid on disk |
| 嵌套库覆盖 | codesign -dvvv MyApp.app/Contents/Frameworks/*.dylib |
designated => ... 包含主 bundle ID |
graph TD
A[MyApp.app] --> B[Frameworks/Dep.framework]
B --> C[Versions/A/Dep]
C --> D[Resources/libhelper.dylib]
style A fill:#4CAF50,stroke:#388E3C
style D fill:#f44336,stroke:#d32f2f
第五章:避坑指南与现代化替代路径
常见的 YAML 配置陷阱与修复实践
在 Kubernetes 生产环境中,replicas: 0 被误置于 Deployment 的 spec.template.spec.containers 下(而非 spec.replicas),导致 Pod 永远无法启动。正确结构应严格遵循 OpenAPI v3 Schema:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 3 # ✅ 正确位置
template:
spec:
containers:
- name: app
image: nginx:1.25
# replicas: 0 ❌ 错误嵌套——Kubernetes 忽略该字段且无报错
Node.js 后端服务中未处理的 Promise 拒绝链
某电商订单微服务因未捕获 async/await 中的异常,导致 12% 的支付回调请求静默失败。日志中仅出现 UnhandledPromiseRejectionWarning,无堆栈追踪。修复方案采用统一错误拦截中间件 + process.on('unhandledRejection') 兜底:
app.use(async (ctx, next) => {
try { await next(); }
catch (err) {
ctx.status = 500;
ctx.body = { error: 'Internal server error' };
logger.error('Order handler failed', { err, trace: err.stack });
}
});
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('Uncaught rejection', { reason, promise: promise.toString() });
process.exit(1);
});
过度依赖 Docker Hub 公共镜像的风险案例
2023 年某团队使用 python:3.9-slim 镜像构建 CI 流水线,因 Docker Hub 临时限流(每 IP 每小时 200 次拉取),导致 73% 的流水线超时失败。迁移至私有 Harbor 镜像仓库后,平均构建耗时下降 41%,且支持镜像签名验证与漏洞扫描集成。
现代化替代路径对比表
| 技术栈 | 传统方案 | 推荐替代方案 | 关键收益 | 迁移成本评估 |
|---|---|---|---|---|
| 日志收集 | Filebeat + Logstash | OpenTelemetry Collector | 统一指标/日志/链路三合一,降低运维复杂度 | 中(需重写 pipeline) |
| 前端状态管理 | Redux + Redux-Thunk | Zustand + TanStack Query | 减少样板代码 68%,SSR 支持开箱即用 | 低 |
| 数据库连接池 | HikariCP(Java) | R2DBC + Connection Pool | 支持非阻塞 I/O,QPS 提升 3.2 倍(压测数据) | 高(需重构 DAO 层) |
使用 Mermaid 可视化技术债演进路径
flowchart LR
A[遗留系统:Spring Boot 2.3 + MyBatis] -->|2022 Q3| B[引入 OpenTelemetry SDK]
B -->|2023 Q1| C[替换 Logback 为 SLF4J + OTel Appender]
C -->|2023 Q4| D[迁移至 Spring Boot 3.2 + Jakarta EE 9+]
D -->|2024 Q2| E[全链路异步化:WebFlux + R2DBC]
style A fill:#ffebee,stroke:#f44336
style E fill:#e8f5e9,stroke:#4caf50
云原生配置中心迁移踩坑实录
某金融客户将 Consul 配置迁移到 Nacos 时,未适配其 namespace 隔离机制,导致测试环境配置意外覆盖生产环境。根本原因在于 Nacos 的 group 字段默认值为 DEFAULT_GROUP,而 Consul 无等效概念。解决方案:在 bootstrap.yml 中强制指定 namespace: prod,并增加自动化校验脚本:
curl -s "http://nacos:8848/nacos/v1/cs/configs?dataId=app.yaml&group=DEFAULT_GROUP&tenant=prod" \
| jq -e '. | contains("spring:")' > /dev/null || exit 1
构建缓存污染问题排查方法论
在 GitHub Actions 中启用 actions/cache@v3 后,Node.js 项目频繁出现 Cannot find module 'lodash' 错误。根源是 node_modules 缓存未绑定 package-lock.json 的 SHA256 哈希值。修正后配置:
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 