0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LaravelのCI/CD、AWS CodeBuildでユニットテストして AWS ECSにデプロイしてみる

Posted at

概要

Laravel プロジェクトを GitLabにプッシュと同時に、Laravelのログイン機構(Laravel Breeze)を AWS CodeBuildでユニットテストします。
ユニットテスト成功後、AWS ECSに自動でデプロイします。

なお、記事全体を通して、プロジェクトやアプリ名、その他AWSリソースのプレフィックスを「myapp」に統一していますので、本記事を参考にされるかたは myapp を読み替えて参考にしてみてください。

開発環境

OS

  • Windows11上のWSL2で動作するUbuntu

以下がインストールされている事

  • PHP(v8.2以上)

前提

  • ドメインの取得と、Route53でホストゾーンを作っておくこと
  • ACMで取得したドメインのパブリック証明書をリクエストしておくこと

目次

1章. Laravelのインストール
2章. Unitテスト(ローカル実行編)
3章. AWSリソースの用意-前編(DEV環境のみ)
4章. GitLabの設定
5章. Docker関連ファイルの設定
6章. AWSリソースの用意-後編(DEV環境のみ)

1章. Laravelのインストール

Laravelプロジェクト「myapp」の作成

任意のディレクトリで以下コマンドを実行する。

$ composer create-project --prefer-dist laravel/laravel myapp

開発/STG/本番環境でのHTTPS強制

後ほどECSにデプロイして画面確認するとき用に、開発/STG/本番環境の場合にはHTTPSを強制するようにします。

app/Providers/AppServiceProvider.php
  :
    public function boot(): void
    {
        if (app()->environment('develop') || app()->environment('staging') || app()->environment('production')) {
            URL::forceScheme('https');
        }
    }
    

Laravel Sailのインストール

$ cd myapp
$ composer require laravel/sail --dev
$ php artisan sail:install   # 以下画面が表示されるので、必要なサービスをSpaceキーで選択してEnter
 ┌ Which services would you like to install? ───────────────────┐
 │ › ◻ mysql                                                  ┃ │
 │   ◻ pgsql                                                  │ │
 │   ◻ mariadb                                                │ │
 │   ◻ mongodb                                                │ │
 │   ◻ redis                                                  │ │
 └────────────────────────────────────────────────── 0 selected ┘
  ※mysql, redis を選択しておく

Laravel Sailの起動

Sail は docker-compose を利用して、必要なコンテナを起動します。

$ ./vendor/bin/sail up -d

Dockerコンテナの起動確認

以下3つのプロセスが起動していること

$ docker ps
CONTAINER ID   IMAGE                    COMMAND                  CREATED              STATUS                        PORTS                                                                                            NAMES
f79af7ce5a24   sail-8.4/app             "start-container"        About a minute ago   Up 5 seconds                  0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:5173->5173/tcp, :::5173->5173/tcp                     myapp-laravel.test-1
91591fd6fb81   redis:alpine             "docker-entrypoint.s…"   About a minute ago   Up About a minute (healthy)   0.0.0.0:6379->6379/tcp, :::6379->6379/tcp                                                        myapp-redis-1
7c0d080a8fe0   mysql/mysql-server:8.0   "/entrypoint.sh mysq…"   About a minute ago   Up About a minute (healthy)   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060-33061/tcp                                       myapp-mysql-1

DBにテーブル作成

$ ./vendor/bin/sail artisan migrate

ブラウザで確認してみる

http://localhost/ (Laravelトップ)

DBに接続してみる

$ ./vendor/bin/sail mysql

Tinker(対話型シェル)を試してみる

$ ./vendor/bin/sail artisan tinker

ログイン機能実装の為に、Breeze のインストール

Breeze は laravel/ui より新しく、Jetstream よりは設定が簡単です

$ composer require laravel/breeze --dev

Breeze をセットアップ

$ php artisan breeze:install

 ┌ Which Breeze stack would you like to install? ───────────────┐
 │ › ● Blade with Alpine                                        │
 │   ○ Livewire (Volt Class API) with Alpine                    │
 │   ○ Livewire (Volt Functional API) with Alpine               │
 │   ○ React with Inertia                                       │
 │   ○ Vue with Inertia                                         │
 │   ○ API only                                                 │
 └──────────────────────────────────────────────────────────────┘
 ※Blade with Alpineを選択してEnter
 ┌ Would you like dark mode support? ───────────────────────────┐
 │ ○ Yes / ● No                                                 │
 └──────────────────────────────────────────────────────────────┘
 ※Noを選択してEnter
 ┌ Which testing framework do you prefer? ──────────────────────┐
 │   ○ Pest                                                     │
 │ › ● PHPUnit                                                  │
 └──────────────────────────────────────────────────────────────┘
 ※PHPUnitを選択してEnter

Breeze には Tailwind CSS などのフロントエンドパッケージが必要なのでインストール

$ npm install && npm run build

ログインボタン、登録ボタンが表示されることを、ブラウザで確認

http://localhost/ (Laravelトップ)

参考コマンド

Laravel Sailの停止

$ ./vendor/bin/sail down

すべてのコンテナ・ネットワーク・ボリュームを削除する場合

$ ./vendor/bin/sail down --volumes

2章. Unitテスト(ローカル実行編)

.env.testing を作成

高速化の為、DBはインメモリを利用します

$ cp .env .env.testing
.env.testing
 :
+ DB_CONNECTION=sqlite  # SQLiteに変更
  DB_HOST=mysql
  DB_PORT=3306
+ DB_DATABASE=:memory:  # メモリに変更
  DB_USERNAME=sail
  DB_PASSWORD=password
 :

phpunit.xmlを修正

phpunit.xml
     :
        <env name="CACHE_STORE" value="array"/>
+       <env name="DB_CONNECTION" value="sqlite"/>  この行を追加
+       <env name="DB_DATABASE" value=":memory:"/>  この行を変更
        <env name="MAIL_MAILER" value="array"/>
     :

サンプルで用意されているログイン機構(Breeze)のUnitテストを実行

$ ./vendor/bin/phpunit
PHPUnit 11.5.15 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.27
Configuration: /xxxxx/myapp/phpunit.xml

.........................                                         25 / 25 (100%)

Time: 00:00.772, Memory: 44.50 MB

OK (25 tests, 61 assertions)

※E: Unable to locate package php82-php-sqlite3 エラーが出た場合は、以下コマンドを実行してください

$ add-apt-repository ppa:ondrej/php # ondrej/phpリポジトリを追加(このリポジトリは、PHPの最新バージョンや拡張モジュールを提供)
$ apt update
$ apt install php8.2-sqlite3        # SQLiteをインストール

GitLabにプッシュしておく

Unitテストが正常に終了できたら、一旦GitLabにプッシュしておく。
また、develop ブランチを作成して、同様にプッシュしておく。
以降は develop ブランチを使用していきます。

3章. AWSリソースの用意-前編(DEV環境のみ)

システム構成図

以下のようなリソースを用意します。
また、説明が複雑にならないよう、今回はDEV(開発用)環境のみ用意します。
system.png

デプロイの流れ

これから説明する設定をすることで、下図のような流れで自動デプロイをすることができます。

下図はDEV環境用のフローですが、一部Lambda関数などは本番/STG/HFX(ホットフィックス)用にも利用できるよう汎用的な作りにしています。
deploy.png

VPCの作成

AWSコンソールからVPCを作成します。
※DNS解決を有効化、DNSホスト名を有効化の両方にチェックをいれてください。

vpc1.png

サブネットの作成

AWSコンソールからサブネットを作成します。
subnet1.png

上記サブネット「myapp-subnet-private-1a」を作成したら、同様に以下のサブネットも追加で作成します。

サブネット名 アベイラビリティ―ゾーン IPv4 VPC CIDR ブロック IPv4 サブネット CIDR ブロック
myapp-subnet-private-1c ap-northeast-1c 10.0.0.0/16 10.0.2.0/24
myapp-subnet-public-1a ap-northeast-1a 10.0.0.0/16 10.0.3.0/24
myapp-subnet-public-1c ap-northeast-1c 10.0.0.0/16 10.0.4.0/24

インターネットゲートウェイの作成

AWSコンソールからインターネットゲートウェイを作成します。
igw1.png

作成したインターネットゲートウェイの右上にある「アクション」メニューから「VPCにアタッチ」を選び、「myapp-vpc」にアタッチします。

igw2.png

パブリック用のルートテーブルの作成

AWSコンソールからパブリック用のルートテーブルを作成します。
rtb-public1.png

パブリック用のルートテーブル作成後、パブリックルートテーブル画面右上の「アクション」メニューの中から「サブネットの関連付けを編集」をクリック。
rtb-public3.png

「サブネットの関連付けを編集」画面で、publicサブネットをチェックして「関連付けを保存」をクリック。
rtb-public4.png

再びパブリックルートテーブル画面に遷移し、画面右下の「ルートを編集」をクリック。
rtb-public5.png

「ルート編集」画面から、上記で作成したインターネットゲートウェイを設定します。
rtb-public6.png

プライベート用のルートテーブルの作成

AWSコンソールからプライベート用のルートテーブルを作成します。
rtb-private1.png

パブリック用ルートテーブル同様に、画面右上の「アクション」メニューから「サブネットの関連付けを編集」をクリック。
rtb-private2.png

「サブネットの関連付けを編集」画面で、privateサブネットをチェックして「関連付けを保存」をクリック。
rtb-private3.png

セキュリティグループの作成

VPCのセキュリティグループを作ります。
sg1.png

ALBのセキュリティグループを作ります。
sg2.png

ターゲットグループの作成

AWSコンソールからALBのターゲットグループを作成します。
tg1.png
tg2.png

ALBの作成

AWSコンソールからALBを作成します。
alb1.png

証明書(ACMから)には事前にリクエストしておいた証明書を選択してください。
alb2.png
alb3.png
alb4.png
alb5.png
alb6.png

Route53でレコードを作成

AWSコンソールからレコードを作成します。
ドメインは既に取得済のものを指定してください。
route53.png

VPCエンドポイントの作成

AWSコンソールからVPCエンドポイントを作成します。
これにより、S3、ECR へのアクセスがインターネットを通らずにAWS内のみになります。

S3へのVPCエンドポイント設定

ルートテーブルには private を選択してください。

vpce-s3_1.png
vpce-s3_2.png

ECRへのVPCエンドポイント設定1

サブネットには private を選択してください。

vpce-dkr_1.png
vpce-dkr_2.png
vpce-dkr_3.png

ECRへのVPCエンドポイント設定2

サブネットには private を選択してください。

vpce-ecr_1.png
vpce-ecr_2.png
vpce-ecr_3.png

ECRへのVPCエンドポイント設定3

サブネットには private を選択してください。

vpce-log_1.png
vpce-log_2.png
vpce-log_3.png

S3バケットの作成

zipファイルアップロード用のS3バケットを作成

S3バケット「myapp-<ユニークな値>-git」を作成します。
その際、バージョニングは「有効にする」を選択してください。

s3_git_1.png
s3_git_2.png

環境設定ファイル(.env)格納用のS3バケットを作成

S3バケット「myapp-<ユニークな値>-config」を作成します。
その他はデフォルト設定のままとする。

s3_config_1.png

CodePipelineのアーティファクト格納用のS3を作成

S3バケット「myapp-<ユニークな値>-artifact」を作成します。
その他はデフォルト設定のままとする。

s3_artifact_1.png

S3バケットのライフサイクルルールについて

特にアーティファクト等は、デプロイする度にデータが増えていきます。
不要なデータを定期的に削除したい場合には、以下の手順で「ライフサイクルルール」を設定して、古いファイルは削除してください。

バケットの「管理」タブをクリック
lifecycle.png

「ライフサイクルルールを作成する」をクリック
lifecycle2.png

以下の設定をする(7日でバケットのオブジェクトが削除される例)
lifecycle3a.png
lifecycle3b.png

Lambdaに必要なIAMロールの作成

AWSコンソールから、Lambdaに必要なIAMロールを作成します。

ユースケースに「Lambda」を選択して次へ。
iam1.png

許可ポリシーから「AmazonS3FullAccess」検索して選択。
iam2a.png

同じく許可ポリシーから「AWSLambdaBasicExecutionRole」を検索して選択して次へ。
iam2b.png

ロール名に「myapp_s3_lambda_basic_role」を入力して、ロールを作成をクリック。
iam3a.png
iam3b.png

Lambda関数の作成

AWSコンソールから、Lambda関数を作成します。
実行ロールには上記で作成した「myapp_s3_lambda_basic_role」を指定してください。

lambda_1.png
lambda_2.png

Lambda関数の設定(一般設定)

Lambda関数が作成できたら、「設定」>「一般設定」>「編集」クリックして、一般設定の編集画面に遷移します。

lambda_3.png

その後、メモリを10240MBに、タイムアウトを15分0秒に変更してください。

lambda_4.png

Lambda関数の設定(環境変数)

次に「設定」>「環境変数」>「編集」をクリックして、環境変数の編集画面に遷移します。

lambda_5.png

その後、以下の環境変数を登録します。

  • BUCKET_NAME: 前述で作成した、S3バケット(myapp-<ユニークな値>-git)
  • GIT_USER: GitLabのログインユーザー名
  • GIT_PASS: GitLabのログインパスワード
  • GIT_REPOSITORY: GitLabのリポジトリ名
  • HTTPS_CLONE_URL: GitLabのHTTPSのclone URL
  • MAIN_BRANCH_NAME: main(将来用にmainブランチを指定)
  • DEVELOP_BRANCH_NAME: develop(developブランチを指定)
  • HOTFIX_BRANCH_NAME: hotfix(将来用にhotfixブランチを指定)

lambda_6.png

Lambdaレイヤーの作成

ローカルLinux環境(WSL2環境)の任意のディレクトリで以下のコマンドを実行し、node_modules.zip を作成する。

$ mkdir npm_library
$ cd npm_library
$ npm install simple-git      #node_moduleが出来上がる
$ npm install aws-sdk
$ npm install adm-zip
$ zip -r node_modules.zip node_modules

Lambdaのトップ画面に遷移し、左メニューの「レイヤー」クリックし、レイヤーの作成画面に遷移する。

lambda_7.png

次に、上記で作成した node_modules.zip を アップロードします。
その際の名称は node_modules とします。

lambda_8.png

Lambdaレイヤーの追加

再び、作成したLambda関数「myapp_s3_lambda_basic_role」に戻り、ページ下部にある「レイヤーの追加」をクリックし、レイヤーの追加画面に遷移します。

lambda_9.png

レイヤーの追加画面では、「カスタムレイヤー」を選択したのち、上記で作成したLambdaレイヤー「node_modules」が選べる状態になっているので、選択し、バージョンを最新のものに選択して、「追加」をクリックします。

lambda_10.png

再びレイヤー追加画面に戻り、「ARNを指定」を選択したのち、「ARNを指定」フォームに
arn:aws:lambda:ap-northeast-1:553035198032:layer:git-lambda2:8 を入力します。
参考

lambda_11.png

Lambda関数のコードを作成

作成したLambda関数「myapp-gitclone-upload-to-s3」に戻り、「コード」タブをクリックして、以下のコードを貼り付けます。
このコードは develop ブランチだけではなく、将来 main / hotfix ブランチを作ったときにも使えるような仕組みになっています。
その後、「Deploy」をクリックしてデプロイします。

import url from 'url';
import fs from 'fs';
import simpleGit from 'simple-git';
import path from 'path';
import admZip from 'adm-zip';
import aws from 'aws-sdk';
import { execSync } from 'child_process';

// 環境変数の呼び出し
const HTTPS_CLONE_URL = process.env.HTTPS_CLONE_URL;
const BUCKET_NAME = process.env.BUCKET_NAME;
const GIT_USER = process.env.GIT_USER;
const GIT_PASS = process.env.GIT_PASS;
const GIT_REPOSITORY = process.env.GIT_REPOSITORY;
const MAIN_BRANCH_NAME = process.env.MAIN_BRANCH_NAME;
const DEVELOP_BRANCH_NAME = process.env.DEVELOP_BRANCH_NAME;
const HOTFIX_BRANCH_NAME = process.env.HOTFIX_BRANCH_NAME;

export const handler = async(event) => {
    
    //JSONデータをオブジェクトとして取得する
    const jsonObj = event;
    
    const rcv_ref = jsonObj['ref']; //ブランチ名が格納
    const rcv_repository = jsonObj['repository']['name']; //リポジトリ名
    console.log(rcv_repository + " - " + rcv_ref);
    
    let rcv_refs, rcv_branch, target_branch, clone_url;

    if(rcv_repository == GIT_REPOSITORY){
        rcv_refs = rcv_ref.split("/");
        rcv_branch = rcv_refs[rcv_refs.length-1];
        
        if(rcv_branch == MAIN_BRANCH_NAME || rcv_branch == DEVELOP_BRANCH_NAME || rcv_branch == HOTFIX_BRANCH_NAME){
            
            if(rcv_branch == MAIN_BRANCH_NAME){
                target_branch = "main";
            }
            else if(rcv_branch == DEVELOP_BRANCH_NAME){
                target_branch = "develop";
            }
            else if(rcv_branch == HOTFIX_BRANCH_NAME){
                target_branch = "hotfix";
            }
            else{
                console.log("対象外ブランチの為、処理なし");
                return;                
            }

            clone_url = HTTPS_CLONE_URL;
            console.log(clone_url + "" + rcv_branch + " ブランチからcloneします!");
            
        }else{
            console.log("対象外ブランチの為、処理なし");
            return;
        }
    }else{
        console.log("対象外リポジトリの為、処理なし");
        return;
    }
    
    // GitのURLをパースして、cloneのURLを組み立てる
    const site = url.parse(clone_url, true);
    const userStr = encodeURIComponent(GIT_USER);
    const passStr = encodeURIComponent(GIT_PASS);
    const uri = site['protocol'] +"//" + userStr + ":" + passStr +"@" + site['host'] + site['pathname'];
    console.log("clone実行URL : " + uri);
    
    try {
        // 一時ディレクトリ作成
        const tmpDir = fs.mkdtempSync('/tmp/');
       
        // clone実行(tmpDir直下にclone)
        // await simpleGit().clone(uri, tmpDir);
        execSync(`git -c http.sslVerify=false clone ${uri} ${tmpDir}`); // SSL認証をfalseにしてgit clone
        execSync(`cd ${tmpDir}; git config --local http.sslVerify false`);   // ローカルリポジトリをSSL認証falseにする

        // 該当ブランチをcheckout
        const git = simpleGit(tmpDir);
        await git.checkout(rcv_branch);
        await git.pull();
        
        // zip圧縮(git情報は含めない)
        rmDir(tmpDir + "/.git");
        await zipArchive(tmpDir, target_branch);
        
        let stat = fs.statSync(path.join(tmpDir, target_branch + ".zip"));
        console.log(path.join(tmpDir, target_branch + ".zip") + "ファイルのサイズ:" + stat.size);
        
        const filenames = fs.readdirSync(tmpDir);
        console.log(tmpDir +"ディレクトリ内のファイル一覧", filenames);

        // S3への設置
        const s3 = new aws.S3();
        let params = {
            Bucket: BUCKET_NAME,
            Key: GIT_REPOSITORY + "/" + target_branch + ".zip"
        };
        console.log(params);
        await s3.deleteObject(params).promise();
        console.log("S3削除完了");
        const v= fs.readFileSync(path.join(tmpDir, target_branch + ".zip"));
        params.Body=v;
        await s3.putObject(params).promise();
        console.log("S3アップロード完了");
        
        // 一時ディレクトリ削除
        rmDir(tmpDir);
        
    } catch (err) {
        console.log(err.name, err);
    }

    return;
};

// フォルダの圧縮
const zipArchive = async (targetDir, zipFileName) => {

    const zip = new admZip();
    // zipにフォルダを追加
    zip.addLocalFolder(targetDir);
    // zipファイル書き出し
    zip.writeZip(path.join(targetDir, zipFileName + ".zip"));
    return;
};

// ディレクトリを再帰的に削除
const rmDir = (dirPath) => {
  if (!fs.existsSync(dirPath)) { return }

  const items = fs.readdirSync(dirPath);
  for (const item of items) {
    const deleteTarget = path.join(dirPath, item);
    if (fs.lstatSync(deleteTarget).isDirectory()) {
      rmDir(deleteTarget);
    } else {
      fs.unlinkSync(deleteTarget);
    }
  }
  fs.rmdirSync(dirPath);
};

API Gateway の作成

AWSコンソールから、API Gatewayを作成します。
画面右上の「APIを作成」をクリックして「APIタイプを選択」画面に遷移します。
そこで、「REST API」欄の「構築」ボタンをクリックします。

api_gw1.png

「新しいAPI」にを選択したまま、API名に「myapp-apigw」と入力、APIエンドポイントタイプに「リージョン」を選択して、画面右下の「APIを作成」をクリックして、リソース画面に遷移します。

api_gw2.png

リソース画面に遷移したら、画面右側の「メソッドを作成」をクリックします。

api_gw3.png

メソッドを作成画面に遷移したら、メソッドタイプには「POST」、統合タイプには「Lambda関数」を選択し、Lambda関数には前述で作成した「myapp-gitclone-upload-to-s3」を選択して、画面右下の「メソッドを作成」をクリックします。これでPOSTメソッドが作成されます。

api_gw4.png

次に、API GatewayとLambda間を非同期通信にして、API Gatewayのタイムアウト(29秒)を回避します。
「リソース」画面で「POST」を選択したまま、「統合リクエスト」タブを選択して、「編集」ボタンをクリックします。

api_gw5.png

ページ下部に「URLリクエストヘッダーのパラメータ」欄があるので、「リクエストヘッダーのパラメータを追加」をクリックして展開します。

api_gw6.png

展開されたら、
名前:X-Amz-Invocation-Type
マッピング元:'Event' ※シングルクォーテーションで囲む
を入力します。

api_gw7.png

次に「マッピングテンプレートの設定」を行います。これをしないと400エラーになります。
すぐ下にある「マッピングテンプレートの追加」ボタンをクリックして、展開します。

api_gw8.png

展開されたら、
コンテンツタイプ:application/json
テンプレート生成:空欄
テンプレート本文:空欄
を入力して、画面右下の「保存」ボタンをクリックします。

api_gw9.png

リソース画面に戻ったら、画面右上にある「APIをデプロイ」をクリックします。

api_gw10.png

Deploy API ダイアログが表示されるので、
ステージ:新しいステージ
ステージ名:v1 ※バージョン管理用
と入力して、「デプロイ」ボタンをクリックします。

api_gw11.png

その後、ステージ画面に遷移しますので、「URLを呼び出す」をコピーして、ブラウザでそのURLを表示してみてください。ブラウザに「{"message":"Missing Authentication Token"}」と表示されれば、とりあえずOKです。

api_gw12.png

API Gateway のログを見たいとき

必須ではありませんが、API Gateway のログを見たいときは、下図のように「ログとトレース」を編集して、「CloudWatchログ」に見たいログを選択します。
その際、「CloudWatch Logs role ARN must be set in account settings to enable logging」というエラーが出た場合には、こちら の記事を参考にして設定してみてください。

api_gw13.png

api_gw14.png

4章. GitLabの設定

Webhookの設定

GitLabの「設定」>「Webhooks」をクリックして、Webhooks画面のURL欄に、上記 API Gateway の設定で表示されていた「URLを呼び出す」のURLを入力して保存して「Add webhook」ボタンクリックで保存してください。

ここで一度、developブランチに git pushしてみてください。
前述のS3バケット「myapp-<ユニークな値>-git」に 「リポジトリ名/develop.zip」ファイルが作成されていればOKです。

gitlab.png

5章. Docker関連ファイルの設定

ECSコンテナ内で使用する Dockerコンテナを用意します。
具体的には Laravelプロジェクトディレクトリ直下に、以下の構成でファイルを準備します。

:
:
├── docker_setting
│   ├── nginx # NGINXコンテナ用
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php   # PHPコンテナ用
│       ├── Dockerfile
│       ├── cache.sh
│       ├── php.ini
│       └── www.conf
:
:

まずはディレクトリを作成します。

mkdir -p docker_setting/nginx
mkdir -p docker_setting/php

NGINXコンテナ用の各ファイルの内容

docker_setting/nginx/Dockerfile
FROM nginx:1.20-alpine

ENV TZ Asia/Tokyo

COPY ./docker_setting/nginx/default.conf /etc/nginx/conf.d/default.conf

# ECS Execでaws cliを使う場合はコメントをはずす
#RUN apt install python3-pip -f
#RUN pip3 install awscli --upgrade --user
#RUN apt  install awscli -f

WORKDIR /app

COPY ./ ./
docker_setting/nginx/default.conf
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;
    root /app/public;

    client_body_buffer_size 64m;
    client_max_body_size 64m;

    #sendfile              on;
    tcp_nopush            on;
    tcp_nodelay           on;
    #keepalive_timeout     65;
    server_tokens        off;
    types_hash_max_size 8192;

    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options SAMEORIGIN;
    add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload';

    proxy_temp_path /var/cache/nginx/proxy_temp;
    proxy_max_temp_file_size 1024m;
    proxy_buffers 8 64m;
    proxy_buffer_size 64m;

    fastcgi_buffer_size 64m;
    fastcgi_buffers 50 64m;
    fastcgi_busy_buffers_size 64m;
    fastcgi_temp_file_write_size 64m;

    index index.php index.html;

    charset utf-8;

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        root         /app/public;
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

PHPコンテナ用の各ファイルの内容

以下はPHP8.2のイメージ作成用です。
PHP8.2以上にしたい場合には、適宜変更をお願いします。

docker_setting/php/Dockerfile
FROM php:8.2-fpm-alpine

ENV TZ Asia/Tokyo
ENV LANG C.UTF-8
ENV LANGUAGE ja_JP

RUN apk update && \
    apk add --update --no-cache libjpeg-turbo-dev libpng-dev git libzip-dev mysql-client && \
	docker-php-ext-install pdo_mysql zip && \
    docker-php-ext-configure gd --with-jpeg && \
    docker-php-ext-install -j$(nproc) gd

RUN git clone --branch release/5.3.6 https://github.com/phpredis/phpredis.git /usr/src/php/ext/redis 
RUN docker-php-ext-install redis
		
COPY ./docker_setting/php/php.ini /usr/local/etc/php/php.ini
COPY ./docker_setting/php/www.conf /usr/local/etc/php-fpm.d/www.conf

COPY --from=composer:2.2 /usr/bin/composer /usr/bin/composer

WORKDIR /app
COPY ./ ./

# php8.2に対応するように以下だけアップデート
RUN composer require nette/schema:"^1.2"

RUN composer install --optimize-autoloader --no-dev

RUN docker-php-ext-install opcache

RUN chmod -R 777 /app/storage

COPY ./docker_setting/php/cache.sh /usr/local/bin/cache.sh
RUN chmod 777 /usr/local/bin/cache.sh
ENTRYPOINT  ["/usr/local/bin/cache.sh"]
docker_setting/php/php.ini
zend.exception_ignore_args = off
expose_php = on
max_execution_time = 30
max_input_vars = 1000
upload_max_filesize = 512M
post_max_size = 512M
memory_limit = 1GB
max_file_uploads = 300
error_reporting = E_ALL
display_errors = on
display_startup_errors = on
log_errors = on
error_log = /var/log/php/php-error.log
default_charset = UTF-8
expose_php = off
extension=pdo_mysql

[Date]
date.timezone = Asia/Tokyo

[mysqlnd]
mysqlnd.collect_memory_statistics = on

[Assertion]
zend.assertions = 1

[mbstring]
mbstring.language = Japanese

[opcache]
opcache.enable=1
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.max_accelerated_files=10000
opcache.memory_consumption=192
opcache.max_wasted_percentage=10
opcache.interned_strings_buffer=16
opcache.fast_shutdown=1
docker_setting/php/www.conf
[www]
user = www-data
group = www-data

listen = 127.0.0.1:9000
pm = static
pm.max_children = 150
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 8192

;メモリリーク対策
env[NSS_SDB_USE_CACHE] = "YES"
env[TMPDIR] = "/dev/shm"
docker_setting/php/cache.sh
#!/bin/sh

# キャッシュクリア
php /app/artisan event:clear
php /app/artisan view:clear
php /app/artisan route:clear
php /app/artisan config:clear
php /app/artisan clear-compiled

# キャッシュ生成
php /app/artisan config:cache
php /app/artisan route:cache
php /app/artisan view:cache

# php-fpm起動
php-fpm

6章. AWSリソースの用意-後編(DEV環境のみ)

RDSの作成

AWSコンソールから、RDS(MySQL)を作成します。
今回は無料利用枠で最小構成で作成します。

rds_1a.png
rds_1b.png
rds_1c.png
rds_1d.png
rds_1e.png
rds_1f.png
rds_1g.png
rds_1h.png
rds_1i.png
rds_1j.png

CodeBuildにアタッチするポリシーの作成

AWSコンソールから、IAMポリシーを作成します。
ポリシーエディタの「JSON」タブをクリックして、ポリシーを作成します。

policy1.png

ポリシーエディタには以下のポリシーを張り付けて、ページ下部の「次へ」をクリック。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Resource": [
                "arn:aws:logs:ap-northeast-1:<AWSアカウント>:log-group:/aws/codebuild/build-docker",
                "arn:aws:logs:ap-northeast-1:<AWSアカウント>:log-group:/aws/codebuild/build-docker:*"
            ],
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ]
        },
        {
            "Effect": "Allow",
            "Resource": [
                "arn:aws:s3:::*"
            ],
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:GetObjectVersion"
            ]
        },
        {
            "Action": [
                "ecr:BatchCheckLayerAvailability",
                "ecr:CompleteLayerUpload",
                "ecr:GetAuthorizationToken",
                "ecr:InitiateLayerUpload",
                "ecr:PutImage",
                "ecr:UploadLayerPart"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}

ポリシー名に「myapp_ecr_access_policy」を入力して、ページ下部の「ポリシーの作成」をクリック。

policy2.png

ECRの作成

「phpコンテナdevelop」用のECRを作成

AWSコンソールから、ECRを作成します。
ECRの画面から「作成」をクリックし、リポジトリ名に「dev-myapp-php」を入力して、画面右下の「作成」をクリックします。

ecr1.png

「nginxコンテナdevelop」用のECRを作成

同じように、ECRの画面から「作成」をクリックし、リポジトリ名に「dev-myapp-nginx」を入力して、画面右下の「作成」をクリックします。

ecr2.png

ライフサイクルルールの設定

ECRが溜まるのを防止する為に、ライフサイクルルールを設定します。
※ECRが無くなるとリビジョンを元に戻せなくなるので、運用ルールは事前に決めておく必要があります。

まず、ライフサイクルルールを設定したいECRを選択して、「アクション」>「ライフサイクルポリシー」を選択します。

ecr3.png

「ルールの作成」をクリックします。

ecr4.png

下図のように、「イメージをプッシュしてから」の日数か、「数値を超えるイメージ数」かを選択してから「数値」を入力します。

ecr5.png

CodeBuildの作成

AWSコンソールから、CodeBuildを作成します。
CodeBuild画面から「プロジェクトを作成する」をクリックします。

下図のように入力します。

ソースのバケット名には、前述で作成した「myapp-<ユニークな値>-git」を選択してください。

S3オブジェクトキーまたはS3フォルダの「Gitリポジトリ」には、使っているGitリポジトリを指定してください。

環境の「イメージ」には、phpをサポートしている「x86_64-standard:5.0」を指定します。

ロール名は「codebuild-dev-myapp-service-role」のように自動入力されます
以降利用するので控えておいてください。

環境変数は以下を設定してください。

  • AWS_DEFAULT_REGION:ap-northeast-1
  • AWS_ACCOUNT_ID:AWSアカウントID
  • ENV_NAME:dev
  • IMAGE_PHP_REPO_NAME: dev-myapp-php ※前述のECRと同じ
  • IMAGE_NGINX_REPO_NAME: dev-myapp-nginx ※前述のECRと同じ
  • CONTAINER_PHP_NAME: dev-myapp-php-container
  • CONTAINER_NGINX_NAME: dev-myapp-nginx-container
  • DOCKERHUB_USER:DockerHubのユーザー(メルアドではない)
  • DOCKERHUB_PASS:DockerHubのパスワード

設定が終わったら、「ビルドプロジェクトを作成する」ボタンをクリックしてください。

codebuild1.png
codebuild2.png
codebuild3.png
codebuild4.png
codebuild5.png
codebuild6.png
codebuild7.png

IAMロールの編集

上記で自動作成された「codebuild-dev-myapp-service-role」に、既に作成済ポリシー「myapp_ecr_access_policy」をアタッチします。

iam_attach1.png

CodeBuildに必要なファイルを設置

Laravelのプロジェクトルートに以下の2つのファイルを設置します。

buildspec-dev.yml
version: 0.2

phases:
  install:
    runtime-versions:
      php: 8.2
      # nodejs: 22
    commands:
      # 全フェーズでエラー時に即終了
      - set -e

      # システム依存関係をインストール
      - echo Install system dependencies on `date`
      - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
      - npm install -g npm

  pre_build:
    commands:
      # イメージのタグ設定
      - IMAGE_TAG=`sh getTag.sh`
      - export IMAGE_TAG
      - echo ${IMAGE_TAG}

      # ECR にログイン
      - echo Logging in to Amazon ECR on `date`
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com

      # Docker Hub にログイン
      - echo Logging in to Docker Hub on `date`
      - echo ${DOCKERHUB_PASS} | docker login -u ${DOCKERHUB_USER} --password-stdin

      # Laravel のセットアップ
      - echo Set up Laravel on `date`
      - composer install --no-interaction --prefer-dist --optimize-autoloader
      - php artisan key:generate --env=testing
      - php artisan migrate --env=testing --force
      - npm install && npm run build

  build:
    commands:
      # Laravel Breeze のユニットテスト
      - echo Run Laravel Breeze tests on `date`
      - ./vendor/bin/phpunit

      # PHPのイメージ作成
      - echo Build PHP started on `date`
      - echo Building the PHP Docker image on `date`
      - docker build -t ${IMAGE_PHP_REPO_NAME} -f ./docker_setting/php/Dockerfile .
      - docker tag ${IMAGE_PHP_REPO_NAME}:latest  ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_PHP_REPO_NAME}:${IMAGE_TAG}

      # NGINXイメージ作成
      - echo Build NGINX started on `date`
      - echo Building the NGINX Docker image on `date`
      - docker build -t ${IMAGE_NGINX_REPO_NAME} -f ./docker_setting/nginx/Dockerfile .
      - docker tag ${IMAGE_NGINX_REPO_NAME}:latest  ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_NGINX_REPO_NAME}:${IMAGE_TAG}

  post_build:
    commands:
      - echo Build completed on `date`

      # PHPのイメージをECRにプッシュ
      - echo Pushing the PHP Docker image...
      - docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_PHP_REPO_NAME}:${IMAGE_TAG}

      # NGINXのイメージをECRにプッシュ
      - echo Pushing the NGINX Docker image...
      - docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_NGINX_REPO_NAME}:${IMAGE_TAG}

      - printf '[{"name":"%s","imageUri":"%s"},{"name":"%s","imageUri":"%s"}]' ${CONTAINER_PHP_NAME} ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_PHP_REPO_NAME}:${IMAGE_TAG} ${CONTAINER_NGINX_NAME} ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_NGINX_REPO_NAME}:${IMAGE_TAG} > ${ENV_NAME}_imagedefinitions.json
artifacts:
  files:
    # S3のアーティファクトフォルダにアップロード(CodePipelineで設定)
    - ${ENV_NAME}_imagedefinitions.json
getTag.sh
#!/bin/bash

# 日付をナノ秒まで取得
ymdhisn=`TZ=JST-9 date +'%Y%m%d%H%M%S_%N'`
echo $ymdhisn

ヘルスチェック用ファイルの設置

Laravelのプロジェクトルートの public フォルダ内に以下のファイルを設置します。

/public/healthy.html
Healthy

git push後に、CodeBuildの画面右上にある「ビルドを開始」をクリックして、ECRのイメージが作成されていればOK。

Laravelの .envファイルを S3に配置

Laravelの.envファイルを、dev.env にコピーします。
その後、dev.envの内容をDEV環境用に以下のように修正して S3バケット「myapp-<ユニークな値>-config」バケットの dev フォルダにアップロードしてください。ECSデプロイ時にこの dev.env を参照します。
※アップロード後はローカルにある dev.env は Git にコミットしないよう削除してください。

dev.env
APP_NAME=Laravel
APP_ENV=develop # develop
 :
APP_DEBUG=true
APP_URL=<developのドメイン>       # Route53で取得したドメイン(https://~)
ASSET_URL=<developのドメイン>     # Route53で取得したドメイン(https://~)

APP_LOCALE=ja                    # jaに変更
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ja_JP           # ja_JPに変更
 :
 :
DB_CONNECTION=mysql
DB_HOST=<RDSのホスト名>           # RDSのホスト名
DB_PORT=3306
DB_DATABASE=laravel              # RDS構築後にCREATE DATABASE で作成したDB
DB_USERNAME=admin                # RDSのユーザー名
DB_PASSWORD=xxxxxxxxx            # RDSのパスワード
DB_CHARSET=utf8mb4               # UTF-8とする
DB_COLLATION=utf8mb4_unicode_ci  # UTF-8とする
 :
 :

ECS用のIAMロール作成

ecsInstanceRole の作成

AWSコンソールから、ECS用のIAMロール「ecsInstanceRole」を作成します。
ロール作成画面で、以下のように選択して「次へ」をクリック

iam_ecs1a.png

以下が表示されていることを確認して「次へ」クリック

iam_ecs1b.png

ロール名に「ecsInstanceRole」と入力して「ロールを作成」をクリックしてください。

iam_ecs1c.png

ecsRole の作成

続いて、ECS用のIAMロール「ecsRole」を作成します。
ロール作成画面で、以下のように選択して「次へ」をクリック

iam_ecs2a.png

以下が表示されていることを確認して「次へ」クリック

iam_ecs2b.png

ロール名に「ecsRole」と入力して「ロールを作成」をクリックしてください。

iam_ecs2c.png

ecsAutoScalingRole の作成

続いて、ECS用のIAMロール「ecsAutoScalingRole」を作成します。
ロール作成画面で、以下のように選択して「次へ」をクリック

iam_ecs3a.png

以下が表示されていることを確認して「次へ」クリック

iam_ecs3b.png

ロール名に「ecsAutoScalingRole」と入力して「ロールを作成」をクリックしてください。

iam_ecs3c.png

ecsTaskExecutionRole の作成

続いて、ECS用のIAMロール「ecsTaskExecutionRole」を作成します。
ロール作成画面で、以下のように選択して「次へ」をクリック

iam_ecs4a.png

大量に表示されるので「task」文字で絞り込み、以下が表示されていることを確認する。
また、S3FullAccess も付与 して「次へ」クリック

iam_ecs4b.png

ロール名に「ecsTaskExecutionRole」と入力

iam_ecs4c.png

S3FullAccess も付与されていることを確認したら「ロールを作成」をクリックしてください。

iam_ecs4d.png

ECSクラスターの作成

AWSコンソールから ECSクラスター を作成します。
名前には「dev-myapp-cluster」と入力して、インフラストラクチャには「AWS Fargate」を選択してください。

cluster.png

ECSのタスク定義の作成

AWSコンソールから タスク定義 を作成します。
「新しいタスク定義の作成」ボタンをクリックし、下図のように入力して、画面右下の「作成」をクリックします。

注意点は以下です。

  • コンテナ-1の「イメージURL(赤枠)」には、ECR(dev-myapp-php)から任意のイメージのURLをコピーして貼り付けてください
  • コンテナ-1の「ロケーション(赤枠)には、前述で dev.env をアップロードした S3のARNを入力してください
  • コンテナ-1のログ収集の awslogs-group の値(赤枠)には、/ecs/dev-myapp-php になるよう変更してください
  • コンテナ-2の「イメージURL(赤枠)」には、ECR(dev-myapp-nginx)から任意のイメージのURLをコピーして貼り付けてください
  • コンテナ-2のログ収集の awslogs-group の値(赤枠)には、/ecs/dev-myapp-nginx になるよう変更してください

taskdef1.png
taskdef2.png
taskdef3.png
taskdef4.png
taskdef5.png
taskdef6.png
taskdef7.png
taskdef8.png
taskdef9.png

ECSサービスの作成

AWSコンソールから サービス を作成します。
dev-myapp-cluster を選択し、「サービス」タブをクリック、「作成」ボタンをクリックします。
service1.png

下図のように入力して、「作成」ボタンをクリックします。

service2.png
service3.png
service4.png
service5.png
service6.png
service7.png
service8.png

CodePipelineの作成

AWSコンソールから CodePipeline を作成します。
まずは「作成オプション」を選択します。
下図のように「カスタムパイプラインを構築する」を選択し、「次に」をクリック。

codepipeline_1.png

次に「パイプラインの設定」を選択します。
下図のように入力して「次に」をクリック。
※バケットには前述の「myapp-<ユニークな値>-artifact」を選択してください。

codepipeline_2a.png
codepipeline_2b.png

次に「ソースステージ」を追加します。
下図のように入力して「次に」をクリック。
※バケットには前述の「myapp-<ユニークな値>-git」を選択してください。
※S3オブジェクトキーには「Gitリポジトリ名/develop.zip」を入力してください。

codepipeline_3.png

次に「ビルドステージ」を追加します。
下図のように入力します。
ちなみに、このステージでUnitテストも実施します。
その為Unitテストが失敗したらビルド全体も停止するように、赤枠にある「ステージ障害時の自動再試行を有効にする」のチェックを外してください。
入力したら「次に」をクリックします。

codepipeline_4a.png
codepipeline_4b.png

次の「テストステージ」はスキップしてください(※テストはビルドステージで実施するため)

codepipeline_5.png

次に「デプロイステージ」を追加します。
下図のように入力してください。
「イメージ定義ファイル」には、CodeBuild(buildspec_dev.yml)で作成した「dev_imagedefinitions.json」を指定します。このファイルの内容を見て、ECSコンテナを作成します)
入力したら「次に」をクリックします。

codepipeline_6a.png
codepipeline_6b.png

最後に確認画面が表示されますので、問題なければ画面下部の「パイプラインを作成する」をクリックしてください。

CodePipelineの開始直後は、以下のように自動でパイプラインが実行されます。

codepipeline_7.png

以下のように、Source → Build → Deploy ステージが全て成功したらOKです。

codepipeline_8.png

Git の develop ブランチに Git Push してみて、上記のようにCodePipeline が実行することも確認してください。

ECS Exec の設定

Fargate の Docker コンテナにログインできるように、ECS Exec の設定をします。
まず最初に、ローカル環境に Session Manager プラグインをインストールします。

Windowsの方は、こちら を参考にインストーラーをダウンロードし、インストーラーを起動して進めてください。

Macの方は、こちら を参考にインストールを進めてください。

インストールが完了したら「session-manager-plugin」コマンドを実行して、以下のように成功しているか確認してください。

$ session-manager-plugin
The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.

次に、VPCエンドポイントを追加します。

サブネットには private を指定してください。

1_vpce_ssmmessages_a.png
vpce-log_2.png
vpce-log_3.png

次に ECS Exec 用のポリシーを作成します。
IAM ポリシー画面から「ポリシーの作成」画面を開き、下図のように「JSON」を選択し、ポリシーエディタに以下のコードを貼り付けます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssmmessages:CreateControlChannel",
                "ssmmessages:CreateDataChannel",
                "ssmmessages:OpenControlChannel",
                "ssmmessages:OpenDataChannel"
            ],
            "Resource": "*"
        }
    ]
}

貼り付けたら、画面下部の「次へ」をクリックします。

ecsexec_0a.png

ポリシー名に「ecsFargateExecPolicy」と入力して、画面右下の「ポリシーの作成」をクリックしてポリシーを作成します。

ecsexec_0b.png

次に、前述で作成した「ecsTaskExecutionRole」ロールに上記で作成したポリシー「ecsFargateExecPolicy」を追加します。

IAMロール画面から「ecsTaskExecutionRole」を選択して、「許可を追加」の「ポリシーをアタッチ」をクリックしてください。

ecsexec_1.png

「ecsFargateExecPolicy」を検索して選択し、画面右下の「許可を追加」をクリックします。

ecsexec_2.png

次に、以下のコマンドを実行し、ECS Exec フラグを有効化し、サービスに対して ECS Exec の許可を与えます。
※AWS CLIで実施しますので、必要ならプロファイルも指定してください。

aws ecs update-service --region <リージョン名> --cluster <クラスター名> --service <サービス名> --enable-execute-command

例)aws ecs update-service --region ap-northeast-1 --cluster dev-myapp-cluster --service dev-myapp-service --enable-execute-command

実行すると長文の結果が表示されます。以下のように「enableExecuteCommand」の値が「true」になっていればOKです。

            :
            :
                ],
                "assignPublicIp": "DISABLED"
            }
        },
        "healthCheckGracePeriodSeconds": 0,
        "schedulingStrategy": "REPLICA",
        "deploymentController": {
            "type": "ECS"
        },
        "createdBy": "arn:aws:iam::792130004390:user/azito-tech",
        "enableECSManagedTags": true,
        "propagateTags": "NONE",
        "enableExecuteCommand": true,  # true になっていればOK
        "availabilityZoneRebalancing": "ENABLED"
    }
}

最後、現在起動中のタスクには、まだ ECS Exec のサービスが適用されていないので、サービスを更新します。

クラスター「dev-myapp-cluster」を選択し、「サービス」タブから「dev-myapp-service」を選択して、「更新」ボタンをクリックします。

ecsexec_3.png

「新しいデプロイの強制」を選択して、画面右下の「更新」をクリックしてください。

ecsexec_4.png

下図の赤枠内のように、デプロイが進みます。

ecsexec_5.png

デプロイがステータスが進行中になります。

ecsexec_6.png

デプロイのステータスが「成功」になれば完了です。

ecsexec_7.png

デプロイが完了したら、以下のコマンドでECSにログインができます。

aws ecs execute-command --region <リージョン名> --cluster <クラスター名> --task <タスクID> --container <コンテナ名> --interactive --command "/bin/sh"

例)aws ecs execute-command --region ap-northeast-1 --cluster dev-myapp-cluster --task 95d8ab3a92184ffda2c2097795874d2a --container dev-myapp-php-container --interactive --command "/bin/sh"

ECSログイン後、以下コマンドでマイグレーションを実施します。

/app # php artisan migrate

マイグレーションが成功したらブラウザのURL欄に、事前に取得したドメイン名を入力してください。
Laravelの画面が表示できたら成功です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?