お名前comの独自ドメイン + お名前メールな環境で、SendGrid の Domain Authentication によるDKIM/SPF認証を設定してみた

手元の自作アプリにて、SendGridでメールを送信するための準備として、Domain AuthenticationによるDKIM/SPF認証の設定をしたときのメモを残します。

 

目次

 

環境

  • SendGrid本体でアカウント作成
  • お名前.comで契約

 

調査

Domain Authentication用CNAMEレコードをどこに登録するか

SendGridでメールを送信するための準備として、Domain Authentication の設定を行います。
SendGrid 新人成長記 第六回 Domain Authentication: メールの到達率を高めるために | SendGridブログ

 
今回の環境はお名前.comの独自ドメイン + お名前メールなので、お名前メールの方でDNSにCNAMEレコードを追加すれば良さそうです。
お名前.com お名前メール 活用ガイド

 
ただ、お名前メール ライトプランの契約内容を読んだところ、サブドメインが使えないことに気づきました。
お名前メール 月あたり46円(税込)からのメールアドレス取得|お名前.com

サブドメインが使えないとすれば、SendGrid の Domain Authentication 用のCNAMEレコードをお名前メールのDNSに作成できません。

もしかしたらスタンダードプランに上げれば使えるかもしれませんが、他に何か良い方法がないかを調べてみました。

 
すると、

などの記事より、

  • ネームサーバは、 お名前.com
    • 01.dnsv.jp、02.dnsv.jp、03.dnsv.jp、04.dnsv.jp
  • お名前.comのネームサーバに以下のレコードを登録
    • SendGrid用のCNAMEレコードを登録
    • お名前メール用のMXレコードを登録

とすればいけそうでした。

今回の環境ではお名前メールはほとんど使ってなかったため、もし失敗してメールが届かなくなっても良かったこともあり、試してみることにしました。

 

設定

SendGrid にて、Domain Authentication用レコードを作成

Settings > Sender Authentication > Domain Authentication より、設定を行います。

なお、今回、独自ドメインとして example.com を使いますが、実際には自分が取得しているドメインとなります。

項目 理由など
DNS host I'm not sure お名前.comがリストになかったため
Would you also like to brand the links for this domain? No 今回はLink Brandingの設定はしないため
From domain example.com 自分が取得している独自ドメインを設定
Use automated security チェックする CNAMEレコードでドメイン認証するため
Use custom return path チェックしない 使わないため
Use custom DKIM selector チェックする 既存のサブドメインとの重複をさけるため
DKIM selector my 任意の値で良い

 
設定後、以下の3つのCNAMEレコードが表示されます。

これらをお名前.comのネームサーバに登録します。

 

お名前.comのネームサーバーを使うように設定

以下の手順に従い、現在のネームサーバをお名前.comにします。
ネームサーバーの変更|お名前.com Navi ガイド|ドメイン取るならお名前.com

ネームサーバー設定にて、 現在のネームサーバー情報お名前.com となっていればOKです。

 

お名前.comのネームサーバへDNSレコードを登録

以下の手順に従い、お名前.comのネームサーバへDNSレコードを登録します。
DNS関連機能の設定:DNSレコード設定|お名前.com Navi ガイド|ドメイン取るならお名前.com

登録するレコードは、SendGridのDomain Authentication用の3レコード、および、MXレコードとなります。

CNAMEレコードはSendGridで表示された

を登録します。

 
MXレコードは、お名前メールのコントロールパネルにある サーバー情報SMTP・POPサーバの番号を使って設定します。
お名前.com お名前メール 活用ガイド

例えば、番号が smtp10.gmoserver.jp の場合は、

項目
ホスト名 example.com
TYPE MX
TTL 3600
VALUE mx10.gmoserver.jp
優先 10

として登録します。

 

SendGridにてverifyする

SendGridのDomain Authenticationのページにある Verify ボタンをクリックし、3つのCNAMEレコードが verified になることを確認します。

なお、登録後反映されるまで最大72時間かかるようですので、気長に待ちます。

 
Verifiedになっているかどうかは、SendGridの画面のほか、SendGridの List all Domains APIでも確認できます。
Domain Whitelabel - ドキュメント | SendGrid

 
curl

> curl https://api.sendgrid.com/v3/whitelabel/domains --header 'authorization: <APIキー>'

を実行すると、Verifiedになっていれば

[
    {
        "id": 123,
        "user_id": 456,
        "subdomain": "em9999",
        "domain": "example.com",
        "username": "user_name",
        "ips": [],
        "custom_spf": false,
        "default": false,
        "legacy": false,
        "automatic_security": true,
        "valid": true,
        "dns": {
            "mail_cname": {
                "valid": true,
                "type": "cname",
                "host": "em9999.example.com",
                "data": "u123.xxx.sendgrid.net"
            },
            "dkim1": {
                "valid": true,
                "type": "cname",
                "host": "my._domainkey.example.com",
                "data": "my.domainkey.u123.xxx.sendgrid.net"
            },
            "dkim2": {
                "valid": true,
                "type": "cname",
                "host": "my2._domainkey.example.com",
                "data": "my2.domainkey.u123.xxx.sendgrid.net"
            }
        },
        "last_validation_attempt_at": 1615966760
    }
]

のように表示されます。

 

動作確認

SendGridから受信したメールの確認

SendGridのMarketing機能などを使って、自分のGmail宛にメールを送信してみます。

すると、 Authentication-Results ヘッダが Domain Authentication 設定前は

Authentication-Results: mx.google.com;
  dkim=pass header.i=@sendgrid.net header.s=smtpapi header.b=xxx;
  arc=pass (i=1 spf=pass spfdomain=sendgrid.net dkim=pass dkdomain=sendgrid.net);
  spf=pass (google.com: domain of xxx@gmail.com designates xxx.xxx.xxx.xxx as permitted sender) 

だったような内容が、設定後は

Authentication-Results: mx.google.com;
  dkim=pass header.i=@example.com header.s=my header.b=xxx;
  spf=pass (google.com: domain of bounces@em9999.example.com designates xxx.xxx.xxx.xxx as permitted sender) 

のように変わっていました。

また、Gmailからも sendgrid.net 経由 のような表示が消えていました。

これにより、Domain AuthenticationによるDKIM/SPF認証は成功しているようでした。

Windows10 + aws-vault + AWS CDK + Serverless Frameworkにて、最低限の権限を持つユーザでAWS Lambda + API GatewayなAPIを作ってみた

前回の記事で、 aws-vaultAWS CLIを連携させて使えるようになりました。
Windows + aws-vaultにて、AWSのアクセスキーを保護し、 AWS CLIを AssumeRole で使えるようにしてみた - メモ的な思考的な

 
今回は、 aws-vault + Serverless Frameworkを連携させ、AWS Lambda + API Gateway を使ったAPIを作成してみます。

また、Serverless Frameworkでデプロイする時のユーザについて、公式では

Search for and select AdministratorAccess ... Note that the above steps grant the Serverless Framework administrative access to your account. While this makes things simple when you are just starting out, we recommend that you create and use more fine grained permissions once you determine the scope of your serverless applications and move them into production.

https://www.serverless.com/framework/docs/providers/aws/guide/credentials/

と書かれています。

ただ、どのような権限があれば最低限となるのか分からなかったため、試してみることにします。

なお、手動で権限を与えるのは手間なので、今回は AWS CDK for TypeScript を使って必要なIAMまわりを作成し、最後にデプロイ用ユーザに手動で割り当てるだけにします*1

 
目次

 

環境

  • Windows10 (1909, 18363.1379)
  • aws-vault v6.2.0
  • Serverless Framework 2.25.2
    • Plugin 4.4.3
    • SDK 2.3.2
    • Components: 3.7.0
  • AWS CDK for TypeScript 1.90.1 (build 0aee440)
  • nvm-windows 1.1.7
    • WindowsにおけるNode.jsのバージョン管理ツール
  • Node.js 14.15.5

 
なお、前回の環境から引き続きで行っているため、

  • aws-vault はインストール済
  • aws-vault で AdministratorAccess にAssumeRoleして、AWSリソースを操作可能

な状況とします。

一方、nvm-windowsやNode.js、CDKは今回初めてインストールするものとします。

 

Node.js環境の構築

Serverless FrameworkやAWS CDKはNode.jsを使うため、Node.js環境を構築します。

 

nvm-windowsのインストール

Node.jsをそのままインストールしてもよいのですが、今後色々試すことを考えて複数バージョンを使えるようにしておきます。

Windowsではどのようなツールがあるかを調べたところ、以下にまとまっていました。
Windows における Node.js バージョン管理マネージャの選択(nvm-windows, nodist 等) - clock-up-blog

 
最近見かけるWindowsのパッケージマネージャの一つ Scoop のMainバケットには何が含まれているかを確認したところ、 nvm-windows は含まれているものの、nodist はありませんでした。そこで今回は nvm-windows を使うことにしました。
https://github.com/ScoopInstaller/Main/blob/master/bucket/nvm.json

 
次に nvm-windowsのインストールについてです。

Scoopを使ってインストールすることも考えましたが、今後 winget が登場するとまた変わるのかなと思いました。
https://github.com/microsoft/winget-cli

 
そこで今回は、Githubから nvm-windows をダウンロード・インストールしました。
https://github.com/coreybutler/nvm-windows

 

Node.jsのインストール

管理者権限でWindows Terminalを起動し、 nvm-windows を使ってNode.jsをインストールします。

まずは使えるNode.jsのバージョンを確認します。

>nvm list available

|   CURRENT    |     LTS      |  OLD STABLE  | OLD UNSTABLE |
|--------------|--------------|--------------|--------------|
|    15.9.0    |   14.15.5    |   0.12.18    |   0.11.16    |
|    15.8.0    |   14.15.4    |   0.12.17    |   0.11.15    |
...

This is a partial list. For a complete list, visit https://nodejs.org/download/release

 
バージョンリストより、Node.js LTSの最新をインストールします。

>nvm install 14.15.5

Downloading node.js version 14.15.5 (64-bit)...
Complete
Creating %USERPROFILE%\AppData\Roaming\nvm\temp

Downloading npm version 6.14.11... Complete
Installing npm v6.14.11...

Installation complete. If you want to use this version, type

nvm use 14.15.5

 
使用するNode.jsを有効化し、バージョンを確認します。

>nvm use 14.15.5
Now using node v14.15.5 (64-bit)

>node -v
v14.15.5

 
これでNode.jsが使えるようになりました。

 

Serverless Frameworkのセットアップ

続いてServerless Frameworkをセットアップし、AdministratorAccess権限を持ったユーザでデプロイできるかを確認してみます。

 

インストール

Node.jsのグローバルにインストールします。

>npm install -g serverless

+ serverless@2.25.2

 

動作確認

Serverless Framework の aws-python3 テンプレートを使って、aws-vaultと組み合わせてデプロイできるかを確認してみます。
https://www.serverless.com/framework/docs/providers/aws/guide/services#creation

>serverless create --template aws-python3 --path hello
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "path\to\hello"
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v2.25.2
 -------'

Serverless: Successfully generated boilerplate for template: "aws-python3"

 
デフォルトの serverless.yml では us-east-1 リージョンにデプロイされてしまうため、東京リージョンへと変更します。

provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  region: ap-northeast-1  # 追加

 
念のためデプロイ前のLambdaの状態を aws-vault + AWS CLI にて確認してみましたが、何もありません。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 
Serverless Frameworkを使って、AdministratorAccess権限にてデプロイしてみます。

aws-vaultと組み合わせて使うので、 aws-vault exec admin -- <serverless frameworkのコマンド> という形で実行します。

>aws-vault exec admin -- serverless deploy -v
...
Serverless: Stack update finished...
Service Information
service: hello
stage: dev
region: ap-northeast-1
stack: hello-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  hello: hello-dev-hello
layers:
  None

Stack Outputs
HelloLambdaFunctionQualifiedArn: arn:aws:lambda:ap-northeast-1:<account_id>:function:hello-dev-hello:1
ServerlessDeploymentBucketName: hello-dev-serverlessdeploymentbucket-xxx

 
Lambdaもできています。

>aws-vault exec admin -- aws lambda list-functions
Enter token for arn:aws:iam::<account_id>:mfa/gate: xxx
{
    "Functions": [
        {
            "FunctionName": "hello-dev-hello",
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:<account_id>:function:hello-dev-hello",
            "Runtime": "python3.8",
            "Role": "arn:aws:iam::<account_id>:role/hello-dev-ap-northeast-1-lambdaRole",
            "Handler": "handler.hello",
            "CodeSize": 640,
            "Description": "",
            "Timeout": 6,
            "MemorySize": 1024,
            "LastModified": "2021-02-21T01:55:02.396+0000",
            "CodeSha256": "xxx",
            "Version": "$LATEST",
            "TracingConfig": {
                "Mode": "PassThrough"
            },
            "RevisionId": "xxx",
            "PackageType": "Zip"
        }
    ]
}

 
続いて、Serverless Frameworkを使って削除してみます。

>av exec admin -- serverless remove -v
...
Serverless: Stack delete finished...

 
Lambdaもなくなっていました。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 

Serverless FrameworkでデプロイするためのIAMまわりを考える

ここまでは AdministratorAccess 権限でデプロイしてきました。

ただ、これだと権限が広すぎるため、必要最低限の権限を持つIAMまわりを考えてみます。

 

必要な権限について

今回は

をServerless Frameworkで自動生成するための権限を考えてみます。

 
Serverless Frameworkで最低限の権限でデプロイする方法を調べたところ、以下のissueや記事がありました。

 
今回は

  • IAMユーザはAssumeRoleする権限を所持
  • デプロイする権限やCloudFormationはAssumeRoleして使う

とするため、以下の2つのIAMロールを用意します。

  • aws-vault exec <profile> -- serverless deploy するためのIAMロール
  • デプロイするため上記ロールから、CloudFormationへPassRoleされた時のIAMロール
    • Serverless FrameworkはCloudFormationを使ってAWSリソースを構築するため

 
一方、AWS Lambdaから他のAWSリソースは使わないため、AWS Lambda用のロールはServerless Frameworkで自動生成されるものを使います。

なお、自動生成されるロール(<PROJECT>-<STAGE>-<REGION>-lambdaRole)は、以下のインラインポリシーを持って生成されます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:<region>:<account_id>:log-group:/aws/lambda/<PROJECT>-<STAGE>*:*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:a<region>:<account_id>:log-group:/aws/lambda/<PROJECT>-<STAGE>*:*:*"
            ],
            "Effect": "Allow"
        }
    ]
}

 
次に、各IAMロールに必要な権限を考えてみます。

serverless deploy でできるリソースですが、

  • AWS
    • CloudFormationスタック
    • デプロイ中間ファイル配置用S3バケット
    • LambdaのIAMロール
    • Lambdaのロググループ
    • Lambdaのfunction

serverless deployで何が更新されるのか確認してみました(Serverless Framework / AWS) | DevelopersIO

の他、今回はAPI Gatewayまわりも作成されます。

そのため、各IAMロールで必要な権限を考えてみます*2

 

デプロイするための権限を持つIAMロール

デプロイするときは

  • CloudFormationを操作する権限
  • S3に対して中間ファイルを操作する権限
  • CloudFormationにPassRoleする権限

の3つがあれば良さそうです。

また、上記の各権限に対し、Serverless Frameworkで必要な Resource のみに制限します。

 

CloudFormationのための権限を持つIAMロール

実際のAWSリソースを生成するときは、AWSリソースごとの権限があれば良さそうです。

  • S3に対して中間ファイルを操作する権限
  • API Gatewayを操作する権限
  • ログ(CloudWatch)を操作する権限
  • AWS Lambdaを操作する権限

 
また、今回はLambdaの権限をServerless Frameworkで生成するため、以下の権限も必要です。

  • IAMを操作する権限
  • Lambda用ロールにPassRoleする権限

 
こちらも上記の各権限に対し、Serverless Frameworkで必要な Resource のみに制限します。

 

その他IAMまわりで必要なもの

デプロイするためのIAMロールですが、デプロイユーザがAssumeRoleできる必要があります。

そのため、

  1. デプロイするためのIAMロールに対してAssumeRoleできるIAMポリシーを作成
  2. 上記1.のポリシーを割り当てたIAMグループを作成
  3. 上記3のグループに、デプロイするユーザを所属させる

も行います。

 

AWS CDKでIAMまわりを作成する

上記で考えたIAMまわりを手動で作成してもよいのですが、

  • 後で同じようなことをしたくても、使い回しがきかない
  • IAMポリシーのResourceで対象を制限するときに、AWSアカウントIDや対象リソースをtypoしそう

という問題があります。

そこで、AWS CDK + TypeScriptにてIAMまわりを作成します。
@aws-cdk/aws-iam module · AWS CDK

なお、冒頭で書いたとおり、グループへの割当は手動のままとします。

 

AWS CDKまわりの環境構築

CDK本体をインストールします。

>npm install -g aws-cdk

+ aws-cdk@1.90.1

 
続いてCDK appを作成します。今回はTypeScriptで作成します。

なお、 cdk init は空のディレクトリでないと実行できないことから、IAMモジュールの追加は後で行います。

> cdk init app --language typescript
...
✅ All done!

 
最後に、IAMモジュールをインストールします。

> npm install -S @aws-cdk/aws-iam
...
+ @aws-cdk/aws-iam@1.90.1

 

CDK全体の構成

生成されたTypeScriptファイルは、以下の2つがありました。

  • bin/cdk.ts
  • lib/cdk-stack.ts

このうち、cdk.tsはcdk-stack.tsに含まれるクラス CdkStackインスタンス化しているだけなので、実装は cdk-stack.tsCdkStack クラスに行えば良さそうです。

 
まずは、CdkStack.ts に全体像を作ります。

今回は複数のリソースを作成することから、各リソースのまとまりごとにメソッドを3つに分割しておきます。

  • createRoleForCfn()
    • CloudFormation用のIAMロールを作成するメソッド
  • createRoleForDeployUser()
    • デプロイするユーザがAssumeRoleできるIAMロールを作成するメソッド
  • createGroupOfDeployUser()
    • デプロイするユーザが所属するIAMグループを作成するメソッド

 

import * as cdk from '@aws-cdk/core';
import IAM = require('@aws-cdk/aws-iam')
import { Effect } from '@aws-cdk/aws-iam';

// Serverless Frameworkのプロジェクトとステージ
const PROJECT = 'hello-sls'
const STAGE = '*'  // どのステージにも適用できるようにした(必要に応じて、ステージを分ける)

export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const cfnRole = this.createRoleForCfn();
    const deployRole = this.createRoleForDeployUser(cfnRole);
    this.createGroupOfDeployUser(deployRole);
  }
}

 

CloudFormation用のIAMロールをCDKで実装

デプロイするためのIAMロールを作る際CloudFormation用IAMロールのARNが必要となるため、まずはCloudFormation用から作成します。

作成順は

  1. IAMポリシーのステートメント
  2. ステートメントをまとめたユーザ管理ポリシー
  3. ユーザ管理ポリシーを含めたIAMロール

となります。

 

IAMポリシーのステートメント群を作成

IAM.PolicyStatement クラスを使って、IAMポリシーのステートメントを1つずつ作成していきます。

AWSアカウントIDは this.account 、リージョンは this.region でそれぞれ参照できます。

また、resourcesで今回使うServerless Frameworkのリソースだけに絞ります。

なお、API Gatewayの場合、ResourceだけではServerless Frameworkのリソースのみに限定できなかったため、広めの権限を与えています。

// CFnでリソースを操作するロールを作成
createRoleForCfn(): IAM.Role {
  const statements = [];

  // Serverless FrameworkがLambda用Roleを作るときに、権限を渡してあげる
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'iam:PassRole'
    ],
    resources: [
      `arn:aws:iam::${this.account}:role/${PROJECT}-${STAGE}-${this.region}-lambdaRole`
    ]
  }));

  // CFnがS3からデータを取得できるようにする
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:*'
    ],
    resources: [
      `arn:aws:s3:::${PROJECT}-${STAGE}`,
      `arn:aws:s3:::${PROJECT}-${STAGE}/`,
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:ListAllMyBuckets',
      's3:CreateBucket',
    ],
    resources: [
      '*'
    ]
  }));

  // API Gatewayまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'apigateway:GET',
      'apigateway:PATCH',
      'apigateway:POST',
      'apigateway:PUT',
      'apigateway:DELETE'
    ],
    resources: [
      `arn:aws:apigateway:${this.region}::/restapis`,
      `arn:aws:apigateway:${this.region}::/restapis/*`
    ]
  }));

  // Lambdaがログを出力する先であるCloudWatchまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'logs:DescribeLogGroups',
    ],
    resources: [
      `arn:aws:logs:${this.region}:${this.account}:log-group::log-stream:*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'logs:CreateLogGroup',
      'logs:CreateLogStream',
      'logs:DeleteLogGroup',
      'logs:DeleteLogStream',
      'logs:DescribeLogStreams',
      'logs:FilterLogEvents'
    ],
    resources: [
      `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/${PROJECT}-${STAGE}:log-stream:*`
    ]
  }));

  // Serverless FrameworkがLambda用ロールを扱えるようにするための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      "iam:GetRole",
      "iam:GetRolePolicy",
      "iam:CreateRole",
      "iam:DeleteRole",
      "iam:DeleteRolePolicy",
      "iam:PutRolePolicy"
    ],
    resources: [
      `arn:aws:iam::${this.account}:role/${PROJECT}-${STAGE}-${this.region}-lambdaRole`
    ]
  }));

  // Lambdaまわりの権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'lambda:GetFunction',
      'lambda:CreateFunction',
      'lambda:DeleteFunction',
      'lambda:UpdateFunctionConfiguration',
      'lambda:UpdateFunctionCode',
      'lambda:ListVersionsByFunction',
      'lambda:PublishVersion',
      'lambda:CreateAlias',
      'lambda:DeleteAlias',
      'lambda:UpdateAlias',
      'lambda:GetFunctionConfiguration',
      'lambda:AddPermission',
      'lambda:RemovePermission',
      'lambda:InvokeFunction'
    ],
    resources: [
      `arn:aws:lambda:${this.region}:${this.account}:function:${PROJECT}-${STAGE}`
    ]
  }));

  // 2. ステートメントをまとめたユーザ管理ポリシーを書く
  // 3. ユーザ管理ポリシーを含めたIAMロール
}

 

ステートメントをまとめたユーザ管理ポリシー

続いて、ステートメントをまとめたユーザ管理ポリシーを IAM.ManagedPolicy クラスを使って実装します。

statementesに、ステートメント群を指定します。

const cfnPolicy = new IAM.ManagedPolicy(
  this,
  'thinkAmiCfnPolicy',
  {
    managedPolicyName: 'thinkAmi-Serverless-CFn',
    statements: statements
  }
);

 

ユーザ管理ポリシーを含めたIAMロール

最後にIAMロールを作成します。

assumedBy にて、CloudFormationでしか使えないように指定します。なお、CloudFormationはAWSのサービスのため、 assumedBy で使うクラスは IAM.ServicePrincipal です。

return new IAM.Role(
  this,
  'thinkAmiCfnRole',
  {
    roleName: 'thinkAmi-Serverless-CFn-Role',
    assumedBy: new IAM.ServicePrincipal('cloudformation.amazonaws.com'),
    managedPolicies: [
      cfnPolicy
    ]
  }
);

 

デプロイするためのIAMロール

次に、デプロイするためのIAMロールを作成します。

作成順はCloudFormation用のIAMロールと同様です。

 

IAMポリシーのステートメント

CloudFormationで作成するAWSリソースに対する権限はCloudFormation用のIAMロールに任せるため、 iam:PassRole します。

あとは必要な権限を追加します。

// Serverless FrameworkでAWSリソースを扱うためのロールを作成
createRoleForDeployUser(cfnRole: IAM.Role): IAM.Role {
  const statements = []

  // CFnでのリソース作成をCFn用ロールにPassRoleする権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'iam:PassRole',
    ],
    resources: [
      cfnRole.roleArn
    ]
  }));

  // S3を使ってデプロイするための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:*',
    ],
    resources: [
      `arn:aws:s3:::${PROJECT}-${STAGE}`,
      `arn:aws:s3:::${PROJECT}-${STAGE}/*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      's3:ListAllMyBuckets',
      's3:CreateBucket'
    ],
    resources: [
      '*'
    ]
  }));

  // CFnを扱うための権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'cloudformation:CreateStack',
      'cloudformation:UpdateStack',
      'cloudformation:DeleteStack'
    ],
    resources: [
      `arn:aws:cloudformation:${this.region}:${this.account}:stack/${PROJECT}-${STAGE}/*`
    ]
  }));

  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'cloudformation:Describe*',
      'cloudformation:List*',
      'cloudformation:Get*',
      'cloudformation:ValidateTemplate'
    ],
    resources: [
      '*'
    ]
  }));

  // Paramter Storeから値を取得する権限
  // serverless.yml中のdeploymentRoleにて指定するARNをParamter Storeに設定する
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'ssm:GetParameter'
    ],
    resources: [
      `arn:aws:ssm:${this.region}:${this.account}:parameter/hello-sls/CFn-Role`
    ]
  }));
// ...
}

 

ステートメントをまとめたユーザ管理ポリシー

書き方はCloudFormationのときと同じです。

// ユーザ管理ポリシーとして作成
const deployPolicy = new IAM.ManagedPolicy(
  this,
  'thinkAmiDeployPolicy',
  {
    managedPolicyName: 'thinkAmi-Serverless-Deploy',
    statements: statements
  }
);

 

ユーザ管理ポリシーを含めたIAMロール

同じようにIAMロールを作成します。

ただ、AssumeRoleの対象がアカウントIDであるため、 IAM.AccountPrincipal クラスに this.account を渡しています。

// デプロイするユーザ用ロールとして作成
return new IAM.Role(
  this,
  'thinkAmiDeployRole',
  {
    roleName: 'thinkAmi-Serverless-Deploy-Role',
    assumedBy: new IAM.AccountPrincipal(this.account),
    managedPolicies: [deployPolicy]
  }
);

 

デプロイするユーザが所属するグループを作成

デプロイ用IAMロールに対してAssumeRoleできるIAMポリシーをIAMグループに割り当てます。

// Deployするユーザが所属するグループを作成
// Serverless FrameworkでAWSリソースを扱うためのロールに対してAssumeRoleできる権限を、このグループに割り当てる
createGroupOfDeployUser(deployRole: IAM.Role) {
  const statements = [];

  // AssumeRoleする権限
  statements.push(new IAM.PolicyStatement({
    effect: Effect.ALLOW,
    actions: [
      'sts:AssumeRole'
    ],
    resources: [
      deployRole.roleArn
    ]
  }));

  // ユーザ管理ポリシーとして作成
  const assumePolicy = new IAM.ManagedPolicy(
    this,
    'thinkAmiAssumePolicy',
    {
      managedPolicyName: 'thinkAmi-Serverless-Assume-By-User',
      statements: statements
    }
  );

  // グループに割り当て
  new IAM.Group(
    this,
    'thinkAmiAssumeGroup',
    {
      groupName: 'thinkAmi-Serverless-Assume',
      managedPolicies:[assumePolicy]
    }
  );
}

 

CloudFormationテンプレートの確認

cdk synth を使うことで、CDKで実装した内容がCloudFormationテンプレートとして表示されます。

そのため、CDKでデプロイする前にCloudFormation用テンプレートを確認できます。

# cdk.json ファイルがある階層で実施
>cdk synth

 

CDKでのデプロイ

IAMまわりを作成するため、AdministratorAccess権限を持つIAMロールで実行します。

>aws-vault exec admin -- cdk deploy -v
...
 ✅  CdkStack

Stack ARN:
arn:aws:cloudformation:<region>:<account_id>:stack/CdkStack/xxx

 

手動での設定
ユーザをグループに割り当て

手動にて、 thinkAmi-Serverless-Assume グループへデプロイユーザを割り当てます。

 

aws-vault用にデプロイロール設定を追加

既存のgateユーザがデプロイロールを使えるようにするため、 .aws\config へ追記します。

[default]
region=ap-northeast-1
output=json

[profile gate]


# 以下を追加
[profile hello]
source_profile = gate
role_arn = <thinkAmi-Serverless-Deploy-RoleのARN>
role_session_name = thinkAmiHelloDeploy
mfa_serial = <gateユーザが持つMFAのARN>

 

Systems ManagerのParameter Storeにデプロイ用ロールのARNをセット

デプロイ用ロールのARNをserverless.ymlに設定する必要があるものの、ARNの値をハードコーディングしたくありません。

そこで今回は、Systems ManagerのParameter Storeに値を手動で設定します。
Secrets Management for AWS Powered Serverless Applications

名前 種類
/hello-sls/CFn-Role デプロイ用IAM RoleのARN SecureString

 
ここまでで、Serverless Frameworkでデプロイするのに必要な権限まわりの準備は終わりました。

 

Serverless Frameworkによる実装

続いて、Serverless Framewrokでの設定を行います。

 

serverless.yamlでの設定

Serverless FrameworkでデプロイするAWSリソースなどを serverless.yml に記載します。

 

serviceまわり

service名は hello-sls とします。

また、frameworkVersionはデフォルトの 2 のままとします。

 
なお、Serverless Frameworkの2系では、Parameter Storeから値が取得できなくてもデプロイが継続されてしまいます。

一方、今後リリースされる3系ではエラーになるようです。
https://www.serverless.com/framework/docs/deprecations/#PROVIDER_IAM_SETTINGS

そこで今回は、3系と同じくParameter Storeより値が取得できなければエラーとなるように設定します。

service: hello-sls
frameworkVersion: '2'

# Parameter Storeより値が取得できなければエラー
unresolvedVariablesNotificationMode: error

 

provider

通常設定するものの他、Serverless Framework 3系の機能を先取りするよう設定します。

まずはAPI Gatewayの名前を <service>-<stage> とするよう、 apiGateway.shouldStartNameWithService を設定します。
https://www.serverless.com/framework/docs/deprecations/#AWS_API_GATEWAY_NAME_STARTING_WITH_SERVICE

 
また、Serverless FrameworkでCloudFormationを実行するRoleの指定について、今までは provider.cfnRole だったものが今後は provider.iam.deploymentRole になるため、その変更も行います。
https://www.serverless.com/framework/docs/deprecations/#AWS_API_GATEWAY_NAME_STARTING_WITH_SERVICE

 
なお、 deploymentRole の値は Parameter Storeから取得するように指定します。
Secrets Management for AWS Powered Serverless Applications

今回のParamter Storeは SecureString としたため、serverless.ymlで指定する場合は末尾に ~true を付与します。  

provider:
  name: aws
  runtime: python3.8
  lambdaHashingVersion: 20201221
  region: ap-northeast-1
  stage: development

  apiGateway:
    shouldStartNameWithService: true

  iam:
    deploymentRole: ${ssm:/hello-sls/CFn-Role~true}

 

functions

ここからはAPI GatewayとLambdaの設定を行います。

handler.pyhello 関数をLambdaで実行するための設定を行います。

functions:
  hello:
    handler: handler.hello

 
また、API Gatewayも作成します。今回は以下の内容でAPI Gatewayを作成します。

項目
パス /req
メソッド GET
統合リクエス Lambda
マッピングテンプレート - リクエスト本文のパススルー リクエストの Content-Type ヘッダーに一致するテンプレートがない場合
マッピングテンプレート 定義なし

 
serverless.ymlでは以下の通りになります。

なお、 template では2つのContent-Typeに null を設定しています。何も設定しないとデフォルトのテンプレートが設定されてしまうためです。
https://www.serverless.com/framework/docs/providers/aws/events/apigateway#custom-request-templates

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: req
          method: get
          integration: lambda
          request:
            passThrough: WHEN_NO_MATCH
            template:
              application/json: null
              application/x-www-form-urlencoded: null

 
詳細は公式ドキュメントにも記載があります。
Serverless Framework - AWS Lambda Events - API Gateway

 
以上で serverless.yml の設定は完了です。

 

Lambdaの実装

serverless.yml に設定した handler.py を実装します。

今回は動作確認が取れれば良いので、生成されたLambdaをそのまま使います。

import json

def hello(event, context):
    body = {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "input": event
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(body)
    }

    return response

    # Use this code if you don't use the http event with the LAMBDA-PROXY
    # integration
    """
    return {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "event": event
    }
    """

 

デプロイと動作確認

デプロイ

ここまでで準備ができたため、Serverless Frameworkによるデプロイを行います。

デプロイが成功すると、Service Informationが表示されます。

>aws-vault exec hello -- serverless deploy -v

Enter token for arn:aws:iam::<account_id>:mfa/<user>: xxx
...
Serverless: Stack update finished...
Service Information
service: hello-sls
stage: development
region: ap-northeast-1
stack: hello-sls-development
resources: 11
api keys:
  None
endpoints:
  GET - https://path.to.ap-northeast-1.amazonaws.com/development/req
functions:
  hello: hello-sls-development-hello
layers:
  None

Stack Outputs
HelloLambdaFunctionQualifiedArn: arn:aws:lambda:<region>:<account_id>:function:hello-sls-development-hello:8
ServiceEndpoint: https://path.to.ap-northeast-1.amazonaws.com/development
ServerlessDeploymentBucketName: hello-sls-development-serverlessdeploymentbucket-xxx

 

動作確認

ここまでで Lambda + API GatewayAPIができました。

curl でアクセスしてみたところ、APIが動作するのを確認できました。

>curl -i https://path.to.ap-northeast-1.amazonaws.com/development/req

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 121
...
{"statusCode": 200, "body": "{\"message\": \"Go Serverless v1.0! Your function executed successfully!\", \"input\": {}}"}

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/sls_hello_with_cdk_by_min_perm

*1:割り当てるところまでやってしまうとユーザ間での使い回しが面倒なため、一部手動を残しました

*2:と、さくっと書きましたが、実際にはトライアンドエラーでした。「serverless deploy」で失敗したら、CloudFormationのログを確認し、どの権限が不足しているかを確認しながら進めました

Windows + aws-vaultにて、AWSのアクセスキーを保護し、 AWS CLIを AssumeRole で使えるようにしてみた

AWSのIAMアカウントを保護するために、

  • IAMユーザにはほとんど権限を与えない
    • MFAは有効化
  • IAMユーザはIAMグループに所属
  • IAMグループに対し、特定のIAMロールへAssumeRoleするIAMポリシーを割り当て

を行ったりします。

また、aws-vault により、ローカルにあるAWSのアクセスキーを保護したり、AWS CLIを AssumeRole して使ったりします。

 

ただ、上記の参考ページではWindowsではどうなるかが書かれていなかったため、試してみた時のメモを残します。

 

目次

 

環境

 

準備

IAMユーザの作成

aws-vault を使って AssumeRole を試すために、以下の内容でIAMユーザを作成します。

  • 付与するポリシー: AmazonS3FullAccess
    • AssumeRoleできるか検証するため、S3のみFullAccess
  • AWS API Keyを有効化
  • コンソールのパスワードを無効化
    • プログラムでのみ利用可能

 

AWS CLI のインストール

aws-vaultとともに使うため、セットアップされていない場合は以下の手順で AWS CLI をインストールします。

以下より、64bitのWindowsインストーラーをダウンロード・インストールします。
AWS コマンドラインインターフェイス(CLI: AWSサービスを管理する統合ツール)| AWS

インストール後、初期セットアップを行います。

>aws configure

AWS Access Key ID [None]: xxx
AWS Secret Access Key [None]: xxx
Default region name [None]: ap-northeast-1
Default output format [None]: json

 

aws-vaultを使う

aws-vault のインストール

aws-vaultのGithubのREADMEによると、Windows用のインストール方法はいくつか用意されています。
https://github.com/99designs/aws-vault

今回はパッケージマネージャを使わず、実行可能なファイルを使います。

 
Releaseページから aws-vault-windows-386.exe をダウンロードし、任意のフォルダに置きます。

また、今回使いやすくするため、

  • ユーザー環境変数 Path に、ダウンロードしたexeがあるディレクトリを追加
  • 都度 aws-vault-windows-386.exe を入力するのが手間なので、ダウンロードしたファイルを aws-vault.exe にリネーム *1

としました。

その後、Windows Terminalを起動してバージョンが表示されればOKです。

>aws-vault --version
v6.2.0

 

aws-vault 用環境変数を追加

今回、aws-vaultの Vaulting Backends として Encrypted file を使います。
https://github.com/99designs/aws-vault#vaulting-backends  
そこで、以下のユーザー環境変数を追加します。

  • キー: AWS_VAULT_BACKEND
  • 値: file

なお、ファイルは %USERPROFILE%\.awsvault\keys に保管されます。  
 
また、aws-vaultのパスワードを毎回入力するのが手間な場合は、以下もユーザー環境変数に追加します。ただし、環境変数を見るとパスワードが丸見えになるので注意が必要です。

  • キー:AWS_VAULT_FILE_PASSPHRASE
  • 値: aws-vault向けファイルのパスワード

 

aws-vaultにIAMユーザを追加

上記で作成したIAMユーザ gate を追加します。

>aws-vault add gate

Enter Access Key ID: xxx
Enter Secret Access Key: xxx
Enter passphrase to unlock %USERPROFILE%/.awsvault/keys/:
Added credentials to profile "gate" in vault

 

aws-vault経由でAWS CLIが動作するか確認します。IAMユーザに権限のあるS3は操作できました。

>aws-vault exec gate -- aws s3api list-buckets

Enter passphrase to unlock %USERPROFILE%/.awsvault/keys/:
{
    "Buckets": [],
    "Owner": {
        "DisplayName": "xxx",
        "ID": "xxx"
    }
}

 
一方、権限のないLambdaの操作はできません。

>aws-vault exec gate -- aws lambda list-functions

An error occurred (AccessDeniedException) when calling the ListFunctions operation ...

 
動作は良さそうだったため、 %USERPROFILE%/.aws/credentials より、すべての内容を削除します。

これでAWSのアクセスキーが平文で保存しなくて済むようになりました。

 

AssumeRoleを使う

ここまではIAMユーザにIAMポリシーが紐付いていました。

次はIAMユーザにIAMポリシーを直接紐付けるのではなく、管理者IAMロールに対してAssumeRoleするように変更します。

 

管理者IAMロールの作成

以下の内容で管理者IAMロールを作成します。

  • 名前:thinkAmiAdmin
  • ポリシーにはAWS 管理ポリシーの AdministratorAccess をセット
  • 信頼されたエンティティに、AWSアカウントIDを設定

 

AssumeRoleできるIAMポリシーを作成

まずはAssumeRoleできるIAMポリシーを作成します。

AssumeRole先を制限するため、Resourceに上記で作成した thinkAmiAdmin を指定します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::<account_id>:role/thinkAmiAdmin"
        }
    ]
}

 

AssumeRoleできるIAMポリシーを、IAMユーザに追加

続いて、上記のIAMポリシーをIAMユーザに追加します。

これにより、IAMユーザには

  • AmazonS3FullAccess
  • thinkAmiAdminにAssumeRole可能

な権限が付与されます。

 

aws-vault向けに .aws/config を編集

AWS CLIからAssumeRoleできるよう、.aws/config を変更します。

[profile gate]

[profile admin]
source_profile = gate
role_arn = arn:aws:iam::<account_id>:role/thinkAmiAdmin

 

動作確認

AWS CLIを使って確認します。

AssumeRoleでLambdaまわりも操作できています。

>aws-vault exec admin -- aws lambda list-functions
{
    "Functions": []
}

 

参考:パスワードレスサインインについて

さらにIAMユーザをセキュアにするため、書籍「クラウド破産を回避するAWS実践ガイド - KOS-MOS - BOOTH」に従い、パスワードレスサインインを試してみました。

すると、IAMユーザに追加したMFAを入力するだけで、ログイン済のAWS コンソールがブラウザに表示されました。

>aws-vault login admin

Enter token for arn:aws:iam::<account_id>:mfa/gate: xxx

# ブラウザが起動し、AWSコンソールでログイン済になる

*1:手元では更に短い 「av」 にしていますが、Blog上ではわかりやすいよう 「aws-vault」 にしています

Python + PuLP + ortoolpy による組合せ最適化を使って、行事の当番表を作ってみた

最近、行事の当番表を作る機会がありました。

行事の回数や当番対象の人数がそれなりだったこと、「今日の当番は何でこの組み合わせなの?」と質問された時に「プログラムが勝手にやりました」と答えたかったことから、プログラムを作って解決することにしました。

 
目次

環境

  • Python 3.9.1
  • ortoolpy 0.2.38
  • pandas 1.2.0
  • numpy 1.19.5
  • openpyxl 3.0.6
    • Excelへ出力する時に使用

 

ランダムな組み合わせ編

実装

当番をする人の組み合わせは自由とのことでした。

その他の条件を聞いたところ、以下でした。

  • 行事は18回
  • 一行事あたり3名の当番が必要
  • 一人1回当番をすれば良い

 
そこで、上記の条件を満たしつつ、「行事ごとにグループを作り、そのグループへランダムに人を割り当てる」仕様にて、Pythonスクリプトを書きました。

# shuffle.py
GROUP_COUNT = 18

def divide_users(users):
    current_index = 0
    
    # 当番の人のリストを要素として持つ、行事のリスト
    groups = [[] for _ in range(GROUP_COUNT)]

    random.shuffle(users)

    for user in users:
        groups[current_index].append(user)

        if current_index < GROUP_COUNT - 1:
            current_index += 1
        else:
            current_index = 0

    return groups

 

テスト

動作を確認するため、テストコードを用意します。

# test_shuffle.py
import unittest
from shuffle import divide_users

class TestShuffle(unittest.TestCase):
    def test_divide_users(self):
        users = [f'user{i}' for i in range(1, 54 + 1)]
        group = divide_users(users)

        user_set = set()
        for users in group:
            # 1当番3名か
            self.assertEqual(len(users), 3)

            # 1回のみ割り当てられているか
            for user in users:
                self.assertNotIn(user, user_set)
                user_set.add(user)


if __name__ == '__main__':
    unittest.main()

 
実行したところ、テストがパスしました。

% python test_shuffle.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

 
これで良さそうです。

 

組合せ最適化編

前置き

ひとまず作ってみたものの、現実には「この日はちょっと用事があるので、当番はできない」がありそうです。

そこで各個人で「行事ごとに、当番可/不可を記入」したリストを用意してもらう前提で、プログラムを改修することにしました。

 
当番表の作成はアルバイトのシフトを組む感じなので、組合せ最適化とかナーススケジューリングの方面でやり方を探したところ、参考となる記事がありました。

 
遺伝的アルゴリズムは面白そうだったのですが、今の自分だと yak shaving になりそうだったので、組合せ最適化で作ることにしました。

 

目的関数と制約条件

ナーススケジューリングの場合、一人が複数回シフトに入ります。一方、今回は一人1回のシフトです。

そこで、組合せ最適化でナーススケジューリング問題を解く - Qiita をベースにしつつ、目的関数と制約条件を差し替えることにしました。

 
ベースコードでは目的関数として複数挙げられています。ただ、今回は「必要人数差」だけそうです。これが最小になるものを求めます。

次に制約条件ですが、こちらはベースコードとはだいぶ異なります。

  • 1当番あたり3人
  • 一人あたり1回の当番
    • 複数回は不可
  • その人が当番可能な時に割り当てる
    • 無理に当番をお願いすることはできない

 
また、各個人からもらったリストを元に、当番可/不可のExcel表を作成します。その書式は以下のとおりです。

    • 行事の開催回数
    • 当番対象者
  • セル
    • 1 が当番可
    • 0 が当番

 
イメージはこんな感じです。

f:id:thinkAmi:20210202082108p:plain

 

実装

Excelを読んで当番のグループを作る関数を用意します。

 

全体像

個別の実装は後述するとして、まずは全体像です。

def divide_users():
    # デフォルトだと 54 x 54 で読まれるので、不要行は読み込まないようにする
    skip_rows = [i + GROUP_COUNT for i in range(1, USER_COUNT - GROUP_COUNT + 1)]
    df = pd.read_excel(FILE_NAME, header=0, index_col=0, skiprows=skip_rows)

    # 当番回数
    event_count = df.shape[0]
    # print(f'{type(box_size)}: {box_size}')
    # => <class 'int'>: 18

    # ユーザ数
    user_count = df.shape[1]
    # print(f'{type(user_size)}: {user_size}')
    # => <class 'int'>: 54

    # 数理モデル
    model = LpProblem()

    # 変数を準備(当番/非当番の2値なので、0-1変数リスト)
    # https://docs.pyq.jp/python/math_opt/pdopt.html
    var_schedule = np.array(addbinvars(event_count, user_count))
    df['必要人数差'] = addvars(event_count)

    # 重み
    weight = 1

    # 目的関数の割り当て
    model += lpSum(df.必要人数差) * weight

    # 制約
    # 1当番あたり3人
    for idx, row in df.iterrows():
        model += row.必要人数差 >= (lpSum(var_schedule[row.name]) - 3)
        model += row.必要人数差 >= -(lpSum(var_schedule[row.name]) - 3)

    # 一人あたり1回当番すればよい
    for user in range(user_count):
        scheduled = [var_schedule[event, user] for event in range(event_count)]
        model += lpSum(pd.Series(scheduled)) <= 1

    # 当番可能なイベントだけ割り当てる
    df_rev = df[df.columns].apply(lambda r: 1 - r[df.columns], 1)
    for (_, d), (_, s) in zip(df_rev.iterrows(), pd.DataFrame(var_schedule).iterrows()):
        model += lpDot(d, s) <= 0

    # 実行
    model.solve()

    # 結果取得
    vectorized_results = np.vectorize(value)(var_schedule).astype(int)
    # print(type(vectorized_results))
    # => <class 'numpy.ndarray'>

    group = [[] for _ in range(event_count)]
    for i, vectorized_result in enumerate(vectorized_results):
        for result, name in zip(vectorized_result, df.columns):
            if result * name:
                group[i].append(name)

    return group

 

個別に見る

まずはpandasでExcelからデータを読み込みます。

    skip_rows = [i + GROUP_COUNT for i in range(1, USER_COUNT - GROUP_COUNT + 1)]
    df = pd.read_excel(FILE_NAME, header=0, index_col=0, skiprows=skip_rows)

    # 当番回数
    event_count = df.shape[0]

    # ユーザ数
    user_count = df.shape[1]

 
数理モデルを作成します。

model = LpProblem()

 
次に、ortoolpyを使った変数を用意します。
データ分析と最適化 — Pythonオンライン学習サービス PyQ(パイキュー)ドキュメント

今回は当番/非当番の2値を持っていれば良いので、0-1変数リストとします。

# スケジュールされるもの
var_schedule = np.array(addbinvars(event_count, user_count))

# スケジュールした時の人数差
df['必要人数差'] = addvars(event_count)

 
次は目的関数です。重みを乗じた上で値が最小となるものを探します。

# 重み
weight = 1

# 目的関数の割り当て
model += lpSum(df.必要人数差) * weight

 
制約条件その1は「1当番あたり3人」です。こちらはベースと変わりません。

for idx, row in df.iterrows():
    model += row.必要人数差 >= (lpSum(var_schedule[row.name]) - 3)
    model += row.必要人数差 >= -(lpSum(var_schedule[row.name]) - 3)

 
制約条件その2は「一人あたりの当番は1回」です。

ユーザーごとにイベントで当番が割り当てられたかをサマリし、回数が1になっているものとします。

for user in range(user_count):
    scheduled = [var_schedule[event, user] for event in range(event_count)]
    model += lpSum(pd.Series(scheduled)) <= 1

 
制約条件その3は「当番可能なときだけ割り当てる」です。
Pythonでシフトを自動作成するアプリを作成、運用した話 | 機械学習、ウェブ開発、自動化の備忘録

df_rev = df[df.columns].apply(lambda r: 1 - r[df.columns], 1)
for (_, d), (_, s) in zip(df_rev.iterrows(), pd.DataFrame(var_schedule).iterrows()):
    model += lpDot(d, s) <= 0

 
あとは、実行した後、当番グループを作っています。

# 実行
model.solve()

# 結果取得
vectorized_results = np.vectorize(value)(var_schedule).astype(int)
# print(type(vectorized_results))
# => <class 'numpy.ndarray'>

group = [[] for _ in range(event_count)]
for i, vectorized_result in enumerate(vectorized_results):
    for result, name in zip(vectorized_result, df.columns):
        if result * name:
            group[i].append(name)

return group

 

テスト

今回もテストコードにて確認します。

 

テストデータ作成

テストをするために、適当な当番可/不可一覧のExcelファイルを生成します。

当番可能なのは、一人あたり全体の6割とします。

import copy
import random

import openpyxl

OK = [1] * 10  # 18回中6割がOK
NG = [0] * 8

FILE_NAME = 'users_for_optimization.xlsx'
GROUP_COUNT = 18
USER_COUNT = 54


def main():
    wb = openpyxl.Workbook()
    ws = wb.worksheets[0]

    # タイトル行
    name_cell = ws.cell(row=1, column=1)
    name_cell.value = '開催番号'

    for i in range(2, USER_COUNT + 2):
        name_cell = ws.cell(row=1, column=i)
        name_cell.value = f'ユーザ_{i - 1}'

    # 開催回数列
    for i in range(2, GROUP_COUNT + 2):
        group_cell = ws.cell(row=i, column=1)
        group_cell.value = i - 2

    # 54人分のデータ
    for i in range(2, USER_COUNT + 2):
        ok = copy.deepcopy(OK)
        ng = copy.deepcopy(NG)
        total = ok + ng
        random.shuffle(total)

        # 18回の情報を埋める
        row = 2
        while total:
            result = total.pop()
            cell = ws.cell(row=row, column=i)
            cell.value = result

            row += 1

    wb.save(FILE_NAME)


if __name__ == "__main__":
    main()

 

テストコード

関数 divide_users() の戻り値が条件を満たしているかを確認します。

import unittest

import openpyxl

from make_data import FILE_NAME, USER_COUNT, GROUP_COUNT
from optimization import divide_users


class TestOptimization(unittest.TestCase):
    def create_ok_list(self):
        ok_list = {i: [] for i in range(18)}

        wb = openpyxl.load_workbook(FILE_NAME)
        ws = wb.worksheets[0]

        for col_index in range(2, USER_COUNT + 2):
            user_name = ws.cell(row=1, column=col_index).value

            for row_index in range(2, GROUP_COUNT + 2):
                if ws.cell(row=row_index, column=col_index).value == 1:
                    ok_list[row_index - 2].append(user_name)

        return ok_list

    def test_divide_users(self):
        group = divide_users()

        user_set = set()
        for users in group:
            # 1当番3名か
            self.assertEqual(len(users), 3)

            # 1回のみ割り当てられているか
            for user in users:
                self.assertNotIn(user, user_set)
                user_set.add(user)

        # 自分の希望した場所のみか
        ok_list = self.create_ok_list()
        for i, users in enumerate(group):
            for user in users:
                self.assertIn(user, ok_list[i])


if __name__ == '__main__':
    unittest.main()

 
実行するとテストをパスするため、これで良さそうです。

% python test_optimization.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.154s

OK

 

ソースコード

Githubに上げました。

https://github.com/thinkAmi/toban_kuji

2020年の振り返りと2021年の目標

例年通り、2020年の振り返りと2021年の目標っぽいものを書いてみます。

 

2020年の振り返り

2019年の振り返りと2020年の目標 - メモ的な思考的なで立てた目標を振り返ってみます。

 

色々な分野の素振り

2020年は公私ともにいろんな言語やフレームワークにさわりました。

深さはそれぞれですが、以下のような感じでした。

 

レーニングの復活

まずはウォーキングから、ということで2020年の新年から始めました。

ドラクエウォークをお供にウォーキングを続けたところ、360万歩程度歩けました。

f:id:thinkAmi:20210101203247p:plain

 
夏過ぎには1日49kmくらい歩いたり、ランニングもできるようになりました。

 

引き続き、何らかの試験を受ける

残ってたITストラテジスト試験を受けようと思いましたが、2021年春に延期されたため、結局何も受けず...
IPA 独立行政法人 情報処理推進機構:情報処理技術者試験:令和3年度春期試験の実施予定について

 

その他

COVID-19の影響もあり、2020年は全般的にパブリックな活動が減ってしまいました。

イベント

ほとんどオンラインでしたが通しでは参加できず、Blogにも残せませんでした。

オフラインの方が時間確保しやすいのを実感しました。

 

GitHub

2020年はアウトプットが足りてないですね...

f:id:thinkAmi:20201231123830p:plain

 

2021年の目標っぽいもの

新年から新天地ということもあり、今年も自分の中で完結できる

  • 新しい環境に溶け込む
  • レーニングの継続と筋トレの復活

を目標っぽいものにします。

 
というところで、今年もよろしくお願いします。

Raspberry Pi と python-cec で、HDMI CEC を経由してテレビの電源ON/OFFや音量調整を行う

先日Google Nest miniをお迎えしたので、家のテレビを操作してみようと思ったところ、手元のテレビでは直接の操作に対応していませんでした。

Google Nest mini以外でもテレビを操作する方法がないかを調べたところ、HDMI CECを使えばいけそうでした。

 
手元にある道具では、Raspberry PiとテレビをHDMIで接続し、libceccec-client を使えば良さそうでした。
Raspberry Pi のcec 制御とHDMIのオンオフ - それマグで!

 
せっかくなのでPythonでlibcecを直接扱う方法がないかを調べたところ python-cec がありました。READMEには「libcec bindings for Python」と書かれていました。
trainman419/python-cec

最新リリースは 2018/11/9 でしたが、Github上では今年もcommitされていたため、試してみることにしました。

なお、久しぶりにRaspberry Piをさわるため、セットアップするところからメモに残します。

 
目次

 

環境

開発はWindows上で、実行はRaspberry Piで行います。

Raspberry Pi

 

Windows 10

 

テレビ

 

ネットワーク構成図

Windows10 - Raspberry Pi間は有線LAN、Raspberry Pi - テレビ間はHDMIとします。

-----------------------------------------
Windows10
[IPアドレス:DHCP (192.168.0.xxx)]
-----------------------------------------
|
(LANケーブル)
|
-----------------------------------------
スイッチングハブ
-----------------------------------------
|
(LANケーブル)
|
-----------------------------------------
(`eth0` : オンボードLANアダプタ)
Raspberry Pi 2 Model B
[IPアドレス:固定 (192.168.0.50)]
-----------------------------------------
|
(HDMIケーブル)
|
-----------------------------------------
テレビ
-----------------------------------------

 

事前準備

Windows Terminalの準備

以下を参考に、Windows TerminalをMicrosoft storeからインストールします。また、自分がPowerShellに慣れていないため、デフォルトで cmd.exe が動くように切り替えます。
Windows Terminal Tips - Qiita

 

Raspberry Pi ImagerによるOS書き込み

最近は Raspberry Pi Imager を使ってmicroSDにOSを書き込むようになっていました。
「圧倒的に速い」──ラズパイにOSをインストールする新ツール「Raspberry Pi Imager」 (1/2) - ITmedia NEWS

今回はCLIでしかラズパイを使いませんが、ひとまず Raspbian を選んで書き込んでおきます。

 

Raspberry PiにてSSHを許可

以下を参考に、WindowsにOSの入ったmicroSDを接続し、Windows Terminalを使ってmicroSD上に ssh ファイルを置いておきます。

# microSDへ移動
>cd /d E:\

# 空のsshファイルを作成
E:\>cd . > ssh

# 確認
E:\>dir
 ドライブ E のボリューム ラベルは boot です
...
2020/12/31  09:08                 0 ssh

 
ちなみに、macの場合は以下のコマンドでsshファイルを作成します。

% touch /Volumes/boot/ssh

 

Raspberry Piの起動と接続確認

OSの入ったmicroSDRaspberry Piに接続し、電源を入れます。

その後、Windows TerminalからSSHで接続確認をします。

# パスワード認証によるSSH接続
>ssh pi@raspberrypi.local
pi@raspberrypi.local's password: <raspberry>

# Raspberry Piのバージョン確認
$ lsb_release -a
No LSB modules are available.
Distributor ID: Raspbian
Description:    Raspbian GNU/Linux 10 (buster)
Release:        10
Codename:       buster

 

Raspberry Piのログインを公開鍵認証に切り替え

パスワード認証から公開鍵認証に切り替えます。

 

Windows TerminalでSSH鍵の生成
# ssh-kegenで生成し、ログインユーザの .ssh フォルダにSSH鍵 pi_rsa を入れる
>ssh-keygen -t rsa -b 4096 -f %USERPROFILE%/.ssh/pi_rsa

# パスフレーズなし
Enter passphrase (empty for no passphrase):

 

SSH用公開鍵をRaspberry Piに登録

Raspberry Piに公開鍵でSSHするために、Windowsで作成した公開鍵を登録します。

しかし、Windowsには ssh-copy-id コマンドがありません。

代替案はいくつかあるようです。
Is there an equivalent to ssh-copy-id for Windows? - Server Fault

上記方法でも良いのですが、手元に何かないかなと思ったところ、Git bashがインストールされていることを思い出しました。

Git bashには ssh-copy-id コマンドがあったため、使ってみます。

$ ssh-copy-id -i ~/.ssh/pi_rsa.pub pi@raspberrypi.local
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
pi@raspberrypi.local's password:

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh 'pi@raspberrypi.local'"
and check to make sure that only the key(s) you wanted were added.

 
登録できたようなので、Windows Terminalから公開鍵方式でログインしてみます。

# 公開鍵認証によるSSH
>ssh -i %USERPROFILE%/.ssh/pi_rsa pi@raspberrypi.local

# Raspberry Piのバージョン確認
$ lsb_release -a
No LSB modules are available.
...

 

Raspberry Pivimを入れる

デフォルトでは vim-tiny なので、 vim に差し替えます。
RaspberryPi3のセットアップ続き〜VimやNFS設定 - Qiita

# アンインストール
$ sudo apt-get --purge remove vim-common vim-tiny

# vimをインストール
$ sudo apt-get install vim

 

Raspberry Piを固定IP化

以前行ったとおり、 /etc/dhcpcd.conf を修正し、固定IP化します。
Python2 + Scapyで、Raspberry Pi 2 Model B をブリッジにできるか試してみた #router_jisaku - メモ的な思考的な

# /etc/dhcpcd.conf を開く
$ vi /etc/dhcpcd.conf

# 末尾に追加
interface eth0
static ip_address=192.168.0.50/24
static routers=192.168.0.1
static domain_name_servers=192.168.0.1

 
追加した内容で有効化します。

$ sudo service dhcpcd reload

 
IPアドレスが変更となるので、Windows TerminalのSSH接続が切れます。そのため、再接続します。

> ssh -i %USERPROFILE%/.ssh/pi_rsa pi@raspberrypi.local

 

/boot/config.txtの編集

デフォルトでは、Raspberry PiHDMI接続したときにCEC信号が送られてしまうため、それを無効化しておきます。

$ vi /boot/config.txt

# 以下を追加
hdmi_ignore_cec_init=1

 

Raspberry PiのデフォルトのPythonをPython3にする

Raspberry PiのデフォルトのPythonのバージョンを見たところ、Python2系でした。

そのため、デフォルトをPython3系へと切り替えます。インストール済はPython3.7でしたが、今回扱う範囲では問題なかったので、Python3系の最新にはしません。
RaspberryPiでPythonのデフォルトをPython2.7からPython3に変更する | そう備忘録

# シンボリックリンクの確認
$ ls -l /usr/bin | grep python
...
lrwxrwxrwx 1 root root          7 Mar  4  2019 python -> python2
...
lrwxrwxrwx 1 root root          9 Mar 26  2019 python3 -> python3.7
...

# 変更
$ cd /usr/bin
pi@raspberrypi:/usr/bin $ sudo unlink python
pi@raspberrypi:/usr/bin $ sudo ln -s python3 python

# バージョン確認
$ python --version
Python 3.7.3

 

cec-clientのインストールと動作確認

まずは、Raspberry PiからHDMI CECを使った操作ができるかを確認します。

cec-clientは cec-utils に含まれるため、インストールします。

$ sudo apt-get update
$ sudo apt-get upgrade -y
$ sudo apt-get install cec-utils -y

 
cec-clientの動作確認をします。

$ sudo cec-client -l
libCEC version: 4.0.4, compiled on Linux-4.15.0-48-generic ... , features: P8_USB, DRM, P8_detect, randr, RPi, Exynos, AOCEC
Found devices: 1

device:              1
com port:            RPI
vendor id:           2708
product id:          1001
firmware version:    1
type:                Raspberry Pi

 
cec-clientを使った操作ですが、Raspberry Pitvservice をoffにしておかないと動作しません。

$ echo "scan" | cec-client -d 1 -s

# エラーが出て動かない
log level set to 1
opening a connection to the CEC adapter...
ERROR:   [             421]     RegisterLogicalAddress - CEC is being used by another application. Run "tvservice --off" and try again.
ERROR:   [             421]     Open - vc_cec could not be initialised
ERROR:   [             421]     could not open a connection (try 1)

 
そこで、 tvserviceをoffにします。
Raspberry Pi Documentation

$ tvservice -o
Powering off HDMI

 
再度実行すると、scanや電源ON/OFFができました。

# Scan
$ echo "scan" | cec-client -d 1 -s
log level set to 1
opening a connection to the CEC adapter...
requesting CEC bus information ...
CEC bus information
===================
device #0: TV
address:       0.0.0.0
active source: no
vendor:        Unknown
osd string:    TV
CEC version:   1.4
power status:  standby
language:      ???


device #1: Recorder 1
address:       1.0.0.0
active source: no
vendor:        Pulse Eight
osd string:    CECTester
CEC version:   1.4
power status:  on
language:      eng


currently active source: unknown (-1)

# 電源ON
$ echo 'on 0' | cec-client -s 
...
DEBUG:   [            1814]     >> TV (0) -> Recorder 1 (1): report power status (90)
DEBUG:   [            1814]     expected response received (90: report power status)
DEBUG:   [            1814]     << requesting vendor ID of 'TV' (0)
DEBUG:   [            1814]     'give device vendor id' is marked as unsupported feature for device 'TV'
NOTICE:  [            1814]     << powering on 'TV' (0)
TRAFFIC: [            1815]     << 10:04
DEBUG:   [            1906]     TV (0): power status changed from 'standby' to 'in transition from standby to on'

# 電源OFF (スタンバイ)
$ echo 'standby 0' | cec-client -s
...
TRAFFIC: [            2590]     >> 01:9f
DEBUG:   [            2591]     >> TV (0) -> Recorder 1 (1): get cec version (9F)
TRAFFIC: [            3351]     >> 0f:36
DEBUG:   [            3351]     TV (0): power status changed from 'on' to 'standby'
DEBUG:   [            3351]     >> TV (0) -> Broadcast (F): standby (36)

 

python-cecを使った操作

ここからが本題です。

今回はpython-cecを使い、Raspberry Piからテレビを操作します。
trainman419/python-cec

 

Windows上のPyCharmのPythonインタプリタRaspberry PiPythonにする

Raspberry Pi上で実装しても良いですが、せっかくので、ローカルのWindows上で実装したものをRaspberry Pi上で実行することにします。

なお、この方法はPyCharm Professionalが必要です。

 

Raspberry Pi上でvenv上にpython-cecを入れる
# ディレクトリを作り移動
pi@raspberrypi:~ $ mkdir projects
pi@raspberrypi:~ $ cd projects/
pi@raspberrypi:~/projects $ mkdir python_cec_sample
pi@raspberrypi:~/projects $ cd python_cec_sample

# venv環境を作る
$ python -m venv env
$ source env/bin/activate

# python-cecを入れる
$ pip install cec --no-cache-dir
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting cec
  Downloading https://www.piwheels.org/simple/cec/cec-0.2.7-cp37-cp37m-linux_armv7l.whl (146kB)
    100% |████████████████████████████████| 153kB 268kB/s
Installing collected packages: cec
Successfully installed cec-0.2.7

 

Windows上でPyCharmからプロジェクトを作成する

File > New Project... から新しいPythonプロジェクトを作成します。

設定は以下のようにします。

  • 左ペインで Pure Python を選択
  • 右側の Location に、ローカルに保存する場所(例:D:\projects\python_cec_sample) を指定
  • Python Interpreter欄にある、 Previously configured Interperter の右ボタンより、 Add Python Interpreter へ遷移
  • SSH Interpreter を選択
  • Existing server configuration の右ボタンより SSH configurations へ遷移
  • + を押して追加
  • 設定内容
    • Host: 上記で設定したRaspberry Piの固定IP (192.168.0.50)
    • Port: 22
    • Username: pi
    • Authentication type: Key pair
    • private key file: 上記で作ったprivateキー pi_rsa の場所
    • Passphrase: 空欄
  • Test connectionをクリック、接続できればOKとする
  • Connected to pi@192.168.0.50:22 のInterpriter指定は、Raspberry Piのvenv環境のPythonを指定 (/home/pi/projects/python_cec_sample/env/bin/python)
  • Execute code using this interpreter with root privileges via sudo にチェックを入れる
  • Remote project locationには、 /home/pi/projects/python_cec_sample を指定

 
上記により、PyCharm上で import ce と入力したときの補完が効くようになります。

もし補完が効かない場合は、以下を参考にリモートの再読み込みを行います。
【PyCharm】リモートインタプリタでライブラリ追加した際に正しく認識させる方法 | ゆとって生きたい。

  • Project Interpreterの歯車マークで Show All... を選択
  • ツリーマーク (Show paths for the selected interpreter) をクリック
  • リフレッシュマーク (Reload List of Paths) をクリック

 

Pythonスクリプトの作成

python-cecのREADMEを読むと、できることが一通り書かれています。

そのため、以下のようなPythonスクリプトを用意します。

このPythonスクリプトを実行すると、テレビの電源ON/OFFや音量調整ができたり、情報を出力できました。

import cec

def main():
    cec.init()

    tv = cec.Device(cec.CECDEVICE_TV)

    # 電源が入っているか
    print(tv.is_on())
    # => True / False


    # if tv.is_on():
    #     # 電源がONの場合、次はスタンバイにする
    #     tv.standby()
    # else:
    #     # 電源が入っていない場合、電源を入れる
    #     tv.power_on()

    # ベンダ
    print(tv.vendor)
    # => 000000

    # 言語
    print(tv.language)
    # => ??? (電源ONの場合は、jpn)

    print(tv.osd_string)
    # => TV

    print(tv.cec_version)
    # => 1.4

    # 音量周りは、一度にどちらかだけ
    # 音量を一段階上げる
    # cec.volume_up()
    # 音量を一段階下げる
    cec.volume_down()


if __name__ == '__main__':
    main()

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/python_cec-sample

ダンボールに入れた本を管理するDjangoアプリ「danborary」を作った

家の本棚スペースが限られているため、年末の大掃除であまり読まなくなった本をダンボールに詰めようと考えました。

ただ、何も考えずに詰めると、どの箱に何の本があるか分からなくなります。

そこで、今年さわった技術を使って、ダンボールに入れた本を管理するDjangoアプリ danborary を作りました*1

 

目次  

 

環境

 

機能概要

  • ダンボールに管理用バーコードを貼り付けるため、印刷用バーコードラベルをpdf形式で作成する
  • 書籍のISBNを元に、国立国会図書館サーチの検索APIを使い、タイトルなどを取得する
  • ダンボールの管理用バーコードと書籍のISBNを紐付けて、SQLiteへ保存する
  • ダンボールに詰めた本は、jQuery Datatablesによりグリッドで表示する
  • できる限り、バーコードの読み取りだけで保存までできるUIにする

 

機能詳細

ラベル印刷

印刷用バーコードラベルで使うバーコード形式は、

  • 手元にあるバーコードスキャナが1次元しか対応していない
  • ダンボールの量はそんなに多くない

ことから、Code39としました。

 
Djangoアプリでpdfを作成する方法は以前やった方法を流用します。
Django + ReportLabをHerokuで動かしてpdfを表示する - メモ的な思考的な

印刷用ラベルは、A-oneの品番 28923 を使い、A4サイズに1片70mm×42.3mmのシールとして用意します。
[ 28923:ラベルシール[インクジェット] ] - 商品情報|ラベル・シールのエーワン

以下の記事を参考に、ラベルの中心あたりに印字するように調整します。
PDFをpythonで生成してみる

また、ラベルをなくしてしまっても再生成できるよう、URLで開始番号を指定します (http://localhost:8000/packing/print/start/<開始番号>/)。

できたものはこんな感じです。

f:id:thinkAmi:20201228104120j:plain

 

メニュー

印刷と箱詰めを切り替えられるよう、メインメニューを用意します。

f:id:thinkAmi:20201228103041j:plain

 

箱詰め画面

BootstrapとjQuery Datatablesを使った画面です。

f:id:thinkAmi:20201228103137j:plain

 
一番上の検索欄に書籍のISBNを読み込ませると、国立国会図書館の検索APIへリクエストし、書籍データを画面に反映します。

なお、検索APIを短期間で多数使うと問題があるかもしれないので、強制的に1秒くらい time.sleep させています。

また、同じISBNを読み込んだ場合は、検索APIを使わずにデータベースの内容を取得するようにしています。

f:id:thinkAmi:20201228103422j:plain

 
あとは、巻数を入力して登録します。

国立国会図書館の検索APIで巻数が取れなかったのですが、

  • 手元に1から始まるバーコードを用意しておけば、バーコードを読み込ませるだけで済む
  • 箱詰めする書籍はそんなに多くない

と考えて、自分で入力する形としました。

登録するとこんな感じになります。

f:id:thinkAmi:20201228103940j:plain

 

作らなかった機能

年末の大掃除前についカッとなって作ったDjangoアプリであり、以前からあたためていたものではありません。

そのため、「年末の大掃除」という絶対の納期を守らないといけないことから、いくつかの機能を省いています。

 

削除機能

DB自体を消せばいいし、ということで削除機能は用意しませんでした。

 

所有している本の管理機能

本を管理するには本自体にもバーコードを貼る必要がありそうでした。

ただ、今回は「ダンボールに入れた本を管理する」という目的だったので、工数がかかりそうなこの機能は不要と考えました。

 

技術的なところ

国立国会図書館の検索API用ライブラリについて

APIの仕様書を見ると、いくつか用意されているインタフェースのレスポンスは、いずれもXML形式のようでした。
API仕様の概要 « 国立国会図書館サーチについて

そこでラッパーライブラリがないかを探してみたところ、 pyndlsearch があったため、使うことにしました。
https://github.com/nocotan/pyndlsearch

 

DjangoアプリのView

今回はグリッド表示でjQuery Datatablesを使うことから、その部分のViewは django-datatables-view で実装しました。
https://bitbucket.org/pigletto/django-datatables-view/

一方、今後 jQueryから別のライブラリに移行するかもしれないことを考え、他の部分はDjango REST frameworkのViewで実装し、アプリも分けておきました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/danborary

*1:ダンボールとライブラリ(図書館)を組み合わせた名前にしました