Travis CIからGitLab CIに移行した
Tweetデジカでは長らく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
のキャッシュが働くようになったため - 最大並列度が大幅に向上したため混雑時も待ち時間はありません
- Travis CI: 約15 ~ 20分
今後の課題
なにもかも完璧かというともちろんそんなことはなく、いくつか今後解決すべき課題が残っています。 思いついた範囲で以下に列挙します。
ランナーホストの異常検知
ランナーホストに異常が発生すると、以降そのホストで開始されるジョブが全部失敗します。 気づいたタイミングで 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"