tech.guitarrapc.cóm

Technical updates

Slotを用いたAppService のStaging環境とAzureDevOps PipelineのリリースによるBlueGreen Deployment

Azure の App Service には Slotがあります。

Slotはただ利用してもそれなりにうれしいのですが、Terraform での構成とAzure DevOps の リリースパイプラインでの展開を行えるようにすることで、「CI/CD による App Service の Slot による展開前のStaging環境での確認」と「リリースゲートを使った任意でのSwapによるStaging->Production展開」、「Azure上のSlot環境の明確な管理」ができるようになります。

AzureをTerraform で展開しておくことで、AppService + Slot 構成がDev/Production の両方で展開でき、運用上の負担も小さいのがいい感じです。 実際にどんな感じで組むのか紹介します。

こういうのよくありますが、実際に設定を含めてどう組めばいいのかまで含めた公開はあんまりされないのでしておきましょう。

www.edmondek.com

目次

TL;DR

Slotを用いることで、Prod環境への適用前のStaging環境確認が簡単に行えます。 また、IISなどのAppSettingsの入れ替えに伴う再起動でWebAppsが応答できず500を返すのもSwap入れ替え中のWarm upで解消されます。 Terraform + AzureDevOps のCI/CD で自動化しつつ組んでみましょう。

ここで注意するべきは、Terraform の azurerm_app_service_active_slot を使ったSwapではないということです。このリソースは制約が大きいので、CDに利用することは避けたほうがいいでしょう。

www.terraform.io

概要

Azure AppService にはSlot機能があり、本番に適用する前にStaging環境で検証、Prod環境にSwapで反映できます。 Azure DevOps のPipelineでリリース時にDeployとSwapを分けることで、「本番に反映」のタイミングだけ承認を求めて、普段のStaging展開は自動CI/CD を行い開発者はステージング環境で常に確認できます。

想定されるワークフロー

  • Git Commit により、PRごとにCIが実行
  • CIの成功をtriggerに、CDのdeployタスクにより成果物を常にstaging slot にデプロイ
  • 開発者は AppService の staging slotを確認することでステージング環境を確認(ここまで毎PRで自動CD)
  • ステージングの動作に問題がなければ、CDのswapのゲートの承認を行う
  • CDはswap承認を受けて、自動的にswap を実行し ステージング環境が production と入れ替わり本番リリースされる (Swapによるユーザー影響は明確にリリース制御できる)

App Service Slot と AzureDevOps のリリースタスクによるBlue/Green デプロイ

Azure環境はTerraform で構成されており、AppService は各Envごとに自動的に構成されます。

Slotとは

どんな資料をみるよりも公式資料がわかりやすいので見てみましょう。

https://docs.microsoft.com/en-us/azure/app-service/deploy-staging-slots

Slotでポイントとなるのは、Slot専用の環境変数を持てることです。

  • Slot環境ではStaging へ接続
  • Slot環境では環境変数をStagingにして、アプリの動作を変える

といったことが可能です。

Slot の用意

ここではTerraform でSlotを用意します。Azure Portal から用意したいからはそちらでどうぞ。

今回は ASP.NET Coreで組んでいますが、JavaでもGolang でもほぼ変わりません。 Golangなどを使うなら Linux Container がいい感じです。*1

https://www.terraform.io/docs/providers/azurerm/r/app_service_slot.html

このようなWebAppsの構成がある前提です。

resource "azurerm_app_service_plan" "webapps" {
  name                = "prod-plan"
  location            = "${local.location}"
  resource_group_name = "${data.azurerm_resource_group.current.name}"
  kind                = "Windows"
  tags                = "${local.tags}"

  sku {
    tier = "Standard"
    size = "S1"
  }
}

resource "azurerm_app_service" "webapps" {
  name                    = "prod-webapp"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "Production"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }
  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

Slot は次の内容で作成してみます。

  • staging というSlot名にする
  • ASPNETCORE_ENVIRONMENT をProductionとStaging で入れ替える

この場合、Slot名となるnamestagingapp_settingsASPNETCORE_ENVIRONMENT=Staging を設定し、他はProductionとなるApp Service設定と同様に組んでおきます。

resource "azurerm_app_service_slot" "staging" {
  name                    = "staging"
  app_service_name        = "${azurerm_app_service.webapps.name}"
  location                = "${local.location}"
  resource_group_name     = "${data.azurerm_resource_group.current.name}"
  app_service_plan_id     = "${azurerm_app_service_plan.webapps.id}"
  client_affinity_enabled = false
  https_only              = true
  tags                    = "${local.tags}"

  app_settings {
    ASPNETCORE_ENVIRONMENT                   = "Staging"
    APPINSIGHTS_INSTRUMENTATIONKEY           = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ApplicationInsights:InstrumentationKey" = "${azurerm_application_insights.webapps.instrumentation_key}"
    "ConnectionStrings:Storage"              = "${azurerm_storage_account.webapps.primary_connection_string}"
    "ConnectionStrings:LogStorage"           = "${azurerm_storage_account.logs.primary_connection_string}"
  }
  site_config {
    dotnet_framework_version = "v4.0"
    always_on                = "true"
    remote_debugging_enabled = false
    remote_debugging_version = "VS2017"
    http2_enabled            = true
  }
  identity {
    type = "SystemAssigned"
  }

  lifecycle {
    ignore_changes = [
      "app_settings.%",
      "app_settings.MSDEPLOY_RENAME_LOCKED_FILES",
      "app_settings.WEBSITE_NODE_DEFAULT_VERSION",
      "app_settings.WEBSITE_RUN_FROM_PACKAGE",
      "app_settings.WEBSITE_HTTPLOGGING_CONTAINER_URL",
      "app_settings.WEBSITE_HTTPLOGGING_RETENTION_DAYS",
    ]
  }
}

あとはTerraformを実行することで、Slotが生成され維持されます。

AzureDevOps Pipeline

DevOps Pipeline でリリースパイプラインを組みます。Azure Portal でSwapしたい方はやる必要がありません。

完成図は次の通りです。

リリースパイプラインのステージ構成はDeployとSwapで分割する

  • Deploy: Staging までのPackage Deploy を行います
  • Swap: Staging Slot と Production を Swap します

DeployをSwapを分けることで、実際にProductionが入れ替わるタイミングだけGateをかけることができるようになります。

  • Deployまでを自動CI/CDにして事前動作確認
  • Swap は、Slackやほかの手段でチームの合意があった時だけデプロイ

Deploy

Deployタスクは、WebApps のSlot環境へのデプロイを行います。

DeployはRun as package の構成まで(app servce in linux ならcliやFTP展開)

デプロイはRun from package を用いていますが、このRun from zip/Run from packageはWindows Web Apps でのみ有効です。

https://docs.microsoft.com/en-us/azure/devops/pipelines/targets/webapp?view=azure-devops&tabs=yaml

もしLinuxにしたい場合は別の手段を使いましょう。Container であればDevOps Pipeline があります。

https://docs.microsoft.com/en-us/azure/devops/pipelines/apps/cd/deploy-docker-webapp?view=azure-devops

このリリースタスクは、CIでPackageをArtifactに上がっている前提です。 あとは、CD時点の時間を取得、Zip生成、Blobにアップロード、SASを取得してSlot のAppSettings に埋め込みをすることでRun from Packageが実行できます。

PowerShell Scriptのタスクは次の通りです。time環境変数を作っています。

steps:
- powershell: |
   $date=$([System.DateTimeOffset]::UtcNow.AddHours(9).ToString("yyyyMMddHHmmss"))
   Write-Host "##vso[task.setvariable variable=time]$date"
   
  displayName: 'PowerShell Script'

Zipタスクは次の通りです。time環境変数の時間をzipのファイル名に利用しています。

steps:
- task: ArchiveFiles@2
  displayName: 'Archive $(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
  inputs:
    rootFolderOrFile: '$(System.DefaultWorkingDirectory)/$(RELEASE.PRIMARYARTIFACTSOURCEALIAS)/drop'
    includeRootFolder: false
    archiveFile: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'

Blob コピータスクは次の通りです。Storage Blobにアップロードして、storageUri 環境変数にURIを取得します。

steps:
- task: AzureFileCopy@1
  displayName: 'AzureBlob File Copy'
  inputs:
    SourcePath: '$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip'
    azureSubscription: YOUR_SUBSCRIPTION
    Destination: AzureBlob
    storage: YOUR_STORAGE_ACCOUNT
    ContainerName: YOUR_STORAGE_CONTAINER
    BlobPrefix: YOUR_STORAGE_BLOB_PREFIX
    outputStorageUri: storageUri

SAS生成タスクは次の通りです。SasTokenをstorageToken 変数にかき出しています。

steps:
- task: pascalnaber.PascalNaber-Xpirit-CreateSasToken.Xpirit-Vsts-Release-SasToken.createsastoken@1
  displayName: 'Create SAS Token for Storage Account'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    StorageAccountRM: YOUR_STORAGE_ACCOUNT
    SasTokenTimeOutInHours: 87600
    Permission: 'r'
    StorageContainerName: packages
    outputStorageUri: 'storageUri'
    outputStorageContainerSasToken: 'storageToken'

最後にRun from Package を行います。前のタスクまでで生成した、time環境変数、アップロードしたstorageUri変数、SasトークンのstorageToken 変数を利用します。 ポイントは、slotです。ここで作成しておいたstagingを指定することで、production環境ではなく、staging Slotへデプロイします。

steps:
- task: hboelman.AzureAppServiceSetAppSettings.Hboelman-Vsts-Release-AppSettings.AzureAppServiceSetAppSettings@2
  displayName: 'Run from package : Set App Settings'
  inputs:
    ConnectedServiceName: YOUR_SUBSCRIPTION
    WebAppName: 'prod-webapp'
    ResourceGroupName: 'YOUR_RESOURCE_GROUP_NAME'
    Slot: staging
    AppSettings: 'WEBSITE_RUN_FROM_PACKAGE=''$(storageUri)/prod/$(Release.DefinitionName)-$(time)-$(Release.ReleaseName)-$(Build.SourceVersion).zip$(storageToken)''

Swap

Deploy後のSwap タスクのpre-deployment condition で、実行前の条件を付けることができます。 ここで、承認を要するようにすることで、チームメンバーのだれかの承認があったときだけ実行、ということが可能です。

Swap実行前にPre-deployment approval でチームの承認による任意展開をひっかける

タスクを見てみましょう。タスクは簡潔にSwapを行うだけです。

SwapはSlot Swap を実行するだけ

Swapは、Deployタスクでデプロイしたstaging Slot とProductionの入れ替えを行うだけです。

steps:
- task: AzureAppServiceManage@0
  displayName: 'Manage Azure App Service - Slot Swap'
  inputs:
    azureSubscription: '$(Parameters.ConnectedServiceName)'
    WebAppName: '$(Parameters.WebAppName)'
    ResourceGroupName: '$(Parameters.ResourceGroupName)'
    SourceSlot: '$(Parameters.SlotName)'
    SwapWithProduction: True

これで CIが実行されると Releaseパイプラインで、Deploy タスク、承認後にSwap タスクが実行されます。

CIからトリガーしてCD実行完了時の様子

Deployタスク

Deployの各タスクの状態

Swap タスク

Swapの各タスクの状態

Swap はおおよそ 60sec - 90sec かかりますが、これは Azure RM APIのレスポンスとApp Serviceの制約なので諦めます。

改善点

Deploy と Swap を直接でつなぐことで、Deployタスクの完了時に連動するように依存性を組んでいます。しかしタスクを分けたために、DeployとSwapそれぞれで CIの成果物(Artifact) のダウンロード処理が2sec程度かかっています。無視してokなレベルですが、無駄は無駄。

Swap ごときに60-90sec かかるのなんというか、シカタナイとは言えやりようあると思うんですが... Azureの制約にかかるので諦めです。(Slot Warmup とこのSwapの時間により無停止でアプリが展開できるのは皮肉というべきか)

Azure Functionsの場合も、ほぼ同様に行けますが、Durable Functions で Slot でちょっと挙動がおかしい感じです。(Slot変数が展開しないような動きが見えるような)

Ref

blogs.msdn.microsoft.com

docs.microsoft.com

www.azuredevopslabs.com

mikepfeiffer.io

*1:現在、同一Resource Group で Windows/Linux のApp Service Plan が混在できないので注意が必要ですが!

PowerShell 6.0 のImport-Csv に W3C 拡張ログ ファイル形式のサポートを追加 #2482 について調べてみた

これを調べていたのは本を書いていたときなので、そろそろ一年経つのですがお蔵入りの前に出しておきます。 PowerShell 6.0 において、W3C 拡張ログが Import-Csv で読み込み可能になったという内容でリリースノートが出ているのですがその内容について。

docs.microsoft.com

目次

TL;DR

Import-Csv がW3Cログに対応したといっても、それはIISログではなくMicrosoft Exchange のログ (W3Cログ形式)となる。 なんという期待を裏切る罠。

まとめおいておきます。

Import-Csv in PowerShell 6.0 supports w3c log format, if delimiter is `,`. https://github.com/PowerShell/PowerShell/pull/2482 · GitHub

W3C 拡張ログとは

W3C に定義があります。

www.w3.org

Introduction で用途がだいたいわかるので引用します。

Most Web servers offer the option to store logfiles in either the common log format or a proprietary format. The common log file format is supported by the majority of analysis tools but the information about each server transaction is fixed. In many cases it is desirable to record more information. Sites sensitive to personal data issues may wish to omit the recording of certain data. In addition ambiguities arise in analyzing the common log file format since field separator characters may in some cases occur within fields. The extended log file format is designed to meet the following needs:

* Permit control over the data recorded.
* Support needs of proxies, clients and servers in a common format
* Provide robust handling of character escaping issues
* Allow exchange of demographic data.
* Allow summary data to be expressed.

重要な箇所はFormat にあります、引用します。

Format
An extended log file contains a sequence of lines containing ASCII characters terminated by either the sequence LF or CRLF. Log file generators should follow the line termination convention for the platform on which they are executed. Analyzers should accept either form. Each line may contain either a directive or an entry.

Entries consist of a sequence of fields relating to a single HTTP transaction. Fields are separated by whitespace, the use of tab characters for this purpose is encouraged. If a field is unused in a particular entry dash "-" marks the omitted field. Directives record information about the logging process itself.

Lines beginning with the # character contain directives. The following directives are defined:

Version: <integer>.<integer>
The version of the extended log file format used. This draft defines version 1.0.
Fields: [<specifier>...]
Specifies the fields recorded in the log.
Software: string
Identifies the software which generated the log.
Start-Date: <date> <time>
The date and time at which the log was started.
End-Date:<date> <time>
The date and time at which the log was finished.
Date:<date> <time>
The date and time at which the entry was added.
Remark: <text>
Comment information. Data recorded in this field should be ignored by analysis tools.
The directives Version and Fields are required and should precede all entries in the log. The Fields directive specifies the data recorded in the fields of each entry.

Example
The following is an example file in the extended log format:

#Version: 1.0
#Date: 12-Jan-1996 00:00:00
#Fields: time cs-method cs-uri
00:34:23 GET /foo/bar.html
12:21:16 GET /foo/bar.html
12:45:52 GET /foo/bar.html
12:57:34 GET /foo/bar.html

ここで最も重要なのは、Fields are separated by whitespace という箇所で、いわゆるデリミター(Delimiter/区切り文字) が 空白スペースであるという記述です。Exampleもそのようになっています。

Import-Csv でIIS ログを読んでみる

リリースノートを見ただけだと、W3Cログ = IIS ログが読み込めるのかと思った方もいらっしゃるかもしれません。 IISログを試していましょう。

#Software: Microsoft Internet Information Services 7.5
#Version: 1.0
#Date: 2013-06-24 10:56:45
#Fields: date time s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) sc-status sc-substatus sc-win32-status time-taken
2013-06-24 10:56:45 192.168.0.1 POST /xas/ - 80 - 222.222.222.222 Mozilla/5.0+(Windows+NT+6.1;+WOW64)+AppleWebKit/537.36+(KHTML,+like+Gecko)+Chrome/27.0.1453.110+Safari/537.36 404 0 2 471

しかし次のようにデリミターは空白文字であり、Import-Csvでは区切りを認識できません。

PS> import-csv .\iis_log.log
 
 <#
 date time s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) sc-status sc-substatus sc-win32-status time-taken
------------------------------------------------------------------------------------------------------------------------------------------
2013-06-24 10:56:45 192.168.0.1 POST /xas/ - 80 - 222.222.222.222 Mozilla/5.0+(Windows+NT+6.1;+WOW64)+AppleWebKit/537.36+(KHTML
 #>

-Delimiter に " " を指定すると読み込みエラーが起こることから、ログのデリミターを変えるしかなさそうです。 IISログに関して、デリミターを変更できるか IIS_SCHEMA.xml で調整を試みるとできないことが分かります。

<!-- just for logFile section -->
<!-- there's no attribute, value to change delimiter -->
      <element name="logFile">
        <attribute name="logExtFileFlags" type="flags" defaultValue="Date, Time, ClientIP, UserName, ServerIP, Method, UriStem, UriQuery, TimeTaken, HttpStatus, Win32Status, ServerPort, UserAgent, HttpSubStatus, Referer">
          <flag name="Date" value="1"/>
          <flag name="Time" value="2"/>
          <flag name="ClientIP" value="4"/>
          <flag name="UserName" value="8"/>
          <flag name="SiteName" value="16"/>
          <flag name="ComputerName" value="32"/>
          <flag name="ServerIP" value="64"/>
          <flag name="Method" value="128"/>
          <flag name="UriStem" value="256"/>
          <flag name="UriQuery" value="512"/>
          <flag name="HttpStatus" value="1024"/>
          <flag name="Win32Status" value="2048"/>
          <flag name="BytesSent" value="4096"/>
          <flag name="BytesRecv" value="8192"/>
          <flag name="TimeTaken" value="16384"/>
          <flag name="ServerPort" value="32768"/>
          <flag name="UserAgent" value="65536"/>
          <flag name="Cookie" value="131072"/>
          <flag name="Referer" value="262144"/>
          <flag name="ProtocolVersion" value="524288"/>
          <flag name="Host" value="1048576"/>
          <flag name="HttpSubStatus" value="2097152"/>
        </attribute>
        <attribute name="customLogPluginClsid" type="string" defaultValue=""/>
        <attribute name="logFormat" type="enum" defaultValue="W3C">
          <enum name="IIS" value="0"/>
          <enum name="NCSA" value="1"/>
          <enum name="W3C" value="2"/>
          <enum name="Custom" value="3"/>
        </attribute>
        <attribute name="logTargetW3C" type="flags" defaultValue="File">
          <flag name="File" value="1"/>
          <flag name="ETW" value="2"/>
        </attribute>
        <attribute name="directory" type="string" expanded="true" defaultValue="%SystemDrive%\inetpub\logs\LogFiles" validationType="nonEmptyString" />
        <attribute name="period" type="enum" defaultValue="Daily">
          <enum name="MaxSize" value="0"/>
          <enum name="Daily" value="1"/>
          <enum name="Weekly" value="2"/>
          <enum name="Monthly" value="3"/>
          <enum name="Hourly" value="4"/>
        </attribute>
        <attribute name="truncateSize" type="int64" defaultValue="20971520" validationType="integerRange" validationParameter="1048576,4294967295" />
        <attribute name="localTimeRollover" type="bool" defaultValue="false"/>
        <attribute name="enabled" type="bool" defaultValue="true" />
        <attribute name="logSiteId" type="bool" defaultValue="true" />
        <attribute name="flushByEntryCountW3CLog" type="uint" defaultValue="0" />
        <attribute name="maxLogLineLength" type="uint" validationType="integerRange" validationParameter="2,65536" defaultValue="65536" />
        <element name="customFields">
          <attribute name="maxCustomFieldLength" type="uint" validationType="integerRange" validationParameter="2,65536" defaultValue="4096" />
          <collection addElement="add" clearElement="clear">
            <attribute name="logFieldName" type="string" required="true" isUniqueKey="true" validationType="nonEmptyString" />
            <attribute name="sourceName" type="string" required="true" validationType="nonEmptyString" />
            <attribute name="sourceType" type="enum" required="true" >
              <enum name="RequestHeader" value="0"/>
              <enum name="ResponseHeader" value="1"/>
              <enum name="ServerVariable" value="2"/>
            </attribute>
          </collection>
        </element>
      </element>
      <element name="traceFailedRequestsLogging">
        <attribute name="enabled" type="bool" defaultValue="false" />
        <attribute name="directory" type="string" expanded="true" defaultValue="%SystemDrive%\inetpub\logs\FailedReqLogFiles"/>
        <attribute name="maxLogFiles" type="uint" defaultValue="50" validationType="integerRange" validationParameter="1,10000"/>
        <attribute name="maxLogFileSizeKB" type="uint" defaultValue="1024" validationType="integerRange" validationParameter="0,1048576"/>
        <attribute name="customActionsEnabled" type="bool" defaultValue="false"/>
      </element>

web.config でも変更できないのは、IIS10 でも変わっていません。

stackoverflow.com

https://docs.mendix.com/refguide5/review-log-files-ms-iis-serverdocs.mendix.com

Advanced Logging ならわんちゃん....?

forums.iis.net

Import-Csv でExchange ログを読んでみる

Import-Csv が対象にしているのは、Delimiter が カンマ , つまり、csv のフォーマットです。 W3C でCSV フォーマットのログ、パッとでませんでしたがあります、Microsoft Exchange です。

docs.microsoft.com

これは実際に、PowerShell チームが Import-Csv で W3C を読むときのテストデータからもわかります。

https://github.com/PowerShell/PowerShell/blob/master/test/powershell/Modules/Microsoft.PowerShell.Utility/assets/TestImportCsv_W3C_ELF.csv

#Software: Microsoft Exchange Server
#Version: 15.0.0.0
#Log-type: Transport Connectivity Log
#Date: 2016-09-16T23:30:07.338Z
#Fields: Column1,Column2,Column 3
data1,1,A
data2,2,B
data3,3,C
data4,4,D

これを読んでみると、ヘッダ部分の # が無視されてデータが読み込まれたのが分かります。

PS> import-csv .\iis_log.csv
 
 # https://github.com/iSazonov/PowerShell/blob/0818b6c921c1970dc294669134266f878352891a/test/powershell/Modules/Microsoft.PowerShell.Utility/assets/TestImportCsv_W3C_ELF.csv
 <#
 Column1 Column2 Column 3
------- ------- --------
data1   1       A
data2   2       B
data3   3       C
data4   4       D
 #>

まとめ

Import-Csv で IISログの W3C ログは読めない、Exchange は読める。なるほど、Import-Csv ですからね。

base64urlを扱えるNuGetライラブラリと.NET Core Global Toolを作りました(MicroBatchFrameworkも使ったよ)

最近 JWT を取り扱っているのですが、仕様上base64url フォーマットを頻繁に利用します。 C# で base64 というと、Convert.FromBase64String あたりですが、base64url にしてくれるような気の利いた仕組みはなく、入力がbase64url仕様に沿ってないとすぐに例外を吐いて使いにくさが目立ちます。*1

個人的には、base64url への変換、base64 と base64url の相互変換 をしてくれれば十分で、フォーマットもstring で上出来です。そこで、自分のJWT操作用に base64url / base64 の対応をするnuget ライブラリ、それとJWTをコンソール上で検証するために、CLIを .NET Core Global Toolとして作りました。

今回はその内容と、dotnet global tool (要はコンソールアプリ) で MicroBatchFramework を使えるようにフィードバックしてた話です。

目次

TL;DR;

C#CLIでbase64url をさくっと操作できます。

.NET Core のCLIもMicroBatchFrameworkで書きやすくなったのでオススメです。

GitHub

ライブラリと .NET Core Global Tool はnuget に置いてあります。普段使っており、npmなどほか言語実装との挙動チェックはしているので問題ないと思いますが、何かあればリポジトリまでお願いします。

github.com

今回、CLIを提供するにあたり.NET Core Global Tool を作りました。*2 当初自前コマンドライン処理で書いたのですが、MicroBatchFrameworkに改善フィードバックを送り続けた結果、CLIでも使いやすくなり MicroBatchFramework へ移行完了しました。

github.com

ゴールとなる使い心地

NuGet ライブラリ

文字列やバイト配列を受けて、utf8 *3 のbase64urlでエンコード/デコードします。また、base64url と base64 の文字列をお互いに切り替えます。普通です。

CLI

次のコマンド入力を満たしつつ、MicroBatchFramework で提供することを目指します。

base64urls [-version] [-help] [encode|decode|escape|unescape] [args]

これはnpm で提供されている b64-cli や base64-url-cli の base64url [encode|decode|escape|unescape|binarydecode] [input] がちょうどいい使い勝手のバランス、かつよく利用されているので、コマンド入力から想定されていない入力時のエンコード/デコードも含めてこの挙動に合わせました。*4

www.npmjs.com

www.npmjs.com

NuGet ライブラリ

.NET Standard 2.0で作っています。

www.nuget.org

仕様に沿ってもくもくと書くだけなので余り書くことがありません。ほとんどすべて1行の処理で、コメントを除くと全部で20行程度なことからも察しかと。

Base64UrlCore/Base64Url.cs at d0538fdb8aa7386b01a9644b2563b0f8b88c5d1d · guitarrapc/Base64UrlCore · GitHub

base64url では、エンコーディング時に文字列長が4の倍数となるようにパディング(=)を末尾追加するのですが、ショートハンドでこう書けます。*5

base64String.Length + (4 - base64String.Length % 4) % 4, '='

.NET Core Global Tool

.NET Core 2.2以上で作っています。実は .NET Core 2.1 でもいいのですが、私はもう 2.2 未満は作らないので上げておきます。*6

www.nuget.org

さて、.NET Core Global Tool はただの.NET Core なコンソールアプリで、配布、インストール、アップグレード、アンインストールをNuGet基盤を利用できます。

利用側は、.NET Core SDKがあればokすぐに使えます。

docs.microsoft.com

開発側は、.NET Core Console テンプレートで作ればokです。詳細は公式Docs で十分書かれているので参照してみるといいでしょう。

docs.microsoft.com

ポイントは、csproj にいれるPackAsToolです。また、私は base64urls とパッケージの名称でC# プロジェクトをわざわざ切りましたが、ToolCommandName を使うことでパッケージに別名を付けることができるので厳守したい方はこっちでどうぞ。*7

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>

    <PackAsTool>true</PackAsTool>
    <ToolCommandName>botsay</ToolCommandName>
    <PackageOutputPath>./nupkg</PackageOutputPath>

  </PropertyGroup>

</Project>

自前のコマンドパーサーで組んでみる

さて、 base64urls としてはじめにCLIを組んだときは自分で引数からコマンドまで書いていました。その時に書いていたコードを置いておきます。

Base64UrlCore/Program.cs at d0538fdb8aa7386b01a9644b2563b0f8b88c5d1d · guitarrapc/Base64UrlCore · GitHub

ごく少量のコマンドをサクッと処理すればいいだけだったので、MonoOptions なども使わずざくっとゴールと同じ挙動を作っています。引数を検査して、コマンドに導いて処理をするだけのよくある小規模なものです。

MicroBatchFramework をこの時使わなかったのは、バッチ用途に限定されており、CLIとして使うには違和感が大きかったためです。例えば次のことがはじめはできませんでした。

* `base64urls encode 入力` という、提供したいコマンド入力の提供はできない。
    * BatchBaseを継承してメソッドをたたくのですが、`-p params` 形式で解釈されるため、一般的なCLIとして提供するのは苦しいです。
* メソッドごとにコマンドにマッピングして提供したいが、`Class.Method` が強制される。
    * バッチとしてはいいのですが、CLIとしては困ります。
* helpとversionはGNUスタイルに沿うようにしたいが、任意のフォーマットでのパラメーター形式ができない
    * クロスプラットフォームで利用するので、GNU スタイルで、-h と -help、--help を提供したいもの対応できません。
* help はデフォルトの引数で、オーバーライドが許可されていない
    * どうしようもない
* コマンド入力なしの時に help を出したいがhelp をオーバーライドできず、独自内容になる。
    * バッチとしては理解して使う分にはいいのですが、CLIとしては初めて使う人にとってはヘルプになってないのでアウト
* 想定されていないコマンド入力時にヘルプを出したいがエラーに回される。
    * しょうがない

MicroBatchFramework で CLI を組む

CLIとしてはいまいち使いにくいことを作者にフィードバックしたところ、「メソッドに対してサブコマンドのマッピングが[Command("decode")]のようなフォーマットで可能になった」、「引数も([Option(0)]T param) で可能になった「と連絡が来ました。

つまり、base64urls decode 値 を次のように表現できるようになったということです。

        [Command("encode")]
        public void Encode([Option(0)]string input) => コマンド;

連絡を受けて、MicroBatchFramework に移行することにしたのが次のPRです。

github.com

MicroBatchFramework 0.45-beta3 の状態で組んでみたところ、次のようになりました。

Base64UrlCore/Program.cs at 8a36ea0f4692581fb65eb1516bc820b3b6ab5c07 · guitarrapc/Base64UrlCore · GitHub

自作コマンドと比較してみると、処理に集中できるようになってきているのが分かります。

左: MicroBatchFramework / 右 : Self command parser

一方で、次の課題が残っています。

* MicroBatchFramework のデフォルト引数である help の挙動をオーバーライドできない。
    * 引数段階で`help` を`-help` に差し替えている
* MicroBatchFramework のデフォルト引数である list の挙動をオーバーライドできない
    * 引数段階で`list` を`-help` に差し替えている
* `[Command]` 属性が複数のコマンドを受け付けない
    * `-v`、`-version`、`--version` や -helpのメソッドを冗長に組んでいる
* コマンドに引数を空で渡したときに、オーバーライドしたhelp が表示されない
* 誤ったコマンドを渡したときに、オーバーライドしたhelpが表示されない

これらは、0.4.5-beta9 のリリースで次のように改善されました。

* MicroBatchFramework のデフォルト引数である help の挙動をオーバーライドできない。
    * コマンドでオーバーライド可能になりました。
* MicroBatchFramework のデフォルト引数である list の挙動をオーバーライドできない
    * コマンドでオーバーライド可能になりました。
* `[Command]` 属性が複数のコマンドを受け付けない`
    * `[Command(new [] {"-v", "-version", "--version"})]` のようなフォーマットで指定できるようになりました。
* コマンドに引数を空で渡したときに、オーバーライドしたhelp が表示されない
    * 空の引数を渡したときにオーバーライドされたhelp が表示できるようになりました。

残りの課題は1つですが、これはすぐに対応はしないとのことだったので、受け付けるコマンドのホワイトリストを作って事前検査することで対応しました。

* 誤ったコマンドを渡したときに、オーバーライドしたhelpが表示されない

CLI のAPI、挙動的にはこの時点で自前コマンドからMicroBatchFrameworkで差し替え可能になりました。コードを比較するとかなりシンプルになっています。

左: MicroBatchFramework / 右 : Self command parser
残った課題は、0.4.5-beta10 で 対応されたので私がGolang や C# で書くCLI的には要件がすべて達成されています。各種処理がほぼ1行に収まりユニットテストと処理内容的にデバッガを挟む必要もないので Expression Methodに書き換えて、MicroBatchFramework への移行が完了しました。

Merge pull request #3 from guitarrapc/chore/ready_for_new_release · guitarrapc/Base64UrlCore@0a6d836 · GitHub

コードを比較してみましょう。自前コマンドパーサーの時から見ると、引数の取り扱いやヘルプにどうやって回すかを考えることなく、やりたい処理だけに集中できるようになっていることが分かるかと思います。特に、Mainメソッドが1行になったことで見通しが良くなり、とっつきやすくなっています。

左: MicroBatchFramework / 右 : Self command parser

まとめ

MicroBatchFramework は、私も注目しているGenericHost の仕組みをうまく活用しつつ、.NET Core でCLIを書くときも書きやすいレベルまで改善されたのでオススメです。*8

*1:NuGet を見ても使いやすいライブラリがなく、 Convert.FromBase64String と Convert.FromBase64String しているだけのものだったりしてカオス

*2:いろいろあるのどうかと思いつつ、あってもいいし、配布機構を考えたくなかった

*3:base64はutf8を想定していますが、一応任意のエンコーディングも

*4:私も自作しても時にこれを使っているので同じ体験がうれしい。

*5:採用していませんが

*6:下げたのをあげるのは大変なので初めから上げておくスタイル

*7:私はこういうところは気にしないというか、あえて合わせに行く方が後日分かりやすいので別名を避けています

*8:NuGet上はPreleaseなので注意です

Visual Studio 2019 で使っている拡張機能

以前 VS2017 で使っている拡張機能について書きました。

tech.guitarrapc.com

VS2019 もRCとなり、いよいよリリースが近づいてきたにつれ、各種拡張機能もサポート対応が進んでいます。

一部はまだ対応されていないものの、今のところいい感じで使えつつあるので一度まとめてみます。

余談ですが、VS2019 では初期読み込みに時間がかかり、VS起動時間に影響のある拡張機能は拡張機能側の実装にバックグラウンドで読み込むことを推奨しています。もし新規に拡張機能を作る場合は検討されるといいでしょう。

左上にSolution名でないのが慣れないのですが、右上のQuick Search横にSolution名出ているので慣れましょう。

右下にはgit リポジトリが出てますが、Slnとずれていることもあります。

目次

Extensions一覧

Recommend Name Purpose Desc
5 Git Diff Margin - Visual Studio Marketplace git status をエディタ画面に表示 差分のハイライト表示をしてくれます
5 SwitchStartupProject for VS 2019 - Visual Studio Marketplace Startup Project の変更と定義 Webなどで複数プロジェクトを同時に起動するのが楽になります
5 VSColorOutput - Visual Studio Marketplace Output Window の色付け 出力された文字に状態に応じて色が付きます。
5 Open on GitHub - Visual Studio Marketplace VSからGitHub へのジャンプ VS2019対応のPRが作者にマージされました。また、VS2019対応だけではパフォーマンス警告がでるので、非同期読み込み対応もマージされています。
5 BuildVision - Visual Studio Marketplace ビルド結果表示の高機能化 [2020/6時点でリリースは確認済み。VS2019 対応は2018/12時点でTrial的に行われておりGitHub上で利用可能です。Background読み込み対応をするまではだVS拡張として配布しない方針のようです。]
3 Swap Selection - Visual Studio Marketplace 選択箇所の入れ替え テストとかで値を入れ替える時に便利
3 Fix Mixed Tabs - Visual Studio Marketplace ソフトタブ、ハードタブの混在ファイルを開いた時に統一できます。 2020/6 不具合はなくなっています。現在パッケージの定義がおかしいためインストール後にVSのエラーが表示されます。
2 Open UserSecrets - Visual Studio Marketplace .NET Core Console で UserSecrets ASP.NET Core MVC の Manage UserSecrets と同様の機能を .NET Core Console でも提供します。.NET Core Console でも標準で出てる気がするのでいらないかな?

修正されるまで入れない方がいいもの

すべて解消されました。

リリースを待っているもの

VS2019向けのリリースを待っている状態です。

すべてリリースされました。

それぞれの拡張

Git Diff Margin

git status の状態がVS上に表示されます。未保存が黄色、追加行が緑、変更が青、削除が赤で表示されています。削除内容も赤い三角から見えます。

marketplace.visualstudio.com

SwitchStartupProject for VS 2019

この拡張を使うとツールバーから Start up Project を確認、設定できます。他にも multi-project startup として、複数プロジェクトの同時起動もjsonで設定できるので、チームでプロジェクト設定をgit共有も簡単です。

marketplace.visualstudio.com

便利じゃないですか?

https://bitbucket.org/thirteen/switchstartupproject/src/tip/Configuration.md?fileviewer=file-view-default

VSColorOutput

Visual Studio なぜかデフォルトでは Output (出力) Window に表示された文字が単色ですが、この拡張をいれるエラーは赤字になったり色付けしてくれます。

marketplace.visualstudio.com

例えば正常ビルド

例えばエラー

Open On GitHub

VS2019 から GitHub に飛ぶ。シンプルで最高によい。

BuildVision

これがないビルド辛すぎるのでリリースされてよかった。

Open UserSecrets

ASP.NET Core では、プロジェクト名を右クリックすると Mange User Secrets (ユーザーシークレットの管理) メニューが出て、ローカル開発用 secrets.json をVSで編集しやすくします。 しかし、.NET Core Console ではこの機能がなく、せっかくMicrosoft.Extensions.Configuration.UserSecrets nuget をいれて User Secrets を取り扱えるようにしても、VSであつかうのに困ります。

この拡張を使うとそんな苦しみがなくなります。

VS2019 (16.9.4) でConsoelApp でも Manage User Secrets が出ているので、そろそろいらない気がします。

marketplace.visualstudio.com

VS2019で使わなくなった拡張

  • Visual Studio IntelliCode: Preview おわって VS2019 に同梱されました。インテリセンス、コード補完と呼ばれるコード支援機能をさらに支援します。手癖を含めて、普段使っているものが優先して表示されるので地味にめちゃめちゃ便利です
  • BlackSpace: eidtorconfig でお任せになったので不要になりました
  • LetMeEdit : 旧csprojでは必要ですが、新csprojではVS標準で提供されるようになりました。特にVS2019ではプロジェクト名ダブルクリックでcsprojの内容を表示するようになり便利です
  • CustomDocumentWell:title: VS2017 までは縦タブがなかったの必須でしたが、VS2019 で標準サポートされたので不要です。色付けなど少し劣りますが、今後の改善待ちで
  • GoToDnSpy: 2021年、dnspy オワコンしたのでおわり。ILSpy でもどうぞ

まとめ

様々な拡張があります。VS2017からVS2019 に対応してみて感じたのですが、VS2019 の対応自体はたいしたことないのですが、バックグラウンドでの非同期読み込み実装はだるい部分があるのでVS2019対応が遅れているやつはそういうのありそうです。

git submodule と git subtree から見る外部リポジトリの取り扱い

先日、外部のgitリポジトリを参照しつつ開発を進めたい時に、改めて今ならどのようにやるといいのか調査と検証を行いました。

開発においてシンプルさは重要です。そのため、利用している言語やフレームワークで標準提供されたパッケージシステムを使うのは優先的に検討するべきです。 しかし会社などでprivate repoでコード参照をしたいときには、参照する外部リポジトリも適切にgit管理したいものです。

gitは、自分のリポジトリを扱うことに関してとても便利な機能がそろっています。 一方で、自分のリポジトリの外、外部リポジトリを連携させることについては、自リポジトリほど楽に取り扱えるわけではありません。

そこで今回は、gitが提供している外部リポジトリのハンドリングとして、submodule と subtree の2つを通して外部リポジトリをどのように扱うのか考えてみます。 これまでは外部リポジトリを参照するときは git submoduleを多用してきたのですが、git subtree も併せてどのようにすればいいのでしょうか。

目次

TL;DR;

  • サブリポジトリでLFS を使いたい場合は、git submodule 一択となる
  • submodule でも書き込みはできるので、読み込み専用というのは違う
  • submodule で編集を行う際は、ブランチを切ってdetouched HEAD 状態をいかに把握可能な状態にするかは更新頻度が高ければ高いほど重要
  • CIのワークフローも考えると、submodule を用いるとSSHがほぼ確定するので、subtree の方がただビルドするだけなら楽
  • subtree は git clone 後に git subtree add でリンクしなおさないと整合性を維持できないので注意
  • subtree はLFS対応さえしていれば、リポジトリ構造的にはシンプル
  • subtree は、サブリポジトリの状態が親リポジトリとローカルで分離されるので仕組みを理解せず使うのは危険

Unity で LFSを使っていて、upmも使えないならgit submodule 一択になります。 依存している外部ライブラリの更新頻度が高く、でもコード参照したいなら git subtree はいい手段になり得ます。

前提

  • CI や普段の開発フローから、git 標準提供しているコマンドを用いる (このため git subrepo は用いない)
  • LFSの対応状況も改めて調べる
  • GUIツールのサポート状況も考慮する

submodule / subtree を使わないという選択

submodule も subtree も、リポジトリの管理に伴い考えることが増えます。

もしgitではなくツールレベルの解決でいいなら、RubyGem、CocoaPods、go get (go modules) などの標準提供されたコード参照ツーリングを使うほうが圧倒的に管理が楽です。 もしコードレベルでの参照が不要であれば、NuGetなどを使ったバイナリ参照 + シンボル参照を使うと管理が減って楽です。 Unityなら upm を使うことでコード参照しつつ、readonly に、循環参照のない一方参照を担保できます。

できれば、submodule も subtree も使わない、そういう選択を検討してください。

ただし、パッケージングにもビルドや展開、バージョン更新が必要となってきます。 はsubmoduleなどでgit pushして参照ヘッドを変えるの同様に、それぞれのパッケージマネージャーにおける時間や手間がかかるため、gitの仕組みでなんとかしたいことも多いでしょう。

概論

git submodule と git subtree は、外部リポジトリを自リポジトリでどのように扱うかの違いです。 大きく分けると、submodule はCommitIdを使った参照、subtree は subtree merge を使ったリポジトリのまとめ上げに相当します。

概略図

理解を整理して説明するために、雑に図を書いていました。 Parent Repo を中心として、右が submodule、左がsubtree の場合の違いです。

git submodule と git subtree の概略図

submodule

最も広く利用されている外部リポジトリの利用方法です。多くのケースはこれで済むはずです。

git submodule は、外部リポジトリの特定のcommit id への参照だけを自分のリポジトリにおきます。(便宜上ここでは、このsubmoduleで取り込んだローカルの外部リポジトリをサブリポジトリと呼びます。また、submoduleを行ったリポジトリを親リポジトリと呼びます。)

参照であるがゆえに、外部リポジトリのコード自体は、親リポジトリの管理下に置かないことがうれしいところです。(つまり git history はそれぞれのリポジトリが自分で管理してお互いに知ったことではありません)

ここからの説明を読む前に、公式資料を読むのがおすすめです。

git - submodule

ポイントはこれです。

サブモジュールを使うと、ある Git リポジトリを別の Git リポジトリのサブディレクトリとして扱うことができるようになります。これで、別のリポジトリをプロジェクト内にクローンしても自分のコミットは別管理とすることができるようになります。

submodule の参照はポインタ

感覚的には、.gitmodule に書かれた commit id がポインタとなり、サブリポジトリの該当commit id のコード状態を自リポジトリで利用するとイメージしやすいです。

git submodule はcommit id をポインタのように参照に利用する

submodule の特徴

項目 対応 備考
参照の柔軟性 ブランチなどではなく特定の commit id でのみ参照する
読み取り利用 readonly で参照するには十分
書き込み利用 submodule で行った変更をcommit/checkout/pushすること。
LFS対応 git lfs を利用していても正常にポインターが解決されます
gitリポジトリの独立性 X サブリポジトリは親リポジトリのgit操作の影響を受ける
clone時に実体化されるか X git submodule update --init が必要 (再帰なら --recursive 追加)

submodule の利用用途

利用用途 推奨度 備考
サブリポジトリを参照用として利用 サブリポジトリにコミットしない場合に最適です。
サブリポジトリを変更もして双方向で利用 サブリポジトリにコミットして、それをpushして親でもmergeするならありです。
サブリポジトリを特定プロジェクトでポートする × おとなしく fork しましょう。サブリポジトリのブランチがどんどん増えて、ブランチごとにプロジェクトでの参照切り替えるんですか?

submodule の運用上の注意

参照であるがために、いくつかsubmodule 用の操作が必要になります。

サブリポジトリはclone以外にsubmodule updateで実体化が必要

サブリポジトリをクローンしても、その時点ではsubmoduleで参照している親リポジトリのあるべきフォルダは.git以外空っぽになっています。そのため、親リポジトリで、git submodule initgit submodule update を行いサブリポジトリのcommitid からコードを実体化する必要があります。これは1つのgitコマンドで行えます。

git submodule update --init --recursive

もしもsubmoduleの参照先が更新されたときには、各自は必ず git submodule update を行う必要があります。

サブリポジトリで行った保持したい変更は必ずpushする

サブリポジトリは、親リポジトリから見ると別のgitリポジトリです。 そのため、サブリポジトリで変更を行って、それを親リポジトリから参照するためには、サブリポジトリで行った変更を必ずpushする必要gがあります。

もしpushを忘れたら?を考えてみましょう。 元のsubmodule参照がcommit id 1234 とします。 ローカルのサブリポジトリで変更してcommit id 5678 ができました。しかし、ここでサブリポジトリの変更をpushしないということは非公開の変更といえます。とはいえ、ローカルの親リポジトリからは変更が検知できているのでsubmodule の参照を1234 から5678に変更できます。 ではこの親リポジトリのsubmodule変更をpushするとどうなるでしょうか?

答えはほかの人がsubmodule の変更にある参照コミット5678を取り込もうと思っても、サブリポジトリの変更コミットはpushされていないので1234までしかなく5678は見つからずエラーになります。

この状態を解決するには、push忘れをしている人がローカルのサブリポジトリの変更コミット5678 をpushして、ほかの人はsubmodule update しましょう。

犯人がわかっても、メールで彼を怒鳴りつけるのはやめましょう。

サブリポジトリに変更を行う場合の親リポジトリの注意

サブリポジトリは事実上1つのgitリポジトリです。そのため、そこに変更をすることもできますし、コミットも、ブランチ作成もpushもできます、普通の git リポジトリです。

しかし、通常のgitリポジトリと大きく違うのが親リポジトリの操作でサブリポジトリの状態が変わりえることです。通常のgitリポジトリは、各gitリポジトリが個別に独立しているのでお互いの操作は自身にしか影響しませんが、submodule を行うと親リポジトリの操作で、サブリポジトリが操作されます。

親リポジトリがgit submodule updateすると、サブリポジトリでは親が参照しているcommit idに基づき git checkout commithashが実行されます。当然ですね。 しかし、サブリポジトリに変更を入れていた場合はどうでしょうか? commitid にcheckout するということは、まだcommit/checkout/push していない変更は当然なくなります。

この親gitリポジトリの操作がサブリポジトリに影響するというgitリポジトリの独立性がなくなることが、submoduleの最大の注意点でわかりにくいといわれる所以のようです。

lfs 利用時の注意

LFSは対象ファイルを、git 上にポインターファイル / 外部ストレージに実体 と分離します。 コミットごとに、ポインターファイルによって正常に解決される必要があり、またGit上もポインターファイルが実体ではなく、実体ファイルが実体です。つまり、git上にポインターファイルだけがあって、それがLFSとして認識されていなかったら困るということです。

LFS は対象ファイルのコミットごとにポインター/実体の解決をsmuge フィルター/cleanフィルターで行います。そのため、commit id 参照を戻したりする操作とはあまり相性がよくありません。フィルターを通して作業する分には問題ないのですが、何かしらの不具合でフィルターを通らずポインターファイルがそのままコミットされることも起こりえます。

とくにsubmoduleを使っていると起こりやすいので、submoduleにはご注意ください。

submodule の問題点

先ほどの注意はそのまま問題点といえます。

サブリポジトリでの作業とgit submodule update

submoduleのディレクトリであるサブリポジトリで作業するときは、必ずgit submodule updateをして特定のバージョンをチェックアウトする必要があります。しかしこの commitid は「常にブランチの中にあるもの(どこかのブランチのHEAD)」を示すわけではなく特定のcommit に過ぎず切り離された HEAD (detached HEAD) をさすことになります。さて、普段のgit操作でHEADでないところで操作することは頻繁にあるでしょうか? 普段から行うことはまず思わないはずです。HEADにいないということは、手元の変更が簡単に失われ、またその特定のcommid id に合わせるのが難しいからです。

怖い状況を作ってみましょう。git submodule update を最初に行い、適当にサブリポジトリに変更をコミットし、再びgit submodule updateしてみると、親プロジェクトでコミットが何もなくてもサブリポジトリの参照状態が更新されます。もちろんcommid id を丹念に眺めて checkout すれば戻せますが、commitid を探して頑張って戻すのは大変です。しかもそれが、こんなささいなことで変わるのです。

これに対処するためには、submodule で作業をするときにはブランチを切って自分でheadを示す必要があります。 ブランチを切ってあればそのブランチのHEADに戻せばいいので簡単です。 最も楽なのは、submodule の元リポジトリを更新して、自分のリポジトリのサブリポジトリをそのHEADに合わせることですが、そうも言ってられないときには留意しましょう。

submoduleとブランチ切り替え

もっと厄介な問題が、ブランチ間のsubmodule の参照です。

例えばmasterではsubmodule をもっていない状態で、ブランチAを作ります。ここでリポジトリX をsubmodule で追加します。この後、masterに戻るとどうなるでしょうか?submodule のディレクトリが「追跡されていないディレクトリ」として残ったままになります。

master で継続して作業するには、このディレクトリをどこかに移すか削除する必要があります。そして、先ほどのブランチXに戻ったときに、改めてクローンしなおし必要があるでしょう。もしブランチAでsubmoduleに関するpush し忘れていたら? ローカルからは変更はpush、あるいはローカルコピーしていないと失われるでしょう。

サブディレクトリからsubmoduleへの切り替え

もしも HOGEというディレクトリをそのまま submodule HOGEで置き換えたくなったら、まずHOGEというディレクトリをunstage する必要があります。いきなり HOGEを削除して、submodule add で HOGeリポジトリを追加しようとしても起こられます。

ブランチを切り替えるとどうでしょうか? 先ほどの操作をブランチB で行い、まだsubmoduleへ切り替えていないサブディレクトリのある master ブランチにチェックアウトしようとすると当然起こられます。これは、HOGEディレクトリをいったん逃がせばokです。 またブランチBに戻ったときは? git submodule update をしましょう、忘れやすいですね!

subtree

外部のgitリポジトリを自分のgitリポジトリのブランチとして取り込み、そのブランチを丸ごとサブディレクトリに配置します。

git subtree は subtree merge 戦略に基づき、外部リポジトリの対象ブランチをサブディレクトリに配置しそこで対象のリポジトリを管理します。(便宜上、このsubtree で取り込んだローカルの外部リポジトリをサブリポジトリと呼びます。また、subtreeを行ったリポジトリを親リポジトリと呼びます。)

git submodule と違い、git subtree では外部リポジトリのコード自体が親リポジトリの管理下に入ります。(つまり親リポジトリのgit history にサブリポジトリのヒストリが乗ってきます。squash でまとめることはできます)

subtree といったときに subtree merge と git subtree で混乱します。朗報です、git subtree はsubtree merge 操作のラッパーです。そのため、以降subtree といったときは git subtree を指し、subtree merge はその通り呼びます。

git subtree が subtree merge 操作のラッパーということは、git subtree の挙動を理解して利用するには、subtree merge 戦略を理解するのが早いです。実際git subtree を提供している Shell Scriptにはgit read-treegit merge -s subtree があるのがわかります。

Github - git/git - git-subtree.sh

git-subtree の実装は subtree merge 戦略のコマンドのラッパー

git-subtree の実装は subtree merge 戦略のコマンドのラッパー 2

ここからの説明を読む前に、公式資料を読むのがおすすめです。

git - subtree merge

ポイントはこれです。

サブツリーマージの考え方は、ふたつのプロジェクトがあるときに一方のプロジェクトをもうひとつのプロジェクトのサブディレクトリに位置づけたりその逆を行ったりするというものです。サブツリーマージを指定すると、Git は一方が他方のサブツリーであることを理解して適切にマージを行います。驚くべきことです。

subtree はリモートリポジトリのブランチをローカルサブディレクトリに展開する

subtree merge を使って親リポジトリのサブディレクトリとして展開したサブリポジトリは、subtree対象となったリモートリポジトリの指定したブランチの状態であり実体を持ちます。このため、サブリポジトリの変更は親リポジトリにコミット/プッシュされます。また、subtree対象のリモートリポジトリにも反映したい場合は、git subtree pull/pushで、リモートリポジトリとのマージを行います。

git subtree は自リポジトリのディレクトリに外部リポジトリのブランチを展開して取り込む

subtree の特徴

項目 対応 備考
参照の柔軟性 リモートリポジトリのブランチを使って柔軟に対応ができる。また親リポジトリだけに変更することもでき、完全に乖離させることも簡単にできる。
読み取り利用 readonly で参照するには十分。親リポジトリで管理されているサブリポジトリと同期させる場合、subtreeのリモートリポジトリとのマージを行っていく必要があるため煩雑
書き込み利用 サブリポジトリの変更は親リポジトリに記録されるので簡単。subtreeのリモートリポジトリと同期する場合は、リモートリポジトリの反映を漏らさないようにしていく必要はある。
LFS対応 × git lfs はサポート対象外 (2019/3現在)
gitリポジトリの独立性 サブリポジトリは親リポジトリの一部としてヒストリにも書き込まれる。subtreeもとのリモートリポジトリとは同期を試みない限り独立している
clone時に実体化されるか 通常のgit push/pull操作で実体化されます。ただしclone後に git subtree add をして外部リポジトリ

subtree の利用用途

利用用途 推奨度 備考
サブリポジトリを参照用として利用 それ submodule じゃだめですか?
サブリポジトリを変更もして双方向で利用 まさにそれようです。親リポジトリで完結する一方で、リモートリポジトリと乖離しやすいので注意。
サブリポジトリを特定プロジェクトでポートする fork でいいならfork がいいですが、プロジェクトに突っ込みたいときの選択肢としては有用です。

subtree の運用上の注意

git subtree は実体であり、ただの subtree merge に基づいたコマンドに過ぎません。これに伴いいくつかの注意があります。

サブリポジトリの追加時にサブリポジトリのgit historyが大量に入ってしまう

git subtree で親リポジトリにサブリポジトリを追加する際に、サブリポジトリのgit historyが入ります。 Mergeであることから当然なのですが、サブリポジトリに10000コミットあった場合、ただ git subtreee add すると親リポジトリのコミットヒストリに10000件追加されます。親リポジトリのhistoryが埋め尽くされることがどれだけつらいことかは共感いただけるのではないでしょうか。

しかしこの対処は簡単です、squash を使ってコミットをまとめ上げましょう。git subbree add は、mergeにすぎないので親リポジトリにサブリポジトリを追加する時にmerge commit 1つにコミットをまとめてしまえばいいのです。これでmerge commit 1件のhistory追加で済みます。良かったよかった。

git push と git subtree push

親リポジトリのリモートにpush するときは、git pushでokです。 一方で、サブリポジトリのリモートに対してサブリポジトリの変更を反映するためには、git subtree push を別途する必要があります。 もちろんこの時に、リモート側に変更があった場合は先にgit subtree pull をしてコンフリクト解決、Mergeを行ってから git subtree pushをする必要があ倫さう。

親リポジトリのリモートはだれがsubtree mergeで管理されているのか知らない

subtree merge はローカルで行われます。そのため、git subtree でsubtree add した親リポジトリをpushして、ほかの開発者がそのリポジトリをクローンしても git subtree 操作がされていない状態になります。

CIでビルドするだけなら、何もする必要はなくすぐにビルドを開始できます。最高! 一方で、開発者が継続してそのリポジトリでsubtree として開発していくには、git clone した後git subtree add を使って、どのローカルのサブフォルダがどのリモートリポジトリのsubtree なのかリンクする必要があります。

たとえば、subtree化されたリポジトリをclone してみると、次のようにsubtree が解けています。

他の人がsubtree addしてpushしたリモートリポジトリをcloneした直後の状態

そこでsubtree 対象のサブディレクトリをsubtree add します。

clone 後にsubtreeをリンクしなおす

これで意図したとおりsubtree として利用ができるようになります。

subtree が意図した通りリンクされて他の開発者と同様にあつかえるようになる

subtree の問題点

サブリポジトリのリモートの変更状況がわからない

git subtree を使っていても、git subtree を行っているサブリポジトリのリモートとの差分はツリー上でわかりません。

あくまでも親リポジトリのツリーしかでず、subtree 側のツリーを出すことはできないので注意です。

サブリポジトリのツリーは表示されず、サブリポジトリのリモートの状態もわからない

コミット履歴の複雑化

subtree を使った場合、サブリポジトリのコミットは親リポジトリのコミットとなりヒストリに交じってきます。そして、subtree で追加したサブリポジトリのリモートリポジトリの変更を取り込んだ時のmergeコミットも交じってきます。圧倒的なコミット爆増 と複雑化は否めません。

サブリポジトリと、リモートにあるsubtree add したリポジトリの2つ状態を持っているのですから仕方ないでしょう。 ただ、git subtree addの時と同様に、 git subtree push / git subtree pull した時のコミットもsquash してまとめ上げることはできるので、追加タイミングをこれで明示的に示すのは可能です。

LFSが非対応

LFS を使っているリポジトリをsubtree で追加して、push しようとすると失敗します。

これは、LFSがまだ git subtree に対応していないためです。

https://github.com/git-lfs/git-lfs/issues/1948

頑張ってLFSしつつ自前で書くという手もなきにしもあらず、サポートはありませんが。

git GUIクライアントの対応状況

対応済み

  • SourceTree

未対応

  • GitKraken

upstream 操作という複雑性

git subtree の最大の利点は、submodule のように meta情報や git submodule コマンドを知らずとも、git clone すればすぐに依存モジュールも触れて始めやすいことです。

一方で、複数リポジトリがまとまることによる肥大化、コミット履歴が混じること、subtree merge 戦略を知らないとまともに扱おうとしたときに苦労すること、upstream とのmerge という複雑性と戦う必要があることが複雑性を呼びます。

submodule に比べて、別の理解を求められるケースがあるため注意してください。

参考サイト

When to use git subtree?

Git submoduleの押さえておきたい理解ポイントのまとめ

gitで外部moduleを扱う方法(subtree)