By

Travis CIからGitLab CIに移行した

デジカでは長らくCIサービスとして Travis CI を使用してきましたが、数カ月前に Komoju 開発に使用する CI を GitLab CI に(ほぼ)移行しました。この記事では移行を決めた理由と現在使用している構成を解説します。

詳細は以下に書きますが、CIにかかる時間が大幅に短縮、CI環境にかかる費用も半分近くに改善、そしてよりよいデプロイ管理機構を獲得し、今回の移行は成功だったと現在のところ判断しています。

デジカとTravis CI

4年くらい前からデジカではCIサービスとして Travis CI を使用してきました。現在でも多くのプロジェクトで利用されています。 しかし最近になり、特に Komoju の規模が大きくなるにつれて様々な問題が顕在化してきました。主なものは

規模の小さなプロジェクトであれば無視できるかもしれませんが、これらはKomojuの開発において大きな問題となることを懸念していました。 CIサービスの移行は時間と労力のかかるタスクですので、開発が大きなインパクトを受ける前に移行の検討を開始しました。その結果、移行先として決まったのが GitLab CI です。

デジカとGitLab CI

GitLab CI とは

詳しい説明は公式サイトに書かれているので省略しますが、デジカにとって決め手となったのは、設定 (.gitlab-ci.yml) の自由度の高さと、ランナー (CIのジョブを実行するホスト)を自前で持てることです。

なお、デジカはソースコードとプロジェクトの管理に GitHub を使用していますので、 GitLab の利用は CI 部分に限定しています。それから、GitLab のインスタンスは運用せずに gitlab.com を使用しています。

Group Runners

GitLab CI の Runner には複数の種類がありますが、デジカではAWSにランナー用の EC2 インスタンス (以下、ランナーホストと呼びます) を起動して、それをGroup Runners (Group内のレポジトリが利用可能なランナー) として運営しています。

ランナーホストを起動するための CloudFormation テンプレートはこの記事の後半に載せていますので参考にしてください。 GitLab が保有する Shared Runner を使わずに自前でランナーホストを運営することには以下のように様々なメリットがあります。

ランナーホスト所持のメリット1: ランナーホストに対して自由な設定ができる

submodule レポジトリの pull に用いる SSH キーをホストに配置して各ジョブがそのキーをマウントしたり、docker build の高速化のためにレイヤーをキャッシュしたり、単に Shared Runners を使うだけでは実現できない仕組みをランナーホストを自前で保持することで実現できます。

ランナーホスト所持のメリット2: コストとAWS構成

Shared Runners を使用しない場合、gitlab.com の使用にかかるコストはなんと驚くべきことに 0 円です。 したがって、発生する費用はランナーホストの運用にかかる AWS の費用だけです。

デジカでは、後述の CloudFormation テンプレートを見ればわかるとおり、ランナーホストにEC2 Spot Fleet を使用していて、以下のような設定になっています。

  • t3.xlarge または m5.xlarge
  • 平日の 9AM から 8PM の間は3台稼働、それ以外の時間帯は1台だけ
    • 平日はエンジニアの働いている可能性のある時間帯は3台いつもあるように、またオフタイムの緊急事態に迅速に対応できるように1台だけは常に動かしています。

Environments を利用したデプロイ管理

これは移行を決めるまでは気づいていなかったのですが、 GitLab CI には Environments と呼ばれるデプロイ管理機能があります。 これも詳細は公式ページにゆずりますが、

deploy_staging:
  stage: deploy
  script:
    - echo "Deploy to staging server"
  environment:
    name: staging
    url: https://staging.example.com
  only:
  - master

公式ページにある上記の例では、「masterブランチにpushされたら staging という名前の環境 (environment) にデプロイする。デプロイのためのスクリプトはdeploy_staging.script セクションで、その環境の URL は deploy_staging.environment.url である」という宣言をしています。

この機能のおかげで、デプロイの履歴の確認やロールバックが gitlab.com を使って実現できます。

Review Apps

この Environments はいわゆる「Review Apps」という仕組みにも応用できます。

デジカは社内で開発している PaaS、 Barcelona を使って GitHub 上にプルリクエストが開かれると、専用のドメイン名を持つレビュー用の web アプリを AWS に起動しており、これを Review Apps と呼んでいます。

deploy-reviewapp:
  # 中略
  script:
     - bcn review deploy $CI_ENVIRONMENT_SLUG --token $REVIEWAPP_TOKEN --tag $CI_COMMIT_REF_SLUG
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: https://$CI_ENVIRONMENT_SLUG.reviewapps.example.com
    on_stop: stop_reviewapp
  except:
    - master
    - production
    - /^rc-.*$/

一部変えましたが、これが実際に使用している review apps に関するジョブの記述です。 script 中の bcn というのが BarcelonaのCLIクライアント です。bcn review deploy... という部分でpushされたコミットをAWSのreview apps環境にデプロイしています (細かいところはよくわからないと思いますが、なんとなく雰囲気は伝わるかと思います)。そのときの環境名は review/$CI_COMMIT_REF_NAME ($CI_COMMIT_REF_NAME はブランチ名) で、そのドメインは $CI_ENVIRONMENT_SLUG.reviewapps.example.com になるという宣言です。$CI_ENVIRONMENT_SLUG は GitLab CI が割り振る、 environment.name ごとにユニークな文字列です。

こんな感じでGitLab上からreview appsの一覧が確認できます。

数字で見る改善

  • 月額: $249 (Travis CI) -> 約 $128 (AWS)
    • AWS の金額は以下のように計算
    • 0.2176 (t3.xlarge on-demand price) * 0.3 (spot 価格平均) * 1204 (インスタン時間合計) + $50 (Managed Nat Gateway)
    • 1204 = 11 * 22 * 2 (2台は平日9時から20時まで) + 24 * 30 (1台は常時稼働)
  • 最大並列度: 5 -> 24
    • GitLab runner の並列度は自由に決めることができますが、チューニングの結果 t3.xlarge 1 台あたり 8 並列ジョブが最も安定しました。
    • Travis CI では混雑時に頻繁にジョブの待ちが発生していましたが、現在は待ち時間はほぼゼロです。
  • ひとつのCIが全部完了する時間:
    • Travis CI: 約15 ~ 20分
      • 並列の待ち時間は含みません。混雑時は待ち時間込みで40分かかることもありました。
    • GitLab CI: 4 ~ 6分
      • テスト高速化: テストの並列度を Travis CI のときより高くしたため
      • Docker build高速化: docker build のキャッシュが働くようになったため
      • 最大並列度が大幅に向上したため混雑時も待ち時間はありません

今後の課題

なにもかも完璧かというともちろんそんなことはなく、いくつか今後解決すべき課題が残っています。 思いついた範囲で以下に列挙します。

ランナーホストの異常検知

ランナーホストに異常が発生すると、以降そのホストで開始されるジョブが全部失敗します。 気づいたタイミングで AWS の Web コンソールから該当インスタンスを殺してしまえばいいのですが (Spot Fleetが自動で新しいインスタンスを作ります)、この部分の運用はマニュアルの作業が要求されます。発生頻度は月に1回あるかどうかくらいなので、大きな負荷にはなっていません。

ミラーレポジトリのブランチ削除同期

GitLab CI を GitHub から使うためには GitLab にミラーのレポジトリを作成するのですが、GitLab のミラーリングはオリジナル側 (GitHub) のブランチ削除を同期しません。

これの何が問題かというと、デジカの開発フローでは GitHub の同一レポジトリ (フォークではなく) にブランチを push してプルリクエストを開いて、 master にマージされるとそのブランチは削除するのですが、このブランチ削除を GitLab のミラーが拾ってくれないので、Review Apps の削除ジョブが実行されません。本来はブランチ削除と同時に削除ジョブが起動されるのですが、現在は Environments のページにゴミがたまり続けています。

このミラーリングの挙動についてのIssue は存在していて、将来的には改善されるかもしれません。

GitHub プルリクエストの番号が拾えない

上記のように、GitLab CI の GitHub 連携はレポジトリミラーにより実現されます。なので、push されたブランチに対応する GitHub プルリクエストの番号を GitLab CI のスクリプト上で知ることができません。

この問題による弊害は意外にもひとつだけです。 デジカでは Danger というツールを使って、危険な可能性のある変更を検知すると Danger が GitHub PR にコメントするというのを実現しているのですが、Danger を GitLab CI で動かすことができません。なので、Danger は未だに Travis で動作させています。 現在 Komoju プロジェクトにおいて Travis で動くジョブは Danger だけです。

付録: CloudFormationテンプレート

一部のデジカ専用設定みたいなのを削除しましたが、ほとんどこのままデジカでも使っています。 VPC含め全部入りなので、このテンプレートから作成するだけでランナーが実際に動くとこまでいくはずです。 デジカのポリシーにのっとり、ネットワークをpublicとprivateで分けてManaged Nat Gatewayを使っていますが、 publicだけのネットワーク構成にすればNATいらないので更にお安くできます。

Parameters:
  VpcCIDR: 
    Type: String
    Default: 172.30.0.0/16
  PublicSubnet1CIDR:
    Type: String
    Default: 172.30.128.0/20
  PublicSubnet2CIDR:
    Type: String
    Default: 172.30.144.0/20
  PrivateSubnet1CIDR:
    Type: String
    Default: 172.30.0.0/20
  PrivateSubnet2CIDR:
    Type: String
    Default: 172.30.16.0/20
  GitlabRegistrationToken:
    Type: String
    NoEcho: true
  BucketName:
    Type: String

Resources:
  VPC: 
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCIDR
      EnableDnsHostnames: true
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: gitlab-runner
          Value: ""
        - Key: Name
          Value: gitlab-runner
  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC
  PublicSubnet1: 
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      CidrBlock: !Ref PublicSubnet1CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Public Subnet (AZ1)
  PublicSubnet2: 
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 1, !GetAZs '' ]
      CidrBlock: !Ref PublicSubnet2CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Public Subnet (AZ2)
  PrivateSubnet1: 
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      CidrBlock: !Ref PrivateSubnet1CIDR
      MapPublicIpOnLaunch: false
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Private Subnet (AZ1)
  PrivateSubnet2: 
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 1, !GetAZs '' ]
      CidrBlock: !Ref PrivateSubnet2CIDR
      MapPublicIpOnLaunch: false
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Private Subnet (AZ2)
  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties: 
      Domain: vpc
  NatGateway: 
    Type: AWS::EC2::NatGateway
    Properties: 
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties: 
      VpcId: !Ref VPC
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Public Routes
  DefaultPublicRoute: 
    Type: AWS::EC2::Route
    DependsOn: InternetGatewayAttachment
    Properties: 
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet1
  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet2
  PrivateRouteTable1:
    Type: AWS::EC2::RouteTable
    Properties: 
      VpcId: !Ref VPC
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Private Routes (AZ1)
  DefaultPrivateRoute1:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable1
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway
  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable1
      SubnetId: !Ref PrivateSubnet1
  PrivateRouteTable2:
    Type: AWS::EC2::RouteTable
    Properties: 
      VpcId: !Ref VPC
      Tags: 
        - Key: gitlab-runner
          Value: ""
        - Key: Name 
          Value: gitlab-runner Private Routes (AZ2)
  DefaultPrivateRoute2:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable2
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway
  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable2
      SubnetId: !Ref PrivateSubnet2
  InstancesSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: "Security group for the instances"
      VpcId: !Ref VPC
  InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument: |
        {
          "Statement": [{
            "Action": "sts:AssumeRole",
            "Effect": "Allow",
            "Principal": {
              "Service": "ec2.amazonaws.com"
            }
          }]
        }
      Policies:
        - PolicyName: instance-policy
          PolicyDocument: !Sub |
            {
              "Statement": [{
                "Effect": "Allow",
                "Action": [
                  "logs:CreateLogGroup",
                  "logs:CreateLogStream",
                  "logs:DescribeLogGroups",
                  "logs:DescribeLogStreams",
                  "logs:PutLogEvents",
                  "ssm:DescribeAssociation",
                  "ssm:GetDeployablePatchSnapshotForInstance",
                  "ssm:GetDocument",
                  "ssm:GetManifest",
                  "ssm:GetParameters",
                  "ssm:ListAssociations",
                  "ssm:ListInstanceAssociations",
                  "ssm:PutInventory",
                  "ssm:PutComplianceItems",
                  "ssm:PutConfigurePackageResult",
                  "ssm:UpdateAssociationStatus",
                  "ssm:UpdateInstanceAssociationStatus",
                  "ssm:UpdateInstanceInformation",
                  "ssmmessages:CreateControlChannel",
                  "ssmmessages:CreateDataChannel",
                  "ssmmessages:OpenControlChannel",
                  "ssmmessages:OpenDataChannel",
                  "ec2:DescribeInstanceStatus",
                  "ds:CreateComputer",
                  "ds:DescribeDirectories",
                  "ec2messages:AcknowledgeMessage",
                  "ec2messages:DeleteMessage",
                  "ec2messages:FailMessage",
                  "ec2messages:GetEndpoint",
                  "ec2messages:GetMessages",
                  "ec2messages:SendReply",
                  "cloudwatch:PutMetricData",
                  "s3:ListAllMyBuckets"
                ],
                "Resource": "*"
              }, {
                "Effect": "Allow",
                "Action": [
                    "s3:ListBucket",
                    "s3:GetBucketLocation"
                ],
                "Resource": [
                    "arn:aws:s3:::${BucketName}"
                ]
              }, {
                "Effect": "Allow",
                "Action": [
                    "s3:*"
                ],
                "Resource": [
                    "arn:aws:s3:::${BucketName}/*"
                ]
              }]
            }
  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref InstanceRole
  CacheBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName
  SpotLaunchTemplate:
    Type: "AWS::EC2::LaunchTemplate"
    Properties:
      LaunchTemplateName: GitlabRunnerFleetTemplate
      LaunchTemplateData:
        ImageId: ami-07ad4b1c3af1ea214 # 東京リージョンの公式 Ubuntu 18.04 AMI
        SecurityGroupIds:
          - !Ref InstancesSecurityGroup
        IamInstanceProfile:
          Name: !Ref InstanceProfile
        BlockDeviceMappings:
          - DeviceName: /dev/sda1
            Ebs:
              DeleteOnTermination: true
              VolumeSize: 30
              VolumeType: gp2
        TagSpecifications:
          - ResourceType: instance
            Tags:
            - Key: gitlab-runner
              Value: ""
            - Key: Name 
              Value: gitlab-runner
        UserData:
          "Fn::Base64": !Sub |
            #!/bin/bash
            set -ex
            curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash
            apt-get update
            apt-get install -y \
                apt-transport-https \
                ca-certificates \
                curl \
                gnupg2 \
                software-properties-common \
                python-pip
            curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
            add-apt-repository \
               "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
               $(lsb_release -cs) \
               stable"
            apt-get update
            pip install https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz
            pip install awscli
            cp -a /usr/local/init/ubuntu/cfn-hup /etc/init.d/cfn-hup
            chmod u+x /etc/init.d/cfn-hup
            /usr/local/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource SpotLaunchTemplate 
    Metadata:
      AWS::CloudFormation::Init:
        config:
          packages:
            apt:
              gitlab-runner: ["11.10.1"]
              docker-ce: ["5:18.09.6~3-0~ubuntu-bionic"]
          commands:
            register_generic_runner:
              command: !Sub |
                gitlab-runner register --non-interactive \
                --description "Generic docker runner" \
                --url "https://gitlab.com/" \
                --registration-token "${GitlabRegistrationToken}" \
                --executor "docker" \
                --tag-list "docker,linux" \
                --cache-type s3 \
                --cache-shared \
                --cache-s3-bucket-name ${BucketName} \
                --cache-s3-bucket-location ap-northeast-1 \
                --docker-image "docker:git" \
                --docker-privileged
            register_builder_runner:
              command: !Sub |
                gitlab-runner register --non-interactive \
                --description "Docker build runner. DO NOT run containers" \
                --url "https://gitlab.com/" \
                --registration-token "${GitlabRegistrationToken}" \
                --executor "docker" \
                --tag-list "docker-sock,linux" \
                --cache-type s3 \
                --cache-shared \
                --cache-s3-bucket-name ${BucketName} \
                --cache-s3-bucket-location ap-northeast-1 \
                --docker-image "docker:git" \
                --docker-volumes /var/run/docker.sock:/var/run/docker.sock
          files:
            "/etc/gitlab-runner/config.toml":
              mode: "000644"
              content: |
                concurrent = 8
            "/etc/cron.daily/docker-prune":
              mode: "000755"
              content: |
                #!/bin/bash
                docker system prune --all --volumes --force
            "/etc/cron.hourly/docker-prune":
              mode: "000755"
              content: |
                #!/bin/bash
                docker system prune --volumes --force
  SpotFleet:
    Type: AWS::EC2::SpotFleet
    Properties:
      SpotFleetRequestConfigData:
        AllocationStrategy: lowestPrice
        IamFleetRole: arn:aws:iam::822761295011:role/aws-ec2-spot-fleet-tagging-role
        TargetCapacity: 12
        Type: maintain
        LaunchTemplateConfigs:
          - LaunchTemplateSpecification:
              LaunchTemplateId: !Ref SpotLaunchTemplate
              Version: !GetAtt SpotLaunchTemplate.LatestVersionNumber
            Overrides:
              - SubnetId: !Ref PrivateSubnet1
                InstanceType: t3.xlarge
                SpotPrice: "0.2176"
                WeightedCapacity: 4
              - SubnetId: !Ref PrivateSubnet2
                InstanceType: t3.xlarge
                SpotPrice: "0.2176"
                WeightedCapacity: 4

              - SubnetId: !Ref PrivateSubnet1
                InstanceType: t2.xlarge
                SpotPrice: "0.2176"
                WeightedCapacity: 4
              - SubnetId: !Ref PrivateSubnet2
                InstanceType: t2.xlarge
                SpotPrice: "0.2176"
                WeightedCapacity: 4

              - SubnetId: !Ref PrivateSubnet1
                InstanceType: m5.xlarge
                SpotPrice: "0.248"
                WeightedCapacity: 4
              - SubnetId: !Ref PrivateSubnet2
                InstanceType: m5.xlarge
                SpotPrice: "0.248"
                WeightedCapacity: 4

              - SubnetId: !Ref PrivateSubnet1
                InstanceType: m4.xlarge
                SpotPrice: "0.248"
                WeightedCapacity: 4
              - SubnetId: !Ref PrivateSubnet2
                InstanceType: m4.xlarge
                SpotPrice: "0.248"
                WeightedCapacity: 4
  SpotFleetScalableTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 16
      MinCapacity: 0
      ResourceId: !Join
        - /
        - - spot-fleet-request
          - !Ref SpotFleet
      RoleARN: !GetAtt
        - SpotFleetAutoscaleRole
        - Arn
      ScalableDimension: 'ec2:spot-fleet-request:TargetCapacity'
      ServiceNamespace: ec2
      ScheduledActions:
        - ScheduledActionName: Weekday morning
          ScalableTargetAction:
            MaxCapacity: 12
            MinCapacity: 12
          Schedule: "cron(0 0 ? * MON-FRI *)"
        - ScheduledActionName: Weekday evening
          ScalableTargetAction:
            MaxCapacity: 4
            MinCapacity: 4
          Schedule: "cron(0 11 ? * MON-FRI *)"
  SpotFleetAutoscaleRole:
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Action:
          - sts:AssumeRole
          Effect: Allow
          Principal:
            Service:
            - application-autoscaling.amazonaws.com
        Version: 2012-10-17
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonEC2SpotFleetAutoscaleRole
      Path: /
    Type: AWS::IAM::Role

Outputs: 
  VPC: 
    Value: !Ref VPC
    Export: 
      Name: !Sub "gitlab-runner:VPC"
  PublicSubnet1:
    Value: !Ref PublicSubnet1
    Export: 
      Name: !Sub "gitlab-runner:PublicSubnet1"
  PublicSubnet2: 
    Value: !Ref PublicSubnet2
    Export: 
      Name: !Sub "gitlab-runner:PublicSubnet2"
  PrivateSubnet1:
    Value: !Ref PrivateSubnet1
    Export: 
      Name: !Sub "gitlab-runner:PrivateSubnet1"
  PrivateSubnet2: 
    Value: !Ref PrivateSubnet2
    Export: 
      Name: !Sub "gitlab-runner:PrivateSubnet2"
  VpcCIDR:
    Description: VPC CIDR
    Value: !Ref VpcCIDR
    Export:
      Name: !Sub "gitlab-runner:VpcCIDR"
一緒にユニークな決済サービスを作ってくれる Rails エンジニアを募集中です!
多国籍なメンバーと一緒に仕事をしてみませんか?詳細はこちらのリンクです:D