Compare commits

...
3 Commits
Author SHA1 Message Date
labmem e4fc4af912 feat(lyra): 添加 PostgreSQL 初始化示例
- 定义用户、分类和链接表,包含密码哈希字段
- 添加数据约束、索引及更新时间触发器
2026-10-16 17:40:01 +08:00
labmem d63b006135 feat: 添加 chi 练习与 Gitea CI 实践
- 新增 chi 路由示例并更新依赖
- 用简单 CI 实践笔记替换 Polaris 部署笔记
- 添加本地 Gitea 与 runner 配置示例,注册令牌从环境变量读取
2026-10-16 15:02:25 +08:00
labmem 3280c8a7bc docs(lyra): 记录技术选型与分层架构
- 记录 Gin、PostgreSQL、Squirrel、sqlx 和 Goose 选型
- 明确 handler、service、model 调用链及 API 职责边界
- 整理目标目录结构、待定事项与后续验证要求
2026-10-16 14:52:32 +08:00
10 changed files with 877 additions and 331 deletions
+1
View File
@@ -5,6 +5,7 @@ go 1.27.0
require ( require (
github.com/gin-contrib/sessions v1.1.2 github.com/gin-contrib/sessions v1.1.2
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-chi/chi/v5 v5.5.0
golang.org/x/crypto v0.56.0 golang.org/x/crypto v0.56.0
) )
+2
View File
@@ -17,6 +17,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg=
github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
})
http.ListenAndServe(":30000", r)
}
+71
View File
@@ -0,0 +1,71 @@
# Lyra 目标结构
日期:2026-10-16
本文记录已确认的目标目录结构与职责边界。Lyra 当前仅有 go.mod 和文档,代码尚未实施;以下文件为计划形态,不代表已存在。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md)。
## 已确认技术栈
Gin(HTTP 路由)、PostgreSQL、Squirrel + sqlx(数据访问,pgx stdlib 驱动)、Goose(数据库迁移)。
## 目录结构
```text
lyra/
├── cmd/lyra/main.go # 启动、依赖组装、退出清理
├── internal/
│ ├── api/ # HTTP 请求/响应 DTO,按资源分文件
│ │ ├── links.go # links 资源示例(下同)
│ │ ├── categories.go
│ │ └── settings.go
│ ├── config/
│ │ └── config.go # 配置定义与加载
│ ├── postgres/
│ │ └── postgres.go # 数据库连接
│ ├── router/
│ │ └── router.go # 路由注册、项目路径前缀
│ ├── handler/
│ │ └── links.go # 参数绑定、DTO 转换、响应输出
│ ├── service/
│ │ ├── links.go # 业务规则、事务协调
│ │ └── links_test.go # 测试与源文件同目录
│ ├── model/
│ │ ├── link.go # Link 数据结构定义
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(示例)
│ │ └── link_model_test.go # 查询测试(示例)
│ └── httpx/
│ └── response.go # 公共响应辅助,按需实现
├── migrations/ # Goose SQL 迁移文件
├── docs/
│ ├── plans/
│ └── architecture.md # 本文
├── README.md
├── go.mod
└── go.sum # 引入依赖后由 Go 工具生成
```
- 标注示例的文件展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。model/link.go 定义 Link 数据结构,model/link_model.go 定义数据访问与 SQL,model/link_model_test.go 测试查询,均为示例,实际文件随实现新增。
- 只创建实际需要的文件;不预建空目录或空测试文件,具体迁移文件名随首个迁移确定。
## 组织原则
- 不按业务项目建代码子目录(无 polaris 等项目目录);项目通过 `/api/polaris` 等路由前缀区分,代码按技术职责分组,包内文件按资源划分。
- 不保留 platform/modules 分类层;配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx。
- 简单接口不强制走完整三层,也不按每张表机械复制层级。
## 层间边界
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码。
- handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。
- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib;不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。
- service/model 使用标准 context.Context,不接收 gin.Context。
## 尚未确定
以下仍为计划中的推荐或待确认项,未定为已决:
- 代码尚未实施,也未进行数据库连接或结构修改。
- Goose 的部署执行入口与并发控制(串行执行或数据库锁)方案。
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
- 日志方案(候选标准库 slog)与中间件集成方式。
+130
View File
@@ -0,0 +1,130 @@
-- Polaris 导航站 PostgreSQL 新库初始化示例(非幂等,仅执行一次)。
BEGIN;
-- updated_at 维护函数
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- 用户表
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
);
COMMENT ON TABLE public.users IS '后台管理用户表';
COMMENT ON COLUMN public.users.id IS '用户 ID';
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
-- 分类表
CREATE TABLE public.categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
icon text NOT NULL,
sort integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
);
COMMENT ON TABLE public.categories IS '链接分类表';
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
COMMENT ON COLUMN public.categories.title IS '分类标题';
COMMENT ON COLUMN public.categories.description IS '分类描述';
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
-- 链接表
CREATE TABLE public.links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id bigint NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
url text NOT NULL,
icon text,
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
sort integer NOT NULL DEFAULT 0,
pinned boolean NOT NULL DEFAULT false,
pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk
FOREIGN KEY (category_id) REFERENCES public.categories (id)
ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension
CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CONSTRAINT links_keywords_no_null_elements
CHECK (array_position(keywords, NULL) IS NULL)
);
COMMENT ON TABLE public.links IS '链接表,归属分类';
COMMENT ON COLUMN public.links.id IS '链接 ID';
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
COMMENT ON COLUMN public.links.title IS '链接标题';
COMMENT ON COLUMN public.links.description IS '链接描述';
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
COMMENT ON COLUMN public.links.created_at IS '创建时间';
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
-- 索引
CREATE INDEX categories_order_idx
ON public.categories (sort, id);
CREATE INDEX links_category_order_idx
ON public.links (category_id, sort, id);
CREATE INDEX links_pinned_order_idx
ON public.links (pinned_sort, id)
WHERE pinned;
CREATE UNIQUE INDEX links_normalized_url_unique
ON public.links (rtrim(url, '/'));
-- 触发器
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER links_set_updated_at
BEFORE UPDATE ON public.links
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
COMMIT;
@@ -0,0 +1,123 @@
# Lyra 技术选型与架构讨论记录
日期:2026-10-16
状态:HTTP 路由、数据访问、数据库迁移工具与目标目录结构已确认(结构见 [architecture.md](../architecture.md));本文档方案已获文档层面批准,代码实施尚未批准。
## 已确认
- Lyra 是个人 Go 后端,先承接 Polaris,未来承接其他项目接口。
- Polaris 采用一人管理、匿名公开浏览的模式。
- 已有服务器和 PostgreSQL;具体部署与连接配置尚未核实。
- 数据访问选择 Squirrel + sqlx,PostgreSQL 驱动使用 pgx stdlib。
- HTTP 路由选择 Gin;用户无明显接口风格偏好,结合文档与开发便利性作出选择。
- 数据库迁移工具选择 Goose。
- 本次确认仅涉及选型,不代表批准搭建代码或接入真实数据库。
## 数据访问约定
- 固定查询直接写 SQL,动态查询使用 Squirrel,统一由 sqlx 执行。
- Squirrel 使用 Dollar 占位符,普通值使用参数绑定,动态标识符采用服务端白名单。
- 使用带 Context 的执行方法;需要原子性的业务操作使用事务。
- 显式声明查询字段,处理可空值和 JOIN 列名冲突。
- 不同时引入 sqlc 或 golang-migrate。
- 已讨论的取舍:无代码生成步骤,但查询与结果映射主要在运行时校验;上游维护低频。
## 当前实现状态
- Lyra 仅有 go.mod 与 docs/ 下的文档,尚无接口、数据库或业务代码。
- Polaris 的 services 仍同步读取本地 JSON,未接入 Lyra。
- 未进行数据库连接或数据库结构修改。
## 架构建议(尚未定案)
- 一个 Go 服务;根据用户不希望按 Polaris 建立独立项目目录的偏好,调整为按技术职责组织代码,项目在 HTTP 路由上使用不同前缀。
- 各项目使用独立 HTTP 前缀,共享配置、日志、连接池等基础设施。
- PostgreSQL 可按项目划分 schema;这是组织边界,不等于权限隔离。
- 不提前建设微服务、统一用户中心或通用 CRUD 框架。
- 日志候选为标准库 slog,具体中间件与日志集成方式另行确认。
## 目录结构与边界(已确认)
2026-10-16 用户确认以下目标组织方式;同日确认将数据访问职责并入 model,不再设独立 internal/store 目录。完整结构记录见 [architecture.md](../architecture.md)。确认的是结构与职责划分,不代表立即创建所有文件或占位目录,代码实施仍需另行批准。
```text
lyra/
├── cmd/lyra/main.go
├── internal/
│ ├── api/
│ │ ├── links.go
│ │ ├── categories.go
│ │ └── settings.go
│ ├── config/
│ │ └── config.go
│ ├── postgres/
│ │ └── postgres.go
│ ├── router/
│ │ └── router.go
│ ├── handler/
│ │ └── links.go
│ ├── service/
│ │ ├── links.go
│ │ └── links_test.go
│ ├── model/
│ │ ├── link.go
│ │ ├── link_model.go
│ │ └── link_model_test.go
│ └── httpx/
│ └── response.go
├── migrations/
│ └── 00001_polaris_create_tables.sql
├── docs/plans/
├── README.md
├── go.mod
└── go.sum
```
- cmd/lyra 负责启动、依赖组装、调用路由注册和退出清理,不放业务规则。
- 用户提出统一管理接口请求与响应;推荐 internal/api,只放 HTTP 请求与响应 DTO 及其字段标签,按资源组织文件,不放 handler、业务逻辑或数据库查询。
- internal/router 注册路由与项目路径前缀;handler 处理参数绑定、DTO 转换与响应输出;service 管理业务规则;model 承载数据结构定义与数据访问(Squirrel/SQL 构造、sqlx 执行与结果映射),驱动为 pgx stdlib。测试与被测文件同目录。
- 树中 handler/service/model 仅列出链接资源示例;model/link.go 定义 Link 数据结构,model/link_model.go 定义数据访问与 SQL,model/link_model_test.go 测试查询,均为示例,实际文件随实现新增,不创建空占位目录或文件。
- handler 引用 API DTO 并转换为业务输入或响应;service/model 不依赖 API DTO 包,API DTO 包不引用 handler/service/model,避免传输契约和实现相互耦合或循环引用。
- 请求、响应与数据库模型职责分开,即使初期字段相近,也不默认直接把数据库模型暴露为响应;接受必要的显式转换代码。
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 上述文件是职责划分建议,不要求简单接口机械地走完整三层,也不按每张表或每种类型机械复制一套层级与文件。
- HTTP 层可以依赖 Gin;service 和 model 使用标准 context.Context,不接收 gin.Context。
- 移除 platform 和 modules 分类层;配置、连接和 HTTP 辅助直接位于 internal/config、internal/postgres、internal/httpx,减少无必要的路径深度。httpx 只在需要统一响应输出时实现,不提前抽象用户、设置、通用 CRUD 等业务。
- go.sum 在引入依赖后由 Go 工具生成;测试文件随实际功能编写,不创建空占位文件。
- 新项目可以在路由上增加独立前缀并复用上述技术层目录,不强制增加项目目录。不同领域的同名类型需要明确命名;文件名本身不提供 Go 包级符号隔离。
- 路由前缀只区分 HTTP 入口,不自动隔离代码依赖、数据库或权限;业务所有权与鉴权仍需显式设计,不能因路径不同就假定隔离成立。
- PostgreSQL 按项目划分 schema 仍为建议,尚未确认;schema 不自动提供权限隔离。
### 外部示例参考
- 参考 https://github.com/vespeng/gin-pathway ,读取 main 分支 commit 67582d1。
- 借鉴其薄启动入口、显式依赖组装与统一错误处理思路;曾考虑其按业务分组,后按用户偏好改为技术分层,不直接复制具体代码或全部依赖。
- 该示例没有独立 API DTO 目录,部分数据库模型直接作为响应,本方案不沿用这一点。
- 不沿用其启动时同步表结构的机制;Lyra 仍使用已确认的 Goose 管理迁移,具体执行方式待确认。
- 不预建空 pkg、scripts、tests 等目录,不引入当前无需求的 Redis、ORM 或全局数据库单例。
- internal/api 与根 api 都是可行布局,小写 api 是命名习惯;推荐 internal/api 是因为当前 DTO 仅供 Lyra 内部使用,而非 Go 语言强制要求。
- 另参考 https://github.com/llmons/go-dianping ,读取 main 分支 commit cc7fbcf9:借鉴其独立 API 响应定义、技术分层与构造函数注入依赖;不照搬 ORM 生成层及缓存设施。
- go-dianping 的部分请求与响应复用数据库模型,Lyra 的独立请求/响应 DTO 方案不沿用这一做法。
## 推荐迁移执行方式(待用户确认)
- 起步采用 Goose CLI,在独立部署步骤中显式执行 SQL 迁移;Web 服务启动时不自动迁移。
- 迁移成功后再启动或更新服务;迁移失败则停止后续发布,检查实际状态。
- 同一个 Lyra 数据库初期使用一个 migrations 目录和统一版本序列,文件名标注所属模块;不让不同模块共用版本表却各自维护重复版本号。
- 部署流程保证同一数据库的迁移串行执行;如以后存在多个迁移入口或并发发布,再选择带数据库锁的迁移执行入口,不假定事务本身能防止并发迁移。
- 优先 SQL 迁移,非事务 DDL 单独设计;生产回退需要评估数据影响,不自动执行 Down。
- 表结构迁移与 Polaris JSON 初次导入分开设计,不能把 Goose 等同于业务数据导入工具。
## 后续讨论顺序
1. 确认 Goose 的执行入口、串行执行或数据库锁方案(技术分层与路由前缀组织已确认,见 [architecture.md](../architecture.md))。
2. 确认首阶段范围:建议先做 JSON 导入、只读 API 和 Polaris 接入;管理写入另行确定认证方式。
3. 编写实施与验证方案,获得明确批准后再实施。
## 后续实施方案必须覆盖的验证
- 使用隔离的 PostgreSQL 测试环境验证迁移、核心查询与事务,不默认操作现有真实数据。
- 核对导入前后的分类、链接、排序和置顶语义。
- 接入 Polaris 时验证 service 异步化、动态分类路由、缓存与后端故障处理。
- 管理写接口上线前验证鉴权,不能以隐藏入口代替权限控制。
@@ -0,0 +1,143 @@
# Gitea 简单 CI 实践
> 用 Gitea Actions 给仓库搭一条最简单的持续集成:推送代码后自动做语法检查、跑单元测试。
## 1. 启用 Gitea Actions
- Gitea 1.21 及以上版本的实例默认启用 Actions(`app.ini` 中 `[actions] ENABLED` 默认为 true);
- 但仓库级默认禁用,需在仓库 **设置 → Actions** 中打开。
## 2. 准备并注册 act_runner
job 由 act_runner 执行,可以二进制安装或者容器安装,官方文档:<https://docs.gitea.com/runner/>
准备前提:
- runner 所在机器能通过网络访问 Gitea 实例;
- 本例用 Docker 执行器(label 里的 `docker://` 前缀),runner 所在机器需有可用的 Docker。
本地 [Compose 示例](local-ci/compose.yaml) 用 `gitea/runner` 容器跑 Runner:
首次启动时 entrypoint 读取 `GITEA_INSTANCE_URL`、`GITEA_RUNNER_REGISTRATION_TOKEN`、
`GITEA_RUNNER_LABELS` 自动完成注册,注册结果写入挂载的 `/data` 下的 `.runner`
文件并持久化,重启容器不必手动注册。只有不用 Compose、直接安装二进制运行时,
才需要手动执行注册与启动命令(见官方文档)。
注册令牌等同凭据:妥善保管,只在环境变量、secrets 或 runner 自己的配置文件里
出现,不要写进命令行历史、文档或仓库文件。
label 的格式是 `名称:执行器://参数`:以 Compose 中的
`demo-ci:docker://node:22-bookworm` 为例,名称为 `demo-ci`,
执行器为 Docker,参数为 job 容器镜像 `node:22-bookworm`。
工作流的 `runs-on` 按**名称部分**匹配。
### Runner 的 Docker 运行方式
`docker://` 表示 job 在容器中运行,但不决定 Runner 使用哪一个 Docker daemon:
| 术语 | 含义 | 取舍 |
|---|---|---|
| DinD(Docker-in-Docker) | Runner 容器里另起 Docker daemon,由它创建 job 容器 | 与宿主 Docker 分开;job 默认也与外层 Compose 网络分开,需要配置访问 Gitea 的路径 |
| DooD(Docker-outside-of-Docker) | Runner 通过宿主机 Docker socket 使用宿主 daemon,不在容器里另起 daemon | 配置和缓存复用较简单;能接触 socket 的 job 可能控制宿主 Docker,不宜在共享宿主上运行不可信代码 |
| Rootless | Docker daemon 以非 root 身份运行,不是 DinD 的反义词 | 可以与 DinD 组合;降低部分权限风险,但仍需留意网络、存储限制及宿主资源访问 |
本地 [Compose 示例](local-ci/compose.yaml) 使用需要特权容器的 `3-dind`,
并非 Rootless。三种术语并不处于同一分类:DinD / DooD 说明**使用哪个 daemon**,
Rootless 说明**daemon 以什么权限运行**;它也不等于完全隔离。
## 3. 最小 Node demo
两个零依赖文件,放在仓库根目录。
`demo.js`(CommonJS,一个加法函数):
```js
function add(a, b) {
return a + b;
}
module.exports = { add };
```
`demo.test.js`(用 Node 内置的 `node:test` 与 `node:assert/strict`):
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { add } = require('./demo');
test('整数相加', () => {
assert.equal(add(1, 2), 3);
});
test('负数相加', () => {
assert.equal(add(-1, -2), -3);
});
```
先在本地验证能跑:`node --test demo.test.js`。
## 4. 编写工作流
在仓库根目录新建 `.gitea/workflows/ci.yml`:
```yaml
name: Demo CI
on:
push:
branches: [main] # 默认分支不是 main 的仓库按需修改
jobs:
test:
runs-on: demo-ci
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@v4
- name: 语法检查
run: node --check demo.js && node --check demo.test.js
- name: 运行测试
run: node --test demo.test.js
```
要点:
- `runs-on: demo-ci` 必须与 runner 注册 label 的名称部分一致;
- Gitea 中 `uses` 不带完整 URL 时默认从 GitHub 拉取,这里显式写 gitea.com
的绝对地址;runner 仍需能访问该 action 源;
- job 会跑在 label 指定的 `node:22-bookworm` 容器里:Node 内置测试运行器
开箱即用,不需要 `npm install`,也没有任何第三方依赖。
## 5. 推送并查看结果
```bash
git add demo.js demo.test.js .gitea/workflows/ci.yml
git commit -m "add demo CI"
git push
```
打开仓库 **Actions** 页,能看到这次 push 触发的 run;
点进 job 可展开每个 step 的日志。
常见排查:
| 现象 | 排查方向 |
|---|---|
| run 一直等待、没有 job 被领取 | 仓库 Actions 是否启用;runner 是否在线(runner 页看状态);`runs-on` 与 runner label 名称是否一致。不同版本对不匹配的处理有差异,结合 runner 日志与实际执行环境核对 |
| checkout 步骤失败或超时 | runner 到 Gitea 实例、到 action 源(gitea.com)的网络连通性 |
| 语法检查或测试失败 | 直接看该 step 日志里的报错/断言输出,在本地用同样命令复现 |
本地用 Compose + Docker-in-Docker 时,还要区分 runner 容器与 job 容器的网络:
runner 能解析 `gitea`,不代表 job 也能。若 checkout 的 `git fetch` 报
`Could not resolve host: gitea`,先检查 job 容器能否访问 Gitea,而不只是检查 runner。
本地测试可在 Runner 配置的 `container:` 下设 `network: host`,通过 Compose
挂载该配置、设置 `CONFIG_FILE` 并重建 runner,见 [本地 Compose 示例](local-ci/compose.yaml)。
这里的 `host` 指 runner 容器所在的网络命名空间,不是宿主机网络;job 与 runner
共用网络会降低隔离性,只适合可信的本地测试,不应直接当作生产环境通用方案。
## 参考
- Gitea Actions 快速上手:<https://docs.gitea.com/usage/actions/quickstart/>
- act_runner 注册:<https://docs.gitea.com/runner/registration/>
- act_runner 标签:<https://docs.gitea.com/runner/labels/>
@@ -1,331 +0,0 @@
# Polaris 前端容器化 CI 与部署实践
> 复盘 + 可复用教程:记录 Polaris(Next.js 导航站,纯前端、数据为仓库内本地 JSON)
> 在单机服务器上从 0 到「CI 出镜像、人工按 digest 部署上线」的完整过程与踩坑。
>
> **本文位置与定位**:本文位于仓库 `note/`(系统性知识),与代码严格分离。
> 操作步骤的权威版本在 [`ts/web/nextjs/polaris/deploy/README.md`](../../../../ts/web/nextjs/polaris/deploy/README.md)——
> README 管「怎么做」,本文管「为什么这么做、踩了什么坑」;若细节随演进不一致,以仓库内文件为准。
> 文中命令均使用占位符(`<owner>`、`<full-commit-sha>`、`sha256:<digest>`、`<server>` 等),
> 不含任何真实令牌、私钥或机器标识;`git.labmem.icu` / `labmem.icu` 是已公开域名,仅作示例。
## 1. 环境拓扑:单机多角色
一台服务器同时承担多个角色,容器按 Docker 网络分工:同一 bridge 网络内可互通;不同网络的容器不能直接通过彼此的容器名互访,但宿主端口映射等路径仍可能提供访问。
注意这只是宿主本地的网络分层,**不是**公网隔离 / 物理隔离的同义词。
| 层 | 容器 | 网络 | 说明 |
|---|---|---|---|
| web | nginx | 仅 `net-public` | 对外入口 / 反代,按域名分流 |
| web | gitea | `net-public` + `net-db` | 同时服务 git 与 registry,桥接两网 |
| 应用 | polaris | 仅 `net-public` | 纯前端(本地 JSON),无 DB/Redis;3000 仅容器内 expose |
| infra | PostgreSQL、Redis | 仅 `net-db` | 数据库不暴露公网 |
| CI | gitea act runner | `net-public`(runner 自身) | 镜像 `gitea/runner:3-dind-rootless`;job 容器/rootless daemon 的内部网络是另一套,与宿主的 `net-public` **不是同一个网络** |
几个关键点:
- **runner 是 rootless dind**:`gitea/runner:3-dind-rootless` 内跑一个非 root(uid 1000)
的 Docker daemon,socket 为 `unix:///var/run/user/1000/docker.sock`,再挂进 job 容器的
`/var/run/docker.sock`;数据落在 `runner-docker` 卷里。CI 里只装 Docker CLI,
`DOCKER_HOST` 不用设,但**这个 daemon 不是宿主机的 Docker**(见第 4 节,这是最容易误解的一点)。
- **网络最小化**:Polaris 只需要被 nginx 反代,所以只加入外部网络 `net-public`;
`net-db` 里是 PG/Redis,前端用不到,**不连**——网络面越小越安全。
- 账号与路径约定:服务器操作 shell 实际为 root;本文不写个人绝对路径,部署目录统一以
`<deploy-dir>` 代指。注意 `docker login` 与 `docker compose` 需在**同一宿主账号**下执行——
登录凭据存在该账号的 `~/.docker/config.json`,换了账号就读不到私有镜像。
### 1.1 网络 / 流量总图(纯文本,等宽字体下查看)
```text
外部访客(浏览器 / git 客户端 / docker CLI)
│ 只见宿主 80 / 443 / 222;80 仅重定向到 HTTPS
▼
═══════════════════════════ 宿主机(单机) ════════════════════════════
[nginx](net-public,TLS 终结)── 按 Host 域名分流
├─ labmem.icu ──────HTTP──► [polaris :3000](net-public)
│ └ 3000 仅容器内 expose,宿主无映射
└─ git.labmem.icu ──HTTP──► [gitea](net-public + net-db)
(网页 / git http / registry API 同一路径)
宿主 :222 ──► gitea :22(git over SSH,直连容器端口,不过 nginx)
[gitea] ──仅 net-db──► [postgres :5432] [redis :6379]
宿主侧 127.0.0.1:5432/6379 为回环绑定,非公网开放
[runner](net-public,gitea/runner:3-dind-rootless)
└─ 内部 rootless daemon(uid 1000):job 容器跑在其自建网络里
└─ build 出镜像 ──push(HTTPS 经 nginx)──► Gitea packages storage
宿主 Docker daemon(与 runner 内的 daemon 是两个独立进程)
└─ docker login / pull ◄── Gitea registry(HTTPS 经 nginx)
└─ <deploy-dir> compose 按 digest up
net-public / net-db:宿主创建的 external bridge 网络,多个 compose 项目共用
══════════════════════════════════════════════════════════════════════
```
读图路径(与正文对应的主线):
1. 访客页面:`labmem.icu:443` → nginx → `polaris:3000`(仅容器网络内,宿主无 3000 端口)。
2. git / registry:`git.labmem.icu:443` → 同一个 nginx → gitea;
CI 的 push 与宿主的 pull 走的是这同一条 HTTPS 路径,只是方向和客户端不同。
3. git SSH:宿主 222 → gitea 容器 22,直连端口映射,不经 nginx。
4. CI 上行:runner 自身在 net-public,其内部 rootless daemon 为每个 job 建独立网络,
构建出的镜像经 HTTPS(经 nginx)推入 Gitea packages。
5. 部署下行:宿主另一个 Docker daemon 从 Gitea registry pull,再由
`<deploy-dir>` 里的 compose 按固定 digest 启动 polaris。
6. 后端依赖:gitea → postgres/redis 只走 net-db;Polaris 不在其列,前端不直接连 DB。
### 1.2 网络、端口与容器职责
| 容器/进程 | 所在网络 | 宿主端口发布 | 容器端口 | 职责 |
|---|---|---|---|---|
| nginx | net-public | 80、443 | 80、443 | 统一入口:TLS 终结、按域名分流、反代 |
| polaris | net-public | 无(宿主不映射 3000) | 3000(仅 expose) | Polaris 前端;无 DB/Redis 依赖 |
| gitea | net-public + net-db | 222 → 22 | 22(SSH);HTTP 走 nginx 反代 | git 托管 + 容器 registry(packages) |
| postgres | 仅 net-db | 127.0.0.1:5432 → 5432 | 5432 | infra 数据库(回环绑定,非公网开放) |
| redis | 仅 net-db | 127.0.0.1:6379 → 6379 | 6379 | infra 缓存(回环绑定,非公网开放) |
| runner | net-public(自身) | 无 | — | 执行 CI job;内部 rootless daemon 自建 job 网络 |
| 宿主 dockerd | —(创建/管理上述 external 网络) | 发布 80、443、222 | — | 运行全部 compose 项目;与 CI 内 daemon 相互独立 |
补充说明:
- `net-public` / `net-db` 是**宿主创建的 external bridge 网络**(compose 里 `external: true`,
多个 compose 项目共用);名字里的 public / db 只是用途约定,**不等于**公网或物理隔离。
- 示意 CIDR:当时截图 net-db ≈ `172.22.0.0/16`、net-public ≈ `172.23.0.0/16`;
实地以 `docker network inspect net-public net-db` 为准,本文不硬编码。
- 回环绑定(127.0.0.1)意味着 PG/Redis 的宿主端口只服务本机管理,不经公网可达;
宿主防火墙规则未核验,但不影响回环绑定本身的限定。
- 面板自带的 `1panel-network` 与默认 bridge 属既有背景,与 Polaris 主链路无关;
其网关 / 路由规则不在本文展开,也不猜测。
### 1.3 两个 Docker daemon 与持久化落点
| Docker daemon | 跑在哪 | 负责什么 | 数据 / 持久化落点 |
|---|---|---|---|
| 宿主 dockerd | 宿主机 | web / infra / polaris 各 compose 项目 | gitea:`/data` → 项目目录下 `./data/gitea`(packages 在其内部的精确路径依 Gitea 配置而定,不写死、也不复制物理文件);postgres / redis:bind 挂载的数据目录;polaris:无卷 |
| runner 内 rootless daemon | runner 容器内(uid 1000,`unix:///var/run/user/1000/docker.sock`) | CI job 的 build / run / 冒烟 | `runner-docker` 卷(镜像与构建缓存),与宿主 daemon 互不相通 |
镜像如何从 CI daemon → Gitea packages → 宿主 daemon 的完整旅程见第 4 节。
> 本节图表的信息来源:用户提供的各 compose 定义与实际上线反馈
> (流量路径、端口发布、健康状态均为已验证行为);
> 物理存储内部布局与防火墙规则未逐一核验。
## 2. CI 流水线:verify 之前不出镜像
流水线定义在 [`.gitea/workflows/polaris-ci.yml`](../../../../.gitea/workflows/polaris-ci.yml),
两个 job 串联:
```
push main(路径过滤:ts/web/nextjs/polaris/** 或 workflow 本身)
└─ verify job(node:22.23.2-bookworm 容器)
lint → typecheck → test → next build(standalone)
→ 备齐 standalone 产物 → 起本地进程冒烟(/、分类页、静态资源、/_next 资产)
└─ publish job(needs: verify,仅 main 分支 push 触发)
装 Docker CLI(仅 CLI,无宿主 socket)
→ 解析镜像引用 git.labmem.icu/<owner>/polaris:${GITHUB_SHA}
→ docker build
→ 对镜像本身冒烟(docker run + 容器内 node fetch 探测)
→ docker login(secrets)→ push → 从 push 日志解析 digest 并校验格式
```
设计动机,值得复用的三条:
1. **校验不过不出镜像**:`publish` 依赖 `verify`,坏代码最多到测试为止,永远进不了 registry。
2. **tag 用完整 commit SHA**:`git.labmem.icu/<owner>/polaris:<full-commit-sha>`,
镜像与提交一一对应、可追溯;不用 `latest`,避免「部署的到底是谁」。
3. **部署固定 digest 而非 tag**:tag 理论上可被覆盖,digest 是内容寻址、不可变;
部署侧 `.env` 写 `@sha256:<digest>`,回滚也就是「换回旧 digest」。
三个工程细节:
- 镜像冒烟复用了与 verify 相同的 node fetch 探测思路(镜像内没有 curl),且用 `docker run -d`
返回的**唯一容器 ID**,避免共享 runner daemon 上并发跑撞名。
- 登录凭据放在 `mktemp -d` 的临时 `--config` 目录里,step 结束即销毁,
不落 job 容器的 `~/.docker/config.json`。
- digest 解析脚本带格式校验(`sha256:[0-9a-f]{64}`),解析不到就**失败退出**并提示
去 registry 界面人工确认——宁可失败,也不把一个没核对过的引用输出成「部署参考」。
## 3. 踩坑复盘:两次失败,两种典型
### 3.1 第一次失败:缺 registry 凭据(secrets 未配置)
现象:`publish` 的登录步骤直接报缺少
`secrets.POLARIS_REGISTRY_USER` / `secrets.POLARIS_REGISTRY_TOKEN`。
解决(一次性,管理员操作):
1. Gitea 个人设置 → 应用(Applications)→ 新建 token,权限勾选 **package 写**;
2. 仓库 → 设置 → Actions → Secrets,分别新增:
- `POLARIS_REGISTRY_USER` = 登录用户名
- `POLARIS_REGISTRY_TOKEN` = 上一步的 PAT
3. 重跑失败的工作流。
**教训:凭据要按用途分清(四类)。** 这台服务器上至少有四种长得很像的凭据,用途完全不同:
| 凭据 | 用途 | 给谁用 |
|---|---|---|
| package 写权限 PAT | 推送镜像 | 只进 Gitea Actions secrets |
| 只读(拉取)PAT | 拉取镜像 | 只在宿主机 `docker login` |
| runner 注册令牌 | 注册 act runner | 属 runner 部署配置;注册令牌可长期保留在 runner 的 compose env 中持续生效 |
| SSH 部署密钥 | git 拉取 / 服务器登录 | 与 registry 无关 |
不要把 SSH 部署密钥或 runner 注册令牌当成 registry token 填进 secrets——
名字对不上时登录直接失败;名字碰巧对得上的凭据更隐蔽:权限错位、行为怪异,排查更绕。
### 3.2 第二次失败:413 PUT /v2/blobs/uploads
现象:只出现在 CI 的 push 步骤日志里——推送输出报 `413`(`PUT /v2/blobs/uploads/...`)。
服务端侧当时的日志并未留档,判断依据是反代默认 `client_max_body_size 1m` 远小于镜像层体积。
原因:registry 流量走 gitea 站点前面的 nginx,`client_max_body_size` 默认只有 1m,
而镜像层动辄上百 MB。
解决:在 **Gitea 站点对应的 server 块**放开限制(按最大单层留余量,这里给 1g):
```nginx
server {
# ... 监听/TLS/gitea 反代配置保持不变 ...
client_max_body_size 1g;
}
```
```bash
# nginx 运行在容器里,用 docker exec 操作,不是宿主机命令
docker exec <nginx容器名> nginx -t && docker exec <nginx容器名> nginx -s reload
```
**教训**:413 不一定是应用问题,先看反代;registry 场景里
`client_max_body_size` 要按「最大镜像层」而不是「镜像总体积」估算。
### 3.3 推送成功:digest 是什么、不是什么
push 成功后日志末尾给出 manifest digest(本文一律以占位 `sha256:<digest>` 表示)。
两个容易误解的点:
- **digest 对应的是 manifest**。push 日志里 digest 旁边的 `size` 是 manifest JSON 本身的
大小(几 KB),**不是镜像总体积**;镜像体积是各层 blob 之和,另看各层输出。
- **tag 是可变的名字**:SHA 命名的好处是便于追溯,但并不阻止同名 tag 被重推覆盖;
一旦重推,tag 指向新内容,digest 随之改变。真正锁定内容的是 digest——
部署记录里应同时留 tag(溯源)和 digest(部署事实)。
## 4. 三个 Docker/存储的边界:CI 里的镜像 ≠ 宿主的镜像
这是本次实践里最值得写下来的一条认知修正
(网络 / 端口视角见第 1 节架构图;本节只看镜像与数据在三个 daemon/存储之间的旅程):
```
CI job 里的 docker build/push Gitea 服务端 宿主机
┌────────────────────────┐ push ┌──────────────────┐ pull ┌──────────────┐
│ runner 的 rootless │ ───────→ │ packages storage │ ───────→ │ 宿主 Docker │
│ daemon(runner-docker │ │(由 Gitea 管理) │ │ (另一个 daemon│
│ 卷,uid 1000) │ │ 存储布局随配置, │ │ 独立拉取) │
└────────────────────────┘ │ 不必关心精确路径 │ └──────────────┘
└──────────────────┘
```
- CI 构建出的镜像只存在于 **runner 的 rootless daemon**(`runner-docker` 卷)里,
宿主机 `docker images` 看不到它,这是设计使然而非故障;
- push 成功后,镜像的权威副本由 **Gitea packages storage** 管理(存储后端与磁盘布局
随 Gitea 配置而变,不需要也不应写死具体路径);
- 宿主机 `docker pull` 是**第三个独立 daemon** 从 registry 的一次全新拉取。
所以「CI 构建成功」和「宿主机上能跑」是两件事,中间的桥梁就是 registry + digest。
## 5. 服务器手动部署(第一阶段:人工、按 digest)
第一阶段刻意保持**手动部署、非自动**:CI 只负责出镜像,上不上线由人决定。
```bash
# ① 宿主机登录 registry —— 用独立的只读 PAT,与 CI 写权限分离
docker login git.labmem.icu -u <registry用户名> # 交互输入只读 token
# ② 准备部署文件(仓库 ts/web/nextjs/polaris/deploy/ 拷到服务器,见 README)
cd <deploy-dir>
cp .env.example .env
# ③ .env 里固定 digest(同时注释保留来源 tag,便于溯源与回退):
# POLARIS_IMAGE=git.labmem.icu/<owner>/polaris@sha256:<digest>
# # 来源:git.labmem.icu/<owner>/polaris:<full-commit-sha>
# ④ 拉取并启动(compose 顶层 name: polaris,单服务,仅 net-public)
docker compose pull polaris
docker compose up -d polaris
docker compose ps # health: starting → healthy
docker inspect -f '{{.State.Health.Status}}' polaris
# ⑤ 服务连通性(net-public 内,从 nginx 侧验证服务名可达)
docker exec <nginx容器名> wget -qO- http://polaris:3000/ >/dev/null && echo ok
```
compose 侧的约束(详见 [`deploy/compose.yaml`](../../../../ts/web/nextjs/polaris/deploy/compose.yaml)):
镜像 digest 必填(`${POLARIS_IMAGE:?...}`)、不映射宿主端口、无卷/secrets 挂载、
healthcheck 用镜像内现成的 node 原生 fetch 探测 `/`(不引入 curl);
应用本身在镜像里就是非 root 的 `node` 用户、监听 3000
(见 [`Dockerfile`](../../../../ts/web/nextjs/polaris/Dockerfile) 的 runner 阶段)。
## 6. Nginx 反代接入:从占位首页到动态解析
背景:`labmem.icu` 的 HTTPS 站点此前只是一张占位首页;80 端口的重定向保持不变。
接入 Polaris 时改的是该站点的 `location /`:
```nginx
# labmem.icu 站点内(示意;域名/证书路径以实际配置为准)
location / {
# Docker 内嵌 DNS;变量式 proxy_pass 才会走 resolver 动态解析
resolver 127.0.0.11 valid=10s ipv6=off;
set $polaris_backend polaris:3000;
proxy_pass http://$polaris_backend; # 变量式:运行时经 resolver 动态解析
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
为什么用动态解析:静态 `proxy_pass http://polaris:3000;` 在 nginx 载入配置时只解析一次,
polaris 容器每次重建 IP 都可能变化,静态写法会 502;变量 + `resolver`(Docker 内嵌 DNS
`127.0.0.11`,`valid=10s` 缓存十秒,`ipv6=off` 关掉 AAAA 噪声)让 nginx 跟着容器重建走。
收尾三步:
```bash
docker exec <nginx容器名> nginx -t && docker exec <nginx容器名> nginx -s reload
curl -I https://labmem.icu/ # 外部路由核验:应返回 Polaris 首页响应
curl -s https://labmem.icu/categories/dev/ -o /dev/null -w '%{http_code}\n' # 分类页(应用开了 trailingSlash)
```
注意证书生命周期:续期(如 certbot renew)后需要 reload nginx 才会生效;
这与容器重建是两件独立的事,都要纳入巡检。
## 7. 运维边界、风险与展望
- **回滚**:`.env` 换回上一个 digest(建议平时就把旧 digest 留成注释行)→ `pull + up -d`;
首发失败且无旧版本可回退时,直接 `docker compose down`(只影响 polaris 项目,
不碰 nginx/gitea/PG/Redis 所在项目)。
- **停机风险**:`up -d` 是停旧建新,有秒级中断;第一阶段单实例、不做零停机承诺,
换版尽量选低峰。
- **凭据维护**:宿主机 `~/.docker/config.json` 是 base64 明文,注意账号权限,
不用时 `docker logout git.labmem.icu`;只读 PAT 泄露面小,但也要有轮换预案。
- **磁盘维护**:每个 commit 一个镜像 tag,Gitea packages 会持续增长,
定期在 packages 界面清理旧版本;runner 的 `runner-docker` 卷同样会积累构建缓存与悬空镜像,
需要周期性清理——两边都不要按「镜像总体积」估算磁盘,按层与历史版本算。
- **安全红线**:不要为了「自动化部署」把宿主机 Docker socket 挂给 CI/runner——
那等于交出宿主机 root;runner 已用 rootless dind 隔离,保持这个边界。
- **展望(未实施)**:后续可探索「CI 通过受限通道触发部署脚本」之类的自动化,
但第一阶段保持人工按 digest 部署:少一层自动化,少一类事故。
- **net-db 仍不相干**:Polaris 目前没有后端依赖;将来若接入真实数据源,
再单独评估网络与凭据设计。
## 8. 时间线小结
| 步骤 | 结果 |
|---|---|
| verify(lint/typecheck/test/build/standalone 冒烟) | ✅ 首次即通过 |
| publish:build + 镜像冒烟 | ✅ 通过 |
| publish:login/push | ❌ 缺 secrets → 配置 package 写 PAT 后修复 |
| push blob | ❌ 413 → gitea 站点 `client_max_body_size 1g` + 容器内 reload 后修复 |
| push 成功 | ✅ 得到 manifest digest(`sha256:<digest>`) |
| 宿主机部署 | ✅ 只读 PAT login → `.env` 固定 digest → pull/up → healthy |
| 反代接入 | ✅ net-public 内可达 `polaris:3000` → 动态解析反代 → 外部 curl 核验通过 |
@@ -0,0 +1,88 @@
# 本地单机 Gitea + runner(DinD)测试环境,仅用于本机可信代码测试。
# 配套笔记:上级目录《Gitea 简单 CI 实践》——让其中的 demo workflow
# (runs-on: demo-ci、checkout@v4、Node 22)不依赖云服务器即可本机跑通。
#
# ── 使用步骤(命令均从仓库根目录执行;若已 cd 进本目录,可去掉 -f 直接 `docker compose up -d`)──
#
# 1. 生成并导出注册令牌(只放在当前 shell 里,切勿写进仓库或 .env 文件):
# export GITEA_RUNNER_REGISTRATION_TOKEN="$(openssl rand -hex 32)"
# Gitea >= 1.23:在 Gitea 首次初始化(完成安装向导)之前设置该变量,
# 它会被固定为 runner 注册令牌;实例已初始化后再设置不会改变令牌,
# 此时以 管理后台/仓库 → Actions → Runners 页面显示的令牌为准。
#
# 2. 启动(首次运行会拉取两个镜像):
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml up -d
#
# 3. 打开 http://localhost:3000 完成首次安装(数据库保持默认 SQLite 即可)。
# 若 runner 早于安装完成启动,注册会失败并按 restart 策略自动重试,
# 安装完成后无需手动干预,稍等即可看到 runner 在线。
#
# 4. 注册/登录 → 新建仓库 → 仓库 设置 → Actions 启用;
# 按《Gitea 简单 CI 实践》加入 demo.js、demo.test.js 与
# .gitea/workflows/ci.yml。本机 git 克隆/推送走 HTTP 协议:
# git clone http://localhost:3000/<owner>/<repo>.git
#
# 5. 仓库 Actions 页查看 run;需要排查 runner 时:
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml logs runner
#
# 6. 账号、仓库、runner 注册与镜像缓存在三个命名卷里,保留卷即保留数据;
# 彻底重来:docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml down -v
#
# ── 安全提醒 ──
# runner 容器 privileged: true(DinD 必需),权限等同宿主 root;
# 本文件只用于本机可信仓库的本地测试,勿用于不可信仓库或对外提供服务。
services:
gitea:
image: docker.gitea.com/gitea:latest
restart: unless-stopped
environment:
# 单机演示固定 SQLite,数据随 gitea-data 卷持久化
GITEA__database__DB_TYPE: sqlite3
# 页面/克隆地址显示为 localhost,方便本机浏览器与 git 客户端
GITEA__server__ROOT_URL: http://localhost:3000/
# 与 runner 用同一个固定注册令牌(含义见文件头第 1 步)
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
ports:
# 只绑定回环地址,不暴露到局域网;git 走 HTTP,无需映射 SSH 端口
- "127.0.0.1:3000:3000"
volumes:
- gitea-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
runner:
image: docker.io/gitea/runner:3-dind
restart: unless-stopped
# DinD:job 容器跑在 runner 内部的嵌套 Docker daemon 里,需要 privileged
privileged: true
depends_on:
gitea:
condition: service_healthy
environment:
CONFIG_FILE: /config.yaml
# 注册与克隆都发生在 runner 容器自身,它在 compose 网络内可直连 gitea 服务
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_NAME: local-demo
# 与 demo workflow 的 runs-on: demo-ci 一致;node:22-bookworm 提供 Node 22
GITEA_RUNNER_LABELS: demo-ci:docker://node:22-bookworm
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
volumes:
- ./runner-config.yaml:/config.yaml:ro
# runner 状态(含 .runner 注册文件)
- runner-data:/data
# 嵌套 dockerd 的镜像与构建缓存
- runner-docker:/var/lib/docker
# DinD 默认给 job 建独立网络;checkout 的 git fetch 会访问 http://gitea:3000,
# job 若无法解析 gitea 就会失败,即使 runner 容器能解析也没用。
# runner-config.yaml 设置 container.network: host,使 job 共用 runner 容器的
# 网络命名空间(不是宿主机网络);隔离性随之降低,仅限本地可信测试。
volumes:
gitea-data:
runner-data:
runner-docker:
@@ -0,0 +1,302 @@
# Every option with its default value, all commented out. Read this file, do not copy it.
# `./gitea-runner config init` writes a config file to copy the lines you change into.
# Logging for the runner process itself (messages printed to stderr), plus the copy of
# each task's log kept under log.job. Neither controls how workflow step output is streamed
# to the Gitea UI; tune that with runner.log_report_* below.
log:
# logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
#level: info
# Write a copy of each task's log to dir as <start time>-task-<id>.log, so a job's output
# survives a failure to send it to Gitea. A path turns them on, empty turns them off.
# retention is how long a log is kept (0s keeps all), max_size caps one log (0 is no limit).
#job:
# dir: ""
# retention: 168h
# max_size: 1GB
runner:
# Where to store the registration result.
#file: .runner
# Execute how many tasks concurrently at the same time.
# With `container.network` empty, each concurrent docker job takes a subnet from the
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
# in the docker daemon config.
#capacity: 1
# Extra environment variables to run jobs.
#envs:
# A_TEST_ENV_NAME_1: a_test_env_value_1
# A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
#env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
#timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
#shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
#insecure: false
# The timeout for fetching the job from the Gitea instance.
# Values above the 60s RPC timeout are capped to it.
#fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
#fetch_interval: 2s
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
#fetch_interval_max: 5s
# While idle, remove stale bind-workdir task directories and orphaned host-mode
# scratch directories (left behind when a host cleanup delete stalls) older than
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
# (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network and volume cleanup below.
#workdir_cleanup_age: 24h
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
# docker it removes the per-job networks and volumes of jobs this runner did not live to
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
# workspace contents until the host is rebuilt.
#idle_cleanup_interval: 10m
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
#log_report_interval: 5s
# The maximum time a log row can wait before being sent.
# This ensures even a single log line appears on the frontend within this duration.
# Must be less than log_report_interval to have any effect.
#log_report_max_latency: 3s
# Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly.
#log_report_batch_size: 100
# The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported on step transitions (start/stop) and after each log flush.
#state_report_interval: 5s
# Per-attempt deadline for flushing the final logs and task state when a job
# finishes, on a detached context so a server cancel can't block the acknowledgement.
#report_close_timeout: 10s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
#github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
#action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
#set_act_env: true
# When true (the default), apply compatibility patches to the actions a job runs, so actions
# written for GitHub work against this instance. Set to false to run them exactly as published,
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
#patch_actions: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
#labels:
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
# Default false matches GitHub actions/runner. Enable only for jobs that need an interactive
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present.
#allocate_pty: false
# Image for a job whose runs-on matches none of the labels above. A runner without a
# docker daemon runs such a job on the host instead.
#default_image: "docker.gitea.com/runner-images:ubuntu-latest"
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
# either way what it installs is gone when the job ends.
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
# corrupt it, so use it only with capacity 1.
#tool_cache_mode: none
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
# IMPORTANT: While this script runs the runner stops task heartbeats and stays
# offline from Gitea's perspective until the script exits. A script that never
# returns blocks new work until post_task_script_timeout kills it (default 5m).
# Keep scripts short; set post_task_script_timeout to a safe upper bound.
#
# Output -> runner process log (not the job log). Non-zero exit -> warning only.
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md
#post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
#post_task_script_timeout: 5m
# Scripts run inside the job environment before the job's first step and after its last
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
# resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md
#hooks:
# job_started: ''
# job_completed: ''
cache:
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
# started and no job is registered with an external_server either.
#enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set.
#dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
#host: ""
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
#port: 0
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# Set on every runner that should share a cache pool. A trailing slash is optional.
# Jobs reach the server at this URL too, so set it to the reverse proxy when one fronts the server.
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
#external_server: ""
# Shared secret between this runner and the external cache-server.
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
#external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
#external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
#offline_mode: false
# Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
# put back after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
# cache server unless container jobs cannot reach its Docker network.
#v2: true
# How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
# Whatever these allow, the cache still sheds entries to keep free space on its volume
# above health_check.min_free_disk_space_mb when health checks are enabled.
# Remove entries nothing has read or written within this window. Only last access counts.
#retention: 168h
# Cap one repository, removing its least recently accessed entries until it fits. An entry
# larger than the limit is dropped rather than emptying the repository to make room.
#repo_size_limit: 10GB
# Cap the whole cache the same way. Off by default, since the free space floor bounds it.
#size_limit: 0
# Minimum time between two eviction sweeps. This one has no "off".
#sweep_interval: 1h
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: host
# network_create_options only apply when `network` is left empty and the runner
# auto-creates a per-job network that does not already exist. They have no effect
# when a custom `network` name is set, because that network is used as-is and never
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
#network_create_options:
# enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
# enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
#privileged: false
# Any other options to be used when the container is started, for example:
# options: --add-host=my.gitea.url:host-gateway
# A volume declared here replaces the one the runner would mount on the same container path, so the
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
# options: --volume /host/toolcache:/opt/hostedtoolcache
# With privileged disabled, options that could escape the container (--security-opt, --device,
# --cap-add, --pid, ...) are ignored in a workflow's container.options, but keep working here.
#options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
#workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
# valid_volumes:
# - data
# - /src/*.json
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
#valid_volumes: []
# Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically.
# If it's "-", runner will find an available docker host automatically, but won't mount it into the job and action containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
#docker_host: ""
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
# which runs on that copy with a warning in its log.
#force_pull: false
# Rebuild docker image(s) even if already present
#force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner
#require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs
# can bind-mount it by its own path into sibling containers (".:/app" in docker
# compose). Not needed when jobs get the Docker proxy or workflows use the
# GITEA_DOCKER_WORKSPACE variable.
# The workspace parent directory must be mounted into the runner container.
#bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
#service_ready_timeout: 5m
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
#workdir_parent:
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
health_check:
#enabled: false
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
#min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
#script: ''
# How long a script result is cached and its maximum execution time.
#interval: 30s
#timeout: 10s
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz.
#enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
#addr: "127.0.0.1:9101"
# Consecutive polling failures may last this long before /readyz returns 503.
#readiness_grace: 30s