使用 GitHub & Gitlab 进行 Docker 化 PHP 应用的 CI 流水线





1.00/5 (5投票s)
如何在 Github Actions 和 Gitlab Pipelines 上设置 CI 流水线来运行代码质量工具和测试
所有代码示例都可以在我的 GitHub 上的 Docker PHP 教程仓库 中公开获取。
您可以在 part-7-ci-pipeline-docker-php-gitlab-github 分支上找到本教程。
Docker PHP 教程已发布的部分
- 设置用于 Docker 本地开发的 PHP、PHP-FPM 和 NGINX (2018-07-08)
- 设置用于 Docker 本地开发的带有 Xdebug 的 PhpStorm (2018-08-06)
- 为 PHP 项目构建 Docker 设置结构 (2019-05-20)
- 2022 年从零开始为 PHP 8.1 应用构建 Docker (2022-03-21)
- 2022 年 PHP 8.1 上的 PhpStorm、Docker 和 Xdebug 3 (2022-03-22)
- 2022 年在 Docker 上运行 Laravel 9 (2022-03-23)
- 设置 PHP QA 工具并通过 make 进行控制 (2022-04-25)
- 使用 git-secret 加密仓库中的敏感信息 (2022-04-25)
- 为 Docker 化 PHP 应用创建 CI 流水线 (2022-04-25)
如果您想继续学习,请订阅 RSS 源或 通过电子邮件,以便在下一部分发布时获得自动通知。 :)
引言
CI 是 **C**ontinuous **I**ntegration(持续集成)的缩写,对我来说,它主要意味着 **在隔离的环境中运行代码库的代码质量工具和测试**(最好是自动化的)。这
在团队协作中尤其重要,因为 **CI 系统在功能或 bug 修复合并到主分支之前充当最终的守门员**。
我最初接触 CI 系统是在涉足开源领域时。以前,我为自己的项目使用了 Travis CI,后来在某个时候将其替换为 Github Actions。在 ABOUT YOU,我们最初使用的是自托管的 Jenkins 服务器,然后迁移到了 Gitlab CI 作为完全托管的解决方案(尽管我们使用 自定义 runner)。
推荐阅读
本教程建立在之前部分的基础上。我会尽力在必要时交叉引用相应的文章,但我仍然建议提前阅读有关
- 关于 通用文件夹结构、
.docker/
目录的更新以及.make/
目录的介绍 - 关于
make
的通用用法及其 演变以及 与docker compose
命令的关联 - 关于 Docker 容器和
docker compose
设置的概念
以及作为附加知识
方法
在本教程中,我将解释 **如何使我们现有的 Docker 设置与 Github Actions 和 Gitlab CI/CD Pipelines 协同工作**。由于我非常推崇“渐进增强”的方法,我们将确保 **所有必要的步骤都可以通过 make
在本地完成**。这还有一个额外的好处,就是保持一个单一的真相来源(Makefile
),这在我们设置两个不同提供商(Github 和 Gitlab)的 CI 系统时会很有用。
通用流程将与本地开发非常相似
- 构建 Docker 设置
- 启动 Docker 设置
- 运行 QA 工具
- 运行测试
您可以在 CI 设置 部分看到最终结果,包括具体的 yml
文件和仓库链接,请参阅
在代码层面,我们将 **将 CI 视为一个环境**,通过环境变量 ENV
进行配置。到目前为止,我们只使用了 ENV=local
,现在我们将扩展它以使用 ENV=ci
。必要的更改将在具体 CI 设置说明之后的章节中进行解释
自己动手
为了感受一下发生了什么,您可以先 执行本地 CI 运行
- 检出分支 part-7-ci-pipeline-docker-php-gitlab-github
- 初始化
make
- 运行
.local-ci.sh
脚本
这应该会给您一个与 执行示例 中呈现的类似输出。
git checkout part-7-ci-pipeline-docker-php-gitlab-github
# Initialize make
make make-init
# Execute the local CI run
bash .local-ci.sh
CI 设置
通用 CI 注意事项
为 CI 初始化 make
作为第一步,我们需要“配置”代码库以在 ci
环境下运行。这是通过 make-init
目标完成的,稍后将在 Makefile 更改 部分更详细地解释,通过
make make-init ENVS="ENV=ci TAG=latest EXECUTE_IN_CONTAINER=true GPG_PASSWORD=12345678"
$ make make-init ENVS="ENV=ci TAG=latest EXECUTE_IN_CONTAINER=true GPG_PASSWORD=12345678"
Created a local .make/.env file
ENV=ci
确保我们
TAG=latest
目前只是一个简化,因为我们还没有对镜像做任何处理。在接下来的教程中,我们将把它们推送到容器注册表以供将来用于生产部署,然后将 TAG
设置为更有意义的值(如构建号)。
EXECUTE_IN_CONTAINER=true
强制所有使用 RUN_IN_*_CONTAINER
设置 的 make
命令在容器中运行。这一点很重要,因为 **Gitlab runner 本身将在一个 docker 容器中运行**。但是,这会导致任何受影响的目标 **省略 $(DOCKER_COMPOSER) exec
前缀**。
GPG_PASSWORD=12345678
是敏感 gpg
密钥的密码,如 添加密码保护的敏感 gpg
密钥 中所述。
wait-for-service.sh
我将在本文后面 添加 mysql
的健康检查 部分解释“容器已启动但底层服务未就绪”的问题以及我们如何解决它。我们有意不让 docker compose
来处理等待,因为我们可以“更好地利用等待时间”,而是用一个简单的 bash 脚本来实现它,该脚本位于 .docker/scripts/wait-for-service.sh。
#!/bin/bash
name=$1
max=$2
interval=$3
[ -z "$1" ] && echo "Usage example: bash wait-for-service.sh mysql 5 1"
[ -z "$2" ] && max=30
[ -z "$3" ] && interval=1
echo "Waiting for service '$name' to become healthy,
checking every $interval second(s) for max. $max times"
while true; do
((i++))
echo "[$i/$max] ...";
status=$(docker inspect --format "{{json .State.Health.Status }}"
"$(docker ps --filter name="$name" -q)")
if echo "$status" | grep -q '"healthy"'; then
echo "SUCCESS";
break
fi
if [ $i == $max ]; then
echo "FAIL";
exit 1
fi
sleep $interval;
done
该脚本通过 检查 docker inspect
命令的 .State.Health.Status
信息 来等待 docker $service
变为“健康”。
注意:脚本使用 $(docker ps --filter name="$name" -q)
来确定容器的 ID,即它将“匹配”所有正在运行的容器与 $name
- 如果匹配的容器超过一个,这就会失败!也就是说,您必须确保 $name
足够具体,能够唯一地识别一个容器。
脚本最多会尝试 $max
次,每次间隔 $interval
秒。请参阅关于“如何在脚本中编写重试逻辑,最多重试 5 次?”这个问题的 这些 答案 来实现重试逻辑。要检查 mysql
服务 5 次,每次间隔 1 秒,可以通过以下方式调用它:
bash wait-for-service.sh mysql 5 1
输出
$ bash wait-for-service.sh mysql 5 1
Waiting for service 'mysql' to become healthy, checking every 1 second(s) for
max. 5 times
[1/5] ...
[2/5] ...
[3/5] ...
[4/5] ...
[5/5] ...
FAIL
# OR
$ bash wait-for-service.sh mysql 5 1
Waiting for service 'mysql' to become healthy, checking every 1 second(s) for
max. 5 times
[1/5] ...
[2/5] ...
SUCCESS
“容器依赖”的问题并不新鲜,并且已经有一些现有的解决方案,例如
但不幸的是,所有这些解决方案都是通过检查 host:port
组合的可用性来工作的,而在 mysql
的情况下,这并没有帮助,因为容器已经启动,端口可以访问,但容器中的 mysql
服务却没有。
“本地” CI 运行设置
正如在 方法 部分所述,我们希望能够在本地执行所有必要的步骤,并且我创建了一个相应的脚本 .local-ci.sh
。
#!/bin/bash
# fail on any error
# @see https://stackoverflow.com/a/3474556/413531
set -e
make docker-down ENV=ci || true
start_total=$(date +%s)
# STORE GPG KEY
cp secret-protected.gpg.example secret.gpg
# DEBUG
docker version
docker compose version
cat /etc/*-release || true
# SETUP DOCKER
make make-init ENVS="ENV=ci TAG=latest EXECUTE_IN_CONTAINER=true GPG_PASSWORD=12345678"
start_docker_build=$(date +%s)
make docker-build
end_docker_build=$(date +%s)
mkdir -p .build && chmod 777 .build
# START DOCKER
start_docker_up=$(date +%s)
make docker-up
end_docker_up=$(date +%s)
make gpg-init
make secret-decrypt-with-password
# QA
start_qa=$(date +%s)
make qa || FAILED=true
end_qa=$(date +%s)
# WAIT FOR CONTAINERS
start_wait_for_containers=$(date +%s)
bash .docker/scripts/wait-for-service.sh mysql 30 1
end_wait_for_containers=$(date +%s)
# TEST
start_test=$(date +%s)
make test || FAILED=true
end_test=$(date +%s)
end_total=$(date +%s)
# RUNTIMES
echo "Build docker: " `expr $end_docker_build - $start_docker_build`
echo "Start docker: " `expr $end_docker_up - $start_docker_up `
echo "QA: " `expr $end_qa - $start_qa`
echo "Wait for containers: " `expr $end_wait_for_containers - $start_wait_for_containers`
echo "Tests: " `expr $end_test - $start_test`
echo "---------------------"
echo "Total: " `expr $end_total - $start_total`
# CLEANUP
# reset the default make variables
make make-init
make docker-down ENV=ci || true
# EVALUATE RESULTS
if [ "$FAILED" == "true" ]; then echo "FAILED"; exit 1; fi
echo "SUCCESS"
运行详情
- 作为准备步骤,我们首先确保没有过时的
ci
容器正在运行(这仅在本地需要,因为远程 CI 系统上的 runner 将“从头开始”启动)make docker-down ENV=ci || true
- 我们花费一些时间来测量某些部分需要多长时间,通过
start_total=$(date +%s)
存储当前时间戳
- 我们需要敏感
gpg
密钥才能解密敏感信息,并简单地复制 密码保护的示例密钥(在实际的 CI 系统中,密钥将被配置为注入到运行中的一个秘密值)# STORE GPG KEY cp secret-protected.gpg.example secret.gpg
- 我喜欢打印一些调试信息,以便了解我们所处的具体情况(说实话,这主要在设置 CI 系统或对其进行修改时相关)
# DEBUG docker version docker compose version cat /etc/*-release || true
- 对于 Docker 设置,我们从 为
ci
初始化环境 开始# SETUP DOCKER make make-init ENVS="ENV=ci TAG=latest EXECUTE_IN_CONTAINER=true GPG_PASSWORD=12345678"
然后构建 Docker 设置
make docker-build
最后添加一个
.build/
目录以 收集构建伪影mkdir -p .build && chmod 777 .build
- 然后,启动 Docker 设置
# START DOCKER make docker-up
并初始化
gpg
,以便 解密敏感信息make gpg-init make secret-decrypt-with-password
我们不需要将
GPG_PASSWORD
传递给secret-decrypt-with-password
,因为我们已经在上一步通过make-init
设置了默认值。 - 一旦
application
容器运行起来,就可以通过调用qa
make 目标 来运行 QA 工具。# QA make qa || FAILED=true
|| FAILED=true
部分确保在检查失败时脚本不会终止。相反,失败的事实被“记录”在FAILED
变量中,以便我们最后进行评估。我们不希望脚本在这里停止,因为我们希望后续步骤也能执行(例如,测试)。 - 为了缓解 “
mysql
未准备就绪”问题,我们现在将应用 wait-for-service.sh 脚本。# WAIT FOR CONTAINERS bash .docker/scripts/wait-for-service.sh mysql 30 1
- 一旦
mysql
准备就绪,我们就可以通过test
make 目标 执行测试,并对 QA 工具应用相同的|| FAILED=true
变通方法。# TEST make test || FAILED=true
- 最后,打印所有计时器
# RUNTIMES echo "Build docker: " `expr $end_docker_build - $start_docker_build` echo "Start docker: " `expr $end_docker_up - $start_docker_up ` echo "QA: " `expr $end_qa - $start_qa` echo "Wait for containers: " `expr $end_wait_for_containers - $start_wait_for_containers` echo "Tests: " `expr $end_test - $start_test` echo "---------------------" echo "Total: " `expr $end_total - $start_total`
- 我们清理资源(这仅在本地运行时需要,因为 CI 系统的 runner 会被关闭)
# CLEANUP make make-init make docker-down ENV=ci || true
-
最后,评估在运行 QA 工具或测试时是否发生了任何错误
# EVALUATE RESULTS if [ "$FAILED" == "true" ]; then echo "FAILED"; exit 1; fi echo "SUCCESS"
执行示例
通过以下方式执行脚本
bash .local-ci.sh
产生以下(缩短的)输出
$ bash .local-ci.sh
Container dofroscra_ci-redis-1 Stopping
# Stopping all other `ci` containers ...
# ...
Client:
Cloud integration: v1.0.22
Version: 20.10.13
# Print more debugging info ...
# ...
Created a local .make/.env file
ENV=ci TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra
APP_USER_NAME=application APP_GROUP_NAME=application docker
compose -p dofroscra_ci --env-file ./.docker/.env
-f ./.docker/docker-compose/docker-compose-php-base.yml build php-base
#1 [internal] load build definition from Dockerfile
# Output from building the docker containers
# ...
ENV=ci TAG=latest DOCKER_REGISTRY=docker.io DOCKER_NAMESPACE=dofroscra
APP_USER_NAME=application APP_GROUP_NAME=application docker compose
-p dofroscra_ci --env-file ./.docker/.env
-f ./.docker/docker-compose/docker-compose.local.ci.yml
-f ./.docker/docker-compose/docker-compose.ci.yml up -d
Network dofroscra_ci_network Creating
# Starting all `ci` containers ...
# ...
"C:/Program Files/Git/mingw64/bin/make" -s gpg-import GPG_KEY_FILES="secret.gpg"
gpg: directory '/home/application/.gnupg' created
gpg: keybox '/home/application/.gnupg/pubring.kbx' created
gpg: /home/application/.gnupg/trustdb.gpg: trustdb created
gpg: key D7A860BBB91B60C7: public key "Alice Doe protected
<alice.protected@example.com>" imported
# Output of importing the secret and public gpg keys
# ...
"C:/Program Files/Git/mingw64/bin/make" -s git-secret ARGS="reveal -f -p 12345678"
git-secret: done. 1 of 1 files are revealed.
"C:/Program Files/Git/mingw64/bin/make" -j 8 -k --no-print-directory
--output-sync=target qa-exec NO_PROGRESS=true
phplint done took 4s
phpcs done took 4s
phpstan done took 8s
composer-require-checker done took 8s
Waiting for service 'mysql' to become healthy, checking every 1 second(s)
for max. 30 times
[1/30] ...
SUCCESS
PHPUnit 9.5.19 #StandWithUkraine
........ 8 / 8 (100%)
Time: 00:03.077, Memory: 28.00 MB
OK (8 tests, 15 assertions)
Build docker: 12
Start docker: 2
QA: 9
Wait for containers: 3
Tests: 5
---------------------
Total: 46
Created a local .make/.env file
Container dofroscra_ci-application-1 Stopping
Container dofroscra_ci-mysql-1 Stopping
# Stopping all other `ci` containers ...
# ...
SUCCESS
Github Actions 设置
如果您完全不熟悉 Github Actions,我建议从 GitHub Actions 官方快速入门指南 和 理解 GitHub Actions 文章开始。简而言之:
- Github Actions 基于所谓的 **Workflows(工作流)**
- Workflows 是 yaml 文件,位于仓库中特殊的 .github/workflows 目录下
- 一个 Workflow 可以包含多个 **Jobs(作业)**
- 每个 Job 由一系列 **Steps(步骤)** 组成
- 每个 Step 都需要一个
run:
元素,它代表由新 shell 执行的命令- 需要使用相同 shell 的多行命令的写法如下:
- run : | echo "line 1" echo "line 2"
- 需要使用相同 shell 的多行命令的写法如下:
工作流文件
Github Actions 会基于 .github/workflows 目录中的文件自动触发。我添加了文件 .github/workflows/ci.yml,内容如下:
name: CI build and test
on:
# automatically run for pull request and for pushes to branch
"part-7-ci-pipeline-docker-php-gitlab-github"
# @see https://stackoverflow.com/a/58142412/413531
push:
branches:
- part-7-ci-pipeline-docker-php-gitlab-github
pull_request: {}
# enable to trigger the action manually
# @see https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/
# CAUTION: there is a known bug that makes the "button to trigger the run"
# not show up
# @see https://github.community/t/workflow-dispatch-workflow-not-showing-in-actions-tab/130088/29
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: start timer
run: |
echo "START_TOTAL=$(date +%s)" > $GITHUB_ENV
- name: STORE GPG KEY
run: |
# Note: make sure to wrap the secret in double quotes (")
echo "${{ secrets.GPG_KEY }}" > ./secret.gpg
- name: SETUP TOOLS
run : |
DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
# install docker compose
# @see https://docs.dockerd.com.cn/compose/cli-command/#install-on-linux
# @see https://github.com/docker/compose/issues/8630#issuecomment-1073166114
mkdir -p $DOCKER_CONFIG/cli-plugins
curl -sSL https://github.com/docker/compose/releases/download/v2.2.3/docker-compose-linux-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose
- name: DEBUG
run: |
docker compose version
docker --version
cat /etc/*-release
- name: SETUP DOCKER
run: |
make make-init ENVS="ENV=ci TAG=latest
EXECUTE_IN_CONTAINER=true GPG_PASSWORD=${{ secrets.GPG_PASSWORD }}"
make docker-build
mkdir .build && chmod 777 .build
- name: START DOCKER
run: |
make docker-up
make gpg-init
make secret-decrypt-with-password
- name: QA
run: |
# Run the tests and qa tools but only store the error
# instead of failing immediately
# @see https://stackoverflow.com/a/59200738/413531
make qa || echo "FAILED=qa" >> $GITHUB_ENV
- name: WAIT FOR CONTAINERS
run: |
# We need to wait until mysql is available.
bash .docker/scripts/wait-for-service.sh mysql 30 1
- name: TEST
run: |
make test || echo "FAILED=test $FAILED" >> $GITHUB_ENV
- name: RUNTIMES
run: |
echo `expr $(date +%s) - $START_TOTAL`
- name: EVALUATE
run: |
# Check if $FAILED is NOT empty
if [ ! -z "$FAILED" ]; then echo "Failed at $FAILED" && exit 1; fi
- name: upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-artifacts
path: ./.build
步骤与 本地运行的运行详情 中解释的基本相同。一些额外的说明:
- 我希望 Action 仅在我 推送到分支 part-7-ci-pipeline-docker-php-gitlab-github 或创建拉取请求(通过
pull_request
)时自动触发。此外,我希望能够 手动触发任何分支上的 Action(通过workflow_dispatch
)。on: push: branches: - part-7-ci-pipeline-docker-php-gitlab-github pull_request: {} workflow_dispatch: {}
对于实际项目,我会让 Action 只在长期分支(如
main
或develop
)上自动运行。手动触发对于只想测试当前工作而不将其提交评审很有帮助。 **注意:** 有一个 已知问题,它会“隐藏”手动触发 Action 的“Trigger workflow”按钮。 - 每个
run:
指令都会启动一个新的 shell,因此我们必须将计时器存储在“全局” 环境变量$GITHUB_ENV
中。- name: start timer run: | echo "START_TOTAL=$(date +%s)" > $GITHUB_ENV
这将是我们唯一使用的计时器,因为作业使用了多个自动计时的步骤——所以我们不需要手动记录时间戳:
-
gpg
密钥被配置为 加密的秘密,名为GPG_KEY
,并存储在 ./secret.gpg 中。其值是 secret-protected.gpg.example 文件的内容。- name: STORE GPG KEY run: | echo "${{ secrets.GPG_KEY }}" > ./secret.gpg
秘密在 Github 仓库的 **Settings** > **Secrets** > **Actions** 下配置,网址为
https://github.com/$user/$repository/settings/secrets/actions e.g. https://github.com/paslandau/docker-php-tutorial/settings/secrets/actions
ubuntu-latest
镜像不包含docker compose
插件,因此我们需要 手动安装它。- name: SETUP TOOLS run : | DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} mkdir -p $DOCKER_CONFIG/cli-plugins curl -sSL https://github.com/docker/compose/releases/download/v2.2.3/ docker-compose-linux-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose
- 对于
make
初始化,我们需要第二个名为GPG_PASSWORD
的秘密——在本例中,它被配置为12345678
,请参阅 添加密码保护的秘密 gpg 密钥。- name: SETUP DOCKER run: | make make-init ENVS="ENV=ci TAG=latest EXECUTE_IN_CONTAINER=true GPG_PASSWORD=${{ secrets.GPG_PASSWORD }}"
- 由于 runner 将在运行后关闭,我们需要将构建伪影移动到永久位置,使用 actions/upload-artifact@v3 action。
- name: upload build artifacts uses: actions/upload-artifact@v3 with: name: build-artifacts path: ./.build
您可以在运行概览 UI 中 下载伪影:
Gitlab Pipelines 设置
如果您完全不熟悉 Gitlab Pipelines,我建议从 GitLab CI/CD 官方入门指南 开始。简而言之:
- Gitlab Pipelines 的核心概念是 **Pipeline(流水线)**
- 它在仓库根目录下的 .gitlab-ci.yml yaml 文件中定义
- 一个 Pipeline 可以包含多个 **Stages(阶段)**
- 每个 Stage 由一系列 **Jobs(作业)** 组成
- 每个 Job 包含一个 **
script
** 部分 script
部分由一系列 shell 命令组成
由于技术限制,本文档限制在 40000 个字符。请访问 CI Pipelines for dockerized PHP Apps with Github & Gitlab [Tutorial Part 7] 阅读完整内容。