Skip to content

Commit 6f6aa37

Browse files
Pijukatelclaude
andcommitted
Reach the browser-view sidecar on localhost when the runtime is not on apify-local
Under rootless Podman the runtime container cannot join the network, so the console could not reach the VNC sidecar by its address there and the viewer never got the RFB greeting. The sidecar now shares the runtime container's own network namespace in that case, on a port allocated in that namespace, and the console connects to it on 127.0.0.1. The alias route is unchanged whenever it is available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZhuUP13NByepkP4n3sg3P
1 parent 67b0651 commit 6f6aa37

3 files changed

Lines changed: 72 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Good to know:
7676
- Under rootless Podman the runtime container cannot join the `apify-local` network, so Actors reach
7777
the API through the runtime's published port 3333 instead. Keep `-p 3333:3333` published on all
7878
interfaces, or pre-create the network (`podman network create apify-local`) and add
79-
`--network apify-local` to the run command to use the direct route (browser view needs it).
79+
`--network apify-local` to the run command to use the direct route.
8080
- Rootless engines on a cgroups v1 host do not apply the per-run memory and CPU limits.
8181
- If you restart a hand-started `podman system service`, the socket file mounted into the runtime goes
8282
stale; restart the runtime container too. The `podman.socket` unit does not have this problem.

src/driver/docker-driver.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import { PassThrough } from 'node:stream';
3535
import { createReadStream } from 'node:fs';
3636
import { readFile } from 'node:fs/promises';
37+
import { createServer } from 'node:net';
3738
import * as path from 'node:path';
3839
import Docker from 'dockerode';
3940
import * as tar from 'tar-stream';
@@ -215,6 +216,22 @@ async function statInProbe(container: Docker.Container, containerPath: string):
215216
return parseProbeStatResponse(response);
216217
}
217218

219+
/** A TCP port that is free in this process's own network namespace right now - bound on loopback and
220+
* released again, for a sidecar about to share that namespace (`startBrowserViewer`). */
221+
async function allocateFreePort(): Promise<number> {
222+
return new Promise((resolve, reject) => {
223+
const server = createServer();
224+
server.once('error', reject);
225+
server.listen(0, '127.0.0.1', () => {
226+
const address = server.address();
227+
server.close(() => {
228+
if (address && typeof address === 'object') resolve(address.port);
229+
else reject(new Error('Could not allocate a free port for the browser-view sidecar'));
230+
});
231+
});
232+
});
233+
}
234+
218235
/**
219236
* Maps the `linkTarget` a symlink stat reports back to a host path. Docker resolves a link's target in
220237
* the scope of the container's root filesystem and reports that absolute container path: a target that
@@ -542,8 +559,8 @@ export class DockerDriver implements Driver {
542559
`Could not attach the runtime's own container to the ${NETWORK_NAME} network: ${(error as Error).message}. ` +
543560
`Actor containers will reach this API through the host's published port ${API_PORT} instead ` +
544561
`(${CONTAINER_API_ALIAS} -> host-gateway), so keep -p ${API_PORT}:${API_PORT} published on all interfaces. ` +
545-
`Browser view needs the direct route: pre-create the network (\`podman network create ${NETWORK_NAME}\`) ` +
546-
`and start the runtime container with \`--network ${NETWORK_NAME}\`, which also restores the alias.`,
562+
`To use the network alias anyway, pre-create the network (\`podman network create ${NETWORK_NAME}\`) ` +
563+
`and start the runtime container with \`--network ${NETWORK_NAME}\`.`,
547564
);
548565
}
549566
}
@@ -1191,6 +1208,15 @@ export class DockerDriver implements Driver {
11911208
Labels: labels,
11921209
});
11931210

1211+
// How the console reaches the sidecar's VNC server. Normally the sidecar joins `apify-local` and is
1212+
// reached by its address there. When this process runs in a container that could not join that
1213+
// network (`apiReachableByAlias`'s doc comment - rootless Podman), the sidecar shares this
1214+
// container's own network namespace instead, so the console reaches it on localhost; every sidecar
1215+
// then needs a port of its own in that shared namespace, allocated here where it will be used.
1216+
const selfContainerId = process.env.HOSTNAME;
1217+
const sharesRuntimeNetns = !this.apiReachableByAlias && !!selfContainerId;
1218+
const vncPort = sharesRuntimeNetns ? await allocateFreePort() : BROWSER_VIEWER_VNC_PORT;
1219+
11941220
let container: Docker.Container | undefined;
11951221
try {
11961222
container = await this.docker.createContainer({
@@ -1199,11 +1225,11 @@ export class DockerDriver implements Driver {
11991225
Cmd: ['/bin/sh', BROWSER_VIEWER_SCRIPT],
12001226
Env: [
12011227
`${BROWSER_VIEWER_INTERACTIVE_ENV}=${target.interactive ? '1' : '0'}`,
1202-
`${BROWSER_VIEWER_PORT_ENV}=${BROWSER_VIEWER_VNC_PORT}`,
1228+
`${BROWSER_VIEWER_PORT_ENV}=${vncPort}`,
12031229
],
12041230
Labels: labels,
12051231
HostConfig: {
1206-
NetworkMode: NETWORK_NAME,
1232+
NetworkMode: sharesRuntimeNetns ? `container:${selfContainerId}` : NETWORK_NAME,
12071233
Memory: BROWSER_VIEWER_MEMORY_BYTES,
12081234
AutoRemove: false,
12091235
Mounts: [{ Type: 'volume', Source: volumeName, Target: X11_SOCKET_DIR }],
@@ -1213,12 +1239,15 @@ export class DockerDriver implements Driver {
12131239
this.browserViewers.set(target.runId, { container, volumeName });
12141240
await container.start();
12151241

1242+
if (sharesRuntimeNetns) {
1243+
return { vncHost: '127.0.0.1', vncPort, x11SocketVolume: volumeName };
1244+
}
12161245
const info = await container.inspect();
12171246
const address = info.NetworkSettings?.Networks?.[NETWORK_NAME]?.IPAddress;
12181247
return {
12191248
// The IP also works from a runtime running outside Docker; the name only resolves from inside.
12201249
vncHost: address || containerName,
1221-
vncPort: BROWSER_VIEWER_VNC_PORT,
1250+
vncPort,
12221251
x11SocketVolume: volumeName,
12231252
};
12241253
} catch (error) {

test/unit/docker-driver-browser-view.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ describe('DockerDriver.startBrowserViewer / stopBrowserViewer', () => {
8181
});
8282

8383
afterEach(() => {
84+
vi.unstubAllEnvs();
8485
rmSync(payloadDir, { recursive: true, force: true });
8586
if (originalEnv === undefined) delete process.env[PAYLOAD_ENV];
8687
else process.env[PAYLOAD_ENV] = originalEnv;
@@ -148,6 +149,42 @@ describe('DockerDriver.startBrowserViewer / stopBrowserViewer', () => {
148149
expect(stub.getImage).toHaveBeenCalledTimes(1);
149150
});
150151

152+
it("when this process runs in a container that could not join apify-local (rootless Podman), the sidecar shares this container's network namespace on a port of its own and is reached on localhost", async () => {
153+
vi.stubEnv('HOSTNAME', 'abc123def456');
154+
const stub = stubDockerForViewer({ imagePresent: true });
155+
const driver = new DockerDriver(stub.docker);
156+
driver.available = true; // `apiReachableByAlias` stays false: `init()` never attached this container.
157+
158+
const handle = await driver.startBrowserViewer({ runId: 'run-netns', interactive: false });
159+
160+
const [containerOptions] = stub.createContainer.mock.calls[0]!;
161+
expect(containerOptions.HostConfig?.NetworkMode).toBe('container:abc123def456');
162+
expect(handle.vncHost).toBe('127.0.0.1');
163+
expect(handle.vncPort).toBeGreaterThan(0);
164+
expect(handle.vncPort).not.toBe(5900);
165+
expect(containerOptions.Env).toContain(`APIFY_BROWSER_VIEWER_PORT=${handle.vncPort}`);
166+
// Nothing to look up on the network: the address is this container's own loopback.
167+
expect(stub.container.inspect).not.toHaveBeenCalled();
168+
});
169+
170+
it('joins apify-local as usual when this container did attach to it, even though it runs in a container', async () => {
171+
vi.stubEnv('HOSTNAME', 'abc123def456');
172+
const stub = stubDockerForViewer({ imagePresent: true });
173+
const driver = new DockerDriver(stub.docker);
174+
driver.available = true;
175+
(driver as unknown as { apiReachableByAlias: boolean }).apiReachableByAlias = true;
176+
177+
const handle = await driver.startBrowserViewer({ runId: 'run-alias', interactive: false });
178+
179+
const [containerOptions] = stub.createContainer.mock.calls[0]!;
180+
expect(containerOptions.HostConfig?.NetworkMode).toBe('apify-local');
181+
expect(handle).toEqual({
182+
vncHost: '172.18.0.9',
183+
vncPort: 5900,
184+
x11SocketVolume: 'actor-runtime-x11-run-alias',
185+
});
186+
});
187+
151188
it('falls back to the sidecar container name as vncHost when the daemon reports no IP on apify-local', async () => {
152189
const stub = stubDockerForViewer({ imagePresent: true, ipAddress: '' });
153190
const driver = new DockerDriver(stub.docker);

0 commit comments

Comments
 (0)