TECHSTEP

ITインフラ関連の記事を公開してます。

GitLab CI/CD Catalogの紹介

今回はGitLab 16.7でベータ版をリリースしたCI/CD Catalogを紹介します。

docs.gitlab.com

背景

GitLab CI/CD Catalogの前に、CI/CD componentsについて理解する必要があります。

CI/CD componentsはGitLab内部で再利用可能なパイプラインのユニットです。CI/CD componentを使うことで、小さな部品を組み合わせるようにパイプラインを設定でき、より大きなパイプラインを設定することも可能となります。

CI/CD componentは 以前取り上げた include と似たような機能であり、CI/CD componentを利用するときは include:component という形で定義します。ただし、CI/CD componentにはいくつかの利点があります。

  • CI/CD Catalogの中でリスト化され、必要なものを見つけやすくなります。
  • componentは特定のバージョンをリリース・使用できます。
  • 同じProject内で複数のcomponentを定義し、バージョン管理できます (最大10個まで) 。

CI/CD Catalogは、上記の通りCI/CD componentsをリスト化したものですが、Project内部で用意したものだけでなく、外部に公開されたものも利用できます。

なお、CI/CD CatalogについてはGitLabのブログでも紹介されています。

about.gitlab.com

検証

ここから実際にCI/CD Catalogを使ってみます。

CI/CD componentsを組織内部で利用

まずはCI/CD Catalogの前にCI/CD Componentsを組織内で利用する例を試します。ここでは cicd-catalog-test-component というProjectにテスト用のテンプレートを配置し、 cicd-catalog-test Projectからそれを呼び出します。

CI/CD componentsのテンプレートを用意する場合、いくつかの条件 (と推奨事項) があります。

  • ディレクトリ構成
    • README.mdリポジトリ中のcomponentsの詳細が記載されていること
    • templates ディレクトリがルートに配置され、全てのcomponentsの定義がそこに含まれること
    • componentファイルは .yml という拡張子であること
    • templates ディレクトリ内はサブディレクトリも含められるが、各サブディレクトリにはエントリポイントとして template.yml というファイルが含まれていること
  • 推奨事項
    • .gitlab-ci.yml を使ってcomponentsのテストとリリースを行うこと
    • LICENSE.md ファイルを配置し、ライセンス情報を加えること

1つのcomponentファイルを含むリポジトリの場合は以下のような構成が推奨されています。

├── templates/
│   └── my-component.yml
├── LICENSE.md
├── README.md
└── .gitlab-ci.yml

cicd-catalog-test-component には、以下のようなファイルを用意します。今回は必要最小限のファイルのみ配置します。

├── templates/
│   └── my-component.yml
└── README.md

templates/my-component.yml

spec:
  inputs:
    stage:
      default: test
---
component-job:
  script: echo "This is from cicd-catalog-test-component project."
  stage: $[[ inputs.stage ]]

上記ファイルに含まれる spec とは、GitLab CI/CDで利用するHeader keywordの1つであり、 Pipelineに include が含まれたときの動作設定を定義できます。ここでは spec:inputs で実行時の入力パラメータとして stage を追加しています。また spec:inputs:default でデフォルトの入力値を設定します。

docs.gitlab.com

次に、上記componentを呼び出す側として cicd-catalog-test というProjectを作成し、 .gitlab-ci.yml ファイルからこれを呼び出します。

CI/CD componentsを呼び出す場合、 include:component を使用します。componentを指定するときは <FQDN>/<Projectパス>/<component名>@<バージョン> というフォーマットを使います。

  • FQDN: GitLabホストとマッチするFQDNを指定
  • Projectパス: componentを含むProjectのフルパスを指定
  • component名: 単一ファイルの場合はファイル名、複数の場合は template.yml を含むディレクトリ名を指定
  • バージョン: Commit SHA、タグ、ブランチ名、 latest などを指定

今回は .gitlab-ci.yml で以下のように定義しました。

stages:
  - build
  - test

build:
  stage: build
  script:
    - echo "This is from cicd-catalog-test project at build stage."

test:
  stage: test
  script:
    - echo "This is from cicd-catalog-test project at test stage."

include:
  - component: gitlab.com/fy0323/cicd-catalog-test-component/my-component@main
    inputs:
      stage: build

.gitlab-ci.ymlを配置すると、以下のようにパイプラインが実行され、定義通り build Stageで component-job Jobが実行されます。

ここで component-job を見ると、 cicd-catalog-test-component で定義した通りの文字列が表示されるのを確認できます。

CI/CD Catalogの利用

次にCI/CD Catalogを利用します。

まずCI/CD Catalogのリストを確認します。GitLab UIの 検索または移動先 を選択し、 検索 を選択します。画面左メニューに CI/CDカタログ と表示されるのでそこを選択すると、CI/CD Catalogが表示されます。なお操作時は全部で101のCatalogが登録されていました。

ここではその中から components/sast というCatalogを利用します。このCatalogは名前の通りSASTを実行するものであり、GitLabの提供するSASTと同一の機能を提供します。

各Catalogには README.md に記載された内容が表示され、基本的な使用方法などが紹介されています。

ここでは components/sast の内容に従い、以下のように .gitlab-ci.yml を修正します。またSASTのテスト用に適当なファイルを配置します。

※なお本検証はGitLab 16.8時点に行いましたが、GitLab 16.10からはSemantic versioningによる指定が強制されます

include:
  - component: gitlab.com/gitlab-components/sast/sast@1.0

パイプラインを起動すると、ここでは semgrep-sast というJobが実行されている様子を確認できます。

semgrep-sast を選択するとSASTが実行されていることが確認できます。またArtifactも配置されており、スキャン結果も確認できました。

GitLab Runnerのネットワーク要件を調査する

今回はGitLab Runnerのネットワーク要件を調査しました。なお検証後に該当のドキュメントを見つけてしまったので、検証の意味はあまりないです。

背景

以前GitLab Runnerの登録と利用を検証したのですが、GitLab Runnerを利用するときのネットワーク要件が明確に記載されたドキュメントを見つけられませんでした。いくつかのブログ等を見ると、GitLab SaaS版を使っている場合は gitlab.com へのhttps通信が許可されていればよいようでしたが、公式のドキュメントが見つからなかったので、念のため検証してみました。 検証後に再度探したところ、こちらのドキュメントに記載がありました。GitLabのネットワーク構成はこちらに一通り書かれているようです。

docs.gitlab.com

検証

GitLab Runnerの作成、トークン発行

あらかじめGitLab画面からRunnerを作成しておきます。

Amazon EC2インスタンスの作成

インスタンスの作成は、以前使用したCloudFormationテンプレートから gitlab-runner register の実行箇所を除外したものを使いました。

作成後、DockerのインストールやGitLab Runnerのパッケージが含まれていることを確認します。

[ec2-user@ip-10-0-0-138 ~]$ docker version
Client:
 Version:           20.10.25
 API version:       1.41
 Go version:        go1.20.12
 Git commit:        b82b9f3
 Built:             Fri Dec 29 20:37:18 2023
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server:
 Engine:
  Version:          20.10.25
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.20.12
  Git commit:       5df983c
  Built:            Fri Dec 29 20:38:05 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.7.11
  GitCommit:        64b8a811b07ba6288238eefc14d898ee0b5b99ba
 runc:
  Version:          1.1.11
  GitCommit:        4bccb38cc9cf198d52bebf2b3a90cd14e7af8c06
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
[ec2-user@ip-10-0-0-138 ~]$ yum list | grep gitlab
gitlab-runner.x86_64                     16.9.1-1                     @runner_gitlab-runner
gitlab-runner-fips.x86_64                16.9.1-1                     runner_gitlab-runner
[ec2-user@ip-10-0-0-138 ~]$

Security Groupのアウトバウンド通信制

次にSecurity Groupのアウトバウンド通信を制限します。ここでは443ポートのみを許可するよう設定しました。

GitLab Runnerの登録

Security Groupを修正後、GitLab Runnerの登録を実行します。

[ec2-user@ip-10-0-0-138 ~]$ sudo gitlab-runner register \
>   --non-interactive \
>   --url "https://gitlab.com/" \
>   --token <GitLab runner authentication token> \
>   --executor "docker" \
>   --docker-image alpine:latest \
>   --description "docker-runner"
Runtime platform                                    arch=amd64 os=linux pid=6649 revision=782c6ecb version=16.9.1
Running in system-mode.

Verifying runner... is valid                        runner=ssmZ-8R_n
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml"
[ec2-user@ip-10-0-0-138 ~]$

GitLabパイプラインの実行

GitLab Runnerの登録ができたのでGitLabパイプラインからRunnerを使って処理を実行しています。以前使用した .gitlab-ci.yml を使ってパイプラインを起動すると、問題なく処理が完了することを確認できました。

GitLab includeでワークフローを再利用する

今回はGitLab CI/CDで利用できるキーワードの中から、.gitlab-ci.yml から同じProject上の別のファイル、別Project上のファイルを呼び出せる include について検証しました。

docs.gitlab.com

docs.gitlab.com

背景

ワークフローの定義ファイルを再利用することは、CI/CDをプロジェクトで運用する場合に重要です。パブリックに公開されたワークフローやプロジェクト共通のテンプレートを再利用することで、CI/CDの定義ファイルの記述量を削減したり、何度も同じ記述をコピーペーストしないことで、管理しやすいCI/CD定義ファイルの運用や人的ミスの削減を見込めます。

GitLabではCI/CDの実行を .gitlab-ci.yml で制御するのがデフォルトの設定です。例えば1つのGitLabプロジェクトで実行する全ての処理を .gitlab-ci.ymlスクリプトで記述すると、膨大な記述量となる場合もあります。GitLabでは include というキーワードを使うことで、同じProject上にある .gitlab-ci.yml 以外のファイルや、別Projectの定義ファイルを呼び出すことができます。

GitLabは現在6つの形式で include を利用できます。 components inputs は別の機会に触れるとして、ここでは他4つの利用方法を紹介します。

  • local : 同じProject上にある別ファイルを呼び出します。
  • project : 同じGitLabインスタンス上のプライベートな別Projectのファイルを呼び出します。
  • remote : 別のGitLabインスタンス上のファイルを呼び出します。
  • template : GitLabが公式に提供するテンプレートを呼び出します。
  • components : CI/CD componentという再利用可能なパイプラインのユニットを呼び出します。
  • inputs : 各 include を利用するときに変数を上書きするのに使用します。

なお、 include を利用するには、いくつかの制限事項があります。

  • include という名前のJobから呼び出す形でないと利用できません。
  • 呼び出すファイルの拡張子は yaml でないといけません。
  • project / remote / template / components の場合、呼び出し元のファイルが修正されてもCIは実行されません。
  • include を含むJobまたはパイプラインを再実行した場合、挙動が変化します。
    • Jobの場合: include で指定したファイルに修正を加えた後に再実行しても、その内容はJobに反映されません。
    • パイプラインの場合: include で指定したファイルに修正を加えた後に再実行すると、その内容はパイプラインに反映されます。
  • パイプラインあたりの include の最大数はデフォルトで150までです。

検証

ここから各手法を検証します。

include:local

docs.gitlab.com

include:local は同じProject上のファイルを呼び出す形です。1つのGitLab Projectで管理するような小規模プロジェクト、またはCI/CDの導入初期に利用するのがユースケースかと思います。

include:local を利用する場合は、 .gitlab-ci.yml に以下のように定義します。

include:
  - local: '<呼び出すファイルパスを指定>'

include:local を利用するうえでの注意事項は以下の通りです。

  • .gitlab-ci.yml と同じブランチ上のファイルしか対象にできません。
  • GIt submodule (ある Git リポジトリを別の Git リポジトリのサブディレクトリとして扱う) のパスでファイルを使用することはできません。

ここでは include-local-test というProject上に以下のようなファイルを配置しました。

.gitlab-ci.yml

build:
  stage: build
  script:
    - echo "Start include:local jobs."

include:
  - local: '.gitlab-ci.local.yml'
  - local: '/include/.gitlab-ci.local.yml'

.gitlab-ci.local.yml

test-from-include:
  stage: test
  script:
    - echo "This is from .gitlab-ci.local.yml."

include/.gitlab-ci.local.yml

deploy:
  stage: deploy
  script: 
    - echo "This is from include/.gitlab-ci.local.yml."

include:local.gitlab-ci.yml と別のディレクトリ上のファイルも呼び出し可能です。ここでは .gitlab-ci.local.yml include/.gitlab-ci.local.yml という2つのファイルを呼び出しています。

このProjectでCI/CDを実行すると、以下のように3つのStageに分かれて実行されます。

.gitlab-ci.local.yml を呼び出す test Jobを見ると、 This is from .gitlab-ci.yml.local (テキストを間違っておりました。。。) と表示されており、 .gitlab-ci.local.yml を呼び出しているのを確認できます。

include/gitlab-ci.local.yml を呼び出す deploy Jobも同様に確認できます。

include:project

docs.gitlab.com

include:project は同じインスタンス上の別Projectからファイルを参照します。複数のGitLab Projectにまたがって進むような大規模プロジェクトにおいて、include:project 専用のProjectを用意し、各Projectから利用することでプロジェクト内のCI/CDを利用しやすくする、などのケースが考えられます。

include:project は対象のプロジェクトとファイル名に加え、 ref というキーワードを使ってファイルの参照先 (デフォルトは HEAD を参照) を変更できます。

include:project は以下のように指定します。

include:
  - project: '<Project名を指定>'
    file: '<使用するファイル名を指定>'
    ref: '<ブランチ名、タグなどを指定 (オプション)>'

include:project の注意事項は以下の通りです。

  • パイプラインを実行するユーザーは include:project で指定したProjectのメンバーであり、適切な権限が必要です。
  • include:project で指定したファイルが更新されても、呼び出し元のCI/CDパイプラインは起動しません。またセキュリティの観点から、以下のように ref を使用して参照先を特定することが推奨されます。
    • 特定のSHAハッシュを指定する
    • 別プロジェクト上で protected branch / protected tag を使用する。この設定を適用した参照先は、Merge Requestなどの変更管理を通じて変更する確率が高くなります。

ここでは2つのGitLab Project ( include-project-test include-project-template-test ) を用意し、include-project-test から include-project-template-test 上のファイルを呼び出します。また include-project-template-test 上に feature ブランチを作成し、 ref を利用した場合も実施しています。

include-project-test/.gitlab-ci.yml

stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - echo "Start include jobs from another project."

include:
  - project: 'fy0323/include-project-template-test'
    file: '.gitlab-ci.project.yml'
  - project: 'fy0323/include-project-template-test'
    file: '.gitlab-ci.project.yml'
    ref: feature

include-project-template-test/.gitlab-ci.project.yml

project:
  stage: test
  script:
    - echo "This is from .gitlab-ci.project.yml at include-project-template-test"

include-project-template-test/.gitlab-ci.project.yml at feature branch

project-from-feature:
  stage: deploy
  script:
    - echo "This is from .gitlab-ci.project.yml at include-project-template-test"
    - echo "The branch name is feature."

include-project-test でCI/CDパイプラインを実行すると、以下のように3つのJobを実行します。 test Jobは include-project-template-testmain ブランチから、 deploy Jobは feature ブランチから呼び出しています。

それぞれのJobを見ると、確かに呼び出されていることを確認できます。

include:remote

docs.gitlab.com

include:remote は別インスタンス上のファイルを対象とする時に使用します。正直利用用途があまり浮かばなかったですが、例えば何らかの理由でGitLab Self-managedインスタンス / SaaSを複数利用しており、あるProjectのテンプレートを別のインスタンスでも利用したい場合、インスタンスをまたいでテンプレートを利用できます。ただし本機能は、参照元のProjectがPublicな場合に限り利用可能です。

include:remote は以下のように利用します。

include:
  - remote: '<GitLabインスタンスのURLを含むファイルパスを指定>'

include:remote を利用するうえでの注意事項は以下の通りです。

  • remote のターゲットにできるのは、 GET リクエストでアクセス可能なPublic Projectが対象です。
  • 認証情報を付与してのURLアクセスはサポートされていません。そのためPrivate Projectの利用は基本的にできません。

今回は include-remote-test というProjectで検証します。リモートURLには、以前作成したSelf-managedインスタンス上に作成したPublic Projectを使用しました。

.gitlab-ci.yml では以下のように指定します。

build:
  stage: build
  script:
    - echo "Start jobs from include:remote."

include:
  - remote: 'https://<GitLabインスタンスURL>/root/include-remote-template-test/-/raw/main/.gitlab-ci.remote.yml'

なお、リモートURLは、参照先リポジトリの以下の場所から取得できます。

パイプラインを実行すると、リモートURLから定義ファイルを取得し、問題なく処理が完了したのを確認します。

test jobを見ると、確かにリモートURLが呼び出され、定義された内容が呼び出されていることを確認できます。

include:template

docs.gitlab.com

include:template はGitLabの提供するテンプレートを利用する方法です。自分たちでイチからテンプレートを用意せずとも利用可能なので、用途に合ったものがあれば積極的に利用するのが良いと思います。

include:template は以下のように利用します。

include:
  - template: '<利用するテンプレートファイル名を指定>'

include:template の注意事項は以下の通りです。

  • include:templateで利用できるテンプレートはこちらに配置されています。ただし、すべてのテンプレートが include:template で利用できるわけではないため、利用前には各テンプレート中のコメントを確認する必要があります。

今回は Bash.gitlab-ci.yml というテンプレートを指定し、CI/CDから Bashスクリプトを呼び出します。

.gitlab-ci.yml は以下のように指定します。

build:
  stage: build
  script:
    - echo "Start jobs from include:template."

include:
  - template: Bash.gitlab-ci.yml

上記ファイルを配置し、問題なければパイプラインが完了します。Jobを確認すると、 Bash.gitlab-ci.yml で定義した内容が実行されているのを確認できます。

その他

Nested include

docs.gitlab.com

include入れ子構造で呼び出すことも可能です。 .gitlab-ci.yml から include で呼び出されるテンプレート内部で、さらに別のテンプレートを呼び出すことで、ワークフローのメンテナンス性と再利用性を向上することができます。

なお、Nested includeは1つのパイプラインあたり150ファイルが上限です。

GitLab RunnerとしてLinuxサーバーを登録する

今回はAmazon EC2インスタンスをGitLab Runnerとして登録する手順について紹介します。

背景

GitLab RunnerはGitLab CI/CDパイプラインの実行基盤です。GitLab SaaS版を利用する場合、Shared Runnerという共有リソースを利用できますが、セキュリティ等の理由から自前の仮想マシン・コンテナをRunnerとして管理したいケースもあると思います。

GitLab Runnerは以下の3種類があり、Runnerを登録するうえでどの形態が良いか確認する必要があります。今回はGitLab SaaS版に Project Runner を登録する手順を検証しました。

  • Project Runner: 特定のProject専用のRunner
  • Group Runner: Group内の全てのporjectが利用できるRunner
  • Instance (Shared) Runner: GitLab Instance内の全てのProjectが利用できるRunner※

※ドキュメントを見ると、GitLab 16.8までは Shared runner という名称でしたが、GitLab 16.9からは Instance runnerに変わったようです。

また、GitLab RunnerとGitLabの処理の流れは、GitLabドキュメントに記載されています。GitLab RunnerとGitLab間では、GitLab RunnerからGitLab方向の通信のみが発生します。そのため、GitLab Runnerは、GitLabに対するアウトバウンド方向の通信 (ポート: 443) を許可すれば利用可能です。

docs.gitlab.com

検証

Runner authentication tokenの取得

GitLab Runnerを登録するため、まずはGitLab側でRunnerの認証に使うトークンを発行します。GitLabの画面から 設定 CI/CD を選択します。

Runnerの項目を開き、 新規プロジェクトRunner を選択します。

次のページで、登録するRunnerの情報を入力します。ここでは以下のように設定しました。

  • プラットフォーム: Linux
  • Tags: project-test
  • その他: 空欄

上記設定を入力後に ランナーを作成 を選択すると、Runnerを登録するためのコマンドやRunner authentication tokenが表示されます。Runner authentication tokenはここでしか確認できないため、コピーしておきます。

Runnerのページに戻ると、Project Runnerは割り当てられていますが、黒三角のアイコンが表示されています。これはこのRunnerがまだ利用できない状態であることを示します。

AWS Secrets Managerへの登録

Runner authentication tokenはGitLab Runnerを登録する際に利用します。後ほどAWS CloudFormationテンプレートでAmazon EC2を作成する際にトークン情報を使いますが、テンプレートに直接記述するのを避けるため、事前にAWS Secrets managerに登録したものを呼び出す形にしました。

AWS Secrets managerのメニューを開き、シークレットの作成を行います。ここでは GitLabProjectRunnerToken という名称のシークレットを作成しました。

作成時は その他のシークレットのタイプ を選択し、プレーンテキストでRunner authentication tokenを記載しました。

Amazon EC2の作成

つづいて、GitLab RunnerをインストールするAmazon EC2インスタンスを用意します。ここではVPC/Security Group/IAM等も含めてリソースを作成しており、SSMでインスタンスにアクセスできるよう設定しています。

gitlab-runner-ec2.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: "EC2 instance for GitLab Runner"
Parameters:
  ImageId:
    Default: "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
  InstanceType:
    Default: "t3.large"
    Type: String
  KeyName:
    Type: String
  VPCCidr:
    Default: "10.0.0.0/16"
    Type: String
  PublicSubnetCidr:
    Default: "10.0.0.0/24"
    Type: String
  PublicSubnetAZ:
    Default: "ap-northeast-1a"
    Type: String
  EnvName:
    Default: "gitlab-runner-test"
    Type: String

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCidr
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Ref EnvName
  PublicSubnet:
    Type: AWS::EC2::Subnet
    DependsOn: AttachGateway
    Properties:
      CidrBlock: !Ref PublicSubnetCidr
      VpcId: !Ref VPC
      AvailabilityZone: !Ref PublicSubnetAZ
      Tags:
        - Key: Name
          Value: !Ref EnvName
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Ref EnvName
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    DependsOn: AttachGateway
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Ref EnvName
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable
  EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Ref EnvName
  EIPAssociation:
    Type: AWS::EC2::EIPAssociation
    Properties:
      AllocationId: !GetAtt EIP.AllocationId
      InstanceId: !Ref GitLabInstance
  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EC2SecurityGroup
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-EC2SecurityGroup
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: '0.0.0.0/0'
  EC2IAMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${EnvName}-SSM-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/SecretsManagerReadWrite
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - Ref: EC2IAMRole
      InstanceProfileName: !Sub ${EnvName}-EC2InstanceProfile
  GitLabInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ImageId
      KeyName: !Ref KeyName
      InstanceType: !Ref InstanceType
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      IamInstanceProfile: !Ref EC2InstanceProfile
      SubnetId: !Ref PublicSubnet
      UserData:
        Fn::Base64: |
          #!/bin/bash
          sudo yum update -y
          sudo amazon-linux-extras install -y docker
          sudo systemctl start docker
          sudo systemctl enable docker
          sudo usermod -a -G docker ec2-user

          curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
          sudo yum install -y gitlab-runner

          RUNNER_TOKEN=$(aws --region=ap-northeast-1 secretsmanager get-secret-value --secret-id GitLabProjectRunnerToken --query 'SecretString' --output text)
          sudo gitlab-runner register \
            --non-interactive \
            --url "https://gitlab.com/" \
            --token $RUNNER_TOKEN \
            --executor "docker" \
            --docker-image alpine:latest \
            --description "docker-runner"

      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-EC2

Runnerの登録の確認と動作確認

Amazon EC2が作成されてしばらくすると、GitLabのRunnerページに表示されるProject Runnerは緑丸のアイコンが表示されます。

この状態になると登録したGitLab Runnerは利用可能となるため、テスト用の .gitlab-ci.yml を作成します。登録したRunnerをGitLab CI/CDから呼び出すには、 tags に登録時に指定したタグを指定します (今回は project-test )。ここでは以下のファイルを使用しました。

job:
  stage: build
  tags:
    - project-test
  script:
    - echo "Hello, GitLab Runner!"

上記ファイルを作成するとパイプラインが起動します。

実行後のログを見ると、作成したRunnerを使ってJobを実行したことを確認できます。

CloudFormationのGit SyncをGitLabで動かしてみる

今回は2023年に公開されたCloudFormation Git Syncを紹介します。

aws.amazon.com

背景

AWS CloudFomationはAWSリソースを対象とするIaCツールです。CloudFormationのコード管理はGitHub / GitLabなどのGitホスティングサービスを利用することが多いですが、CloudFormationコードの開発を終えて動作確認のためデプロイする場合、これまではAWS CLIやマネジメントコンソールなどのインターフェイスにアクセスするか、専用のCI/CDを自前で構築する必要がありました。前者の場合はアクセス先を切り替える必要があり、ここでコンテキストスイッチが発生します。

また、近年ではGitOpsという、Gitリポジトリとデプロイ先の環境を同期することで、デプロイの手間の削減や組織内の責任分界点の設定などを実現する手法が普及しています。

※参考:

about.gitlab.com

blog.inductor.me

ただし現状GitOpsをやろうと思うと、基本的にはGitOpsを実現するソフトウェアを自前で用意する必要があります。またAWS CloudFormationのファイルをGitOpsで扱うとなるとさらに選択肢は限られ、AWS CloudFormation Template Sync Controller for Flux などが必要でした。これらOSSやその基盤となるKubernetesを管理する必要もあり、個人的にCloudFormationでGitOpsを実現するハードルはかなり高いものでした。

CloudFormation Git Syncは、一言でいえば AWS CloudFormationに対するGitOpsを提供するAWSマネージドな機能 であり、対象のGitリポジトリをモニタリングし、Gitで管理するCloudFormationファイルの変更を検知すると、それをCloudFormation Stackに自動的に反映する機能を提供します。

今回CloudFormationがGit Syncに対応したことで、コンテキストスイッチやGitOps導入ハードルといった課題を解消し、CloudFormationのコードの開発速度の向上やCloudFormationを中心に扱うプロジェクトでのGitOpsの実現をサポートすることが期待できます。

なおAWSブログではGitHub Codespaces / GitHub Actions / CloudFormation Linterを使ったモダンな開発環境の例も紹介しています。

aws.amazon.com

検証

ここからCloudFormation Git Syncを実際に検証します。なお今回は以下のような条件で検証をしています。

  • Gitサービス: GitLab
  • CloudFormationで扱うAWSリソース: Amazon S3

docs.aws.amazon.com

事前準備

CloudFormation Git Syncを利用するには、いくつかの条件があります。

  1. CloudFormationテンプレートを含むGitリポジトリを用意している
  2. AWS CodeStar Connectionを使ってGitリポジトリと接続可能である
  3. Git Syncを実行するためのIAMロールが用意されている
  4. CloudFormationとは別にパラメータを定義したファイルが必要である
Gitリポジトリの用意

1つ目の条件について、今回は cfn-git-test というGitLabプロジェクトを作成し、以下のようなテンプレートファイルを配置しておきます。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  BucketName:
    Type: String

Resources:
  S3:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName
      Tags:
        - Key: Test
          Value: false
AWS CodeStar Connectionによる接続

2つ目の条件は、AWSマネジメントコンソールにアクセスし、デベロッパー用ツールの画面から 設定 接続 を選択します。

接続を作成 を選択します。

接続先のGitプロバイダを選択します。今回は GitLab を選択し、接続名も適当なものを入力します。

画面が遷移し、GitLabにログインした状態だと以下のような画面が表示されます。ここではAWSからGitLabに対するアクセス権限を許可してくれるよう申請が出されているので、 Authorize を選択して許可します。

再びAWSの画面に戻るので 接続 を選択します。

これでAWSとGitLabとの接続が可能になりました。

IAMロールの用意

3つ目の条件ですが、Git Syncを利用する場合はIAMロールを新規に作成することも選択できます。今回は新規に作成するほうを選択しましたが、事前にIAMロールを作成する場合は以下のような権限が必要となります。

  • cloudformation:CreateChangeSet
  • cloudformation:DeleteChangeSet
  • cloudformation:DescribeChangeSet
  • cloudformation:DescribeStackEvents
  • cloudformation:DescribeStacks
  • cloudformation:ExecuteChangeSet
  • cloudformation:ListChangeSets
  • cloudformation:ValidateTemplate
  • events:PutRule
  • events:PutTargets

docs.aws.amazon.com

deploymentファイルの用意

4つ目の条件ですが、こちらもGit Sync利用時にテンプレートを作成することが可能です。事前に用意する場合は以下のようなファイルをGitリポジトリに格納します。

template-file-path: s3.yaml
parameters:
  BucketName: cfn-git-sync-gitlab-20231228
tags: {}

CloudFomationとGitLabの同期

ここからGit Syncを利用してAmazon S3を作成します。

AWSマネジメントコンソールからCloudFomrationのメニューに移動し、スタックの作成を選択します。メニューの中に Gitから同期 という新しい項目が増えているので、こちらを選択します。

続いてスタックの詳細を設定します。今回スタック名は cfn-git-sync-gitlab としました。次の デプロイファイルをスタック というのは、事前準備の4つ目の条件で上げたdeploymentファイルを指します。今回はここでファイルを新規に作成するため 次のパラメータを使用してファイルを作成し、リポジトリに配置します。 を選択します。

テンプレート定義リポジトリ では、アクセス先のリポジトリの情報を設定します。今回は新規にGitリポジトリとの接続を行うため、 Gitリポジトリをリンクする を選択します。それ以降は使用するCodeStar Connection名やリポジトリ・ブランチなど、必要な値を設定します。

続いてIAMロールの設定です。ここでは事前準備3つ目の条件にあったIAMロールを指定しますが、今回は新規で作成します。

デプロイファイルパラメーターには、CloudFormationの作成で必要なパラメータを指定します。今回は BucketName というパラメータを設定するため、以下のように設定しました。

次に スタックオプションの設定 ですが、ここでIAMロールを指定する必要があります。ここではスタック操作に使用するIAMロールを指定するため、CloudFormationに対する権限を付与したものを使用しました。

以降はデフォルトのまま進め、スタックの作成を行います。すると以下のように、リポジトリとの同期状態やプロビジョニングステータスが表示されます。

この時リソースを見ると GitSyncSetupWaitCondition というリソースが作成されています。

ここでGitLabに移動すると、AWSによって作成されたMerge Request (以降MR) が確認できます。

MRの内容はdeploymentファイルをコミットするものです。

ちなみにこのままの状態で再びCloudFormationのほうを見ると、プロビジョニングステータスが失敗していることを確認できます。

これはMRがマージされていないためにdeploymentファイルが見つからないためです。

GitLabプロジェクトに戻ってMRをマージします。

CloudFormationの画面を見るとリポジトリ同期のステータスやプロビジョニングステータスが変化します。

しばらくするとプロビジョニングに成功しました。

同期イベントを見ると、ChangeSetの作成と実行が行われた様子を確認できます。

リソースを見るとS3バケットが作成されたのを確認できます。

テンプレートファイルの変更と実リソースへの反映

続いてGitlab上のテンプレートに修正を加えてみます。今回はバージョニングを有効にする設定を追加しました。今回は横着してmainブランチに直接コミットしていますが、GitLab CI/CD等と組み合わせてMR作成時に構文チェックなどを走らせるのが安全でしょう。

コミットすると再びリポジトリ同期などのステータスが変化します。

しばらくするとプロビジョニングに成功し、変更も反映されているのを確認できました。

GitLab for Slack appを使用してGitLabのイベントをSlackに通知する

今回はGitLab for Slack appを設定する例を紹介します。

docs.gitlab.com

背景

GitLabとSlackを連携する方法は、現在は GitLab for Slack app の利用が推奨されています。以前は Slack notification も選択肢でしたが、こちらはGitLab 15.9 でdeprecatedとなり、18.0で廃止予定です。

docs.gitlab.com

検証

今回はGitLab SaaS版 (Freeプラン)で slack-notification-test というProjectを使用します。

まずは 設定 から 統合 を選択し、GitLabと統合可能なリストを表示します。

リストの中から GitLab for Slack app を探し、 設定する を選択します。

Slackアプリ用GitLabをインストール... を選択します。

画面が遷移し、Slackワークスペースのログイン画面が表示されます。インストール対象のSlackワークスペースにログインします。

GitLabがSlackにアクセスする権限を要求するので、許可します。

画面が遷移し、GitLab画面に戻ります。これでGitLabとSlackの統合が完了しました。

ここから動作確認をします。

GitLab for Slack app を導入すると、Slackから gitlab コマンドを実行可能になります。試しに /gitlab と入力すると、以下のように表示されます。

利用可能なコマンドはこちらのドキュメントに記載されています。ここでは /gitlab help コマンドを実行した結果を載せておきます。

続いて、GitLabのイベントをSlackに通知するよう設定します。GitLabの画面から 設定 統合 に移動し、 GitLab for Slack app に移動します。移動後の画面ではトリガーや通知設定などが表示されています。

ここではIssueの作成時にSlackに通知するよう設定します。 イシューが作成、更新、または完了されています という項目にチェックを入れると、次に通知先のSlackチャンネルを指定します。ここでは gitlab-test というチャンネルを用意しておき、設定します。

なお、通知先のSlackチャンネルがプライベートチャンネルの場合は、該当のチャンネルにGitLab for Slack appを追加する必要があります。

今回通知設定は特に変更しませんが、以下のような設定項目が並んでいます。特に Notify only boken pipelines は、パイプラインが失敗した時だけ通知を飛ばすので、便利かと思います。

画面下部の テスト設定 を選択すると、Slackとの接続テストを実行するようです。

上記変更後に 変更を保存 を選択し、新しい Issue を作成します。

作成するとSlackの通知が飛ぶことを確認できます。

GitLab CI/CDでAmazon ECSサービスを更新する

今回はGitLab CI/CDでコンテナイメージを作成し、作成したイメージを使ってAmazon ECSのサービスを更新する例を紹介します。

背景

GitLab CI/CDからAmazon ECSサービスを更新するには、大きく2つの方法があります。

  • .gitlab-ci.ymlスクリプトを記載する
  • include:template を利用する

GitLabドキュメントでは include:template の利用例が紹介されていますが、今回は1つ目のほうで実施しました。具体的には以下の処理をGitLab CI/CDで実施します。

  • build-image
    • コンテナイメージのビルド
    • Amazon ECRへのイメージ配置
  • deploy-task-definition
    • task definition定義ファイルの書き換え
    • タスク定義の更新
    • サービスの更新

検証

ここから検証です。今回も使用したのはGitLab SaaS版 (Freeプラン) です。

AWSリソースの作成

まずはCI/CDで利用するリソースを作成します。リソースの作成はAWS CloudFormationで行いました。

ecr.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: ECR for GitLab CI/CD example
Parameters:
  PJPrefix:
    Type: String
    Default: "gitlab-cicd-example"

Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub "${PJPrefix}-ecr"
      EncryptionConfiguration:
        EncryptionType: "KMS"
      ImageScanningConfiguration: 
        ScanOnPush: true  
      ImageTagMutability: IMMUTABLE
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-ecr"

Outputs:
  ECR:
    Value: !Ref ECR
    Export:
      Name: !Sub "${PJPrefix}-ecr"

vpc.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: VPC and Subnet for GitLab CI/CD example

Parameters:
  PJPrefix:
    Type: String
    Default: "gitlab-cicd-example"
  VPCCIDR:
    Type: String
    Default: "10.1.0.0/16"
  PublicSubnetACIDR:
    Type: String
    Default: "10.1.10.0/24"
  PublicSubnetCCIDR:
    Type: String
    Default: "10.1.20.0/24"
  PrivateSubnetACIDR:
    Type: String
    Default: "10.1.100.0/24"
  PrivateSubnetCCIDR:
    Type: String
    Default: "10.1.200.0/24"

Resources: 
# VPC
  VPC: 
    Type: "AWS::EC2::VPC"
    Properties: 
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-vpc"

# Internet Gateway
  InternetGateway: 
    Type: "AWS::EC2::InternetGateway"
    Properties: 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-igw"

  InternetGatewayAttachment: 
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties: 
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC 

# NAT Gateway
  NATGatewayA: 
    Type: "AWS::EC2::NatGateway"
    Properties: 
      AllocationId: !GetAtt NATGatewayAEIP.AllocationId 
      SubnetId: !Ref PublicSubnetA
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-natgw-a"

  NATGatewayAEIP: 
    Type: "AWS::EC2::EIP"
    Properties: 
      Domain: vpc
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-natgw-a"

  NATGatewayC:
    Type: "AWS::EC2::NatGateway"
    Properties:
      AllocationId: !GetAtt NATGatewayCEIP.AllocationId 
      SubnetId: !Ref PublicSubnetC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-natgw-c"

  NATGatewayCEIP:
    Type: "AWS::EC2::EIP"
    Properties:
      Domain: vpc
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-natgw-c"
      
# Subnet
  PublicSubnetA: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PublicSubnetACIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-a"

  PublicSubnetC: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: !Ref PublicSubnetCCIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-c"
                    
  PrivateSubnetA: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PrivateSubnetACIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-private-subnet-a"

  PrivateSubnetC: 
    Type: "AWS::EC2::Subnet"
    Properties: 
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: !Ref PrivateSubnetCCIDR
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-private-subnet-c"
                         
# RouteTable
  PublicRouteTableA: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-a"
          
  PublicRouteTableC: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-c"

  PrivateRouteTableA: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-private-route-a"

  PrivateRouteTableC: 
    Type: "AWS::EC2::RouteTable"
    Properties: 
      VpcId: !Ref VPC 
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-private-route-c"

  PublicRouteA: 
    Type: "AWS::EC2::Route"
    Properties: 
      RouteTableId: !Ref PublicRouteTableA 
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway 

  PublicRouteC: 
    Type: "AWS::EC2::Route"
    Properties: 
      RouteTableId: !Ref PublicRouteTableC 
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway 

  PrivateRouteA: 
    Type: "AWS::EC2::Route"
    Properties: 
      RouteTableId: !Ref PrivateRouteTableA
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NATGatewayA

  PrivateRouteC:
    Type: "AWS::EC2::Route"
    Properties:
      RouteTableId: !Ref PrivateRouteTableC
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NATGatewayC

  PublicSubnetARouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PublicSubnetA 
      RouteTableId: !Ref PublicRouteTableA

  PublicSubnetCRouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PublicSubnetC 
      RouteTableId: !Ref PublicRouteTableC
                
  PrivateSubnetARouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PrivateSubnetA
      RouteTableId: !Ref PrivateRouteTableA 

  PrivateSubnetCRouteTableAssociation: 
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties: 
      SubnetId: !Ref PrivateSubnetC
      RouteTableId: !Ref PrivateRouteTableC
             
Outputs:
  VPC:
    Value: !Ref VPC
    Export:
      Name: !Sub "${PJPrefix}-vpc"

  PublicSubnetA:
    Value: !Ref PublicSubnetA
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-a"

  PublicSubnetC:
    Value: !Ref PublicSubnetC
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-c"

  PrivateSubnetA:
    Value: !Ref PrivateSubnetA
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-a"

  PrivateSubnetC:
    Value: !Ref PrivateSubnetC
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-c"

iam.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: OIDC for GitLab CI/CD example
Parameters:
  RepositoryPath:
    Type: String
  PJPrefix:
    Type: String
    Default: "gitlab-cicd-example"
Resources:
  OIDCProviderForGitLab:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://gitlab.com
      ClientIdList:
        - https://gitlab.com
      ThumbprintList:
        - ffffffffffffffffffffffffffffffffffffffff
  RoleForGitLab:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${PJPrefix}-role"
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRoleWithWebIdentity
            Principal:
              Federated: !Sub 'arn:aws:iam::${AWS::AccountId}:oidc-provider/gitlab.com'
            Effect: Allow
            Condition:
              ForAnyValue:StringLike:
                "gitlab.com:sub":
                  - !Sub "project_path:${RepositoryPath}:ref_type:branch:ref:*"
      Policies:
        - PolicyName: !Sub "${PJPrefix}-policy"
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - sts:GetCallerIdentity
                Resource:
                  - '*'
              - Effect: Allow
                Action:
                  - ecr:GetAuthorizationToken
                Resource: '*'
              - Effect: Allow
                Action: 
                  - ecr:UploadLayerPart
                  - ecr:PutImage
                  - ecr:InitiateLayerUpload
                  - ecr:CompleteLayerUpload
                  - ecr:BatchCheckLayerAvailability
                Resource: !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${PJPrefix}-ecr
              - Effect: Allow
                Action:
                  - ecs:RegisterTaskDefinition
                Resource: '*'
              - Effect: Allow
                Action:
                  - ecs:UpdateServicePrimaryTaskSet
                  - ecs:DescribeServices
                  - ecs:UpdateService
                Resource: !Sub arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:service/${PJPrefix}-cluster/${PJPrefix}-service
              - Effect: Allow
                Action:
                  - iam:PassRole
                Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/${PJPrefix}-task-execution-role
                Condition:
                  StringLike:
                    iam:PassedToService: ecs-tasks.amazonaws.com

ecs.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: ECS and ALB resources for GitLab CI/CD example
Parameters:
  PJPrefix:
    Type: String
    Default: "gitlab-cicd-example"
  ALBSecurityGroupIngressIPAddress:
    Type: String
  InternetALBName:
    Type: String
    Default: "alb"
  TargetGroupName:
    Type: String
    Default: "tg"
  ECSClusterName:
    Type: String
    Default: "cluster"
  ECSTaskName:
    Type: String
    Default: "task"
  ECSTaskCPUUnit:
    AllowedValues: [ 256, 512, 1024, 2048, 4096 ]
    Type: String
    Default: "256"
  ECSTaskMemory:
    AllowedValues: [ 256, 512, 1024, 2048, 4096 ]
    Type: String
    Default: "512"
  ECSContainerName:
    Type: String
    Default: "container"
  ECSImageName:
    Type: String
    Default: "<AWS Account ID>.dkr.ecr.ap-northeast-1.amazonaws.com/gitlab-cicd-example-ecr:0.1"
  ECSServiceName:
    Type: String
    Default: "service"
  ECSTaskDesiredCount:
    Type: Number
    Default: 1

Resources:
#Security Group
  ALBSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      GroupName: !Sub "${PJPrefix}-alb-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${PJPrefix}-alb-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: !Ref ALBSecurityGroupIngressIPAddress

        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !Ref ALBSecurityGroupIngressIPAddress

  ECSSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      GroupName: !Sub "${PJPrefix}-ecs-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${PJPrefix}-ecs-sg"

# Security Group Rule
  ECSSecurityGroupIngress: 
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties: 
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourceSecurityGroupId: !GetAtt [ ALBSecurityGroup, GroupId ] 
      GroupId: !GetAtt [ ECSSecurityGroup, GroupId ]

# Target Group
  TargetGroup: 
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties: 
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" } 
      Name: !Sub "${PJPrefix}-${TargetGroupName}"
      Protocol: HTTP
      Port: 80
      TargetType: ip

# ALB
  InternetALB: 
    Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
    Properties: 
      Name: !Sub "${PJPrefix}-${InternetALBName}"
      Tags: 
        - Key: Name
          Value: !Sub "${PJPrefix}-${InternetALBName}"
      Scheme: "internet-facing"
      LoadBalancerAttributes: 
        - Key: "deletion_protection.enabled"
          Value: false
        - Key: "idle_timeout.timeout_seconds"
          Value: 60
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Subnets: 
        - { "Fn::ImportValue": !Sub "${PJPrefix}-public-subnet-a" }
        - { "Fn::ImportValue": !Sub "${PJPrefix}-public-subnet-c" }

  ALBListener: 
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties: 
      DefaultActions: 
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref InternetALB
      Port: 80
      Protocol: HTTP

# ECS Cluster
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Sub "${PJPrefix}-${ECSClusterName}"

# Loggroup
  ECSLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${PJPrefix}-ecs-group"

# ECS Task Definition
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Cpu: !Ref ECSTaskCPUUnit
      ExecutionRoleArn: !Ref ECSTaskExecutionRole
      Family: !Sub "${PJPrefix}-${ECSTaskName}"
      Memory: !Ref ECSTaskMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        - Name: !Sub "${PJPrefix}-${ECSContainerName}"
          Image: !Ref ECSImageName
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref ECSLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: !Ref PJPrefix
          MemoryReservation: 128
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80

# ECS Service
  ECSService:
    Type: AWS::ECS::Service
    DependsOn: ALBListener
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: !Ref ECSTaskDesiredCount
      LaunchType: FARGATE
      LoadBalancers:
        -
          TargetGroupArn: !Ref TargetGroup
          ContainerPort: 80
          ContainerName: !Sub "${PJPrefix}-${ECSContainerName}"
      NetworkConfiguration:
        AwsvpcConfiguration:
           AssignPublicIp: DISABLED
           SecurityGroups:
             - !Ref ECSSecurityGroup
           Subnets:
             - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-a" }
             - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-c" }
      ServiceName: !Sub "${PJPrefix}-${ECSServiceName}"
      TaskDefinition: !Ref ECSTaskDefinition

  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${PJPrefix}-task-execution-role"
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

Outputs:
  ALBDNSName:
    Value: !GetAtt InternetALB.DNSName
    Export:
      Name: !Sub "${PJPrefix}-${InternetALBName}-dnsname"

VPC/ECR/IAMの作成

上記ファイルを使用してVPC/ECR/IAMを作成します。

IAMについてだけ補足すると、ここではOIDCを使ってGitLabからAWSへのアクセス権限を付与しています。

docs.gitlab.com

Dockerイメージの作成

続いてDockerイメージを作成します。作成には以下のファイルを使用しました。

FROM nginx:latest
COPY ./src/index.html /usr/share/nginx/html/index.html
<html><body>Hello GitLab CI/CD</body></html>

ここではAWS CloudShell上でDockerイメージをビルドし、作成したAmazon ECRにプッシュしました。

aws.amazon.com

ECSの作成

次に上記ファイルを使用してECSを作成します。

リソース作成後、ALB URLからアクセスすると、以下のようにテキストが表示されます。

GitLabの設定

続いてGitLab側を設定します。今回は ecs-example というGitLab Projectを使用します。

CI/CD環境変数の設定

まずは .gitlab-ci.yml上で呼び出す環境変数を設定します。ここでは以下の変数を設定します。なお設定時は Masked Expanded をチェックし、Jobログ中には変数情報をマスクし、 $ で呼び出せるようにしておきます。

  • AWS_DEFAULT_REGION: ap-northeast-1
  • AWS_ECR_LOGIN_URL: <AWS Account ID>.dkr.ecr.ap-northeast-1.amazonaws.com
  • AWS_ECR_REPOSITORY: <AWS Account ID>.dkr.ecr.ap-northeast-1.amazonaws.com/gitlab-cicd-example-ecr
  • AWS_ECS_CLUSTER_NAME: gitlab-cicd-example-cluster
  • AWS_ECS_SERVICE_NAME: gitlab-cicd-example-service
  • AWS_IAM_ROLE: arn:aws:iam::<AWS Account ID>:role/gitlab-cicd-example-role

docs.gitlab.com

ファイルの配置

続いてGitLab Projectにファイルを配置します。ファイルは以下のように配置します。

.
├── .gitlab-ci.yml
├── Dockerfile
├── README.md
├── cfn
│   ├── ecr.yaml
│   ├── ecs.yaml
│   ├── iam.yaml
│   └── vpc.yaml
├── src
│   └── index.html
└── task-definition.json

.gitlab-ci.yml task-definition.json は以下のような内容です。なお今回は .gitlab-ci.yml にruleを設定していないので、ファイルを配置した時からCI/CDパイプラインが実行されます。

.gitlab-ci.yml

variables:
  AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION
  AWS_ECR_LOGIN_URL: $AWS_ECR_LOGIN_URL
  AWS_ECR_REPOSITORY: $AWS_ECR_REPOSITORY
  AWS_ECS_CLUSTER_NAME: $AWS_ECS_CLUSTER_NAME
  AWS_ECS_SERVICE_NAME: $AWS_ECS_SERVICE_NAME
  AWS_IAM_ROLE: $AWS_IAM_ROLE
  DOCKER_TLS_CERTDIR: "/certs"
stages:
  - build
  - deploy
default:
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  before_script:
    - apk add --no-cache aws-cli
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${AWS_IAM_ROLE}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token ${GITLAB_OIDC_TOKEN}
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
    - IMAGE_TAG=$CI_COMMIT_SHORT_SHA
build-image:
  stage: build
  script:
    - docker build -t $AWS_ECR_REPOSITORY:$IMAGE_TAG .
    - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ECR_LOGIN_URL
    - docker push $AWS_ECR_REPOSITORY:$IMAGE_TAG
deploy-task-definition:
  stage: deploy
  script: 
    - sed -i -e s/SED_TARGET_IMAGE_TAG/$IMAGE_TAG/g task-definition.json
    - TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://task-definition.json --query 'taskDefinition.taskDefinitionArn' --output text)
    - aws ecs update-service --cluster $AWS_ECS_CLUSTER_NAME --service $AWS_ECS_SERVICE_NAME --task-definition $TASK_DEF_ARN

task-definition.json

{
    "containerDefinitions": [
        {
            "name": "gitlab-cicd-example-container",
            "image": "<AWS Account ID>.dkr.ecr.ap-northeast-1.amazonaws.com/gitlab-cicd-example-ecr:SED_TARGET_IMAGE_TAG",
            "cpu": 0,
            "memoryReservation": 128,
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "/ecs/logs/gitlab-cicd-example-ecs-group",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "gitlab-cicd-example"
                }
            }
        }
    ],
    "family": "gitlab-cicd-example-task",
    "executionRoleArn": "arn:aws:iam::<AWS Account ID>:role/gitlab-cicd-example-task-execution-role",
    "networkMode": "awsvpc",
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512"
}

少しだけ補足します。

  • .gitlab-ci.yml:
    • default: default には各Jobで共通に利用する処理を記載します。今回はJobごとにOIDCを使ってAWSにアクセスする準備を行うため、その処理を記載しました。
    • build-image: このJobではコンテナイメージの作成とAmazon ECRへのイメージ配置を行います。
    • deploy-task-definition: このJobでは task-definition.jsoncontainerDefinitions[].image 部分を書き換えたうえで、Amazon ECSのタスク定義の登録、サービスの更新を行います。

※参考:

ここまででCI/CDを動かす準備は完了です。

GitLab CI/CDの検証

ここからGitLab CI/CDの検証をします。

まずはProject中のファイルを修正します。ここではindex.htmlを修正しました。

修正後、CI/CDパイプラインの実行を確認し、しばらくすると完了します。実行されたパイプラインを確認すると、 build-image deploy-task-definition のJobが実行されているのを確認できます。

Amazon ECSの画面を見ると、タスク・サービスが更新される様子も確認できます。しばらくたつと、問題なければ起動していることを確認できます。

最後にAmazon ELBのURLにアクセスすると、修正した内容のテキストが表示されているのを確認できます。