Kubernetes上のサービスにおけるPostfixによるメール配送を考える

おはこんばんちは、人事労務フリーのエンジニアを担当している橋本です。この記事は、freee Developers Advent Calendar 2019の6日目となります。

adventar.org

以前はSREに所属しており、この記事ではそのときの知見をご紹介いたします。

アプリケーションからのメール配送

早速ですが、表題のとおりアプリケーションにおけるメール配送を考えていきます。

freeeではお客様へのメール配送にSendGridを利用していますが、アプリケーションからSendGridに送信するまでの方法はいくつか考えられます。freeeでは以下の方法を検討しました。

  • SendGrid APIを用いてアプリケーションから直接SendGridへ送信する
  • アプリケーションからPostfixに送信させ、SendGridにリレーする

最終的に後者の方法を採用することにしました。メリットとしては、SendGridまでの経路でなんらかの障害が発生した場合にPostfix側で再送処理を担保してくれることや、仮にメールサービスをAmazon SESなどに変更したい場合でもPostfixの設定を変更するのみでアプリケーション側は責任を負う必要がない点が挙げられます。

Kubernetes上におけるサービスとPostfixの構成

freeeのいくつかのサービスはKubernetes (Amazon EKS) 上で動作しており、サービスごとにクラスタを用意するシングルテナント方式を採用しています。詳細は「AWSのマネージドサービスを活かした Kubernetes 運用とAmazon EKS によるクラスタのシングルテナント戦略について - Speaker Deck」をご参照ください。

ここであるサービスについて、Kubernetes環境における各サービスのPodとPostfix Podに関する概略図は以下のようになります。PostfixはDaemonSetとして各ノードに1台ずつ配置します。また、Podは同一Node上のPostfixに送信し、PostfixがSendGridにリレーする形をとっています。これは、サービスのPodからPostfix Pod間のネットワーク障害などによる配送障害を抑える意図があります。

f:id:tak84mt:20191201123806p:plain
Kubernetes環境におけるPodとPostfixの関連図

Kubernetesの設定などを書く

Dockerfile

Alpine LinuxベースのDockerイメージを作成する場合は、以下のようなDockerfileになります。

FROM alpine:3.10

apk add --update --no-cache \
  ca-certificates \
  cyrus-sasl-login \
  cyrus-sasl-plain \
  libsasl \
  postfix==3.4.7-r0 \
  rsyslog==8.1904.0-r1 \
&& mkdir -p /var/spool/postfix/etc/

libsaslcyrus-saslはSASL認証に必要になります*1。Postfixのログはrsyslogによって標準出力に流しておきます*2。また、念のため現時点で利用可能な最新バージョンを明示しておきます。

DaemonSet

DaemonSetのYAML定義はおおよそ以下のようになります(説明に必要な箇所だけ明示しています)。sasl_passwdやPostfixの各種設定ファイルはConfigMapやSecretとして渡しておきます。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: postfix
spec:
  template:
    spec:
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet
      volumes:
        - name: postfix-templates
          # 複数のConfigMapやSecretsをまとめてファイルとしてvolumeMountsするため、
          # projectdを定義すると楽
          projected:
            sources:
              - configMap:  # Postfixおよびrsyslogの設定ファイル
                  name: postfix
                  items:
                    - key: main.cf
                      path: main.cf
                      mode: 0644
                    - key: master.cf
                      path: master.cf
                      mode: 0644
                    - key: rsyslog.conf
                      path: rsyslog.conf
                      mode: 0644
              - secret:  # PostfixのSendGridに必要な認証情報
                  name: postfix-sasl-passwd
                  items:
                    - key: sasl-passwd
                      path: sasl_passwd
                      mode: 0600
        - name: postfix-entrypoint
          configMap:
            name: postfix-entrypoint
            items:
              - key: entrypoint.sh
                path: entrypoint.sh
                mode: 0755
        - name: var-spool-postfix  # メールキューをNodeに永続化する
          hostPath:
            path: /var/spool/somedir/postfix
            type: DirectoryOrCreate
      containers:
        - name: postfix
          image: ... # 前述のDocker Imageに向ける
          command: ["sh", "-c", "/opt/entrypoint/entrypoint.sh"]
          # 各種設定ファイルやentrypointスクリプトを所定のパスへ配置する
          volumeMounts:
            - name: postfix-templates
              mountPath: /etc/postfix/main.cf
              subPath: main.cf
            - name: postfix-templates
              mountPath: /etc/postfix/master.cf
              subPath: master.cf
            - name: postfix-templates
              mountPath: /etc/rsyslog.conf
              subPath: rsyslog.conf
            - name: postfix-templates
              mountPath: /etc/postfix/sasl_passwd
              subPath: sasl_passwd
              readOnly: true
            - name: postfix-entrypoint
              mountPath: /opt/entrypoint
            - name: var-spool-postfix  # Node側の/var/spool/somedir/postfixにマウントされる
              mountPath: /var/spool/postfix
          ports:
            - name: smtp-auth
              containerPort: 587
              hostPort: 587
              protocol: TCP
            - name: smtp-alt
              containerPort: 2525
              hostPort: 2525
              protocol: TCP

ここで、Amazon EKSの場合は以下の点に注意します:

  • Node側の/var/spool/postfixはすでにEKS側で確保済みのようでマウントできないため、/var/spool/somedir/postfixを使用する(somrdir以下のパスは任意のものを指定)
  • Node側のTCP 25番ポートも同様にEKS側で確保されており、hostNetwork: trueの際に利用できないため、代替えとなるポート番号を指定する(この例の場合ではsmtp-altにて2525番を指定)

仕様として明示されていないので将来的に変更になる可能性がありますが、現時点ではこのような追加設定が必要となりました。

ConfigMapおよびSecret

起動スクリプトentrypoint.shおよびPostfixの各種設定ファイルを記述します((entrypoint.shにおいて、コンテナ環境でのpostfix start-fgによるPID1での起動はPostfix 3.3以降で利用可能です))。main.cfsasl_passwdの詳細はPostfixのドキュメントをご確認ください(sasl_passwdは認証情報を含むため、Secretとして定義します)。

apiVersion: v1
kind: ConfigMap
metadata:
  name: postfix
data:
  main.cf: |
    ...
  # master.cfにてsmtpdのポートを前述のsmtp-altに合わせる
  master.cf: |
    2525      inet  n       -       y       -       -       smtpd
    ...  # 以降よしなに設定
  rsyslog.conf: |
    # mail.info, mail.errをそれぞれ標準出力・標準エラー出力に指定
    # その他の設定はよしなに記述してください
    mail.info /dev/stdout
    mail.err /dev/stderr
---

apiVersion: v1
kind: ConfigMap
metadata:
  name: postfix-entrypoint
data:
  entrypoint.sh: |
    postmap hash:/etc/postfix/sasl_passwd  # ハッシュテーブルを更新
    postalias hash:/etc/postfix/aliases
    if [ ! -d /var/spool/postfix/etc ]; then mkdir /var/spool/postfix/etc; fi
    cp /etc/resolv.conf /var/spool/postfix/etc/resolv.conf  # postfixが/var/spool/postfixへchrootするため必要となる
    rsyslogd  # rsyslogdデーモンを起動
    exec postfix start-fg  # PostfixをPID 1にてフォアグラウンドで起動させる
---

apiVersion: v1
kind: Secret
metadata:
  name: postfix-sasl-passwd
type: Opaque
data:
  sasl-passwd: ...

これらをkubectl apply -fなどすることで、Postfixが各Nodeに配置されリクエストを受け付けます。実際には、これらをHelm Chartとしてテンプレート化して各クラスタにインストールしています。

各サービスからPostfix Podを参照する

各サービスのPodから同一NodeのPostfixを参照する場合は、Downward APIを用いてNodeのIPを得ます。ここでDaemonSetにおいてhostNetwork: trueとしたことで、当該Nodeへの2525番ポートのアクセスはPostfixに到達できることになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: some-app
spec:
  template:
    spec:
      containers:
        - name: some-app-container
          image: ...
          env:
            - name: SMTP_HOST_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.hostIP
            - name: SMTP_HOST_PORT
              value: "2525"
      ...

ここでたとえばRailsのAction Mailer (config/initializers/mail.rb) の設定は、上記のenvをもとに以下のように設定されます(必要に応じてENVがnilの場合のデフォルト値も設定してください):

ActionMailer::Base.delivery_method = :smtp
ActionMailer::Base.smtp_settings = {
  address: ENV['SMTP_HOST_IP'],
  port: ENV['SMTP_HOST_PORT'].to_i
}

さいごに

Kubernetes環境におけるPostfixの運用例をご紹介しました。今回のは一例で、先日発表されたAmazon EKS on AWS Fargateではこの構成を利用できなかったり*3、ほかにもよいプラクティスがある気がするので、知見をお持ちの方はぜひお話を聞かせてください。

jobs.freee.co.jp

明日、12月7日の記事は二輪ライダーのid:ymizushiが担当いたします!

*1:SendGridのリレーの際に必要です。詳細はSendGridのドキュメントをご参照ください

*2:Postfix 3.4でコンテナ向けのロギング機能が強化され、syslogなしに標準出力が可能となったため、場合によっては不要になります。詳細はPostfix logging to file or stdoutをご参照ください

*3:詳細はリンク先のLimitationsをご確認ください