GitLab CI/CD在Ubuntu上的Docker+SSH持续部署实践

GitLab CI/CD在Ubuntu上的Docker+SSH持续部署实践

1. 项目概述:为什么在 Ubuntu 上用 GitLab CI/CD 做持续部署不是“炫技”,而是工程效率的刚性需求

GitLab CI/CD、Ubuntu、Docker、SSH——这四个词凑在一起,不是技术堆砌,而是一套被无数中小团队验证过的、成本可控、链路清晰、运维可追溯的自动化交付闭环。我从2018年开始在客户现场落地这类方案,最早是给一家做智能硬件固件更新平台的创业公司搭流水线,当时他们还在用“本地打包 → U盘拷贝 → 运维手动上传 → 重启服务”的方式发布新版本,一次发布平均耗时47分钟,出错率高达31%。现在这套 GitLab CI/CD 流水线跑在一台 4核8G 的 Ubuntu 22.04 虚拟机上,从代码 push 到服务热更新完成,全程 92 秒,失败自动回滚,日志可查、步骤可溯、权限可控。它解决的从来不是“能不能跑起来”的问题,而是“能不能让三个人同时改代码、五个人同时测功能、两个人同时上线、零人值守运维”这个真实业务场景下的协同熵增问题。

你可能正面临这些具体困境:开发写完代码不敢合主干,怕影响测试环境;测试同学每天反复问“最新版部署好了吗”;运维凌晨三点被电话叫醒处理一个漏掉的配置文件;产品经理想看个新功能预览,得等两天排期。这些问题背后,本质是“人肉协调”替代了“机器契约”。而 GitLab CI/CD 在 Ubuntu 上的落地,恰恰把“谁在什么时候做了什么、依据什么规则、触发了哪些动作、结果是否符合预期”全部固化进.gitlab-ci.yml这个纯文本文件里——它不依赖某个人的记忆,不依赖口头约定,不依赖临时脚本,只依赖 Git 提交历史和 YAML 语法。Ubuntu 作为宿主系统,不是随便选的:它长期支持周期(LTS 版本支持5年)、包管理成熟(apt)、Docker 官方首选支持平台、SSH 服务开箱即用,且社区文档密度远超其他发行版。Docker 不是为“容器化”而容器化,它是解决“在我机器上能跑,到你服务器上就报错”这个经典环境不一致问题的终极隔离层;SSH 则是整个流水线与目标服务器建立可信、加密、免密通信的唯一通道——没有它,CI 作业连服务器的门都敲不开。所以这不是一个“教你怎么配 YAML”的教程,而是一个从真实故障现场反推出来的、带血丝的部署工程实践笔记。

2. 整体架构设计与核心组件选型逻辑:为什么不用 Jenkins?为什么必须是 Ubuntu?为什么 Docker 和 SSH 缺一不可?

2.1 架构全景图:四层收敛,拒绝过度设计

整套持续部署流水线不是单点工具拼接,而是分层收敛的有机体。我把它拆成四个明确职责层:

  • 第一层:源码中枢层(GitLab)
    所有代码、分支策略、合并请求(MR)、Issue 跟踪、权限控制全部收口于自建或托管的 GitLab 实例。这里不做任何构建,只做“事实记录”——谁提交了什么、何时提交、关联哪个 Issue、是否通过 MR 检查。GitLab Runner 是它的延伸触手,但本身不存储状态。

  • 第二层:执行引擎层(Ubuntu + GitLab Runner)
    一台干净的 Ubuntu 22.04 LTS 服务器(物理机或虚拟机均可),仅安装 GitLab Runner 和基础依赖(curl、jq、openssh-client)。Runner 以shelldockerexecutor 模式运行,绝不安装 Node.js、Python、Java 等语言环境——这些全部交给第三层的 Docker 镜像去承载。这样做的好处是:Runner 主机永远“无状态”,重装系统只需 10 分钟,所有构建环境一致性由镜像保证。

  • 第三层:环境沙盒层(Docker)
    每个构建任务(job)都运行在一个指定的基础镜像中,比如node:18-alpine用于前端构建,python:3.11-slim用于后端测试,docker:24.0.7-dind用于构建并推送 Docker 镜像。关键点在于:所有镜像必须来自可信源(Docker Hub 官方或私有 Harbor),且禁止使用latest标签。我见过太多团队因为node:latest突然升级导致构建失败,最后发现是 Node.js 20 的某个 API 在 18 里根本不存在。我们强制要求镜像标签精确到小版本号,例如node:18.18.2-alpine,并在.gitlab-ci.yml中显式声明,这是稳定性的第一道防火墙。

  • 第四层:交付终点层(目标服务器 + SSH)
    应用最终部署的目标机器(可能是另一台 Ubuntu 服务器,也可能是 Kubernetes 集群节点),其唯一接入方式是 SSH。GitLab CI 作业通过ssh命令连接目标机,执行部署脚本(如deploy.sh)、拉取新镜像、重启容器、校验健康端点。这里 SSH 不是“传输工具”,而是“执行代理”——它把 CI 环境的指令安全、精准地投递到生产环境,且全程可审计(SSH 日志记录每条命令)。

提示:为什么坚决不用 Jenkins?Jenkins 插件生态虽庞大,但配置分散(全局配置、节点配置、Job 配置、Pipeline 脚本),权限模型复杂,升级易断裂。GitLab CI 将一切收敛到.gitlab-ci.yml文件中,版本控制、Code Review、权限继承天然一体化。一个 MR 合并,就等于流水线配置变更生效,无需登录后台点点点。

2.2 Ubuntu 选型的硬性理由:不只是“用的人多”

选择 Ubuntu 22.04 LTS(而非 Debian、CentOS Stream 或 Arch)有三个不可替代的技术动因:

  1. Docker 官方支持矩阵的黄金标准
    Docker Engine 的每个稳定版本(如 24.0.7)的官方安装文档,第一条就是 “Ubuntu 22.04 (Jammy Jellyfish)”。这意味着内核模块(如 overlay2 存储驱动)、cgroup v2 支持、systemd 集成都是经过 Docker 团队逐行验证的。我曾用 CentOS Stream 8 部署 Docker,结果发现docker build时随机卡死,排查三天才发现是 cgroup v1/v2 混用导致的内核竞态,而 Ubuntu 22.04 默认启用 cgroup v2,完全规避此坑。

  2. SSH 密钥管理的最小心智负担
    Ubuntu 的openssh-server默认配置开箱即用,sshd_configPubkeyAuthentication yesPasswordAuthentication no是安全基线。更重要的是,ssh-copy-id工具在 Ubuntu 上行为最稳定——它能正确处理~/.ssh/authorized_keys的权限(必须 600)、目录权限(必须 700)、SELinux 上下文(Ubuntu 无 SELinux 干扰)。对比之下,某些发行版的ssh-copy-id会错误地将公钥追加到 root 用户的 authorized_keys,导致非 root 用户无法免密登录。

  3. APT 包管理的确定性与可重现性
    apt install gitlab-runner命令在 Ubuntu 上安装的是 GitLab 官方仓库提供的二进制包(非 snap),版本锁定严格,无后台自动更新干扰。而某些发行版的包管理器会静默升级 runner,导致.gitlab-ci.yml中声明的image: docker:24.0.7-dind与主机上实际运行的 Docker daemon 版本不匹配,引发dind容器启动失败。Ubuntu 的 APT 仓库策略确保了“今天装的,明天还是这个版本”。

2.3 Docker 与 SSH 的耦合设计:为什么它们必须一起出现?

Docker 和 SSH 在此架构中不是并列关系,而是主从协作:Docker 解决“构建环境一致性”,SSH 解决“部署动作原子性”。

  • Docker 的不可替代性:假设你有一个 Python Web 应用,依赖pandas==1.5.3numpy==1.23.5。如果直接在 Ubuntu 主机上pip install,不同时间安装可能因 PyPI 镜像缓存、编译器版本差异导致二进制 wheel 不同,进而引发线上段错误。而docker build命令基于 Dockerfile,每一层缓存哈希值固定,只要 Dockerfile 和源码不变,产出的镜像 SHA256 值就绝对一致。这是可重现部署的数学基础。

  • SSH 的不可替代性:有人问“为什么不用 GitLab 的deployjob 类型或 Kubernetes 的 Helm?”答案是:简单场景下,SSH 是最轻量、最透明、最易调试的交付通道。一个ssh user@prod-server 'cd /app && git pull && docker-compose up -d'命令,你可以立刻在终端里看到每一步输出,失败时直接登录服务器journalctl -u docker查日志。而 Kubernetes 的kubectl apply抽象层级太高,一个ImagePullBackOff错误,你需要查 Events、查 Pod Describe、查 Registry 认证,调试路径长 5 倍。SSH 让部署过程“看得见、摸得着、改得快”。

注意:SSH 免密登录不是“为了省事”,而是 CI 流水线自动化的前提。GitLab CI 作业运行时没有交互式终端,无法输入密码。必须提前在 Runner 主机上生成密钥对,并将公钥部署到目标服务器的~/.ssh/authorized_keys中。密钥必须设置强密码短语(passphrase),并通过 GitLab CI 变量加密存储私钥内容,这是安全底线。

3. 核心细节解析与实操要点:.gitlab-ci.yml的 7 个生死攸关参数

3.1image字段:别再用latest,精确到 patch 版本是职业素养

.gitlab-ci.ymlimage字段定义每个 job 运行的容器环境。新手常犯的致命错误是写image: nodeimage: docker。这会导致:

  • node标签指向node:20-alpine(当前最新),但你的package.json依赖node@18,构建时报SyntaxError: Unexpected token '??='(空值合并运算符是 Node 19+ 特性);
  • docker标签指向docker:24.0.7,但你的 Runner 主机 Docker daemon 是 23.0.6,dind容器启动时因 API 版本不兼容直接退出。

正确做法是:在项目根目录创建Dockerfile.base,显式声明基础环境

# Dockerfile.base FROM node:18.18.2-alpine RUN npm install -g pnpm@8.15.4 WORKDIR /app

然后在.gitlab-ci.yml中引用:

stages: - build - test - deploy build-app: stage: build image: name: registry.gitlab.com/your-group/your-project:base-v1.0.0 entrypoint: [""] script: - pnpm install - pnpm build artifacts: paths: - dist/

构建base-v1.0.0镜像的 CI job 必须先于其他 job 运行,且每次修改Dockerfile.base都要更新 tag。tag 命名规则:v<主版本>.<次版本>.<修订号>-<环境>,例如v1.0.0-prod。这样,当某天发现v1.0.0-prod镜像有安全漏洞,你只需修复Dockerfile.base,重新构建v1.0.1-prod,然后在应用 job 中修改一行name: ...:v1.0.1-prod,全量滚动更新完成。

3.2before_script:环境初始化的黄金三板斧

每个 job 开始前执行的before_script,是避免“环境漂移”的关键防线。我强制要求所有 job 的before_script包含以下三行:

before_script: - apk add --no-cache curl jq bash # Alpine 系统必备工具 - mkdir -p ~/.ssh && chmod 700 ~/.ssh - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
  • 第一行apk add:Alpine Linux 的包管理器apk默认不包含curl(HTTP 请求)、jq(JSON 解析)、bash(脚本兼容)。很多教程教人用sh,但sh不支持[[ ]]条件判断和数组,写复杂逻辑极易翻车。bash是更健壮的选择。
  • 第二行mkdir -p ~/.ssh:确保 SSH 目录存在且权限正确。chmod 700是硬性要求,OpenSSH 会拒绝读取权限过宽的~/.ssh目录,报错Bad owner or permissions on /root/.ssh/config
  • 第三行echo "$SSH_KNOWN_HOSTS"$SSH_KNOWN_HOSTS是 GitLab CI 变量,存储目标服务器的 SSH 公钥指纹(如github.com ssh-rsa AAAAB3NzaC1yc2E...)。不设置此变量,ssh命令首次连接时会交互式询问 “Are you sure you want to continue connecting (yes/no)?”,导致 CI 卡死。将指纹预置到known_hosts,实现全自动信任。

实操心得:$SSH_KNOWN_HOSTS变量必须在 GitLab 项目 Settings > CI/CD > Variables 中添加,类型选File(不是Variable)。因为known_hosts文件内容含换行符,用普通变量会丢失格式。添加后,GitLab 会自动将文件内容注入 job 环境,echo "$SSH_KNOWN_HOSTS"即可原样输出。

3.3variables:敏感信息的加密保险柜

数据库密码、API 密钥、Docker Registry 凭据,绝不能明文写在.gitlab-ci.yml中。GitLab 提供两种安全变量机制:

  • Project-level Variables(项目级变量):适用于所有 job,如DOCKER_REGISTRY_USERDOCKER_REGISTRY_PASSWORD。在 Settings > CI/CD > Variables 设置,勾选Protected(仅在受保护分支生效)和Mask variable(日志中显示为***)。
  • File-type Variables(文件型变量):适用于私钥、证书等二进制内容。如SSH_PRIVATE_KEY,类型选File,GitLab 会将其内容写入/builds/<group>/<project>/SSH_PRIVATE_KEY文件,job 中可直接cat $SSH_PRIVATE_KEY读取。

关键技巧:用ssh-agent管理私钥,避免硬编码路径
before_script中启动ssh-agent并添加私钥:

before_script: - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null - mkdir -p ~/.ssh && chmod 700 ~/.ssh - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

tr -d '\r'是关键:Windows 编辑器保存的私钥文件末尾有\r\nssh-add会将其识别为非法字符报错invalid formattr命令清除回车符,确保私钥纯净。

3.4cacheartifacts:理解它们的本质区别

新手常混淆cacheartifacts,导致构建变慢或部署失败。

  • cache是“加速器”,作用于 job 内部:缓存node_modules/~/.m2/(Maven 仓库)等重复下载的依赖。它基于 key(如node-modules-${CI_COMMIT_REF_SLUG})匹配,跨 job、跨 pipeline 复用。但 cache 不保证存在——GitLab Runner 可能因磁盘满而清理 cache。因此cache中的内容必须是“可重建的”,不能是构建产物。

  • artifacts是“交付物”,作用于 job 之间dist/目录、编译好的二进制文件、Docker 镜像 tar 包,必须通过artifacts传递给下游 job。artifacts会被 GitLab 服务器持久化存储,直到 pipeline 过期(默认30天)。下游 job 通过dependencies显式声明需要哪些上游 job 的 artifacts。

典型错误配置

# ❌ 错误:把构建产物放 cache,下游 job 无法获取 build: cache: key: ${CI_COMMIT_REF_SLUG} paths: - dist/ deploy: dependencies: [] # 未声明依赖,dist/ 不会自动下载

正确配置

# ✅ 正确:artifacts 传递构建产物 build: artifacts: paths: - dist/ expire_in: 1 week deploy: dependencies: - build # 显式声明依赖 build job 的 artifacts script: - scp -r dist/* user@prod:/var/www/html/

3.5only/exceptrules:分支策略的现代写法

旧语法only: - main已被rules取代,因其支持更复杂的条件表达式。一个健壮的部署策略应满足:

  • main分支 push → 自动部署到预发环境(staging)
  • main分支打 tag(如v1.2.0)→ 自动部署到生产环境(production),并触发 release 创建
  • 其他分支(如feature/login)→ 仅运行构建和测试,不部署
stages: - build - test - deploy deploy-staging: stage: deploy image: alpine:3.18 script: - apk add openssh-client - ssh -o StrictHostKeyChecking=no user@staging "cd /app && git pull origin main && docker-compose up -d" rules: - if: '$CI_COMMIT_BRANCH == "main"' when: always deploy-production: stage: deploy image: alpine:3.18 script: - apk add openssh-client curl - ssh -o StrictHostKeyChecking=no user@prod "cd /app && git pull origin $CI_COMMIT_TAG && docker-compose up -d" - | curl -X POST "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/releases" \ -H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"name\":\"Release $CI_COMMIT_TAG\",\"tag_name\":\"$CI_COMMIT_TAG\",\"description\":\"Deployed from pipeline $CI_PIPELINE_ID\"}" rules: - if: '$CI_COMMIT_TAG' when: always

rules的优势在于可组合:- if: '$CI_COMMIT_TAG && $CI_PIPELINE_SOURCE == "push"'表示“仅当是 tag 推送时才触发”,排除了通过 UI 手动创建 tag 的情况,确保 release 动作与代码变更强绑定。

3.6retryallow_failure:为网络抖动留出呼吸空间

CI 流水线运行在公网环境,网络请求(如npm installdocker pull)失败是常态。盲目设置retry: 2会导致失败 job 重试 3 次(原始 + 2 次),浪费资源。更合理的方式是:

  • 外部依赖拉取类 job(如install-dependencies),设置retry: 2,但限定只重试网络错误:

    install-deps: script: npm install retry: max_times: 2 when: - runner_system_failure - stuck_or_timeout_failure - unknown_failure
  • 非关键路径 job(如code-quality代码质量扫描),设置allow_failure: true,即使失败也不阻塞 pipeline:

    code-quality: script: npx eslint . allow_failure: true

allow_failure: true不代表“可以忽略”,而是“失败时继续执行后续 job,但 pipeline 状态仍为 failed”。GitLab 会在 UI 中明确标出该 job 是“allowed to fail”,便于团队聚焦真正阻断交付的问题。

3.7timeout:给每个 job 设定生命倒计时

默认 job 超时是 1 小时,但一个docker build通常 5 分钟内完成,npm test通常 2 分钟。设置过长 timeout 会导致问题被掩盖:比如ssh连接因防火墙策略变更而卡死,job 一直等待直到 1 小时超时,你收到告警时已过去 60 分钟。

最佳实践:按任务类型设定 timeout

  • buildjob:timeout: 10 minutes
  • testjob:timeout: 15 minutes(集成测试可能较慢)
  • deployjob:timeout: 5 minutes(部署脚本应极致精简,超时说明架构有问题)
deploy: timeout: 5 minutes script: - ssh user@prod 'cd /app && ./deploy.sh'

如果deploy.sh经常超时,说明它承担了不该承担的职责(如数据库迁移、大文件同步),应将其拆分为独立 job,每个 job 职责单一、timeout 明确。

4. 实操过程与核心环节实现:从零搭建一条可运行的 CI/CD 流水线

4.1 环境准备:Ubuntu 主机的 5 个必做初始化操作

在 Ubuntu 22.04 服务器上执行以下命令,这是所有后续操作的基石:

# 1. 更新系统并安装基础工具 sudo apt update && sudo apt upgrade -y sudo apt install -y curl wget gnupg2 software-properties-common apt-transport-https ca-certificates # 2. 配置时区和 locale(避免中文乱码和时间错误) sudo timedatectl set-timezone Asia/Shanghai sudo locale-gen en_US.UTF-8 sudo update-locale LANG=en_US.UTF-8 # 3. 安装 Docker(官方源,非 snap) curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io # 4. 启动 Docker 并加入当前用户组(避免每次 sudo) sudo systemctl enable docker sudo systemctl start docker sudo usermod -aG docker $USER # 重要:执行此命令后需重新登录或运行 `newgrp docker` 刷新组权限 # 5. 安装 GitLab Runner(官方 deb 包) curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash sudo apt install -y gitlab-runner

注意事项:第 4 步usermod -aG docker $USER后,必须退出当前 SSH 会话并重新登录,否则docker命令仍会报Permission denied while trying to connect to the Docker daemon socket。这是 Ubuntu 的标准行为,不是 bug。

4.2 GitLab Runner 注册:Shell Executor 为何是新手首选

对于首次搭建,我强烈推荐shellexecutor(而非dockerkubernetes),原因有三:

  • 零学习成本:Runner 直接在 Ubuntu 主机上执行命令,无需理解容器网络、卷挂载等概念;
  • 调试直观:所有script命令都在你熟悉的 shell 环境中运行,ls -lcat /tmp/log等命令随手可用;
  • 资源占用低:无需额外启动dind容器,节省内存。

注册命令(在 Ubuntu 主机上执行):

sudo gitlab-runner register # 依次输入: # Please enter the gitlab-ci coordinator URL: https://gitlab.com/ (或你的自建 GitLab 地址) # Please enter the gitlab-ci token for this runner: xxxxxxxx (在 GitLab 项目 Settings > CI/CD > Runners 页面获取) # Please enter the gitlab-ci description for this runner: ubuntu-shell-runner # Please enter the gitlab-ci tags for this runner (comma separated): shell,ubuntu # Please enter the executor: shell # Please enter the Docker image (default: ruby:2.7): # 直接回车,shell executor 不需要

注册成功后,检查 Runner 状态:

sudo gitlab-runner list # 输出应为:Listing configured runners ConfigFile=/etc/gitlab-runner/config.toml # ubuntu-shell-runner Executor=shell Token=xxxxxxxxxx URL=https://gitlab.com/ sudo gitlab-runner status # 输出应为:gitlab-runner: Service is running!

4.3 目标服务器 SSH 免密配置:三步建立可信通道

部署的目标服务器(如prod-server)必须预先配置好 SSH 免密访问。这不是 GitLab CI 的配置,而是基础设施准备:

步骤 1:在 Runner 主机生成密钥对

# 切换到 gitlab-runner 用户(重要!Runner 以该用户身份运行 job) sudo su - gitlab-runner # 生成密钥,不设密码短语(因为 CI 无法交互输入) ssh-keygen -t ed25519 -C "gitlab-ci@runner" -f ~/.ssh/id_ed25519 -N "" # -N "" 表示空密码短语

步骤 2:将公钥复制到目标服务器

# 使用 ssh-copy-id(Ubuntu 自带) ssh-copy-id -i ~/.ssh/id_ed25519.pub user@prod-server # 输入 user 用户密码,成功后公钥会追加到 prod-server 的 ~/.ssh/authorized_keys

步骤 3:获取目标服务器的 SSH 公钥指纹,存入 GitLab 变量

# 在 Runner 主机执行,获取 prod-server 的公钥指纹 ssh-keyscan -H prod-server >> ~/.ssh/known_hosts # 查看 known_hosts 文件,找到 prod-server 对应行 cat ~/.ssh/known_hosts | grep prod-server # 输出类似:|1|xxx|xxx ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... # 复制整行内容(包括 |1|... 部分)

将复制的整行内容,粘贴到 GitLab 项目 Settings > CI/CD > Variables 中,Key 填SSH_KNOWN_HOSTS,Type 选File,Save。

4.4.gitlab-ci.yml完整示例:一个可立即运行的 Web 应用部署流水线

以下是一个生产可用的.gitlab-ci.yml,部署一个简单的 Nginx 静态网站。它覆盖了构建、测试、部署全流程,且每个环节都附带注释说明原理:

# .gitlab-ci.yml # 定义全局变量,所有 job 共享 variables: # Docker Registry 地址,用于推送镜像 DOCKER_REGISTRY: registry.gitlab.com # 项目命名空间,由 GitLab 自动生成 CI_PROJECT_PATH_SLUG: $CI_PROJECT_PATH_SLUG # 构建镜像的完整名称 IMAGE_TAG: $DOCKER_REGISTRY/$CI_PROJECT_PATH_SLUG:$CI_COMMIT_SHORT_SHA # 定义流水线阶段,顺序执行 stages: - build - test - deploy # 构建阶段:基于 nginx:alpine 构建静态网站镜像 build-image: stage: build image: docker:24.0.7-dind services: - docker:24.0.7-dind before_script: - apk add --no-cache python3 py3-pip - pip3 install docker-compose # 为后续 compose 部署准备 - docker info script: # 构建镜像,使用当前 commit SHA 作为 tag,确保唯一性 - docker build -t $IMAGE_TAG . # 登录 GitLab Container Registry(需提前在 GitLab 设置 CI 变量 CI_REGISTRY_USER 和 CI_REGISTRY_PASSWORD) - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin # 推送镜像 - docker push $IMAGE_TAG # 构建产物是镜像,无需 artifacts,但需 cache Docker layer 加速 cache: key: ${CI_COMMIT_REF_SLUG} paths: - /var/lib/docker/ # 测试阶段:验证镜像能否正常启动并响应 HTTP 请求 test-image: stage: test image: docker:24.0.7-dind services: - docker:24.0.7-dind before_script: - apk add --no-cache curl script: # 启动容器,映射端口 8080 - docker run -d --name test-nginx -p 8080:80 $IMAGE_TAG # 等待 3 秒让 Nginx 启动 - sleep 3 # 发送 HTTP 请求,检查返回状态码是否为 200 - | if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q "200"; then echo "✅ Nginx container responds with 200" else echo "❌ Nginx container failed to respond" exit 1 fi # 清理测试容器 - docker rm -f test-nginx # 测试 job 不产生交付物,无需 artifacts # 部署阶段:将镜像部署到目标服务器 deploy-production: stage: deploy image: alpine:3.18 before_script: - apk add --no-cache openssh-client curl - mkdir -p ~/.ssh && chmod 700 ~/.ssh - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts script: # 1. SSH 到生产服务器,拉取最新镜像 - ssh -o StrictHostKeyChecking=no user@prod-server "docker pull $IMAGE_TAG" # 2. 停止旧容器 - ssh -o StrictHostKeyChecking=no user@prod-server "docker stop my-web-app || true" # 3. 启动新容器,映射 80 端口,后台运行 - ssh -o StrictHostKeyChecking=no user@prod-server "docker run -d --name my-web-app -p 80:80 --restart=always $IMAGE_TAG" # 4. 验证部署:检查容器是否运行且端口监听 - | if ssh -o StrictHostKeyChecking=no user@prod-server "docker ps --filter name=my-web-app --format '{{.Status}}' | grep -q 'Up'"; then echo "✅ Production deployment successful" else echo "❌ Production deployment failed" exit 1 fi # 仅在 main 分支或 tag 时执行 rules: - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG' when: always

配套的Dockerfile(放在项目根目录):

# Dockerfile FROM nginx:alpine:1.24.0 # 复制静态文件到 Nginx 默认目录 COPY ./html/ /usr/share/nginx/html/ # 暴露 80 端口 EXPOSE 80 # 启动 Nginx CMD ["nginx", "-g", "daemon off;"]

4.5 部署脚本deploy.sh:让 SSH 命令更健壮

上面的deploy-productionjob 直接在script中写了多行ssh命令,适合简单场景。对于复杂应用,建议将部署逻辑封装到deploy.sh脚本中,由 CI 调用:

#!/bin/bash # deploy.sh - 放在项目根目录,与 .gitlab-ci.yml 同级 set -e # 任何命令失败立即退出 set -u # 引用未定义变量时报错 # 参数检查 if [ $# -ne 2 ]; then echo "Usage: $0 <IMAGE_TAG> <ENV>" echo "Example: $0 registry.gitlab.com/group/project:abc123 staging" exit 1 fi IMAGE_TAG=$1 ENV=$2 # 根据环境选择目标服务器和配置 case $ENV in "staging") TARGET_SERVER="staging-server" CONFIG_FILE="docker-compose.staging.yml" ;; "production") TARGET_SERVER="prod-server" CONFIG_FILE="docker-compose.prod.yml" ;; *) echo "Unknown environment: $ENV" exit 1 ;; esac # 执行部署 echo "🚀 Deploying $IMAGE_TAG to $ENV ($TARGET_SERVER)..." scp "$CONFIG_FILE" "$TARGET_SERVER:/tmp/docker-compose.yml" ssh "$TARGET_SERVER" " cd /app && docker-compose -f /tmp/docker-compose.yml pull && docker-compose -f /tmp/docker-compose.yml up -d && docker-compose -f /tmp/docker-compose.yml ps " echo "✅ Deployment completed."

.gitlab-ci.yml中调用:

deploy-staging: