前言

在我们之前的 DevOps Workflow 中,只有一套测试环境,也就是说我们如果要测试功能需要将 feature/* 分支的代码合并到 develop 分支,然后 Gitlab CI 执行部署。

因为我们的主要语言是 PHP 框架是 Laravel,处于成本考虑,没有采用打包容器的方式进行项目的部署。而是采用 Deployer SSH 到远端服务器进行部署。

如果没有并行的需求开发,这样也没什么问题。但经常会出现一些优先级较高的需求,需要穿插在当前已经在开发的功能中。之前我们只能等 develop 分支上的代码发布了以后才能将 feature/* 的代码合并到 develop 分支上进行测试。

而我们想实现的是,在测试服务器上基于分支部署多个测试环境,然后通过请求头区分要访问的测试分支。

例如开发者 A 创建了 feature/sso, 那么当该分支被推送到 Gitlab 时,则执行部署流程,测试人员可以通过在请求头中附加 X-Branch: feature/sso 来访问指定分支的测试环境。

为了实现这一功能,我调研了诸多方案,将在下面罗列并分析优劣,以供参考!

Gitlab CI 改造

将原来的仅监听 develop 分支的变化并执行 CI 流程,改为监听除 master 分支以外的所有流程。

stages:
  - review
  - staging
  - production

review:deploy:
  image: betterde/deployer:7.0.2
  only:
    refs:
      - branches
  tags:
    - backend
  stage: review
  except:
    refs:
      - master
      - develop
  before_script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
  script:
    - deployer deploy --root "$PROJECT_BASE_ROOT" --branch "$CI_COMMIT_REF_NAME" --env_file "$STAGING_ENV" --gitlab_domain "$GITLAB_DOMAIN" --gitlab_personal_token "$PERSONAL_TOKEN" $VERBOSE staging
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: https://laravel.example.com
    on_stop: review:stop
  resource_group: review

review:stop:
  image: betterde/deployer:7.0.2
  when: manual
  only:
    refs:
      - branches
  tags:
    - backend
  stage: review
  except:
    refs:
      - master
      - develop
  before_script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
  script:
    - deployer review:stop --root "$PROJECT_BASE_ROOT" --branch "$CI_COMMIT_REF_NAME" --gitlab_domain "$GITLAB_DOMAIN" --gitlab_personal_token "$PERSONAL_TOKEN" $VERBOSE staging
  variables:
    GIT_STRATEGY: none
  environment:
    name: review/$CI_COMMIT_REF_NAME
    action: stop
  resource_group: review

staging:deploy:
  image: betterde/deployer:7.0.2
  only:
    refs:
      - develop
  stage: staging
  tags:
    - backend
  before_script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
  script:
    - deployer deploy --root "$PROJECT_BASE_ROOT" --branch "$CI_COMMIT_REF_NAME" --env_file "$STAGING_ENV" --gitlab_domain "$GITLAB_DOMAIN" --gitlab_personal_token "$PERSONAL_TOKEN" $VERBOSE staging
  environment:
    name: staging
    url: https://laravel.example.com
  resource_group: staging

production:deploy:
  image: betterde/deployer:7.0.2
  only:
    refs:
      - tags
  when: manual
  tags:
    - backend
  stage: production
  before_script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
  script:
    - deployer deploy --root "$PROJECT_BASE_ROOT" --tag "$CI_COMMIT_TAG" --gitlab_domain "$GITLAB_DOMAIN" --gitlab_personal_token "$PERSONAL_TOKEN" $VERBOSE stage=production
  environment:
    name: production
    url: $PRODUCTION_URL
  resource_group: production

production:rollback:
  image: betterde/deployer:7.0.2
  only:
    refs:
      - tags
  when: manual
  stage: production
  tags:
    - backend
  before_script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
  script:
    - deployer rollback --root "$PROJECT_BASE_ROOT" $VERBOSE production
  resource_group: production

上面的配置中,核心在于 review:deployreview:stop 两个配置,以及 script 的脚本。当除了 masterdevelop 分支以外的分支触发了流程时,会通过 deployer 部署 Review 环境。

注意 review:stop 配置中的如下内容:

variables:
    GIT_STRATEGY: none

这本配置可以实现触发流程时不会拉取分支代码,因为当我们在合并 feature 分支时,如果勾选删除分支的话,没有这个配置会导致后面执行 review:stop 失败,因为分支在合并时已经被删除了!

最后一条指令中用到了 –gitlab_domain 和 –gitlab_personal_token 是我们自定义的参数,因为在我们的项目中使用到了基于 Gitlab 的私有 Composer 扩展包,所以需要提供 Gitlab 身份认证信息,来拉取扩展包。

下面就分析一下 deploy.php 的配置,如何实现基于分支的多环境部署。

Deployer 配置

<?php

namespace Deployer;

use Symfony\Component\Console\Input\InputOption;

require 'contrib/rsync.php';
require 'recipe/laravel.php';

option('gitlab_domain', null, InputOption::VALUE_OPTIONAL, 'Define Gitlab domain name');
option('gitlab_personal_token', null, InputOption::VALUE_OPTIONAL, 'Define gitlab personal token');
option('env', null, InputOption::VALUE_OPTIONAL, 'Define env file path');
option('root', null, InputOption::VALUE_REQUIRED, 'Define root path of the project');

// Keep releases version count
set('keep_releases', 1);

// Set permission of project dircotry
set('writable_chmod_mode', '0777');

// Set change permission mode
set('writable_mode', 'chmod');

// Not use sudo to set permission
set('writable_use_sudo', false);

// Set project repository
set('repository', '[email protected]:backend/laravel.git');

set('deploy_path', function () {
    $branch = input()->getOption('branch');
    if ($branch) {
        return input()->getOption('root') . '/' . $branch;
    }

    return input()->getOption('root');
});

// Install private composer package from private gitlab server
set('gitlab_domain', function () {
    if (input()->hasOption('gitlab_domain') && !empty(input()->getOption('gitlab_domain'))) {
        return input()->getOption('gitlab_domain');
    }
    return 'gitlab.shenjumiaosuan.com';
});

// Install private composer package from private gitlab server
set('gitlab_personal_token', function () {
    if (input()->hasOption('gitlab_personal_token') && !empty(input()->getOption('gitlab_personal_token'))) {
        return input()->getOption('gitlab_personal_token');
    }
    return '';
});

set('bin/php', 'docker exec fpm bash -c "cd {{release_path}} && php"');

// Staging host configuration
host('staging')
    ->set('port', 22)
    ->set('hostname', 'xxx.xxx.xxx.xxx')
    ->set('labels', ['stage' => 'staging'])
    ->set('http_user', 'www-data')
    ->set('remote_user', 'root')
    ->set('keep_releases', 1)
    ->set('identity_file', '~/.ssh/id_rsa')
    ->set('forward_agent', true)
    ->set('ssh_multiplexing', true)
    ->set('update_code_strategy', 'clone')
    ->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
    ->set('composer_install', 'COMPOSER=composer.dev.json composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');

// Production host configuration
host('production-01')
    ->set('port', 22)
    ->set('hostname', 'xxx.xxx.xxx.xxx')
    ->set('labels', ['stage' => 'production'])
    ->set('http_user', 'www-data')
    ->set('remote_user', 'root')
    ->set('keep_releases', 3)
    ->set('identity_file', '~/.ssh/id_rsa')
    ->set('forward_agent', true)
    ->set('ssh_multiplexing', true)
    ->set('git_ssh_command', 'ssh') // When the server openssh version less-than 7.4 this config is required
    ->set('update_code_strategy', 'clone')
    ->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
    ->set('composer_install', 'composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');

host('production-02')
    ->set('port', 22)
    ->set('hostname', 'xxx.xxx.xxx.xxx')
    ->set('labels', ['stage' => 'production'])
    ->set('http_user', 'www-data')
    ->set('remote_user', 'root')
    ->set('keep_releases', 3)
    ->set('identity_file', '~/.ssh/id_rsa')
    ->set('forward_agent', true)
    ->set('ssh_multiplexing', true)
    ->set('git_ssh_command', 'ssh') // When the server openssh version less-than 7.4 this config is required
    ->set('update_code_strategy', 'clone')
    ->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
    ->set('composer_install', 'composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');

desc('Composer auth');
task('composer:auth', function () {
    run('docker exec fpm bash -c "cd {{release_path}} && composer config gitlab-token.{{gitlab_domain}} {{gitlab_personal_token}}"');
});

//option('tag', null, InputOption::VALUE_REQUIRED, 'Tag to deploy');
desc('Installing vendors');
task('deploy:vendors', function () {
    run('docker exec fpm bash -c "cd {{release_path}} && {{composer_install}}"');
});

// Upload .env file to remote server
desc('Upload environment file');
task('env:upload', function () {
    $path = input()->getOption('env');
    if ($path) {
        upload(input()->getOption('env'), '{{deploy_path}}/shared/.env');
    }
});

desc('Stop review environment');
task('review:stop', function () {
    run('rm -rf {{deploy_path}}');
});

before('deploy:vendors', 'composer:auth');
after('deploy:vendors', 'env:upload');
after('deploy:failed', 'deploy:unlock');

CI 在执行上述的 deploy 配置后,会在远端服务器上的 $PROJECT_BASE_ROOT 目录下基于分支创建同名的目录。例如分支为 feature/sso,那么最终的部署路径就是 $PROJECT_BASE_ROOT/feature/sso

到这一步就完成了服务的部署,接下来还需要通过对 Nginx 的配置实现根据 X-Branch 头访问到对应的项目分支。

Nginx 配置

server {
    listen 80;
    server_name laravel.example.com;

    access_log off;

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name laravel.example.com;

    # 设置默认分支,当用户没有在请求头中附加 X-Branch 请求头时,则默认访问 develop 分支
    set $branch develop;
    if ($http_x_branch) {
        # 如果检测到 X-Branch 请求头时,则将值作为分支
        set $branch $http_x_branch;
    }

    # 将项目分支作为 root 的路径参数
    set $base /usr/wwwroot/laravel/$branch/current;
    root $base/public;
    index index.php;

    access_log off;

    include components/ssl.conf;

    include components/security.conf;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # additional config
    include components/general.conf;

    # handle .php
    location ~ \.php$ {
        root $base/public;
        fastcgi_pass fpm:9000;
        include components/fastcgi.conf;
    }
}

通过上述配置,就可以实现 Nginx 将分支路由到指定分支的源码了,且新增分支无需修改 Nginx 的配置。这种好处是基于请求头进行路由,配置简单,不用改变当前环境。

还有一种是使用 Nginx Unit + PHP-CLI 来实现 PHP 服务,因为 Unit 本身支持通过 API 进行配置热更新,所以灵活性更高。

但是对于我们来说有两个问题:

  • 将现有的 PHP-FPM 架构变更为 Unit
  • 我们的项目是前后端混编,所以访问是比较依赖域名和端口的(代码中有较多硬编码,所以不敢轻易动)

Debuging

基于上述考虑,我们最终还是选择了 Nginx + PHP-FPM 的架构。

请求

为了实现分支的切换,需要再浏览器中安装 Header Editor 插件,用来修改请求头。

Header Editor

经过这一番操作下来,就可以在测试服实现基于分支的多环境部署和测试了。

对于纯 API 项目而言,我们后面可能会考虑选择 Unit 这种方式或者直接使用 Go 开发,并使用 API Gateway 基于服务发现,自动路由多分支测试环境。

I hope this is helpful, Happy hacking…