Repository: GoogleContainerTools/skaffold
Pull request #7684 (2022-07-26 16:03:31)

feat: add image platform `nodeAffinity` definition to render output
<!-- Thank you for your contribution! -->

<!-- Include if applicable: -->
Fixes: #7637 <!-- tracking issues that this PR will close -->

**Description**
<!-- Describe your changes here. The more detail, the easier the review! -->
The hydrated manifests now include image platform `nodeAffinity` definitions.

Testing:
1. In any skaffold project, run `skaffold build --default-repo=<your container registry>`.
2. Run `skaffold render --default-repo=<your container registry> --enable-platform-node-affinity=true`
3. The rendered output should include `nodeAffinity` for each built image for its `operating system` and `architecture`:
```yaml
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: kubernetes.io/os
                operator: In
                values:
                - linux
              - key: kubernetes.io/arch
                operator: In
                values:
                - arm64
```


<!--
Please be sure your PR includes unit tests - we don't merge code that brings down test coverage! 
Integration tests are sometimes an appropriate substitute. 
-->


codecov[bot]: # [Codecov](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) Report
> Merging [#7684](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (4ccc382) into [main](https://codecov.io/gh/GoogleContainerTools/skaffold/commit/290280e8c6c19231e9ed5b5c027f2c01d22f75be?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (290280e) will **decrease** coverage by `4.11%`.
> The diff coverage is `53.95%`.

```diff
@@            Coverage Diff             @@
##             main    #7684      +/-   ##
==========================================
- Coverage   70.48%   66.36%   -4.12%     
==========================================
  Files         515      587      +72     
  Lines       23150    28254    +5104     
==========================================
+ Hits        16317    18752    +2435     
- Misses       5776     8132    +2356     
- Partials     1057     1370     +313     
```


| [Impacted Files](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | Coverage Δ | |
|---|---|---|
| [cmd/skaffold/app/cmd/credits/export.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvY3JlZGl0cy9leHBvcnQuZ28=) | `0.00% <0.00%> (ø)` | |
| [cmd/skaffold/app/cmd/deploy.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvZGVwbG95Lmdv) | `40.90% <0.00%> (-12.94%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/test.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvdGVzdC5nbw==) | `44.44% <0.00%> (ø)` | |
| [cmd/skaffold/skaffold.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL3NrYWZmb2xkLmdv) | `0.00% <0.00%> (ø)` | |
| [cmd/skaffold/app/cmd/inspect\_tests.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvaW5zcGVjdF90ZXN0cy5nbw==) | `62.50% <14.28%> (-1.14%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/render.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvcmVuZGVyLmdv) | `35.48% <18.18%> (-5.90%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/lsp.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvbHNwLmdv) | `28.12% <28.12%> (ø)` | |
| [cmd/skaffold/app/cmd/run.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvcnVuLmdv) | `64.28% <33.33%> (-9.63%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/fix.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvZml4Lmdv) | `55.93% <38.88%> (-20.54%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/verify.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvdmVyaWZ5Lmdv) | `41.17% <41.17%> (ø)` | |
| ... and [330 more](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7684/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | |

:mega: Codecov can now indicate which changes are the most critical in Pull Requests. [Learn more](https://about.codecov.io/product/feature/runtime-insights/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools)

tejal29: We need to add some documentation on when to and why to enable `--node-affinity` flag. The flag help can point to the doc link. Can be addressed in a another PR.

Patch:
diff --git a/cmd/skaffold/app/cmd/flags.go b/cmd/skaffold/app/cmd/flags.go
index 5a1f48097..4a73b9960 100644
--- a/cmd/skaffold/app/cmd/flags.go
+++ b/cmd/skaffold/app/cmd/flags.go
@@ -644,6 +644,14 @@ var flagRegistry = []Flag{
 		FlagAddMethod: "StringVar",
 		DefinedOn:     []string{"verify"},
 	},
+	{
+		Name:          "enable-platform-node-affinity",
+		Usage:         "If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms",
+		Value:         &opts.EnablePlatformNodeAffinity,
+		DefValue:      false,
+		FlagAddMethod: "BoolVar",
+		DefinedOn:     []string{"dev", "render", "run", "debug", "deploy"},
+	},
 }
 
 func methodNameByType(v reflect.Value) string {
diff --git a/docs/content/en/docs/references/cli/_index.md b/docs/content/en/docs/references/cli/_index.md
index 08c39c783..44c60df29 100644
--- a/docs/content/en/docs/references/cli/_index.md
+++ b/docs/content/en/docs/references/cli/_index.md
@@ -408,6 +408,7 @@ Options:
   -c, --config='': File for global configurations (defaults to $HOME/.skaffold/config)
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -466,6 +467,7 @@ Env vars:
 * `SKAFFOLD_CONFIG` (same as `--config`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -580,6 +582,7 @@ Options:
   -c, --config='': File for global configurations (defaults to $HOME/.skaffold/config)
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -626,6 +629,7 @@ Env vars:
 * `SKAFFOLD_CONFIG` (same as `--config`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -678,6 +682,7 @@ Options:
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -736,6 +741,7 @@ Env vars:
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -945,6 +951,7 @@ Options:
       --cache-artifacts=true: Set to false to disable default caching of artifacts
   -d, --default-repo='': Default repository value (overrides global config)
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
   -i, --images=: A list of pre-built images to deploy, either tagged images or NAME=TAG pairs
@@ -976,6 +983,7 @@ Env vars:
 * `SKAFFOLD_CACHE_ARTIFACTS` (same as `--cache-artifacts`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
 * `SKAFFOLD_IMAGES` (same as `--images`)
@@ -1019,6 +1027,7 @@ Options:
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -1072,6 +1081,7 @@ Env vars:
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
diff --git a/pkg/skaffold/config/options.go b/pkg/skaffold/config/options.go
index abceeb6bf..2b92a83ef 100644
--- a/pkg/skaffold/config/options.go
+++ b/pkg/skaffold/config/options.go
@@ -32,74 +32,75 @@ type WaitForDeletions struct {
 
 // SkaffoldOptions are options that are set by command line arguments not included in the config file itself
 type SkaffoldOptions struct {
-	Apply                 bool
-	AutoBuild             bool
-	AutoCreateConfig      bool
-	AutoDeploy            bool
-	AutoSync              bool
-	AssumeYes             bool
-	CacheArtifacts        bool
-	ContainerDebugging    bool
-	Cleanup               bool
-	DetectMinikube        bool
-	DryRun                bool
-	EnableRPC             bool
-	Force                 bool
-	ForceLoadImages       bool
-	IterativeStatusCheck  bool
-	FastFailStatusCheck   bool
-	Notification          bool
-	NoPrune               bool
-	NoPruneChildren       bool
-	ProfileAutoActivation bool
-	PropagateProfiles     bool
-	RenderOnly            bool
-	SkipTests             bool
-	SkipConfigDefaults    bool
-	Tail                  bool
-	WaitForConnection     bool
-	MakePathsAbsolute     *bool
-	MultiLevelRepo        *bool
-	ConfigurationFile     string
-	HydrationDir          string
-	InventoryNamespace    string
-	InventoryID           string
-	InventoryName         string
-	GlobalConfig          string
-	EventLogFile          string
-	RenderOutput          string
-	User                  string
-	CustomTag             string
-	Namespace             string
-	CacheFile             string
-	Trigger               string
-	KubeContext           string
-	KubeConfig            string
-	LastLogFile           string
-	DigestSource          string
-	Command               string
-	MinikubeProfile       string
-	RepoCacheDir          string
-	TransformRulesFile    string
-	VerifyDockerNetwork   string
-	CustomLabels          []string
-	TargetImages          []string
-	Profiles              []string
-	InsecureRegistries    []string
-	ConfigurationFilter   []string
-	HydratedManifests     []string
-	Platforms             []string
-	BuildConcurrency      int
-	WatchPollInterval     int
-	StatusCheck           BoolOrUndefined
-	PushImages            BoolOrUndefined
-	RPCPort               IntOrUndefined
-	RPCHTTPPort           IntOrUndefined
-	Muted                 Muted
-	PortForward           PortForwardOptions
-	DefaultRepo           StringOrUndefined
-	SyncRemoteCache       SyncRemoteCacheOption
-	WaitForDeletions      WaitForDeletions
+	Apply                      bool
+	AutoBuild                  bool
+	AutoCreateConfig           bool
+	AutoDeploy                 bool
+	AutoSync                   bool
+	AssumeYes                  bool
+	CacheArtifacts             bool
+	ContainerDebugging         bool
+	Cleanup                    bool
+	DetectMinikube             bool
+	DryRun                     bool
+	EnableRPC                  bool
+	Force                      bool
+	ForceLoadImages            bool
+	IterativeStatusCheck       bool
+	FastFailStatusCheck        bool
+	Notification               bool
+	NoPrune                    bool
+	NoPruneChildren            bool
+	ProfileAutoActivation      bool
+	PropagateProfiles          bool
+	RenderOnly                 bool
+	SkipTests                  bool
+	SkipConfigDefaults         bool
+	Tail                       bool
+	WaitForConnection          bool
+	EnablePlatformNodeAffinity bool
+	MakePathsAbsolute          *bool
+	MultiLevelRepo             *bool
+	ConfigurationFile          string
+	HydrationDir               string
+	InventoryNamespace         string
+	InventoryID                string
+	InventoryName              string
+	GlobalConfig               string
+	EventLogFile               string
+	RenderOutput               string
+	User                       string
+	CustomTag                  string
+	Namespace                  string
+	CacheFile                  string
+	Trigger                    string
+	KubeContext                string
+	KubeConfig                 string
+	LastLogFile                string
+	DigestSource               string
+	Command                    string
+	MinikubeProfile            string
+	RepoCacheDir               string
+	TransformRulesFile         string
+	VerifyDockerNetwork        string
+	CustomLabels               []string
+	TargetImages               []string
+	Profiles                   []string
+	InsecureRegistries         []string
+	ConfigurationFilter        []string
+	HydratedManifests          []string
+	Platforms                  []string
+	BuildConcurrency           int
+	WatchPollInterval          int
+	StatusCheck                BoolOrUndefined
+	PushImages                 BoolOrUndefined
+	RPCPort                    IntOrUndefined
+	RPCHTTPPort                IntOrUndefined
+	Muted                      Muted
+	PortForward                PortForwardOptions
+	DefaultRepo                StringOrUndefined
+	SyncRemoteCache            SyncRemoteCacheOption
+	WaitForDeletions           WaitForDeletions
 }
 
 type RunMode string
diff --git a/pkg/skaffold/deploy/kubectl/cli.go b/pkg/skaffold/deploy/kubectl/cli.go
index 7ddc73902..41af0a5f7 100644
--- a/pkg/skaffold/deploy/kubectl/cli.go
+++ b/pkg/skaffold/deploy/kubectl/cli.go
@@ -61,6 +61,7 @@ type Config interface {
 	Tail() bool
 	PipelineForImage(imageName string) (latest.Pipeline, bool)
 	JSONParseConfig() latest.JSONParseConfig
+	EnablePlatformNodeAffinityInRenderedManifests() bool
 }
 
 func NewCLI(cfg Config, flags latest.KubectlFlags, defaultNamespace string) CLI {
diff --git a/pkg/skaffold/docker/platform.go b/pkg/skaffold/docker/platform.go
new file mode 100644
index 000000000..f04394aad
--- /dev/null
+++ b/pkg/skaffold/docker/platform.go
@@ -0,0 +1,96 @@
+/*
+Copyright 2019 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package docker
+
+import (
+	"fmt"
+
+	"github.com/google/go-containerregistry/pkg/name"
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	"github.com/google/go-containerregistry/pkg/v1/types"
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/util"
+)
+
+// GetPlatforms returns the platforms of the provided image.
+func GetPlatforms(image string) ([]spec.Platform, error) {
+	i, err := getImage(image)
+	if err != nil {
+		return nil, err
+	}
+
+	mt, err := i.MediaType()
+	if err != nil {
+		return nil, err
+	}
+	var p []spec.Platform
+	switch mt {
+	case types.OCIImageIndex, types.DockerManifestList:
+		ix, ok := i.(v1.ImageIndex)
+		if !ok {
+			return nil, fmt.Errorf("failed to interpret %q as index: %v", image, i)
+		}
+		manifests, err := ix.IndexManifest()
+		if err != nil {
+			return nil, err
+		}
+		for _, m := range manifests.Manifests {
+			if m.Platform == nil {
+				continue
+			}
+			p = append(p, util.ConvertFromV1Platform(*m.Platform))
+		}
+	case types.OCIManifestSchema1, types.DockerManifestSchema2:
+		im, ok := i.(v1.Image)
+		if !ok {
+			return nil, fmt.Errorf("failed to interpret %q as image: %v", image, im)
+		}
+		cf, err := im.ConfigFile()
+		if err != nil {
+			return nil, err
+		}
+		p = append(p, spec.Platform{OS: cf.OS, Architecture: cf.Architecture})
+	default:
+		return nil, fmt.Errorf("image media type: %s", mt)
+	}
+	if err != nil {
+		return nil, err
+	}
+	return p, nil
+}
+
+func getImage(image string) (ImageRef, error) {
+	ref, err := name.ParseReference(image, name.WeakValidation)
+	if err != nil {
+		return nil, err
+	}
+	desc, err := remote.Get(ref, remote.WithAuthFromKeychain(primaryKeychain))
+	if err != nil {
+		return nil, err
+	}
+	if desc.MediaType.IsIndex() {
+		return desc.ImageIndex()
+	}
+	return desc.Image()
+}
+
+// ImageRef represents a v1.Image or v1.ImageIndex.
+type ImageRef interface {
+	MediaType() (types.MediaType, error)
+}
diff --git a/pkg/skaffold/kubernetes/manifest/affinity.go b/pkg/skaffold/kubernetes/manifest/affinity.go
new file mode 100644
index 000000000..1c12a0ab7
--- /dev/null
+++ b/pkg/skaffold/kubernetes/manifest/affinity.go
@@ -0,0 +1,259 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifest
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+	v1 "k8s.io/api/core/v1"
+	apimachinery "k8s.io/apimachinery/pkg/runtime/schema"
+
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/docker"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/output/log"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/platform"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/yaml"
+)
+
+// for testing
+var getPlatforms = docker.GetPlatforms
+
+const (
+	specField     = "spec"
+	affinityField = "affinity"
+
+	// kubernetes.io/arch and kubernetes.io/os are known node labels. See https://kubernetes.io/docs/reference/labels-annotations-taints/
+	nodeOperatingSystemLabel = "kubernetes.io/os"
+	nodeArchitectureLabel    = "kubernetes.io/arch"
+)
+
+type ResourceSelectorAffinity struct {
+	allowlist map[apimachinery.GroupKind]latest.ResourceFilter
+	denylist  map[apimachinery.GroupKind]latest.ResourceFilter
+}
+
+func NewResourceSelectorAffinity(allowlist map[apimachinery.GroupKind]latest.ResourceFilter, denylist map[apimachinery.GroupKind]latest.ResourceFilter) *ResourceSelectorAffinity {
+	return &ResourceSelectorAffinity{
+		allowlist: allowlist,
+		denylist:  denylist,
+	}
+}
+
+func (rsa *ResourceSelectorAffinity) allowByGroupKind(gk apimachinery.GroupKind) bool {
+	if _, allowed := rsa.allowlist[gk]; allowed {
+		if rf, disallowed := rsa.denylist[gk]; disallowed {
+			for _, s := range rf.Labels {
+				if s == ".*" {
+					return false
+				}
+			}
+			for _, s := range rf.Image {
+				if s == ".*" {
+					return false
+				}
+			}
+			for _, s := range rf.Affinity {
+				if s == ".*" {
+					return false
+				}
+			}
+		}
+		return true
+	}
+	return false
+}
+
+//nolint:unparam
+func (rsa *ResourceSelectorAffinity) allowByNavpath(gk apimachinery.GroupKind, navpath string, k string) (string, bool) {
+	for _, w := range ConfigConnectorResourceSelector {
+		if w.Matches(gk.Group, gk.Kind) {
+			return "", false
+		}
+	}
+
+	if rf, ok := rsa.denylist[gk]; ok {
+		for _, denypath := range rf.Affinity {
+			if denypath == ".*" {
+				return "", false
+			}
+			if navpath == denypath {
+				return "", false
+			}
+		}
+	}
+
+	if rf, ok := rsa.allowlist[gk]; ok {
+		for _, allowpath := range rf.Affinity {
+			if allowpath == ".*" {
+				if k != specField {
+					return "", false
+				}
+				return "", true
+			}
+			if navpath == allowpath {
+				return "", true
+			}
+		}
+	}
+	return "", false
+}
+
+// SetPlatformNodeAffinity adds `nodeAffinity` definitions specific to the target platform (os/architecture) for each built image.
+func (l *ManifestList) SetPlatformNodeAffinity(ctx context.Context, rs ResourceSelector, platforms PodPlatforms) (ManifestList, error) {
+	r := &nodeAffinitySetter{ctx: ctx, platforms: platforms}
+	return l.Visit(r, rs)
+}
+
+func (l *ManifestList) GetImagePlatforms(ctx context.Context, rs ResourceSelector) (PodPlatforms, error) {
+	s := &imagePlatformSaver{ctx: ctx, platforms: make(PodPlatforms)}
+	_, err := l.Visit(s, rs)
+	return s.platforms, parseImagesInManifestErr(err)
+}
+
+// PodPlatforms maps the pod spec json path to the list of common platforms for all containers in that pod.
+type PodPlatforms map[string][]spec.Platform
+
+type imagePlatformSaver struct {
+	ctx       context.Context
+	platforms PodPlatforms
+}
+
+func (r *imagePlatformSaver) Visit(gk apimachinery.GroupKind, navpath string, o map[string]interface{}, k string, v interface{}, rs ResourceSelector) bool {
+	if k != imageField {
+		return true
+	}
+	image, ok := v.(string)
+	if !ok {
+		return true
+	}
+	platforms, err := getPlatforms(image)
+	if err != nil {
+		log.Entry(r.ctx).Debugf("Couldn't get target platforms for image %q: %s", image, err.Error())
+		return false
+	}
+	specPath := strings.TrimSuffix(navpath, ".containers.image")
+	if pl, ok := r.platforms[specPath]; ok && len(pl) > 0 {
+		// only keep common platforms for all containers in a pod spec.
+		// for example, if a pod spec has two containers where the first image is a manifest list supporting `linux/arm64` and `linux/amd64`,
+		// and the second image only supports `linux/amd64`, then we only save `linux/amd64` platform for that pod spec.
+		r.platforms[specPath] = platform.Matcher{Platforms: pl}.Intersect(platform.Matcher{Platforms: platforms}).Platforms
+	} else {
+		r.platforms[specPath] = platforms
+	}
+	return false
+}
+
+type nodeAffinitySetter struct {
+	ctx       context.Context
+	platforms PodPlatforms
+}
+
+func (s *nodeAffinitySetter) Visit(gk apimachinery.GroupKind, navpath string, o map[string]interface{}, k string, v interface{}, rs ResourceSelector) bool {
+	if _, ok := rs.allowByNavpath(gk, navpath, k); !ok {
+		return true
+	}
+
+	if len(s.platforms) == 0 {
+		return false
+	}
+	platforms, ok := s.platforms[navpath]
+	if !ok || len(platforms) == 0 {
+		return true
+	}
+
+	spec, ok := v.(map[string]interface{})
+	if !ok {
+		return true
+	}
+	affinity, err := updateAffinity(spec[affinityField], platforms)
+	if err != nil {
+		log.Entry(s.ctx).Debugf("failed to update affinity definition: %s", err.Error())
+		return true
+	}
+	spec[affinityField] = affinity
+	return false
+}
+
+func updateAffinity(data interface{}, platforms []spec.Platform) (map[string]interface{}, error) {
+	var affinity v1.Affinity
+	if data == nil {
+		affinity = v1.Affinity{}
+	} else {
+		data, err := json.Marshal(data)
+		if err != nil {
+			return nil, err
+		}
+		if err = json.Unmarshal(data, &affinity); err != nil {
+			return nil, err
+		}
+	}
+
+	if affinity.NodeAffinity == nil {
+		affinity.NodeAffinity = &v1.NodeAffinity{}
+	}
+	if affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil {
+		affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution = &v1.NodeSelector{}
+	}
+
+	for _, term := range affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
+		for _, exp := range term.MatchExpressions {
+			if exp.Key == nodeOperatingSystemLabel || exp.Key == nodeArchitectureLabel {
+				return nil, fmt.Errorf("found existing node affinity for os/arch: %s", exp)
+			}
+		}
+	}
+
+	if len(affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms) == 0 {
+		affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = append(affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms, v1.NodeSelectorTerm{})
+	}
+
+	var terms []v1.NodeSelectorTerm
+	for _, pl := range platforms {
+		for _, term := range affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
+			t := term.DeepCopy()
+			if pl.OS != "" {
+				t.MatchExpressions = append(t.MatchExpressions, v1.NodeSelectorRequirement{
+					Key:      nodeOperatingSystemLabel,
+					Operator: v1.NodeSelectorOpIn,
+					Values:   []string{pl.OS},
+				})
+			}
+			if pl.Architecture != "" {
+				t.MatchExpressions = append(t.MatchExpressions, v1.NodeSelectorRequirement{
+					Key:      nodeArchitectureLabel,
+					Operator: v1.NodeSelectorOpIn,
+					Values:   []string{pl.Architecture},
+				})
+			}
+			terms = append(terms, *t)
+		}
+	}
+	affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = terms
+	bytes, err := json.Marshal(affinity)
+	if err != nil {
+		return nil, err
+	}
+	m := make(map[string]interface{})
+	if err := yaml.Unmarshal(bytes, &m); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
diff --git a/pkg/skaffold/kubernetes/manifest/visitor.go b/pkg/skaffold/kubernetes/manifest/visitor.go
index 7ca3ffe85..7dca96d89 100644
--- a/pkg/skaffold/kubernetes/manifest/visitor.go
+++ b/pkg/skaffold/kubernetes/manifest/visitor.go
@@ -38,57 +38,69 @@ var TransformAllowlist = map[apimachinery.GroupKind]latest.ResourceFilter{
 		GroupKind: "Pod",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "", Kind: "Service"}: {
 		GroupKind: "Service",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "DaemonSet"}: {
 		GroupKind: "DaemonSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "Deployment"}: {
 		GroupKind: "Deployment.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "ReplicaSet"}: {
 		GroupKind: "ReplicaSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "StatefulSet"}: {
 		GroupKind: "StatefulSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "batch", Kind: "CronJob"}: {
 		GroupKind: "CronJob.batch",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "batch", Kind: "Job"}: {
 		GroupKind: "Job.batch",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "DaemonSet"}: {
 		GroupKind: "DaemonSet.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "Deployment"}: {
 		GroupKind: "Deployment.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "ReplicaSet"}: {
 		GroupKind: "ReplicaSet.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
+	// TODO: Investigate exact requirements for adding `affinity` definitions for the following custom resource kinds
 	{Group: "serving.knative.dev", Kind: "Service"}: {
 		GroupKind: "Service.serving.knative.dev",
 		Image:     []string{".*"},
diff --git a/pkg/skaffold/render/config.go b/pkg/skaffold/render/config.go
index 1f22d944c..6e6b363a9 100644
--- a/pkg/skaffold/render/config.go
+++ b/pkg/skaffold/render/config.go
@@ -30,17 +30,19 @@ type Config interface {
 	TransformAllowList() []latest.ResourceFilter
 	TransformDenyList() []latest.ResourceFilter
 	Mode() config.RunMode
+	EnablePlatformNodeAffinityInRenderedManifests() bool
 }
 
 type MockConfig struct {
 	WorkingDir string
 }
 
-func (mc MockConfig) GetWorkingDir() string                       { return mc.WorkingDir }
-func (mc MockConfig) TransformAllowList() []latest.ResourceFilter { return nil }
-func (mc MockConfig) TransformDenyList() []latest.ResourceFilter  { return nil }
-func (mc MockConfig) TransformRulesFile() string                  { return "" }
-func (mc MockConfig) ConfigurationFile() string                   { return "" }
-func (mc MockConfig) GetKubeConfig() string                       { return "" }
-func (mc MockConfig) GetKubeContext() string                      { return "" }
-func (mc MockConfig) Mode() config.RunMode                        { return "" }
+func (mc MockConfig) GetWorkingDir() string                               { return mc.WorkingDir }
+func (mc MockConfig) TransformAllowList() []latest.ResourceFilter         { return nil }
+func (mc MockConfig) TransformDenyList() []latest.ResourceFilter          { return nil }
+func (mc MockConfig) TransformRulesFile() string                          { return "" }
+func (mc MockConfig) ConfigurationFile() string                           { return "" }
+func (mc MockConfig) GetKubeConfig() string                               { return "" }
+func (mc MockConfig) GetKubeContext() string                              { return "" }
+func (mc MockConfig) Mode() config.RunMode                                { return "" }
+func (mc MockConfig) EnablePlatformNodeAffinityInRenderedManifests() bool { return true }
diff --git a/pkg/skaffold/render/renderer/kpt/kpt.go b/pkg/skaffold/render/renderer/kpt/kpt.go
index 54981e9e3..ba0ec1042 100644
--- a/pkg/skaffold/render/renderer/kpt/kpt.go
+++ b/pkg/skaffold/render/renderer/kpt/kpt.go
@@ -44,6 +44,7 @@ import (
 )
 
 type Kpt struct {
+	cfg        render.Config
 	configName string
 
 	generate.Generator
@@ -88,6 +89,7 @@ func New(cfg render.Config, rCfg latest.RenderConfig, hydrationDir string, label
 		transformer, _ = transform.NewTransformer([]latest.Transformer{})
 	}
 	return &Kpt{
+		cfg:                cfg,
 		configName:         configName,
 		Generator:          generator,
 		Validator:          validator,
@@ -146,7 +148,12 @@ func (r *Kpt) Render(ctx context.Context, out io.Writer, builds []graph.Artifact
 		return ml, errors.DeleteKptfileError(err, r.hydrationDir)
 	}
 	endTrace()
-	manifests, errH := rUtil.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, r.transformAllowlist, r.transformDenylist)
+	opts := rUtil.GenerateHydratedManifestsOptions{
+		TransformAllowList:         r.transformAllowlist,
+		TransformDenylist:          r.transformDenylist,
+		EnablePlatformNodeAffinity: r.cfg.EnablePlatformNodeAffinityInRenderedManifests(),
+	}
+	manifests, errH := rUtil.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, opts)
 	if err != nil {
 		return ml, errH
 	}
diff --git a/pkg/skaffold/render/renderer/kubectl/kubectl.go b/pkg/skaffold/render/renderer/kubectl/kubectl.go
index 324e6e193..34e2c4ff6 100644
--- a/pkg/skaffold/render/renderer/kubectl/kubectl.go
+++ b/pkg/skaffold/render/renderer/kubectl/kubectl.go
@@ -33,6 +33,8 @@ import (
 )
 
 type Kubectl struct {
+	cfg render.Config
+
 	configName string
 
 	generate.Generator
@@ -49,6 +51,7 @@ func New(cfg render.Config, rCfg latest.RenderConfig, labels map[string]string,
 		return Kubectl{}, err
 	}
 	return Kubectl{
+		cfg:        cfg,
 		configName: configName,
 		Generator:  generator,
 		labels:     labels,
@@ -64,7 +67,12 @@ func (r Kubectl) Render(ctx context.Context, out io.Writer, builds []graph.Artif
 	instrumentation.AddAttributesToCurrentSpanFromContext(ctx, map[string]string{
 		"RendererType": "kubectl",
 	})
-	manifests, err := util.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, r.transformAllowlist, r.transformDenylist)
+	opts := util.GenerateHydratedManifestsOptions{
+		TransformAllowList:         r.transformAllowlist,
+		TransformDenylist:          r.transformDenylist,
+		EnablePlatformNodeAffinity: r.cfg.EnablePlatformNodeAffinityInRenderedManifests(),
+	}
+	manifests, err := util.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, opts)
 	endTrace()
 	manifestListByConfig := manifest.NewManifestListByConfig()
 	manifestListByConfig.Add(r.configName, manifests)
diff --git a/pkg/skaffold/render/renderer/util/util.go b/pkg/skaffold/render/renderer/util/util.go
index 1af31e68a..03aead69c 100644
--- a/pkg/skaffold/render/renderer/util/util.go
+++ b/pkg/skaffold/render/renderer/util/util.go
@@ -33,7 +33,13 @@ import (
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/yaml"
 )
 
-func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []graph.Artifact, g generate.Generator, labels map[string]string, transformAllowlist, transformDenylist map[apim.GroupKind]latest.ResourceFilter) (manifest.ManifestList, error) {
+type GenerateHydratedManifestsOptions struct {
+	TransformAllowList         map[apim.GroupKind]latest.ResourceFilter
+	TransformDenylist          map[apim.GroupKind]latest.ResourceFilter
+	EnablePlatformNodeAffinity bool
+}
+
+func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []graph.Artifact, g generate.Generator, labels map[string]string, opts GenerateHydratedManifestsOptions) (manifest.ManifestList, error) {
 	// Generate manifests.
 	rCtx, endTrace := instrumentation.StartTrace(ctx, "Render_generateManifest")
 	manifests, err := g.Generate(rCtx, out)
@@ -45,12 +51,25 @@ func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []grap
 	// Update image labels.renderer_test.go
 	rCtx, endTrace = instrumentation.StartTrace(ctx, "Render_setSkaffoldLabels")
 	// TODO(aaron-prindle) wire proper transform allow/deny list args when going to V2
-	manifests, err = manifests.ReplaceImages(rCtx, builds, manifest.NewResourceSelectorImages(transformAllowlist, transformDenylist))
+	manifests, err = manifests.ReplaceImages(rCtx, builds, manifest.NewResourceSelectorImages(opts.TransformAllowList, opts.TransformDenylist))
 	if err != nil {
 		return nil, err
 	}
 	// TODO(aaron-prindle) wire proper transform allow/deny list args when going to V2
-	if manifests, err = manifests.SetLabels(labels, manifest.NewResourceSelectorLabels(transformAllowlist, transformDenylist)); err != nil {
+	if manifests, err = manifests.SetLabels(labels, manifest.NewResourceSelectorLabels(opts.TransformAllowList, opts.TransformDenylist)); err != nil {
+		return nil, err
+	}
+	endTrace()
+
+	if !opts.EnablePlatformNodeAffinity {
+		return manifests, nil
+	}
+	rCtx, endTrace = instrumentation.StartTrace(ctx, "Render_setPlatformNodeAffinity")
+	platforms, err := manifests.GetImagePlatforms(rCtx, manifest.NewResourceSelectorImages(opts.TransformAllowList, opts.TransformDenylist))
+	if err != nil {
+		return nil, err
+	}
+	if manifests, err = manifests.SetPlatformNodeAffinity(rCtx, manifest.NewResourceSelectorAffinity(opts.TransformAllowList, opts.TransformDenylist), platforms); err != nil {
 		return nil, err
 	}
 	endTrace()
@@ -120,6 +139,18 @@ func ConsolidateTransformConfiguration(cfg render.Config) (map[apim.GroupKind]la
 func ConvertJSONPathIndex(rf latest.ResourceFilter) latest.ResourceFilter {
 	nrf := latest.ResourceFilter{}
 	nrf.GroupKind = rf.GroupKind
+	if len(rf.Affinity) > 0 {
+		naffinity := []string{}
+		for _, str := range rf.Affinity {
+			if str == ".*" {
+				naffinity = append(naffinity, str)
+				continue
+			}
+			nstr := strings.ReplaceAll(str, ".*", "")
+			naffinity = append(naffinity, nstr)
+		}
+		nrf.Affinity = naffinity
+	}
 
 	if len(rf.Labels) > 0 {
 		nlabels := []string{}
diff --git a/pkg/skaffold/runner/runcontext/v2/context.go b/pkg/skaffold/runner/runcontext/v2/context.go
index 22ae7ae40..94bae7814 100644
--- a/pkg/skaffold/runner/runcontext/v2/context.go
+++ b/pkg/skaffold/runner/runcontext/v2/context.go
@@ -279,6 +279,9 @@ func (rc *RunContext) VerifyDockerNetwork() string                   { return rc
 func (rc *RunContext) JSONParseConfig() latest.JSONParseConfig {
 	return rc.DefaultPipeline().Deploy.Logs.JSONParse
 }
+func (rc *RunContext) EnablePlatformNodeAffinityInRenderedManifests() bool {
+	return rc.Opts.EnablePlatformNodeAffinity
+}
 
 // GetRenderConfig returns the top tier RenderConfig.
 // TODO: design how to support multi-module.
diff --git a/pkg/skaffold/util/platform.go b/pkg/skaffold/util/platform.go
index 3355c66ef..f760b2e62 100644
--- a/pkg/skaffold/util/platform.go
+++ b/pkg/skaffold/util/platform.go
@@ -24,3 +24,7 @@ import (
 func ConvertToV1Platform(platform specs.Platform) v1.Platform {
 	return v1.Platform{Architecture: platform.Architecture, OS: platform.OS, OSVersion: platform.OSVersion, OSFeatures: platform.OSFeatures, Variant: platform.Variant}
 }
+
+func ConvertFromV1Platform(platform v1.Platform) specs.Platform {
+	return specs.Platform{Architecture: platform.Architecture, OS: platform.OS, OSVersion: platform.OSVersion, OSFeatures: platform.OSFeatures, Variant: platform.Variant}
+}


Test patch:
diff --git a/pkg/skaffold/docker/platform_test.go b/pkg/skaffold/docker/platform_test.go
new file mode 100644
index 000000000..95cf4fadf
--- /dev/null
+++ b/pkg/skaffold/docker/platform_test.go
@@ -0,0 +1,130 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package docker
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/random"
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestGetPlatformsForImage(t *testing.T) {
+	idx, err := random.Image(1024, 1)
+	testutil.CheckError(t, false, err)
+	expectedRepo := "foo/bar"
+	manifestPath := fmt.Sprintf("/v2/%s/manifests/latest", expectedRepo)
+	manifest, err := idx.Manifest()
+	testutil.CheckError(t, false, err)
+
+	configFile, err := idx.ConfigFile()
+	testutil.CheckError(t, false, err)
+	// Update image platform to match test
+	configFile.OS = "linux"
+	configFile.Architecture = "arm64"
+	rawConfigFile, err := json.Marshal(configFile)
+	testutil.CheckError(t, false, err)
+	manifest.Config.Data = rawConfigFile
+	cfgHash, cfgSize, err := v1.SHA256(bytes.NewReader(rawConfigFile))
+	testutil.CheckError(t, false, err)
+	manifest.Config.Digest = cfgHash
+	manifest.Config.Size = cfgSize
+
+	rawManifest, err := json.Marshal(manifest)
+	testutil.CheckError(t, false, err)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v2/":
+			w.WriteHeader(http.StatusOK)
+		case manifestPath:
+			if r.Method != http.MethodGet {
+				t.Errorf("Method; got %v, want %v", r.Method, http.MethodGet)
+			}
+			mt, err := idx.MediaType()
+			testutil.CheckError(t, false, err)
+			w.Header().Set("Content-Type", string(mt))
+			w.Write(rawManifest)
+		default:
+			t.Fatalf("Unexpected path: %v", r.URL.Path)
+		}
+	}))
+	defer server.Close()
+	u, err := url.Parse(server.URL)
+	testutil.CheckError(t, false, err)
+	tag := fmt.Sprintf("%s/%s:latest", u.Host, expectedRepo)
+	platforms, err := GetPlatforms(tag)
+	testutil.CheckError(t, false, err)
+
+	expectedPlatforms := []spec.Platform{
+		{Architecture: "arm64", OS: "linux"},
+	}
+	testutil.CheckDeepEqual(t, expectedPlatforms, platforms)
+}
+
+func TestGetPlatformsForIndex(t *testing.T) {
+	idx, err := random.Index(1024, 1, 3)
+	testutil.CheckError(t, false, err)
+	expectedRepo := "foo/bar"
+	manifestPath := fmt.Sprintf("/v2/%s/manifests/latest", expectedRepo)
+	manifest, err := idx.IndexManifest()
+	testutil.CheckError(t, false, err)
+
+	// Update image platform to match test
+	manifest.Manifests[0].Platform = &v1.Platform{Architecture: "arm64", OS: "linux"}
+	manifest.Manifests[1].Platform = &v1.Platform{Architecture: "amd64", OS: "linux"}
+	rawManifest, err := json.Marshal(manifest)
+	testutil.CheckError(t, false, err)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v2/":
+			w.WriteHeader(http.StatusOK)
+		case manifestPath:
+			if r.Method != http.MethodGet {
+				t.Errorf("Method; got %v, want %v", r.Method, http.MethodGet)
+			}
+			mt, err := idx.MediaType()
+			testutil.CheckError(t, false, err)
+			w.Header().Set("Content-Type", string(mt))
+			w.Write(rawManifest)
+		default:
+			t.Fatalf("Unexpected path: %v", r.URL.Path)
+		}
+	}))
+	defer server.Close()
+	u, err := url.Parse(server.URL)
+	testutil.CheckError(t, false, err)
+	tag := fmt.Sprintf("%s/%s:latest", u.Host, expectedRepo)
+	platforms, err := GetPlatforms(tag)
+	testutil.CheckError(t, false, err)
+
+	expectedPlatforms := []spec.Platform{
+		{Architecture: "arm64", OS: "linux"},
+		{Architecture: "amd64", OS: "linux"},
+	}
+	testutil.CheckDeepEqual(t, expectedPlatforms, platforms)
+}
diff --git a/pkg/skaffold/kubernetes/manifest/affinity_test.go b/pkg/skaffold/kubernetes/manifest/affinity_test.go
new file mode 100644
index 000000000..d0634953d
--- /dev/null
+++ b/pkg/skaffold/kubernetes/manifest/affinity_test.go
@@ -0,0 +1,437 @@
+/*
+Copyright 2019 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifest
+
+import (
+	"context"
+	"testing"
+
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestGetImagePlatforms(t *testing.T) {
+	tests := []struct {
+		description string
+		manifest    string
+		platforms   PodPlatforms
+		expected    PodPlatforms
+	}{
+		{
+			description: "single container in Pod",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+		},
+		{
+			description: "multiple containers in Pod with overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example1:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+				"gcr.io/k8s-skaffold/example2:latest": {{Architecture: "arm64", OS: "linux"}, {Architecture: "arm", OS: "freebsd"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": {{Architecture: "arm64", OS: "linux"}},
+			},
+		},
+		{
+			description: "multiple containers in Pod with no overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example1:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+				"gcr.io/k8s-skaffold/example2:latest": {{Architecture: "arm", OS: "freebsd"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": nil,
+			},
+		},
+		{
+			description: "deployment manifest",
+			manifest: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: getting-started
+  labels:
+    app: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      containers:
+      - name: example
+        image: gcr.io/k8s-skaffold/example:latest`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec.template.spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			t.Override(&getPlatforms, func(image string) ([]spec.Platform, error) {
+				return test.platforms[image], nil
+			})
+			ml := ManifestList{[]byte(test.manifest)}
+			pl, err := ml.GetImagePlatforms(context.Background(), NewResourceSelectorImages(TransformAllowlist, TransformDenylist))
+			t.CheckNoError(err)
+			t.CheckMapsMatch(test.expected, pl)
+		})
+	}
+}
+
+func TestSetPlatformNodeAffinity(t *testing.T) {
+	tests := []struct {
+		description string
+		manifest    string
+		platforms   PodPlatforms
+		expected    string
+	}{
+		{
+			description: "single container in Pod",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+		},
+		{
+			description: "multiple containers in Pod with overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+		},
+		{
+			description: "multiple containers in Pod with no overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				".spec": nil,
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+		},
+		{
+			description: "deployment manifest",
+			manifest: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: getting-started
+  labels:
+    app: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      containers:
+      - name: example
+        image: gcr.io/k8s-skaffold/example:latest`,
+			platforms: map[string][]spec.Platform{
+				".spec.template.spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    app: getting-started
+  name: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      affinity:
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            nodeSelectorTerms:
+            - matchExpressions:
+              - key: kubernetes.io/os
+                operator: In
+                values:
+                - linux
+              - key: kubernetes.io/arch
+                operator: In
+                values:
+                - amd64
+            - matchExpressions:
+              - key: kubernetes.io/os
+                operator: In
+                values:
+                - linux
+              - key: kubernetes.io/arch
+                operator: In
+                values:
+                - arm64
+      containers:
+      - image: gcr.io/k8s-skaffold/example:latest
+        name: example`,
+		},
+		{
+			description: "existing node affinity",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			t.Override(&getPlatforms, func(image string) ([]spec.Platform, error) {
+				return test.platforms[image], nil
+			})
+			ml := ManifestList{[]byte(test.manifest)}
+			m, err := ml.SetPlatformNodeAffinity(context.Background(), NewResourceSelectorAffinity(TransformAllowlist, TransformDenylist), test.platforms)
+			t.CheckNoError(err)
+			expected := ManifestList{[]byte(test.expected)}
+			t.CheckDeepEqual(expected.String(), m.String())
+		})
+	}
+}
diff --git a/pkg/skaffold/schema/latest/config.go b/pkg/skaffold/schema/latest/config.go
index 4f317d3e0..afec3de5a 100644
--- a/pkg/skaffold/schema/latest/config.go
+++ b/pkg/skaffold/schema/latest/config.go
@@ -1560,8 +1560,10 @@ type ResourceFilter struct {
 	GroupKind string `yaml:"groupKind" yamltags:"required"`
 	// Image is an optional slice of JSON-path-like paths of where to rewrite images.
 	Image []string `yaml:"image,omitempty"`
-	// Labels is an optional slide of JSON-path-like paths of where to add a labels block if missing.
+	// Labels is an optional slice of JSON-path-like paths of where to add a labels block if missing.
 	Labels []string `yaml:"labels,omitempty"`
+	// Affinity is an optional slice of JSON-path-like paths of where to add `affinity` definitions for scheduling Pods.
+	Affinity []string `yaml:"affinity,omitempty"`
 }
 
 // UnmarshalYAML provides a custom unmarshaller to deal with
diff --git a/pkg/skaffold/util/platform_test.go b/pkg/skaffold/util/platform_test.go
new file mode 100644
index 000000000..6ff74074d
--- /dev/null
+++ b/pkg/skaffold/util/platform_test.go
@@ -0,0 +1,32 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package util
+
+import (
+	"testing"
+
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestConvert(t *testing.T) {
+	pl1 := v1.Platform{Architecture: "arch", OS: "os", OSVersion: "os_ver", Variant: "variant", OSFeatures: []string{"os_feature"}}
+	pl2 := ConvertFromV1Platform(pl1)
+	pl3 := ConvertToV1Platform(pl2)
+	testutil.CheckDeepEqual(t, pl1, pl3)
+}