最近の砂場活動その18: GKEの設定をTerraformで行なう

前提

というわけで、モジュール分割とかあれこれイケてない部分があるけど、石を投げないでください...。

backendを指定する

Terraformは現在どこまで適用したか、手元のコードと差分はどこにあるかを計算するためにstateファイル(tfstate)というものをbackendに持っている。stateは手元に持っておいてもよいが、PC変えたりrmでミスって消したりすると悲しいので、クラウドに置いておくとよい。S3やDynamoDBも指定できるようだが、今はGCPでリソースをあれこれ作っているので、GCSをbackendに指定する。

このGCSは画面から手でポチポチ作りました。

この辺の情報を元に、main.tfに書いていきます。

provider "google" {
  credentials = file("~/.config/gcloud/terraform-my-project@my-project.json")
  project     = "my-project"
  region      = "us-west1"
  zone        = "us-west1-a"
}

terraform {
  backend "gcs" {
    bucket = "my-project-terrarom-state-file"
  }
}

Terraformを実行する

Terraformを実行する際にはGCPのAPIをあれこれ叩くことになるので、サービスアカウントを発行します。自分でやるなら何でもいいけど、チームでやるんだったら個人のアカウントじゃないほうがいいはず。

% gcloud iam service-accounts create terraform-my-project \
  display-name "Account for Terraform"
% gcloud projects add-iam-policy-binding my-project \
  --member serviceAccount:terraform-my-project@my-project.iam.gserviceaccount.com \
  --role roles/owner

Makefileで便利コマンドを用意する

Terraformのコマンドを毎回覚えきれないので、Makefileでよく叩くコマンドを用意しておきます。production環境やstaging環境など、いくつか環境がある場合には-var-file=で環境毎に切り替えれるようにしておくと便利だけど、お財布の関係で趣味プロジェクトには一つしか環境はないです。

REMOTE_BUCKET_NAME := my-project-terrarom-state-file

create_credentials:
    gcloud iam service-accounts keys create ~/.config/gcloud/terraform-my-project@my-project.json --iam-account=terraform-my-project@my-project.iam.gserviceaccount.com

init:
    terraform fmt --recursive
    terraform init -backend-config="bucket=$(REMOTE_BUCKET_NAME)" -reconfigure

plan: init
    terraform plan

apply: init
    terraform apply

destroy:
    terraform destroy

make planで「意図してないリソースの削除とかはなさそうね、よし!」と確認して、make apply。CFnと同じ感じですね。updateだと思っていたら、delete & createとかが時々あるので注意する。

GKEの設定をTerraformで行なう

実際にやりたかったのはここ。とはいえ、概ねここと同じことを書いてあげればよい。

https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/container_cluster

node_count = 1かつpreemptible = trueなのは「いつクラスタが落ちても許します」ということなので、あまりよろしくはない。安く済ませようと思うと、preemptibleな f1-microのマシンを3台並べるのがよさそうだが、機械学習のタスク的にメモリはある程度欲しいので、この構成にしている(バッチなので、こけてもまあ即死はしない)。

esource "google_container_cluster" "my-project" {
  name                     = "my-project"
  remove_default_node_pool = true
  initial_node_count       = 1
}

resource "google_container_node_pool" "primary_preemptible_nodes" {
  name       = "my-node-pool"
  cluster    = google_container_cluster.my-project.name
  node_count = 1
  node_config {
    oauth_scopes = [
      "https://www.googleapis.com/auth/devstorage.read_only",
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring",
      "https://www.googleapis.com/auth/service.management.readonly",
      "https://www.googleapis.com/auth/servicecontrol",
      "https://www.googleapis.com/auth/trace.append"
    ]
    machine_type    = "n1-standard-1"
    preemptible     = true
    service_account = "default"
  }
  timeouts {
    create = "30m"
    update = "20m"
  }
}

クラスタに紐付いているservice_account = "default"というのが非常に厄介(?)で、かなり強い権限を持っている。GKEのpodを通じてあれこれできすぎてしまってよくない。この辺をWorkload Identityを使ってもっと安全にしていきましょう、というのを次のエントリで書きます。

最近の砂場活動その17: CloudWatch Eventsから直接AWS Batchのジョブをキックする

前置き

  • 機械学習のモデルの定期的な再学習、CloudWatch Eventsを使うと便利です
    • Cron的なスケジュール管理ができる
    • 機械学習のモデルの学習はAWS Batchで行なっていた(今だとSageMakerな人も多いのかもしれない)
  • しかし、以前はCloudWatch Eventsから直接AWS Batchのジョブを起動させることができなかった
    • LambdaからAPIを叩いてsubmitして〜という世界観
    • できるのは分かるけど、率直に言ってダルかった...
  • Lambdaを起動させるためのIAMの管理などもあって、AWS側でどうにかして欲しかった
Parameters:
  LambdaCodeStringSubmitBatchJob:
    Type: String
    Default: |
      import os
      from datetime import datetime as dt
      import boto3
      batch = boto3.client('batch')
      def lambda_handler(event, context):
          try:
              response = batch.submit_job(
                  jobName=os.environ['JOB_NAME'],
                  jobQueue=os.environ['JOB_QUEUE_NAME'],
                  jobDefinition=os.environ['JOB_DEFINITION_ARN']
              )
              jobId = response['jobId']
              return {
                  'jobId': jobId
              }
          except Exception as e:
              raise e
Resources:
  UpdateModelBatchJobTrigger:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: UpdateModelBatchJobTriggerResource
      Description: "学習データから分類器を構築"
      Role: 
        Fn::ImportValue: !Sub "${IAMStackName}:BatchJobTriggerFunctionRole"
      Handler: index.lambda_handler
      Runtime: python3.6
      MemorySize: 128
      Timeout: 30
      Environment:
        Variables:
          JOB_NAME: update_model
          JOB_QUEUE_NAME: go-active-learning
          JOB_DEFINITION_ARN: !Ref UpdateModelJobDefinition
      Code:
        ZipFile: !Sub "${LambdaCodeStringSubmitBatchJob}"
   LambdaUpdateModelRule:
    Type: AWS::Events::Rule
    Properties:
      Name: LambdaUpdateModelRule
      ScheduleExpression: rate(2 days)
      Targets:
        - Id: UpdateModelBatchJobTrigger
          Arn: !GetAtt UpdateModelBatchJobTrigger.Arn
      State: "ENABLED"
  LambdaUpdateModelPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref UpdateModelBatchJobTrigger
      SourceArn: !GetAtt LambdaUpdateModelRule.Arn
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com

現在: CloudWatch Eventsからジョブを直接キックできるようになった!

そうそう、これが欲しかった! 大分昔のアップデートなので、ちゃんとアップデートを追っていなかったことが明らかになってしまった!

CFnも欲しかったものを直接的に書けるようになったので、最高です。

Resources:
  UpdateModelRule:
    Type: AWS::Events::Rule
    Properties:
      Name: UpdateModelRule
      ScheduleExpression: rate(2 days)
      Targets:
        - Id: UpdateModelBatch
          Arn: !Ref JobQueue
          BatchParameters:
            JobDefinition: !Ref UpdateModelJobDefinition
            JobName: update_model
          RoleArn:
            Fn::ImportValue: !Sub "${IAMStackName}:SubmitBatchJobRuleRole"
      State: "ENABLED"

CloudWatch EventsからジョブをキックするためのIAMはこんな感じ。

Resources:
  SubmitBatchJobRuleRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: batch-job-trigger-policy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - batch:SubmitJob
                Resource: 
                  - "*"
      Path: "/"

クラウド側のアップデートに合わせて、IaCもリファクタリングしていこうねという話でした。

最近の砂場活動その16: 機密データをパラメータストアに保存して、AWS Batch & ECS(ecspresso)上のアプリから参照する

アプリケーションのコード内に機密情報(例: DBのユーザー名やパスワード)を直接ハードコードしないのは当然として、環境変数経由で渡すことは多いと思います。CloudFormationのようなInfrastructure as Code(IaC)をしている場合、環境毎の設定をyamlなどで書きますが、「SlackのIncoming WebHookならまあいいか...」といった具合にIaCの設定内にハードコードされてしまうこともまあなくはないでしょう(一度もねえよという人だけ石を投げてください...)。

自分の趣味サービスもその辺、いい加減になっている箇所がありました。春の安全週間ということで、機密データをパラメータストアに保存するようにして、AWS BatchとECS(deployの設定にecspressoを利用)から参照するようにしたので、メモ書きしておきます。

機密データをパラメータストアに入れる

特に迷うところはないかと思う。パラメータのキー名を適当に書いてしまうと、あとで「これは何のやつだ...」となってしまうので、以下のように命名規則をある程度付けておくと安心。

  • /sentry.io/organizations/MY_ORG/projects/MY_PROJECT/keys/dns

一手間増えるけど、SecureStringで暗号化して保存している。

パラメータストアから機密データを取得するためのRoleを作る

ECS(AWS Batchも内部でECSが動くので、ECS用のものだけを準備しておけばよい)で動かす際に、パラメータストアから機密データを取得するための権限が必要になるので、CloudFormationで対応するRoleを事前に作っておく。元々ECSを動かすのに必要なポリシーのアタッチとパラメータストア関係のものを追加して定義する。Resourceに関しては適当に絞ってもよい。

Resources:
  BatchExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: "Allow"
            Principal:
              Service: "ecs-tasks.amazonaws.com"
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "ExecuteEcsTask"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ecr:GetAuthorizationToken"
                  - "ecr:BatchCheckLayerAvailability"
                  - "ecr:GetDownloadUrlForLayer"
                  - "ecr:BatchGetImage"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: "*"
        - PolicyName: "GetSSMParameters"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "ssm:GetParameters"
                  - "secretsmanager:GetSecretValue"
                  - "kms:Decrypt"
                Resource: "*"
Outputs:
  BatchExecutionRole:
    Value: !GetAtt BatchExecutionRole.Arn
    Export:
      Name: !Sub "${AWS::StackName}:BatchExecutionRole"

AWS Batch内でパラメータストアの機密データにアクセスする

簡単。Environmentではなく、Secretsで指定してやる。パラメータストアにアクセスしたり、暗号化されたものをデコードするために、先程作ったロールをExecutionRoleArnに指定してあげればOK。

Parameters:
  IAMStackName:
    Type: String
Resources:
  MyJobDefinition:
    Type: "AWS::Batch::JobDefinition"
    Properties:
      Type: container
      ContainerProperties:
        Command:
          - "/bin/sh"
          - "/app/execute_my_job"
        Memory: 2500
        Privileged: false
        Environment:
          - Name: "AWS_REGION"
            Value: !Sub "${AWS::Region}"
          - Name: "MACKEREL_SERVICE_NAME"
            Value: "my-app-staging"
        Secrets:
          - Name: DB_USER
            ValueFrom: "/example.com/rds/user_name"
          - Name: DB_PASSWORD
            ValueFrom: "/example.com/rds/password"
          - Name: MACKEREL_APIKEY
            ValueFrom: "/mackerel.io/orgs/syou6162/api-key/aws-batch"
        ReadonlyRootFilesystem: false
        Vcpus: 1
        Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/my-image:latest"
        ExecutionRoleArn: 
          Fn::ImportValue: !Sub "${IAMStackName}:BatchExecutionRole"
      JobDefinitionName: my_job_definition
      RetryStrategy:
        Attempts: 1
      Timeout:
        AttemptDurationSeconds: 1800

AWS ECS(ecspresso)内でパラメータストアの機密データにアクセスする

ECSも昔はCloudFormationで管理していたが、deployがしやすいようにkayac/ecspressoでdeployするように最近変更した。

ecspressoもパラメータストアからSecretsを設定するのに対応している。こちらもAWS Batchの時と同じく、secretsexecutionRoleArnを指定してあげればよい。

{
  "containerDefinitions": [
    {
      "command": [],
      "cpu": 0,
      "dnsSearchDomains": [],
      "dnsServers": [],
      "dockerLabels": {},
      "dockerSecurityOptions": [],
      "entryPoint": [
        "/app/my_app.bin",
        "serve"
      ],
      "environment": [
        {
          "name": "POSTGRES_HOST",
          "value": "abc123.def456.us-east-1.rds.amazonaws.com"
        }
      ],
      "environmentFiles": [],
      "essential": true,
      "extraHosts": [],
      "image": "1234567890.dkr.ecr.us-east-1.amazonaws.com/my-app-api:latest",
      "links": [],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/logs/my-app-groups",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        },
        "secretOptions": []
      },
      "mountPoints": [],
      "name": "my-app",
      "portMappings": [
        {
          "containerPort": 7778,
          "hostPort": 7778,
          "protocol": "tcp"
        }
      ],
      "secrets": [
        {
          "name": "DB_USER",
          "valueFrom": "arn:aws:ssm:us-east-1:1234567890:parameter/www.example.com/rds/user_name"
        },
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:ssm:us-east-1:1234567890:parameter/www.example.com/rds/password"
        }
      ],
      "systemControls": [],
      "ulimits": [
        {
          "hardLimit": 65536,
          "name": "nofile",
          "softLimit": 65536
        }
      ],
      "volumesFrom": []
    },
    {
      "command": [],
      "cpu": 0,
      "dnsSearchDomains": [],
      "dnsServers": [],
      "dockerLabels": {},
      "dockerSecurityOptions": [],
      "entryPoint": [],
      "environment": [
        {
          "name": "MACKEREL_ROLES",
          "value": "my-app-staging:backend"
        },
        {
          "name": "MACKEREL_CONTAINER_PLATFORM",
          "value": "ecs"
        }
      ],
      "secrets": [
        {
          "name": "MACKEREL_APIKEY",
          "valueFrom": "arn:aws:ssm:us-east-1:1234567890:parameter/mackerel.io/orgs/syou6162/api-key/mackerel-container-agent"
        }
      ],
      "environmentFiles": [],
      "essential": false,
      "extraHosts": [],
      "image": "mackerel/mackerel-container-agent:latest",
      "links": [],
      "mountPoints": [],
      "name": "mackerel-container-agent",
      "portMappings": [],
      "secrets": [],
      "systemControls": [],
      "ulimits": [],
      "volumesFrom": []
    }
  ],
  "cpu": "1024",
  "executionRoleArn": "arn:aws:iam::1234567890:role/my-iam-BatchExecutionRole-ABCDEFGHIJK",
  "family": "my-app-family",
  "memory": "2048",
  "networkMode": "awsvpc",
  "placementConstraints": [],
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "volumes": []
}

能動学習のツールgo-active-learningのリポジトリをアーカイブしました & アノテーションに関するポエム

能動学習のコマンドラインツールのリポジトリをアーカイブしたついでに、アノテーションに関するポエムを書きました。

背景

数年前、色んなドメインの教師データをアノテーションしまくる必要が仕事でありました。最初はGoogle SpreadSheetでアノテーションしていたものの、めっちゃめんどいし(ブラウザのタブ行ったりきたり)、ランダムのアノテーションするため「この事例追加しても性能あんまり変わらんやろ。時間ないし、もっと性能上がりそうなやつを集中的にアノテーションさせてくれ!」と思って作った能動学習のためのコマンドラインツールがgo-active-learningでした。

実際に使ってみた例は以下のエントリで書いています。コマンドラインだと一人でアノテーションしないといけないので、Slackでチームメンバーもアノテーションできる拡張なども作っていました。

アーカイブの理由

ドッグフーディングする機会が減った

仕事でちょっとテキストを扱う時間が減った(Mackerelで異常検知やってたので)ので、テキストやWebページに対してアノテーションする機会が減った、というのが理由です。自分がアクティブに使わないものはメンテできない。

複数に散らばったリポジトリをモノレポにしたかった

使っていないというのはちょっと言い過ぎで、趣味プロジェクトのML-Newsではgo-active-learningをライブラリとして使っていました。ML-Newsは機械学習っぽいエントリを集めるサービスなので、機械学習かどうかを判定するための教師データが必要なため、ライブラリとして使っていました。

ただ、運用が結構面倒なことになっていました。関連するリポジトリが3つあって、ML-Newsにリリースするまで手順が増えてしまっていました。

CI/CDの手順を改善していく中で、複数のリポジトリに分散してしまっているのが非常に面倒(パイプラインが組みにくい)ため、週末の時間を使ってモノレポにまとめました。まとめた結果、go-active-learningはライブラリとしての役割を終えたので、アーカイブしました。

脱線ポエム: アノテーションを特別なものにしないために

go-active-learningは最初は能動学習で効率的にアノテーションするために作ったわけですが、ML-Newsを数年運用していると、毎日トップページに目を通していて、ほぼ全てのものにアノテーションを付与しています。学習器が大分賢くなってきたため、負例(機械学習系のエントリではない)はほとんどアノテーションする機会がなく、正例(機械学習系のエントリ)のアノテーションの件数が毎日少しずつ増えていってます。特徴量はほぼ触っていないにも関わらず、データの追加だけで性能はじわじわよくなってきてます。

f:id:syou6162:20210404214605p:plainf:id:syou6162:20210404214611p:plain
正例(左)と負例(右)のアーノテーション数の推移。アノテーションの絶対数は負例が正例より多いが、増え方はサチってきてる。一方、正例は順調にまだまだ増えている

三日坊主になりがちな自分が年単位でこのチマチマした作業を続けていられるのは、機械学習やデータに関することが好きだからです。昼休みや通勤時間に暇さえあればアノテーションしている。全然興味のない分野のアノテーションを仕事でやれと言われたら、500件を2日も付けたら飽きて止めてしまう。1000件程度のアノテーションしかないデータで作られた学習器は精度もなかなか出ないでしょうし、出たとしてもすぐ精度が劣化していくでしょう。

サービスに機械学習を使った機能を運用するときにも、似たことを最近よく考えます。機能リリースの前に集中的にアノテーションしまくるのもいいですが、機能は出してからの期間がむしろ長いです。その間にデータの傾向は変わっていきますし(Concept Driftなど)、傾向の変化に追従できなければコードが腐っていくのと同じく性能も徐々に落ちます。また、逆のパターンもありますね。業務フローの中でアノテーションが日々行なわれているにも関わらず、機械学習側に反映されずにユーザーに届けられる価値が限定されてしまう、とか。

こうならないためにどうすればいいかと考えると、機能リリースの前など特別な時に限らず日々の生活や業務フローの中で呼吸するかのごとくアノテーションが組み込まれるのが理想なんだろうと思います(その場合は業務の名前は「アノテーション」ではないものだろうと思う)。一時的にアノテーションしたデータの仕様はあっという間に頭から揮発しますが、日々チームのメンバーが事例を目にして議論して洗練させていく。その中で意思決定や機械学習の精度も人知れず向上していく、そういう全体の仕組みを考えたり整えられる人間になりたいなぁと妄想しています。

ポエム補足

上記のポエムは能動学習不要とかそういうことを言いたいものではないです。そもそも使われるか分からない、PoCでひとまずまとまった量のデータが欲しいといった場合は今後も能動学習は有用なものであると思います。

Apple Watch Series 6を買った

Apple WatchのSeries 6を買いました。Series 1を持ってたと思うので、かなり久しぶりのApple Watchです。

動機

完全にコロナが原因です。元々引きこもり気味な性格に加えて通勤も無くなったので、あっという間に運動不足になりました。階段でも息切れする時があって、マジでやべぇなと思ったのでした。

いきなりランニングとか始めると膝痛めるので、現状のモニタリング + 外をいつもより歩くくらいから始めようと思ったので、Apple Watchを買いました。Garminとかでもよかったけど、運動というより日常生活をトラックしたいと思ったので、Apple Watchにしてみました。

歩数の時系列での定量評価

ヤバさ加減を可視化してみます。客観的に見てもヤバかった。

2015年

はてなに転職する前は通勤に60~90分くらいかかる環境だったので、引き込もりがちな自分でも一日平均7000歩くらい歩いていたようです。昼休みに卓球とかもやってた。

f:id:syou6162:20210331050107p:plain

2019年

はてなに転職後 & コロナ以前は電車には乗るものの15分くらいで到着するので、2015年と比べると半分以下になってしまっています。

f:id:syou6162:20210331050114p:plain

2020年

そしてコロナ以降。順調に運動していなかった2019年と比較しても約半分になっています。2015年と比べると約1/4! これは運動不足で息も上がるわ!

f:id:syou6162:20210331050119p:plain

Apple Pay便利

昔もApple Payは使ってたと思うんですが、関西だとICOCAの定期が使えなかったので、正直あまり便利ではありませんでした。しかし、今はコロナ真っ只中。物理出勤することも稀なので、そもそも定期券を買ってない。都度支払うことがほとんどですが、こうなるとApple Payはとても便利です。(仮想)カードへのチャージもアプリでできるし、クレカを登録しておけばQUICPayで支払うこともできます。財布からカード取り出すのダルいので、Apple Payで済むのは大きい。改札通る時、左手をかざさないといけないですが、まあ普通にできるので問題ない。

バッテリの持ち

あまりよくはないけど、Series 1の頃よりは改善してるかな。一日一回充電すれば何とか持つ。Series 1の頃は職場でも充電しないと途中でバッテリがなくなっていた気はする。シャワー浴びてる時間だけだと充電がフルには回復しないので、ちょっともどかしい。

Pebbleがこの辺はよかったなぁという気持ちがある。

ベルトの話

ディフォルトのベルトでもいいかなと思ったんだけど、手首が蒸れたり、跡が付いたりするのが気になって買い替えました。

サードパーティの金属のベルトを最初に買ったんだけど、サイズ調整がめちゃくちゃに難しくて調整用のパーツも破損してしまいました。色々考えたけど、結局公式から出てるベルトを買いました。つけ心地もいい感じで、予算以外は満足。高いよ!

38mmケース用スペースブラックリンクブレスレット

38mmケース用スペースブラックリンクブレスレット

  • 発売日: 2019/09/20
  • メディア: エレクトロニクス

統計学勉強会でNPSの信頼区間について発表しました

以前ブログにも書いたNPSの信頼区間を題材に、統計学勉強会で発表しました(します)。資料はこちら。

3/19に最終出社したばかりなので発表の時期としては微妙なところですが、3/31までははてなの所属ということで発表することにしました(広報担当の方にも確認済みです)。はてなではアウトプットの重要性を何度も教わったので、最後の最後まで攻めるのです。

内容としては統計学の基礎的な知識をベースに、業務に必要な統計量(今回の場合はNPS)とそのバラツキ(今回の場合は95%信頼区間)を自分で求めてみよう、というものです。教科書的な問題設定だとカハーできないものは案外あるし、自分でこういうものを計算してみるのは結構楽しいよ!というのが伝わるとうれしいです。Enjoy statistics!

聞いてくださっていた方の感想Tweet

参考

最近の砂場活動その15: CI/CDのパイプラインを整備する

最近、新しいPCをセットアップしていたけど、PCのセットアップを完了しないと趣味サイトのdeployすらできないことに気付いた。しばらくdeployしていないと、久しぶりにdeployしたときに大抵事故るし、小まめにやることでdeployの心理的障壁を下げていきたい。現状だと例えば、「寝る前にdeployしてコケると睡眠時間が短かく(2~3時間が飛んでいく)なるから、時間がある週末にやるしかない...」と思ってしまうことが時々ある。そのためにも、テスト済み/ビルド済みの成果物を本番環境にしゅっとリリース可能な状態にしておけるように、CI/CDのパイプラインを整備した。目新しいことは特にはないが、自分用のログを残しておく。

CI/CDの各フェイズの整理

フェイズを分割することで責務を分割して、早い段階でバグに気付いて障害に対応できるようにすることが目的。改めて整理する。

  • Testフェイズ
    • アプリのコードのテストが目的
    • すでにCircleCI上でやっている
  • Buildフェイズ
    • ソースコードや関連するアセットをinputとして、成果物を生成する
    • tarに固めてS3に置いたり、コンテナのイメージにしてECRにpushするなど
      • これまでは手元でDocker buildして、ECRにpushしていた
    • 以下の3つのイメージを準備する必要がある
      • JSONを返すAPI(go)のimage
      • Nuxt.jsでSSR(サーバーサイドレンダリング)するNodeのimage(参考: 最近の砂場活動その11: Nuxt.jsでサーバーサードレンダリング)
      • 静的ファイル(js/css)を配信するNginxのimage
        • 大体はSSRで済むのだが、管理者(私)はアノテーションをぽちぽちやりたい
        • 認証のためAmplifyを使っている && ここだけはクライアントサイドレンダリングでやりたい*1
  • Deployフェイズ
    • Buildフェイズの成果物をAWS ECS(Fargate)上で実行する
    • これまではkayac/ecspressoを使って、手元からdeployしていた
  • Releaseフェイズ
    • Deployフェイズで立ち上げた環境にユーザーへのリクエストを通す

DeployフェイズとReleaseフェイズに分割しているが、自分のサービスの場合、ここがごちゃっと混ざっている。ECSのローリングdeployを選んでいるので、deployされると同時にreleaseされて、新旧それぞれのバージョンのタスクが混在して動いている時間帯がある。障害が発生した場合のロールバックなどは多少時間がかかってしまうが、あくまで趣味プロジェクトなので、大きな影響はない。

新旧タスクの混在を避けたい場合、Blue/Greenデプロイを採用することを考えるとよい。ECSとCodeDeployを連携することで、Blue/Greenデプロイに対応することができる(ECS単体だとダメ)。今回の場合、ひとまず手元からのdeployを止めることが目的なので、ここまではやらないが気が向いたら練習としてやるかもしれない。

Buildフェイズ

GitHub Actions上でbuildして成果物を作って、docker buildしてイメージに含めて、ECRにpushする。

ECRにpushするのに必要なAWS_ECR_ACCESS_KEY_IDAWS_ECR_SECRET_ACCESS_KEYをGitHub ActionsのSecretsとして事前に登録しておく。IAMユーザー(アクセスの種類はプログラムによるアクセスのみを設定)を発行し、最小限の権限を付与するために独自にポリシーを作る。

このフェイズを用意したことによって、Pull Requestがマージされる度に最新のコードが反映されたDocker imageがECRに追加されていく。手元からのbuild & image pushが不要になった。

ビルドに必要なものと最終的なイメージに必要なものは異なることが多い。multi-stage buildを使って、最終的なイメージは最小限のものだけ含まれるようにしておく。

FROM node:alpine as builder
RUN apk add --no-cache git python make g++

WORKDIR /tmp
RUN cd /tmp && git clone https://github.com/syou6162/go-active-learning-web.git
RUN cd /tmp/go-active-learning-web && npm install -g node-gyp && npm install && npm run-script build

FROM nginx:1.19.8
RUN rm -f /etc/nginx/conf.d/*
ADD nginx.conf /etc/nginx/conf.d/app.conf
ADD img /www/app/img
ADD robots.txt /www/app/robots.txt
COPY --from=builder /tmp/go-active-learning-web/.nuxt/dist/client /www/app/_nuxt

CMD ["/usr/sbin/nginx", "-g", "daemon off;", "-c", "/etc/nginx/nginx.conf"]

Deployフェイズ(+ Releaseフェイズ)

sentry

どのエラーがどのリリース以降に出たものかを追跡するため、Sentryを使っている。リリース前にSentryに新しいリリースを登録しておく。

name: Create sentry release

on:
  # masterブランチに対してのみ
  push:
    branches:
      - master

jobs:
  sentry-release:
    runs-on: ubuntu-latest
    env:
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
      SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}        
    steps:
      - uses: actions/checkout@v2
      - name: Create new Sentry release
        run: |
          # Install Sentry CLI
          curl -sL https://sentry.io/get-cli/ | bash
          
          # clone go-active-learning-web
          git clone https://github.com/syou6162/go-active-learning-web.git && cd go-active-learning-web
          
          # Create new Sentry release
          export SENTRY_RELEASE=$(sentry-cli releases propose-version)
          sentry-cli releases new -p $SENTRY_PROJECT $SENTRY_RELEASE
          sentry-cli releases set-commits --auto $SENTRY_RELEASE
          sentry-cli releases finalize $SENTRY_RELEASE
          
          # Create new deploy for this Sentry release
          sentry-cli releases deploys $SENTRY_RELEASE new -e production

ecspresso

ecspressoでDeployを行なう。専用のGitHub Actionsが用意されているので、これもテンプレを埋めていくと割と簡単。

name: Deploy with ecspresso

on:
  # masterブランチに対してのみ
  push:
    branches:
      - master

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: kayac/ecspresso@v0
        with:
          version: v1.1.3
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ECSPRESSO_DEPLOY_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_ECSPRESSO_DEPLOY_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: |
          ecspresso deploy --config ecs-config.yaml

パイプラインを組む

「Buildフェイズが通った場合のみ、Deployフェイズを実行」など、パイプラインは依存関係を持っていることが多い。GitHub Actionsではneedsに事前に必要なものを指定しておくことで、依存関係を持たせたパイプラインを組むことができる。

workflow_dispatchを指定しておくと、手動Deployをすることもできる。パラメータを指定することもできるので、ロールバックが必要であればtagなどを指定するのもよい。

name: Push nuxt & nginx images to ECR, and deploy

on:
  workflow_dispatch:
  push:

jobs:
  sentry-release:
    runs-on: ubuntu-latest
    env:
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      ...
    steps:
      - uses: actions/checkout@v2
      - name: Create new Sentry release
      ...
  nuxt:
    name: nuxt image push
    runs-on: ubuntu-latest
    needs:
      - sentry-release
    ...
  nginx:
    name: nginx image push
    runs-on: ubuntu-latest
    needs:
      - sentry-release
    ...
  deploy:
    runs-on: ubuntu-latest
    needs:
      - nuxt
      - nginx
    ...

まとめ

元々やりたかった「手元からのDeployをやめる」というのができた。GitHubのPull Requestのマージボタンを押すだけで、buildやdeployまでの自動化ができるようになった。

今回やってないこととしては、以下がある。暇を見て、また砂場活動をやっていこう。

  • DeployフェイズとReleaseフェイズの分離
    • 上に書いてたやつ
  • 環境によるDeployの仕方の変更
    • 例: staging環境はdevelopブランチにマージされたら自動的にdeployまで行なうが、production環境はmainブランチにマージされた段階で初めてdeployする
    • そもそも今はstaging環境は(予算的な意味で)ない...
  • GKE上のDeployの改善

*1:この辺ごちゃついているので、どうにかしたい...