NLB配下のSMTPサーバー(Postfix)でSendGridのSMTP認証をしてメールを送信してみた

2024.01.12

PostfixでSMTPサーバー構築したい

こんにちは!AWS事業本部のおつまみです!

皆さんはPostfixでSMTPサーバー構築したいと思ったことはありますか? 私はあります。
今回案件でNLB + SMTP(Postfix) + SendGrid(SMTP認証)の実装しました。

実装中にハマったポイントもあるので、手順とともにご紹介したいと思います!

なお同じ構成でSMTP認証をGmailで実施した記事はこちらになります。(今回大変参考にさせていただきました。ありがとうございます!)

構成図

今回の構成図です。

今回主役となるMTA(Message Transfer Agent)にPostfixをインストールし、Multi-AZ構成にしてNLBにぶら下がるように構築します。
メール配信の流れは以下です。

  • MUA(Message User Agent)
  • NLB
  • MTA(Message Transfer Agent)
  • SendGrid

環境構築

事前準備

まずはメール送信元に使用するドメインおよびSendGridアカウントを準備します。
ドメインはお名前.comやRoute53などお好きな場所からドメインを購入してください。

SendGridはこちらのサイトから無料登録できます。(登録には2~3営業日時間がかかりました。)

SendGrid | クラウドメール配信サービス・メルマガ配信システム

アカウント取得後、SMTP認証に使用するAPIキーを取得します。公式サイトで手順を確認し、取得してください。

APIキーを管理する - ドキュメント | SendGrid

AWSのアクセスキー同様、APIキーは外部に公開されないよう厳重に管理しましょう!

CDK構築初期準備

今回はAWS CDKで環境構築していきます。
利用したCDK versionは2.117.0です。

CDKを使用したことない方はこちらの公式ドキュメントに従って、環境を整備してから始めましょう。

AWS CDK の開始方法 - AWS Cloud Development Kit (AWS CDK) v2

作成するファイル

今回は1つのスタックファイルでまとめて作成しました。
ハイライトをかけた部分がハマったポイントです。

/lib/test-mail.ts

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as logs from "aws-cdk-lib/aws-logs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as elbv2_tg from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets'
import * as fs from "fs";

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

    // Create S3 Bucket for NLB access log
    const nlbAccessLogBucket = new s3.Bucket(this, "NlbAccessLogBucket", {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      }),
    });
    console.log(nlbAccessLogBucket.bucketRegionalDomainName);

    // Create CloudWatch Logs for VPC Flow Logs
    const flowLogsLogGroup = new logs.LogGroup(this, "FlowLogsLogGroup", {
      retention: logs.RetentionDays.ONE_WEEK,
    });

    // Create VPC Flow Logs IAM role
    const flowLogsIamrole = new iam.Role(this, "FlowLogsIamrole", {
      assumedBy: new iam.ServicePrincipal("vpc-flow-logs.amazonaws.com"),
    });

    // Create SSM IAM role
    const ssmIamRole = new iam.Role(this, "SsmIamRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonSSMManagedInstanceCore"
        ),
      ],
    });

    // Create VPC Flow Logs IAM Policy
    const flowLogsIamPolicy = new iam.Policy(this, "FlowLogsIamPolicy", {
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: ["iam:PassRole"],
          resources: [flowLogsIamrole.roleArn],
        }),
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            "logs:CreateLogStream",
            "logs:PutLogEvents",
            "logs:DescribeLogStreams",
          ],
          resources: [flowLogsLogGroup.logGroupArn],
        }),
      ],
    });

    // Atach VPC Flow Logs IAM Policy
    flowLogsIamrole.attachInlinePolicy(flowLogsIamPolicy);

    // Create VPC
    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr('172.29.0.0/22'),
      enableDnsHostnames: true,
      enableDnsSupport: true,
      natGateways: 2,
      maxAzs: 2,
      subnetConfiguration: [
        { name: "Public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
        { name: "Private", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
      ],
    });

    // Setting VPC Flow Logs
    new ec2.CfnFlowLog(this, "FlowLogToLogs", {
      resourceId: vpc.vpcId,
      resourceType: "VPC",
      trafficType: "ALL",
      deliverLogsPermissionArn: flowLogsIamrole.roleArn,
      logDestination: flowLogsLogGroup.logGroupArn,
      logDestinationType: "cloud-watch-logs",
      logFormat:
        "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}",
      maxAggregationInterval: 60,
    });

    // Security Group for Internal MUA
    const internalMuaSg = new ec2.SecurityGroup(this, "InternalMuaSg", {
      allowAllOutbound: true,
      vpc: vpc,
    });

    // Security Group for NLB
    const NLBSg = new ec2.SecurityGroup(this, "NLBSg", {
      allowAllOutbound: true,
      vpc: vpc,
    });
    NLBSg.addIngressRule(
      internalMuaSg,
      ec2.Port.tcp(25),
      "Allow SMTP for InternalMuaSg"
    );


    // Security Group for Internal MTA
    const internalMtaSg = new ec2.SecurityGroup(this, "InternalMtaSg", {
      allowAllOutbound: true,
      vpc: vpc,
    });
    internalMtaSg.addIngressRule(
      NLBSg,
      ec2.Port.tcp(25),
      "Allow SMTP for NLBSg"
    );

    // Create NLB
    const nlb = new elbv2.NetworkLoadBalancer(this, "Nlb", {
      vpc: vpc,
      vpcSubnets: vpc.selectSubnets({ subnetGroupName: "Private" }),
      crossZoneEnabled: true,
      internetFacing: false,
      securityGroups: [NLBSg],
    });
    nlb.logAccessLogs(nlbAccessLogBucket);
    
    // Create NLB Target group
    const targetGroup = new elbv2.NetworkTargetGroup(this, "TargetGroup", {
      vpc: vpc,
      port: 25,
      targetType: elbv2.TargetType.INSTANCE,
    });

    // Create NLB listener
    const listener = nlb.addListener("Listener", {
      port: 25,
      defaultTargetGroups: [targetGroup],
    });

    // User data for Internal MTA
    const userDataforInternalMTA = cdk.aws_ec2.UserData.forLinux();
    
    const userDataDefaultScript = fs.readFileSync(
      "./src/ec2/userDataSettingDefault.sh",
      "utf8"
    );

    const userDataPostfixMTA = fs.readFileSync(
      "./src/ec2/userDataSettingPostfixMTA.sh",
      "utf8"
    );

    const userDataPostfixMUA = fs.readFileSync(
      "./src/ec2/userDataSettingPostfixMUA.sh",
      "utf8"
    );

    userDataforInternalMTA.addCommands(userDataDefaultScript);
    userDataforInternalMTA.addCommands(userDataPostfixMTA);

    // User data for Internal MUA
    const userDataforInternalMUA = cdk.aws_ec2.UserData.forLinux();

    userDataforInternalMUA.addCommands(userDataDefaultScript);
    userDataforInternalMUA.addCommands(userDataPostfixMUA);

    // Create EC2 instance
    // Internal MTA
    vpc
      .selectSubnets({ subnetGroupName: "Private" })
      .subnets.forEach((subnet, index) => {
        const ec2Instance = new ec2.Instance(
          this,
          `InternalMtaEc2Instance${index}`,
          {
            machineImage: ec2.MachineImage.lookup({
              name: "RHEL-9.2.0_HVM-20230905-x86_64-38-Hourly2-GP2",
              owners: ["309956199498"],
            }),
            instanceType: new ec2.InstanceType("t3.micro"),
            vpc: vpc,
            keyName: this.node.tryGetContext("key-pair"),
            role: ssmIamRole,
            vpcSubnets: vpc.selectSubnets({
              subnetGroupName: "Private",
              availabilityZones: [vpc.availabilityZones[index]],
            }),
            securityGroup: internalMtaSg,
            userData: userDataforInternalMTA,
          }
        );

        targetGroup.addTarget(
          new elbv2_tg.InstanceIdTarget(ec2Instance.instanceId, 25)
        );
      });

    // MUA
    new ec2.Instance(this, `MuaEc2Instance`, {
      machineImage: ec2.MachineImage.lookup({
        name: "RHEL-9.2.0_HVM-20230905-x86_64-38-Hourly2-GP2",
        owners: ["309956199498"],
      }),
      instanceType: new ec2.InstanceType("t3.micro"),
      vpc: vpc,
      keyName: this.node.tryGetContext("key-pair"),
      role: ssmIamRole,
      vpcSubnets: vpc.selectSubnets({
        subnetGroupName: "Private",
      }),
      securityGroup: internalMuaSg,
      userData: userDataforInternalMUA,
    });
  }
}

ハマったポイント:NLB・MTAに設定するセキュリティグループ

2023/8のアップデートでNLBにセキュリティグループが設定できるようになりました。

これによりターゲットグループとなるMTAでは、NLBのセキュリティグループIDのみ指定すればOKになりました。
このセキュリティグループの設定ができておらず、なかなか通信できない状態になりました。。

UserData

UserDataについてもご紹介します。

今回OSはRHEL9を使用しているため、SSMAgentが事前にインストールされていません。
そのため、UseDataで事前に設定します。なお、AmazonLinux2,2023などを使用する場合はこちらは不要です。

userDataSettingDefault.sh

#!/bin/bash

# -x to display the command to be executed
set -xe

# Redirect /var/log/user-data.log and /dev/console
exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1

# Install Packages
token=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
region_name=$(curl -H "X-aws-ec2-metadata-token: $token" -v http://169.254.169.254/latest/meta-data/placement/availability-zone | sed -e 's/.$//')

dnf install -y "https://s3.${region_name}.amazonaws.com/amazon-ssm-${region_name}/latest/linux_amd64/amazon-ssm-agent.rpm" \
    unzip \
    nvme-cli

# SSM Agent
systemctl enable amazon-ssm-agent
systemctl start amazon-ssm-agent

# dnf upgrade
dnf upgrade -y

# Install AWS CLI
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
rm -rf aws
rm -rf awscliv2.zip

MTAにインストールするPostfixの設定もUserDataを使って行います。
ハイライト部分であるyourSendGridApiKeyは事前準備で作成したAPIキーを指定してください。

userDataSettingPostfixMTA.sh

#!/bin/bash

# Check if postfix is installed.
sudo dnf install -y postfix

# Install the necessary packages.
sudo dnf install -y cyrus-sasl-plain jq

# Check the current configuration of postfix.
postconf -n

# Back up the postfix configuration file.
sudo cp -a /etc/postfix/main.cf /etc/postfix/main.cf.`date +"%Y%m%d"`

# Edit the postfix configuration file.
sudo postconf -e "myhostname = internal-mta.o2mami.site" \
"mydomain = o2mami.site" \
"myorigin = \$mydomain" \
"inet_interfaces = all" \
"inet_protocols = ipv4" \
"mydestination = \$myhostname, localhost.\$mydomain, localhost" \
"mynetworks = 172.29.0.0/22, 127.0.0.0/8" \
"home_mailbox = Maildir/" \
"masquerade_domains = \$mydomain" \
"smtpd_banner = \$myhostname ESMTP unknown" \
"relayhost = [smtp.sendgrid.net]:587 " \
"smtp_sasl_auth_enable = yes" \
"smtp_sasl_security_options = noanonymous" \
"smtp_sasl_tls_security_options = noanonymous" \
"smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd" \
"smtp_sasl_mechanism_filter = plain, login" \
"smtp_use_tls = yes" \
"smtp_tls_security_level = encrypt" \
"smtp_tls_loglevel = 1" \
"smtp_tls_note_starttls_offer = yes"

# Create the postfix sasl_passwd file
sudo echo "[smtp.sendgrid.net]:587 apikey:yourSendGridApiKey" > /etc/postfix/sasl_passwd

# Set file permissions
sudo chmod 600 /etc/postfix/sasl_passwd

# Reload Postfix configuration
sudo postmap /etc/postfix/sasl_passwd

# Check the differences of postfix configuration files before and after editing.
sudo diff -u /etc/postfix/main.cf.`date +"%Y%m%d"` /etc/postfix/main.cf

# Check the postfix configuration file for incorrect descriptions.
sudo postfix check

# Start postfix.
sudo systemctl start postfix

# Check the status of postfix.
sudo systemctl status postfix

# Enable postfix auto-start.
sudo systemctl enable postfix

# Check the postfix auto-start setting.
sudo systemctl is-enabled postfix

Postfixの各設定値については、先人のブログをご確認ください。
NLB配下のPostfixでGmailのSMTP認証をしてメールを送信してみた | DevelopersIO

なおMUAもPostfixを使用して、メール配信を行うのでUserDataを使い、事前にインストールしておきます。

userDataSettingPostfixMUA.sh

#!/bin/bash

# Check if postfix is installed.
sudo dnf install -y postfix

# Install the necessary packages.
sudo dnf install -y sendmail

# Back up the postfix configuration file.
sudo cp -a /etc/postfix/main.cf /etc/postfix/main.cf.`date +"%Y%m%d"`

AWS CDKでリソースを払い出す

cdk deployでリソースを払い出します。
リソース作成後、NLBのDNS名はMUAからメールを送信する際に設定するので控えておきます。

MUAの設定

postconfコマンドでrelayhostにNLBのDNS名を指定し、postfixを再起動します。

sh-5.1$ sudo postconf -e "relayhost = TestMa-NlbBC-ErWAk0cwJjcy-73f5dc6943ffaa76.elb.ap-northeast-1.amazonaws.com:25 "
sh-5.1$ sudo systemctl restart postfix
sh-5.1$ sudo systemctl status postfix
● postfix.service - Postfix Mail Transport Agent
     Loaded: loaded (/usr/lib/systemd/system/postfix.service; disabled; preset: disabled)
     Active: active (running) since Fri 2024-01-12 20:25:32 JST; 3min 34s ago
(以下略)

これで準備完了です。

メール送信確認

MUAのEC2インスタンスより、自分の社用メールアドレスにメール送信します。

メールログを確認するために、MTAのEC2インスタンス上でそれぞれで以下のコマンドを実行します。egrepでNLBからの疎通確認のログは除外しています。

$ sudo tail -f /var/log/maillog | egrep -v 'disconnect|connect'

次に、MUAからメールを送信します。
Userdateでインストールしたsendmailを使用しました。

sh-5.1$ echo -e 'Subject: TestTitle1\n\nHello!\nWorld' | sendmail -f test@o2mami.site <社用アドレス>
sh-5.1$ echo -e 'Subject: TestTitle2\n\nHello!\nWorld' | sendmail -f test@o2mami.site <社用アドレス>
sh-5.1$ echo -e 'Subject: TestTitle3\n\nHello!\nWorld' | sendmail -f test@o2mami.site <社用アドレス>

MTAでメールログを確認します。

InternalMtaEc2Instance0

sh-5.1$ sudo tail -f /var/log/maillog | egrep -v 'disconnect|connect'
Jan 12 11:35:04 ip-172-29-3-21 postfix/smtpd[59970]: 4B0FA111A2EF: client=ip-172-29-2-76.ap-northeast-1.compute.internal[172.29.2.76]
Jan 12 11:35:04 ip-172-29-3-21 postfix/cleanup[60006]: 4B0FA111A2EF: message-id=<202401121135.40CBZ4rL060360@ip-172-29-2-76.ap-northeast-1.compute.internal>
Jan 12 11:35:04 ip-172-29-3-21 postfix/qmgr[59969]: 4B0FA111A2EF: from=<test@o2mami.site>, size=1036, nrcpt=1 (queue active)
Jan 12 11:35:05 ip-172-29-3-21 postfix/smtp[60007]: 4B0FA111A2EF: to=<社用アドレス>, relay=smtp.sendgrid.net[52.220.95.193]:587, delay=1.3, delays=0.01/0.06/1.1/0.14, dsn=2.0.0, status=sent (250 Ok: queued as dDjkTtTBSC6CSgCnV7Kv0Q)
Jan 12 11:35:05 ip-172-29-3-21 postfix/qmgr[59969]: 4B0FA111A2EF: removed
Jan 12 11:35:16 ip-172-29-3-21 postfix/smtpd[59970]: 40452111A2EF: client=ip-172-29-2-76.ap-northeast-1.compute.internal[172.29.2.76]
Jan 12 11:35:16 ip-172-29-3-21 postfix/cleanup[60006]: 40452111A2EF: message-id=<202401121135.40CBZGXj060363@ip-172-29-2-76.ap-northeast-1.compute.internal>
Jan 12 11:35:16 ip-172-29-3-21 postfix/qmgr[59969]: 40452111A2EF: from=<test@o2mami.site>, size=1036, nrcpt=1 (queue active)
Jan 12 11:35:17 ip-172-29-3-21 postfix/smtp[60007]: 40452111A2EF: to=<社用アドレス>, relay=smtp.sendgrid.net[52.220.95.193]:587, delay=0.86, delays=0/0/0.71/0.14, dsn=2.0.0, status=sent (250 Ok: queued as BaHuakO1Qrm9JV1m1XDkQw)
Jan 12 11:35:17 ip-172-29-3-21 postfix/qmgr[59969]: 40452111A2EF: removed

InternalMtaEc2Instance1

sh-5.1$ sudo tail -f /var/log/maillog | egrep -v 'disconnect|connect'
Jan 12 11:34:44 ip-172-29-2-210 postfix/smtpd[59971]: C211A13A2: client=ip-172-29-2-76.ap-northeast-1.compute.internal[172.29.2.76]
Jan 12 11:34:44 ip-172-29-2-210 postfix/cleanup[59977]: C211A13A2: message-id=<202401121134.40CBYi7S060353@ip-172-29-2-76.ap-northeast-1.compute.internal>
Jan 12 11:34:44 ip-172-29-2-210 postfix/qmgr[59893]: C211A13A2: from=<test@o2mami.site>, size=1032, nrcpt=1 (queue active)
Jan 12 11:34:46 ip-172-29-2-210 postfix/smtp[59979]: C211A13A2: to=<社用アドレス>, relay=smtp.sendgrid.net[52.220.95.193]:587, delay=1.4, delays=0.02/0.08/1.1/0.14, dsn=2.0.0, status=sent (250Ok: queued as p1BpEJGlR72GALHOCSLX5w)
Jan 12 11:34:46 ip-172-29-2-210 postfix/qmgr[59893]: C211A13A2: removed

errorが出ておらず、dsn=2.0.0となっていることから正しくメールの配送が出来ていそうです。

メールボックスを確認してみます。
以下の通り、3通メールを受信できています。

メールの送信元はMUAで設定したアドレスになっており、「SendGrid」経由であることが確認できました!

さいごに

今回はNLB配下のPostfixでSendGridのSMTP認証をしてメールを送信してみました。

PostfixやSendGridを使用するのは今回が初めてでしたが、設定自体は簡単に行えました!
興味がある方はぜひ自分でSMTPサーバを構築してみてください!

最後までお読みいただきありがとうございました!
どなたかのお役に立てれば幸いです。

以上、おつまみ(@AWS11077)でした!

参考

Postfixでメール送信 - ドキュメント | SendGrid APIキーを管理する - ドキュメント | SendGrid NLB配下のPostfixでGmailのSMTP認証をしてメールを送信してみた | DevelopersIO Amazon Linux 2上のPostfixで宛先ドメイン毎にリレー制御してみた | DevelopersIO