GitLab Runner on AWS Fargate を試してみた

2022.07.07

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

概要

https://docs.gitlab.com/runner/configuration/runner_autoscale_aws_fargate/ を実際にやってみました。

背景

GitLab Runner は GitLab と連携し、パイプラインでジョブを実行するアプリケーションです。

GitLab には無料枠分の共有 Runner があるため、必ずしも自前で構築する必要はありません。

しかし、下記のメリットがあります。

  • OSS で提供されているため、自前でも簡単に構築ができる
  • 自前で構築することによって ジョブを実行する環境のスペックを自由にコントロールができる
  • 自前で構築することによって プライベートネットワーク配下で提供できる

ただ、自前で構築する際、ランニングコスト, スケーリングの戦略を機にする必要があります。

なので GitLab Runner を ECS 上で必要な時に必要な時だけ起動するようにすれば何も考えずにすむのではないでしょうか。

GitLab では Fargate 向け Driver が提供されており、参考記事も用意されているため、そちらの記事を参考に構築してみました。

注意事項

参考記事と異なる点があります。

  1. 参考記事では ec2 上に構築し、ssh 経由でセットアップしていきますが、この記事では ECS on Fargate 上に構築しています。
  2. fargate driver は gitlab runner の worker を public ip 経由, もしくは nat 前提の private ip 経由で接続するようになっています。 public ip からの ssh port を公開したいモチベーションがないため, 一部 monkey patch を当てて構築しています。
  3. worker の image は何も入っていません。実際の運用では必要なものを事前にインストールしておくことを推奨します。

やってみた

cdk で構築しているのでコードの説明になります。

https://gitlab.com/kojima.takashi/2022-07-06-gitlab-runner-using-ecs

便宜上, gitlab からのメイン操作を受け取る gitlab runner => runner, gitlab runner から起動される gitlab runner => coordinator と表現しています。

0. VPC の構築

すでに構築している場合は不要です。

const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 1, natGateways: 0 });

1. ECS Cluster の構築

ECS の Cluster をまず作成する必要があります。

fargate で coordinator を立ち上げるため、capacityProvider には FARGATE, FARGATE_SPOT を指定します。

const cluster = new ecs.Cluster(this, "Cluster", {
  vpc,
  // enableFargateCapacityProviders: true, // XXX not supported defaultCapacityProvider
});
new ecs.CfnClusterCapacityProviderAssociations(
  this,
  "ClusterCapacityProviderAssociations",
  {
    cluster: cluster.clusterName,
    capacityProviders: ["FARGATE", "FARGATE_SPOT"],
    defaultCapacityProviderStrategy: [
      { capacityProvider: "FARGATE", base: 0, weight: 1 },
      { capacityProvider: "FARGATE_SPOT", base: 0, weight: 1 },
    ],
  }
);

Ref

2. coordinator の ECSTaskDefinition 作成

次に coordinator のベースとなる ECSTaskDefinition を定義します。

coordinator にも gitlab runner が必要です。また ssh 経由でアクセスするため, openssh-server が必要になります。

ARG GITLAB_RUNNER_VERSION=v15.1.0
ADD https://gitlab-runner-downloads.s3.amazonaws.com/${GITLAB_RUNNER_VERSION}/binaries/gitlab-runner-linux-amd64 /usr/local/bin/gitlab-runner
RUN chmod 0755 /usr/local/bin/gitlab-runner
RUN apt-get update \
  && apt-get install --no-install-recommends --assume-yes \
        ca-certificates \
        openssh-server bash git git-lfs \
  && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /run/sshd
RUN git lfs install --skip-repo

docker-entrypoint.sh で SSH_PUBLIC_KEY を受け取っていますが、こちらは fargate driver が提供しているため、 こちら側で設定する必要はありません。

echo -ne "${SSH_PUBLIC_KEY}" >"${USER_SSH_KEYS_FOLDER}/authorized_keys"
unset SSH_PUBLIC_KEY

ECSTaskDefinition には 先ほど作ったイメージを指定します。 gitlab-runner は環境変数でオプションを色々変えられるため、ここで指定しています。

const coordinatorTask = new ecs.TaskDefinition(
  this,
  "CoordinatorTaskDefinition",
  {
    compatibility: ecs.Compatibility.EC2_AND_FARGATE,
    cpu: "256",
    memoryMiB: "512",
  }
);
coordinatorTask.addContainer("ci-coordinator", {
  cpu: 256,
  memoryLimitMiB: 512,
  memoryReservationMiB: 512,
  image: ecs.ContainerImage.fromAsset(
    path.resolve(__dirname, "images/ci-coordinator")
  ),
  linuxParameters: new ecs.LinuxParameters(this, "CoordinatorLinuxParameter", {
    initProcessEnabled: true,
  }),
  environment: { LOG_FORMAT: "text" },
  logging: ecs.LogDrivers.awsLogs({
    streamPrefix: "GitLabRunnerCoordinator",
  }),
});

Ref

3. runner の ECSTaskDefinition 作成

次に runner 側の image を構築していきます。

runner 側には gitlab-runner binary の他, fargate driver をインストールする必要があります。

しかし、現状の実装ですと public ip を有効化した container を起動した際、public ip で ssh アクセスする挙動になっています。

public ip を固定して制限するなどの手段は多々ありますが、 いずれにしろ private ip でのアクセスしてほしいと考えているため、コードに手を加えることとしました。

FROM golang:1 AS builder

RUN git clone --depth 1 --branch v0.2.0 https://gitlab.com/gitlab-org/ci-cd/custom-executor-drivers/fargate.git /go/gitlab-org/ci-cd/custom-executor-drivers/fargate
WORKDIR /go/gitlab-org/ci-cd/custom-executor-drivers/fargate
RUN sed -i -e "s/if !usePublicIP {/if !usePublicIP || true {/" ./aws/fargate.go
RUN mkdir -p /opt/gitlab-runner/
RUN go build -o /opt/gitlab-runner/fargate ./cmd/fargate/

runner 側は多くのものをインストールする必要がありません。 root 以外のユーザで起動するようにしています。

RUN apt-get update \
  && apt-get install --no-install-recommends --assume-yes \
            ca-certificates \
  && rm -rf /var/lib/apt/lists/*

RUN useradd --comment "GitLab Runner" --create-home gitlab-runner --shell /bin/bash
RUN mkdir -p /opt/gitlab-runner/metadata /opt/gitlab-runner/builds /opt/gitlab-runner/cache \
  && chmod -R 0755 /opt/gitlab-runner \
  && chown -R gitlab-runner: /opt/gitlab-runner

COPY etc/gitlab-runner/ /etc/gitlab-runner/
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/local/bin/gitlab-runner", "run", "--working-directory", "/home/gitlab-runner", "--config", "/etc/gitlab-runner/config.toml", "--service", "gitlab-runner", "--user", "gitlab-runner"]

fargate driver で使用する設定ファイルも作成します。

起動時に環境変数から書き換えるため、適当な文字列で埋めています。

[Fargate]
Cluster = "{{ AWS_ECS_CLUSTER }}"
Region = "{{ AWS_REGION }}"
Subnet = "{{ AWS_VPC_SUBNET }}"
SecurityGroup = "{{ AWS_VPC_SECURITYGROUP_ID }}"
TaskDefinition = "{{ AWS_ECS_TASK_DEFINITION }}"
EnablePublicIP = {{ AWS_VPC_ENABLE_PUBLIC_IP }}

[TaskMetadata]
Directory = "/opt/gitlab-runner/metadata"

[SSH]
Username = "root"
Port = 22

docker-entrypoint.sh 内では 設定ファイルの修正します。

sed -i \
  -e "s%{{ AWS_REGION }}%${AWS_REGION}%g" \
  -e "s%{{ AWS_ECS_CLUSTER }}%${AWS_ECS_CLUSTER}%g" \
  -e "s%{{ AWS_ECS_TASK_DEFINITION }}%${AWS_ECS_TASK_DEFINITION}%g" \
  -e "s%{{ AWS_VPC_ENABLE_PUBLIC_IP }}%${AWS_VPC_ENABLE_PUBLIC_IP}%g" \
  -e "s%{{ AWS_VPC_SECURITYGROUP_ID }}%${AWS_VPC_SECURITYGROUP_ID}%g" \
  -e "s%{{ AWS_VPC_SUBNET }}%${AWS_VPC_SUBNET}%g" \
  /etc/gitlab-runner/fargate.toml

および gitlab への registration を行います。 custom-* には fargate driver を指定します。

/usr/local/bin/gitlab-runner register \
  --executor custom \
  --builds-dir "/opt/gitlab-runner/builds" \
  --cache-dir "/opt/gitlab-runner/cache" \
  --custom-run-exec /opt/gitlab-runner/fargate \
  --custom-run-args "--config" \
  --custom-run-args "/etc/gitlab-runner/fargate.toml" \
  --custom-run-args "custom" \
  --custom-run-args "run" \
  --custom-config-exec /opt/gitlab-runner/fargate \
  --custom-config-args "--config" \
  --custom-config-args "/etc/gitlab-runner/fargate.toml" \
  --custom-config-args "custom" \
  --custom-config-args "config" \
  --custom-prepare-exec /opt/gitlab-runner/fargate \
  --custom-prepare-args "--config" \
  --custom-prepare-args "/etc/gitlab-runner/fargate.toml" \
  --custom-prepare-args "custom" \
  --custom-prepare-args "prepare" \
  --custom-cleanup-exec /opt/gitlab-runner/fargate \
  --custom-cleanup-args "--config" \
  --custom-cleanup-args "/etc/gitlab-runner/fargate.toml" \
  --custom-cleanup-args "custom" \
  --custom-cleanup-args "cleanup" \
  --non-interactive

CDK では ECSTaskDefinition の他, coordinator 側に接続するための securityGroup , 起動するための IAM なども付与していきます。

const coordinatorSecurityGroup = new ec2.SecurityGroup(
  this,
  "CoordinatorSecurityGroup",
  { vpc }
);
const gitlabRunnerTask = new ecs.TaskDefinition(this, "RunnerTaskDefinition", {
  compatibility: ecs.Compatibility.EC2_AND_FARGATE,
  cpu: "256",
  memoryMiB: "512",
});
gitlabRunnerTask.addContainer("ci-runner", {
  cpu: 256,
  memoryLimitMiB: 512,
  memoryReservationMiB: 512,
  image: ecs.ContainerImage.fromAsset(
    path.resolve(__dirname, "images/ci-runner"),
    { file: "Dockerfile.pubnw" }
  ),
  environment: {
    CI_SERVER_URL: gitlabURL,
    REGISTRATION_TOKEN: gitlabRunnerToken,

    LOG_FORMAT: "json",
    RUNNER_NAME: this.stackName,
    RUNNER_TAG_LIST: Fn.join(",", [
      this.region,
      this.stackName,
      cluster.clusterName,
      coordinatorTask.family,
    ]),

    AWS_REGION: this.region,
    AWS_ECS_CLUSTER: cluster.clusterName,
    AWS_ECS_TASK_DEFINITION: coordinatorTask.family,
    AWS_VPC_SECURITYGROUP_ID: coordinatorSecurityGroup.securityGroupId,
    AWS_VPC_ENABLE_PUBLIC_IP: "true",
    AWS_VPC_SUBNET: vpc.publicSubnets[0].subnetId,
  },
  logging: ecs.LogDrivers.awsLogs({ streamPrefix: "GitLabRunner" }),
});
gitlabRunnerTask.taskRole.addManagedPolicy(
  iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonECS_FullAccess")
);
const gitlabRunnerService = new ecs.FargateService(this, "RunnerService", {
  cluster: cluster,
  taskDefinition: gitlabRunnerTask,
  assignPublicIp: true,
  vpcSubnets: { subnets: vpc.publicSubnets },
});
coordinatorSecurityGroup.connections.allowFrom(
  gitlabRunnerService.connections,
  ec2.Port.tcp(22)
);

4. gitlab runner のトークンを取得

前準備が大体完了したので、デプロイするだけなのですが、 GitLab Runner を登録するためのトークンが必要になります。

左のメニューの 設定 > CI/CD > Runner から取得ができます。

取得したら cdk deploy 時に渡せば OK です。

npx cdk deploy --parameters GitLabRunnerToken=${GITLAB_RUNNER_TOKEN}

登録されていることが確認できました。

5. 実際に実行

では作った gitlab runner に対して起動するようにして確認します。

タグを振っているので、そのタグを指定します。

---
deploy:
  stage: deploy
  tags:
    - GitLabRunnerOnFargate
  script:
    - echo "hello from fargate"
  environment:
    name: development

起動していますね、よかったです。

まとめ

GitLab runner を fargate 上でスケーリングするようにすることで ローコストで private の開発環境を使ったテストを行うことができます。

また、全て OSS として公開されているため、困ったときの調査や細かい挙動の確認、挙動の変更が容易です。

一方で 過剰なアクセス権限やネットワークの設定を怠ると セキュリティリスクになりかねません。 ソースコードの修正も影響範囲やリスクを容認した上で行なってください。