概要
社内で Git サーバを立てることになり、金銭面の都合やセキュリティ上の要求から、セルフホストする GitLab サーバを立てることがあると思います。
その場合、GitLab の利用者となる、社内の開発者は、GitLab に関係するサーバをどう管理するかいうことは考えずに開発に集中したいと考えます。 そうすると、自ずと、サーバ管理者がGitLab サーバに関係するインフラ面の管理を行い、開発者はサービスを利用するだけにするといった役割分担をしたくなります。
GitLab には GitLab-CI という機能があります。
これは GitHub Actions や CircleCI、CodeBuild などと同様に、リポジトリ内に yaml ファイルを定義しておくことで CICD を管理できる機能です。
この機能を利用すると、今までは Jenkins などで CI を回していたのを GitLab-CI に寄せていくことができます。
GitLab-CI に寄せることで、Jenkins という別の仕組みを知らなくて良くなるので、認知負荷を減らすことができます。また、リポジトリの内部と外部で責任範囲を明確にすることができるようになります。
もちろん Jenkins にも汎用性や複数のリポジトリをまたいだCICDを組み立てることができるメリットがありますが、影響範囲が一つのリポジトリ内に収まるのであれば、GitLab-CI のほうがメリットがあります。
セルフホストする GitLab-CI の利用を開始するには job を実行するためのインスタンスが必要です。
GitLab-CI として設定するインスタンスは3つの種類があります。
- 特定のリポジトリだけで利用できる Specific runners
- 特定のグループ下で利用できる Group runners
- すべてのリポジトリから利用できる Shared runners
ここで、Specific Runner や Group runner を設定するには各リポジトリやグループの設定からする必要があるため、リポジトリやグループのOwner に当たる人ががインスタンスの設定を行う必要があります。そうすると、利用者側としてはコストを払いたくない、インスタンスの管理作業が発生してしまいます。
そこで、GitLab の管理者が Shared runner を用意することで、GitLab のサーバだけではなく GitLab-CI で利用するインフラに関しても、管理者と利用者で責任分界点を分けて、利用者はリポジトリの管理だけを行えば、良いというように役割を分けることができます。
手順
以下の手順で必要なコードは こちら にあります。
GitLab のリソースを立てる
shared runner を構築するために必要なリソースを立てます。
parameter の MyIp が 0.0.0.0/0
になっています。アクセス元が限定されるのであれば、セキュリティのため絞り込んでください。
AWSTemplateFormatVersion: 2010-09-09
Description: "GitLab related resources"
Parameters:
MyIp:
Type: String
Default: 0.0.0.0/0
AvailabilityZone:
Type: String
Default: "ap-northeast-1a"
SecurityGroupSuffix:
Type: String
Default: gitlabCiWorkerSecurityGroup
Resources:
GitlabServer:
Type: AWS::EC2::Instance
Properties:
AvailabilityZone: !Ref AvailabilityZone
EbsOptimized: true
IamInstanceProfile: !Ref GitlabServerInstanceProfile
ImageId: ami-036d0684fc96830ca
InstanceType: m5a.large
SecurityGroupIds:
- !Ref GitlabServerSecurityGroup
SubnetId: !Ref PublicSubnet
BlockDeviceMappings:
- DeviceName: "/dev/sda1"
Ebs:
VolumeType: "gp3"
DeleteOnTermination: "true"
VolumeSize: "30"
UserData:
Fn::Base64: |
#!/bin/bash
apt-get update
apt-get install ca-certificates curl gnupg lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose
docker -v
docker-compose -v
systemctl disable ssh
systemctl stop ssh
git clone https://github.com/athagi/gitlab-shared-runner-example.git
cd gitlab-shared-runner-example
PUBLIC_IP="$(curl 'http://169.254.169.254/latest/meta-data/public-ipv4')"
ls -al
sed -i -e "s/localhost/${PUBLIC_IP}/g" .env
docker-compose up -d
GitlabServerInstanceRole:
Type: 'AWS::IAM::Role'
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- ec2.amazonaws.com
Action:
- 'sts:AssumeRole'
Path: /
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
GitlabServerInstanceProfile:
Type: 'AWS::IAM::InstanceProfile'
Properties:
Path: /
Roles:
- !Ref GitlabServerInstanceRole
GitlabServerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: gitlab-ci-worker
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: !Ref MyIp
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: !Ref MyIp
- IpProtocol: icmp
FromPort: -1
ToPort: -1
CidrIp: !Ref MyIp
gitlabCiWorkerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: gitlab-ci-worker
VpcId: !Ref VPC
GroupName: !Sub ${AWS::StackName}-${SecurityGroupSuffix}
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
SourceSecurityGroupId: !Ref GitlabServerSecurityGroup
- IpProtocol: tcp
FromPort: 2375
ToPort: 2375
SourceSecurityGroupId: !Ref GitlabServerSecurityGroup
- IpProtocol: tcp
FromPort: 2376
ToPort: 2376
SourceSecurityGroupId: !Ref GitlabServerSecurityGroup
- IpProtocol: icmp
FromPort: -1
ToPort: -1
SourceSecurityGroupId: !Ref GitlabServerSecurityGroup
# this user is used by gitlab-ci config.toml
gitlabCiUser:
Type: AWS::IAM::User
Properties:
Policies:
- PolicyName: gitlab-ci-admin-policy
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- 'ec2:*'
Resource: '*'
gitlabCiUserAccessKey:
Type: AWS::IAM::AccessKey
Properties:
UserName: !Ref gitlabCiUser
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 192.168.0.0/16
InternetGateway:
Type: AWS::EC2::InternetGateway
InternetGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Ref AvailabilityZone
CidrBlock: 192.168.0.0/24
MapPublicIpOnLaunch: true
VpcId: !Ref VPC
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
PublicRouteA:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref InternetGateway
PublicSubnetARouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
Outputs:
VPCID:
Value: !Ref VPC
SubnetID:
Value: !Ref PublicSubnet
Region:
Value: !Ref "AWS::Region"
AvailabilityZone:
Value: !GetAtt PublicSubnet.AvailabilityZone
Zone:
Value: !Join ['', !Split ['ap-northeast-1', !GetAtt PublicSubnet.AvailabilityZone]] # Split delimiter cannot get !Ref value
GitlabCiUserAccessKey:
Value: !Ref gitlabCiUserAccessKey
GitlabCiUserSecretAccessKey:
Value: !GetAtt gitlabCiUserAccessKey.SecretAccessKey
SecurityGroupName:
Value: !Sub ${AWS::StackName}-${SecurityGroupSuffix}
GitLabServerPublicIP:
Value: !GetAtt GitlabServer.PublicIp
GitLabServerInstanceID:
Value: !Ref GitlabServer
#!/bin/bash
STACK_NAME=gitlab-resources1
aws cloudformation deploy --template-file cfn.yaml --stack-name "${STACK_NAME}" --capabilities CAPABILITY_IAM
echo "== output variables =="
outputs=$(aws cloudformation describe-stacks --stack-name "${STACK_NAME}" | jq .Stacks[].Outputs)
echo "-- GitLab server info --"
echo "GitLabServerIP=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "GitLabServerPublicIP")' | jq -r .OutputValue)"
echo "-- GitLab-CI [runners.machine] info --"
echo "amazonec2-access-key=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "GitlabCiUserAccessKey")' | jq -r .OutputValue)"
echo "amazonec2-secret-key=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "GitlabCiUserSecretAccessKey")' | jq -r .OutputValue)"
echo "amazonec2-region=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "Region")' | jq -r .OutputValue)"
echo "amazonec2-vpc-id=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "VPCID")' | jq -r .OutputValue)"
echo "amazonec2-subnet-id=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "SubnetID")' | jq -r .OutputValue)"
echo "amazonec2-zone=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "Zone")' | jq -r .OutputValue)"
echo "amazonec2-security-group=$(echo "${outputs}" | jq '.[] | select(.OutputKey == "SecurityGroupName")' | jq -r .OutputValue)"
ローカルで deploy.sh
を実行すると GitLabサーバが立ち上がります。GitLabServerIP にアクセスするとページが開きます。
また、shared runner の設定に必要な値も出力されます。この値は後で利用します。
GitLab-CI shared runner の設定に必要なトークンを取得する
GitLab-CI を利用できるように設定を行っていきます。
GitLab の root ユーザのパスワードを取得するために、AWS コンソールにログインし、先程立てたインスタンスにセッションマネージャ経由で接続します。
ログインしたらGitLab のルートユーザのパスワードを取得します。
$ sudo docker exec -it gitlab-shared-runner-example_GitLabServer_1 grep 'Password:' /etc/gitlab/initial_root_password
取得したパスワードを利用して GitLab に root ユーザとしてログインします。
その後 {GitLabServerIP}/admin/runners
に移動します。
Registration token を取得します。トークンは後で利用します。
GitLab-CI shared runner の設定を行う
GitLab-CI shared runner の設定をサーバ側で行っていきます。
今回は GitLab server と同居させますが、別のサーバで管理しても大丈夫です。
今回 gitlab runner では docker+machine executor を利用します。
これは、CI を実行する worker を gitlab runner が用意してそこで実行する方法です。
Docker machine は公式ではメンテナンスモードに移行されています1 が、GitLab ではそれをフォークして利用しています。
GitLab-CI の設定ファイルは config.toml
です。
取得した GitLab-CI のトークンを有効化するために以下のコマンドを実行して、config.toml
を取得します。
machine-option の値は、CloudFormation 作成時に取得した値で差し替えます。
current = 4
check_interval = 0
log_level = "info"
[session_server]
session_timeout = 1200
[[runners]]
name = "gitlab-shared-runner"
executor = "docker+machine"
[runners.docker]
tls_verify = false
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
shm_size = 0
[runners.machine]
IdleCount = 0
IdleTime = 1200
MachineDriver = "amazonec2"
MachineName = "gitlab-runner-AS-%s"
MaxBuilds = 10
MachineOptions = [
"amazonec2-access-key={{ amazonec2-access-key }}",
"amazonec2-secret-key={{ amazonec2-secret-key }}",
"amazonec2-region={{ amazonec2-region }}",
"amazonec2-vpc-id={{ amazonec2-vpc-id }}",
"amazonec2-subnet-id={{ amazonec2-subnet-id }}",
"amazonec2-zone={{ amazonec2-zone }}",
"amazonec2-use-private-address=true",
"amazonec2-security-group={{ amazonec2-security-group }}",
"amazonec2-instance-type=t3a.small",
"amazonec2-root-size=16",
]
GitLab サーバで発行したトークンを有効化するために、以下のコマンドを実行します。
そうすると、トークンが有効化され、先程編集した config.toml.template
を元に config.toml
が生成されます。
docker run --rm -v $(pwd)/config:/etc/gitlab-runner gitlab/gitlab-runner register \
--non-interactive --executor docker+machine --url "http://{{ GitLabServerIP }}/" \
--registration-token "{{ GITLAB_RUNNER_TOKEN }}" \
--template-config /etc/gitlab-runner/config.toml.template \
--docker-image alpine
続いて、gitlab-ci のコンテナを立ち上げます。
gitlab-ci:
image: 'gitlab/gitlab-runner:latest'
restart: always
volumes:
- './config:/etc/gitlab-runner'
- '/var/run/docker.sock:/var/run/docker.sock'
コンテナを立ち上げると、GitLab の Admin エリアより、runner が登録されていることが確認できます。
ここまで来ると、
他のプロジェクトから確認してみる
適当なリポジトリを作成します。
{リポジトリ}/-/settings/ci_cd へアクセスし、 Runners のアコーディオンを開くと Shared runner が存在していることが確認できます。
このリポジトリのルートに .gitlab-ci.yml
を作成します。
echo:
stage: build
image: busybox:latest
script:
- echo "hello world"
Appendix
Docker build & push を行う
このままでは GitLab-CI のコンテナ内で完結する job は実行できるのですが、イメージをビルドすることはできません。
そこで gitlab runner を利用して Docker イメージの build と プライベートレジストリへの push を行えるようにします。
例として、docker build を行い、作成したイメージを Amazon ECR に push する仕組みを作ります。
gitlab-runner の設定
gitlab-runner の設定ファイルである config.toml
に以下の設定を追加します。
[[runners]]
[runners.docker]
privileged = true
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
このようにすることで、GitLab-CI が実行されるコンテナはホストのEC2の docker.sock を利用してビルドすることができます。
図にすると以下のようになります。
リポジトリの設定
リポジトリに以下を追加します。
dockerBuild:
stage: build
image: public.ecr.aws/docker/library/docker:20.10.10
before_script:
- apk add --no-cache python3 py3-pip && pip3 install --upgrade pip && pip3 install awscli
- aws --version
script:
- aws ecr get-login-password | docker login --username AWS --password-stdin https://${ECR_URL}
- docker build -t ${ECR_URL}/myapp:${CI_COMMIT_SHORT_SHA} .
- docker images
- docker push ${ECR_URL}/myapp:${CI_COMMIT_SHORT_SHA}
variables:
ECR_URL: {{ myaccountid }}.dkr.ecr.ap-northeast-1.amazonaws.com
FROM public.ecr.aws/nginx/nginx:stable
RUN echo "hello world"
また、AWSリソースを操作するために必要なクレデンシャルを環境変数としてセットしておきます。
CI を実行してみると、、、
一つの GitLab-CI の job に対して、一つのEC2 インスタンスが割り当てられ、同時に複数の job が同じインスタンスで実行されるということが docker+machine executor だとありません。docker executor を選択した場合は、同時に複数の job が実行される可能性があるのでここが大きな違いになります。実行中に他の悪意のある job によってイメージが削除されたり、隣で実行されている job がディスクを多く消費した結果 No space left on device となってしまうことを避けることができます。
また、config.toml
の [runners.machine] の MaxBuilds の値を設定することで、一つの EC2 インスタンスで実行できる job の最大数を設定することができます。
これを設定することで、一度利用したインスタンスは破棄して、job が実行されるたびにインスタンスを作り直したり、数回利用したら破棄するといったことができます。
private registry への透過的なアクセス
プライベートレジストリに push はできるようになりましたが、今度はプライベートレジストリのイメージを利用してビルドしたくなると思います。
サーバ側の設定
GitLab runner のインスタンスがログインせずに pull できるように、ecr-credential-helper の設定を行っていきます。
このときに注意が必要なのは、ログインを行う主体が gitlab runner(GitLab server に同居させているインスタンス)であって、gitlab runner によって作成されたインスタンスではないところです。
CFn テンプレートの GitlabServerInstanceRole.Properties.ManagedPolicyArns
に対して、"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" を追加して再度デプロイします。
gitlab-runner を実行しているインスタンスで amazon-ecr-credential-helper の設定を行います。
また、gitlab-runner の docker-compose.yml にボリュームマウントの設定を追加します。
gitlab-ci:
image: 'gitlab/gitlab-runner:latest'
restart: always
volumes:
- './config:/etc/gitlab-runner'
- '/var/run/docker.sock:/var/run/docker.sock'
- '/usr/bin/docker-credential-ecr-login:/usr/bin/docker-credential-ecr-login'
config.toml
の [[runners]]
のセクションにも設定を追加します。
[[runners]]
environment = ["DOCKER_AUTH_CONFIG={\"credHelpers\":{\"{{ myaccountid }}.dkr.ecr.ap-northeast-1.amazonaws.com\":\"ecr-login\"}}"]
その後、gitlab-runner を再作成します。
リポジトリの設定
.gitlab-ci.yml
に以下を追加します。
dockerPull:
stage: build
image: {{ myaccountid }}.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:6aa3a135
script:
echo "docker pull is succeeded!"
実行してみると、、、
$DOCKER_AUTH_CONFIG
の設定を元に、プライベートレジストリのイメージを利用して GitLab-CI が実行できました!
job の並列度を上げる
利用者が増えてくるとEC2 のオートスケーリングする最大数を増やしたくなってくると思います。
その時は config.toml
の global セクションの concurrent
の数を増やすこと2で解決できます。
job 実行までのオーバーヘッドを減らしたい
今回の説明では、初回 job を実行したタイミングで runner の EC2 インスタンスを作成してから job を実行していました。
しかし、インスタンスを用意するためのオーバーヘッドが掛かってしまい、CIの時間が長くなってしまいます。
そこで config.toml
の [runners.machine]
セクションに IdleCount
を追加3することで、job をいつでも実行できる状態になったインスタンスを待機させておくことができます。
夜間はスケールインさせたい
IdleCount を使うことで日中は快適にビルドすることができる環境ができました。
しかし、夜間利用者が少なくなる時間帯に、大量の待機状態のインスタンスを起動させておくことはもったいないです。
[[runners.machine.autoscaling]]
を利用することで、特定の時間について AutoScaling の設定を上書きすることができます4。
以下のような設定を config.toml
に追加します。
[runners.machine]
MachineName = "auto-scale-%s"
MachineDriver = "amazonec2"
IdleCount = 0
IdleTime = 1200
[[runners.machine.autoscaling]]
Periods = ["* * 9-17 * * mon-fri *"]
IdleCount = 10
IdleTime = 3600
Timezone = "Asia/Tokyo"
このようにすることで、営業時間中(平日の9-17時)では常に10台の待機状態のインスタンスを起動しておいて、それ以外の時間帯ではオンデマンドでインスタンスを用意することができるようになります。
IdleTime は、インスタンスをスケールインするまでの時間を設定できる値です。
雑感
GitLab のサーバの管理者をしていると、利用者にはコードを書くことだけに集中してもらいたいという考えが出てくると思います。
GitLab-CI の shared runner を整備することで、大半の CI を行いたい要求をカバーできるようになります。
利用者にとっても、わざわざ specific runner を用意する手間が省け、CI をすぐに導入できるメリットがあります。
Jenkins おじさんが社内の CI を支えるより、リポジトリを利用する開発者が CI をメンテしていくほうがライフサイクルを別に保つことができますし、なによりスケールすることができます。
実際に社内で shared runner を用意したところ、小回りの効くリポジトリを管理している人や yaml で管理できる CI ツールの利便性を知っている人は速い段階で自然と利用し始めてくれていました。
組織文化という違った問題も出てきますが、まずは shared runner を用意してみて、考えに同意してくれる仲間を集めてみてはいかがでしょうか。
-
https://github.com/docker/machine/issues/4537#issue-341148889 ↩
-
https://docs.gitlab.com/runner/configuration/runner_autoscale_aws/#the-global-section ↩
-
https://docs.gitlab.com/runner/configuration/autoscale.html#how-concurrent-limit-and-idlecount-generate-the-upper-limit-of-running-machines ↩
-
https://docs.gitlab.com/runner/configuration/autoscale.html#autoscaling-periods-configuration ↩