AWSとCloudflareによるプレビュー環境


オンデマンドプレビュー環境は、オンザフライで一時的なインフラストラクチャと孤立した環境を回転させる戦略です.これは、リリースプロセスの初期段階で製品やQAなどの他のチームとのディスカッションを開き、クロスチームの可視性を向上させます.したがって、この記事では、我々はどのように我々はこれを達成することができます参照してくださいよAWS ECS and Cloudflare .
すべてのコードが利用可能ですrepository

なぜ我々はそれが必要ですか?
これがどのように我々のリリースとチームワークフロープロセスに利益をもたらすかについて見ましょう.これは私の個人的経験からの例です.
通常のワークフロー
現時点では、QAと製品のレビューは密接にリリースに結合され、それが頻繁に変更をロールバックするときには、それ自体はリリース自体にロールバックするのは難しい.

新しいワークフロー
彼らは変更についてのソフトレビューを行うことができるように、これはQAと製品チームに大きな利益を提供します.変更するまでステージング環境に到達するまで製品チームはもはや待機する必要があります.QAチームと同じように、プルリクエストレベルで変更をテストできます.


挑戦
私が直面しているいくつかの課題を見てみましょう.
SSL
大きな課題の一つはSSLを設定していたからですcannot 証明書をAWS ACM 独自のカスタムNginx ACMとしてのプロキシは、クラウドフロント、ALB、APIゲートウェイなどのAWSサービスだけで動作します.
これを研究しながらオンラインで見たアプローチはいくつかあります.
  • つのアプローチはLet's Encrypt 一時的なSSL certsを生成します.はい、どうぞgood implementation for this . しかし、これは我々が生成するすべてのCERTSを管理する他の問題を提示します.
  • もう一つのアプローチは、ちょうど新しいルート53記録を加えて、それからALBにそれを進めることです.問題は、これらのリソースを提供する必要はありませんが、我々は提供する必要がありますし、それらをかなり破壊する!
  • 曇りArgo Tunnel 救助に!これによって、我々は単にすべての入口を閉めることができて、代わりにトンネルを通して我々の交通を公開することができます.その後、プロキシのDNSレコードを作成することができますCloudFlareは私たちのためにSSLのスタッフを扱うことができます!
    ここで私はどこで詳細アルゴトンネルカバー
    セキュリティ
    この重要な部分はセキュリティです.私のAWSインフラストラクチャへのバックドアを、意図的に、あるいはミスによって、インターネットに対して単に私のプル要求における危険な変化を含んでいるだけでいるのを止めているのです.我々は、これらの一時的な環境を暴露する安全な方法を必要とします.
    最初に解決した解決策はAWS VPN または同様の何か.それで、我々は我々のVPNを使っている人々に環境へのアクセスを許すだけです.良い音?しかし、結局、これは我々がVPNをセットアップして、使用するためにあらゆるチームメンバーに乗り込むことを我々に要求しました.
    曇りAccess ゲームチェンジャーは、それは最大50ユーザーに無料です!これは私がVPNのない一時的な環境への安全で、より速い、そして、ゼロ信頼アクセスを作成するために必要としたものです.
    私たちはアクセスセクションで詳細にすべてのCloudFlare使用について話します.

    建築
    我々のアーキテクチャはかなりシンプルで直感的です.左側では、我々はどのように我々のアプリを構築し、開発者が新しいプルの要求を開き、ラベルを我々のインフラストラクチャを提供する方法を見ることができます.興味深いコンポーネントは、我々が実装するカスタムスクリプトです.途中で、我々は我々の基盤セットアップについていくつかの詳細に入ります.右側では、私たちは、どのように我々の一時的な環境終点へのアクセスを確保するために、Cloudflare ArgoトンネルとCloudFlareアクセスを利用するかについて見ます.

    これを見るhigher resolution

    実装
    全体を三つのセクションに分けました.
  • セットアップ
  • インフラ
  • アクセス
  • 注意: grepリポジトリtodo- 提供するために必要なすべてのものを得るには(すなわち、keys , token )

    セットアップ
    この手順では、Githubのアクションとカスタムプロビジョニングスクリプトの使用方法を確認します.
    ギタブアクション
    私たちは基本的にpull_request 次の種類のイベントlabeled , unlabeled , synchronize , closed . 以下の手順を行います.

  • 供給
    一旦プル要求があるならば、我々は我々のプレビュー環境をつくりますlabeled , and synchronize 一度新しいコミットがプッシュされます.
  • CloudFlare Argoトンネル、アクセスポリシー、およびアクセスアプリケーションを作成します.
  • 資格情報を保存する config.yml
  • ビルド中に私たちのDockerイメージに資格情報をコピーするので、我々は実行時にCloudFlareにアウトバウンド接続を作成することができます.
  • 塗りつぶし、タスク定義を登録します.
  • Githubからのイベントを処理するスクリプトと一時的なAWSとCloudFlareインフラストラクチャ.

  • 破壊する
    一旦プル要求があるならば、我々は我々のプレビュー環境を破壊しますclosed , or unlabeled .
  • 一時的なAWSとCloudFlareリソースを破壊する
  • GitHubアクションは既にリポジトリに含まれています .github/workflows/preview-environment.yml . これがスニペットです.
    name: Preview Environment
    on:
      pull_request:
        types: [labeled, unlabeled, synchronize, closed]
        branches:
          - develop
    
    env: ...
    
    jobs:
      provision:
        name: Provision
        if: ${{ github.event.action == 'labeled' && github.event.label.name == 'preview' && github.event.pull_request.state == 'open' || github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'preview') }}
        steps: ...
    
      destroy:
        name: Destroy
        if: ${{ github.event.action == 'unlabeled' && github.event.label.name == 'preview' || github.event.action == 'closed' }}
        steps: ...
    
    プロビジョニングスクリプト
    このスクリプトは、我々の一時的なインフラを提供するか破壊するのを助けます scripts/preview . プロビジョニングされたインフラストラクチャについて維持する方法はありませんので、ブランチ名を単にプロセス名を通してスラッグや一意のIDとして使用します.このスクリプトはconfig.ts 下記の通り.
    import * as env from 'env-var';
    
    const config = {
      // Domain for Cloudflare access policy
      domain: '<todo_your_domain>',
      aws: {
        region: 'us-east-1',
      },
      github: {
        // Token and Pull request no. will be available in Github Action
        token: env.get('GITHUB_TOKEN').required().asString(),
        pull_number: env.get('PULL_NUMBER').required().asInt(),
      },
      vpc: {
        securityGroups: {
          filter: '<todo_your_security_group_tag>',
        },
        subnets: {
          filter: '<todo_your_subnet_tag>',
        },
      },
      ecs: {
        cluster: '<todo_your_ecs_cluster_name>',
      },
      cloudflare: {
        path: './outputs/tunnel',
        auth_email: '<todo_your_cloudflare_email>',
        api_key: env.get('CLOUDFLARE_API_KEY').required().asString(),
        token: env.get('CLOUDFLARE_API_TOKEN').required().asString(),
        accountId: env.get('CLOUDFLARE_ACCOUNT_ID').required().asString(),
        zoneId: env.get('CLOUDFLARE_ZONE_ID').required().asString(),
        domain: '<todo_your_cloudflare_domain>',
      },
    };
    
    export default config;
    
    それはすべて一緒に来るpreview.ts :
    import * as github from '@actions/github';
    import slugify from 'slugify';
    import CloudflareUtils from './utils/cloudflare';
    import ECSUtils from './utils/ecs';
    import * as GithubUtils from './utils/github';
    import * as VPCUtils from './utils/vpc';
    import log from './utils/log';
    
    interface PreviewInterface {
      provision(taskDefArn: string): Promise<void>;
      destroy(): Promise<void>;
      tunnel(): Promise<void>;
    }
    
    class Preview implements PreviewInterface {
      private slug: string;
    
      constructor(branch: string) {
        const options = {
          lower: true,
        };
        const suffix = `${branch}-preview`;
        this.slug = slugify(suffix, options);
        log.info(`Using slug "${this.slug}" for branch "${branch}"`);
      }
    
      async provision(taskDefArn: string): Promise<void> {
        try {
          log.info(`Provisioning resources for task definition arn: ${taskDefArn}`);
          const subnets = await VPCUtils.getSubnets();
          const securityGroups = await VPCUtils.getSecurityGroups();
          const ecs = new ECSUtils(this.slug);
          const cloudflare = new CloudflareUtils(this.slug);
          await ecs.runTask(taskDefArn, subnets, securityGroups);
          const comment = `Your preview environment should be up at https://${cloudflare.domain} in few moments! 🎉`;
          if (github.context.payload.action === 'labeled') {
            await GithubUtils.commentOnPR(comment);
          }
          log.success(comment);
        } catch (error) {
          log.error(error);
          log.warn('Performing rollback!');
          this.destroy();
          process.exit(1);
        }
      }
    
      async destroy(): Promise<void> {
        try {
          log.info(`Destroying resources`);
          const ecs = new ECSUtils(this.slug);
          const cloudflare = new CloudflareUtils(this.slug);
          await ecs.stopTask();
          await cloudflare.removeDNSRecord();
          await cloudflare.deleteTunnels();
          await cloudflare.removeAccess();
          log.success('Resources destroyed');
        } catch (error) {
          log.error(error);
          process.exit(1);
        }
      }
    
      async tunnel(): Promise<void> {
        try {
          const cloudflare = new CloudflareUtils(this.slug);
          const tunnelId = await cloudflare.createTunnel();
          cloudflare.createConfigFile(tunnelId);
          await cloudflare.addDNSRecord(tunnelId);
          await cloudflare.createAccess();
          log.success('Tunnel setup complete');
        } catch (error) {
          log.error(error);
          process.exit(1);
        }
      }
    }
    
    export default Preview;
    
    次のように使用します.preview/commands/tunnel.ts
    import Preview from '../preview';
    import * as GithubUtils from '../utils/github';
    
    async function run(): Promise<void> {
      const branch = await GithubUtils.getCurrentBranch();
    
      const preview = new Preview(branch);
      await preview.tunnel();
    }
    
    run();
    
    使用法:
    $ yarn tunnel
    
    これはCloudFlareconfig.yml 下記のように.
    tunnel: <tunnel-id>
    credentials-file: /root/.cloudflared/<tunnel-id>.json
    
    ingress:
      - hostname: subdomain.domain.com
        service: http://localhost:4000
      - service: http_status:404
    
    preview/commands/provision.ts :
    import Preview from '../preview';
    import { ArgumentParser } from 'argparse';
    import * as GithubUtils from '../utils/github';
    
    const parser = new ArgumentParser({
      description: 'Provision preview environment',
    });
    
    parser.add_argument('-td', '--task-def-arn', {
      required: true,
      help: 'Task definition arn',
    });
    
    async function run(): Promise<void> {
      const { task_def_arn } = parser.parse_args();
      const branch = await GithubUtils.getCurrentBranch();
      const preview = new Preview(branch);
      await preview.provision(task_def_arn);
    }
    
    run();
    
    使用法:
    $ yarn provision --task-def-arn $TASK_DEFINITION
    
    preview/commands/destroy.ts :
    import Preview from '../preview';
    import * as GithubUtils from '../utils/github';
    
    async function run(): Promise<void> {
      const branch = await GithubUtils.getCurrentBranch();
    
      const preview = new Preview(branch);
      await preview.destroy();
    }
    
    run();
    
    使用法:
    $ yarn destroy
    

    インフラ
    我々が我々の一時的な仕事を走らせる前に、我々が必要とする基盤は、ここにあります.私は完全な実装をチェックするためにここにスニペットを追加しましたinfrastructure 倉庫のフォルダ.使っているterraform 供給するには
    注:お馴染みのない場合は、地形についての詳細を学ぶことができます
    # ECR repository
    resource "aws_ecr_repository" "ecr_repository" {
      name                 = "app-repository"
      image_tag_mutability = "IMMUTABLE"
      image_scanning_configuration {
        scan_on_push = true
      }
    }
    
    # ECS task definition used by ECS service
    resource "aws_ecs_task_definition" "task_definition" {
      family                   = "app-task-definition"
      network_mode             = "awsvpc"
      cpu                      = 4096
      memory                   = 8192
      requires_compatibilities = ["FARGATE"]
      container_definitions    = jsonencode([
      {
        "name": "app",
        "image": "nginx:latest",
        "essential": true,
        "portMappings": [
          {
            "containerPort": 4000,
            "hostPort": 4000
          }
        ]
      }
    ])
      task_role_arn            = aws_iam_role.task_execution_role.arn
      execution_role_arn       = aws_iam_role.task_execution_role.arn
    }
    
    # Security group
    resource "aws_security_group" "security_group" {
      name   = "app-security-group"
      vpc_id = var.vpc_id
    
      ingress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        cidr_blocks = ["0.0.0.0/0"]
      }
    
      egress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }
    
    # ECS cluster
    resource "aws_ecs_cluster" "cluster" {
      name = "ecs-cluster"
      capacity_providers = ["FARGATE"]
    }
    
    resource "aws_cloudwatch_log_group" "log_group" {
      name = "/ecs/app-log-group"
    }
    

    アクセス
    我々のアプリケーションとインフラストラクチャを実行しているので、アクセスについて話しましょう.より具体的にどのように活用することができますCloudflare Access .

    私たちが以前に議論したように、トンネルを作った後に、私たちはproxied CNAME DNSレコードCloudflare SDK 下記のように.

    アクセスポリシー
    それから、我々はAccess Policy 誰が我々の安全な終点にアクセスできるかについて制御するために.我々も、MFAを実施することができます!

    アクセスグループ
    これは、より良い曲のものですが、アクセスグループを使用するなどのチームを作成することができますEngineering , Product , QA など、我々のアクセスポリシーを設定しながら、これらのグループを使用してはるかに.これをあなたに任せます.


    用途
    以下に、プレビュー環境の使い方を説明します.
    供給
  • 開発者はプル要求を作成します.
  • 開発者のラベルは、preview レーベル.ラベル付けされると、我々のGithubアクションは、アプリケーションを構築し、インフラストラクチャを提供する必要があります.
  • GitHubアクションが完了すると、以下のようなプル要求に関するコメントを残します.環境はhttps://branch-slug.your-domain.com .
  • 製品またはQAチームはプル要求を評価するために新しい環境を使用します.Cloudflareへのアクセスをする誰でも.person@your_domain.com ), IDプロバイダでログインできますOkta ) プレビュー環境にアクセスします.

  • 破壊する
  • 破壊するには、いずれかのプル要求を閉じることができますまたは私たちの破壊ステップを開始するには、ラベルを解除します.


  • 改善
    改善のために、1つのアイデアは移動するスクリプトを移動して、それをterraformプロバイダーにすることができます.

    費用推定
    コストはかなりに翻訳されますAWS ECS pricing ( fargateで) Cloudflareのフリー層を使用しているので.

    結論
    私はこの記事は、常に任意の問題に直面している場合に役立つように役立つと思った.
    うまくいけば、これはあなたの組織でリリースプロセスの初期段階でQA、ソリューションチームとのいくつかのコラボレーションをもたらすでしょう.