Skip to content

Commit 47131ac

Browse files
authored
Merge pull request #1 from tariromukute/fix/scope-static-ip-per-network-in-cni-args
fix: per-network static IP assignment for multi-network containers
2 parents cc067f1 + f7ee528 commit 47131ac

3 files changed

Lines changed: 210 additions & 38 deletions

File tree

pkg/composer/serviceparser/serviceparser.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package serviceparser
1919
import (
2020
"bytes"
2121
"encoding/csv"
22+
"encoding/json"
2223
"errors"
2324
"fmt"
2425
"os"
@@ -33,6 +34,7 @@ import (
3334
"github.com/containerd/log"
3435

3536
"github.com/containerd/nerdctl/v2/pkg/identifiers"
37+
"github.com/containerd/nerdctl/v2/pkg/labels"
3638
"github.com/containerd/nerdctl/v2/pkg/reflectutil"
3739
)
3840

@@ -595,20 +597,36 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e
595597
return nil, err
596598
}
597599
netTypeContainer := false
600+
// Collect per-network static IPs to determine if we need a per-network IP map.
601+
networkIPMap := make(map[string]string)
598602
for _, net := range networks {
599603
if strings.HasPrefix(net.fullName, "container:") {
600604
netTypeContainer = true
601605
}
602606
c.RunArgs = append(c.RunArgs, "--net="+net.fullName)
603607
if value, ok := svc.Networks[net.shortNetworkName]; ok {
604608
if value != nil && value.Ipv4Address != "" {
605-
c.RunArgs = append(c.RunArgs, "--ip="+value.Ipv4Address)
609+
networkIPMap[net.fullName] = value.Ipv4Address
606610
}
607611
if value != nil && value.MacAddress != "" {
608612
c.RunArgs = append(c.RunArgs, "--mac-address="+value.MacAddress)
609613
}
610614
}
611615
}
616+
// When multiple networks have static IPs, pass a per-network IP map as an annotation
617+
// so that each CNI plugin receives only the IP for its own network.
618+
// For a single IP, use the legacy --ip= flag for backward compatibility.
619+
if len(networkIPMap) > 1 {
620+
ipMapJSON, err := json.Marshal(networkIPMap)
621+
if err != nil {
622+
return nil, fmt.Errorf("failed to marshal per-network IP map: %w", err)
623+
}
624+
c.RunArgs = append(c.RunArgs, fmt.Sprintf("--annotation=%s=%s", labels.IPAddressPerNetwork, string(ipMapJSON)))
625+
} else if len(networkIPMap) == 1 {
626+
for _, ip := range networkIPMap {
627+
c.RunArgs = append(c.RunArgs, "--ip="+ip)
628+
}
629+
}
612630

613631
if netTypeContainer && svc.Hostname != "" {
614632
return nil, fmt.Errorf("conflicting options: hostname and container network mode")

pkg/labels/labels.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ const (
7070
// IP6Address is the static IP6 address of the container assigned by the user
7171
IP6Address = Prefix + "ip6"
7272

73+
// IPAddressPerNetwork JSON-encoded map of network names to user-assigned static
74+
// IPv4 addresses. Used for multi-network containers.
75+
IPAddressPerNetwork = Prefix + "ip-per-network"
76+
7377
// LogURI is the log URI
7478
LogURI = Prefix + "log-uri"
7579

pkg/ocihook/ocihook.go

Lines changed: 187 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"strings"
3131
"time"
3232

33+
cnilibrary "github.com/containernetworking/cni/libcni"
3334
types100 "github.com/containernetworking/cni/pkg/types/100"
3435
"github.com/opencontainers/runtime-spec/specs-go"
3536
b4nndclient "github.com/rootless-containers/bypass4netns/pkg/api/daemon/client"
@@ -185,13 +186,15 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, brid
185186
cniOpts := []cni.Opt{
186187
cni.WithPluginDir([]string{cniPath}),
187188
}
189+
o.cniPluginDir = cniPath
188190
var netw *netutil.NetworkConfig
189191
for _, netstr := range networks {
190192
if netw, err = e.NetworkByNameOrID(netstr); err != nil {
191193
return nil, err
192194
}
193195
cniOpts = append(cniOpts, cni.WithConfListBytes(netw.Bytes))
194196
o.cniNames = append(o.cniNames, netstr)
197+
o.cniNetConfigs = append(o.cniNetConfigs, netw.Bytes)
195198
}
196199
o.cni, err = cni.New(cniOpts...)
197200
if err != nil {
@@ -228,6 +231,15 @@ func newHandlerOpts(state *specs.State, dataStore, cniPath, cniNetconfPath, brid
228231
o.containerIP6 = ip6Address
229232
}
230233

234+
// Parse per-network IP map if present (for multi-network containers with per-network static IPs)
235+
if ipPerNetJSON, ok := o.state.Annotations[labels.IPAddressPerNetwork]; ok && ipPerNetJSON != "" {
236+
var ipPerNetwork map[string]string
237+
if err := json.Unmarshal([]byte(ipPerNetJSON), &ipPerNetwork); err != nil {
238+
return nil, fmt.Errorf("failed to unmarshal per-network IP map: %w", err)
239+
}
240+
o.ipPerNetwork = ipPerNetwork
241+
}
242+
231243
if rootlessutil.IsRootlessChild() {
232244
o.rootlessKitClient, err = rootlessutil.NewRootlessKitClient()
233245
if err != nil {
@@ -258,13 +270,16 @@ type handlerOpts struct {
258270
ports []cni.PortMapping
259271
cni cni.CNI
260272
cniNames []string
273+
cniPluginDir string
274+
cniNetConfigs [][]byte
261275
fullID string
262276
rootlessKitClient rlkclient.Client
263277
bypassClient b4nndclient.Client
264278
extraHosts map[string]string // host:ip
265279
containerIP string
266280
containerMAC string
267281
containerIP6 string
282+
ipPerNetwork map[string]string
268283
}
269284

270285
// hookSpec is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/containerd/command/oci-hook.go#L59-L64
@@ -460,6 +475,64 @@ func portReserverPidFilePath(opts *handlerOpts) string {
460475
return filepath.Join("/run/nerdctl/", opts.state.Annotations[labels.Namespace], opts.state.ID, "port-reserver.pid")
461476
}
462477

478+
// perNetworkIfName returns the container-side interface name for a given network index
479+
// (e.g., "eth0", "eth1", "eth2").
480+
func perNetworkIfName(index int) string {
481+
return fmt.Sprintf("eth%d", index)
482+
}
483+
484+
// perNetworkAdd calls cnilibrary.AddNetworkList directly for a single network
485+
// with the correct interface name (ethN) and per-network args.
486+
func perNetworkAdd(ctx context.Context, opts *handlerOpts, networkIndex int, nsPath string, extraArgs [][2]string, portMappings []cni.PortMapping) (*types100.Result, error) {
487+
if networkIndex < 0 || networkIndex >= len(opts.cniNetConfigs) {
488+
return nil, fmt.Errorf("network index %d out of range (have %d networks)", networkIndex, len(opts.cniNetConfigs))
489+
}
490+
confList, err := cnilibrary.ConfListFromBytes(opts.cniNetConfigs[networkIndex])
491+
if err != nil {
492+
return nil, fmt.Errorf("failed to parse conflist for network %d: %w", networkIndex, err)
493+
}
494+
cniConfig := cnilibrary.NewCNIConfig([]string{opts.cniPluginDir}, nil)
495+
rt := &cnilibrary.RuntimeConf{
496+
ContainerID: opts.fullID,
497+
NetNS: nsPath,
498+
IfName: perNetworkIfName(networkIndex),
499+
Args: extraArgs,
500+
CapabilityArgs: make(map[string]interface{}),
501+
}
502+
if len(portMappings) > 0 {
503+
rt.CapabilityArgs["portMappings"] = portMappings
504+
}
505+
result, err := cniConfig.AddNetworkList(ctx, confList, rt)
506+
if err != nil {
507+
return nil, err
508+
}
509+
return types100.NewResultFromResult(result)
510+
}
511+
512+
// perNetworkDel calls cnilibrary.DelNetworkList directly for a single network
513+
// with the correct interface name (ethN).
514+
func perNetworkDel(ctx context.Context, opts *handlerOpts, networkIndex int, nsPath string, extraArgs [][2]string, portMappings []cni.PortMapping) error {
515+
if networkIndex < 0 || networkIndex >= len(opts.cniNetConfigs) {
516+
return fmt.Errorf("network index %d out of range (have %d networks)", networkIndex, len(opts.cniNetConfigs))
517+
}
518+
confList, err := cnilibrary.ConfListFromBytes(opts.cniNetConfigs[networkIndex])
519+
if err != nil {
520+
return fmt.Errorf("failed to parse conflist for network %d: %w", networkIndex, err)
521+
}
522+
cniConfig := cnilibrary.NewCNIConfig([]string{opts.cniPluginDir}, nil)
523+
rt := &cnilibrary.RuntimeConf{
524+
ContainerID: opts.fullID,
525+
NetNS: nsPath,
526+
IfName: perNetworkIfName(networkIndex),
527+
Args: extraArgs,
528+
CapabilityArgs: make(map[string]interface{}),
529+
}
530+
if len(portMappings) > 0 {
531+
rt.CapabilityArgs["portMappings"] = portMappings
532+
}
533+
return cniConfig.DelNetworkList(ctx, confList, rt)
534+
}
535+
463536
func applyNetworkSettings(opts *handlerOpts) (err error) {
464537
portMapOpts, err := getPortMapOpts(opts)
465538
if err != nil {
@@ -530,17 +603,18 @@ func applyNetworkSettings(opts *handlerOpts) (err error) {
530603
if err != nil {
531604
return err
532605
}
533-
var namespaceOpts []cni.NamespaceOpts
534-
namespaceOpts = append(namespaceOpts, portMapOpts...)
535-
namespaceOpts = append(namespaceOpts, ipAddressOpts...)
536-
namespaceOpts = append(namespaceOpts, macAddressOpts...)
537-
namespaceOpts = append(namespaceOpts, ip6AddressOpts...)
538-
namespaceOpts = append(namespaceOpts,
606+
607+
commonOpts := []cni.NamespaceOpts{}
608+
commonOpts = append(commonOpts, portMapOpts...)
609+
commonOpts = append(commonOpts, macAddressOpts...)
610+
commonOpts = append(commonOpts, ip6AddressOpts...)
611+
commonOpts = append(commonOpts,
539612
cni.WithLabels(map[string]string{
540613
"IgnoreUnknown": "1",
541614
}),
542615
cni.WithArgs("NERDCTL_CNI_DHCP_HOSTNAME", opts.state.Annotations[labels.Hostname]),
543616
)
617+
544618
hsMeta := hostsstore.Meta{
545619
ID: opts.state.ID,
546620
Networks: make(map[string]*types100.Result, len(opts.cniNames)),
@@ -550,33 +624,88 @@ func applyNetworkSettings(opts *handlerOpts) (err error) {
550624
Name: opts.state.Annotations[labels.Name],
551625
}
552626

553-
// When containerd gets bounced, containers that were previously running and that are restarted will go again
554-
// through onCreateRuntime (*unlike* in a normal stop/start flow).
555-
// As such, a container may very well have an ip already. The bridge plugin would thus refuse to loan a new one
556-
// and error out, thus making the onCreateRuntime hook fail. In turn, runc (or containerd) will mis-interpret this,
557-
// and subsequently call onPostStop (although the container will not get deleted), and we will release the name...
558-
// leading to a bricked system where multiple containers may share the same name.
559-
// Thus, we do pre-emptively clean things up - error is not checked, as in the majority of cases, that would
560-
// legitimately error (and that does not matter)
561-
// See https://github.com/containerd/nerdctl/issues/3355
562-
_ = opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...)
627+
// When per-network IPs are specified (multi-network with different static IPs),
628+
// we must set up each network individually so each CNI plugin receives only its own IP.
629+
// We use cnilibrary directly (instead of go-cni's Setup) so that each network
630+
// gets the correct interface name (eth0, eth1, eth2, ...) rather than all getting eth0.
631+
if len(opts.ipPerNetwork) > 0 {
632+
// Pre-emptively clean up (see comment below for rationale)
633+
for i := range opts.cniNames {
634+
_ = perNetworkDel(ctx, opts, i, "", nil, nil)
635+
}
563636

564-
// Defer CNI configuration removal to ensure idempotency of oci-hook.
565-
defer func() {
566-
if err != nil {
567-
log.L.Warn("Container failed starting. Removing allocated network configuration.")
568-
_ = opts.cni.Remove(ctx, opts.fullID, nsPath, namespaceOpts...)
637+
defer func() {
638+
if err != nil {
639+
log.L.Warn("Container failed starting. Removing allocated network configuration.")
640+
for i, cniName := range opts.cniNames {
641+
if delErr := perNetworkDel(ctx, opts, i, nsPath, nil, nil); delErr != nil {
642+
log.L.WithError(delErr).Warnf("failed to remove network %s during cleanup", cniName)
643+
}
644+
}
645+
}
646+
}()
647+
648+
// Convert port mappings for cnilibrary RuntimeConf capability args
649+
var capPortMappings []cni.PortMapping
650+
if len(opts.ports) > 0 {
651+
capPortMappings = opts.ports
569652
}
570-
}()
571653

572-
cniRes, err := opts.cni.Setup(ctx, opts.fullID, nsPath, namespaceOpts...)
573-
if err != nil {
574-
return fmt.Errorf("failed to call cni.Setup: %w", err)
575-
}
654+
for i, cniName := range opts.cniNames {
655+
// Build per-network CNI_ARGS
656+
extraArgs := [][2]string{
657+
{"IgnoreUnknown", "1"},
658+
{"NERDCTL_CNI_DHCP_HOSTNAME", opts.state.Annotations[labels.Hostname]},
659+
}
660+
if ip, ok := opts.ipPerNetwork[cniName]; ok && ip != "" {
661+
extraArgs = append(extraArgs, [2]string{"IP", ip})
662+
}
663+
if opts.containerMAC != "" {
664+
extraArgs = append(extraArgs, [2]string{"MAC", opts.containerMAC})
665+
}
666+
667+
cniRes, setupErr := perNetworkAdd(ctx, opts, i, nsPath, extraArgs, capPortMappings)
668+
if setupErr != nil {
669+
return fmt.Errorf("failed to call cni.Setup for network %s: %w", cniName, setupErr)
670+
}
671+
if cniRes != nil {
672+
hsMeta.Networks[cniName] = cniRes
673+
}
674+
}
675+
} else {
676+
// Legacy path: single IP (or no IP) shared across all networks
677+
var namespaceOpts []cni.NamespaceOpts
678+
namespaceOpts = append(namespaceOpts, commonOpts...)
679+
namespaceOpts = append(namespaceOpts, ipAddressOpts...)
680+
681+
// When containerd gets bounced, containers that were previously running and that are restarted will go again
682+
// through onCreateRuntime (*unlike* in a normal stop/start flow).
683+
// As such, a container may very well have an ip already. The bridge plugin would thus refuse to loan a new one
684+
// and error out, thus making the onCreateRuntime hook fail. In turn, runc (or containerd) will mis-interpret this,
685+
// and subsequently call onPostStop (although the container will not get deleted), and we will release the name...
686+
// leading to a bricked system where multiple containers may share the same name.
687+
// Thus, we do pre-emptively clean things up - error is not checked, as in the majority of cases, that would
688+
// legitimately error (and that does not matter)
689+
// See https://github.com/containerd/nerdctl/issues/3355
690+
_ = opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...)
691+
692+
// Defer CNI configuration removal to ensure idempotency of oci-hook.
693+
defer func() {
694+
if err != nil {
695+
log.L.Warn("Container failed starting. Removing allocated network configuration.")
696+
_ = opts.cni.Remove(ctx, opts.fullID, nsPath, namespaceOpts...)
697+
}
698+
}()
576699

577-
cniResRaw := cniRes.Raw()
578-
for i, cniName := range opts.cniNames {
579-
hsMeta.Networks[cniName] = cniResRaw[i]
700+
cniRes, err := opts.cni.Setup(ctx, opts.fullID, nsPath, namespaceOpts...)
701+
if err != nil {
702+
return fmt.Errorf("failed to call cni.Setup: %w", err)
703+
}
704+
705+
cniResRaw := cniRes.Raw()
706+
for i, cniName := range opts.cniNames {
707+
hsMeta.Networks[cniName] = cniResRaw[i]
708+
}
580709
}
581710

582711
b4nnEnabled, b4nnBindEnabled, err := bypass4netnsutil.IsBypass4netnsEnabled(opts.state.Annotations)
@@ -708,14 +837,35 @@ func onPostStop(opts *handlerOpts) error {
708837
if err != nil {
709838
return err
710839
}
711-
var namespaceOpts []cni.NamespaceOpts
712-
namespaceOpts = append(namespaceOpts, portMapOpts...)
713-
namespaceOpts = append(namespaceOpts, ipAddressOpts...)
714-
namespaceOpts = append(namespaceOpts, macAddressOpts...)
715-
namespaceOpts = append(namespaceOpts, ip6AddressOpts...)
716-
if err := opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...); err != nil {
717-
log.L.WithError(err).Errorf("failed to call cni.Remove")
718-
return err
840+
841+
if len(opts.ipPerNetwork) > 0 {
842+
// Per-network cleanup: remove each network individually with its own IP
843+
// and the correct interface name (ethN).
844+
var capPortMappings []cni.PortMapping
845+
if len(opts.ports) > 0 {
846+
capPortMappings = opts.ports
847+
}
848+
for i, cniName := range opts.cniNames {
849+
extraArgs := [][2]string{
850+
{"IgnoreUnknown", "1"},
851+
}
852+
if ip, ok := opts.ipPerNetwork[cniName]; ok && ip != "" {
853+
extraArgs = append(extraArgs, [2]string{"IP", ip})
854+
}
855+
if delErr := perNetworkDel(ctx, opts, i, "", extraArgs, capPortMappings); delErr != nil {
856+
log.L.WithError(delErr).Errorf("failed to call cni.Remove for network %s", cniName)
857+
}
858+
}
859+
} else {
860+
var namespaceOpts []cni.NamespaceOpts
861+
namespaceOpts = append(namespaceOpts, portMapOpts...)
862+
namespaceOpts = append(namespaceOpts, ipAddressOpts...)
863+
namespaceOpts = append(namespaceOpts, macAddressOpts...)
864+
namespaceOpts = append(namespaceOpts, ip6AddressOpts...)
865+
if err := opts.cni.Remove(ctx, opts.fullID, "", namespaceOpts...); err != nil {
866+
log.L.WithError(err).Errorf("failed to call cni.Remove")
867+
return err
868+
}
719869
}
720870

721871
// opts.cni.Remove has trouble removing network configurations when netns is empty.

0 commit comments

Comments
 (0)