1
/
5

-Qiita記事Part.27-CloudFormation使ってみた

こんにちは、ナイトレイインターン生の鈴木です。
Wantedlyをご覧の方に、ナイトレイのエンジニアがどのようなことをしているか知っていただきたく、Qiitaに公開している記事をストーリーに載せています。

そして今回の記事はWantedly初登場、インフラエンジニアの大塩さんの記事です!
少しでも私たちに興味をお持ちいただけた方は下に表示される募集記事もご覧ください↓↓

こんにちは!株式会社ナイトレイで働くインフラエンジニアです。
今回は、CloudFormationを用いた実装の一部を紹介します!

背景

aws opsworksサービス終了に伴い、これまでchefで管理していたwebサイトのインフラリソースを、Cloud Formationで作り直すことに!ついでに、ec2インスタンスからECSに乗り換えることにしたので、一部共有します。

Cloud Formationとは

AWSのリソースをコードで管理できるサービスです。テンプレートと呼ばれるテキストファイル(YAML/JSON)を読み込むと、自動でAWSの環境を作ってくれます。料金は、利用しているリソース分支払えば良いだけで、Cloud Formation自体の利用は無料です。チュートリアルも用意されているのでご参考までに📕

チュートリアル
AWS CloudFormation テンプレートを使用して、スタックをプロビジョニングしたい AWS リソースを定義します。
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/CHAP_Using.html

EC2からECSへの移行について

ECS(Elastic Container Service)とは、Dockerコンテナのデプロイや運用管理を簡単に行うための、AWSのサービスです。コンテナベースでサービスを管理できるため、EC2と比べ、運用を簡素化、また効率化することができます。

プロジェクト構成

.
├── Makefile
├── README.md
└── {service_name}/
   └── cf/
       ├── {env}/ 環境固有のファイル群
       │  ├── .params
       │  └── main.yml
       └── templates/
          ├── app/
          │  ├── task.yml
          │  ├── ecr.yml
          │  └── ecs.yml
          └── lb/
             └── alb.yml

実際に作成したものから、一部抜粋します。

サービス単位でディレクトリを切り、テンプレートとスタック作成のファイルを分離しました。

また、パラメータをgithubなどで管理したくないので、.paramsをs3に置き、実行時にshellでダウンロードしてくる仕様にしました。

(他にもネットワークやセキュリティなどなど作りましたが、本記事では割愛します。)

テンプレート作成

公式リファレンスを見ながら作っていきます。

Template reference
Learn about the resources types, resource properties, resource attributes, intrinsic functions, and pseudo parameters that you can use in CloudFormation templates.
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-reference.html

ecr.yml

Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String

Resources:
  # ECR
  EcrNginx:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${ServiceName}-${Env}-nginx
      ImageTagMutability: MUTABLE
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
          LifecyclePolicyText: >
            {
              "rules": [
                {
                  "action": {
                    "type": "expire"
                  },
                  "selection": {
                    "countType": "imageCountMoreThan",
                    "countNumber": 4,
                    "tagStatus": "any"
                  },
                  "description": "Delete more than 4 images",
                  "rulePriority": 1
                }
              ]
            }
          RegistryId: !Ref AWS::AccountId

  EcrPhp:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${ServiceName}-${Env}-php
      ImageTagMutability: MUTABLE
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
          LifecyclePolicyText: >
            {
              "rules": [
                {
                  "action": {
                    "type": "expire"
                  },
                  "selection": {
                    "countType": "imageCountMoreThan",
                    "countNumber": 4,
                    "tagStatus": "any"
                  },
                  "description": "Delete more than 4 images",
                  "rulePriority": 1
                }
              ]
            }
          RegistryId: !Ref AWS::AccountId

微々たるものですが、コストかかるのでイメージは3世代管理に。

task.yml

Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String
  AccountId:
    Type: String
  WebCpu:
    Type: Number
  WebMemory:
    Type: Number
  NginxLatestTag:
    Type: String
  PhpLatestTag:
    Type: String

Resources:
  # Task Definition
  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub ${ServiceName}-${Env}-web
      Cpu: !Ref WebCpu
      Memory: !Ref WebMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskExecutionRole
      TaskRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskRole
      ContainerDefinitions:
        - Name: !Sub ${ServiceName}-${Env}-nginx-container
          Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-nginx:${NginxLatestTag}
          Essential: true
          PortMappings:
            - HostPort: 80
              ContainerPort: 80
              Protocol: tcp
          LinuxParameters:
            initProcessEnabled: true
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Sub /ecs/${ServiceName}-${Env}-nginx
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: "ecs"
              awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"
        - Name: !Sub ${ServiceName}-${Env}-php-container
          Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-php:${PhpLatestTag}
          EnvironmentFiles:
            - Value: !Sub arn:aws:s3:::${ServiceName}-env/ecs/${Env}.env
              Type: s3
          PortMappings:
            - HostPort: 9000
              ContainerPort: 9000
              Protocol: tcp
          LinuxParameters:
            initProcessEnabled: true
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Sub /ecs/${ServiceName}-${Env}-php
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: "ecs"
              awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"

Outputs:
  TaskDefinition:
    Value: !Ref TaskDefinition

スタンダードなnginxとphpコンテナ構成。

ecs.yml

Parameters:
  Env:
    Type: String
  ServiceName:
    Type: String
  SgWebId:
    Type: AWS::EC2::SecurityGroup::Id
  TaskDefinition:
    Type: String
  PrivateSubnetCId:
    Type: AWS::EC2::Subnet::Id
  PrivateSubnetDId:
    Type: AWS::EC2::Subnet::Id
  TargetGroup:
    Type: String

Resources:
  # ECS Cluster
  EcsCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${ServiceName}-${Env}-cluster

  # ECS Service
  EcsService:
    Type: AWS::ECS::Service
    Properties:
      PlatformVersion: 1.4.0
      ServiceName: !Sub ${ServiceName}-${Env}-service
      Cluster: !Ref EcsCluster
      TaskDefinition: !Ref TaskDefinition
      DesiredCount: 2
      LaunchType: FARGATE
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 100
        DeploymentCircuitBreaker:
          Enable: true
          Rollback: true
      EnableExecuteCommand: true
      HealthCheckGracePeriodSeconds: 120
      PropagateTags: TASK_DEFINITION
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets:
            - !Ref PrivateSubnetCId
            - !Ref PrivateSubnetDId
          SecurityGroups:
            - !Ref SgWebId
          AssignPublicIp: DISABLED
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup
          ContainerName: !Sub ${ServiceName}-${Env}-nginx-container
          ContainerPort: 80

Outputs:
  EcsCluster:
    Value: !Ref EcsCluster
  EcsService:
    Value: !GetAtt EcsService.Name

パラメータのtypeはstringとリソースタイプでばらつきがありますが、まぁ良しとします(本当は、リソースタイプを指定すべきですね…)。

alb.yml

Parameters:
  Env:
    Type: String
  AccountId:
    Type: String
  ServiceName:
    Type: String
  Domain:
    Type: String
  Certificates:
    Type: String
  SgAlbId:
    Type: AWS::EC2::SecurityGroup::Id
  VpcId:
    Type: String
  PublicSubnetCId:
    Type: AWS::EC2::Subnet::Id
  PublicSubnetDId:
    Type: AWS::EC2::Subnet::Id

Resources:
  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub ${ServiceName}-${Env}-alb
      Scheme: internet-facing
      Type: application
      IpAddressType: ipv4
      Subnets:
        - !Ref PublicSubnetCId
        - !Ref PublicSubnetDId
      SecurityGroups: 
        - !Ref SgAlbId
      LoadBalancerAttributes:
        - Key: idle_timeout.timeout_seconds
          Value: 120

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub ${ServiceName}-${Env}-tg
      Port: 80
      Protocol: HTTP
      TargetType: ip
      VpcId: !Ref VpcId
      HealthCheckEnabled: true
      HealthyThresholdCount: 2
      HealthCheckTimeoutSeconds: 5
      HealthCheckProtocol: HTTP
      HealthCheckPort: 80
      HealthCheckPath: /healthcheck
      HealthCheckIntervalSeconds: 30
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: 300
        - Key: stickiness.enabled
          Value: true
        - Key: stickiness.type
          Value: lb_cookie
        - Key: stickiness.lb_cookie.duration_seconds
          Value: 86400

  DefaultListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 443
      Protocol: HTTPS
      Certificates:
        - CertificateArn: !Sub arn:aws:acm:ap-northeast-1:${AWS::AccountId}:certificate/${Certificates}
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            ContentType: text/plain
            StatusCode: 403
  
  CustomListener:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Priority: 100
      ListenerArn: !Ref DefaultListener
      Actions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
      Conditions: 
        - Field: path-pattern
          PathPatternConfig:
            Values: 
              - "/*"
        - Field: host-header
          HostHeaderConfig: 
            Values: 
              - !Ref Domain

Outputs:
  Alb:
    Value: !Ref Alb
  TargetGroup:
    Value: !Ref TargetGroup

ターゲットグループはデフォルト403で返すようにして、Hostが指定したドメインの場合のみECSコンテナにアクセスさせるようにします。

定番のアクセス制限ですね。

余談ですが、サブネットにaがない理由はNatゲータウェイ作成時に下記エラーが出たためです。

“Nat Gateway is not available in this availability zone.”

長年AWS使っていて初めて遭遇しました…こんな事があるんですね。

調べたところAZには新旧あるようで、古いアカウントだと発生しうるようです。

何か対応すれば使えるようになるという類のものではなさそうなので、今回aは使用しないことにしました。

参考:

旧AZ(apne1-az3)でできないこと - サーバーワークスエンジニアブログ
皆さんはap-northeast-1aを使っていらっしゃいますか。 使ってますよね。 私のap-northeast-1aとあなたのap-northeast-1aは本当は違うAZかもしれない。 そんなこと聞いたことありませんか。 自分のAZがなんであるのかはRAMで確認できます。 自分のアカウント間でアベイラビリティーゾーンをマッピングする方法を教えてください。 東京リージョンの場合、一般的にはこんな感じに 「お客様のAZ ID」 が3つ見えます。 ところが、古い時期に作成されたAWSアカウントにはAZが4
https://blog.serverworks.co.jp/tech/2019/10/02/apne1-az3/

スタック作成

main.yml

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  Env:
    Type: String
  VpcId:
    Type: String
  IgwId:
    Type: String
  ServiceName:
    Type: String
  ServiceDirName:
    Type: String
  Domain:
    Type: String
  Certificates:
    Type: String
  TemplateBucketName:
    Type: String
  WebCpu:
    Type: Number
  WebMemory:
    Type: Number
  PublicCidrC:
    Type: String
  PublicCidrD:
    Type: String
  PrivateCidrC:
    Type: String
  PrivateCidrD:
    Type: String
  NginxLatestTag:
    Type: String
  PhpLatestTag:
    Type: String
    
Resources:
  #---ネットワーク割愛---

  Ecr:
    Type: AWS::CloudFormation::Stack
    Properties:
      Parameters:
        Env: !Ref Env
        ServiceName: !Ref ServiceName
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecr.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  Alb:
    Type: AWS::CloudFormation::Stack
    DependsOn: Sg
    Properties:
      Parameters:
        Env: !Ref Env
        AccountId: !Ref "AWS::AccountId"
        ServiceName: !Ref ServiceName
        Domain: !Ref Domain
        Certificates: !Ref Certificates
        SgAlbId: !GetAtt Sg.Outputs.SgAlbId
        VpcId: !Ref VpcId
        PublicSubnetCId: !GetAtt Subnet.Outputs.PublicSubnetC
        PublicSubnetDId: !GetAtt Subnet.Outputs.PublicSubnetD
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/lb/alb.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  EcsTask:
    Type: AWS::CloudFormation::Stack
    DependsOn: Alb
    Properties:
      Parameters:
        Env: !Ref Env
        AccountId: !Ref "AWS::AccountId"
        ServiceName: !Ref ServiceName
        WebCpu: !Ref WebCpu
        WebMemory: !Ref WebMemory
        NginxLatestTag: !Ref NginxLatestTag
        PhpLatestTag: !Ref PhpLatestTag
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/task.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}
  Ecs:
    Type: AWS::CloudFormation::Stack
    DependsOn: EcsTask
    Properties:
      Parameters:
        Env: !Ref Env
        ServiceName: !Ref ServiceName
        SgWebId: !GetAtt Sg.Outputs.SgWebId
        TaskDefinition: !GetAtt EcsTask.Outputs.TaskDefinition
        PrivateSubnetCId: !GetAtt Subnet.Outputs.PrivateSubnetC
        PrivateSubnetDId: !GetAtt Subnet.Outputs.PrivateSubnetD
        TargetGroup: !GetAtt Alb.Outputs.TargetGroup
      TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecs.yaml'
      Tags:
        - Key: Name
          Value: !Sub ${ServiceName}-${Env}

  #---バッチ、スケーリング設定割愛

変更セット作成

CloudFormationには、create-stackコマンドが用意されていますが、今回はterraformっぽくdeployを使います。

aws cloudformation deploy \
            --stack-name "$STACK_NAME" \
            --template-file "./cf/$ENV/main.yaml" \
            --parameter-overrides $(cat ./cf/$ENV/.params) \
            --no-execute-changeset \
            --profile "$PROFILE"

--no-execute-changesetを付与すると、変更セットの作成にとどめてくれます。

デプロイ

aws cloudformation deploy \
            --stack-name "$STACK_NAME" \
            --template-file "./cf/$ENV/main.yaml" \
            --parameter-overrides $(cat ./cf/$ENV/.params) \
            --profile "$PROFILE"

--no-execute-changesetを外しただけです。

ちなみに初回のデプロイに失敗した場合、削除せずに変更セットを再作成しようとするとエラーになります。デプロイ成功後2回目以降は削除不要で変更セットが作成できます。

削除

aws cloudformation delete-stack \
            --stack-name "$STACK_NAME" \
            --profile "$PROFILE"

感想

ymlファイルは非常にシンプルで作成しやすいです(CloudFromationはJsonでも可)。

しかし、変更セットはterraformに比べると差分がわかりにくかったです。システム規模が大きくなるとボトルネックになりそう…。

また、今回は、stacksetsを使わなかったので次回は使ってみたいです!

最後に

私たちの会社、ナイトレイでは一緒に事業を盛り上げてくれるエンジニアを募集しています!
Web開発メンバー、GISエンジニア、サーバーサイドエンジニアなど複数ポジションで募集しているため、
「専攻分野を活かしたい」「横断的に様々な業務にチャレンジしてみたい」と言ったご要望も相談可能です!

✔︎ GISの使用経験があり、観光・まちづくり・交通・防災系などの分野でスキルを活かしてみたい
✔︎ ビッグデータの処理が好き!(達成感を感じられる)
✔︎ データベース構築、サーバー周りを触るのが好き
✔︎ 社内メンバーだけではなく顧客とのやり取りも実はけっこう好き
✔︎ 自社Webサービスの開発で事業の発展に携わってみたい
✔︎ 地理や地図が好きで、位置情報データにも興味を持っている

一つでも当てはまる方は是非お気軽に「話を聞きに行きたい」ボタンを押してください!

株式会社ナイトレイからお誘い
この話題に共感したら、メンバーと話してみませんか?
株式会社ナイトレイでは一緒に働く仲間を募集しています

同じタグの記事

今週のランキング

鈴木 梨子さんにいいねを伝えよう
鈴木 梨子さんや会社があなたに興味を持つかも