# Step 3：通过私有镜像仓库部署到真实 Linux 服务器

本案例独立位于 `jenkins/goapp-server`，自带 Go 源码、Dockerfile、Makefile、Jenkinsfile 和部署脚本。可以复用已配置的 Mac Agent，原 `jenkins/goapp` 的本地案例保持原样。

| 步骤 | 教程 | 目标 |
| --- | --- | --- |
| Step 1 | [helloworld](../helloworld/README.md) | 启动 Jenkins，理解流水线 |
| Step 2 | [goapp](../goapp/README.md) | Mac Agent 构建并部署到 Mac |
| Step 3 | 本目录 | Mac 构建并 push 私有仓库，Linux 服务器 pull 后部署 |

```text
Jenkins → Mac Agent：测试、按目标架构构建
                 │ push（写入权限）
                 ▼
             你的私有镜像仓库
                 │ pull（只读权限，固定 digest）
                 ▼
             Linux 服务器：Compose 部署 → 健康检查 → 版本验证

Mac Agent ── SSH ──> 服务器：只发送部署配置与镜像引用、执行部署命令
```

镜像通过仓库分发。SSH 不传镜像，也不传 Docker 登录密码。服务器不需要安装 Jenkins Agent、Java 或 Go。

## 1. 准备环境

**Mac 构建机**：Docker Desktop、Buildx、Make、Python 3、SSH 和 tar。Python 3 用来读取 Buildx 推送结果中的 digest。

**Linux 服务器**：Docker Engine、Compose v2（支持 `up --wait --wait-timeout`）、Bash、curl、tar、flock。SSH 登录用户能直接执行 `docker info`，无需交互 sudo。Docker 权限很大，请为教程使用合适的实验服务器或专用账号。

**私有仓库**：使用你现有的仓库，先创建项目/命名空间和允许推送的镜像路径。Mac 和服务器都必须能解析、访问该仓库并信任其 TLS 证书。私有 CA 需要在两端相应的 Docker/BuildKit 环境配置好，脚本不会跳过证书验证。

在服务器上查看目标架构：

```bash
docker info --format '{{.OSType}}/{{.Architecture}}'
docker compose version
command -v bash curl tar flock
```

`x86_64` / `amd64` 选 `linux/amd64`；`aarch64` / `arm64` 选 `linux/arm64`。Dockerfile 在构建平台运行测试，然后交叉编译目标平台的纯 Go 程序。最终仍要在目标服务器验证运行结果。

服务器应用默认绑定 `127.0.0.1:8081`。云安全组只需让 Mac 能访问 SSH；公网 HTTP/HTTPS 入口另作后续练习。

## 2. 配置服务器与私有仓库

在 **goapp-server 目录**：

```bash
cd /Users/luca/ZhongQiuProject/jenkins/goapp-server
cp config.env.example .env.server
```

编辑 `.env.server`，例如：

```dotenv
SERVER_HOST=你的服务器地址
SERVER_USER=deploy
SSH_PORT=22
SSH_KEY=/Users/luca/.ssh/你的部署私钥
SSH_KNOWN_HOSTS=/Users/luca/.ssh/known_hosts
TARGET_PLATFORM=linux/amd64
SERVER_PORT=8081
REGISTRY_HOST=registry.example.com:5000
REGISTRY_REPOSITORY=my-project/goapp
```

最终推送的镜像名称是：`registry.example.com:5000/my-project/goapp:release-001`。

| 配置 | 规则 |
| --- | --- |
| `REGISTRY_HOST` | 仓库域名/IPv4，可带端口；不含 `https://` 和路径 |
| `REGISTRY_REPOSITORY` | 仓库内的项目/镜像路径，例如 `my-project/goapp`；使用小写字母、数字、路径分隔符及普通点、横线、下划线 |
| `SERVER_HOST` | 服务器 IPv4 或 DNS 名；本版不支持 IPv6 字面量 |
| `SERVER_USER` / `SSH_PORT` | SSH 登录用户与端口，默认端口 22 |
| `SSH_KEY` | 终端使用的私钥路径；用 ssh-agent 或 Jenkins 时可留空 |
| `SSH_KNOWN_HOSTS` | 已核对服务器身份的 known_hosts 文件绝对路径 |
| `TARGET_PLATFORM` | `linux/amd64` 或 `linux/arm64` |
| `SERVER_PORT` | 服务器回环地址端口，1024～65535，默认 8081 |

这是 Make 配置格式，不写 `export` 或引号；路径使用绝对路径，避免 Make 特殊字符 `$` 和 `#`。文件已被 Git 忽略。仓库密码不写到这里。

首次 SSH 登录时，从云控制台或管理员处核对主机密钥指纹，再将匹配的密钥记录到 known_hosts。脚本使用 `StrictHostKeyChecking=yes`。自定义端口的记录形式是 `[主机]:端口`。私钥有口令时，可以先用 `ssh-add` 加入 ssh-agent，之后将 `SSH_KEY` 留空。

## 3. 两端分别登录仓库

仓库地址相同，权限不同：

- **Mac 手工练习用户**：登录有 push 权限的账号。
- **服务器 SSH 部署用户**：登录有 pull 权限的账号。它不需要 push 权限。

分别在这两台机器上执行，按提示输入对应密码或 token：

```bash
docker login registry.example.com:5000
```

地址要与你配置的 `REGISTRY_HOST` 完全一致。服务器登录时使用实际 SSH 部署账号，不要只用 root 登录后就认为 `deploy` 也能拉取。Docker 登录信息存于对应用户的 Docker 配置或凭据助手中，不会从 Mac 自动同步。

流水线使用 Jenkins 凭据为 Mac 构建阶段登录；服务器仍需预先设置它自己的只读登录身份。当前不把仓库拉取密码从 Jenkins 传到服务器。

## 4. 手工跑通 push → pull → 部署

```bash
make server-check
make server-ci IMAGE_TAG=release-001
```

完整流程相当于：

```bash
make server-check
make server-push IMAGE_TAG=release-001
make server-deploy IMAGE_TAG=release-001
```

`server-check` 检查配置、SSH、服务器 Docker 与平台，不保证仓库登录和网络一定正常；实际 push/pull 才验证仓库访问权限。

`server-push` 使用 Buildx 的 `--push` **测试、构建并推送**，不需要先运行 `server-build`。它将推送结果保存到 `.server-build/release-001/metadata.json`，并生成不可变引用 `image.ref`：

```text
registry.example.com:5000/my-project/goapp@sha256:实际的64位摘要
```

`server-build` 是可选的本地检查命令：只构建并加载到本地 Docker，不会 push。即使已经成功执行它，部署前仍需 `server-push`。

`server-deploy` 将本次 `image.ref`、Compose 与脚本通过 SSH 送到服务器。服务器执行 `docker pull --platform ... 仓库@sha256:摘要`，检查镜像架构，再启动容器并验证 `/healthz` 和响应中的版本。拉取失败会在替换当前容器之前停止。

为什么用 digest？标签可以被再次推送覆盖；digest 标识具体镜像内容。发布记录固定到实际推送结果，回退不会因旧标签被覆盖而偷偷使用新镜像。每次发布仍应使用新标签，服务器发布目录也不允许覆盖。

成功后可见 `Deployment verified: release-001`。新版本使用 `release-002`。如果发布目录已被失败尝试占用，换新标签重新发布；不要覆盖历史目录。

## 5. 从 Mac 访问服务器应用

在一个独立 Mac 终端建立隧道：

```bash
ssh -p 22 -N -o ExitOnForwardFailure=yes \
  -L 18081:127.0.0.1:8081 deploy@你的服务器地址
```

需要显式私钥时增加 `-i /绝对路径/私钥`。右侧 8081 对应 `SERVER_PORT`，左侧 18081 是 Mac 空闲端口。保持终端运行，打开 [http://localhost:18081](http://localhost:18081)，应显示发布标签。Ctrl+C 只关闭隧道。

部署脚本在服务器本机验证 HTTP，隧道用于额外验证访问。二者都不表示公网域名、HTTPS 或负载均衡已配置。

## 6. 接入独立的 Jenkins 任务

继续使用 `mac-local` Agent，新增 `goapp-server` Pipeline；不修改 Step 2 的 `goapp-local`。

安装 **SSH Agent**、**Credentials Binding**、**Docker Pipeline** 插件。新增以下凭据：

| 凭据 ID | 类型 | 作用 |
| --- | --- | --- |
| `goapp-server-ssh` | SSH Username with private key | 服务器部署用户、私钥及可选口令 |
| `goapp-server-known-hosts` | Secret file | 已核对的 known_hosts 文件 |
| `goapp-registry-push` | Username with password | 私有仓库 push 账号；password 可填写仓库 token |

`withDockerRegistry` 使用第三项凭据为 push 阶段提供仓库登录。不要将密码改成普通 Jenkins 参数。服务器的 pull 身份按第 3 节单独准备；更换服务器时也要重新配置。

任务 Definition 选 **Pipeline script**，粘贴本目录 [Jenkinsfile](Jenkinsfile)。填写默认的 `SERVER_HOST`、`REGISTRY_HOST`、`REGISTRY_REPOSITORY`，检查平台、端口与源码路径。如果首次留空，会在预检查失败，之后可在 **Build with Parameters** 中填写并重跑。

流水线阶段：

```text
Prepare source → Check server → Test, build and push image
                              → Pull on server, deploy and verify
```

LOCAL 模式复制本目录源码，不复制 `.env.server`。构建配置来自 Jenkins 参数，认证信息来自凭据。默认镜像标签为 `server-构建编号`，例如 `server-1`。

同一个服务器部署目录使用一个发布 Job。更换 Job 或重置编号时修改 IMAGE_TAG 前缀，避免历史标签冲突。流水线有 `disableConcurrentBuilds()`，服务器有 `flock` 防止同一部署目录并行更新。

接入 Git 时，按 [Step 2 的 Git 配置](../goapp/README.md#第四步接入-git实现代码变化自动部署) 设置 Pipeline script from SCM，但路径填写 **`jenkins/goapp-server/Jenkinsfile`**，源码模式默认改为 SCM。确认能手工部署后，可在 pipeline 顶层加入 `triggers { pollSCM('H/2 * * * *') }`，成功 checkout 一次以注册轮询。自动触发采用默认参数，请先确保仓库、服务器地址等非秘密默认值正确。

## 7. 状态、发布记录与回退

在 Mac 的当前案例目录运行：

```bash
make server-status
make server-logs
make server-verify IMAGE_TAG=release-001
make server-ci IMAGE_TAG=release-002
make server-rollback IMAGE_TAG=release-001
```

回退从旧发布的 `deploy.env` 读取固定 digest 与端口，重新从仓库 pull，再部署验证。它不依赖 Mac 的本地镜像，但依赖服务器仍有仓库访问权限，以及该 digest 尚未被仓库清理。给可回退版本设置合适的仓库保留策略。

服务器目录：

```text
~/jenkins-goapp/
├── deploy.lock
├── current  → 最后一次验证成功的发布
├── previous → 上一次验证成功的发布
└── releases/release-001/
    ├── image.ref              # 仓库地址@digest
    ├── deploy.env             # 固定 IMAGE_REF、版本与端口
    ├── compose.yaml
    ├── remote.sh / verify.sh
    └── verified
```

`current` 是成功记录，不是实时容器状态。如果新容器替换后健康检查失败，current 仍指向上次成功版本，要检查实际 Docker 状态并手工回退。本案例不自动回滚，没有无停机发布，也不回退数据库数据。

首次发布失败且没有 current 时，在服务器查看：

```bash
docker ps -a --filter label=com.docker.compose.project=jenkins-goapp-server
docker compose -p jenkins-goapp-server \
  --env-file "$HOME/jenkins-goapp/releases/release-001/deploy.env" \
  -f "$HOME/jenkins-goapp/releases/release-001/compose.yaml" logs --tail=100
```

停止当前成功发布：

```bash
docker compose -p jenkins-goapp-server \
  --env-file "$HOME/jenkins-goapp/current/deploy.env" \
  -f "$HOME/jenkins-goapp/current/compose.yaml" down
```

Mac 的 `.server-build/` 只保留推送元数据与部署文件，服务器发布目录只保留引用和配置；镜像内容由仓库和 Docker 管理。Jenkins 历史保留策略不清理仓库镜像或服务器发布目录。若之前试过旧的镜像包方案，请使用新标签重新发布；旧记录没有 IMAGE_REF，不能用新版回退脚本回退。

## 8. 常见问题与校验

| 现象 | 优先检查 |
| --- | --- |
| push denied / unauthorized | Mac 或 Jenkins push 凭据、项目路径、仓库项目是否已创建 |
| 服务器 pull unauthorized | SSH 部署用户是否已登录同一个仓库、是否有该路径的读取权限 |
| x509 / DNS / timeout | 两端 Docker/BuildKit 的证书信任、DNS、代理及仓库网络 |
| digest not found | 仓库保留策略是否已删除该版本；不要静默改用 latest |
| Host key verification failed | known_hosts 中主机、端口与指纹是否匹配 |
| 平台不匹配 | TARGET_PLATFORM 必须与服务器 Docker 架构一致 |
| 同标签目录已存在 | 换新标签发布；旧成功版本使用 server-rollback |
| 远端健康但 Mac 无法访问 | SSH 隧道是否运行，左右端口是否正确 |

本地校验：

```bash
make check
make test
```

离线测试用 Docker/HTTP/锁的替身验证推送结果记录、拉取失败、不可变引用、架构与回退，不连接真实仓库和服务器。真实环境仍需按教程完成登录、push、pull、访问和回退验收。

参考：[Buildx --push 与元数据](https://docs.docker.com/reference/cli/docker/buildx/build/)、[Jenkins Docker Pipeline](https://www.jenkins.io/doc/pipeline/steps/docker-workflow/)。
