👋

Notionの無料利用枠を使い切ってしまったのでAppflowyに移行したかった。けど結局Google ドキュメントに移行した。

2024/11/04に公開

移行を検討した背景

Notionをプライベートで結構便利に利用していました。
特に家庭内Wikiみたいな使い方をするにあたり、カレンダー等含め情報が集約されること、UIが使いやすいことなどが相まって非常に便利だったのですが、NotionのFree版では2人以上の共有ワークスペースでは1000ブロックまでという制限が存在します。
この制限は承知していて、以前はワークスペースを共有しているアカウントでもPrivateに配置しているものはカウント対象外だったり、ブロックを削除した場合は制限がまた戻るというのでやりくりしていたのですが、いつからか(もしかすると上のやりくり自体実は最初からできず、私が勘違いしていただけという可能性もありますが)、
共有ワークスペースで制限に達すると自分のPrivateも使えなくなったり、何かを削除しても制限が解除されなくなってしまいました。

こうなると何も書き込めず文鎮と化してしまうので、移行先を探し始めたという流れです。

尚、Notion自体は非常に便利で、もしこれをお仕事に使ったりする場合であれば、2000円/人・月の費用を払うのもやぶさかでないのですが、
さすがにPrivate用途でこのお値段はつらい、、、ということで移行したいと考えてこの検討を始めました。
(画像とか月あたりのブロック数厳しめで、3$/人とかそのぐらいのプランがあれば、、、)

なお、ちゃんと動かすところまでやり切ったのですが、オチとしてはGoogleドキュメントに移行したというオチです。

移行先の選定

Notion的なOpenSourceには、有名どころとしてOutline, AppFlowy, Affineが存在します。
この中ではOutlineが最も古参であり、発足したタイミングを見る限りではNotionよりはConfluenceを意識して作られたようなものに見ます。
Appflowy, Affineについては明確にNotionを意識して始まったプロジェクトであり、Affineが最も新しいです。
とはいえAppflowyもまだまだ若いプロダクトであり、安定したとは言い切りがたい状況ではあります。

今回はNotionからの移行ということで、AppFlowy, Affineのどちらかに焦点を絞って比較し、
 * Affineよりは1年程度古く、少しは枯れていそうなところ。
 * AffineはMiro的な機能も取り込もうとしており、多機能思想のように見え、少し安定感が気になった事。
 * ドキュメントがAppFlowyの方が整備されていること

から、Appflowyを選定しました。

やること

ということで、実構築に入っていくのですが、まずはやることをまとめておきます。
結構多いですが、まぁ仕方ない。

* ドメインの取得
* AWS基盤の準備
* 証明書の取得
* AppFlowy Cloudのインストールと起動
* 証明書の取得
* Appflowy Nginx設定の変更
* 動作確認
* セキュリティ系の設定変更
* Dockerのネットワーク設定をipv6に変更
* GCP(認証用)の準備
* AppFlowy起動と動作確認

ドメインの取得

Appflowyのホスティングにあたり、HTTPS化したいので、ドメインをとっておきます。(オレオレ証明書でもよいのですが、Appflowyの場合認証失敗したときどうなるかがよくわからないので)
お名前ドットコムとかで取得するのも管理するサイトが増えて面倒なので、AWSでそのままドメインを購入します。

コストは最新のものを都度見る必要がありますが、最安は(多分).clickの$3、.linkの$5あたり。ただしここら辺の超新興ドメインが少し心配という場合は、.comの$14 .netの$15あたりでしょうか。

ぶっちゃけた話今時怪しいサイトでもいっちょ前に.comあたりをとることが殆どだと思うので(じゃないと引っかかってくれない)特に気にせずに.clickをとります。
更新費用の値上がりなんかを気にすることを考えると、.clickを雑に10年とか買うのもいいんじゃないでしょうか。(30$ = 4500円くらいで10年持つので)

https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/domain-register.html
https://d32ze2gidvkk54.cloudfront.net/Amazon_Route_53_Domain_Registration_Pricing_20140731.pdf

AWS基盤の準備

ということで、Appflowy稼働用の基盤を用意します。
商用じゃないので、めっちゃ簡単な基盤にします。
なお、最近Ipv4のPublic IPも有料化され、これが意外と高いので、Ipv6で構成します。
あとこれ地味に知らなかったんですが、Ipv6だとインスタンス停止しても特にリリースされないみたいです。固定どうしようかなと思っていたので結構便利な仕様。

IPv6 アドレスは、インスタンスの停止して起動、または休止して起動する際には保持され、インスタンスの終了時にリリースされます。

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/using-instance-addressing.html#ipv6-addressing

・VPC・サブネット(Ipv6対応)
・EC2インスタンス(スポットインスタンス・Instance Connectで接続。AppFlowyの推奨に合わせてt4g.mediumにしています。)
・AWS Backup(EC2インスタンスバックアップ用)
・EventBridge Scheduler(深夜とかの明らかに使わない時間の自動停止用)
・EC2ステータスチェックのアラーム(ほぼ監視する気ないですが、さすがに最低限として。本当はディスク残見た方がいいのですが、さすがにCloudWatch Agent入れるのは面倒で、、、)
・S3バケット(EC2へのファイル受け渡し用。Ipv6だとGithubとかつながらないという問題が、、、)

尚、CFnではSessionManager使うつもりのRoleがEC2についています。もともとSSMでつなぐつもりだったのですが、まさかのIpv6未対応、、、ただInstance ConnectはIpv6に対応しているので、Instance Connectの東京リージョンPrefixリストを開けるようにしてあります。

ということで、GPTにぶん投げつつ作ったCFnは以下の通りです。
余談ですが、GPT3.5とかに任せると、絶妙にパラメタ名が違ったり、階層が違ったりする事象が発生し、意外とデバッグがつらかったです、、、(基盤程度のサイズならいいのですが、本格的なソフトウェア開発にはまだだいぶ辛そうだなぁ、、と)

AMI IDは好きなのを入れてください。(この後の作業はAL2023-Arm前提で進めますので一応推奨は最新のg系インスタンスに対応してAmazon Linux 2023です。)
CFn中にもコメント残していますが、スポットリクエストがCFnを削除しても残存する場合があるようなので、作成・削除をやる場合は留意してください。

CloudFormation テンプレート
AWSTemplateFormatVersion: '2010-09-09'
Description: Create a InfraStructure for Appflowy

Parameters:
  InstanceAMIID:
    Description: AMI ID for EC2 Instance
    Type: String

  ScheduleStartTime:
    Type: String
    Description: JST
    Default: "cron(0 10 * * ? *)"

  ScheduleStopTime:
    Type: String
    Description: JST
    Default: "cron(0 1 * * ? *)"

  BackupPlanName:
    Type: String
    Default: "MyBackupPlan"
    Description: Name of the backup plan

  BackupVaultName:
    Type: String
    Default: "MyBackupVault"
    Description: Name of the backup vault

  SnsTopicName:
    Type: String
    Default: "EC2StatusCheckAlerts"
    Description: Name of the SNS topic for alerts
  
  S3BucketName:
    Type: String


Resources:

  S3Bucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Ref S3BucketName
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration: 
        Rules:
          - ExpirationInDays : 3
            Prefix: ""
            Status: "Enabled"

  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: MyVPC

  AddIPv6CIDR:
    Type: AWS::EC2::VPCCidrBlock
    Properties:
      AmazonProvidedIpv6CidrBlock: true
      VpcId: !Ref MyVPC

  MySubnet:
    DependsOn: AddIPv6CIDR
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref MyVPC
      AvailabilityZone: "ap-northeast-1a"
      AssignIpv6AddressOnCreation: true
      Ipv6CidrBlock: !Select [ 0, !Cidr [ !Select [ 0, !GetAtt MyVPC.Ipv6CidrBlocks], 1, 64 ]]
      Ipv6Native: true
      Tags:
        - Key: Name
          Value: MyIPv6Subnet

  MyInternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties: {}

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      InternetGatewayId: !Ref MyInternetGateway

  MyRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref MyVPC
      Tags:
        - Key: Name
          Value: MyRouteTable

  MyRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref MyRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref MyInternetGateway

  MyIPv6Route:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref MyRouteTable
      DestinationIpv6CidrBlock: ::/0
      GatewayId: !Ref MyInternetGateway

  SubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref MySubnet
      RouteTableId: !Ref MyRouteTable

  MySecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group allowing inbound TCP traffic on port 443 for IPv6
      VpcId: !Ref MyVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIpv6: ::/0 # IPv6アドレスからのトラフィックを許可
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIpv6: ::/0 # IPv6アドレスからのトラフィックを許可
        # インスタンスコネクト用
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          SourcePrefixListId: "pl-012493c5f82b88e8e"
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIpv6: ::/0 # IPv6アドレスからのトラフィックを許可
          
      Tags:
        - Key: Name
          Value: MySecurityGroup443

  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: AppflowyEC2Role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: "allowUseOfTheS3Bucket"
          PolicyDocument: 
            Version: 2012-10-17
            Statement:
                - Sid: s3Access
                  Effect: Allow
                  Action:
                    - 's3:List*'
                    - 's3:GetObject*'
                  Resource: '*'

                - Sid: s3Put
                  Effect: Allow
                  Action:
                    - 's3:PutObject*'
                  Resource: !Sub '${S3Bucket.Arn}/*'

      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  # 尚、SpotリクエストはCloudFormation管轄外となり、CloudFormationを消しても消えないことがあるらしいので要注意。
  ECSplotLaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        InstanceType: "t4g.medium"
        InstanceMarketOptions:
          MarketType: spot
          SpotOptions:
            InstanceInterruptionBehavior: stop
            MaxPrice: !Ref 'AWS::NoValue' # OnDemand
            SpotInstanceType: persistent
  
  MyEC2Instance:
    Type:  AWS::EC2::Instance
    Properties:
      ImageId: !Ref InstanceAMIID
      IamInstanceProfile: !Ref EC2InstanceProfile
      SecurityGroupIds:
        - !Ref MySecurityGroup
      SubnetId: !Ref MySubnet
      LaunchTemplate:
        LaunchTemplateId: !Ref ECSplotLaunchTemplate
        Version: !GetAtt ECSplotLaunchTemplate.LatestVersionNumber

  # IAM Instance Profile
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref EC2Role

  # https://dev.classmethod.jp/articles/cloudformation-template-eventbridge-scheduler-ec2-start-stop/

  ScheduleEC2Sart:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub 'EC2-Start-${MyEC2Instance}'
      Description: Start EC2 Instance
      ScheduleExpression: !Ref ScheduleStartTime 
      ScheduleExpressionTimezone: "Japan"
      FlexibleTimeWindow:
        Mode: "OFF"
      State: ENABLED
      Target:
        Arn: arn:aws:scheduler:::aws-sdk:ec2:startInstances
        Input: !Sub |-
          {
            "InstanceIds": ["${MyEC2Instance}"]
          }
        RoleArn:
          Fn::GetAtt:
          - SchedulerEC2StopStartRole
          - Arn

  ScheduleEC2Stop:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub 'EC2-Stop-${MyEC2Instance}'
      Description: Stop EC2 Instance
      ScheduleExpression: !Ref ScheduleStopTime 
      ScheduleExpressionTimezone: "Japan"
      FlexibleTimeWindow:
        Mode: "OFF"
      State: ENABLED
      Target:
        Arn: arn:aws:scheduler:::aws-sdk:ec2:stopInstances
        Input: !Sub |-
          {
            "InstanceIds": ["${MyEC2Instance}"]
          }
        RoleArn:
          Fn::GetAtt:
          - SchedulerEC2StopStartRole
          - Arn
  SchedulerEC2StopStartRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - scheduler.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: EC2StopStart
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:StartInstances
                  - ec2:StopInstances
                Resource:
                  - "*"

  BackupVault:
    Type: AWS::Backup::BackupVault
    Properties:
      BackupVaultName: !Ref BackupVaultName

  # Backup Plan
  BackupPlan:
    Type: AWS::Backup::BackupPlan
    Properties:
      BackupPlan: 
        BackupPlanName: !Sub '${BackupPlanName}-Daily'
        BackupPlanRule:
          - RuleName: "DailyBackup"
            TargetBackupVault: !Ref BackupVault
            ScheduleExpression: "cron(0 0 * * ? *)" # 毎日00:00 UTC
            Lifecycle:
              DeleteAfterDays: 2 # 2日後に削除

  BackupPlanWeekly:
    Type: AWS::Backup::BackupPlan
    Properties:
      BackupPlan: 
        BackupPlanName: !Sub '${BackupPlanName}-Weekly'
        BackupPlanRule:
          - RuleName: "WeeklyBackup"
            TargetBackupVault: !Ref BackupVault
            ScheduleExpression: "cron(0 0 ? * 1 *)" # 毎週日曜日00:00 UTC
            Lifecycle:
              DeleteAfterDays: 15 # 15日後に削除

  # Backup Selection
  BackupSelection:
    Type: AWS::Backup::BackupSelection
    Properties:
      BackupPlanId: !Ref BackupPlan
      BackupSelection: 
        SelectionName: "MyBackupSelection"
        IamRoleArn: !GetAtt BackupRole.Arn
        Resources:
          - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${MyEC2Instance}"

  BackupSelectionWeekly:
    Type: AWS::Backup::BackupSelection
    Properties:
      BackupPlanId: !Ref BackupPlanWeekly
      BackupSelection: 
        SelectionName: "MyBackupSelectionWeekly"
        IamRoleArn: !GetAtt BackupRole.Arn
        Resources:
          - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${MyEC2Instance}"

  # IAM Role for AWS Backup
  BackupRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: backup.amazonaws.com
            Action: sts:AssumeRole
      
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores
        - arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup

      Policies:
        - PolicyName: BackupPermissions
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - "iam:PassRole"
                Resource: !GetAtt EC2Role.Arn
              - Effect: Allow
                Action:
                  - "iam:ListRoles"
                Resource: "*"

  SnsTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Ref SnsTopicName


  # CloudWatch Alarm for EC2 Status Checks
  StatusCheckAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${MyEC2Instance}-StatusCheckAlarm"
      MetricName: StatusCheckFailed
      Namespace: AWS/EC2
      Statistic: Sum
      Period: 300 # 5分間隔
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanThreshold
      Dimensions:
        - Name: InstanceId
          Value: !Ref MyEC2Instance
      AlarmActions:
        - !Ref SnsTopic
      OKActions:
        - !Ref SnsTopic
      AlarmDescription: "Alarm when EC2 status check fails"

AppFlowy Cloudのインストールと起動

この後証明書とかを入れるのですが、まずは一度AppFlowy Cloudを立ち上げておきます。
最低限の動作確認です。

基本的には以下のドキュメントに従ってDeployしたいのですが、、、
GithubがIpv6に対応していないという致命的な問題があるので、手で後ほどファイルを持ってくることになります。
https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/DEPLOYMENT.md

さてまずAL2023にはdockerもdocker composeもないので、インストールしておきます。
とりあえずdockerを入れてenableまでしておきます。

sudo dnf install -y docker
sudo systemctl start docker
sudo systemctl enable docker
sudo systemctl status docker

次にDocker Composeを入れるのですが、、、GithubがIpv6に対応していないという致命的な問題があるため、スタンドアロン版を手で取ってきた後、S3バケットへアップロードし、S3バケットからEC2へ渡します。

↓から対応するバイナリをとってきて、S3へ上げ
https://github.com/docker/compose/releases/

S3からコピーしたうえで配置して、実行権限を渡す。
また、ec2-userをdocker groupに入れて、dockerの権限を渡しておきます。そのあといったんreboot。
最後にバージョン確認。
docker composeをプラグインとして、ec2-userだけに渡す方がよいとは思うのですが、後述のcertbotとの権限のコントロールがちょっと面倒になるので、standaloneとして全ユーザに利用可能にします。
尚、s3 cpの時にendpointでipv6に対応した方を指定してあげないとつながらないので注意。

sudo aws s3 cp s3://<バケット名>/docker-compose-linux-aarch64 /usr/local/bin/docker-compose --endpoint-url https://s3.dualstack.ap-northeast-1.amazonaws.com
sudo chmod +x /usr/local/bin/docker-compose
sudo usermod -aG docker ec2-user

sudo reboot

docker-compose version

次にappflowyの作業用フォルダを切って、再びappflowyのmainブランチをs3経由で持ってきた後に解凍します。(Githubがipv6に対応してさえいれば、、、)

mkdir ~/appflowy
aws s3 cp s3://<バケット名>/AppFlowy-Cloud-main.zip . --endpoint-url https://s3.dualstack.ap-northeast-1.amazonaws.com
unzip AppFlowy-Cloud-main.zip
cd AppFlowy-Cloud-main/

ここまでくればおおよそドキュメントに従って進めることができます。
以下の通りでdocker起動

cp deploy.env .env
docker compose up -d

さてここで残ディスクを確認しておきます。
尚、私の環境ではここまででざっくり50%くらいの消費となったので、すぐすぐ拡張の必要性はなさそうでしたが、様子を見てディスク拡張してください。

df

確認が終わったら、docker落としておきます。

docker compose down

証明書の取得

証明書は前半で取得したドメインを元に、Let's Encryptからとります。
そのため、AL2023にCertbot入れます。(snapdが対応していないので、pip経由)
以下リンクをベースとしつつ、Appflowyのコンテナで稼働しているNginxに対してHTTP-01 Challengeでの更新を構成するのはいろいろ面倒(Ipv6周りでまた出来なくなりそうだし)というのがあるため、standaloneでの作成・更新とします。(なので証明書更新のタイミングで一瞬Appflowyを止める必要が出てきます。が、仕方なし。なおDNS-01 Challengeができればよかったのですが、Route53のPublicエンドポイントがまだIpv6に対応していなかったため諦めました、、、)

それに伴い、python3を、python3.11と読み替えて進めます。
手順6のシンボリックリンクを張ってパスを通すところまでをおおよそベースとして進めます。
https://certbot.eff.org/instructions?ws=nginx&os=pip

sudo dnf install -y python3.11 augeas-libs
sudo python3.11 -m venv /opt/certbot/
sudo /opt/certbot/bin/pip install --upgrade pip
sudo /opt/certbot/bin/pip install certbot 
sudo ln -s /opt/certbot/bin/certbot /usr/bin/certbot

ここまでで、certbotコマンドが使用できるようになっているはずなので、AppflowyのNginx証明書パスに証明書を作成します。
まずは自サーバのipv6をPublicレコードとして、Route53に登録しておきます。(使用したいドメイン->Appflowyを起動するEC2インスタンスのAAAAレコードを作成しておきます。)

そうしたら、証明書発行の前に、Appflowyのリポジトリにあらかじめある証明書を削除しておきます。(削除しておかないと、証明書の上書きができず、不正な証明書になってしまう)

rm /home/ec2-user/appflowy/AppFlowy-Cloud-main/nginx/ssl/private_key.key
rm /home/ec2-user/appflowy/AppFlowy-Cloud-main/nginx/ssl/certificate.crt

そのうえで以下の通り実施し、証明書を発行します。

To use your own SSL certificates for https, replace certificate.crt and private_key.key with your own in nginx/ssl/ directory.
https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/DEPLOYMENT.md

sudo certbot certonly --standalone -d <証明書を発行するFQDN>

発行した証明書について、certbotでは最初から別の場所に発行するのが難しいので、発行完了ログの証明書と秘密鍵にシンボリックリンクを貼っておきます。

ln -s /etc/letsencrypt/live/<ドメイン名>/fullchain.pem /home/ec2-user/appflowy/AppFlowy-Cloud-main/nginx/ssl/certificate.crt
ln -s /etc/letsencrypt/live/<ドメイン名>/privkey.pem /home/ec2-user/appflowy/AppFlowy-Cloud-main/nginx/ssl/private_key.key

さて、更新用の設定も修正しておきます。certbotの証明書更新設定は、/etc/letsencrypt/renewal/<ドメイン名>.confにあります。
Appflowyを落としてから更新、その後立ち上げるのでpre_hookとpost_hookを不格好ですが書いておきます。ぶっちゃけこれはうまくいくかわかりません。(本チャンの更新走るまで動作確認が取れないので、、、)

[renewalparams]
<もともとあった設定に追記>
pre_hook = docker-compose down
post_hook = (cd /home/ec2-user/appflowy/AppFlowy-Cloud-main/ && docker-compose up -d)

そしたら、certbot renewをカレンダーsystemd.timerでカレンダー設定しておきます。
※動確まで見れてないので、もしかしたら動かないかもです。すみません。

[Unit]
Description = CertBot Renew Certification
After=network-online.target

[Service]
Type=oneshot
ExecStart=certbot renew

[Install]
WantedBy=multi-user.target
[Unit]
Description=Timer for renew_cert.service

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

そしたら、timerを有効&開始しておきます。

sudo systemctl start renew_cert.timer
sudo systemctl enable renew_cert.timer
sudo systemctl status renew_cert.timer

いったんstatusがActiveになっていればOK。

Appflowy Nginx設定の変更

AppflowyのNginxの設定を変更して、Ipv6を受け入れられるようにしておきます。
また、証明書に合わせてserver_nameも設定しておきます。
追加部分だけ抜粋

        ssl_certificate /etc/nginx/ssl/certificate.crt;
        ssl_certificate_key /etc/nginx/ssl/private_key.key;
        server_name <ドメイン名>; //追加

        listen 80;
        listen 443 ssl;
        listen [::]:443 ssl; //追加

動作確認

ここまででいったんWeb版が使えるようになっているはずなので、
https://<ドメイン>
にアクセスして、動作確認します。
以下のような画面が表示されていれば大丈夫です。

セキュリティ系の設定変更

Appflowyデフォルトの設定ファイルは明らかに弱い部分があるので、補強します。
最低でも以下を適切に変更します。

# PostgreSQL Settings
POSTGRES_HOST=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changepassword
# Postgresのパスワード。変えておきましょう。

# Supabase user settings
SUPABASE_PASSWORD=root
# 同様。

# authentication key, change this and keep the key safe and secret
# self defined key, you can use any string
GOTRUE_JWT_SECRET=hello456
#ここを変えます。結構長くて複雑なものにした方がよいです。

# You would then need to set GOTRUE_MAILER_AUTOCONFIRM=false
# Check for logs in gotrue service if there are any issues with email confirmation
# Note that smtps will be used for port 465, otherwise plain smtp with optional STARTTLS
GOTRUE_SMTP_HOST=smtp.gmail.com
GOTRUE_SMTP_PORT=465
GOTRUE_SMTP_USER=email_sender@some_company.com
GOTRUE_SMTP_PASS=email_sender_password
GOTRUE_SMTP_ADMIN_EMAIL=comp_admin@some_company.com
#SMTP系の設定もできるだけ変えておいた方がいいのは間違いないです。

# This user will be created when AppFlowy Cloud starts successfully
# You can use this user to login to the admin panel
GOTRUE_ADMIN_EMAIL=admin@example.com
GOTRUE_ADMIN_PASSWORD=password
#ここも変えます。gotrueのadminパネルのユーザなので、メルアド・パスワードともに強固にしておく必要があります。

# File Storage
# Create the bucket if not exists on AppFlowy Cloud start up.
# Set this to false if the bucket has been created externally.
APPFLOWY_S3_CREATE_BUCKET=true
# This is where storage like images, files, etc. will be stored.
# By default, Minio is used as the default file storage which uses host's file system.
# Keep this as true if you are using other S3 compatible storage provider other than AWS.
APPFLOWY_S3_USE_MINIO=true
APPFLOWY_S3_MINIO_URL=http://minio:9000 # change this if you are using a different address for minio
APPFLOWY_S3_ACCESS_KEY=minioadmin
APPFLOWY_S3_SECRET_KEY=minioadmin
APPFLOWY_S3_BUCKET=appflowy
#APPFLOWY_S3_REGION=us-east-1
#ここのSecretKey系も変更:

# PgAdmin
# Optional module to manage the postgres database
# You can access the pgadmin at http://your-host/pgadmin
# Refer to the APPFLOWY_DATABASE_URL for password when connecting to the database
PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=password
# パスワード変更のほか、docker-composeを編集し、pgadminごと停止してしまうのも手と思います。(私はそっちです)

# Portainer (username: admin)
PORTAINER_PASSWORD=password1234
# pgadminと同様、Portainerごと停止がよいかもしれません。

ついでに、少なくとも動作確認の間はユーザ追加を無効にするべく、ドキュメントに従ってユーザ追加を無効にしておきます。(GOTRUE_ADMIN_EMAIL、GOTRUE_ADMIN_PASSWORDで設定したユーザ、パスワードで入ることができるので、その点は問題ないです。)

- GOTRUE_DISABLE_SIGNUP=true

https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/DEPLOYMENT.md#how-do-i-disable-signups

さて、設定ファイルを弄ったら、いったんdocker volume類を削除しておきます(でないと脆弱なパスワード・ユーザが残存していてうまく起動しなくなる。)

Dockerのネットワーク設定をipv6に変更

ここでDockerのネットワークをipv6対応にしておきます。
Dockerのネットワークをipv6対応にしておかないと、ipv6のEC2インスタンスから、OAuthのトークン交換するときの接続に失敗することになります。

さてまずはdocker-compose.ymlを編集してdocker-composeで立ち上がるコンテナ群のデフォルトネットワークでipv6を有効にしておきます。
ipv6 CIDRにはVPCの余っているCIDR範囲を適当に割り当てます。
(CloudFormationで作った場合VPCのCIDRが余っているはずなので、それを割り当てます)

networks:
  default:
    enable_ipv6: true
    ipam:
      config:
        - subnet: <適当なipv6 CIDR>

ここまでで、コンテナにipv6のアドレスが割り当てられるようになるのですが、この状態だと外からコンテナへのアクセス(とその戻り)通信は可能なのですが、コンテナから外へのアクセスができません。
普通にNginxなどを稼働する限りではあまり困らないのですが、ことAppFlowyについてはOAuthのトークン交換の際にアウトバウンド通信が必要なので、この設定だと認証ができなくて詰みます。

めちゃくちゃ苦労して(マジで5時間とか溶かした)見つけた結論としては、Dockerデフォルトではiptablesがipv4の方にしか入っていません。(ip6tablesが入っていません)
この状態でもインバウンド通信については、docker-proxyがいい感じに処理しているようなのですが、アウトバウンド通信についてはipv4が優先解決される挙動となってしまっていました。(このいい感じの処理の全貌が分からなくて非常に時間がかかった。)

ipv4が優先解決されると、Public IPv4アドレスを持たないインスタンスからは外に出ることができず、通信できなくなっています。

そこで、docker-daemonの設定を変更して、ip6tablesの設定もdocker-daemonが変更できるようにしてあげます。experimentalですが、多分これが一番簡単な解決法なはず、、、

{
  "experimental": true,
  "ip6tables": true
}

そうしたら、docker daemonをsystemctlなどでリスタートします。
これで、ipv6でアウトバウンド通信できるようになっているはず。
例えば以下のようなもので確認可能です。設定がちゃんと入っていないと応答なしになります。
(appflowyが立ち上がっている状態で)

docker-compose exec nginx bash
<!-- 以下 nginxコンテナ内 -->
curl https://google.co.jp

GCP(認証用)の準備

Appflowyをセルフホストする場合、認証系には外部IdPが使えます。
SAMLにも対応はしているのですがちょっと手軽にやろうとすると、GCP/Github/DiscordでのOAuthからの選択になります。

今回はGCPを選択して進めます。GCP以外で言うと多分Discordはipv6に対応している(AAAAを引ける)ので利用できるかもしれません。Githubはipv6では無理です。

さて、以下設定です。
GCPのプロジェクトとアカウントを作成したのち、OAuth Client IDを作成します。
GCPプロジェクトの同意画面を作成していなかったので、新規に設定します。(よくある、アプリhogeはfugaの許可をリクエストしていますみたいなやつ)
ユーザはExternal(Internalでもいいのですが、CloudIdentity組織を作っていなかったので、Externalのテストモードで運用するつもり)、
スコープは特に追加しません。(ログイン用途だけなので、GCP側のスコープは不要)

証明書の準備が全項で終わっているので、証明書に使うドメインでOAuth認証情報を登録します。

これ以降は、公式のドキュメント(ただしまだGithubにしか無くて、サイトで公開はされていない?)
に沿えば問題ありません。

https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/AUTHENTICATION.md

また、APIの同意画面やらなんやらは以下も参考になります。
https://zenn.dev/acompany/articles/51e1dcc83279ee

AppFlowy起動と動作確認

さて、ようやっと設定が終わったのでAppflowyを再度立ち上げます。
そしたら、アプリから使えるようにアプリ側の設定を変更します。
とはいっても、このドキュメントの通りで、接続先のサーバを自前のサーバにするだけです。

https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy

繋がったら、設定したGoogleのOAuthでログインして、、、
いけました!

オチ:Googleドキュメントでよかった。

この検証と並行して、Notionが使えなくて困っていたため、Googleドキュメントを使っていたのですが、、、

Googleドキュメントでよくね?ポイント

  • 最近はNotionっぽいページ区切り無し表示ができる
  • マークダウンが使える!(コードとかは使えないが超メインどころは使える)
  • サブページも行けるしページアイコンも足せる!


ということで、Googleドキュメントで要件を満たすため、セルフホストAppFlowy君はお蔵入りになりました。ぶっちゃけセキュリティとか証明書管理とか面倒なので、、、
まぁ、ipv6 Dockerに詳しくなれたからいいか、、、という気持ち。

最後に

Appflowyに限らずIpv6で何かをセルフホストしたい方は私の屍を越えて行ってもらえると早いかなと思います。

Discussion