Container-to-host networking. (#1078)

- Closes #346.
- This PR enables connecting host's localhost ports from
  containers.
- It adds an option `--localhost <localhost>` to DNS
  create command, after which the packets heading
  ip address in container are redirected to localhost in
  host machine. Packet filter rule is added and deleted
  along with the creation and deletion of localhost domain.
This commit is contained in:
jwhur
2026-01-23 17:59:28 -08:00
committed by GitHub
parent 14c279fcd2
commit ed1185d489
11 changed files with 616 additions and 28 deletions
@@ -16,6 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerizationError
import ContainerizationExtras
import Foundation
@@ -30,22 +31,54 @@ extension Application {
@OptionGroup
public var logOptions: Flags.Logging
@Option(name: .long, help: "Set the ip address to be redirected to localhost")
var localhost: String?
@Argument(help: "The local domain name")
var domainName: String
public init() {}
public func run() async throws {
var localhostIP: IPAddress? = nil
if let localhost {
localhostIP = try? IPAddress(localhost)
guard let localhostIP, case .v4(_) = localhostIP else {
throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)")
}
}
let resolver: HostDNSResolver = HostDNSResolver()
do {
try resolver.createDomain(name: domainName)
print(domainName)
try resolver.createDomain(name: domainName, localhost: localhostIP)
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)")
}
let pf = PacketFilter()
if let from = localhostIP {
let to = try! IPAddress("127.0.0.1")
do {
try pf.createRedirectRule(from: from, to: to, domain: domainName)
} catch {
_ = try resolver.deleteDomain(name: domainName)
throw error
}
}
print(domainName)
if localhostIP != nil {
do {
try pf.reinitialize()
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
}
}
do {
try HostDNSResolver.reinitialize()
} catch {
@@ -17,6 +17,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import ContainerizationExtras
import Foundation
extension Application {
@@ -37,9 +38,9 @@ extension Application {
public func run() async throws {
let resolver = HostDNSResolver()
var localhostIP: IPAddress?
do {
try resolver.deleteDomain(name: domainName)
print(domainName)
localhostIP = try resolver.deleteDomain(name: domainName)
} catch {
throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)")
}
@@ -49,6 +50,23 @@ extension Application {
} catch {
throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain")
}
guard let localhostIP else {
print(domainName)
return
}
let pf = PacketFilter()
try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName)
do {
try pf.reinitialize()
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
}
print(domainName)
}
}
}
@@ -33,6 +33,7 @@ extension APIServer {
)
static let listenAddress = "127.0.0.1"
static let localhostDNSPort = 1053
static let dnsPort = 2053
@Flag(name: .long, help: "Enable debug logging")
@@ -97,13 +98,33 @@ extension APIServer {
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS host query resolver",
"starting DNS resolver for container hostnames",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.dnsPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
}
// start up realhost DNS
group.addTask {
let localhostResolver = LocalhostDNSHandler(log: log)
try localhostResolver.monitorResolvers()
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for localhost",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.localhostDNSPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
}
}
} catch {
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project 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
//
// https://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.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
import Logging
public class DirectoryWatcher {
public let directoryURL: URL
private let monitorQueue: DispatchQueue
private var source: DispatchSourceFileSystemObject?
private let log: Logger
init(directoryURL: URL, log: Logger) {
self.directoryURL = directoryURL
self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)")
self.log = log
}
public func startWatching(handler: @escaping ([URL]) throws -> Void) throws {
guard source == nil else {
throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)")
}
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try handler(files.map { directoryURL.appending(path: $0) })
} catch {
throw ContainerizationError(.invalidState, message: "failed to start watching on \(directoryURL.path)")
}
log.info("starting directory watcher for \(directoryURL.path)")
let descriptor = open(directoryURL.path, O_EVTONLY)
source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: .write,
queue: monitorQueue
)
source?.setEventHandler { [weak self] in
guard let self else { return }
do {
let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path)
try? handler(files.map { directoryURL.appending(path: $0) })
} catch {
self.log.info("failed to run handler for \(directoryURL.path)")
}
}
source?.resume()
}
deinit {
guard let source else {
return
}
source.cancel()
}
}
@@ -0,0 +1,112 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project 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
//
// https://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.
//===----------------------------------------------------------------------===//
import ContainerAPIClient
import ContainerPersistence
import ContainerizationError
import DNS
import DNSServer
import Foundation
import Logging
class LocalhostDNSHandler: DNSHandler {
private let ttl: UInt32
private let watcher: DirectoryWatcher
private var dns: [String: IPv4]
public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) {
self.ttl = ttl
self.watcher = DirectoryWatcher(directoryURL: resolversURL, log: log)
self.dns = [:]
}
public func monitorResolvers() throws {
try self.watcher.startWatching { fileURLs in
var dns: [String: IPv4] = [:]
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
for file in fileURLs.filter({ $0.lastPathComponent.starts(with: HostDNSResolver.containerizationPrefix) }) {
let content = try String(contentsOf: file, encoding: .utf8)
if let match = content.firstMatch(of: regex),
let ipv4 = IPv4(String(match[1].substring ?? ""))
{
let name = String(file.lastPathComponent.dropFirst(HostDNSResolver.containerizationPrefix.count))
dns[name + "."] = ipv4
}
}
self.dns = dns
}
}
public func answer(query: Message) async throws -> Message? {
let question = query.questions[0]
var record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
if let ip = dns[question.name] {
record = HostRecord<IPv4>(name: question.name, ttl: ttl, ip: ip)
}
case ResourceRecordType.host6:
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
case ResourceRecordType.nameServer,
ResourceRecordType.alias,
ResourceRecordType.startOfAuthority,
ResourceRecordType.pointer,
ResourceRecordType.mailExchange,
ResourceRecordType.text,
ResourceRecordType.service,
ResourceRecordType.incrementalZoneTransfer,
ResourceRecordType.standardZoneTransfer,
ResourceRecordType.all:
return Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
default:
return Message(
id: query.id,
type: .response,
returnCode: .formatError,
questions: query.questions,
answers: []
)
}
guard let record else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: [record]
)
}
}
@@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import Foundation
/// Functions for managing local DNS domains for containers.
@@ -22,7 +23,8 @@ public struct HostDNSResolver {
public static let defaultConfigPath = URL(filePath: "/etc/resolver")
// prefix used to mark our files as /etc/resolver/{prefix}{domainName}
private static let containerizationPrefix = "containerization."
public static let containerizationPrefix = "containerization."
public static let localhostOptionsRegex = #"options localhost:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"#
private let configURL: URL
@@ -31,7 +33,7 @@ public struct HostDNSResolver {
}
/// Creates a DNS resolver configuration file for domain resolved by the application.
public func createDomain(name: String) throws {
public func createDomain(name: String, localhost: IPAddress? = nil) throws {
let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path
let fm: FileManager = FileManager.default
@@ -47,33 +49,44 @@ public struct HostDNSResolver {
throw ContainerizationError(.exists, message: "domain \(name) already exists")
}
let dnsPort = localhost == nil ? "2053" : "1053"
let options =
localhost == nil
? ""
: HostDNSResolver.localhostOptionsRegex.replacingOccurrences(
of: #"\((.*?)\)"#, with: localhost!.description, options: .regularExpression)
let resolverText = """
domain \(name)
search \(name)
nameserver 127.0.0.1
port 2053
port \(dnsPort)
\(options)
"""
do {
try resolverText.write(toFile: path, atomically: true, encoding: .utf8)
} catch {
throw ContainerizationError(.invalidState, message: "failed to write resolver configuration for \(name)")
}
try resolverText.write(toFile: path, atomically: true, encoding: .utf8)
}
/// Removes a DNS resolver configuration file for domain resolved by the application.
public func deleteDomain(name: String) throws {
public func deleteDomain(name: String) throws -> IPAddress? {
let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path
let fm = FileManager.default
guard fm.fileExists(atPath: path) else {
throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found")
}
var localhost: IPAddress?
let content = try String(contentsOfFile: path, encoding: .utf8)
if let match = content.firstMatch(of: try Regex(HostDNSResolver.localhostOptionsRegex)) {
localhost = try? IPAddress(String(match[1].substring ?? ""))
}
do {
try fm.removeItem(atPath: path)
} catch {
throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)")
}
return localhost
}
/// Lists application-created local DNS domains.
@@ -0,0 +1,198 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project 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
//
// https://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.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import Foundation
public struct PacketFilter {
public static let anchor = "com.apple.container"
public static let defaultConfigPath = URL(filePath: "/etc/pf.conf")
public static let defaultAnchorsPath = URL(filePath: "/etc/pf.anchors")
private let configURL: URL
private let anchorsURL: URL
public init(configURL: URL = Self.defaultConfigPath, anchorsURL: URL = Self.defaultAnchorsPath) {
self.configURL = configURL
self.anchorsURL = anchorsURL
}
public func createRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws {
guard type(of: from) == type(of: to) else {
throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)")
}
let fm: FileManager = FileManager.default
let anchorURL = self.anchorsURL.appending(path: Self.anchor)
let inet: String
switch from {
case .v4: inet = "inet"
case .v6: inet = "inet6"
}
let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)"
var content = ""
if fm.fileExists(atPath: anchorURL.path) {
content = try String(contentsOfFile: anchorURL.path, encoding: .utf8)
} else {
try addAnchorToConfig()
}
var lines = content.components(separatedBy: .newlines)
if !content.contains(redirectRule) {
lines.insert(redirectRule, at: lines.endIndex - 1)
}
try lines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8)
}
public func removeRedirectRule(from: IPAddress, to: IPAddress, domain: String) throws {
guard type(of: from) == type(of: to) else {
throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)")
}
let fm: FileManager = FileManager.default
let anchorURL = self.anchorsURL.appending(path: Self.anchor)
let inet: String
switch from {
case .v4: inet = "inet"
case .v6: inet = "inet6"
}
let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain)"
guard fm.fileExists(atPath: anchorURL.path) else {
return
}
let content = try String(contentsOfFile: anchorURL.path, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
let removedLines = lines.filter { l in
l != redirectRule
}
if removedLines == [""] {
try fm.removeItem(atPath: anchorURL.path)
try removeAnchorFromConfig()
} else {
try removedLines.joined(separator: "\n").write(toFile: anchorURL.path, atomically: true, encoding: .utf8)
}
}
private func addAnchorToConfig() throws {
let fm: FileManager = FileManager.default
let anchorURL = self.anchorsURL.appending(path: Self.anchor)
/* PF requires strict ordering of anchors:
scrub-anchor, nat-anchor, rdr-anchor, dummynet-anchor, anchor, load anchor
*/
let anchorKeywords = ["scrub-anchor", "nat-anchor", "rdr-anchor", "dummynet-anchor", "anchor", "load anchor"]
let loadAnchorText = "load anchor \"\(Self.anchor)\" from \"\(anchorURL.path)\""
var content: String = ""
var lines: [String] = []
if fm.fileExists(atPath: self.configURL.path) {
content = try String(contentsOfFile: self.configURL.path, encoding: .utf8)
}
lines = content.components(separatedBy: .newlines)
for (i, keyword) in anchorKeywords[..<(anchorKeywords.endIndex - 1)].enumerated() {
let anchorText = "\(keyword) \"\(Self.anchor)\""
if content.contains(anchorText) {
continue
}
let idx = lines.firstIndex { l in
anchorKeywords[i...].map { k in l.starts(with: k) }.contains(true)
}
lines.insert(anchorText, at: idx ?? lines.endIndex - 1)
}
if !content.contains(loadAnchorText) {
lines.insert(loadAnchorText, at: lines.endIndex - 1)
}
do {
try lines.joined(separator: "\n").write(toFile: self.configURL.path, atomically: true, encoding: .utf8)
} catch {
throw ContainerizationError(.invalidState, message: "failed to write \"\(self.configURL.path)\"")
}
}
private func removeAnchorFromConfig() throws {
let fm: FileManager = FileManager.default
guard fm.fileExists(atPath: configURL.path) else {
return
}
let content = try String(contentsOfFile: configURL.path, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
let removedLines = lines.filter { l in !l.contains(Self.anchor) }
do {
try removedLines.joined(separator: "\n").write(toFile: configURL.path, atomically: true, encoding: .utf8)
} catch {
throw ContainerizationError(.invalidState, message: "failed to write \"\(configURL.path)\"")
}
}
public func reinitialize() throws {
do {
let pfctl = Foundation.Process()
let null = FileHandle.nullDevice
var status: Int32
pfctl.executableURL = URL(fileURLWithPath: "/sbin/pfctl")
pfctl.arguments = ["-n", "-f", configURL.path]
pfctl.standardOutput = null
pfctl.standardError = null
try pfctl.run()
pfctl.waitUntilExit()
status = pfctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(.internalError, message: "invalid pf config \"\(configURL.path)\"")
}
}
do {
let pfctl = Foundation.Process()
let null = FileHandle.nullDevice
var status: Int32
pfctl.executableURL = URL(fileURLWithPath: "/sbin/pfctl")
pfctl.arguments = ["-f", configURL.path]
pfctl.standardOutput = null
pfctl.standardError = null
try pfctl.run()
pfctl.waitUntilExit()
status = pfctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(.invalidState, message: "pfctl -f \"\(configURL.path)\" failed with status \(status)")
}
}
}
}
@@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing
@@ -41,6 +42,7 @@ struct HostDNSResolverTest {
search foo.bar
nameserver 127.0.0.1
port 2053
"""
#expect(actualText == expectedText)
@@ -86,7 +88,13 @@ struct HostDNSResolverTest {
let resolver = HostDNSResolver(configURL: tempURL)
try resolver.createDomain(name: "foo.bar")
try resolver.deleteDomain(name: "foo.bar")
_ = try resolver.deleteDomain(name: "foo.bar")
let localhost = try! IPAddress("127.0.0.1")
try resolver.createDomain(name: "bar.baz", localhost: localhost)
let deletedLocalhost = try resolver.deleteDomain(name: "bar.baz")
#expect(localhost == deletedLocalhost)
let domains = resolver.listDomains()
#expect(domains == [])
}
@@ -105,7 +113,7 @@ struct HostDNSResolverTest {
let resolver = HostDNSResolver(configURL: tempURL)
try resolver.createDomain(name: "foo.bar")
#expect {
try resolver.deleteDomain(name: "bar.foo")
_ = try resolver.deleteDomain(name: "bar.foo")
} throws: { error in
guard let error = error as? ContainerizationError, error.code == .notFound else {
return false
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project 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
//
// https://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.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing
@testable import ContainerAPIClient
struct PacketFilterTest {
@Test
func testRedirectRuleUpdate() async throws {
let fm = FileManager.default
let tempURL = try fm.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: .temporaryDirectory,
create: true
)
defer { try? FileManager.default.removeItem(at: tempURL) }
let configURL = tempURL.appending(path: "pf.conf")
let pf = PacketFilter(configURL: configURL, anchorsURL: tempURL)
let from1 = try! IPAddress("203.0.113.113")
let domain1 = "aaa.com"
let to = try! IPAddress("127.0.0.1")
try pf.createRedirectRule(from: from1, to: to, domain: domain1)
let anchorURL = tempURL.appending(path: "com.apple.container")
var actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8)
var expectedAnchorTest = """
rdr inet from any to \(from1) -> \(to) # \(domain1)\n
"""
#expect(actualAnchorText == expectedAnchorTest)
let from2 = try! IPAddress("172.31.72.1")
let domain2 = "bbb.com"
try pf.createRedirectRule(from: from2, to: to, domain: domain2)
actualAnchorText = try String(contentsOf: anchorURL, encoding: .utf8)
expectedAnchorTest += """
rdr inet from any to \(from2) -> \(to) # \(domain2)\n
"""
#expect(actualAnchorText == expectedAnchorTest)
let actualConfigText = try String(contentsOf: configURL, encoding: .utf8)
let expectedConfigText = try Regex(
#"""
scrub-anchor "([^"]+)"
nat-anchor "([^"]+)"
rdr-anchor "([^"]+)"
dummynet-anchor "([^"]+)"
anchor "([^"]+)"
load anchor "([^"]+)" from "[^"]+"
"""#
)
#expect(actualConfigText.contains(expectedConfigText))
try pf.removeRedirectRule(from: from1, to: to, domain: domain1)
try pf.removeRedirectRule(from: from2, to: to, domain: domain2)
#expect(!fm.fileExists(atPath: anchorURL.path))
let configText = try String(contentsOf: configURL, encoding: .utf8)
#expect(configText == "")
}
@Test
func testPacketFilterReinitialize() async throws {
let pf = PacketFilter()
#expect(throws: ContainerizationError.self) {
try pf.reinitialize()
}
}
}
+29
View File
@@ -198,6 +198,35 @@ Test access using `curl`:
</body></html>
```
## Access a host service from a container
Create a DNS domain with `--localhost <ipv4-address>` to make a domain used by a container to access a host service. Any IPv4 address can be used as `<ipv4-address>`, which will be assigned to the domain name in container.
Choose an IP address that is least likely to conflict with any networks or reserved IP addresses in your environment. Reasonably safe address ranges include:
- The documentation ranges 192.0.2.0/24, 198.51.100.0/24, and 203.0.113.0/24.
- The 172.16.0.0/12 private range.
To connect a host HTTP server from a container, run:
```bash
mkdir -p /tmp/test; cd /tmp/test; echo "hello" > index.html
python3 -m http.server 8000 --bind 127.0.0.1
```
Create a domain for host connection:
```bash
sudo container system dns create host.container.internal --localhost 203.0.113.113
```
Test access to the host HTTP server from a container:
```console
% container run -it --rm alpine/curl curl http://host.container.internal:8000
hello
```
## Set a custom MAC address for your container
Use the `mac` option to specify a custom MAC address for your container's network interface. This is useful for:
-12
View File
@@ -52,18 +52,6 @@ When `container-apiserver` starts, it launches an XPC helper `container-core-ima
With the initial release of `container`, you get basic facilities for building and running containers, but many common containerization features remain to be implemented. Consider [contributing](../CONTRIBUTING.md) new features and bug fixes to `container` and the Containerization projects!
### Container to host networking
In the initial release, there is no way to route traffic directly from a client in a container to a host-based application listening on the loopback interface at 127.0.0.1. If you were to configure the application in your container to connect to 127.0.0.1 or `localhost`, requests would simply go to the loopback interface in the container, rather than your host-based service.
You can work around this limitation by configuring the host-based application to listen on the wildcard address 0.0.0.0, but this practice is insecure and not recommended because, without firewall rules, this exposes the application to external requests.
A more secure approach uses `socat` to redirect traffic from the container network gateway to the host-based service. For example, to forward traffic for port 8000, configure your containerized application to connect to `192.168.64.1:8000` instead of `127.0.0.1:8000`, and then run the following command in a terminal on your Mac to forward the port traffic from the gateway to the host:
```bash
socat TCP-LISTEN:8000,fork,bind=192.168.64.1 TCP:127.0.0.1:8000
```
### Releasing container memory to macOS
The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 GiBytes of RAM in the macOS Activity Monitor.