AWS CloudWatch Logs のログデータを S3 に配信する(Kinesis Data Firehose)

この記事は個人ブログと同じ内容です

www.ritolab.com


CloudWatch Logs では、AWS のコンソールからログデータを S3 にエクスポートできます。

f:id:ro9rito:20210118223828p:plain

全ログデータを CloudWatch 側で保持して、ログデータを抽出する必要があった場合は都度 S3 にエクスポートして使うっていう運用ならこれで問題ないと思います。

別のケースとして、以下のような場合は、CloudWatch Logs から S3 に定期的にログを自動で配信する仕組みが欲しくなります。

  • CloudWatch Logs のログデータを活用するために全てのデータを S3 にも溜めたい(データレイク的なあれ)
  • CloudWatch Logs 側のログデータ保持は限定的(数カ月間だけとか)にするけど、過去全てのログデータは S3 に保管しておきたい(Glacier で氷漬けにする的なあれ)

今回は、CloudWatch Logs のログデータを自動で S3 に配信してみます。

Kinesis Data Firehose

CloudWatch Logs のログデータを S3 に配信するためには、Kinesis Data Firehose を使うとサクッといけます。

Amazon Kinesis Data Firehose

https://aws.amazon.com/jp/kinesis/data-firehose/

Amazon Kinesis Data Firehose は、ストリーミングデータをデータレイクやデータストア、分析サービスに確実にロードする最も簡単な方法を提供するサービスです。
ストリーミングデータを取り込んで変換し、Amazon S3Amazon Redshift、Amazon Elasticsearch Service、汎用 HTTP エンドポイント、さらに Datadog、New Relic、MongoDB、Splunk のようなサービスプロバイダーに配信できます。

流れとしては以下になります。

  1. CloudWatch Logs から Kinesis Data Firehose にログデータを送信
  2. Kinesis Data Firehose から S3 にログデータを配信

サブスクリプションフィルタ

CloudWatch Logs にはサブスクリプションフィルタという機能があり、これを設定する事で Kinesis Data Firehose にログデータを送信できるようになります。

f:id:ro9rito:20210118224036p:plain

しかしながらキャプチャを見ての通り、AWS コンソールからは Elasticsearch もしくは Lambda のサブスクリプションフィルタしか作成できません。(2021年1月16日現在)

なので、Kinesis へのサブスクリプションフィルタを作成するためには AWS CLI などを使用する必要があります。

そういう理由もあり今回は AWS コンソール画面ではなく Terraform(AWS に対応したインフラオーケストレーションツール)を使って一連の仕組みを構築していこうと思います。

Terraform

https://www.terraform.io/

S3 サブスクリプションフィルタ以外は AWS コンソール画面からも作成できるので、この記事の前半はすっ飛ばせます。

ロググループと S3 バケットの作成

main.yml

# CloudWatch Logs - LogGroup
resource "aws_cloudwatch_log_group" "my_test" {
  name = "my-test"
}

# S3 - Bucket
resource "aws_s3_bucket" "cloud_watch_logs" {
  bucket = "my-test-logs"
  acl    = "private"
}

Kinesis Data Firehose 配信ストリームの作成

まずは S3 への配信が行えるように Kinesis 用の IAM Role を作成します。

main.yml

# CloudWatch Logs - LogGroup
# IAM Role - for Kinesis
resource "aws_iam_role" "kinesis_data_firehose_send_log_to_s3" {
  name               = "tf-KinesisFirehoseServiceRole-my-test"
  assume_role_policy = file("aws_iam_role_kinesis_data_firehose.json")
}

# IAM Policy - for Kinesis
resource "aws_iam_policy" "kinesis_data_firehose_send_log_to_s3" {
  name        = "tf-KinesisFirehoseServicePolicy-my-test"
  policy      = templatefile("aws_iam_policy_kinesis_data_firehose", {
    aws_id              = var.aws_id
    region              = var.region
    bucket_arn          = aws_s3_bucket.my_test.arn
    cloudwatch_logs_arn = aws_cloudwatch_log_group.my_test.arn
  })
}

# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "kinesis_data_firehose_send_log_to_s3" {
  role       = aws_iam_role.kinesis_data_firehose_send_log_to_s3.name
  policy_arn = aws_iam_policy.kinesis_data_firehose_send_log_to_s3.arn
}

読み込んでいるポリシーのファイルはそれぞれ以下

aws_iam_role_kinesis_data_firehose.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "firehose.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:GetBucketLocation",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads",
                "s3:PutObject"
            ],
            "Resource": [
                "${bucket_arn}",
                "${bucket_arn}/*"
            ]
        },
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "logs:PutLogEvents"
            ],
            "Resource": [
                "${cloudwatch_logs_arn}"
            ]
        },
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "kinesis:DescribeStream",
                "kinesis:GetShardIterator",
                "kinesis:GetRecords",
                "kinesis:ListShards"
            ],
            "Resource": "arn:aws:kinesis:${region}:${aws_id}:stream/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
        },
        {
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "arn:aws:kms:${region}:${aws_id}:key/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
            ],
            "Condition": {
                "StringEquals": {
                    "kms:ViaService": "kinesis.${region}.amazonaws.com"
                },
                "StringLike": {
                    "kms:EncryptionContext:aws:kinesis:arn": "arn:aws:kinesis:${region}:${aws_id}:stream/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%"
                }
            }
        }
    ]
}

Kinesis Data Firehose 側の配信ストリームを作成します。

main.yml

# Kinesis Data Firehose - delivery stream
resource "aws_kinesis_firehose_delivery_stream" "s3_stream" {
  name        = "tf-my-test-send-log-from-cwl-to-s3-stream"
  destination = "extended_s3"

  extended_s3_configuration {
    bucket_arn         = aws_s3_bucket.my_test.arn
    role_arn           = aws_iam_role.kinesis_data_firehose_send_log_to_s3.arn
    buffer_size        = 10  # MB
    buffer_interval    = 300 # second
    compression_format = "UNCOMPRESSED"
    s3_backup_mode     = "Disabled"
  }
}
  • destination には S3 を宛て先とするので extended_s3 を指定します
  • buffer_size は MB 単位です。今回の設定の場合は、受信バッファーが 10MB に到達したら buffer_interval に関係無く S3 に配信します。
  • buffer_interval は 秒単位です。今回の設定の場合は、5 分経過したら(=5分おきに)buffer_size に関係無く S3 に配信します。
  • compression_format は圧縮についての設定ですが、CloudWatch Logs のサブスクリプションフィルタを介して Kinesis に送信されるログは Base64 エンコードされて gzip に圧縮されるので、ここでの圧縮は不要です。

サブスクリプションフィルタのための IAM Role 作成

CloudWatch Logs のサブスクリプションフィルタを作成するにあたり、必要なロールを作成しておきます。

main.yml

# IAM Role - for Cloud Watch Logs
resource "aws_iam_role" "cloud_watch_logs_send_log_to_kinesis" {
  name               = "tf-CloudWatchLogsServiceRole-my-test-send-log-to-kinesis"
  assume_role_policy = templatefile("aws_iam_role_cloud_watch_logs_send_log_to_kinesis.json", {
    region = var.region
  })
}

# IAM Role Policy - Cloud Watch Logs
resource "aws_iam_policy" "cloud_watch_logs_send_log_to_kinesis" {
  name        = "tf-CloudWatchLogsServicePolicy-my-test-send-log-to-kinesis"
  policy      = templatefile("aws_iam_policy_cloudwatch_logs_send_log_to_kinesis.json", {
    kinesis_data_firehouse_arn = aws_kinesis_firehose_delivery_stream.s3_stream.arn
  })
}

# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "cloud_watch_logs_send_log_to_kinesis" {
  role       = aws_iam_role.cloud_watch_logs_send_log_to_kinesis.name
  policy_arn = aws_iam_policy.cloud_watch_logs_send_log_to_kinesis.arn
}

読み込んでいるポリシーのファイルはそれぞれ以下

aws_iam_role_cloud_watch_logs_send_log_to_kinesis.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "logs.${region}.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

aws_iam_policy_cloudwatch_logs_send_log_to_kinesis.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "firehose:PutRecord",
                "firehose:PutRecordBatch"
            ],
            "Resource": [
                "${kinesis_data_firehouse_arn}"
            ]
        }
    ]
}

ここまでサブスクリプションフィルタ設定の前の準備として Terraform での設定を書きましたが、これらは AWS コンソール画面からでも作成できます。

次の S3 サブスクリプションフィルタ作成が、前述の通り AWS CLI かその他 Terraform 等のツールで行う必要があります。

CloudWatch Logs S3 サブスクリプションフィルタの作成

S3 サブスクリプションフィルタを作成します。

main.yml

## Subscription Filter
resource "aws_cloudwatch_log_subscription_filter" "s3" {
  name            = "tf-my-test-s3-subscription-filter"
  role_arn        = aws_iam_role.cloud_watch_logs_send_log_to_kinesis.arn
  log_group_name  = aws_cloudwatch_log_group.my_test.name
  destination_arn = aws_kinesis_firehose_delivery_stream.s3_stream.arn
  filter_pattern  = ""
}

filter_pattern(フィルターパターン)について、ここには何も記述していないですが、この場合は全てのログイベントに一致(=全てのログが対象)します。

これを例えば、「ELB のヘルスチェックは除外したい」の場合は以下のように設定する事で kinesis への送信を行わないようにできます。

filter_pattern  = "-\"ELB-HealthChecker/2.0\""

フィルターとパターンの構文

docs.aws.amazon.com

動作確認

S3 サブスクリプションフィルタを CloudWatch Logs に設定できたので、S3 を確認してみます。

f:id:ro9rito:20210118224616p:plain

S3 にログが配信された事が確認できました。

ちなみにこれを S3 Select で見てみると

{
    "messageType": "DATA_MESSAGE",
    "owner": "xxxxxxxx",
    "logGroup": "/my-test",
    "logStream": "php/sample/4478f375fl19400daf513cb5b0cd77a9",
    "subscriptionFilters": [
        "tf-my-test-s3-subscription-filter"
    ],
    "logEvents": [
        {
            "id": "123456789...",
            "timestamp": 1610777406264,
            "message": "[2021-01-16 15:10:06] local.INFO: message test. {\"type\":\"info\"} \n"
        },
        {
            "id": "123456789...",
            "timestamp": 1610777406264,
            "message": "[2021-01-16 15:10:06] local.WARNING: message test. {\"type\":\"warning\"} \n"
        }
    ]
},
{
    "messageType": "DATA_MESSAGE",
    "owner": "xxxxxxxx",
    "logGroup": "/my-test",
    "logStream": "nginx/sample/44k86375f03b410daf513p2xb0cd77a9",
    "subscriptionFilters": [
        "tf-my-test-s3-subscription-filter"
    ],
    "logEvents": [
        {
            "id": "123456789...",
            "timestamp": 1610777410150,
            "message": "12.3.45.57 - - [16/Jan/2021:15:10:10 +0900] \"GET /sample/1 HTTP/1.1\" 200 17486 \"-\" \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36\" \"123.45.67.89\""
        }
    ]
}

問題なく配信されてきているようですね。

コストどんな感じだろう

とはいえ、kinesis もお高いんでしょう?という気持ちになったので、CloudWatch ログデータ保存と S3 のデータ保存、そして Kinesis Data Firehose の取り込みデータ料金を見てみました。(2021/01/16時点・東京リージョン)

  • CloudWatch Logs(ログデータ保存)
    • 0.033 USD/GB
  • S3 標準(ストレージ 〜 50TB)
    • 0.025 USD/GB
  • S3 Glacier(〜 50TB)
    • 0.005 USD/GB
  • Kinesis Data Firehose(取込データ量 最初の 500 TB/月)
    • 0.036USD/GB

aws.amazon.com

aws.amazon.com

aws.amazon.com

「ログデータはサービスが終了するまで永久に持ち続ける」とした場合に、まあふつうに考えたら Kinesis 稼働分のコストもあるので CloudWatch Logs のみを使い続けた方がコストは安いようにも思えるけど、例えば、以下のような使い方をした場合

  • CloudWatch Logs
    • 3 ヶ月間だけログデータを保持
  • Kinesis Data Firehose
    • 常時 S3 へログデータを配信
  • S3
    • kinesis から配信されたログデータは 標準 に格納後、3 ヶ月後に Glacier へ移動

この構成だと、7 ヶ月後にはコストがトントンになって、8 ヶ月後には CloudWatch Logs のみで運用するよりも月額のコストが安くなった。(参考程度なのできちんとした試算は各自でお願いします)

f:id:ro9rito:20210118224837p:plain

永久に持ち続けないとしても年単位では持つと思うので、そう考えてもお得かな。ログデータはサービスが動いている以上永延に溜まるし、Glacier 様々という結論に至りました。(あんまり本筋とは関係ない結論)

まとめ

AWS にログデータを溜める時には Glacier を上手く活用しましょう。

ともあれ CloudWatch Logs から S3 へログデータをそっと配信できたのでこれにて終了です。

Twilioを使ってLaravelのSMS通知をする

※ 本記事はZennに投稿したものを転載したものになります。よかったらZennの方の評価もよろしくおねがいします。

zenn.dev

こんにちはみなさん

うん、まあ、ちょっとしたアクシデントがありまして、Laravelが公式提供しているVonage Communication Apiを使ったSMS通知が使えなくなってしまったのですよね。 というわけで、別のSMSサービスとしてTwilioを使うようにします。

ではやっていきましょう。

問題設定

今回はユーザへのSMS通知を、Twilioを使って実現してみましょう。 方針は以下の通りです。

  • SMS通知チャンネルを作る
  • 通知チャンネルで使用するクライアントをTwilioにする。
  • 実際に送信する

結局どうしたかというと、パッケージを作成して、簡単にtwilioを入れられるようにしたって感じです。

Quick Start

どうやっているのかを知りたい人はほとんどおらんと思いますので、どうやれば動かせるかを先に解説しちゃいます。

パッケージインストール

まず、パッケージとsdkを導入します。

composer require niisan/laravel-sms-notification twilio/sdk

個人的な趣向により、sdkを直接はパッケージに依存させていないのです。

ついで、設定ファイルを作ります。

php artisan vendor:publish --provider "Niisan\Notification\SmsNotificationServiceProvider"

設定ファイルの中身は、基本的には環境変数で外から入れることができます。

SMS_DRIVER=twilio
SMS_DEFAULT_FROM='+1111222233'
TWILIO_SID=***********************
TWILIO_TOKEN=***********************

SID と TOKEN はTwilioに登録したときにもらえます。 また、FROMはTwilioにログインしたときのダッシュボードから、無料の電話番号を発行して、それを使います。

通知の方法

SMS通知をたくさんうつ場合は通知対象のモデルに以下のメソッドをはやしておきます。

<?php
// 省略
class User extends Authenticatable
// 省略
    public function routeNotificationForSms()
    {
        return $this->phone_number;
    }

細かいことですが、Twilioでは国際電話番号の規格を使用していて、+から必要になるようです。(ex. +1122223333 )

次にNotificationを作ります。

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use Niisan\Notification\SmsMessage;

class SmsNotification extends Notification
{
    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return ['sms'];
    }

    public function toSms($notifiable): SmsMessage
    {
        return (new SmsMessage)
                ->unicode()
                ->body('hello world! こんにちは!' . $notifiable->name);
    }
}

viaメソッドでsmsを追加し、toSmsメソッドで、メッセージクラスを返します。 SmsMessageオブジェクトで、メッセージに日本語を含める場合はunicodeメソッドで、マルチバイトをオンにしておきます。

あとは、普通にnotifyするだけです。

$user->notify(new SmsNotification);

おまけ

送信先や送信元をNotificationで変更することもできます。

        return (new SmsMessage)
                ->unicode()
                ->body('hello world! こんにちは!' . $notifiable->name)
        ->to('+1122223333')
        ->from('+2233334444')

こうすると、notifiableやdefault_fromに設定してある電話番号を無視して、ここで設定した電話番号を使うようにします。

SMS通知チャンネルを作る

ここからはパッケージの実装を説明します。多くの人にはいらんですが、興味のある方だけ見て行ってくださいませ。

公式のパッケージではないので、Laravelの通知チャンネルを拡張する必要があるわけですが、公式のマニュアルがちょっと不親切というか、interface用意しておいてほしいのよね。 https://laravel.com/docs/8.x/notifications#custom-channels

チャンネルの拡張

SMSの通知チャンネルをこんな風に作っておきます。

<?php
namespace Niisan\Notification;

use Illuminate\Notifications\Notification;
use Niisan\Notification\Contracts\SmsClient;
use RuntimeException;

class SmsChannel
{

    /** @var SmsClient $client */
    private $client;

    public function __construct(SmsClient $client)
    {
        $this->client = $client;
    }

    public function send($notifiable, Notification $notification)
    {
        $message = $notification->toSms($notifiable);
        if (!$message->to) {
            if (!method_exists($notifiable, 'routeNotificationForSms')) {
                throw new RuntimeException('A notifiable object must have "routeNotificationForSms" method.');
            }
            $message->to($notifiable->routeNotificationForSms());
        }

        $message->from(config('sms-notification.default_from'));
        
        $this->client->send($message);
    }
}

NotificationオブジェとのtoSmsメソッドからメッセージオブジェクトを取得し、必要であればtoやfromを入れて、クライアントに投げる形式になっています。 基本、クライアントはTwilio以外のも考えられると思ったので、他にも入れられるようにしてあります。

SmsClientはinterfaceになっており、public function send(SmsMessage $message): void;の未定義されていますので、これをクライアントごとに実装すればオッケーとなっています。

チャンネルの拡張を適用する

サービスプロバイダーを使って、チャンネルの拡張を適用します。

<?php
namespace Niisan\Notification;

use Illuminate\Notifications\ChannelManager;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\ServiceProvider;
use Niisan\Notification\Clients\Twilio;
use Niisan\Notification\Contracts\SmsClient;

class SmsNotificationServiceProvider extends ServiceProvider
{

    public function boot()
    {
        $this->publishes([
            __DIR__.'/config/sms-notification.php' => config_path('sms-notification.php'),
        ]);
    }

    public function register()
    {
        $this->app->bind(SmsClient::class, function ($app) {
            if (config('sms-notification.driver') === 'twilio') {
                $client = new \Twilio\Rest\Client(
                    config('sms-notification.twilio.sid'),
                    config('sms-notification.twilio.token')
                );
                return new Twilio($client);
            }
        });

        Notification::resolved(function (ChannelManager $service) {
            $service->extend('sms', function ($app) {
                return $app[SmsChannel::class];
            });
        });
    }
}

まあ、Twilioしかないんですがね。 一応、SmsClientについては入れ替えできるようになっているので、別のSMSサービス使いたい場合は新しいクライアントクラスを実装して、サービスプロバイダで入れ替えちゃえばオッケーです。 多分。

クライアントクラスの実装

クライアントクラスはそのまま、Twilioのクライアントをラップする感じです。

<?php
namespace Niisan\Notification\Clients;

use Niisan\Notification\Contracts\SmsClient;
use Niisan\Notification\SmsMessage;
use Twilio\Rest\Client;

class Twilio implements SmsClient
{

    private $client;

    public function __construct(Client $client)
    {
        $this->client = $client;
    }
    
    /**
     * Send message through Twilio.
     *
     * @param SmsMessage $message
     *
     * @return void
     */
    public function send(SmsMessage $message): void
    {
        $this->client->messages->create(
            $message->to,
            [
                'from' => $message->from,
                'body' => $message->body,
                'smartEncoded' => $message->unicode
            ]
        );
    }
}

これはもう、単純にTwilio SDKのクライアントをラップしているだけです。 別のサービスのクライアントを実装する場合は、Niisan\Notification\Contracts\SmsClientを実装しておいて、サービスプロバイダで入れ替えればオッケーです。

SmsMessageクラスは対して言及するところもないので、割愛します。

まとめ

ということで、Laravel公式のSMS通知が使えなくなってしまったので、自作したという話でした。 いや、Laravelはパッケージ自作して突っ込むの簡単ななんですが、微妙にinterfaceがないとか、足りんところもあるなぁといった感想。 まあ、パッケージづくりは楽しいというか、プロダクト開発のようにカネに直結しない分、気楽にできますね。 いや、カネ儲けも好きなんですがね。

今回はこんなところです。

おまけ

弊社ROXXでは、エンジニアさんをとてもとても採用しております。 興味のある方は私のtwitterにDMするかリプライつけてくれると、反応すると思います。 よろしくどうぞ!

twitter.com

docker で nginx & php-fpm の PHP 実行環境を構築する(TCP/UNIX domain socket)

この記事は個人ブログと同じ内容です

www.ritolab.com


nginx と php-fpm の構成で docker コンテナの PHP アプリケーション実行環境を構築してみます。

(開発環境構築の話ではないので docker-compose は使いません)

nginx と php-fpm の通信(How)が今回の話のメインです。

ディレクトリ構成など

docker イメージとコンテナの作成に入る前に、今回は php-fpm と nginx のコンテナを作成するため、それぞれ php-fpm 用に php というディレクトリを作成し、nginx 用に web というディレクトリを切って進めます。

そして、アプリケーション(の代わり)として index.phpphp-fpm 側に配置しておきます。

docker/
├─ php/
│   └ src/
│       └─ index.php
└─ web/

なお、index.php は phpinfo を表示させるだけの簡単なものになっています。

php/src/index.php

<?php phpinfo(); ?>

php-fpm の Dockerfile を作成

Dockerfile を作成して、まずは最もベースとなる部分の定義を行います。

php/ 配下に Dockerfile を作成し以下を記述します。

php/Dockerfile

FROM php:8.0-fpm-alpine

# install configure file
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# settings
COPY settings/php.ini /usr/local/etc/php/conf.d/php.ini

# app sources
COPY src /usr/share/nginx/html
  1. ベースイメージには PHP の公式イメージを指定しています。
  2. PHP の設定ファイルである php.ini を作成します。
  3. 追加で設定するための PHP 設定ファイルを設置しています。
  4. アプリケーションをドキュメントルートにコピーしています。

PHP の設定ファイル php.ini について

工程 2 では、PHP に予め用意されているテンプレートからそのまま php.ini を作成し設置しています。

設定値をデフォルトから変更したいものについては、予め用意した php.ini にその設定を記述し、工程 3 で conf.d/ 配下に設置する事で設定値を上書きしています。

php/settings/php.ini

[PHP]
date.timezone = "Asia/Tokyo"

なので、変更が不要なら工程 3 の記述は不要です。

nginx の Dockerfile を作成

次に、nginx 側の Dockerfile を作成します。

web/ 配下に Dockerfile を作成し以下を記述します。

web/Dockerfile

FROM nginx:1.19-alpine

# settings
COPY settings/default.conf /etc/nginx/conf.d/default.conf
  1. ベースイメージには nginx の公式イメージを指定しています。
  2. nginx の設定ファイルを設置します。

nginx - Docker Official Images

hub.docker.com

nginx と php-fpm の通信を定義する

nginx と php-fpm の通信方式は 2 通りあって、それぞれで設定ファイルや Dockerfile への記述が異なります。

どちらを採用すべきか?については検証と結論に至れていないので、今回は両方で構築してみます。

TCP で通信を行う

TCP で通信する場合は、nginx の設定ファイルを以下のように定義します。

web/settings/default.conf

server {
    listen 80;
    root   /usr/share/nginx/html;

    location / {
        index          index.php index.html index.htm;
        fastcgi_pass   php-sample:9000;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}

設定のもろもろは条件によって書き方が異なるためここの記述自体はかなり簡略化してますが、ポイントは fastcgi_pass の値です。

fastcgi_pass php-sample:9000;

  • php-sample というのは、今回作成する php-fpm のコンテナ名です。この後、php-fpm のコンテナを立ち上げる際にこの名前で起動させるので、それをここに指定します。

  • :9000 はポート番号です。php-fpm のデフォルトの待受ポートは 9000 なのでこれを指定しています。

前項で php-fpm と nginx の、ベースの定義を行いましたが、TCP で通信する場合は nginx の設定ファイルの定義のみで実現できます。

動作確認

ではイメージの作成とコンテナの起動を行いアプリケーションを動作させてみます。

以下のコマンドを実行して環境を構築します。

# 1. PHP のイメージを作成
docker build --no-cache -t php/sample:20210109 .

# 2. nginx のイメージを作成
docker build --no-cache -t nginx/sample:20210109 .

# 3. ネットワークを作成
docker network create --driver bridge sample_nw

# 4. PHP のコンテナを起動
docker run --net=sample_nw --name php-sample php/sample:20210109

# 5. nginx のコンテナを起動
docker run -p 80:80 --net=sample_nw --name nginx-sample nginx/sample:20210109

ブラウザからアクセスします。

f:id:ro9rito:20210112202912p:plain

nginx と php-fpm を TCP で通信させてアプリケーションが実行できました。

最終的なファイル構成

docker/
├── php/
│   ├ Dockerfile
│   ├ settings/
│   │   └─ php.ini
│   └ src/
│       └─ index.php
└── web/
    ├ Dockerfile
    └ settings/
        └─ default.conf

Docker コンテナ・ネットワークについて

先程の環境立ち上げの際に、イメージとコンテナ以外に「ネットワーク」を作成しました。

1つのネットワークを作成しそのネットワークに各コンテナを接続する事で、コンテナ間が互いを識別できるようにしています。

作成したネットワークの詳細を確認すると、コンテナが2つ接続されている事がわかります。

# ネットワークを作成
docker network create --driver bridge sample_nw

# ネットワーク一覧
% docker network ls
NETWORK ID     NAME             DRIVER    SCOPE
9326dc6f3546   sample_nw        bridge    local

# 作成したネットワークを確認
% docker network inspect sample_nw
[
  {
      "Name": "sample_nw",
      "Id": "9326dc6f35463041ec9511fba0426bd81311a704f757da755a81a42fd5da51ef",
      "Created": "2021-01-09T04:31:03.73817688Z",
      "Scope": "local",
      "Driver": "bridge",
      "EnableIPv6": false,
      "IPAM": {
          "Driver": "default",
          "Options": {},
          "Config": [
              {
                  "Subnet": "172.18.0.0/16",
                  "Gateway": "172.18.0.1"
              }
          ]
      },
      "Internal": false,
      "Attachable": false,
      "Ingress": false,
      "ConfigFrom": {
          "Network": ""
      },
      "ConfigOnly": false,
      "Containers": {
          "61d2d36499511f76ea2e64cd3124da3fc0fc05aa5b8af115bd70a3b937258e31": {
              "Name": "nginx-sample",
              "EndpointID": "27a848cb6d4e39121a2a8fcd0ec69c0929411e6bf353b868c7dc70354a351ef9",
              "MacAddress": "08:19:ac:14:00:06",
              "IPv4Address": "172.18.0.3/16",
              "IPv6Address": ""
          },
          "acbc9a2bf88caeb1fad3a8d0d0e5a409642ea2651487abede28157ad883b027d": {
              "Name": "php-sample",
              "EndpointID": "b1fe75cad1c2e6d9541cfca82577a1e02e93107b6e026ecb476e9309456ae1ce",
              "MacAddress": "08:19:ac:14:00:05",
              "IPv4Address": "172.18.0.2/16",
              "IPv6Address": ""
          }
      },
      "Options": {},
      "Labels": {}
  }
]

Docker コンテナ・ネットワークの理解

docs.docker.jp

ちなみに、ネットワークを作成せずに、 nginx 側のコンテナ起動時に --link オプションを使って php-fpm 側のコンテナを識別させる方法もありますが、現在では非推奨となっています。

コンテナ・リンク機能(古い機能)

docs.docker.jp

UNIX ドメインソケット で通信を行う

UNIX ドメインソケットで通信する場合に TCP の時と違うのは、通信の為にソケットを指定する事と、nginx 側からソケットを参照できるようにする事です。

php-fpm

php/Dockerfile

FROM php:8.0-fpm-alpine

# add user
RUN addgroup -S nginx && adduser -S nginx -G nginx

# install setting
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# settings
COPY settings/zzz-docker.conf /usr/local/etc/php-fpm.d/zzz-docker.conf
COPY settings/php.ini /usr/local/etc/php/conf.d/app-php.ini

# unix socket
RUN mkdir /var/run/php-fpm
VOLUME ["/var/run/php-fpm"]

# app sources
COPY src /usr/share/nginx/html
  1. ベースイメージには PHP の公式イメージを指定しています。
  2. nginx ユーザを作成します。
  3. PHP の設定ファイルである php.ini を作成します。
  4. php-fpm の設定ファイル(更新分)を設置します。
  5. PHP の設定ファイル(更新分)を設置します。
  6. ソケットを設置するディレクトリを作成し、ボリュームマウントします。
  7. アプリケーションをドキュメントルートにコピーします。

php-fpm の設定ファイルはデフォルトで設置されていますが、UNIX ドメインソケットを利用するために値を変更したい(工程 5 のところ)ので、設定値を上書きするための設定ファイルを作成します。

php/settings/zzz-docker.conf

[www]
listen = /var/run/php-fpm/php-fpm.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660

unix ドメインソケットを使うように指定したのと、ソケットを使用するユーザ(パーミッション)を設定しています。

owner/group は、nginx 側からソケットを参照するユーザを指定しています。

nginx

web/Dockerfile

FROM nginx:1.19-alpine

# settings
COPY settings/default.conf /etc/nginx/conf.d/default.conf
  1. ベースイメージには nginx の公式イメージを指定しています。
  2. nginx の設定ファイルを設置します。

Dockerfile は TCP の時と変わりません。

nginx の設定ファイルを以下に定義します。

web/settings/default.conf

server {
    listen       80;
    server_name  localhost;
    root         /usr/share/nginx/html;

    location / {
        index          index.php index.html index.htm;
        fastcgi_pass   unix:/var/run/php-fpm/php-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}

TCP の時との違いは、fastcgi_pass に unix ドメインソケットを指定している点です。

動作確認

ではイメージの作成とコンテナの起動を行いアプリケーションを動作させてみます。

以下のコマンドを実行して環境を構築します。

# 1. PHP のイメージを作成
docker build --no-cache -t php/sample:20210109 .

# 2. nginx のイメージを作成
docker build --no-cache -t nginx/sample:20210109 .

# 3. PHP のコンテナを起動
docker run --name php-sample php/sample:20210109

# 4. nginx のコンテナを起動
docker run -p 80:80 --volumes-from php-sample --name nginx-sample nginx/sample:20210109

ブラウザからアクセスします。

f:id:ro9rito:20210112203405p:plain

nginx と php-fpm を UNIX ドメインソケット で通信させてアプリケーションが実行できました。

最終的なファイル構成

docker/
├ php/
│   ├ Dockerfile
│   ├ settings/
│   │   ├─ php.ini
│   │   └─ zzz-docker.conf
│   └ src/
│       └─ index.php
└ web/
    ├ Dockerfile
    └ settings/
        └── default.conf

まとめ

Laravel や CakePHP 等の PHP フレームワークを動かす際も基本的には同じ要領で、nginx の設定ファイル側を調整したり、必要なパッケージを PHP 側に入れたりなどすれば動作させる事ができます。

実行環境なのでアプリケーションごとイメージに固めていますが、これらの dockerfile と docker-compose を併せて役割を分ければ開発環境の構築も可能です。

TypeScriptで深いJSON構造から要素を取り出すときに型をちゃんと取るTIPS

このブログは個人ブログと同じ内容です

kotamat.com

下記のような多段のマスターデータが存在しているときに、ちゃんと型安全に値を取り出したいとなったときの型定義を考える

large.json

{
  "1": "foo",
  "2": "baz"
}

detail.json

{
  "1": {
    "1001": "foo1",
    "1002": "foo2"
  },
  "2": {
    "2001": "baz1",
    "2002": "baz2"
  }
}

これに対して、下記のような参照をしたら、ちゃんと値を取りたい。

import large from "~/large.json";
import detail from "~/detail.json";

// anyもちゃんとした型に変更する
function getValue(largeKey: any, detailKey: any) {
  // hogeを型がちゃんとついてる状態で取得する
  const hoge = detail[largeKey][detailKey];
}

※ちなみに上記で import している large, detail は as const したような厳密な型定義になっていることを前提とする。

declare module "~/detail.json" {
  type Detail = {
    "1": {
      "1001": "foo1";
      "1002": "foo2";
    };
    "2": {
      "2001": "baz1";
      "2002": "baz2";
    };
  };
  const data: Detail;
  export default data;
}

Step1: 深さ指定して key の型を取れるようにする

https://qiita.com/KuwaK/items/587205867d333b705a41 を参考にさせていただき、下記のような型を作成する(Property2 を NthDepthProperty に変えているが、必要であればもとに戻します)

type NumMap = {
  3: 2;
  2: 1;
  1: 1;
};

type ValueOf<T> = T[keyof T];

export type NthDepthProperty<T, P extends keyof NumMap> = P extends 1
  ? keyof T
  : ValueOf<
      {
        [K in keyof T]: T[K] extends object
          ? NthDepthProperty<T[K], NumMap[P]>
          : never;
      }
    >;

簡単に説明すると、最大 3 段ネストするオブジェクトに対して、型引数 2 つ目に指定した階層に存在する key を Union Type にして返却するというもの。NumMap を増やせば当然いくらでもネストは可能だが、今回は 3 段のままで大丈夫なのでそのままにする

その上で元々のファイルを下記のように変えると、引数として望まれる型が適切に指定できる

import large from "~/large.json";
import detail from "~/detail.json";
import { NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>, // largeKey: "1" | "2"
  detailKey: NthDepthProperty<typeof detail, 2> // detailKey: ValueOf<{"1": "1001" | "1002"; "2": "2001" | "2002"}> = "1001" | "1002" | "2001" | "2002"
) {
  const hoge = detail[largeKey][detailKey];
}

largeKey は keyof でもいいんだけど、見た目的に揃えた

Step2: Union Type を Intersection に変える型を用意する

上記のままでも行けそうな感じはするが、下記のようなエラーが出てしまう

(parameter) detailKey: ValueOf<{
    1: "1001" | "1002";
    2: "2001" | "2002";
}>
型 'ValueOf<{ 1: "1001" | "1002"; 2: "2001" | "2002"; }>' の式を使用して型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。
  プロパティ '1001' は型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' に存在しません。

これはdetail[largeKey] で取得した値の型が

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

となっており、例えばこのオブジェクトに対して"1001"という key を指定した場合、largeKey=2 で参照すると出てくる後方の型にマッチしない可能性があるためエラーになるかもしれないから。

回避策としては、下記のように丁寧に TypeGuard をして行くことも考えられるが、key が増えるたびに処理が増えてしまい、なんのための型定義をしているのかよくわからなくなってしまう。

if (largeKey === "1" && detailKey === "1001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
} else if (largeKey === "2" && detailKey === "2001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
}

ここで開発者は事前に largeKey と detailKey の組み合わせがほぼ確実に正しい形で渡って生きていることを知っている(そうでない場合は一旦想定しないで OK)とした場合、Union 型を Intersection 型に変える事によって解決する。 つまり

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

{
    "1001": "foo1";
    "1002": "foo2";
} & {
    "2001": "baz1";
    "2002": "baz2";
}

に変えてしまえば、"1001"でアクセスしたとしても確実に値を得られるということになる。

ここで、Intersection に変換する方法はこちらを参考に下記のような型で解決できる

export type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;

あとはこんな感じでコードを変更すれば hoge がいい感じの型で取得できるようになる

import large from "~/large.json";
import detail from "~/detail.json";
import { UnionToIntersection, NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>,
  detailKey: NthDepthProperty<typeof detail, 2>
) {
  const l = detail[largeKey];
  const hoge = (l as UnionToIntersection<typeof l>)[detailKey]; // hoge: "foo1" | "foo2" | "baz1" | "baz2"
}

おしまい

「プロジェクトマネージャーいらなくね?」と思って辞めた話

back check 開発チームの秋葉です

概要

※ PjM: プロジェクトマネージャー

三行でまとめると

  • PjMの役割を開発チームへと移譲していった
  • チームとしてPjMいならいと思い辞めてみた
  • 辞めた結果支障なかった

PjMの仕事

自分は2020年の4月頃から、開発チームのPjMというポジションにつきました。
PjMをやっていましたが、1メンバーとして普通に開発もしていました。(開発をするうえで、負荷は減らしてもらっていました)

PjMというと、会社やチームによってやることが千差万別だとおもいますが、自分は以下のようなことをやってました。

  • スケジュールの管理(短期)
  • 差し込みタスクのハンドリング
  • スプリントプランニング
  • POと開発チームの橋渡し的な役割
  • POがいないときの開発の意思決定
  • etc....

色々書きましたが個人的には、やる人が決まっていな雑多なこと を やっていました。

PjM辞めたわけ

back check 開発チーム は アジャイル開発の手法として、スクラム開発 を導入しています。
チームとしてはより良いアジャイル開発、スクラム開発を目指しています。

ここで、アジャイル開発名著のである アジャイルサムライ から言葉をいくつか引用させていただきます。

自己組織化

最良のアーキテクチャ・要求・設計は、
自己組織的なチームから生み出されます。

成果責任と権限委譲

意欲に満ちた人々を集めてプロジェクトを構成します。
環境と支援を与え仕事が無事終わるまで彼らを信頼します。

といことで、開発チームを自己組織化していくために、
スプリント内での開発の成果責任 と PjMの権限 を開発チーム移譲してみました、
徐々にやっていくなかで、「あれ、もうプロジェクトマネージャーいらなくね?」となり、 自分がこの役割を辞めることを決めました。
(開発チームの内訳には自分も含まれています)

実際には以下のようなことをやった。

  • スプリントの成果を個人ではなくチームの成果であることを意識した

    • デイリーで進捗を共有し相互にフォローをしあう
    • 達成した成果も達成できなかった成果も個人ではなくチームのもの
  • 開発への依頼は、個人へ宛ではなく開発チーム全体への依頼としてもらう

    • Slack で開発チーム全体にメンションしてもらうようにした
    • 差し込みのタスクもチーム全体のスケジュール影響することとして認識する
  • スプリントプランニングをチーム全員で考えるようにした

    • 実施するタスクを、指名性ではなく挙手制にし、やりたいことを優先する
  • 開発の進捗は全員で管理し、プロダクトオーナを含めた全員が見えるようにした

上記については自分が提案した内容もありますが、基本的にはスクラム開発のチームビルディングの一貫とて行ったことが多いです。
スクラムマスターとしてチームビルディングを実施してくれた、同僚に感謝です。

PjM辞めた結果

何も変わらなかった

辞めて1ヶ月ほど現状としては、自分がPjM辞めた前と後では特に変化はなく、チームも問題なく回っている状況です。
新しい問題が発生しても、基本的にはチームとして対処していく形です。

無くして良かった というよりは、 無くしても問題なかった という結果でした。

まとめ

PjMは必要ない という話ではなく、今の開発チームはPjM必要ない という状態にチームがなったというお話です。

状況やチームの編成が変わればまた必要になるかもしれないし、新しい役割が必要になるかもしれません。
特定の役割がなくなっても、自然とチームが回るのはチームとして成長し、より良チームに近づけたのかなと思います。

PHP 8.0 の設定ファイル php.ini-development と php.ini-production の違い

この記事は個人ブログと同じ内容です

www.ritolab.com


PHP には php.ini という設定ファイルがありますが、これを作成する元となる設定ファイルには本番用と開発用の2つが存在しています。

今回はこの2つの php.ini の違いを見ていきたいと思います。

環境

PHP 8.0 の環境で見ていきます。

$ php  -v
PHP 8.0.0 (cli) (built: Dec 17 2020 08:54:56) ( NTS )
Copyright (c) The PHP Group
Zend Engine v4.0.0-dev, Copyright (c) Zend Technologies

php.ini

php.ini は PHP の設定ファイルの事で、PHP の動作に関する様々な設定をここに記述することができます。

設定ファイル
https://www.php.net/manual/ja/configuration.file.php

php.ini がどこに設置されているのかは以下のコマンドを叩くと確認する事ができます。(環境によって設置箇所は異なります)

# php.ini の場所を確認する
php --ini

# 実行結果
$ php --ini
Configuration File (php.ini) Path: /usr/local/etc/php
Loaded Configuration File:         /usr/local/etc/php/php.ini
Scan for additional .ini files in: /usr/local/etc/php/conf.d

本番用と開発用の php.ini

php.ini が設置されているディレクトリに php.ini-development と php.ini-production が存在しています。

/usr/local/etc/php
├── conf.d
├── php.ini
├── php.ini-development
└── php.ini-production

この2つのファイルを基に php.ini を作成するわけですが、これらのファイルについて、具体的にどこの設定項目がどう違うのでしょうか。

その違いを見ていきます。

余談ですが php.ini が無い場合はその項目ごとにデフォルト値が決まっていて、デフォルト値が適用されます

php.ini-development と php.ini-production の違い

2つのファイルの差分を取り、設定の違いを確認していきます。

f:id:ro9rito:20201228080006p:plain

zend.exception_ignore_args

例外用に生成されたスタックトレースに引数を含めるかどうかの設定。

  • 本番環境ではこの設定をオンにしてスタックトレースで機密情報を出力しないようにすることが推奨されている。

  • デフォルト:Off
  • 開発用:Off
  • 本番用:On

-zend.exception_ignore_args = Off
+zend.exception_ignore_args = On

開発用では引数を出力するようになっているが、本播用では出力しない設定になっている

zend.exception_ignore_args
https://www.php.net/manual/ja/ini.core.php#ini.zend.exception-ignore-args

zend.exception_string_param_max_len

文字列化されたスタックトレースの引数の最大文字列長を 0 から 1000000 までの範囲で値を設定できる。

  • zend.exception_ignore_args が有効になっている場合、ここは無視される。
  • 本番環境ではこれを 0 に設定してスタックトレースでの機密情報の出力を減らすことが推奨されている。

  • デフォルト:15
  • 開発用:15
  • 本番用:0

-zend.exception_string_param_max_len = 15
+zend.exception_string_param_max_len = 0

開発用では最大文字列長が 15 文字になっていて、本番用では 0 になっている。 (本番用は zend.exception_ignore_args = On なのでそもそも使われない)

ちなみにこれは PHP 8.0 で新たに追加されたディレクティブです。(RFC

error_reporting

エラー出力レベルを設定できる。

-error_reporting = E_ALL
+error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

開発用では全てのエラーと警告を出力するようにしているが、本番用では E_DEPRECATED と E_STRICT 以外のエラーや警告が出力されるように設定されている。

エラー出力レベルについては以下を参照ください

www.ritolab.com

error_reporting

https://www.php.net/manual/ja/errorfunc.configuration.php#ini.error-reporting

display_errors

PHP がエラーや通知、警告を表示するかどうかを設定する。

  • エラー表示は開発時には役に立つが、本番環境では機密情報が漏れるかもしれないので非表示にする事が推奨されている。
  • 本番環境では、エラーは STDOUT に送信するのではなく、ログに記録することが推奨されている。

  • デフォルト:On
  • 開発用:On
  • 本番用:Off

-display_errors = On
+display_errors = Off

開発用ではエラーを表示するが、本番用では表示しない設定になっている。

display_errors

https://www.php.net/manual/ja/errorfunc.configuration.php#ini.display-errors

display_startup_errors

PHPの起動シーケンス中に発生するエラーの表示は display_errors とは別に処理されるため、こちらで表示有無を設定する。

  • 構成の詳細が漏洩しないように本番環境ではオフにする事が推奨されている。

  • デフォルト:On
  • 開発用:On
  • 本番用:Off

-display_startup_errors = On
+display_startup_errors = Off

開発用ではエラーの表示が行われるのに対し、本番用では表示されない設定になっている。

display_startup_errors

https://www.php.net/manual/ja/errorfunc.configuration.php#ini.display-startup-errors

mysqlnd.collect_memory_statistics

mysqlnd によるメモリ使用統計の収集を行うかの設定。(MySQLのチューニングやモニタリングに使える)

-mysqlnd.collect_memory_statistics = On
+mysqlnd.collect_memory_statistics = Off

開発用では有効になっているのに対し、本番用では無効になっている。

mysqlnd.collect_memory_statistics

https://www.php.net/manual/ja/mysqlnd.config.php#ini.mysqlnd.collect-memory-statistics

zend.assertions

PHP が実行コードを生成する際にアサーションを含めるかの設定

設定値

  • -1:含めない
  • 0:含めるが実行時はアサーションをスキップする
  • 1:含める

  • 負の値から、または負の値への変更は、php.iniでのみ可能。
  • zend.assertions = 1 にした場合、実行時のアサーションのオン・オフの切り替えは assert.active で行える。

  • デフォルト値:1
  • 開発用:1
  • 本番用:-1

-zend.assertions = 1
+zend.assertions = -1

開発用はアサーションを含めるのに対して、本番用では含めない設定になっている。

zend.assertions

https://www.php.net/manual/ja/ini.core.php#ini.zend.assertions

assert.active

https://www.php.net/manual/ja/info.configuration.php#ini.assert.active

opcache.huge_code_pages

PHPコードを Huge Page にコピーするかどうかの設定。

  • Huge Page は Linux の機能で、メモリページングの際のチャンクサイズを増やしてパフォーマンスを向上させる機能。
  • Linux では PHP 7.0.0 以降、FreeBSD では PHP 7.4.0 以降が必要。
-;opcache.huge_code_pages=0
+;opcache.huge_code_pages=1

開発用では無効になっていて、本番用では有効になっている。

しかしどちらの環境でもコメントアウトされているので、opcache を有効化してここをチューニングする場合はコメントアウトを外してあげる必要がある。

opcache.huge_code_pages

https://www.php.net/manual/ja/opcache.configuration.php#ini.opcache.huge_code_pages

まとめ

開発時にはデバッグがしやすいように、そして本番では不要な情報の露出や無駄なリソースの消費がないように、それぞれの ini ファイルで設定されていましたね。

あなたが作っているサービスの本番環境の php.ini は、本当に本番用を元に作成されていますか?

見直してみても良いかもしれません。

ちなみに、php.ini を php.ini-development もしくは php.ini-production のどちらかから作成した場合は About this file セクションを見ると以下のように表記されているので、それをみればどちらから生成したのかがわかると思います。

# php.ini-development から作成
; This is the php.ini-development INI file.

# php.ini-production から作成
; This is the php.ini-production INI file.

GitHub ActionsにCronがあると聞いたんだ

自己紹介

agent bank開発チームのhironekoです。 個人ブログは、こちらです。

腹筋ローラーを初めて以降、筋肉痛なのか、姿勢の悪さからのコリなのかわからない苦しみが起きています。

あとウォーキングデットやゴールデンカムイを見ていついかなる状況になっても生存しなくてはいけないと感じてキャンプに興味を持ちました。

さて今回の目的

GitHub ActionsのCronで何すっかって話なのですが

最近に限ったことではなくて、僕は、数字の4桁以上を覚えるのがなかなか苦手なレベルの脳内メモリ所有者なのですが

来期のアニメの見なくてはいけないタイトルが10タイトルくらいあり、放送日時を把握し切れる訳がないじゃないか!って危機的状況に陥っている訳です。

仮にリアタイで見れなくてもアマプラさんで解決する部分もあったり絶命するわけではないのですが、個人的にHPが2割以下くらいになって日々のモチベーションがダダ下がりするのだろうと想定されるのです。

なのでよしGitHub Actionsの学習がてら毎朝か業後くらいの時間に通知行くようにしようぜ!

なんならPythonでやってみようぜ!

って昨日の夜中に思い立ったのでやってみたいとおもいます。

補足

我が家は、録画機能を有した何かしらの文明機器を持ち合わせていません。

前提

ゴール

最終的に以下のようにslackへ放送日に通知が行くようにします。 f:id:hironekosun:20201223154138p:plain

実装:Python

コードを先に晒します。

import json
import slackweb
from os import getenv
import datetime as dt
import calendar

today = dt.date.today()
dayName = calendar.day_name[today.weekday()]

if today.month < 10:
    month = f'0{today.month}'
else:
    month = today.month

data = json.load(open(f'./data/anime/{today.year}{month}.json', 'r'))

slack = slackweb.Slack(url=getenv('SLACK_WEBHOOK_URL'))

for v in data:
    if v['day_of_week'] == dayName:
        attachments = [
           {
               "fallback": 'アニメの放送時間のご案内',
               "pretext": '本日放送のアニメ',
               "fields": [
                   {
                       "title": 'タイトル',
                       "value": v['title'],
                   },
                   {
                       "title": '放送時間',
                       "value": v['publish_at'],
                       "short": "true"
                   },
                   {
                       "title": 'チャンネル',
                       "value": v['channel'],
                       "short": "true"
                   }
               ]
           } 
        ]
        slack.notify(attachments=attachments)

解説

  • 特別語ることは少ないです。

  • slackweb

こちらのライブラリが一番手っ取り早く簡単そうだったので使いました。

所感としては、ほんと単純なコードで通知することが可能なので誰でも迷わず実装が行えると思います。

  • getenv

getenvを使用して環境変数を取得してslackのweb hook urlをコードにハードコードしないようにします。

  • 全体

読めばわかるとは思うのですが、予めJSONのfileにデータを入れておきそのデータを元に今日放送をするアニメかどうかを判定して通知を行っています。

.
├── attachments.json
├── data
│   └── anime
│      ├── 202001.json
│      └── 202012.json
├── read.py
└── requirements.txt
  • JSON 下記の形式で通知対象にしたい情報を入れておきます(ここはスクレイピングでどうにかしたい)

file名は、必ずyyyymm.jsonとします。

[
    {
        "title": "転生したらスライムだった件",
        "publish_at": "23:00",
        "channel": "TOKYO MX",
        "day_of_week": "Tuesday"
    },
    ....
]

実装:GitHub Actions

  • こちらも先にコードを晒します。
on: 
  schedule:
    - cron: '55 0 * * *'

jobs:
  slack:
    name: Run slack
    runs-on: ubuntu-latest
    env:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Set up Python 3.8
        uses: actions/setup-python@v1
        with:
          python-version: 3.8
      - name: pip install
        run: pip install -r requirements.txt
      - name: post
        run: python read.py
  • cron

注意点として5分以下は動かないっぽいのとdefault branch以外は、cronの対象にならないとのこと

またこのcronが結構いい加減なので5分くらいのズレは、デフォです。

なので今回実行時間を10時に実行されるといいなーって気持ちでUTC 00:55 = 日本時刻 09:55 に設定しました。

GitHub Actionsよろしく!

  • secrets

今回slackのweb hook urlを使いますのでコードにハードコードしたくなく、外に晒すのもなーっていう気持ちなのでGitHubの機能を使用しました

Settings > Secretsで設定ページへ遷移します。New repository secretボタンを押下し追加します。

使い方は${{ secrets.KEY_NAME }}となります。

今後

漫画やアニメが好きなんですが既存の世に出ているサービスでは、僕のやってほしいことを満たすサービスが1mmもないのでこれを気に作ってやるんだから!って気持ちがふつふつと湧いてきました。

年末年始、時間があるようでないようなものですが積読している書籍読みつつ、開発していきたいなって気持ちです。

最後に独り言

グーカレでよくね?という意見は、受け付けますん。すみませんブログネタ考えたあとに振り返ったらグーカレの定期でよくねって考えに至りました。